Skip to main content

datui_lib/analysis/
analysis_modal.rs

1use crate::analysis::data_quality::{
2    DataQualityPlan, DataQualityResults, IntervalClock, IntervalFact, QUALITY_WINDOW_WIDTHS,
3    QualityComparison, QualityCompute, QualityGrain, QualityMetric, QualityPage, TIME_FORMATS,
4    TemporalRole, TemporalRoleAssignment, TimeInterpretation, TimeKind,
5};
6use crate::analysis::quality_report::{EvidenceRows, Finding, FindingsView, QualityReport};
7use crate::analysis::statistics::{AnalysisResults, DistributionType};
8use ratatui::widgets::TableState;
9
10/// The rows of Data Quality Setup, top to bottom: the rows read, what the columns
11/// mean, and how the study splits and compares them.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SetupRow {
14    Sample,
15    TextAsTime,
16    TimeRoles,
17    Intervals,
18    /// What columns must hold, declared: the key and each column's rules.
19    Intent,
20    Grain,
21    Expected,
22    Compare,
23    Values,
24    Latency,
25    WindowBy,
26}
27
28impl SetupRow {
29    pub const ALL: [Self; 11] = [
30        Self::Sample,
31        Self::TextAsTime,
32        Self::TimeRoles,
33        Self::Intervals,
34        Self::Intent,
35        Self::Grain,
36        Self::Expected,
37        Self::Compare,
38        Self::Values,
39        Self::Latency,
40        Self::WindowBy,
41    ];
42
43    pub fn label(self) -> &'static str {
44        match self {
45            Self::Sample => "Sample",
46            Self::TextAsTime => "Text as time",
47            Self::TimeRoles => "Time roles",
48            Self::Intervals => "Intervals",
49            Self::Intent => "Column intent",
50            Self::Grain => "Grain",
51            Self::Expected => "Expected",
52            Self::Compare => "Compare",
53            Self::Values => "Values",
54            Self::Latency => "Latency over",
55            Self::WindowBy => "Window by",
56        }
57    }
58
59    /// The row at `index`, the last one past the end.
60    pub fn at(index: usize) -> Self {
61        Self::ALL[index.min(Self::ALL.len() - 1)]
62    }
63
64    pub fn index(self) -> usize {
65        Self::ALL.iter().position(|row| *row == self).unwrap_or(0)
66    }
67}
68
69/// What a Setup row's picker sets.
70#[derive(Debug, Clone, PartialEq)]
71pub enum PlanChoice {
72    Grain(QualityGrain),
73    Values(QualityCompute),
74    Compare(QualityComparison),
75    Latency(Option<i64>),
76    /// Which time puts an interval in a window.
77    Clock(IntervalClock),
78    /// A text column to read as time; choosing it asks for the format next.
79    TextColumn(String),
80    /// How a text column is read as time, or `None` to read it as text again.
81    Format(String, Option<(TimeKind, &'static str)>),
82    /// Only the findings that name this column, or all of them.
83    FindingColumn(Option<String>),
84    /// Only the findings this check made, or all of them.
85    FindingCheck(Option<&'static str>),
86}
87
88impl PlanChoice {
89    fn is_current(&self, plan: &DataQualityPlan) -> bool {
90        match self {
91            Self::Grain(grain) => &plan.grain == grain,
92            Self::Values(QualityCompute::Metadata) => plan.compute == QualityCompute::Metadata,
93            Self::Values(_) => plan.compute != QualityCompute::Metadata,
94            Self::Compare(comparison) => &plan.comparison == comparison,
95            Self::Latency(seconds) => &plan.latency_threshold_seconds == seconds,
96            Self::Clock(clock) => plan.interval_clock == *clock,
97            Self::TextColumn(column) => plan.time_format(column).is_some(),
98            Self::Format(column, format) => {
99                plan.time_format(column)
100                    .map(|current| (current.kind, current.format.as_str()))
101                    == format.map(|(kind, format)| (kind, format))
102            }
103            // The findings list is not the plan: its picker selects its own current.
104            Self::FindingColumn(_) | Self::FindingCheck(_) => false,
105        }
106    }
107}
108
109/// A Setup row's choices, or the findings list's narrowing, open as a list.
110#[derive(Debug, Clone)]
111pub struct PlanPicker {
112    pub title: String,
113    pub choices: Vec<PlanChoice>,
114    pub state: crate::widgets::ui::PickerState,
115}
116
117/// What the data offers Setup's choices: partition columns, time-window columns
118/// (and whether they hold times of day), whether there are files to split by, and
119/// text columns readable as time with a few values from the rows on screen.
120#[derive(Debug, Clone, Default)]
121pub struct PlanContext {
122    pub partitions: Vec<String>,
123    pub time_columns: Vec<(String, bool)>,
124    pub files: bool,
125    pub text_columns: Vec<(String, Vec<String>)>,
126}
127
128/// Rows a finding names that the run did not keep, waiting for Enter to read them.
129/// Nothing reads until then, and Esc drops it.
130#[derive(Debug, Clone)]
131pub struct EvidenceRead {
132    pub rows: EvidenceRows,
133    /// The table's label once the rows are shown.
134    pub label: String,
135    /// Draw this sample again from its seed, rather than read the scope.
136    pub sample: Option<crate::analysis::sampling::Sample>,
137    /// The scope the rows are read from.
138    pub scope: crate::analysis::data_quality::QualityScope,
139    /// What the read is, as the dialog says it: label, value.
140    pub summary: Vec<(&'static str, String)>,
141}
142
143/// A latency threshold as Setup offers it, or its length when it is none of those.
144pub fn threshold_label(seconds: Option<i64>) -> String {
145    match seconds {
146        None => "none".to_string(),
147        Some(3_600) => "1 hour".to_string(),
148        Some(86_400) => "1 day".to_string(),
149        Some(604_800) => "1 week".to_string(),
150        Some(seconds) => crate::numfmt::duration(seconds),
151    }
152}
153
154/// Which windows the Expected editor's first row says rows are expected in.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum ExpectedCadence {
157    /// None stated: no window is a gap.
158    None,
159    /// Every window of the grain.
160    Every,
161    /// Monday to Friday's hours or days.
162    Weekdays,
163}
164
165/// The rows of the Expected editor.
166pub const EXPECTED_ROWS: [&str; 3] = ["Windows", "From", "Before"];
167
168/// The Expected editor while it is open: the cadence, and the range as typed. Its
169/// Enter writes them into the draft, and its Esc leaves the draft as it was.
170#[derive(Debug, Clone)]
171pub struct ExpectedForm {
172    /// The row under the cursor, in [`EXPECTED_ROWS`].
173    pub field: usize,
174    pub cadence: ExpectedCadence,
175    pub from: crate::widgets::text_input::TextInput,
176    pub before: crate::widgets::text_input::TextInput,
177    /// Why Enter did not apply, until the next edit.
178    pub error: Option<String>,
179}
180
181impl ExpectedForm {
182    pub fn new(plan: &DataQualityPlan, theme: &crate::config::Theme) -> Self {
183        let input = || crate::widgets::text_input::TextInput::new().with_theme(theme);
184        let (mut from, mut before) = (input(), input());
185        let cadence = match &plan.expected {
186            None => ExpectedCadence::None,
187            Some(expected) => {
188                from.set_value(expected.from.as_deref().unwrap_or_default());
189                before.set_value(expected.before.as_deref().unwrap_or_default());
190                // Weekdays stated for days read as every window once the grain is
191                // weeks or months, as Setup and the check read it.
192                let every = match &plan.grain {
193                    crate::analysis::data_quality::QualityGrain::TimeWindows { every, .. } => {
194                        every.as_str()
195                    }
196                    _ => "",
197                };
198                if expected.weekdays
199                    && crate::analysis::data_quality::ExpectedWindows::weekdays_apply(every)
200                {
201                    ExpectedCadence::Weekdays
202                } else {
203                    ExpectedCadence::Every
204                }
205            }
206        };
207        let mut form = Self {
208            field: 0,
209            cadence,
210            from,
211            before,
212            error: None,
213        };
214        form.sync_focus();
215        form
216    }
217
218    /// The choices the Windows row cycles through for windows `every` wide.
219    pub fn cadences(every: &str) -> Vec<ExpectedCadence> {
220        let mut cadences = vec![ExpectedCadence::None, ExpectedCadence::Every];
221        if crate::analysis::data_quality::ExpectedWindows::weekdays_apply(every) {
222            cadences.push(ExpectedCadence::Weekdays);
223        }
224        cadences
225    }
226
227    /// The cadence in the editor's words.
228    pub fn cadence_label(&self, every: &str) -> String {
229        match self.cadence {
230            ExpectedCadence::None => "none: no window is a gap".to_string(),
231            ExpectedCadence::Every => {
232                crate::analysis::data_quality::ExpectedWindows::default().cadence_label(every)
233            }
234            ExpectedCadence::Weekdays => "weekdays, Monday to Friday".to_string(),
235        }
236    }
237
238    pub fn cycle(&mut self, every: &str, forward: bool) {
239        let cadences = Self::cadences(every);
240        let at = cadences
241            .iter()
242            .position(|cadence| *cadence == self.cadence)
243            .unwrap_or(0);
244        let next = if forward {
245            (at + 1) % cadences.len()
246        } else {
247            (at + cadences.len() - 1) % cadences.len()
248        };
249        self.cadence = cadences[next];
250        self.error = None;
251    }
252
253    /// Whether the row under the cursor is one typed into.
254    pub fn typing(&self) -> bool {
255        self.field > 0
256    }
257
258    pub fn input_mut(&mut self) -> Option<&mut crate::widgets::text_input::TextInput> {
259        match self.field {
260            1 => Some(&mut self.from),
261            2 => Some(&mut self.before),
262            _ => None,
263        }
264    }
265
266    pub fn sync_focus(&mut self) {
267        self.from.set_focused(self.field == 1);
268        self.before.set_focused(self.field == 2);
269    }
270
271    /// What the editor states, or why it cannot be read.
272    pub fn expected(
273        &self,
274    ) -> Result<Option<crate::analysis::data_quality::ExpectedWindows>, String> {
275        if self.cadence == ExpectedCadence::None {
276            return Ok(None);
277        }
278        let typed = |input: &crate::widgets::text_input::TextInput| {
279            let text = input.value().trim();
280            (!text.is_empty()).then(|| text.to_string())
281        };
282        let expected = crate::analysis::data_quality::ExpectedWindows {
283            weekdays: self.cadence == ExpectedCadence::Weekdays,
284            from: typed(&self.from),
285            before: typed(&self.before),
286        };
287        match expected.problem() {
288            Some(problem) => Err(problem),
289            None => Ok(Some(expected)),
290        }
291    }
292}
293
294impl crate::app::form::Form for ExpectedForm {
295    /// The row, in [`EXPECTED_ROWS`].
296    type Field = usize;
297
298    fn fields(&self) -> Vec<(usize, crate::app::form::FieldKind)> {
299        use crate::app::form::FieldKind;
300        (0..EXPECTED_ROWS.len())
301            .map(|row| {
302                let kind = if row == 0 {
303                    FieldKind::Choice
304                } else {
305                    FieldKind::Text
306                };
307                (row, kind)
308            })
309            .collect()
310    }
311
312    fn focused(&self) -> usize {
313        self.field
314    }
315
316    fn set_focused(&mut self, field: usize) {
317        self.field = field;
318        self.sync_focus();
319    }
320}
321
322/// Read `column` as time through `format`, or as text again with `None`; a time
323/// window on a column back to text falls back to the whole-dataset grain.
324pub fn set_time_format(plan: &mut DataQualityPlan, column: &str, format: Option<(TimeKind, &str)>) {
325    plan.time_formats
326        .retain(|interpretation| interpretation.column != column);
327    match format {
328        Some((kind, format)) => plan.time_formats.push(TimeInterpretation {
329            column: column.to_string(),
330            kind,
331            format: format.to_string(),
332        }),
333        None => {
334            if matches!(&plan.grain, QualityGrain::TimeWindows { column: on, .. } if on == column) {
335                plan.grain = QualityGrain::Dataset;
336                plan.baseline_segment = None;
337            }
338        }
339    }
340}
341
342#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
343pub enum AnalysisView {
344    #[default]
345    Main, // Main tool view
346    DistributionDetail, // Full-screen distribution detail view
347    CorrelationDetail,  // Full-screen correlation pair detail view
348}
349
350#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
351pub enum AnalysisTool {
352    #[default]
353    Describe, // Column describe table
354    DistributionAnalysis, // Distribution analysis table
355    CorrelationMatrix,    // Correlation matrix
356    DataQuality,          // Multi-scale quality profile
357}
358
359impl AnalysisTool {
360    /// The tools in the order the sidebar lists them.
361    pub const ALL: [Self; 4] = [
362        Self::Describe,
363        Self::DistributionAnalysis,
364        Self::CorrelationMatrix,
365        Self::DataQuality,
366    ];
367
368    /// The tool's row in the sidebar.
369    pub fn index(self) -> usize {
370        Self::ALL.iter().position(|tool| *tool == self).unwrap_or(0)
371    }
372}
373
374/// Progress state for the analysis progress overlay (display only).
375#[derive(Debug, Clone)]
376pub struct AnalysisProgress {
377    pub phase: String,
378    /// When the run began, for the elapsed time: a run has no total, so time is its
379    /// progress.
380    pub started: std::time::Instant,
381    /// Whether the running stage reads the source or rows already read; `None` until
382    /// a Data Quality run names its first stage.
383    pub reads_source: Option<bool>,
384    /// Whether a cancel stops the running stage partway; `None` until a Data Quality run
385    /// names its first stage.
386    pub interruptible: Option<bool>,
387    /// The sampler's count of rows seen, where the read can count them.
388    pub read: Option<crate::analysis::sampling::ReadWatch>,
389    /// What the run starts from, when it reuses something: said before it starts.
390    pub reuse: Option<String>,
391}
392
393impl AnalysisProgress {
394    pub fn new(phase: &str) -> Self {
395        Self {
396            phase: phase.to_string(),
397            started: std::time::Instant::now(),
398            reads_source: None,
399            interruptible: None,
400            read: None,
401            reuse: None,
402        }
403    }
404
405    /// The stage now running reads the source in one collect nothing can stop: a
406    /// cancel waits for its end.
407    pub fn read_runs_out(&self) -> bool {
408        self.reads_source == Some(true) && self.interruptible == Some(false)
409    }
410}
411
412#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
413pub enum AnalysisFocus {
414    #[default]
415    Main, // Focus on main area (tool view)
416    Sidebar,              // Focus on sidebar (tool list)
417    DistributionSelector, // Focus on distribution selector in detail view
418}
419
420/// A popup taller than the screen scrolls: the offset, and the most it can be.
421#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
422pub struct DetailScroll {
423    pub offset: u16,
424    pub max: u16,
425}
426
427/// Scrolling of a result table wider than its pane, by statistic: the first shown,
428/// and `max`, set by the table as it draws, where the last comes into view.
429#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
430pub struct ColumnScroll {
431    pub offset: usize,
432    pub max: usize,
433}
434
435impl ColumnScroll {
436    pub fn left(&mut self) {
437        self.offset = self.offset.min(self.max).saturating_sub(1);
438    }
439
440    pub fn right(&mut self) {
441        if self.offset < self.max {
442            self.offset += 1;
443        }
444    }
445}
446
447#[derive(Default)]
448pub struct AnalysisModal {
449    pub scroll_position: usize,
450    pub selected_column: Option<usize>,
451    pub describe_columns: ColumnScroll,
452    pub distribution_columns: ColumnScroll,
453    /// Kept on the selected cell by the matrix as it draws.
454    pub correlation_columns: ColumnScroll,
455    /// The rows every tool reads (one scope, method, size and seed), so tools compare
456    /// like with like. Kept across opens; `s` edits it.
457    pub sample: crate::analysis::sampling::Sample,
458    /// The dataset the sample's scope was chosen for: a partition or file scope means
459    /// nothing on another.
460    pub sample_dataset: Option<u64>,
461    /// The dataset a sample last ran on; after that, a tool with no result runs at once
462    /// instead of asking with the Sample form.
463    pub sample_run_for: Option<u64>,
464    /// The Sample form, while it is open.
465    pub sample_form: Option<crate::analysis::sample_modal::SampleForm>,
466    /// The tools' own sample, set aside while the view has its own sample, which every
467    /// tool then reads whole.
468    pub own_sample: Option<crate::analysis::sampling::Sample>,
469    pub table_state: TableState,              // For describe table
470    pub distribution_table_state: TableState, // For distribution table
471    pub correlation_table_state: TableState,  // For correlation matrix
472    pub sidebar_state: TableState,            // For sidebar tool list
473    /// Cached results per tool; each tool computes and stores its own state independently.
474    pub describe_results: Option<AnalysisResults>,
475    pub distribution_results: Option<AnalysisResults>,
476    pub correlation_results: Option<AnalysisResults>,
477    /// Set while a tool runs: its phase and progress, drawn in place of results.
478    pub computing: Option<AnalysisProgress>,
479    pub view: AnalysisView,
480    pub focus: AnalysisFocus,
481    /// None = no tool selected yet (show instructions); Some(tool) = user chose a tool (may be computing or showing results).
482    pub selected_tool: Option<AnalysisTool>,
483    pub selected_distribution: Option<usize>, // Selected row in distribution table
484    pub selected_correlation: Option<(usize, usize)>, // Selected cell in correlation matrix (row, col)
485    /// The coefficient the matrix and the pair detail show; both are computed.
486    pub correlation_method: crate::analysis::statistics::CorrelationMethod,
487    pub selected_theoretical_distribution: DistributionType, // Selected theoretical distribution for Q-Q plot
488    pub distribution_selector_state: TableState,             // For distribution selector list
489    pub histogram_scale: HistogramScale,
490    /// Data Quality: its plan, its report and where the cursor is in each.
491    pub quality: QualityState, // Scale for histogram (linear or log)
492    /// The view (`DataTableState::len_generation`) the results are of.
493    pub results_view: Option<u64>,
494    /// The results and positions a close put down, restored by the next open on the
495    /// same view.
496    kept: Option<Kept>,
497}
498
499/// Data Quality's part of the analysis modal: the plan, Setup's draft and its
500/// editors, the report and where the cursor stands on each of its pages.
501#[derive(Default)]
502pub struct QualityState {
503    pub results: Option<DataQualityResults>,
504    pub page: QualityPage,
505    /// The plan the last Run committed; while Setup is open, the draft for the next.
506    pub plan: DataQualityPlan,
507    /// The plan when Setup opened, restored by Esc; `None` while Setup is closed.
508    pub setup_before: Option<DataQualityPlan>,
509    /// The report page Setup was opened from, and goes back to.
510    pub setup_return: QualityPage,
511    /// Why Enter did not run, said on Setup's own line until the next edit.
512    pub setup_note: Option<String>,
513    pub table_state: TableState,
514    /// The list a Setup row's choices open in, while it is open.
515    pub picker: Option<PlanPicker>,
516    /// The Setup row under the cursor, or the role in the time roles editor.
517    pub plan_field: usize,
518    pub show_access: bool,
519    pub observation_detail: bool,
520    /// Where the finding popup is scrolled to, and how far it can go (set as it draws).
521    pub detail_scroll: DetailScroll,
522    /// The segment a drill-in shows, and the one Segments selects on the way back.
523    pub segment_index: usize,
524    /// Segments listed clearest change first rather than in their own order.
525    pub segments_by_change: bool,
526    /// The clean entry's popup lists every check rather than the most important.
527    pub checks_expanded: bool,
528    pub plan_before_edit: Option<DataQualityPlan>,
529    pub last_plan: Option<DataQualityPlan>,
530    pub from_cache: bool,
531    pub metric: QualityMetric,
532    pub column_index: usize,
533    /// The interval a detail shows, and the one Intervals selects on the way back.
534    pub interval_index: usize,
535    /// How Overview narrows and orders its findings; the report is not measured again.
536    pub findings: FindingsView,
537    /// A read for a finding's rows, shown with what it reads until Enter or Esc.
538    pub evidence_read: Option<EvidenceRead>,
539    /// The Trends line a bar detail shows, and the one Trends selects on the way back.
540    pub trend_line: usize,
541    /// The Expected editor, while it is open.
542    pub expected_form: Option<ExpectedForm>,
543    /// One column's declared intent, being edited over the Column intent list.
544    pub intent_form: Option<crate::analysis::intent_modal::IntentForm>,
545    /// The dialog that writes the report on screen to a file.
546    pub export: Option<crate::analysis::quality_export::ExportForm>,
547}
548
549impl QualityState {
550    /// A new dataset is on screen: findings narrowed to the last one's columns would
551    /// hide this one's, and a read for its rows was over the last one.
552    pub(crate) fn reset_for_dataset(&mut self) {
553        self.findings = FindingsView::default();
554        self.evidence_read = None;
555    }
556
557    /// What a close leaves: the plan, and how Segments is ordered.
558    fn close(&mut self) {
559        *self = Self {
560            plan: std::mem::take(&mut self.plan),
561            segments_by_change: self.segments_by_change,
562            ..Self::default()
563        };
564    }
565}
566
567/// The tools' results as a close left them, and the cursor in each.
568struct Kept {
569    view: Option<u64>,
570    tool: Option<AnalysisTool>,
571    describe: Option<AnalysisResults>,
572    distribution: Option<AnalysisResults>,
573    correlation: Option<AnalysisResults>,
574    row: Option<usize>,
575    distribution_row: Option<usize>,
576    cell: Option<(usize, usize)>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
580pub enum HistogramScale {
581    #[default]
582    Linear,
583    Log,
584}
585
586impl AnalysisModal {
587    /// A modal whose shared sample starts at the configured size: `[performance]
588    /// analysis_sample_rows`, where 0 means every row.
589    pub fn with_sample_rows(rows: usize) -> Self {
590        let mut modal = Self::default();
591        modal.sample.seed = crate::analysis::sample_modal::new_seed();
592        if rows == 0 {
593            modal.sample.method = crate::analysis::sampling::SampleMethod::EveryRow;
594        } else {
595            modal.sample.rows = rows;
596        }
597        modal
598    }
599
600    /// Open the screen on `view`: a close's results on this view come back as they
601    /// were; on any other view every tool starts empty.
602    pub fn open(&mut self, view: Option<u64>) {
603        let kept = self
604            .kept
605            .take()
606            .filter(|kept| view.is_some() && kept.view == view);
607        self.results_view = view;
608        self.scroll_position = 0;
609        self.selected_column = None;
610        self.describe_columns = ColumnScroll::default();
611        self.distribution_columns = ColumnScroll::default();
612        self.correlation_columns = ColumnScroll::default();
613        self.table_state.select(Some(0));
614        self.distribution_table_state.select(Some(0));
615        self.correlation_table_state.select(Some(0));
616        self.sidebar_state.select(Some(0)); // Highlight first tool; user must press Enter to select
617        self.view = AnalysisView::Main;
618        self.focus = AnalysisFocus::Sidebar; // Sidebar focused by default when no tool selected
619        self.selected_tool = None; // No tool until user selects from sidebar
620        self.selected_distribution = Some(0);
621        self.selected_correlation = None;
622        self.computing = None;
623        self.describe_results = None;
624        self.distribution_results = None;
625        self.correlation_results = None;
626        self.quality.close();
627        self.sample_form = None;
628        if let Some(kept) = kept {
629            self.describe_results = kept.describe;
630            self.distribution_results = kept.distribution;
631            self.correlation_results = kept.correlation;
632            self.selected_tool = kept.tool;
633            if self.current_results().is_none() {
634                self.selected_tool = None;
635            }
636            if let Some(tool) = self.selected_tool {
637                self.sidebar_state.select(Some(tool.index()));
638            }
639            self.table_state.select(kept.row.or(Some(0)));
640            self.distribution_table_state
641                .select(kept.distribution_row.or(Some(0)));
642            self.selected_distribution = kept.distribution_row.or(Some(0));
643            if let Some(cell) = kept.cell {
644                self.selected_correlation = Some(cell);
645                self.correlation_table_state.select(Some(cell.0));
646            }
647        }
648        if self.correlation_results.is_none() {
649            self.selected_correlation = None;
650        }
651    }
652
653    /// Close the screen, keeping the results for the next open on the same view.
654    pub fn close(&mut self) {
655        self.kept = Some(Kept {
656            view: self.results_view.take(),
657            tool: self
658                .selected_tool
659                .filter(|tool| *tool != AnalysisTool::DataQuality),
660            describe: self.describe_results.take(),
661            distribution: self.distribution_results.take(),
662            correlation: self.correlation_results.take(),
663            row: self.table_state.selected(),
664            distribution_row: self.distribution_table_state.selected(),
665            cell: self.selected_correlation,
666        });
667        self.scroll_position = 0;
668        self.selected_column = None;
669        self.describe_columns = ColumnScroll::default();
670        self.distribution_columns = ColumnScroll::default();
671        self.correlation_columns = ColumnScroll::default();
672        self.view = AnalysisView::Main;
673        self.focus = AnalysisFocus::Main;
674        self.selected_tool = None;
675        self.selected_distribution = None;
676        self.selected_correlation = None;
677        self.computing = None;
678        self.describe_results = None;
679        self.distribution_results = None;
680        self.correlation_results = None;
681        self.quality.results = None;
682        self.quality.page = QualityPage::Setup;
683        self.quality.setup_before = None;
684        self.quality.setup_note = None;
685        self.quality.picker = None;
686        self.quality.show_access = false;
687        self.quality.observation_detail = false;
688        self.quality.plan_before_edit = None;
689        self.quality.last_plan = None;
690        self.quality.from_cache = false;
691        self.quality.metric = QualityMetric::NullRate;
692        self.quality.column_index = 0;
693        self.quality.interval_index = 0;
694        self.quality.trend_line = 0;
695        self.quality.expected_form = None;
696        self.quality.intent_form = None;
697        self.quality.export = None;
698    }
699
700    /// Returns the cached results for the currently selected tool, if any.
701    pub fn current_results(&self) -> Option<&AnalysisResults> {
702        match self.selected_tool {
703            Some(AnalysisTool::Describe) => self.describe_results.as_ref(),
704            Some(AnalysisTool::DistributionAnalysis) => self.distribution_results.as_ref(),
705            Some(AnalysisTool::CorrelationMatrix) => self.correlation_results.as_ref(),
706            Some(AnalysisTool::DataQuality) => None,
707            None => None,
708        }
709    }
710
711    /// Tab on the main view: the tool list and the result trade focus. Not offered on
712    /// detail views (one focusable thing) or before a tool is chosen.
713    pub fn switch_focus(&mut self) {
714        self.focus = match self.focus {
715            AnalysisFocus::Sidebar if self.selected_tool.is_some() => AnalysisFocus::Main,
716            _ => AnalysisFocus::Sidebar,
717        };
718    }
719
720    /// The tool under the sidebar cursor.
721    pub fn highlighted_tool(&self) -> Option<AnalysisTool> {
722        AnalysisTool::ALL
723            .get(self.sidebar_state.selected()?)
724            .copied()
725    }
726
727    /// Select the tool under the sidebar cursor; the caller places the cursor.
728    pub fn select_tool(&mut self) {
729        if self.sidebar_state.selected().is_some() {
730            self.selected_tool = Some(self.highlighted_tool().unwrap_or_default());
731        }
732    }
733
734    pub fn next_tool(&mut self) {
735        if let Some(current) = self.sidebar_state.selected() {
736            let next = (current + 1).min(AnalysisTool::ALL.len() - 1);
737            self.sidebar_state.select(Some(next));
738        }
739    }
740
741    pub fn previous_tool(&mut self) {
742        if let Some(current) = self.sidebar_state.selected()
743            && current > 0
744        {
745            self.sidebar_state.select(Some(current - 1));
746        }
747    }
748
749    pub fn open_distribution_detail(&mut self) {
750        if self.focus == AnalysisFocus::Main
751            && self.selected_tool == Some(AnalysisTool::DistributionAnalysis)
752            && let Some(idx) = self.distribution_table_state.selected()
753        {
754            if let Some(results) = &self.distribution_results
755                && let Some(dist_analysis) = results.distribution_analyses.get(idx)
756            {
757                self.selected_theoretical_distribution = dist_analysis.distribution_type;
758            }
759            self.view = AnalysisView::DistributionDetail;
760            self.focus = AnalysisFocus::DistributionSelector;
761            if self.selected_theoretical_distribution == DistributionType::Unknown {
762                self.selected_theoretical_distribution = DistributionType::Normal;
763            }
764            self.distribution_selector_state.select(None);
765        }
766    }
767
768    pub fn open_correlation_detail(&mut self) {
769        if self.focus == AnalysisFocus::Main
770            && self.selected_tool == Some(AnalysisTool::CorrelationMatrix)
771            && let Some((row, col)) = self.selected_correlation
772            && row != col
773        {
774            self.view = AnalysisView::CorrelationDetail;
775        }
776    }
777
778    pub fn close_detail(&mut self) {
779        self.view = AnalysisView::Main;
780        self.focus = AnalysisFocus::Main;
781    }
782
783    pub fn scroll_left(&mut self) {
784        if let Some(columns) = self.column_scroll_mut() {
785            columns.left();
786        }
787    }
788
789    pub fn scroll_right(&mut self) {
790        if let Some(columns) = self.column_scroll_mut() {
791            columns.right();
792        }
793    }
794
795    /// The selected tool's statistic scroll, for the tools that scroll by statistic.
796    pub fn column_scroll(&self) -> Option<&ColumnScroll> {
797        match self.selected_tool {
798            Some(AnalysisTool::Describe) => Some(&self.describe_columns),
799            Some(AnalysisTool::DistributionAnalysis) => Some(&self.distribution_columns),
800            _ => None,
801        }
802    }
803
804    fn column_scroll_mut(&mut self) -> Option<&mut ColumnScroll> {
805        match self.selected_tool {
806            Some(AnalysisTool::Describe) => Some(&mut self.describe_columns),
807            Some(AnalysisTool::DistributionAnalysis) => Some(&mut self.distribution_columns),
808            _ => None,
809        }
810    }
811
812    /// Read the view's sample whole while it has one (`sampled`), and the tools' own
813    /// sample again once it has none.
814    pub fn follow_view_sample(&mut self, sampled: bool) {
815        match (sampled, self.own_sample.is_some()) {
816            (true, false) => {
817                let every = crate::analysis::sampling::Sample {
818                    scope: crate::analysis::data_quality::QualityScope::CurrentView,
819                    method: crate::analysis::sampling::SampleMethod::EveryRow,
820                    ..self.sample.clone()
821                };
822                self.own_sample = Some(std::mem::replace(&mut self.sample, every));
823            }
824            (false, true) => {
825                if let Some(own) = self.own_sample.take() {
826                    self.sample = own;
827                }
828            }
829            _ => {}
830        }
831    }
832
833    pub fn quality_row_count(&self) -> usize {
834        let Some(results) = self.quality.results.as_ref() else {
835            return 0;
836        };
837        match self.quality.page {
838            QualityPage::Setup => SetupRow::ALL.len(),
839            QualityPage::TimeRoles => TemporalRole::ALL.len(),
840            QualityPage::IntervalPairs => self.quality.plan.candidate_pairs().len(),
841            // The scope's columns are not held here: `App` moves the list by them.
842            QualityPage::Intent => 0,
843            QualityPage::Intervals => results.temporal.len(),
844            QualityPage::IntervalDetail => self.interval_facts().len(),
845            QualityPage::Overview => self.quality.findings.shown(results.report()).len(),
846            QualityPage::Columns | QualityPage::Detail => results.columns.len(),
847            QualityPage::Segments => results.segments.len(),
848            QualityPage::SegmentDetail => {
849                crate::analysis::data_quality::segment_changes(results, self.quality.segment_index)
850                    .len()
851            }
852            // The trend table's lines; the width only changes how many bars.
853            QualityPage::Trends => {
854                crate::analysis::quality_trends::trend_view(results, self.quality.metric, 1)
855                    .lines
856                    .len()
857            }
858            // At most one bar per segment: the width decides how many, and the page clamps
859            // the cursor as it draws.
860            QualityPage::TrendDetail => crate::analysis::quality_trends::trend_slots(results).len(),
861            QualityPage::Gaps => {
862                match crate::analysis::quality_trends::expected_gaps(
863                    self.quality_result_plan(),
864                    results,
865                ) {
866                    Some(crate::analysis::quality_trends::Gaps::Checked(check)) => check.runs.len(),
867                    _ => 0,
868                }
869            }
870            QualityPage::ExpectedWindows => EXPECTED_ROWS.len(),
871        }
872    }
873
874    /// Whether the highlighted Overview entry is the clean-columns entry, which opens
875    /// no rows and instead lists the checks.
876    pub fn quality_selected_is_clean(&self) -> bool {
877        self.quality.page == QualityPage::Overview
878            && self
879                .selected_finding()
880                .is_some_and(|(_, finding)| finding.kind.is_none())
881    }
882
883    /// The report on screen and the finding under the cursor, as Overview lists
884    /// them: narrowed and ordered.
885    pub fn selected_finding(&self) -> Option<(&QualityReport, Finding)> {
886        let report = self.quality.results.as_ref()?.report();
887        let finding = self
888            .quality
889            .findings
890            .selected(report, self.quality.table_state.selected()?)?
891            .clone();
892        Some((report, finding))
893    }
894
895    /// Narrow the findings to a column (`by_column`) or a check, from a list of
896    /// those the report has, the current one selected.
897    pub fn open_findings_picker(&mut self, by_column: bool) {
898        let Some(results) = self.quality.results.as_ref() else {
899            return;
900        };
901        let report = results.report();
902        let view = &self.quality.findings;
903        let findings = |count: usize| match count {
904            0 => "none".to_string(),
905            1 => "1 finding".to_string(),
906            count => format!("{} findings", crate::numfmt::group_chrome(count)),
907        };
908        let (title, choices, current) = if by_column {
909            let columns = crate::analysis::quality_report::column_choices(report, results);
910            let width = columns
911                .iter()
912                .map(|(name, _)| crate::glyphs::display_width(name))
913                .max()
914                .unwrap_or(0);
915            let current = view
916                .column
917                .as_ref()
918                .and_then(|column| columns.iter().position(|(name, _)| name == column))
919                .map_or(0, |position| position + 1);
920            let mut choices = vec![("All columns".to_string(), PlanChoice::FindingColumn(None))];
921            choices.extend(columns.into_iter().map(|(name, count)| {
922                let pad = width.saturating_sub(crate::glyphs::display_width(&name));
923                (
924                    format!("{name}{}  {}", " ".repeat(pad), findings(count)),
925                    PlanChoice::FindingColumn(Some(name)),
926                )
927            }));
928            ("Findings by Column", choices, current)
929        } else {
930            let checks = crate::analysis::quality_report::check_choices(report);
931            let width = checks
932                .iter()
933                .map(|(name, _)| crate::glyphs::display_width(name))
934                .max()
935                .unwrap_or(0);
936            let current = view
937                .check
938                .and_then(|check| checks.iter().position(|(name, _)| *name == check))
939                .map_or(0, |position| position + 1);
940            let mut choices = vec![("All types".to_string(), PlanChoice::FindingCheck(None))];
941            choices.extend(checks.into_iter().map(|(name, count)| {
942                let pad = width.saturating_sub(crate::glyphs::display_width(name));
943                (
944                    format!("{name}{}  {}", " ".repeat(pad), findings(count)),
945                    PlanChoice::FindingCheck(Some(name)),
946                )
947            }));
948            ("Findings by Type", choices, current)
949        };
950        let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
951        let mut state = crate::widgets::ui::PickerState::new(labels);
952        state.select_original(current);
953        self.quality.picker = Some(PlanPicker {
954            title: title.to_string(),
955            choices,
956            state,
957        });
958    }
959
960    /// The next order for the findings, keeping the finding under the cursor under it.
961    pub fn cycle_findings_order(&mut self) {
962        let selected = self.selected_finding().map(|(_, finding)| finding);
963        self.quality.findings.order = self.quality.findings.order.next();
964        self.reselect_finding(selected);
965    }
966
967    /// Show every finding again, in the order chosen.
968    pub fn clear_findings_narrowing(&mut self) {
969        let selected = self.selected_finding().map(|(_, finding)| finding);
970        self.quality.findings.column = None;
971        self.quality.findings.check = None;
972        self.reselect_finding(selected);
973    }
974
975    /// Put the cursor on `finding` where the list now shows it, or on the first.
976    fn reselect_finding(&mut self, finding: Option<Finding>) {
977        let position = self.quality.results.as_ref().and_then(|results| {
978            let report = results.report();
979            let finding = finding?;
980            let shown = self.quality.findings.shown(report);
981            shown.iter().position(|index| {
982                let listed = &report.findings[*index];
983                listed.same_as(&finding)
984            })
985        });
986        self.quality.table_state.select(Some(position.unwrap_or(0)));
987        *self.quality.table_state.offset_mut() = 0;
988    }
989
990    /// Whether `s` opens the Sample form: on a tool's main view with nothing else
991    /// holding keys (run, popup, editor, text field).
992    pub fn sample_key_opens_form(&self) -> bool {
993        self.view == AnalysisView::Main
994            && self.selected_tool.is_some()
995            && self.computing.is_none()
996            && self.quality.picker.is_none()
997            && !matches!(
998                self.quality.page,
999                QualityPage::TimeRoles
1000                    | QualityPage::IntervalPairs
1001                    | QualityPage::ExpectedWindows
1002                    | QualityPage::Intent
1003            )
1004            && !self.quality.show_access
1005            && !self.quality.observation_detail
1006            && self.quality.evidence_read.is_none()
1007            && self.quality.export.is_none()
1008            && self.quality.intent_form.is_none()
1009            && self.sample_form.is_none()
1010    }
1011
1012    /// Whether the export dialog's path owns typed characters.
1013    pub fn export_typing(&self) -> bool {
1014        self.quality
1015            .export
1016            .as_ref()
1017            .is_some_and(|form| !form.on_format)
1018    }
1019
1020    /// Whether a Column intent field owns typed characters, so Ctrl-C and `?` type.
1021    pub fn intent_typing(&self) -> bool {
1022        self.quality
1023            .intent_form
1024            .as_ref()
1025            .is_some_and(crate::analysis::intent_modal::IntentForm::typing)
1026    }
1027
1028    /// Whether the Sample form's scope field owns typed characters, so Ctrl-C and `?`
1029    /// type.
1030    pub fn sample_scope_typing(&self) -> bool {
1031        self.sample_form.as_ref().is_some_and(|form| {
1032            form.field.is_text() && (!form.inline || self.focus == AnalysisFocus::Main)
1033        })
1034    }
1035
1036    /// Whether the Expected editor's From or Before has the cursor, so all but its own
1037    /// keys type.
1038    pub fn quality_expected_typing(&self) -> bool {
1039        self.quality.page == QualityPage::ExpectedWindows
1040            && self
1041                .quality
1042                .expected_form
1043                .as_ref()
1044                .is_some_and(ExpectedForm::typing)
1045    }
1046
1047    /// Open the bars of the Trends line under the cursor, the first bar selected.
1048    pub fn open_trend_detail(&mut self) {
1049        let line = self.quality.table_state.selected().unwrap_or(0);
1050        self.quality.trend_line = line;
1051        self.set_quality_page(QualityPage::TrendDetail);
1052    }
1053
1054    /// The next measure in a bar's detail, on the same column's line. A column with
1055    /// nothing to draw in it has no line, and Trends lists what does.
1056    pub fn cycle_trend_detail_metric(&mut self) {
1057        let Some(results) = self.quality.results.as_ref() else {
1058            return;
1059        };
1060        let view = crate::analysis::quality_trends::trend_view(results, self.quality.metric, 1);
1061        let line = view
1062            .lines
1063            .get(self.quality.trend_line)
1064            .map(|line| (line.measure, line.names.clone()));
1065        self.cycle_quality_metric();
1066        let Some(results) = self.quality.results.as_ref() else {
1067            return;
1068        };
1069        let view = crate::analysis::quality_trends::trend_view(results, self.quality.metric, 1);
1070        let found = line.and_then(|(measure, names)| {
1071            view.lines.iter().position(|candidate| {
1072                candidate.measure == measure
1073                    || candidate
1074                        .names
1075                        .iter()
1076                        .any(|name| !candidate.rows() && names.contains(name))
1077            })
1078        });
1079        match found {
1080            Some(index) => self.quality.trend_line = index,
1081            None => {
1082                self.quality.trend_line = 0;
1083                self.close_to_trends();
1084            }
1085        }
1086    }
1087
1088    /// Back to Trends from a bar detail or the gaps, the line still selected.
1089    pub fn close_to_trends(&mut self) {
1090        let line =
1091            (self.quality.page == QualityPage::TrendDetail).then_some(self.quality.trend_line);
1092        self.set_quality_page(QualityPage::Trends);
1093        self.quality.table_state.select(Some(line.unwrap_or(0)));
1094    }
1095
1096    pub fn set_quality_page(&mut self, page: QualityPage) {
1097        self.quality.page = page;
1098        self.quality.observation_detail = false;
1099        self.quality.evidence_read = None;
1100        self.quality.table_state.select(Some(0));
1101    }
1102
1103    /// Move between the column lens and its detail, keeping the selected column.
1104    pub fn set_quality_column_page(&mut self, page: QualityPage) {
1105        // Detail moves the same selection with Up/Down, so take it from there too.
1106        if matches!(
1107            self.quality.page,
1108            QualityPage::Columns | QualityPage::Detail
1109        ) {
1110            self.quality.column_index = self.quality.table_state.selected().unwrap_or(0);
1111        }
1112        self.set_quality_page(page);
1113        self.quality
1114            .table_state
1115            .select(Some(self.quality.column_index));
1116    }
1117
1118    /// Open the highlighted segment's columns, or go back to the list with the
1119    /// segment still selected.
1120    pub fn open_segment_detail(&mut self) {
1121        if let Some(segment) = self.selected_segment() {
1122            self.quality.segment_index = segment;
1123            self.set_quality_page(QualityPage::SegmentDetail);
1124        }
1125    }
1126
1127    pub fn close_segment_detail(&mut self) {
1128        self.set_quality_page(QualityPage::Segments);
1129        let position = self
1130            .segment_order()
1131            .iter()
1132            .position(|segment| *segment == self.quality.segment_index);
1133        self.quality.table_state.select(Some(position.unwrap_or(0)));
1134    }
1135
1136    /// The order Segments lists its rows in.
1137    pub fn segment_order(&self) -> Vec<usize> {
1138        self.quality
1139            .results
1140            .as_ref()
1141            .map(|results| {
1142                crate::analysis::data_quality::segment_order(
1143                    results,
1144                    self.quality.segments_by_change,
1145                )
1146            })
1147            .unwrap_or_default()
1148    }
1149
1150    /// The segment under the cursor on Segments, whichever order it is listed in.
1151    pub fn selected_segment(&self) -> Option<usize> {
1152        let position = self.quality.table_state.selected()?;
1153        self.segment_order().get(position).copied()
1154    }
1155
1156    /// List segments in their own order or clearest change first, keeping the one
1157    /// under the cursor under it.
1158    pub fn toggle_segment_order(&mut self) {
1159        let segment = self.selected_segment();
1160        self.quality.segments_by_change = !self.quality.segments_by_change;
1161        let position = segment
1162            .and_then(|segment| self.segment_order().iter().position(|s| *s == segment))
1163            .unwrap_or(0);
1164        self.quality.table_state.select(Some(position));
1165    }
1166
1167    /// Open the highlighted interval's detail, its first count under the cursor.
1168    pub fn open_interval_detail(&mut self) {
1169        let Some(index) = self.quality.table_state.selected() else {
1170            return;
1171        };
1172        if self
1173            .quality
1174            .results
1175            .as_ref()
1176            .is_some_and(|results| index < results.temporal.len())
1177        {
1178            self.quality.interval_index = index;
1179            self.set_quality_page(QualityPage::IntervalDetail);
1180        }
1181    }
1182
1183    /// Back to the list, the interval still selected.
1184    pub fn close_interval_detail(&mut self) {
1185        self.set_quality_page(QualityPage::Intervals);
1186        self.quality
1187            .table_state
1188            .select(Some(self.quality.interval_index));
1189    }
1190
1191    /// The counts an interval's detail lists, each with rows it can open: those
1192    /// the interval measured, in the order the detail shows them.
1193    pub fn interval_facts(&self) -> Vec<IntervalFact> {
1194        let plan = self.quality_result_plan();
1195        self.quality
1196            .results
1197            .as_ref()
1198            .and_then(|results| results.temporal.get(self.quality.interval_index))
1199            .map(|profile| {
1200                IntervalFact::ALL
1201                    .into_iter()
1202                    .filter(|fact| profile.count(*fact, plan).is_some())
1203                    .collect()
1204            })
1205            .unwrap_or_default()
1206    }
1207
1208    /// The count under the cursor in an interval's detail.
1209    pub fn selected_interval_fact(&self) -> Option<IntervalFact> {
1210        let facts = self.interval_facts();
1211        facts
1212            .get(self.quality.table_state.selected().unwrap_or(0))
1213            .copied()
1214    }
1215
1216    /// The rows behind the selected count in an interval's detail, when its values
1217    /// identify them: a predicate over the run's rows, with a label. `schema` (where
1218    /// known) lets a partition segment compare in its column's type.
1219    pub fn interval_evidence(
1220        &self,
1221        schema: Option<&polars::prelude::Schema>,
1222    ) -> Option<(polars::prelude::Expr, String, usize)> {
1223        let results = self.quality.results.as_ref()?;
1224        if !matches!(
1225            results.precision,
1226            crate::analysis::data_quality::QualityPrecision::Exact
1227                | crate::analysis::data_quality::QualityPrecision::Sampled
1228        ) {
1229            return None;
1230        }
1231        let plan = self.quality_result_plan();
1232        let profile = results.temporal.get(self.quality.interval_index)?;
1233        let fact = self.selected_interval_fact()?;
1234        let (count, _) = profile.count(fact, plan)?;
1235        if count == 0 {
1236            return None;
1237        }
1238        let predicate = profile.evidence_predicate(fact, plan, schema)?;
1239        Some((
1240            predicate,
1241            format!(
1242                "Data Quality / {} / {} / {}",
1243                profile.label(),
1244                profile.segment,
1245                fact.short()
1246            ),
1247            count,
1248        ))
1249    }
1250
1251    /// Move the finding popup by `rows`, within what it last drew.
1252    pub fn scroll_quality_detail(&mut self, rows: i32) {
1253        let scroll = &mut self.quality.detail_scroll;
1254        scroll.offset = (scroll.offset as i32 + rows).clamp(0, scroll.max as i32) as u16;
1255    }
1256
1257    /// Show a tab, keeping the column in view across Columns, Segments and Trends.
1258    pub fn show_quality_tab(&mut self, page: QualityPage) {
1259        if matches!(
1260            self.quality.page,
1261            QualityPage::Columns | QualityPage::Detail
1262        ) {
1263            self.quality.column_index = self.quality.table_state.selected().unwrap_or(0);
1264        }
1265        self.set_quality_page(page);
1266        if page == QualityPage::Columns {
1267            self.quality
1268                .table_state
1269                .select(Some(self.quality.column_index));
1270        }
1271    }
1272
1273    /// The next or previous tab, stopping at either end.
1274    pub fn step_quality_tab(&mut self, forward: bool) {
1275        let tabs = QualityPage::TABS;
1276        let Some(at) = tabs
1277            .iter()
1278            .position(|page| *page == self.quality.page.tab())
1279        else {
1280            return;
1281        };
1282        let next = if forward {
1283            (at + 1).min(tabs.len() - 1)
1284        } else {
1285            at.saturating_sub(1)
1286        };
1287        if next != at {
1288            self.show_quality_tab(tabs[next]);
1289        }
1290    }
1291
1292    pub fn cycle_quality_metric(&mut self) {
1293        let current = QualityMetric::ALL
1294            .iter()
1295            .position(|metric| *metric == self.quality.metric)
1296            .unwrap_or(0);
1297        self.quality.metric = QualityMetric::ALL[(current + 1) % QualityMetric::ALL.len()];
1298    }
1299
1300    /// The plan the on-screen result was measured with (the plan until a run exists),
1301    /// so a draft never relabels what was measured.
1302    pub fn quality_result_plan(&self) -> &DataQualityPlan {
1303        self.quality
1304            .last_plan
1305            .as_ref()
1306            .unwrap_or(&self.quality.plan)
1307    }
1308
1309    /// The plan differs from the one the result on screen was measured with.
1310    pub fn quality_plan_pending(&self) -> bool {
1311        self.quality.results.is_some()
1312            && self.quality.last_plan.as_ref() != Some(&self.quality.plan)
1313    }
1314
1315    /// The Setup row under the cursor.
1316    pub fn setup_row(&self) -> SetupRow {
1317        SetupRow::at(self.quality.plan_field)
1318    }
1319
1320    /// Whether Setup holds staged changes Esc would discard.
1321    pub fn setup_edited(&self) -> bool {
1322        self.quality
1323            .setup_before
1324            .as_ref()
1325            .is_some_and(|before| *before != self.quality.plan)
1326    }
1327
1328    /// The choices a Setup row offers, in the words the header uses.
1329    pub fn plan_choices(&self, row: SetupRow, context: &PlanContext) -> Vec<(String, PlanChoice)> {
1330        match row {
1331            SetupRow::Grain => {
1332                let mut grains = vec![QualityGrain::Dataset];
1333                if context.files {
1334                    grains.push(QualityGrain::File);
1335                }
1336                grains.extend(
1337                    context
1338                        .partitions
1339                        .iter()
1340                        .cloned()
1341                        .map(QualityGrain::Partition),
1342                );
1343                // Any date column can split by day, week or month; hours only where there are times.
1344                for (column, has_time) in &context.time_columns {
1345                    for every in QUALITY_WINDOW_WIDTHS {
1346                        if every == "1h" && !has_time {
1347                            continue;
1348                        }
1349                        grains.push(QualityGrain::TimeWindows {
1350                            column: column.clone(),
1351                            every: every.to_string(),
1352                        });
1353                    }
1354                }
1355                grains.extend([100_000, 1_000_000].map(QualityGrain::RowChunks));
1356                if !grains.contains(&self.quality.plan.grain) {
1357                    grains.insert(0, self.quality.plan.grain.clone());
1358                }
1359                grains
1360                    .into_iter()
1361                    .map(|grain| (grain.label(), PlanChoice::Grain(grain)))
1362                    .collect()
1363            }
1364            SetupRow::Values => {
1365                // The draft's sample says which kind of read it is.
1366                let read = if self.quality.plan.method
1367                    == crate::analysis::sampling::SampleMethod::EveryRow
1368                {
1369                    QualityCompute::Full
1370                } else {
1371                    QualityCompute::Sample
1372                };
1373                vec![
1374                    ("read".to_string(), PlanChoice::Values(read)),
1375                    (
1376                        "file metadata only".to_string(),
1377                        PlanChoice::Values(QualityCompute::Metadata),
1378                    ),
1379                ]
1380            }
1381            SetupRow::Compare => [
1382                QualityComparison::None,
1383                QualityComparison::Previous,
1384                QualityComparison::Baseline,
1385            ]
1386            .into_iter()
1387            .map(|comparison| {
1388                (
1389                    comparison.choice_label().to_string(),
1390                    PlanChoice::Compare(comparison),
1391                )
1392            })
1393            .collect(),
1394            SetupRow::WindowBy if !self.quality.plan.windows_intervals() => Vec::new(),
1395            SetupRow::WindowBy => IntervalClock::ALL
1396                .into_iter()
1397                .map(|clock| (clock.label().to_string(), PlanChoice::Clock(clock)))
1398                .collect(),
1399            SetupRow::Latency if self.quality.plan.interval_pairs().is_empty() => Vec::new(),
1400            SetupRow::Latency => [None, Some(3_600), Some(86_400), Some(604_800)]
1401                .into_iter()
1402                .map(|seconds| (threshold_label(seconds), PlanChoice::Latency(seconds)))
1403                .collect(),
1404            // Each text column with its first value on screen, so the choice is among things
1405            // seen.
1406            SetupRow::TextAsTime => context
1407                .text_columns
1408                .iter()
1409                .map(|(column, examples)| {
1410                    let label = match (self.quality.plan.time_format(column), examples.first()) {
1411                        (Some(format), _) => format!("{column}  as {}", format.label()),
1412                        (None, Some(example)) => format!("{column}  {example}"),
1413                        (None, None) => column.clone(),
1414                    };
1415                    (label, PlanChoice::TextColumn(column.clone()))
1416                })
1417                .collect(),
1418            SetupRow::Sample
1419            | SetupRow::TimeRoles
1420            | SetupRow::Intervals
1421            | SetupRow::Expected
1422            | SetupRow::Intent => Vec::new(),
1423        }
1424    }
1425
1426    /// Open the focused Setup row's choices, the current one selected.
1427    pub fn open_plan_picker(&mut self, row: SetupRow, context: &PlanContext) {
1428        let choices = self.plan_choices(row, context);
1429        self.quality.plan_field = row.index();
1430        self.show_picker(row.label().to_string(), choices);
1431    }
1432
1433    fn show_picker(&mut self, title: String, choices: Vec<(String, PlanChoice)>) {
1434        if choices.is_empty() {
1435            return;
1436        }
1437        let current = choices
1438            .iter()
1439            .position(|(_, choice)| choice.is_current(&self.quality.plan))
1440            .unwrap_or(0);
1441        let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1442        let mut state = crate::widgets::ui::PickerState::new(labels);
1443        state.select_original(current);
1444        self.quality.picker = Some(PlanPicker {
1445            title,
1446            choices,
1447            state,
1448        });
1449    }
1450
1451    /// How `column` can be read as time: each format with how many of the on-screen
1452    /// `examples` it reads, best first, and reading as text again when it has a format.
1453    pub fn open_format_picker(&mut self, column: &str, examples: &[String]) {
1454        let mut formats = TIME_FORMATS
1455            .iter()
1456            .enumerate()
1457            .map(|(order, (kind, format))| {
1458                let interpretation = TimeInterpretation {
1459                    column: column.to_string(),
1460                    kind: *kind,
1461                    format: format.to_string(),
1462                };
1463                let read = examples
1464                    .iter()
1465                    .filter(|value| interpretation.reads(value))
1466                    .count();
1467                (read, order, *kind, *format)
1468            })
1469            .collect::<Vec<_>>();
1470        // Most read first; the offered order among equals, so the list is stable.
1471        formats.sort_by_key(|(read, order, _, _)| (std::cmp::Reverse(*read), *order));
1472        let mut choices = formats
1473            .into_iter()
1474            .map(|(read, _, kind, format)| {
1475                // The count first, so a narrow list clips the format, not the evidence.
1476                let label = if examples.is_empty() {
1477                    format!("{} {format}", kind.label())
1478                } else {
1479                    format!(
1480                        "reads {read} of {}  {} {format}",
1481                        examples.len(),
1482                        kind.label()
1483                    )
1484                };
1485                (
1486                    label,
1487                    PlanChoice::Format(column.to_string(), Some((kind, format))),
1488                )
1489            })
1490            .collect::<Vec<_>>();
1491        if self.quality.plan.time_format(column).is_some() {
1492            choices.push((
1493                "text, not a time".to_string(),
1494                PlanChoice::Format(column.to_string(), None),
1495            ));
1496        }
1497        self.show_picker(format!("Read {column} As"), choices);
1498    }
1499
1500    /// Take the picker's selection into the plan and close it. Returns a text column
1501    /// chosen to read as time: its format is the next choice.
1502    pub fn choose_plan_picker(&mut self) -> Option<String> {
1503        let picker = self.quality.picker.take()?;
1504        let choice = picker
1505            .state
1506            .selected_original()
1507            .and_then(|index| picker.choices.get(index))?;
1508        let plan = &mut self.quality.plan;
1509        match choice.clone() {
1510            PlanChoice::Grain(grain) => {
1511                if plan.grain != grain {
1512                    plan.baseline_segment = None;
1513                }
1514                plan.grain = grain;
1515            }
1516            PlanChoice::Values(compute) => plan.compute = compute,
1517            PlanChoice::Compare(comparison) => {
1518                plan.comparison = comparison;
1519                if comparison != QualityComparison::Baseline {
1520                    plan.baseline_segment = None;
1521                }
1522            }
1523            PlanChoice::Latency(seconds) => plan.latency_threshold_seconds = seconds,
1524            PlanChoice::Clock(clock) => plan.interval_clock = clock,
1525            PlanChoice::TextColumn(column) => return Some(column),
1526            PlanChoice::Format(column, format) => set_time_format(plan, &column, format),
1527            PlanChoice::FindingColumn(column) => {
1528                let selected = self.selected_finding().map(|(_, finding)| finding);
1529                self.quality.findings.column = column;
1530                self.reselect_finding(selected);
1531            }
1532            PlanChoice::FindingCheck(check) => {
1533                let selected = self.selected_finding().map(|(_, finding)| finding);
1534                self.quality.findings.check = check;
1535                self.reselect_finding(selected);
1536            }
1537        }
1538        None
1539    }
1540
1541    /// The next or previous choice of a Setup row whose choices are a short list,
1542    /// in place: ←→ on Grain, Compare, Values, Latency and Window by.
1543    pub fn cycle_setup_choice(&mut self, row: SetupRow, context: &PlanContext, forward: bool) {
1544        let choices = self.plan_choices(row, context);
1545        if choices.is_empty() || matches!(row, SetupRow::TextAsTime) {
1546            return;
1547        }
1548        let current = choices
1549            .iter()
1550            .position(|(_, choice)| choice.is_current(&self.quality.plan))
1551            .unwrap_or(0);
1552        let next = if forward {
1553            (current + 1).min(choices.len() - 1)
1554        } else {
1555            current.saturating_sub(1)
1556        };
1557        let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1558        let mut state = crate::widgets::ui::PickerState::new(labels);
1559        state.select_original(next);
1560        self.quality.picker = Some(PlanPicker {
1561            title: row.label().to_string(),
1562            choices,
1563            state,
1564        });
1565        self.choose_plan_picker();
1566    }
1567
1568    pub fn cycle_quality_time_role(
1569        &mut self,
1570        role_index: usize,
1571        columns: &[String],
1572        forward: bool,
1573    ) {
1574        let Some(role) = TemporalRole::ALL.get(role_index).copied() else {
1575            return;
1576        };
1577        let current = self
1578            .quality
1579            .plan
1580            .temporal_roles
1581            .iter()
1582            .find(|assignment| assignment.role == role)
1583            .and_then(|assignment| columns.iter().position(|name| name == &assignment.column))
1584            .map(|index| index + 1)
1585            .unwrap_or(0);
1586        let choices = columns.len() + 1;
1587        let next = if forward {
1588            (current + 1) % choices
1589        } else if current == 0 {
1590            choices - 1
1591        } else {
1592            current - 1
1593        };
1594        self.quality
1595            .plan
1596            .temporal_roles
1597            .retain(|assignment| assignment.role != role);
1598        if next > 0 {
1599            self.quality
1600                .plan
1601                .temporal_roles
1602                .push(TemporalRoleAssignment {
1603                    role,
1604                    column: columns[next - 1].clone(),
1605                    timezone: None,
1606                });
1607        }
1608    }
1609
1610    /// Move the focused tool's cursor by `step` in `rows` rows, ten to a page. The
1611    /// correlation matrix moves its row, keeping its column; Home and End take the first
1612    /// and last off-diagonal pair.
1613    pub fn move_row(&mut self, step: crate::app::form::ListMove, rows: usize) {
1614        use crate::app::form::ListMove;
1615        const PAGE: usize = 10;
1616        let to = |at: Option<usize>| match (at, step) {
1617            (Some(at), _) => Some(step.apply(at, rows, PAGE)),
1618            // Nothing selected yet: Down and Home start the cursor at the top, End at
1619            // the bottom.
1620            (None, ListMove::Down | ListMove::Home) => Some(0),
1621            (None, ListMove::End) => rows.checked_sub(1),
1622            (None, _) => None,
1623        };
1624        match self.selected_tool {
1625            Some(AnalysisTool::Describe) => {
1626                if let Some(next) = to(self.table_state.selected()) {
1627                    self.table_state.select(Some(next));
1628                }
1629            }
1630            Some(AnalysisTool::DistributionAnalysis) => {
1631                if let Some(next) = to(self.distribution_table_state.selected()) {
1632                    self.distribution_table_state.select(Some(next));
1633                    self.selected_distribution = Some(next);
1634                }
1635            }
1636            Some(AnalysisTool::CorrelationMatrix) => {
1637                let n = self.correlation_size();
1638                let cell = match (step, self.selected_correlation) {
1639                    (ListMove::Down, _) => return self.move_correlation_cell((1, 0)),
1640                    (ListMove::Home, _) if n > 0 => (0, 1.min(n - 1)),
1641                    (ListMove::End, _) if n > 0 => (n - 1, (n - 1).saturating_sub(1)),
1642                    (ListMove::Up | ListMove::PageUp | ListMove::PageDown, Some((row, col))) => {
1643                        (step.apply(row, rows, PAGE), col)
1644                    }
1645                    _ => return,
1646                };
1647                self.selected_correlation = Some(cell);
1648                self.correlation_table_state.select(Some(cell.0));
1649            }
1650            Some(AnalysisTool::DataQuality) => {
1651                let at = self.quality.table_state.selected().unwrap_or(0);
1652                self.quality
1653                    .table_state
1654                    .select(Some(step.apply(at, rows, PAGE)));
1655            }
1656            None => {}
1657        }
1658    }
1659
1660    /// Install the matrix a run read. The cursor stays if still a cell, else starts on
1661    /// the first off-diagonal pair, where Enter opens a detail.
1662    pub fn install_correlations(&mut self, results: AnalysisResults) {
1663        let n = results
1664            .correlation_matrix
1665            .as_ref()
1666            .map_or(0, |matrix| matrix.columns.len());
1667        self.correlation_results = Some(results);
1668        let cell = match self.selected_correlation {
1669            Some((row, col)) if row < n && col < n => (row, col),
1670            _ if n >= 2 => (0, 1),
1671            _ => (0, 0),
1672        };
1673        self.selected_correlation = Some(cell);
1674        self.correlation_table_state.select(Some(cell.0));
1675    }
1676
1677    /// The number of columns in the correlation matrix on screen.
1678    pub fn correlation_size(&self) -> usize {
1679        self.correlation_results
1680            .as_ref()
1681            .and_then(|results| results.correlation_matrix.as_ref())
1682            .map_or(0, |matrix| matrix.columns.len())
1683    }
1684
1685    /// The families the distribution detail's selector lists for the column under
1686    /// the cursor.
1687    pub fn distribution_choices(&self) -> usize {
1688        let row = self.distribution_table_state.selected().unwrap_or(0);
1689        self.distribution_results
1690            .as_ref()
1691            .and_then(|results| results.distribution_analyses.get(row))
1692            .map_or(0, |analysis| {
1693                crate::analysis::distribution_fit::listing_order(&analysis.fits).len()
1694            })
1695    }
1696
1697    /// Move the correlation cursor by `(rows, columns)`, stopping at edges; the matrix
1698    /// scrolls to keep it in view as it draws.
1699    pub fn move_correlation_cell(&mut self, (rows, cols): (isize, isize)) {
1700        let n = self.correlation_size();
1701        if n == 0 {
1702            return;
1703        }
1704        if let Some((row, col)) = self.selected_correlation {
1705            let row = row.saturating_add_signed(rows).min(n - 1);
1706            let col = col.saturating_add_signed(cols).min(n - 1);
1707            self.selected_correlation = Some((row, col));
1708            self.correlation_table_state.select(Some(row));
1709        }
1710    }
1711
1712    pub fn next_distribution(&mut self) {
1713        let max_idx = self.distribution_choices().saturating_sub(1);
1714
1715        if let Some(current) = self.distribution_selector_state.selected() {
1716            let next = (current + 1).min(max_idx);
1717            self.distribution_selector_state.select(Some(next));
1718            self.select_distribution();
1719        } else {
1720            self.distribution_selector_state.select(Some(0));
1721            self.select_distribution();
1722        }
1723    }
1724
1725    pub fn previous_distribution(&mut self) {
1726        if let Some(current) = self.distribution_selector_state.selected() {
1727            if current > 0 {
1728                self.distribution_selector_state.select(Some(current - 1));
1729                self.select_distribution();
1730            }
1731        } else {
1732            self.distribution_selector_state.select(Some(0));
1733            self.select_distribution();
1734        }
1735    }
1736
1737    pub fn select_distribution(&mut self) {
1738        if let Some(idx) = self.distribution_selector_state.selected()
1739            && let Some(results) = &self.distribution_results
1740        {
1741            let dist_analysis_idx = self.distribution_table_state.selected().unwrap_or(0);
1742            if let Some(dist_analysis) = results.distribution_analyses.get(dist_analysis_idx) {
1743                // The order the selector lists them in.
1744                let distribution_scores =
1745                    crate::analysis::distribution_fit::listing_order(&dist_analysis.fits);
1746                let valid_idx = idx.min(distribution_scores.len().saturating_sub(1));
1747                if let Some(dist_type) = distribution_scores.get(valid_idx) {
1748                    self.selected_theoretical_distribution = *dist_type;
1749                    if idx != valid_idx {
1750                        self.distribution_selector_state.select(Some(valid_idx));
1751                    }
1752                }
1753            }
1754        }
1755    }
1756}
1757
1758#[cfg(test)]
1759mod quality_scope_tests {
1760    use super::*;
1761
1762    /// Weekdays stated for days, then a coarser grain: the editor offers what the
1763    /// grain allows and shows what Setup and the check read, every week.
1764    #[test]
1765    fn expected_weekdays_read_as_every_window_on_weeks() {
1766        let theme =
1767            crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
1768        let mut plan = DataQualityPlan {
1769            grain: QualityGrain::TimeWindows {
1770                column: "day".to_string(),
1771                every: "1d".to_string(),
1772            },
1773            expected: Some(crate::analysis::data_quality::ExpectedWindows {
1774                weekdays: true,
1775                ..Default::default()
1776            }),
1777            ..DataQualityPlan::default()
1778        };
1779        assert_eq!(
1780            ExpectedForm::new(&plan, &theme).cadence,
1781            ExpectedCadence::Weekdays
1782        );
1783        plan.grain = QualityGrain::TimeWindows {
1784            column: "day".to_string(),
1785            every: "1w".to_string(),
1786        };
1787        let form = ExpectedForm::new(&plan, &theme);
1788        assert_eq!(form.cadence, ExpectedCadence::Every);
1789        assert_eq!(form.cadence_label("1w"), "every week");
1790        assert!(!form.expected().unwrap().unwrap().weekdays);
1791    }
1792
1793    /// Grain choices come from the data: files, partition columns, and a day, week
1794    /// or month of any date column (hours only where there are times), then chunks.
1795    #[test]
1796    fn grain_choices_come_from_the_data() {
1797        let mut modal = AnalysisModal::default();
1798        let context = PlanContext {
1799            partitions: vec!["year".to_string()],
1800            time_columns: vec![("date".to_string(), false), ("stamp".to_string(), true)],
1801            files: true,
1802            text_columns: Vec::new(),
1803        };
1804        let labels = modal
1805            .plan_choices(SetupRow::Grain, &context)
1806            .into_iter()
1807            .map(|(label, _)| label)
1808            .collect::<Vec<_>>();
1809        assert_eq!(
1810            labels,
1811            [
1812                "whole dataset",
1813                "by file",
1814                "by year",
1815                "by day of date",
1816                "by week of date",
1817                "by month of date",
1818                "by hour of stamp",
1819                "by day of stamp",
1820                "by week of stamp",
1821                "by month of stamp",
1822                "in chunks of 100,000 rows",
1823                "in chunks of 1,000,000 rows",
1824            ]
1825        );
1826        // Choosing opens the list on the current value and sets the one chosen.
1827        modal.open_plan_picker(SetupRow::Grain, &context);
1828        let picker = modal.quality.picker.as_mut().unwrap();
1829        assert_eq!(picker.state.selected_original(), Some(0));
1830        picker.state.move_down();
1831        picker.state.move_down();
1832        picker.state.move_down();
1833        modal.choose_plan_picker();
1834        assert!(modal.quality.picker.is_none());
1835        assert_eq!(
1836            modal.quality.plan.grain,
1837            QualityGrain::TimeWindows {
1838                column: "date".to_string(),
1839                every: "1d".to_string()
1840            }
1841        );
1842    }
1843
1844    /// Values: read or metadata only; which of sample and full scan a read is, the
1845    /// shared sample says.
1846    #[test]
1847    fn a_read_is_the_samples_kind() {
1848        let mut modal = AnalysisModal::default();
1849        let context = PlanContext::default();
1850        modal.quality.plan.compute = QualityCompute::Metadata;
1851        modal.open_plan_picker(SetupRow::Values, &context);
1852        modal
1853            .quality
1854            .picker
1855            .as_mut()
1856            .unwrap()
1857            .state
1858            .select_original(0);
1859        modal.choose_plan_picker();
1860        assert_eq!(modal.quality.plan.compute, QualityCompute::Sample);
1861        modal.quality.plan.method = crate::analysis::sampling::SampleMethod::EveryRow;
1862        modal.open_plan_picker(SetupRow::Values, &context);
1863        modal.choose_plan_picker();
1864        assert_eq!(modal.quality.plan.compute, QualityCompute::Full);
1865        modal.open_plan_picker(SetupRow::Latency, &context);
1866        assert!(
1867            modal.quality.picker.is_none(),
1868            "no threshold without an interval to measure"
1869        );
1870    }
1871
1872    /// A text column is read as time in two choices, the column and then its
1873    /// format, the formats that read the values on screen first; reading it as text
1874    /// again takes back a time window that needed it.
1875    #[test]
1876    fn text_is_read_as_time_through_a_chosen_format() {
1877        let mut modal = AnalysisModal::default();
1878        let context = PlanContext {
1879            text_columns: vec![(
1880                "created".to_string(),
1881                vec!["2024-01-31 08:15:00".to_string()],
1882            )],
1883            ..PlanContext::default()
1884        };
1885        modal.open_plan_picker(SetupRow::TextAsTime, &context);
1886        assert_eq!(modal.choose_plan_picker(), Some("created".to_string()));
1887        modal.open_format_picker("created", &["2024-01-31 08:15:00".to_string()]);
1888        let picker = modal.quality.picker.as_ref().unwrap();
1889        assert_eq!(picker.title, "Read created As");
1890        let first = picker.state.filtered()[0].1.to_string();
1891        assert_eq!(first, "reads 1 of 1  datetime %Y-%m-%d %H:%M:%S");
1892        assert_eq!(modal.choose_plan_picker(), None);
1893        let format = modal.quality.plan.time_format("created").unwrap();
1894        assert_eq!(format.kind, TimeKind::Datetime);
1895
1896        // Now a time window can split by it; as text again, the window goes.
1897        let windows = PlanContext {
1898            time_columns: vec![("created".to_string(), true)],
1899            ..context.clone()
1900        };
1901        modal.open_plan_picker(SetupRow::Grain, &windows);
1902        let picker = modal.quality.picker.as_mut().unwrap();
1903        picker.state.move_down();
1904        picker.state.move_down();
1905        modal.choose_plan_picker();
1906        assert!(matches!(
1907            modal.quality.plan.grain,
1908            QualityGrain::TimeWindows { .. }
1909        ));
1910        modal.open_format_picker("created", &[]);
1911        let picker = modal.quality.picker.as_mut().unwrap();
1912        let text = picker
1913            .state
1914            .filtered()
1915            .iter()
1916            .position(|(_, label)| *label == "text, not a time")
1917            .unwrap();
1918        for _ in 0..text {
1919            picker.state.move_down();
1920        }
1921        modal.choose_plan_picker();
1922        assert!(modal.quality.plan.time_formats.is_empty());
1923        assert_eq!(modal.quality.plan.grain, QualityGrain::Dataset);
1924    }
1925}