Skip to main content

datui_lib/widgets/
quality_intent.rs

1//! Column intent in Data Quality Setup: the list of the scope's columns with what
2//! each is declared to hold, and the form that declares it for one.
3
4use 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
17/// The scope's columns Setup can declare intent on: the schema it reads, less
18/// datui's own bookkeeping.
19pub 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
27/// A column's type as the list names it: text read as time says how.
28fn 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
35/// Every column of the scope, each with its declared rules; the key named under it.
36pub 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
147/// Where the form's values start, past the rail gutter and its longest label.
148const LABEL_WIDTH: u16 = 11;
149
150/// One column's declaration, over the list: a row per rule its type takes, and a
151/// line saying what the focused row expects, or why Enter did not apply.
152pub 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    // The type, the rows, a blank and the status line, inside the frame.
162    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    // What the focused row takes, or why Enter refused: the form's own line.
209    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;