use std::error::Error as StdError;
use std::ffi::OsString;
use std::marker::PhantomData;
use std::time::Duration;
use clap::{Command, CommandFactory, Parser};
use ratatui::Frame;
use crate::config::TuiConfig;
use crate::controller;
use crate::error::TuiError;
use crate::frame_snapshot::FrameSnapshot;
use crate::input::AppState;
use crate::runtime::{AppEvent, CrosstermRuntime, Runtime};
use crate::ui;
use crate::update::{self, Effect};
pub struct TuiApp<R: Runtime = CrosstermRuntime> {
command: Command,
config: TuiConfig,
runtime: R,
}
pub struct Tui<T, R: Runtime = CrosstermRuntime> {
inner: TuiApp<R>,
_parser: PhantomData<fn() -> T>,
}
impl TuiApp<CrosstermRuntime> {
#[must_use]
pub fn from_command(command: Command) -> Self {
Self {
command,
config: TuiConfig::default(),
runtime: CrosstermRuntime,
}
}
}
impl<R: Runtime> TuiApp<R> {
#[must_use]
pub fn with_config(mut self, config: TuiConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> TuiApp<NR> {
TuiApp {
command: self.command,
config: self.config,
runtime,
}
}
pub fn run(self) -> Result<Option<Vec<OsString>>, TuiError> {
match self.run_inner() {
Ok(argv) => Ok(Some(argv)),
Err(TuiError::Cancelled) => Ok(None),
Err(err) => Err(err),
}
}
pub fn run_with_matches<F, E>(self, runner: F) -> Result<(), TuiError>
where
F: FnOnce(clap::ArgMatches) -> Result<(), E>,
E: StdError + Send + Sync + 'static,
{
let command = self.command.clone();
let Some(argv) = self.run()? else {
return Ok(());
};
run_matches_handler(command, argv, runner)
}
fn run_inner(self) -> Result<Vec<OsString>, TuiError> {
let Self {
command,
config,
mut runtime,
} = self;
let terminal = runtime.init_terminal()?;
let mut session = TerminalSession::new(&mut runtime, terminal);
event_loop(&command, &config, &mut session)
}
}
impl<T> Tui<T, CrosstermRuntime>
where
T: Parser + CommandFactory,
{
#[must_use]
pub fn new() -> Self {
Self {
inner: TuiApp::from_command(T::command()),
_parser: PhantomData,
}
}
}
impl<T> Default for Tui<T, CrosstermRuntime>
where
T: Parser + CommandFactory,
{
fn default() -> Self {
Self::new()
}
}
impl<T, R: Runtime> Tui<T, R>
where
T: Parser + CommandFactory,
{
pub fn hide_entrypoint(mut self, name: impl Into<String>) -> Result<Self, TuiError> {
let name = name.into();
hide_top_level_entrypoint(&mut self.inner.command, &name)?;
Ok(self)
}
#[must_use]
pub fn with_config(self, config: TuiConfig) -> Self {
Self {
inner: self.inner.with_config(config),
_parser: PhantomData,
}
}
#[must_use]
pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> Tui<T, NR> {
Tui {
inner: self.inner.with_runtime(runtime),
_parser: PhantomData,
}
}
pub fn run(self) -> Result<Option<T>, TuiError> {
let Some(argv) = self.inner.run()? else {
return Ok(None);
};
parse_result(T::try_parse_from(argv)).map(Some)
}
#[must_use]
pub fn into_untyped(self) -> TuiApp<R> {
self.inner
}
}
fn parse_result<T>(result: Result<T, clap::Error>) -> Result<T, TuiError> {
result.map_err(TuiError::from)
}
fn hide_top_level_entrypoint(command: &mut Command, name: &str) -> Result<(), TuiError> {
let candidates = top_level_entrypoint_candidates(command);
for subcommand in command.get_subcommands_mut() {
if subcommand.get_name() == name {
*subcommand = subcommand.clone().hide(true);
return Ok(());
}
}
Err(TuiError::UnknownEntrypoint {
name: name.to_string(),
candidates,
})
}
fn top_level_entrypoint_candidates(command: &Command) -> Vec<String> {
command
.get_subcommands()
.map(|subcommand| subcommand.get_name().to_string())
.collect()
}
fn run_matches_handler<F, E>(
command: Command,
argv: Vec<OsString>,
runner: F,
) -> Result<(), TuiError>
where
F: FnOnce(clap::ArgMatches) -> Result<(), E>,
E: StdError + Send + Sync + 'static,
{
let matches = parse_result(command.try_get_matches_from(argv))?;
runner(matches).map_err(|err| TuiError::Runner(Box::new(err)))
}
fn event_loop<R: Runtime>(
command: &Command,
config: &TuiConfig,
session: &mut TerminalSession<'_, R>,
) -> Result<Vec<OsString>, TuiError> {
let mut observer = NoopDrawObserver;
event_loop_with_observer(command, config, session, &mut observer)
}
fn event_loop_with_observer<R, O>(
command: &Command,
config: &TuiConfig,
session: &mut TerminalSession<'_, R>,
observer: &mut O,
) -> Result<Vec<OsString>, TuiError>
where
R: Runtime,
O: DrawObserver<R::Backend>,
{
let mut state = AppState::from_command(command);
if let Some(start) = config.start_command.clone() {
controller::navigation::apply_start_command(&mut state, &start);
}
let mut frame_snapshot = FrameSnapshot::default();
let mut needs_redraw = true;
loop {
if needs_redraw {
session.draw(|frame| {
frame_snapshot = render_frame(frame, &mut state, config);
})?;
observer.observe(session.backend(), &frame_snapshot)?;
needs_redraw = false;
}
if !session.poll_event(redraw_timeout(&state))? {
needs_redraw |= clear_expired_toast_and_request_redraw(&mut state);
continue;
}
match handle_app_event(
&session.read_event()?,
&mut state,
&frame_snapshot,
config,
session,
) {
EventOutcome::Continue {
needs_redraw: redraw,
} => {
needs_redraw |= redraw;
}
EventOutcome::Exit => return Err(TuiError::Cancelled),
EventOutcome::Run(argv) => return Ok(argv),
}
}
}
fn redraw_timeout(state: &AppState) -> Duration {
state
.notifications
.toast
.as_ref()
.map_or(Duration::from_secs(60 * 60), |toast| {
toast
.expires_at
.saturating_duration_since(std::time::Instant::now())
})
}
fn clear_expired_toast_and_request_redraw(state: &mut AppState) -> bool {
let had_toast = state.notifications.toast.is_some();
state.notifications.clear_expired_toast();
had_toast && state.notifications.toast.is_none()
}
fn handle_effect<R: Runtime>(
effect: Effect,
state: &mut AppState,
session: &mut TerminalSession<'_, R>,
) -> ActionOutcome {
match effect {
Effect::None => ActionOutcome::Continue,
Effect::Run(argv) => {
let validation = state.derived_validation();
if validation.is_valid {
ActionOutcome::Run(argv)
} else {
state.notifications.show_toast(
validation
.summary
.unwrap_or_else(|| "Command is invalid".to_string()),
Duration::from_secs(3),
true,
);
ActionOutcome::Continue
}
}
Effect::CopyToClipboard(command) => {
let result = session.copy_to_clipboard(&command);
match result {
Ok(()) => {
state.notifications.show_toast(
"Copied command to clipboard",
Duration::from_secs(2),
false,
);
}
Err(_) => state.notifications.show_toast(
"Clipboard unavailable",
Duration::from_secs(2),
true,
),
}
ActionOutcome::Continue
}
Effect::Exit => ActionOutcome::Exit,
}
}
fn handle_app_event<R: Runtime>(
event: &AppEvent,
state: &mut AppState,
frame_snapshot: &FrameSnapshot,
config: &TuiConfig,
session: &mut TerminalSession<'_, R>,
) -> EventOutcome {
let mut needs_redraw = clear_expired_toast_and_request_redraw(state);
match event {
AppEvent::Key(key) => {
if let Some(action) = controller::handle_key_event(*key, state, frame_snapshot, config)
{
let effect = update::apply_action(&action, state, frame_snapshot);
match handle_effect(effect, state, session) {
ActionOutcome::Continue => {
needs_redraw |= true;
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
ActionOutcome::Exit => EventOutcome::Exit,
ActionOutcome::Run(argv) => EventOutcome::Run(argv),
}
} else {
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
}
AppEvent::Mouse(mouse) => {
if let Some(action) =
controller::handle_mouse_event(*mouse, state, frame_snapshot, config)
{
let effect = update::apply_action(&action, state, frame_snapshot);
match handle_effect(effect, state, session) {
ActionOutcome::Continue => {
needs_redraw |= true;
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
ActionOutcome::Exit => EventOutcome::Exit,
ActionOutcome::Run(argv) => EventOutcome::Run(argv),
}
} else {
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
}
AppEvent::Resize { .. } => {
needs_redraw = true;
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
AppEvent::Paste(text) => {
let effect =
update::apply_action(&update::Action::Paste(text.clone()), state, frame_snapshot);
match handle_effect(effect, state, session) {
ActionOutcome::Continue => {
needs_redraw |= true;
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
ActionOutcome::Exit => EventOutcome::Exit,
ActionOutcome::Run(argv) => EventOutcome::Run(argv),
}
}
AppEvent::FocusGained | AppEvent::FocusLost | AppEvent::Unsupported => {
needs_redraw |= clear_expired_toast_and_request_redraw(state);
EventOutcome::Continue { needs_redraw }
}
}
}
fn render_frame(frame: &mut Frame<'_>, state: &mut AppState, config: &TuiConfig) -> FrameSnapshot {
ui::render(frame, state, config)
}
trait DrawObserver<B: ratatui::backend::Backend> {
fn observe(&mut self, _backend: &B, _frame_snapshot: &FrameSnapshot) -> Result<(), TuiError> {
Ok(())
}
}
struct NoopDrawObserver;
impl<B: ratatui::backend::Backend> DrawObserver<B> for NoopDrawObserver {}
enum ActionOutcome {
Continue,
Exit,
Run(Vec<OsString>),
}
enum EventOutcome {
Continue { needs_redraw: bool },
Exit,
Run(Vec<OsString>),
}
struct TerminalSession<'a, R: Runtime> {
runtime: &'a mut R,
terminal: Option<ratatui::Terminal<R::Backend>>,
}
impl<'a, R: Runtime> TerminalSession<'a, R> {
fn new(runtime: &'a mut R, terminal: ratatui::Terminal<R::Backend>) -> Self {
Self {
runtime,
terminal: Some(terminal),
}
}
fn draw<F>(&mut self, draw_fn: F) -> Result<(), TuiError>
where
F: FnOnce(&mut Frame<'_>),
{
self.terminal
.as_mut()
.expect("terminal session is active")
.draw(draw_fn)
.map(|_| ())
.map_err(|e| TuiError::Terminal(std::io::Error::other(e.to_string())))
}
fn backend(&self) -> &R::Backend {
self.terminal
.as_ref()
.expect("terminal session is active")
.backend()
}
fn poll_event(&mut self, timeout: Duration) -> Result<bool, TuiError> {
self.runtime.poll_event(timeout)
}
fn read_event(&mut self) -> Result<AppEvent, TuiError> {
self.runtime.read_event()
}
fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
self.runtime.copy_to_clipboard(text)
}
}
impl<R: Runtime> Drop for TerminalSession<'_, R> {
fn drop(&mut self) {
if let Some(mut terminal) = self.terminal.take() {
self.runtime.restore_terminal(&mut terminal);
}
}
}
#[cfg(test)]
mod scripted;
#[cfg(test)]
mod scripted_tests;
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::ffi::OsString;
use std::time::{Duration, Instant};
use clap::error::ErrorKind;
use clap::{Arg, ArgAction, Command, Parser};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::{
ActionOutcome, EventOutcome, TerminalSession, event_loop, handle_app_event, handle_effect,
redraw_timeout,
};
use crate::frame_snapshot::FrameSnapshot;
use crate::input::{AppState, Toast};
use crate::pipeline;
use crate::runtime::{AppEvent, AppKeyCode, AppKeyEvent, AppKeyModifiers, Runtime};
use crate::spec::CommandSpec;
use crate::update::Effect;
use crate::{TuiConfig, TuiError};
fn os_vec(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[derive(Debug)]
struct TestRuntime {
events: VecDeque<AppEvent>,
clipboard_result: Result<(), String>,
copied_text: Option<String>,
}
impl TestRuntime {
fn with_events(events: impl IntoIterator<Item = AppEvent>) -> Self {
Self {
events: events.into_iter().collect(),
clipboard_result: Ok(()),
copied_text: None,
}
}
}
impl Runtime for TestRuntime {
type Backend = TestBackend;
fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
let Ok(terminal) = Terminal::new(TestBackend::new(80, 24));
Ok(terminal)
}
fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
Ok(!self.events.is_empty())
}
fn read_event(&mut self) -> Result<AppEvent, TuiError> {
Ok(self.events.pop_front().expect("queued event"))
}
fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
self.copied_text = Some(text.to_string());
self.clipboard_result.clone()
}
}
fn terminal_session(runtime: &mut TestRuntime) -> TerminalSession<'_, TestRuntime> {
let terminal = runtime.init_terminal().expect("terminal");
TerminalSession::new(runtime, terminal)
}
fn app_state() -> AppState {
AppState::new(CommandSpec::from_command(&Command::new("tool")))
}
fn app_state_from_command(command: &Command) -> AppState {
AppState::from_command(command)
}
#[test]
fn event_loop_returns_cancelled_on_ctrl_c() {
let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Char('c'),
AppKeyModifiers {
control: true,
alt: false,
shift: false,
},
))]);
let terminal = runtime.init_terminal().expect("terminal");
let mut session = TerminalSession::new(&mut runtime, terminal);
let result = event_loop(&Command::new("tool"), &TuiConfig::default(), &mut session);
assert!(matches!(result, Err(TuiError::Cancelled)));
}
#[test]
fn event_loop_returns_built_argv_on_ctrl_enter() {
let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Enter,
AppKeyModifiers {
control: true,
alt: false,
shift: false,
},
))]);
let terminal = runtime.init_terminal().expect("terminal");
let mut session = TerminalSession::new(&mut runtime, terminal);
let command = Command::new("tool").arg(
Arg::new("verbose")
.long("verbose")
.action(ArgAction::SetTrue),
);
let result = event_loop(&command, &TuiConfig::default(), &mut session);
assert_eq!(result.expect("run result"), os_vec(&["tool"]));
}
#[test]
fn event_loop_returns_built_argv_on_ctrl_r() {
let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Char('r'),
AppKeyModifiers {
control: true,
alt: false,
shift: false,
},
))]);
let terminal = runtime.init_terminal().expect("terminal");
let mut session = TerminalSession::new(&mut runtime, terminal);
let command = Command::new("tool").arg(
Arg::new("verbose")
.long("verbose")
.action(ArgAction::SetTrue),
);
let result = event_loop(&command, &TuiConfig::default(), &mut session);
assert_eq!(result.expect("run result"), os_vec(&["tool"]));
}
#[test]
fn copy_effect_success_shows_success_toast() {
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state();
let outcome = handle_effect(
Effect::CopyToClipboard("tool --verbose".to_string()),
&mut state,
&mut session,
);
drop(session);
assert!(matches!(outcome, ActionOutcome::Continue));
assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
let toast = state.notifications.toast.as_ref().expect("toast");
assert_eq!(toast.message, "Copied command to clipboard");
assert!(!toast.is_error);
}
#[test]
fn copy_effect_failure_shows_error_toast() {
let mut runtime = TestRuntime::with_events([]);
runtime.clipboard_result = Err("clipboard unavailable".to_string());
let mut session = terminal_session(&mut runtime);
let mut state = app_state();
let outcome = handle_effect(
Effect::CopyToClipboard("tool --verbose".to_string()),
&mut state,
&mut session,
);
drop(session);
assert!(matches!(outcome, ActionOutcome::Continue));
assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
let toast = state.notifications.toast.as_ref().expect("toast");
assert_eq!(toast.message, "Clipboard unavailable");
assert!(toast.is_error);
}
#[test]
fn invalid_run_effect_is_blocked_and_surfaces_validation_summary() {
let command = Command::new("tool").arg(
Arg::new("name")
.long("name")
.required(true)
.action(ArgAction::Set),
);
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state_from_command(&command);
let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
assert!(matches!(outcome, ActionOutcome::Continue));
let toast = state.notifications.toast.as_ref().expect("toast");
assert!(toast.is_error);
assert_eq!(toast.message, "Missing required argument: --name");
}
#[test]
fn run_uses_cached_validation_state_without_revalidating() {
pipeline::reset_validation_call_count();
let command = Command::new("tool").arg(
Arg::new("name")
.long("name")
.required(true)
.action(ArgAction::Set),
);
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state_from_command(&command);
let argv = state.authoritative_argv();
assert_eq!(pipeline::validation_call_count(), 1);
let outcome = handle_effect(Effect::Run(argv), &mut state, &mut session);
assert!(matches!(outcome, ActionOutcome::Continue));
assert_eq!(pipeline::validation_call_count(), 1);
let toast = state.notifications.toast.as_ref().expect("toast");
assert!(toast.is_error);
assert_eq!(toast.message, "Missing required argument: --name");
}
#[test]
fn run_matches_handler_returns_clap_display_errors_without_running_callback() {
let mut called = false;
let result = super::run_matches_handler(
Command::new("tool").version("1.2.3"),
os_vec(&["tool", "--version"]),
|_matches| {
called = true;
Ok::<_, std::io::Error>(())
},
);
let error = result.expect_err("version display should be returned");
assert!(
matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
);
assert!(!called);
}
#[test]
fn tui_run_returns_typed_value_on_submit() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
struct Cli {
#[arg(long, default_value = "world")]
name: String,
}
let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Char('r'),
AppKeyModifiers {
control: true,
..AppKeyModifiers::default()
},
))]);
let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
assert_eq!(
result.expect("typed run should succeed"),
Some(Cli {
name: "world".to_string()
})
);
}
#[test]
fn hide_entrypoint_hides_a_matching_top_level_subcommand_from_the_render_tree() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
enum Cli {
Tui,
Hello {
#[arg(long, default_value = "world")]
name: String,
},
}
let app = super::Tui::<Cli>::new()
.hide_entrypoint("tui")
.expect("top-level entrypoint should exist");
let spec = crate::spec::CommandSpec::from_command(&app.inner.command);
assert!(
spec.subcommands
.iter()
.all(|subcommand| subcommand.name != "tui")
);
assert!(
app.inner
.command
.get_subcommands()
.all(|subcommand| subcommand.get_name() != "tui" || subcommand.is_hide_set())
);
}
#[test]
fn hide_entrypoint_returns_unknown_entrypoint_with_candidates() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
enum Cli {
Tui,
Build,
Serve,
}
let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("missing") else {
panic!("missing entrypoint should fail");
};
assert!(matches!(
error,
TuiError::UnknownEntrypoint { ref name, ref candidates }
if name == "missing" && candidates == &vec![
"tui".to_string(),
"build".to_string(),
"serve".to_string()
]
));
}
#[test]
fn hide_entrypoint_does_not_match_aliases() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
enum Cli {
#[command(visible_alias = "interactive")]
Tui,
Build,
}
let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("interactive") else {
panic!("aliases should not match");
};
assert!(matches!(
error,
TuiError::UnknownEntrypoint { ref name, ref candidates }
if name == "interactive" && candidates == &vec![
"tui".to_string(),
"build".to_string()
]
));
}
#[test]
fn hide_entrypoint_can_be_applied_twice() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
enum Cli {
Tui,
Build,
}
let app = super::Tui::<Cli>::new()
.hide_entrypoint("tui")
.expect("first hide should succeed")
.hide_entrypoint("tui")
.expect("second hide should also succeed");
let hidden = app
.inner
.command
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "tui")
.expect("tui subcommand should still exist");
assert!(hidden.is_hide_set());
}
#[test]
fn tui_run_returns_none_on_cancel() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool", version = "1.2.3")]
struct Cli;
let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Char('c'),
AppKeyModifiers {
control: true,
..AppKeyModifiers::default()
},
))]);
let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
assert_eq!(result.expect("cancel should map to None"), None);
}
#[test]
fn tui_run_returns_clap_display_errors_without_printing() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool", version = "1.2.3")]
struct Cli;
let error = super::parse_result(Cli::try_parse_from(os_vec(&["tool", "--version"])))
.expect_err("version display should be returned");
assert!(
matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
);
}
#[test]
fn tui_run_reparses_selected_command_after_hiding_entrypoint() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
enum Cli {
Tui,
Hello {
#[arg(long, default_value = "world")]
name: String,
},
}
let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Char('r'),
AppKeyModifiers {
control: true,
..AppKeyModifiers::default()
},
))]);
let config = TuiConfig {
start_command: Some("hello".to_string()),
..TuiConfig::default()
};
let result = super::Tui::<Cli, _>::new()
.hide_entrypoint("tui")
.expect("entrypoint should exist")
.with_config(config)
.with_runtime(runtime)
.run();
assert_eq!(
result.expect("typed run should succeed"),
Some(Cli::Hello {
name: "world".to_string()
})
);
}
#[test]
fn tui_run_propagates_runtime_failures() {
#[derive(Debug, clap::Parser, PartialEq, Eq)]
#[command(name = "tool")]
struct Cli;
#[derive(Debug)]
struct FailingRuntime;
impl Runtime for FailingRuntime {
type Backend = TestBackend;
fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
Err(std::io::Error::other("boom").into())
}
fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
unreachable!("terminal initialization should fail first")
}
fn read_event(&mut self) -> Result<AppEvent, TuiError> {
unreachable!("terminal initialization should fail first")
}
fn copy_to_clipboard(&mut self, _text: &str) -> Result<(), String> {
unreachable!("terminal initialization should fail first")
}
}
let error = super::Tui::<Cli, _>::new()
.with_runtime(FailingRuntime)
.run()
.expect_err("runtime failure should propagate");
assert!(matches!(error, TuiError::Terminal(_)));
}
#[test]
fn help_style_invalid_run_toast_does_not_show_about_text() {
let command = Command::new("tool")
.about("Run the selected tool")
.arg_required_else_help(true)
.arg(Arg::new("path").required(true));
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state_from_command(&command);
let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
assert!(matches!(outcome, ActionOutcome::Continue));
let toast = state.notifications.toast.as_ref().expect("toast");
assert!(toast.is_error);
assert_eq!(toast.message, "Missing required argument: path");
assert!(!toast.message.contains("Run the selected tool"));
}
#[test]
fn resize_event_requests_redraw() {
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state();
let outcome = handle_app_event(
&AppEvent::Resize {
width: 120,
height: 40,
},
&mut state,
&FrameSnapshot::default(),
&TuiConfig::default(),
&mut session,
);
assert!(matches!(
outcome,
EventOutcome::Continue { needs_redraw: true }
));
}
#[test]
fn paste_event_updates_focused_form_field() {
let command = Command::new("tool").arg(Arg::new("path").long("path"));
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state_from_command(&command);
state.ui.focus_form();
let outcome = handle_app_event(
&AppEvent::Paste("/tmp/foo".to_string()),
&mut state,
&FrameSnapshot::default(),
&TuiConfig::default(),
&mut session,
);
assert!(matches!(
outcome,
EventOutcome::Continue { needs_redraw: true }
));
let form = state.domain.current_form().expect("form");
let arg = state.domain.arg_for_input("path").expect("path arg");
assert_eq!(
form.compatibility_value(arg),
Some(crate::input::ArgValue::Text("/tmp/foo".to_string()))
);
let derived = crate::pipeline::derive(&state);
assert_eq!(
derived.authoritative_argv,
vec![
"tool".to_string(),
"--path".to_string(),
"/tmp/foo".to_string(),
]
);
assert!(derived.validation.is_valid);
}
#[test]
fn paste_event_updates_search_query_when_search_is_focused() {
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state();
state.ui.focus_search();
let outcome = handle_app_event(
&AppEvent::Paste("build".to_string()),
&mut state,
&FrameSnapshot::default(),
&TuiConfig::default(),
&mut session,
);
assert!(matches!(
outcome,
EventOutcome::Continue { needs_redraw: true }
));
assert_eq!(state.ui.search_query, "build");
}
#[test]
fn toast_timeout_behavior_is_unchanged() {
let mut state = app_state();
state.notifications.show_toast(
"Copied command to clipboard",
Duration::from_millis(250),
false,
);
let timeout = redraw_timeout(&state);
assert!(timeout > Duration::ZERO);
assert!(timeout <= Duration::from_millis(250));
}
#[test]
fn expired_toast_clears_during_continuous_key_input() {
let mut runtime = TestRuntime::with_events([]);
let mut session = terminal_session(&mut runtime);
let mut state = app_state();
state.notifications.toast = Some(Toast {
message: "Copied command to clipboard".to_string(),
expires_at: Instant::now()
.checked_sub(Duration::from_millis(1))
.expect("duration should be representable"),
is_error: false,
});
let outcome = handle_app_event(
&AppEvent::Key(AppKeyEvent::new(
AppKeyCode::Char('x'),
AppKeyModifiers::default(),
)),
&mut state,
&FrameSnapshot::default(),
&TuiConfig::default(),
&mut session,
);
assert!(matches!(
outcome,
EventOutcome::Continue { needs_redraw: true }
));
assert!(state.notifications.toast.is_none());
}
}