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

src/preview_pane.rs (18.8K)

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