Krolyxon
Commit 1d9227a

Branches

Tags

Refs →

remagesrcuimod.rs

899 lines · 29.2 KB · Rust

1//! UI surfaces: status bar, grid view, palette, which-key, help and info panel.
2//!
3//! Each frame the UI turns state into a [`Scene`]; the renderer only draws it.
4
5pub mod template;
6
7use std::path::Path;
8use std::time::Instant;
9
10use crate::config::theme::{Rgba, Theme};
11use crate::render::atlas::ThumbDraw;
12use crate::render::image::AlphaStyle;
13use crate::render::prims::Prim;
14use crate::render::text::{Align, FontRole, TextItem};
15use crate::render::{ImageDraw, Scene};
16use crate::state::overlay::{HelpRow, InputKind, LIST_ROWS};
17use crate::state::{
18 AlphaBg, AppState, BAR_HEIGHT, GRID_NAME_SIZE, HELP_HEAD, Mode, MsgKind, PANEL_MARGIN,
19 PANEL_PAD, PANEL_ROW, ThumbState,
20};
21
22/// Space Mono label tracking.
23const LABEL_TRACKING: f32 = 0.08;
24
25pub use crate::state::file_name;
26
27/// Key notation for display: `<lt>`, `<Bar>` and `<Bslash>` as the characters themselves.
28pub fn pretty_keys(s: &str) -> String {
29 s.replace("<lt>", "<")
30 .replace("<Bar>", "|")
31 .replace("<Bslash>", "\\")
32}
33
34/// Space Mono caps: labels and data.
35fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
36 mono(&text.to_uppercase(), color, sf, x, y, align)
37}
38
39/// Space Mono as written: key names, where `g` and `G` differ, and action grammar.
40fn mono(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
41 TextItem {
42 spans: vec![(text.to_string(), color)],
43 role: FontRole::Mono,
44 size: (11.0 * sf).round(),
45 weight: 400,
46 tracking: LABEL_TRACKING,
47 x,
48 y,
49 align,
50 max_width: None,
51 clip: None,
52 }
53}
54
55/// Space Grotesk 14: names and descriptions.
56fn body(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
57 TextItem {
58 spans: vec![(text.to_string(), color)],
59 role: FontRole::Ui,
60 size: (14.0 * sf).round(),
61 weight: 400,
62 tracking: 0.0,
63 x,
64 y,
65 align,
66 max_width: None,
67 clip: None,
68 }
69}
70
71/// Longest message shown before truncation, in characters.
72const MAX_MESSAGE: usize = 72;
73
74/// `measure` returns the laid-out width of a text item, for layouts that depend on it.
75pub fn scene<'a>(
76 state: &'a AppState,
77 theme: &Theme,
78 now: Instant,
79 measure: &mut dyn FnMut(&TextItem) -> f32,
80) -> Scene<'a> {
81 let sf = state.ui_scale();
82 let [w, h] = state.window();
83 let bar_h = state.bar_height();
84 let mut scene = Scene {
85 clear: theme.background,
86 image_clip: [0.0, 0.0, w, h - bar_h],
87 alpha: AlphaStyle {
88 adjust: [
89 state.adjust.gamma,
90 state.adjust.brightness,
91 state.adjust.contrast,
92 ],
93 ..alpha_style(state.alpha_bg, theme, sf)
94 },
95 images: Vec::new(),
96 thumbs: Vec::new(),
97 under: Vec::new(),
98 prims: Vec::new(),
99 texts: Vec::new(),
100 overlay_prims: Vec::new(),
101 overlay_texts: Vec::new(),
102 };
103
104 if state.in_grid() {
105 grid(&mut scene, state, theme, now);
106 } else {
107 image(&mut scene, state, now);
108 }
109 if state.queue.is_empty() {
110 let hint = if state.queue.all_files().is_empty() {
111 None
112 } else if state.input.is_some() {
113 Some("<Esc> brings everything back")
114 } else {
115 Some("filter-clear shows everything")
116 };
117 empty_state(&mut scene, theme, sf, [w, h - bar_h], hint);
118 }
119 if bar_h > 0.0 {
120 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
121 } else if state.prompt.is_some() {
122 // A question can't hide with the bar: it overlays the image until answered.
123 let bar_h = (BAR_HEIGHT * sf).round();
124 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
125 }
126
127 // Overlays, anchored to the bottom (above the bar) or the right edge.
128 let o = Overlay {
129 sf,
130 m: (PANEL_MARGIN * sf).round(),
131 pad: (PANEL_PAD * sf).round(),
132 row: (PANEL_ROW * sf).round(),
133 w,
134 bottom: h - bar_h,
135 };
136 // Panels share one text layer, so they never overlap: help takes the right edge alone,
137 // and the info panel stays in the upper part, clear of the bottom panels.
138 if state.help.is_some() {
139 help_panel(&mut scene, state, theme, &o);
140 } else if state.info_open {
141 info_panel(&mut scene, state, theme, &o);
142 }
143 if state.input.is_some() {
144 prompt_panel(&mut scene, state, theme, &o, measure);
145 } else if state.which_key.is_some() && state.help.is_none() {
146 which_key_panel(&mut scene, state, theme, &o);
147 } else {
148 count_numerals(&mut scene, state, theme, &o);
149 }
150 scene
151}
152
153/// The current image, and the previous one fading out under it.
154fn image<'a>(scene: &mut Scene<'a>, state: &'a AppState, now: Instant) {
155 let nearest = state.effective_nearest(now);
156 if let Some(fade) = &state.fade {
157 scene.images.push(ImageDraw {
158 path: &fade.path,
159 affine: fade.affine,
160 alpha: 1.0,
161 nearest,
162 });
163 }
164 if let Some(shown) = &state.shown {
165 let alpha = state.fade.as_ref().map_or(1.0, |f| f.alpha.value(now));
166 let affine = state.view.transform(now);
167 scene.images.push(ImageDraw {
168 path: &shown.path,
169 affine,
170 alpha,
171 nearest,
172 });
173 }
174}
175
176/// `r` grown by `d` on every side.
177fn expand(r: [f32; 4], d: f32) -> [f32; 4] {
178 [r[0] - d, r[1] - d, r[2] + 2.0 * d, r[3] + 2.0 * d]
179}
180
181/// Thumbnails, the cursor, marks and the visual range.
182fn grid<'a>(scene: &mut Scene<'a>, state: &'a AppState, theme: &Theme, now: Instant) {
183 let sf = state.ui_scale();
184 let l = state.grid_layout();
185 // Whole pixels keep 1:1 thumbnails crisp while scrolling.
186 let scroll = state.grid.scroll(&l, now).round();
187 let cursor = state.queue.index();
188 let range = state.visual_range();
189 let line = sf.round().max(1.0);
190 let area = [0.0, 0.0, l.viewport[0], l.viewport[1]];
191 for i in l.visible(scroll) {
192 let Some(path) = state.queue.get(i) else {
193 continue;
194 };
195 let [x, y] = l.origin(i);
196 let cell = [x, y - scroll, l.cell, l.cell];
197 let thumb = state.thumbs.get(path);
198 let rect = match thumb {
199 Some(ThumbState { size, .. }) => {
200 let k = (l.cell / size[0].max(size[1]) as f32).min(1.0);
201 let (tw, th) = ((size[0] as f32 * k).round(), (size[1] as f32 * k).round());
202 [
203 cell[0] + ((l.cell - tw) * 0.5).floor(),
204 cell[1] + ((l.cell - th) * 0.5).floor(),
205 tw,
206 th,
207 ]
208 }
209 _ => cell,
210 };
211 if range.as_ref().is_some_and(|r| r.contains(&i)) {
212 scene.under.push(Prim::rounded(
213 expand(cell, 6.0 * sf),
214 theme.surface_raised.linear(),
215 8.0 * sf,
216 ));
217 }
218 let (cx, cy) = (cell[0] + l.cell * 0.5, cell[1] + l.cell * 0.5);
219 match thumb {
220 Some(t) => scene.thumbs.push(ThumbDraw {
221 path,
222 rect,
223 alpha: state.thumb_alpha(t.at, now),
224 }),
225 // Pending: one dim dot, no skeleton.
226 None => scene.prims.push(Prim::dot(
227 [cx, cy],
228 2.0 * sf,
229 theme.text_disabled.linear(),
230 None,
231 )),
232 }
233 if state.queue.is_marked(i) {
234 let r = 4.0 * sf;
235 scene.prims.push(Prim::dot(
236 [rect[0] + rect[2] - 2.0 * r, rect[1] + 2.0 * r],
237 r,
238 theme.accent.linear(),
239 Some((theme.background.linear(), line)),
240 ));
241 }
242 if i == cursor {
243 scene.prims.push(Prim::outline(
244 expand(rect, 4.0 * sf),
245 theme.text_display.linear(),
246 4.0 * sf,
247 line,
248 ));
249 }
250 if l.label > 0.0 {
251 let color = if i == cursor {
252 theme.text_primary
253 } else {
254 theme.text_secondary
255 };
256 scene.texts.push(TextItem {
257 spans: vec![(file_name(path), color)],
258 role: FontRole::Ui,
259 size: (GRID_NAME_SIZE * sf).round(),
260 weight: 400,
261 tracking: 0.0,
262 x: cx,
263 y: cell[1] + l.cell + l.label * 0.5,
264 align: Align::Center,
265 max_width: Some(l.cell),
266 clip: Some(area),
267 });
268 }
269 }
270}
271
272fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
273 let (mode, dot) = match bg {
274 AlphaBg::Solid => (0, theme.image_background),
275 AlphaBg::Dots => (1, theme.border_visible),
276 AlphaBg::Checker => (2, theme.surface_raised),
277 };
278 let cell = if mode == 2 { 8.0 } else { 16.0 };
279 AlphaStyle {
280 adjust: [1.0, 0.0, 1.0],
281 mode,
282 bg: theme.image_background.linear(),
283 dot: dot.linear(),
284 cell: (cell * sf).round(),
285 radius: sf.max(1.0),
286 }
287}
288
289/// Doto `NO IMAGES` (or `NO MATCHES` behind a filter) and one hint line.
290fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2], hint: Option<&str>) {
291 let cx = area[0] * 0.5;
292 let cy = area[1] * 0.5;
293 let title = if hint.is_some() {
294 "NO MATCHES"
295 } else {
296 "NO IMAGES"
297 };
298 scene.texts.push(TextItem {
299 spans: vec![(title.into(), theme.text_secondary)],
300 role: FontRole::Display,
301 size: (48.0 * sf).round(),
302 weight: 700,
303 tracking: -0.02,
304 x: cx,
305 y: cy - 12.0 * sf,
306 align: Align::Center,
307 max_width: None,
308 clip: None,
309 });
310 let hint = hint.unwrap_or("remage <file|dir>…");
311 scene.texts.push(label(
312 hint,
313 theme.text_disabled,
314 sf,
315 cx,
316 cy + 32.0 * sf,
317 Align::Center,
318 ));
319}
320
321/// A template placeholder's value for the current state: `Some("")` when it has none right
322/// now (the segment drops out), `None` for unknown names.
323pub fn var(state: &AppState, name: &str) -> Option<String> {
324 let q = &state.queue;
325 let cur = q.current();
326 let lossy = |s: &std::ffi::OsStr| s.to_string_lossy().into_owned();
327 let shown = state
328 .shown
329 .as_ref()
330 .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == cur);
331 let v = match name {
332 "name" => cur.map(file_name),
333 "path" => cur.map(|p| p.display().to_string()),
334 "dir" => cur.and_then(Path::parent).map(|d| d.display().to_string()),
335 "stem" => cur.and_then(Path::file_stem).map(lossy),
336 "ext" => cur.and_then(Path::extension).map(lossy),
337 "format" => shown.map(|s| s.format.to_string()),
338 "width" => shown.map(|s| s.size[0].to_string()),
339 "height" => shown.map(|s| s.size[1].to_string()),
340 "zoom" => shown.map(|_| format!("{}", (state.view.scale() * 100.0).round())),
341 "index" => (!q.is_empty()).then(|| {
342 let digits = q.len().to_string().len().max(3);
343 format!("{:0digits$}", q.index() + 1)
344 }),
345 "count" => (!q.is_empty()).then(|| q.len().to_string()),
346 "marked" => (q.marked_count() > 0).then(|| q.marked_count().to_string()),
347 "frame" => shown
348 .and(state.playback.as_ref())
349 .map(|pb| format!("{}/{}", pb.index + 1, pb.len())),
350 "mode" => Some(state.mode.name().to_string()),
351 "filter" => q.filter().map(str::to_string),
352 "info" => state
353 .info_line
354 .as_ref()
355 .filter(|(p, _)| Some(p.as_path()) == cur)
356 .map(|(_, l)| l.clone()),
357 _ => return None,
358 };
359 Some(v.unwrap_or_default())
360}
361
362fn status_bar(
363 scene: &mut Scene,
364 state: &AppState,
365 theme: &Theme,
366 now: Instant,
367 [w, h]: [f32; 2],
368 bar_h: f32,
369 measure: &mut dyn FnMut(&TextItem) -> f32,
370) {
371 let sf = state.ui_scale();
372 let pad = 16.0 * sf;
373 let y = h - bar_h * 0.5;
374 scene.prims.push(Prim::rect(
375 [0.0, h - bar_h, w, bar_h],
376 theme.background.linear(),
377 ));
378 let var = |n: &str| var(state, n);
379
380 let mut spans: Vec<(String, Rgba)> = Vec::new();
381 if !state.pending.is_empty() {
382 spans.push((state.pending.clone(), theme.text_disabled));
383 }
384 if let Some((_, name)) = state.running.last() {
385 let more = match state.running.len() {
386 1 => String::new(),
387 n => format!(" +{}", n - 1),
388 };
389 spans.push((
390 format!("[RUNNING {}{more}]", name.to_uppercase()),
391 theme.text_secondary,
392 ));
393 }
394 if let Some(m) = &state.message {
395 let color = if m.kind == MsgKind::Error {
396 theme.accent
397 } else {
398 theme.text_primary
399 };
400 let mut text = m.text.to_uppercase();
401 if let Some((cut, _)) = text.char_indices().nth(MAX_MESSAGE) {
402 text.truncate(cut);
403 text.push('…');
404 }
405 spans.push((format!("[{text}]"), color));
406 }
407 if state.loading_visible(now) {
408 spans.push(("[LOADING]".into(), theme.text_secondary));
409 }
410 if state.skipped > 0 {
411 spans.push((format!("[SKIPPED {}]", state.skipped), theme.text_secondary));
412 }
413 if let Some(r) = state.visual_range() {
414 spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary));
415 }
416 let mut info = Vec::new();
417 if let Some(f) = state.queue.filter() {
418 info.push(format!("FILTER {f}"));
419 }
420 if state.slideshow.is_some() {
421 info.push(format!(
422 "SLIDESHOW {}S",
423 (state.slideshow_delay.as_secs_f32() * 10.0).round() / 10.0
424 ));
425 }
426 if let Some(pb) = state
427 .playback
428 .as_ref()
429 .filter(|_| state.mode == Mode::Image)
430 {
431 let at = format!("{}/{}", pb.index + 1, pb.len());
432 if !pb.animated {
433 info.push(format!("PAGE {at}"));
434 } else if !pb.playing {
435 info.push(format!("FRAME {at}"));
436 }
437 }
438 let right = template::expand(&state.settings.bar_right, &var);
439 if !right.is_empty() {
440 info.push(right);
441 }
442 if !info.is_empty() {
443 spans.push((info.join(" ").to_uppercase(), theme.text_secondary));
444 }
445 // Two spaces between segments, attached to the preceding span so colours stay put.
446 let n = spans.len();
447 for (s, _) in spans.iter_mut().take(n.saturating_sub(1)) {
448 s.push_str(" ");
449 }
450 let mut right_w = 0.0;
451 if !spans.is_empty() {
452 let mut item = label("", theme.text_secondary, sf, w - pad, y, Align::Right);
453 // Already in caps, except pending keys: `g` and `G` are different keys.
454 item.spans = spans;
455 right_w = measure(&item);
456 scene.texts.push(item);
457 }
458
459 // Marks: an accent dot and the count, filled when the current file is marked.
460 let marks = state.queue.marked_count();
461 if marks > 0 {
462 let gap = if right_w > 0.0 { 16.0 * sf } else { 0.0 };
463 let item = label(
464 &marks.to_string(),
465 theme.text_secondary,
466 sf,
467 w - pad - right_w - gap,
468 y,
469 Align::Right,
470 );
471 let count_w = measure(&item);
472 scene.texts.push(item);
473 let r = 3.0 * sf;
474 let cx = w - pad - right_w - gap - count_w - 6.0 * sf - r;
475 let accent = theme.accent.linear();
476 let dot = if state.queue.is_marked(state.queue.index()) {
477 Prim::dot([cx, y], r, accent, None)
478 } else {
479 Prim::outline(
480 [cx - r, y - r, 2.0 * r, 2.0 * r],
481 accent,
482 r,
483 sf.round().max(1.0),
484 )
485 };
486 scene.prims.push(dot);
487 right_w += gap + count_w + 6.0 * sf + 2.0 * r;
488 }
489
490 // A pending question takes the filename's place: `DELETE 3 FILES? Y / N`.
491 if let Some(q) = &state.prompt {
492 let mut item = label("", theme.text_display, sf, pad, y, Align::Left);
493 item.spans = vec![
494 (format!("{} ", q.to_uppercase()), theme.text_display),
495 ("Y / N".into(), theme.text_secondary),
496 ];
497 item.max_width = Some((w - 2.0 * pad - right_w - 32.0 * sf).max(0.0));
498 scene.texts.push(item);
499 return;
500 }
501
502 // The left side (the filename by default) takes whatever the right leaves, ellipsised in
503 // the middle. An `info_command` line replaces it unless a template places `{info}`.
504 let s = &state.settings;
505 let placed = s.bar_left.contains("{info}") || s.bar_right.contains("{info}");
506 let left = match var("info") {
507 Some(line) if !placed && !line.is_empty() => line,
508 _ => template::expand(&s.bar_left, &var),
509 };
510 if !left.is_empty() {
511 let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);
512 let mut item = body(&left, theme.text_primary, sf, pad, y, Align::Left);
513 item.max_width = Some(room);
514 scene.texts.push(item);
515 }
516}
517
518/// Overlay geometry for this frame, in physical pixels.
519struct Overlay {
520 sf: f32,
521 m: f32,
522 pad: f32,
523 row: f32,
524 w: f32,
525 /// The bottom of the area above the status bar.
526 bottom: f32,
527}
528
529impl Overlay {
530 /// A panel: `surface`, a 1 px `border_visible` border, 12 px radius.
531 fn panel(&self, scene: &mut Scene, theme: &Theme, r: [f32; 4]) {
532 let line = self.sf.round().max(1.0);
533 let radius = 12.0 * self.sf;
534 scene
535 .overlay_prims
536 .push(Prim::rounded(r, theme.surface.linear(), radius));
537 scene.overlay_prims.push(Prim::outline(
538 r,
539 theme.border_visible.linear(),
540 radius,
541 line,
542 ));
543 }
544
545 /// The selected row: `surface_raised` with a 2 px accent bar on the left.
546 fn selection(&self, scene: &mut Scene, theme: &Theme, r: [f32; 4]) {
547 let sf = self.sf;
548 scene
549 .overlay_prims
550 .push(Prim::rounded(r, theme.surface_raised.linear(), 6.0 * sf));
551 let bar = (2.0 * sf).round().max(1.0);
552 scene.overlay_prims.push(Prim::rect(
553 [r[0], r[1] + 6.0 * sf, bar, r[3] - 12.0 * sf],
554 theme.accent.linear(),
555 ));
556 }
557}
558
559fn clipped(mut t: TextItem, max: f32) -> TextItem {
560 t.max_width = Some(max.max(0.0));
561 t
562}
563
564/// The `:` palette or `/` search: the line at the bottom, matches above it, best nearest.
565fn prompt_panel(
566 scene: &mut Scene,
567 state: &AppState,
568 theme: &Theme,
569 o: &Overlay,
570 measure: &mut dyn FnMut(&TextItem) -> f32,
571) {
572 let Some(input) = &state.input else { return };
573 let sf = o.sf;
574 let pw = (o.w - 2.0 * o.m).min(720.0 * sf).max(0.0);
575 let x = o.m;
576 let line_h = (40.0 * sf).round();
577 let image_search = input.kind == InputKind::Search && state.mode == Mode::Image;
578 let listed = input.kind == InputKind::Palette || image_search;
579 let start = input.window_start();
580 let shown = if listed {
581 input.matches.len().saturating_sub(start).min(LIST_ROWS)
582 } else {
583 0
584 };
585 let gap = 8.0 * sf;
586 let list_h = if shown > 0 {
587 shown as f32 * o.row + gap
588 } else {
589 0.0
590 };
591 let ph = line_h + list_h;
592 let y0 = o.bottom - o.m - ph;
593 o.panel(scene, theme, [x, y0, pw, ph]);
594
595 // The line: prompt character, text, caret; the match count on the right.
596 let ly = y0 + list_h + line_h * 0.5;
597 let tx = x + o.pad;
598 scene.overlay_texts.push(mono(
599 input.prefix(),
600 theme.text_secondary,
601 sf,
602 tx,
603 ly,
604 Align::Left,
605 ));
606 let text_x = tx + 16.0 * sf;
607 let count = if listed {
608 format!("{}", input.matches.len())
609 } else {
610 format!("{}/{}", state.queue.len(), state.queue.all_files().len())
611 };
612 let count_item = label(
613 &count,
614 theme.text_disabled,
615 sf,
616 x + pw - o.pad,
617 ly,
618 Align::Right,
619 );
620 let count_w = measure(&count_item);
621 scene.overlay_texts.push(count_item);
622 let room = pw - (text_x - x) - o.pad - count_w - 16.0 * sf;
623 let text = body(&input.text, theme.text_display, sf, text_x, ly, Align::Left);
624 let text_w = if input.text.is_empty() {
625 0.0
626 } else {
627 measure(&text).min(room)
628 };
629 if !input.text.is_empty() {
630 scene.overlay_texts.push(clipped(text, room));
631 }
632 let caret_h = (16.0 * sf).round();
633 scene.overlay_prims.push(Prim::rect(
634 [
635 text_x + text_w + sf,
636 ly - caret_h * 0.5,
637 sf.round().max(1.0),
638 caret_h,
639 ],
640 theme.text_display.linear(),
641 ));
642
643 // Matches, bottom-up from the line.
644 let inner = pw - 2.0 * 8.0 * sf;
645 for k in 0..shown {
646 let idx = start + k;
647 let Some(&m) = input.matches.get(idx) else {
648 break;
649 };
650 let ry = y0 + list_h - gap - (k + 1) as f32 * o.row;
651 let rx = x + 8.0 * sf;
652 if idx == input.selected {
653 o.selection(scene, theme, [rx, ry, inner, o.row]);
654 }
655 let cy = ry + o.row * 0.5;
656 let lx = rx + 16.0 * sf;
657 let right = rx + inner - 8.0 * sf;
658 match input.kind {
659 InputKind::Palette => {
660 let Some(item) = input.items.get(m) else {
661 continue;
662 };
663 let keys_w = inner * 0.2;
664 let action_x = rx + inner * 0.55;
665 scene.overlay_texts.push(clipped(
666 body(&item.desc, theme.text_primary, sf, lx, cy, Align::Left),
667 action_x - lx - 16.0 * sf,
668 ));
669 scene.overlay_texts.push(clipped(
670 mono(
671 &item.action,
672 theme.text_disabled,
673 sf,
674 action_x,
675 cy,
676 Align::Left,
677 ),
678 right - keys_w - action_x - 16.0 * sf,
679 ));
680 if !item.keys.is_empty() {
681 scene.overlay_texts.push(clipped(
682 mono(
683 &item.keys,
684 theme.text_secondary,
685 sf,
686 right,
687 cy,
688 Align::Right,
689 ),
690 keys_w,
691 ));
692 }
693 }
694 InputKind::Search => {
695 let Some(p) = state.queue.get(m) else {
696 continue;
697 };
698 let digits = state.queue.len().to_string().len().max(3);
699 scene.overlay_texts.push(clipped(
700 body(&file_name(p), theme.text_primary, sf, lx, cy, Align::Left),
701 inner * 0.8,
702 ));
703 scene.overlay_texts.push(label(
704 &format!("{:0digits$}", m + 1),
705 theme.text_secondary,
706 sf,
707 right,
708 cy,
709 Align::Right,
710 ));
711 }
712 }
713 }
714}
715
716/// Keys that continue the pending sequence, in columns above the bar.
717fn which_key_panel(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
718 let Some(rows) = &state.which_key else { return };
719 if rows.is_empty() {
720 return;
721 }
722 let sf = o.sf;
723 let room = (o.w - 2.0 * o.m).max(0.0);
724 let col_w = 280.0 * sf;
725 let cols = (((room - 2.0 * o.pad) / col_w).floor() as usize).clamp(1, rows.len());
726 let pw = (cols as f32 * col_w + 2.0 * o.pad).min(room);
727 let per_col = rows.len().div_ceil(cols).min(12);
728 let head = 32.0 * sf;
729 let ph = 2.0 * o.pad + head + per_col as f32 * o.row - 8.0 * sf;
730 let (x0, y0) = (o.m, o.bottom - o.m - ph);
731 o.panel(scene, theme, [x0, y0, pw, ph]);
732 scene.overlay_texts.push(mono(
733 &state.pending,
734 theme.text_secondary,
735 sf,
736 x0 + o.pad,
737 y0 + o.pad + 8.0 * sf,
738 Align::Left,
739 ));
740 let key_w = 72.0 * sf;
741 for (i, r) in rows.iter().take(per_col * cols).enumerate() {
742 let (c, k) = (i / per_col, i % per_col);
743 let x = x0 + o.pad + c as f32 * col_w;
744 let cy = y0 + o.pad + head + k as f32 * o.row + o.row * 0.5 - 4.0 * sf;
745 scene.overlay_texts.push(clipped(
746 mono(&r.key, theme.text_display, sf, x, cy, Align::Left),
747 key_w - 8.0 * sf,
748 ));
749 let color = if r.prefix {
750 theme.text_disabled
751 } else {
752 theme.text_secondary
753 };
754 scene.overlay_texts.push(clipped(
755 body(&r.desc, color, sf, x + key_w, cy, Align::Left),
756 col_w - key_w - 16.0 * sf,
757 ));
758 }
759}
760
761/// The key reference, on the right edge.
762fn help_panel(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
763 let Some(help) = &state.help else { return };
764 let sf = o.sf;
765 let pw = (o.w - 2.0 * o.m).min(520.0 * sf).max(0.0);
766 let ph = (o.bottom - 2.0 * o.m).max(0.0);
767 let (x0, y0) = (o.w - o.m - pw, o.m);
768 o.panel(scene, theme, [x0, y0, pw, ph]);
769 let page = state.help_page();
770 let title = format!("Keys · {}", state.mode.name());
771 let head_y = y0 + o.pad + 8.0 * sf;
772 scene.overlay_texts.push(label(
773 &title,
774 theme.text_secondary,
775 sf,
776 x0 + o.pad,
777 head_y,
778 Align::Left,
779 ));
780 if help.rows.len() > page {
781 let last = (help.scroll + page).min(help.rows.len());
782 scene.overlay_texts.push(label(
783 &format!("{}–{} / {}", help.scroll + 1, last, help.rows.len()),
784 theme.text_disabled,
785 sf,
786 x0 + pw - o.pad,
787 head_y,
788 Align::Right,
789 ));
790 }
791 let key_w = 136.0 * sf;
792 let top = y0 + o.pad + HELP_HEAD * sf;
793 for (k, row) in help.rows.iter().skip(help.scroll).take(page).enumerate() {
794 let cy = top + k as f32 * o.row + o.row * 0.5;
795 let x = x0 + o.pad;
796 match row {
797 HelpRow::Header(h) => {
798 scene.overlay_texts.push(label(
799 h,
800 theme.text_disabled,
801 sf,
802 x,
803 cy + 4.0 * sf,
804 Align::Left,
805 ));
806 }
807 HelpRow::Binding { keys, desc } => {
808 scene.overlay_texts.push(clipped(
809 mono(keys, theme.text_secondary, sf, x, cy, Align::Left),
810 key_w - 12.0 * sf,
811 ));
812 scene.overlay_texts.push(clipped(
813 body(desc, theme.text_primary, sf, x + key_w, cy, Align::Left),
814 pw - 2.0 * o.pad - key_w,
815 ));
816 }
817 }
818 }
819}
820
821/// File and image metadata, top right.
822fn info_panel(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
823 let sf = o.sf;
824 let cur = state.queue.current();
825 let mut rows: Vec<(String, String)> = match (&state.info, cur) {
826 (Some(info), Some(c)) if info.path == c => info.rows.clone(),
827 (_, Some(_)) => vec![("".into(), "[LOADING]".into())],
828 (_, None) => return,
829 };
830 // What the decoder found, after the file rows.
831 if let Some(s) = state
832 .shown
833 .as_ref()
834 .filter(|s| Some(s.path.as_path()) == cur && state.info.is_some())
835 {
836 let at = rows
837 .iter()
838 .position(|(l, _)| l == "Modified")
839 .map_or(rows.len(), |i| i + 1);
840 let [w, h] = s.size;
841 let mp = w as f64 * h as f64 / 1e6;
842 let mut add = vec![
843 ("Format".to_string(), s.format.to_string()),
844 ("Pixels".to_string(), format!("{w} × {h} ({mp:.1} MP)")),
845 ];
846 if let Some(pb) = &state.playback {
847 let what = if pb.animated { "Frames" } else { "Pages" };
848 add.push((what.to_string(), pb.len().to_string()));
849 }
850 rows.splice(at..at, add);
851 }
852 let pw = (o.w - 2.0 * o.m).min(400.0 * sf).max(0.0);
853 let max_rows = (((o.bottom * 0.55 - o.m - 2.0 * o.pad) / o.row).floor() as usize).max(1);
854 let n = rows.len().min(max_rows);
855 let ph = 2.0 * o.pad + n as f32 * o.row - 8.0 * sf;
856 let (x0, y0) = (o.w - o.m - pw, o.m);
857 o.panel(scene, theme, [x0, y0, pw, ph]);
858 let label_w = 112.0 * sf;
859 for (k, (l, v)) in rows.iter().take(n).enumerate() {
860 let cy = y0 + o.pad + k as f32 * o.row + o.row * 0.5 - 4.0 * sf;
861 scene.overlay_texts.push(label(
862 l,
863 theme.text_secondary,
864 sf,
865 x0 + o.pad,
866 cy,
867 Align::Left,
868 ));
869 scene.overlay_texts.push(clipped(
870 body(v, theme.text_primary, sf, x0 + pw - o.pad, cy, Align::Right),
871 pw - 2.0 * o.pad - label_w,
872 ));
873 }
874}
875
876/// A count being typed shows as large Doto numerals in the corner.
877fn count_numerals(scene: &mut Scene, state: &AppState, theme: &Theme, o: &Overlay) {
878 let digits: String = state
879 .pending
880 .chars()
881 .take_while(char::is_ascii_digit)
882 .collect();
883 if digits.is_empty() {
884 return;
885 }
886 let size = (72.0 * o.sf).round();
887 scene.overlay_texts.push(TextItem {
888 spans: vec![(digits, theme.text_display)],
889 role: FontRole::Display,
890 size,
891 weight: 700,
892 tracking: 0.0,
893 x: o.w - o.m - 8.0 * o.sf,
894 y: o.bottom - o.m - size * 0.5,
895 align: Align::Right,
896 max_width: None,
897 clip: None,
898 });
899}