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