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 }