//! The pieces every terminal app draws, drawn once. //! //! # Not `widget` //! //! `makeover-layout` owns that word for something else, and the two meanings do //! not sit together. A `Region::Widget` there is host-agnostic: a named //! assembly of primitives that every renderer draws its own way. What is in //! this module is the opposite end, renderer-local, the answer to *what a meter //! looks like in cells*, taking a description plus what only a terminal knows. //! The style type is `PieceStyle`. //! //! A meter, a badge, a control, a figure and a form field are what a screen is //! made of below the level [`table`](crate::table) works at. [`activity`] and //! [`awaiting`] draw a wait, out of wiki `loading-and-progress-standard`. //! //! # What these take, and what they leave alone //! //! Each takes a `makeover-layout` description, a [`PieceStyle`], and whatever //! the *host* knows that a description never carries. That last part is the //! shape worth copying: [`field`] takes what is currently typed in the box as a //! separate argument, because [`Field`] deliberately does not carry a value and //! is not going to. `makeover-immediate` reached the same seam from the other //! side with its `Filling`, and [`Held`] is that seam here. //! //! Focus is the other one. Nothing in a description says which control the user //! is on, so every drawing here takes `focused` as an argument and the caller //! is what counts. What focus *looks like* is this crate's answer and not the //! caller's, which is the point of it being here: see //! [`PieceStyle::focused`]. //! //! # What they do not do //! //! No layout. Each answers rows for a width, or draws into the rect it is //! given, top-aligned, and never below it. Nothing here measures twice and //! nothing here places anything relative to anything else, because the moment //! it did it would be a layout engine with one consumer's flow baked into it. use makeover_layout::{ Act, Awaiting, Bar, Chart, Field, FieldKind, Figure, Heading, Meter, ThemeVariant, Token, Tone, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use crate::text; use std::time::Duration; /// The colours and marks the drawings below use. /// /// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an /// ungated struct of styles with a [`Default`], plus a /// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded /// theme should reach for first. A consumer painting bevels and nothing else /// should not have to supply text tones it never uses, and gating the whole /// module on `theme` would make these unreachable to anyone hand-picking /// colours. /// /// The default is the one that survives a terminal with no colour at all: /// modifiers only, no foreground anywhere. That is not a placeholder. A /// two-colour terminal is the case where a `Style` carrying a foreground is a /// foreground that will not land, and bold-and-reversed is what is left. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PieceStyle { /// Ordinary content, and what [`Tone::Neutral`] reads as. pub content: Style, /// Content one step back: a field's label, a quoted run. pub secondary: Style, /// Content two steps back: a caption, a hint, a meter's reading. pub muted: Style, /// Something worth knowing and nothing to do about it. pub info: Style, /// Something finished and it worked. pub success: Style, /// Something the user should look at. pub warning: Style, /// Something broken, or about to be destroyed. pub danger: Style, /// A page title. pub page: Style, /// A section title. pub section: Style, /// A subsection title. pub subsection: Style, /// Text that goes somewhere, and a control's label. pub action: Style, /// A control filled with the action colour, for the one on a screen that is /// the thing to press. A form's submit is the case that has it. pub filled: Style, /// A surface set back from the one it sits on, by colour and nothing else. /// What a code run takes, since every cell is monospace and the thing a /// webview says with a typeface cannot be said that way here. pub sunken: Style, /// What "you are on this one" adds to whatever it lands on. /// /// Reversed video by default, which is the affordance a cell has left once /// colour is spent on tone and bold on weight. A webview says it with an /// outline; a terminal has no outline that is not four more cells. pub focus: Modifier, /// How many cells [`meter`] spends on its bar. pub meter_cells: u16, /// The filled part of a bar. pub meter_full: char, /// The empty part of a bar. pub meter_empty: char, /// What marks a compulsory field, appended to its label. /// /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy* /// here, and copy is not a renderer's call. pub required_marker: &'static str, } impl Default for PieceStyle { /// Modifiers only, no foreground: what survives a terminal with two /// colours. fn default() -> Self { Self { content: Style::new(), secondary: Style::new(), muted: Style::new().add_modifier(Modifier::DIM), info: Style::new(), success: Style::new(), warning: Style::new(), danger: Style::new().add_modifier(Modifier::BOLD), page: Style::new().add_modifier(Modifier::BOLD), section: Style::new().add_modifier(Modifier::BOLD), subsection: Style::new(), action: Style::new().add_modifier(Modifier::UNDERLINED), filled: Style::new().add_modifier(Modifier::REVERSED), sunken: Style::new().add_modifier(Modifier::DIM), focus: Modifier::REVERSED, meter_cells: 10, meter_full: '#', meter_empty: '-', required_marker: "*", } } } impl PieceStyle { /// The house widgets, from a loaded theme. /// /// The lift this module exists for. `quasi-tui` carried every line of this /// as private methods on its own renderer; a second terminal app wanting a /// toned control had no way to reach them and would have picked its own /// colours for the same five tones. #[cfg(feature = "theme")] #[must_use] pub fn from_theme(theme: &crate::Theme) -> Self { Self { content: Style::new().fg(theme.content_primary), secondary: Style::new().fg(theme.content_secondary), muted: Style::new().fg(theme.content_muted), info: Style::new().fg(theme.status_info), success: Style::new().fg(theme.status_success), warning: Style::new().fg(theme.status_warning), danger: Style::new().fg(theme.status_danger), // Three depths and two of them are bold, which is the whole of what // a terminal has: there is no type scale in a grid of one cell // size. A page title takes bold and the accent, a section bold, a // subsection the secondary colour. That is the emphasis order a // webview's type scale says with size, said with the two axes a // cell has. page: Style::new() .fg(theme.action_primary) .add_modifier(Modifier::BOLD), section: Style::new() .fg(theme.content_primary) .add_modifier(Modifier::BOLD), subsection: Style::new().fg(theme.content_secondary), action: Style::new().fg(theme.action_primary), filled: Style::new().fg(theme.selection_on).bg(theme.action_primary), sunken: Style::new().bg(theme.surface_sunken), focus: Modifier::REVERSED, meter_cells: 10, meter_full: '#', meter_empty: '-', required_marker: "*", } } /// The style a tone reads as. /// /// [`Tone`] is closed and stays closed, so this is total and needs no /// fallback arm. #[must_use] pub const fn tone(&self, tone: Tone) -> Style { match tone { Tone::Neutral => self.content, Tone::Info => self.info, Tone::Success => self.success, Tone::Warning => self.warning, Tone::Danger => self.danger, } } /// The style a heading reads as. #[must_use] pub const fn heading(&self, level: Heading) -> Style { match level { Heading::Page => self.page, Heading::Section => self.section, Heading::Subsection => self.subsection, } } /// `style`, plus the mark that says the user is on this one. /// /// Takes the flag rather than being called behind an `if`, because every /// caller has a bool in hand and the branch is the part that gets forgotten. #[must_use] pub fn focused(&self, focused: bool, style: Style) -> Style { if focused { style.add_modifier(self.focus) } else { style } } } /// What a field currently holds, which a description never carries. /// /// The terminal counterpart of `makeover_immediate::Filling`, and the same seam: /// there the widget writes through a `&mut` as the value is edited, and here the /// caller keeps an edit buffer and lends it out for the draw. Neither is /// something [`Field`] could carry without becoming a form model. /// /// An enum rather than a bag of options, for `Filling`'s reason: a checkbox /// holding a string is unsayable here, where a struct would let it be said and /// then have to cope. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum Held<'a> { /// Nothing typed and nothing chosen. The control draws empty. #[default] Absent, /// What is in the box, or the `value` of the chosen [`Choice`]. /// /// [`Choice`]: makeover_layout::Choice Text(&'a str), /// A checkbox, on or off. On(bool), /// Both ends of a [`FieldKind::Interval`], lower first. /// /// Two values rather than one string with a separator, which is /// [`makeover_layout::Field::upper_name`]'s reason one level down: an /// interval is submitted under two names, so it is held as two values, and /// a delimiter this crate owned could appear inside either of them. /// /// Either end may be empty while the other stands. An open end is an /// answer -- "over 120 BPM" -- rather than a half-filled box. Between { /// What the lower box holds now. lower: &'a str, /// What the upper box holds now. upper: &'a str, }, } impl<'a> Held<'a> { /// What is typed, as a string. A checkbox has no text and answers empty. #[must_use] pub const fn text(self) -> &'a str { match self { Self::Text(text) | Self::Between { lower: text, .. } => text, Self::Absent | Self::On(_) => "", } } /// The upper end, for the one variant that has one. #[must_use] pub const fn upper(self) -> &'a str { match self { Self::Between { upper, .. } => upper, Self::Absent | Self::Text(_) | Self::On(_) => "", } } /// Whether a checkbox is ticked. #[must_use] pub const fn on(self) -> bool { matches!(self, Self::On(true)) } } /// What a host can see about a wait that is running. /// /// Neither half is derivable from a description, which is why both are here and /// not on [`Awaiting`]. That type says how big the payload is; how much of it /// has landed is a fact about a transfer in flight, and only whoever is running /// the transfer knows it. /// /// The same shape `makeover-immediate` carries, deliberately: a wait is one /// reading on every surface and the two renderers should not disagree about /// what a host owes them. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Progress { /// How much has arrived, in whatever unit the description counted. pub delivered: Option, /// How long the wait has lasted so far. /// /// The one time value a wait may show. See [`awaiting`] for the three it /// may not. pub elapsed: Option, } /// The activity mark: one cell, lit or dark. /// /// Rule 2 of wiki `loading-and-progress-standard`, and the surface the metaphor /// came from. A hard-disk light is one cell that blinks, and a terminal draws /// that with no metaphor in the way — where a webview needs a keyframe and egui /// needs a repaint schedule, this is a character. /// /// The two glyphs are [`PieceStyle::meter_full`] and /// [`PieceStyle::meter_empty`], not a third pair. A bar's filled cell and a lit /// mark are the same statement in the same alphabet, and a terminal that had to /// render two vocabularies of "on" would be saying there are two kinds of on. /// /// **Dark, not absent.** A mark that is drawn half the time is a hole in the /// line, and the line reflows around it or the reader loses where to look. It /// occupies its cell either way. /// /// `lit` is the caller's: this module holds no clock. [`crate::activity_lit`] /// is the one place the phase is worked out from the cadence, so a caller /// should reach for that rather than dividing by 500 itself. #[must_use] pub fn activity(style: &PieceStyle, lit: bool) -> Span<'static> { if lit { Span::styled(style.meter_full.to_string(), style.action) } else { Span::styled(style.meter_empty.to_string(), style.muted) } } /// A wait as one line, drawn from what is actually known about it. /// /// [`Awaiting::is_determinate`] is the first branch and there is a second the /// description cannot answer: whether anything is watching the transfer. A bar /// wants a total and a numerator both, so a described amount with no /// [`Progress::delivered`] beside it draws the mark and the size it is waiting /// on, rather than an empty trough implying somebody is counting. /// /// So three drawings for three states, which is the point: /// /// ```text /// unmeasured # a blinking cell /// measured, nothing watching # 41943040 the cell, and how much there is /// measured and observed ####------ 17825792/41943040 4s /// ``` /// /// **What the bar may not do**, from rule 1 of the standard and from /// [`Awaiting`]'s own docs: what is done over what there is, plus the time it /// has taken. Never a remaining time, an arrival time, or a rate extrapolated /// forward. A prediction is wrong the moment the transfer stalls, and being /// confidently wrong is worse than being honestly indeterminate. /// /// The numbers are raw. The unit is the app's — bytes for an upload, rows for /// an import — and a renderer that formatted one as a file size would be /// dressing up a quantity it was deliberately not told about. #[must_use] pub fn awaiting( style: &PieceStyle, awaiting: Awaiting, progress: Progress, lit: bool, ) -> Line<'static> { let Some(total) = awaiting.amount else { return Line::from(vec![activity(style, lit)]); }; let Some(done) = progress.delivered else { return Line::from(vec![ activity(style, lit), Span::styled(format!(" {total}"), style.muted), ]); }; let cells = u32::from(style.meter_cells); // In cells rather than in floating point, the way `meter` does it: a // terminal's bar has ten states and rounding through an f64 to reach one of // ten is arithmetic nobody needs. Saturating rather than wrapping, because // a transfer that over-delivers is a real case and a panicking bar is not // the way to report it. let filled = u32::try_from( done.saturating_mul(u64::from(cells)) .checked_div(total) .unwrap_or(0), ) .unwrap_or(cells) .min(cells); let bar = format!( "{}{}", style.meter_full.to_string().repeat(filled as usize), style .meter_empty .to_string() .repeat((cells - filled) as usize) ); let reading = match progress.elapsed { Some(elapsed) => format!(" {done}/{total} {}s", elapsed.as_secs()), None => format!(" {done}/{total}"), }; Line::from(vec![ Span::styled(bar, style.action), Span::styled(reading, style.muted), ]) } /// A proportion as one line: the bar, then the reading beside it. /// /// The reading is built here from the two numbers and the noun rather than /// taken assembled, which is what [`Meter::label`] carrying the noun alone is /// for: a terminal at one line and a tooltip want different sentence orders. #[must_use] pub fn meter(style: &PieceStyle, meter: &Meter<'_>) -> Line<'static> { let cells = u32::from(style.meter_cells); let filled = meter .done .checked_mul(cells) .and_then(|reached| reached.checked_div(meter.total)) .unwrap_or(0) .min(cells); let bar = format!( "{}{}", style.meter_full.to_string().repeat(filled as usize), style .meter_empty .to_string() .repeat((cells - filled) as usize) ); let reading = match meter.label { Some(label) => format!(" {}/{} {label}", meter.done, meter.total), None => format!(" {}/{}", meter.done, meter.total), }; Line::from(vec![ Span::styled(bar, style.tone(meter.tone)), Span::styled(reading, style.muted), ]) } /// A badge or a chip as one span. /// /// Round for a badge, square for a chip. A chip answers a press and a badge does /// not, and the bracket is the only affordance a cell has left once colour is /// spent on the tone. /// /// `latched` is a chip that is switched on, and it reads as reversed. So does /// focus, which is a collision a terminal cannot avoid: latched is "this filter /// is on" and focused is "you are here", and there is one spare axis for two /// facts. Said here rather than resolved by inventing a third look nobody would /// read. /// /// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a /// second control inside one span, and a terminal reaches a control by focusing /// it; two targets in one cell run is a question for whoever owns the /// interaction, not for a drawing. #[must_use] pub fn token( style: &PieceStyle, label: &str, kind: Token, tone: Tone, latched: bool, focused: bool, ) -> Span<'static> { let painted = style.tone(tone); let painted = if latched { painted.add_modifier(style.focus) } else { style.focused(focused, painted) }; match kind { Token::Badge => Span::styled(format!("({label})"), painted), Token::Chip { .. } => Span::styled(format!("[{label}]"), painted), } } /// A control as one line. /// /// `< Label > (key)`, and the key only where the description named one. That /// member is the one place `makeover-layout` anticipated a terminal before there /// was one, and this is the renderer that reads it. /// /// A disabled control is drawn muted and is not marked focused, whatever the /// caller passed: it is present, visible and not answering, so a focus mark on /// it would be an affordance that lies. Whether it is reachable at all is the /// caller's count to keep — ask [`Act::disabled`]. #[must_use] pub fn act(style: &PieceStyle, act: &Act<'_>, focused: bool) -> Line<'static> { let painted = if act.disabled() { style.muted } else { style.focused(focused, style.tone(act.tone)) }; let label = match act.key { Some(key) => format!("< {} > ({key})", act.label), None => format!("< {} >", act.label), }; Line::from(Span::styled(label, painted)) } /// The muted line a control's [`Act::hint`] draws as, or `None` where it has /// none. /// /// A terminal has no pointer, so the hover the other two renderers spend a hint /// on is not available and is not the thing anyway: what the description says /// is that the sentence is true, never that it is hidden. A row under the /// control is this renderer's answer, and it is the same muted row /// [`field`] gives a field's note, so the two read alike wherever they land. /// /// Its own function rather than extra lines out of [`act`], because a control /// is one [`Line`] everywhere it is drawn and a caller laying out a run needs /// to know it is placing two things. #[must_use] pub fn act_note(style: &PieceStyle, act: &Act<'_>) -> Option> { act.hint .map(|hint| Line::from(Span::styled(hint.to_owned(), style.muted))) } /// A control filled with the action colour, for the one press a screen is about. /// /// `[ Label ]` rather than `< Label >`, which is the weight difference a webview /// carries as a primary-versus-secondary button. A form's submit is the case /// this exists for. #[must_use] pub fn filled_act(style: &PieceStyle, label: &str, focused: bool) -> Line<'static> { Line::from(Span::styled( format!("[ {label} ]"), style.focused(focused, style.filled), )) } /// The rows [`figure`] wants at `width`. #[must_use] pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 { text::height(figure.value, width) + text::height(figure.caption, width) } /// A figure: the number, then what it counts under it. /// /// The tone lands on the value and its change rather than on the caption, which /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the /// movement that reads as good or bad. pub fn figure(style: &PieceStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 { let value = match figure.change { Some(change) => format!("{} {change}", figure.value), None => figure.value.to_owned(), }; let used = text::draw( &value, style.tone(figure.tone).add_modifier(Modifier::BOLD), area, buf, ); used + text::draw(figure.caption, style.muted, below(area, used), buf) } /// The rows [`field`] wants at `width`. /// /// A label row, the control's rows, and a row for whatever went wrong. A hidden /// field is nothing at all, which is the one field kind a terminal and a webview /// agree on completely. #[must_use] pub fn field_height(style: &PieceStyle, field: &Field<'_>, width: u16) -> u16 { if !field.kind.visible() { return 0; } let label = text::height(&label_of(style, field), width); // A range is one row like every other single control: the bar, its two ends // and the reading are one line by construction, and a bar that wrapped // would stop being a bar. let body = match field.kind { // Both multi-line kinds get the same three rows, keyed on the // description's own `multiline` rather than on the member: a markdown // field falling through to the single-row arm is one line for a value // whose whole point is that it has several. What a terminal does *with* // the markdown is another question and the answer here is nothing -- // the source is the text, and drawing it as text is honest. kind if kind.multiline() => 3, kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX), // A row per theme, a row per group heading, and a row for the follow // entry when there is one. The headings are counted by walking the // variants rather than by assuming three, because a machine with only // dark themes installed draws one heading and reserving three would // leave two blank rows under every picker. kind if kind.offers_themes() => { let mut variants = 0u16; let mut open: Option = None; for theme in field.themes { if open != Some(theme.variant) { variants = variants.saturating_add(1); open = Some(theme.variant); } } let rows = u16::try_from(field.themes.len()).unwrap_or(u16::MAX); rows.saturating_add(variants) .saturating_add(u16::from(field.follows.is_some())) } _ => 1, }; let note = message_of(style, field).map_or(0, |(text, _)| text::height(text, width)); label + body + note } /// A question: its label, the box, and its standing help or what is wrong now. /// /// `held` is what the user has done to it since the screen arrived, which is the /// argument a description cannot supply. See [`Held`]. /// /// `focused` marks the box rather than the label, because the box is where the /// typing lands. /// /// [`makeover_layout::Field::as_instant`] is carried and not honoured. It asks /// for a wall-clock value to be submitted as the moment it names, and this /// renderer has no submission: it draws the box and the runtime above it /// gathers what a submit sends, so the conversion belongs where that gathering /// happens. The value drawn and read here is the local one, in /// `makeover_layout::DATETIME_FORMAT`. pub fn field( style: &PieceStyle, field: &Field<'_>, held: Held<'_>, focused: bool, area: Rect, buf: &mut Buffer, ) -> u16 { // A hidden field is data travelling with the form. There is nothing to // draw, and whoever submits carries it. if !field.kind.visible() || area.width == 0 || area.height == 0 { return 0; } let mut used = text::draw(&label_of(style, field), style.secondary, area, buf); let well = style.focused(focused, style.content); let placeholder = field.placeholder.unwrap_or_default(); used += match field.kind { FieldKind::Checkbox => text::draw( if held.on() { "[x]" } else { "[ ]" }, well, below(area, used), buf, ), // A range's two ends are what the question means, so they are drawn // rather than left to a hint. A terminal has the bar already: this is // `meter`'s cells with the extent read out at either side of them. // // An unbounded range has no extent to draw and falls through to the // text path, which is `makeover-immediate`'s answer as well and for the // same reason: bounds this crate invented are bounds the user would // then drag against. FieldKind::Range if field.bounded() => { let line = range_line(style, field, held.text(), well); text::draw_line(&line, below(area, used), buf) } // One question, so one line. The two ends read left to right with the // word between them, which is what a terminal has instead of two boxes // side by side: a second row would read as a second question, and that // is the reading the kind exists to prevent. FieldKind::Interval => { let line = interval_line(style, field, held, well); text::draw_line(&line, below(area, used), buf) } // The grouping comes out of the order, not out of a group list: // `Field::themes` arrives sorted by variant, so the run of one variant // is the group and a heading opens whenever the variant changes. Same // walk the other two renderers do, which is what keeps three renderers // from disagreeing about where a group starts. // // Drawn as the radio group above rather than as a closed control, // because a terminal has no closed control: the list is already on // screen and always was, so the group headings cost a row each and buy // the structure the description finally carries. kind if kind.offers_themes() => { let mut rows = 0; if let Some(follow) = field.follows { // First, and under no heading. It names no theme and sits in no // variant, so a heading over it would be inventing a fourth // variant for one row. let chosen = held.text() == follow.value; let (mark, painted) = if chosen { ("(*)", well) } else { ("( )", style.secondary) }; rows += text::draw( &format!("{mark} {}", follow.label), painted, below(area, used + rows), buf, ); } let mut open: Option = None; for theme in field.themes { if open != Some(theme.variant) { // Muted, which is the one place it is the truth rather than // the lie: a heading will not answer, exactly as an // unavailable option will not. rows += text::draw( theme.variant.heading(), style.muted, below(area, used + rows), buf, ); open = Some(theme.variant); } let chosen = held.text() == theme.id; let (mark, painted) = if chosen { ("(*)", well) } else { ("( )", style.secondary) }; rows += text::draw( &format!("{mark} {} [{}]", theme.name, theme.contrast.badge()), painted, below(area, used + rows), buf, ); } rows } kind if kind.offers_options() => { let mut rows = 0; for choice in field.options { let chosen = held.text() == choice.value; // An option that cannot be picked yet reads as inert, which is // the one place muted is the truth rather than the lie below: // it will not answer, and the reason it will not is on the row // beside it rather than nowhere. let (mark, painted, suffix) = match choice.unavailable { Some(reason) => ("( )", style.muted, format!(": {reason}")), None if chosen => ("(*)", well, String::new()), // An option that is not chosen is still an option: pressing // it chooses it. So it takes the secondary content intent // and not the muted one, which is what disabled looks like // (`State::Disabled` resolves to it). Muted here read as a // list of five where four were greyed out. None => ("( )", style.secondary, String::new()), }; rows += text::draw( &format!("{mark} {}{suffix}", choice.label), painted, below(area, used + rows), buf, ); // What picking it means, on a row of its own under the option. // makeover-layout 0.39.0, and this is the host with the most // room of the three: a browser's `