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