Skip to main content

datui_lib/app/
run.rs

1//! The process around the app: the terminal session, the event loop's thread, the
2//! signals that end it, and handing a file to the system's opener.
3
4use crate::app::terminal::{
5    QuietTerminal, TakenTerminal, bracket_pastes, follow_focus, push_keyboard_flags,
6    restore_terminal, take_screen,
7};
8use crate::config::AppConfig;
9use crate::render::context::Repaint;
10use crate::view::Views;
11use crate::{
12    App, AppEvent, RunInput, app::event_pump, app::pointer, app::startup, app::terminal_color,
13    app::terminal_input, glyphs, inspector::external_open, logging,
14};
15use color_eyre::Result;
16use crossterm::event::{KeyCode, KeyModifiers};
17use polars::prelude::LazyFrame;
18
19/// What datui hands to other programs: values to open, where they are written, and the
20/// clipboard.
21#[derive(Default)]
22pub struct External {
23    /// A value the inspector wrote for another program, for the run loop to open (it
24    /// owns the terminal a waiting program takes).
25    pub(crate) open: Option<crate::inspector::external_open::ExternalOpen>,
26    /// Where those values are written; removed when the app is.
27    pub(crate) open_dir: Option<tempfile::TempDir>,
28    /// Where copies go, built at the first copy and kept: on Wayland and X11 the
29    /// clipboard offer dies with its owner.
30    pub(crate) clipboard: Option<Box<dyn crate::clipboard::Destination>>,
31}
32
33/// Standard input and output when datui sits in a pipe, and the recording of what it read.
34#[derive(Default)]
35pub struct Pipes {
36    /// What `-` reads in place of standard input: a test's pipe.
37    pub(crate) stdin_reader: Option<Box<dyn std::io::Read + Send>>,
38    /// Where `--tee -` passes the stream on: standard output as the process got it.
39    pub(crate) stdout_pass: Option<Box<dyn std::io::Write + Send>>,
40    /// The follow mark as last drawn, so its clock redraws only when it changes.
41    pub(crate) follow_drawn: Option<crate::render::footer::FollowMark>,
42    /// A recording kept going after the user went home or quit, until its stream ends.
43    pub(crate) recording_on: Option<std::sync::Arc<crate::loading::follow::SpoolHandle>>,
44    /// A recording's end has been said: once, in the bar or the error dialog.
45    pub(crate) recording_end_said: bool,
46}
47
48/// Restore the terminal and turn how the loop ended into `run_impl`'s result. The
49/// reader stops first so nothing typed afterwards is read; the capture comes after,
50/// so a refused one still leaves the terminal usable.
51fn conclude(
52    end: event_pump::Ended,
53    app: &App,
54    capture: bool,
55    reader: &mut terminal_input::TerminalInput,
56    screen: &mut TakenTerminal,
57) -> Result<Option<LazyFrame>> {
58    reader.stop();
59    screen.restore();
60    match end {
61        event_pump::Ended::Quit if capture => app.capture_view(),
62        event_pump::Ended::Quit => Ok(None),
63        event_pump::Ended::Crash(msg) => Err(color_eyre::eyre::eyre!(msg)),
64        event_pump::Ended::NotFound(path) => Err(std::io::Error::new(
65            std::io::ErrorKind::NotFound,
66            format!("File not found: {}", path.display()),
67        )
68        .into()),
69    }
70}
71
72/// The exit status the ending signal calls for, set once; 0 until then.
73static ENDED_BY_SIGNAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
74
75/// The exit status when a signal ended the session [`run`] returned from: `128 + n`
76/// for SIGTERM, SIGHUP or SIGINT (as `datui_cli::exit`), and on Windows a closed
77/// console's status.
78pub fn ended_by_signal() -> Option<i32> {
79    let status = ENDED_BY_SIGNAL.load(std::sync::atomic::Ordering::SeqCst);
80    (status != 0).then_some(status)
81}
82
83/// A session-ending signal: quit as `q` does, handing back the screen and removing
84/// temp files. A second signal, or a session still running after a few seconds,
85/// ends the process at once, so a stuck loop cannot make datui unkillable. Called on
86/// the runtime.
87fn end_session(status: i32, tx: &std::sync::mpsc::Sender<AppEvent>) {
88    use std::sync::atomic::Ordering;
89    // Longer than the exit sweep's grace, shorter than Windows' five seconds for closing
90    // a console.
91    const STRAGGLE: std::time::Duration = std::time::Duration::from_secs(3);
92    fn end_now(status: i32) -> ! {
93        restore_terminal();
94        std::process::exit(status)
95    }
96    if ENDED_BY_SIGNAL
97        .compare_exchange(0, status, Ordering::SeqCst, Ordering::SeqCst)
98        .is_err()
99    {
100        end_now(ENDED_BY_SIGNAL.load(Ordering::SeqCst));
101    }
102    let _ = tx.send(AppEvent::Exit);
103    tokio::spawn(async move {
104        tokio::time::sleep(STRAGGLE).await;
105        end_now(status);
106    });
107}
108
109/// End the session on SIGTERM, SIGHUP (the terminal closing) or SIGINT (`kill -INT`;
110/// Ctrl+C at the terminal is a key, not this signal).
111#[cfg(unix)]
112fn quit_on_signals(runtime: &tokio::runtime::Handle, tx: &std::sync::mpsc::Sender<AppEvent>) {
113    use tokio::signal::unix::{SignalKind, signal};
114    // `signal` registers with the runtime it is called in.
115    let _runtime = runtime.enter();
116    for kind in [
117        SignalKind::terminate(),
118        SignalKind::hangup(),
119        SignalKind::interrupt(),
120    ] {
121        let Ok(mut arrivals) = signal(kind) else {
122            continue;
123        };
124        let tx = tx.clone();
125        runtime.spawn(async move {
126            while arrivals.recv().await.is_some() {
127                end_session(128 + kind.as_raw_value(), &tx);
128            }
129        });
130    }
131}
132
133/// End the session when the console closes, the user logs off or the machine shuts
134/// down. Tokio holds the control handler until exit, so the quit runs first.
135#[cfg(windows)]
136fn quit_on_signals(runtime: &tokio::runtime::Handle, tx: &std::sync::mpsc::Sender<AppEvent>) {
137    use tokio::signal::windows::{ctrl_close, ctrl_logoff, ctrl_shutdown};
138    /// STATUS_CONTROL_C_EXIT, what a console process ends with when closed.
139    const CLOSED: i32 = 0xC000_013A_u32 as i32;
140    // Each registers with the runtime it is called in.
141    let _runtime = runtime.enter();
142    // Three listener types with one shape and no trait in common.
143    macro_rules! quit_on {
144        ($listen:expr) => {
145            if let Ok(mut arrivals) = $listen {
146                let tx = tx.clone();
147                runtime.spawn(async move {
148                    while arrivals.recv().await.is_some() {
149                        end_session(CLOSED, &tx);
150                    }
151                });
152            }
153        };
154    }
155    quit_on!(ctrl_close());
156    quit_on!(ctrl_logoff());
157    quit_on!(ctrl_shutdown());
158}
159
160/// Run the TUI with file paths or an existing LazyFrame: the one event loop for the
161/// CLI and the Python binding.
162pub fn run(input: RunInput, config: Option<AppConfig>) -> Result<()> {
163    run_impl(input, config, false).map(|_| ())
164}
165
166/// As `run`, but a normal quit returns the active table's final view (the Python
167/// binding's `capture=True`); `None` with no dataset. See `App::capture_view`.
168pub fn run_captured(input: RunInput, config: Option<AppConfig>) -> Result<Option<LazyFrame>> {
169    run_impl(input, config, true)
170}
171
172fn run_impl(
173    input: RunInput,
174    config: Option<AppConfig>,
175    capture: bool,
176) -> Result<Option<LazyFrame>> {
177    use event_pump::EventPump;
178    use std::io::Write;
179
180    // First, so a missing file is named with the home directory expanded.
181    let input = startup::expand_home(input);
182    use std::sync::{Mutex, Once, mpsc};
183
184    // Saved views are read on a worker; the first user waits for the rest.
185    let views = Views::read_in_background();
186
187    // Install color_eyre at most once per process (e.g. repeated datui.view() in Python).
188    static COLOR_EYRE_INIT: Once = Once::new();
189    static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
190    COLOR_EYRE_INIT.call_once(|| {
191        *INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
192    });
193    if let Some(Err(e)) = INSTALL_RESULT
194        .lock()
195        .unwrap_or_else(|e| e.into_inner())
196        .as_ref()
197    {
198        return Err(color_eyre::eyre::eyre!(e.to_string()));
199    }
200    let rt = tokio::runtime::Builder::new_multi_thread()
201        .worker_threads(2)
202        .enable_all()
203        .build()
204        .map_err(|e| color_eyre::eyre::eyre!("Failed to create tokio runtime: {}", e))?;
205
206    // Dropping the runtime joins its blocking pool, so quit would wait on an in-flight
207    // count (minutes over a huge hive). Shut it down in the background instead; the
208    // read-only tasks die with the process. Covers every return path.
209    struct RtGuard(Option<tokio::runtime::Runtime>);
210    impl Drop for RtGuard {
211        fn drop(&mut self) {
212            if let Some(rt) = self.0.take() {
213                rt.shutdown_background();
214            }
215        }
216    }
217    let rt_guard = RtGuard(Some(rt));
218    let rt_handle = rt_guard
219        .0
220        .as_ref()
221        .expect("runtime present")
222        .handle()
223        .clone();
224
225    // `--tee -` passes the stream to stdout, so the screen is drawn on the terminal;
226    // the original stdout is kept for the copy.
227    let passed = match &input {
228        RunInput::Cli(args)
229            if args
230                .tee
231                .as_deref()
232                .is_some_and(crate::loading::stdin::is_stdin) =>
233        {
234            Some(crate::loading::tee::pass_stdout_on().map_err(|e| color_eyre::eyre::eyre!(e))?)
235        }
236        _ => None,
237    };
238    let mut terminal = match take_screen() {
239        Ok(terminal) => QuietTerminal::new(terminal),
240        Err(e) => {
241            // No screen to keep up: an unusable config or a missing named file is said first.
242            if config.is_none() {
243                startup::load_config(&input)?;
244            }
245            // Without a screen nothing is opened, so no specs are loaded to look.
246            if let Some(missing) =
247                App::missing_named_path(startup::named_paths(&input), &Default::default())
248            {
249                return Err(std::io::Error::new(
250                    std::io::ErrorKind::NotFound,
251                    format!("File not found: {}", missing.display()),
252                )
253                .into());
254            }
255            return Err(color_eyre::eyre::eyre!(
256                "datui requires an interactive terminal (TTY). No terminal detected: {}. \
257                 There is no TTY inside a Jupyter notebook or when output is piped or \
258                 redirected; run from a terminal with stdout connected to it.",
259                e
260            ));
261        }
262    };
263    // Handed back on every way out, after the reader below lets go.
264    let mut screen = TakenTerminal { restored: false };
265    // stderr goes to the log until this drops, so nothing draws over the screen.
266    let session = logging::TuiSession::begin(restore_terminal);
267    push_keyboard_flags();
268    bracket_pastes(&mut std::io::stdout());
269    // Asked before reading settings, so the answer is usually in by then; dropped under
270    // an explicit `theme.mode`. The reader takes it off the input stream.
271    let asked = terminal_color::supported()
272        && config.as_ref().is_none_or(|c| c.theme.follow)
273        && terminal_color::ask(&mut std::io::stdout());
274    let mut background = None;
275    let (tx, rx) = mpsc::channel::<AppEvent>();
276    {
277        let tx = tx.clone();
278        session.wake_with(move || {
279            let _ = tx.send(AppEvent::Wake);
280        });
281    }
282    let mut reader = terminal_input::TerminalInput::start(tx.clone())?;
283    // Only for the datui binary: handlers last the process, and hosts like Python keep
284    // their own.
285    #[cfg(any(unix, windows))]
286    if matches!(input, RunInput::Cli(_)) {
287        quit_on_signals(&rt_handle, &tx);
288    }
289
290    // Settings are files, read on a worker while keys are read: a slow mount shows a
291    // screen, and Ctrl+C or Ctrl+Q leave it.
292    let waiting_on = startup::named(&input);
293    {
294        let tx = tx.clone();
295        std::thread::Builder::new()
296            .name("datui-settings".into())
297            .spawn(move || {
298                let read = logging::catch_panic(|| startup::read(input, config))
299                    .unwrap_or_else(|panic| Err(color_eyre::eyre::eyre!(panic)));
300                let _ = tx.send(AppEvent::SettingsRead(Box::new(read)));
301            })?;
302    }
303    let mut backlog = Vec::new();
304    let grace_ends = std::time::Instant::now() + startup::GRACE;
305    let mut waiting_shown = false;
306    let settings = loop {
307        let timeout = if waiting_shown {
308            std::time::Duration::MAX
309        } else {
310            grace_ends.saturating_duration_since(std::time::Instant::now())
311        };
312        match rx.recv_timeout(timeout) {
313            Ok(AppEvent::SettingsRead(read)) => break *read,
314            Ok(AppEvent::TerminalBackground(mode)) => background = Some(mode),
315            Ok(AppEvent::Terminal(crossterm::event::Event::Key(key)))
316                if key.modifiers.contains(KeyModifiers::CONTROL)
317                    && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q')) =>
318            {
319                reader.stop();
320                screen.restore();
321                return Ok(None);
322            }
323            Ok(AppEvent::Crash(msg)) => {
324                reader.stop();
325                screen.restore();
326                return Err(color_eyre::eyre::eyre!(msg));
327            }
328            // A signal, before there was an app to quit.
329            Ok(AppEvent::Exit) => {
330                reader.stop();
331                screen.restore();
332                return Ok(None);
333            }
334            Ok(event) => {
335                if waiting_shown
336                    && matches!(
337                        event,
338                        AppEvent::Terminal(crossterm::event::Event::Resize(..))
339                    )
340                {
341                    terminal.draw(|frame| startup::draw_waiting(frame, waiting_on.as_deref()))?;
342                }
343                // Typed before there was an app to take it: handled, in order, first.
344                backlog.push(event);
345            }
346            Err(_) => {
347                terminal.draw(|frame| startup::draw_waiting(frame, waiting_on.as_deref()))?;
348                let _ = std::io::stdout().flush();
349                waiting_shown = true;
350            }
351        }
352    };
353    let startup::Settings {
354        config,
355        theme,
356        input,
357        opts,
358        notes,
359    } = match settings {
360        Ok(settings) => settings,
361        Err(e) => {
362            reader.stop();
363            screen.restore();
364            return Err(e);
365        }
366    };
367
368    // Polars sizes its pool from the environment at its first compute, after this.
369    // Only in the binary's own process: a host's threads may read the environment
370    // outside std's lock, and it is not datui's to change.
371    let asked_threads = std::env::var_os("POLARS_MAX_THREADS");
372    if matches!(input, RunInput::Cli(_))
373        && let Some(threads) =
374            startup::polars_threads(config.performance.threads, asked_threads.as_deref())
375    {
376        // SAFETY: the other threads alive now (the key reader, the runtime's idle
377        // workers, the saved-views reader) read the environment only through std,
378        // which locks it, and none is in foreign code that reads it unlocked.
379        unsafe { std::env::set_var("POLARS_MAX_THREADS", threads) };
380    }
381
382    // The first frame does not wait for the terminal's answer: one already in, else
383    // this terminal's last one (see `App::settle_first_palette`).
384    let background = (asked && config.theme.follow)
385        .then(|| startup::take_answer(&rx, background, &mut backlog))
386        .flatten();
387    // Focus reports: under `auto` the background is asked again, and a frame back in
388    // focus is repainted whole before the terminal next moves lines, since a move
389    // carries along whatever drifted on screen meanwhile.
390    let focus_reports =
391        (config.theme.follow && terminal_color::supported()) || config.display.scroll_region;
392    if focus_reports {
393        follow_focus(&mut std::io::stdout());
394    }
395
396    // Choose glyphs before the first frame: without UTF-8, box drawing renders as
397    // replacement boxes.
398    glyphs::init_with_overrides(config.display.unicode, &config.glyphs.overrides);
399    crate::limits::set(config.limits);
400
401    // Taken once the settings say so; handed back with the screen.
402    pointer::capture(config.display.mouse, &mut std::io::stdout());
403    terminal.scroll_with_region(config.display.scroll_region);
404
405    let mut app = App::new_with_views(tx.clone(), rt_handle, theme, config, views);
406    app.settle_first_palette(background);
407    if let Some(out) = passed {
408        app.pass_stdout_to(out);
409    }
410    app.source.startup_view = opts.view.clone();
411    // A developer's overlay: an environment variable, not a flag.
412    let debug_env = std::env::var_os("DATUI_DEBUG").is_some_and(|v| !v.is_empty() && v != "0");
413    if opts.debug || debug_env {
414        app.enable_debug();
415    }
416
417    // Show the first frame immediately; the open it announces is handled right after.
418    let open = match input {
419        // No paths: open the home screen instead of loading anything.
420        RunInput::Paths(paths, _) if paths.is_empty() => {
421            app.rest_at_start();
422            app.enter_home();
423            None
424        }
425        RunInput::Paths(paths, opts) => {
426            // Whether each path exists, and is a directory, is asked on a worker after this
427            // frame, which names what is being opened.
428            app.set_loading_phase("Scanning input", 10);
429            if let [path] = paths.as_slice() {
430                app.name_what_is_loading(path.clone());
431            }
432            Some(AppEvent::OpenNamed(paths, opts))
433        }
434        RunInput::LazyFrame(lf, opts) => {
435            app.set_loading_phase("Scanning input", 10);
436            Some(AppEvent::OpenLazyFrame(lf, opts))
437        }
438        RunInput::Cli(_) | RunInput::Host(..) => {
439            unreachable!("read_settings resolves the command line")
440        }
441    };
442    // Declared before the pump so it drops after: the app's files go with it, then
443    // this removes what workers were still writing.
444    let _sweep = app.exit_sweep();
445    let input_tx = tx.clone();
446    let mut pump = EventPump::new(app, tx, rx);
447    // The open goes before keys typed during settings, so they meet it as an open in
448    // flight: Ctrl+O puts it down, `q` quits.
449    pump.handle_first(backlog.into_iter().chain(open));
450    let end = pump.run(|app| {
451        if let Some(open) = app.take_external_open() {
452            let mouse = app.mouse_enabled();
453            let note = open_externally(
454                &open,
455                &mut reader,
456                &input_tx,
457                mouse,
458                focus_reports,
459                &mut terminal,
460            );
461            app.external_opened(&open, note);
462        }
463        // Between frames, so the question is never written into the middle of one.
464        if app.take_background_query() && terminal_color::supported() {
465            terminal_color::ask(&mut std::io::stdout());
466        }
467        match app.take_repaint() {
468            Some(Repaint::Whole) => terminal.repaint(),
469            Some(Repaint::BeforeMoving) => terminal.repaint_before_moving(),
470            None => {}
471        }
472        terminal.draw(|frame| frame.render_widget(app, frame.area()))?;
473        Ok(())
474    })?;
475    let result = conclude(end, &pump.app, capture, &mut reader, &mut screen);
476    // stderr is the terminal again once the session is over.
477    drop(session);
478    // Not `eprintln!`, which panics when a hangup has taken the terminal away.
479    for note in notes {
480        let _ = writeln!(std::io::stderr(), "datui: {note}");
481    }
482    // Quit with the recording kept going: it continues to its stream's end with the
483    // terminal handed back.
484    if let Some((tee, handle)) = pump.app.recording_after_exit() {
485        let to = if tee.to_stdout() {
486            format!("passing standard input on to {}", tee.name())
487        } else {
488            format!("recording standard input to {}", tee.path.display())
489        };
490        let _ = writeln!(
491            std::io::stderr(),
492            "datui: {to} until it ends (Ctrl+C stops it)"
493        );
494        handle.spool().wait();
495        let done = if tee.to_stdout() {
496            "datui: standard input ended".to_string()
497        } else {
498            format!("datui: saved {}", tee.path.display())
499        };
500        let _ = writeln!(std::io::stderr(), "{done}");
501    }
502    result
503}
504
505/// Open a value the inspector wrote: a terminal program gets the terminal (reader
506/// stopped, screen and raw mode handed back) until it returns; an opener is only
507/// started. Returns what went wrong, if anything.
508fn open_externally(
509    open: &external_open::ExternalOpen,
510    reader: &mut terminal_input::TerminalInput,
511    tx: &std::sync::mpsc::Sender<AppEvent>,
512    mouse: bool,
513    focus: bool,
514    terminal: &mut QuietTerminal,
515) -> Option<String> {
516    let program = external_open::program_for(open.document, |name| std::env::var(name).ok());
517    let result = match &program {
518        external_open::Program::Opener(_) => external_open::run(&program, &open.path),
519        external_open::Program::Wait(_) => {
520            reader.stop();
521            restore_terminal();
522            let result = external_open::run(&program, &open.path);
523            let _ = crossterm::terminal::enable_raw_mode();
524            let _ = crossterm::execute!(
525                std::io::stdout(),
526                crossterm::terminal::EnterAlternateScreen,
527                crossterm::cursor::Hide
528            );
529            push_keyboard_flags();
530            bracket_pastes(&mut std::io::stdout());
531            pointer::capture(mouse, &mut std::io::stdout());
532            if focus {
533                follow_focus(&mut std::io::stdout());
534            }
535            let _ = terminal.clear();
536            match terminal_input::TerminalInput::start(tx.clone()) {
537                Ok(started) => *reader = started,
538                Err(e) => {
539                    let _ = tx.send(AppEvent::Crash(format!("Could not read keys again: {e}")));
540                }
541            }
542            result
543        }
544    };
545    result.err().map(|e| e.to_string())
546}