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