Skip to main content

max / makeover-tui

0.16.0: the pieces every terminal app draws, drawn once Two modules lifted out of quasi-tui, which had written both and was the second consumer to do so. Neither is about a described screen, which is what makes them this crate's rather than quasi's. text. A wrap that answers a height and a drawing from the same measurement. Flow layout is the shape every terminal consumer here ends up with, ask for a height at a width then draw into the rect you were given, and it needs both answers to agree or a node draws over the one under it. ratatui's Paragraph wraps and will not say how many rows it took, which is exactly the half a flow layout cannot do without. widget. A meter, a badge, a control, a figure and a form field: what a screen is made of below the level table works at, every one hand-rolled at least twice in this tree before it was lifted. The seam is the part worth copying. Each drawing takes a makeover-layout description, a WidgetStyle, and whatever the host knows that a description never carries. field takes what is currently typed as a separate argument because Field deliberately holds no value and is not going to; Held is that seam here, and makeover-immediate reached the same one from the other side with Filling. Focus is the other: nothing in a description says which control the user is on, so every drawing takes focused as an argument and the caller counts. What focus looks like is this crate's answer. Not feature-gated, on table's footing: WidgetStyle carries the tones and a caller with a loaded theme reaches for WidgetStyle::from_theme. Follows makeover-layout 0.17.0, whose Act is what widget's control draws.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 19:15 UTC
Signed with PGP, not checked
Commit: 320c747543014d8e2e401f66d211eecedcdc16b8
Parent: ce142c5
4 files changed, +787 insertions, -2 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.15.0"
3 + version = "0.16.0"
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"
@@ -19,7 +19,7 @@
19 19 # compile against an API added in a later one -- which is what `Depth::Overlay`
20 20 # would do here. The rest of the suite has pinned this way since
21 21 # makeover-webview found it the hard way.
22 - makeover-layout = "0.16.0"
22 + makeover-layout = "0.17.0"
23 23 makeover = { version = "2.5", optional = true }
24 24
25 25 [lints.rust]
M src/lib.rs +14
@@ -138,6 +138,20 @@
138 138 /// `TableStyle::from_theme` instead of supplying them.
139 139 pub mod table;
140 140
141 + /// Word wrapping that answers a height and a drawing from the same measurement.
142 + ///
143 + /// The half ratatui's `Paragraph` leaves out. A flow layout asks for rows at a
144 + /// width and then draws into the rect it was given, and if the two disagree by
145 + /// one row a node draws over the one under it.
146 + pub mod text;
147 +
148 + /// A meter, a badge, a control, a figure and a form field.
149 + ///
150 + /// The pieces below the level [`table`] works at. Not feature-gated, on
151 + /// [`table`]'s footing: [`WidgetStyle`](widget::WidgetStyle) carries the tones,
152 + /// and a caller with a loaded theme reaches for `WidgetStyle::from_theme`.
153 + pub mod widget;
154 +
141 155 /// How many colours the terminal can actually show.
142 156 ///
143 157 /// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
A src/text.rs +271
@@ -1,0 +1,271 @@
1 + //! Words into cells.
2 + //!
3 + //! A terminal wraps on words and counts rows, and both halves have to agree or
4 + //! a node draws over the one under it. So the wrap is written once here and
5 + //! both [`height`] and [`draw`] read it, rather than each having its own idea
6 + //! of how many rows a paragraph takes.
7 + //!
8 + //! Arrived here from `quasi-tui` in 0.16.0, which is where it was written and
9 + //! where it stopped being quasi's: nothing below is about a described screen.
10 + //! Flow layout is the shape every terminal consumer in the tree ends up with —
11 + //! ask for a height at a width, then draw into the rect you were given — and it
12 + //! needs a wrap that answers both questions the same way. ratatui's own
13 + //! `Paragraph` wraps but will not tell you how many rows it took, which is the
14 + //! half a flow layout cannot do without.
15 + //!
16 + //! Width is counted in `char`s. That is wrong for a terminal in the general
17 + //! case -- a CJK glyph occupies two cells and a combining mark none -- and it
18 + //! is deliberately not fixed here: the fix is a `unicode-width` dependency, and
19 + //! taking one before anything in the tree has non-ASCII content to draw is
20 + //! paying for a problem nobody has yet. Filed rather than hidden.
21 +
22 + use ratatui::buffer::Buffer;
23 + use ratatui::layout::Rect;
24 + use ratatui::style::Style;
25 + use ratatui::text::{Line, Span};
26 +
27 + /// Break `spans` into lines no wider than `width`, keeping each word under the
28 + /// style it arrived with.
29 + ///
30 + /// Breaks on whitespace, and breaks inside a word only when the word cannot fit
31 + /// on a line of its own. A word longer than the whole width is the case that
32 + /// has no good answer; cutting it is the least bad one, because the alternative
33 + /// is a line wider than the region and a buffer that swallows the overflow
34 + /// silently.
35 + ///
36 + /// The one wrap in this crate. [`wrap`] is this with a single style over the
37 + /// whole string, rather than a second implementation that would be free to
38 + /// disagree with it about how many rows a paragraph takes -- and a disagreement
39 + /// there is a node drawing over the one under it.
40 + pub fn wrap_spans(spans: &[Span<'_>], width: u16) -> Vec<Line<'static>> {
41 + if width == 0 {
42 + return Vec::new();
43 + }
44 + let width = width as usize;
45 + let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
46 + let mut line: Vec<Span<'static>> = Vec::new();
47 + let mut column = 0usize;
48 + // The style of the whitespace last passed over, held until a word turns up
49 + // to need a separator before it. Kept rather than taken from the word,
50 + // because the space between `*lean*` and `~~gone~~` belongs to the plain
51 + // run that held it: a strikethrough that starts one cell early is drawn
52 + // through a space the author never struck.
53 + let mut separator: Option<Style> = None;
54 +
55 + for span in spans {
56 + let mut rest: &str = span.content.as_ref();
57 + while !rest.is_empty() {
58 + let gap = rest
59 + .find(|c: char| !c.is_whitespace())
60 + .unwrap_or(rest.len());
61 + if gap > 0 {
62 + // Authored breaks are breaks. A description that put a newline
63 + // in a string meant it, and rewrapping across it would join two
64 + // paragraphs.
65 + for _ in 0..rest[..gap].matches('\n').count() {
66 + lines.push(std::mem::take(&mut line));
67 + column = 0;
68 + }
69 + separator = Some(span.style);
70 + rest = &rest[gap..];
71 + continue;
72 + }
73 +
74 + let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
75 + let (mut word, after) = rest.split_at(end);
76 + rest = after;
77 +
78 + // A word too long for any line, cut to fit rather than overflowed.
79 + // The case has no good answer; cutting is the least bad one,
80 + // because the alternative is a line wider than the region and a
81 + // buffer that swallows the overflow silently.
82 + while word.chars().count() > width {
83 + if column > 0 {
84 + lines.push(std::mem::take(&mut line));
85 + column = 0;
86 + }
87 + let cut = word
88 + .char_indices()
89 + .nth(width)
90 + .map_or(word.len(), |(index, _)| index);
91 + lines.push(vec![Span::styled(word[..cut].to_string(), span.style)]);
92 + word = &word[cut..];
93 + }
94 +
95 + let room = width - column;
96 + let wanted = word.chars().count() + usize::from(column > 0);
97 + if wanted > room && column > 0 {
98 + lines.push(std::mem::take(&mut line));
99 + column = 0;
100 + }
101 + // Leading whitespace on a line is the wrap's own business and not
102 + // the author's, so a separator is drawn only between two words that
103 + // ended up on the same row.
104 + if column > 0 {
105 + line.push(Span::styled(" ", separator.unwrap_or(span.style)));
106 + column += 1;
107 + }
108 + separator = None;
109 + column += word.chars().count();
110 + line.push(Span::styled(word.to_string(), span.style));
111 + }
112 + }
113 + lines.push(line);
114 +
115 + // Nothing to say is no rows rather than one blank one, so a node with an
116 + // empty string costs nothing. A blank line inside a paragraph survives,
117 + // because that one was authored.
118 + if lines.len() == 1 && lines[0].is_empty() {
119 + return Vec::new();
120 + }
121 + lines.into_iter().map(Line::from).collect()
122 + }
123 +
124 + /// Break `text` into lines no wider than `width`.
125 + ///
126 + /// [`wrap_spans`] under one style, flattened back to strings for the callers
127 + /// that have no styles to keep.
128 + pub fn wrap(text: &str, width: u16) -> Vec<String> {
129 + wrap_spans(&[Span::raw(text.to_string())], width)
130 + .into_iter()
131 + .map(|line| {
132 + line.spans
133 + .iter()
134 + .map(|span| span.content.as_ref())
135 + .collect()
136 + })
137 + .collect()
138 + }
139 +
140 + /// The rows `text` takes at `width`.
141 + pub fn height(text: &str, width: u16) -> u16 {
142 + u16::try_from(wrap(text, width).len()).unwrap_or(u16::MAX)
143 + }
144 +
145 + /// The rows `spans` take at `width`, wrapped as a block.
146 + pub fn spans_height(spans: &[Span<'_>], width: u16) -> u16 {
147 + u16::try_from(wrap_spans(spans, width).len()).unwrap_or(u16::MAX)
148 + }
149 +
150 + /// Draw wrapped text at the top of `area`, and answer the rows it used.
151 + pub fn draw(text: &str, style: Style, area: Rect, buf: &mut Buffer) -> u16 {
152 + let mut used = 0;
153 + for line in wrap(text, area.width) {
154 + if used >= area.height {
155 + break;
156 + }
157 + buf.set_stringn(area.x, area.y + used, &line, area.width as usize, style);
158 + used += 1;
159 + }
160 + used
161 + }
162 +
163 + /// Draw wrapped spans at the top of `area`, and answer the rows they used.
164 + ///
165 + /// The block counterpart to [`draw_line`]: that one takes a run that is one
166 + /// line by construction and wraps it because it might not fit, and this one
167 + /// takes a run with authored breaks in it and keeps them.
168 + pub fn draw_spans(spans: &[Span<'_>], area: Rect, buf: &mut Buffer) -> u16 {
169 + let mut used = 0;
170 + for line in wrap_spans(spans, area.width) {
171 + if used >= area.height {
172 + break;
173 + }
174 + let mut column = 0u16;
175 + for span in &line.spans {
176 + let room = area.width.saturating_sub(column) as usize;
177 + if room == 0 {
178 + break;
179 + }
180 + buf.set_stringn(
181 + area.x + column,
182 + area.y + used,
183 + &span.content,
184 + room,
185 + span.style,
186 + );
187 + column += u16::try_from(span.content.chars().count().min(room)).unwrap_or(u16::MAX);
188 + }
189 + used += 1;
190 + }
191 + used
192 + }
193 +
194 + /// Draw a line of spans at the top of `area`, wrapping onto further rows.
195 + ///
196 + /// A run that is one line by construction -- a row's parts, a control, a meter
197 + /// -- rather than a block that may carry breaks of its own. It is the same wrap
198 + /// either way, and was its own implementation until a rich node started putting
199 + /// styled runs inside a row: the separate copy drew the space before a struck
200 + /// word struck, because it took the separator's style from the word after it
201 + /// instead of from the whitespace it replaced.
202 + pub fn draw_line(line: &Line<'_>, area: Rect, buf: &mut Buffer) -> u16 {
203 + draw_spans(&line.spans, area, buf)
204 + }
205 +
206 + /// The rows a line of spans takes at `width`.
207 + pub fn line_height(line: &Line<'_>, width: u16) -> u16 {
208 + spans_height(&line.spans, width)
209 + }
210 +
211 + #[cfg(test)]
212 + mod tests {
213 + use super::*;
214 + use ratatui::style::Modifier;
215 +
216 + #[test]
217 + fn a_paragraph_wraps_on_words_and_counts_the_rows_it_took() {
218 + // The two halves that have to agree. A height that disagreed with the
219 + // drawing by one row is a node drawing over the one under it.
220 + assert_eq!(wrap("the quick brown fox", 10), ["the quick", "brown fox"]);
221 + assert_eq!(height("the quick brown fox", 10), 2);
222 + }
223 +
224 + #[test]
225 + fn nothing_to_say_costs_no_rows_rather_than_one_blank_one() {
226 + assert_eq!(height("", 10), 0);
227 + assert!(wrap("", 10).is_empty());
228 + // A width of zero is a region with no room, not a division to do.
229 + assert!(wrap("anything", 0).is_empty());
230 + }
231 +
232 + #[test]
233 + fn an_authored_break_is_a_break() {
234 + // A description that put a newline in a string meant it, and rewrapping
235 + // across it would join two paragraphs.
236 + assert_eq!(wrap("one\ntwo", 20), ["one", "two"]);
237 + }
238 +
239 + #[test]
240 + fn a_word_wider_than_the_region_is_cut_rather_than_overflowed() {
241 + // The case with no good answer. Cutting is the least bad one: the
242 + // alternative is a line wider than the region and a buffer that
243 + // swallows the overflow silently.
244 + assert_eq!(wrap("supercalifragilistic", 6), ["superc", "alifra", "gilist", "ic"]);
245 + }
246 +
247 + #[test]
248 + fn the_space_between_two_runs_belongs_to_the_run_that_held_it() {
249 + // A strikethrough that starts one cell early is drawn through a space
250 + // the author never struck. This is why the separator's style is kept
251 + // rather than taken from the word after it.
252 + let struck = Style::new().add_modifier(Modifier::CROSSED_OUT);
253 + let spans = [Span::raw("lean "), Span::styled("gone", struck)];
254 + let lines = wrap_spans(&spans, 20);
255 + assert_eq!(lines.len(), 1);
256 + let separator = lines[0]
257 + .spans
258 + .iter()
259 + .find(|span| span.content.as_ref() == " ")
260 + .expect("a separator between the two words");
261 + assert!(!separator.style.add_modifier.contains(Modifier::CROSSED_OUT));
262 + }
263 +
264 + #[test]
265 + fn a_drawing_stops_at_the_bottom_of_the_area_it_was_given() {
266 + // Never below the rect, which is what a terminal does with everything.
267 + let mut buf = Buffer::empty(Rect::new(0, 0, 10, 2));
268 + let used = draw("the quick brown fox jumps over", Style::new(), buf.area, &mut buf);
269 + assert_eq!(used, 2);
270 + }
271 + }
A src/widget.rs +500
@@ -1,0 +1,786 @@
1 + //! The pieces every terminal app draws, drawn once.
2 + //!
3 + //! Arrived in 0.16.0 out of `quasi-tui`, which had written all of them and was
4 + //! the second consumer to do so. A meter, a badge, a control, a figure and a
5 + //! form field are what a screen is made of below the level [`table`](crate::table)
6 + //! works at, and every one of them had been hand-rolled at least twice in this
7 + //! tree before it was lifted.
8 + //!
9 + //! # What these take, and what they leave alone
10 + //!
11 + //! Each takes a `makeover-layout` description, a [`WidgetStyle`], and whatever
12 + //! the *host* knows that a description never carries. That last part is the
13 + //! shape worth copying: [`field`] takes what is currently typed in the box as a
14 + //! separate argument, because [`Field`] deliberately does not carry a value and
15 + //! is not going to. `makeover-immediate` reached the same seam from the other
16 + //! side with its `Filling`, and [`Held`] is that seam here.
17 + //!
18 + //! Focus is the other one. Nothing in a description says which control the user
19 + //! is on, so every drawing here takes `focused` as an argument and the caller
20 + //! is what counts. What focus *looks like* is this crate's answer and not the
21 + //! caller's, which is the point of it being here: see
22 + //! [`WidgetStyle::focused`].
23 + //!
24 + //! # What they do not do
25 + //!
26 + //! No layout. Each answers rows for a width, or draws into the rect it is
27 + //! given, top-aligned, and never below it. Nothing here measures twice and
28 + //! nothing here places anything relative to anything else, because the moment
29 + //! it did it would be a layout engine with one consumer's flow baked into it.
30 +
31 + use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
32 + use ratatui::buffer::Buffer;
33 + use ratatui::layout::Rect;
34 + use ratatui::style::{Modifier, Style};
35 + use ratatui::text::{Line, Span};
36 +
37 + use crate::text;
38 +
39 + /// The colours and marks the drawings below use.
40 + ///
41 + /// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an
42 + /// ungated struct of styles with a [`Default`], plus a
43 + /// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded
44 + /// theme should reach for first. A consumer painting bevels and nothing else
45 + /// should not have to supply text tones it never uses, and gating the whole
46 + /// module on `theme` would make these unreachable to anyone hand-picking
47 + /// colours.
48 + ///
49 + /// The default is the one that survives a terminal with no colour at all:
50 + /// modifiers only, no foreground anywhere. That is not a placeholder. A
51 + /// two-colour terminal is the case where a `Style` carrying a foreground is a
52 + /// foreground that will not land, and bold-and-reversed is what is left.
53 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 + pub struct WidgetStyle {
55 + /// Ordinary content, and what [`Tone::Neutral`] reads as.
56 + pub content: Style,
57 + /// Content one step back: a field's label, a quoted run.
58 + pub secondary: Style,
59 + /// Content two steps back: a caption, a hint, a meter's reading.
60 + pub muted: Style,
61 + /// Something worth knowing and nothing to do about it.
62 + pub info: Style,
63 + /// Something finished and it worked.
64 + pub success: Style,
65 + /// Something the user should look at.
66 + pub warning: Style,
67 + /// Something broken, or about to be destroyed.
68 + pub danger: Style,
69 + /// A page title.
70 + pub page: Style,
71 + /// A section title.
72 + pub section: Style,
73 + /// A subsection title.
74 + pub subsection: Style,
75 + /// Text that goes somewhere, and a control's label.
76 + pub action: Style,
77 + /// A control filled with the action colour, for the one on a screen that is
78 + /// the thing to press. A form's submit is the case that has it.
79 + pub filled: Style,
80 + /// A surface set back from the one it sits on, by colour and nothing else.
81 + /// What a code run takes, since every cell is monospace and the thing a
82 + /// webview says with a typeface cannot be said that way here.
83 + pub sunken: Style,
84 + /// What "you are on this one" adds to whatever it lands on.
85 + ///
86 + /// Reversed video by default, which is the affordance a cell has left once
87 + /// colour is spent on tone and bold on weight. A webview says it with an
88 + /// outline; a terminal has no outline that is not four more cells.
89 + pub focus: Modifier,
90 + /// How many cells [`meter`] spends on its bar.
91 + pub meter_cells: u16,
92 + /// The filled part of a bar.
93 + pub meter_full: char,
94 + /// The empty part of a bar.
95 + pub meter_empty: char,
96 + /// What marks a compulsory field, appended to its label.
97 + ///
98 + /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy*
99 + /// here, and copy is not a renderer's call.
100 + pub required_marker: &'static str,
101 + }
102 +
103 + impl Default for WidgetStyle {
104 + /// Modifiers only, no foreground: what survives a terminal with two
105 + /// colours.
106 + fn default() -> Self {
107 + Self {
108 + content: Style::new(),
109 + secondary: Style::new(),
110 + muted: Style::new().add_modifier(Modifier::DIM),
111 + info: Style::new(),
112 + success: Style::new(),
113 + warning: Style::new(),
114 + danger: Style::new().add_modifier(Modifier::BOLD),
115 + page: Style::new().add_modifier(Modifier::BOLD),
116 + section: Style::new().add_modifier(Modifier::BOLD),
117 + subsection: Style::new(),
118 + action: Style::new().add_modifier(Modifier::UNDERLINED),
119 + filled: Style::new().add_modifier(Modifier::REVERSED),
120 + sunken: Style::new().add_modifier(Modifier::DIM),
121 + focus: Modifier::REVERSED,
122 + meter_cells: 10,
123 + meter_full: '#',
124 + meter_empty: '-',
125 + required_marker: "*",
126 + }
127 + }
128 + }
129 +
130 + impl WidgetStyle {
131 + /// The house widgets, from a loaded theme.
132 + ///
133 + /// The lift this module exists for. `quasi-tui` carried every line of this
134 + /// as private methods on its own renderer; a second terminal app wanting a
135 + /// toned control had no way to reach them and would have picked its own
136 + /// colours for the same five tones.
137 + #[cfg(feature = "theme")]
138 + #[must_use]
139 + pub fn from_theme(theme: &crate::Theme) -> Self {
140 + Self {
141 + content: Style::new().fg(theme.content_primary),
142 + secondary: Style::new().fg(theme.content_secondary),
143 + muted: Style::new().fg(theme.content_muted),
144 + info: Style::new().fg(theme.status_info),
145 + success: Style::new().fg(theme.status_success),
146 + warning: Style::new().fg(theme.status_warning),
147 + danger: Style::new().fg(theme.status_danger),
148 + // Three depths and two of them are bold, which is the whole of what
149 + // a terminal has: there is no type scale in a grid of one cell
150 + // size. A page title takes bold and the accent, a section bold, a
151 + // subsection the secondary colour. That is the emphasis order a
152 + // webview's type scale says with size, said with the two axes a
153 + // cell has.
154 + page: Style::new()
155 + .fg(theme.action_primary)
156 + .add_modifier(Modifier::BOLD),
157 + section: Style::new()
158 + .fg(theme.content_primary)
159 + .add_modifier(Modifier::BOLD),
160 + subsection: Style::new().fg(theme.content_secondary),
161 + action: Style::new().fg(theme.action_primary),
162 + filled: Style::new()
163 + .fg(theme.selection_on)
164 + .bg(theme.action_primary),
165 + sunken: Style::new().bg(theme.surface_sunken),
166 + focus: Modifier::REVERSED,
167 + meter_cells: 10,
168 + meter_full: '#',
169 + meter_empty: '-',
170 + required_marker: "*",
171 + }
172 + }
173 +
174 + /// The style a tone reads as.
175 + ///
176 + /// [`Tone`] is closed and stays closed, so this is total and needs no
177 + /// fallback arm.
178 + #[must_use]
179 + pub const fn tone(&self, tone: Tone) -> Style {
180 + match tone {
181 + Tone::Neutral => self.content,
182 + Tone::Info => self.info,
183 + Tone::Success => self.success,
184 + Tone::Warning => self.warning,
185 + Tone::Danger => self.danger,
186 + }
187 + }
188 +
189 + /// The style a heading reads as.
190 + #[must_use]
191 + pub const fn heading(&self, level: Heading) -> Style {
192 + match level {
193 + Heading::Page => self.page,
194 + Heading::Section => self.section,
195 + Heading::Subsection => self.subsection,
196 + }
197 + }
198 +
199 + /// `style`, plus the mark that says the user is on this one.
200 + ///
201 + /// Takes the flag rather than being called behind an `if`, because every
202 + /// caller has a bool in hand and the branch is the part that gets forgotten.
203 + #[must_use]
204 + pub fn focused(&self, focused: bool, style: Style) -> Style {
205 + if focused {
206 + style.add_modifier(self.focus)
207 + } else {
208 + style
209 + }
210 + }
211 + }
212 +
213 + /// What a field currently holds, which a description never carries.
214 + ///
215 + /// The terminal counterpart of `makeover_immediate::Filling`, and the same seam:
216 + /// there the widget writes through a `&mut` as the value is edited, and here the
217 + /// caller keeps an edit buffer and lends it out for the draw. Neither is
218 + /// something [`Field`] could carry without becoming a form model.
219 + ///
220 + /// An enum rather than a bag of options, for `Filling`'s reason: a checkbox
221 + /// holding a string is unsayable here, where a struct would let it be said and
222 + /// then have to cope.
223 + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
224 + pub enum Held<'a> {
225 + /// Nothing typed and nothing chosen. The control draws empty.
226 + #[default]
227 + Absent,
228 + /// What is in the box, or the `value` of the chosen [`Choice`].
229 + ///
230 + /// [`Choice`]: makeover_layout::Choice
231 + Text(&'a str),
232 + /// A checkbox, on or off.
233 + On(bool),
234 + }
235 +
236 + impl<'a> Held<'a> {
237 + /// What is typed, as a string. A checkbox has no text and answers empty.
238 + #[must_use]
239 + pub const fn text(self) -> &'a str {
240 + match self {
241 + Self::Text(text) => text,
242 + Self::Absent | Self::On(_) => "",
243 + }
244 + }
245 +
246 + /// Whether a checkbox is ticked.
247 + #[must_use]
248 + pub const fn on(self) -> bool {
249 + matches!(self, Self::On(true))
250 + }
251 + }
252 +
253 + /// A proportion as one line: the bar, then the reading beside it.
254 + ///
255 + /// The reading is built here from the two numbers and the noun rather than
256 + /// taken assembled, which is what [`Meter::label`] carrying the noun alone is
257 + /// for: a terminal at one line and a tooltip want different sentence orders.
258 + #[must_use]
259 + pub fn meter(style: &WidgetStyle, meter: &Meter<'_>) -> Line<'static> {
260 + let cells = u32::from(style.meter_cells);
261 + let filled = meter
262 + .done
263 + .checked_mul(cells)
264 + .and_then(|reached| reached.checked_div(meter.total))
265 + .unwrap_or(0)
266 + .min(cells);
267 + let bar = format!(
268 + "{}{}",
269 + style.meter_full.to_string().repeat(filled as usize),
270 + style.meter_empty.to_string().repeat((cells - filled) as usize)
271 + );
272 + let reading = match meter.label {
273 + Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
274 + None => format!(" {}/{}", meter.done, meter.total),
275 + };
276 + Line::from(vec![
277 + Span::styled(bar, style.tone(meter.tone)),
278 + Span::styled(reading, style.muted),
279 + ])
280 + }
281 +
282 + /// A badge or a chip as one span.
283 + ///
284 + /// Round for a badge, square for a chip. A chip answers a press and a badge does
285 + /// not, and the bracket is the only affordance a cell has left once colour is
286 + /// spent on the tone.
287 + ///
288 + /// `latched` is a chip that is switched on, and it reads as reversed. So does
289 + /// focus, which is a collision a terminal cannot avoid: latched is "this filter
290 + /// is on" and focused is "you are here", and there is one spare axis for two
291 + /// facts. Said here rather than resolved by inventing a third look nobody would
292 + /// read.
293 + ///
294 + /// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
295 + /// second control inside one span, and a terminal reaches a control by focusing
296 + /// it; two targets in one cell run is a question for whoever owns the
297 + /// interaction, not for a drawing.
298 + #[must_use]
299 + pub fn token(
300 + style: &WidgetStyle,
301 + label: &str,
302 + kind: Token,
303 + tone: Tone,
304 + latched: bool,
305 + focused: bool,
306 + ) -> Span<'static> {
307 + let painted = style.tone(tone);
308 + let painted = if latched {
309 + painted.add_modifier(style.focus)
310 + } else {
311 + style.focused(focused, painted)
312 + };
313 + match kind {
314 + Token::Badge => Span::styled(format!("({label})"), painted),
315 + Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
316 + }
317 + }
318 +
319 + /// A control as one line.
320 + ///
321 + /// `< Label > (key)`, and the key only where the description named one. That
322 + /// member is the one place `makeover-layout` anticipated a terminal before there
323 + /// was one, and this is the renderer that reads it.
324 + ///
325 + /// A disabled control is drawn muted and is not marked focused, whatever the
326 + /// caller passed: it is present, visible and not answering, so a focus mark on
327 + /// it would be an affordance that lies. Whether it is reachable at all is the
328 + /// caller's count to keep — ask [`Act::disabled`].
329 + #[must_use]
330 + pub fn act(style: &WidgetStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
331 + let painted = if act.disabled() {
332 + style.muted
333 + } else {
334 + style.focused(focused, style.tone(act.tone))
335 + };
336 + let label = match act.key {
337 + Some(key) => format!("< {} > ({key})", act.label),
338 + None => format!("< {} >", act.label),
339 + };
340 + Line::from(Span::styled(label, painted))
341 + }
342 +
343 + /// A control filled with the action colour, for the one press a screen is about.
344 + ///
345 + /// `[ Label ]` rather than `< Label >`, which is the weight difference a webview
346 + /// carries as a primary-versus-secondary button. A form's submit is the case
347 + /// this exists for.
348 + #[must_use]
349 + pub fn filled_act(style: &WidgetStyle, label: &str, focused: bool) -> Line<'static> {
350 + Line::from(Span::styled(
351 + format!("[ {label} ]"),
352 + style.focused(focused, style.filled),
353 + ))
354 + }
355 +
356 + /// The rows [`figure`] wants at `width`.
357 + #[must_use]
358 + pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
359 + text::height(figure.value, width) + text::height(figure.caption, width)
360 + }
361 +
362 + /// A figure: the number, then what it counts under it.
363 + ///
364 + /// The tone lands on the value and its change rather than on the caption, which
365 + /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
366 + /// movement that reads as good or bad.
367 + pub fn figure(style: &WidgetStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
368 + let value = match figure.change {
369 + Some(change) => format!("{} {change}", figure.value),
370 + None => figure.value.to_owned(),
371 + };
372 + let used = text::draw(
373 + &value,
374 + style.tone(figure.tone).add_modifier(Modifier::BOLD),
375 + area,
376 + buf,
377 + );
378 + used + text::draw(figure.caption, style.muted, below(area, used), buf)
379 + }
380 +
381 + /// The rows [`field`] wants at `width`.
382 + ///
383 + /// A label row, the control's rows, and a row for whatever went wrong. A hidden
384 + /// field is nothing at all, which is the one field kind a terminal and a webview
385 + /// agree on completely.
386 + #[must_use]
387 + pub fn field_height(style: &WidgetStyle, field: &Field<'_>, width: u16) -> u16 {
388 + if !field.kind.visible() {
389 + return 0;
390 + }
391 + let label = text::height(&label_of(style, field), width);
392 + let body = match field.kind {
393 + FieldKind::Textarea => 3,
394 + kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
395 + _ => 1,
396 + };
397 + let note = note_of(field).map_or(0, |note| text::height(note, width));
398 + label + body + note
399 + }
400 +
401 + /// A question: its label, the box, and its standing help or what is wrong now.
402 + ///
403 + /// `held` is what the user has done to it since the screen arrived, which is the
404 + /// argument a description cannot supply. See [`Held`].
405 + ///
406 + /// `focused` marks the box rather than the label, because the box is where the
407 + /// typing lands.
408 + pub fn field(
409 + style: &WidgetStyle,
410 + field: &Field<'_>,
411 + held: Held<'_>,
412 + focused: bool,
413 + area: Rect,
414 + buf: &mut Buffer,
415 + ) -> u16 {
416 + // A hidden field is data travelling with the form. There is nothing to
417 + // draw, and whoever submits carries it.
418 + if !field.kind.visible() || area.width == 0 || area.height == 0 {
419 + return 0;
420 + }
421 +
422 + let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
423 +
424 + let well = style.focused(focused, style.content);
425 + let placeholder = field.placeholder.unwrap_or_default();
426 +
427 + used += match field.kind {
428 + FieldKind::Checkbox => text::draw(
429 + if held.on() { "[x]" } else { "[ ]" },
430 + well,
431 + below(area, used),
432 + buf,
433 + ),
434 + kind if kind.offers_options() => {
435 + let mut rows = 0;
436 + for choice in field.options {
437 + let chosen = held.text() == choice.value;
438 + let mark = if chosen { "(*)" } else { "( )" };
439 + rows += text::draw(
440 + &format!("{mark} {}", choice.label),
441 + if chosen { well } else { style.muted },
442 + below(area, used + rows),
443 + buf,
444 + );
445 + }
446 + rows
447 + }
448 + // A secret's dots come from the caller's buffer and can come from
449 + // nowhere else: a password that comes back down the wire is a password
450 + // in a page and in a proxy log, so a description carries nothing to dot
451 + // out. This is the one control that would be undrawable without `held`.
452 + FieldKind::Secret if !held.text().is_empty() => {
453 + let dots = "*".repeat(held.text().chars().count());
454 + text::draw(&dots, well, below(area, used), buf).max(1)
455 + }
456 + // A file field has no way back on a terminal any more than it has on an
457 + // HTTP host. The name is drawn and picking one belongs to whoever owns
458 + // the interaction.
459 + _ if held.text().is_empty() => empty_well(style, placeholder, well, focused, below(area, used), buf),
460 + _ => text::draw(held.text(), well, below(area, used), buf),
461 + };
462 +
463 + // The error wins over the hint, the same order a webview uses: a hint is
464 + // what to type and an error is what went wrong, and once something has gone
465 + // wrong that is the sentence worth the row.
466 + match note_of(field) {
467 + Some(note) => {
468 + let painted = if field.error.is_some() {
469 + style.danger
470 + } else {
471 + style.muted
472 + };
473 + used + text::draw(note, painted, below(area, used), buf)
474 + }
475 + None => used,
476 + }
477 + }
478 +
479 + /// The label, marked where the field is compulsory.
480 + fn label_of(style: &WidgetStyle, field: &Field<'_>) -> String {
481 + if field.required {
482 + format!("{} {}", field.label, style.required_marker)
483 + } else {
484 + field.label.to_owned()
485 + }
486 + }
487 +
488 + /// What goes under the box: what is wrong now, or the standing help.
489 + fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
490 + field.error.or(field.hint)
491 + }
492 +
493 + /// A box with nothing in it: the ghost text, and the caret when it has focus.
494 + ///
495 + /// The caret is not decoration. An empty field under a style is an empty field,
496 + /// so a focused one with no placeholder drew literally nothing and there was no
497 + /// way to tell the box was where the typing would go. A browser has a blinking
498 + /// bar for this and gets it without asking; a terminal has one cell of reversed
499 + /// video, put on the first column, which is where the first character lands.
500 + fn empty_well(
Lines truncated