Skip to main content

max / makeover-tui

Draw a chart in cells, with the bars lying down A webview stands its bars on an axis; a terminal has one glyph per cell and a handful of rows, so standing them up would mean drawing each as a stack of partial blocks and giving up the labels, which are the half a reader actually reads. Laid down, every bar keeps its place, its magnitude and its reading, and the drawing is `meter`'s repeated. The axis is stated rather than ruled: every bar is `meter_cells` wide and full means `Chart::most`, so the widths are comparable across the run, which is the one thing a chart has to get right. Places are padded to the widest so the bars line up beside them. `quasi-tui`'s timeline draws no gridlines for the same reason -- a terminal draws what a terminal draws rather than an impression of the other renderer.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 18:12 UTC
Signed with PGP, not checked
Commit: e8857d9e7ae22e7217e89969a68994be509d4004
Parent: b7ea670
3 files changed, +143 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.44.0"
3 + version = "0.44.1"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
M src/piece.rs +91 -1
@@ -36,7 +36,7 @@
36 36 //! it did it would be a layout engine with one consumer's flow baked into it.
37 37
38 38 use makeover_layout::{
39 - Act, Awaiting, Field, FieldKind, Figure, Heading, Meter, ThemeVariant, Token, Tone,
39 + Act, Awaiting, Bar, Chart, Field, FieldKind, Figure, Heading, Meter, ThemeVariant, Token, Tone,
40 40 };
41 41 use ratatui::buffer::Buffer;
42 42 use ratatui::layout::Rect;
@@ -991,3 +991,93 @@
991 991
992 992 #[cfg(test)]
993 993 mod tests;
994 +
995 + /// A chart, one line per bar.
996 + ///
997 + /// # Why the bars lie down here
998 + ///
999 + /// A webview draws a chart as columns standing on an axis, and a terminal has
1000 + /// one glyph per cell and a handful of rows. Standing the bars up would mean
1001 + /// drawing each one as a stack of partial blocks and giving up the labels,
1002 + /// which are the half a reader actually reads. Laid down, every bar keeps its
1003 + /// place on the axis, its magnitude and its reading, and the drawing is
1004 + /// [`meter`]'s repeated -- which is the honest answer for the same reason
1005 + /// `quasi-tui`'s timeline draws no gridlines: a terminal draws what a terminal
1006 + /// draws rather than an impression of the other renderer.
1007 + ///
1008 + /// The axis is not drawn as a rule or a scale, for that same reason. It is
1009 + /// stated instead: every bar is `meter_cells` wide and full means
1010 + /// [`Chart::most`], so the widths are comparable across the run, which is the
1011 + /// one thing a chart has to get right.
1012 + ///
1013 + /// # What is left out
1014 + ///
1015 + /// [`Chart::label`] is not drawn. It names what the magnitudes are and every
1016 + /// bar's own [`Bar::reading`] already carries the units, so drawing it would be
1017 + /// a heading this function does not own the room for. A caller that wants it
1018 + /// says it as a heading, which is what a description does anyway.
1019 + ///
1020 + /// Labels are padded to the widest, so the bars line up. That is measured in
1021 + /// characters rather than in display cells, which is wrong for a label holding
1022 + /// a wide glyph and is what [`crate::text`] would cost to bring in for a case
1023 + /// that has not turned up.
1024 + #[must_use]
1025 + pub fn chart(style: &PieceStyle, chart: &Chart<'_>, bars: &[Bar<'_>]) -> Vec<Line<'static>> {
1026 + let widest = bars
1027 + .iter()
1028 + .map(|bar| bar.at.chars().count())
1029 + .max()
1030 + .unwrap_or(0);
1031 + bars.iter()
1032 + .map(|bar| chart_line(style, chart, bar, widest))
1033 + .collect()
1034 + }
1035 +
1036 + /// One bar's line: where it sits, how far it reaches, and what it says.
1037 + fn chart_line(
1038 + style: &PieceStyle,
1039 + chart: &Chart<'_>,
1040 + bar: &Bar<'_>,
1041 + widest: usize,
1042 + ) -> Line<'static> {
1043 + let cells = usize::from(style.meter_cells);
1044 + // Rounded rather than truncated, so a bar that is nearly full does not read
1045 + // as one cell short of every other. The multiplication is done before the
1046 + // division for the reason it is in `meter`: in integers, the other order is
1047 + // zero.
1048 + let filled = if chart.most == 0 {
1049 + 0
1050 + } else {
1051 + let scaled = (bar.value as u128 * cells as u128).div_ceil(chart.most as u128);
1052 + (scaled as usize).min(cells)
1053 + };
1054 +
1055 + let mut spans = vec![Span::styled(
1056 + format!("{:width$} ", bar.at, width = widest),
1057 + style.secondary,
1058 + )];
1059 + spans.push(Span::styled(
1060 + format!(
1061 + "{}{}",
1062 + style.meter_full.to_string().repeat(filled),
1063 + style.meter_empty.to_string().repeat(cells - filled)
1064 + ),
1065 + style.tone(chart.tone),
1066 + ));
1067 + if let Some(reading) = chart_reading(bar) {
1068 + spans.push(Span::styled(reading, style.muted));
1069 + }
1070 + Line::from(spans)
1071 + }
1072 +
1073 + /// What a bar says beside its own drawing, or nothing.
1074 + ///
1075 + /// The webview's `bar_text` in this renderer's spelling. Both facts joined the
1076 + /// same way, and both left out when the description carried neither.
1077 + fn chart_reading(bar: &Bar<'_>) -> Option<String> {
1078 + match (bar.reading, bar.note) {
1079 + (Some(reading), Some(note)) => Some(format!(" {reading} / {note}")),
1080 + (Some(only), None) | (None, Some(only)) => Some(format!(" {only}")),
1081 + (None, None) => None,
1082 + }
1083 + }
@@ -745,3 +745,54 @@
745 745 let one = Field::theme("theme", "Theme", &THEMES[..1]);
746 746 assert_eq!(field_height(&style, &one, 32), 3);
747 747 }
748 +
749 + /// A chart's bars all measure against one axis, which is the whole reason it is
750 + /// a member rather than a run of meters. Two bars against the same `most` have
751 + /// widths that can be compared; two meters would each have brought their own.
752 + #[test]
753 + fn every_bar_in_a_chart_is_drawn_against_the_one_axis() {
754 + let style = style();
755 + let bars = [
756 + Bar::at("Mar 3").of(10).reading("$0.10"),
757 + Bar::at("Mar 4").of(5),
758 + Bar::at("Mar 5").of(20).reading("$0.20").note("3 sales"),
759 + ];
760 + let lines = chart(&style, &Chart::new(20), &bars);
761 + let drawn: Vec<String> = lines
762 + .iter()
763 + .map(|line| {
764 + line.spans
765 + .iter()
766 + .map(|s| s.content.as_ref())
767 + .collect::<String>()
768 + })
769 + .collect();
770 + assert_eq!(
771 + drawn,
772 + vec![
773 + "Mar 3 #####----- $0.10",
774 + "Mar 4 ###-------",
775 + "Mar 5 ########## $0.20 / 3 sales",
776 + ]
777 + );
778 + }
779 +
780 + /// An axis of zero is sayable, the way an empty set is for a meter, and it draws
781 + /// empty bars rather than dividing by nothing.
782 + #[test]
783 + fn a_chart_over_nothing_draws_empty_bars() {
784 + let lines = chart(&style(), &Chart::new(0), &[Bar::at("Mar 3").of(0)]);
785 + let drawn: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
786 + assert_eq!(drawn, "Mar 3 ----------");
787 + }
788 +
789 + /// The places on the axis line up, so the bars beside them can be read against
790 + /// each other rather than against where each label happened to end.
791 + #[test]
792 + fn the_places_are_padded_to_the_widest() {
793 + let bars = [Bar::at("Mar 3").of(1), Bar::at("September").of(1)];
794 + let lines = chart(&style(), &Chart::new(1), &bars);
795 + let first: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
796 + let second: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
797 + assert_eq!(first.find('#'), second.find('#'));
798 + }