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::app::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    /// Styled text, such as column names in their types' colors. `dimmed` reads the
37    /// whole row, label too, as not in use.
38    Spans { spans: Vec<Span<'a>>, dimmed: bool },
39}
40
41pub struct FormRow<'a> {
42    pub label: &'a str,
43    pub value: FormValue<'a>,
44    pub focused: bool,
45    /// Where the value column starts, past the rail gutter, shared by every
46    /// row so values align.
47    pub label_width: u16,
48}
49
50impl FormRow<'_> {
51    pub fn render(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
52        self.render_picking(area, buf, ctx, false);
53    }
54
55    /// Draw the row, its picker open when `picking`: the picker's current line
56    /// has the keys and the one rail, so the row keeps its accent label and
57    /// gives up its rail.
58    pub fn render_picking(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext, picking: bool) {
59        if area.height == 0 || area.width == 0 {
60            return;
61        }
62        // The rail gutter is always there, so focus arriving moves nothing —
63        // Tab walks the rail down the rows, which is what says the rows are
64        // walkable. Same mark as the table's current row and the Picker's
65        // selection: one focus signal everywhere.
66        let g = crate::glyphs::get();
67        let rail = if self.focused && !picking {
68            g.rail
69        } else {
70            " "
71        };
72        Paragraph::new(rail)
73            .style(Style::default().fg(ctx.accent))
74            .render(Rect { width: 1, ..area }, buf);
75        self.render_body(
76            Rect {
77                x: area.x + 1,
78                width: area.width - 1,
79                ..area
80            },
81            buf,
82            ctx,
83        );
84    }
85
86    /// The label and the value, from `area.x`, for a list that draws its own rail.
87    pub fn render_body(&self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
88        if area.width == 0 || area.height == 0 {
89            return;
90        }
91        let g = crate::glyphs::get();
92        let dimmed = matches!(self.value, FormValue::Spans { dimmed: true, .. });
93        let label_style = if dimmed {
94            Style::default().fg(ctx.dimmed)
95        } else if self.focused {
96            Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
97        } else {
98            Style::default().fg(ctx.label)
99        };
100        let label_w = self.label_width.min(area.width);
101        // Cells of air between the label and the value column.
102        let air = label_w.saturating_sub(crate::glyphs::display_width(self.label) as u16);
103        Paragraph::new(self.label).style(label_style).render(
104            Rect {
105                width: label_w,
106                ..area
107            },
108            buf,
109        );
110
111        let value_area = Rect {
112            x: area.x + label_w,
113            width: area.width.saturating_sub(label_w),
114            ..area
115        };
116        if value_area.width == 0 {
117            return;
118        }
119        match &self.value {
120            FormValue::Input(input) => (*input).render(value_area, buf),
121            FormValue::Toggle(on) => {
122                let marker = if *on { g.checkbox_on } else { g.checkbox_off };
123                Paragraph::new(marker)
124                    .style(Style::default().fg(ctx.text_primary))
125                    .render(value_area, buf);
126            }
127            FormValue::Choice(value) => {
128                Paragraph::new(*value)
129                    .style(Style::default().fg(ctx.text_primary))
130                    .render(value_area, buf);
131            }
132            FormValue::Placeholder(value) => {
133                Paragraph::new(*value)
134                    .style(Style::default().fg(ctx.dimmed))
135                    .render(value_area, buf);
136            }
137            FormValue::Spans { spans, dimmed } => {
138                let spans: Vec<Span> = spans
139                    .iter()
140                    .map(|s| match dimmed {
141                        true => Span::styled(s.content.clone(), Style::default().fg(ctx.dimmed)),
142                        false => s.clone(),
143                    })
144                    .collect();
145                Paragraph::new(Line::from(spans)).render(value_area, buf);
146            }
147            FormValue::Options {
148                items,
149                selected,
150                clicks,
151            } => OptionsRow {
152                items,
153                selected: *selected,
154                clicks: clicks.as_ref(),
155                focused: self.focused,
156                air,
157            }
158            .render(value_area, buf, ctx),
159        }
160    }
161}
162
163/// A choice's values on one row, or the chosen one alone where they do not fit.
164struct OptionsRow<'a> {
165    items: &'a [&'a str],
166    selected: usize,
167    clicks: Option<&'a FieldId>,
168    focused: bool,
169    /// Cells of air between the label and the value column, which the row may
170    /// draw into so its chosen text lines up with the other rows' values.
171    air: u16,
172}
173
174impl OptionsRow<'_> {
175    fn render(&self, value_area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
176        // The chosen one keeps its tint wherever focus is, so the choice reads
177        // from anywhere in the form; the others recede when focus is elsewhere.
178        let chosen = Style::default()
179            .fg(ctx.text_primary)
180            .add_modifier(Modifier::BOLD)
181            .patch(ctx.highlight_style());
182        let other = Style::default().fg(if self.focused {
183            ctx.text_secondary
184        } else {
185            ctx.dimmed
186        });
187        let hit = |index: usize, current: usize| {
188            self.clicks.map(|field| Hit::Option {
189                field: Some(field.clone()),
190                index,
191                current,
192            })
193        };
194        // Each value padded a cell each side, for the tint; drawn a cell into the
195        // air, the first value's text sits in the value column. Compact, the step
196        // mark takes a second cell.
197        let full: usize = self
198            .items
199            .iter()
200            .map(|item| crate::glyphs::display_width(item) + 2)
201            .sum();
202        let compact = full > usize::from(value_area.width + self.air.min(1));
203        let lead = self.air.min(if compact { 2 } else { 1 });
204        let mut spans = Vec::new();
205        let mut hits = Vec::new();
206        if !compact {
207            for (i, item) in self.items.iter().enumerate() {
208                if let Some(hit) = hit(i, self.selected) {
209                    hits.push((spans.len(), hit));
210                }
211                let style = if i == self.selected { chosen } else { other };
212                spans.push(Span::styled(format!(" {item} "), style));
213            }
214        } else {
215            // Too narrow: the chosen value alone between step marks, which a
216            // click steps by one as ←/→ do; the name itself takes Space.
217            let g = crate::glyphs::get();
218            let name = self.items.get(self.selected).copied().unwrap_or("");
219            let mark = Style::default().fg(ctx.text_secondary);
220            spans.push(Span::styled(g.choice_prev, mark));
221            spans.push(Span::styled(format!(" {name} "), chosen));
222            spans.push(Span::styled(g.choice_next, mark));
223            if let (Some(back), Some(on)) = (hit(0, 1), hit(1, 0)) {
224                hits.push((0, back));
225                hits.push((2, on));
226            }
227        }
228        let area = Rect {
229            x: value_area.x - lead,
230            width: value_area.width + lead,
231            ..value_area
232        };
233        let line = Line::from(spans);
234        crate::app::pointer::record_spans(area, &line, hits);
235        Paragraph::new(line).render(area, buf);
236    }
237}
238
239#[cfg(test)]
240mod tests;