//! What a time-derived readout says, and how often it says it again. //! //! The description carries an instant and which way the readout runs against //! now ([`Clock`]); the words and the cadence are this renderer's. Both halves //! are here so that a terminal's answer is one file rather than an arm in the //! drawing and a number in the runtime. //! //! The spellings match the webview's on purpose. A screen described once and //! drawn twice should not read as two different facts, and nothing about a //! terminal makes `1:04:05` the wrong width -- the entitlement to differ is //! kept for the renderer that needs it rather than spent because it exists. //! //! # A notice is removed at Dismiss here, with no leaving //! //! And it is a decision rather than the unfinished half of one. `makeover- //! timing` says `Intent::Dismiss` is how long a notice lives *before it starts //! to leave* and that the leaving is `Motion::Fade`, and quasi-webview draws //! that fade as of 0.81.0. This renderer does not, because there is no //! transition to run: a terminal repaints, and a character cell is either //! painted or it is not -- there is no opacity to move through. //! //! Adding the duration anyway would keep the message on screen 300ms longer //! and change nothing about how it goes, which is a longer message rather than //! a softer one. Written down so the next reader finds the answer instead of //! filing it again. use std::time::{Duration, SystemTime}; use makeover_timing::notice_lifetime; use quasi_router::Clock; /// How often a second-granularity readout is drawn again. /// /// [`Clock::Since`] and [`Clock::Until`] show seconds, so a second is what the /// granularity demands. Shorter would redraw a screen that has not changed; /// longer would show a stopwatch that visibly skips. /// /// Much shorter than [`CADENCE`](crate::CADENCE), and the two are not in /// competition: that one is how often a live region *asks something*, over a /// network or across a process, and this is how often the terminal repaints /// arithmetic it can do itself. pub const TICK: Duration = Duration::from_secs(1); /// How often a coarse readout is drawn again. /// /// [`Clock::Age`] reads "3h ago", which changes on the minute at its finest. /// Drawing it every second would repaint a screen 60 times to change nothing, /// which is the failure the kind exists to prevent: a stamp drawn as a /// stopwatch. pub const COARSE: Duration = Duration::from_secs(30); /// How long a toast stays on the screen before this renderer takes it away. /// /// The number is on the renderer's side of the description line: /// `Notice::Toast` says the message goes away on its own and deliberately does /// not say when, so the when is presentation policy, the same class of value /// as how long an undo stays offered. /// /// It is [`Intent::Dismiss`] and it comes from `makeover-timing`, not from a /// literal here: this renderer, the egui one and the browser one held the same four /// seconds written out three times, which is one drift away from a screen /// described once keeping its messages for three different lengths of time. /// Named rather than numbered, and named once. /// /// A banner takes none of this. [`notice_lifetime`] returns `None` for it, and /// `None` means it has no lifetime rather than that the caller picks one: it /// goes when the condition it reports is fixed. pub const LINGER: Duration = match notice_lifetime(true) { Some(lifetime) => lifetime, // `notice_lifetime` is `Some` for exactly the transient case, and `true` is // it. A `const` cannot unwrap, so the arm is written out. None => panic!("a transient notice has a lifetime"), }; /// How long a screen holding this kind of readout may sit before it is stale. #[must_use] pub const fn cadence(clock: Clock) -> Duration { match clock { Clock::Since | Clock::Until => TICK, Clock::Age => COARSE, } } /// The words a readout of this kind shows, reckoned against `now`. #[must_use] pub(crate) fn text(clock: Clock, at: SystemTime, now: SystemTime) -> String { match clock { Clock::Since => face(now.duration_since(at).unwrap_or_default()), // A countdown that has run out reads as zero rather than as a negative // number. Nothing in the description says which, and a terminal has no // room to spell "overdue by" beside a row of other facts. Clock::Until => face(at.duration_since(now).unwrap_or_default()), Clock::Age => ago(now.duration_since(at).unwrap_or_default()), } } /// A span as a stopwatch: `h:mm:ss`, hours unbounded. /// /// Unbounded rather than rolling into days, because the readout this serves is /// a running timer and a timer that reads `2d 3:04:05` has stopped being one. fn face(span: Duration) -> String { let secs = span.as_secs(); format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60) } /// A span as a stamp: the largest unit that is not zero, and how long ago. fn ago(span: Duration) -> String { let secs = span.as_secs(); if secs < 60 { "just now".to_owned() } else if secs < 3600 { format!("{}m ago", secs / 60) } else if secs < 86_400 { format!("{}h ago", secs / 3600) } else { format!("{}d ago", secs / 86_400) } } #[cfg(test)] mod tests { use super::*; fn at(seconds: u64) -> SystemTime { SystemTime::UNIX_EPOCH + Duration::from_secs(seconds) } #[test] fn a_stopwatch_counts_up_and_a_countdown_counts_down() { assert_eq!(text(Clock::Since, at(0), at(3845)), "1:04:05"); assert_eq!(text(Clock::Until, at(3845), at(0)), "1:04:05"); } #[test] fn a_readout_that_has_run_out_reads_as_zero_rather_than_backwards() { assert_eq!(text(Clock::Until, at(0), at(90)), "0:00:00"); assert_eq!(text(Clock::Since, at(90), at(0)), "0:00:00"); } #[test] fn a_stamp_is_coarse_and_a_stopwatch_is_not() { assert_eq!(text(Clock::Age, at(0), at(30)), "just now"); assert_eq!(text(Clock::Age, at(0), at(300)), "5m ago"); assert_eq!(text(Clock::Age, at(0), at(10_800)), "3h ago"); assert_eq!(text(Clock::Age, at(0), at(345_600)), "4d ago"); assert_eq!(text(Clock::Since, at(0), at(30)), "0:00:30"); } #[test] fn a_toast_takes_its_lifetime_from_the_named_intent() { assert_eq!(LINGER, makeover_timing::Intent::Dismiss.duration()); assert_eq!(notice_lifetime(true), Some(LINGER)); } #[test] fn a_banner_has_no_lifetime_rather_than_a_long_one() { assert_eq!(notice_lifetime(false), None); } #[test] fn the_cadence_follows_the_granularity_the_spelling_chose() { assert_eq!(cadence(Clock::Since), TICK); assert_eq!(cadence(Clock::Until), TICK); assert_eq!(cadence(Clock::Age), COARSE); assert!(COARSE > TICK); } }