Skip to main content

max / quasi

7.1 KB · 175 lines History Blame Raw
1 //! What a time-derived readout says, and how often it says it again.
2 //!
3 //! The description carries an instant and which way the readout runs against
4 //! now ([`Clock`]); the words and the cadence are this renderer's. Both halves
5 //! are here so that this host's answer is one file rather than an arm in the
6 //! drawing and a number in the runtime.
7 //!
8 //! An immediate host has the easiest half of this: it redraws whenever it is
9 //! asked to and the words are made fresh every frame. What it does not get for
10 //! free is being asked -- egui sleeps between frames when nothing is happening,
11 //! so a screen holding a stopwatch has to say when it wants the next one. That
12 //! is [`cadence`], read by `Runtime::show`.
13 //!
14 //! The spellings match the other two renderers' on purpose. A screen described
15 //! once and drawn three times should not read as three different facts.
16 //!
17 //! # A notice is removed at Dismiss here, with no leaving
18 //!
19 //! And it is a decision rather than the unfinished half of one. `makeover-
20 //! timing` says `Intent::Dismiss` is how long a notice lives *before it starts
21 //! to leave* and that the leaving is `Motion::Fade`, and quasi-webview draws
22 //! that fade as of 0.81.0. This renderer does not, because there is no
23 //! transition to run: egui could fade a widget, and the toast is not a widget
24 //! this renderer owns a frame of. It is drawn and gone between two paints, and
25 //! an alpha ramp would be this renderer inventing a transition the description
26 //! did not ask any host for.
27 //!
28 //! Adding the duration anyway would keep the message on screen 300ms longer
29 //! and change nothing about how it goes, which is a longer message rather than
30 //! a softer one. Written down so the next reader finds the answer instead of
31 //! filing it again.
32
33 use std::time::{Duration, SystemTime};
34
35 use makeover_timing::notice_lifetime;
36 use quasi_router::Clock;
37
38 /// How often a second-granularity readout is drawn again.
39 ///
40 /// [`Clock::Since`] and [`Clock::Until`] show seconds, so a second is what the
41 /// granularity demands. Shorter would redraw a screen that has not changed;
42 /// longer would show a stopwatch that visibly skips.
43 ///
44 /// Much shorter than [`CADENCE`](crate::CADENCE), and the two are not in
45 /// competition: that one is how often a live region *asks something*, over a
46 /// network or across a process, and this is how often a frame is asked for to
47 /// redo arithmetic this host can do itself.
48 pub const TICK: Duration = Duration::from_secs(1);
49
50 /// How often a coarse readout is drawn again.
51 ///
52 /// [`Clock::Age`] reads "3h ago", which changes on the minute at its finest.
53 /// Asking for a frame every second would wake the host 60 times to change
54 /// nothing, which is the failure the kind exists to prevent: a stamp drawn as a
55 /// stopwatch.
56 pub const COARSE: Duration = Duration::from_secs(30);
57
58 /// How long a toast stays on the screen before this renderer takes it away.
59 ///
60 /// The number is on the renderer's side of the description line:
61 /// `Notice::Toast` says the message goes away on its own and deliberately does
62 /// not say when, so the when is presentation policy, the same class of value
63 /// as how long an undo stays offered.
64 ///
65 /// It is [`Intent::Dismiss`] and it comes from `makeover-timing`, not from a
66 /// literal here: this renderer, the terminal one and the browser one held the same four
67 /// seconds written out three times, which is one drift away from a screen
68 /// described once keeping its messages for three different lengths of time.
69 /// Named rather than numbered, and named once.
70 ///
71 /// A banner takes none of this. [`notice_lifetime`] returns `None` for it, and
72 /// `None` means it has no lifetime rather than that the caller picks one: it
73 /// goes when the condition it reports is fixed.
74 pub const LINGER: Duration = match notice_lifetime(true) {
75 Some(lifetime) => lifetime,
76 // `notice_lifetime` is `Some` for exactly the transient case, and `true` is
77 // it. A `const` cannot unwrap, so the arm is written out.
78 None => panic!("a transient notice has a lifetime"),
79 };
80
81 /// How long a screen holding this kind of readout may sit before it is stale.
82 #[must_use]
83 pub const fn cadence(clock: Clock) -> Duration {
84 match clock {
85 Clock::Since | Clock::Until => TICK,
86 Clock::Age => COARSE,
87 }
88 }
89
90 /// The words a readout of this kind shows, reckoned against `now`.
91 #[must_use]
92 pub(crate) fn text(clock: Clock, at: SystemTime, now: SystemTime) -> String {
93 match clock {
94 Clock::Since => face(now.duration_since(at).unwrap_or_default()),
95 // A countdown that has run out reads as zero rather than as a negative
96 // number. Nothing in the description says which, and inventing
97 // "overdue by" here would be this renderer saying something the other
98 // two do not.
99 Clock::Until => face(at.duration_since(now).unwrap_or_default()),
100 Clock::Age => ago(now.duration_since(at).unwrap_or_default()),
101 }
102 }
103
104 /// A span as a stopwatch: `h:mm:ss`, hours unbounded.
105 ///
106 /// Unbounded rather than rolling into days, because the readout this serves is
107 /// a running timer and a timer that reads `2d 3:04:05` has stopped being one.
108 fn face(span: Duration) -> String {
109 let secs = span.as_secs();
110 format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
111 }
112
113 /// A span as a stamp: the largest unit that is not zero, and how long ago.
114 fn ago(span: Duration) -> String {
115 let secs = span.as_secs();
116 if secs < 60 {
117 "just now".to_owned()
118 } else if secs < 3600 {
119 format!("{}m ago", secs / 60)
120 } else if secs < 86_400 {
121 format!("{}h ago", secs / 3600)
122 } else {
123 format!("{}d ago", secs / 86_400)
124 }
125 }
126
127 #[cfg(test)]
128 mod tests {
129 use super::*;
130
131 fn at(seconds: u64) -> SystemTime {
132 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
133 }
134
135 #[test]
136 fn a_stopwatch_counts_up_and_a_countdown_counts_down() {
137 assert_eq!(text(Clock::Since, at(0), at(3845)), "1:04:05");
138 assert_eq!(text(Clock::Until, at(3845), at(0)), "1:04:05");
139 }
140
141 #[test]
142 fn a_readout_that_has_run_out_reads_as_zero_rather_than_backwards() {
143 assert_eq!(text(Clock::Until, at(0), at(90)), "0:00:00");
144 assert_eq!(text(Clock::Since, at(90), at(0)), "0:00:00");
145 }
146
147 #[test]
148 fn a_stamp_is_coarse_and_a_stopwatch_is_not() {
149 assert_eq!(text(Clock::Age, at(0), at(30)), "just now");
150 assert_eq!(text(Clock::Age, at(0), at(300)), "5m ago");
151 assert_eq!(text(Clock::Age, at(0), at(10_800)), "3h ago");
152 assert_eq!(text(Clock::Age, at(0), at(345_600)), "4d ago");
153 assert_eq!(text(Clock::Since, at(0), at(30)), "0:00:30");
154 }
155
156 #[test]
157 fn a_toast_takes_its_lifetime_from_the_named_intent() {
158 assert_eq!(LINGER, makeover_timing::Intent::Dismiss.duration());
159 assert_eq!(notice_lifetime(true), Some(LINGER));
160 }
161
162 #[test]
163 fn a_banner_has_no_lifetime_rather_than_a_long_one() {
164 assert_eq!(notice_lifetime(false), None);
165 }
166
167 #[test]
168 fn the_cadence_follows_the_granularity_the_spelling_chose() {
169 assert_eq!(cadence(Clock::Since), TICK);
170 assert_eq!(cadence(Clock::Until), TICK);
171 assert_eq!(cadence(Clock::Age), COARSE);
172 assert!(COARSE > TICK);
173 }
174 }
175