Skip to main content

max / makenotwork

15.5 KB · 419 lines History Blame Raw
1 //! Turning the ten value kinds into something on screen.
2 //!
3 //! This module is the entire reason producers are forbidden from formatting
4 //! their own values. Every `progress` in the UI gets the same bar, every
5 //! `instant` the same relative phrasing, every `ident` the same truncation —
6 //! and consistency survives a new producer written a year from now by someone
7 //! who never read this file, because the producer never gets a say.
8 //!
9 //! Everything here is a pure function. `instant` takes `now` as an argument
10 //! rather than reading the clock, which is what keeps the whole render
11 //! snapshot-testable.
12
13 use chrono::{DateTime, Utc};
14 use makeover_tui::Theme;
15 use ops_status::{Status, Value};
16 use ratatui::style::{Modifier, Style};
17
18 /// The one place a status becomes a color.
19 ///
20 /// The contract has six statuses and makeover has four status intents, so two
21 /// of these have to be said some other way. Not with a category slot, which is
22 /// where this landed first and is wrong twice over: category colors exist for
23 /// data with no inherent meaning (a project's color, a series on a chart), and
24 /// a theme is free to author them as the status hues restated — `makenotwork`
25 /// gives `category.six` and `status.info` the same value, which collapsed
26 /// `undistributed` onto `pending` in exactly the theme magicmirror ships with.
27 ///
28 /// So the two extra states are carried by a modifier on the nearest intent,
29 /// which is the renderer's own doctrine: a terminal's real constraint is
30 /// geometry rather than color, and a distinction that has to survive any theme
31 /// cannot rest on a hue the vocabulary does not name.
32 pub(crate) fn status_style(theme: &Theme, status: Status) -> Style {
33 match status {
34 Status::Ok => Style::default().fg(theme.status_success),
35 // Success, faded: a build waiting on its upload is finished work, not
36 // broken work, and the thing it is short of is arrival rather than
37 // health.
38 Status::Undistributed => Style::default()
39 .fg(theme.status_success)
40 .add_modifier(Modifier::DIM),
41 Status::Degraded => Style::default().fg(theme.status_warning),
42 Status::Failed => Style::default()
43 .fg(theme.status_danger)
44 .add_modifier(Modifier::BOLD),
45 Status::Pending => Style::default().fg(theme.status_info),
46 // Muted, and inverted so it is anything but quiet. An unreachable
47 // source is a real absence — no status intent is honest about it — but
48 // it is the loudest thing magicmirror can fail to tell you, so it is drawn
49 // as a filled block rather than as gray text.
50 Status::Unknown => Style::default()
51 .fg(theme.content_muted)
52 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
53 }
54 }
55
56 /// A short, fixed-width-ish marker so columns line up.
57 pub(crate) fn status_mark(status: Status) -> &'static str {
58 match status {
59 Status::Ok => "ok",
60 Status::Undistributed => "dist",
61 Status::Degraded => "degr",
62 Status::Failed => "FAIL",
63 Status::Pending => "pend",
64 Status::Unknown => "????",
65 }
66 }
67
68 /// Render one value. `width` is the budget for things that elide.
69 pub(crate) fn render(value: &Value, now: DateTime<Utc>, width: usize) -> String {
70 match value {
71 Value::Text { value } => value.clone(),
72 Value::Ident { value, abbrev_to } => abbreviate(value, abbrev_to.unwrap_or(12)),
73 Value::Version { value } => value.clone(),
74 Value::Instant { value } => relative(*value, now),
75 Value::Duration { seconds } => duration(*seconds),
76 Value::Progress { value, max, unit } => progress(*value, *max, unit.as_deref(), width),
77 Value::Quantity { value, unit } => quantity(*value, unit.as_deref()),
78 Value::State { value } => status_mark(*value).to_string(),
79 Value::Link { url, text } => text.clone().unwrap_or_else(|| url.clone()),
80 Value::Path { value } => middle_elide(value, width.max(12)),
81 }
82 }
83
84 /// The style a value carries on its own, if any.
85 pub(crate) fn style(theme: &Theme, value: &Value) -> Style {
86 match value {
87 Value::State { value } => status_style(theme, *value),
88 // A digest and a path are data you read across rather than prose:
89 // secondary content, set apart from the muted label to its left and
90 // from the primary text of an ordinary value.
91 Value::Ident { .. } | Value::Path { .. } => Style::default().fg(theme.content_secondary),
92 // The action colour, which is the one place accent on text is not
93 // decoration: a link is the only value here that names somewhere to go.
94 Value::Link { .. } => Style::default()
95 .fg(theme.action_primary)
96 .add_modifier(Modifier::UNDERLINED),
97 _ => Style::default().fg(theme.content_primary),
98 }
99 }
100
101 /// Truncate an identifier at a character boundary.
102 ///
103 /// Char-wise rather than byte-wise: a digest is ASCII, but nothing in the
104 /// contract promises that, and slicing a multi-byte value at a byte index
105 /// panics.
106 pub(crate) fn abbreviate(value: &str, to: usize) -> String {
107 if value.chars().count() <= to {
108 return value.to_string();
109 }
110 value.chars().take(to).collect()
111 }
112
113 /// Elide the middle of a path, keeping both ends: the leading directories say
114 /// where you are and the basename says what it is. Truncating either end alone
115 /// throws away the half you needed.
116 pub(crate) fn middle_elide(value: &str, max: usize) -> String {
117 let chars: Vec<char> = value.chars().collect();
118 if chars.len() <= max {
119 return value.to_string();
120 }
121 if max <= 3 {
122 return "...".into();
123 }
124 let keep = max - 3;
125 let head = keep.div_ceil(2);
126 let tail = keep - head;
127 let mut out: String = chars[..head].iter().collect();
128 out.push_str("...");
129 out.extend(&chars[chars.len() - tail..]);
130 out
131 }
132
133 /// "3m ago", "in 2m", "just now".
134 pub(crate) fn relative(at: DateTime<Utc>, now: DateTime<Utc>) -> String {
135 let delta = now - at;
136 let secs = delta.num_seconds();
137 if secs.abs() < 5 {
138 return "just now".into();
139 }
140 let magnitude = duration(secs.abs());
141 if secs > 0 {
142 format!("{magnitude} ago")
143 } else {
144 format!("in {magnitude}")
145 }
146 }
147
148 /// Humanize a span. Two units at most: "2h 14m" is useful, "2h 14m 7s" is
149 /// noise at every scale where hours matter.
150 pub(crate) fn duration(seconds: i64) -> String {
151 let s = seconds.abs();
152 let (d, h, m, sec) = (s / 86_400, (s % 86_400) / 3600, (s % 3600) / 60, s % 60);
153 match (d, h, m) {
154 (0, 0, 0) => format!("{sec}s"),
155 (0, 0, _) => format!("{m}m {sec}s"),
156 (0, _, 0) => format!("{h}h"),
157 (0, _, _) => format!("{h}h {m}m"),
158 (_, 0, _) => format!("{d}d"),
159 (_, _, _) => format!("{d}d {h}h"),
160 }
161 }
162
163 /// A magnitude with its unit. The number is humanized; the unit is the
164 /// producer's, verbatim.
165 pub(crate) fn quantity(value: f64, unit: Option<&str>) -> String {
166 let n = humanize_number(value);
167 match unit {
168 Some(u) => format!("{n} {u}"),
169 None => n,
170 }
171 }
172
173 fn humanize_number(value: f64) -> String {
174 let abs = value.abs();
175 let (scaled, suffix) = if abs >= 1e9 {
176 (value / 1e9, "G")
177 } else if abs >= 1e6 {
178 (value / 1e6, "M")
179 } else if abs >= 1_000.0 {
180 (value / 1_000.0, "k")
181 } else {
182 (value, "")
183 };
184 let rendered = if scaled.fract().abs() < 0.05 {
185 format!("{scaled:.0}")
186 } else {
187 format!("{scaled:.1}")
188 };
189 format!("{rendered}{suffix}")
190 }
191
192 /// A progress bar plus its numbers, e.g. `[####------] 31/48 hour`.
193 pub(crate) fn progress(value: f64, max: f64, unit: Option<&str>, width: usize) -> String {
194 let numbers = match unit {
195 Some(u) => format!("{}/{} {u}", humanize_number(value), humanize_number(max)),
196 None => format!("{}/{}", humanize_number(value), humanize_number(max)),
197 };
198
199 // The bar is whatever is left after the numbers, floored at something still
200 // readable and capped so it does not sprawl across a wide terminal.
201 let bar_width = width.saturating_sub(numbers.len() + 3).clamp(4, 24);
202 let fraction = if max > 0.0 {
203 (value / max).clamp(0.0, 1.0)
204 } else {
205 0.0
206 };
207 let filled = (fraction * bar_width as f64).round() as usize;
208 let bar: String = "#".repeat(filled) + &"-".repeat(bar_width - filled);
209 format!("[{bar}] {numbers}")
210 }
211
212 /// How a progress value should be colored: complete is done, not merely far
213 /// along.
214 pub(crate) fn progress_style(theme: &Theme, value: f64, max: f64) -> Style {
215 if max > 0.0 && value >= max {
216 Style::default().fg(theme.status_success)
217 } else {
218 Style::default().fg(theme.status_info)
219 }
220 }
221
222 #[cfg(test)]
223 mod tests {
224 use super::*;
225 use chrono::TimeDelta;
226
227 fn now() -> DateTime<Utc> {
228 "2026-07-21T18:00:00Z".parse().unwrap()
229 }
230
231 #[test]
232 fn durations_stop_at_two_units() {
233 assert_eq!(duration(45), "45s");
234 assert_eq!(duration(125), "2m 5s");
235 assert_eq!(duration(7200), "2h");
236 assert_eq!(duration(8040), "2h 14m");
237 assert_eq!(duration(172_800), "2d");
238 assert_eq!(duration(180_000), "2d 2h");
239 }
240
241 #[test]
242 fn relative_time_reads_in_both_directions() {
243 let base = now();
244 assert_eq!(relative(base, base), "just now");
245 assert_eq!(relative(base - TimeDelta::minutes(3), base), "3m 0s ago");
246 // A producer whose clock runs ahead must not render as a negative age.
247 assert_eq!(relative(base + TimeDelta::minutes(2), base), "in 2m 0s");
248 }
249
250 #[test]
251 fn identifiers_truncate_at_a_character_boundary() {
252 assert_eq!(abbreviate("a3f9c21b7e4d8056", 8), "a3f9c21b");
253 assert_eq!(abbreviate("short", 8), "short");
254 // Multi-byte: byte slicing here would panic.
255 assert_eq!(abbreviate("ααααββββ", 4), "αααα");
256 }
257
258 #[test]
259 fn paths_keep_both_ends() {
260 let path = "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork";
261 let out = middle_elide(path, 30);
262 assert_eq!(out.chars().count(), 30);
263 assert!(out.starts_with("/srv/sando"), "{out}");
264 assert!(out.ends_with("makenotwork"), "{out}");
265 assert!(out.contains("..."));
266 }
267
268 #[test]
269 fn a_short_path_is_left_alone() {
270 assert_eq!(middle_elide("/etc/sando", 30), "/etc/sando");
271 }
272
273 #[test]
274 fn quantities_humanize_the_number_and_keep_the_producers_unit() {
275 assert_eq!(quantity(43.5, Some("MB")), "43.5 MB");
276 assert_eq!(quantity(1_200.0, None), "1.2k");
277 assert_eq!(quantity(2_000_000.0, None), "2M");
278 assert_eq!(quantity(7.0, Some("node")), "7 node");
279 }
280
281 #[test]
282 fn progress_renders_a_bar_and_its_numbers() {
283 let out = progress(31.0, 48.0, Some("hour"), 40);
284 assert!(out.contains("31/48 hour"), "{out}");
285 assert!(out.starts_with('['), "{out}");
286 assert!(out.contains('#') && out.contains('-'), "{out}");
287 }
288
289 #[test]
290 fn progress_survives_nonsense_without_panicking() {
291 // A producer bug must not take magicmirror down.
292 assert!(progress(5.0, 0.0, None, 40).contains("5/0"));
293 assert!(progress(-1.0, 10.0, None, 40).starts_with('['));
294 assert!(progress(99.0, 10.0, None, 40).starts_with('['));
295 // Width smaller than the numbers still yields a bar, not a panic.
296 assert!(progress(31.0, 48.0, Some("hour"), 1).starts_with('['));
297 }
298
299 #[test]
300 fn a_complete_bar_reads_as_done() {
301 let theme = crate::theme::tests::fixed();
302 assert_eq!(
303 progress_style(&theme, 48.0, 48.0),
304 Style::default().fg(theme.status_success)
305 );
306 assert_eq!(
307 progress_style(&theme, 31.0, 48.0),
308 Style::default().fg(theme.status_info)
309 );
310 }
311
312 #[test]
313 fn every_kind_renders_to_something_non_empty() {
314 let values = vec![
315 Value::Text { value: "x".into() },
316 Value::Ident {
317 value: "a3f9c21b7e4d8056".into(),
318 abbrev_to: Some(8),
319 },
320 Value::Version {
321 value: "0.10.14".into(),
322 },
323 Value::Instant {
324 value: now() - TimeDelta::minutes(3),
325 },
326 Value::Duration { seconds: 3600 },
327 Value::Progress {
328 value: 31.0,
329 max: 48.0,
330 unit: Some("hour".into()),
331 },
332 Value::Quantity {
333 value: 43.5,
334 unit: Some("MB".into()),
335 },
336 Value::State {
337 value: Status::Degraded,
338 },
339 Value::Link {
340 url: "https://makenot.work".into(),
341 text: None,
342 },
343 Value::Path {
344 value: "/srv/sando".into(),
345 },
346 ];
347 for value in values {
348 let out = render(&value, now(), 40);
349 assert!(!out.is_empty(), "{} rendered empty", value.kind());
350 assert!(!out.contains('\n'), "{} rendered multi-line", value.kind());
351 }
352 }
353
354 #[test]
355 fn a_link_prefers_its_text_and_falls_back_to_the_url() {
356 let with = Value::Link {
357 url: "https://makenot.work".into(),
358 text: Some("site".into()),
359 };
360 assert_eq!(render(&with, now(), 40), "site");
361 let without = Value::Link {
362 url: "https://makenot.work".into(),
363 text: None,
364 };
365 assert_eq!(render(&without, now(), 40), "https://makenot.work");
366 }
367
368 const ALL_STATUSES: [Status; 6] = [
369 Status::Ok,
370 Status::Undistributed,
371 Status::Degraded,
372 Status::Failed,
373 Status::Pending,
374 Status::Unknown,
375 ];
376
377 #[test]
378 fn an_unknown_status_is_loud_not_gray() {
379 // A source nobody can reach must not read as a quiet absence. Stated
380 // against the theme's own intents rather than against a hex value: what
381 // makes it loud is the inversion, which no theme can author away.
382 let theme = crate::theme::tests::fixed();
383 let unknown = status_style(&theme, Status::Unknown);
384 assert!(
385 unknown.add_modifier.contains(Modifier::REVERSED),
386 "an unreachable source must be a filled block, not gray text",
387 );
388 assert_eq!(
389 status_style(&theme, Status::Ok).fg,
390 Some(theme.status_success)
391 );
392 }
393
394 #[test]
395 fn no_two_statuses_render_the_same() {
396 // The failure this guards is silent: a theme free to author its intents
397 // however it likes can hand two statuses the same hue, and the one
398 // screen whose whole job is telling them apart stops doing it. Checked
399 // over every theme makeover ships, not only magicmirror's own default,
400 // since an operator can name any of them in `magicmirror.toml`.
401 for (id, source) in makeover::embedded_themes() {
402 let colors = makeover::parse_theme_str(id, source, false)
403 .unwrap_or_else(|e| panic!("bundled theme `{id}` does not parse: {e}"));
404 let Ok(theme) = Theme::from_theme(&colors) else {
405 continue; // incomplete themes are `theme::load`'s error to report
406 };
407 for (i, a) in ALL_STATUSES.iter().enumerate() {
408 for b in &ALL_STATUSES[i + 1..] {
409 let (sa, sb) = (status_style(&theme, *a), status_style(&theme, *b));
410 assert!(
411 (sa.fg, sa.add_modifier) != (sb.fg, sb.add_modifier),
412 "theme `{id}` renders {a:?} and {b:?} identically",
413 );
414 }
415 }
416 }
417 }
418 }
419