Skip to content

Commit

Permalink
feat: add dialog with variable buttons
Browse files Browse the repository at this point in the history
  • Loading branch information
roele committed May 15, 2024
1 parent d7e3246 commit 4af061e
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 0 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ fn main() {
}
```

## Dialog

Show a dialog with multiple buttons.
Run example with [`cargo run --example dialog`](./examples/dialog.rs).

![Dialog](./assets/dialog.gif)

```rust
use demand::{Dialog, DialogButton};

fn main() {
let ms = Dialog::new("Are you sure?")
.description("This will do a thing.")
.buttons(vec![
DialogButton::new("Ok"),
DialogButton::new("Not sure"),
DialogButton::new("Cancel"),
])
.selected_button(1);
ms.run().expect("error running confirm");
}
```

## Spinner

Spinners are used to indicate that a process is running.
Expand Down
Binary file added assets/dialog.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 77 additions & 0 deletions assets/dialog.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# VHS documentation
#
# Output:
# Output <path>.gif Create a GIF output at the given <path>
# Output <path>.mp4 Create an MP4 output at the given <path>
# Output <path>.webm Create a WebM output at the given <path>
#
# Require:
# Require <string> Ensure a program is on the $PATH to proceed
#
# Settings:
# Set FontSize <number> Set the font size of the terminal
# Set FontFamily <string> Set the font family of the terminal
# Set Height <number> Set the height of the terminal
# Set Width <number> Set the width of the terminal
# Set LetterSpacing <float> Set the font letter spacing (tracking)
# Set LineHeight <float> Set the font line height
# Set LoopOffset <float>% Set the starting frame offset for the GIF loop
# Set Theme <json|string> Set the theme of the terminal
# Set Padding <number> Set the padding of the terminal
# Set Framerate <number> Set the framerate of the recording
# Set PlaybackSpeed <float> Set the playback speed of the recording
# Set MarginFill <file|#000000> Set the file or color the margin will be filled with.
# Set Margin <number> Set the size of the margin. Has no effect if MarginFill isn't set.
# Set BorderRadius <number> Set terminal border radius, in pixels.
# Set WindowBar <string> Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight)
# Set WindowBarSize <number> Set window bar size, in pixels. Default is 40.
# Set TypingSpeed <time> Set the typing speed of the terminal. Default is 50ms.
#
# Sleep:
# Sleep <time> Sleep for a set amount of <time> in seconds
#
# Type:
# Type[@<time>] "<characters>" Type <characters> into the terminal with a
# <time> delay between each character
#
# Keys:
# Escape[@<time>] [number] Press the Escape key
# Backspace[@<time>] [number] Press the Backspace key
# Delete[@<time>] [number] Press the Delete key
# Insert[@<time>] [number] Press the Insert key
# Down[@<time>] [number] Press the Down key
# Enter[@<time>] [number] Press the Enter key
# Space[@<time>] [number] Press the Space key
# Tab[@<time>] [number] Press the Tab key
# Left[@<time>] [number] Press the Left Arrow key
# Right[@<time>] [number] Press the Right Arrow key
# Up[@<time>] [number] Press the Up Arrow key
# Down[@<time>] [number] Press the Down Arrow key
# PageUp[@<time>] [number] Press the Page Up key
# PageDown[@<time>] [number] Press the Page Down key
# Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C)
#
# Display:
# Hide Hide the subsequent commands from the output
# Show Show the subsequent commands in the output

Output assets/dialog.gif

Set Shell "fish"
Set Padding 10
Set FontSize 16
Set Width 800
Set Height 300
Set TypingSpeed 100ms

Hide
Type "cargo build --example dialog && clear" Enter
Sleep 2s
Show

Type "target/debug/examples/dialog" Enter
Sleep 2s
Right Sleep 1
Left Sleep 1
Enter
Sleep 2s
13 changes: 13 additions & 0 deletions examples/dialog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use demand::{Dialog, DialogButton};

fn main() {
let ms = Dialog::new("Are you sure?")
.description("This will do a thing.")
.buttons(vec![
DialogButton::new("Ok"),
DialogButton::new("Not sure"),
DialogButton::new("Cancel"),
])
.selected_button(1);
ms.run().expect("error running confirm");
}
275 changes: 275 additions & 0 deletions src/dialog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
use std::io;
use std::io::Write;

use console::{Key, Term};
use termcolor::{Buffer, WriteColor};

use crate::theme;
use crate::theme::Theme;

#[derive(Clone, Debug, Default, PartialEq)]
/// A button to select in a dialog
pub struct DialogButton {
/// The text to display for the option
pub label: String,
/// The key to press to select the option
pub key: char,
}

impl DialogButton {
/// Create a new button with the given label.
/// The key will be the first character of the label, lowercased.
pub fn new(label: &str) -> Self {
let label = label.to_string();
let key = label.to_lowercase().chars().next().unwrap();
Self { label, key }
}
/// Create a new button with the given label and key.
pub fn with_key(label: &str, key: char) -> Self {
let label = label.to_string();
Self { label, key }
}
}

/// A dialog to display to the user
///
/// # Example
/// ```rust
/// use demand::Dialog;
/// use demand::DialogButton;
///
/// let dialog = Dialog::new("Are you sure?")
/// .description("This will do a thing.")
/// .buttons(vec![
/// DialogButton::new("Ok"),
/// DialogButton::new("Not sure"),
/// DialogButton::new("Cancel"),
/// ]);
/// let result = dialog.run().expect("error running confirm");
/// ```
pub struct Dialog<'a> {
/// The title of the selector
pub title: String,
/// The colors/style of the selector
pub theme: &'a Theme,
/// A description to display above the selector
pub description: String,
/// The buttons to display to the user
pub buttons: Vec<DialogButton>,

term: Term,
height: usize,
selected_button_idx: usize,
}

impl<'a> Dialog<'a> {
/// Create a new dialog with the given title
///
/// By default, the dialog will have a single "Ok" button and a "Cancel" button.
pub fn new<S: Into<String>>(title: S) -> Self {
Self {
title: title.into(),
description: String::new(),
theme: &*theme::DEFAULT,
term: Term::stderr(),
buttons: vec![DialogButton::new("Ok"), DialogButton::new("Cancel")],
height: 0,
selected_button_idx: 0,
}
}

/// Set the description of the dialog
pub fn description(mut self, description: &str) -> Self {
self.description = description.to_string();
self
}

/// Set the buttons of the dialog
pub fn buttons(mut self, buttons: Vec<DialogButton>) -> Self {
self.buttons = buttons;
self
}

/// Set the index of the initially selected button.
///
/// The `idx` is the index of the button in the `buttons` vector and is 0-indexed.
///
/// # Errors
///
/// This will panic if there are no buttons to select or if the index is out of bounds.
pub fn selected_button(mut self, idx: usize) -> Self {
if self.buttons.is_empty() {
panic!("No buttons to select");
}
if idx >= self.buttons.len() {
panic!("Selected button index out of bounds");
}
self.selected_button_idx = idx;
self
}

/// Set the theme of the dialog
pub fn theme(mut self, theme: &'a Theme) -> Self {
self.theme = theme;
self
}

/// Displays the dialog to the user and returns their response.
///
/// The response will be the label of the selected button.
///
/// This will block until the user selects a button or presses one of the submit keys.
pub fn run(mut self) -> io::Result<String> {
loop {
self.clear()?;
let output = self.render()?;
self.height = output.lines().count() - 1;
self.term.write_all(output.as_bytes())?;
self.term.flush()?;
match self.term.read_key()? {
Key::ArrowLeft | Key::Char('h') => self.handle_left(),
Key::ArrowRight | Key::Char('l') => self.handle_right(),
Key::Char(c) if self.buttons.iter().any(|b| b.key == c) => {
self.selected_button_idx =
self.buttons.iter().position(|b| b.key == c).unwrap();
return self.handle_submit();
}
Key::Enter => {
return self.handle_submit();
}
_ => {}
}
}
}

fn handle_submit(mut self) -> io::Result<String> {
self.clear()?;
let output = self.render_success()?;
self.term.write_all(output.as_bytes())?;
let result = if !self.buttons.is_empty() {
self.buttons[self.selected_button_idx].label.clone()
} else {
"".to_string()
};
Ok(result)
}

fn handle_left(&mut self) {
self.selected_button_idx =
(self.selected_button_idx + self.buttons.len() - 1) % self.buttons.len();
}

fn handle_right(&mut self) {
self.selected_button_idx = (self.selected_button_idx + 1) % self.buttons.len();
}

fn render(&self) -> io::Result<String> {
let mut out = Buffer::ansi();

out.set_color(&self.theme.title)?;
writeln!(out, " {}", self.title)?;

if !self.description.is_empty() {
out.set_color(&self.theme.description)?;
write!(out, " {}", self.description)?;
}

writeln!(out, "\n")?;

for (i, button) in self.buttons.iter().enumerate() {
write!(out, " ")?;
if self.selected_button_idx == i {
out.set_color(&self.theme.focused_button)?;
} else {
out.set_color(&self.theme.blurred_button)?;
}
write!(out, " {} ", button.label)?;
out.reset()?;
}

writeln!(out, "\n")?;

let mut help_keys = vec![("←/→", "toggle")];
let button_keys = self
.buttons
.clone()
.iter()
.fold(String::new(), |mut output, button| {
output.push_str(button.key.to_string().as_str());
output.push('/');
output
});
let submit_keys = format!("{}enter", button_keys);
help_keys.push((&submit_keys, "submit"));
for (i, (key, desc)) in help_keys.iter().enumerate() {
if i > 0 {
out.set_color(&self.theme.help_sep)?;
write!(out, " • ")?;
}
out.set_color(&self.theme.help_key)?;
write!(out, "{}", key)?;
out.set_color(&self.theme.help_desc)?;
write!(out, " {}", desc)?;
}
writeln!(out)?;

out.reset()?;
Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string())
}

fn render_success(&self) -> io::Result<String> {
let mut out = Buffer::ansi();
out.set_color(&self.theme.title)?;
write!(out, " {}", self.title)?;
out.set_color(&self.theme.selected_option)?;
writeln!(
out,
" {}",
if !self.buttons.is_empty() {
self.buttons[self.selected_button_idx].label.clone()
} else {
"".to_string()
}
)?;
out.reset()?;
Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string())
}

fn clear(&mut self) -> io::Result<()> {
self.term.clear_to_end_of_screen()?;
self.term.clear_last_lines(self.height)?;
self.height = 0;
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test::without_ansi;
use indoc::indoc;

#[test]
fn test_render() {
let dialog = Dialog::new("Are you sure?")
.description("This will do a thing.")
.buttons(vec![
DialogButton::new("Ok"),
DialogButton::new("Not sure"),
DialogButton::new("Cancel"),
]);

assert_eq!(
indoc! {
" Are you sure?
This will do a thing.
Ok Not sure Cancel
←/→ toggle • o/n/c/enter submit
"
},
without_ansi(dialog.render().unwrap().as_str())
);
}
}
Loading

0 comments on commit 4af061e

Please sign in to comment.