git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit3d72e1c02bd6161761b4cbe26b0bb7c89ef3856e
parent9ef2344961
authorLucas Galante <[email protected]>
date2026-06-22 21:33
Implement image downscaling and integrate image previews into file browser

 Cargo.toml           |  1 +
 src/main.rs          |  2 +-
 src/pages/preview.rs |  1 +
 src/services/fs.rs   | 91 +++++++++++++++++++++++++++++++++++++---------------
 4 files changed, 69 insertions(+), 26 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index c2e12d6..972e947 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ calloop = "0.13.0"
 chrono = "0.4.44"
 tokio = { version = "1", features = ["full"] }
 notify = "8.2.0"
+image = { version = "0.25.10", features = ["png", "jpeg"] }
 
 [lib]
 name = "cce_filesystem_interface"
diff --git a/src/main.rs b/src/main.rs
index e8fc427..3900578 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -500,7 +500,7 @@ impl Application for FilesystemApp {
         let _current_dir = browse.current_dir.clone();
 
         let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
-        let paginator = cce_ui::widget::Paginator::new(56.0, pages_names);
+        let paginator = cce_ui::widget::Paginator::new(pages_names);
 
         let fs_service = services::fs::FsService::new(sender.clone());
         let initial_w = if select_mode { 900 } else { 1200 };
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index 86b111c..8298e1f 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -34,6 +34,7 @@ pub fn update(state: &mut PreviewState, msg: PreviewMessage) {
                 file_type: data.file_type,
                 target: data.target,
                 content_preview: data.content_preview,
+                image_preview: data.image_preview,
                 scroll_line: 0,
                 ..PreviewState::default()
             };
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 0e54179..62189b3 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -3,6 +3,8 @@ use std::os::unix::fs::PermissionsExt;
 use std::path::{Path, PathBuf};
 use tokio::sync::mpsc;
 use crate::pages::browse::DirEntry;
+use image::GenericImageView;
+use cce_ui::widget::ImagePreviewData;
 
 #[derive(Debug, Clone, Default)]
 pub struct PreviewData {
@@ -14,6 +16,7 @@ pub struct PreviewData {
     pub file_type: String,
     pub target: String,
     pub content_preview: Option<String>,
+    pub image_preview: Option<ImagePreviewData>,
 }
 
 #[derive(Debug, Clone)]
@@ -150,6 +153,36 @@ pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
     entries
 }
 
+fn load_image_preview(path: &Path) -> Option<ImagePreviewData> {
+    let img = image::open(path).ok()?;
+    let (orig_w, orig_h) = img.dimensions();
+    if orig_w == 0 || orig_h == 0 {
+        return None;
+    }
+    let max_dim = 96.0;
+    let ratio = (max_dim / orig_w as f32).min(max_dim / orig_h as f32).min(1.0);
+    let target_w = (orig_w as f32 * ratio).round() as u32;
+    let target_h = (orig_h as f32 * ratio).round() as u32;
+    if target_w == 0 || target_h == 0 {
+        return None;
+    }
+    
+    let resized = if target_w == orig_w && target_h == orig_h {
+        img
+    } else {
+        img.resize(target_w, target_h, image::imageops::FilterType::Triangle)
+    };
+    
+    let rgba = resized.to_rgba8();
+    let pixels = rgba.chunks_exact(4).map(|p| [p[0], p[1], p[2], p[3]]).collect();
+    
+    Some(ImagePreviewData {
+        width: target_w,
+        height: target_h,
+        pixels,
+    })
+}
+
 fn load_preview_data_internal(path: &Path) -> PreviewData {
     let meta = fs::symlink_metadata(path).ok();
     let name = path
@@ -184,8 +217,10 @@ fn load_preview_data_internal(path: &Path) -> PreviewData {
         String::new()
     };
 
-    // Load content preview
-    let content_preview = if is_dir {
+    let mut content_preview = None;
+    let mut image_preview = None;
+
+    if is_dir {
         if let Ok(entries) = fs::read_dir(path) {
             let mut names = Vec::new();
             for entry in entries.flatten().take(100) {
@@ -195,34 +230,39 @@ fn load_preview_data_internal(path: &Path) -> PreviewData {
                 names.push(format!("{} {}", icon, name));
             }
             if names.is_empty() {
-                Some("[Empty directory]".to_string())
+                content_preview = Some("[Empty directory]".to_string())
             } else {
-                Some(names.join("\n"))
+                content_preview = Some(names.join("\n"))
             }
-        } else {
-            None
         }
-    } else if let Ok(mut file) = fs::File::open(path) {
-        use std::io::Read;
-        let mut buf = vec![0u8; 65536];
-        if let Ok(n) = file.read(&mut buf) {
-            buf.truncate(n);
-            let is_text = match std::str::from_utf8(&buf) {
-                Ok(_) => true,
-                Err(err) => err.error_len().is_none() && err.valid_up_to() > 0,
-            };
-            if is_text {
-                let utf8_str = String::from_utf8_lossy(&buf).into_owned();
-                Some(utf8_str)
-            } else {
-                Some("[Binary file content]".to_string())
+    } else {
+        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
+        let is_img_ext = matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico");
+        
+        if is_img_ext {
+            image_preview = load_image_preview(path);
+        }
+        
+        if image_preview.is_none() {
+            if let Ok(mut file) = fs::File::open(path) {
+                use std::io::Read;
+                let mut buf = vec![0u8; 65536];
+                if let Ok(n) = file.read(&mut buf) {
+                    buf.truncate(n);
+                    let is_text = match std::str::from_utf8(&buf) {
+                        Ok(_) => true,
+                        Err(err) => err.error_len().is_none() && err.valid_up_to() > 0,
+                    };
+                    if is_text {
+                        let utf8_str = String::from_utf8_lossy(&buf).into_owned();
+                        content_preview = Some(utf8_str);
+                    } else {
+                        content_preview = Some("[Binary file content]".to_string());
+                    }
+                }
             }
-        } else {
-            None
         }
-    } else {
-        None
-    };
+    }
 
     PreviewData {
         name,
@@ -233,6 +273,7 @@ fn load_preview_data_internal(path: &Path) -> PreviewData {
         file_type,
         target,
         content_preview,
+        image_preview,
     }
 }