Skip to main content

max / quasi

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