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