Skip to main content

datui_lib/widgets/ui/
form_view.rs

1//! A dialog of form rows: one Surface, a FormRow per field (with the lines a field
2//! brings under it), the open Picker below the rows, and a status line above the
3//! footer saying what Enter will do or why it did not.
4
5use crate::app::form::Form;
6use crate::render::context::RenderContext;
7use crate::widgets::ui::{FormRow, FormValue, HintBar, Picker, PickerState, Surface};
8use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::Style;
11use ratatui::text::Line;
12use ratatui::widgets::{Paragraph, Widget};
13
14/// One line of a form dialog.
15pub enum FormLine<'a, F> {
16    /// A field: its label and value.
17    Field(F, &'a str, FormValue<'a>),
18    /// A line under the field above it, in the value column: what it holds, or the
19    /// forms its value takes.
20    Note(Line<'a>),
21}
22
23/// What a form dialog draws. `footer` is the form's own keys; while a picker is open
24/// its keys stand in, from the `Picker` group of `screen`'s registry entries.
25pub struct FormView<'a, F> {
26    pub title: &'a str,
27    pub screen: datui_cli::keys::Context,
28    pub footer: Option<HintBar>,
29    /// Where the values start, past the longest label.
30    pub label_width: u16,
31    /// The fields shown, in order, with the lines they bring.
32    pub rows: Vec<FormLine<'a, F>>,
33    /// The field with the rail; `None` while something beside the form has the focus.
34    pub focused: Option<F>,
35    pub picker: Option<&'a PickerState>,
36    /// The last line: what Enter will do, or what stops it. While it is `Some`, a
37    /// blank and the line are kept under the rows, empty or not, so nothing moves
38    /// when a reason comes. A long one wraps upward into rows the fields leave free.
39    pub status: Option<(String, Style)>,
40    /// Whether its whole area takes no clicks but its own, as a dialog over the screen.
41    /// `false` only where the caller decides what is around it: the Sample form inline
42    /// beside the analysis tools, or over a pane the caller shields.
43    pub shields: bool,
44}
45
46impl<'a, F: Copy + PartialEq> FormView<'a, F> {
47    /// Draw it in `area`, recording each field's row for clicks.
48    pub fn render<T: Form<Field = F>>(self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
49        self.draw(area, buf, ctx, Some(crate::app::pointer::record_field::<T>));
50    }
51
52    /// Draw it in `area` for a form whose fields take no clicks.
53    pub fn render_unrecorded(self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
54        self.draw(area, buf, ctx, None);
55    }
56
57    fn draw(self, area: Rect, buf: &mut Buffer, ctx: &RenderContext, record: Option<fn(Rect, F)>) {
58        let footer = match (self.picker, self.footer) {
59            (Some(_), _) => Some(
60                HintBar::from_ctx(ctx)
61                    .screen(self.screen)
62                    .group("Picker")
63                    .key("Enter")
64                    .weight(3)
65                    .key("(type)")
66                    .weight(1)
67                    .key("Esc")
68                    .weight(4),
69            ),
70            (None, footer) => footer,
71        };
72        if self.shields {
73            crate::app::pointer::record(area, crate::app::pointer::Hit::Modal);
74        }
75        let mut surface = Surface::new(self.title);
76        if let Some(footer) = &footer {
77            surface = surface.footer(footer);
78        }
79        let content = surface.render(area, buf, ctx);
80        if content.height == 0 || content.width < 4 {
81            return;
82        }
83        let bottom = content.bottom();
84        // The status line and the blank above it, while there is a row left for a field.
85        let reserved = match self.status {
86            Some(_) if content.height > 2 => 2,
87            _ => 0,
88        };
89        let room = (content.height - reserved) as usize;
90        // Overlays scroll inside a capped frame: the focused field stays on screen.
91        let focused = self.focused;
92        let at = self
93            .rows
94            .iter()
95            .position(|line| matches!(line, FormLine::Field(f, ..) if Some(*f) == focused))
96            .unwrap_or(0);
97        // With the notes under it, as many as fit beside it.
98        let mut end = at;
99        while end + 1 - at < room && matches!(self.rows.get(end + 1), Some(FormLine::Note(_))) {
100            end += 1;
101        }
102        let first = end.saturating_sub(room.saturating_sub(1));
103        let picking = self.picker.is_some();
104        let mut y = content.y;
105        for line in self.rows.into_iter().skip(first).take(room) {
106            let row = Rect {
107                y,
108                height: 1,
109                ..content
110            };
111            match line {
112                FormLine::Field(field, label, value) => {
113                    // The row first: values that record their own clicks lie on top.
114                    if let Some(record) = record {
115                        record(row, field);
116                    }
117                    FormRow {
118                        label,
119                        value,
120                        focused: Some(field) == focused,
121                        label_width: self.label_width,
122                    }
123                    .render_picking(row, buf, ctx, picking);
124                }
125                FormLine::Note(text) => {
126                    let indent = self.label_width + 1;
127                    Paragraph::new(text).render(
128                        Rect {
129                            x: row.x + indent,
130                            width: row.width.saturating_sub(indent),
131                            ..row
132                        },
133                        buf,
134                    );
135                }
136            }
137            y += 1;
138        }
139        let status_y = bottom - 1;
140        // The open Picker drops in below the rows and reaches down to the status line.
141        if let Some(state) = self.picker {
142            // It owns the keys even with no room to draw: the rows take no clicks.
143            crate::app::pointer::record(content, crate::app::pointer::Hit::Picker);
144            let picker_y = y + 1;
145            if picker_y < status_y {
146                let picker_area = Rect {
147                    x: content.x + 2,
148                    y: picker_y,
149                    width: content.width.saturating_sub(2),
150                    height: status_y - picker_y,
151                };
152                Picker::from_state(state, true).render(picker_area, buf, ctx);
153            }
154        }
155        let Some((text, style)) = self.status.filter(|(text, _)| !text.is_empty()) else {
156            return;
157        };
158        if reserved == 0 {
159            return;
160        }
161        // At the rail's column, as the spec line under a form reads, keeping a blank
162        // under the last row (one line under an open picker); cut with an ellipsis
163        // past the room there is.
164        let width = content.width as usize;
165        let free = if picking {
166            1
167        } else {
168            bottom.saturating_sub(y + 1).max(1) as usize
169        };
170        let mut lines = crate::widgets::info::wrap_to(&text, width);
171        if lines.len() > free {
172            lines.truncate(free);
173            if let Some(last) = lines.last_mut() {
174                let cut = format!("{last} {}", crate::glyphs::get().ellipsis);
175                *last = crate::glyphs::fit(&cut, width);
176            }
177        }
178        let top = bottom - lines.len() as u16;
179        for (i, line) in lines.into_iter().enumerate() {
180            Paragraph::new(line).style(style).render(
181                Rect {
182                    y: top + i as u16,
183                    height: 1,
184                    ..content
185                },
186                buf,
187            );
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests;