1use 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
14pub enum FormValue<'a> {
16 Input(&'a TextInput),
19 Toggle(bool),
21 Choice(&'a str),
24 Placeholder(&'a str),
26 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 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 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 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 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
139struct OptionsRow<'a> {
141 items: &'a [&'a str],
142 selected: usize,
143 clicks: Option<&'a FieldId>,
144 focused: bool,
145 air: u16,
148}
149
150impl OptionsRow<'_> {
151 fn render(&self, value_area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
152 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 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 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 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 #[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 #[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}