Skip to main content

datui_lib/widgets/
retype.rs

1//! The type picker and the combine form: one Surface each over the table.
2
3use crate::render::context::RenderContext;
4use crate::retype_modal::{CombineField, CombineModal, RetypeModal, Stage};
5use crate::widgets::ui::{FormRow, FormValue, HintBar, Picker, Surface};
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Paragraph, Widget};
11
12/// The type picker over `area`: the types, or a type's formats with what each makes
13/// of the column's first value, and a format typed with what it makes of it.
14pub fn render_retype(area: Rect, buf: &mut Buffer, modal: &RetypeModal, ctx: &RenderContext) {
15    let picker = &modal.picker;
16    let widest = picker
17        .items()
18        .iter()
19        .map(|item| crate::glyphs::display_width(item))
20        .max()
21        .unwrap_or(0) as u16;
22    let width = (widest + 6).clamp(40, 60).min(area.width);
23    // Frame, filter line, the list, the blank above the footer and the footer.
24    let height = (picker.items().len() as u16 + 5).min(23).min(area.height);
25    if width < 10 || height < 6 {
26        return;
27    }
28    let popup = Rect {
29        x: area.x + area.width.saturating_sub(width) / 2,
30        y: area.y + area.height.saturating_sub(height) / 3,
31        width,
32        height,
33    };
34    crate::pointer::record(popup, crate::pointer::Hit::Modal);
35    let back = match modal.stage {
36        Stage::Type => "Cancel",
37        Stage::Format { .. } => "Back",
38    };
39    let footer = HintBar::from_ctx(ctx).hints(&[("Enter", "Choose"), ("Esc", back)]);
40    let title = modal.title();
41    let inner = Surface::new(&title).footer(&footer).render(popup, buf, ctx);
42    if inner.height < 3 {
43        return;
44    }
45    let hint = match modal.stage {
46        Stage::Type => "type to narrow",
47        Stage::Format { .. } => "type to narrow, or a format such as %d.%m.%Y",
48    };
49    let filter = if picker.filter.is_empty() {
50        Line::from(Span::styled(hint, Style::default().fg(ctx.dimmed)))
51    } else {
52        Line::from(Span::raw(picker.filter.clone()))
53    };
54    Paragraph::new(filter).render(Rect { height: 1, ..inner }, buf);
55    let list = Rect {
56        y: inner.y + 1,
57        height: inner.height.saturating_sub(1),
58        ..inner
59    };
60    if picker.filtered().is_empty() {
61        let said = match modal.typed_format() {
62            Some((format, Some(read))) => {
63                let arrow = crate::glyphs::get().arrow_right;
64                let first = modal.examples.first().cloned().unwrap_or_default();
65                format!("{format}  {first} {arrow} {read}")
66            }
67            Some((format, None)) => format!("{format}: does not read the first value"),
68            None => "No type matches".to_string(),
69        };
70        Paragraph::new(Span::styled(said, Style::default().fg(ctx.text_secondary)))
71            .render(Rect { height: 1, ..list }, buf);
72    } else {
73        Picker::from_state(picker, true).render(list, buf, ctx);
74    }
75}
76
77fn label(field: CombineField) -> &'static str {
78    match field {
79        CombineField::Date => "Date:",
80        CombineField::Time => "Time:",
81        CombineField::Offset => "UTC offset:",
82        CombineField::Kind => "As:",
83        CombineField::Name => "Name:",
84    }
85}
86
87/// Past the longest label, "UTC offset:", plus air.
88const LABEL_WIDTH: u16 = 13;
89
90/// The combine form over `area`.
91pub fn render_combine(area: Rect, buf: &mut Buffer, modal: &CombineModal, ctx: &RenderContext) {
92    let fields: Vec<CombineField> = crate::form::Form::fields(modal)
93        .into_iter()
94        .map(|(f, _)| f)
95        .collect();
96    let width = (area.width * 3 / 4).clamp(30, 56).min(area.width);
97    let wanted = if modal.picker.is_some() {
98        16
99    } else {
100        fields.len() as u16 + 6
101    };
102    let height = wanted.min(area.height);
103    if height < 6 {
104        return;
105    }
106    let popup = Rect {
107        x: area.x + area.width.saturating_sub(width) / 2,
108        y: area.y + area.height.saturating_sub(height) / 2,
109        width,
110        height,
111    };
112    crate::pointer::record(popup, crate::pointer::Hit::Modal);
113    let footer = match &modal.picker {
114        Some(_) => HintBar::from_ctx(ctx)
115            .hint_weighted("Enter", "Choose", 3)
116            .hint_weighted("type", "Narrow", 1)
117            .hint_weighted("Esc", "Back", 4),
118        None => HintBar::from_ctx(ctx)
119            .hint_weighted("Enter", "Make", 3)
120            .hint_weighted("Space", "Pick", 2)
121            .hint_weighted("Tab", "Next", 1)
122            .hint_weighted("Esc", "Cancel", 4),
123    };
124    let content = Surface::new("Combine into Datetime")
125        .footer(&footer)
126        .render(popup, buf, ctx);
127    if content.height < 3 {
128        return;
129    }
130    let spec_y = content.y + content.height - 1;
131    let mut y = content.y;
132    let kinds: Vec<&str> = crate::column_types::DerivedKind::ALL
133        .iter()
134        .map(|k| k.name())
135        .collect();
136    let kind_at = crate::column_types::DerivedKind::ALL
137        .iter()
138        .position(|k| *k == modal.kind)
139        .unwrap_or(0);
140    for &field in &fields {
141        if y >= spec_y {
142            break;
143        }
144        let row = Rect {
145            y,
146            height: 1,
147            ..content
148        };
149        crate::pointer::record_field::<CombineModal>(row, field);
150        let value = match field {
151            CombineField::Date => FormValue::Choice(&modal.date),
152            CombineField::Time => match &modal.time {
153                Some(c) => FormValue::Choice(c),
154                None => FormValue::Placeholder(crate::retype_modal::NONE),
155            },
156            CombineField::Offset => match &modal.offset {
157                Some(c) => FormValue::Choice(c),
158                None => FormValue::Placeholder(crate::retype_modal::NONE),
159            },
160            CombineField::Kind => FormValue::Options {
161                items: &kinds,
162                selected: kind_at,
163                clicks: None,
164            },
165            CombineField::Name => FormValue::Choice(&modal.name),
166        };
167        FormRow {
168            label: label(field),
169            value,
170            focused: modal.focus == field,
171            label_width: LABEL_WIDTH,
172        }
173        .render_picking(row, buf, ctx, modal.picker.is_some());
174        y += 1;
175    }
176    if let Some((_, state)) = &modal.picker {
177        crate::pointer::record(content, crate::pointer::Hit::Picker);
178        let picker_y = y + 1;
179        if picker_y < spec_y {
180            let picker_area = Rect {
181                x: content.x + 2,
182                y: picker_y,
183                width: content.width.saturating_sub(2),
184                height: spec_y - picker_y,
185            };
186            Picker::from_state(state, true).render(picker_area, buf, ctx);
187        }
188    }
189    let (text, style) = match &modal.problem {
190        Some(problem) => (problem.clone(), Style::default().fg(ctx.warning)),
191        None => (modal.spec_line(), Style::default().fg(ctx.text_primary)),
192    };
193    Paragraph::new(text).style(style).render(
194        Rect {
195            y: spec_y,
196            height: 1,
197            ..content
198        },
199        buf,
200    );
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use polars::prelude::DataType;
207
208    fn text(buf: &Buffer) -> String {
209        let area = buf.area;
210        (0..area.height)
211            .map(|y| {
212                (0..area.width)
213                    .map(|x| buf[(x, y)].symbol().to_string())
214                    .collect::<String>()
215            })
216            .collect::<Vec<_>>()
217            .join("\n")
218    }
219
220    #[test]
221    fn the_picker_lists_the_types_then_the_formats_with_a_preview() {
222        let ctx = RenderContext::for_test();
223        let area = Rect::new(0, 0, 80, 24);
224        let mut modal = RetypeModal::new(
225            "when".into(),
226            DataType::String,
227            None,
228            vec!["03/04/2024".into()],
229        );
230        let mut buf = Buffer::empty(area);
231        render_retype(area, &mut buf, &modal, &ctx);
232        let shown = text(&buf);
233        assert!(shown.contains("Type of when, read as str"), "{shown}");
234        assert!(shown.contains("as read"), "{shown}");
235        assert!(shown.contains("datetime"), "{shown}");
236        let date = crate::column_types::TYPE_NAMES
237            .iter()
238            .position(|n| *n == "date")
239            .unwrap();
240        modal.picker.select_original(date + 1);
241        modal.choose();
242        let mut buf = Buffer::empty(area);
243        render_retype(area, &mut buf, &modal, &ctx);
244        let shown = text(&buf);
245        assert!(shown.contains("Date Format"), "{shown}");
246        assert!(shown.contains("%d/%m/%Y  03/04/2024"), "{shown}");
247        assert!(shown.contains("2024-04-03"), "{shown}");
248        for c in "%d %m".chars() {
249            modal.picker.type_char(c);
250        }
251        let mut buf = Buffer::empty(area);
252        render_retype(area, &mut buf, &modal, &ctx);
253        let shown = text(&buf);
254        assert!(
255            shown.contains("%d %m: does not read the first value"),
256            "{shown}"
257        );
258    }
259
260    #[test]
261    fn the_combine_form_echoes_what_it_makes() {
262        let ctx = RenderContext::for_test();
263        let area = Rect::new(0, 0, 80, 24);
264        let columns = vec!["Lcl Date".to_string(), "Lcl Time".to_string()];
265        let modal = CombineModal::new("Lcl Date".into(), columns.clone(), &columns);
266        let mut buf = Buffer::empty(area);
267        render_combine(area, &mut buf, &modal, &ctx);
268        let shown = text(&buf);
269        assert!(shown.contains("Combine into Datetime"), "{shown}");
270        assert!(shown.contains("UTC offset:"), "{shown}");
271        assert!(
272            shown.contains("datetime = datetime from Lcl Date, Lcl Time"),
273            "{shown}"
274        );
275    }
276}