Krolyxon
Commit e3295f9

Branches

Tags

Refs →

remagesrcuimod.rs

397 lines · 12.6 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
5use std::path::Path;
6use std::time::Instant;
7
8use crate::config::theme::{Rgba, Theme};
9use crate::render::atlas::ThumbDraw;
10use crate::render::image::AlphaStyle;
11use crate::render::prims::Prim;
12use crate::render::text::{Align, FontRole, TextItem};
13use crate::render::{ImageDraw, Scene};
14use crate::state::{AlphaBg, AppState, BAR_HEIGHT, GRID_NAME_SIZE, Mode, MsgKind, ThumbState};
15
16/// Space Mono label tracking.
17const LABEL_TRACKING: f32 = 0.08;
18
19pub fn file_name(path: &Path) -> String {
20 path.file_name().map_or_else(
21 || path.display().to_string(),
22 |n| n.to_string_lossy().into(),
23 )
24}
25
26fn label(text: &str, color: Rgba, sf: f32, x: f32, y: f32, align: Align) -> TextItem {
27 TextItem {
28 spans: vec![(text.to_uppercase(), color)],
29 role: FontRole::Mono,
30 size: (11.0 * sf).round(),
31 weight: 400,
32 tracking: LABEL_TRACKING,
33 x,
34 y,
35 align,
36 max_width: None,
37 clip: None,
38 }
39}
40
41/// Longest message shown before truncation, in characters.
42const MAX_MESSAGE: usize = 72;
43
44/// `measure` returns the laid-out width of a text item, for layouts that depend on it.
45pub fn scene<'a>(
46 state: &'a AppState,
47 theme: &Theme,
48 now: Instant,
49 measure: &mut dyn FnMut(&TextItem) -> f32,
50) -> Scene<'a> {
51 let sf = state.ui_scale();
52 let [w, h] = state.window();
53 let bar_h = state.bar_height();
54 let mut scene = Scene {
55 clear: theme.background,
56 image_clip: [0.0, 0.0, w, h - bar_h],
57 alpha: alpha_style(state.alpha_bg, theme, sf),
58 images: Vec::new(),
59 thumbs: Vec::new(),
60 under: Vec::new(),
61 prims: Vec::new(),
62 texts: Vec::new(),
63 };
64
65 if state.in_grid() {
66 grid(&mut scene, state, theme, now);
67 } else {
68 image(&mut scene, state, now);
69 }
70 if state.queue.is_empty() {
71 empty_state(&mut scene, theme, sf, [w, h - bar_h]);
72 }
73 if bar_h > 0.0 {
74 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
75 } else if state.prompt.is_some() {
76 // A question can't hide with the bar: it overlays the image until answered.
77 let bar_h = (BAR_HEIGHT * sf).round();
78 status_bar(&mut scene, state, theme, now, [w, h], bar_h, measure);
79 }
80 scene
81}
82
83/// The current image, and the previous one fading out under it.
84fn image<'a>(scene: &mut Scene<'a>, state: &'a AppState, now: Instant) {
85 let nearest = state.effective_nearest(now);
86 if let Some(fade) = &state.fade {
87 scene.images.push(ImageDraw {
88 path: &fade.path,
89 affine: fade.affine,
90 alpha: 1.0,
91 nearest,
92 });
93 }
94 if let Some(shown) = &state.shown {
95 let alpha = state.fade.as_ref().map_or(1.0, |f| f.alpha.value(now));
96 let affine = state.view.transform(now);
97 scene.images.push(ImageDraw {
98 path: &shown.path,
99 affine,
100 alpha,
101 nearest,
102 });
103 }
104}
105
106/// `r` grown by `d` on every side.
107fn expand(r: [f32; 4], d: f32) -> [f32; 4] {
108 [r[0] - d, r[1] - d, r[2] + 2.0 * d, r[3] + 2.0 * d]
109}
110
111/// Thumbnails, the cursor, marks and the visual range.
112fn grid<'a>(scene: &mut Scene<'a>, state: &'a AppState, theme: &Theme, now: Instant) {
113 let sf = state.ui_scale();
114 let l = state.grid_layout();
115 // Whole pixels keep 1:1 thumbnails crisp while scrolling.
116 let scroll = state.grid.scroll(&l, now).round();
117 let cursor = state.queue.index();
118 let range = state.visual_range();
119 let line = sf.round().max(1.0);
120 let area = [0.0, 0.0, l.viewport[0], l.viewport[1]];
121 for i in l.visible(scroll) {
122 let Some(path) = state.queue.get(i) else {
123 continue;
124 };
125 let [x, y] = l.origin(i);
126 let cell = [x, y - scroll, l.cell, l.cell];
127 let thumb = state.thumbs.get(path);
128 let rect = match thumb {
129 Some(ThumbState { size, .. }) => {
130 let k = (l.cell / size[0].max(size[1]) as f32).min(1.0);
131 let (tw, th) = ((size[0] as f32 * k).round(), (size[1] as f32 * k).round());
132 [
133 cell[0] + ((l.cell - tw) * 0.5).floor(),
134 cell[1] + ((l.cell - th) * 0.5).floor(),
135 tw,
136 th,
137 ]
138 }
139 _ => cell,
140 };
141 if range.as_ref().is_some_and(|r| r.contains(&i)) {
142 scene.under.push(Prim::rounded(
143 expand(cell, 6.0 * sf),
144 theme.surface_raised.linear(),
145 8.0 * sf,
146 ));
147 }
148 let (cx, cy) = (cell[0] + l.cell * 0.5, cell[1] + l.cell * 0.5);
149 match thumb {
150 Some(t) => scene.thumbs.push(ThumbDraw {
151 path,
152 rect,
153 alpha: state.thumb_alpha(t.at, now),
154 }),
155 // Pending: one dim dot, no skeleton.
156 None => scene.prims.push(Prim::dot(
157 [cx, cy],
158 2.0 * sf,
159 theme.text_disabled.linear(),
160 None,
161 )),
162 }
163 if state.queue.is_marked(i) {
164 let r = 4.0 * sf;
165 scene.prims.push(Prim::dot(
166 [rect[0] + rect[2] - 2.0 * r, rect[1] + 2.0 * r],
167 r,
168 theme.accent.linear(),
169 Some((theme.background.linear(), line)),
170 ));
171 }
172 if i == cursor {
173 scene.prims.push(Prim::outline(
174 expand(rect, 4.0 * sf),
175 theme.text_display.linear(),
176 4.0 * sf,
177 line,
178 ));
179 }
180 if l.label > 0.0 {
181 let color = if i == cursor {
182 theme.text_primary
183 } else {
184 theme.text_secondary
185 };
186 scene.texts.push(TextItem {
187 spans: vec![(file_name(path), color)],
188 role: FontRole::Ui,
189 size: (GRID_NAME_SIZE * sf).round(),
190 weight: 400,
191 tracking: 0.0,
192 x: cx,
193 y: cell[1] + l.cell + l.label * 0.5,
194 align: Align::Center,
195 max_width: Some(l.cell),
196 clip: Some(area),
197 });
198 }
199 }
200}
201
202fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
203 let (mode, dot) = match bg {
204 AlphaBg::Solid => (0, theme.image_background),
205 AlphaBg::Dots => (1, theme.border_visible),
206 AlphaBg::Checker => (2, theme.surface_raised),
207 };
208 let cell = if mode == 2 { 8.0 } else { 16.0 };
209 AlphaStyle {
210 mode,
211 bg: theme.image_background.linear(),
212 dot: dot.linear(),
213 cell: (cell * sf).round(),
214 radius: sf.max(1.0),
215 }
216}
217
218/// Doto `NO IMAGES` and one hint line.
219fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
220 let cx = area[0] * 0.5;
221 let cy = area[1] * 0.5;
222 scene.texts.push(TextItem {
223 spans: vec![("NO IMAGES".into(), theme.text_secondary)],
224 role: FontRole::Display,
225 size: (48.0 * sf).round(),
226 weight: 700,
227 tracking: -0.02,
228 x: cx,
229 y: cy - 12.0 * sf,
230 align: Align::Center,
231 max_width: None,
232 clip: None,
233 });
234 let hint = "remage <file|dir>…";
235 scene.texts.push(label(
236 hint,
237 theme.text_disabled,
238 sf,
239 cx,
240 cy + 32.0 * sf,
241 Align::Center,
242 ));
243}
244
245fn status_bar(
246 scene: &mut Scene,
247 state: &AppState,
248 theme: &Theme,
249 now: Instant,
250 [w, h]: [f32; 2],
251 bar_h: f32,
252 measure: &mut dyn FnMut(&TextItem) -> f32,
253) {
254 let sf = state.ui_scale();
255 let pad = 16.0 * sf;
256 let y = h - bar_h * 0.5;
257 scene.prims.push(Prim::rect(
258 [0.0, h - bar_h, w, bar_h],
259 theme.background.linear(),
260 ));
261
262 let mut spans: Vec<(String, Rgba)> = Vec::new();
263 if !state.pending.is_empty() {
264 spans.push((state.pending.clone(), theme.text_disabled));
265 }
266 if let Some((_, name)) = state.running.last() {
267 let more = match state.running.len() {
268 1 => String::new(),
269 n => format!(" +{}", n - 1),
270 };
271 spans.push((
272 format!("[RUNNING {}{more}]", name.to_uppercase()),
273 theme.text_secondary,
274 ));
275 }
276 if let Some(m) = &state.message {
277 let color = if m.kind == MsgKind::Error {
278 theme.accent
279 } else {
280 theme.text_primary
281 };
282 let mut text = m.text.to_uppercase();
283 if let Some((cut, _)) = text.char_indices().nth(MAX_MESSAGE) {
284 text.truncate(cut);
285 text.push('…');
286 }
287 spans.push((format!("[{text}]"), color));
288 }
289 if state.loading_visible(now) {
290 spans.push(("[LOADING]".into(), theme.text_secondary));
291 }
292 if state.skipped > 0 {
293 spans.push((format!("[SKIPPED {}]", state.skipped), theme.text_secondary));
294 }
295 if let Some(r) = state.visual_range() {
296 spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary));
297 }
298 let mut info = Vec::new();
299 if let Some(s) = state
300 .shown
301 .as_ref()
302 .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current())
303 {
304 if let Some(pb) = &state.playback {
305 let at = format!("{}/{}", pb.index + 1, pb.len());
306 if !pb.animated {
307 info.push(format!("PAGE {at}"));
308 } else if !pb.playing {
309 info.push(format!("FRAME {at}"));
310 }
311 }
312 info.push(s.format.to_string());
313 info.push(format!("{}×{}", s.size[0], s.size[1]));
314 info.push(format!("{}%", (state.view.scale() * 100.0).round()));
315 }
316 if !state.queue.is_empty() {
317 let count = state.queue.len();
318 let digits = count.to_string().len().max(3);
319 info.push(format!("{:0digits$}/{count}", state.queue.index() + 1));
320 }
321 if !info.is_empty() {
322 spans.push((info.join(" ").to_uppercase(), theme.text_secondary));
323 }
324 // Two spaces between segments, attached to the preceding span so colours stay put.
325 let n = spans.len();
326 for (s, _) in spans.iter_mut().take(n.saturating_sub(1)) {
327 s.push_str(" ");
328 }
329 let mut right_w = 0.0;
330 if !spans.is_empty() {
331 let mut item = label("", theme.text_secondary, sf, w - pad, y, Align::Right);
332 // Already in caps, except pending keys: `g` and `G` are different keys.
333 item.spans = spans;
334 right_w = measure(&item);
335 scene.texts.push(item);
336 }
337
338 // Marks: an accent dot and the count, filled when the current file is marked.
339 let marks = state.queue.marked_count();
340 if marks > 0 {
341 let gap = if right_w > 0.0 { 16.0 * sf } else { 0.0 };
342 let item = label(
343 &marks.to_string(),
344 theme.text_secondary,
345 sf,
346 w - pad - right_w - gap,
347 y,
348 Align::Right,
349 );
350 let count_w = measure(&item);
351 scene.texts.push(item);
352 let r = 3.0 * sf;
353 let cx = w - pad - right_w - gap - count_w - 6.0 * sf - r;
354 let accent = theme.accent.linear();
355 let dot = if state.queue.is_marked(state.queue.index()) {
356 Prim::dot([cx, y], r, accent, None)
357 } else {
358 Prim::outline(
359 [cx - r, y - r, 2.0 * r, 2.0 * r],
360 accent,
361 r,
362 sf.round().max(1.0),
363 )
364 };
365 scene.prims.push(dot);
366 right_w += gap + count_w + 6.0 * sf + 2.0 * r;
367 }
368
369 // A pending question takes the filename's place: `DELETE 3 FILES? Y / N`.
370 if let Some(q) = &state.prompt {
371 let mut item = label("", theme.text_display, sf, pad, y, Align::Left);
372 item.spans = vec![
373 (format!("{} ", q.to_uppercase()), theme.text_display),
374 ("Y / N".into(), theme.text_secondary),
375 ];
376 item.max_width = Some((w - 2.0 * pad - right_w - 32.0 * sf).max(0.0));
377 scene.texts.push(item);
378 return;
379 }
380
381 // The filename takes whatever the right side leaves, ellipsised in the middle.
382 if let Some(cur) = state.queue.current() {
383 let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);
384 scene.texts.push(TextItem {
385 spans: vec![(file_name(cur), theme.text_primary)],
386 role: FontRole::Ui,
387 size: (14.0 * sf).round(),
388 weight: 400,
389 tracking: 0.0,
390 x: pad,
391 y,
392 align: Align::Left,
393 max_width: Some(room),
394 clip: None,
395 });
396 }
397}