1use 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
12pub 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 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
87const LABEL_WIDTH: u16 = 13;
89
90pub 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}