use crate::app::terminal::{
QuietTerminal, TakenTerminal, bracket_pastes, follow_focus, push_keyboard_flags,
restore_terminal, take_screen,
};
use crate::config::AppConfig;
use crate::render::context::Repaint;
use crate::view::Views;
use crate::{
App, AppEvent, RunInput, app::event_pump, app::pointer, app::startup, app::terminal_color,
app::terminal_input, glyphs, inspector::external_open, logging,
};
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyModifiers};
use polars::prelude::LazyFrame;
#[derive(Default)]
pub struct External {
pub(crate) open: Option<crate::inspector::external_open::ExternalOpen>,
pub(crate) open_dir: Option<tempfile::TempDir>,
pub(crate) clipboard: Option<Box<dyn crate::clipboard::Destination>>,
}
#[derive(Default)]
pub struct Pipes {
pub(crate) stdin_reader: Option<Box<dyn std::io::Read + Send>>,
pub(crate) stdout_pass: Option<Box<dyn std::io::Write + Send>>,
pub(crate) follow_drawn: Option<crate::render::footer::FollowMark>,
pub(crate) recording_on: Option<std::sync::Arc<crate::loading::follow::SpoolHandle>>,
pub(crate) recording_end_said: bool,
}
fn conclude(
end: event_pump::Ended,
app: &App,
capture: bool,
reader: &mut terminal_input::TerminalInput,
screen: &mut TakenTerminal,
) -> Result<Option<LazyFrame>> {
reader.stop();
screen.restore();
match end {
event_pump::Ended::Quit if capture => app.capture_view(),
event_pump::Ended::Quit => Ok(None),
event_pump::Ended::Crash(msg) => Err(color_eyre::eyre::eyre!(msg)),
event_pump::Ended::NotFound(path) => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {}", path.display()),
)
.into()),
}
}
static ENDED_BY_SIGNAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
pub fn ended_by_signal() -> Option<i32> {
let status = ENDED_BY_SIGNAL.load(std::sync::atomic::Ordering::SeqCst);
(status != 0).then_some(status)
}
fn end_session(status: i32, tx: &std::sync::mpsc::Sender<AppEvent>) {
use std::sync::atomic::Ordering;
const STRAGGLE: std::time::Duration = std::time::Duration::from_secs(3);
fn end_now(status: i32) -> ! {
restore_terminal();
std::process::exit(status)
}
if ENDED_BY_SIGNAL
.compare_exchange(0, status, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
end_now(ENDED_BY_SIGNAL.load(Ordering::SeqCst));
}
let _ = tx.send(AppEvent::Exit);
tokio::spawn(async move {
tokio::time::sleep(STRAGGLE).await;
end_now(status);
});
}
#[cfg(unix)]
fn quit_on_signals(runtime: &tokio::runtime::Handle, tx: &std::sync::mpsc::Sender<AppEvent>) {
use tokio::signal::unix::{SignalKind, signal};
let _runtime = runtime.enter();
for kind in [
SignalKind::terminate(),
SignalKind::hangup(),
SignalKind::interrupt(),
] {
let Ok(mut arrivals) = signal(kind) else {
continue;
};
let tx = tx.clone();
runtime.spawn(async move {
while arrivals.recv().await.is_some() {
end_session(128 + kind.as_raw_value(), &tx);
}
});
}
}
#[cfg(windows)]
fn quit_on_signals(runtime: &tokio::runtime::Handle, tx: &std::sync::mpsc::Sender<AppEvent>) {
use tokio::signal::windows::{ctrl_close, ctrl_logoff, ctrl_shutdown};
const CLOSED: i32 = 0xC000_013A_u32 as i32;
let _runtime = runtime.enter();
macro_rules! quit_on {
($listen:expr) => {
if let Ok(mut arrivals) = $listen {
let tx = tx.clone();
runtime.spawn(async move {
while arrivals.recv().await.is_some() {
end_session(CLOSED, &tx);
}
});
}
};
}
quit_on!(ctrl_close());
quit_on!(ctrl_logoff());
quit_on!(ctrl_shutdown());
}
pub fn run(input: RunInput, config: Option<AppConfig>) -> Result<()> {
run_impl(input, config, false).map(|_| ())
}
pub fn run_captured(input: RunInput, config: Option<AppConfig>) -> Result<Option<LazyFrame>> {
run_impl(input, config, true)
}
fn run_impl(
input: RunInput,
config: Option<AppConfig>,
capture: bool,
) -> Result<Option<LazyFrame>> {
use event_pump::EventPump;
use std::io::Write;
let input = startup::expand_home(input);
use std::sync::{Mutex, Once, mpsc};
let views = Views::read_in_background();
static COLOR_EYRE_INIT: Once = Once::new();
static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
COLOR_EYRE_INIT.call_once(|| {
*INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
});
if let Some(Err(e)) = INSTALL_RESULT
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
{
return Err(color_eyre::eyre::eyre!(e.to_string()));
}
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| color_eyre::eyre::eyre!("Failed to create tokio runtime: {}", e))?;
struct RtGuard(Option<tokio::runtime::Runtime>);
impl Drop for RtGuard {
fn drop(&mut self) {
if let Some(rt) = self.0.take() {
rt.shutdown_background();
}
}
}
let rt_guard = RtGuard(Some(rt));
let rt_handle = rt_guard
.0
.as_ref()
.expect("runtime present")
.handle()
.clone();
let passed = match &input {
RunInput::Cli(args)
if args
.tee
.as_deref()
.is_some_and(crate::loading::stdin::is_stdin) =>
{
Some(crate::loading::tee::pass_stdout_on().map_err(|e| color_eyre::eyre::eyre!(e))?)
}
_ => None,
};
let mut terminal = match take_screen() {
Ok(terminal) => QuietTerminal::new(terminal),
Err(e) => {
if config.is_none() {
startup::load_config(&input)?;
}
if let Some(missing) =
App::missing_named_path(startup::named_paths(&input), &Default::default())
{
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {}", missing.display()),
)
.into());
}
return Err(color_eyre::eyre::eyre!(
"datui requires an interactive terminal (TTY). No terminal detected: {}. \
There is no TTY inside a Jupyter notebook or when output is piped or \
redirected; run from a terminal with stdout connected to it.",
e
));
}
};
let mut screen = TakenTerminal { restored: false };
let session = logging::TuiSession::begin(restore_terminal);
push_keyboard_flags();
bracket_pastes(&mut std::io::stdout());
let asked = terminal_color::supported()
&& config.as_ref().is_none_or(|c| c.theme.follow)
&& terminal_color::ask(&mut std::io::stdout());
let mut background = None;
let (tx, rx) = mpsc::channel::<AppEvent>();
{
let tx = tx.clone();
session.wake_with(move || {
let _ = tx.send(AppEvent::Wake);
});
}
let mut reader = terminal_input::TerminalInput::start(tx.clone())?;
#[cfg(any(unix, windows))]
if matches!(input, RunInput::Cli(_)) {
quit_on_signals(&rt_handle, &tx);
}
let waiting_on = startup::named(&input);
{
let tx = tx.clone();
std::thread::Builder::new()
.name("datui-settings".into())
.spawn(move || {
let read = logging::catch_panic(|| startup::read(input, config))
.unwrap_or_else(|panic| Err(color_eyre::eyre::eyre!(panic)));
let _ = tx.send(AppEvent::SettingsRead(Box::new(read)));
})?;
}
let mut backlog = Vec::new();
let grace_ends = std::time::Instant::now() + startup::GRACE;
let mut waiting_shown = false;
let settings = loop {
let timeout = if waiting_shown {
std::time::Duration::MAX
} else {
grace_ends.saturating_duration_since(std::time::Instant::now())
};
match rx.recv_timeout(timeout) {
Ok(AppEvent::SettingsRead(read)) => break *read,
Ok(AppEvent::TerminalBackground(mode)) => background = Some(mode),
Ok(AppEvent::Terminal(crossterm::event::Event::Key(key)))
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q')) =>
{
reader.stop();
screen.restore();
return Ok(None);
}
Ok(AppEvent::Crash(msg)) => {
reader.stop();
screen.restore();
return Err(color_eyre::eyre::eyre!(msg));
}
Ok(AppEvent::Exit) => {
reader.stop();
screen.restore();
return Ok(None);
}
Ok(event) => {
if waiting_shown
&& matches!(
event,
AppEvent::Terminal(crossterm::event::Event::Resize(..))
)
{
terminal.draw(|frame| startup::draw_waiting(frame, waiting_on.as_deref()))?;
}
backlog.push(event);
}
Err(_) => {
terminal.draw(|frame| startup::draw_waiting(frame, waiting_on.as_deref()))?;
let _ = std::io::stdout().flush();
waiting_shown = true;
}
}
};
let startup::Settings {
config,
theme,
input,
opts,
notes,
} = match settings {
Ok(settings) => settings,
Err(e) => {
reader.stop();
screen.restore();
return Err(e);
}
};
let asked_threads = std::env::var_os("POLARS_MAX_THREADS");
if matches!(input, RunInput::Cli(_))
&& let Some(threads) =
startup::polars_threads(config.performance.threads, asked_threads.as_deref())
{
unsafe { std::env::set_var("POLARS_MAX_THREADS", threads) };
}
let background = (asked && config.theme.follow)
.then(|| startup::take_answer(&rx, background, &mut backlog))
.flatten();
let focus_reports =
(config.theme.follow && terminal_color::supported()) || config.display.scroll_region;
if focus_reports {
follow_focus(&mut std::io::stdout());
}
glyphs::init_with_overrides(config.display.unicode, &config.glyphs.overrides);
crate::limits::set(config.limits);
pointer::capture(config.display.mouse, &mut std::io::stdout());
terminal.scroll_with_region(config.display.scroll_region);
let mut app = App::new_with_views(tx.clone(), rt_handle, theme, config, views);
app.settle_first_palette(background);
if let Some(out) = passed {
app.pass_stdout_to(out);
}
app.source.startup_view = opts.view.clone();
let debug_env = std::env::var_os("DATUI_DEBUG").is_some_and(|v| !v.is_empty() && v != "0");
if opts.debug || debug_env {
app.enable_debug();
}
let open = match input {
RunInput::Paths(paths, _) if paths.is_empty() => {
app.rest_at_start();
app.enter_home();
None
}
RunInput::Paths(paths, opts) => {
app.set_loading_phase("Scanning input", 10);
if let [path] = paths.as_slice() {
app.name_what_is_loading(path.clone());
}
Some(AppEvent::OpenNamed(paths, opts))
}
RunInput::LazyFrame(lf, opts) => {
app.set_loading_phase("Scanning input", 10);
Some(AppEvent::OpenLazyFrame(lf, opts))
}
RunInput::Cli(_) | RunInput::Host(..) => {
unreachable!("read_settings resolves the command line")
}
};
let _sweep = app.exit_sweep();
let input_tx = tx.clone();
let mut pump = EventPump::new(app, tx, rx);
pump.handle_first(backlog.into_iter().chain(open));
let end = pump.run(|app| {
if let Some(open) = app.take_external_open() {
let mouse = app.mouse_enabled();
let note = open_externally(
&open,
&mut reader,
&input_tx,
mouse,
focus_reports,
&mut terminal,
);
app.external_opened(&open, note);
}
if app.take_background_query() && terminal_color::supported() {
terminal_color::ask(&mut std::io::stdout());
}
match app.take_repaint() {
Some(Repaint::Whole) => terminal.repaint(),
Some(Repaint::BeforeMoving) => terminal.repaint_before_moving(),
None => {}
}
terminal.draw(|frame| frame.render_widget(app, frame.area()))?;
Ok(())
})?;
let result = conclude(end, &pump.app, capture, &mut reader, &mut screen);
drop(session);
for note in notes {
let _ = writeln!(std::io::stderr(), "datui: {note}");
}
if let Some((tee, handle)) = pump.app.recording_after_exit() {
let to = if tee.to_stdout() {
format!("passing standard input on to {}", tee.name())
} else {
format!("recording standard input to {}", tee.path.display())
};
let _ = writeln!(
std::io::stderr(),
"datui: {to} until it ends (Ctrl+C stops it)"
);
handle.spool().wait();
let done = if tee.to_stdout() {
"datui: standard input ended".to_string()
} else {
format!("datui: saved {}", tee.path.display())
};
let _ = writeln!(std::io::stderr(), "{done}");
}
result
}
fn open_externally(
open: &external_open::ExternalOpen,
reader: &mut terminal_input::TerminalInput,
tx: &std::sync::mpsc::Sender<AppEvent>,
mouse: bool,
focus: bool,
terminal: &mut QuietTerminal,
) -> Option<String> {
let program = external_open::program_for(open.document, |name| std::env::var(name).ok());
let result = match &program {
external_open::Program::Opener(_) => external_open::run(&program, &open.path),
external_open::Program::Wait(_) => {
reader.stop();
restore_terminal();
let result = external_open::run(&program, &open.path);
let _ = crossterm::terminal::enable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::cursor::Hide
);
push_keyboard_flags();
bracket_pastes(&mut std::io::stdout());
pointer::capture(mouse, &mut std::io::stdout());
if focus {
follow_focus(&mut std::io::stdout());
}
let _ = terminal.clear();
match terminal_input::TerminalInput::start(tx.clone()) {
Ok(started) => *reader = started,
Err(e) => {
let _ = tx.send(AppEvent::Crash(format!("Could not read keys again: {e}")));
}
}
result
}
};
result.err().map(|e| e.to_string())
}