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

commit20c439c9632c3370943f7f89b17bd0b975a4b6c4
parent9cc8ddfb2d
authorLucas Galante <[email protected]>
date2026-07-21 08:43
refactor: bring the preview pane in-crate as flat PreviewPane

Port of cce-ui's PreviewState (this app was its only consumer) to the
RowList idiom: a plain struct whose push_prims emits rects and text ONCE
into PageContent — SectionContext chrome unchanged (PageContent is a
RenderTarget), per-text clip bounds pre-clamped to the pane rect
(absorbing rebuild_layout's caller-side clamp), and wheel() absorbing
the hand-built scroll hit-test. Kills the Adapted wrapper, the
render_widget call in the unsafe block, and the twice-per-frame
render_to_canvas (quads pull + legacy-labels pull). ImagePreviewData now
lives beside PreviewData in services/fs.rs. The RLE image path is kept
verbatim this commit so the diff is a pure move — verified
pixel-identical against the pre-move build.

Builds against both current cce-ui and the upcoming revision that drops
the PreviewState export.

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

 src/lib.rs           |   1 +
 src/main.rs          |  44 ++------
 src/pages/preview.rs |   6 +-
 src/preview_pane.rs  | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/services/fs.rs   |   9 +-
 5 files changed, 304 insertions(+), 39 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index e513306..620605f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
 pub mod pages;
+pub mod preview_pane;
 pub mod row_list;
 pub mod services;
 pub mod util;
diff --git a/src/main.rs b/src/main.rs
index 469d857..a50e873 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -292,7 +292,7 @@ struct FilesystemApp {
     current_page: Page,
     browse: pages::browse::BrowseState,
     network: pages::network::NetworkState,
-    preview: cce_ui::widget::Adapted<pages::preview::PreviewState>,
+    preview: cce_files::preview_pane::PreviewPane,
 
     // Command-line chooser options
     select_mode: bool,
@@ -396,7 +396,6 @@ impl FilesystemApp {
         // Clear all widgets' hierarchy links
         self.paginator.clear_children(&mut self.ui_context); self.paginator.set_parent(None, &mut self.ui_context);
         self.view_dropdown.clear_children(&mut self.ui_context); self.view_dropdown.set_parent(None, &mut self.ui_context);
-        self.preview.clear_children(&mut self.ui_context); self.preview.set_parent(None, &mut self.ui_context);
 
 
         self.browse.save_name_box.clear_children(&mut self.ui_context); self.browse.save_name_box.set_parent(None, &mut self.ui_context);
@@ -478,25 +477,10 @@ impl FilesystemApp {
                     let (dx, dy, dw, dh, dc) = split.divider_quad();
                     plain_pc.rects.push((dc, dx, dy, dw, dh, 0.0, (true, true, true, true)));
                     let (px_r, py_r, pw_r, ph_r) = split.right_rect();
-                    let mut preview_pc = pages::PageContent::new();
-                    cce_ui::layout::render_widget(&mut preview_pc, &mut (*self_ptr).preview, px_r, py_r, pw_r, ph_r, &mut self.ui_context);
-                    plain_pc.rects.extend(preview_pc.rects);
-                    // The legacy SplitBox clamped pane text to its bounds (None -> the
-                    // splitter rect); keep the preview's text inside its pane.
-                    for (t, size, tx, ty, col, font, bounds) in preview_pc.texts {
-                        let cb = match bounds {
-                            Some(b) => {
-                                let x0 = b[0].max(px_r);
-                                let y0 = b[1].max(py_r);
-                                let x1 = b[2].min(px_r + pw_r);
-                                let y1 = b[3].min(py_r + ph_r);
-                                if x1 <= x0 || y1 <= y0 { continue; }
-                                Some([x0, y0, x1, y1])
-                            }
-                            None => Some([px_r, py_r, px_r + pw_r, py_r + ph_r]),
-                        };
-                        plain_pc.texts.push((t, size, tx, ty, col, font, cb));
-                    }
+                    // The pane clamps its own text bounds to its rect inside
+                    // push_prims (the old SplitBox clamp, absorbed).
+                    (*self_ptr).preview.set_rect(px_r, py_r, pw_r, ph_r);
+                    (*self_ptr).preview.push_prims(&mut plain_pc);
                 }
                 if let Some((_, textbox)) = &mut (*self_ptr).open_with_dialog {
                     let (x, y, w, h) = textbox.rect();
@@ -1820,20 +1804,10 @@ impl Application for FilesystemApp {
 
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
         if self.current_page == Page::Browse || self.current_page == Page::Network {
-            // Hit-test against the preview widget's actual laid-out rect. Recomputing a
-            // hardcoded 50/50 split here was wrong once the list/preview splitter had been
-            // dragged off-center, so wheel events over the preview were misrouted.
-            let (prev_x, prev_y, prev_w, prev_h) = self.preview.rect();
-            let content_h = prev_h;
-
-            // Inner content-preview region (below the metadata header).
-            let px = prev_x + 12.0;
-            let py = prev_y + 32.0;
-            let pw = prev_w - 24.0;
-            let ph = prev_h * 0.5 - 40.0;
-
-            if pos.x as f32 >= px && pos.x as f32 <= px + pw && pos.y as f32 >= py && pos.y as f32 <= py + ph {
-                if self.preview.handle_mouse_wheel(delta, content_h) {
+            // The pane hit-tests its own laid-out rect and consumes any wheel
+            // over its content region, scrolled or not.
+            if let Some(changed) = self.preview.wheel(delta, pos.x as f32, pos.y as f32) {
+                if changed {
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
                 }
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index 9d6d4b2..d7faf2f 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -1,6 +1,6 @@
 use std::path::PathBuf;
 
-pub use cce_ui::widget::PreviewState;
+use crate::preview_pane::PreviewPane;
 
 // ── Data ────────────────────────────────────────────────────────────
 
@@ -13,10 +13,10 @@ pub enum PreviewMessage {
 
 // ── Update ──────────────────────────────────────────────────────────
 
-pub fn update(state: &mut PreviewState, msg: PreviewMessage) {
+pub fn update(state: &mut PreviewPane, msg: PreviewMessage) {
     match msg {
         PreviewMessage::Clear => {
-            *state = PreviewState::default();
+            *state = PreviewPane::default();
         }
         PreviewMessage::SetPath { path: _ } => {
             // Deprecated direct SetPath, as we now load previews via the FsService.
diff --git a/src/preview_pane.rs b/src/preview_pane.rs
new file mode 100644
index 0000000..547e143
--- /dev/null
+++ b/src/preview_pane.rs
@@ -0,0 +1,283 @@
+//! The file-preview pane — Preview (text lines / image pixels) over Details
+//! (name + metadata rows), drawn in cce-ui `SectionContext` titled frames.
+//!
+//! Moved in-crate from cce-ui's `PreviewState` (its only consumer was this app)
+//! and flattened to the RowList idiom: a plain struct whose `push_prims` emits
+//! rects and texts ONCE into a `PageContent`, per-text clip bounds pre-clamped
+//! to the pane rect. This replaces the old Adapted<W> widget whose paint ran
+//! twice per frame (quads via `Paint::paint`, text via the legacy-labels
+//! hatch) and the caller-side text clamp in `rebuild_layout`. Scrolling is
+//! `wheel()`, called imperatively from the app like `RowList::wheel`.
+
+use std::path::PathBuf;
+
+use cce_ui::layout::SectionContext;
+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 {
+    rect: (f32, f32, f32, f32),
+    pub path: Option<PathBuf>,
+    pub path_display: String,
+    pub name: String,
+    pub is_dir: bool,
+    pub size: String,
+    pub permissions: String,
+    pub modified: String,
+    pub file_type: String,
+    pub target: String, // for symlinks
+    pub content_preview: Option<String>,
+    pub image_preview: Option<ImagePreviewData>,
+    pub scroll_line: usize,
+}
+
+impl Default for PreviewPane {
+    fn default() -> Self {
+        Self {
+            rect: (0.0, 0.0, 0.0, 0.0),
+            path: None,
+            path_display: String::new(),
+            name: String::new(),
+            is_dir: false,
+            size: String::new(),
+            permissions: String::new(),
+            modified: String::new(),
+            file_type: String::new(),
+            target: String::new(),
+            content_preview: None,
+            image_preview: None,
+            scroll_line: 0,
+        }
+    }
+}
+
+impl PreviewPane {
+    pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        self.rect = (x, y, w, h);
+    }
+
+    pub fn rect(&self) -> (f32, f32, f32, f32) {
+        self.rect
+    }
+
+    /// Wheel routing: `None` if the pointer is outside the inner content-preview
+    /// region (the caller falls through to the list/graph), `Some(changed)` if
+    /// the event is consumed — scrolled or not, a wheel over the preview region
+    /// never reaches the widgets beneath it.
+    pub fn wheel(&mut self, delta: &MouseScrollDelta, mx: f32, my: f32) -> Option<bool> {
+        let (prev_x, prev_y, prev_w, prev_h) = self.rect;
+        // Inner content-preview region (below the metadata header).
+        let px = prev_x + 12.0;
+        let py = prev_y + 32.0;
+        let pw = prev_w - 24.0;
+        let ph = prev_h * 0.5 - 40.0;
+        if mx < px || mx > px + pw || my < py || my > py + ph {
+            return None;
+        }
+        Some(self.scroll(delta, prev_h))
+    }
+
+    fn scroll(&mut self, delta: &MouseScrollDelta, ch: f32) -> bool {
+        let content = match &self.content_preview {
+            Some(c) => c,
+            None => return false,
+        };
+        let total_lines = content.lines().count();
+        let half_h = ch * 0.5;
+        let mut max_visible_lines = 0;
+        let mut text_y = 44.0;
+        while text_y + 14.0 <= half_h - 16.0 {
+            max_visible_lines += 1;
+            text_y += 15.0;
+        }
+        if total_lines <= max_visible_lines {
+            if self.scroll_line != 0 {
+                self.scroll_line = 0;
+                return true;
+            }
+            return false;
+        }
+        let max_scroll = total_lines.saturating_sub(max_visible_lines);
+        let scroll_speed = 3.0;
+        let diff = match delta {
+            MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
+            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32 / 15.0,
+        };
+        let prev_scroll = self.scroll_line;
+        let new_scroll = (self.scroll_line as f32 + diff).round() as isize;
+        self.scroll_line = new_scroll.clamp(0, max_scroll as isize) as usize;
+        self.scroll_line != prev_scroll
+    }
+
+    /// One pass: sections, fills, image runs, and text — every text emitted with
+    /// bounds intersected against the pane rect (the old caller-side clamp).
+    pub fn push_prims(&self, pc: &mut PageContent) {
+        let text_start = pc.texts.len();
+        self.emit(pc);
+
+        let (cx, cy, cw, ch) = self.rect;
+        let (pane_l, pane_t, pane_r, pane_b) = (cx, cy, cx + cw, cy + ch);
+        let mut i = text_start;
+        while i < pc.texts.len() {
+            let bounds = &mut pc.texts[i].6;
+            let b = bounds.unwrap_or([pane_l, pane_t, pane_r, pane_b]);
+            let clamped = [
+                b[0].max(pane_l),
+                b[1].max(pane_t),
+                b[2].min(pane_r),
+                b[3].min(pane_b),
+            ];
+            if clamped[2] <= clamped[0] || clamped[3] <= clamped[1] {
+                pc.texts.remove(i);
+                continue;
+            }
+            *bounds = Some(clamped);
+            i += 1;
+        }
+    }
+
+    fn emit(&self, pc: &mut PageContent) {
+        let (cx, cy, cw, ch) = self.rect;
+
+        let text_fg = cce_ui::color::TEXT_FG;
+        let text_dim = cce_ui::color::TEXT_DIM;
+        let label_fg = cce_ui::color::TEXT_ACCENT;
+
+        if self.path.is_none() {
+            pc.text("Select a file to view details", cx + 12.0, cy + 12.0, 13.0, text_dim);
+            return;
+        }
+
+        let half_h = ch * 0.5;
+        let pad = cce_ui::layout::section_padding();
+
+        // 1. Top pane: File Preview Section
+        let mut preview_sec = SectionContext::new(pc, cx + 4.0, cy + 12.0, cw - 8.0, "Preview", false, false);
+        let rect_y = cy + pad + 31.0;
+        let rect_h = half_h - 2.0 * pad - 43.0;
+        preview_sec.content_y = cy + half_h - pad - 12.0;
+        preview_sec.finish();
+
+        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;
+                }
+            }
+        } 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) {
+                if text_y + 14.0 > rect_y + rect_h - 8.0 {
+                    break;
+                }
+                let limit = (((cw - 40.0) / 6.8).floor() as usize).max(20);
+                let line_truncated = truncate_tail(line, limit);
+                pc.text_with_font(&line_truncated, cx + 20.0, text_y, 11.0, text_fg, "monospace");
+                text_y += 15.0;
+            }
+        } else {
+            pc.text("No preview available", cx + 20.0, rect_y + 12.0, 11.0, text_dim);
+        }
+
+        // 2. Bottom pane: Details Section
+        let bottom_y = cy + half_h + 12.0;
+        let icon = if self.is_dir { "📁" } else { "📄" };
+
+        let details = [
+            ("Path", &self.path_display),
+            ("Type", &self.file_type),
+            ("Size", &self.size),
+            ("Permissions", &self.permissions),
+            ("Modified", &self.modified),
+        ];
+
+        let details_content_start_y = bottom_y + pad + 19.0;
+        let mut details_content_end_y = details_content_start_y + 36.0 + details.len() as f32 * 20.0;
+        if !self.target.is_empty() {
+            details_content_end_y += 24.0;
+        }
+
+        let mut details_sec = SectionContext::new(pc, cx + 4.0, bottom_y, cw - 8.0, "Details", false, false);
+        details_sec.content_y = details_content_end_y;
+        details_sec.finish();
+
+        let header_y = details_content_start_y + 6.0;
+        pc.text(icon, cx + 12.0, header_y, 20.0, text_fg);
+
+        let name_truncated = truncate_tail(&self.name, 30);
+        pc.text(&name_truncated, cx + 42.0, header_y + 4.0, 16.0, text_fg);
+
+        let mut y = details_content_start_y + 36.0;
+        for (label, val) in &details {
+            pc.text(label, cx + 12.0, y, 12.0, label_fg);
+            let val_str = truncate_head(val, 40);
+            pc.text(&val_str, cx + 112.0, y, 12.0, text_dim);
+            y += 20.0;
+        }
+
+        if !self.target.is_empty() {
+            y += 8.0;
+            pc.text("Target", cx + 12.0, y, 12.0, label_fg);
+            let target_str = truncate_head(&self.target, 40);
+            pc.text(&target_str, cx + 112.0, y, 12.0, text_dim);
+        }
+    }
+}
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 0a4626f..4a5386d 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -5,7 +5,14 @@ use tokio::sync::mpsc;
 use crate::pages::browse::DirEntry;
 use crate::util::{format_size, format_permissions};
 use image::GenericImageView;
-use cce_ui::widget::ImagePreviewData;
+
+/// A downscaled RGBA thumbnail of an image file, drawn by the preview pane.
+#[derive(Debug, Clone, Default)]
+pub struct ImagePreviewData {
+    pub width: u32,
+    pub height: u32,
+    pub pixels: Vec<[u8; 4]>,
+}
 
 #[derive(Debug, Clone, Default)]
 pub struct PreviewData {