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

commitc6dc8cce758574416a909d76e55dd6c33f26848a
parent20c439c963
authorLucas Galante <[email protected]>
date2026-07-21 09:12
feat: render image previews via the GPU texture path (512px cap)

The RLE mosaic (one colored quad per same-color pixel run, from a 96px
thumbnail) is replaced by one linear-sampled textured quad: fs.rs stores
flat RGBA8 at up to 512px, PreviewPane::set_image is the sole
upload/free choke point (frees the prior id before every replace or
clear, so the renderer's image budget can't leak), and the quad is
placed with cce-ui's new fit_rect — Contain, 4x upscale cap, no
downscale floor. PageContent grows an images vec riding the AppWidget
stream as WidgetFx::Image, drawn after each part's rects so overlays
still cover it.

Requires cce-ui 0fbfa43+ (fit_rect).

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs          | 26 ++++++++++++++++
 src/pages/mod.rs     | 10 ++++++
 src/pages/preview.rs |  5 ++-
 src/preview_pane.rs  | 87 ++++++++++++++++------------------------------------
 src/services/fs.rs   | 14 +++++----
 5 files changed, 75 insertions(+), 67 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index a50e873..9d3e0bd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -130,6 +130,9 @@ enum WidgetFx {
     Boss(f32),
     /// Edges-only carve into whatever is painted below (recessed wells).
     Recess(f32),
+    /// A GPU-textured quad; the id comes from `cce_ui::vk::upload_rgba`
+    /// (the preview pane's image). `color` is unused.
+    Image { id: u32, alpha: f32 },
 }
 
 struct AppWidget {
@@ -497,6 +500,7 @@ impl FilesystemApp {
                 window_pc.texts.extend(plain_pc.texts);
                 window_pc.buttons.extend(plain_pc.buttons);
                 window_pc.reliefs.extend(plain_pc.reliefs);
+                window_pc.images.extend(plain_pc.images);
 
                 // The view dropdown's raised plate (control_relief) lives in its
                 // modern paint(); the flat view loses it — restore it edges-only.
@@ -516,6 +520,7 @@ impl FilesystemApp {
                 pc.texts.extend(browse_pc.texts);
                 pc.buttons.extend(browse_pc.buttons);
                 pc.reliefs.extend(browse_pc.reliefs);
+                pc.images.extend(browse_pc.images);
             }
             Page::Network => {
                 let (nx, ny, nw, nh) = self.network_split.left_rect();
@@ -525,6 +530,7 @@ impl FilesystemApp {
                 pc.texts.extend(network_pc.texts);
                 pc.buttons.extend(network_pc.buttons);
                 pc.reliefs.extend(network_pc.reliefs);
+                pc.images.extend(network_pc.images);
             }
         }
 
@@ -681,6 +687,25 @@ impl FilesystemApp {
                     fx: WidgetFx::Flat,
                 });
             }
+            for (id, ix, iy, iw, ih, alpha) in &pc_part.images {
+                let (mut wy, mut wh) = (*iy, *ih);
+                if is_page_content {
+                    match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
+                        Some((cy, ch)) => { wy = cy; wh = ch; }
+                        None => continue,
+                    }
+                }
+                widgets.push(AppWidget {
+                    x: *ix,
+                    y: wy,
+                    w: *iw,
+                    h: wh,
+                    color: [0.0; 4],
+                    radius: 0.0,
+                    corners: (true, true, true, true),
+                    fx: WidgetFx::Image { id: *id, alpha: *alpha },
+                });
+            }
             for (rx, ry, rw, rh, rr, rd, raised) in &pc_part.reliefs {
                 let (mut wy, mut wh) = (*ry, *rh);
                 if is_page_content {
@@ -1231,6 +1256,7 @@ impl Application for FilesystemApp {
                 WidgetFx::Bevel(depth) => pc.bevel(rect, radii, w.color, depth),
                 WidgetFx::Boss(depth) => pc.boss(rect, radii, depth),
                 WidgetFx::Recess(depth) => pc.recess(rect, radii, depth),
+                WidgetFx::Image { id, alpha } => pc.image(id, rect, alpha),
                 WidgetFx::Flat => {
                     if w.radius > 0.1 {
                         pc.rounded_rect(rect, w.radius, w.corners, w.color);
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index ed4b483..feb45cc 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -39,6 +39,10 @@ pub struct PageContent {
     /// raised). The flat rects own the faces; these are the edges-only boss/recess
     /// walls emitted over them (the ParametersBg::reliefs idiom for flat-view hosts).
     pub reliefs: Vec<(f32, f32, f32, f32, f32, f32, bool)>,
+    /// GPU-textured quads — (image id from `cce_ui::vk::upload_rgba`, x, y, w, h,
+    /// alpha). Drawn after the part's rects, so a fill emitted earlier is the floor
+    /// beneath the image and overlay parts still cover it.
+    pub images: Vec<(u32, f32, f32, f32, f32, f32)>,
 }
 
 impl PageContent {
@@ -48,9 +52,15 @@ impl PageContent {
             texts: Vec::new(),
             buttons: Vec::new(),
             reliefs: Vec::new(),
+            images: Vec::new(),
         }
     }
 
+    /// A GPU-textured quad (id from `cce_ui::vk::upload_rgba`).
+    pub fn image(&mut self, id: u32, x: f32, y: f32, w: f32, h: f32, alpha: f32) {
+        self.images.push((id, x, y, w, h, alpha));
+    }
+
     pub fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
         self.rects.push((color, x, y, w, h, 0.0, (true, true, true, true)));
     }
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index d7faf2f..9c919f7 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -16,6 +16,9 @@ pub enum PreviewMessage {
 pub fn update(state: &mut PreviewPane, msg: PreviewMessage) {
     match msg {
         PreviewMessage::Clear => {
+            // Free the texture BEFORE the wholesale replace — a plain
+            // Default::default() swap would leak the uploaded id.
+            state.set_image(None);
             *state = PreviewPane::default();
         }
         PreviewMessage::SetPath { path: _ } => {
@@ -32,7 +35,7 @@ pub fn update(state: &mut PreviewPane, msg: PreviewMessage) {
             state.file_type = data.file_type;
             state.target = data.target;
             state.content_preview = data.content_preview;
-            state.image_preview = data.image_preview;
+            state.set_image(data.image_preview.map(|img| (img.pixels, img.width, img.height)));
             state.scroll_line = 0;
         }
     }
diff --git a/src/preview_pane.rs b/src/preview_pane.rs
index 547e143..3c15f1f 100644
--- a/src/preview_pane.rs
+++ b/src/preview_pane.rs
@@ -12,11 +12,11 @@
 use std::path::PathBuf;
 
 use cce_ui::layout::SectionContext;
+use cce_ui::scene::layout::{fit_rect, FitMode, Rect};
 use cce_ui::widget::display::{truncate_head, truncate_tail};
 use cce_ui::widget::MouseScrollDelta;
 
 use crate::pages::PageContent;
-use crate::services::fs::ImagePreviewData;
 
 #[derive(Debug, Clone)]
 pub struct PreviewPane {
@@ -31,7 +31,10 @@ pub struct PreviewPane {
     pub file_type: String,
     pub target: String, // for symlinks
     pub content_preview: Option<String>,
-    pub image_preview: Option<ImagePreviewData>,
+    /// The uploaded preview texture as (image id, native w, native h). Owned
+    /// exclusively through [`PreviewPane::set_image`] — the sole upload/free
+    /// site, so a stale id can never leak against the renderer's image budget.
+    image_tex: Option<(u32, u32, u32)>,
     pub scroll_line: usize,
 }
 
@@ -49,13 +52,26 @@ impl Default for PreviewPane {
             file_type: String::new(),
             target: String::new(),
             content_preview: None,
-            image_preview: None,
+            image_tex: None,
             scroll_line: 0,
         }
     }
 }
 
 impl PreviewPane {
+    /// Replace (or clear) the preview texture. Always frees the previous id
+    /// first; upload happens here — at update() level, never during paint.
+    /// `img` is flat RGBA8 pixels + native dimensions.
+    pub fn set_image(&mut self, img: Option<(Vec<u8>, u32, u32)>) {
+        if let Some((id, _, _)) = self.image_tex.take() {
+            cce_ui::vk::free_image(id);
+        }
+        if let Some((pixels, w, h)) = img {
+            let id = cce_ui::vk::upload_rgba(pixels, w, h);
+            self.image_tex = Some((id, w, h));
+        }
+    }
+
     pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
         self.rect = (x, y, w, h);
     }
@@ -165,63 +181,14 @@ impl PreviewPane {
         let bg_color = cce_ui::color::list_bg_color();
         pc.rect(bg_color, cx + 12.0, rect_y, cw - 24.0, rect_h);
 
-        if let Some(image_data) = &self.image_preview {
-            let box_w = cw - 24.0;
-            let box_h = rect_h;
-            let img_w = image_data.width as f32;
-            let img_h = image_data.height as f32;
-
-            let scale_x = box_w / img_w;
-            let scale_y = box_h / img_h;
-            let scale = scale_x.min(scale_y).min(4.0).max(1.0);
-
-            let draw_w = img_w * scale;
-            let draw_h = img_h * scale;
-
-            let start_x = cx + 12.0 + (box_w - draw_w) * 0.5;
-            let start_y = rect_y + (box_h - draw_h) * 0.5;
-
-            for row in 0..image_data.height {
-                let mut col = 0;
-                while col < image_data.width {
-                    let idx = (row * image_data.width + col) as usize;
-                    if idx >= image_data.pixels.len() {
-                        break;
-                    }
-                    let pixel = image_data.pixels[idx];
-
-                    let mut run_len = 1;
-                    while col + run_len < image_data.width {
-                        let next_idx = (row * image_data.width + col + run_len) as usize;
-                        if next_idx >= image_data.pixels.len() {
-                            break;
-                        }
-                        if image_data.pixels[next_idx] == pixel {
-                            run_len += 1;
-                        } else {
-                            break;
-                        }
-                    }
-
-                    let alpha = pixel[3] as f32 / 255.0;
-                    if alpha > 0.0 {
-                        pc.rect(
-                            [
-                                pixel[0] as f32 / 255.0,
-                                pixel[1] as f32 / 255.0,
-                                pixel[2] as f32 / 255.0,
-                                alpha,
-                            ],
-                            start_x + col as f32 * scale,
-                            start_y + row as f32 * scale,
-                            run_len as f32 * scale,
-                            scale,
-                        );
-                    }
-
-                    col += run_len;
-                }
-            }
+        if let Some((id, img_w, img_h)) = self.image_tex {
+            let fitted = fit_rect(
+                img_w,
+                img_h,
+                Rect { x: cx + 12.0, y: rect_y, width: cw - 24.0, height: rect_h },
+                FitMode::Contain { max_upscale: 4.0 },
+            );
+            pc.image(id, fitted.x, fitted.y, fitted.width, fitted.height, 1.0);
         } else if let Some(content) = &self.content_preview {
             let mut text_y = rect_y + 12.0;
             for line in content.lines().skip(self.scroll_line) {
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 4a5386d..efded5b 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -6,12 +6,13 @@ use crate::pages::browse::DirEntry;
 use crate::util::{format_size, format_permissions};
 use image::GenericImageView;
 
-/// A downscaled RGBA thumbnail of an image file, drawn by the preview pane.
+/// A downscaled RGBA thumbnail of an image file. `pixels` is flat RGBA8
+/// (width * height * 4 bytes) — exactly what `cce_ui::vk::upload_rgba` takes.
 #[derive(Debug, Clone, Default)]
 pub struct ImagePreviewData {
     pub width: u32,
     pub height: u32,
-    pub pixels: Vec<[u8; 4]>,
+    pub pixels: Vec<u8>,
 }
 
 #[derive(Debug, Clone, Default)]
@@ -167,7 +168,9 @@ fn load_image_preview(path: &Path) -> Option<ImagePreviewData> {
     if orig_w == 0 || orig_h == 0 {
         return None;
     }
-    let max_dim = 96.0;
+    // GPU-textured previews: 512px is crisp at pane size for one live image
+    // (1 MB RGBA) while keeping the Triangle resize quick per selection.
+    let max_dim = 512.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;
@@ -181,9 +184,8 @@ fn load_image_preview(path: &Path) -> Option<ImagePreviewData> {
         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();
-    
+    let pixels = resized.to_rgba8().into_raw();
+
     Some(ImagePreviewData {
         width: target_w,
         height: target_h,