makeover_layout/readiness.rs
1use std::time::Duration;
2
3use crate::Tone;
4
5/// What is in a region right now.
6///
7/// The state, not the shimmer. Whether pending paints a skeleton, a spinner or
8/// nothing at all is renderer policy, the same class of decision that got
9/// `Fill::fallback` deleted from this crate. goingson and Balanced Breakfast
10/// each grew a skeleton with differently-named parts; both keep them, as the
11/// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none
12/// and needs none, because an immediate-mode renderer simply repaints.
13///
14/// # Four states and not two
15///
16/// Naming only `Ready` and `Pending` leaves a screen whose list came back empty
17/// with nothing to say about it, so it renders an empty region or invents its
18/// own placeholder text and neither says what it is. Left to the apps, the
19/// class family drifts: `empty-state`, `empty-state--error`, `error-state` and
20/// six more.
21///
22/// The four are one axis because they are mutually exclusive: a region shows its
23/// content, or a sign that it is coming, or a sign that there is none, or a sign
24/// that it broke. Never two. That is the test for one enum against several
25/// fields, and it is why this grew rather than a new member arriving beside it.
26///
27/// # What is not here
28///
29/// **The message.** "No projects yet" is content, and this names a state. It
30/// lives with whatever holds the region (in quasi's case a `Slot`) alongside
31/// the action that leads out of the emptiness, since an address is the one thing
32/// this crate never names.
33///
34/// **How much room it gets.** goingson's `--compact`, `--dashboard` and
35/// `--padded` are the same state at three sizes, and a size is
36/// `makeover-geometry`'s question. Naming them here would be this crate stating
37/// values again.
38///
39/// **The icon.** Presentation, and each host has its own answer or none.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum Readiness {
43 /// The content is here.
44 Ready,
45 /// The content is on its way.
46 ///
47 /// For a region that changes *after* the first paint, and never for the
48 /// first paint itself: see "First paint is final paint" in the crate header.
49 /// A host that renders once, with its data already in hand, has nothing to
50 /// say this about, and a screen arriving in this state is describing a
51 /// moment its host should not have been in.
52 ///
53 /// What stands in occupies the geometry the content will occupy. A stand-in
54 /// sized to itself rather than to what replaces it is the reflow the rule
55 /// forbids, arriving one repaint later.
56 Pending,
57 /// The content arrived and there is none of it.
58 ///
59 /// Not a failure. An empty list is the normal state of a new install, and a
60 /// renderer that drew it in a danger tone would be reporting a fault where
61 /// there is none.
62 Empty,
63 /// The content did not arrive.
64 Failed,
65}
66
67impl Readiness {
68 /// Whether the region draws its own content, or something standing in for
69 /// it.
70 ///
71 /// The question every renderer asks first, so it is answered once here
72 /// rather than by a `matches!` in each. A state added later is a stand-in
73 /// until proven otherwise: falling back to drawing content that may not be
74 /// there is the worse of the two mistakes.
75 #[must_use]
76 pub const fn shows_content(self) -> bool {
77 matches!(self, Self::Ready)
78 }
79
80 /// What the state means, for a renderer choosing a colour.
81 ///
82 /// Derived rather than carried, which is the opposite of a meter's tone and
83 /// a figure's, and the difference is worth stating: a proportion's meaning
84 /// depends on what is being counted and only the app knows it, while
85 /// "nothing here yet" and "this broke" mean the same thing in every app that
86 /// will ever have them.
87 #[must_use]
88 pub const fn tone(self) -> Tone {
89 match self {
90 Self::Failed => Tone::Danger,
91 _ => Tone::Neutral,
92 }
93 }
94}
95
96/// An action is waiting on something that resolves once, in expected finite
97/// time.
98///
99/// The control-side sibling of [`Readiness`]. That enum names four states for a
100/// region and named nothing at all for the button that is currently doing what
101/// it was clicked for, so the in-flight treatment is hand-written wherever it
102/// exists: the MNW server carries 57 in-flight indicators against 2 guards
103/// against a second press, which is the spinner mostly present and the guard
104/// mostly absent, on a codebase whose money path is a purchase button.
105///
106/// # What is described here, and what is not
107///
108/// The fact is that there is an outstanding thing which will complete. Not that
109/// the address is remote: a heavy local query waits too, and a server calling a
110/// payment provider is not the browser leaving the app. Not that the call is
111/// slow either, which is a judgement about a call rather than a property of one.
112///
113/// Resolving **once** is the boundary, and it is what separates this from a
114/// screen that keeps changing. A live screen never resolves and has no name in
115/// this crate yet.
116///
117/// # One mark, two renderings
118///
119/// | what reads it | what it does |
120/// |---|---|
121/// | a control that was pressed | goes busy and refuses a second press until it resolves |
122/// | a region fed by it | stands in as [`Readiness::Pending`], then fills |
123///
124/// The two were on the table separately and both were taken. Controls alone
125/// leaves a slow region hand-split into its own route, which is what MNW's user
126/// dashboard does with its payout summary; regions alone leaves the purchase
127/// button unguarded.
128///
129/// # A quantity when it is measured, never a duration
130///
131/// [`amount`](Self::amount) is stated only when it is a measured fact about the
132/// payload. An upload's file length, yes; a round trip to a payment provider,
133/// [`None`]. A duration is described nowhere, and a renderer may not manufacture
134/// one from the amount either: a determinate bar shows what is done over what
135/// there is, plus the time it has taken so far, and never a remaining time, an
136/// arrival time or a rate extrapolated forwards. A prediction is wrong the
137/// moment the transfer stalls, and being confidently wrong is worse than being
138/// honestly indeterminate.
139///
140/// This is why the crate refuses to say how long an undo stays offered and
141/// accepts a byte count here. The refusal is about naming a decision that
142/// belongs to the renderer; a file's length is not a decision, nobody chose it.
143///
144/// # Not a meter
145///
146/// A meter is how much of a set is done, and it refuses the progress of an
147/// operation on the grounds that a description is built once and dropped
148/// while an operation runs between renders. That refusal stands. This names the
149/// operation and its size, which is all that is known before it starts; how much
150/// of it has gone through is the renderer's to observe live, and nothing round
151/// trips through a description to say so.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
153#[non_exhaustive]
154pub struct Awaiting {
155 /// Total work to get through, when it is a measured fact about the payload.
156 ///
157 /// `None` when the wait has no countable size, which is the common case and
158 /// the default.
159 ///
160 /// Unit-agnostic on purpose. Bytes for an upload, rows for an import; what
161 /// is being counted is the app's business and a renderer draws a proportion
162 /// either way.
163 pub amount: Option<u64>,
164}
165
166impl Awaiting {
167 /// A wait with no countable size.
168 #[must_use]
169 pub const fn unmeasured() -> Self {
170 Self { amount: None }
171 }
172
173 /// A wait whose size is known.
174 ///
175 /// Reach for it only with a measured figure. An estimate written in here is
176 /// a prediction wearing a fact's clothes, and the renderer has no way to
177 /// tell the two apart.
178 #[must_use]
179 pub const fn of(amount: u64) -> Self {
180 Self {
181 amount: Some(amount),
182 }
183 }
184
185 /// Whether there is a proportion to draw.
186 ///
187 /// The question every renderer asks first, answered once here rather than by
188 /// a `matches!` in each. False means indeterminate, which is the honest
189 /// drawing when nothing countable was measured.
190 #[must_use]
191 pub const fn is_determinate(self) -> bool {
192 self.amount.is_some()
193 }
194}
195
196/// What a host can see about a wait that is running.
197///
198/// The renderer's input beside [`Awaiting`], never part of a description:
199/// how much has landed and how long it has taken are facts about a transfer in
200/// flight, which only whoever runs the transfer knows.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
202pub struct Progress {
203 /// How much has arrived, in the unit [`Awaiting::amount`] counted.
204 ///
205 /// `None` means nothing is watching the transfer.
206 pub delivered: Option<u64>,
207 /// How long the wait has lasted so far.
208 pub elapsed: Option<Duration>,
209}
210
211/// The words beside a determinate bar: what is done over what there is, then
212/// the whole seconds the wait has taken when that is known.
213///
214/// Never a remaining time, an arrival time or a rate, for [`Awaiting`]'s
215/// reason. The numbers are raw, in the description's unit. Padding between the
216/// bar and the reading is the host's.
217///
218/// ```
219/// # use makeover_layout::progress_reading;
220/// # use std::time::Duration;
221/// assert_eq!(progress_reading(17, 40, None), "17/40");
222/// assert_eq!(progress_reading(17, 40, Some(Duration::from_millis(4900))), "17/40 4s");
223/// ```
224#[must_use]
225pub fn progress_reading(done: u64, total: u64, elapsed: Option<Duration>) -> String {
226 match elapsed {
227 Some(elapsed) => format!("{done}/{total} {}s", elapsed.as_secs()),
228 None => format!("{done}/{total}"),
229 }
230}
231
232/// When a picture is needed.
233///
234/// A claim about *importance and position* rather than a fetch mechanism, which
235/// is why it is the description's to make: only the app knows whether a picture
236/// is the first thing on the screen or the fortieth thing down a list.
237///
238/// # Eager is the default, and that is a correctness choice
239///
240/// Emitting the webview's `loading="lazy"` for every picture reads one
241/// consumer's habit as a rule. Deferring a picture that is on screen at first paint does not
242/// save anything (it is needed immediately either way) and it delays the
243/// arrival, so the space it eventually takes is claimed later and the shift is
244/// more visible, not less.
245///
246/// So the safe answer is the default and the optimisation is opted into. A
247/// carousel is the case that proves the two cannot be one setting for the
248/// renderer to choose: its first frame is on screen and its other frames are
249/// not, in the same widget, at the same moment.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
251#[non_exhaustive]
252pub enum Loading {
253 /// Needed with the screen. Fetch it now.
254 #[default]
255 Eager,
256 /// Not on screen yet. It can wait until it is near.
257 Lazy,
258}