file manager
git clone https://git.lucas.co/cce-files.git
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,