-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add dialog with variable buttons #54
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if we need a value property on the DialogButton or if the label is good enough