Skip to main content

datui_lib/
lib.rs

1use color_eyre::Result;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use polars::datatypes::DataType;
4use polars::prelude::{DataFrame, LazyFrame, col};
5use std::collections::HashMap;
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, mpsc::Sender};
9use widgets::info::{FileFacts, InfoModal};
10
11use ratatui::style::Style;
12use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
13
14use ratatui::widgets::{Block, Clear};
15
16pub mod analysis;
17pub mod app;
18pub mod cache;
19pub mod canonical;
20pub mod chart;
21pub mod cli;
22pub mod clipboard;
23pub mod cloud;
24pub mod commands;
25pub mod config;
26pub mod error_display;
27pub mod exact;
28pub mod export;
29pub mod find;
30pub mod formats;
31pub mod glyphs;
32pub mod home;
33pub mod inspector;
34pub mod limits;
35pub mod loading;
36pub mod logging;
37pub mod notes;
38pub mod numfmt;
39pub use app::applied::Applied;
40pub use app::overlay::Overlay;
41pub mod past_calendar;
42pub use app::run::{ended_by_signal, run, run_captured};
43// Public for the fuzz targets in `fuzz/`.
44pub mod query;
45mod render;
46pub mod sanitize;
47pub mod table;
48pub mod typed_value;
49pub mod view;
50pub mod widgets;
51
52pub use cache::CacheManager;
53pub use cli::Args;
54pub use config::{
55    AppConfig, ColorParser, ConfigManager, QueryMode, Theme, ThemeMode, rgb_to_256_color,
56    rgb_to_basic_ansi,
57};
58
59use analysis::analysis_modal::{AnalysisModal, AnalysisProgress};
60use app::background::{CacheWrites, InflightCollect, LenCount, OwedCount};
61use chart::chart_export::ChartExportRequest;
62use chart::chart_export_modal::ChartExportModal;
63use chart::chart_jobs::ChartRequest;
64use chart::chart_modal::ChartColumns;
65
66pub use analysis::quality_memory::{KeptQualitySample, QUALITY_MEMORY_BUDGET, RetainedCopy};
67use app::feedback::Confirm;
68pub use app::feedback::{ConfirmationModal, ErrorModal, Flash};
69use app::jobs::{Answer, Job, Jobs, Outcome};
70pub use app::jobs::{JobKind, Progress, Ticket};
71use app::modals::filter_modal::{FilterOperator, FilterStatement, LogicalOperator};
72use app::modals::pivot_melt_modal::PivotMeltModal;
73use app::modals::sort_filter_modal::SortFilterModal;
74use app::modals::sort_modal::{SortColumn, order_with_hidden};
75pub use error_display::{ErrorKindForPython, error_for_python};
76use export::export_modal::{ExportFocus, ExportModal};
77use export::output_file::Overwrite;
78pub use export::{ExportOptions, ExportRequest};
79pub use loading::open_options::{
80    OpenOptions, ParseStringsTarget, ReadReport, SqliteOpen, TypedDialect, UnaskedDownload,
81};
82pub use loading::unfinished::ExitSweep;
83use numfmt::NumberFormatSettings;
84use table::{DataTableState, DrillRow};
85pub use view::{SavedView, ViewManager, Views};
86use widgets::column_widths::WidthChoice;
87use widgets::debug::DebugState;
88use widgets::text_input::TextInput;
89use widgets::view_modal::{FormFocus, ViewModal, ViewModalMode};
90
91/// Application name, used for cache and config paths.
92pub const APP_NAME: &str = "datui";
93
94/// What a file no reader takes, and no hex view can show, is told.
95pub(crate) const UNSUPPORTED: &str =
96    "Unsupported file type. --format names the format to read it as.";
97
98pub use cli::{CompressionFormat, FileFormat, ReadMode, RemoteRead, Stored, Summary};
99
100#[cfg(test)]
101pub mod tests;
102
103/// A move through the table's rows.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Scroll {
106    Next,
107    Prev,
108    PageDown,
109    PageUp,
110    HalfDown,
111    HalfUp,
112    Start,
113    End,
114}
115
116impl Scroll {
117    /// Rows the move goes, to ask whether the buffer holds where it lands.
118    fn delta(self, state: &DataTableState) -> i64 {
119        let page = state.visible_rows as i64;
120        let half = (state.visible_rows / 2).max(1) as i64;
121        match self {
122            Scroll::Next => 1,
123            Scroll::Prev => -1,
124            Scroll::PageDown => page,
125            Scroll::PageUp => -page,
126            Scroll::HalfDown => half,
127            Scroll::HalfUp => -half,
128            Scroll::Start | Scroll::End => 0,
129        }
130    }
131
132    /// Make the move; true when the rows it lands on have to be read.
133    fn run(self, state: &mut DataTableState) -> bool {
134        match self {
135            Scroll::Next => state.select_next(),
136            Scroll::Prev => state.select_previous(),
137            Scroll::PageDown => state.page_down(),
138            Scroll::PageUp => state.page_up(),
139            Scroll::HalfDown => state.half_page_down(),
140            Scroll::HalfUp => state.half_page_up(),
141            Scroll::Start => state.scroll_to_start(),
142            Scroll::End => state.scroll_to_end(),
143        }
144    }
145}
146
147pub enum AppEvent {
148    Key(KeyEvent),
149    /// A key taken as if typed (Enter on a help line). The pump runs it through
150    /// `classify` like a typed key; outside the pump it is a `Key`.
151    Press(KeyEvent),
152    /// Read from the terminal by [`app::terminal_input::TerminalInput`]. The
153    /// [`app::event_pump::EventPump`] turns it into `Key`/`Resize`.
154    Terminal(crossterm::event::Event),
155    /// Something polled rather than sent changed (a background panic, a Polars
156    /// warning): wakes the loop. Handled as nothing.
157    Wake,
158    /// The terminal's background (an OSC 11 reply); `theme.mode = "auto"` follows it.
159    TerminalBackground(ThemeMode),
160    /// The terminal regained focus: under `auto` the background is asked again.
161    TerminalFocused,
162    /// Settings `run` reads on a worker before building the app; never reaches the app.
163    SettingsRead(Box<Result<app::startup::Settings>>),
164    /// Paths from the command line or the Python binding, checked on a worker
165    /// ([`JobKind::OpenNamed`]): a local-looking path may be a slow mount.
166    OpenNamed(Vec<PathBuf>, OpenOptions),
167    /// A path named on the command line is not there: the session ends.
168    NamedPathMissing(PathBuf),
169    /// Open these paths, through the loading controller's phases.
170    Open(Vec<PathBuf>, OpenOptions),
171    /// Open with an existing LazyFrame (e.g. from Python binding); no file load.
172    OpenLazyFrame(Box<LazyFrame>, OpenOptions),
173    /// A home listing built off-thread is ready.
174    HomeListingReady {
175        generation: u64,
176        listing: Box<crate::home::Listing>,
177        /// What earlier runs measured, read from the cache with the listing.
178        known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
179        /// How often and how lately each recent was opened.
180        visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
181        /// The recent opened last, where the cursor lands.
182        newest: Option<PathBuf>,
183        /// The saved folds, when entering the home screen asked for them.
184        folds: Option<std::collections::HashMap<String, bool>>,
185    },
186    /// The home listing worker panicked; the panic is flashed.
187    HomeListingFailed,
188    /// The directory the `~` prompt is typing, read off-thread.
189    HomePathListed {
190        listing: Box<crate::home::PathListing>,
191    },
192    /// A completed path, worked out off-thread.
193    HomePathCompleted {
194        generation: u64,
195        /// What was typed when completion was asked; a later keystroke makes it stale.
196        typed: String,
197        completed: String,
198        candidates: usize,
199    },
200    /// The highlighted file's first rows, read as its open reads them, and the
201    /// dataset that read built, for the open to install.
202    HomePreviewReady {
203        path: PathBuf,
204        /// The row's stamp when it was asked for: what the rows are kept under.
205        stamp: crate::home::home_preview::Stamp,
206        /// The file's stamp when it was read: what the dataset is installed under.
207        read_at: Option<crate::home::home_preview::Stamp>,
208        rows: Option<Arc<crate::home::home_preview::PreviewRows>>,
209        prepared: crate::home::home_preview::Handoff,
210    },
211    /// A schema read off-thread for the highlighted dataset.
212    HomeSchemaReady {
213        generation: u64,
214        path: PathBuf,
215        preview: Option<crate::home::discover::SchemaPreview>,
216    },
217    /// Measurements for home rows, sent per row so a slow one holds back no other.
218    /// `done` ends the batch. No generation: a measurement is keyed by path and
219    /// stays true whichever listing asked.
220    HomeMeasured {
221        measured: Vec<(PathBuf, crate::home::Measured)>,
222        done: bool,
223    },
224    /// What a HEAD said an HTTP(S) file on home weighs: its row, measured.
225    HomeSized {
226        path: PathBuf,
227        measured: crate::home::Measured,
228    },
229    /// What a HEAD settled about an HTTP(S) file on home: it cannot be had.
230    HomeWebGone {
231        path: PathBuf,
232        gone: crate::error_display::HttpGone,
233    },
234    /// What the rows on screen turned out to be; folded like
235    /// [`AppEvent::HomeMeasured`].
236    HomeClassified {
237        measured: Vec<(PathBuf, crate::home::Measured)>,
238        done: bool,
239    },
240    /// A batch of datasets from the background search, sent repeatedly while it walks.
241    HomeSearchBatch {
242        generation: u64,
243        root: PathBuf,
244        found: Vec<crate::home::discover::Entry>,
245        scanned: usize,
246    },
247    /// The filter scored against the search's files, for the walk `epoch` names.
248    HomeSearchScored {
249        epoch: u64,
250        /// `None` from a worker that died.
251        matches: Option<Box<crate::home::search::Matches>>,
252    },
253    /// The background search stopped; `limited` says why if it stopped short.
254    HomeSearchDone {
255        generation: u64,
256        root: PathBuf,
257        scanned: usize,
258        limited: Option<String>,
259    },
260    /// The cloud sources on this machine with what an earlier run listed, sent before
261    /// anything is fetched.
262    #[cfg(feature = "cloud")]
263    HomeCloudSources {
264        sources: Vec<crate::home::CloudSource>,
265    },
266    /// One source's buckets listed, or not. Each source reports on its own.
267    #[cfg(feature = "cloud")]
268    HomeCloudListed {
269        id: String,
270        buckets: Vec<PathBuf>,
271        /// Lines for the details pane of each listed place that has any.
272        details: Vec<(PathBuf, Vec<(String, String)>)>,
273        /// `(short, detail)` when the listing failed.
274        failure: Option<(String, String)>,
275        listed_at: std::time::SystemTime,
276    },
277    /// A network root has been listed off-thread, or could not be.
278    HomeProbeReady {
279        root: PathBuf,
280        rows: Option<Vec<crate::home::discover::Entry>>,
281        /// The listing stopped at [`crate::home::discover::MAX_ENTRIES_PER_DIR`].
282        cut_short: bool,
283    },
284    /// Rows of a network directory read since its last batch.
285    HomeProbeProgress {
286        root: PathBuf,
287        rows: Vec<crate::home::discover::Entry>,
288    },
289    /// Cloud directories that peeking found to be partitioned or Parquet datasets.
290    HomeCloudKinds {
291        kinds: Vec<(
292            PathBuf,
293            (
294                crate::home::discover::EntryKind,
295                crate::home::discover::Holds,
296            ),
297        )>,
298        /// Directories whose peek failed or was lost, so left unlabeled.
299        failed: Vec<PathBuf>,
300    },
301    /// A cloud listing stopped because its place was left; it is listed again on
302    /// return.
303    HomeProbeCancelled {
304        root: PathBuf,
305    },
306    /// Names under `prefix` in a cut-short cloud directory, asked for by a filter;
307    /// `None` when the listing failed or was stopped.
308    HomeNarrowed {
309        dir: PathBuf,
310        prefix: String,
311        listed: Option<(Vec<crate::home::discover::Entry>, bool)>,
312    },
313    /// A cloud listing was refused, with the service's reason.
314    HomeProbeFailed {
315        root: PathBuf,
316        message: String,
317    },
318    /// A followed file's watcher found more rows, or that the file went.
319    Followed(crate::loading::follow::News),
320    Exit,
321    Crash(String),
322    /// A dialog or prompt answered: what it asks of the app. See [`Applied`].
323    Applied(Applied),
324    Collect,
325    Update,
326    Reset,
327    Resize(u16, u16), // resized (width, height)
328    /// A scroll deferred one frame, so the spinner shows while its rows are read.
329    Scroll(Scroll),
330    /// Run an analysis tool off the UI thread; deferred so its progress shows first.
331    AnalysisCompute(analysis::analysis_modal::AnalysisTool),
332    /// The sample a stopped Data Quality run had read, kept for the next run.
333    BackgroundQualitySampleKept {
334        kept: KeptQualitySample,
335    },
336    /// A full scan's local copy of a remote dataset, kept for that dataset. `None`
337    /// when the copy did not read as the source.
338    BackgroundQualityCopyKept {
339        dataset_generation: u64,
340        copy: Option<Arc<crate::cloud::local_copy::LocalCopy>>,
341    },
342    /// The exact row count for the current LazyFrame, applied only if
343    /// `len_generation` still matches. Runs alongside the first paint, never
344    /// blocking it.
345    BackgroundLenReady {
346        len_generation: u64,
347        num_rows: usize,
348        /// For a remote multi-file dataset, each file's row-group sizes from its footer.
349        file_row_groups: Option<Vec<Vec<usize>>>,
350    },
351    /// The row count failed: the total stays unknown. Scrolling does not count
352    /// again; End does.
353    BackgroundLenFailed {
354        len_generation: u64,
355    },
356    /// A frame was painted. The run loop calls [`App::frame_painted`]; a harness
357    /// sends this when [`App::count_waits_for_a_frame`].
358    FramePainted,
359    /// A directory named on the command line: look at it on a worker, then do what
360    /// `Enter` on its row would. An event so the first frame, with a spinner and a
361    /// way out, is drawn before a look that can take seconds.
362    LookThenOpenDirectory(PathBuf, OpenOptions),
363    /// Look at a path off the UI thread, then browse into it, report a lake table, or
364    /// open it. The filesystem calls can hang on a slow mount.
365    ClassifyThenOpen {
366        path: PathBuf,
367        /// A path typed at `~` rather than a listed row: Esc returns to the listing.
368        jump: bool,
369    },
370    /// A background job's outcome is in its record: `app::jobs::Jobs::end` takes it.
371    JobEnded(Ticket),
372    /// A report from a background job still running.
373    JobProgress {
374        ticket: Ticket,
375        progress: Progress,
376    },
377}
378
379impl AppEvent {
380    /// A report sent many times while work runs: the loop may fold several into one
381    /// frame.
382    pub fn is_progress(&self) -> bool {
383        matches!(
384            self,
385            AppEvent::HomeMeasured { done: false, .. }
386                | AppEvent::HomeClassified { done: false, .. }
387                | AppEvent::HomeSearchBatch { .. }
388                | AppEvent::HomeProbeProgress { .. }
389                | AppEvent::JobProgress {
390                    progress: Progress::QualityPhase(_),
391                    ..
392                }
393        )
394    }
395}
396
397/// Picks the home-screen workers that panic before their work starts, by the answer
398/// they would owe; see `App::home_worker_dies`.
399#[cfg(test)]
400type HomeWorkerDies = Box<dyn FnMut(&AppEvent) -> bool + Send>;
401
402/// Stands in for [`FileFacts::read`]; see `App::file_facts_reader`.
403#[cfg(test)]
404type FileFactsReader = Arc<
405    dyn Fn(&Path, Option<crate::formats::readers::Facts>) -> std::result::Result<FileFacts, String>
406        + Send
407        + Sync,
408>;
409
410/// What [`App::handle`] did: `Ok` carries a follow-up event; `Err` returns a key
411/// that arrived while busy, untouched, for the caller to offer again when idle.
412pub type EventOutcome = Result<Option<AppEvent>, KeyEvent>;
413
414/// What <kbd>Enter</kbd> will do on the highlighted row, for the footer and the
415/// details pane. A prediction of `App::home_open_selected`;
416/// `test_the_bar_says_what_enter_will_really_do` keeps the two in agreement.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum WhatEnter {
419    /// Load the file on the row.
420    OpensFile,
421    /// Read the whole directory as one table: a hive root, a directory whose files are
422    /// one table, or the `(all files)` row.
423    OpensDirectory,
424    /// Step into the directory. What `→` does too, on these rows.
425    GoesInside,
426    /// Look at the row first, then do whichever of the above the answer calls for.
427    LooksFirst,
428    /// Fold or unfold a section.
429    FoldsSection,
430    /// Show the rest of `RECENT`, or of a directory cut short.
431    ShowsMore,
432    /// Go up a level: the `..` row.
433    GoesUp,
434    /// Show the files datui cannot open, as `Ctrl+A` does.
435    ShowsHidden,
436    /// Nothing to open and nowhere to go: an HTTP place, which has no listing to
437    /// browse and says so.
438    Explains,
439    /// A local file datui has no reader for: Enter shows its bytes in the hex view.
440    OpensHex,
441    /// A remote file datui has no reader for. Enter says so, and the bar offers nothing.
442    Nothing,
443}
444
445impl App {
446    /// See [`WhatEnter`].
447    pub fn what_enter_does(&self) -> WhatEnter {
448        let entry = match self.home.selected_row() {
449            // A place browses as `→` does; an HTTP place has no listing and says so.
450            Some(home::Row::Place { path, .. }) => {
451                return if home::place_is_browsable(&path) {
452                    WhatEnter::GoesInside
453                } else {
454                    WhatEnter::Explains
455                };
456            }
457            Some(home::Row::Header { .. }) => return WhatEnter::FoldsSection,
458            Some(home::Row::More { .. }) => return WhatEnter::ShowsMore,
459            Some(home::Row::Up { .. }) => return WhatEnter::GoesUp,
460            Some(home::Row::Hidden { .. }) => return WhatEnter::ShowsHidden,
461            None => return WhatEnter::Nothing,
462            // The door reads its directory whatever its label, lake tables included.
463            Some(home::Row::Door { .. }) => return WhatEnter::OpensDirectory,
464            Some(home::Row::Entry { entry, .. }) => entry,
465        };
466        // A bookmark opens whole.
467        if entry.kind != home::discover::EntryKind::File
468            && self.home.bookmark(&entry.path).is_some()
469        {
470            return WhatEnter::OpensDirectory;
471        }
472        match entry.kind {
473            home::discover::EntryKind::Unknown => WhatEnter::LooksFirst,
474            // A database of several tables lists them.
475            home::discover::EntryKind::File if entry.enter_lists_tables() => WhatEnter::GoesInside,
476            home::discover::EntryKind::File => WhatEnter::OpensFile,
477            home::discover::EntryKind::Other
478                if matches!(
479                    cloud::source::input_source(&entry.path),
480                    cloud::source::InputSource::Local(_)
481                ) =>
482            {
483                WhatEnter::OpensHex
484            }
485            home::discover::EntryKind::Other => WhatEnter::Nothing,
486            home::discover::EntryKind::Hive | home::discover::EntryKind::MultiFile => {
487                WhatEnter::OpensDirectory
488            }
489            // A plain directory, and a lake table, whose files are not its rows.
490            home::discover::EntryKind::Directory
491            | home::discover::EntryKind::Delta
492            | home::discover::EntryKind::Iceberg
493            | home::discover::EntryKind::Hudi => WhatEnter::GoesInside,
494        }
495    }
496}
497
498impl App {
499    /// Whether `download` is held because the dataset came from a remote `path`;
500    /// local stream conversions and stdin spools are held the same way.
501    fn was_fetched(
502        download: Option<&crate::cloud::download::TempDownload>,
503        path: Option<&Path>,
504    ) -> bool {
505        download.is_some() && path.is_some_and(cloud::source::is_remote_url)
506    }
507}
508
509/// Input for the shared run loop.
510#[derive(Clone)]
511pub enum RunInput {
512    /// The command line as parsed; config is read behind the first frame
513    /// ([`app::startup`]).
514    Cli(Box<Args>),
515    /// A host program's options, read as the command line's (`-c` included), with a
516    /// frame to show instead of its paths. Stdin is the host's, never data.
517    Host(Box<Args>, Option<Box<LazyFrame>>),
518    Paths(Vec<PathBuf>, OpenOptions),
519    LazyFrame(Box<LazyFrame>, OpenOptions),
520}
521
522/// The screen keys go to when no overlay is open (see [`Overlay`]).
523#[derive(Debug, Default, PartialEq, Eq)]
524pub enum InputMode {
525    #[default]
526    Normal,
527    /// The home screen: pick a dataset to open, at startup or from a session.
528    Home,
529    /// The command line or the find line.
530    Editing,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum InputType {
535    /// The command line (`:`): a row number, or a query in SQL or q.
536    Query,
537    Find,
538}
539
540/// A query whose first rows are being read. It can still fail on the data (a
541/// value that will not cast); until the rows are in, the replaced view is kept.
542struct QueryRun {
543    origin: RunOrigin,
544    /// The `len_generation` of the query's frame; once that frame is gone the
545    /// rollback no longer applies.
546    frame: u64,
547    rollback: crate::table::ViewRollback,
548    /// The count markers for the frame the rollback restores. A count of it still
549    /// running lands into `rollback`.
550    counts: loading::counting::CountMarkers,
551    /// Rows `df` holds, when known, so a failure can say "of N".
552    rows: Option<usize>,
553}
554
555/// Where a running query came from, which decides where its failure is said.
556enum RunOrigin {
557    /// The query prompt: inline under it while open in this mode, else a dialog.
558    Query(QueryMode),
559    /// A view applied: a dialog on failure, and the previous view marked applied
560    /// again. `matched` says why it was applied for a match.
561    View {
562        previous: Option<String>,
563        matched: Option<(String, view::MatchReason)>,
564    },
565}
566
567/// The bar's recording label (`--tee`): `rec` with size and rate, `saved` with
568/// size, length and file (`sent` for `--tee -`), or `stopped` and why. The bool
569/// is true for that last (warning color).
570fn recording_label(spool: &crate::loading::follow::Spool) -> (String, bool) {
571    let dot = crate::glyphs::get().middot;
572    let size = crate::numfmt::bytes(spool.bytes());
573    match spool.ended() {
574        None => (
575            format!(
576                "rec {size} {dot} {}/s",
577                crate::numfmt::bytes(spool.rate() as u64)
578            ),
579            false,
580        ),
581        Some(None) => {
582            let secs = spool.duration().as_secs();
583            let length = if secs >= 3600 {
584                format!("{}:{:02}:{:02}", secs / 3600, secs / 60 % 60, secs % 60)
585            } else {
586                format!("{}:{:02}", secs / 60, secs % 60)
587            };
588            match spool.tee().filter(|tee| !tee.to_stdout()) {
589                Some(tee) => (
590                    format!("saved {size} {dot} {length} {dot} {}", tee.name()),
591                    false,
592                ),
593                None => (format!("sent {size} {dot} {length}"), false),
594            }
595        }
596        Some(Some(reason)) => (format!("stopped: {reason}"), true),
597    }
598}
599
600/// Where a key was taking the user when leaving was asked about.
601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
602pub(crate) enum Leaving {
603    Quit,
604    Home,
605}
606
607/// An export under way, for the footer.
608#[derive(Clone, Debug)]
609pub struct ExportProgress {
610    pub file_path: PathBuf,
611    pub current_phase: String,
612    pub written: Option<u64>,
613}
614
615impl ExportProgress {
616    /// An export to `file_path` starting `phase`, nothing written yet.
617    pub fn new(file_path: &Path, phase: &str) -> Self {
618        Self {
619            file_path: file_path.to_path_buf(),
620            current_phase: phase.to_string(),
621            written: None,
622        }
623    }
624}
625
626/// The correlation matrix of the sample's numeric columns, reading only those.
627fn correlations_of_sample(
628    lf: &LazyFrame,
629    sample: &analysis::sampling::Sample,
630    known_total: Option<usize>,
631    streaming: bool,
632) -> Result<crate::analysis::statistics::AnalysisResults> {
633    let schema = lf.clone().collect_schema()?;
634    let numeric: Vec<polars::prelude::Expr> = schema
635        .iter()
636        .filter(|(_, dtype)| dtype.is_numeric())
637        .map(|(name, _)| col(name.clone()))
638        .collect();
639    let rows = crate::analysis::sampling::read(
640        &lf.clone().select(numeric),
641        sample,
642        known_total,
643        streaming,
644    )?;
645    Ok(crate::analysis::statistics::AnalysisResults {
646        column_statistics: vec![],
647        total_rows: rows.total_rows,
648        sample_size: rows.sample_size,
649        per_value: rows.per_value.map(|per_value| per_value.kept),
650        correlation_matrix: crate::analysis::statistics::compute_correlation_matrix(&rows.df).ok(),
651        distribution_analyses: vec![],
652    })
653}
654
655/// Returns (query, sql_query, fuzzy_query) with only the active one set: SQL over
656/// fuzzy over DSL. A saved view keeps one.
657fn active_query_settings(
658    dsl_query: &str,
659    sql_query: &str,
660    fuzzy_query: &str,
661) -> (Option<String>, Option<String>, Option<String>) {
662    let sql_trimmed = sql_query.trim();
663    let fuzzy_trimmed = fuzzy_query.trim();
664    let dsl_trimmed = dsl_query.trim();
665    if !sql_trimmed.is_empty() {
666        (None, Some(sql_trimmed.to_string()), None)
667    } else if !fuzzy_trimmed.is_empty() {
668        (None, None, Some(fuzzy_trimmed.to_string()))
669    } else if !dsl_trimmed.is_empty() {
670        (Some(dsl_trimmed.to_string()), None, None)
671    } else {
672        (None, None, None)
673    }
674}
675
676/// The steps `state` shows, as a saved view keeps them.
677pub(crate) fn view_settings_of(state: &DataTableState) -> view::ViewSettings {
678    let (query, sql_query, fuzzy_query) = active_query_settings(
679        state.get_active_query(),
680        state.get_active_sql_query(),
681        state.get_active_fuzzy_query(),
682    );
683    view::ViewSettings {
684        chart: None,
685        sample: saved_sample_of(state),
686        query,
687        sql_query,
688        fuzzy_query,
689        filters: state.get_filters().to_vec(),
690        sort_columns: state.get_sort_columns().to_vec(),
691        sort_descending: state.get_sort_descending().to_vec(),
692        sort_ascending: state.get_sort_ascending(),
693        column_order: state.get_column_order().to_vec(),
694        locked_columns_count: state.locked_columns_count(),
695        pivot: state.last_pivot_spec().cloned(),
696        melt: state.last_melt_spec().cloned(),
697        reshape_source: state.reshape_source().cloned(),
698        columns: state.column_changes().to_vec(),
699    }
700}
701
702/// The sample `state` is, as a view keeps it, with the query and filters it was
703/// drawn through when drawn from the view's rows.
704fn saved_sample_of(state: &DataTableState) -> Option<view::SavedSample> {
705    let sampled = state.sampled()?;
706    // Column types, a reshape and a sort pick its rows as much as a query does.
707    let through = sampled.through().then(|| view::ViewSettings {
708        sample: None,
709        chart: None,
710        ..view_settings_of(sampled.source())
711    });
712    Some(view::SavedSample::of(
713        sampled.sample(),
714        sampled.path(),
715        through,
716    ))
717}
718
719/// How far planning a view's steps got.
720pub(crate) enum Replayed {
721    /// Every step is planned; the view's rows are still to be read.
722    Planned,
723    /// Stopped at the pivot, which must be read before later steps can be planned.
724    Pivot(Box<crate::table::PivotJob>),
725}
726
727/// Why Data Quality's Run did not start: a cancelled run has not exited yet.
728const QUALITY_RUN_WAITS: &str = "Run waits: the cancelled run is still stopping";
729
730/// Why another source read did not start: a second read beside a cancelled one
731/// can run memory out.
732const ANALYSIS_READ_WAITS: &str = "A cancelled run is still finishing; try again shortly";
733
734/// How long a cancelled run may take to finish its batch before the screen says
735/// it is still going.
736const CANCEL_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
737
738pub struct App {
739    pub data_table_state: Option<DataTableState>,
740    /// The dataset's row count, footer pass and line indexing, and what waits on them.
741    counting: loading::counting::Counting,
742    /// The home screen's work in flight, and what it keeps for the session.
743    pub home_app: home::home_app::HomeApp,
744    /// Home screen state, rebuilt whenever home is entered.
745    pub home: home::HomeState,
746    /// Where the dataset on screen came from, and how it was opened.
747    source: loading::open_scan::OpenedSource,
748    path: Option<PathBuf>,
749    /// Standard input and output when datui sits in a pipe.
750    pipes: app::run::Pipes,
751    events: Sender<AppEvent>,
752    debug: DebugState,
753    pub info_modal: InfoModal,
754    /// What the Info panel shows of the dataset beyond its schema.
755    pub info: app::keys::info_keys::InfoState,
756    /// The command line: its inputs per mode, completion, and the query it is running.
757    pub prompt: query::query_prompt::QueryPrompt,
758    pub input_mode: InputMode,
759    /// What is open over the table. See [`Overlay`].
760    pub overlay: Overlay,
761    pub sort_filter_modal: SortFilterModal,
762    pub pivot_melt_modal: PivotMeltModal,
763    pub view_modal: ViewModal,
764    pub analysis_modal: AnalysisModal,
765    /// The sample form, and what the draws learned of memory and of the paths they took.
766    pub sample: analysis::sample_draw::SampleState,
767    /// What Data Quality runs keep within the memory budget.
768    quality: analysis::quality_runs::QualityRuns,
769    /// The chart view, its export form, and the preparations it keeps or waits on.
770    pub chart: chart::chart_jobs::Charts,
771    pub export_modal: ExportModal,
772    pub copy_modal: app::modals::copy_modal::CopyModal,
773    pub inspector_modal: inspector::inspector_modal::InspectorModal,
774    /// What datui hands to other programs, and the clipboard.
775    external: app::run::External,
776    /// The go-to-column, format and table pickers.
777    pub pickers: app::keys::picker_keys::Pickers,
778    /// The Value Counts screen (`F`).
779    pub value_counts: analysis::value_counts_modal::ValueCountsModal,
780    /// The retype and combine forms.
781    pub column_forms: app::keys::retype_keys::ColumnForms,
782    /// The hex view, and the number its next read is tagged with.
783    pub hex: app::keys::hex_keys::HexState,
784    error_modal: ErrorModal,
785    flash: Option<Flash>,
786    pub confirmation_modal: ConfirmationModal,
787    /// The help overlay, over whatever screen it was opened at.
788    help: app::help::Help,
789    /// What the mouse can land on in the last frame, and the last click.
790    pointer: app::pointer::Pointing,
791    /// The menu a right click on a cell opened, while it is open.
792    context_menu: Option<app::context_menu::ContextMenu>,
793    cache: CacheManager,
794    /// The recent and the shape an open writes, which the home listing waits on.
795    cache_writes: CacheWrites,
796    /// Saved views, and the one applied to the dataset on screen.
797    views: view::view_apply::SavedViews,
798    /// An export under way, which the footer reports.
799    export_progress: Option<ExportProgress>,
800    theme: Theme, // Color theme for UI rendering
801    /// How the table is drawn this session: from the config, with the session's own toggles.
802    display: render::context::DisplaySettings,
803    runtime: tokio::runtime::Handle, // Tokio runtime handle for background tasks
804    /// Background jobs and the generation their answers are judged by. See [`app::jobs`].
805    jobs: Jobs,
806    /// The open in flight, from request to first rows. See [`loading`].
807    loading: loading::Loader,
808    /// Bumped once per dataset put on screen; the jobs' generation also moves per
809    /// collect, and a footer pass outlives several. Says whether arriving columns
810    /// belong to the dataset on screen.
811    dataset_generation: u64,
812    /// Replaces [`FileFacts::read`] in tests of a slow or failing read.
813    #[cfg(test)]
814    file_facts_reader: Option<FileFactsReader>,
815    /// Show the throbber and hold keys (see [`App::handle`]).
816    busy: bool,
817    /// Bumped when the screen is replaced without the user asking (going home, an
818    /// abandoned load). Held keys carry the value they were typed under and are
819    /// dropped once it moves.
820    screen_generation: u64,
821    /// The main loop dropped a key typed while busy; cleared once held keys replay.
822    input_dropped: bool,
823    /// The spinner's frame, counting up; each spinner takes it modulo its own frames.
824    throbber_frame: u8,
825    /// Footer status at the table view, shown even when not busy: an End waiting on a
826    /// remote count parks without setting `busy`.
827    status_message: Option<String>,
828    app_config: AppConfig,
829    /// The format specs on the search path, read when the app was built.
830    formats: Arc<crate::formats::Registry>,
831}
832
833impl App {
834    /// Whether keys wait: a job the user waits on runs or is owed, an errand is
835    /// between phases, or an open is on its way.
836    pub fn is_busy(&self) -> bool {
837        self.busy || self.jobs.holds_keys() || self.loading.waits()
838    }
839
840    /// The generation background answers are judged by.
841    pub fn task_generation(&self) -> u64 {
842        self.jobs.generation()
843    }
844
845    /// Whether the job `ticket` names is still running and its answer still wanted.
846    pub fn job_is_current(&self, ticket: Ticket) -> bool {
847        self.jobs.is_current(ticket)
848    }
849
850    /// Use `cache` from now on. For tests that read their store back: a shared one
851    /// loses entries to other tests' opens.
852    pub fn use_cache(&mut self, cache: CacheManager) {
853        self.cache = cache;
854    }
855
856    /// Read and write `catalog.toml` in `dir`, so a test's Ctrl+D stays out of the
857    /// shared config directory.
858    pub fn use_catalog_dir(&mut self, dir: &Path) -> Result<()> {
859        self.app_config.read_catalog_files(Some(dir))
860    }
861
862    /// Path of the installed dataset, if any.
863    pub fn open_path(&self) -> Option<&Path> {
864        self.path.as_deref()
865    }
866
867    /// Whether the dataset on screen was piped in.
868    fn reads_stdin(&self) -> bool {
869        self.source.opened.as_ref().is_some_and(
870            |(paths, _)| matches!(paths.as_slice(), [path] if loading::stdin::is_stdin(path)),
871        )
872    }
873
874    /// What views are matched against: the path and table. Piped input or a handed
875    /// frame is `-`, matching by columns alone.
876    fn view_dataset(&self) -> Option<view::Dataset<'_>> {
877        self.data_table_state.as_ref()?;
878        let path = match self.path.as_deref() {
879            Some(path) if !self.reads_stdin() => path,
880            _ => Path::new(loading::stdin::PATH),
881        };
882        Some(view::Dataset {
883            path,
884            table: self.view_table(),
885        })
886    }
887
888    /// The table within a file of tables: from `--table` or a path inside the file
889    /// (`shop.db/orders`).
890    fn view_table(&self) -> Option<&str> {
891        let (_, options) = self.source.opened.as_ref()?;
892        options.table.as_deref()
893    }
894
895    /// Wait for the cache writes in flight (recents, dataset facts) to land, as the
896    /// home listing does. For tests.
897    #[doc(hidden)]
898    pub fn settle_cache_writes(&self) {
899        self.cache_writes.settle();
900    }
901
902    /// Whether any leased background work, current or abandoned, has yet to report.
903    pub fn background_work_in_flight(&self) -> bool {
904        self.jobs.in_flight()
905    }
906
907    /// See the `screen_generation` field.
908    pub fn screen_generation(&self) -> u64 {
909        self.screen_generation
910    }
911
912    /// True while a message is in front of the user that has to be dismissed.
913    pub fn modal_showing(&self) -> bool {
914        self.error_modal.active || self.confirmation_modal.active
915    }
916
917    /// The analysis on screen was run on a sample: what `r` and `a` act on.
918    fn analysis_results_are_sampled(&self) -> bool {
919        self.analysis_modal.view == analysis::analysis_modal::AnalysisView::Main
920            && self.analysis_modal.computing.is_none()
921            && self
922                .analysis_modal
923                .current_results()
924                .is_some_and(|r| r.sample_size.is_some())
925    }
926
927    /// An analysis cancelled whose worker has not exited, and when it was cancelled.
928    /// While it runs, Data Quality does not start another beside it, and says so.
929    pub(crate) fn cancelled_analysis_running(&self) -> Option<std::time::Instant> {
930        self.cancelled_analysis().map(|(since, _)| since)
931    }
932
933    /// The newest cancelled analysis or sample read still running, and whether it was
934    /// cancelled during an unstoppable read.
935    fn cancelled_analysis(&self) -> Option<(std::time::Instant, bool)> {
936        let (since, job) = self.jobs.cancelled_running(Self::is_analysis_read)?;
937        let runs_out = match job {
938            Job::Analysis(run) => run.runs_out,
939            _ => true,
940        };
941        Some((since, runs_out))
942    }
943
944    /// A read for the Analysis tools: a run, or the sample read to show as a table.
945    fn is_analysis_read(job: &Job) -> bool {
946        matches!(job, Job::Analysis(_) | Job::SampleRows)
947    }
948
949    /// A cancelled run the screen should show as still going: at once when cancelled
950    /// during an unstoppable read, else once it outlasts its batch.
951    pub(crate) fn cancelled_run_shown(&self) -> Option<crate::widgets::data_quality::Cancelling> {
952        let (since, read_runs_out) = self.cancelled_analysis()?;
953        (read_runs_out || since.elapsed() >= CANCEL_GRACE).then_some(
954            crate::widgets::data_quality::Cancelling {
955                since,
956                read_runs_out,
957            },
958        )
959    }
960
961    /// Work a cancel passed that is still running: leased on a generation since left.
962    fn cancelled_work_running(&self) -> bool {
963        self.jobs.running_behind()
964    }
965
966    /// A cancelled analysis is still reading: say so, and start no read beside it.
967    fn read_waits_for_cancelled(&mut self) -> bool {
968        if self.cancelled_analysis_running().is_none() {
969            return false;
970        }
971        self.flash_note(ANALYSIS_READ_WAITS.to_string());
972        true
973    }
974
975    /// Run Describe, Distributions or Correlations on the sample, off the UI thread.
976    /// Data Quality runs its own plan.
977    fn spawn_analysis(&mut self, tool: analysis::analysis_modal::AnalysisTool) -> Option<AppEvent> {
978        use analysis::analysis_modal::AnalysisTool;
979        type Compute = fn(
980            &LazyFrame,
981            &analysis::sampling::Sample,
982            Option<usize>,
983            bool,
984        ) -> Result<crate::analysis::statistics::AnalysisResults>;
985        type Install = fn(
986            &mut analysis::analysis_modal::AnalysisModal,
987            crate::analysis::statistics::AnalysisResults,
988        );
989        let (status, compute, install): (&str, Compute, Install) = match tool {
990            AnalysisTool::DataQuality => return self.run_quality_compute(),
991            AnalysisTool::Describe => (
992                "Running analysis...",
993                |lf, sample, known, streaming| {
994                    crate::analysis::statistics::compute_describe_from_lazy(
995                        lf, known, sample, streaming,
996                    )
997                },
998                |modal, results| modal.describe_results = Some(results),
999            ),
1000            AnalysisTool::DistributionAnalysis => (
1001                "Analyzing distributions...",
1002                |lf, sample, known, streaming| {
1003                    let options = crate::analysis::statistics::ComputeOptions {
1004                        include_distribution_info: true,
1005                        include_distribution_analyses: true,
1006                        include_correlation_matrix: false,
1007                        include_skewness_kurtosis_outliers: true,
1008                        polars_streaming: streaming,
1009                    };
1010                    crate::analysis::statistics::compute_statistics_for_sample(
1011                        lf, sample, known, options,
1012                    )
1013                },
1014                |modal, results| modal.distribution_results = Some(results),
1015            ),
1016            AnalysisTool::CorrelationMatrix => (
1017                "Computing correlation matrix...",
1018                correlations_of_sample,
1019                analysis::analysis_modal::AnalysisModal::install_correlations,
1020            ),
1021        };
1022        let Some(state) = &self.data_table_state else {
1023            self.analysis_modal.computing = None;
1024            self.busy = false;
1025            return None;
1026        };
1027        // Binary columns are stubbed by the source: multi-GB blobs could exhaust memory.
1028        let (source, known_total) = self.sample_source(state);
1029        let streaming = match tool {
1030            AnalysisTool::CorrelationMatrix => state.polars_streaming(),
1031            _ => self.app_config.performance.streaming,
1032        };
1033        let sample = self.analysis_modal.sample.clone();
1034        self.spawn_job(
1035            Job::Analysis(app::jobs::AnalysisRun::default()),
1036            Some(status),
1037            move |_| {
1038                let results = source
1039                    .cut(&sample.scope)
1040                    .and_then(|lf| compute(&lf, &sample, known_total, streaming))
1041                    .map_err(|e| format!("{e}"))?;
1042                Ok(Answer::Analysis(install, results))
1043            },
1044        );
1045        None
1046    }
1047
1048    /// Run the selected tool again from scratch, as `r` and `a` do.
1049    fn start_analysis_run(&mut self) -> Option<AppEvent> {
1050        let tool = self.analysis_modal.selected_tool?;
1051        if tool != analysis::analysis_modal::AnalysisTool::DataQuality
1052            && self.read_waits_for_cancelled()
1053        {
1054            return None;
1055        }
1056        let phase = match tool {
1057            analysis::analysis_modal::AnalysisTool::Describe => {
1058                self.analysis_modal.describe_results = None;
1059                "Describing data"
1060            }
1061            analysis::analysis_modal::AnalysisTool::DistributionAnalysis => {
1062                self.analysis_modal.distribution_results = None;
1063                "Analyzing distributions"
1064            }
1065            analysis::analysis_modal::AnalysisTool::CorrelationMatrix => {
1066                self.analysis_modal.correlation_results = None;
1067                "Computing correlations"
1068            }
1069            analysis::analysis_modal::AnalysisTool::DataQuality => return None,
1070        };
1071        self.analysis_modal.computing = Some(AnalysisProgress::new(phase));
1072        self.busy = true;
1073        Some(AppEvent::AnalysisCompute(tool))
1074    }
1075
1076    /// Stop waiting for the analysis in flight. The bump makes its answer stale; a
1077    /// Data Quality run stops at its next batch or stage, a plain collect runs out.
1078    /// The tool is put back unchosen so its view does not wait on a spinner.
1079    fn cancel_analysis(&mut self) {
1080        // The record stays running until the worker ends. Only Data Quality has a watch
1081        // to stop it and stages that say whether they stop partway.
1082        let reads_out = self
1083            .analysis_modal
1084            .computing
1085            .as_ref()
1086            .is_some_and(AnalysisProgress::read_runs_out);
1087        let mut read_runs_out = true;
1088        if let Some(Job::Analysis(run)) =
1089            self.jobs.current_mut(|job| matches!(job, Job::Analysis(_)))
1090        {
1091            read_runs_out = run.watch.is_none() || reads_out;
1092            run.runs_out = read_runs_out;
1093            if let Some(watch) = &run.watch {
1094                watch.cancel();
1095            }
1096        }
1097        let reading_sample = self
1098            .jobs
1099            .current(|job| matches!(job, Job::SampleRows))
1100            .is_some();
1101        self.jobs.cancel(Self::is_analysis_read);
1102        self.jobs.advance();
1103        // Keys typed at the run are stale: a replayed second Enter would restart it.
1104        self.screen_generation = self.screen_generation.wrapping_add(1);
1105        self.analysis_modal.computing = None;
1106        self.busy = false;
1107        self.status_message = None;
1108        // Reading the sample to look at changed nothing on screen; the tool stays.
1109        if reading_sample {
1110            self.flash_note("Row view cancelled".to_string());
1111            return;
1112        }
1113        // Data Quality keeps its last report and returns to Setup. A read that runs to
1114        // its end is shown by the header and Setup until the worker exits; one that
1115        // stops at its next batch is done, and a flash says so.
1116        if self.analysis_modal.selected_tool
1117            == Some(analysis::analysis_modal::AnalysisTool::DataQuality)
1118        {
1119            self.open_quality_setup();
1120            if !read_runs_out {
1121                self.flash_note("Run cancelled".to_string());
1122            }
1123            return;
1124        }
1125        self.analysis_modal.selected_tool = None;
1126        self.analysis_modal.focus = analysis::analysis_modal::AnalysisFocus::Sidebar;
1127        self.flash_note("Analysis cancelled".to_string());
1128    }
1129
1130    /// Whether the Pivot & Melt builder is waiting on a pivot it started.
1131    pub(crate) fn pivot_computing(&self) -> bool {
1132        self.overlay == Overlay::PivotMelt
1133            && self.jobs.current(|job| matches!(job, Job::Pivot)).is_some()
1134    }
1135
1136    /// Stop waiting for the pivot in flight: the bump drops its answer; the form keeps
1137    /// its spec.
1138    fn cancel_pivot(&mut self) {
1139        self.jobs.advance();
1140        self.screen_generation = self.screen_generation.wrapping_add(1);
1141        self.busy = false;
1142        self.status_message = None;
1143        self.flash_note("Pivot cancelled".to_string());
1144    }
1145
1146    /// Drill into the group on row `group_index` (values `row`), fetching its rows off
1147    /// the UI thread. A failure is said on the bar and leaves the grouped view.
1148    fn drill_into(&mut self, group_index: usize, row: &DataFrame) {
1149        let Some(state) = self.data_table_state.as_mut() else {
1150            return;
1151        };
1152        let drilled = state.deferred(|s| s.drill_down_with_row(group_index, row));
1153        match drilled {
1154            Ok(()) => {
1155                self.sync_sort_filter_modal();
1156                self.spawn_async_collect(Self::LOADING_BUFFER);
1157            }
1158            Err(e) => self.flash_note(format!(
1159                "Could not drill in: {}",
1160                crate::error_display::user_message_from_report(&e, None)
1161            )),
1162        }
1163    }
1164
1165    /// Read `reader` where `-` reads standard input: what a test pipes in.
1166    #[doc(hidden)]
1167    pub fn read_stdin_from(&mut self, reader: impl std::io::Read + Send + 'static) {
1168        self.pipes.stdin_reader = Some(Box::new(reader));
1169    }
1170
1171    /// Pass the stream on to `out` for `--tee -` (stdout, or a test's pipe).
1172    #[doc(hidden)]
1173    pub fn pass_stdout_to(&mut self, out: impl std::io::Write + Send + 'static) {
1174        self.pipes.stdout_pass = Some(Box::new(out));
1175    }
1176
1177    /// The follow of the dataset on screen, while it is followed.
1178    pub fn follow(&self) -> Option<&crate::loading::follow::Follow> {
1179        self.data_table_state.as_ref()?.follow()
1180    }
1181
1182    /// Whether the follow's rows are on hand: no background read out and the view has
1183    /// taken what was counted. Refreshes hold no keys, so tests wait on this.
1184    #[doc(hidden)]
1185    pub fn follow_settled(&self) -> bool {
1186        self.rows_in_flight().is_none() && !self.follow().is_some_and(|f| f.behind())
1187    }
1188
1189    /// Ask the follow's watcher to look now rather than at the end of its interval.
1190    #[doc(hidden)]
1191    pub fn check_follow_now(&self) {
1192        if let Some(follow) = self.follow() {
1193            follow.check_now();
1194        }
1195    }
1196
1197    /// A watcher report: counted rows wait for the view; news for the user is flashed.
1198    fn followed(&mut self, news: &crate::loading::follow::News) {
1199        let Some(state) = self.data_table_state.as_mut() else {
1200            return;
1201        };
1202        let Some(follow) = state.follow_mut().filter(|f| f.id() == news.id) else {
1203            return;
1204        };
1205        let message = follow.take(&news.change);
1206        let fields = follow.take_new_fields();
1207        if let Some(handle) = follow.take_held() {
1208            state.read_followed_through(&handle);
1209        }
1210        if let Some(message) = message {
1211            self.flash_note(message);
1212        }
1213        if !fields.is_empty() {
1214            self.counting.followed_fields_held = Some((self.dataset_generation, fields));
1215        }
1216        self.catch_up_follow();
1217        self.join_followed_fields();
1218        self.describe_ended_journal();
1219    }
1220
1221    /// Re-describe a piped journal's Info tab over every entry once it has ended.
1222    /// Nobody waits on it.
1223    fn describe_ended_journal(&mut self) {
1224        let Some(lf) = self
1225            .data_table_state
1226            .as_mut()
1227            .and_then(|state| state.ended_journal_to_describe())
1228        else {
1229            return;
1230        };
1231        let dataset = self.dataset_generation;
1232        self.spawn_job(Job::JournalDetail { dataset }, None, move |_| {
1233            crate::formats::journal::summary(&lf)
1234                .map(|detail| Answer::JournalDescribed(Box::new(detail)))
1235                .map_err(|e| e.to_string())
1236        });
1237    }
1238
1239    /// Join fields a followed pipe brought after the open, if the dataset can take them
1240    /// now, and re-read the rows on screen. Retried after every event while waiting.
1241    fn join_followed_fields(&mut self) {
1242        let Some((generation, _)) = self.counting.followed_fields_held.as_ref() else {
1243            return;
1244        };
1245        if *generation != self.dataset_generation {
1246            self.counting.followed_fields_held = None;
1247            return;
1248        }
1249        // Not under rows still being taken: the view reads the new rows first.
1250        if self.data_table_state.is_none()
1251            || self.work_the_join_would_cancel()
1252            || self.follow().is_some_and(|f| f.behind())
1253        {
1254            return;
1255        }
1256        let Some((generation, fields)) = self.counting.followed_fields_held.take() else {
1257            return;
1258        };
1259        let Some(state) = self.data_table_state.as_mut() else {
1260            return;
1261        };
1262        match state.join_followed_fields(&fields) {
1263            Ok(true) => {
1264                self.spawn_async_collect(Self::LOADING_BUFFER);
1265            }
1266            Ok(false) => {}
1267            Err(()) => self.counting.followed_fields_held = Some((generation, fields)),
1268        }
1269    }
1270
1271    /// Show the rows a follow counted, when the table is on screen with nothing running.
1272    /// A cursor on the last row stays there; elsewhere it stays put and the rows
1273    /// below are counted for the bar.
1274    fn catch_up_follow(&mut self) {
1275        if !self.in_normal_table_view()
1276            || self.is_busy()
1277            || self.loading.awaiting_dataset()
1278            || self.rows_in_flight().is_some()
1279        {
1280            return;
1281        }
1282        self.take_follow_rows(true);
1283    }
1284
1285    /// Give the view the rows the follow counted, reading those on screen with `read`.
1286    /// Returns whether there were rows to take.
1287    fn take_follow_rows(&mut self, read: bool) -> bool {
1288        let Some(state) = self.data_table_state.as_mut() else {
1289            return false;
1290        };
1291        let on_last_row = state.on_last_row();
1292        let counted = state.is_num_rows_valid();
1293        let drawn = state.visible_rows > 0 && counted;
1294        let Some(follow) = state.follow_mut() else {
1295            return false;
1296        };
1297        // Move to the last row only once its rows are on hand; earlier, a frame drawn
1298        // meanwhile is shorter and the table clamps the cursor back.
1299        let settle = read && drawn && std::mem::take(&mut follow.settle_at_end);
1300        let to_end = settle || (read && counted && std::mem::take(&mut follow.end_pending));
1301        let stale = read && std::mem::take(&mut follow.stale_view);
1302        let at_bottom = to_end || on_last_row || follow.end_pending;
1303        if at_bottom {
1304            follow.new_below = 0;
1305        }
1306        if !follow.behind() {
1307            if (to_end && state.scroll_to_end()) || stale {
1308                self.spawn_collect(None);
1309            }
1310            return false;
1311        }
1312        let before = follow.shown();
1313        let (rows, restarted) = follow.catch_up();
1314        if at_bottom {
1315            follow.end_pending = true;
1316        } else if !restarted {
1317            follow.new_below += rows.saturating_sub(before);
1318        }
1319        follow.stale_view = !read;
1320        state.follow_to(rows, restarted);
1321        if at_bottom && read {
1322            if state.is_num_rows_valid() {
1323                state.aim_at_end();
1324            } else {
1325                // A filtered view's end is known once its count lands.
1326                self.counting.end_after_count = Some(state.len_generation());
1327            }
1328        }
1329        if read && !self.spawn_collect(None) {
1330            // Nothing to read: the rows on hand already reach the end.
1331            self.catch_up_follow();
1332        }
1333        true
1334    }
1335
1336    /// `t` over a surface that keeps its opening rows (Value Counts, Analysis, a chart):
1337    /// whether the follow has rows for it.
1338    fn follow_rows_waiting(&self) -> bool {
1339        self.follow().is_some_and(|f| f.behind()) && !self.loading.awaiting_dataset()
1340    }
1341
1342    /// Standard input being recorded to the `--tee` file, for the dataset on screen.
1343    pub fn recording(&self) -> Option<&Arc<crate::loading::follow::Spool>> {
1344        self.data_table_state.as_ref()?;
1345        self.source
1346            .opened
1347            .as_ref()
1348            .and_then(|(_, options)| options.spool.as_ref())
1349            .map(|handle| handle.spool())
1350            .filter(|spool| spool.tee().is_some())
1351    }
1352
1353    /// Where `key` takes the user out of the dataset: quitting, or home.
1354    fn leaving_by(&self, key: &KeyEvent) -> Option<Leaving> {
1355        if !key.is_press() {
1356            return None;
1357        }
1358        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1359        match key.code {
1360            KeyCode::Char('q') if ctrl => Some(Leaving::Quit),
1361            KeyCode::Char('c') if ctrl => Some(Leaving::Quit),
1362            KeyCode::Char('o') if ctrl => Some(Leaving::Home),
1363            KeyCode::Char('Q') if !ctrl && self.in_normal_table_view() => Some(Leaving::Quit),
1364            KeyCode::Char('q') if !ctrl && self.in_normal_table_view() => {
1365                Some(if self.source.opened_from_home {
1366                    Leaving::Home
1367                } else {
1368                    Leaving::Quit
1369                })
1370            }
1371            _ => None,
1372        }
1373    }
1374
1375    /// Ask whether to stop the recording or keep it going while the user leaves.
1376    fn ask_about_recording(&mut self, leaving: Leaving) {
1377        let Some(tee) = self.recording().and_then(|spool| spool.tee()) else {
1378            return;
1379        };
1380        let doing = if tee.to_stdout() {
1381            "passed on"
1382        } else {
1383            "recorded"
1384        };
1385        let message = format!(
1386            "Standard input is still being {doing} to {}. Stop recording, or keep \
1387             recording until the stream ends?",
1388            tee.name()
1389        );
1390        self.confirmation_modal.show_choice(
1391            message,
1392            "Stop recording",
1393            "Keep recording",
1394            Confirm::Leave(leaving),
1395        );
1396    }
1397
1398    /// Leave as asked, stopping the recording or keeping it until its stream ends.
1399    fn leave_recording(&mut self, leaving: Leaving, stop: bool) -> Option<AppEvent> {
1400        let handle = self
1401            .source
1402            .opened
1403            .as_ref()
1404            .and_then(|(_, options)| options.spool.clone());
1405        if stop {
1406            if let Some(handle) = &handle {
1407                handle.spool().stop();
1408            }
1409        } else {
1410            // Held past the dataset, so letting it go does not stop the copy.
1411            self.pipes.recording_on = handle;
1412        }
1413        match leaving {
1414            Leaving::Quit => Some(AppEvent::Exit),
1415            Leaving::Home => {
1416                self.enter_home();
1417                None
1418            }
1419        }
1420    }
1421
1422    /// The recording to wait for after the terminal is handed back.
1423    pub fn recording_after_exit(
1424        &mut self,
1425    ) -> Option<(
1426        crate::loading::follow::Tee,
1427        Arc<crate::loading::follow::SpoolHandle>,
1428    )> {
1429        let handle = self.pipes.recording_on.take()?;
1430        let spool = handle.spool();
1431        let tee = spool.tee()?.clone();
1432        spool.live().then_some((tee, handle))
1433    }
1434
1435    /// Say once that the recording ended, saved or stopped by an error. True when the
1436    /// frame must redraw.
1437    fn notice_recording_end(&mut self) -> bool {
1438        let Some(spool) = self.recording().cloned() else {
1439            return false;
1440        };
1441        let Some(ended) = spool.ended() else {
1442            self.pipes.recording_end_said = false;
1443            return false;
1444        };
1445        if std::mem::replace(&mut self.pipes.recording_end_said, true) {
1446            return false;
1447        }
1448        let said = match spool.tee() {
1449            Some(tee) if tee.to_stdout() => "Standard input ended".to_string(),
1450            Some(tee) => format!("Saved {}", tee.path.display()),
1451            None => String::new(),
1452        };
1453        match ended {
1454            Some(reason) => self.error_modal.show(reason),
1455            None => self.flash_note(said),
1456        }
1457        true
1458    }
1459
1460    /// Show a completion flash on the footer.
1461    fn flash_note(&mut self, message: String) {
1462        self.flash = Some(Flash::new(message));
1463    }
1464
1465    /// A completion flash that ends in the path written: `Exported to …/out.csv`.
1466    fn flash_path(&mut self, prefix: &str, path: &std::path::Path) {
1467        self.flash = Some(Flash::path(prefix, path));
1468    }
1469
1470    /// The completion flash on the footer, if one is showing.
1471    pub fn flash_message(&self) -> Option<&str> {
1472        self.flash.as_ref().map(|f| f.message.as_str())
1473    }
1474
1475    /// The error dialog's message, if one is showing.
1476    pub fn error_message(&self) -> Option<&str> {
1477        self.error_modal
1478            .active
1479            .then_some(self.error_modal.message.as_str())
1480    }
1481
1482    /// When the screen next changes with no event (a flash expiring): the loop sleeps
1483    /// until then at most.
1484    pub fn next_deadline(&self) -> Option<std::time::Instant> {
1485        let flash = self.flash.as_ref().map(|f| f.expires);
1486        let clock = self
1487            .follow()
1488            .filter(|f| f.standing == crate::loading::follow::Standing::Following)
1489            .and_then(|f| f.last_append)
1490            .map(crate::loading::follow::next_tick);
1491        // A recording's size and rate change every second until it ends.
1492        let recording = self
1493            .recording()
1494            .filter(|spool| spool.live())
1495            .map(|_| std::time::Instant::now() + std::time::Duration::from_secs(1));
1496        flash.into_iter().chain(clock).chain(recording).min()
1497    }
1498
1499    /// Whether the follow chip's clock reads differently now: the frame must redraw.
1500    pub fn tick_follow_clock(&mut self) -> bool {
1501        let ended = self.notice_recording_end();
1502        let now = self.follow_mark();
1503        if now == self.pipes.follow_drawn {
1504            return ended;
1505        }
1506        self.pipes.follow_drawn = now;
1507        true
1508    }
1509
1510    /// What the footer says about the follow of the dataset on screen.
1511    fn follow_mark(&self) -> Option<crate::render::footer::FollowMark> {
1512        use crate::loading::follow::Standing;
1513        // The hex view shows a file's bytes, not the table the follow moves.
1514        if self.overlay == Overlay::Hex {
1515            return None;
1516        }
1517        let state = self.data_table_state.as_ref()?;
1518        let rows = |n: usize, what: &str| format!("{} {what}", crate::numfmt::group_chrome(n));
1519        let (rec, rec_stopped) = match self.recording().map(|spool| recording_label(spool)) {
1520            Some((label, stopped)) => (Some(label), stopped),
1521            None => (None, false),
1522        };
1523        let Some(follow) = state.follow().filter(|f| f.standing != Standing::Ended) else {
1524            return rec.is_some().then(|| crate::render::footer::FollowMark {
1525                rec,
1526                rec_stopped,
1527                ..Default::default()
1528            });
1529        };
1530        let waiting = follow.waiting();
1531        let (chip, note) = match follow.standing {
1532            // Standard input read as it arrives: how much has, until it ends.
1533            Standing::Following if follow.is_pipe() => {
1534                let read = follow.spool().map_or(0, |spool| spool.bytes());
1535                let chip = format!(
1536                    "reading stdin {} {}",
1537                    crate::glyphs::get().middot,
1538                    crate::numfmt::bytes(read)
1539                );
1540                let note = (follow.new_below > 0 && !state.on_last_row())
1541                    .then(|| rows(follow.new_below, "new below"));
1542                (chip, note)
1543            }
1544            Standing::Following => {
1545                let chip = match follow.last_append {
1546                    Some(at) => format!(
1547                        "following {} {}",
1548                        crate::glyphs::get().middot,
1549                        crate::loading::follow::age(at.elapsed())
1550                    ),
1551                    None => "following".to_string(),
1552                };
1553                let note = if waiting > 0 && !self.in_normal_table_view() {
1554                    // A takeover keeps the rows it was opened on.
1555                    Some(rows(waiting, "new rows"))
1556                } else if follow.new_below > 0 && !state.on_last_row() {
1557                    Some(rows(follow.new_below, "new below"))
1558                } else {
1559                    None
1560                };
1561                (chip, note)
1562            }
1563            _ => (
1564                "paused".to_string(),
1565                (waiting > 0).then(|| rows(waiting, "new rows")),
1566            ),
1567        };
1568        let misfits = follow.misfits();
1569        // What `t` does here: pause or resume at the table; over a surface that keeps its
1570        // rows, read the new ones.
1571        let refreshes = self.overlay == Overlay::ValueCounts
1572            || (self.overlay == Overlay::Chart && self.chart.modal.picker.is_none())
1573            || (self.overlay == Overlay::Analysis
1574                && self.analysis_modal.current_results().is_some());
1575        let key = if self.in_normal_table_view() {
1576            Some(match follow.standing {
1577                Standing::Paused => "Resume",
1578                _ => "Pause",
1579            })
1580        } else if refreshes && follow.behind() {
1581            Some("Refresh")
1582        } else {
1583            None
1584        };
1585        Some(crate::render::footer::FollowMark {
1586            key,
1587            chip: Some(chip),
1588            note,
1589            warning: (misfits > 0).then(|| {
1590                if misfits == 1 {
1591                    "1 row does not fit".to_string()
1592                } else {
1593                    rows(misfits, "rows do not fit")
1594                }
1595            }),
1596            rec,
1597            rec_stopped,
1598        })
1599    }
1600
1601    /// `t` at the table: pause or resume the follow, or start following (re-reading as
1602    /// `H` does).
1603    fn toggle_follow(&mut self) -> Option<AppEvent> {
1604        use crate::loading::follow::Standing;
1605        if let Some(follow) = self.data_table_state.as_mut().and_then(|s| s.follow_mut()) {
1606            match follow.standing {
1607                Standing::Following => follow.pause(),
1608                Standing::Paused => {
1609                    follow.resume();
1610                    self.catch_up_follow();
1611                }
1612                Standing::Ended => {}
1613            }
1614            return None;
1615        }
1616        let (paths, options) = self.source.opened.clone()?;
1617        if paths
1618            .iter()
1619            .any(|path| crate::loading::stdin::is_stdin(path))
1620        {
1621            self.flash_note("Standard input is followed from the start: datui -f -".to_string());
1622            return None;
1623        }
1624        if let Some(refusal) = crate::loading::follow::refuse_paths(&paths, &options) {
1625            self.flash_note(refusal);
1626            return None;
1627        }
1628        let options = OpenOptions {
1629            follow: true,
1630            ..options
1631        };
1632        self.set_loading_phase("Scanning input", 10);
1633        self.name_what_is_loading(paths[0].clone());
1634        Some(AppEvent::Open(paths, options))
1635    }
1636
1637    /// Drop an expired flash. Returns true when the frame must redraw.
1638    pub fn tick_flash(&mut self) -> bool {
1639        if self.flash.as_ref().is_some_and(Flash::expired) {
1640            self.flash = None;
1641            return true;
1642        }
1643        false
1644    }
1645
1646    /// Flash the next Polars user warning once per session when the bar is free. True
1647    /// when the frame must redraw.
1648    pub fn flash_polars_warning(&mut self) -> bool {
1649        if !self.bar_is_free() {
1650            return false;
1651        }
1652        match logging::next_polars_warning() {
1653            Some(warning) => {
1654                self.flash_note(format!("Polars: {warning}"));
1655                true
1656            }
1657            None => false,
1658        }
1659    }
1660
1661    /// Flash a background thread panic nothing else reported (a job's panic ends the
1662    /// job). True when the frame must redraw.
1663    pub fn flash_background_panic(&mut self) -> bool {
1664        if !self.bar_is_free() {
1665            return false;
1666        }
1667        match logging::take_unreported_panic() {
1668            Some(message) => {
1669                self.flash_note(message);
1670                true
1671            }
1672            None => false,
1673        }
1674    }
1675
1676    /// Whether a flash would be seen: not under a busy message or a modal.
1677    fn bar_is_free(&self) -> bool {
1678        !self.is_busy()
1679            && self.flash.is_none()
1680            && !self.error_modal.active
1681            && !self.confirmation_modal.active
1682    }
1683
1684    /// See the `input_dropped` field.
1685    pub fn set_input_dropped(&mut self, dropped: bool) {
1686        self.input_dropped = dropped;
1687    }
1688
1689    /// Escapes that act at once while busy and jump the queue: Ctrl-Q and Ctrl-C quit,
1690    /// Ctrl-O goes home, confirmation modals keep their keys, and the home screen
1691    /// (never busy on its own account) keeps every key.
1692    pub fn hard_escape_while_busy(&self, key: &KeyEvent) -> bool {
1693        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1694        let quit = ctrl && matches!(key.code, KeyCode::Char('q' | 'c'));
1695        let home = ctrl && key.code == KeyCode::Char('o');
1696        let cancel_analysis = self.overlay == Overlay::Analysis
1697            && self.analysis_modal.computing.is_some()
1698            && key.code == KeyCode::Esc;
1699        let cancel_pivot = self.pivot_computing() && key.code == KeyCode::Esc;
1700        let leave_quality_evidence =
1701            self.quality.evidence_return.is_some() && self.at_table() && key.code == KeyCode::Esc;
1702        let cancel_view = key.code == KeyCode::Esc && self.view_applying();
1703        let cancel_find = key.code == KeyCode::Esc && self.finding();
1704        let stop_sample =
1705            key.code == KeyCode::Esc && self.sample_drawing() && self.in_normal_table_view();
1706        // The help reads nothing, so it can always be closed, a load's screen included.
1707        let close_help = self.help.is_open()
1708            && matches!(key.code, KeyCode::Esc | KeyCode::F(1) | KeyCode::Char('?'));
1709        quit || home
1710            || close_help
1711            || cancel_analysis
1712            || cancel_pivot
1713            || cancel_view
1714            || cancel_find
1715            || stop_sample
1716            || leave_quality_evidence
1717            || self.confirmation_modal.active
1718            || self.input_mode == InputMode::Home
1719    }
1720
1721    /// The column cursor keys: `h` `l` (←→), `[` `]` (Shift+←→) a page, `{` `}` first
1722    /// and last. Never with Ctrl or Alt: Ctrl+[ is Esc on a terminal.
1723    fn column_cursor_key(key: &KeyEvent) -> Option<crate::widgets::column_paging::CursorMove> {
1724        use crate::widgets::column_paging::CursorMove;
1725        if key
1726            .modifiers
1727            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
1728        {
1729            return None;
1730        }
1731        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1732        match key.code {
1733            KeyCode::Left if shift => Some(CursorMove::PageLeft),
1734            KeyCode::Right if shift => Some(CursorMove::PageRight),
1735            KeyCode::Left | KeyCode::Char('h') => Some(CursorMove::Left),
1736            KeyCode::Right | KeyCode::Char('l') => Some(CursorMove::Right),
1737            KeyCode::Char('{') => Some(CursorMove::First),
1738            KeyCode::Char('}') => Some(CursorMove::Last),
1739            _ => None,
1740        }
1741    }
1742
1743    /// Whether a key may act while busy; the main loop adds "nothing queued" for the
1744    /// second group. The hard escapes always qualify. At the plain table view, keys
1745    /// that read nothing act too: quit, help, and the column cursor (which re-slices
1746    /// the held buffer, never collects; a count here would read cloud metadata on this
1747    /// thread, see `a_key_that_acts_while_busy_reads_nothing`). Everything else is
1748    /// type-ahead and waits. Never by keycode alone: the `h` in `/hello` never scrolls.
1749    pub fn key_acts_while_busy(&self, key: &KeyEvent) -> bool {
1750        if self.hard_escape_while_busy(key) || self.menu_takes(key) {
1751            return true;
1752        }
1753        if self.key_acts_while_sampling(key) {
1754            return true;
1755        }
1756        if !self.in_normal_table_view() {
1757            return false;
1758        }
1759        // One row up or down inside the held rows while only more rows are awaited: the
1760        // table on screen is the one they are for.
1761        let step = match key.code {
1762            KeyCode::Down | KeyCode::Char('j') => Some(1),
1763            KeyCode::Up | KeyCode::Char('k') => Some(-1),
1764            _ => None,
1765        };
1766        if let Some(step) = step {
1767            return !self.busy
1768                && !self.loading.waits()
1769                && self
1770                    .jobs
1771                    .keys_held_only_by(|job| matches!(job, Job::Rows(_) | Job::OwedRows { .. }))
1772                && self
1773                    .data_table_state
1774                    .as_ref()
1775                    .is_some_and(|s| !s.scroll_would_trigger_collect(step));
1776        }
1777        matches!(
1778            key.code,
1779            KeyCode::Char('q')
1780                    | KeyCode::Char('Q')
1781                    // Drawn from what the table holds.
1782                    | KeyCode::Char('#')
1783                    | KeyCode::Char('<')
1784                    | KeyCode::Char('>')
1785                    | KeyCode::Char('=')
1786                    | KeyCode::Char('w')
1787                    | KeyCode::Char(',')
1788                    | KeyCode::Char('D')
1789                    | KeyCode::Left
1790                    | KeyCode::Right
1791                    | KeyCode::Char('h')
1792                    | KeyCode::Char('l')
1793                    | KeyCode::Char('{')
1794                    | KeyCode::Char('}')
1795                    | KeyCode::F(1)
1796                    | KeyCode::Char('?')
1797        )
1798    }
1799
1800    /// The plain table view: Normal mode with nothing drawn over it.
1801    pub fn in_normal_table_view(&self) -> bool {
1802        self.at_table()
1803            && !self.help.is_open()
1804            && !self.error_modal.active
1805            && !self.confirmation_modal.active
1806            && self.context_menu.is_none()
1807    }
1808
1809    /// While a header is dragged over another column, a rule where it would land:
1810    /// after that column moving right, before it moving left.
1811    fn render_drop_mark(&self, buf: &mut Buffer, ctx: &crate::render::context::RenderContext) {
1812        let Some(app::pointer::Drag::Move { column, over }) = self.pointer.drag() else {
1813            return;
1814        };
1815        let Some(state) = self.data_table_state.as_ref() else {
1816            return;
1817        };
1818        let Some((header, columns)) = state.drawn_header() else {
1819            return;
1820        };
1821        let order = state.get_column_order();
1822        let (Some(from), Some(to)) = (
1823            order.iter().position(|c| c == column),
1824            order.iter().position(|c| c == over),
1825        ) else {
1826            return;
1827        };
1828        // Only where a drop would land: frozen among frozen, scrolling among scrolling.
1829        let locked = state.locked_columns_count().min(order.len());
1830        if from == to || (from < locked) != (to < locked) {
1831            return;
1832        }
1833        let Some((left, right, _)) = columns.iter().find(|(_, _, name)| name == over) else {
1834            return;
1835        };
1836        let x = if to > from {
1837            *right
1838        } else {
1839            left.saturating_sub(1)
1840        };
1841        if !(header.x..header.right()).contains(&x) {
1842            return;
1843        }
1844        let g = crate::glyphs::get();
1845        for y in header.y..header.bottom() {
1846            let cell = &mut buf[(x, y)];
1847            cell.set_symbol(g.rule);
1848            cell.set_style(Style::default().fg(ctx.accent));
1849        }
1850    }
1851
1852    /// The context menu is open over the plain table view, nothing over it.
1853    pub(crate) fn menu_showing(&self) -> bool {
1854        self.context_menu.is_some()
1855            && self.at_table()
1856            && self.data_table_state.is_some()
1857            && !self.help.is_open()
1858            && !self.error_modal.active
1859            && !self.confirmation_modal.active
1860    }
1861
1862    /// Whether the open menu answers `key` itself; that reads nothing, so it acts
1863    /// while busy.
1864    pub(crate) fn menu_takes(&self, key: &KeyEvent) -> bool {
1865        self.menu_showing()
1866            && key.modifiers.is_empty()
1867            && matches!(
1868                key.code,
1869                KeyCode::Up
1870                    | KeyCode::Down
1871                    | KeyCode::Char('j')
1872                    | KeyCode::Char('k')
1873                    | KeyCode::Enter
1874                    | KeyCode::Esc
1875            )
1876    }
1877
1878    /// Open the context menu at `at`, over the cell the cursor was just put on.
1879    pub fn open_context_menu(&mut self, at: ratatui::layout::Position) {
1880        // A datetime is made from text, a date or a time.
1881        let combine = self
1882            .data_table_state
1883            .as_ref()
1884            .and_then(|state| {
1885                let column = state.current_column()?;
1886                state.schema().get(column).cloned()
1887            })
1888            .is_some_and(|dtype| {
1889                matches!(dtype, DataType::String | DataType::Date | DataType::Time)
1890            });
1891        self.context_menu = Some(app::context_menu::ContextMenu::with(
1892            at,
1893            app::context_menu::column_items(combine),
1894        ));
1895    }
1896
1897    /// Close the context menu, if it is open.
1898    pub fn close_context_menu(&mut self) {
1899        self.context_menu = None;
1900    }
1901
1902    /// Choose line `i` of the open menu: it closes and its key is offered as typed.
1903    pub fn choose_from_menu(&mut self, i: usize) -> Option<AppEvent> {
1904        let menu = self.context_menu.take()?;
1905        match menu.chosen(i)? {
1906            app::context_menu::MenuKey::Run(key) => Some(AppEvent::Press(key)),
1907            app::context_menu::MenuKey::Do(action) => {
1908                self.menu_action(action);
1909                None
1910            }
1911            _ => None,
1912        }
1913    }
1914
1915    /// A header dropped on another column: `column` moves to `onto`, as repeated `H` /
1916    /// `L` would. Frozen columns move among the frozen, scrolling among the scrolling.
1917    pub fn drop_column(&mut self, column: &str, onto: &str) -> Option<AppEvent> {
1918        let state = self.data_table_state.as_ref()?;
1919        let mut order = state.headers();
1920        let locked = state.locked_columns_count().min(order.len());
1921        let from = order.iter().position(|c| c == column)?;
1922        let to = order.iter().position(|c| c == onto)?;
1923        if from == to || (from < locked) != (to < locked) {
1924            return None;
1925        }
1926        self.flash = None;
1927        self.data_table_state.as_mut()?.set_current_column(column);
1928        let moving = order.remove(from);
1929        order.insert(to, moving);
1930        // The sidebar orders hidden columns by its last applied order; move the column
1931        // there too so the shown order agrees with the table.
1932        let applied = &mut self.sort_filter_modal.sort.applied_order;
1933        if let (Some(i), Some(j)) = (
1934            applied.iter().position(|c| c == column),
1935            applied.iter().position(|c| c == onto),
1936        ) {
1937            let moving = applied.remove(i);
1938            applied.insert(j, moving);
1939        }
1940        Some(AppEvent::Applied(Applied::ColumnOrder(order, locked)))
1941    }
1942
1943    /// Whether a text field owns typed characters, so the wheel and `?` leave it alone.
1944    /// The home filter is excluded on purpose.
1945    pub fn text_field_focused(&self) -> bool {
1946        match self.overlay {
1947            Overlay::None => match self.input_mode {
1948                InputMode::Editing => true,
1949                InputMode::Home => false,
1950                InputMode::Normal => false,
1951            },
1952            Overlay::Analysis => {
1953                self.analysis_modal.sample_scope_typing()
1954                    || self.analysis_modal.quality_expected_typing()
1955                    || self.analysis_modal.intent_typing()
1956                    || self.analysis_modal.export_typing()
1957            }
1958            Overlay::View => {
1959                self.view_modal.mode != ViewModalMode::List
1960                    && matches!(
1961                        self.view_modal.form_focus,
1962                        FormFocus::Name
1963                            | FormFocus::Description
1964                            | FormFocus::ExactPath
1965                            | FormFocus::RelativePath
1966                            | FormFocus::PathPattern
1967                            | FormFocus::FilenamePattern
1968                    )
1969            }
1970            Overlay::Export { .. } => matches!(
1971                self.export_modal.focus,
1972                ExportFocus::PathInput | ExportFocus::CsvDelimiter
1973            ),
1974            Overlay::Copy => self.copy_modal.picker.is_some(),
1975            Overlay::Inspect => self.inspector_modal.finding,
1976            Overlay::GoToColumn => true,
1977            Overlay::PickFormat | Overlay::Retype { .. } => true,
1978            Overlay::Combine { .. } => self.column_forms.combine.as_ref().is_some_and(|c| {
1979                c.picker.is_some() || c.focus == app::modals::retype_modal::CombineField::Name
1980            }),
1981            Overlay::PickTable => true,
1982            Overlay::Sample => self
1983                .sample
1984                .form
1985                .as_ref()
1986                .is_some_and(|form| form.field.is_text()),
1987            // The inline editor, the add-sort Picker and the Columns tab's find type.
1988            Overlay::SortFilter => self.sort_filter_modal.typing(),
1989            Overlay::PivotMelt => {
1990                self.pivot_melt_modal.picker.is_some()
1991                    || self
1992                        .pivot_melt_modal
1993                        .is_text_row(self.pivot_melt_modal.focus)
1994            }
1995            Overlay::ChartExport => self.chart.export_modal.focus.is_text(),
1996            Overlay::Chart => self.chart.modal.picker.is_some(),
1997            Overlay::Info | Overlay::ValueCounts => false,
1998            // The prompt and the spec picker's filter type.
1999            Overlay::Hex => self
2000                .hex
2001                .view
2002                .as_ref()
2003                .is_some_and(|view| view.prompt.is_some() || view.picker.is_some()),
2004        }
2005    }
2006
2007    /// Whether a spinner is on screen, so the run loop turns it and redraws.
2008    pub fn something_is_spinning(&self) -> bool {
2009        self.is_busy()
2010            || (self.row_count_pending() && !self.awaiting_open_confirmation())
2011            // The clock beside "source read finishing" keeps time until it has.
2012            || (self.overlay == Overlay::Analysis && self.cancelled_analysis_running().is_some())
2013            || self.chart_preparing()
2014            || self.value_counts_computing()
2015            || (self.input_mode == InputMode::Home
2016                && (self.home.awaiting_listing().is_some()
2017                    || self.home.sections_waiting()
2018                    || !self.home.peeking.is_empty()))
2019    }
2020
2021    /// Take the numbers the whole frame is drawn from: the footer count, read once
2022    /// because two parts of the screen show it while a thread moves it.
2023    fn begin_frame(&mut self) {
2024        // Back from home to the table whose lines were being indexed.
2025        if self.counting.indexing_paused && self.input_mode != InputMode::Home {
2026            self.index_lines();
2027        }
2028        self.counting.footers_this_frame = self.footer_progress().reading();
2029        self.counting.listed_this_frame = self.footer_progress().listed();
2030        // Whatever this frame does not draw cannot be clicked.
2031        self.pointer.forget_drawn();
2032        if let Some(state) = self.data_table_state.as_mut() {
2033            state.forget_drawn();
2034        }
2035    }
2036
2037    /// The footer counter: the open's while one is on its way, else the dataset's. Each
2038    /// open counts on its own, so a replaced one cannot count under another's name.
2039    pub fn footer_progress(&self) -> &Arc<crate::formats::schema_union::FooterProgress> {
2040        self.loading
2041            .progress()
2042            .unwrap_or(&self.counting.footer_progress)
2043    }
2044
2045    /// Held past the app: when dropped it removes temp files the app's opens were still
2046    /// writing, giving workers up to a second to stop.
2047    pub fn exit_sweep(&self) -> ExitSweep {
2048        ExitSweep(self.loading.unfinished().clone())
2049    }
2050
2051    /// What the status line says while `:N` waits for the lines to be indexed.
2052    const INDEXING_FOR_ROW: &'static str = "Reading lines to find the row...";
2053
2054    /// What the status line says while an End waits on a row count; named so only
2055    /// this message is taken down when the End is retired.
2056    const COUNTING_FOR_END: &'static str = "Counting rows to find the end...";
2057
2058    /// What the footer says while a path is looked at; named so the answer takes down
2059    /// only its own line.
2060    const LOOKING: &'static str = "Looking...";
2061
2062    /// The wait while a directory named on the command line is looked at (seconds for
2063    /// large Parquet).
2064    pub const LOOKING_AT_A_DIRECTORY: &'static str = "Looking at the directory";
2065
2066    /// The wait while the rows for the view are fetched.
2067    pub const LOADING_BUFFER: &'static str = "Loading buffer...";
2068
2069    /// The wait while a view's pivot or first rows are read.
2070    const APPLYING_VIEW: &'static str = "Applying view...";
2071
2072    /// The wait while a grouped row the buffer does not hold is read to drill into.
2073    const READING_GROUP: &'static str = "Reading the group...";
2074
2075    /// The wait while the inspector reads a row's hidden and binary fields.
2076    const READING_FIELDS: &'static str = "Reading fields...";
2077
2078    /// Above this a field is copied off the UI thread: a long list's JSON is slow.
2079    const FIELD_COPY_INLINE_BYTES: usize = 1024 * 1024;
2080    /// The most JSON a copy of a JSON value writes where the clipboard sets no cap.
2081    const JSON_COPY_MAX_BYTES: usize = 64 * 1024 * 1024;
2082    /// The wait while the inspector parses long text as JSON.
2083    const READING_JSON: &'static str = "Reading JSON...";
2084
2085    /// The wait while a pivot reads the view.
2086    const COMPUTING_PIVOT: &'static str = "Computing pivot...";
2087
2088    /// How long a fetch goes unmentioned, so local paging does not blink a message.
2089    const A_FETCH_WORTH_SAYING: std::time::Duration = std::time::Duration::from_millis(300);
2090
2091    /// Grow the buffer before the view reaches its end. Nothing waits on it: no `busy`,
2092    /// no message. One at a time; a scroll that outruns it waits on it or supersedes it
2093    /// by generation. Never when a bump would strand other work.
2094    fn load_ahead(&mut self) {
2095        // Check the generation before marking the position asked: otherwise a frame while
2096        // the generation is held spends the position on the refusal and never asks again.
2097        if self.is_busy()
2098            || self.jobs.owed(Self::owed_rows).is_some()
2099            || self.rows_in_flight().is_some()
2100            || self.work_a_bump_would_strand()
2101        {
2102            return;
2103        }
2104        let Some(state) = self.data_table_state.as_ref() else {
2105            return;
2106        };
2107        // Ask once per position: planning can return the buffer on hand (a row group too
2108        // large for the caps), and replanning every frame would be wasted.
2109        let position = state.buffer_position();
2110        if !state.wants_to_load_ahead() || self.counting.loaded_ahead_from == Some(position) {
2111            return;
2112        }
2113        self.counting.loaded_ahead_from = Some(position);
2114        self.spawn_collect(None);
2115    }
2116
2117    /// Say that the view `name` was applied because its criteria fit as `why` says.
2118    fn flash_view_applied(&mut self, name: &str, why: view::MatchReason) {
2119        self.flash_note(format!("View \"{name}\" applied: {}", why.as_str()));
2120    }
2121
2122    /// Spawn a buffer collect if needed; true if a job was spawned. Advances the
2123    /// generation, invalidating any prior collect. An unknown row count is computed
2124    /// in the background (`BackgroundLenReady`) and never gates the paint: the plan
2125    /// is then a top-of-data window.
2126    pub fn spawn_async_collect(&mut self, status: &str) -> bool {
2127        self.spawn_collect(Some(status))
2128    }
2129
2130    /// As [`Self::spawn_async_collect`]; with no `status`, a load-ahead whose job holds
2131    /// no keys. See [`InflightCollect`].
2132    fn spawn_collect(&mut self, status: Option<&str>) -> bool {
2133        let held = self
2134            .data_table_state
2135            .as_ref()
2136            .is_some_and(|state| self.count_held_at_estimate(state));
2137        let Some(state) = self.data_table_state.as_mut() else {
2138            return false;
2139        };
2140
2141        // The exact row count, if unknown and none is coming for this data version.
2142        // Independent of the generation (a scroll must not restart it); not busy. A
2143        // footer-only count runs now. On an object store a data count rides in the
2144        // collect below (answered outright by a short read). On a local frame it waits
2145        // for the paint (`count_after_paint`), and a short page makes it unnecessary.
2146        let mut count = None;
2147        let generation = state.len_generation();
2148        if !state.is_num_rows_valid()
2149            && self.counting.len_count_inflight != Some(generation)
2150            // Mark running only if it will run: a dataset still reading its footers counts
2151            // itself, and a marker set for a count never started stays set for the session.
2152            && !state.counts_itself_later()
2153            // A count that failed is not tried again on every scroll. End asks again.
2154            && self.counting.len_count_failed != Some(generation)
2155            // A dataset of too many files to count unasked shows its estimate.
2156            && !held
2157        {
2158            self.counting.len_count_inflight = Some(generation);
2159            count = Some(LenCount::for_state(state));
2160        }
2161        let footers = count.take_if(|job| job.reads_footers());
2162        if count.take_if(|_| !state.is_remote_source()).is_some() {
2163            self.counting.count_after_paint = Some(generation);
2164        }
2165        if let Some(job) = footers {
2166            self.spawn_count(job);
2167        }
2168
2169        // Read before the frame is borrowed: the predicate is over the whole App.
2170        let a_bump_would_strand = self.work_a_bump_would_strand();
2171        let inflight = self.rows_in_flight();
2172
2173        // With the count unknown this plans `slice(0, N)`, touching only the first files of
2174        // a partitioned set.
2175        let Some(state) = self.data_table_state.as_mut() else {
2176            return false;
2177        };
2178        let covered = inflight.is_some_and(|inflight| inflight.covers(state));
2179        // The rows are already coming in a load-ahead: wait on it (it takes the keys).
2180        if covered
2181            && let Some(status) = status
2182            && !self.jobs.waited_on(Self::reading_rows)
2183        {
2184            self.jobs.wait_on(Self::reading_rows, status);
2185            self.busy = false;
2186            self.status_message = Some(status.to_string());
2187        }
2188        let request = (!covered).then(|| state.prepare_async_collect(None));
2189        let Some(Some(request)) = request else {
2190            // Nothing to ride in: the view is covered, or the buffer on hand serves it.
2191            if let Some(job) = count {
2192                self.spawn_count(job);
2193            }
2194            return covered;
2195        };
2196        // Everything past here advances the generation. If that would strand work (an
2197        // open's phase, say), queue the collect: the throbber keeps turning and it is
2198        // retried after every event. A count landing minutes later jumps to the end
2199        // through here with no key pressed (#238).
2200        if a_bump_would_strand {
2201            // Drop the count that would ride this collect rather than run it alone: alone it
2202            // is a full remote `len()`. Clearing the marker lets the retry ask again.
2203            if count.is_some() {
2204                self.counting.len_count_inflight = None;
2205            }
2206            // A load-ahead is not owed: nobody asked for it.
2207            let Some(status) = status else {
2208                return false;
2209            };
2210            // One page is owed at a time, the newest; the user waits on it.
2211            self.jobs.take_owed(Self::owed_rows);
2212            let owed = Job::OwedRows {
2213                dataset: self.dataset_generation,
2214                status: status.to_string(),
2215            };
2216            self.jobs.owe(owed, Some(status));
2217            self.busy = false;
2218            self.status_message = Some(status.to_string());
2219            return true;
2220        }
2221        self.jobs.advance();
2222        self.home_app.reads.pages += 1;
2223        let inflight = InflightCollect {
2224            began: std::time::Instant::now(),
2225            files: state.files_a_page_reads(
2226                request.buffer_start,
2227                request.buffer_end.saturating_sub(request.buffer_start),
2228            ),
2229            dataset: state.len_generation(),
2230            columns: InflightCollect::columns_of(state),
2231            start: request.buffer_start,
2232            end: request.buffer_end,
2233        };
2234        // Owed from here, so a worker that dies first still answers the count.
2235        let count = count.map(|job| OwedCount::new(job, self.events.clone()));
2236        self.spawn_job(Job::Rows(inflight), status, move |_| {
2237            let plan = request.plan;
2238            // The count is answered after the page goes out: it may need its own pass.
2239            Ok(
2240                match crate::analysis::statistics::collect_lazy(
2241                    request.lf,
2242                    request.polars_streaming,
2243                ) {
2244                    Ok(df) => {
2245                        let returned = df.height();
2246                        let requested = request.buffer_end - request.buffer_start;
2247                        let start = request.buffer_start;
2248                        // Stitched and cut on the worker: a cut may copy up to the byte budget.
2249                        Answer::Rows(plan.fit(df)).then(move || {
2250                            if let Some(count) = count {
2251                                count.answer(|job| job.after_collect(start, returned, requested));
2252                            }
2253                        })
2254                    }
2255                    // A pass over a frame that just failed would fail too: the count goes unanswered,
2256                    // reports itself failed, and waits for a later interaction.
2257                    Err(e) => Answer::RowsFailed {
2258                        message: crate::error_display::user_message_from_polars(&e),
2259                        conversion: crate::error_display::conversion_failure(&e).map(Box::new),
2260                    }
2261                    .then(move || drop(count)),
2262                },
2263            )
2264        });
2265        true
2266    }
2267
2268    /// Start `job` on a worker. With a `status` the app is busy with it: the bar says
2269    /// so and keys wait. Its answer, `Err`, or panic arrives via
2270    /// [`AppEvent::JobEnded`] at [`App::job_ended`]. Does not advance the generation:
2271    /// a caller replacing work in flight advances it first.
2272    fn spawn_job<F, R>(&mut self, job: Job, status: Option<&str>, work: F) -> Ticket
2273    where
2274        F: FnOnce(&app::jobs::Worker) -> std::result::Result<R, String> + Send + 'static,
2275        R: Into<app::jobs::Answered>,
2276    {
2277        let started = self.start_job(job, status);
2278        let ticket = started.ticket();
2279        started.run(&self.runtime, work);
2280        ticket
2281    }
2282
2283    /// As [`Self::spawn_job`], with the ticket before the work: run it with
2284    /// [`app::jobs::Started::run`].
2285    fn start_job(&mut self, job: Job, status: Option<&str>) -> app::jobs::Started {
2286        if let Some(status) = status {
2287            // Any errand that led here passes its keys to this job's record.
2288            self.busy = false;
2289            self.status_message = Some(status.to_string());
2290        }
2291        self.jobs.start(job, status)
2292    }
2293
2294    fn owed_rows(job: &Job) -> bool {
2295        matches!(job, Job::OwedRows { .. })
2296    }
2297
2298    fn reading_rows(job: &Job) -> bool {
2299        matches!(job, Job::Rows(_))
2300    }
2301
2302    /// The wanted read of the table's rows in flight, if any.
2303    fn rows_in_flight(&self) -> Option<InflightCollect> {
2304        match self.jobs.current(Self::reading_rows) {
2305            Some((_, Job::Rows(inflight))) => Some(*inflight),
2306            _ => None,
2307        }
2308    }
2309
2310    /// Whether the user waits on the read of the table's rows in flight.
2311    fn rows_waited_on(&self) -> bool {
2312        self.jobs.waited_on(Self::reading_rows)
2313    }
2314
2315    /// The rows read or owed are no longer for the table on screen: drop them on
2316    /// arrival.
2317    fn forget_the_rows_read(&mut self) {
2318        self.jobs
2319            .supersede(|job| Self::reading_rows(job) || Self::owed_rows(job));
2320    }
2321
2322    /// Hold the generation: a continuation waiting to run, or an errand waiting on the
2323    /// user. See [`app::jobs::Hold`].
2324    pub(crate) fn hold_the_generation(&self) -> app::jobs::Hold {
2325        self.jobs.hold()
2326    }
2327
2328    /// A job with no worker, started as `spawn_job` does, for tests that end it.
2329    #[cfg(test)]
2330    pub(crate) fn job_for_tests(&mut self, job: Job, status: Option<&str>) -> app::jobs::Started {
2331        self.start_job(job, status)
2332    }
2333
2334    /// Whether the bar has no open and no export to report.
2335    #[cfg(test)]
2336    pub(crate) fn nothing_loading(&self) -> bool {
2337        self.loading.current().is_none() && self.export_progress.is_none()
2338    }
2339
2340    /// An open on the loading screen saying `phase` about `path` of `size` bytes, with
2341    /// nothing running.
2342    #[cfg(test)]
2343    pub(crate) fn loading_for_tests(
2344        &mut self,
2345        path: Option<PathBuf>,
2346        size: u64,
2347        phase: &str,
2348        percent: u16,
2349    ) {
2350        self.announce_open(false, phase.to_string(), percent);
2351        if let Some(path) = path {
2352            self.loading.name(path);
2353        }
2354        self.loading.size_for_tests(size);
2355    }
2356
2357    /// An open of `path` begun and scanning with no worker. Its jobs are [`Job::Load`]
2358    /// with the id returned.
2359    #[cfg(test)]
2360    pub(crate) fn open_for_tests(&mut self, path: &str) -> loading::LoadId {
2361        self.put_down_load_in_flight();
2362        let _ = self.loading.open(loading::OpenRequest {
2363            paths: vec![PathBuf::from(path)],
2364            options: OpenOptions::default(),
2365            size: 0,
2366            recent: None,
2367            shown: None,
2368            warn_in_memory_above: None,
2369        });
2370        self.loading.id().expect("an open was begun")
2371    }
2372
2373    /// Install `state` through the loader as an open of a frame does, without reading
2374    /// its first rows.
2375    #[cfg(test)]
2376    pub(crate) fn install_for_tests(
2377        &mut self,
2378        state: DataTableState,
2379        path: Option<PathBuf>,
2380        options: &OpenOptions,
2381        debug_label: Option<String>,
2382    ) -> bool {
2383        self.put_down_load_in_flight();
2384        let _ = self
2385            .loading
2386            .open_frame(LazyFrame::default(), options.clone());
2387        let load = self.loading.id().expect("an open was begun");
2388        let loading::Step::Install(loaded) = self.loading.answered(
2389            load,
2390            loading::LoadAnswer::SchemaRead {
2391                state: Box::new(state),
2392                path,
2393                options: options.clone(),
2394                debug_label,
2395            },
2396            #[cfg(any(feature = "http", feature = "cloud"))]
2397            &self.jobs,
2398        ) else {
2399            unreachable!("a schema read installs");
2400        };
2401        let view = self.install_dataset(*loaded);
2402        self.first_rows_settled();
2403        view
2404    }
2405
2406    /// A page owed to the dataset on screen, as when the generation was held.
2407    #[cfg(test)]
2408    pub(crate) fn owe_rows_for_tests(&mut self, status: &str) {
2409        let owed = Job::OwedRows {
2410            dataset: self.dataset_generation,
2411            status: status.to_string(),
2412        };
2413        self.jobs.owe(owed, Some(status));
2414    }
2415
2416    /// Whether a page is owed.
2417    #[cfg(test)]
2418    pub(crate) fn rows_owed(&self) -> bool {
2419        self.jobs.owed(Self::owed_rows).is_some()
2420    }
2421
2422    /// A current `job` answers `answer` at once and the app handles it.
2423    #[cfg(test)]
2424    pub(crate) fn answer_for_tests(&mut self, job: Job, answer: Answer) -> Option<AppEvent> {
2425        let started = self.jobs.start(job, None);
2426        let ticket = started.ticket();
2427        started.end(Outcome::answered(answer));
2428        self.job_ended(ticket)
2429    }
2430
2431    /// Whether advancing the generation would throw away an answer nothing will ask for
2432    /// again. See [`Jobs::would_strand`].
2433    fn work_a_bump_would_strand(&self) -> bool {
2434        self.jobs.would_strand()
2435    }
2436
2437    /// A move through the rows: now if the buffer holds its landing, else deferred a
2438    /// frame while the rows are read.
2439    fn scroll_key(&mut self, scroll: Scroll) -> Option<AppEvent> {
2440        let state = self.data_table_state.as_mut()?;
2441        if state.scroll_would_trigger_collect(scroll.delta(state)) {
2442            self.busy = true;
2443            return Some(AppEvent::Scroll(scroll));
2444        }
2445        scroll.run(state);
2446        None
2447    }
2448
2449    /// Home, End and G. A jump needing a fill is deferred behind a throbber frame;
2450    /// if the view is already there only the selection settles.
2451    fn jump_key(&mut self, jump: Scroll) -> Option<AppEvent> {
2452        // End on a remote dataset waits for the row count rather than reading every file
2453        // up to a guess. A dataset still joining its footers gets its count from that
2454        // pass; a second count would answer a `len_generation` the join replaces, and
2455        // the jump would never happen (`scan_is_the_root`: filters and sorts are rebuilt
2456        // over the joined scan). Lines still being indexed end where the indexing ends.
2457        if jump == Scroll::End
2458            && let Some(state) = self.data_table_state.as_ref()
2459            && state.indexing().is_some()
2460        {
2461            self.counting.end_when_indexed = Some(self.dataset_generation);
2462            self.status_message = Some(Self::COUNTING_FOR_END.to_string());
2463            return None;
2464        }
2465        if jump == Scroll::End
2466            && let Some(state) = self.data_table_state.as_ref()
2467            && state.footers_pending().is_some()
2468            && state.scan_is_the_root()
2469            && !state.is_num_rows_valid()
2470        {
2471            self.counting.end_when_the_footers_land = Some(self.dataset_generation);
2472            self.status_message = Some(Self::COUNTING_FOR_END.to_string());
2473            return None;
2474        }
2475        // Any other frame of unknown length waits for its count too. A count waiting on a
2476        // paint starts now; one running or riding a collect is waited on.
2477        if jump == Scroll::End
2478            && let Some(state) = self.data_table_state.as_ref()
2479            && !state.is_num_rows_valid()
2480        {
2481            let generation = state.len_generation();
2482            self.counting.end_after_count = Some(generation);
2483            self.status_message = Some(Self::COUNTING_FOR_END.to_string());
2484            let held = self.counting.count_after_paint == Some(generation);
2485            if held {
2486                self.counting.count_after_paint = None;
2487            }
2488            if held || self.counting.len_count_inflight != Some(generation) {
2489                self.counting.len_count_inflight = Some(generation);
2490                self.spawn_count(LenCount::for_state(state));
2491            }
2492            return None;
2493        }
2494        let state = self.data_table_state.as_mut()?;
2495        let already_there = match jump {
2496            Scroll::Start => state.start_row() == 0,
2497            _ => state.at_end(),
2498        };
2499        if already_there {
2500            jump.run(state);
2501            return None;
2502        }
2503        self.busy = true;
2504        Some(AppEvent::Scroll(jump))
2505    }
2506
2507    /// Run a scroll; `scroll` returns true when it leaves the buffer. Clears `busy`
2508    /// when no collect is spawned, else the key handler's flag would hold input.
2509    fn handle_scroll<F>(&mut self, scroll: F) -> Option<AppEvent>
2510    where
2511        F: FnOnce(&mut crate::table::DataTableState) -> bool,
2512    {
2513        let needs = self.data_table_state.as_mut().is_some_and(scroll);
2514        if !needs || !self.spawn_async_collect(Self::LOADING_BUFFER) {
2515            self.busy = false;
2516            self.status_message = None;
2517        }
2518        None
2519    }
2520
2521    pub fn new(events: Sender<AppEvent>, runtime: tokio::runtime::Handle) -> App {
2522        let theme = Theme::from_config(&AppConfig::default().theme).unwrap_or_else(|_| Theme {
2523            colors: std::collections::HashMap::new(),
2524        });
2525
2526        Self::new_with_config(events, runtime, theme, AppConfig::default())
2527    }
2528
2529    pub fn new_with_theme(
2530        events: Sender<AppEvent>,
2531        runtime: tokio::runtime::Handle,
2532        theme: Theme,
2533    ) -> App {
2534        Self::new_with_config(events, runtime, theme, AppConfig::default())
2535    }
2536
2537    /// An app with its saved views read here, before it is returned.
2538    pub fn new_with_config(
2539        events: Sender<AppEvent>,
2540        runtime: tokio::runtime::Handle,
2541        theme: Theme,
2542        app_config: AppConfig,
2543    ) -> App {
2544        let views = ViewManager::load_or_empty().into();
2545        Self::new_with_views(events, runtime, theme, app_config, views)
2546    }
2547
2548    /// An app whose saved views may still be on their way ([`Views`]).
2549    pub fn new_with_views(
2550        events: Sender<AppEvent>,
2551        runtime: tokio::runtime::Handle,
2552        theme: Theme,
2553        app_config: AppConfig,
2554        view_manager: Views,
2555    ) -> App {
2556        let cache = CacheManager::new(APP_NAME).unwrap_or_else(|_| CacheManager {
2557            cache_dir: std::env::temp_dir().join(APP_NAME),
2558        });
2559        let jobs = Jobs::new(events.clone());
2560        let formats = Arc::new(crate::formats::Registry::load(
2561            &crate::formats::search_path_for(&app_config),
2562        ));
2563        for error in &formats.errors {
2564            log::warn!("format spec skipped: {error}");
2565        }
2566
2567        let theme_problem = app_config.theme.fallbacks.first().cloned();
2568        let chart_export_modal = ChartExportModal {
2569            recipe: app_config.chart.export_recipe,
2570            ..ChartExportModal::new()
2571        };
2572        let mut app = App {
2573            path: None,
2574            data_table_state: None,
2575            counting: loading::counting::Counting::default(),
2576            home: home::HomeState {
2577                hide_unreadable: !app_config.home.show_unreadable,
2578                formats: formats.clone(),
2579                ..Default::default()
2580            },
2581            home_app: home::home_app::HomeApp {
2582                local_desktop: app::link_open::local_desktop(
2583                    app::link_open::Platform::current(),
2584                    |name| std::env::var(name).ok(),
2585                ),
2586                ..Default::default()
2587            },
2588            source: loading::open_scan::OpenedSource::default(),
2589            pipes: app::run::Pipes::default(),
2590            events,
2591            debug: DebugState::default(),
2592            info_modal: InfoModal::new(),
2593            info: app::keys::info_keys::InfoState {
2594                head_web_rows: !cache::running_as_a_cargo_test(),
2595                ..Default::default()
2596            },
2597            prompt: query::query_prompt::QueryPrompt {
2598                query_input: TextInput::new()
2599                    .with_history_limit(app_config.query.history_limit)
2600                    .with_theme(&theme)
2601                    .with_history("query".to_string()),
2602                sql_input: TextInput::statement()
2603                    .with_history_limit(app_config.query.history_limit)
2604                    .with_theme(&theme)
2605                    .with_history("sql".to_string()),
2606                find: find::Find::new(
2607                    TextInput::new()
2608                        .with_history_limit(app_config.query.history_limit)
2609                        .with_theme(&theme)
2610                        .with_history("find".to_string()),
2611                ),
2612                column_hints: false,
2613                input_type: None,
2614                query_mode: QueryMode::default().resolve(),
2615                query_mode_chosen: None,
2616                query_text_restored: false,
2617                sql_columns: Vec::new(),
2618                sql_completion: None,
2619                query_running: None,
2620                query_run_error: None,
2621                inline_failures: 0,
2622            },
2623            input_mode: InputMode::Normal,
2624            overlay: Overlay::None,
2625            sort_filter_modal: SortFilterModal::new(),
2626            pivot_melt_modal: PivotMeltModal::new(),
2627            view_modal: ViewModal::new(),
2628            analysis_modal: AnalysisModal::with_sample_rows(app_config.analysis.sample_rows),
2629            sample: analysis::sample_draw::SampleState {
2630                form: None,
2631                memory_probe: std::sync::Arc::new(analysis::table_sample::available_memory),
2632                paths: Vec::new(),
2633            },
2634            quality: analysis::quality_runs::QualityRuns {
2635                memory_budget: QUALITY_MEMORY_BUDGET,
2636                ..Default::default()
2637            },
2638            chart: chart::chart_jobs::Charts {
2639                export_modal: chart_export_modal,
2640                ..Default::default()
2641            },
2642            export_modal: ExportModal::new(),
2643            copy_modal: app::modals::copy_modal::CopyModal::new(),
2644            inspector_modal: inspector::inspector_modal::InspectorModal::new(),
2645            external: app::run::External::default(),
2646            pickers: app::keys::picker_keys::Pickers::default(),
2647            value_counts: analysis::value_counts_modal::ValueCountsModal::default(),
2648            hex: app::keys::hex_keys::HexState::default(),
2649            column_forms: app::keys::retype_keys::ColumnForms::default(),
2650            error_modal: ErrorModal::new(),
2651            flash: None,
2652            confirmation_modal: ConfirmationModal::new(),
2653            help: app::help::Help::default(),
2654            pointer: app::pointer::Pointing::default(),
2655            context_menu: None,
2656            cache,
2657            cache_writes: CacheWrites::default(),
2658            views: view::view_apply::SavedViews {
2659                manager: view_manager,
2660                active_id: None,
2661            },
2662            export_progress: None,
2663            theme,
2664            display: render::context::DisplaySettings {
2665                history_limit: app_config.query.history_limit,
2666                table_cell_padding: app_config.display.cell_padding.cells(),
2667                column_colors: app_config.display.column_colors,
2668                dtype_row: app_config.display.type_row,
2669                number_format: app_config
2670                    .display
2671                    .number_format
2672                    .resolve(app_config.display.right_align_numbers)
2673                    // App can be built from an unvalidated config (the Python API): fall back to no
2674                    // formatting, keeping the alignment setting.
2675                    .unwrap_or_else(|_| NumberFormatSettings {
2676                        align_numeric_right: app_config.display.right_align_numbers,
2677                        ..Default::default()
2678                    }),
2679                background_query: false,
2680            },
2681            jobs,
2682            runtime,
2683            loading: loading::Loader::default(),
2684            dataset_generation: 0,
2685            #[cfg(test)]
2686            file_facts_reader: None,
2687            busy: false,
2688            throbber_frame: 0,
2689            screen_generation: 0,
2690            input_dropped: false,
2691            status_message: None,
2692            app_config,
2693            formats,
2694        };
2695        // A theme that could not be used: why is said on stderr after exit.
2696        if let Some(problem) = theme_problem {
2697            app.flash_note(problem);
2698        }
2699        app
2700    }
2701
2702    /// Use `registry` as the format specs on the search path.
2703    pub fn set_formats(&mut self, registry: crate::formats::Registry) {
2704        let registry = Arc::new(registry);
2705        self.home.formats = registry.clone();
2706        self.formats = registry;
2707    }
2708
2709    pub fn enable_debug(&mut self) {
2710        self.debug.enabled = true;
2711    }
2712
2713    // ---- Home screen -----------------------------------------------------
2714
2715    /// Fold the measurements that arrived since the last frame into the rows.
2716    pub(crate) fn apply_owed_measurements(&mut self) {
2717        if std::mem::take(&mut self.home_app.measured_owed) {
2718            self.home.apply_measurements();
2719        }
2720    }
2721
2722    /// After a frame, ask for what it lacked: counts for the rows on screen with none,
2723    /// and kinds for rows nothing has looked into. Workers read; this thread decides.
2724    pub fn request_what_the_frame_needs(&mut self) {
2725        if self.at_table() {
2726            self.load_ahead();
2727            self.catch_up_follow();
2728        }
2729        self.inspector_needs();
2730        if self.input_mode != InputMode::Home {
2731            return;
2732        }
2733        self.apply_owed_measurements();
2734        if self.home_app.refresh_owed {
2735            self.home_refresh();
2736        }
2737        #[cfg(feature = "http")]
2738        self.size_selected_web_file();
2739        // Each pass asks for the rows still unknown, a batch at a time; not under the
2740        // path prompt, which hides the list.
2741        if !self.home.path_input_active {
2742            self.request_home_measurements();
2743            self.request_home_classifications();
2744            #[cfg(feature = "cloud")]
2745            self.peek_cloud_directories();
2746        }
2747    }
2748}
2749
2750impl App {
2751    /// Whether the help overlay is on screen.
2752    pub fn help_visible(&self) -> bool {
2753        self.help.is_open()
2754    }
2755
2756    /// The screen the help overlay shows the keys of, while it is up.
2757    pub fn help_context(&self) -> Option<datui_cli::keys::Context> {
2758        self.help.context()
2759    }
2760
2761    /// Open the help overlay on the keys of the current screen, unless already up.
2762    pub(crate) fn open_help_overlay(&mut self) {
2763        // A question or an error under the help would take its keys unseen.
2764        if self.help.is_open() || self.confirmation_modal.active || self.error_modal.active {
2765            return;
2766        }
2767        let context = self.keys_context();
2768        // The home filter types too once something is typed into it.
2769        let typing = self.text_field_focused()
2770            || (self.input_mode == InputMode::Home
2771                && !self.info.documentation.is_open()
2772                && (!self.home.filter.is_empty() || self.home.path_input_active));
2773        self.help.open(context, typing);
2774    }
2775
2776    /// Close the help when the screen under it changed on its own (a query finished, a
2777    /// load failed): its keys are for a screen that is gone. A question or error
2778    /// that arrived under it closes it too.
2779    fn close_help_left_behind(&mut self) {
2780        let left = self
2781            .help
2782            .context()
2783            .is_some_and(|shown| shown != self.keys_context());
2784        if left || self.confirmation_modal.active || self.error_modal.active {
2785            self.help.close();
2786        }
2787    }
2788
2789    /// The screen the keys typed now go to, as the key registry names it.
2790    pub fn keys_context(&self) -> datui_cli::keys::Context {
2791        use crate::analysis::analysis_modal::{AnalysisTool, AnalysisView};
2792        use datui_cli::keys::Context;
2793        match self.overlay {
2794            Overlay::Analysis => match self.analysis_modal.view {
2795                AnalysisView::DistributionDetail => Context::DistributionDetail,
2796                AnalysisView::CorrelationDetail => Context::CorrelationDetail,
2797                AnalysisView::Main => match self.analysis_modal.selected_tool {
2798                    Some(AnalysisTool::DistributionAnalysis) => Context::Distribution,
2799                    Some(AnalysisTool::CorrelationMatrix) => Context::Correlation,
2800                    Some(AnalysisTool::DataQuality) => Context::DataQuality,
2801                    Some(AnalysisTool::Describe) | None => Context::Describe,
2802                },
2803            },
2804            Overlay::View => Context::Views,
2805            Overlay::None => match self.input_mode {
2806                InputMode::Normal => Context::Table,
2807                InputMode::Editing => match self.prompt.input_type {
2808                    Some(InputType::Find) => Context::Find,
2809                    _ => Context::Query,
2810                },
2811                InputMode::Home if self.info.documentation.is_open() => Context::Documentation,
2812                InputMode::Home => Context::Home,
2813            },
2814            Overlay::SortFilter => Context::SortFilter,
2815            Overlay::PivotMelt => Context::PivotMelt,
2816            Overlay::Export { .. } => Context::Export,
2817            Overlay::Copy => Context::Copy,
2818            Overlay::Inspect => Context::Inspector,
2819            Overlay::GoToColumn => Context::GoToColumn,
2820            Overlay::PickFormat => Context::FormatPicker,
2821            Overlay::Retype { .. } => Context::Retype,
2822            Overlay::Combine { .. } => Context::Combine,
2823            Overlay::PickTable => Context::TablePicker,
2824            Overlay::Sample => Context::Sample,
2825            Overlay::Info => Context::Info,
2826            Overlay::Chart | Overlay::ChartExport => Context::Chart,
2827            Overlay::Hex => Context::Hex,
2828            Overlay::ValueCounts => Context::ValueCounts,
2829        }
2830    }
2831
2832    /// True while the confirmation modal asks whether to download a remote file or read
2833    /// a large one whole. These can be walked away from (to home, not exit): the
2834    /// size probe behind them can take seconds.
2835    pub fn awaiting_open_confirmation(&self) -> bool {
2836        self.confirmation_modal.active
2837            && matches!(self.confirmation_modal.asking, Some(Confirm::Download))
2838    }
2839
2840    /// Enter on the confirmation's Yes, or on either choice of one whose No acts too.
2841    fn confirmed(&mut self) -> Option<AppEvent> {
2842        let stop = self.confirmation_modal.focus_yes;
2843        match self.confirmation_modal.take()? {
2844            Confirm::Leave(leaving) => self.leave_recording(leaving, stop),
2845            Confirm::ReadAll => {
2846                // Every row is a sample method like the others: shown in the strip, `s` changes it.
2847                let sample = analysis::sampling::Sample {
2848                    method: analysis::sampling::SampleMethod::EveryRow,
2849                    ..self.analysis_modal.sample.clone()
2850                };
2851                self.apply_sample(sample)
2852            }
2853            Confirm::OpenLink(url) => Some(AppEvent::Applied(Applied::OpenLink(url))),
2854            Confirm::ClearRecents => {
2855                self.cache.clear_recents();
2856                self.home_refresh();
2857                self.home.status = None;
2858                None
2859            }
2860            Confirm::QualityFullScan => self.run_quality_setup(true),
2861            Confirm::HideExamples => {
2862                self.cache.hide_examples();
2863                self.home_refresh();
2864                self.home.select_first_entry();
2865                None
2866            }
2867            Confirm::DeleteView(id) => {
2868                if self.views.manager.delete_view(&id).is_ok() {
2869                    self.refresh_view_list();
2870                }
2871                None
2872            }
2873            Confirm::ForgetPlace(place) => {
2874                let paths = self.home.recents_in(&place);
2875                self.cache.forget_recents(&paths);
2876                self.home_refresh();
2877                self.home.status = None;
2878                None
2879            }
2880            // The overwrite was agreed to, for this export only.
2881            Confirm::QualityExport(path, format) => Some(AppEvent::Applied(
2882                Applied::QualityReportExport(path, format, Overwrite::Replace),
2883            )),
2884            Confirm::ChartExport(request) => Some(AppEvent::Applied(Applied::ChartExport(
2885                ChartExportRequest {
2886                    overwrite: Overwrite::Replace,
2887                    ..*request
2888                },
2889            ))),
2890            Confirm::Export(request) => Some(AppEvent::Applied(Applied::Export(ExportRequest {
2891                overwrite: Overwrite::Replace,
2892                ..*request
2893            }))),
2894            Confirm::Copy(format, header) => {
2895                Some(AppEvent::Applied(Applied::CopyTable { format, header }))
2896            }
2897            Confirm::Download => {
2898                // The loader releases the generation as the download or read starts, and its job
2899                // takes it at once.
2900                let step = self.loading.confirmed();
2901                self.run_load_step(step)
2902            }
2903        }
2904    }
2905
2906    /// No or Esc on the confirmation: nothing asked about happens. A declined overwrite
2907    /// returns to the filled form; other dialogs stay as they were.
2908    fn declined(&mut self) -> Option<AppEvent> {
2909        match self.confirmation_modal.take() {
2910            Some(Confirm::ChartExport(_)) => self.open_overlay(Overlay::ChartExport),
2911            Some(Confirm::Export(_)) => self.open_over(|returns_to| Overlay::Export { returns_to }),
2912            // Backing out of a download or a large read goes home, which puts the open down.
2913            Some(Confirm::Download) => self.enter_home(),
2914            _ => {}
2915        }
2916        None
2917    }
2918
2919    fn key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
2920        self.debug.on_key(event);
2921
2922        // A completion flash lasts until the next key.
2923        self.flash = None;
2924        // A key puts back a header being carried, so a release later moves nothing.
2925        self.cancel_drag();
2926
2927        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
2928        // Ctrl-Q quits from anywhere, before any mode (the chart view has no CONTROL arm
2929        // and would swallow it while busy).
2930        if ctrl && event.code == KeyCode::Char('q') {
2931            return Some(AppEvent::Exit);
2932        }
2933        // Ctrl-C too, even in a text field (which copies with Alt+W instead).
2934        if ctrl && event.code == KeyCode::Char('c') {
2935            return Some(AppEvent::Exit);
2936        }
2937
2938        // The context menu takes keys first. A chosen line closes it and presses its key,
2939        // offered as typed; any other key closes it and then acts. A menu covered since
2940        // (an error, a load's screen) is gone.
2941        if !self.menu_showing() {
2942            self.context_menu = None;
2943        }
2944        if let Some(menu) = self.context_menu.as_mut() {
2945            match menu.key(event) {
2946                app::context_menu::MenuKey::Moved => return None,
2947                app::context_menu::MenuKey::Close => {
2948                    self.context_menu = None;
2949                    return None;
2950                }
2951                app::context_menu::MenuKey::Run(key) => {
2952                    self.context_menu = None;
2953                    return Some(AppEvent::Press(key));
2954                }
2955                app::context_menu::MenuKey::Do(action) => {
2956                    self.context_menu = None;
2957                    self.menu_action(action);
2958                    return None;
2959                }
2960                app::context_menu::MenuKey::Other => self.context_menu = None,
2961            }
2962        }
2963
2964        // Acts at once (see `hard_escape_while_busy`), ahead of keys held behind the view.
2965        if event.code == KeyCode::Esc && self.view_applying() {
2966            self.cancel_view();
2967            return None;
2968        }
2969        // The same for a find that is reading.
2970        if event.code == KeyCode::Esc && self.finding() {
2971            self.cancel_find();
2972            return None;
2973        }
2974        // And for a count of footers, at the table its progress line is on.
2975        if event.code == KeyCode::Esc
2976            && self.at_table()
2977            && self.in_normal_table_view()
2978            && self.footers_counted().is_some()
2979        {
2980            self.stop_count();
2981            return None;
2982        }
2983
2984        if event.code == KeyCode::Esc
2985            && self.at_table()
2986            && !self.error_modal.active
2987            && !self.confirmation_modal.active
2988            && self.return_from_quality_evidence(true)
2989        {
2990            return None;
2991        }
2992
2993        // F1 toggles help before any other branch (e.g. Editing) can consume it.
2994        if event.code == KeyCode::F(1) {
2995            if self.help.is_open() {
2996                self.help.close();
2997            } else {
2998                self.open_help_overlay();
2999            }
3000            return None;
3001        }
3002
3003        // Home owns every key, except under a modal or the help overlay: both render over
3004        // home, and if home ate their keys they could not be dismissed.
3005        if self.input_mode == InputMode::Home
3006            && !self.confirmation_modal.active
3007            && !self.error_modal.active
3008            && !self.help.is_open()
3009        {
3010            return self.home_key(event);
3011        }
3012
3013        // Ctrl+O goes home from anywhere, mid-load included.
3014        if event.code == KeyCode::Char('o')
3015            && event.modifiers.contains(KeyModifiers::CONTROL)
3016            && (!self.confirmation_modal.active || self.awaiting_open_confirmation())
3017        {
3018            self.help.close();
3019            self.enter_home();
3020            return None;
3021        }
3022
3023        if self.confirmation_modal.active {
3024            match event.code {
3025                KeyCode::Left | KeyCode::Char('h') => {
3026                    self.confirmation_modal.focus_yes = true;
3027                }
3028                KeyCode::Right | KeyCode::Char('l') => {
3029                    self.confirmation_modal.focus_yes = false;
3030                }
3031                KeyCode::Tab => {
3032                    self.confirmation_modal.focus_yes = !self.confirmation_modal.focus_yes;
3033                }
3034                // ←→ carry the choice, so ↑↓ (k/j) scroll a long question; the render clamps.
3035                KeyCode::Up | KeyCode::Char('k') => {
3036                    self.confirmation_modal.scroll =
3037                        self.confirmation_modal.scroll.saturating_sub(1);
3038                }
3039                KeyCode::Down | KeyCode::Char('j') => {
3040                    self.confirmation_modal.scroll =
3041                        self.confirmation_modal.scroll.saturating_add(1);
3042                }
3043                KeyCode::Enter if self.confirmation_modal.focus_yes => {
3044                    return self.confirmed();
3045                }
3046                KeyCode::Enter => {
3047                    // The recording question's No is a choice too: keep recording.
3048                    if matches!(self.confirmation_modal.asking, Some(Confirm::Leave(_))) {
3049                        return self.confirmed();
3050                    }
3051                    return self.declined();
3052                }
3053                KeyCode::Esc => {
3054                    // Staying: the recording goes on, and so does the view.
3055                    if matches!(self.confirmation_modal.asking, Some(Confirm::Leave(_))) {
3056                        self.confirmation_modal.hide();
3057                        return None;
3058                    }
3059                    return self.declined();
3060                }
3061                _ => {}
3062            }
3063            return None;
3064        }
3065        if self.error_modal.active {
3066            match event.code {
3067                // A long diagnostic scrolls; the render clamps the offset.
3068                KeyCode::Up | KeyCode::Char('k') => {
3069                    self.error_modal.scroll = self.error_modal.scroll.saturating_sub(1);
3070                    return None;
3071                }
3072                KeyCode::Down | KeyCode::Char('j') => {
3073                    self.error_modal.scroll = self.error_modal.scroll.saturating_add(1);
3074                    return None;
3075                }
3076                KeyCode::PageUp => {
3077                    self.error_modal.scroll = self.error_modal.scroll.saturating_sub(8);
3078                    return None;
3079                }
3080                KeyCode::PageDown => {
3081                    self.error_modal.scroll = self.error_modal.scroll.saturating_add(8);
3082                    return None;
3083                }
3084                KeyCode::Esc | KeyCode::Enter => {
3085                    self.error_modal.hide();
3086                    // With nothing loaded, go back to the list the dataset was chosen from, with the
3087                    // reason, rather than leave an empty table.
3088                    if self.data_table_state.is_none() {
3089                        let reason = self.home_app.last_load_error.take();
3090                        self.enter_home();
3091                        self.home.status = reason;
3092                    }
3093                }
3094                _ => {}
3095            }
3096            return None;
3097        }
3098
3099        // The column cursor keys at the main table, before the help and mode blocks. No
3100        // is_press() check: some terminals misreport key kind.
3101        let in_main_table = self.at_table() && !self.help.is_open();
3102        // The footer offers the column's keys once the cursor moves, until another key.
3103        if in_main_table && event.is_press() {
3104            self.prompt.column_hints = Self::column_cursor_key(event).is_some()
3105                || (self.prompt.column_hints
3106                    && matches!(
3107                        event.code,
3108                        KeyCode::Char(
3109                            '+' | '-' | 'F' | '[' | ']' | 'H' | 'L' | '<' | '>' | '=' | 'w'
3110                        )
3111                    ));
3112        }
3113        if in_main_table
3114            && let Some(mv) = Self::column_cursor_key(event)
3115            && let Some(state) = self.data_table_state.as_mut()
3116        {
3117            state.move_cursor(mv);
3118            self.debug.action(|| format!("move_cursor({mv:?})"));
3119            return None;
3120        }
3121
3122        // The help owns the keys while up. Enter on a line closes it and returns that key
3123        // as the follow-up, reaching the screen under it as a typed key would.
3124        self.close_help_left_behind();
3125        if self.help.is_open() {
3126            return match self.help.key(event) {
3127                app::help::HelpKey::Press(key) => Some(AppEvent::Press(key)),
3128                app::help::HelpKey::Stay | app::help::HelpKey::Closed => None,
3129            };
3130        }
3131
3132        if event.code == KeyCode::Char('?') {
3133            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
3134            // The home screen always accepts characters, into its filter or path input.
3135            let in_text_input = self.text_field_focused() || self.input_mode == InputMode::Home;
3136            // Ctrl-? always opens help; bare ? only outside a text field.
3137            if ctrl_help || !in_text_input {
3138                self.open_help_overlay();
3139                return None;
3140            }
3141        }
3142
3143        match self.overlay {
3144            Overlay::None => {}
3145            Overlay::SortFilter => return self.sort_filter_key(event),
3146            Overlay::Export { .. } => return self.export_key(event),
3147            Overlay::Sample => return self.table_sample_form_key(event),
3148            Overlay::Inspect => return self.inspector_key(event),
3149            Overlay::ValueCounts => return self.value_counts_key(event),
3150            Overlay::Hex => return self.hex_key(event),
3151            Overlay::GoToColumn => {
3152                self.go_to_column_key(event);
3153                return None;
3154            }
3155            Overlay::PickFormat => return self.format_picker_key(event),
3156            Overlay::Retype { .. } => return self.retype_key(event),
3157            Overlay::Combine { .. } => return self.combine_key(event),
3158            Overlay::PickTable => return self.table_picker_key(event),
3159            Overlay::Copy => return self.copy_key(event),
3160            Overlay::PivotMelt => return self.pivot_melt_key(event),
3161            Overlay::Info => return self.info_key(event),
3162            Overlay::Chart | Overlay::ChartExport => return self.chart_key(event),
3163            Overlay::Analysis => return self.analysis_key(event),
3164            Overlay::View => return self.view_key(event),
3165        }
3166
3167        if self.input_mode == InputMode::Editing {
3168            return self.editing_key(event);
3169        }
3170
3171        const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
3172
3173        const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
3174
3175        const DOWN_KEYS: [KeyCode; 2] = [KeyCode::Down, KeyCode::Char('j')];
3176
3177        const UP_KEYS: [KeyCode; 2] = [KeyCode::Up, KeyCode::Char('k')];
3178
3179        // The letter arms below are unmodified keys only (else Ctrl+E would open Export).
3180        // Paging (Ctrl+F/B/D/U) is the only modified set here; global escapes came first.
3181        if event
3182            .modifiers
3183            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
3184            && !matches!(event.code, KeyCode::Char('f' | 'b' | 'd' | 'u'))
3185        {
3186            return None;
3187        }
3188
3189        match event.code {
3190            // q pops the context: back home if opened from there, else quit. Q and Ctrl+Q
3191            // always quit.
3192            KeyCode::Char('q') => {
3193                if self.source.opened_from_home {
3194                    self.enter_home();
3195                    None
3196                } else {
3197                    Some(AppEvent::Exit)
3198                }
3199            }
3200            KeyCode::Char('Q') => Some(AppEvent::Exit),
3201            KeyCode::Char('R') => Some(AppEvent::Reset),
3202            KeyCode::Char('H' | 'L') if event.is_press() => {
3203                self.move_cursor_column(event.code == KeyCode::Char('L'))
3204            }
3205            KeyCode::Char('+' | '-') if event.is_press() => {
3206                self.quick_filter(event.code == KeyCode::Char('+'))
3207            }
3208            KeyCode::Char('#') => {
3209                let renumbered = self
3210                    .data_table_state
3211                    .as_mut()
3212                    .is_some_and(|state| state.deferred(|s| s.toggle_row_numbers()));
3213                if renumbered {
3214                    self.spawn_async_collect(Self::LOADING_BUFFER);
3215                }
3216                None
3217            }
3218            // The column's width, applied as typed.
3219            KeyCode::Char('<' | '>' | '=' | 'w')
3220                if event.is_press() && !event.modifiers.contains(KeyModifiers::CONTROL) =>
3221            {
3222                if let Some(state) = self.data_table_state.as_mut()
3223                    && let Some(name) = state.current_column().map(str::to_string)
3224                {
3225                    // From the width on screen: `>` on a column clipped at the right edge widens what
3226                    // is seen.
3227                    let (choice, shown) = (state.width_choice(&name), state.on_screen_width(&name));
3228                    let width = match event.code {
3229                        KeyCode::Char('<') => choice.narrower(shown),
3230                        KeyCode::Char('>') => choice.wider(shown),
3231                        KeyCode::Char('=') => WidthChoice::Fit,
3232                        _ => WidthChoice::Auto,
3233                    };
3234                    state.set_width_choices([(name, width)]);
3235                }
3236                None
3237            }
3238            // `/` finds, as in less and vim; `f` too. Ctrl+F pages down, below.
3239            KeyCode::Char('/' | 'f')
3240                if event.is_press() && !event.modifiers.contains(KeyModifiers::CONTROL) =>
3241            {
3242                self.open_find();
3243                None
3244            }
3245            KeyCode::Char('[' | ']') if event.is_press() => {
3246                self.sort_by_cursor_column(event.code == KeyCode::Char(']'))
3247            }
3248            KeyCode::Char('n') if event.is_press() => {
3249                self.find_again(find::Direction::Next);
3250                None
3251            }
3252            KeyCode::Char('N') if event.is_press() => {
3253                self.find_again(find::Direction::Previous);
3254                None
3255            }
3256            KeyCode::Char('D') => {
3257                // Drawn from the schema at render time, like `,`. Session-only.
3258                self.display.dtype_row = !self.display.dtype_row;
3259                let on = if self.display.dtype_row { "on" } else { "off" };
3260                self.debug.action(|| format!("toggle_dtype_row({on})"));
3261                None
3262            }
3263            KeyCode::Char('F') => {
3264                self.open_value_counts();
3265                None
3266            }
3267            KeyCode::Char(',') => {
3268                // Applied at render time, so no re-collect. Session-only.
3269                self.display.number_format.enabled = !self.display.number_format.enabled;
3270                let on = if self.display.number_format.enabled {
3271                    "on"
3272                } else {
3273                    "off"
3274                };
3275                self.debug.action(|| format!("toggle_number_format({on})"));
3276                None
3277            }
3278            KeyCode::Esc => {
3279                // The find is the nearest layer: its mark goes first, then a drill.
3280                if self.find_shown() {
3281                    self.prompt.find.active = None;
3282                    return None;
3283                }
3284                // A sample being drawn stops, keeping the rows so far.
3285                if self.sample_drawing() {
3286                    self.stop_sample_draw();
3287                    return None;
3288                }
3289                let mut from_counts = false;
3290                let drilled_up = if let Some(ref mut state) = self.data_table_state {
3291                    if state.is_drilled_down() {
3292                        from_counts = state.drilled_into_value();
3293                        let _ = state.deferred(|s| s.drill_up());
3294                        true
3295                    } else {
3296                        false
3297                    }
3298                } else {
3299                    false
3300                };
3301                if drilled_up {
3302                    self.sync_sort_filter_modal();
3303                }
3304                // Out of a drill from Value Counts, back to the counts of this view.
3305                if from_counts
3306                    && std::mem::take(&mut self.value_counts.drill_return)
3307                    && let Some(state) = self.data_table_state.as_ref()
3308                {
3309                    self.value_counts.rebase(state.len_generation());
3310                    self.open_overlay(Overlay::ValueCounts);
3311                }
3312                if drilled_up {
3313                    self.spawn_async_collect(Self::LOADING_BUFFER);
3314                    return None;
3315                }
3316                // Out of a follow: the rows read so far stay.
3317                if let Some(state) = self.data_table_state.as_mut()
3318                    && state.follow().is_some()
3319                {
3320                    state.stop_following();
3321                    self.flash_note("Stopped following".to_string());
3322                }
3323                // The Info panel handles Esc in its own block.
3324                None
3325            }
3326            KeyCode::Char('t') if event.is_press() => self.toggle_follow(),
3327            code if RIGHT_KEYS.contains(&code) || LEFT_KEYS.contains(&code) => {
3328                if let Some(ref mut state) = self.data_table_state {
3329                    state.move_cursor(if RIGHT_KEYS.contains(&code) {
3330                        crate::widgets::column_paging::CursorMove::Right
3331                    } else {
3332                        crate::widgets::column_paging::CursorMove::Left
3333                    });
3334                }
3335                None
3336            }
3337            code if event.is_press() && DOWN_KEYS.contains(&code) => self.scroll_key(Scroll::Next),
3338            code if event.is_press() && UP_KEYS.contains(&code) => self.scroll_key(Scroll::Prev),
3339            KeyCode::PageDown if event.is_press() => self.scroll_key(Scroll::PageDown),
3340            KeyCode::Home if event.is_press() => self.jump_key(Scroll::Start),
3341            KeyCode::End | KeyCode::Char('G') if event.is_press() => self.jump_key(Scroll::End),
3342            KeyCode::Char('f')
3343                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
3344            {
3345                self.scroll_key(Scroll::PageDown)
3346            }
3347            KeyCode::Char('b')
3348                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
3349            {
3350                self.scroll_key(Scroll::PageUp)
3351            }
3352            KeyCode::Char('d')
3353                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
3354            {
3355                self.scroll_key(Scroll::HalfDown)
3356            }
3357            KeyCode::Char('u')
3358                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
3359            {
3360                self.scroll_key(Scroll::HalfUp)
3361            }
3362            KeyCode::PageUp if event.is_press() => self.scroll_key(Scroll::PageUp),
3363            KeyCode::Enter if event.is_press() => {
3364                if !self.at_table() {
3365                    return None;
3366                }
3367                // With no group to drill into, Enter is Space: the row inspector.
3368                if self.enter_inspects() {
3369                    self.open_inspector();
3370                    return None;
3371                }
3372                self.drill_selected_row();
3373                None
3374            }
3375            KeyCode::Char('i') if event.is_press() => {
3376                if let Some(state) = self.data_table_state.as_mut() {
3377                    // Unread notes open the Notes tab (the accented `i` chip's promise). Read before
3378                    // the mark, which retires the accent.
3379                    let unseen = state.notes_unseen();
3380                    state.mark_notes_seen();
3381                    if unseen {
3382                        self.info_modal
3383                            .open_on(crate::widgets::info::InfoTab::Notes);
3384                    } else if state.format_detail().is_some_and(|d| d.first) {
3385                        // A table whose columns are the same for every file (model tensors, audio frames,
3386                        // VCD changes) opens on its format's own tab.
3387                        self.info_modal
3388                            .open_on(crate::widgets::info::InfoTab::Format);
3389                    } else {
3390                        self.info_modal.open();
3391                    }
3392                    // A list of the file's tables starts its cursor on the one open.
3393                    if let Some(detail) = state.format_detail()
3394                        && let Some(at) = detail.list.iter().position(|(key, _)| {
3395                            detail.table.as_ref() == Some(key) && detail.tables.contains(key)
3396                        })
3397                    {
3398                        self.info_modal.detail_selected = at;
3399                    }
3400                    self.open_overlay(Overlay::Info);
3401                    self.read_file_facts();
3402                    self.count_unfit();
3403                }
3404                None
3405            }
3406            KeyCode::Char(':') if event.is_press() => {
3407                self.open_command_line();
3408                None
3409            }
3410            KeyCode::Char('V') => {
3411                // Apply the best view whose criteria match. When none does, open the list so the
3412                // user picks or saves one.
3413                if let Some(ref state) = self.data_table_state
3414                    && let Some(dataset) = self.view_dataset()
3415                {
3416                    match self
3417                        .views
3418                        .manager
3419                        .get_most_relevant(dataset, state.source_schema())
3420                    {
3421                        Some((view, why)) => {
3422                            if let Err(e) = self.apply_matched_view(&view, why) {
3423                                self.error_modal.show(format!("Error applying view: {}", e));
3424                            }
3425                        }
3426                        None => self.open_view_list(),
3427                    }
3428                }
3429                None
3430            }
3431            KeyCode::Char('v') => {
3432                self.open_view_list();
3433                None
3434            }
3435            KeyCode::Char('S') => {
3436                if self.at_table() {
3437                    self.open_table_sample_form();
3438                }
3439                None
3440            }
3441            KeyCode::Char('s') => {
3442                if self.data_table_state.is_some() {
3443                    // Rebuilt from the table's applied state, never the modal's last contents: a
3444                    // canceled edit must not come back staged.
3445                    self.sync_sort_filter_modal();
3446                    let current = self
3447                        .data_table_state
3448                        .as_ref()
3449                        .and_then(|state| state.current_column())
3450                        .map(str::to_string);
3451                    self.sort_filter_modal.open(
3452                        self.display.history_limit,
3453                        &self.theme,
3454                        current.as_deref(),
3455                    );
3456                    self.open_overlay(Overlay::SortFilter);
3457                }
3458                None
3459            }
3460            KeyCode::Char('r') => {
3461                if let Some(state) = &mut self.data_table_state {
3462                    state.deferred(DataTableState::reverse);
3463                    self.spawn_async_collect("Sorting...");
3464                }
3465                None
3466            }
3467            KeyCode::Char('a') => {
3468                // Nothing computes until a tool is chosen.
3469                if self.data_table_state.is_some()
3470                    && self.at_table()
3471                    && self.quality.evidence_return.is_none()
3472                {
3473                    // The results a close put down come back on the view they are of.
3474                    let view = self.data_table_state.as_ref().map(|s| s.len_generation());
3475                    self.analysis_modal.open(view);
3476                    self.open_overlay(Overlay::Analysis);
3477                    // The sample outlives a close, but its scope names this dataset's rows: another
3478                    // dataset starts from its current view.
3479                    if self.analysis_modal.sample_dataset != Some(self.dataset_generation) {
3480                        self.analysis_modal.sample.scope =
3481                            analysis::data_quality::QualityScope::CurrentView;
3482                        self.analysis_modal.sample_dataset = Some(self.dataset_generation);
3483                    }
3484                    // A view with a sample: every tool reads it, whole.
3485                    let sampled = self
3486                        .data_table_state
3487                        .as_ref()
3488                        .is_some_and(|state| state.sampled().is_some());
3489                    self.analysis_modal.follow_view_sample(sampled);
3490                    self.sync_quality_plan();
3491                }
3492                None
3493            }
3494            KeyCode::Char('c') => {
3495                if let Some(state) = &self.data_table_state
3496                    && self.at_table()
3497                {
3498                    let numeric_columns: Vec<String> = state
3499                        .schema()
3500                        .iter()
3501                        .filter(|(_, dtype)| dtype.is_numeric())
3502                        .map(|(name, _)| name.to_string())
3503                        .collect();
3504                    let datetime_columns: Vec<String> = state
3505                        .schema()
3506                        .iter()
3507                        .filter(|(_, dtype)| {
3508                            matches!(
3509                                dtype,
3510                                DataType::Datetime(_, _) | DataType::Date | DataType::Time
3511                            )
3512                        })
3513                        .map(|(name, _)| name.to_string())
3514                        .collect();
3515                    let category_columns: Vec<String> = state
3516                        .schema()
3517                        .iter()
3518                        .filter(|(_, dtype)| chart::chart_data::is_category_dtype(dtype))
3519                        .map(|(name, _)| name.to_string())
3520                        .collect();
3521                    // Show Me: the chart starts from the cursor column's type.
3522                    let cursor = state.current_column().and_then(|name| {
3523                        let dtype = state.schema().get(name)?.clone();
3524                        Some((name.to_string(), dtype))
3525                    });
3526                    // Dates and datetimes take a time bucket; a time of day does not.
3527                    let bucketable_columns: Vec<String> = state
3528                        .schema()
3529                        .iter()
3530                        .filter(|(_, dtype)| {
3531                            matches!(dtype, DataType::Datetime(_, _) | DataType::Date)
3532                        })
3533                        .map(|(name, _)| name.to_string())
3534                        .collect();
3535                    self.chart.modal.series_cap = Some(self.theme.series_colors().len());
3536                    self.chart.modal.row_order = self.view_state().sort;
3537                    let sampled = state.sampled().is_some();
3538                    self.chart.modal.open(
3539                        ChartColumns {
3540                            numeric: &numeric_columns,
3541                            datetime: &datetime_columns,
3542                            bucketable: &bucketable_columns,
3543                            category: &category_columns,
3544                        },
3545                        cursor.as_ref().map(|(name, dtype)| (name.as_str(), dtype)),
3546                        Some(self.app_config.analysis.chart_rows),
3547                        self.app_config.analysis.chart_grid,
3548                        self.dataset_generation,
3549                    );
3550                    // A view's sample is read whole: the chart has no sample of its own.
3551                    if sampled {
3552                        self.chart.modal.row_limit = None;
3553                    } else if self.chart.modal.view_sampled {
3554                        self.chart.modal.row_limit = Some(self.chart.modal.sample_rows);
3555                    }
3556                    self.chart.modal.view_sampled = sampled;
3557                    self.chart.cache.clear();
3558                    self.open_overlay(Overlay::Chart);
3559                }
3560                None
3561            }
3562            KeyCode::Char('p') => {
3563                if self.data_table_state.is_some() && self.at_table() {
3564                    self.open_pivot_builder();
3565                }
3566                None
3567            }
3568            KeyCode::Char('e') => {
3569                if self.data_table_state.is_some() && self.at_table() {
3570                    self.export_modal.open(
3571                        self.source.original_file_format,
3572                        self.display.history_limit,
3573                        &self.theme,
3574                        self.source.original_file_delimiter,
3575                    );
3576                    // A name to start from, beside the source's rather than on it.
3577                    let stem = self.dataset_stem();
3578                    self.export_modal.suggest_path(&format!("{stem}-export"));
3579                    if let Some(state) = self.data_table_state.as_ref() {
3580                        self.export_modal.offer_source_file = state.can_name_source_files();
3581                        self.export_modal.nested_columns = state
3582                            .get_column_order()
3583                            .iter()
3584                            .filter_map(|name| state.schema().get(name))
3585                            .any(crate::export::nested_json::is_nested);
3586                        self.export_modal.avro_renames =
3587                            state.get_column_order().iter().any(|name| {
3588                                state.schema().get(name).is_some_and(|dtype| {
3589                                    crate::export::avro_types::renames(name, dtype)
3590                                })
3591                            });
3592                    }
3593                    self.open_over(|returns_to| Overlay::Export { returns_to });
3594                }
3595                None
3596            }
3597            KeyCode::Char(' ') if event.is_press() => {
3598                if self.at_table() {
3599                    self.open_inspector();
3600                }
3601                None
3602            }
3603            KeyCode::Char('g') if event.is_press() => {
3604                if self.at_table() {
3605                    self.open_go_to_column();
3606                }
3607                None
3608            }
3609            KeyCode::Char('b') if event.is_press() => {
3610                if self.at_table() {
3611                    self.open_format_picker();
3612                }
3613                None
3614            }
3615            KeyCode::Char('T') if event.is_press() => {
3616                if self.at_table() {
3617                    self.open_table_picker();
3618                }
3619                None
3620            }
3621            KeyCode::Char('y') => {
3622                if self.at_table()
3623                    && let Some(state) = self.data_table_state.as_ref()
3624                {
3625                    let columns = state.get_column_order().to_vec();
3626                    let context = app::modals::copy_modal::CopyContext {
3627                        row_number: state.selected_display_row().unwrap_or(0),
3628                        view_rows: state.copy_view_df().map(|d| d.height()).unwrap_or(0),
3629                        view_cols: columns.len(),
3630                        total_rows: state.num_rows_if_valid(),
3631                    };
3632                    let current = state.current_column().map(str::to_string);
3633                    self.copy_modal.open(columns, current.as_deref(), context);
3634                    self.open_overlay(Overlay::Copy);
3635                }
3636                None
3637            }
3638            _ => None,
3639        }
3640    }
3641
3642    /// Handle one event. A key arriving while busy is neither acted on nor dropped: it
3643    /// returns as `Err(key)` for the caller to hold until idle, as
3644    /// [`app::event_pump::EventPump`] does. [`App::event`] is for callers with nowhere to
3645    /// hold a key.
3646    pub fn handle(&mut self, event: AppEvent) -> EventOutcome {
3647        let started = std::time::Instant::now();
3648        let outcome = self.handle_event(event);
3649        self.debug.times.handler(started.elapsed());
3650        outcome
3651    }
3652
3653    fn handle_event(&mut self, event: AppEvent) -> EventOutcome {
3654        // Without the pump to offer it as typed, a pressed key is a key.
3655        if let AppEvent::Press(key) = event {
3656            return self.handle_event(AppEvent::Key(key));
3657        }
3658        if let AppEvent::Key(key) = event
3659            && self.is_busy()
3660            && !self.key_acts_while_busy(&key)
3661        {
3662            return Err(key);
3663        }
3664        let out = self.dispatch_event(event);
3665        // Not while returning a continuation: the follow-up is the rest of this event
3666        // (e.g. `AnalysisCompute` before its job spawns), nothing holds the generation
3667        // yet, and an errand would advance it underneath. Errands wait for the next event.
3668        if out.is_none() {
3669            self.let_waiting_errands_in();
3670        }
3671        self.ensure_chart_data();
3672        self.home_score_search();
3673        // New rows on hand under an open find prompt: light up their matches.
3674        self.refresh_stale_live_matches();
3675        Ok(out)
3676    }
3677
3678    /// Give the errands waiting for a free generation their turn: after every event,
3679    /// and when a continuation's hold is released.
3680    pub(crate) fn let_waiting_errands_in(&mut self) {
3681        // Columns footers found while the user was inside a query are held; they join on
3682        // the first event after the view returns to the data.
3683        if self.join_held_footers() {
3684            self.reread_after_the_footers_joined();
3685        }
3686        self.join_followed_fields();
3687        self.describe_ended_journal();
3688        // Likewise a re-read owed to a dataset whose footers could not be read.
3689        self.reread_when_the_work_allows();
3690        self.collect_when_the_work_allows();
3691    }
3692
3693    pub fn event(&mut self, event: AppEvent) -> Option<AppEvent> {
3694        self.handle(event).unwrap_or(None)
3695    }
3696
3697    fn dispatch_event(&mut self, event: AppEvent) -> Option<AppEvent> {
3698        self.debug.num_events += 1;
3699
3700        match event {
3701            AppEvent::Key(key) => {
3702                // Leaving while standard input is still being recorded asks first.
3703                if let Some(leaving) = self.leaving_by(&key)
3704                    && !self.confirmation_modal.active
3705                    && self.recording().is_some_and(|spool| spool.live())
3706                {
3707                    self.ask_about_recording(leaving);
3708                    return None;
3709                }
3710                self.key(&key)
3711            }
3712            AppEvent::Open(paths, options) => {
3713                if paths.is_empty() {
3714                    return Some(AppEvent::Crash("No paths provided".to_string()));
3715                }
3716                // Home is now in the stack, so q pops back to it. Never unset: a reread (H) is not
3717                // a new place.
3718                if self.input_mode == InputMode::Home {
3719                    self.source.opened_from_home = true;
3720                }
3721                // `az://container/path` names no account; where it was typed, or the config, does.
3722                #[cfg(feature = "cloud")]
3723                let expanded = match paths
3724                    .iter()
3725                    .map(|p| {
3726                        crate::cloud::cloud_sources::expand_azure_short_url(
3727                            p,
3728                            &self.app_config.cloud,
3729                            self.home.browsing.as_deref(),
3730                        )
3731                    })
3732                    .collect::<std::result::Result<Vec<_>, _>>()
3733                {
3734                    Ok(expanded) => expanded,
3735                    Err(message) => return Some(AppEvent::Crash(message)),
3736                };
3737                #[cfg(feature = "cloud")]
3738                if expanded != paths {
3739                    return Some(AppEvent::Open(expanded, options));
3740                }
3741                // Asks the filesystem for the loading screen's size and whether the path exists to
3742                // be a recent.
3743                let mut request = loading::OpenRequest::named(paths, options, &self.formats);
3744                request.warn_in_memory_above = self.app_config.read.memory_warning();
3745                self.begin_new_dataset();
3746                let step = self.loading.open(request);
3747                self.run_load_step(step)
3748            }
3749            AppEvent::OpenLazyFrame(lf, options) => {
3750                self.begin_new_dataset();
3751                let step = self.loading.open_frame(*lf, options);
3752                self.run_load_step(step)
3753            }
3754            AppEvent::HomeListingReady { .. }
3755            | AppEvent::HomeListingFailed
3756            | AppEvent::HomeMeasured { .. }
3757            | AppEvent::HomeSized { .. }
3758            | AppEvent::HomeWebGone { .. }
3759            | AppEvent::HomeClassified { .. }
3760            | AppEvent::HomePathListed { .. }
3761            | AppEvent::HomePathCompleted { .. }
3762            | AppEvent::HomePreviewReady { .. }
3763            | AppEvent::HomeSchemaReady { .. }
3764            | AppEvent::HomeSearchBatch { .. }
3765            | AppEvent::HomeSearchScored { .. }
3766            | AppEvent::HomeSearchDone { .. }
3767            | AppEvent::HomeNarrowed { .. }
3768            | AppEvent::HomeProbeCancelled { .. }
3769            | AppEvent::HomeProbeFailed { .. }
3770            | AppEvent::HomeProbeProgress { .. }
3771            | AppEvent::HomeProbeReady { .. }
3772            | AppEvent::HomeCloudKinds { .. } => self.home_event(event),
3773            #[cfg(feature = "cloud")]
3774            AppEvent::HomeCloudSources { .. } | AppEvent::HomeCloudListed { .. } => {
3775                self.home_event(event)
3776            }
3777            AppEvent::Resize(_cols, _rows) => {
3778                // The next render sets visible_rows and needs_recollect; the main loop collects.
3779                None
3780            }
3781            AppEvent::Collect => {
3782                self.spawn_async_collect(Self::LOADING_BUFFER);
3783                None
3784            }
3785            AppEvent::Scroll(scroll) => self.handle_scroll(|s| scroll.run(s)),
3786            AppEvent::AnalysisCompute(tool) => self.spawn_analysis(tool),
3787            AppEvent::BackgroundLenReady { .. }
3788            | AppEvent::FramePainted
3789            | AppEvent::BackgroundLenFailed { .. } => self.counting_event(event),
3790            AppEvent::BackgroundQualitySampleKept { kept } => {
3791                self.retain_quality_sample(&kept);
3792                None
3793            }
3794            AppEvent::BackgroundQualityCopyKept {
3795                dataset_generation,
3796                copy,
3797            } => {
3798                self.retain_quality_copy(dataset_generation, copy);
3799                None
3800            }
3801            AppEvent::OpenNamed(paths, options) => {
3802                if let Some(event) = Self::route_named_without_looking(&paths, &options) {
3803                    return Some(event);
3804                }
3805                let formats = self.formats.clone();
3806                // The open's first phase. Unleased: an answer for an open the user left is thrown
3807                // away by the loader, not waited for.
3808                self.put_down_load_in_flight();
3809                let load = self.loading.look_at_paths();
3810                self.spawn_job(Job::OpenNamed(load), Some("Scanning input..."), move |_| {
3811                    if let Some(missing) = Self::missing_named_path(&paths, &formats) {
3812                        return Ok(Answer::NamedPathMissing(missing));
3813                    }
3814                    let (paths, options, directory) =
3815                        match Self::route_named_paths_with(paths, options, &formats) {
3816                            AppEvent::LookThenOpenDirectory(dir, options) => {
3817                                (Vec::new(), options, Some(dir))
3818                            }
3819                            AppEvent::Open(paths, options) => (paths, options, None),
3820                            _ => unreachable!("a named path is opened or looked at"),
3821                        };
3822                    Ok(Answer::NamedPaths {
3823                        paths,
3824                        options: Box::new(options),
3825                        directory,
3826                    })
3827                });
3828                None
3829            }
3830            AppEvent::LookThenOpenDirectory(looking, options) => {
3831                // Named on the wait so the first frame says which directory is being looked at;
3832                // Ctrl+C and Ctrl+O keep working during it.
3833                self.put_down_load_in_flight();
3834                let load = self.loading.look_at_directory(looking.clone());
3835                // A newer look replaces an older one.
3836                self.jobs
3837                    .supersede(|job| matches!(job, Job::LookAtDirectory { .. }));
3838                // The loading screen's words, so the footer agrees with it. Unleased: the answer
3839                // is meant to be dropped when the user moves on, and a lease would make the
3840                // next open's collect wait for the abandoned look.
3841                #[cfg(feature = "cloud")]
3842                let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
3843                let job = Job::LookAtDirectory {
3844                    load,
3845                    path: looking.clone(),
3846                };
3847                self.spawn_job(job, Some(Self::LOOKING_AT_A_DIRECTORY), move |_| {
3848                    #[cfg(feature = "cloud")]
3849                    if home::is_object_store_url(&looking) {
3850                        let url = looking.to_string_lossy().into_owned();
3851                        let peeked = wait_on_runtime(&runtime, async move {
3852                            crate::cloud::cloud_browse::peek_kind(&url, &cloud).await
3853                        })
3854                        .and_then(Result::ok);
3855                        let (kind, holds) = match peeked {
3856                            Some((kind, holds)) => (kind, Some(Box::new(holds))),
3857                            None => (home::discover::EntryKind::Unknown, None),
3858                        };
3859                        return Ok(Answer::LookedAt {
3860                            kind,
3861                            holds,
3862                            options: Box::new(options),
3863                        });
3864                    }
3865                    // Caught: on a worker a panic would be swallowed and the spinner stay up forever,
3866                    // so the answer becomes "a directory" and home opens on it. Read as this open
3867                    // will read, so the rule judges the directory the user is about to see.
3868                    let as_read = Self::read_as(&options);
3869                    let looked = logging::catch_panic(|| {
3870                        let mut entry = home::discover::Entry::directory(&looking);
3871                        entry.kind = home::discover::EntryKind::Unknown;
3872                        home::look_into_as(&entry, &as_read)
3873                    });
3874                    let kind = match looked {
3875                        Ok(entry) => entry.kind,
3876                        Err(_) => home::discover::EntryKind::Directory,
3877                    };
3878                    Ok(Answer::LookedAt {
3879                        kind,
3880                        holds: None,
3881                        options: Box::new(options),
3882                    })
3883                });
3884                None
3885            }
3886            AppEvent::ClassifyThenOpen {
3887                path: looking,
3888                jump,
3889            } => {
3890                // A second Enter supersedes the first (home keys act while busy): the newer look
3891                // is the one waited for, and refusing would let a dead share block every look.
3892                self.jobs.supersede(|job| matches!(job, Job::Classify(_)));
3893                let look = Job::Classify(app::jobs::Classify {
3894                    path: looking.clone(),
3895                    browsing: self.home.browsing.clone(),
3896                    jump,
3897                });
3898                let name = looking
3899                    .file_name()
3900                    .map(|n| n.to_string_lossy().into_owned())
3901                    .unwrap_or_else(|| looking.display().to_string());
3902                // The home screen's own line, because the footer's is the table's.
3903                self.home.status = Some(format!("Looking at {name}..."));
3904                self.spawn_job(look, Some(Self::LOOKING), move |_| {
3905                    // Each of these can hang on a share that went away; hence off the key thread.
3906                    let found = if !looking.exists() {
3907                        None
3908                    } else if looking.is_dir() {
3909                        Some(crate::home::discover::classify_directory(&looking))
3910                    } else {
3911                        Some(crate::home::discover::EntryKind::File)
3912                    };
3913                    Ok(Answer::Kind(found))
3914                });
3915                None
3916            }
3917            AppEvent::JobEnded(ticket) => self.job_ended(ticket),
3918            AppEvent::Applied(applied) => self.apply(applied),
3919            AppEvent::JobProgress { ticket, progress } => {
3920                self.job_progress(ticket, &progress);
3921                None
3922            }
3923            AppEvent::Reset => {
3924                // The sample is a step of the view: a reset takes it away too.
3925                if self
3926                    .data_table_state
3927                    .as_ref()
3928                    .is_some_and(|state| state.sampled().is_some())
3929                {
3930                    self.put_down_sample_draw();
3931                    if let Some(state) = self.data_table_state.take() {
3932                        self.data_table_state = Some(state.into_unsampled());
3933                    }
3934                    self.sample_changed();
3935                }
3936                if let Some(state) = &mut self.data_table_state {
3937                    state.deferred(|s| s.reset());
3938                }
3939                self.spawn_async_collect(Self::LOADING_BUFFER);
3940                self.views.active_id = None;
3941                None
3942            }
3943            AppEvent::Followed(news) => {
3944                self.followed(&news);
3945                None
3946            }
3947            AppEvent::TerminalBackground(mode) => {
3948                self.terminal_answered(mode);
3949                None
3950            }
3951            AppEvent::TerminalFocused => {
3952                self.display.background_query |= self.app_config.theme.follow;
3953                None
3954            }
3955            // Taken before here: a press becomes a key in `handle_event`; terminal, wake, exit,
3956            // crash and missing-path events in the pump; settings in `run`.
3957            AppEvent::Press(_)
3958            | AppEvent::Terminal(_)
3959            | AppEvent::Wake
3960            | AppEvent::SettingsRead(_)
3961            | AppEvent::NamedPathMissing(_)
3962            | AppEvent::Exit
3963            | AppEvent::Crash(_)
3964            | AppEvent::Update => None,
3965        }
3966    }
3967
3968    /// The first sidebar filter whose value does not read as its column's type, said
3969    /// for the user.
3970    fn filter_problem(&self) -> Option<String> {
3971        let schema = self.data_table_state.as_ref()?.schema();
3972        self.sort_filter_modal
3973            .filter
3974            .statements
3975            .iter()
3976            .find_map(|f| {
3977                crate::export::python_script::SidebarFilter::problem(f, schema.get(&f.column))
3978            })
3979    }
3980
3981    /// Whether the dataset is delimited text, whose header `H` on Info's Schema tab
3982    /// toggles.
3983    pub fn header_toggle_offered(&self) -> bool {
3984        self.source
3985            .opened
3986            .as_ref()
3987            .and_then(|(_, options)| options.format)
3988            .and_then(FileFormat::separator)
3989            .is_some()
3990    }
3991
3992    /// Read the dataset again with its first row the other way: as names, or as data
3993    /// under `column_1`, …. Only delimited text; elsewhere a no-op.
3994    pub(crate) fn toggle_header(&mut self) -> Option<AppEvent> {
3995        if !self.header_toggle_offered() {
3996            return None;
3997        }
3998        let (paths, options) = self.source.opened.clone()?;
3999        let options = OpenOptions {
4000            has_header: Some(!options.has_header.unwrap_or(true)),
4001            ..options
4002        };
4003        self.set_loading_phase("Scanning input", 10);
4004        self.name_what_is_loading(paths[0].clone());
4005        Some(AppEvent::Open(paths, options))
4006    }
4007
4008    /// `H` / `L`: move the cursor's column one place in the sidebar's order, the
4009    /// cursor with it. Frozen among frozen, scrolling among scrolling; nothing at an
4010    /// end.
4011    fn move_cursor_column(&mut self, right: bool) -> Option<AppEvent> {
4012        let state = self.data_table_state.as_ref()?;
4013        let at = state.current_column_index()?;
4014        let mut order = state.headers();
4015        let locked = state.locked_columns_count().min(order.len());
4016        let to = if right { at + 1 } else { at.checked_sub(1)? };
4017        if to >= order.len() || (at < locked) != (to < locked) {
4018            return None;
4019        }
4020        // Held by name, so it lands on the column where the move puts it.
4021        let moving = order[at].clone();
4022        self.data_table_state.as_mut()?.set_current_column(&moving);
4023        order.swap(at, to);
4024        // The sidebar orders hidden columns by its last applied order; swap them there
4025        // too so it agrees with the table.
4026        let applied = &mut self.sort_filter_modal.sort.applied_order;
4027        if let (Some(i), Some(j)) = (
4028            applied.iter().position(|c| *c == order[at]),
4029            applied.iter().position(|c| *c == order[to]),
4030        ) {
4031            applied.swap(i, j);
4032        }
4033        Some(AppEvent::Applied(Applied::ColumnOrder(order, locked)))
4034    }
4035
4036    /// `[` / `]` at the table: sort by the cursor's column, replacing the sort in
4037    /// effect. The same key again on that sort alone removes it.
4038    fn sort_by_cursor_column(&mut self, descending: bool) -> Option<AppEvent> {
4039        let state = self.data_table_state.as_ref()?;
4040        let column = state.current_column()?.to_string();
4041        let already = state.view_sort_columns() == std::slice::from_ref(&column)
4042            && state.view_sort_descending() == [descending];
4043        if already {
4044            // Back to natural order: `sort` with no columns also resets the direction `]` left,
4045            // which would read as a reversal.
4046            if let Some(state) = self.data_table_state.as_mut() {
4047                state.deferred(|s| s.sort(Vec::new(), true));
4048            }
4049            self.spawn_async_collect("Sorting...");
4050            return None;
4051        }
4052        Some(AppEvent::Applied(Applied::Sort(
4053            vec![column],
4054            vec![descending],
4055        )))
4056    }
4057}
4058
4059impl App {
4060    /// `+` / `-`: add a sidebar filter keeping (or dropping) rows with the cursor's
4061    /// cell value exactly as stored, and apply it. A null cell is "is null" / "not
4062    /// null"; `R` clears it.
4063    fn quick_filter(&mut self, keep: bool) -> Option<AppEvent> {
4064        let state = self.data_table_state.as_ref()?;
4065        let column = state.current_column()?.to_string();
4066        let row = state.copy_row_df()?;
4067        let series = row.column(&column).ok()?.as_materialized_series().clone();
4068        let value = series.get(0).ok()?;
4069        // The schema's type, not the buffer's: binary is buffered as a stub.
4070        let dtype = state.schema().get(&column)?.clone();
4071        let (operator, text) = if value.is_null() {
4072            let operator = if keep {
4073                FilterOperator::IsNull
4074            } else {
4075                FilterOperator::IsNotNull
4076            };
4077            (operator, String::new())
4078        } else {
4079            let operator = if keep {
4080                FilterOperator::Eq
4081            } else {
4082                FilterOperator::NotEq
4083            };
4084            // Text that reads back to exactly this value: a float as stored, a datetime to its
4085            // last digit, in its zone.
4086            let text = crate::typed_value::text_of(&value, &dtype);
4087            let Some(text) = text else {
4088                let kind = match dtype {
4089                    DataType::List(_) => "lists",
4090                    DataType::Array(..) => "arrays",
4091                    DataType::Struct(_) => "structs",
4092                    DataType::Binary | DataType::BinaryOffset => "binary",
4093                    _ => "this type",
4094                };
4095                self.flash_note(format!("+ and - filter on plain values, not {kind}"));
4096                return None;
4097            };
4098            (operator, text)
4099        };
4100        let statement = FilterStatement {
4101            columns: Vec::new(),
4102            column,
4103            operator,
4104            value: text,
4105            logical_op: LogicalOperator::And,
4106        };
4107        let mut statements = state.view_filters().to_vec();
4108        if statements.contains(&statement) {
4109            return None;
4110        }
4111        statements.push(statement);
4112        Some(AppEvent::Applied(Applied::Filter(statements)))
4113    }
4114
4115    /// Bring the sidebar in line with what is applied to the frame on screen (column
4116    /// order, hidden set, sort, filters). Called on open, so a canceled edit never
4117    /// returns staged, and after a drill-down, where stale filters would hit a List
4118    /// column.
4119    fn sync_sort_filter_modal(&mut self) {
4120        let Some(state) = self.data_table_state.as_ref() else {
4121            return;
4122        };
4123        let filters = state.view_filters().to_vec();
4124        let sort_columns = state.view_sort_columns().to_vec();
4125        let sort_descending = state.view_sort_descending().to_vec();
4126        let headers: Vec<String> = state.schema().iter_names().map(|s| s.to_string()).collect();
4127        let schema = state.schema().clone();
4128        let order = state.headers();
4129        let locked = state.locked_columns_count();
4130
4131        let modal = &mut self.sort_filter_modal;
4132        modal.filter.applied = filters.clone();
4133        modal.filter.statements = filters;
4134        modal.filter.operands = order
4135            .iter()
4136            .map(|name| {
4137                schema
4138                    .get(name)
4139                    .map(crate::app::modals::filter_modal::Operand::of)
4140                    .unwrap_or_default()
4141            })
4142            .collect();
4143        modal.filter.available_columns = order.clone();
4144        // The cursor starts on the add row; the editor never survives a resync.
4145        modal.filter.cursor = modal.filter.statements.len();
4146        modal.filter.editor = None;
4147        // A schema column the applied order leaves out is hidden, and listed where it stood
4148        // so showing it restores its place. The sidebar's last applied order decides that
4149        // only while the table still shows it; after a view, query or reshape set the
4150        // order, the schema does.
4151        let shown: std::collections::HashSet<&str> = order.iter().map(String::as_str).collect();
4152        let applied = &modal.sort.applied_order;
4153        let current = applied
4154            .iter()
4155            .filter(|name| shown.contains(name.as_str()))
4156            .eq(order.iter());
4157        let reference: &[String] = if current { applied } else { &[] };
4158        let full = order_with_hidden(&order, &headers, reference);
4159        let places: HashMap<&str, usize> = full
4160            .iter()
4161            .enumerate()
4162            .map(|(i, name)| (name.as_str(), i))
4163            .collect();
4164        let place = |name: &String| places.get(name.as_str()).copied();
4165        // Everything up to the last frozen column stays frozen, hidden ones included; only
4166        // the applied order knows a hidden column that ended the frozen span.
4167        let last_locked = applied
4168            .get(..modal.sort.applied_locked)
4169            .filter(|span| {
4170                current && span.iter().filter(|n| shown.contains(n.as_str())).count() == locked
4171            })
4172            .and_then(|span| span.iter().rev().find_map(place))
4173            .or_else(|| {
4174                locked
4175                    .checked_sub(1)
4176                    .and_then(|i| order.get(i))
4177                    .and_then(place)
4178            });
4179        modal.sort.columns = headers
4180            .iter()
4181            .map(|name| {
4182                let display_order = places[name.as_str()];
4183                SortColumn {
4184                    name: name.clone(),
4185                    // 1-based, as the modal assigns and the sidebar prints.
4186                    sort_order: sort_columns.iter().position(|c| c == name).map(|o| o + 1),
4187                    sort_descending: sort_columns
4188                        .iter()
4189                        .position(|c| c == name)
4190                        .and_then(|i| sort_descending.get(i).copied())
4191                        .unwrap_or(false),
4192                    display_order,
4193                    is_locked: last_locked.is_some_and(|l| display_order <= l),
4194                    is_to_be_locked: false,
4195                    is_visible: shown.contains(name.as_str()),
4196                    width: state.width_choice(name),
4197                    shown_width: state.on_screen_width(name),
4198                }
4199            })
4200            .collect();
4201        modal.sort.has_unapplied_changes = false;
4202    }
4203
4204    /// Apply everything the sidebar stages and close it (Enter or Ctrl+Enter).
4205    fn apply_sort_filter(&mut self) -> Option<AppEvent> {
4206        // A row still under edit is committed, never silently dropped.
4207        if self.sort_filter_modal.filter.editor.is_some() {
4208            self.sort_filter_modal.filter.commit_editor();
4209        }
4210        // A value its column cannot compare with stays in the sidebar, which says why.
4211        if let Some(why) = self.filter_problem() {
4212            self.sort_filter_modal.sort.status = Some(why);
4213            return None;
4214        }
4215        let (columns, descending) = self.sort_filter_modal.sort.sorted_columns_and_directions();
4216        let column_order = self.sort_filter_modal.sort.get_column_order();
4217        let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
4218        self.sort_filter_modal.sort.applied_order =
4219            self.sort_filter_modal.sort.get_full_column_order();
4220        self.sort_filter_modal.sort.applied_locked = self.sort_filter_modal.sort.get_locked_span();
4221        let statements = self.sort_filter_modal.filter.statements.clone();
4222        // Widths read nothing, so they apply here. With nothing else changed the view
4223        // stays on its page: re-applying order, filters and sort would read from the top.
4224        let view_unchanged = self.data_table_state.as_mut().is_some_and(|state| {
4225            state.set_width_choices(self.sort_filter_modal.sort.width_choices());
4226            state.headers() == column_order
4227                && state.locked_columns_count() == locked_count
4228                && state.view_filters() == statements.as_slice()
4229                && state.view_sort_columns() == columns.as_slice()
4230                && state.view_sort_descending() == descending.as_slice()
4231        });
4232        for col in &mut self.sort_filter_modal.sort.columns {
4233            col.is_to_be_locked = false;
4234        }
4235        self.sort_filter_modal.sort.has_unapplied_changes = false;
4236        self.close_overlay();
4237        if view_unchanged {
4238            return None;
4239        }
4240        Some(AppEvent::Applied(Applied::ApplyView(
4241            column_order,
4242            locked_count,
4243            statements,
4244            columns,
4245            descending,
4246        )))
4247    }
4248
4249    /// Facts read for the dataset's single file stored as its format says (a stream or
4250    /// compressed copy has no footer).
4251    fn facts_of_open(&self) -> Option<(FileFormat, crate::formats::readers::Facts)> {
4252        let hive = self
4253            .source
4254            .opened
4255            .as_ref()
4256            .is_some_and(|(_, options)| options.hive);
4257        let format = self.opened_format()?;
4258        let facts = crate::formats::readers::of(format).facts?;
4259        let state = self.data_table_state.as_ref()?;
4260        let plain = state
4261            .read_mode()
4262            .is_none_or(|mode| Some(mode) == format.read_mode(crate::Stored::Plain));
4263        // Several files, whose footers the Notes and Schema tabs already sum up.
4264        let one_file = state.dataset_schema().is_none();
4265        (!hive && plain && one_file).then_some((format, facts))
4266    }
4267
4268    /// The footer's line while a query's first rows are read; the rows drawn meanwhile
4269    /// are the replaced view's.
4270    pub(crate) fn query_reading(&self) -> Option<&str> {
4271        let run = self.prompt.query_running.as_ref()?;
4272        let frame = self.data_table_state.as_ref()?.len_generation();
4273        if !matches!(run.origin, RunOrigin::Query(_)) || run.frame != frame {
4274            return None;
4275        }
4276        self.jobs
4277            .waiting_status(|job| Self::reading_rows(job) || Self::owed_rows(job))
4278    }
4279
4280    /// A job's outcome is in: take it and its record from [`Jobs`] and act on it. The
4281    /// job holds the generation and keys until its answer is handled, so whatever the
4282    /// answer starts next holds them first. A waited-on job gives back the keys and
4283    /// its footer line here, unless the answer continues, keeping the wait up.
4284    fn job_ended(&mut self, ticket: Ticket) -> Option<AppEvent> {
4285        let app::jobs::Ended {
4286            job,
4287            current,
4288            keys,
4289            outcome,
4290            ..
4291        } = self.jobs.end(ticket)?;
4292        let cancelled_analysis = !current && Self::is_analysis_read(&job);
4293        let waited = keys.is_some();
4294        let out = match outcome {
4295            Outcome::Answered(answer) => self.answered(job, current, waited, *answer),
4296            Outcome::Failed { message, panicked } => {
4297                self.background_failed(&job, current, waited, &message, panicked);
4298                None
4299            }
4300        };
4301        if let Some(status) = keys {
4302            if out.is_some() {
4303                self.busy = true;
4304            } else {
4305                self.busy = false;
4306                // Unless a job still running shows the same line (a view's rows read after its
4307                // pivot).
4308                if self.status_message.as_deref() == Some(status.as_str())
4309                    && !self.jobs.shows(&status)
4310                {
4311                    self.status_message = None;
4312                }
4313            }
4314        }
4315        // A cancelled analysis's worker exited: once no other is going, Run is free again.
4316        if cancelled_analysis
4317            && self.cancelled_analysis().is_none()
4318            && self.analysis_modal.quality.setup_note.as_deref() == Some(QUALITY_RUN_WAITS)
4319        {
4320            self.analysis_modal.quality.setup_note = None;
4321        }
4322        out
4323    }
4324
4325    /// A report from a job still running, taken while the job is current.
4326    fn job_progress(&mut self, ticket: Ticket, progress: &Progress) {
4327        if !self.jobs.is_current(ticket) {
4328            return;
4329        }
4330        match progress {
4331            Progress::ExportWriting { phase, bytes } => {
4332                if let Some(export) = self.export_progress.as_mut() {
4333                    export.current_phase = phase.to_string();
4334                    export.written = Some(*bytes);
4335                }
4336            }
4337            Progress::QualityPhase(phase) => {
4338                if let Some(progress) = self.analysis_modal.computing.as_mut() {
4339                    progress.phase = phase.stage.label().to_string();
4340                    progress.reads_source = Some(phase.reads_source);
4341                    progress.interruptible = Some(phase.interruptible);
4342                }
4343            }
4344            Progress::Finding { rows } => self.find_progress(*rows),
4345            Progress::HexFinding { read, total } => self.hex_find_progress(*read, *total),
4346            Progress::SampleBegun(schema) => self.sample_begun(schema),
4347            Progress::SampleGrew => self.sample_grew(),
4348        }
4349    }
4350
4351    /// `job` answered. `current`: still the answer waited for; a stale one changes
4352    /// nothing and its payload is dropped here. `waited`: the user waited on it.
4353    fn answered(
4354        &mut self,
4355        job: Job,
4356        current: bool,
4357        waited: bool,
4358        answer: Answer,
4359    ) -> Option<AppEvent> {
4360        match (job, answer) {
4361            (Job::JournalDetail { dataset }, Answer::JournalDescribed(detail)) => {
4362                if dataset == self.dataset_generation
4363                    && let Some(state) = self.data_table_state.as_mut()
4364                {
4365                    state.set_format_detail(*detail);
4366                }
4367                None
4368            }
4369            (Job::IndexLines { dataset }, Answer::LinesIndexed(rows)) => {
4370                self.lines_indexed(dataset, rows);
4371                None
4372            }
4373            (Job::FootersJoin { dataset }, Answer::FootersJoined(found)) => {
4374                self.footers_joined(dataset, found.map(|found| *found))
4375            }
4376            (Job::Load(load), Answer::Load(answer)) => {
4377                // The loader judges by load identity, not generation: an answer for a replaced or
4378                // abandoned open, or a phase it left, is dropped with what it carries.
4379                let step = self.loading.answered(
4380                    load,
4381                    *answer,
4382                    #[cfg(any(feature = "http", feature = "cloud"))]
4383                    &self.jobs,
4384                );
4385                self.run_load_step(step)
4386            }
4387            (
4388                Job::OpenNamed(load),
4389                Answer::NamedPaths {
4390                    paths,
4391                    options,
4392                    directory,
4393                },
4394            ) => {
4395                // The user left the open while its paths were looked at, or another replaced it.
4396                if !self.loading.looking_at_paths(load) {
4397                    return None;
4398                }
4399                // Either carries the same open on: it is still starting.
4400                Some(match directory {
4401                    Some(dir) => AppEvent::LookThenOpenDirectory(dir, *options),
4402                    None => AppEvent::Open(paths, *options),
4403                })
4404            }
4405            (Job::OpenNamed(load), Answer::NamedPathMissing(path)) => {
4406                if !self.loading.looking_at_paths(load) {
4407                    return None;
4408                }
4409                // The session ends saying so; nothing is opened.
4410                if let Some(retired) = self.loading.retire() {
4411                    self.put_down_load(retired);
4412                }
4413                Some(AppEvent::NamedPathMissing(path))
4414            }
4415            (
4416                Job::LookAtDirectory { load, path },
4417                Answer::LookedAt {
4418                    kind,
4419                    holds,
4420                    options,
4421                },
4422            ) => {
4423                // Ctrl+O, a newer look or another open replaced this one: nobody waits for it.
4424                if !self.loading.looking_at_directory(load) {
4425                    return None;
4426                }
4427                // An `Open` that follows carries the same open on.
4428                self.open_the_directory_looked_at(path, kind, holds.as_deref(), *options)
4429            }
4430            (Job::Classify(asked), Answer::Kind(found)) => {
4431                // Superseded: whatever replaced it owns the wait.
4432                if !current {
4433                    return None;
4434                }
4435                self.home.status = None;
4436
4437                // A home key answers on home: if the user went back to the data, or browsed
4438                // elsewhere, acting now would pull them back.
4439                if self.input_mode != InputMode::Home || self.home.browsing != asked.browsing {
4440                    return None;
4441                }
4442
4443                let path = asked.path;
4444                let Some(kind) = found else {
4445                    self.home.status = Some(format!("No such path: {}", path.display()));
4446                    if asked.jump {
4447                        // A typo typed at `~` is worth another go without retyping it.
4448                        self.home.path_input = path.display().to_string();
4449                        self.home.path_input_active = true;
4450                        self.list_the_typed_directory();
4451                    }
4452                    return None;
4453                };
4454                self.open_what_it_is(path, kind, asked.jump)
4455            }
4456            (Job::Rows(inflight), Answer::Rows(result)) => {
4457                // A stale page is dropped; the wait belongs to whatever replaced it.
4458                if !current {
4459                    return None;
4460                }
4461                // Timed here, when the rows exist to be drawn; the paint costs the same whatever
4462                // the fetch did.
4463                if let Some(state) = self.data_table_state.as_ref() {
4464                    let took = inflight.began.elapsed();
4465                    log::debug!(
4466                        target: "datui",
4467                        "rows {}..{} of {}: read in {took:.1?}",
4468                        inflight.start,
4469                        inflight.end,
4470                        inflight.dataset
4471                    );
4472                    state.measurements().read_page(took, inflight.files);
4473                }
4474                if let Some(state) = &mut self.data_table_state {
4475                    state.apply_async_collect(result);
4476                }
4477                self.retire_a_count_the_rows_answered();
4478                self.remember_a_downloads_shape();
4479                // Rows a follow counted while these were read are shown next.
4480                self.catch_up_follow();
4481                // The query's first rows are in: it stands.
4482                let ran = self.take_query_run();
4483                // A load-ahead's end is nobody's wait ending: whatever else is under
4484                // way meanwhile keeps its spinner and its message.
4485                if waited {
4486                    self.first_rows_settled();
4487                    match ran.map(|run| run.origin) {
4488                        Some(RunOrigin::Query(mode)) if self.query_prompt_mode() == Some(mode) => {
4489                            self.leave_query_prompt_after_run();
4490                        }
4491                        // Shown once the wait is over, or the spinner's message hides it.
4492                        Some(RunOrigin::View {
4493                            matched: Some((name, why)),
4494                            ..
4495                        }) => self.flash_view_applied(&name, why),
4496                        _ => {}
4497                    }
4498                }
4499                None
4500            }
4501            (
4502                Job::Rows(_),
4503                Answer::RowsFailed {
4504                    message,
4505                    conversion,
4506                },
4507            ) => {
4508                self.rows_failed(current, waited, &message, conversion.as_deref());
4509                None
4510            }
4511            (Job::Analysis(_), Answer::Analysis(install, results)) => {
4512                if current {
4513                    install(&mut self.analysis_modal, results);
4514                    self.analysis_modal.computing = None;
4515                }
4516                None
4517            }
4518            (
4519                Job::Analysis(_),
4520                Answer::DataQuality {
4521                    results,
4522                    kept,
4523                    plan,
4524                },
4525            ) => {
4526                // Kept whatever became of the results: a read is not to be thrown away.
4527                if let Some(kept) = kept {
4528                    self.retain_quality_sample(&kept);
4529                }
4530                if current
4531                    && self.overlay == Overlay::Analysis
4532                    && self.analysis_modal.selected_tool
4533                        == Some(analysis::analysis_modal::AnalysisTool::DataQuality)
4534                {
4535                    // Labeled with the plan it ran with, whatever has been staged since.
4536                    self.cache_quality_result(&results, (*plan).clone());
4537                    self.analysis_modal.quality.last_plan = Some(*plan);
4538                    self.analysis_modal.quality.results = Some(*results);
4539                    self.analysis_modal.quality.from_cache = false;
4540                    self.analysis_modal
4541                        .set_quality_page(crate::analysis::data_quality::QualityPage::Overview);
4542                    self.analysis_modal.computing = None;
4543                }
4544                None
4545            }
4546            (Job::SampleDraw(draw), Answer::SampleDrawn(drawn)) => {
4547                self.sample_drawn(*draw, current, drawn)
4548            }
4549            (Job::SampleRows, Answer::Sample { df, label }) => {
4550                if current {
4551                    self.analysis_modal.computing = None;
4552                    self.show_sample_view(df, label);
4553                }
4554                None
4555            }
4556            (Job::Pivot, Answer::Pivoted { spec, pivoted }) => {
4557                // Superseded means something replaced the view, which owns the wait.
4558                if !current {
4559                    return None;
4560                }
4561                let installed = self.data_table_state.as_mut().map(|state| {
4562                    state
4563                        .deferred(|s| s.install_pivot(&spec, pivoted))
4564                        .map_err(|e| crate::error_display::user_message_from_report(&e, None))
4565                });
4566                match installed {
4567                    Some(Ok(())) => {
4568                        // Only from the modal: a trip home meanwhile stays home.
4569                        if self.overlay == Overlay::PivotMelt {
4570                            self.close_overlay();
4571                        }
4572                        // The wait passes to the read of its rows.
4573                        self.spawn_async_collect(Self::LOADING_BUFFER);
4574                    }
4575                    Some(Err(message)) => self.error_modal.show(message),
4576                    None => {}
4577                }
4578                None
4579            }
4580            (Job::ReshapePreview { epoch, token }, Answer::ReshapePreviewed { input, result }) => {
4581                self.reshape_preview_ended(epoch, token, input, result);
4582                None
4583            }
4584            (Job::ViewPivot(pivot), Answer::ViewPivoted(pivoted)) => {
4585                // Superseded: cancelled or replaced, and the replacement owns the wait.
4586                let (view, why) = *pivot;
4587                if !current {
4588                    return None;
4589                }
4590                let planned = self.data_table_state.as_mut().map(|state| {
4591                    // Nothing changed while the pivot was read, so earlier steps plan as before, now
4592                    // with the pivot in hand.
4593                    state
4594                        .try_transition(|s| Self::replay_view(s, &view.settings, Some(pivoted)))
4595                        .map(|(_, rollback)| rollback)
4596                        .map_err(|e| e.to_string())
4597                });
4598                match planned {
4599                    // The wait passes to the read of its rows.
4600                    Some(Ok(rollback)) => self.view_planned(&view, rollback, why),
4601                    Some(Err(message)) => self.view_pivot_failed(&message),
4602                    None => {}
4603                }
4604                None
4605            }
4606            (Job::DrillRow, Answer::DrillRow { group_index, row }) => {
4607                // Superseded means something replaced the view, which owns the wait.
4608                if current {
4609                    self.drill_into(group_index, &row);
4610                }
4611                None
4612            }
4613            (Job::InspectRow { frame, row }, Answer::FieldsRead(values)) => {
4614                // Superseded means something replaced the view, which owns the wait.
4615                if !current {
4616                    return None;
4617                }
4618                let asked = self
4619                    .inspector_modal
4620                    .read
4621                    .as_ref()
4622                    .is_some_and(|read| read.key() == (frame, row));
4623                if self.overlay == Overlay::Inspect && asked {
4624                    self.inspector_modal.read =
4625                        Some(inspector::inspector_modal::FieldRead::Read { frame, row, values });
4626                }
4627                None
4628            }
4629            (Job::InspectJson { token }, Answer::JsonParsed(root)) => {
4630                // Superseded means something replaced the view, which owns the wait.
4631                if !current || self.overlay != Overlay::Inspect {
4632                    return None;
4633                }
4634                let modal = &mut self.inspector_modal;
4635                if let Some(wait) = modal.json_wait.take_if(|w| w.token == token) {
4636                    let node = inspector::inspector_drill::Node::Json {
4637                        root,
4638                        path: Vec::new(),
4639                    };
4640                    modal.drill_in(wait.frame, wait.row, wait.label, node);
4641                }
4642                None
4643            }
4644            (Job::InspectPretty { token }, Answer::Indented(text)) => {
4645                let modal = &mut self.inspector_modal;
4646                if current
4647                    && let Some(inspector::inspector_modal::Pretty::Pending { token: t, place }) =
4648                        modal.pretty.as_ref()
4649                    && *t == token
4650                {
4651                    modal.pretty = Some(inspector::inspector_modal::Pretty::Ready {
4652                        place: place.clone(),
4653                        text,
4654                    });
4655                }
4656                None
4657            }
4658            (Job::InspectUnpack { token }, Answer::Unpacked(decoded)) => {
4659                let modal = &mut self.inspector_modal;
4660                if current
4661                    && let Some(inspector::inspector_modal::Unpack::Pending { token: t, place }) =
4662                        modal.unpack.as_ref()
4663                    && *t == token
4664                {
4665                    modal.unpack = Some(inspector::inspector_modal::Unpack::Ready {
4666                        place: place.clone(),
4667                        text: std::sync::Arc::new(decoded),
4668                    });
4669                }
4670                None
4671            }
4672            (Job::OpenValue, Answer::ValueWritten(open)) => {
4673                if current && self.overlay == Overlay::Inspect {
4674                    self.external.open = Some(open);
4675                }
4676                None
4677            }
4678            (Job::Export, Answer::Exported(path)) => {
4679                // Written: the dialog held for a failure is done with.
4680                self.export_modal.close();
4681                if current {
4682                    self.export_progress = None;
4683                    self.flash_path("Exported to ", &path);
4684                }
4685                None
4686            }
4687            (Job::Copy, Answer::Copied { payload, message }) => {
4688                if current {
4689                    self.export_progress = None;
4690                    self.finish_copy(payload, message);
4691                }
4692                None
4693            }
4694            (Job::QualityReport, Answer::QualityReportWritten(path)) => {
4695                self.analysis_modal.quality.export = None;
4696                if current {
4697                    self.flash_path("Report written to ", &path);
4698                }
4699                None
4700            }
4701            (Job::ChartPrepare(prep), Answer::ChartPrepared(prepared)) => {
4702                self.chart_prepared(*prep, current, Ok(*prepared));
4703                None
4704            }
4705            (Job::ChartExport { path, format }, Answer::ChartExported) => {
4706                // Leaving the chart's dataset supersedes the write, so a late finish does not
4707                // reopen its modal over home.
4708                if current {
4709                    self.finish_chart_export(&path, format, Ok(()));
4710                }
4711                None
4712            }
4713            (Job::FileFacts { dataset }, Answer::FileFacts(facts)) => {
4714                self.file_facts_landed(dataset, facts);
4715                None
4716            }
4717            (Job::UnfitCount { dataset, version }, Answer::UnfitCounted(unfit)) => {
4718                // Every value fitting says nothing in the Notes; the log says it ran.
4719                let columns: Vec<&str> = unfit.iter().map(|u| u.column.as_str()).collect();
4720                let said = if columns.is_empty() {
4721                    "none".to_string()
4722                } else {
4723                    columns.join(", ")
4724                };
4725                log::debug!(target: "datui", "values column types made null, by column: {said}");
4726                if dataset == self.dataset_generation
4727                    && let Some(state) = self.data_table_state.as_mut()
4728                {
4729                    match version {
4730                        None => state.unfit_counted(&unfit),
4731                        Some(version) => state.changes_unfit_counted(version, &unfit),
4732                    }
4733                }
4734                None
4735            }
4736            (Job::Find(run), Answer::Found(found)) => {
4737                self.find_answered(run, current, found);
4738                None
4739            }
4740            (
4741                Job::HexOpen {
4742                    origin,
4743                    fallback,
4744                    record_size,
4745                },
4746                Answer::HexOpened(source),
4747            ) => {
4748                // Superseded: another file, or the view was left.
4749                if current {
4750                    self.hex_opened(origin, fallback, record_size, *source);
4751                }
4752                None
4753            }
4754            (Job::HexFind(run), Answer::HexFound(hit)) => {
4755                if current {
4756                    self.hex_found(run, hit);
4757                }
4758                None
4759            }
4760            (Job::ValueCounts, Answer::ValueCounts(counts)) => {
4761                // Superseded: another column, a cancel, or a trip away.
4762                if current {
4763                    self.value_counts.computing = None;
4764                    self.value_counts.hold(*counts);
4765                }
4766                None
4767            }
4768            // What a test's answer carries goes with it; it rides any job.
4769            #[cfg(test)]
4770            (_, Answer::Probe(held)) => {
4771                drop(held);
4772                None
4773            }
4774            // An answer under another job than its own is a bug: dropped with what it
4775            // carries. Every answer is named, so a new one needs its own arm above.
4776            (
4777                job,
4778                Answer::Load(_)
4779                | Answer::NamedPaths { .. }
4780                | Answer::NamedPathMissing(_)
4781                | Answer::LookedAt { .. }
4782                | Answer::Kind(_)
4783                | Answer::Rows(_)
4784                | Answer::ReshapePreviewed { .. }
4785                | Answer::ViewPivoted(_)
4786                | Answer::FieldsRead(_)
4787                | Answer::JsonParsed(_)
4788                | Answer::Indented(_)
4789                | Answer::Unpacked(_)
4790                | Answer::ChartPrepared(_)
4791                | Answer::ChartExported
4792                | Answer::FileFacts(_)
4793                | Answer::UnfitCounted(_)
4794                | Answer::Found(_)
4795                | Answer::HexOpened(_)
4796                | Answer::HexFound(_)
4797                | Answer::FootersJoined(_)
4798                | Answer::JournalDescribed(_)
4799                | Answer::LinesIndexed(_)
4800                | Answer::RowsFailed { .. }
4801                | Answer::Analysis(..)
4802                | Answer::DataQuality { .. }
4803                | Answer::SampleDrawn(_)
4804                | Answer::Sample { .. }
4805                | Answer::Pivoted { .. }
4806                | Answer::DrillRow { .. }
4807                | Answer::ValueWritten(_)
4808                | Answer::Exported(_)
4809                | Answer::Copied { .. }
4810                | Answer::QualityReportWritten(_)
4811                | Answer::ValueCounts(_),
4812            ) => {
4813                log::error!(target: "datui", "a {:?} job got another job's answer; dropped", job.kind());
4814                None
4815            }
4816        }
4817    }
4818
4819    /// Put down what a failed job started, and say why. [`Self::job_ended`] already
4820    /// released its keys and line. Each arm clears only what that job started; most act
4821    /// only when it is current, and the rest are judged by something of their own.
4822    fn background_failed(
4823        &mut self,
4824        job: &Job,
4825        current: bool,
4826        waited: bool,
4827        message: &str,
4828        panicked: bool,
4829    ) {
4830        // With one line for the reason, a panic's message gives way to the log.
4831        let could_not = |what: &str| {
4832            if panicked {
4833                format!("Could not {what}; see the log")
4834            } else {
4835                format!("Could not {what}: {message}")
4836            }
4837        };
4838        match job {
4839            // Judged by the open: one put down or replaced is not the one waited on.
4840            Job::Load(load) | Job::OpenNamed(load) | Job::LookAtDirectory { load, .. } => {
4841                if let loading::Step::Failed(failed) = self.loading.failed(*load, message) {
4842                    self.load_failed(failed);
4843                }
4844            }
4845            // A pass that failed could not read them: the dataset stops waiting.
4846            Job::FootersJoin { dataset } => {
4847                self.footers_joined(*dataset, None);
4848            }
4849            Job::ChartPrepare(prep) => {
4850                let message = if panicked {
4851                    "Chart preparation panicked".to_string()
4852                } else {
4853                    message.to_string()
4854                };
4855                self.chart_prepared(*prep.clone(), current, Err(message));
4856            }
4857            Job::Rows(_) | Job::OwedRows { .. } => {
4858                self.rows_failed(current, waited, message, None);
4859            }
4860            Job::SampleDraw(draw) => self.sample_draw_failed(draw, current, message),
4861            // The preview says why in its own pane; the log has a panic's details.
4862            Job::ReshapePreview { epoch, token } => {
4863                let message = if panicked {
4864                    "Could not preview; see the log".to_string()
4865                } else {
4866                    message.to_string()
4867                };
4868                self.reshape_preview_ended(*epoch, *token, None, Err(message));
4869            }
4870            Job::InspectPretty { token } => {
4871                let modal = &mut self.inspector_modal;
4872                if let Some(inspector::inspector_modal::Pretty::Pending { token: t, place }) =
4873                    modal.pretty.as_ref()
4874                    && t == token
4875                {
4876                    modal.pretty = Some(inspector::inspector_modal::Pretty::Failed {
4877                        place: place.clone(),
4878                    });
4879                }
4880            }
4881            Job::InspectUnpack { token } => {
4882                let modal = &mut self.inspector_modal;
4883                if let Some(inspector::inspector_modal::Unpack::Pending { token: t, place }) =
4884                    modal.unpack.as_ref()
4885                    && t == token
4886                {
4887                    modal.unpack = Some(inspector::inspector_modal::Unpack::Failed {
4888                        place: place.clone(),
4889                    });
4890                }
4891            }
4892            Job::Find(_) => self.find_failed(current, message),
4893            // Judged by the dataset, as its answer is; the panel has one line for it.
4894            Job::FileFacts { dataset } => {
4895                let why = if panicked {
4896                    "could not read; see the log".to_string()
4897                } else {
4898                    message.to_string()
4899                };
4900                self.file_facts_landed(*dataset, FileFacts::Failed(why));
4901            }
4902            // The note is left unsaid; the log has why.
4903            Job::UnfitCount { .. } => {
4904                log::warn!(target: "datui", "counting values that did not fit their type failed: {message}");
4905            }
4906            // The Info tab keeps what the open read; the log says why it has no more.
4907            Job::JournalDetail { .. } => {
4908                log::warn!(target: "datui", "reading the journal's detail failed: {message}");
4909            }
4910            // Stopped: whoever stopped it says what becomes of the reads waiting on the
4911            // lines.
4912            Job::IndexLines { .. } => {}
4913            // The rest act only for the job still current; a stale failure is dropped.
4914            _ if !current => {
4915                if matches!(job, Job::Export) {
4916                    // The dialog held for a failure is done with.
4917                    self.export_modal.close();
4918                }
4919            }
4920            Job::Classify(_) => {
4921                self.home.status = None;
4922                self.error_modal.show(message.to_string());
4923            }
4924            Job::Analysis(_) | Job::SampleRows => {
4925                self.analysis_modal.computing = None;
4926                self.error_modal.show(message.to_string());
4927            }
4928            // The form stays up with its spec, to be fixed.
4929            Job::Pivot | Job::Copy | Job::HexOpen { .. } => {
4930                self.error_modal.show(message.to_string());
4931            }
4932            // The dialog is still up, the reason on its status line under the path.
4933            Job::QualityReport => match self.analysis_modal.quality.export.as_mut() {
4934                Some(form) => form.error = Some(message.to_string()),
4935                None => self.error_modal.show(message.to_string()),
4936            },
4937            Job::ViewPivot(_) => self.view_pivot_failed(message),
4938            // The grouped view stays as it was.
4939            Job::DrillRow => self.flash_note(could_not("drill in")),
4940            Job::InspectJson { token } => {
4941                let modal = &mut self.inspector_modal;
4942                if let Some(wait) = modal.json_wait.take_if(|w| w.token == *token) {
4943                    modal.not_json = Some((wait.frame, wait.row, wait.path));
4944                    self.flash_note(if panicked {
4945                        "Could not read the JSON; see the log".to_string()
4946                    } else {
4947                        sentence(message)
4948                    });
4949                }
4950            }
4951            Job::OpenValue => self.flash_note(could_not("open the value")),
4952            Job::InspectRow { frame, row } => {
4953                let asked = self
4954                    .inspector_modal
4955                    .read
4956                    .as_ref()
4957                    .is_some_and(|read| read.key() == (*frame, *row));
4958                if asked {
4959                    self.inspector_modal.read =
4960                        Some(inspector::inspector_modal::FieldRead::Failed {
4961                            frame: *frame,
4962                            row: *row,
4963                            message: could_not("read the field"),
4964                        });
4965                }
4966            }
4967            // The form comes back with the reason on its status line.
4968            Job::Export => {
4969                self.export_progress = None;
4970                self.export_modal.path_error = Some(message.to_string());
4971                self.open_over(|returns_to| Overlay::Export { returns_to });
4972            }
4973            Job::ChartExport { path, format } => {
4974                self.finish_chart_export(path, *format, Err(message.to_string()));
4975            }
4976            Job::HexFind(_) => {
4977                self.status_message = None;
4978                self.flash_note(message.to_string());
4979            }
4980            // Said on the screen, in place of the counts.
4981            Job::ValueCounts => {
4982                if let Some(computing) = self.value_counts.computing.take() {
4983                    let why = if panicked {
4984                        "could not count; see the log".to_string()
4985                    } else {
4986                        message.to_string()
4987                    };
4988                    self.value_counts.failed = Some((computing.column, why));
4989                }
4990            }
4991        }
4992    }
4993
4994    /// Ask a worker for the open file's size and footer, once per dataset; a source
4995    /// with no local file has none. Unleased and not busy: judged by
4996    /// `dataset_generation`, which bumps leave alone, so it neither strands nor holds
4997    /// collects or the panel's keys.
4998    fn read_file_facts(&mut self) {
4999        let dataset = self.dataset_generation;
5000        if self.data_table_state.is_none()
5001            || self
5002                .info
5003                .file_facts
5004                .as_ref()
5005                .is_some_and(|(read, _)| *read == dataset)
5006            || self.file_facts_reading()
5007        {
5008            return;
5009        }
5010        // One local file only: a glob has nothing to stat, and several files are not the
5011        // first one's size.
5012        let several = self
5013            .source
5014            .opened
5015            .as_ref()
5016            .is_some_and(|(paths, _)| paths.len() > 1);
5017        let piped = self.reads_stdin();
5018        let Some(path) = self.path.clone().filter(|path| {
5019            !several
5020                && !piped
5021                && !cloud::source::is_remote_url(path)
5022                && !cloud::source::is_prefix_or_glob(&path.to_string_lossy())
5023        }) else {
5024            return;
5025        };
5026        let facts = self.facts_of_open().map(|(_, facts)| facts);
5027        #[cfg(test)]
5028        let read: FileFactsReader = self
5029            .file_facts_reader
5030            .clone()
5031            .unwrap_or_else(|| Arc::new(FileFacts::read));
5032        #[cfg(not(test))]
5033        let read = FileFacts::read;
5034        self.spawn_job(Job::FileFacts { dataset }, None, move |_| {
5035            Ok(Answer::FileFacts(read(&path, facts)?))
5036        });
5037    }
5038
5039    /// Whether the open dataset's file facts are being read.
5040    pub(crate) fn file_facts_reading(&self) -> bool {
5041        let dataset = self.dataset_generation;
5042        self.jobs
5043            .current(|job| matches!(job, Job::FileFacts { dataset: asked } if *asked == dataset))
5044            .is_some()
5045    }
5046
5047    /// Keep the facts read for `dataset` if it is still on screen.
5048    fn file_facts_landed(&mut self, dataset: u64, facts: FileFacts) {
5049        if dataset == self.dataset_generation {
5050            self.info.file_facts = Some((dataset, facts));
5051        }
5052    }
5053
5054    /// What the Info panel knows about the open file: `None` until asked, and for a
5055    /// source with no local file. Cleared on install.
5056    pub fn file_facts(&self) -> Option<&FileFacts> {
5057        Self::facts_shown(
5058            &self.info.file_facts,
5059            self.dataset_generation,
5060            self.file_facts_reading(),
5061        )
5062    }
5063
5064    /// [`Self::file_facts`] from its fields, for a caller borrowing the rest of the app.
5065    pub(crate) fn facts_shown(
5066        read: &Option<(u64, FileFacts)>,
5067        dataset: u64,
5068        reading: bool,
5069    ) -> Option<&FileFacts> {
5070        static READING: FileFacts = FileFacts::Reading;
5071        match read {
5072            Some((read_for, facts)) if *read_for == dataset => Some(facts),
5073            _ => reading.then_some(&READING),
5074        }
5075    }
5076
5077    /// An open found a database of several tables: the home screen lists them.
5078    fn land_on_tables(&mut self, tables: loading::Tables) {
5079        let loading::Tables {
5080            database,
5081            from_home,
5082        } = tables;
5083        self.status_message = None;
5084        self.busy = false;
5085        self.enter_home();
5086        if from_home {
5087            self.home_browse_into(database);
5088        } else {
5089            self.home_jump_into(database);
5090        }
5091    }
5092
5093    /// An open failed before its first rows; the loader has put it down. The previous
5094    /// dataset, or home if the open came from there, is current again.
5095    fn load_failed(&mut self, failed: loading::Failed) {
5096        let loading::Failed { message, from_home } = failed;
5097        self.status_message = None;
5098        self.busy = false;
5099        // Kept so home can say why when dismissing the error lands there from a command
5100        // line. Chosen at home, the dialog already said it.
5101        if from_home {
5102            self.home_app.last_load_error = None;
5103            self.enter_home();
5104        } else {
5105            self.home_app.last_load_error = Some(message.clone());
5106        }
5107        self.error_modal.show(message);
5108    }
5109
5110    /// The table's rows could not be read. A waiting query or view is not applied; a
5111    /// waited-on page ends its wait with the reason; a load-ahead's failure is left for
5112    /// the page that needs those rows.
5113    fn rows_failed(
5114        &mut self,
5115        current: bool,
5116        waited: bool,
5117        message: &str,
5118        conversion: Option<&crate::error_display::ConversionFailure>,
5119    ) {
5120        if !current {
5121            return;
5122        }
5123        let message = &self.named_by_source(message);
5124        if let Some(run) = self.take_query_run() {
5125            self.fail_query_run(run, message, conversion);
5126            return;
5127        }
5128        if !waited {
5129            return;
5130        }
5131        // A count waiting on this paint would read the frame that just failed: it fails
5132        // too, as a count riding the collect does.
5133        if let Some(generation) = self.counting.count_after_paint.take() {
5134            if self.counting.len_count_inflight == Some(generation) {
5135                self.counting.len_count_inflight = None;
5136            }
5137            if self
5138                .data_table_state
5139                .as_ref()
5140                .is_some_and(|state| state.len_generation() == generation)
5141            {
5142                self.counting.len_count_failed = Some(generation);
5143            }
5144        }
5145        self.first_rows_settled();
5146        self.error_modal.show(message.to_string());
5147    }
5148
5149    /// `message` with the dataset's temp files (a download, a decompressed copy) named
5150    /// by what the user opened.
5151    fn named_by_source(&self, message: &str) -> String {
5152        let (Some(state), Some(source)) = (self.data_table_state.as_ref(), self.path.as_deref())
5153        else {
5154            return message.to_string();
5155        };
5156        state
5157            .temp_files()
5158            .into_iter()
5159            .fold(message.to_string(), |message, file| {
5160                crate::error_display::named_by_source(&message, file, source)
5161            })
5162    }
5163
5164    /// Above this estimated size a table copy asks first: most paste targets choke
5165    /// sooner, and the clipboard holds it all.
5166    const COPY_CONFIRM_BYTES: usize = 10 * 1024 * 1024;
5167    /// Above this a table copy is refused; export writes files this size without
5168    /// holding them as text.
5169    const COPY_REFUSE_BYTES: usize = 200 * 1024 * 1024;
5170
5171    /// Enter on a grouped row: its group's rows, read off-thread when the buffer lacks
5172    /// the row.
5173    fn drill_selected_row(&mut self) {
5174        let Some(state) = self.data_table_state.as_ref() else {
5175            return;
5176        };
5177        // An empty result has no row selected, and says so like any other.
5178        let drill = state
5179            .table_state
5180            .selected()
5181            .map(|selected| state.start_row() + selected)
5182            .and_then(|index| Some((index, state.drill_row(index)?)));
5183        match drill {
5184            None => self.flash_note("No group to drill down into".to_string()),
5185            Some((group_index, DrillRow::Buffered(row))) => self.drill_into(group_index, &row),
5186            Some((group_index, DrillRow::Read(lf))) => {
5187                let streaming = state.polars_streaming();
5188                self.spawn_job(Job::DrillRow, Some(Self::READING_GROUP), move |_| {
5189                    let row = crate::analysis::statistics::collect_lazy(*lf, streaming)
5190                        .map_err(|e| crate::error_display::user_message_from_polars(&e))?;
5191                    Ok(Answer::DrillRow { group_index, row })
5192                });
5193            }
5194        }
5195    }
5196
5197    /// Under `theme.mode = "auto"`, switch to `theme.dark` or `theme.light` for the
5198    /// terminal's background, with `theme.colors` over it. An explicit mode ignores
5199    /// the terminal.
5200    pub fn follow_terminal_background(&mut self, mode: ThemeMode) {
5201        let theme = &self.app_config.theme;
5202        if !theme.follow || theme.mode == Some(mode) {
5203            return;
5204        }
5205        let mut next = theme.clone();
5206        let built = theme.palette_for(mode).and_then(|colors| {
5207            next.colors = colors;
5208            next.mode = Some(mode);
5209            Theme::from_config(&next)
5210        });
5211        match built {
5212            Ok(built) => {
5213                self.theme = built;
5214                self.chart.modal.series_cap = Some(self.theme.series_colors().len());
5215                self.app_config.theme = next;
5216                // The prompts live as long as the app and keep their colors; dialogs take the
5217                // theme each time they open.
5218                for input in [
5219                    &mut self.prompt.query_input,
5220                    &mut self.prompt.sql_input,
5221                    &mut self.prompt.find.input,
5222                ] {
5223                    *input = std::mem::take(input).with_theme(&self.theme);
5224                }
5225            }
5226            // The colors parsed at startup, so unexpected; keep the current palette.
5227            Err(e) => log::warn!("cannot switch to the {mode:?} palette: {e}"),
5228        }
5229    }
5230
5231    /// The terminal's background: follow it under `auto`, and remember it for the next
5232    /// start's first frame.
5233    fn terminal_answered(&mut self, mode: ThemeMode) {
5234        if self.app_config.theme.follow {
5235            self.cache
5236                .remember_terminal_mode(&app::terminal_color::terminal_key(), mode);
5237        }
5238        self.follow_terminal_background(mode);
5239    }
5240
5241    /// Settle the first frame's palette under `auto` without waiting: `answered` if
5242    /// given, else this terminal's last answer. A later answer switches if it differs.
5243    pub fn settle_first_palette(&mut self, answered: Option<ThemeMode>) {
5244        if let Some(mode) = answered {
5245            self.terminal_answered(mode);
5246        } else if self.app_config.theme.follow
5247            && let Some(mode) = self
5248                .cache
5249                .terminal_mode(&app::terminal_color::terminal_key())
5250        {
5251            self.follow_terminal_background(mode);
5252        }
5253    }
5254
5255    /// Whether the run loop should ask the terminal's background, once (set by
5256    /// [`AppEvent::TerminalFocused`] under `auto`).
5257    pub fn take_background_query(&mut self) -> bool {
5258        std::mem::take(&mut self.display.background_query)
5259    }
5260
5261    /// The colors the next frame is drawn with.
5262    pub fn theme(&self) -> &Theme {
5263        &self.theme
5264    }
5265
5266    /// Whether the palette follows the terminal (`theme.mode = "auto"`).
5267    pub fn follows_terminal(&self) -> bool {
5268        self.app_config.theme.follow
5269    }
5270
5271    /// The value the inspector wrote for another program, for the run loop.
5272    pub fn take_external_open(&mut self) -> Option<inspector::external_open::ExternalOpen> {
5273        self.external.open.take()
5274    }
5275
5276    /// Whether the session reports the mouse, to retake it after a program had the
5277    /// terminal.
5278    pub fn mouse_enabled(&self) -> bool {
5279        self.app_config.display.mouse
5280    }
5281
5282    /// The run loop opened `open`: a waiting program is done with its file; a failure
5283    /// goes on the bar.
5284    pub fn external_opened(
5285        &mut self,
5286        open: &inspector::external_open::ExternalOpen,
5287        failed: Option<String>,
5288    ) {
5289        let program =
5290            inspector::external_open::program_for(open.document, |name| std::env::var(name).ok());
5291        if matches!(program, inspector::external_open::Program::Wait(_)) {
5292            let _ = std::fs::remove_file(&open.path);
5293        }
5294        match failed {
5295            Some(e) => self.flash_note(format!("Could not open the value: {e}")),
5296            None if matches!(program, inspector::external_open::Program::Opener(_)) => {
5297                self.flash_note("Opened in the system viewer".to_string())
5298            }
5299            None => {}
5300        }
5301    }
5302}
5303
5304impl Widget for &mut App {
5305    fn render(self, area: Rect, buf: &mut Buffer) {
5306        let started = std::time::Instant::now();
5307        self.draw_frame(area, buf);
5308        self.debug.times.frame(started.elapsed());
5309    }
5310}
5311
5312impl App {
5313    fn draw_frame(&mut self, area: Rect, buf: &mut Buffer) {
5314        self.begin_frame();
5315        self.debug.num_frames += 1;
5316        if self.debug.enabled {
5317            self.debug.show_help_at_render = self.help.is_open();
5318        }
5319
5320        use crate::render::context::RenderContext;
5321        use crate::render::layout::app_layout;
5322        use crate::render::main_view::MainViewContent;
5323
5324        let ctx = RenderContext::from_theme_and_config(
5325            &self.theme,
5326            self.display.table_cell_padding,
5327            self.display.column_colors,
5328            self.display.number_format.clone(),
5329        )
5330        .with_dtype_row(self.display.dtype_row);
5331
5332        let main_view_content = MainViewContent::current(self);
5333
5334        Clear.render(area, buf);
5335        let background_color = self.theme.background();
5336        Block::default()
5337            .style(Style::default().bg(background_color))
5338            .render(area, buf);
5339
5340        // The footer grows into the view only for a prompt being typed or a job with
5341        // progress.
5342        let progress = self.footer_progress_line(main_view_content);
5343        let prompt_room = crate::render::footer::MAX_LINES - 1;
5344        let prompt_rows = if main_view_content == MainViewContent::Datatable {
5345            crate::render::input_strip::rows(self, area.width, prompt_room)
5346        } else {
5347            0
5348        };
5349        let progress_rows = u16::from(progress.is_some() && prompt_rows < prompt_room);
5350        let footer_lines = 1 + prompt_rows + progress_rows;
5351        // The inspector is framed; its border sets it off from the footer.
5352        let rule = self.overlay != Overlay::Inspect;
5353        let app_layout = app_layout(area, self.debug.enabled, footer_lines, rule);
5354        // A short terminal keeps the status line first, then the prompt, then progress.
5355        let room = app_layout.footer.height.saturating_sub(1);
5356        let prompt_rows = prompt_rows.min(room);
5357        let progress_rows = progress_rows.min(room - prompt_rows);
5358        let main_area = app_layout.main_view;
5359        Clear.render(main_area, buf);
5360
5361        crate::render::main_view_render::render_main_view(area, main_area, buf, self, &ctx);
5362        if self.in_normal_table_view() {
5363            self.render_drop_mark(buf, &ctx);
5364        }
5365        if self.menu_showing()
5366            && let Some(menu) = self.context_menu.clone()
5367        {
5368            menu.render(main_area, buf, &ctx);
5369        }
5370
5371        if self.confirmation_modal.active {
5372            crate::render::overlays::render_confirmation_modal(
5373                area,
5374                buf,
5375                &mut self.confirmation_modal,
5376                &ctx,
5377            );
5378        }
5379        if self.error_modal.active {
5380            crate::render::overlays::render_error_modal(area, buf, &mut self.error_modal, &ctx);
5381        }
5382        self.close_help_left_behind();
5383        if self.help.is_open() {
5384            // Over the view, never the footer, whose rule and extra lines are drawn after.
5385            crate::render::help::render_help(app_layout.main_view, buf, &mut self.help, &ctx);
5386        }
5387
5388        let footer = self.footer(main_view_content, progress_rows > 0);
5389        crate::render::footer::render_rule(app_layout.rule, buf, &ctx);
5390        let line = Rect {
5391            height: 1,
5392            ..app_layout.footer
5393        };
5394        let drawn = footer.render_line(line, buf, &ctx);
5395        if prompt_rows > 0 {
5396            crate::render::input_strip::render(
5397                Rect {
5398                    y: line.y + 1,
5399                    height: prompt_rows,
5400                    ..line
5401                },
5402                buf,
5403                self,
5404                &ctx,
5405            );
5406        }
5407        if let Some(progress) = progress.filter(|_| progress_rows > 0) {
5408            crate::render::footer::render_progress(
5409                &progress,
5410                Rect {
5411                    y: line.y + 1 + prompt_rows,
5412                    height: 1,
5413                    ..line
5414                },
5415                buf,
5416                &ctx,
5417            );
5418        }
5419        self.pointer.chips_drawn(
5420            drawn
5421                .iter()
5422                .map(|(rect, key)| (*rect, key.as_str()))
5423                .collect(),
5424        );
5425        if let Some(debug_area) = app_layout.debug {
5426            self.debug.render(debug_area, buf);
5427        }
5428        self.pointer.drawn();
5429
5430        // Last, deliberately. Widgets draw untrusted text (cells, names, filenames,
5431        // parser messages); ratatui strips control characters in `set_stringn` but not in
5432        // `Span`/`Line`, and crossterm prints cells unfiltered, so a cell holding
5433        // `\x1b]52;c;...\x07` would write the clipboard. One sweep here covers every
5434        // widget. See `crate::sanitize`.
5435        crate::sanitize::sanitize_buffer(buf);
5436    }
5437}
5438
5439impl App {
5440    /// The view returned on exit to a caller that asked (`datui.view(...,
5441    /// capture=True)`): the committed frame without internal columns; `None` with no
5442    /// dataset. Refused when the frame scans a temp file (download, decompressed or
5443    /// converted copy), which is removed on exit; export (`e`) is the way out.
5444    pub fn capture_view(&self) -> Result<Option<LazyFrame>> {
5445        let Some(state) = &self.data_table_state else {
5446            return Ok(None);
5447        };
5448        if state.scans_a_download() {
5449            return Err(color_eyre::eyre::eyre!(
5450                "cannot return this view: the data was downloaded to a temporary file \
5451                 that is removed when datui exits. Export it from inside datui (press \
5452                 e) instead."
5453            ));
5454        }
5455        if state.scans_a_temp_file() {
5456            return Err(color_eyre::eyre::eyre!(
5457                "cannot return this view: the data was decompressed or converted into a \
5458                 temporary file that is removed when datui exits. Export it from \
5459                 inside datui (press e) instead."
5460            ));
5461        }
5462        Ok(Some(state.visible_lf()))
5463    }
5464}
5465
5466impl Drop for App {
5467    fn drop(&mut self) {
5468        // Stop the footer pass. The loader stops the open in flight as it drops (a running
5469        // download removes its partial file). In Drop to cover every exit: quit, error,
5470        // panic unwind, and the Python binding running again in-process.
5471        self.counting.stop_footer_pass();
5472        // Stop indexing so nothing holds the file once the app is gone (the Python
5473        // binding runs on).
5474        self.counting.stop_indexing();
5475    }
5476}
5477
5478/// Run a future on the app's runtime from an outside thread and wait for it. Not
5479/// `Handle::block_on`: that polls on the calling thread, and after quit's runtime
5480/// shutdown the next timer or socket panics ("Tokio 1.x context ... being
5481/// shutdown"). A spawned task is dropped instead, and the wait ends with `None`.
5482#[cfg(feature = "cloud")]
5483pub(crate) fn wait_on_runtime<F>(runtime: &tokio::runtime::Handle, future: F) -> Option<F::Output>
5484where
5485    F: std::future::Future + Send + 'static,
5486    F::Output: Send + 'static,
5487{
5488    let (tx, rx) = std::sync::mpsc::sync_channel(1);
5489    runtime.spawn(async move {
5490        let _ = tx.send(future.await);
5491    });
5492    rx.recv().ok()
5493}
5494
5495/// `text` as a sentence for a flash: its first letter capitalized.
5496fn sentence(text: &str) -> String {
5497    let mut chars = text.chars();
5498    match chars.next() {
5499        Some(first) => first.to_uppercase().chain(chars).collect(),
5500        None => String::new(),
5501    }
5502}