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 }