Krolyxon
Commit c498905

Branches

Tags

Refs →

remagesrcuimod.rs

363 lines · 11.3 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, 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 }
76 scene
77}
78
79/// The current image, and the previous one fading out under it.
80fn image<'a>(scene: &mut Scene<'a>, state: &'a AppState, now: Instant) {
81 let nearest = state.effective_nearest(now);
82 if let Some(fade) = &state.fade {
83 scene.images.push(ImageDraw {
84 path: &fade.path,
85 affine: fade.affine,
86 alpha: 1.0,
87 nearest,
88 });
89 }
90 if let Some(shown) = &state.shown {
91 let alpha = state.fade.as_ref().map_or(1.0, |f| f.alpha.value(now));
92 let affine = state.view.transform(now);
93 scene.images.push(ImageDraw {
94 path: &shown.path,
95 affine,
96 alpha,
97 nearest,
98 });
99 }
100}
101
102/// `r` grown by `d` on every side.
103fn expand(r: [f32; 4], d: f32) -> [f32; 4] {
104 [r[0] - d, r[1] - d, r[2] + 2.0 * d, r[3] + 2.0 * d]
105}
106
107/// Thumbnails, the cursor, marks and the visual range.
108fn grid<'a>(scene: &mut Scene<'a>, state: &'a AppState, theme: &Theme, now: Instant) {
109 let sf = state.ui_scale();
110 let l = state.grid_layout();
111 // Whole pixels keep 1:1 thumbnails crisp while scrolling.
112 let scroll = state.grid.scroll(&l, now).round();
113 let cursor = state.queue.index();
114 let range = state.visual_range();
115 let line = sf.round().max(1.0);
116 let area = [0.0, 0.0, l.viewport[0], l.viewport[1]];
117 for i in l.visible(scroll) {
118 let Some(path) = state.queue.get(i) else {
119 continue;
120 };
121 let [x, y] = l.origin(i);
122 let cell = [x, y - scroll, l.cell, l.cell];
123 let thumb = state.thumbs.get(path);
124 let rect = match thumb {
125 Some(ThumbState { size, .. }) => {
126 let k = (l.cell / size[0].max(size[1]) as f32).min(1.0);
127 let (tw, th) = ((size[0] as f32 * k).round(), (size[1] as f32 * k).round());
128 [
129 cell[0] + ((l.cell - tw) * 0.5).floor(),
130 cell[1] + ((l.cell - th) * 0.5).floor(),
131 tw,
132 th,
133 ]
134 }
135 _ => cell,
136 };
137 if range.as_ref().is_some_and(|r| r.contains(&i)) {
138 scene.under.push(Prim::rounded(
139 expand(cell, 6.0 * sf),
140 theme.surface_raised.linear(),
141 8.0 * sf,
142 ));
143 }
144 let (cx, cy) = (cell[0] + l.cell * 0.5, cell[1] + l.cell * 0.5);
145 match thumb {
146 Some(t) => scene.thumbs.push(ThumbDraw {
147 path,
148 rect,
149 alpha: state.thumb_alpha(t.at, now),
150 }),
151 // Pending: one dim dot, no skeleton.
152 None => scene.prims.push(Prim::dot(
153 [cx, cy],
154 2.0 * sf,
155 theme.text_disabled.linear(),
156 None,
157 )),
158 }
159 if state.queue.is_marked(i) {
160 let r = 4.0 * sf;
161 scene.prims.push(Prim::dot(
162 [rect[0] + rect[2] - 2.0 * r, rect[1] + 2.0 * r],
163 r,
164 theme.accent.linear(),
165 Some((theme.background.linear(), line)),
166 ));
167 }
168 if i == cursor {
169 scene.prims.push(Prim::outline(
170 expand(rect, 4.0 * sf),
171 theme.text_display.linear(),
172 4.0 * sf,
173 line,
174 ));
175 }
176 if l.label > 0.0 {
177 let color = if i == cursor {
178 theme.text_primary
179 } else {
180 theme.text_secondary
181 };
182 scene.texts.push(TextItem {
183 spans: vec![(file_name(path), color)],
184 role: FontRole::Ui,
185 size: (GRID_NAME_SIZE * sf).round(),
186 weight: 400,
187 tracking: 0.0,
188 x: cx,
189 y: cell[1] + l.cell + l.label * 0.5,
190 align: Align::Center,
191 max_width: Some(l.cell),
192 clip: Some(area),
193 });
194 }
195 }
196}
197
198fn alpha_style(bg: AlphaBg, theme: &Theme, sf: f32) -> AlphaStyle {
199 let (mode, dot) = match bg {
200 AlphaBg::Solid => (0, theme.image_background),
201 AlphaBg::Dots => (1, theme.border_visible),
202 AlphaBg::Checker => (2, theme.surface_raised),
203 };
204 let cell = if mode == 2 { 8.0 } else { 16.0 };
205 AlphaStyle {
206 mode,
207 bg: theme.image_background.linear(),
208 dot: dot.linear(),
209 cell: (cell * sf).round(),
210 radius: sf.max(1.0),
211 }
212}
213
214/// Doto `NO IMAGES` and one hint line.
215fn empty_state(scene: &mut Scene, theme: &Theme, sf: f32, area: [f32; 2]) {
216 let cx = area[0] * 0.5;
217 let cy = area[1] * 0.5;
218 scene.texts.push(TextItem {
219 spans: vec![("NO IMAGES".into(), theme.text_secondary)],
220 role: FontRole::Display,
221 size: (48.0 * sf).round(),
222 weight: 700,
223 tracking: -0.02,
224 x: cx,
225 y: cy - 12.0 * sf,
226 align: Align::Center,
227 max_width: None,
228 clip: None,
229 });
230 let hint = "remage <file|dir>…";
231 scene.texts.push(label(
232 hint,
233 theme.text_disabled,
234 sf,
235 cx,
236 cy + 32.0 * sf,
237 Align::Center,
238 ));
239}
240
241fn status_bar(
242 scene: &mut Scene,
243 state: &AppState,
244 theme: &Theme,
245 now: Instant,
246 [w, h]: [f32; 2],
247 bar_h: f32,
248 measure: &mut dyn FnMut(&TextItem) -> f32,
249) {
250 let sf = state.ui_scale();
251 let pad = 16.0 * sf;
252 let y = h - bar_h * 0.5;
253 scene.prims.push(Prim::rect(
254 [0.0, h - bar_h, w, bar_h],
255 theme.background.linear(),
256 ));
257
258 let mut spans: Vec<(String, Rgba)> = Vec::new();
259 if !state.pending.is_empty() {
260 spans.push((state.pending.clone(), theme.text_disabled));
261 }
262 if let Some(m) = &state.message {
263 let color = if m.kind == MsgKind::Error {
264 theme.accent
265 } else {
266 theme.text_primary
267 };
268 let mut text = m.text.to_uppercase();
269 if let Some((cut, _)) = text.char_indices().nth(MAX_MESSAGE) {
270 text.truncate(cut);
271 text.push('…');
272 }
273 spans.push((format!("[{text}]"), color));
274 }
275 if state.loading_visible(now) {
276 spans.push(("[LOADING]".into(), theme.text_secondary));
277 }
278 if state.skipped > 0 {
279 spans.push((format!("[SKIPPED {}]", state.skipped), theme.text_secondary));
280 }
281 if let Some(r) = state.visual_range() {
282 spans.push((format!("[VISUAL {}]", r.len()), theme.text_secondary));
283 }
284 let mut info = Vec::new();
285 if let Some(s) = state
286 .shown
287 .as_ref()
288 .filter(|s| state.mode == Mode::Image && Some(s.path.as_path()) == state.queue.current())
289 {
290 info.push(s.format.to_string());
291 info.push(format!("{}×{}", s.size[0], s.size[1]));
292 info.push(format!("{}%", (state.view.scale() * 100.0).round()));
293 }
294 if !state.queue.is_empty() {
295 let count = state.queue.len();
296 let digits = count.to_string().len().max(3);
297 info.push(format!("{:0digits$}/{count}", state.queue.index() + 1));
298 }
299 if !info.is_empty() {
300 spans.push((info.join(" ").to_uppercase(), theme.text_secondary));
301 }
302 // Two spaces between segments, attached to the preceding span so colours stay put.
303 let n = spans.len();
304 for (s, _) in spans.iter_mut().take(n.saturating_sub(1)) {
305 s.push_str(" ");
306 }
307 let mut right_w = 0.0;
308 if !spans.is_empty() {
309 let mut item = label("", theme.text_secondary, sf, w - pad, y, Align::Right);
310 // Already in caps, except pending keys: `g` and `G` are different keys.
311 item.spans = spans;
312 right_w = measure(&item);
313 scene.texts.push(item);
314 }
315
316 // Marks: an accent dot and the count, filled when the current file is marked.
317 let marks = state.queue.marked_count();
318 if marks > 0 {
319 let gap = if right_w > 0.0 { 16.0 * sf } else { 0.0 };
320 let item = label(
321 &marks.to_string(),
322 theme.text_secondary,
323 sf,
324 w - pad - right_w - gap,
325 y,
326 Align::Right,
327 );
328 let count_w = measure(&item);
329 scene.texts.push(item);
330 let r = 3.0 * sf;
331 let cx = w - pad - right_w - gap - count_w - 6.0 * sf - r;
332 let accent = theme.accent.linear();
333 let dot = if state.queue.is_marked(state.queue.index()) {
334 Prim::dot([cx, y], r, accent, None)
335 } else {
336 Prim::outline(
337 [cx - r, y - r, 2.0 * r, 2.0 * r],
338 accent,
339 r,
340 sf.round().max(1.0),
341 )
342 };
343 scene.prims.push(dot);
344 right_w += gap + count_w + 6.0 * sf + 2.0 * r;
345 }
346
347 // The filename takes whatever the right side leaves, ellipsised in the middle.
348 if let Some(cur) = state.queue.current() {
349 let room = (w - 2.0 * pad - right_w - 32.0 * sf).max(0.0);
350 scene.texts.push(TextItem {
351 spans: vec![(file_name(cur), theme.text_primary)],
352 role: FontRole::Ui,
353 size: (14.0 * sf).round(),
354 weight: 400,
355 tracking: 0.0,
356 x: pad,
357 y,
358 align: Align::Left,
359 max_width: Some(room),
360 clip: None,
361 });
362 }
363}