|
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(
|