1use std::error::Error as StdError;
2use std::ffi::OsString;
3use std::marker::PhantomData;
4use std::time::Duration;
5
6use clap::{Command, CommandFactory, Parser};
7use ratatui::Frame;
8
9use crate::config::TuiConfig;
10use crate::controller;
11use crate::error::TuiError;
12use crate::frame_snapshot::FrameSnapshot;
13use crate::input::AppState;
14use crate::runtime::{AppEvent, CrosstermRuntime, Runtime};
15use crate::ui;
16use crate::update::{self, Effect};
17
18pub struct TuiApp<R: Runtime = CrosstermRuntime> {
24 command: Command,
25 config: TuiConfig,
26 runtime: R,
27}
28
29pub struct Tui<T, R: Runtime = CrosstermRuntime> {
34 inner: TuiApp<R>,
35 _parser: PhantomData<fn() -> T>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub struct TuiInvocation<T> {
42 pub command: T,
44 pub argv: Vec<OsString>,
46}
47
48impl TuiApp<CrosstermRuntime> {
49 #[must_use]
51 pub fn from_command(command: Command) -> Self {
52 Self {
53 command,
54 config: TuiConfig::default(),
55 runtime: CrosstermRuntime,
56 }
57 }
58}
59
60impl<R: Runtime> TuiApp<R> {
61 #[must_use]
63 pub fn with_config(mut self, config: TuiConfig) -> Self {
64 self.config = config;
65 self
66 }
67
68 #[must_use]
70 pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> TuiApp<NR> {
71 TuiApp {
72 command: self.command,
73 config: self.config,
74 runtime,
75 }
76 }
77
78 pub fn run(self) -> Result<Option<Vec<OsString>>, TuiError> {
92 match self.run_inner() {
93 Ok(argv) => Ok(Some(argv)),
94 Err(TuiError::Cancelled) => Ok(None),
95 Err(err) => Err(err),
96 }
97 }
98
99 pub fn run_with_matches<F, E>(self, runner: F) -> Result<(), TuiError>
110 where
111 F: FnOnce(clap::ArgMatches) -> Result<(), E>,
112 E: StdError + Send + Sync + 'static,
113 {
114 let command = self.command.clone();
115 let Some(argv) = self.run()? else {
116 return Ok(());
117 };
118 run_matches_handler(command, argv, runner)
119 }
120
121 fn run_inner(self) -> Result<Vec<OsString>, TuiError> {
122 let Self {
123 command,
124 config,
125 mut runtime,
126 } = self;
127 let terminal = runtime.init_terminal()?;
128 let mut session = TerminalSession::new(&mut runtime, terminal);
129 event_loop(&command, &config, &mut session)
130 }
131}
132
133impl<T> Tui<T, CrosstermRuntime>
134where
135 T: Parser + CommandFactory,
136{
137 #[must_use]
139 pub fn new() -> Self {
140 Self {
141 inner: TuiApp::from_command(T::command()),
142 _parser: PhantomData,
143 }
144 }
145}
146
147impl<T> Default for Tui<T, CrosstermRuntime>
148where
149 T: Parser + CommandFactory,
150{
151 fn default() -> Self {
152 Self::new()
153 }
154}
155
156impl<T, R: Runtime> Tui<T, R>
157where
158 T: Parser + CommandFactory,
159{
160 pub fn hide_entrypoint(mut self, name: impl Into<String>) -> Result<Self, TuiError> {
170 let name = name.into();
171 hide_top_level_entrypoint(&mut self.inner.command, &name)?;
172 Ok(self)
173 }
174
175 #[must_use]
177 pub fn with_config(self, config: TuiConfig) -> Self {
178 Self {
179 inner: self.inner.with_config(config),
180 _parser: PhantomData,
181 }
182 }
183
184 #[must_use]
186 pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> Tui<T, NR> {
187 Tui {
188 inner: self.inner.with_runtime(runtime),
189 _parser: PhantomData,
190 }
191 }
192
193 pub fn run(self) -> Result<Option<T>, TuiError> {
205 self.run_with_argv()
206 .map(|invocation| invocation.map(|invocation| invocation.command))
207 }
208
209 pub fn run_with_argv(self) -> Result<Option<TuiInvocation<T>>, TuiError> {
222 let Some(argv) = self.inner.run()? else {
223 return Ok(None);
224 };
225 let command = parse_result(T::try_parse_from(&argv))?;
226 Ok(Some(TuiInvocation { command, argv }))
227 }
228
229 #[must_use]
231 pub fn into_untyped(self) -> TuiApp<R> {
232 self.inner
233 }
234}
235
236fn parse_result<T>(result: Result<T, clap::Error>) -> Result<T, TuiError> {
237 result.map_err(TuiError::from)
238}
239
240fn hide_top_level_entrypoint(command: &mut Command, name: &str) -> Result<(), TuiError> {
241 let candidates = top_level_entrypoint_candidates(command);
242
243 for subcommand in command.get_subcommands_mut() {
244 if subcommand.get_name() == name {
245 *subcommand = subcommand.clone().hide(true);
246 return Ok(());
247 }
248 }
249
250 Err(TuiError::UnknownEntrypoint {
251 name: name.to_string(),
252 candidates,
253 })
254}
255
256fn top_level_entrypoint_candidates(command: &Command) -> Vec<String> {
257 command
258 .get_subcommands()
259 .map(|subcommand| subcommand.get_name().to_string())
260 .collect()
261}
262
263fn run_matches_handler<F, E>(
264 command: Command,
265 argv: Vec<OsString>,
266 runner: F,
267) -> Result<(), TuiError>
268where
269 F: FnOnce(clap::ArgMatches) -> Result<(), E>,
270 E: StdError + Send + Sync + 'static,
271{
272 let matches = parse_result(command.try_get_matches_from(argv))?;
273 runner(matches).map_err(|err| TuiError::Runner(Box::new(err)))
274}
275
276fn event_loop<R: Runtime>(
277 command: &Command,
278 config: &TuiConfig,
279 session: &mut TerminalSession<'_, R>,
280) -> Result<Vec<OsString>, TuiError> {
281 let mut observer = NoopDrawObserver;
282 event_loop_with_observer(command, config, session, &mut observer)
283}
284
285fn event_loop_with_observer<R, O>(
286 command: &Command,
287 config: &TuiConfig,
288 session: &mut TerminalSession<'_, R>,
289 observer: &mut O,
290) -> Result<Vec<OsString>, TuiError>
291where
292 R: Runtime,
293 O: DrawObserver<R::Backend>,
294{
295 let mut state = AppState::from_command(command);
296 if let Some(start) = config.start_command.clone() {
297 controller::navigation::apply_start_command(&mut state, &start);
298 }
299 let mut frame_snapshot = FrameSnapshot::default();
300 let mut needs_redraw = true;
301
302 loop {
303 if needs_redraw {
304 session.draw(|frame| {
305 frame_snapshot = render_frame(frame, &mut state, config);
306 })?;
307 observer.observe(session.backend(), &frame_snapshot)?;
308 needs_redraw = false;
309 }
310
311 if !session.poll_event(redraw_timeout(&state))? {
312 needs_redraw |= clear_expired_toast_and_request_redraw(&mut state);
313 continue;
314 }
315
316 match handle_app_event(
317 &session.read_event()?,
318 &mut state,
319 &frame_snapshot,
320 config,
321 session,
322 ) {
323 EventOutcome::Continue {
324 needs_redraw: redraw,
325 } => {
326 needs_redraw |= redraw;
327 }
328 EventOutcome::Exit => return Err(TuiError::Cancelled),
329 EventOutcome::Run(argv) => return Ok(argv),
330 }
331 }
332}
333
334fn redraw_timeout(state: &AppState) -> Duration {
335 state
336 .notifications
337 .toast
338 .as_ref()
339 .map_or(Duration::from_secs(60 * 60), |toast| {
340 toast
341 .expires_at
342 .saturating_duration_since(std::time::Instant::now())
343 })
344}
345
346fn clear_expired_toast_and_request_redraw(state: &mut AppState) -> bool {
347 let had_toast = state.notifications.toast.is_some();
348 state.notifications.clear_expired_toast();
349 had_toast && state.notifications.toast.is_none()
350}
351
352fn handle_effect<R: Runtime>(
353 effect: Effect,
354 state: &mut AppState,
355 session: &mut TerminalSession<'_, R>,
356) -> ActionOutcome {
357 match effect {
358 Effect::None => ActionOutcome::Continue,
359 Effect::Run(argv) => {
360 let validation = state.derived_validation();
361 if validation.is_valid {
362 ActionOutcome::Run(argv)
363 } else {
364 state.notifications.show_toast(
365 validation
366 .summary
367 .unwrap_or_else(|| "Command is invalid".to_string()),
368 Duration::from_secs(3),
369 true,
370 );
371 ActionOutcome::Continue
372 }
373 }
374 Effect::CopyToClipboard(command) => {
375 let result = session.copy_to_clipboard(&command);
376 match result {
377 Ok(()) => {
378 state.notifications.show_toast(
379 "Copied command to clipboard",
380 Duration::from_secs(2),
381 false,
382 );
383 }
384 Err(_) => state.notifications.show_toast(
385 "Clipboard unavailable",
386 Duration::from_secs(2),
387 true,
388 ),
389 }
390 ActionOutcome::Continue
391 }
392 Effect::Exit => ActionOutcome::Exit,
393 }
394}
395
396fn handle_app_event<R: Runtime>(
397 event: &AppEvent,
398 state: &mut AppState,
399 frame_snapshot: &FrameSnapshot,
400 config: &TuiConfig,
401 session: &mut TerminalSession<'_, R>,
402) -> EventOutcome {
403 let mut needs_redraw = clear_expired_toast_and_request_redraw(state);
404
405 match event {
406 AppEvent::Key(key) => {
407 if let Some(action) = controller::handle_key_event(*key, state, frame_snapshot, config)
408 {
409 let effect = update::apply_action(&action, state, frame_snapshot);
410 match handle_effect(effect, state, session) {
411 ActionOutcome::Continue => {
412 needs_redraw |= true;
413 needs_redraw |= clear_expired_toast_and_request_redraw(state);
414 EventOutcome::Continue { needs_redraw }
415 }
416 ActionOutcome::Exit => EventOutcome::Exit,
417 ActionOutcome::Run(argv) => EventOutcome::Run(argv),
418 }
419 } else {
420 needs_redraw |= clear_expired_toast_and_request_redraw(state);
421 EventOutcome::Continue { needs_redraw }
422 }
423 }
424 AppEvent::Mouse(mouse) => {
425 if let Some(action) =
426 controller::handle_mouse_event(*mouse, state, frame_snapshot, config)
427 {
428 let effect = update::apply_action(&action, state, frame_snapshot);
429 match handle_effect(effect, state, session) {
430 ActionOutcome::Continue => {
431 needs_redraw |= true;
432 needs_redraw |= clear_expired_toast_and_request_redraw(state);
433 EventOutcome::Continue { needs_redraw }
434 }
435 ActionOutcome::Exit => EventOutcome::Exit,
436 ActionOutcome::Run(argv) => EventOutcome::Run(argv),
437 }
438 } else {
439 needs_redraw |= clear_expired_toast_and_request_redraw(state);
440 EventOutcome::Continue { needs_redraw }
441 }
442 }
443 AppEvent::Resize { .. } => {
444 needs_redraw = true;
445 needs_redraw |= clear_expired_toast_and_request_redraw(state);
446 EventOutcome::Continue { needs_redraw }
447 }
448 AppEvent::Paste(text) => {
449 let effect =
450 update::apply_action(&update::Action::Paste(text.clone()), state, frame_snapshot);
451 match handle_effect(effect, state, session) {
452 ActionOutcome::Continue => {
453 needs_redraw |= true;
454 needs_redraw |= clear_expired_toast_and_request_redraw(state);
455 EventOutcome::Continue { needs_redraw }
456 }
457 ActionOutcome::Exit => EventOutcome::Exit,
458 ActionOutcome::Run(argv) => EventOutcome::Run(argv),
459 }
460 }
461 AppEvent::FocusGained | AppEvent::FocusLost | AppEvent::Unsupported => {
462 needs_redraw |= clear_expired_toast_and_request_redraw(state);
463 EventOutcome::Continue { needs_redraw }
464 }
465 }
466}
467
468fn render_frame(frame: &mut Frame<'_>, state: &mut AppState, config: &TuiConfig) -> FrameSnapshot {
469 ui::render(frame, state, config)
470}
471
472trait DrawObserver<B: ratatui::backend::Backend> {
473 fn observe(&mut self, _backend: &B, _frame_snapshot: &FrameSnapshot) -> Result<(), TuiError> {
474 Ok(())
475 }
476}
477
478struct NoopDrawObserver;
479
480impl<B: ratatui::backend::Backend> DrawObserver<B> for NoopDrawObserver {}
481
482enum ActionOutcome {
483 Continue,
484 Exit,
485 Run(Vec<OsString>),
486}
487
488enum EventOutcome {
489 Continue { needs_redraw: bool },
490 Exit,
491 Run(Vec<OsString>),
492}
493
494struct TerminalSession<'a, R: Runtime> {
495 runtime: &'a mut R,
496 terminal: Option<ratatui::Terminal<R::Backend>>,
497}
498
499impl<'a, R: Runtime> TerminalSession<'a, R> {
500 fn new(runtime: &'a mut R, terminal: ratatui::Terminal<R::Backend>) -> Self {
501 Self {
502 runtime,
503 terminal: Some(terminal),
504 }
505 }
506
507 fn draw<F>(&mut self, draw_fn: F) -> Result<(), TuiError>
508 where
509 F: FnOnce(&mut Frame<'_>),
510 {
511 self.terminal
512 .as_mut()
513 .expect("terminal session is active")
514 .draw(draw_fn)
515 .map(|_| ())
516 .map_err(|e| TuiError::Terminal(std::io::Error::other(e.to_string())))
517 }
518
519 fn backend(&self) -> &R::Backend {
520 self.terminal
521 .as_ref()
522 .expect("terminal session is active")
523 .backend()
524 }
525
526 fn poll_event(&mut self, timeout: Duration) -> Result<bool, TuiError> {
527 self.runtime.poll_event(timeout)
528 }
529
530 fn read_event(&mut self) -> Result<AppEvent, TuiError> {
531 self.runtime.read_event()
532 }
533
534 fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
535 self.runtime.copy_to_clipboard(text)
536 }
537}
538
539impl<R: Runtime> Drop for TerminalSession<'_, R> {
540 fn drop(&mut self) {
541 if let Some(mut terminal) = self.terminal.take() {
542 self.runtime.restore_terminal(&mut terminal);
543 }
544 }
545}
546
547#[cfg(test)]
548mod scripted;
549#[cfg(test)]
550mod scripted_tests;
551
552#[cfg(test)]
553mod tests {
554 use std::collections::VecDeque;
555 use std::ffi::OsString;
556 use std::time::{Duration, Instant};
557
558 use clap::error::ErrorKind;
559 use clap::{Arg, ArgAction, Command, Parser};
560 use ratatui::Terminal;
561 use ratatui::backend::TestBackend;
562
563 use super::{
564 ActionOutcome, EventOutcome, TerminalSession, event_loop, handle_app_event, handle_effect,
565 redraw_timeout,
566 };
567 use crate::frame_snapshot::FrameSnapshot;
568 use crate::input::{AppState, Toast};
569 use crate::pipeline;
570 use crate::runtime::{AppEvent, AppKeyCode, AppKeyEvent, AppKeyModifiers, Runtime};
571 use crate::spec::CommandSpec;
572 use crate::update::Effect;
573 use crate::{TuiConfig, TuiError};
574
575 fn os_vec(values: &[&str]) -> Vec<OsString> {
576 values.iter().map(OsString::from).collect()
577 }
578
579 #[derive(Debug)]
580 struct TestRuntime {
581 events: VecDeque<AppEvent>,
582 clipboard_result: Result<(), String>,
583 copied_text: Option<String>,
584 }
585
586 impl TestRuntime {
587 fn with_events(events: impl IntoIterator<Item = AppEvent>) -> Self {
588 Self {
589 events: events.into_iter().collect(),
590 clipboard_result: Ok(()),
591 copied_text: None,
592 }
593 }
594 }
595
596 impl Runtime for TestRuntime {
597 type Backend = TestBackend;
598
599 fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
600 let Ok(terminal) = Terminal::new(TestBackend::new(80, 24));
601 Ok(terminal)
602 }
603
604 fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
605
606 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
607 Ok(!self.events.is_empty())
608 }
609
610 fn read_event(&mut self) -> Result<AppEvent, TuiError> {
611 Ok(self.events.pop_front().expect("queued event"))
612 }
613
614 fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
615 self.copied_text = Some(text.to_string());
616 self.clipboard_result.clone()
617 }
618 }
619
620 fn terminal_session(runtime: &mut TestRuntime) -> TerminalSession<'_, TestRuntime> {
621 let terminal = runtime.init_terminal().expect("terminal");
622 TerminalSession::new(runtime, terminal)
623 }
624
625 fn app_state() -> AppState {
626 AppState::new(CommandSpec::from_command(&Command::new("tool")))
627 }
628
629 fn app_state_from_command(command: &Command) -> AppState {
630 AppState::from_command(command)
631 }
632
633 #[test]
634 fn event_loop_returns_cancelled_on_ctrl_c() {
635 let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
636 AppKeyCode::Char('c'),
637 AppKeyModifiers {
638 control: true,
639 alt: false,
640 shift: false,
641 },
642 ))]);
643 let terminal = runtime.init_terminal().expect("terminal");
644 let mut session = TerminalSession::new(&mut runtime, terminal);
645
646 let result = event_loop(&Command::new("tool"), &TuiConfig::default(), &mut session);
647
648 assert!(matches!(result, Err(TuiError::Cancelled)));
649 }
650
651 #[test]
652 fn event_loop_returns_built_argv_on_ctrl_enter() {
653 let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
654 AppKeyCode::Enter,
655 AppKeyModifiers {
656 control: true,
657 alt: false,
658 shift: false,
659 },
660 ))]);
661 let terminal = runtime.init_terminal().expect("terminal");
662 let mut session = TerminalSession::new(&mut runtime, terminal);
663 let command = Command::new("tool").arg(
664 Arg::new("verbose")
665 .long("verbose")
666 .action(ArgAction::SetTrue),
667 );
668
669 let result = event_loop(&command, &TuiConfig::default(), &mut session);
670
671 assert_eq!(result.expect("run result"), os_vec(&["tool"]));
672 }
673
674 #[test]
675 fn event_loop_returns_built_argv_on_ctrl_r() {
676 let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
677 AppKeyCode::Char('r'),
678 AppKeyModifiers {
679 control: true,
680 alt: false,
681 shift: false,
682 },
683 ))]);
684 let terminal = runtime.init_terminal().expect("terminal");
685 let mut session = TerminalSession::new(&mut runtime, terminal);
686 let command = Command::new("tool").arg(
687 Arg::new("verbose")
688 .long("verbose")
689 .action(ArgAction::SetTrue),
690 );
691
692 let result = event_loop(&command, &TuiConfig::default(), &mut session);
693
694 assert_eq!(result.expect("run result"), os_vec(&["tool"]));
695 }
696
697 #[test]
698 fn copy_effect_success_shows_success_toast() {
699 let mut runtime = TestRuntime::with_events([]);
700 let mut session = terminal_session(&mut runtime);
701 let mut state = app_state();
702
703 let outcome = handle_effect(
704 Effect::CopyToClipboard("tool --verbose".to_string()),
705 &mut state,
706 &mut session,
707 );
708 drop(session);
709
710 assert!(matches!(outcome, ActionOutcome::Continue));
711 assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
712 let toast = state.notifications.toast.as_ref().expect("toast");
713 assert_eq!(toast.message, "Copied command to clipboard");
714 assert!(!toast.is_error);
715 }
716
717 #[test]
718 fn copy_effect_failure_shows_error_toast() {
719 let mut runtime = TestRuntime::with_events([]);
720 runtime.clipboard_result = Err("clipboard unavailable".to_string());
721 let mut session = terminal_session(&mut runtime);
722 let mut state = app_state();
723
724 let outcome = handle_effect(
725 Effect::CopyToClipboard("tool --verbose".to_string()),
726 &mut state,
727 &mut session,
728 );
729 drop(session);
730
731 assert!(matches!(outcome, ActionOutcome::Continue));
732 assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
733 let toast = state.notifications.toast.as_ref().expect("toast");
734 assert_eq!(toast.message, "Clipboard unavailable");
735 assert!(toast.is_error);
736 }
737
738 #[test]
739 fn invalid_run_effect_is_blocked_and_surfaces_validation_summary() {
740 let command = Command::new("tool").arg(
741 Arg::new("name")
742 .long("name")
743 .required(true)
744 .action(ArgAction::Set),
745 );
746 let mut runtime = TestRuntime::with_events([]);
747 let mut session = terminal_session(&mut runtime);
748 let mut state = app_state_from_command(&command);
749
750 let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
751
752 assert!(matches!(outcome, ActionOutcome::Continue));
753 let toast = state.notifications.toast.as_ref().expect("toast");
754 assert!(toast.is_error);
755 assert_eq!(toast.message, "Missing required argument: --name");
756 }
757
758 #[test]
759 fn run_uses_cached_validation_state_without_revalidating() {
760 pipeline::reset_validation_call_count();
761
762 let command = Command::new("tool").arg(
763 Arg::new("name")
764 .long("name")
765 .required(true)
766 .action(ArgAction::Set),
767 );
768 let mut runtime = TestRuntime::with_events([]);
769 let mut session = terminal_session(&mut runtime);
770 let mut state = app_state_from_command(&command);
771 let argv = state.authoritative_argv();
772
773 assert_eq!(pipeline::validation_call_count(), 1);
774
775 let outcome = handle_effect(Effect::Run(argv), &mut state, &mut session);
776
777 assert!(matches!(outcome, ActionOutcome::Continue));
778 assert_eq!(pipeline::validation_call_count(), 1);
779 let toast = state.notifications.toast.as_ref().expect("toast");
780 assert!(toast.is_error);
781 assert_eq!(toast.message, "Missing required argument: --name");
782 }
783
784 #[test]
785 fn run_matches_handler_returns_clap_display_errors_without_running_callback() {
786 let mut called = false;
787
788 let result = super::run_matches_handler(
789 Command::new("tool").version("1.2.3"),
790 os_vec(&["tool", "--version"]),
791 |_matches| {
792 called = true;
793 Ok::<_, std::io::Error>(())
794 },
795 );
796
797 let error = result.expect_err("version display should be returned");
798 assert!(
799 matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
800 );
801 assert!(!called);
802 }
803
804 #[test]
805 fn tui_run_returns_typed_value_on_submit() {
806 #[derive(Debug, clap::Parser, PartialEq, Eq)]
807 #[command(name = "tool")]
808 struct Cli {
809 #[arg(long, default_value = "world")]
810 name: String,
811 }
812
813 let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
814 AppKeyCode::Char('r'),
815 AppKeyModifiers {
816 control: true,
817 ..AppKeyModifiers::default()
818 },
819 ))]);
820
821 let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
822
823 assert_eq!(
824 result.expect("typed run should succeed"),
825 Some(Cli {
826 name: "world".to_string()
827 })
828 );
829 }
830
831 #[test]
832 fn tui_run_with_argv_returns_typed_value_and_exact_canonical_argv() {
833 #[derive(Debug, clap::Parser, PartialEq, Eq)]
834 #[command(name = "tool")]
835 struct Cli {
836 #[arg(long, default_value = "world")]
837 name: String,
838 }
839
840 let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
841 AppKeyCode::Char('r'),
842 AppKeyModifiers {
843 control: true,
844 ..AppKeyModifiers::default()
845 },
846 ))]);
847
848 let invocation = super::Tui::<Cli, _>::new()
849 .with_runtime(runtime)
850 .run_with_argv()
851 .expect("typed run should succeed")
852 .expect("run should produce an invocation");
853
854 assert_eq!(
855 invocation.command,
856 Cli {
857 name: "world".to_string()
858 }
859 );
860 assert_eq!(invocation.argv, os_vec(&["tool"]));
861 }
862
863 #[test]
864 fn hide_entrypoint_hides_a_matching_top_level_subcommand_from_the_render_tree() {
865 #[derive(Debug, clap::Parser, PartialEq, Eq)]
866 #[command(name = "tool")]
867 enum Cli {
868 Tui,
869 Hello {
870 #[arg(long, default_value = "world")]
871 name: String,
872 },
873 }
874
875 let app = super::Tui::<Cli>::new()
876 .hide_entrypoint("tui")
877 .expect("top-level entrypoint should exist");
878 let spec = crate::spec::CommandSpec::from_command(&app.inner.command);
879
880 assert!(
881 spec.subcommands
882 .iter()
883 .all(|subcommand| subcommand.name != "tui")
884 );
885 assert!(
886 app.inner
887 .command
888 .get_subcommands()
889 .all(|subcommand| subcommand.get_name() != "tui" || subcommand.is_hide_set())
890 );
891 }
892
893 #[test]
894 fn hide_entrypoint_returns_unknown_entrypoint_with_candidates() {
895 #[derive(Debug, clap::Parser, PartialEq, Eq)]
896 #[command(name = "tool")]
897 enum Cli {
898 Tui,
899 Build,
900 Serve,
901 }
902
903 let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("missing") else {
904 panic!("missing entrypoint should fail");
905 };
906
907 assert!(matches!(
908 error,
909 TuiError::UnknownEntrypoint { ref name, ref candidates }
910 if name == "missing" && candidates == &vec![
911 "tui".to_string(),
912 "build".to_string(),
913 "serve".to_string()
914 ]
915 ));
916 }
917
918 #[test]
919 fn hide_entrypoint_does_not_match_aliases() {
920 #[derive(Debug, clap::Parser, PartialEq, Eq)]
921 #[command(name = "tool")]
922 enum Cli {
923 #[command(visible_alias = "interactive")]
924 Tui,
925 Build,
926 }
927
928 let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("interactive") else {
929 panic!("aliases should not match");
930 };
931
932 assert!(matches!(
933 error,
934 TuiError::UnknownEntrypoint { ref name, ref candidates }
935 if name == "interactive" && candidates == &vec![
936 "tui".to_string(),
937 "build".to_string()
938 ]
939 ));
940 }
941
942 #[test]
943 fn hide_entrypoint_can_be_applied_twice() {
944 #[derive(Debug, clap::Parser, PartialEq, Eq)]
945 #[command(name = "tool")]
946 enum Cli {
947 Tui,
948 Build,
949 }
950
951 let app = super::Tui::<Cli>::new()
952 .hide_entrypoint("tui")
953 .expect("first hide should succeed")
954 .hide_entrypoint("tui")
955 .expect("second hide should also succeed");
956
957 let hidden = app
958 .inner
959 .command
960 .get_subcommands()
961 .find(|subcommand| subcommand.get_name() == "tui")
962 .expect("tui subcommand should still exist");
963
964 assert!(hidden.is_hide_set());
965 }
966
967 #[test]
968 fn tui_run_returns_none_on_cancel() {
969 #[derive(Debug, clap::Parser, PartialEq, Eq)]
970 #[command(name = "tool", version = "1.2.3")]
971 struct Cli;
972
973 let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
974 AppKeyCode::Char('c'),
975 AppKeyModifiers {
976 control: true,
977 ..AppKeyModifiers::default()
978 },
979 ))]);
980
981 let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
982
983 assert_eq!(result.expect("cancel should map to None"), None);
984 }
985
986 #[test]
987 fn tui_run_with_argv_returns_none_on_cancel() {
988 #[derive(Debug, clap::Parser, PartialEq, Eq)]
989 #[command(name = "tool")]
990 struct Cli;
991
992 let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
993 AppKeyCode::Char('c'),
994 AppKeyModifiers {
995 control: true,
996 ..AppKeyModifiers::default()
997 },
998 ))]);
999
1000 let result = super::Tui::<Cli, _>::new()
1001 .with_runtime(runtime)
1002 .run_with_argv();
1003
1004 assert_eq!(result.expect("cancel should map to None"), None);
1005 }
1006
1007 #[test]
1008 fn tui_run_returns_clap_display_errors_without_printing() {
1009 #[derive(Debug, clap::Parser, PartialEq, Eq)]
1010 #[command(name = "tool", version = "1.2.3")]
1011 struct Cli;
1012
1013 let error = super::parse_result(Cli::try_parse_from(os_vec(&["tool", "--version"])))
1014 .expect_err("version display should be returned");
1015 assert!(
1016 matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
1017 );
1018 }
1019
1020 #[test]
1021 fn tui_run_with_argv_reparses_selected_command_after_hiding_entrypoint() {
1022 #[derive(Debug, clap::Parser, PartialEq, Eq)]
1023 #[command(name = "tool")]
1024 enum Cli {
1025 Tui,
1026 Hello {
1027 #[arg(long, default_value = "world")]
1028 name: String,
1029 },
1030 }
1031
1032 let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
1033 AppKeyCode::Char('r'),
1034 AppKeyModifiers {
1035 control: true,
1036 ..AppKeyModifiers::default()
1037 },
1038 ))]);
1039
1040 let config = TuiConfig {
1041 start_command: Some("hello".to_string()),
1042 ..TuiConfig::default()
1043 };
1044
1045 let result = super::Tui::<Cli, _>::new()
1046 .hide_entrypoint("tui")
1047 .expect("entrypoint should exist")
1048 .with_config(config)
1049 .with_runtime(runtime)
1050 .run_with_argv();
1051
1052 let invocation = result
1053 .expect("typed run should succeed")
1054 .expect("run should produce an invocation");
1055 assert_eq!(
1056 invocation.command,
1057 Cli::Hello {
1058 name: "world".to_string()
1059 }
1060 );
1061 assert_eq!(invocation.argv, os_vec(&["tool", "hello"]));
1062 }
1063
1064 #[test]
1065 fn tui_run_with_argv_propagates_runtime_failures() {
1066 #[derive(Debug, clap::Parser, PartialEq, Eq)]
1067 #[command(name = "tool")]
1068 struct Cli;
1069
1070 #[derive(Debug)]
1071 struct FailingRuntime;
1072
1073 impl Runtime for FailingRuntime {
1074 type Backend = TestBackend;
1075
1076 fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
1077 Err(std::io::Error::other("boom").into())
1078 }
1079
1080 fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
1081
1082 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
1083 unreachable!("terminal initialization should fail first")
1084 }
1085
1086 fn read_event(&mut self) -> Result<AppEvent, TuiError> {
1087 unreachable!("terminal initialization should fail first")
1088 }
1089
1090 fn copy_to_clipboard(&mut self, _text: &str) -> Result<(), String> {
1091 unreachable!("terminal initialization should fail first")
1092 }
1093 }
1094
1095 let error = super::Tui::<Cli, _>::new()
1096 .with_runtime(FailingRuntime)
1097 .run_with_argv()
1098 .expect_err("runtime failure should propagate");
1099
1100 assert!(matches!(error, TuiError::Terminal(_)));
1101 }
1102
1103 #[test]
1104 fn help_style_invalid_run_toast_does_not_show_about_text() {
1105 let command = Command::new("tool")
1106 .about("Run the selected tool")
1107 .arg_required_else_help(true)
1108 .arg(Arg::new("path").required(true));
1109 let mut runtime = TestRuntime::with_events([]);
1110 let mut session = terminal_session(&mut runtime);
1111 let mut state = app_state_from_command(&command);
1112
1113 let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
1114
1115 assert!(matches!(outcome, ActionOutcome::Continue));
1116 let toast = state.notifications.toast.as_ref().expect("toast");
1117 assert!(toast.is_error);
1118 assert_eq!(toast.message, "Missing required argument: path");
1119 assert!(!toast.message.contains("Run the selected tool"));
1120 }
1121
1122 #[test]
1123 fn resize_event_requests_redraw() {
1124 let mut runtime = TestRuntime::with_events([]);
1125 let mut session = terminal_session(&mut runtime);
1126 let mut state = app_state();
1127
1128 let outcome = handle_app_event(
1129 &AppEvent::Resize {
1130 width: 120,
1131 height: 40,
1132 },
1133 &mut state,
1134 &FrameSnapshot::default(),
1135 &TuiConfig::default(),
1136 &mut session,
1137 );
1138
1139 assert!(matches!(
1140 outcome,
1141 EventOutcome::Continue { needs_redraw: true }
1142 ));
1143 }
1144
1145 #[test]
1146 fn paste_event_updates_focused_form_field() {
1147 let command = Command::new("tool").arg(Arg::new("path").long("path"));
1148 let mut runtime = TestRuntime::with_events([]);
1149 let mut session = terminal_session(&mut runtime);
1150 let mut state = app_state_from_command(&command);
1151 state.ui.focus_form();
1152
1153 let outcome = handle_app_event(
1154 &AppEvent::Paste("/tmp/foo".to_string()),
1155 &mut state,
1156 &FrameSnapshot::default(),
1157 &TuiConfig::default(),
1158 &mut session,
1159 );
1160
1161 assert!(matches!(
1162 outcome,
1163 EventOutcome::Continue { needs_redraw: true }
1164 ));
1165 let form = state.domain.current_form().expect("form");
1166 let arg = state.domain.arg_for_input("path").expect("path arg");
1167 assert_eq!(
1168 form.compatibility_value(arg),
1169 Some(crate::input::ArgValue::Text("/tmp/foo".to_string()))
1170 );
1171 let derived = crate::pipeline::derive(&state);
1172 assert_eq!(
1173 derived.authoritative_argv,
1174 vec![
1175 "tool".to_string(),
1176 "--path".to_string(),
1177 "/tmp/foo".to_string(),
1178 ]
1179 );
1180 assert!(derived.validation.is_valid);
1181 }
1182
1183 #[test]
1184 fn paste_event_updates_search_query_when_search_is_focused() {
1185 let mut runtime = TestRuntime::with_events([]);
1186 let mut session = terminal_session(&mut runtime);
1187 let mut state = app_state();
1188 state.ui.focus_search();
1189
1190 let outcome = handle_app_event(
1191 &AppEvent::Paste("build".to_string()),
1192 &mut state,
1193 &FrameSnapshot::default(),
1194 &TuiConfig::default(),
1195 &mut session,
1196 );
1197
1198 assert!(matches!(
1199 outcome,
1200 EventOutcome::Continue { needs_redraw: true }
1201 ));
1202 assert_eq!(state.ui.search_query, "build");
1203 }
1204
1205 #[test]
1206 fn toast_timeout_behavior_is_unchanged() {
1207 let mut state = app_state();
1208 state.notifications.show_toast(
1209 "Copied command to clipboard",
1210 Duration::from_millis(250),
1211 false,
1212 );
1213
1214 let timeout = redraw_timeout(&state);
1215
1216 assert!(timeout > Duration::ZERO);
1217 assert!(timeout <= Duration::from_millis(250));
1218 }
1219
1220 #[test]
1221 fn expired_toast_clears_during_continuous_key_input() {
1222 let mut runtime = TestRuntime::with_events([]);
1223 let mut session = terminal_session(&mut runtime);
1224 let mut state = app_state();
1225 state.notifications.toast = Some(Toast {
1226 message: "Copied command to clipboard".to_string(),
1227 expires_at: Instant::now()
1228 .checked_sub(Duration::from_millis(1))
1229 .expect("duration should be representable"),
1230 is_error: false,
1231 });
1232
1233 let outcome = handle_app_event(
1234 &AppEvent::Key(AppKeyEvent::new(
1235 AppKeyCode::Char('x'),
1236 AppKeyModifiers::default(),
1237 )),
1238 &mut state,
1239 &FrameSnapshot::default(),
1240 &TuiConfig::default(),
1241 &mut session,
1242 );
1243
1244 assert!(matches!(
1245 outcome,
1246 EventOutcome::Continue { needs_redraw: true }
1247 ));
1248 assert!(state.notifications.toast.is_none());
1249 }
1250}