返回 CodeWhale
receipt.rs
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
1098 lines RUST