use crate::Tone; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::{Choice, Notice, Readiness}; /// How much of a set is done. /// /// Nine sites across the two webview apps drew a bar and nothing here named /// one, so every described screen concatenated the two numbers into its /// heading text instead: "Subtasks 3/7", "Time Tracking 45m tracked / 30m est, /// over". Every fact survives that and the reading does not, which is the same /// loss `RowPart::Tokens` closed when a toned status badge became prose. /// /// # Why a pair and not a percentage /// /// Both numbers, not the percentage the apps compute from them. The percentage /// was the obvious shape and it had already been tried: goingson's /// `Task::time_progress` divides, rounds, and then clamps to 100, which throws /// away the one case the bar exists to show — 45 minutes tracked against a /// 30-minute estimate. It carries a separate `is_over_estimate` boolean beside /// it to recover the fact the clamp dropped. A pair keeps the over-run without a /// companion flag, and [`percent`](Meter::percent) is still one call away for a /// renderer that wants it. /// /// The pair is also what the apps already have at every site. All seven /// determinate bars write the ratio into the accessible layer and never the /// percentage: `title="3/7 subtasks"`, `aria-label="3 of 7 subtasks completed"`, /// a milestone's own `3/7` span. Given 43 nothing can recover "3 of 7", so a /// percentage member would have made [`label`](Meter::label) mandatory at every /// call site, which is the concatenated text this member removes, moved one /// layer down. /// /// # What this is not /// /// The progress of an *operation*. Two of the nine sites are that — goingson's /// focus timer, Balanced Breakfast's feed fetch — and they get nothing here, on /// purpose. Both are imperative controllers over a live handle, driven by a tick /// or an event stream, and a description is built once and dropped. Holding one /// would mean growing a way to update a description between renders, which is a /// different feature. [`Readiness::Pending`] and a [`Notice::Toast`] carry the /// honest part. /// /// The two cases are distinguishable in the markup rather than by taste: every /// determinate bar in both apps carries a tone, and neither operation bar /// carries one. Two codebases drew that line the same way without coordinating. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Meter<'a> { /// How much is done. May exceed [`total`](Self::total), and that is the /// case worth drawing. pub done: u32, /// How much there is to do. Zero means there is no set, not that the set is /// complete. pub total: u32, /// What the proportion means right now. /// /// Carried rather than derived, because no renderer can work it out. The /// same 90% is [`Tone::Success`] on a subtask rollup and [`Tone::Danger`] on /// a time estimate, and goingson picks between them from `is_over_estimate`, /// a fact about the data and not about the number. pub tone: Tone, /// What is being counted, if the bar says so: "subtasks", "tasks". /// /// The noun, not the ratio. A renderer builds "3 of 7 subtasks" from this /// and the two numbers; handing it the assembled string would put the /// sentence order in the description, where a terminal at one line and a /// tooltip want different ones. pub label: Option<&'a str>, } impl<'a> Meter<'a> { /// A proportion with no tone and no label. #[must_use] pub const fn new(done: u32, total: u32) -> Self { Self { done, total, tone: Tone::Neutral, label: None, } } /// What the proportion means. #[must_use] pub const fn tone(mut self, tone: Tone) -> Self { self.tone = tone; self } /// What is being counted. #[must_use] pub const fn label(mut self, label: &'a str) -> Self { self.label = Some(label); self } /// How full the bar is, 0 to 100, clamped. /// /// For drawing, which is the only thing a clamped number is good for. Ask /// [`overflowing`](Self::overflowing) before reporting it as a fact, or this /// is `time_progress`'s bug again with the clamp moved. /// /// An empty set reads as 0. Nothing is done, because there is nothing to do /// and no bar to fill; the apps guard on the count before drawing at all. #[must_use] pub const fn percent(&self) -> u8 { if self.total == 0 { return 0; } let scaled = (self.done as u64 * 100) / self.total as u64; if scaled > 100 { 100 } else { scaled as u8 } } /// Whether more is done than there was to do. /// /// The fact [`percent`](Self::percent) destroys, kept reachable so a /// renderer can mark the over-run rather than drawing a full bar and /// implying it landed exactly. #[must_use] pub const fn overflowing(&self) -> bool { self.done > self.total } /// Whether there is a set at all. /// /// A meter over nothing is sayable on purpose, for the same reason a field /// with no options is: it is what an app with an unloaded count actually /// has, and a renderer that shows an empty bar says so on screen rather than /// dividing by zero. #[must_use] pub const fn is_empty(&self) -> bool { self.total == 0 } } /// One figure with a caption: a number and what it counts. /// /// The dashboard shape. A large value over a small caption, several of them in /// a strip: a current streak, a completion rate, a total. Four put the value /// above the caption and one inverts it, which is drift inside the shape /// rather than a second shape. /// /// # Why the value is text /// /// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already /// formatted, and the formatting is the app's because only it knows whether the /// number is a percentage, a duration or a ratio. This carries none of the /// arithmetic [`Meter`] carries, and that is the difference between them: a /// meter is a proportion a renderer draws, and a figure is a fact a renderer /// sets in type. /// /// # Tone is carried, for [`Meter`]'s reason /// /// Three of the five sites tone the figure by their own means — `red`/`blue` on /// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on /// sync. So tone is carried at every site that needs it and derived at none, and /// no renderer can work out that a streak of zero is worth colouring. /// /// # What is not here /// /// Whether the figure answers a click. One of the five is a control — sync's /// "Not Applied: 3" opens the list — and an action is not something this crate /// can name: nothing here knows what a route is. That belongs beside the figure /// in whatever layer holds the actions, the same way a row's activation sits /// beside its parts rather than inside them. /// /// The arrangement is not here either. Several figures in a strip is a set, and /// a renderer given them one at a time cannot tell it is looking at one; the /// layer that holds the tree is where the set gets said. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Figure<'a> { /// The number, formatted the way the app means it to read. pub value: &'a str, /// What it counts. The caption under the value. pub caption: &'a str, /// How the value has moved, if the app is tracking that. /// /// Text, for [`value`](Self::value)'s reason: only the app knows whether a /// move reads as `+12.5%`, `+3` or `2x`, and a renderer handed a number /// would have to guess. /// /// This is what [`tone`](Self::tone) was for and had no consumer of. The MNW /// server has four screens whose stat card is a label, a value and a delta, /// and the delta is the toned part: the figure itself is an ordinary fact /// and it is the movement that reads as good or bad. Without this the delta /// has to be folded into the caption, which loses the tone and reads as a /// longer caption rather than as a second, smaller line. pub change: Option<&'a str>, /// What the figure means right now. [`Tone::Neutral`] is an ordinary fact. /// /// Applies to [`change`](Self::change) where there is one, since that is the /// part that carries the judgement, and to the value where there is not. pub tone: Tone, } impl<'a> Figure<'a> { /// A figure that is an ordinary fact. #[must_use] pub const fn new(value: &'a str, caption: &'a str) -> Self { Self { value, caption, change: None, tone: Tone::Neutral, } } /// How the value has moved. #[must_use] pub const fn change(mut self, change: &'a str) -> Self { self.change = Some(change); self } /// What the figure means. #[must_use] pub const fn tone(mut self, tone: Tone) -> Self { self.tone = tone; self } } /// Something the user can do, and what it costs to say so. /// /// Beside [`Meter`] and [`Figure`] for the reason those are here: a renderer /// that is handed the parts has to decide how to say them, and a renderer that /// is handed a finished string has already had the decision made for it. /// /// No address. Where a control goes is the app's business and every host /// follows it differently — an `hx-get`, a protocol URL, a function call — so /// the description says what the control *is* and the caller keeps what it /// does. That is the same split [`Choice`] makes. /// /// No confirmation flag either, and that one is a finding rather than an /// omission: a question asked *after* a control is pressed belongs to whatever /// is holding the interaction, and a renderer that drew it would be asking /// before there was anything to answer. /// How a picture sits in the box it is given. /// /// An intent rather than a value, so a renderer picks the expression it has: /// `object-fit` in a webview, a texture's UV rect in egui, and in a terminal a /// choice about how many cells the blit gets. Named because MNW already makes /// the distinction deliberately at 17 sites and makes it three different ways, /// which is a policy the app decided rather than one a shared crate would be /// picking by accident. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[non_exhaustive] pub enum Fit { /// The picture's own proportions, and the box takes the height they imply. /// /// The default because it is the only one that shows the whole picture at /// its own shape, so a renderer that ignores this enum entirely is still /// right about the common case. A screenshot wants this; the shipped MNW /// carousel sets no `object-fit` at all, which is this. #[default] Natural, /// Fill the box and crop whatever does not fit. /// /// For a picture in a slot whose shape the layout fixed: a thumbnail, an /// avatar, cover art. 15 of MNW's 17 sites. Cover, /// Fit inside the box whole, leaving space on two sides. /// /// The letterbox. For when the whole picture matters more than filling the /// space, and the space is not the picture's shape. Contain, } /// A picture's own pixel dimensions. /// /// Deliberately not [`makeover_geometry`]'s business. Geometry answers *how /// much space a thing should get*, which is a scale question with the same /// answer on every screen. This is the intrinsic size of one asset, which is a /// fact about that asset and varies per picture. /// /// [`makeover_geometry`]: https://docs.rs/makeover-geometry #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Extent { /// Width in the picture's own pixels. pub width: u32, /// Height in the picture's own pixels. pub height: u32, } impl Extent { /// A picture's dimensions. #[must_use] pub const fn new(width: u32, height: u32) -> Self { Self { width, height } } /// Width over height, or `None` if either side is zero. /// /// The form a renderer actually reserves space with: a box that knows its /// proportion holds the right height at any width, which is what a /// responsive picture needs and what a fixed pixel height cannot give. #[must_use] pub fn ratio(self) -> Option { (self.width > 0 && self.height > 0).then(|| self.width as f32 / self.height as f32) } } /// A run of magnitudes read against one axis. /// /// [`Meter`] is one proportion; this is a series of them that share a maximum, /// and the shared maximum is the whole difference. A run of meters draws each /// bar against its own `total`, so a chart said that way states the axis once /// per bar and nothing holds the copies together. Here the axis is stated once /// and a bar carries only where it sits on it. /// /// # Why the axis and not a percentage per bar /// /// [`Meter`]'s reason, one layer out. The app that drew MNW's revenue chart /// computed `revenue / most * 100.0` and put the percentage in the markup, and /// what reached the reader was a width with no numbers behind it: a bar at 100% /// because it is the largest and a bar at 100% because the axis is wrong are the /// same width and are not the same fact. Carrying both integers keeps the fact, /// and [`Bar::fraction`] is one call away for a renderer that wants the ratio. /// /// It is also the only shape that survives a compiled template. A residual holds /// numbers the description HANDS a renderer, never ones a renderer works out /// from two of them, so a chart drawn from a supplied percentage could be /// described and could not be compiled. See `quasi_router::stage::number_at`. /// /// # What is worded here and what is not /// /// [`Bar::at`] is where the bar sits on the axis and [`label`](Self::label) is /// what the magnitudes are, which is [`Meter::label`]'s split exactly. What /// differs is [`Bar::reading`] and [`Bar::note`]: both arrive already worded, /// because a magnitude's own units are the app's ("$42.10", not 4210) and a /// count's noun inflects ("1 sale", "3 sales"). A renderer that pluralised /// would be growing a lexer for one language. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Chart<'a> { /// The magnitude the axis runs to. Every bar is read against this. /// /// Zero means there is no axis, not that every bar is full. A renderer draws /// nothing rather than dividing by it; see [`is_empty`](Self::is_empty). /// /// `usize` because that is what a description counts in -- a pager's offset /// and page size are the same -- and because it is the only width /// `quasi_router::stage::number_at` has a stand-in for, which is what lets a /// chart reach a compiled template at all. pub most: usize, /// What the magnitudes are: "revenue", "plays". /// /// The noun, not the unit and not the ratio. The unit is already in each /// [`Bar::reading`], where it belongs, because only the app knows it. pub label: Option<&'a str>, /// What the axis means, where it means anything. pub tone: Tone, } impl<'a> Chart<'a> { /// An axis running to `most`, untoned and unlabelled. #[must_use] pub const fn new(most: usize) -> Self { Self { most, label: None, tone: Tone::Neutral, } } /// What the magnitudes are. #[must_use] pub const fn label(mut self, label: &'a str) -> Self { self.label = Some(label); self } /// What the axis means. #[must_use] pub const fn tone(mut self, tone: Tone) -> Self { self.tone = tone; self } /// Whether there is an axis to read against. /// /// [`Meter::is_empty`]'s case: an axis running to zero is what an app with /// nothing to chart actually has, and saying so beats dividing by it. #[must_use] pub const fn is_empty(&self) -> bool { self.most == 0 } } /// One magnitude in a [`Chart`], at its place on the axis. /// /// Carries no axis of its own on purpose: a bar read against a maximum it /// states itself is a meter, and a run of those is not a chart. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Bar<'a> { /// Where on the axis this sits: "Mar 3", "Week 12". /// /// The position's own name rather than an index, for the reason a pager's /// jump carries its page number: what a chart shows is a window over a /// series, and an index into that window is not the point it names. pub at: &'a str, /// The magnitude, in the chart's units, read against [`Chart::most`]. pub value: usize, /// The magnitude as the app words it: "$42.10". /// /// Worded rather than derived because the units are the app's. A renderer /// handed 4210 cannot know it is money, let alone which money. pub reading: Option<&'a str>, /// A second fact about this bar, already worded: "3 sales". /// /// Worded for the reason [`reading`](Self::reading) is, plus one of its own: /// a count's noun inflects with the count, and that is language rather than /// drawing. pub note: Option<&'a str>, } impl<'a> Bar<'a> { /// A place on the axis, with no magnitude on it yet. /// /// The magnitude arrives through [`of`](Self::of) rather than as a second /// argument, and that is not stylistic: `quasi-declare` stages a /// constructor's plain arguments all one way or all the other, so a /// constructor taking a place AND a magnitude would have the place standing /// in as a number. Split, the place is a value and `of` is a count, which is /// the same split a pager's `of` makes and the reason it is spelled the /// same. #[must_use] pub const fn at(at: &'a str) -> Self { Self { at, value: 0, reading: None, note: None, } } /// How far up the axis this bar reaches. #[must_use] pub const fn of(mut self, value: usize) -> Self { self.value = value; self } /// How the app words this magnitude. #[must_use] pub const fn reading(mut self, reading: &'a str) -> Self { self.reading = Some(reading); self } /// A second fact about the bar, already worded. #[must_use] pub const fn note(mut self, note: &'a str) -> Self { self.note = Some(note); self } /// How far up the axis this bar reaches, 0.0 to 1.0, clamped. /// /// For drawing, which is what a clamped number is good for, and for the two /// renderers that draw in cells and pixels rather than in CSS. An empty axis /// reads as 0.0 rather than dividing by zero. /// /// A bar over [`Chart::most`] clamps, and unlike [`Meter`] that is not a /// fact being lost: `most` is the maximum of the bars, so a bar above it is /// an axis the app got wrong rather than an over-run worth drawing. #[must_use] pub fn fraction(&self, chart: &Chart<'_>) -> f32 { if chart.most == 0 { return 0.0; } (self.value as f64 / chart.most as f64).min(1.0) as f32 } }