Skip to main content

datui_lib/widgets/ui/
form_row.rs

1//! `label  value` on one line inside a Surface. The focused row carries the
2//! rail and its label in the accent; the chosen value is always echoed, so
3//! nothing is ambiguous when focus is elsewhere.
4
5use crate::pointer::{FieldId, Hit};
6use crate::render::context::RenderContext;
7use crate::widgets::text_input::TextInput;
8use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Paragraph, Widget};
13
14/// What sits after the label.
15pub enum FormValue<'a> {
16    /// A text field. The caller sets the input's focus before rendering; the
17    /// input draws its own cursor.
18    Input(&'a TextInput),
19    /// A checkbox.
20    Toggle(bool),
21    /// A pick-one value, cycled or chosen through a Picker; the row echoes the
22    /// current choice.
23    Choice(&'a str),
24    /// A choice not yet made: the row says so quietly instead of sitting blank.
25    Placeholder(&'a str),
26    /// A short Choice's values side by side, the chosen one tinted, so ←/→ move
27    /// along what is drawn. When they do not fit, the chosen one alone between
28    /// step marks: `‹ TSV ›`. With `clicks`, each value records a click that
29    /// steps the field to it; record the row's field before rendering, so the
30    /// values lie on top of it.
31    Options {
32        items: &'a [&'a str],
33        selected: usize,
34        clicks: Option<FieldId>,
35    },
36}
37
38pub struct FormRow<'a> {
39    pub label: &'a str,
40    pub value: FormValue<'a>,
41    pub focused: bool,
42    /// Where the value column starts, past the rail gutter, shared by every
43    /// row so values align.
44    pub label_width: u16,
45}
46
47impl FormRow<'_> {
48    pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
49        self.render_picking(area, buf, ctx, false);
50    }
51
52    /// Draw the row, its picker open when `picking`: the picker's current line
53    /// has the keys and the one rail, so the row keeps its accent label and
54    /// gives up its rail.
55    pub fn render_picking(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext, picking: bool) {
56        if area.height == 0 || area.width == 0 {
57            return;
58        }
59        // The rail gutter is always there, so focus arriving moves nothing —
60        // Tab walks the rail down the rows, which is what says the rows are
61        // walkable. Same mark as the table's current row and the Picker's
62        // selection: one focus signal everywhere.
63        let g = crate::glyphs::get();
64        let rail = if self.focused && !picking {
65            g.rail
66        } else {
67            " "
68        };
69        Paragraph::new(rail)
70            .style(Style::default().fg(ctx.accent))
71            .render(Rect { width: 1, ..area }, buf);
72        let area = Rect {
73            x: area.x + 1,
74            width: area.width - 1,
75            ..area
76        };
77        if area.width == 0 {
78            return;
79        }
80
81        let label_style = if self.focused {
82            Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
83        } else {
84            Style::default().fg(ctx.label)
85        };
86        let label_w = self.label_width.min(area.width);
87        // Cells of air between the label and the value column.
88        let air = label_w.saturating_sub(crate::glyphs::display_width(self.label) as u16);
89        Paragraph::new(self.label).style(label_style).render(
90            Rect {
91                width: label_w,
92                ..area
93            },
94            buf,
95        );
96
97        let value_area = Rect {
98            x: area.x + label_w,
99            width: area.width.saturating_sub(label_w),
100            ..area
101        };
102        if value_area.width == 0 {
103            return;
104        }
105        match &self.value {
106            FormValue::Input(input) => (*input).render(value_area, buf),
107            FormValue::Toggle(on) => {
108                let marker = if *on { g.checkbox_on } else { g.checkbox_off };
109                Paragraph::new(marker)
110                    .style(Style::default().fg(ctx.text_primary))
111                    .render(value_area, buf);
112            }
113            FormValue::Choice(value) => {
114                Paragraph::new(*value)
115                    .style(Style::default().fg(ctx.text_primary))
116                    .render(value_area, buf);
117            }
118            FormValue::Placeholder(value) => {
119                Paragraph::new(*value)
120                    .style(Style::default().fg(ctx.dimmed))
121                    .render(value_area, buf);
122            }
123            FormValue::Options {
124                items,
125                selected,
126                clicks,
127            } => OptionsRow {
128                items,
129                selected: *selected,
130                clicks: clicks.as_ref(),
131                focused: self.focused,
132                air,
133            }
134            .render(value_area, buf, ctx),
135        }
136    }
137}
138
139/// A choice's values on one row, or the chosen one alone where they do not fit.
140struct OptionsRow<'a> {
141    items: &'a [&'a str],
142    selected: usize,
143    clicks: Option<&'a FieldId>,
144    focused: bool,
145    /// Cells of air between the label and the value column, which the row may
146    /// draw into so its chosen text lines up with the other rows' values.
147    air: u16,
148}
149
150impl OptionsRow<'_> {
151    fn render(&self, value_area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
152        // The chosen one keeps its tint wherever focus is, so the choice reads
153        // from anywhere in the form; the others recede when focus is elsewhere.
154        let chosen = Style::default()
155            .fg(ctx.text_primary)
156            .add_modifier(Modifier::BOLD)
157            .patch(ctx.highlight_style());
158        let other = Style::default().fg(if self.focused {
159            ctx.text_secondary
160        } else {
161            ctx.dimmed
162        });
163        let hit = |index: usize, current: usize| {
164            self.clicks.map(|field| Hit::Option {
165                field: Some(field.clone()),
166                index,
167                current,
168            })
169        };
170        // Each value padded a cell each side, for the tint; drawn a cell into the
171        // air, the first value's text sits in the value column. Compact, the step
172        // mark takes a second cell.
173        let full: usize = self
174            .items
175            .iter()
176            .map(|item| crate::glyphs::display_width(item) + 2)
177            .sum();
178        let compact = full > usize::from(value_area.width + self.air.min(1));
179        let lead = self.air.min(if compact { 2 } else { 1 });
180        let mut spans = Vec::new();
181        let mut hits = Vec::new();
182        if !compact {
183            for (i, item) in self.items.iter().enumerate() {
184                if let Some(hit) = hit(i, self.selected) {
185                    hits.push((spans.len(), hit));
186                }
187                let style = if i == self.selected { chosen } else { other };
188                spans.push(Span::styled(format!(" {item} "), style));
189            }
190        } else {
191            // Too narrow: the chosen value alone between step marks, which a
192            // click steps by one as ←/→ do; the name itself takes Space.
193            let g = crate::glyphs::get();
194            let name = self.items.get(self.selected).copied().unwrap_or("");
195            let mark = Style::default().fg(ctx.text_secondary);
196            spans.push(Span::styled(g.choice_prev, mark));
197            spans.push(Span::styled(format!(" {name} "), chosen));
198            spans.push(Span::styled(g.choice_next, mark));
199            if let (Some(back), Some(on)) = (hit(0, 1), hit(1, 0)) {
200                hits.push((0, back));
201                hits.push((2, on));
202            }
203        }
204        let area = Rect {
205            x: value_area.x - lead,
206            width: value_area.width + lead,
207            ..value_area
208        };
209        let line = Line::from(spans);
210        crate::pointer::record_spans(area, &line, hits);
211        Paragraph::new(line).render(area, buf);
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn render_row(row: &FormRow, width: u16) -> (String, Buffer) {
220        let ctx = RenderContext::for_test();
221        let area = Rect::new(0, 0, width, 1);
222        let mut buf = Buffer::empty(area);
223        row.render(area, &mut buf, &ctx);
224        let text = (0..width)
225            .map(|x| buf[(x, 0)].symbol().to_string())
226            .collect();
227        (text, buf)
228    }
229
230    #[test]
231    fn the_value_is_echoed_at_the_shared_column() {
232        let row = FormRow {
233            label: "Compression:",
234            value: FormValue::Choice("Gzip"),
235            focused: false,
236            label_width: 17,
237        };
238        let (text, _) = render_row(&row, 40);
239        // One rail-gutter column, then the label, then the value column.
240        assert_eq!(text.find("Gzip"), Some(18), "got {text:?}");
241    }
242
243    #[test]
244    fn a_toggle_draws_the_checkbox_glyph() {
245        let g = crate::glyphs::get();
246        for (on, marker) in [(true, g.checkbox_on), (false, g.checkbox_off)] {
247            let row = FormRow {
248                label: "Include header:",
249                value: FormValue::Toggle(on),
250                focused: false,
251                label_width: 17,
252            };
253            let (text, _) = render_row(&row, 40);
254            assert!(text.contains(marker), "expected {marker:?} in {text:?}");
255        }
256    }
257
258    /// With its picker open the row gives the rail to the picker's line: one
259    /// rail on screen. The label keeps the accent, naming what is being picked.
260    #[test]
261    fn an_open_picker_takes_the_rail() {
262        let g = crate::glyphs::get();
263        let ctx = RenderContext::for_test();
264        let row = FormRow {
265            label: "Index:",
266            value: FormValue::Choice("dept"),
267            focused: true,
268            label_width: 10,
269        };
270        let area = Rect::new(0, 0, 30, 1);
271        let mut buf = Buffer::empty(area);
272        row.render_picking(area, &mut buf, &ctx, true);
273        assert_ne!(buf[(0, 0)].symbol(), g.rail);
274        assert_eq!(buf[(1, 0)].fg, ctx.accent, "the label keeps the accent");
275        let (text, _) = render_row(&row, 30);
276        assert!(
277            text.starts_with(g.rail),
278            "closed, the rail is back: {text:?}"
279        );
280    }
281
282    /// Focus is the rail and the accent, never a layout change: the gutter is
283    /// reserved, so the label and value sit still while the rail arrives.
284    #[test]
285    fn focus_brings_the_rail_and_moves_nothing() {
286        let g = crate::glyphs::get();
287        let make = |focused| FormRow {
288            label: "Path:",
289            value: FormValue::Choice("out.csv"),
290            focused,
291            label_width: 17,
292        };
293        let (plain_text, plain) = render_row(&make(false), 40);
294        let (focused_text, focused) = render_row(&make(true), 40);
295        assert!(plain_text.starts_with(' '), "the gutter is reserved");
296        assert!(
297            focused_text.starts_with(g.rail),
298            "the focused row is marked"
299        );
300        assert!(
301            plain_text.chars().skip(1).eq(focused_text.chars().skip(1)),
302            "past the rail, focus changes no text: {plain_text:?} vs {focused_text:?}"
303        );
304        let changed: Vec<u16> = (0..40)
305            .filter(|&x| plain[(x, 0)].fg != focused[(x, 0)].fg)
306            .collect();
307        assert!(!changed.is_empty(), "focus is invisible");
308        assert!(
309            changed.iter().all(|&x| x < 18),
310            "focus colored the value, not just the rail and label: {changed:?}"
311        );
312    }
313
314    #[test]
315    fn an_options_row_never_panics_and_falls_back_when_narrow() {
316        let g = crate::glyphs::get();
317        let items = ["CSV", "TSV", "Parquet"];
318        for width in 0..40 {
319            for label_width in [0, 5, 9] {
320                let row = FormRow {
321                    label: "Format:",
322                    value: FormValue::Options {
323                        items: &items,
324                        selected: 2,
325                        clicks: None,
326                    },
327                    focused: true,
328                    label_width,
329                };
330                let (text, _) = render_row(&row, width);
331                if width >= 30 && label_width == 9 {
332                    assert!(text.contains(" CSV  TSV  Parquet "), "{text:?}");
333                } else if width == 20 && label_width == 9 {
334                    let compact = format!("{} Parquet {}", g.choice_prev, g.choice_next);
335                    assert!(text.contains(&compact), "{text:?}");
336                }
337            }
338        }
339    }
340
341    #[test]
342    fn a_narrow_row_never_panics() {
343        for width in 0..20 {
344            let row = FormRow {
345                label: "Include header:",
346                value: FormValue::Toggle(true),
347                focused: true,
348                label_width: 17,
349            };
350            let _ = render_row(&row, width);
351        }
352    }
353}