makeover_layout/figure.rs
1use crate::Tone;
2
3// Names this module's prose links to, resolved for rustdoc.
4#[allow(unused_imports)]
5use crate::{Choice, Notice, Readiness};
6
7/// How much of a set is done.
8///
9/// Nine sites across the two webview apps drew a bar and nothing here named
10/// one, so every described screen concatenated the two numbers into its
11/// heading text instead: "Subtasks 3/7", "Time Tracking 45m tracked / 30m est,
12/// over". Every fact survives that and the reading does not, which is the same
13/// loss `RowPart::Tokens` closed when a toned status badge became prose.
14///
15/// # Why a pair and not a percentage
16///
17/// Both numbers, not the percentage the apps compute from them. The percentage
18/// was the obvious shape and it had already been tried: goingson's
19/// `Task::time_progress` divides, rounds, and then clamps to 100, which throws
20/// away the one case the bar exists to show, 45 minutes tracked against a
21/// 30-minute estimate. It carries a separate `is_over_estimate` boolean beside
22/// it to recover the fact the clamp dropped. A pair keeps the over-run without a
23/// companion flag, and [`percent`](Meter::percent) is still one call away for a
24/// renderer that wants it.
25///
26/// The pair is also what the apps already have at every site. All seven
27/// determinate bars write the ratio into the accessible layer and never the
28/// percentage: `title="3/7 subtasks"`, `aria-label="3 of 7 subtasks completed"`,
29/// a milestone's own `3/7` span. Given 43 nothing can recover "3 of 7", so a
30/// percentage member would have made [`label`](Meter::label) mandatory at every
31/// call site, which is the concatenated text this member removes, moved one
32/// layer down.
33///
34/// # What this is not
35///
36/// The progress of an *operation*. Two of the nine sites are that (goingson's
37/// focus timer, Balanced Breakfast's feed fetch) and they get nothing here, on
38/// purpose. Both are imperative controllers over a live handle, driven by a tick
39/// or an event stream, and a description is built once and dropped. Holding one
40/// would mean growing a way to update a description between renders, which is a
41/// different feature. [`Readiness::Pending`] and a [`Notice::Toast`] carry the
42/// honest part.
43///
44/// The two cases are distinguishable in the markup rather than by taste: every
45/// determinate bar in both apps carries a tone, and neither operation bar
46/// carries one. Two codebases drew that line the same way without coordinating.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48#[non_exhaustive]
49pub struct Meter<'a> {
50 /// How much is done. May exceed [`total`](Self::total), and that is the
51 /// case worth drawing.
52 ///
53 /// `usize` rather than `u32` because a meter has to be able to cross a
54 /// residual: a stand-in number is a six-digit opening and ten digits after
55 /// it, which does not fit in a `u32`. [`Chart::most`](crate::Chart) carries
56 /// its magnitude for the same reason. Apps counting a `Vec` had been
57 /// narrowing with a saturating `try_from` to reach the old type, so this is
58 /// the width they already had.
59 pub done: usize,
60 /// How much there is to do. Zero means there is no set, not that the set is
61 /// complete.
62 pub total: usize,
63 /// What the proportion means right now.
64 ///
65 /// Carried rather than derived, because no renderer can work it out. The
66 /// same 90% is [`Tone::Success`] on a subtask rollup and [`Tone::Danger`] on
67 /// a time estimate, and goingson picks between them from `is_over_estimate`,
68 /// a fact about the data and not about the number.
69 pub tone: Tone,
70 /// What is being counted, if the bar says so: "subtasks", "tasks".
71 ///
72 /// The noun, not the ratio. A renderer builds "3 of 7 subtasks" from this
73 /// and the two numbers; handing it the assembled string would put the
74 /// sentence order in the description, where a terminal at one line and a
75 /// tooltip want different ones.
76 pub label: Option<&'a str>,
77}
78
79impl<'a> Meter<'a> {
80 /// A proportion with no tone and no label.
81 #[must_use]
82 pub const fn new(done: usize, total: usize) -> Self {
83 Self {
84 done,
85 total,
86 tone: Tone::Neutral,
87 label: None,
88 }
89 }
90
91 /// What the proportion means.
92 #[must_use]
93 pub const fn tone(mut self, tone: Tone) -> Self {
94 self.tone = tone;
95 self
96 }
97
98 /// What is being counted.
99 #[must_use]
100 pub const fn label(mut self, label: &'a str) -> Self {
101 self.label = Some(label);
102 self
103 }
104
105 /// How full the bar is, 0 to 100, clamped.
106 ///
107 /// For drawing, which is the only thing a clamped number is good for. Ask
108 /// [`overflowing`](Self::overflowing) before reporting it as a fact, or this
109 /// is `time_progress`'s bug again with the clamp moved.
110 ///
111 /// An empty set reads as 0. Nothing is done, because there is nothing to do
112 /// and no bar to fill; the apps guard on the count before drawing at all.
113 #[must_use]
114 pub const fn percent(&self) -> u8 {
115 if self.total == 0 {
116 return 0;
117 }
118 let scaled = (self.done as u128 * 100) / self.total as u128;
119 if scaled > 100 { 100 } else { scaled as u8 }
120 }
121
122 /// Whether more is done than there was to do.
123 ///
124 /// The fact [`percent`](Self::percent) destroys, kept reachable so a
125 /// renderer can mark the over-run rather than drawing a full bar and
126 /// implying it landed exactly.
127 #[must_use]
128 pub const fn overflowing(&self) -> bool {
129 self.done > self.total
130 }
131
132 /// Whether there is a set at all.
133 ///
134 /// A meter over nothing is sayable on purpose, for the same reason a field
135 /// with no options is: it is what an app with an unloaded count actually
136 /// has, and a renderer that shows an empty bar says so on screen rather than
137 /// dividing by zero.
138 #[must_use]
139 pub const fn is_empty(&self) -> bool {
140 self.total == 0
141 }
142}
143
144/// One figure with a caption: a number and what it counts.
145///
146/// The dashboard shape. A large value over a small caption, several of them in
147/// a strip: a current streak, a completion rate, a total. Four put the value
148/// above the caption and one inverts it, which is drift inside the shape
149/// rather than a second shape.
150///
151/// # Why the value is text
152///
153/// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already
154/// formatted, and the formatting is the app's because only it knows whether the
155/// number is a percentage, a duration or a ratio. This carries none of the
156/// arithmetic [`Meter`] carries, and that is the difference between them: a
157/// meter is a proportion a renderer draws, and a figure is a fact a renderer
158/// sets in type.
159///
160/// # Tone is carried, for [`Meter`]'s reason
161///
162/// Three of the five sites tone the figure by their own means, `red`/`blue` on
163/// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on
164/// sync. So tone is carried at every site that needs it and derived at none, and
165/// no renderer can work out that a streak of zero is worth colouring.
166///
167/// # What is not here
168///
169/// Whether the figure answers a click. One of the five is a control, sync's
170/// "Not Applied: 3" opens the list, and an action is not something this crate
171/// can name: nothing here knows what a route is. That belongs beside the figure
172/// in whatever layer holds the actions, the same way a row's activation sits
173/// beside its parts rather than inside them.
174///
175/// The arrangement is not here either. Several figures in a strip is a set, and
176/// a renderer given them one at a time cannot tell it is looking at one; the
177/// layer that holds the tree is where the set gets said.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179#[non_exhaustive]
180pub struct Figure<'a> {
181 /// The number, formatted the way the app means it to read.
182 pub value: &'a str,
183 /// What it counts. The caption under the value.
184 pub caption: &'a str,
185 /// How the value has moved, if the app is tracking that.
186 ///
187 /// Text, for [`value`](Self::value)'s reason: only the app knows whether a
188 /// move reads as `+12.5%`, `+3` or `2x`, and a renderer handed a number
189 /// would have to guess.
190 ///
191 /// This is what [`tone`](Self::tone) was for and had no consumer of. The MNW
192 /// server has four screens whose stat card is a label, a value and a delta,
193 /// and the delta is the toned part: the figure itself is an ordinary fact
194 /// and it is the movement that reads as good or bad. Without this the delta
195 /// has to be folded into the caption, which loses the tone and reads as a
196 /// longer caption rather than as a second, smaller line.
197 pub change: Option<&'a str>,
198 /// What the figure means right now. [`Tone::Neutral`] is an ordinary fact.
199 ///
200 /// Applies to [`change`](Self::change) where there is one, since that is the
201 /// part that carries the judgement, and to the value where there is not.
202 pub tone: Tone,
203}
204
205impl<'a> Figure<'a> {
206 /// A figure that is an ordinary fact.
207 #[must_use]
208 pub const fn new(value: &'a str, caption: &'a str) -> Self {
209 Self {
210 value,
211 caption,
212 change: None,
213 tone: Tone::Neutral,
214 }
215 }
216
217 /// How the value has moved.
218 #[must_use]
219 pub const fn change(mut self, change: &'a str) -> Self {
220 self.change = Some(change);
221 self
222 }
223
224 /// What the figure means.
225 #[must_use]
226 pub const fn tone(mut self, tone: Tone) -> Self {
227 self.tone = tone;
228 self
229 }
230}
231
232/// Something the user can do, and what it costs to say so.
233///
234/// Beside [`Meter`] and [`Figure`] for the reason those are here: a renderer
235/// that is handed the parts has to decide how to say them, and a renderer that
236/// is handed a finished string has already had the decision made for it.
237///
238/// No address. Where a control goes is the app's business and every host
239/// follows it differently (an `hx-get`, a protocol URL, a function call) so
240/// the description says what the control *is* and the caller keeps what it
241/// does. That is the same split [`Choice`] makes.
242///
243/// No confirmation flag either, and that one is a finding rather than an
244/// omission: a question asked *after* a control is pressed belongs to whatever
245/// is holding the interaction, and a renderer that drew it would be asking
246/// before there was anything to answer.
247/// How a picture sits in the box it is given.
248///
249/// An intent rather than a value, so a renderer picks the expression it has:
250/// `object-fit` in a webview, a texture's UV rect in egui, and in a terminal a
251/// choice about how many cells the blit gets. Named because MNW already makes
252/// the distinction deliberately at 17 sites and makes it three different ways,
253/// which is a policy the app decided rather than one a shared crate would be
254/// picking by accident.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
256#[non_exhaustive]
257pub enum Fit {
258 /// The picture's own proportions, and the box takes the height they imply.
259 ///
260 /// The default because it is the only one that shows the whole picture at
261 /// its own shape, so a renderer that ignores this enum entirely is still
262 /// right about the common case. A screenshot wants this; the shipped MNW
263 /// carousel sets no `object-fit` at all, which is this.
264 #[default]
265 Natural,
266 /// Fill the box and crop whatever does not fit.
267 ///
268 /// For a picture in a slot whose shape the layout fixed: a thumbnail, an
269 /// avatar, cover art. 15 of MNW's 17 sites.
270 Cover,
271 /// Fit inside the box whole, leaving space on two sides.
272 ///
273 /// The letterbox. For when the whole picture matters more than filling the
274 /// space, and the space is not the picture's shape.
275 Contain,
276}
277
278/// A picture's own pixel dimensions.
279///
280/// Deliberately not [`makeover_geometry`]'s business. Geometry answers *how
281/// much space a thing should get*, which is a scale question with the same
282/// answer on every screen. This is the intrinsic size of one asset, which is a
283/// fact about that asset and varies per picture.
284///
285/// [`makeover_geometry`]: https://docs.rs/makeover-geometry
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
287#[non_exhaustive]
288pub struct Extent {
289 /// Width in the picture's own pixels.
290 pub width: u32,
291 /// Height in the picture's own pixels.
292 pub height: u32,
293}
294
295impl Extent {
296 /// A picture's dimensions.
297 #[must_use]
298 pub const fn new(width: u32, height: u32) -> Self {
299 Self { width, height }
300 }
301
302 /// Width over height, or `None` if either side is zero.
303 ///
304 /// The form a renderer actually reserves space with: a box that knows its
305 /// proportion holds the right height at any width, which is what a
306 /// responsive picture needs and what a fixed pixel height cannot give.
307 #[must_use]
308 pub fn ratio(self) -> Option<f32> {
309 (self.width > 0 && self.height > 0).then(|| self.width as f32 / self.height as f32)
310 }
311}
312
313/// A run of magnitudes read against one axis.
314///
315/// [`Meter`] is one proportion; this is a series of them that share a maximum,
316/// and the shared maximum is the whole difference. A run of meters draws each
317/// bar against its own `total`, so a chart said that way states the axis once
318/// per bar and nothing holds the copies together. Here the axis is stated once
319/// and a bar carries only where it sits on it.
320///
321/// # Why the axis and not a percentage per bar
322///
323/// [`Meter`]'s reason, one layer out. The app that drew MNW's revenue chart
324/// computed `revenue / most * 100.0` and put the percentage in the markup, and
325/// what reached the reader was a width with no numbers behind it: a bar at 100%
326/// because it is the largest and a bar at 100% because the axis is wrong are the
327/// same width and are not the same fact. Carrying both integers keeps the fact,
328/// and [`Bar::fraction`] is one call away for a renderer that wants the ratio.
329///
330/// It is also the only shape that survives a compiled template. A residual holds
331/// numbers the description HANDS a renderer, never ones a renderer works out
332/// from two of them, so a chart drawn from a supplied percentage could be
333/// described and could not be compiled. See `quasi_router::stage::number_at`.
334///
335/// # What is worded here and what is not
336///
337/// [`Bar::at`] is where the bar sits on the axis and [`label`](Self::label) is
338/// what the magnitudes are, which is [`Meter::label`]'s split exactly. What
339/// differs is [`Bar::reading`] and [`Bar::note`]: both arrive already worded,
340/// because a magnitude's own units are the app's ("$42.10", not 4210) and a
341/// count's noun inflects ("1 sale", "3 sales"). A renderer that pluralised
342/// would be growing a lexer for one language.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
344#[non_exhaustive]
345pub struct Chart<'a> {
346 /// The magnitude the axis runs to. Every bar is read against this.
347 ///
348 /// Zero means there is no axis, not that every bar is full. A renderer
349 /// never divides by it: every bar reads as nothing against a zero axis. It
350 /// still draws the bars, since which elements a chart has must not depend
351 /// on a value (a staged screen compiles the bars as one loop and fills the
352 /// numbers in later); see [`is_empty`](Self::is_empty).
353 ///
354 /// `usize` because that is what a description counts in (a pager's offset
355 /// and page size are the same) and because it is the only width
356 /// `quasi_router::stage::number_at` has a stand-in for, which is what lets a
357 /// chart reach a compiled template at all.
358 pub most: usize,
359 /// What the magnitudes are: "revenue", "plays".
360 ///
361 /// The noun, not the unit and not the ratio. The unit is already in each
362 /// [`Bar::reading`], where it belongs, because only the app knows it.
363 pub label: Option<&'a str>,
364 /// What the axis means, where it means anything.
365 pub tone: Tone,
366}
367
368impl<'a> Chart<'a> {
369 /// An axis running to `most`, untoned and unlabelled.
370 #[must_use]
371 pub const fn new(most: usize) -> Self {
372 Self {
373 most,
374 label: None,
375 tone: Tone::Neutral,
376 }
377 }
378
379 /// What the magnitudes are.
380 #[must_use]
381 pub const fn label(mut self, label: &'a str) -> Self {
382 self.label = Some(label);
383 self
384 }
385
386 /// What the axis means.
387 #[must_use]
388 pub const fn tone(mut self, tone: Tone) -> Self {
389 self.tone = tone;
390 self
391 }
392
393 /// Whether there is an axis to read against.
394 ///
395 /// [`Meter::is_empty`]'s case: an axis running to zero is what an app with
396 /// nothing to chart actually has, and saying so beats dividing by it.
397 #[must_use]
398 pub const fn is_empty(&self) -> bool {
399 self.most == 0
400 }
401}
402
403/// One magnitude in a [`Chart`], at its place on the axis.
404///
405/// Carries no axis of its own on purpose: a bar read against a maximum it
406/// states itself is a meter, and a run of those is not a chart.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
408#[non_exhaustive]
409pub struct Bar<'a> {
410 /// Where on the axis this sits: "Mar 3", "Week 12".
411 ///
412 /// The position's own name rather than an index, for the reason a pager's
413 /// jump carries its page number: what a chart shows is a window over a
414 /// series, and an index into that window is not the point it names.
415 pub at: &'a str,
416 /// The magnitude, in the chart's units, read against [`Chart::most`].
417 pub value: usize,
418 /// The magnitude as the app words it: "$42.10".
419 ///
420 /// Worded rather than derived because the units are the app's. A renderer
421 /// handed 4210 cannot know it is money, let alone which money.
422 pub reading: Option<&'a str>,
423 /// A second fact about this bar, already worded: "3 sales".
424 ///
425 /// Worded for the reason [`reading`](Self::reading) is, plus one of its own:
426 /// a count's noun inflects with the count, and that is language rather than
427 /// drawing.
428 pub note: Option<&'a str>,
429}
430
431impl<'a> Bar<'a> {
432 /// A place on the axis, with no magnitude on it yet.
433 ///
434 /// The magnitude arrives through [`of`](Self::of) rather than as a second
435 /// argument, and that is not stylistic: `quasi-declare` stages a
436 /// constructor's plain arguments all one way or all the other, so a
437 /// constructor taking a place AND a magnitude would have the place standing
438 /// in as a number. Split, the place is a value and `of` is a count, which is
439 /// the same split a pager's `of` makes and the reason it is spelled the
440 /// same.
441 #[must_use]
442 pub const fn at(at: &'a str) -> Self {
443 Self {
444 at,
445 value: 0,
446 reading: None,
447 note: None,
448 }
449 }
450
451 /// How far up the axis this bar reaches.
452 #[must_use]
453 pub const fn of(mut self, value: usize) -> Self {
454 self.value = value;
455 self
456 }
457
458 /// How the app words this magnitude.
459 #[must_use]
460 pub const fn reading(mut self, reading: &'a str) -> Self {
461 self.reading = Some(reading);
462 self
463 }
464
465 /// A second fact about the bar, already worded.
466 #[must_use]
467 pub const fn note(mut self, note: &'a str) -> Self {
468 self.note = Some(note);
469 self
470 }
471
472 /// How far up the axis this bar reaches, 0.0 to 1.0, clamped.
473 ///
474 /// For drawing, which is what a clamped number is good for, and for the two
475 /// renderers that draw in cells and pixels rather than in CSS. An empty axis
476 /// reads as 0.0 rather than dividing by zero.
477 ///
478 /// A bar over [`Chart::most`] clamps, and unlike [`Meter`] that is not a
479 /// fact being lost: `most` is the maximum of the bars, so a bar above it is
480 /// an axis the app got wrong rather than an over-run worth drawing.
481 #[must_use]
482 pub fn fraction(&self, chart: &Chart<'_>) -> f32 {
483 if chart.most == 0 {
484 return 0.0;
485 }
486 (self.value as f64 / chart.most as f64).min(1.0) as f32
487 }
488}