//! The pieces every terminal app draws, drawn once. //! //! # Called `widget` until 0.19.0 //! //! Renamed because `makeover-layout` 0.20.0 took the 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. //! //! One word for both would have made the tier unreadable in the crate that //! implements it. This half moved because the other half is the ecosystem-facing //! one: a second or third party naming a widget is naming the layout kind, and //! nothing outside this tree ever needed a word for a drawing routine. //! //! `WidgetStyle` went with it and is `PieceStyle`. //! //! Arrived in 0.16.0 out of `quasi-tui`, which had written all of them and was //! the second consumer to do so. 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, and every one of them had been hand-rolled at least twice in this //! tree before it was lifted. //! //! # 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, Field, FieldKind, Figure, Heading, Meter, Token, Tone}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use crate::text; /// 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), } 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) => text, Self::Absent | Self::On(_) => "", } } /// Whether a checkbox is ticked. #[must_use] pub const fn on(self) -> bool { matches!(self, Self::On(true)) } } /// 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)) } /// 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 { FieldKind::Textarea => 3, kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX), _ => 1, }; let note = note_of(field).map_or(0, |note| text::height(note, 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. 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) } 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, ); } rows } // A secret's dots come from the caller's buffer and can come from // nowhere else: a password that comes back down the wire is a password // in a page and in a proxy log, so a description carries nothing to dot // out. This is the one control that would be undrawable without `held`. FieldKind::Secret if !held.text().is_empty() => { let dots = "*".repeat(held.text().chars().count()); text::draw(&dots, well, below(area, used), buf).max(1) } // A file field has no way back on a terminal any more than it has on an // HTTP host. The name is drawn and picking one belongs to whoever owns // the interaction. _ if held.text().is_empty() => { empty_well(style, placeholder, well, focused, below(area, used), buf) } _ => text::draw(held.text(), well, below(area, used), buf), }; // The error wins over the hint, the same order a webview uses: a hint is // what to type and an error is what went wrong, and once something has gone // wrong that is the sentence worth the row. match note_of(field) { Some(note) => { let painted = if field.error.is_some() { style.danger } else { style.muted }; used + text::draw(note, painted, below(area, used), buf) } None => used, } } /// A bounded number as one line: the low end, the bar, the high end, then what /// it currently reads. /// /// The two ends are drawn because they are the question. A threshold of 0.72 /// says nothing without them, which is the whole argument for /// [`FieldKind::Range`] being a kind rather than a number with bounds, and a /// terminal is where it would be easiest to quietly drop them and show a figure. /// /// The bar is [`meter`]'s cells, so a range and a proportion read as the same /// object in the same app. What differs is the reading beside it: a meter counts /// something and a range holds a value. /// /// A value the host cannot read as a number empties the bar and is still shown /// as itself. That is [`empty_well`]'s position on an unreadable value: the app /// put it there, and a terminal that silently rounded it to a bound would be /// reporting a value nobody set. fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> { let cells = usize::from(style.meter_cells); let ends = field .min .zip(field.max) .and_then(|(min, max)| Some((min.parse::().ok()?, max.parse::().ok()?))); let filled = match (ends, value.parse::()) { (Some((min, max)), Ok(number)) if max > min => { #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "the proportion is clamped to 0..=1 before it is scaled by a cell count \ that came from a u16" )] let reached = (((number - min) / (max - min)).clamp(0.0, 1.0) * cells as f64) as usize; reached } _ => 0, }; let bar = format!( "{}{}", style.meter_full.to_string().repeat(filled), style.meter_empty.to_string().repeat(cells - filled) ); Line::from(vec![ Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted), Span::styled(bar, well), Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted), Span::styled(format!(" {value}"), well), ]) } /// The label, marked where the field is compulsory. fn label_of(style: &PieceStyle, field: &Field<'_>) -> String { if field.required { format!("{} {}", field.label, style.required_marker) } else { field.label.to_owned() } } /// What goes under the box: what is wrong now, or the standing help. fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> { field.error.or(field.hint) } /// A box with nothing in it: the ghost text, and the caret when it has focus. /// /// The caret is not decoration. An empty field under a style is an empty field, /// so a focused one with no placeholder drew literally nothing and there was no /// way to tell the box was where the typing would go. A browser has a blinking /// bar for this and gets it without asking; a terminal has one cell of reversed /// video, put on the first column, which is where the first character lands. fn empty_well( style: &PieceStyle, placeholder: &str, well: Style, focused: bool, area: Rect, buf: &mut Buffer, ) -> u16 { let used = text::draw(placeholder, style.muted, area, buf).max(1); if focused && area.height > 0 && area.width > 0 && let Some(cell) = buf.cell_mut((area.x, area.y)) { cell.set_style(well); } used } /// What is left of `area` after `used` rows from the top. fn below(area: Rect, used: u16) -> Rect { let used = used.min(area.height); Rect { x: area.x, y: area.y + used, width: area.width, height: area.height - used, } } #[cfg(test)] mod tests { use super::*; use makeover_layout::{Choice, State}; /// The style the drawings are read against: one distinguishable modifier /// per role, so a test can say which style landed without a colour. fn style() -> PieceStyle { PieceStyle { content: Style::new().add_modifier(Modifier::BOLD), secondary: Style::new().add_modifier(Modifier::ITALIC), muted: Style::new().add_modifier(Modifier::DIM), danger: Style::new().add_modifier(Modifier::CROSSED_OUT), ..PieceStyle::default() } } fn buffer(width: u16, height: u16) -> Buffer { Buffer::empty(Rect::new(0, 0, width, height)) } /// Everything in the buffer, one string per row. fn rows(buf: &Buffer) -> Vec { (0..buf.area.height) .map(|y| { (0..buf.area.width) .map(|x| { buf.cell((x, y)) .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' ')) }) .collect::() .trim_end() .to_owned() }) .collect() } #[test] fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() { let style = style(); let line = meter(&style, &Meter::new(3, 10).label("subtasks")); let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(drawn, "###------- 3/10 subtasks"); // The noun is optional and the ratio is not, because a bar with no // reading is a bar you cannot check. let bare = meter(&style, &Meter::new(3, 10)); let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(drawn, "###------- 3/10"); } #[test] fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() { // `Meter::total` of zero means there is no set, and the checked // division is what keeps that from being a panic in a draw. let line = meter(&style(), &Meter::new(0, 0)); let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(drawn, "---------- 0/0"); } #[test] fn an_over_run_fills_the_bar_and_still_reports_the_overflow() { // The clamp is for drawing only. The reading is what keeps the fact // `Meter::percent` destroys. let line = meter(&style(), &Meter::new(14, 10)); let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(drawn, "########## 14/10"); } #[test] fn a_badge_is_round_and_a_chip_is_square() { // The one affordance a cell has left once colour is spent on the tone, // and the whole of how a terminal says "this one answers a press". let style = style(); let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false); assert_eq!(badge.content.as_ref(), "(draft)"); let chip = token( &style, "rust", Token::Chip { removable: false }, Tone::Neutral, false, false, ); assert_eq!(chip.content.as_ref(), "[rust]"); } #[test] fn a_latched_chip_reads_the_same_as_a_focused_one() { // The collision a terminal cannot avoid, asserted rather than left to // be rediscovered: latched is "this filter is on" and focused is "you // are here", and there is one spare axis for two facts. let style = style(); let kind = Token::Chip { removable: false }; let latched = token(&style, "rust", kind, Tone::Neutral, true, false); let focused = token(&style, "rust", kind, Tone::Neutral, false, true); assert_eq!(latched.style, focused.style); assert!(latched.style.add_modifier.contains(Modifier::REVERSED)); } #[test] fn a_control_draws_its_key_only_where_one_was_named() { let style = style(); let line = act(&style, &Act::new("Delete"), false); assert_eq!(line.spans[0].content.as_ref(), "< Delete >"); let line = act(&style, &Act::new("Quit").key("q"), false); assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)"); } #[test] fn a_disabled_control_is_never_marked_focused() { // Present, visible, and not answering. A focus mark on it would be an // affordance that lies, so the flag is overridden rather than trusted. let style = style(); let disabled = Act::new("Save").state(State::Disabled); let line = act(&style, &disabled, true); assert!( !line.spans[0] .style .add_modifier .contains(Modifier::REVERSED) ); assert_eq!(line.spans[0].style, style.muted); // The same call on a control the description says nothing about: the // mark is this renderer's own focus flag and always was, which is why // only `Disabled` can override it. let unstated = Act::new("Save"); let line = act(&style, &unstated, true); assert!( line.spans[0] .style .add_modifier .contains(Modifier::REVERSED) ); } #[test] fn a_danger_control_keeps_its_tone_under_focus() { // Focus adds a modifier rather than repainting, so the fact that this // is the button that destroys something survives being landed on. let style = style(); let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true); assert_eq!( line.spans[0].style.add_modifier, style.danger.add_modifier | Modifier::REVERSED ); } #[test] fn a_figure_puts_the_number_over_what_it_counts() { let style = style(); let figure_ = Figure::new("42", "open tasks"); let mut buf = buffer(20, 4); let used = figure(&style, &figure_, buf.area, &mut buf); assert_eq!(used, 2); assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]); assert_eq!(figure_height(&figure_, 20), 2); } #[test] fn a_figures_change_rides_on_the_value_row() { // The delta is the toned part and the value is an ordinary fact, so the // two share a row rather than the caption growing a second sentence. let style = style(); let figure_ = Figure::new("42", "open tasks") .change("+3") .tone(Tone::Success); let mut buf = buffer(20, 4); figure(&style, &figure_, buf.area, &mut buf); assert_eq!(rows(&buf)[0], "42 +3"); } #[test] fn a_compulsory_field_says_so_in_its_label() { let style = style(); let mut field_ = Field::new(FieldKind::Text, "email", "Email"); field_.required = true; let mut buf = buffer(20, 4); field(&style, &field_, Held::Absent, false, buf.area, &mut buf); assert_eq!(rows(&buf)[0], "Email *"); } #[test] fn a_hidden_field_costs_no_rows_at_all() { // The one field kind a terminal and a webview agree on completely. let style = style(); let field_ = Field::new(FieldKind::Hidden, "csrf", "Token"); let mut buf = buffer(20, 4); assert_eq!( field( &style, &field_, Held::Text("abc"), false, buf.area, &mut buf ), 0 ); assert_eq!(field_height(&style, &field_, 20), 0); assert_eq!(rows(&buf)[0], ""); } #[test] fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() { // The one control that would be undrawable without `held`: a password // that came back down the wire is a password in a page and in a log. let style = style(); let field_ = Field::new(FieldKind::Secret, "password", "Password"); let mut buf = buffer(20, 4); field( &style, &field_, Held::Text("hunter2"), false, buf.area, &mut buf, ); assert_eq!(rows(&buf)[1], "*******"); } #[test] fn an_error_takes_the_row_the_hint_would_have_had() { // Once something has gone wrong that is the sentence worth the row, // which is the order a webview uses too. let style = style(); let mut field_ = Field::new(FieldKind::Text, "email", "Email"); field_.hint = Some("work address"); field_.error = Some("not an address"); let mut buf = buffer(20, 5); field( &style, &field_, Held::Text("nope"), false, buf.area, &mut buf, ); assert_eq!(rows(&buf)[2], "not an address"); assert_eq!(field_height(&style, &field_, 20), 3); } #[test] fn a_focused_empty_box_shows_where_the_typing_will_land() { // An empty field under a style is an empty field. Without the caret a // focused box with no placeholder drew literally nothing. let style = style(); let field_ = Field::new(FieldKind::Text, "email", "Email"); let mut buf = buffer(20, 4); field(&style, &field_, Held::Absent, true, buf.area, &mut buf); let caret = buf.cell((0, 1)).expect("the well's first cell").style(); assert!(caret.add_modifier.contains(Modifier::REVERSED)); } #[test] fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() { let style = style(); let mut field_ = Field::new(FieldKind::Radio, "size", "Size"); let options = [Choice::plain("small"), Choice::plain("large")]; field_.options = &options; let mut buf = buffer(20, 5); field( &style, &field_, Held::Text("large"), false, buf.area, &mut buf, ); assert_eq!(rows(&buf)[1], "( ) small"); assert_eq!(rows(&buf)[2], "(*) large"); assert_eq!(field_height(&style, &field_, 20), 3); } #[test] fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() { let style = style(); let field_ = Field::range("review", "Review above", "0", "1"); let mut buf = buffer(40, 3); field( &style, &field_, Held::Text("0.5"), false, buf.area, &mut buf, ); // Ten cells by default, half of them filled, with the extent read out // at either side: 0.5 means nothing without the 0 and the 1. assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5"); assert_eq!(field_height(&style, &field_, 40), 2); } #[test] fn a_range_holding_something_unreadable_still_shows_it() { // The app put the value there. A terminal that quietly rounded it to a // bound would be reporting a value nobody set, which is `empty_well`'s // position on the same problem. let style = style(); let field_ = Field::range("review", "Review above", "0", "1"); let mut buf = buffer(40, 3); field( &style, &field_, Held::Text("unset"), false, buf.area, &mut buf, ); assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset"); } #[test] fn an_unbounded_range_is_typed_into_rather_than_dragged() { // Bounds this crate invented are bounds the user would then drag // against. The text path takes every answer the bar would. let style = style(); let field_ = Field { max: Some("1"), ..Field::new(FieldKind::Range, "review", "Review above") }; let mut buf = buffer(40, 3); field( &style, &field_, Held::Text("0.5"), false, buf.area, &mut buf, ); assert_eq!(rows(&buf)[1].trim_end(), "0.5"); } #[test] fn an_unavailable_option_reads_as_inert_and_says_why() { // The one place muted is the truth rather than the lie the convention // warns about: this option will not answer, and the reason is on the // row rather than nowhere. let style = style(); let options = [ Choice::new("chromatic", "Chromatic"), Choice::new("multi", "Multi-sample").unless("Drop a second sample."), ]; let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode"); field_.options = &options; let mut buf = buffer(46, 4); field( &style, &field_, Held::Text("chromatic"), false, buf.area, &mut buf, ); assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic"); assert_eq!( rows(&buf)[2].trim_end(), "( ) Multi-sample: Drop a second sample." ); let muted = buf.cell((0, 2)).expect("the unavailable row").style(); assert!(muted.add_modifier.contains(Modifier::DIM)); } #[test] fn an_unchosen_option_does_not_read_as_disabled() { // The three-tone convention: muted is inert, and every option in this // list answers a press. Drawn muted, a five-option radio read as one // live row and four dead ones. let style = style(); let mut field_ = Field::new(FieldKind::Radio, "size", "Size"); let options = [Choice::plain("small"), Choice::plain("large")]; field_.options = &options; let mut buf = buffer(20, 5); field( &style, &field_, Held::Text("large"), false, buf.area, &mut buf, ); let unchosen = buf.cell((0, 1)).expect("the first option").style(); assert_eq!(unchosen.add_modifier, style.secondary.add_modifier); assert_ne!(unchosen.add_modifier, style.muted.add_modifier); } #[test] fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() { // `Held::On` exists so a host's own submission convention -- quasi // sends "value" -- stays the host's and never reaches a drawing. let style = style(); let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree"); let mut buf = buffer(20, 4); field(&style, &field_, Held::On(true), false, buf.area, &mut buf); assert_eq!(rows(&buf)[1], "[x]"); let mut buf = buffer(20, 4); field(&style, &field_, Held::On(false), false, buf.area, &mut buf); assert_eq!(rows(&buf)[1], "[ ]"); } #[test] fn a_tone_and_a_heading_map_without_a_fallback_arm() { // Both source enums are closed, which is what lets these be total. A // renderer that had to guess would be picking its own colours again. let style = style(); assert_eq!(style.tone(Tone::Neutral), style.content); assert_eq!(style.tone(Tone::Danger), style.danger); assert_eq!(style.heading(Heading::Page), style.page); assert_eq!(style.heading(Heading::Subsection), style.subsection); } #[test] fn the_default_style_carries_no_colour_at_all() { // A two-colour terminal is the case where a foreground will not land, // so the default is modifiers only rather than a placeholder palette. let style = PieceStyle::default(); for painted in [style.content, style.danger, style.page, style.action] { assert_eq!(painted.fg, None); assert_eq!(painted.bg, None); } } }