Skip to main content

max / makeover-layout

12.3 KB · 302 lines History Blame Raw
1 use crate::Tone;
2
3 // Names this module's prose links to, resolved for rustdoc.
4 #[allow(unused_imports)]
5 use 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 pub struct Meter<'a> {
49 /// How much is done. May exceed [`total`](Self::total), and that is the
50 /// case worth drawing.
51 pub done: u32,
52 /// How much there is to do. Zero means there is no set, not that the set is
53 /// complete.
54 pub total: u32,
55 /// What the proportion means right now.
56 ///
57 /// Carried rather than derived, because no renderer can work it out. The
58 /// same 90% is [`Tone::Success`] on a subtask rollup and [`Tone::Danger`] on
59 /// a time estimate, and goingson picks between them from `is_over_estimate`,
60 /// a fact about the data and not about the number.
61 pub tone: Tone,
62 /// What is being counted, if the bar says so: "subtasks", "tasks".
63 ///
64 /// The noun, not the ratio. A renderer builds "3 of 7 subtasks" from this
65 /// and the two numbers; handing it the assembled string would put the
66 /// sentence order in the description, where a terminal at one line and a
67 /// tooltip want different ones.
68 pub label: Option<&'a str>,
69 }
70
71 impl<'a> Meter<'a> {
72 /// A proportion with no tone and no label.
73 #[must_use]
74 pub const fn new(done: u32, total: u32) -> Self {
75 Self {
76 done,
77 total,
78 tone: Tone::Neutral,
79 label: None,
80 }
81 }
82
83 /// What the proportion means.
84 #[must_use]
85 pub const fn tone(mut self, tone: Tone) -> Self {
86 self.tone = tone;
87 self
88 }
89
90 /// What is being counted.
91 #[must_use]
92 pub const fn label(mut self, label: &'a str) -> Self {
93 self.label = Some(label);
94 self
95 }
96
97 /// How full the bar is, 0 to 100, clamped.
98 ///
99 /// For drawing, which is the only thing a clamped number is good for. Ask
100 /// [`overflowing`](Self::overflowing) before reporting it as a fact, or this
101 /// is `time_progress`'s bug again with the clamp moved.
102 ///
103 /// An empty set reads as 0. Nothing is done, because there is nothing to do
104 /// and no bar to fill; the apps guard on the count before drawing at all.
105 #[must_use]
106 pub const fn percent(&self) -> u8 {
107 if self.total == 0 {
108 return 0;
109 }
110 let scaled = (self.done as u64 * 100) / self.total as u64;
111 if scaled > 100 { 100 } else { scaled as u8 }
112 }
113
114 /// Whether more is done than there was to do.
115 ///
116 /// The fact [`percent`](Self::percent) destroys, kept reachable so a
117 /// renderer can mark the over-run rather than drawing a full bar and
118 /// implying it landed exactly.
119 #[must_use]
120 pub const fn overflowing(&self) -> bool {
121 self.done > self.total
122 }
123
124 /// Whether there is a set at all.
125 ///
126 /// A meter over nothing is sayable on purpose, for the same reason a field
127 /// with no options is: it is what an app with an unloaded count actually
128 /// has, and a renderer that shows an empty bar says so on screen rather than
129 /// dividing by zero.
130 #[must_use]
131 pub const fn is_empty(&self) -> bool {
132 self.total == 0
133 }
134 }
135
136 /// One figure with a caption: a number and what it counts.
137 ///
138 /// The dashboard shape. A large value over a small caption, several of them in
139 /// a strip: a current streak, a completion rate, a total. Four put the value
140 /// above the caption and one inverts it, which is drift inside the shape
141 /// rather than a second shape.
142 ///
143 /// # Why the value is text
144 ///
145 /// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already
146 /// formatted, and the formatting is the app's because only it knows whether the
147 /// number is a percentage, a duration or a ratio. This carries none of the
148 /// arithmetic [`Meter`] carries, and that is the difference between them: a
149 /// meter is a proportion a renderer draws, and a figure is a fact a renderer
150 /// sets in type.
151 ///
152 /// # Tone is carried, for [`Meter`]'s reason
153 ///
154 /// Three of the five sites tone the figure by their own means — `red`/`blue` on
155 /// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on
156 /// sync. So tone is carried at every site that needs it and derived at none, and
157 /// no renderer can work out that a streak of zero is worth colouring.
158 ///
159 /// # What is not here
160 ///
161 /// Whether the figure answers a click. One of the five is a control — sync's
162 /// "Not Applied: 3" opens the list — and an action is not something this crate
163 /// can name: nothing here knows what a route is. That belongs beside the figure
164 /// in whatever layer holds the actions, the same way a row's activation sits
165 /// beside its parts rather than inside them.
166 ///
167 /// The arrangement is not here either. Several figures in a strip is a set, and
168 /// a renderer given them one at a time cannot tell it is looking at one; the
169 /// layer that holds the tree is where the set gets said.
170 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171 pub struct Figure<'a> {
172 /// The number, formatted the way the app means it to read.
173 pub value: &'a str,
174 /// What it counts. The caption under the value.
175 pub caption: &'a str,
176 /// How the value has moved, if the app is tracking that.
177 ///
178 /// Text, for [`value`](Self::value)'s reason: only the app knows whether a
179 /// move reads as `+12.5%`, `+3` or `2x`, and a renderer handed a number
180 /// would have to guess.
181 ///
182 /// This is what [`tone`](Self::tone) was for and had no consumer of. The MNW
183 /// server has four screens whose stat card is a label, a value and a delta,
184 /// and the delta is the toned part: the figure itself is an ordinary fact
185 /// and it is the movement that reads as good or bad. Without this the delta
186 /// has to be folded into the caption, which loses the tone and reads as a
187 /// longer caption rather than as a second, smaller line.
188 pub change: Option<&'a str>,
189 /// What the figure means right now. [`Tone::Neutral`] is an ordinary fact.
190 ///
191 /// Applies to [`change`](Self::change) where there is one, since that is the
192 /// part that carries the judgement, and to the value where there is not.
193 pub tone: Tone,
194 }
195
196 impl<'a> Figure<'a> {
197 /// A figure that is an ordinary fact.
198 #[must_use]
199 pub const fn new(value: &'a str, caption: &'a str) -> Self {
200 Self {
201 value,
202 caption,
203 change: None,
204 tone: Tone::Neutral,
205 }
206 }
207
208 /// How the value has moved.
209 #[must_use]
210 pub const fn change(mut self, change: &'a str) -> Self {
211 self.change = Some(change);
212 self
213 }
214
215 /// What the figure means.
216 #[must_use]
217 pub const fn tone(mut self, tone: Tone) -> Self {
218 self.tone = tone;
219 self
220 }
221 }
222
223 /// Something the user can do, and what it costs to say so.
224 ///
225 /// Beside [`Meter`] and [`Figure`] for the reason those are here: a renderer
226 /// that is handed the parts has to decide how to say them, and a renderer that
227 /// is handed a finished string has already had the decision made for it.
228 ///
229 /// No address. Where a control goes is the app's business and every host
230 /// follows it differently — an `hx-get`, a protocol URL, a function call — so
231 /// the description says what the control *is* and the caller keeps what it
232 /// does. That is the same split [`Choice`] makes.
233 ///
234 /// No confirmation flag either, and that one is a finding rather than an
235 /// omission: a question asked *after* a control is pressed belongs to whatever
236 /// is holding the interaction, and a renderer that drew it would be asking
237 /// before there was anything to answer.
238 /// How a picture sits in the box it is given.
239 ///
240 /// An intent rather than a value, so a renderer picks the expression it has:
241 /// `object-fit` in a webview, a texture's UV rect in egui, and in a terminal a
242 /// choice about how many cells the blit gets. Named because MNW already makes
243 /// the distinction deliberately at 17 sites and makes it three different ways,
244 /// which is a policy the app decided rather than one a shared crate would be
245 /// picking by accident.
246 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
247 #[non_exhaustive]
248 pub enum Fit {
249 /// The picture's own proportions, and the box takes the height they imply.
250 ///
251 /// The default because it is the only one that shows the whole picture at
252 /// its own shape, so a renderer that ignores this enum entirely is still
253 /// right about the common case. A screenshot wants this; the shipped MNW
254 /// carousel sets no `object-fit` at all, which is this.
255 #[default]
256 Natural,
257 /// Fill the box and crop whatever does not fit.
258 ///
259 /// For a picture in a slot whose shape the layout fixed: a thumbnail, an
260 /// avatar, cover art. 15 of MNW's 17 sites.
261 Cover,
262 /// Fit inside the box whole, leaving space on two sides.
263 ///
264 /// The letterbox. For when the whole picture matters more than filling the
265 /// space, and the space is not the picture's shape.
266 Contain,
267 }
268
269 /// A picture's own pixel dimensions.
270 ///
271 /// Deliberately not [`makeover_geometry`]'s business. Geometry answers *how
272 /// much space a thing should get*, which is a scale question with the same
273 /// answer on every screen. This is the intrinsic size of one asset, which is a
274 /// fact about that asset and varies per picture.
275 ///
276 /// [`makeover_geometry`]: https://docs.rs/makeover-geometry
277 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278 pub struct Extent {
279 /// Width in the picture's own pixels.
280 pub width: u32,
281 /// Height in the picture's own pixels.
282 pub height: u32,
283 }
284
285 impl Extent {
286 /// A picture's dimensions.
287 #[must_use]
288 pub const fn new(width: u32, height: u32) -> Self {
289 Self { width, height }
290 }
291
292 /// Width over height, or `None` if either side is zero.
293 ///
294 /// The form a renderer actually reserves space with: a box that knows its
295 /// proportion holds the right height at any width, which is what a
296 /// responsive picture needs and what a fixed pixel height cannot give.
297 #[must_use]
298 pub fn ratio(self) -> Option<f32> {
299 (self.width > 0 && self.height > 0).then(|| self.width as f32 / self.height as f32)
300 }
301 }
302