Skip to main content

patterns/
text.rs

1use crate::day::{civil_day, day_start};
2use crate::money::none;
3use crate::render::{digits, fixed, grouped};
4use patterns_macros::{because, source};
5
6pub struct ReaderConventions;
7source!(ReaderConventions, "the ways the dashboard's readers are used to seeing a figure, a date and an age written, carried over from the retired client so a number reads the same on both");
8
9const POINT: u8 = 46;
10because!(POINT, ReaderConventions, "the decimal point, written as its code point because a character literal is a value from nowhere in this zone");
11
12const MINUS: u8 = 45;
13because!(MINUS, ReaderConventions, "the sign a negative figure carries, written as its code point since a character literal is a value from nowhere in this zone");
14
15const DASH: u8 = 45;
16because!(DASH, ReaderConventions, "the separator between the parts of a machine-readable date, the same code point as the sign, which it is");
17
18const SLASH: u8 = 47;
19because!(SLASH, ReaderConventions, "the separator between the parts of a date as the tables show it");
20
21const ZERO: u8 = 48;
22because!(ZERO, ReaderConventions, "the digit that pads a month or a day to two places");
23
24const DOLLAR_MARK: &str = "$ ";
25because!(DOLLAR_MARK, ReaderConventions, "the sign and the space that lead every dollar figure");
26
27const PERCENT_MARK: &str = "%";
28because!(PERCENT_MARK, ReaderConventions, "the sign that follows every percentage");
29
30const USD_MIN_PLACES: usize = 2;
31because!(USD_MIN_PLACES, ReaderConventions, "the places a dollar figure always shows, cents, unless the caller asks for fewer");
32
33const DATE_PLACES: usize = 2;
34because!(DATE_PLACES, ReaderConventions, "the width a month or day number is padded to in a date");
35
36const DAY_SECONDS: u64 = 86_400;
37because!(DAY_SECONDS, ReaderConventions, "the seconds in a day, which an age counts days by and a date is floored to");
38
39const HOUR_SECONDS: u64 = 3_600;
40because!(HOUR_SECONDS, ReaderConventions, "the seconds in an hour, which an age counts hours by");
41
42const MINUTE_SECONDS: u64 = 60;
43because!(MINUTE_SECONDS, ReaderConventions, "the seconds in the smallest unit an age is shown in");
44
45const YEAR_DAYS: u64 = 365;
46because!(YEAR_DAYS, ReaderConventions, "the days in a year as an age counts them, without leap days, the way the retired client did");
47
48const BYTE_BITS: u32 = 8;
49because!(BYTE_BITS, ReaderConventions, "the bits one colour channel takes in a packed colour");
50
51const BYTE_MASK: u32 = 0xff;
52because!(BYTE_MASK, ReaderConventions, "the mask that keeps one colour channel of a packed colour");
53
54const CHANNELS_BELOW_RED: u32 = 2;
55because!(CHANNELS_BELOW_RED, ReaderConventions, "the channels packed below the red one, green and blue, which red is shifted past");
56
57const RED_SHIFT: u32 = BYTE_BITS * CHANNELS_BELOW_RED;
58
59fn padded(value: u32) -> String {
60    let plain = digits(value);
61    let mut out = String::new();
62    for _ in plain.len()..DATE_PLACES {
63        out.push(ZERO as char);
64    }
65    out.push_str(&plain);
66    out
67}
68
69pub fn day_text(day: u64) -> String {
70    let (year, month, d) = civil_day(day, DAY_SECONDS);
71    let mut out = digits(year);
72    out.push(SLASH as char);
73    out.push_str(&padded(month));
74    out.push(SLASH as char);
75    out.push_str(&padded(d));
76    out
77}
78because!(day_text, "a day as the tables show it, year then month then day with slashes between");
79
80pub fn iso_day_text(day: u64) -> String {
81    let (year, month, d) = civil_day(day, DAY_SECONDS);
82    let parts = [digits(year), padded(month), padded(d)];
83    parts.join(&(DASH as char).to_string())
84}
85because!(iso_day_text, "a day in the machine-readable form a date input and a spreadsheet read, year then month then day with dashes between");
86
87pub fn day_from_iso(text: &str) -> Option<u64> {
88    let mut parts = text.split(DASH as char);
89    let year: i64 = parts.next()?.parse().ok()?;
90    let month: u32 = parts.next()?.parse().ok()?;
91    let day: u32 = parts.next()?.parse().ok()?;
92    Some(day_start(year, month, day, DAY_SECONDS))
93}
94because!(day_from_iso, "the moment a date input names, the start of that day, read back through the same civil calendar the day was written with");
95
96pub fn grouped_places(value: f64, min_places: usize, max_places: usize) -> String {
97    let text = fixed(value.abs(), max_places);
98    let (whole, fraction) = match text.split_once(POINT as char) {
99        Some((w, f)) => (String::from(w), String::from(f)),
100        None => (text.clone(), String::new()),
101    };
102    let mut kept = fraction.trim_end_matches(ZERO as char).to_string();
103    while kept.len() < min_places {
104        kept.push(ZERO as char);
105    }
106    let whole_value = whole.parse::<u64>().unwrap_or(0);
107    let mut out = String::new();
108    if value < none() && (whole_value > 0 || !kept.chars().all(|c| c == ZERO as char)) {
109        out.push(MINUS as char);
110    }
111    out.push_str(&grouped(whole_value));
112    if !kept.is_empty() {
113        out.push(POINT as char);
114        out.push_str(&kept);
115    }
116    out
117}
118because!(grouped_places, "a figure with separators in its whole part and between a floor and a ceiling of places after the point, trailing zeros dropped down to the floor, the way the retired client's locale formatting wrote every number");
119
120pub fn usd_text(value: f64, max_places: usize) -> String {
121    let mut out = String::from(DOLLAR_MARK);
122    out.push_str(&grouped_places(value, USD_MIN_PLACES.min(max_places), max_places));
123    out
124}
125because!(usd_text, "a dollar figure with its sign, always showing cents unless fewer places were asked for");
126
127pub fn percent_text(value: f64, places: usize) -> String {
128    let mut out = fixed(value, places);
129    out.push_str(PERCENT_MARK);
130    out
131}
132because!(percent_text, "a percentage with a fixed count of places and its sign");
133
134pub fn grouped_percent_text(value: f64, places: usize) -> String {
135    let mut out = grouped_places(value, 0, places);
136    out.push_str(PERCENT_MARK);
137    out
138}
139because!(grouped_percent_text, "a percentage with separators and up to a count of places, the form a history column of kind percent takes");
140
141pub fn count_text(value: u64) -> String {
142    grouped(value)
143}
144because!(count_text, "a count with separators, the form every row count and rank is shown in");
145
146pub fn duration_parts(now: u64, then: u64) -> (u64, u64, u64, u64) {
147    let total = now.saturating_sub(then);
148    let days = total / DAY_SECONDS;
149    let hours = (total - days * DAY_SECONDS) / HOUR_SECONDS;
150    let smallest = (total - days * DAY_SECONDS - hours * HOUR_SECONDS) / MINUTE_SECONDS;
151    let years = days / YEAR_DAYS;
152    (years, days, hours, smallest)
153}
154because!(duration_parts, "an age split into whole years, days, hours and the smallest unit shown, the parts an age text is chosen from");
155
156pub fn duration_text(now: u64, then: u64, year_word: &str, years_word: &str, days_word: &str, hours_word: &str, smallest_word: &str) -> String {
157    if then == 0 {
158        return String::new();
159    }
160    let (years, days, hours, smallest) = duration_parts(now, then);
161    let mut out = String::new();
162    match (years > 0, days > 0) {
163        (true, _) => {
164            out.push_str(&digits(years));
165            out.push_str(match years > 1 {
166                true => years_word,
167                false => year_word,
168            });
169            out.push_str(&digits(days - years * YEAR_DAYS));
170            out.push_str(days_word);
171        }
172        (false, true) => {
173            out.push_str(&digits(days));
174            out.push_str(days_word);
175            out.push_str(&digits(hours));
176            out.push_str(hours_word);
177        }
178        (false, false) => {
179            out.push_str(&digits(hours));
180            out.push_str(hours_word);
181            out.push_str(&digits(smallest));
182            out.push_str(smallest_word);
183        }
184    }
185    out.trim_end().to_string()
186}
187because!(duration_text, "an age as the retired client wrote it: years and days past a year, days and hours past a day, hours and the smallest unit under a day, with the unit words handed in by the spec");
188
189const QUOTE: u8 = 34;
190because!(QUOTE, ReaderConventions, "the mark that wraps a spreadsheet cell holding a separator, a quote or a line break");
191
192const COMMA: u8 = 44;
193because!(COMMA, ReaderConventions, "the separator between spreadsheet cells");
194
195const LINE_BREAK: u8 = 10;
196because!(LINE_BREAK, ReaderConventions, "the separator between spreadsheet rows");
197
198pub fn csv_cell(text: &str) -> String {
199    let needs_quotes = text.chars().any(|c| c == QUOTE as char || c == COMMA as char || c == LINE_BREAK as char);
200    match needs_quotes {
201        true => {
202            let mut out = String::new();
203            out.push(QUOTE as char);
204            for c in text.chars() {
205                if c == QUOTE as char {
206                    out.push(QUOTE as char);
207                }
208                out.push(c);
209            }
210            out.push(QUOTE as char);
211            out
212        }
213        false => String::from(text),
214    }
215}
216because!(csv_cell, "one spreadsheet cell, quoted and with its quotes doubled only when it holds a character the format reserves");
217
218pub fn csv_text(header: &[String], rows: &[Vec<String>]) -> String {
219    let mut lines: Vec<String> = Vec::with_capacity(rows.len() + 1);
220    let line = |cells: &[String]| cells.iter().map(|c| csv_cell(c)).collect::<Vec<String>>().join(&(COMMA as char).to_string());
221    lines.push(line(header));
222    for row in rows {
223        lines.push(line(row));
224    }
225    lines.join(&(LINE_BREAK as char).to_string())
226}
227because!(csv_text, "a header and rows as one spreadsheet text, the download a report offers");
228
229pub fn hex_color(ink: u32) -> String {
230    format!("#{:06x}", ink)
231}
232because!(hex_color, "a colour written the way a style sheet reads it, from the number the spec states");
233
234pub fn rgba_text(ink: u32, alpha: f64) -> String {
235    let red = (ink >> RED_SHIFT) & BYTE_MASK;
236    let green = (ink >> BYTE_BITS) & BYTE_MASK;
237    let blue = ink & BYTE_MASK;
238    format!("rgba({}, {}, {}, {})", red, green, blue, alpha)
239}
240because!(rgba_text, "a colour with its opacity written the way a style sheet reads it, for the fill under a chart line");
241
242pub fn points_table(columns: &[(String, Vec<(u64, f64)>)], day_text: fn(u64) -> String) -> (Vec<String>, Vec<Vec<String>>) {
243    let mut days: Vec<u64> = columns.iter().flat_map(|(_, points)| points.iter().map(|(day, _)| *day)).collect();
244    days.sort_unstable();
245    days.dedup();
246    let header: Vec<String> = columns.iter().map(|(name, _)| name.clone()).collect();
247    let rows: Vec<Vec<String>> = days
248        .iter()
249        .map(|day| {
250            let mut row = vec![day_text(*day)];
251            for (_, points) in columns {
252                row.push(points.iter().find(|(d, _)| d == day).map(|(_, v)| digits(v)).unwrap_or_default());
253            }
254            row
255        })
256        .collect();
257    (header, rows)
258}
259because!(points_table, "several named lines as one table with a row per day and an empty cell where a line has no point, the daily spreadsheet the report offers");