1use crate::analysis::intent_modal::{IntentField, IntentForm};
5use crate::glyphs;
6use crate::numfmt;
7use crate::render::layout::dialog_in;
8use crate::widgets::data_quality::{DataQualityWidgetConfig, rule_line};
9use crate::widgets::ui::{FormRow, FormValue, Surface};
10use polars::prelude::DataType;
11use ratatui::buffer::Buffer;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::Style;
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Cell, Paragraph, Row, StatefulWidget, Table, TableState, Widget};
16
17pub fn intent_columns(schema: &polars::prelude::Schema) -> Vec<(String, DataType)> {
20 schema
21 .iter()
22 .filter(|(name, _)| name.as_str() != crate::formats::schema_union::DRIFT_COLUMN)
23 .map(|(name, dtype)| (name.to_string(), dtype.clone()))
24 .collect()
25}
26
27fn type_label(config: &DataQualityWidgetConfig<'_>, column: &str, dtype: &DataType) -> String {
29 match config.plan.time_format(column) {
30 Some(format) => format!("text as {}", format.kind.label()),
31 None => crate::formats::column_types::dtype_label(dtype),
32 }
33}
34
35pub fn render_list(
37 config: &DataQualityWidgetConfig<'_>,
38 table_state: &mut TableState,
39 area: Rect,
40 buf: &mut Buffer,
41) {
42 let theme = config.theme;
43 let plan = config.plan;
44 let dimmed = Style::default().fg(theme.dimmed());
45 let columns = intent_columns(config.state.quality_schema(&plan.scope));
46 let [title, body, key] = Layout::default()
47 .direction(Direction::Vertical)
48 .constraints([
49 Constraint::Length(2),
50 Constraint::Fill(1),
51 Constraint::Length(2),
52 ])
53 .margin(1)
54 .areas(area);
55 let declared = plan.intent.declared_columns().len();
56 Paragraph::new(rule_line(
57 "Column intent",
58 (declared > 0)
59 .then(|| format!("{} declared", numfmt::group_chrome(declared)))
60 .as_deref(),
61 title.width,
62 theme,
63 ))
64 .render(title, buf);
65 if columns.is_empty() {
66 Paragraph::new(Span::styled("No columns in this scope", dimmed)).render(body, buf);
67 return;
68 }
69 let g = glyphs::get();
70 let types = columns
71 .iter()
72 .map(|(column, dtype)| type_label(config, column, dtype))
73 .collect::<Vec<_>>();
74 let name_width = columns
75 .iter()
76 .map(|(column, _)| glyphs::display_width(column))
77 .max()
78 .unwrap_or(0)
79 .clamp(6, 24) as u16
80 + 2;
81 let type_width = types
82 .iter()
83 .map(|dtype| glyphs::display_width(dtype))
84 .max()
85 .unwrap_or(0)
86 .clamp(4, 18) as u16
87 + 2;
88 let rules_width = (body.width as usize)
89 .saturating_sub(glyphs::display_width(g.selector) + (name_width + type_width) as usize);
90 let rows = columns.iter().zip(&types).map(|((column, _), dtype)| {
91 let mut rules = Vec::new();
92 if plan.intent.key.iter().any(|name| name == column) {
93 rules.push("key".to_string());
94 }
95 if let Some(intent) = plan.intent.column(column) {
96 rules.extend(intent.rules());
97 }
98 let rules = if rules.is_empty() {
99 Cell::from(Span::styled("any", dimmed))
100 } else {
101 Cell::from(crate::glyphs::fit(
102 &rules.join(&format!(" {} ", g.middot)),
103 rules_width,
104 ))
105 };
106 Row::new(vec![
107 Cell::from(crate::glyphs::fit(column, name_width as usize - 2)),
108 Cell::from(Span::styled(
109 crate::glyphs::fit(dtype, type_width as usize - 2),
110 dimmed,
111 )),
112 rules,
113 ])
114 });
115 table_state.select(Some(config.plan_field.min(columns.len().saturating_sub(1))));
116 let table = Table::new(
117 rows,
118 [
119 Constraint::Length(name_width),
120 Constraint::Length(type_width),
121 Constraint::Fill(1),
122 ],
123 )
124 .header(Row::new(["Column", "Type", "Must hold"]).style(dimmed))
125 .row_highlight_style(theme.highlight_style())
126 .highlight_symbol(g.selector);
127 StatefulWidget::render(table, body, buf, table_state);
128 let key_line = if plan.intent.key.is_empty() {
129 "Key: none declared".to_string()
130 } else {
131 format!("Key: {}", plan.intent.key.join(", "))
132 };
133 Paragraph::new(Line::styled(
134 crate::glyphs::fit(&key_line, key.width as usize),
135 dimmed,
136 ))
137 .render(
138 Rect {
139 y: key.y + 1,
140 height: 1,
141 ..key
142 },
143 buf,
144 );
145}
146
147const LABEL_WIDTH: u16 = 11;
149
150pub fn render_form(
153 form: &IntentForm,
154 config: &DataQualityWidgetConfig<'_>,
155 area: Rect,
156 buf: &mut Buffer,
157) {
158 let ctx = config.ctx;
159 let fields = form.fields();
160 let width = 64.min(area.width.saturating_sub(2));
161 let height = fields.len() as u16 + 3 + 2;
163 let popup = dialog_in(area, width, height);
164 let title = format!("Intent: {}", form.column);
165 let content = Surface::new(&title).render(popup, buf, ctx);
166 if content.height < 2 || content.width < 8 {
167 return;
168 }
169 let line = |index: u16| Rect {
170 y: content.y + index,
171 height: 1,
172 ..content
173 };
174 FormRow {
175 label: "Type:",
176 value: FormValue::Choice(&crate::formats::column_types::dtype_label(&form.dtype)),
177 focused: false,
178 label_width: LABEL_WIDTH,
179 }
180 .render(line(0), buf, ctx);
181 let reading = form.reading_label();
182 for (index, field) in fields.iter().enumerate() {
183 let row = index as u16 + 1;
184 if row >= content.height.saturating_sub(1) {
185 break;
186 }
187 let value = match field {
188 IntentField::Key => FormValue::Toggle(form.in_key),
189 IntentField::Required => FormValue::Toggle(form.required),
190 IntentField::ReadAs => FormValue::Choice(&reading),
191 text => match form.input(*text) {
192 Some(input) => FormValue::Input(input),
193 None => continue,
194 },
195 };
196 FormRow {
197 label: field.label(),
198 value,
199 focused: form.field == *field,
200 label_width: LABEL_WIDTH,
201 }
202 .render(line(row), buf, ctx);
203 crate::app::pointer::record_field::<crate::analysis::intent_modal::IntentForm>(
204 line(row),
205 *field,
206 );
207 }
208 let (status, warn) = match &form.error {
210 Some(error) => (error.clone(), true),
211 None => (
212 match form.field {
213 IntentField::Key => "Key column: together the key names one row".to_string(),
214 IntentField::Required => "Every row has a value".to_string(),
215 IntentField::ReadAs if form.time.is_some() => {
216 crate::glyphs::dotted("Read by Text as time · change in Setup")
217 }
218 IntentField::ReadAs => "Text that does not read is counted".to_string(),
219 IntentField::Allowed => {
220 crate::glyphs::dotted("Comma-separated · \"a, b\" holds a comma")
221 }
222 IntentField::Minimum | IntentField::Maximum => crate::glyphs::dotted(&format!(
223 "{} · empty for no bound",
224 upper_first(form.value_kind().bound_hint())
225 )),
226 },
227 false,
228 ),
229 };
230 Paragraph::new(Line::styled(
231 crate::glyphs::fit(&status, content.width as usize),
232 Style::default().fg(if warn { ctx.warning } else { ctx.dimmed }),
233 ))
234 .render(line(content.height - 1), buf);
235}
236
237fn upper_first(text: &str) -> String {
238 let mut chars = text.chars();
239 match chars.next() {
240 Some(first) => first.to_uppercase().chain(chars).collect(),
241 None => String::new(),
242 }
243}
244
245#[cfg(test)]
246mod tests;