tuify your clap CLI apps and make them more interactive
- Introduction
- CLI design concepts
- Show me
- The r3bl_tuify crate and clap
- Next steps
- Build with Naz video series on developerlife.com YouTube channel
Introduction #
As developers we tend to spend a lot of time in the terminal. It is a great place to exercise precise control over our computers. And it is a great place to automate tasks. However, there are some rough edges to this experience. For example, even though the interaction metaphor w/ CLI apps is a conversation, we have to be very precise in the language we use in this conversation. Lots of trial and error, patience and resilience are required to make it work. And it does not have to be this way.
To use a racing analogy, terminals are like race cars. They are fast and powerful, and you can exercise direct and precise control over them. But if you get things wrong, there can be consequences. Porsche is a car company that wins endurance races, and a very long time ago, they decided to make a race car that was friendly to the ergonomics of their drivers.
The thinking was that if the driver is comfortable, they will perform better, and the 24 hour race
wins will be just a little bit closer within reach. Similarly, we can add some interactivity to our
CLI apps to make them more ergonomic to use. And we can do this without going full TUI. We can do
this in a way that is additive to the existing CLI experience. We can “tuify” our CLI apps that are
built using clap
.
For more information on general Rust type system design (functional approach rather than object oriented), please take a look at this paper by Will Crichton demonstrating Typed Design Patterns with Rust.
CLI design concepts #
Here are some great resources to learn more about good CLI design concepts. The Rust crate clap
is
used by a lot of Rust apps to implement this CLI. And in this tutorial we will take a look at how to
add some interactivity to these clap
CLI apps using the r3bl_tuify
crate.
Note that these resources are all about CLI and not TUI. There isn’t very much information out there about TUIs. It is a new and evolving space.
- Command Line Interface Guidelines
clap
docsclap
command and subcommand structure guidelines- Hierarchy of configuration
The CLI guidelines above do a great job of explaining how to create a good CLI experience. However they do not cover how to add interactivity to your CLI apps. Why would we want to do this? Let’s take a real example to illustrate the benefits of this next.
Show me #
This example is a little “meta”. The r3bl_tuify
crate, that allows interactivity to be added to
clap
CLI apps, is available as a binary and library. The binary which can be used from the command
line (and uses clap
) uses the library to provide an interactive experience when certain arguments
aren’t provided on the command line.
The idea with the binary target is that you might want to quickly incorporate some interactivity
into your shell scripts without getting into the Rust library. In this case, you can use the rt
binary target to do that. This binary takes quite a few arguments as you might imagine. However, you
don’t have to supply all of them at the start.
So instead of typing this massive command at the start (where cargo run --
simply runs the binary
called rt
):
cat TODO.todo | cargo run -- select-from-list \
--selection-mode single \
--command-to-run-with-each-selection "echo %"
You can simply type the following shorter command and have the app prompt you for the rest of the information that it needs:
cat TODO.todo | cargo run -- select-from-list
Here’s a video of this in action, where the app is prompting the user for two items:
- the
selection-mode
and command-to-run-with-each-selection
interactively 🎉:
The r3bl_tuify crate and clap #
The r3bl_tuify
app itself uses clap
to parse the command line arguments. Here’s an overview of
what that looks like (all of it using the derive
macro approach). Here’s a link to the
main.rs::AppArgs
.
#[derive(Debug, Parser)]
#[command(bin_name = "rt")]
#[command(about = "Easily add lightweight TUI capabilities to any CLI apps using pipes", long_about = None)]
#[command(version)]
#[command(next_line_help = true)]
#[command(arg_required_else_help(true))]
pub struct AppArgs {
#[clap(subcommand)]
command: CLICommand,
#[clap(flatten)]
global_opts: GlobalOpts,
}
#[derive(Debug, Args)]
struct GlobalOpts {
/// Print debug output to log file (log.txt)
#[arg(long, short = 'l')]
enable_logging: bool,
/// Optional maximum height of the TUI (rows)
#[arg(value_name = "height", long, short = 'r')]
tui_height: Option<usize>,
/// Optional maximum width of the TUI (columns)
#[arg(value_name = "width", long, short = 'c')]
tui_width: Option<usize>,
}
#[derive(Debug, Subcommand)]
enum CLICommand {
/// Show TUI to allow you to select one or more options from a list, piped in via stdin 👉
SelectFromList {
/// Would you like to select one or more items?
#[arg(value_name = "mode", long, short = 's')]
selection_mode: Option<SelectionMode>,
/// Each selected item is passed to this command as `%` and executed in your shell.
/// For eg: "echo %". Please wrap the command in quotes 💡
#[arg(value_name = "command", long, short = 'c')]
command_to_run_with_each_selection: Option<String>,
},
}
The things to note are that the selection_mode
and command_to_run_with_each_selection
fields of
the CliCommand::SelectFromList
enum are optional. This is where the r3bl_tuify
crate comes in.
It will prompt the user for these two fields if they are not supplied on the command line.
You can add this programmatically using the library to your existing CLI apps.
The piping option using the binary is severely limited, so the library option is strongly recommended. The binary is more of a convenience for shell scripts only on Linux.
You can see how to use the library to perform this interactivity in
main.rs::show_tui()
.
Here are two examples of adding interactivity.
Example 1: Add interactivity using a list selection component #
Here’s an example of adding interactivity using a list selection component. This is useful
when the values that a field can take are known in advance. In this example, they are
since selection-mode
is a clap
EnumValue
that can only take one of the following
values: single
, or multiple
.
In this scenario, --selection-mode
is not passed in the command line. So it only
interactively prompts the user for this piece of information. Similarly, if the user does
not provide this information, the app exits and provides a help message.
cat TODO.todo | cargo run -- select-from-list --command-to-run-with-each-selection "echo %"
// Handle `selection-mode` is not passed in.
let selection_mode = if let Some(selection_mode) = maybe_selection_mode {
selection_mode
} else {
let possible_values_for_selection_mode =
get_possible_values_for_subcommand_and_option(
"select-from-list",
"selection-mode",
);
print_help_for_subcommand_and_option("select-from-list", "selection-mode").ok();
let user_selection = select_from_list(
"Choose selection-mode".to_string(),
possible_values_for_selection_mode,
max_height_row_count,
max_width_col_count,
SelectionMode::Single,
);
let it = if let Some(user_selection) = user_selection {
if let Some(it) = user_selection.first() {
println!("selection-mode: {}", it);
SelectionMode::from_str(it, true).unwrap_or(SelectionMode::Single)
} else {
print_help_for("select-from-list").ok();
return;
}
} else {
print_help_for("select-from-list").ok();
return;
};
it
};
Example 2: Adding interactivity using a text input field #
Here’s an example of adding interactivity using a text input field. This is useful when the values
that a field can take are not known in advance. The r3bl_tuify
crate uses the reedline
crate to
do this.
Fun fact:
reedline
is the text input field (line editor) that is used innushell
.
In this scenario, --command-to-run-with-each-selection
is not passed in the command
line. So it only interactively prompts the user for this piece of information. Similarly,
if the user does not provide this information, the app exits and provides a help message.
cat TODO.todo | cargo run -- select-from-list --selection-mode single
// Handle `command-to-run-with-each-selection` is not passed in.
let command_to_run_with_each_selection =
match maybe_command_to_run_with_each_selection {
Some(it) => it,
None => {
print_help_for_subcommand_and_option(
"select-from-list",
"command-to-run-with-each-selection",
)
.ok();
let mut line_editor = Reedline::create();
let prompt = DefaultPrompt {
left_prompt: DefaultPromptSegment::Basic(
"Enter command to run w/ each selection `%`: ".to_string(),
),
right_prompt: DefaultPromptSegment::Empty,
};
let sig = line_editor.read_line(&prompt);
match sig {
Ok(Signal::Success(buffer)) => {
if buffer.is_empty() {
print_help_for("select-from-list").ok();
return;
}
println!("Command to run w/ each selection: {}", buffer);
buffer
}
_ => {
print_help_for("select-from-list").ok();
return;
}
}
}
};
// Actually get input from the user.
let selected_items = {
let it = select_from_list(
"Select one line".to_string(),
lines,
max_height_row_count,
max_width_col_count,
selection_mode,
);
convert_user_input_into_vec_of_strings(it)
};
Next steps #
There are many more components that need to be added to make it easier to “tuify” lots of existing CLI experiences. Things like multi line editor component w/ syntax highlighting, scroll view, form input fields, and more. If you would like to contribute to this effort, it would be great to have your help.
Build with Naz video series on developerlife.com YouTube channel #
If you have comments and feedback on this content, or would like to request new content (articles & videos) on developerlife.com, please join our discord server.
You can watch a video series on building this crate with Naz on the developerlife.com YouTube channel.
- YT channel
- Playlists
👀 Watch Rust 🦀 live coding videos on our YouTube Channel.
📦 Install our useful Rust command line apps usingcargo install r3bl-cmdr
(they are from the r3bl-open-core project):
- 🐱
giti
: run interactive git commands with confidence in your terminal- 🦜
edi
: edit Markdown with style in your terminalgiti in action
edi in action