clawless_tui/runner.rs
1//! TUI application runner
2//!
3//! This module defines [`ApplicationRunner`], which encapsulates the full lifecycle of a TUI
4//! application: event channel creation, context construction, projection setup, signal handling, and
5//! application execution. The `main!()` macro dispatches to `ApplicationRunner::run` when the
6//! resolved leaf is an application.
7//!
8//! Application authors do not interact with this module directly. The `main!()` macro calls
9//! [`ApplicationRunner::run`] with the resolved matches and the leaf's exec function. The runner
10//! takes any callable, so a caller that builds its command tree at run time can dispatch to a
11//! closure that owns the application it resolved.
12
13use std::future::Future;
14
15use clawless_core::cancellation::Cancellation;
16use clawless_core::context::Context;
17use clawless_core::event::event_channel;
18use clawless_core::output::Output;
19use clawless_core::signal::wait_for_shutdown;
20
21use crate::projection::Projection;
22
23/// TUI application runner
24///
25/// Encapsulates the lifecycle of running a TUI application: creating the event channel,
26/// constructing the [`Context`], building a [`Projection`] inside a Tokio runtime, registering
27/// signal handlers, and calling the application function. Unlike `CommandRunner`, there is no
28/// presenter — the application owns its own render loop and queries the projection for accumulated
29/// state.
30///
31/// # Examples
32///
33/// ```rust,ignore
34/// // This is what main!() expands to for applications:
35/// ResolvedLeaf::Application { matches, exec } => {
36/// clawless::tui::runner::ApplicationRunner::run(matches, exec)
37/// }
38/// ```
39///
40/// [`Context`]: clawless_core::context::Context
41/// [`Projection`]: crate::projection::Projection
42// r[impl dispatch.exec.application-runner]
43#[derive(Debug)]
44pub struct ApplicationRunner;
45
46impl ApplicationRunner {
47 /// Runs a TUI application to completion
48 ///
49 /// Sets up the application lifecycle:
50 ///
51 /// 1. Creates a [`Cancellation`] token for cooperative shutdown
52 /// 2. Creates an event channel and builds the [`Context`]
53 /// 3. Creates a Tokio runtime
54 /// 4. Inside the runtime, creates a [`Projection`] (which spawns a background drain task)
55 /// 5. Spawns the signal handler and calls the application function
56 ///
57 /// The [`Projection`] must be created inside the Tokio runtime because its constructor calls
58 /// `tokio::spawn` to start the background event drain.
59 ///
60 /// # Arguments
61 ///
62 /// * `matches` — The parsed [`ArgMatches`] for this application leaf, as resolved by the
63 /// subcommand tree walk.
64 /// * `exec` — Executes the application with the given matches, context, and projection. The
65 /// `#[application]` macro generates a function for this, and any other callable works. A
66 /// caller that builds its command tree at run time passes a closure that owns the
67 /// application it resolved.
68 ///
69 /// # Errors
70 ///
71 /// Returns an error if context construction fails (e.g., the current working directory cannot be
72 /// determined), if the Tokio runtime cannot be created, or if the application itself fails.
73 ///
74 /// [`ArgMatches`]: clap::ArgMatches
75 /// [`Cancellation`]: clawless_core::cancellation::Cancellation
76 /// [`Context`]: clawless_core::context::Context
77 /// [`Projection`]: crate::projection::Projection
78 // r[impl dispatch.exec.callable]
79 pub fn run<E, F>(matches: clap::ArgMatches, exec: E) -> Result<(), Box<dyn std::error::Error>>
80 where
81 E: FnOnce(clap::ArgMatches, Context, Projection) -> F,
82 F: Future<Output = anyhow::Result<()>> + Send + 'static,
83 {
84 let cancellation = Cancellation::new();
85 let (sender, receiver) = event_channel();
86 let output = Output::new(sender);
87
88 let context = Context::builder()
89 .cancellation(cancellation.clone())
90 .output(output)
91 .build()?;
92
93 let rt = tokio::runtime::Runtime::new()?;
94 rt.block_on(async {
95 let projection = Projection::new(receiver);
96
97 tokio::spawn(wait_for_shutdown(cancellation));
98 exec(matches, context, projection).await
99 })?;
100
101 Ok(())
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 // An assertion in a test panics by design. A `# Panics` section on every test
108 // would repeat that and give the reader no information.
109 #![allow(clippy::missing_panics_doc)]
110
111 use std::sync::Arc;
112 use std::sync::atomic::{AtomicBool, Ordering};
113
114 use super::*;
115
116 // r[verify dispatch.exec.callable]
117 #[test]
118 fn run_with_a_closure_that_owns_state_executes_the_leaf() {
119 let matches = clap::Command::new("test").get_matches_from(["test"]);
120 let executed = Arc::new(AtomicBool::new(false));
121 let owned = Arc::clone(&executed);
122
123 ApplicationRunner::run(matches, move |_matches, _context, _projection| async move {
124 owned.store(true, Ordering::SeqCst);
125 Ok(())
126 })
127 .expect("the runner runs the leaf to completion");
128
129 assert!(executed.load(Ordering::SeqCst));
130 }
131
132 #[test]
133 fn trait_send() {
134 fn assert_send<T: Send>() {}
135 assert_send::<ApplicationRunner>();
136 }
137
138 #[test]
139 fn trait_sync() {
140 fn assert_sync<T: Sync>() {}
141 assert_sync::<ApplicationRunner>();
142 }
143
144 #[test]
145 fn trait_unpin() {
146 fn assert_unpin<T: Unpin>() {}
147 assert_unpin::<ApplicationRunner>();
148 }
149}