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

src/preview_pane.rs (17K)

  1 //! The file-preview pane — Preview (text lines / image pixels) over Details
  2 //! (name + metadata rows), each in its own well (a 1px outline
  3 //! with control relief off).
  4 //!
  5 //! Moved in-crate from cce-ui's `PreviewState` (its only consumer was this app)
  6 //! and flattened to the RowList idiom: a plain struct whose `push_prims` emits
  7 //! rects and texts ONCE into a `PageContent`, per-text clip bounds pre-clamped
  8 //! to the pane rect. This replaces the old Adapted<W> widget whose paint ran
  9 //! twice per frame (quads via `Paint::paint`, text via the legacy-labels
 10 //! hatch) and the caller-side text clamp in `rebuild_layout`. Scrolling is
 11 //! `wheel()`, called imperatively from the app like `RowList::wheel`.
 12 
 13 use std::path::PathBuf;
 14 
 15 use cce_ui::scene::layout::{fit_rect, FitMode, Rect};
 16 use cce_ui::widget::display::{measure_text_width, truncate_tail};
 17 
 18 use crate::util::truncate_px;
 19 use cce_ui::widget::{Bounds, MouseScrollDelta, ScrollMotion};
 20 
 21 use crate::pages::PageContent;
 22 
 23 /// Pitch of the text preview's lines (11px monospace on a 15px advance).
 24 const PREVIEW_LINE_H: f32 = 15.0;
 25 /// One wheel notch moves the text preview three lines (the legacy speed).
 26 const PREVIEW_NOTCH_PX: f32 = 3.0 * PREVIEW_LINE_H;
 27 
 28 #[derive(Debug, Clone)]
 29 pub struct PreviewPane {
 30     rect: (f32, f32, f32, f32),
 31     pub path: Option<PathBuf>,
 32     pub path_display: String,
 33     pub name: String,
 34     pub is_dir: bool,
 35     pub size: String,
 36     pub permissions: String,
 37     pub modified: String,
 38     pub file_type: String,
 39     pub target: String, // for symlinks
 40     pub content_preview: Option<String>,
 41     /// The uploaded preview texture as (image id, native w, native h). Owned
 42     /// exclusively through [`PreviewPane::set_image`] — the sole upload/free
 43     /// site, so a stale id can never leak against the renderer's image budget.
 44     image_tex: Option<(u32, u32, u32)>,
 45     /// Text-preview scroll offset in pixels — the DRAWN value, which
 46     /// `scroll_motion` glides (wheel) or coasts (trackpad flick). The paint
 47     /// derives the first whole line and a sub-line remainder from it, so a
 48     /// multi-notch wheel slides through the lines instead of stepping.
 49     scroll_px: f32,
 50     scroll_motion: ScrollMotion,
 51     /// Which of the pane's two wells holds pointer focus (the app's well-focus
 52     /// tracking): that well renders as the tinted carve — accent ring
 53     /// replacing the relief lighting.
 54     pub focused_well: Option<PreviewWell>,
 55 }
 56 
 57 /// The preview pane's two recessed wells: the file-preview section (top half)
 58 /// and the details section below it.
 59 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 60 pub enum PreviewWell {
 61     Top,
 62     Bottom,
 63 }
 64 
 65 impl Default for PreviewPane {
 66     fn default() -> Self {
 67         Self {
 68             rect: (0.0, 0.0, 0.0, 0.0),
 69             path: None,
 70             path_display: String::new(),
 71             name: String::new(),
 72             is_dir: false,
 73             size: String::new(),
 74             permissions: String::new(),
 75             modified: String::new(),
 76             file_type: String::new(),
 77             target: String::new(),
 78             content_preview: None,
 79             image_tex: None,
 80             scroll_px: 0.0,
 81             scroll_motion: ScrollMotion::new(),
 82             focused_well: None,
 83         }
 84     }
 85 }
 86 
 87 impl PreviewPane {
 88     /// Replace (or clear) the preview texture. Always frees the previous id
 89     /// first; upload happens here — at update() level, never during paint.
 90     /// `img` is flat RGBA8 pixels + native dimensions.
 91     pub fn set_image(&mut self, img: Option<(Vec<u8>, u32, u32)>) {
 92         if let Some((id, _, _)) = self.image_tex.take() {
 93             cce_ui::vk::free_image(id);
 94         }
 95         if let Some((pixels, w, h)) = img {
 96             let id = cce_ui::vk::upload_rgba(pixels, w, h);
 97             self.image_tex = Some((id, w, h));
 98         }
 99     }
100 
101     /// Forget the uploaded texture, freeing it, and say whether there was one.
102     ///
103     /// For the renderer-replaced path only (see `FilesystemApp::renderer_init`):
104     /// the id belongs to a renderer that no longer exists, so this is a drop
105     /// rather than a clear — everything else about the shown file stays, and
106     /// the caller re-requests the preview to get a live texture back.
107     pub fn drop_texture(&mut self) -> bool {
108         match self.image_tex.take() {
109             Some((id, _, _)) => {
110                 cce_ui::vk::free_image(id);
111                 true
112             }
113             None => false,
114         }
115     }
116 
117     pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
118         self.rect = (x, y, w, h);
119     }
120 
121     /// Which well the point lands in, mirroring the paint-time geometry below
122     /// (top well = upper half of the pane rect; details well from half + the
123     /// plate gap down). `None` when no file is shown — the wells aren't drawn,
124     /// so there is nothing to focus.
125     pub fn well_at(&self, px: f32, py: f32) -> Option<PreviewWell> {
126         if self.path.is_none() {
127             return None;
128         }
129         let (cx, cy, cw, ch) = self.rect;
130         if cw <= 0.0 || ch <= 0.0 || px < cx || px > cx + cw {
131             return None;
132         }
133         let half_h = ch * 0.5;
134         let details_top = cy + half_h + cce_ui::layout::root_plate_gap();
135         if py >= cy && py <= cy + half_h {
136             Some(PreviewWell::Top)
137         } else if py >= details_top && py <= cy + ch {
138             Some(PreviewWell::Bottom)
139         } else {
140             None
141         }
142     }
143 
144     pub fn rect(&self) -> (f32, f32, f32, f32) {
145         self.rect
146     }
147 
148     /// Wheel routing: `None` if the pointer is outside the inner content-preview
149     /// region (the caller falls through to the list/graph), `Some(changed)` if
150     /// the event is consumed — scrolled or not, a wheel over the preview region
151     /// never reaches the widgets beneath it.
152     pub fn wheel(&mut self, delta: &MouseScrollDelta, mx: f32, my: f32) -> Option<bool> {
153         let (prev_x, prev_y, prev_w, prev_h) = self.rect;
154         // Inner content-preview region (below the metadata header); the fill
155         // spans the full pane width, so the hit region does too.
156         let px = prev_x;
157         let py = prev_y + 32.0;
158         let pw = prev_w;
159         let ph = prev_h * 0.5 - 40.0;
160         if mx < px || mx > px + pw || my < py || my > py + ph {
161             return None;
162         }
163         Some(self.scroll(delta, prev_h))
164     }
165 
166     /// Back to the top, motion cancelled (a new file loaded).
167     pub fn reset_scroll(&mut self) {
168         self.scroll_px = 0.0;
169         self.scroll_motion = ScrollMotion::new();
170     }
171 
172     /// How far the text preview can scroll, in pixels, for a pane `ch` tall:
173     /// the lines that don't fit the content well, times the line pitch. Zero
174     /// when there is no text or it all fits.
175     fn max_scroll_px(&self, ch: f32) -> f32 {
176         let Some(content) = &self.content_preview else {
177             return 0.0;
178         };
179         let total_lines = content.lines().count();
180         let half_h = ch * 0.5;
181         let mut max_visible_lines = 0;
182         let mut text_y = 44.0;
183         while text_y + 14.0 <= half_h - 16.0 {
184             max_visible_lines += 1;
185             text_y += 15.0;
186         }
187         total_lines.saturating_sub(max_visible_lines) as f32 * PREVIEW_LINE_H
188     }
189 
190     fn scroll(&mut self, delta: &MouseScrollDelta, ch: f32) -> bool {
191         let max = self.max_scroll_px(ch);
192         if max <= 0.0 {
193             if self.scroll_px != 0.0 {
194                 self.reset_scroll();
195                 return true;
196             }
197             return false;
198         }
199         // A notch is three lines (the legacy `scroll_speed`); a pixel delta
200         // is pixels, as before (it used to be divided by the line pitch and
201         // rounded to whole lines).
202         self.scroll_motion.reconcile(0.0, self.scroll_px);
203         let moved = self.scroll_motion.apply(delta, (PREVIEW_NOTCH_PX, PREVIEW_NOTCH_PX), Bounds::max(0.0), Bounds::max(max));
204         self.scroll_px = self.scroll_motion.y.pos();
205         moved
206     }
207 
208     /// Advance the text preview's wheel glide / flick coast; true while the
209     /// offset is moving, so the host keeps frames coming until it settles.
210     pub fn tick(&mut self, dt: f32) -> bool {
211         self.scroll_motion.reconcile(0.0, self.scroll_px);
212         if !self.scroll_motion.is_animating() {
213             return false;
214         }
215         let max = self.max_scroll_px(self.rect.3);
216         let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
217         self.scroll_px = self.scroll_motion.y.pos();
218         moved || self.scroll_motion.is_animating()
219     }
220 
221     /// One pass: sections, fills, image runs, and text — every text emitted with
222     /// bounds intersected against the pane rect (the old caller-side clamp).
223     pub fn push_prims(&self, pc: &mut PageContent) {
224         let text_start = pc.texts.len();
225         self.emit(pc);
226 
227         let (cx, cy, cw, ch) = self.rect;
228         let (pane_l, pane_t, pane_r, pane_b) = (cx, cy, cx + cw, cy + ch);
229         let mut i = text_start;
230         while i < pc.texts.len() {
231             let bounds = &mut pc.texts[i].6;
232             let b = bounds.unwrap_or([pane_l, pane_t, pane_r, pane_b]);
233             let clamped = [
234                 b[0].max(pane_l),
235                 b[1].max(pane_t),
236                 b[2].min(pane_r),
237                 b[3].min(pane_b),
238             ];
239             if clamped[2] <= clamped[0] || clamped[3] <= clamped[1] {
240                 pc.texts.remove(i);
241                 continue;
242             }
243             *bounds = Some(clamped);
244             i += 1;
245         }
246     }
247 
248     fn emit(&self, pc: &mut PageContent) {
249         // For `rect_with_radius_corners` on the well fill — the inherent
250         // `rect`/`text` methods still win over the trait's by the usual
251         // inherent-first rule, so the calls below are unaffected.
252         use cce_ui::layout::RenderTarget;
253         let (cx, cy, cw, ch) = self.rect;
254 
255         let text_fg = cce_ui::color::TEXT_FG;
256         let text_dim = cce_ui::color::TEXT_DIM;
257         let label_fg = cce_ui::color::TEXT_ACCENT;
258 
259         if self.path.is_none() {
260             let pad = cce_ui::layout::plate_padding();
261             pc.text("Select a file to view details", cx + pad, cy + pad, 13.0, text_dim);
262             return;
263         }
264 
265         let half_h = ch * 0.5;
266         let pad = cce_ui::layout::section_padding();
267         // Inside each well the content stands off the rim by the pane rung.
268         let inset = cce_ui::layout::plate_padding();
269         // Under control_relief each well is recessed into the plate (the
270         // list's treatment); with it off, the well is its 1px outline
271         // (`well_outline`).
272         let relief = cce_ui::layout::control_relief();
273         let radius = cce_ui::layout::list_corner_radius();
274 
275         // 1. Top pane: File Preview. The well spans the FULL pane rect, so
276         // its edge sits at the pane edge like the list across the split — the
277         // visible split gap is exactly root_plate_gap on both sides — and its
278         // top edge at the pane top, aligned with the breadcrumb.
279         if !relief {
280             well_outline(pc, cx, cy, cw, half_h);
281         }
282         if relief {
283             if self.focused_well == Some(PreviewWell::Top) {
284                 pc.relief_recessed_focused(cx, cy, cw, half_h, radius);
285             } else {
286                 pc.relief_recessed(cx, cy, cw, half_h, radius);
287             }
288         }
289         let rect_y = cy + inset;
290         let rect_h = half_h - pad - 2.0 * inset;
291 
292         // Content fill spans the FULL well RECT, rounded to the well's radius —
293         // `RowList::push_prims` verbatim, so the two read as the same material
294         // across the split. It used to be a square-cornered rect inset to the
295         // content box (cy + 12, half_h - pad - 24), which left the well floor
296         // showing plate colour in a shelf ~7px deep at the top and ~12px at the
297         // bottom while the list's fill ran edge to edge into its rim. The
298         // content box below is unchanged: text and images already start at
299         // rect_y, so widening the fill moves nothing but the shelf.
300         let bg_color = cce_ui::color::list_bg_color();
301         pc.rect_with_radius_corners(bg_color, cx, cy, cw, half_h, radius, (true, true, true, true));
302 
303         if let Some((id, img_w, img_h)) = self.image_tex {
304             let fitted = fit_rect(
305                 img_w,
306                 img_h,
307                 Rect { x: cx, y: rect_y, width: cw, height: rect_h },
308                 FitMode::Contain { max_upscale: 4.0 },
309             );
310             pc.image(id, fitted.x, fitted.y, fitted.width, fitted.height, 1.0);
311         } else if let Some(content) = &self.content_preview {
312             // The first whole line scrolled past, plus the sub-line remainder
313             // the glide is mid-way through: lines slide under the well's top
314             // and bottom edges, clipped to the content box so a partial line
315             // renders cut rather than popping.
316             let first_line = (self.scroll_px / PREVIEW_LINE_H).floor().max(0.0);
317             let frac = self.scroll_px - first_line * PREVIEW_LINE_H;
318             let clip_top = rect_y;
319             let clip_bottom = rect_y + rect_h - 8.0;
320             let clip = [cx, clip_top, cx + cw, clip_bottom];
321             let mut text_y = rect_y + inset - frac;
322             for line in content.lines().skip(first_line as usize) {
323                 if text_y >= clip_bottom {
324                     break;
325                 }
326                 // Chars-per-width from one measured glyph (cached) instead of
327                 // the old magic 6.8 px/char guess.
328                 let char_w = measure_text_width("M", "monospace", 11.0).max(1.0);
329                 let limit = (((cw - 2.0 * inset) / char_w).floor() as usize).max(20);
330                 let line_truncated = truncate_tail(line, limit);
331                 pc.text_with_font_bounded(&line_truncated, cx + inset, text_y, 11.0, text_fg, "monospace", clip);
332                 text_y += PREVIEW_LINE_H;
333             }
334         } else {
335             pc.text("No preview available", cx + inset, rect_y + inset, 11.0, text_dim);
336         }
337 
338         // 2. Bottom pane: Details Section. The visible gap between the wells
339         // is exactly root_plate_gap — the same separator width as everywhere
340         // else on the plate (it was a hardcoded 12+7=19px before).
341         let details_top = cy + half_h + cce_ui::layout::root_plate_gap();
342         let icon = if self.is_dir { "folder" } else { "file" };
343 
344         let details = [
345             ("Path", &self.path_display),
346             ("Type", &self.file_type),
347             ("Size", &self.size),
348             ("Permissions", &self.permissions),
349             ("Modified", &self.modified),
350         ];
351 
352         let details_content_start_y = details_top + pad + inset;
353 
354         // The well's bottom edge sits AT the pane bottom, aligned with the
355         // list across the split (content-sized before; short panes just show
356         // empty well below the rows).
357         if !relief {
358             well_outline(pc, cx, details_top, cw, (cy + ch) - details_top);
359         }
360         if relief {
361             if self.focused_well == Some(PreviewWell::Bottom) {
362                 pc.relief_recessed_focused(cx, details_top, cw, (cy + ch) - details_top, radius);
363             } else {
364                 pc.relief_recessed(cx, details_top, cw, (cy + ch) - details_top, radius);
365             }
366         }
367 
368         // TODO(style): the header's 6px drop, the 42/112 label columns and
369         // the 20px row pitch are the details form's own rhythm.
370         let header_y = details_content_start_y + 6.0;
371         // A 20px glyph centred on the 16px name beside it.
372         pc.icon(icon, cx + inset, header_y + 4.0 + (16.0 * 1.2 - 20.0) * 0.5, 20.0, 20.0, text_fg);
373 
374         // Pixel-measured budgets against the section frame's inner right edge
375         // (None-font text renders sans-serif — measure with the same family).
376         let frame_right = cx + cw - 4.0 - pad;
377         let name_avail = (frame_right - (cx + 42.0) - 8.0).max(40.0);
378         let val_avail = (frame_right - (cx + 112.0) - 8.0).max(40.0);
379 
380         let name_truncated = truncate_px(&self.name, "sans-serif", 16.0, name_avail, false);
381         pc.text(&name_truncated, cx + 42.0, header_y + 4.0, 16.0, text_fg);
382 
383         let mut y = details_content_start_y + 36.0;
384         for (label, val) in &details {
385             pc.text(label, cx + inset, y, 12.0, label_fg);
386             let val_str = truncate_px(val, "sans-serif", 12.0, val_avail, true);
387             pc.text(&val_str, cx + 112.0, y, 12.0, text_dim);
388             y += 20.0;
389         }
390 
391         if !self.target.is_empty() {
392             // One pane gap sets the link target off from the rows above.
393             y += cce_ui::layout::plate_gap();
394             pc.text("Target", cx + inset, y, 12.0, label_fg);
395             let target_str = truncate_px(&self.target, "sans-serif", 12.0, val_avail, true);
396             pc.text(&target_str, cx + 112.0, y, 12.0, text_dim);
397         }
398     }
399 }
400 
401 /// A well's frame with control relief off: a 1px outline round its rect, in
402 /// the section border colour.
403 fn well_outline(pc: &mut PageContent, x: f32, y: f32, w: f32, h: f32) {
404     const BORDER: [f32; 4] = [0.25, 0.25, 0.35, 1.0];
405     pc.rect(BORDER, x, y, w, 1.0);
406     pc.rect(BORDER, x, y + h, w, 1.0);
407     pc.rect(BORDER, x, y, 1.0, h);
408     pc.rect(BORDER, x + w - 1.0, y, 1.0, h);
409 }