Add text previews

This commit is contained in:
Jeremy Soller 2024-10-10 10:05:48 -06:00
parent 3ea075b2c6
commit 081156670e
No known key found for this signature in database
GPG key ID: D02FD439211AF56F
3 changed files with 87 additions and 30 deletions

View file

@ -1109,12 +1109,12 @@ impl App {
widget::settings::view_column(children).into() widget::settings::view_column(children).into()
} }
fn preview( fn preview<'a>(
&self, &'a self,
entity_opt: &Option<Entity>, entity_opt: &Option<Entity>,
kind: &PreviewKind, kind: &'a PreviewKind,
context_drawer: bool, context_drawer: bool,
) -> Element<Message> { ) -> Element<'a, Message> {
let mut children = Vec::with_capacity(1); let mut children = Vec::with_capacity(1);
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
match kind { match kind {

View file

@ -450,7 +450,7 @@ impl App {
col.into() col.into()
} }
fn preview(&self, kind: &PreviewKind) -> Element<AppMessage> { fn preview<'a>(&'a self, kind: &'a PreviewKind) -> Element<'a, AppMessage> {
let mut children = Vec::with_capacity(1); let mut children = Vec::with_capacity(1);
match kind { match kind {
PreviewKind::Custom(PreviewItem(item)) => { PreviewKind::Custom(PreviewItem(item)) => {

View file

@ -77,6 +77,8 @@ pub const HOVER_DURATION: Duration = Duration::from_millis(1600);
//TODO: best limit for search items //TODO: best limit for search items
const MAX_SEARCH_LATENCY: Duration = Duration::from_millis(20); const MAX_SEARCH_LATENCY: Duration = Duration::from_millis(20);
const MAX_SEARCH_RESULTS: usize = 200; const MAX_SEARCH_RESULTS: usize = 200;
//TODO: configurable thumbnail size?
const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32);
//TODO: adjust for locales? //TODO: adjust for locales?
const DATE_TIME_FORMAT: &'static str = "%b %-d, %-Y, %-I:%M %p"; const DATE_TIME_FORMAT: &'static str = "%b %-d, %-Y, %-I:%M %p";
@ -1049,18 +1051,49 @@ impl ItemMetadata {
} }
} }
#[derive(Clone, Debug)] #[derive(Debug)]
pub enum ItemThumbnail { pub enum ItemThumbnail {
NotImage, NotImage,
Image(widget::image::Handle, Option<(u32, u32)>), Image(widget::image::Handle, Option<(u32, u32)>),
Svg(widget::svg::Handle), Svg(widget::svg::Handle),
Text(widget::text_editor::Content),
}
impl Clone for ItemThumbnail {
fn clone(&self) -> Self {
match self {
Self::NotImage => Self::NotImage,
Self::Image(handle, size_opt) => Self::Image(handle.clone(), *size_opt),
Self::Svg(handle) => Self::Svg(handle.clone()),
// Content cannot be cloned
Self::Text(content) => Self::NotImage,
}
}
} }
impl ItemThumbnail { impl ItemThumbnail {
pub fn new(path: &Path, mime: mime::Mime, thumbnail_size: u32) -> Self { pub fn new(path: &Path, metadata: fs::Metadata, mime: mime::Mime, thumbnail_size: u32) -> Self {
if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG { let size = metadata.len();
let check_size = |thumbnailer: &str, max_size| {
if size <= max_size {
true
} else {
log::warn!(
"skipping internal {} thumbnailer for {:?}: file size {} is larger than {}",
thumbnailer,
path,
format_size(size),
format_size(max_size)
);
false
}
};
//TODO: adjust limits for internal thumbnailers as desired
if mime.type_() == mime::IMAGE
&& mime.subtype() == mime::SVG
&& check_size("svg", 8 * 1000 * 1000)
{
// Try built-in svg thumbnailer // Try built-in svg thumbnailer
//TODO: have a reasonable limit on SVG size?
match fs::read(&path) { match fs::read(&path) {
Ok(data) => { Ok(data) => {
//TODO: validate SVG data //TODO: validate SVG data
@ -1070,7 +1103,7 @@ impl ItemThumbnail {
log::warn!("failed to read {:?}: {}", path, err); log::warn!("failed to read {:?}: {}", path, err);
} }
} }
} else if mime.type_() == mime::IMAGE { } else if mime.type_() == mime::IMAGE && check_size("image", 64 * 1000 * 1000) {
// Try built-in image thumbnailer // Try built-in image thumbnailer
match image::io::Reader::open(&path).and_then(|img| img.with_guessed_format()) { match image::io::Reader::open(&path).and_then(|img| img.with_guessed_format()) {
Ok(reader) => match reader.decode() { Ok(reader) => match reader.decode() {
@ -1094,6 +1127,15 @@ impl ItemThumbnail {
log::warn!("failed to read {:?}: {}", path, err); log::warn!("failed to read {:?}: {}", path, err);
} }
} }
} else if mime.type_() == mime::TEXT && check_size("text", 8 * 1000 * 1000) {
match fs::read_to_string(&path) {
Ok(data) => {
return ItemThumbnail::Text(widget::text_editor::Content::with_text(&data));
}
Err(err) => {
log::warn!("failed to read {:?}: {}", path, err);
}
}
} }
// Try external thumbnailers // Try external thumbnailers
@ -1188,11 +1230,11 @@ impl Item {
self.location_opt.as_ref()?.path_opt() self.location_opt.as_ref()?.path_opt()
} }
pub fn is_image(&self) -> bool { pub fn can_gallery(&self) -> bool {
self.mime.type_() == mime::IMAGE self.mime.type_() == mime::IMAGE || self.mime.type_() == mime::TEXT
} }
fn preview(&self, sizes: IconSizes) -> Element<'static, app::Message> { fn preview<'a>(&'a self, sizes: IconSizes) -> Element<'a, app::Message> {
// This loads the image only if thumbnailing worked // This loads the image only if thumbnailing worked
let icon = widget::icon::icon(self.icon_handle_grid.clone()) let icon = widget::icon::icon(self.icon_handle_grid.clone())
.content_fit(ContentFit::Contain) .content_fit(ContentFit::Contain)
@ -1206,17 +1248,25 @@ impl Item {
ItemThumbnail::NotImage => icon, ItemThumbnail::NotImage => icon,
ItemThumbnail::Image(handle, _) => { ItemThumbnail::Image(handle, _) => {
if let Some(path) = self.path_opt() { if let Some(path) = self.path_opt() {
if self.is_image() { if self.mime.type_() == mime::IMAGE {
return widget::image(widget::image::Handle::from_path(path)).into(); return widget::image(widget::image::Handle::from_path(path)).into();
} }
} }
widget::image(handle.clone()).into() widget::image(handle.clone()).into()
} }
ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(), ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(),
ItemThumbnail::Text(content) => widget::container(widget::text_editor(content))
.width(Length::Fixed(THUMBNAIL_SIZE as f32))
.height(Length::Fixed(THUMBNAIL_SIZE as f32))
.into(),
} }
} }
pub fn preview_view(&self, sizes: IconSizes, nav_row: bool) -> Element<'static, app::Message> { pub fn preview_view<'a>(
&'a self,
sizes: IconSizes,
nav_row: bool,
) -> Element<'a, app::Message> {
let cosmic_theme::Spacing { let cosmic_theme::Spacing {
space_xxxs, space_xxxs,
space_xxs, space_xxs,
@ -1237,7 +1287,7 @@ impl Item {
.on_press(app::Message::TabMessage(None, Message::ItemRight)), .on_press(app::Message::TabMessage(None, Message::ItemRight)),
); );
if self.is_image() { if self.can_gallery() {
if let Some(_path) = self.path_opt() { if let Some(_path) = self.path_opt() {
row = row.push( row = row.push(
widget::button::icon(widget::icon::from_name("view-fullscreen-symbolic")) widget::button::icon(widget::icon::from_name("view-fullscreen-symbolic"))
@ -1349,11 +1399,11 @@ impl Item {
column.into() column.into()
} }
pub fn replace_view( pub fn replace_view<'a>(
&self, &'a self,
heading: String, heading: String,
sizes: IconSizes, sizes: IconSizes,
) -> Element<'static, app::Message> { ) -> Element<'a, app::Message> {
let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing; let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing;
let mut row = widget::row().spacing(space_xxxs); let mut row = widget::row().spacing(space_xxxs);
@ -2188,7 +2238,7 @@ impl Tab {
continue; continue;
} }
if found { if found {
if item.is_image() { if item.can_gallery() {
pos_opt = item.pos_opt.get(); pos_opt = item.pos_opt.get();
if pos_opt.is_some() { if pos_opt.is_some() {
break; break;
@ -2214,7 +2264,7 @@ impl Tab {
Message::GalleryToggle => { Message::GalleryToggle => {
if let Some(indices) = self.column_sort() { if let Some(indices) = self.column_sort() {
for (_, item) in indices.iter() { for (_, item) in indices.iter() {
if item.selected && item.is_image() { if item.selected && item.can_gallery() {
self.gallery = !self.gallery; self.gallery = !self.gallery;
break; break;
} }
@ -2572,6 +2622,8 @@ impl Tab {
symbolic: false, symbolic: false,
data: widget::icon::Data::Svg(handle.clone()), data: widget::icon::Data::Svg(handle.clone()),
}), }),
//TODO: text thumbnails?
ItemThumbnail::Text(_text) => None,
}; };
if let Some(handle) = handle_opt { if let Some(handle) = handle_opt {
item.icon_handle_grid = handle.clone(); item.icon_handle_grid = handle.clone();
@ -2886,7 +2938,7 @@ impl Tab {
//TODO: display error messages when image not found? //TODO: display error messages when image not found?
let mut name_opt = None; let mut name_opt = None;
let mut image_opt: Option<Element<Message>> = None; let mut element_opt: Option<Element<Message>> = None;
if let Some(index) = self.select_focus { if let Some(index) = self.select_focus {
if let Some(items) = &self.items_opt { if let Some(items) = &self.items_opt {
if let Some(item) = items.get(index) { if let Some(item) = items.get(index) {
@ -2899,7 +2951,7 @@ impl Tab {
ItemThumbnail::NotImage => {} ItemThumbnail::NotImage => {}
ItemThumbnail::Image(handle, _) => { ItemThumbnail::Image(handle, _) => {
if let Some(path) = item.path_opt() { if let Some(path) = item.path_opt() {
image_opt = Some( element_opt = Some(
//TODO: use widget::image::viewer, when its zoom can be reset //TODO: use widget::image::viewer, when its zoom can be reset
widget::image(widget::image::Handle::from_path(path)) widget::image(widget::image::Handle::from_path(path))
.width(Length::Fill) .width(Length::Fill)
@ -2907,7 +2959,7 @@ impl Tab {
.into(), .into(),
); );
} else { } else {
image_opt = Some( element_opt = Some(
//TODO: use widget::image::viewer, when its zoom can be reset //TODO: use widget::image::viewer, when its zoom can be reset
widget::image(handle.clone()) widget::image(handle.clone())
.width(Length::Fill) .width(Length::Fill)
@ -2917,13 +2969,16 @@ impl Tab {
} }
} }
ItemThumbnail::Svg(handle) => { ItemThumbnail::Svg(handle) => {
image_opt = Some( element_opt = Some(
widget::svg(handle.clone()) widget::svg(handle.clone())
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into(), .into(),
); );
} }
ItemThumbnail::Text(text) => {
element_opt = Some(widget::text_editor(text).into())
}
} }
} }
} }
@ -2960,8 +3015,8 @@ impl Tab {
.on_press(Message::GalleryPrevious), .on_press(Message::GalleryPrevious),
); );
row = row.push(widget::horizontal_space(Length::Fixed(space_xxs.into()))); row = row.push(widget::horizontal_space(Length::Fixed(space_xxs.into())));
if let Some(image) = image_opt { if let Some(element) = element_opt {
row = row.push(image); row = row.push(element);
} else { } else {
//TODO: what to do when no image? //TODO: what to do when no image?
row = row.push(widget::Space::new(Length::Fill, Length::Fill)); row = row.push(widget::Space::new(Length::Fill, Length::Fill));
@ -4167,6 +4222,9 @@ impl Tab {
let Some(path) = item.path_opt().map(|path| path.to_path_buf()) else { let Some(path) = item.path_opt().map(|path| path.to_path_buf()) else {
continue; continue;
}; };
let ItemMetadata::Path { metadata, .. } = item.metadata.clone() else {
continue;
};
let mime = item.mime.clone(); let mime = item.mime.clone();
subscriptions.push(subscription::channel( subscriptions.push(subscription::channel(
path.clone(), path.clone(),
@ -4176,9 +4234,8 @@ impl Tab {
let path = path.clone(); let path = path.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let start = Instant::now(); let start = Instant::now();
//TODO: configurable thumbnail size? let thumbnail =
let thumbnail_size = (ICON_SIZE_GRID * ICON_SCALE_MAX) as u32; ItemThumbnail::new(&path, metadata, mime, THUMBNAIL_SIZE);
let thumbnail = ItemThumbnail::new(&path, mime, thumbnail_size);
log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed()); log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed());
Message::Thumbnail(path.clone(), thumbnail) Message::Thumbnail(path.clone(), thumbnail)
}) })