| 1 | //! Receipts: what work cost, as rows a person can scan. |
| 2 | //! |
| 3 | //! A [`Receipt`] is one row (`✓ Edited summary.md Done 4 s $0.01`) and a |
| 4 | //! [`ReceiptTable`] aligns many of them in columns. Numbers are right-aligned |
| 5 | //! on the unit or the decimal point, there are no boxes or stripes, and a |
| 6 | //! value nobody reported shows as `—`, never `0`: a zero is a measurement and |
| 7 | //! an unknown is not. Totals say when they cover only the rows that reported. |
| 8 | //! |
| 9 | //! The formatting helpers ([`format_duration`], [`format_tokens`], |
| 10 | //! [`format_cost`], [`format_bytes`], [`format_count`]) are compact and |
| 11 | //! locale-neutral: no thousands separators, binary bytes (`KiB`), rounding |
| 12 | //! only where the row is too narrow for more digits, and no value that |
| 13 | //! rounds a real quantity down to zero. |
| 14 | //! |
| 15 | //! Modelled on the engine's roster receipts (`crates/tui/src/tui/ |
| 16 | //! agent_roster.rs` and `agent_roster::format_duration` / `format_tokens`, |
| 17 | //! `Hmbown/CodeWhale` `58b1dd3dd`), whose "absent receipts render as `—`" |
| 18 | //! rule this keeps. |
| 19 | |
| 20 | use std::{borrow::Cow, time::Duration}; |
| 21 | |
| 22 | use ratatui::{ |
| 23 | buffer::Buffer, |
| 24 | layout::Rect, |
| 25 | style::Modifier, |
| 26 | text::{Line, Span}, |
| 27 | widgets::Widget, |
| 28 | }; |
| 29 | |
| 30 | use crate::{Paint, Role, State, StateWords, Theme, glyphs, text}; |
| 31 | |
| 32 | /// The mark for a value nobody reported. Zero is a measurement; this is not. |
| 33 | pub const UNKNOWN_VALUE: &str = "\u{2014}"; |
| 34 | /// [`UNKNOWN_VALUE`] in ASCII-safe terminals. |
| 35 | pub const UNKNOWN_VALUE_ASCII: &str = "-"; |
| 36 | |
| 37 | /// `—`, or `-` where marks are ASCII-safe. |
| 38 | #[must_use] |
| 39 | pub const fn unknown_value(ascii: bool) -> &'static str { |
| 40 | if ascii { |
| 41 | UNKNOWN_VALUE_ASCII |
| 42 | } else { |
| 43 | UNKNOWN_VALUE |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // --------------------------------------------------------------------------- |
| 48 | // Formatting |
| 49 | // --------------------------------------------------------------------------- |
| 50 | |
| 51 | /// `850 ms`, `12 s`, `4m 06s`, `1h 02m`, `2d 03h`: the spinner's spelling |
| 52 | /// ([`crate::duration`]), continued past a day. Fractions are dropped, not |
| 53 | /// rounded up. A real duration that is not zero but shorter than a |
| 54 | /// millisecond reads `<1 ms`, never `0 ms`. |
| 55 | #[must_use] |
| 56 | pub fn format_duration(d: Duration) -> String { |
| 57 | let secs = d.as_secs(); |
| 58 | if secs == 0 { |
| 59 | let ms = d.as_millis(); |
| 60 | if ms == 0 && !d.is_zero() { |
| 61 | return "<1 ms".to_string(); |
| 62 | } |
| 63 | format!("{ms} ms") |
| 64 | } else if secs < 60 { |
| 65 | format!("{secs} s") |
| 66 | } else if secs < 3_600 { |
| 67 | format!("{}m {:02}s", secs / 60, secs % 60) |
| 68 | } else if secs < 86_400 { |
| 69 | format!("{}h {:02}m", secs / 3_600, (secs % 3_600) / 60) |
| 70 | } else { |
| 71 | format!("{}d {:02}h", secs / 86_400, (secs % 86_400) / 3_600) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// The value `n / unit`, in tenths, rounded half up. Integer arithmetic, so |
| 76 | /// no float error reaches a count. |
| 77 | fn tenths(n: u64, unit: u128) -> u128 { |
| 78 | (u128::from(n) * 10 + unit / 2) / unit |
| 79 | } |
| 80 | |
| 81 | /// `value` in tenths as `12.4`, or `12` when the fraction is zero. |
| 82 | fn trim_tenths(tenths: u128) -> String { |
| 83 | let (whole, frac) = (tenths / 10, tenths % 10); |
| 84 | if frac == 0 { |
| 85 | whole.to_string() |
| 86 | } else { |
| 87 | format!("{whole}.{frac}") |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// Scale `n` by steps of `base` through `units`, one decimal, promoting a |
| 92 | /// unit when rounding would show `1000` of the one below (`999.95k` is `1M`, |
| 93 | /// not `1000k`). With `whole_from` set, values at or above it drop the |
| 94 | /// decimal (`12 MiB`); the promotion check uses what is shown. |
| 95 | fn scaled(n: u64, base: u128, units: &[&str], whole_from: Option<u128>) -> String { |
| 96 | let mut step = 0; |
| 97 | let mut unit = 1u128; |
| 98 | while step + 1 < units.len() && u128::from(n) >= unit * base { |
| 99 | unit *= base; |
| 100 | step += 1; |
| 101 | } |
| 102 | loop { |
| 103 | let mut shown = tenths(n, unit); |
| 104 | if let Some(limit) = whole_from |
| 105 | && shown >= limit * 10 |
| 106 | { |
| 107 | shown = (shown + 5) / 10 * 10; |
| 108 | } |
| 109 | if shown >= base * 10 && step + 1 < units.len() { |
| 110 | unit *= base; |
| 111 | step += 1; |
| 112 | continue; |
| 113 | } |
| 114 | return format!("{}{}", trim_tenths(shown), units[step]); |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// `812`, `12.4k`, `1.2M`, `3B`: a token count, one decimal at most. Below a |
| 119 | /// thousand it is exact. |
| 120 | #[must_use] |
| 121 | pub fn format_tokens(tokens: u64) -> String { |
| 122 | scaled(tokens, 1_000, &["", "k", "M", "B", "T"], None) |
| 123 | } |
| 124 | |
| 125 | /// A count of things (files, calls): exact below ten thousand, then compact |
| 126 | /// as [`format_tokens`] is. No separators: `1234`, not `1,234`. |
| 127 | #[must_use] |
| 128 | pub fn format_count(count: u64) -> String { |
| 129 | if count < 10_000 { |
| 130 | count.to_string() |
| 131 | } else { |
| 132 | format_tokens(count) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /// `812 B`, `1.5 KiB`, `12 MiB`: binary units, one decimal below ten of a |
| 137 | /// unit and none above, so a column of them keeps its width. |
| 138 | #[must_use] |
| 139 | pub fn format_bytes(bytes: u64) -> String { |
| 140 | if bytes < 1_024 { |
| 141 | return format!("{bytes} B"); |
| 142 | } |
| 143 | scaled( |
| 144 | bytes, |
| 145 | 1_024, |
| 146 | &[" B", " KiB", " MiB", " GiB", " TiB", " PiB", " EiB"], |
| 147 | Some(10), |
| 148 | ) |
| 149 | } |
| 150 | |
| 151 | /// A cost in millionths of the currency's unit, with its symbol: `$0.38`, |
| 152 | /// `$12.40`. Under one cent it keeps four decimals (`$0.0042`) so cheap work |
| 153 | /// is not shown as free; under a hundredth of a cent it reads `<$0.0001`. |
| 154 | /// A true zero reads `$0.00`: it is a measurement. |
| 155 | #[must_use] |
| 156 | pub fn format_cost(micros: u64, symbol: &str) -> String { |
| 157 | if micros == 0 { |
| 158 | return format!("{symbol}0.00"); |
| 159 | } |
| 160 | if micros < 50 { |
| 161 | return format!("<{symbol}0.0001"); |
| 162 | } |
| 163 | if micros < 9_950 { |
| 164 | let tenths_of_cents = (micros + 50) / 100; |
| 165 | return format!("{symbol}0.{tenths_of_cents:04}"); |
| 166 | } |
| 167 | let cents = (u128::from(micros) + 5_000) / 10_000; |
| 168 | format!("{symbol}{}.{:02}", cents / 100, cents % 100) |
| 169 | } |
| 170 | |
| 171 | // --------------------------------------------------------------------------- |
| 172 | // Values |
| 173 | // --------------------------------------------------------------------------- |
| 174 | |
| 175 | /// A cost: millionths of a currency unit, its symbol, and whether it is an |
| 176 | /// estimate (shown with `~`, never as a plain figure). |
| 177 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 178 | pub struct Cost { |
| 179 | pub micros: u64, |
| 180 | pub symbol: Cow<'static, str>, |
| 181 | /// What to print where marks are ASCII-safe: `$`, or `EUR ` for `€`. |
| 182 | pub ascii_symbol: Cow<'static, str>, |
| 183 | pub estimate: bool, |
| 184 | } |
| 185 | |
| 186 | impl Cost { |
| 187 | /// `micros` millionths of a unit, in `symbol`. The ASCII form is the |
| 188 | /// symbol when it is ASCII and empty otherwise; set it with |
| 189 | /// [`Cost::ascii_symbol`]. |
| 190 | #[must_use] |
| 191 | pub fn new(micros: u64, symbol: impl Into<Cow<'static, str>>) -> Self { |
| 192 | let symbol = symbol.into(); |
| 193 | let ascii_symbol = if symbol.is_ascii() { |
| 194 | symbol.clone() |
| 195 | } else { |
| 196 | Cow::Borrowed("") |
| 197 | }; |
| 198 | Self { |
| 199 | micros, |
| 200 | symbol, |
| 201 | ascii_symbol, |
| 202 | estimate: false, |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | /// US dollars from whole cents. |
| 207 | #[must_use] |
| 208 | pub fn usd_cents(cents: u64) -> Self { |
| 209 | Self::new(cents.saturating_mul(10_000), "$") |
| 210 | } |
| 211 | |
| 212 | #[must_use] |
| 213 | pub fn ascii_symbol(mut self, symbol: impl Into<Cow<'static, str>>) -> Self { |
| 214 | self.ascii_symbol = symbol.into(); |
| 215 | self |
| 216 | } |
| 217 | |
| 218 | #[must_use] |
| 219 | pub fn estimate(mut self) -> Self { |
| 220 | self.estimate = true; |
| 221 | self |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// One cell of a receipt. `None` is "nobody reported this", and shows as |
| 226 | /// [`UNKNOWN_VALUE`], never as zero. |
| 227 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 228 | pub enum ReceiptValue { |
| 229 | /// Words the caller already formatted (a time of day, a model name). |
| 230 | Text(Cow<'static, str>), |
| 231 | Duration(Option<Duration>), |
| 232 | Tokens(Option<u64>), |
| 233 | Count(Option<u64>), |
| 234 | Bytes(Option<u64>), |
| 235 | Cost(Option<Cost>), |
| 236 | } |
| 237 | |
| 238 | /// How a column lines its numbers up. |
| 239 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 240 | enum Align { |
| 241 | /// The end of the text: units line up. |
| 242 | End, |
| 243 | /// The decimal point (or the end of the digits): `$0.38` over `$12.40`. |
| 244 | Point, |
| 245 | } |
| 246 | |
| 247 | impl ReceiptValue { |
| 248 | #[must_use] |
| 249 | pub fn text(text: impl Into<Cow<'static, str>>) -> Self { |
| 250 | Self::Text(text.into()) |
| 251 | } |
| 252 | |
| 253 | /// Whether this value is the unknown mark. |
| 254 | #[must_use] |
| 255 | pub fn is_unknown(&self) -> bool { |
| 256 | matches!( |
| 257 | self, |
| 258 | Self::Duration(None) |
| 259 | | Self::Tokens(None) |
| 260 | | Self::Count(None) |
| 261 | | Self::Bytes(None) |
| 262 | | Self::Cost(None) |
| 263 | ) |
| 264 | } |
| 265 | |
| 266 | fn is_estimate(&self) -> bool { |
| 267 | matches!(self, Self::Cost(Some(c)) if c.estimate) |
| 268 | } |
| 269 | |
| 270 | fn align(&self) -> Align { |
| 271 | match self { |
| 272 | Self::Bytes(_) | Self::Cost(_) => Align::Point, |
| 273 | _ => Align::End, |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | /// The text this value shows. |
| 278 | #[must_use] |
| 279 | pub fn render(&self, ascii: bool) -> String { |
| 280 | match self { |
| 281 | Self::Text(t) => text::display_safe(t).into_owned(), |
| 282 | Self::Duration(Some(d)) => format_duration(*d), |
| 283 | Self::Tokens(Some(n)) => format_tokens(*n), |
| 284 | Self::Count(Some(n)) => format_count(*n), |
| 285 | Self::Bytes(Some(n)) => format_bytes(*n), |
| 286 | Self::Cost(Some(c)) => { |
| 287 | let symbol = if ascii { &c.ascii_symbol } else { &c.symbol }; |
| 288 | let figure = format_cost(c.micros, &text::display_safe(symbol)); |
| 289 | if c.estimate { |
| 290 | format!("~{figure}") |
| 291 | } else { |
| 292 | figure |
| 293 | } |
| 294 | } |
| 295 | _ => unknown_value(ascii).to_string(), |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | fn kind(&self) -> u8 { |
| 300 | match self { |
| 301 | Self::Text(_) => 0, |
| 302 | Self::Duration(_) => 1, |
| 303 | Self::Tokens(_) => 2, |
| 304 | Self::Count(_) => 3, |
| 305 | Self::Bytes(_) => 4, |
| 306 | Self::Cost(_) => 5, |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | /// What a column of values adds up to, and whether every row reported. |
| 312 | struct Sum { |
| 313 | value: ReceiptValue, |
| 314 | /// Some rows reported and some did not: the figure is a floor. |
| 315 | partial: bool, |
| 316 | } |
| 317 | |
| 318 | /// Add up a column. Mixed kinds, text, and costs in different currencies |
| 319 | /// have no total (`None`): a sum of unlike things is not a fact. |
| 320 | fn sum(values: &[&ReceiptValue]) -> Option<Sum> { |
| 321 | let first = values.first()?; |
| 322 | let kind = first.kind(); |
| 323 | if kind == 0 || values.iter().any(|v| v.kind() != kind) { |
| 324 | return None; |
| 325 | } |
| 326 | let known = values.iter().filter(|v| !v.is_unknown()).count(); |
| 327 | let partial = known > 0 && known < values.len(); |
| 328 | let fold = |pick: &dyn Fn(&ReceiptValue) -> Option<u128>| -> Option<u128> { |
| 329 | let mut total = None; |
| 330 | for v in values { |
| 331 | if let Some(n) = pick(v) { |
| 332 | total = Some(total.unwrap_or(0u128).saturating_add(n)); |
| 333 | } |
| 334 | } |
| 335 | total |
| 336 | }; |
| 337 | let clamp = |n: u128| u64::try_from(n).unwrap_or(u64::MAX); |
| 338 | let value = match first { |
| 339 | ReceiptValue::Duration(_) => { |
| 340 | let total = fold(&|v| match v { |
| 341 | ReceiptValue::Duration(Some(d)) => Some(d.as_nanos()), |
| 342 | _ => None, |
| 343 | }); |
| 344 | ReceiptValue::Duration(total.map(|n| { |
| 345 | let secs = u64::try_from(n / 1_000_000_000).unwrap_or(u64::MAX); |
| 346 | Duration::new(secs, (n % 1_000_000_000) as u32) |
| 347 | })) |
| 348 | } |
| 349 | ReceiptValue::Tokens(_) | ReceiptValue::Count(_) | ReceiptValue::Bytes(_) => { |
| 350 | let total = fold(&|v| match v { |
| 351 | ReceiptValue::Tokens(Some(n)) |
| 352 | | ReceiptValue::Count(Some(n)) |
| 353 | | ReceiptValue::Bytes(Some(n)) => Some(u128::from(*n)), |
| 354 | _ => None, |
| 355 | }) |
| 356 | .map(clamp); |
| 357 | match first { |
| 358 | ReceiptValue::Tokens(_) => ReceiptValue::Tokens(total), |
| 359 | ReceiptValue::Count(_) => ReceiptValue::Count(total), |
| 360 | _ => ReceiptValue::Bytes(total), |
| 361 | } |
| 362 | } |
| 363 | ReceiptValue::Cost(_) => { |
| 364 | let mut symbol: Option<&Cost> = None; |
| 365 | for v in values { |
| 366 | if let ReceiptValue::Cost(Some(c)) = v { |
| 367 | match symbol { |
| 368 | None => symbol = Some(c), |
| 369 | Some(s) if s.symbol == c.symbol => {} |
| 370 | Some(_) => return None, |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 | let total = fold(&|v| match v { |
| 375 | ReceiptValue::Cost(Some(c)) => Some(u128::from(c.micros)), |
| 376 | _ => None, |
| 377 | }); |
| 378 | ReceiptValue::Cost(match (symbol, total) { |
| 379 | (Some(s), Some(n)) => Some(Cost { |
| 380 | micros: clamp(n), |
| 381 | symbol: s.symbol.clone(), |
| 382 | ascii_symbol: s.ascii_symbol.clone(), |
| 383 | estimate: values.iter().any(|v| v.is_estimate()), |
| 384 | }), |
| 385 | _ => None, |
| 386 | }) |
| 387 | } |
| 388 | ReceiptValue::Text(_) => return None, |
| 389 | }; |
| 390 | Some(Sum { value, partial }) |
| 391 | } |
| 392 | |
| 393 | // --------------------------------------------------------------------------- |
| 394 | // Words, densities, rows |
| 395 | // --------------------------------------------------------------------------- |
| 396 | |
| 397 | /// The words a receipt prints beyond its caller's text. `Default` is English. |
| 398 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 399 | pub struct ReceiptWords { |
| 400 | /// After the unknown mark in the legend: `— not reported`. |
| 401 | pub unknown: Cow<'static, str>, |
| 402 | /// After `~` in the legend: `~ estimated`. |
| 403 | pub estimate: Cow<'static, str>, |
| 404 | /// After `≥` in the legend: `≥ some rows did not report`. |
| 405 | pub partial: Cow<'static, str>, |
| 406 | /// The totals row's label. |
| 407 | pub total: Cow<'static, str>, |
| 408 | /// The header over the state-word column. |
| 409 | pub outcome: Cow<'static, str>, |
| 410 | /// `+3 more`, when rows do not fit. |
| 411 | pub more: Cow<'static, str>, |
| 412 | } |
| 413 | |
| 414 | impl Default for ReceiptWords { |
| 415 | fn default() -> Self { |
| 416 | Self { |
| 417 | unknown: Cow::Borrowed("not reported"), |
| 418 | estimate: Cow::Borrowed("estimated"), |
| 419 | partial: Cow::Borrowed("some rows did not report"), |
| 420 | total: Cow::Borrowed("Total"), |
| 421 | outcome: Cow::Borrowed("Outcome"), |
| 422 | more: Cow::Borrowed("more"), |
| 423 | } |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | /// How much a table shows, chosen by width ([`ReceiptDensity::for_width`]). |
| 428 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 429 | pub enum ReceiptDensity { |
| 430 | /// Header, every column, the state word. |
| 431 | Full, |
| 432 | /// The essential columns and the state word. |
| 433 | Compact, |
| 434 | /// The mark, the label and the first essential column. |
| 435 | Minimal, |
| 436 | } |
| 437 | |
| 438 | impl ReceiptDensity { |
| 439 | /// 64 columns and up are `Full`, 44 and up `Compact`, narrower |
| 440 | /// `Minimal`. |
| 441 | #[must_use] |
| 442 | pub const fn for_width(width: u16) -> Self { |
| 443 | if width >= 64 { |
| 444 | Self::Full |
| 445 | } else if width >= 44 { |
| 446 | Self::Compact |
| 447 | } else { |
| 448 | Self::Minimal |
| 449 | } |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | /// One value column: its header (the unit, once) and whether it survives the |
| 454 | /// narrower densities. |
| 455 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 456 | pub struct ReceiptColumn { |
| 457 | pub header: Cow<'static, str>, |
| 458 | pub essential: bool, |
| 459 | } |
| 460 | |
| 461 | impl ReceiptColumn { |
| 462 | #[must_use] |
| 463 | pub fn new(header: impl Into<Cow<'static, str>>) -> Self { |
| 464 | Self { |
| 465 | header: header.into(), |
| 466 | essential: false, |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | /// Keep this column in `Compact` and `Minimal`. |
| 471 | #[must_use] |
| 472 | pub fn essential(mut self) -> Self { |
| 473 | self.essential = true; |
| 474 | self |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | /// One row: a state's mark, a label, the state's word, then values. |
| 479 | /// |
| 480 | /// `✓ Edited summary.md Done 4 s $0.01`. As a [`Paint`] it draws one |
| 481 | /// row with each value as wide as it is; put several in a [`ReceiptTable`] |
| 482 | /// to align them. |
| 483 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 484 | pub struct Receipt { |
| 485 | pub state: State, |
| 486 | pub label: Cow<'static, str>, |
| 487 | /// The state's word; defaults to [`State::word`]. |
| 488 | pub word: Cow<'static, str>, |
| 489 | pub values: Vec<ReceiptValue>, |
| 490 | /// Draws `→`: there is a detail to open. |
| 491 | pub opens: bool, |
| 492 | } |
| 493 | |
| 494 | impl Receipt { |
| 495 | #[must_use] |
| 496 | pub fn new(state: State, label: impl Into<Cow<'static, str>>) -> Self { |
| 497 | Self { |
| 498 | state, |
| 499 | label: label.into(), |
| 500 | word: Cow::Borrowed(state.word()), |
| 501 | values: Vec::new(), |
| 502 | opens: false, |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | /// The state's word from the host's [`StateWords`]. |
| 507 | #[must_use] |
| 508 | pub fn with_words(mut self, words: &StateWords) -> Self { |
| 509 | self.word = Cow::Owned(words.get(self.state).to_owned()); |
| 510 | self |
| 511 | } |
| 512 | |
| 513 | #[must_use] |
| 514 | pub fn word(mut self, word: impl Into<Cow<'static, str>>) -> Self { |
| 515 | self.word = word.into(); |
| 516 | self |
| 517 | } |
| 518 | |
| 519 | #[must_use] |
| 520 | pub fn value(mut self, value: ReceiptValue) -> Self { |
| 521 | self.values.push(value); |
| 522 | self |
| 523 | } |
| 524 | |
| 525 | #[must_use] |
| 526 | pub fn values(mut self, values: impl IntoIterator<Item = ReceiptValue>) -> Self { |
| 527 | self.values.extend(values); |
| 528 | self |
| 529 | } |
| 530 | |
| 531 | #[must_use] |
| 532 | pub fn opens(mut self) -> Self { |
| 533 | self.opens = true; |
| 534 | self |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | /// A value split at its alignment point. |
| 539 | struct Split { |
| 540 | lead: String, |
| 541 | tail: String, |
| 542 | role: Role, |
| 543 | } |
| 544 | |
| 545 | fn split(value: &ReceiptValue, ascii: bool) -> Split { |
| 546 | let shown = value.render(ascii); |
| 547 | let role = if value.is_unknown() { |
| 548 | Role::Hint |
| 549 | } else if value.is_estimate() { |
| 550 | Role::Muted |
| 551 | } else { |
| 552 | Role::Foreground |
| 553 | }; |
| 554 | let at = match value.align() { |
| 555 | Align::End => shown.len(), |
| 556 | Align::Point => shown.find('.').unwrap_or_else(|| { |
| 557 | shown |
| 558 | .rfind(|c: char| c.is_ascii_digit()) |
| 559 | .map_or(shown.len(), |i| i + 1) |
| 560 | }), |
| 561 | }; |
| 562 | Split { |
| 563 | lead: shown[..at].to_string(), |
| 564 | tail: shown[at..].to_string(), |
| 565 | role, |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | /// The widths of one value column. |
| 570 | #[derive(Clone, Copy, Default)] |
| 571 | struct Slot { |
| 572 | lead: usize, |
| 573 | tail: usize, |
| 574 | } |
| 575 | |
| 576 | impl Slot { |
| 577 | fn width(self) -> usize { |
| 578 | self.lead + self.tail |
| 579 | } |
| 580 | |
| 581 | fn take(&mut self, s: &Split) { |
| 582 | self.lead = self.lead.max(text::width(&s.lead)); |
| 583 | self.tail = self.tail.max(text::width(&s.tail)); |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | /// One row of the grid, before it is spans. |
| 588 | struct GridRow { |
| 589 | mark: Option<(&'static str, Role)>, |
| 590 | label: String, |
| 591 | label_role: Role, |
| 592 | bold: bool, |
| 593 | word: String, |
| 594 | cells: Vec<Option<Split>>, |
| 595 | opens: bool, |
| 596 | } |
| 597 | |
| 598 | /// Everything the layout needs, borrowed from a table or a single receipt. |
| 599 | struct Grid<'a> { |
| 600 | columns: &'a [ReceiptColumn], |
| 601 | rows: &'a [Receipt], |
| 602 | totals: Option<&'a Vec<Option<(ReceiptValue, bool)>>>, |
| 603 | header: bool, |
| 604 | label_header: &'a str, |
| 605 | density: Option<ReceiptDensity>, |
| 606 | words: &'a ReceiptWords, |
| 607 | /// Stretch the label column to the area (a lone row); otherwise it is as |
| 608 | /// wide as the longest label, so values stay near what they describe. |
| 609 | fill: bool, |
| 610 | } |
| 611 | |
| 612 | fn pad_start(s: &str, w: usize) -> String { |
| 613 | format!("{}{s}", " ".repeat(w.saturating_sub(text::width(s)))) |
| 614 | } |
| 615 | |
| 616 | fn pad_end(s: &str, w: usize) -> String { |
| 617 | format!("{s}{}", " ".repeat(w.saturating_sub(text::width(s)))) |
| 618 | } |
| 619 | |
| 620 | /// What a laid-out grid needs to paint. |
| 621 | struct Layout { |
| 622 | lines: Vec<(Line<'static>, Option<Role>)>, |
| 623 | /// Rows of data (not header, totals or legend) in `lines`. |
| 624 | legend: Option<String>, |
| 625 | } |
| 626 | |
| 627 | impl Grid<'_> { |
| 628 | fn density(&self, width: u16) -> ReceiptDensity { |
| 629 | self.density |
| 630 | .unwrap_or_else(|| ReceiptDensity::for_width(width)) |
| 631 | } |
| 632 | |
| 633 | /// Which value columns this density keeps, in order. |
| 634 | fn visible(&self, density: ReceiptDensity) -> Vec<usize> { |
| 635 | let all = 0..self.columns.len(); |
| 636 | match density { |
| 637 | ReceiptDensity::Full => all.collect(), |
| 638 | ReceiptDensity::Compact => all.filter(|i| self.columns[*i].essential).collect(), |
| 639 | ReceiptDensity::Minimal => { |
| 640 | let first = all |
| 641 | .clone() |
| 642 | .find(|i| self.columns[*i].essential) |
| 643 | .or_else(|| all.into_iter().next()); |
| 644 | first.into_iter().collect() |
| 645 | } |
| 646 | } |
| 647 | } |
| 648 | |
| 649 | /// Lines, one per row, at `width`, plus the legend text when one applies. |
| 650 | fn layout(&self, width: u16, theme: &Theme) -> Layout { |
| 651 | let ascii = theme.ascii(); |
| 652 | let density = self.density(width); |
| 653 | let mut cols = self.visible(density); |
| 654 | let mut show_word = density != ReceiptDensity::Minimal; |
| 655 | let show_header = self.header && density == ReceiptDensity::Full; |
| 656 | let any_opens = density != ReceiptDensity::Minimal && self.rows.iter().any(|r| r.opens); |
| 657 | let gap = if density == ReceiptDensity::Full { |
| 658 | 2 |
| 659 | } else { |
| 660 | 1 |
| 661 | }; |
| 662 | let width = usize::from(width); |
| 663 | |
| 664 | let mut grid: Vec<GridRow> = self |
| 665 | .rows |
| 666 | .iter() |
| 667 | .map(|r| GridRow { |
| 668 | mark: Some((glyphs::pick(r.state.glyph(), ascii), r.state.role())), |
| 669 | label: text::display_safe(&r.label).into_owned(), |
| 670 | label_role: Role::Foreground, |
| 671 | bold: false, |
| 672 | word: text::display_safe(&r.word).into_owned(), |
| 673 | cells: (0..self.columns.len()) |
| 674 | .map(|i| r.values.get(i).map(|v| split(v, ascii))) |
| 675 | .collect(), |
| 676 | opens: r.opens, |
| 677 | }) |
| 678 | .collect(); |
| 679 | if let Some(totals) = self.totals { |
| 680 | grid.push(GridRow { |
| 681 | mark: None, |
| 682 | label: text::display_safe(&self.words.total).into_owned(), |
| 683 | label_role: Role::Foreground, |
| 684 | bold: true, |
| 685 | word: String::new(), |
| 686 | cells: (0..self.columns.len()) |
| 687 | .map(|i| { |
| 688 | totals.get(i).cloned().flatten().map(|(v, p)| { |
| 689 | let mut s = split(&v, ascii); |
| 690 | if p { |
| 691 | let floor = if ascii { ">= " } else { "\u{2265} " }; |
| 692 | s.lead.insert_str(0, floor); |
| 693 | } |
| 694 | s |
| 695 | }) |
| 696 | }) |
| 697 | .collect(), |
| 698 | opens: false, |
| 699 | }); |
| 700 | } |
| 701 | |
| 702 | let unknown = split(&ReceiptValue::Tokens(None), ascii); |
| 703 | let slots = |cols: &[usize]| -> Vec<Slot> { |
| 704 | cols.iter() |
| 705 | .map(|i| { |
| 706 | let mut slot = Slot::default(); |
| 707 | for row in &grid { |
| 708 | match row.cells.get(*i) { |
| 709 | Some(Some(s)) => slot.take(s), |
| 710 | _ => slot.take(&unknown), |
| 711 | } |
| 712 | } |
| 713 | let header = text::width(&self.columns[*i].header); |
| 714 | if show_header && header > slot.width() { |
| 715 | slot.lead += header - slot.width(); |
| 716 | } |
| 717 | slot |
| 718 | }) |
| 719 | .collect() |
| 720 | }; |
| 721 | let word_w = |show: bool| -> usize { |
| 722 | if !show { |
| 723 | return 0; |
| 724 | } |
| 725 | let rows = grid.iter().map(|r| text::width(&r.word)); |
| 726 | let head = if show_header { |
| 727 | text::width(&self.words.outcome) |
| 728 | } else { |
| 729 | 0 |
| 730 | }; |
| 731 | rows.chain(Some(head)).max().unwrap_or(0) |
| 732 | }; |
| 733 | let fixed = |slots: &[Slot], show_word: bool| -> usize { |
| 734 | let values: usize = slots.iter().map(|s| gap + s.width()).sum(); |
| 735 | let word = if show_word { gap + word_w(true) } else { 0 }; |
| 736 | let arrow = if any_opens { gap + 1 } else { 0 }; |
| 737 | 2 + values + word + arrow |
| 738 | }; |
| 739 | let mut slots_now = slots(&cols); |
| 740 | // The label keeps room before anything else is shown: narrower than |
| 741 | // that, drop the last value column, then the state word. |
| 742 | while width.saturating_sub(fixed(&slots_now, show_word)) < MIN_LABEL |
| 743 | && (!cols.is_empty() || show_word) |
| 744 | { |
| 745 | if cols.len() > 1 { |
| 746 | cols.pop(); |
| 747 | } else if show_word { |
| 748 | show_word = false; |
| 749 | } else { |
| 750 | cols.pop(); |
| 751 | } |
| 752 | slots_now = slots(&cols); |
| 753 | } |
| 754 | let word_cells = word_w(show_word); |
| 755 | let natural = grid |
| 756 | .iter() |
| 757 | .map(|r| text::width(&r.label)) |
| 758 | .chain(show_header.then(|| text::width(self.label_header))) |
| 759 | .max() |
| 760 | .unwrap_or(1); |
| 761 | let room = width.saturating_sub(fixed(&slots_now, show_word)); |
| 762 | let label_w = if self.fill { room } else { room.min(natural) }.max(1); |
| 763 | |
| 764 | let compose = |row: &GridRow, theme: &Theme| -> Line<'static> { |
| 765 | let mut spans: Vec<Span<'static>> = Vec::new(); |
| 766 | let weight = if row.bold { |
| 767 | Modifier::BOLD |
| 768 | } else { |
| 769 | Modifier::empty() |
| 770 | }; |
| 771 | match row.mark { |
| 772 | Some((glyph, role)) => { |
| 773 | spans.push(Span::styled(glyph, theme.fg(role))); |
| 774 | spans.push(Span::raw(" ")); |
| 775 | } |
| 776 | None => spans.push(Span::raw(" ")), |
| 777 | } |
| 778 | let label = text::pad(&row.label, label_w, ascii); |
| 779 | spans.push(Span::styled( |
| 780 | label, |
| 781 | theme.fg(row.label_role).add_modifier(weight), |
| 782 | )); |
| 783 | if show_word { |
| 784 | spans.push(Span::raw(" ".repeat(gap))); |
| 785 | spans.push(Span::styled( |
| 786 | pad_end(&row.word, word_cells), |
| 787 | theme.fg(Role::Muted), |
| 788 | )); |
| 789 | } |
| 790 | for (slot, i) in slots_now.iter().zip(&cols) { |
| 791 | spans.push(Span::raw(" ".repeat(gap))); |
| 792 | let cell = match row.cells.get(*i) { |
| 793 | Some(Some(s)) => s, |
| 794 | _ => &unknown, |
| 795 | }; |
| 796 | let cell_text = format!( |
| 797 | "{}{}", |
| 798 | pad_start(&cell.lead, slot.lead), |
| 799 | pad_end(&cell.tail, slot.tail) |
| 800 | ); |
| 801 | spans.push(Span::styled( |
| 802 | cell_text, |
| 803 | theme.fg(cell.role).add_modifier(weight), |
| 804 | )); |
| 805 | } |
| 806 | if any_opens { |
| 807 | spans.push(Span::raw(" ".repeat(gap))); |
| 808 | let arrow = if row.opens { |
| 809 | if ascii { ">" } else { "\u{2192}" } |
| 810 | } else { |
| 811 | " " |
| 812 | }; |
| 813 | spans.push(Span::styled(arrow, theme.fg(Role::Muted))); |
| 814 | } |
| 815 | Line::from(spans) |
| 816 | }; |
| 817 | |
| 818 | let mut lines = Vec::new(); |
| 819 | if show_header { |
| 820 | let mut spans = vec![Span::raw(" ")]; |
| 821 | spans.push(Span::styled( |
| 822 | text::pad(self.label_header, label_w, ascii), |
| 823 | theme.fg(Role::Muted), |
| 824 | )); |
| 825 | if show_word { |
| 826 | spans.push(Span::raw(" ".repeat(gap))); |
| 827 | spans.push(Span::styled( |
| 828 | pad_end(&text::display_safe(&self.words.outcome), word_cells), |
| 829 | theme.fg(Role::Muted), |
| 830 | )); |
| 831 | } |
| 832 | for (slot, i) in slots_now.iter().zip(&cols) { |
| 833 | spans.push(Span::raw(" ".repeat(gap))); |
| 834 | let header = text::display_safe(&self.columns[*i].header); |
| 835 | spans.push(Span::styled( |
| 836 | pad_start(&header, slot.width()), |
| 837 | theme.fg(Role::Muted), |
| 838 | )); |
| 839 | } |
| 840 | lines.push((Line::from(spans), None)); |
| 841 | } |
| 842 | for row in &grid { |
| 843 | lines.push((compose(row, theme), None)); |
| 844 | } |
| 845 | |
| 846 | // The legend names every mark the table used, so none is a puzzle. |
| 847 | let used = |f: &dyn Fn(&ReceiptValue) -> bool| { |
| 848 | self.rows |
| 849 | .iter() |
| 850 | .flat_map(|r| cols.iter().filter_map(|i| r.values.get(*i))) |
| 851 | .any(f) |
| 852 | }; |
| 853 | let partial = self.totals.is_some_and(|t| { |
| 854 | cols.iter() |
| 855 | .any(|i| matches!(t.get(*i), Some(Some((_, true))))) |
| 856 | }); |
| 857 | let any_missing = self |
| 858 | .rows |
| 859 | .iter() |
| 860 | .any(|r| cols.iter().any(|i| r.values.get(*i).is_none())); |
| 861 | let mut legend = Vec::new(); |
| 862 | if used(&|v| v.is_unknown()) || any_missing { |
| 863 | legend.push(format!( |
| 864 | "{unknown} {}", |
| 865 | text::display_safe(&self.words.unknown), |
| 866 | unknown = unknown_value(ascii) |
| 867 | )); |
| 868 | } |
| 869 | if used(&|v| v.is_estimate()) { |
| 870 | legend.push(format!("~ {}", text::display_safe(&self.words.estimate))); |
| 871 | } |
| 872 | if partial { |
| 873 | let floor = if ascii { ">=" } else { "\u{2265}" }; |
| 874 | legend.push(format!( |
| 875 | "{floor} {}", |
| 876 | text::display_safe(&self.words.partial) |
| 877 | )); |
| 878 | } |
| 879 | // Not ` - `: that is the unknown mark's own ASCII form. |
| 880 | let sep = if ascii { " " } else { " \u{b7} " }; |
| 881 | let legend = (!legend.is_empty()).then(|| legend.join(sep)); |
| 882 | Layout { lines, legend } |
| 883 | } |
| 884 | } |
| 885 | |
| 886 | /// A label never gets less than this before columns are dropped. |
| 887 | const MIN_LABEL: usize = 10; |
| 888 | |
| 889 | fn paint_grid(grid: &Grid, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 890 | let area = area.intersection(buf.area); |
| 891 | if area.is_empty() { |
| 892 | return; |
| 893 | } |
| 894 | let layout = grid.layout(area.width, theme); |
| 895 | let legend = layout.legend.map(|l| { |
| 896 | let shown = text::truncate(&l, usize::from(area.width).saturating_sub(2), theme.ascii()); |
| 897 | Line::from(vec![ |
| 898 | Span::raw(" "), |
| 899 | Span::styled(shown.into_owned(), theme.fg(Role::Hint)), |
| 900 | ]) |
| 901 | }); |
| 902 | let total = layout.lines.len(); |
| 903 | let rows = usize::from(area.height); |
| 904 | let totals_rows = usize::from(grid.totals.is_some()); |
| 905 | // Rows that do not fit end in `+n more` with the real n; the totals row |
| 906 | // and the legend are the last things to go. |
| 907 | let reserve = totals_rows + usize::from(legend.is_some()); |
| 908 | let header_rows = total - grid.rows.len() - totals_rows; |
| 909 | let body_fit = rows.saturating_sub(header_rows + reserve); |
| 910 | let mut y = area.y; |
| 911 | let mut put = |line: &Line<'static>, y: &mut u16| { |
| 912 | if *y < area.bottom() { |
| 913 | line.clone() |
| 914 | .render(Rect::new(area.x, *y, area.width, 1), buf); |
| 915 | *y += 1; |
| 916 | } |
| 917 | }; |
| 918 | for line in layout.lines.iter().take(header_rows) { |
| 919 | put(&line.0, &mut y); |
| 920 | } |
| 921 | let body: Vec<_> = layout |
| 922 | .lines |
| 923 | .iter() |
| 924 | .skip(header_rows) |
| 925 | .take(grid.rows.len()) |
| 926 | .collect(); |
| 927 | if body.len() <= body_fit { |
| 928 | for line in &body { |
| 929 | put(&line.0, &mut y); |
| 930 | } |
| 931 | } else if body_fit > 0 { |
| 932 | let shown = body_fit - 1; |
| 933 | for line in body.iter().take(shown) { |
| 934 | put(&line.0, &mut y); |
| 935 | } |
| 936 | let more = format!( |
| 937 | "+{} {}", |
| 938 | body.len() - shown, |
| 939 | text::display_safe(&grid.words.more) |
| 940 | ); |
| 941 | let more = Line::from(vec![ |
| 942 | Span::raw(" "), |
| 943 | Span::styled( |
| 944 | text::truncate( |
| 945 | &more, |
| 946 | usize::from(area.width).saturating_sub(2), |
| 947 | theme.ascii(), |
| 948 | ) |
| 949 | .into_owned(), |
| 950 | theme.fg(Role::Muted), |
| 951 | ), |
| 952 | ]); |
| 953 | put(&more, &mut y); |
| 954 | } |
| 955 | if totals_rows == 1 |
| 956 | && let Some(line) = layout.lines.last() |
| 957 | { |
| 958 | put(&line.0, &mut y); |
| 959 | } |
| 960 | if let Some(legend) = &legend { |
| 961 | put(legend, &mut y); |
| 962 | } |
| 963 | } |
| 964 | |
| 965 | fn grid_height(grid: &Grid, width: u16, theme: &Theme) -> u16 { |
| 966 | let layout = grid.layout(width, theme); |
| 967 | let rows = layout.lines.len() + usize::from(layout.legend.is_some()); |
| 968 | u16::try_from(rows).unwrap_or(u16::MAX) |
| 969 | } |
| 970 | |
| 971 | impl Paint for Receipt { |
| 972 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 973 | let columns = vec![ReceiptColumn::new("").essential(); self.values.len()]; |
| 974 | let words = ReceiptWords::default(); |
| 975 | let grid = Grid { |
| 976 | columns: &columns, |
| 977 | rows: std::slice::from_ref(self), |
| 978 | totals: None, |
| 979 | header: false, |
| 980 | label_header: "", |
| 981 | density: None, |
| 982 | words: &words, |
| 983 | fill: true, |
| 984 | }; |
| 985 | paint_grid(&grid, area, buf, theme); |
| 986 | } |
| 987 | } |
| 988 | |
| 989 | /// Many receipts in aligned columns, with an optional header and totals row. |
| 990 | /// |
| 991 | /// Numbers are right-aligned on their unit or decimal point. The header |
| 992 | /// carries each unit once. Width picks the [`ReceiptDensity`]: the full table |
| 993 | /// down to a label and one number. Rows that do not fit end in `+n more` |
| 994 | /// with the real count; the totals row is kept. |
| 995 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 996 | pub struct ReceiptTable { |
| 997 | pub columns: Vec<ReceiptColumn>, |
| 998 | pub rows: Vec<Receipt>, |
| 999 | /// The header over the label column (`Action`). |
| 1000 | pub label_header: Cow<'static, str>, |
| 1001 | pub header: bool, |
| 1002 | pub totals: bool, |
| 1003 | /// `None` picks by width. |
| 1004 | pub density: Option<ReceiptDensity>, |
| 1005 | pub words: ReceiptWords, |
| 1006 | } |
| 1007 | |
| 1008 | impl ReceiptTable { |
| 1009 | #[must_use] |
| 1010 | pub fn new(columns: Vec<ReceiptColumn>, rows: Vec<Receipt>) -> Self { |
| 1011 | Self { |
| 1012 | columns, |
| 1013 | rows, |
| 1014 | label_header: Cow::Borrowed(""), |
| 1015 | header: true, |
| 1016 | totals: false, |
| 1017 | density: None, |
| 1018 | words: ReceiptWords::default(), |
| 1019 | } |
| 1020 | } |
| 1021 | |
| 1022 | #[must_use] |
| 1023 | pub fn label_header(mut self, header: impl Into<Cow<'static, str>>) -> Self { |
| 1024 | self.label_header = header.into(); |
| 1025 | self |
| 1026 | } |
| 1027 | |
| 1028 | #[must_use] |
| 1029 | pub fn header(mut self, header: bool) -> Self { |
| 1030 | self.header = header; |
| 1031 | self |
| 1032 | } |
| 1033 | |
| 1034 | /// Add a totals row: each column's sum where its values are all one kind. |
| 1035 | /// A column where some rows did not report shows `≥` and the legend says |
| 1036 | /// so; a column where none did shows `—`. |
| 1037 | #[must_use] |
| 1038 | pub fn totals(mut self) -> Self { |
| 1039 | self.totals = true; |
| 1040 | self |
| 1041 | } |
| 1042 | |
| 1043 | #[must_use] |
| 1044 | pub fn density(mut self, density: ReceiptDensity) -> Self { |
| 1045 | self.density = Some(density); |
| 1046 | self |
| 1047 | } |
| 1048 | |
| 1049 | #[must_use] |
| 1050 | pub fn words(mut self, words: ReceiptWords) -> Self { |
| 1051 | self.words = words; |
| 1052 | self |
| 1053 | } |
| 1054 | |
| 1055 | /// The sums the totals row shows, per column: `(value, partial)`, or |
| 1056 | /// `None` where the column has no total. A missing cell counts as not |
| 1057 | /// reported. |
| 1058 | #[must_use] |
| 1059 | pub fn column_totals(&self) -> Vec<Option<(ReceiptValue, bool)>> { |
| 1060 | (0..self.columns.len()) |
| 1061 | .map(|i| { |
| 1062 | let present: Vec<&ReceiptValue> = |
| 1063 | self.rows.iter().filter_map(|r| r.values.get(i)).collect(); |
| 1064 | let missing = self.rows.len() - present.len(); |
| 1065 | let total = sum(&present)?; |
| 1066 | // A row with no cell at all did not report either. |
| 1067 | let partial = total.partial || (missing > 0 && !total.value.is_unknown()); |
| 1068 | Some((total.value, partial)) |
| 1069 | }) |
| 1070 | .collect() |
| 1071 | } |
| 1072 | |
| 1073 | fn grid<'a>(&'a self, totals: Option<&'a Vec<Option<(ReceiptValue, bool)>>>) -> Grid<'a> { |
| 1074 | Grid { |
| 1075 | columns: &self.columns, |
| 1076 | rows: &self.rows, |
| 1077 | totals, |
| 1078 | header: self.header, |
| 1079 | label_header: &self.label_header, |
| 1080 | density: self.density, |
| 1081 | words: &self.words, |
| 1082 | fill: false, |
| 1083 | } |
| 1084 | } |
| 1085 | } |
| 1086 | |
| 1087 | impl Paint for ReceiptTable { |
| 1088 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 1089 | let totals = self.totals.then(|| self.column_totals()); |
| 1090 | paint_grid(&self.grid(totals.as_ref()), area, buf, theme); |
| 1091 | } |
| 1092 | |
| 1093 | fn height(&self, width: u16, theme: &Theme) -> u16 { |
| 1094 | let totals = self.totals.then(|| self.column_totals()); |
| 1095 | grid_height(&self.grid(totals.as_ref()), width, theme) |
| 1096 | } |
| 1097 | } |
| 1098 |