diff --git a/examples/spinner-prompts.rs b/examples/spinner-prompts.rs new file mode 100644 index 0000000..b78b1c8 --- /dev/null +++ b/examples/spinner-prompts.rs @@ -0,0 +1,44 @@ +use demand::{Confirm, DemandOption, Input, MultiSelect, Select, Spinner, Theme}; + +fn main() { + let spinner = Spinner::new("im out here"); + spinner + .run(|| { + Confirm::new("confirm") + .description("it says confirm") + .run() + .unwrap(); + Input::new("input ") + .description("go on say something") + .suggestions(vec!["hello there"]) + .validation(|s| match !s.contains('j') { + true => Ok(()), + false => Err("ew stinky 'j' not welcome here"), + }) + .theme(&Theme::catppuccin()) + .placeholder("Words go here") + .run() + .unwrap(); + Select::new("select") + .description("hi") + .options(vec![ + DemandOption::new("hi"), + DemandOption::new("hello"), + DemandOption::new("how are you"), + ]) + .run() + .unwrap(); + MultiSelect::new("more select") + .description("hewo") + .options(vec![ + DemandOption::new("hi"), + DemandOption::new("hello"), + DemandOption::new("how are you"), + ]) + .run() + .unwrap(); + // Spinner::new("spinnerception") + // .run(|| std::thread::sleep(std::time::Duration::from_secs(1))) + }) + .unwrap(); +} diff --git a/src/confirm.rs b/src/confirm.rs index 805b330..6aa4389 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -173,6 +173,7 @@ impl<'a> Confirm<'a> { 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()) @@ -220,7 +221,8 @@ mod tests { Yes! No. - ←/→ toggle • y/n/enter submit" + ←/→ toggle • y/n/enter submit + " }, without_ansi(confirm.render().unwrap().as_str()) ); diff --git a/src/input.rs b/src/input.rs index b300bf0..f5bf96e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -110,7 +110,7 @@ impl<'a> Input<'a> { self } - // Sets the suggestions of the input + /// Sets the suggestions of the input pub fn suggestions(mut self, suggestions: Vec<&'static str>) -> Self { self.suggestions = suggestions; self @@ -140,7 +140,7 @@ impl<'a> Input<'a> { /// Displays the input to the user and returns the response pub fn run(mut self) -> io::Result<String> { - self.term.show_cursor()?; + self.term.hide_cursor()?; loop { self.clear()?; let output = self.render()?; @@ -297,19 +297,7 @@ impl<'a> Input<'a> { } out.reset()?; - if !self.placeholder.is_empty() && self.input.is_empty() { - out.set_color(&self.theme.input_placeholder)?; - write!(out, "{}", &self.placeholder)?; - out.reset()?; - } - - write!(out, "{}", &self.render_input()?)?; - - if self.suggestion.is_some() { - out.set_color(&self.theme.input_placeholder)?; - write!(out, "{}", self.suggestion.as_ref().unwrap())?; - out.reset()?; - } + self.render_input(&mut out)?; if self.err.is_some() { out.set_color(&self.theme.error_indicator)?; @@ -319,14 +307,75 @@ impl<'a> Input<'a> { out.reset()?; } + writeln!(out)?; + out.reset()?; + Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string()) } - fn render_input(&mut self) -> io::Result<String> { + fn render_input(&mut self, out: &mut Buffer) -> io::Result<String> { let input = match self.password { true => self.input.chars().map(|_| '*').collect::<String>(), false => self.input.to_string(), }; + + if !self.placeholder.is_empty() && self.input.is_empty() { + out.set_color( + &self + .theme + .real_cursor_color(Some(&self.theme.input_placeholder)), + )?; + write!(out, "{}", &self.placeholder[..1])?; + if self.placeholder.len() > 1 { + out.set_color(&self.theme.input_placeholder)?; + write!(out, "{}", &self.placeholder[1..])?; + out.reset()?; + } + return Ok(input); + } + + let cursor_idx = self.get_char_idx(&input, self.cursor); + write!(out, "{}", &input[..cursor_idx])?; + + if cursor_idx < input.len() { + out.set_color(&self.theme.real_cursor_color(None))?; + write!(out, "{}", &input[cursor_idx..cursor_idx + 1])?; + out.reset()?; + } + if cursor_idx + 1 < input.len() { + out.reset()?; + write!(out, "{}", &input[cursor_idx + 1..])?; + } + + if let Some(suggestion) = &self.suggestion { + if !suggestion.is_empty() { + if cursor_idx >= input.len() { + out.set_color( + &self + .theme + .real_cursor_color(Some(&self.theme.input_placeholder)), + )?; + write!(out, "{}", &suggestion[..1])?; + if suggestion.len() > 1 { + out.set_color(&self.theme.input_placeholder)?; + write!(out, "{}", &suggestion[1..])?; + } + } else { + out.set_color(&self.theme.input_placeholder)?; + write!(out, "{suggestion}")?; + } + out.reset()?; + } else if cursor_idx >= input.len() { + out.set_color(&self.theme.real_cursor_color(None))?; + write!(out, " ")?; + out.reset()?; + } + } else if cursor_idx >= input.len() { + out.set_color(&self.theme.real_cursor_color(None))?; + write!(out, " ")?; + out.reset()?; + } + Ok(input) } @@ -335,7 +384,7 @@ impl<'a> Input<'a> { out.set_color(&self.theme.title)?; write!(out, " {}", self.title)?; out.set_color(&self.theme.selected_option)?; - writeln!(out, " {}", &self.render_input()?.to_string())?; + writeln!(out, " {}", self.input)?; out.reset()?; Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string()) } @@ -377,7 +426,7 @@ impl<'a> Input<'a> { } fn set_cursor(&mut self) -> io::Result<()> { - // if we have a placeholer, move the cursor left to beginning of the input + // if we have a placeholder, move the cursor left to beginning of the input if !self.placeholder.is_empty() && self.input.is_empty() { self.term .move_cursor_left(self.placeholder.chars().count())?; @@ -450,7 +499,7 @@ mod tests { .placeholder("Placeholder"); assert_eq!( - " Title\n Description\n $ Placeholder", + " Title\n Description\n $ Placeholder\n", without_ansi(input.render().unwrap().as_str()) ); } @@ -460,7 +509,7 @@ mod tests { let mut input = Input::new("Title"); assert_eq!( - " Title\n > ", + " Title\n > \n", without_ansi(input.render().unwrap().as_str()) ); } @@ -470,7 +519,7 @@ mod tests { let mut input = Input::new("Title").description("Description"); assert_eq!( - " Title\n Description\n > ", + " Title\n Description\n > \n", without_ansi(input.render().unwrap().as_str()) ); } @@ -480,7 +529,7 @@ mod tests { let mut input = Input::new("Title").prompt("$ "); assert_eq!( - " Title\n $ ", + " Title\n $ \n", without_ansi(input.render().unwrap().as_str()) ); } @@ -490,7 +539,7 @@ mod tests { let mut input = Input::new("Title").placeholder("Placeholder"); assert_eq!( - " Title\n > Placeholder", + " Title\n > Placeholder\n", without_ansi(input.render().unwrap().as_str()) ); } @@ -504,7 +553,7 @@ mod tests { .inline(true); assert_eq!( - " Title?Description.Prompt:Placeholder", + " Title?Description.Prompt:Placeholder\n", without_ansi(input.render().unwrap().as_str()) ); } @@ -518,14 +567,14 @@ mod tests { input.input = "".to_string(); input.validate().unwrap(); assert_eq!( - " Title\n Description\n > \n\n * Name cannot be empty", + " Title\n Description\n > \n\n * Name cannot be empty\n", without_ansi(input.render().unwrap().as_str()) ); input.input = "non empty".to_string(); input.validate().unwrap(); assert_eq!( - " Title\n Description\n > non empty", + " Title\n Description\n > non empty\n", without_ansi(input.render().unwrap().as_str()) ); } @@ -540,14 +589,14 @@ mod tests { input.input = "".to_string(); input.validate().unwrap(); assert_eq!( - " Title?Description.> \n\n * Name cannot be empty", + " Title?Description.> \n\n * Name cannot be empty\n", without_ansi(input.render().unwrap().as_str()) ); input.input = "non empty".to_string(); input.validate().unwrap(); assert_eq!( - " Title?Description.> non empty", + " Title?Description.> non empty\n", without_ansi(input.render().unwrap().as_str()) ); } diff --git a/src/multiselect.rs b/src/multiselect.rs index 6919d4c..2709510 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -414,6 +414,7 @@ impl<'a, T> MultiSelect<'a, T> { write!(out, " {}", desc)?; } } + writeln!(out)?; out.reset()?; Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string()) @@ -468,7 +469,8 @@ mod tests { [ ] Vegan Cheese [ ] Nutella - ↑/↓/k/j up/down • x/space toggle • a toggle all • enter confirm" + ↑/↓/k/j up/down • x/space toggle • a toggle all • enter confirm + " }, without_ansi(select.render().unwrap().as_str()) ); @@ -517,7 +519,8 @@ mod tests { [•] 2 [•] 3 - ↑/↓/k/j up/down • x/space toggle • a toggle all • enter confirm" + ↑/↓/k/j up/down • x/space toggle • a toggle all • enter confirm + " }, without_ansi(select.render().unwrap().as_str()) ); diff --git a/src/select.rs b/src/select.rs index f07f456..e2eb270 100644 --- a/src/select.rs +++ b/src/select.rs @@ -303,6 +303,7 @@ impl<'a, T> Select<'a, T> { write!(out, " {}", desc)?; } } + writeln!(out)?; out.reset()?; Ok(std::str::from_utf8(out.as_slice()).unwrap().to_string()) @@ -353,7 +354,8 @@ mod tests { Canada Mexico - ↑/↓/k/j up/down • enter confirm" + ↑/↓/k/j up/down • enter confirm + " }, without_ansi(select.render().unwrap().as_str()) ); @@ -395,7 +397,8 @@ mod tests { > First 2 - ↑/↓/k/j up/down • enter confirm" + ↑/↓/k/j up/down • enter confirm + " }, without_ansi(select.render().unwrap().as_str()) ); diff --git a/src/spinner.rs b/src/spinner.rs index b762a8c..6a92749 100644 --- a/src/spinner.rs +++ b/src/spinner.rs @@ -75,7 +75,7 @@ impl<'a> Spinner<'a> { loop { self.clear()?; let output = self.render()?; - self.height = output.lines().count(); + self.height = output.lines().count() - 1; self.term.write_all(output.as_bytes())?; sleep(self.style.fps); if handle.is_finished() { @@ -110,8 +110,11 @@ impl<'a> Spinner<'a> { } fn clear(&mut self) -> io::Result<()> { - self.term.clear_to_end_of_screen()?; - self.term.clear_last_lines(self.height)?; + if self.height == 0 { + self.term.clear_line()?; + } else { + self.term.clear_last_lines(self.height)?; + } self.height = 0; Ok(()) } diff --git a/src/theme.rs b/src/theme.rs index 42289d0..17e48bf 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -3,6 +3,12 @@ use termcolor::{Color, ColorSpec}; pub(crate) static DEFAULT: Lazy<Theme> = Lazy::new(Theme::default); +#[derive(Clone, Debug)] +pub enum CursorShape { + Block, + Underline, +} + /// Theme for styling the UI. /// /// # Example @@ -38,6 +44,13 @@ pub struct Theme { /// Unselected prefix foreground color pub unselected_prefix_fg: ColorSpec, + /// Char to use for the cursor + pub cursor_shape: CursorShape, + /// the color when there isnt text to get color from + pub cursor_style: ColorSpec, + /// use cusor_style even when there is text to get color from + pub force_style: bool, + /// Input cursor color pub input_cursor: ColorSpec, /// Input placeholder color @@ -72,6 +85,12 @@ impl Theme { let mut blurred_button = make_color(Color::Ansi256(7)); blurred_button.set_bg(Some(Color::Ansi256(0))); + // TODO: theme them + let mut cursor_style = ColorSpec::new(); + cursor_style + .set_fg(Some(Color::White)) + .set_bg(Some(Color::Black)); + Self { title: ColorSpec::new(), error_indicator: ColorSpec::new(), @@ -91,7 +110,37 @@ impl Theme { help_sep: ColorSpec::new(), focused_button, blurred_button, + + // TODO: theme these + cursor_shape: CursorShape::Block, + cursor_style, + force_style: true, + } + } + + pub fn real_cursor_color(&self, other: Option<&ColorSpec>) -> ColorSpec { + // let mut c = self.input_cursor.clone(); + let other = if self.force_style { + &self.cursor_style + } else { + other.unwrap_or(&self.cursor_style) + }; + + let mut c = ColorSpec::new(); + match self.cursor_shape { + CursorShape::Block => { + c.set_bg(other.fg().copied()); + c.set_fg(other.bg().copied()); + } + CursorShape::Underline => { + c.set_bg(other.bg().copied()); + c.set_fg(other.fg().copied()); + c.set_underline(true); + } } + // c.set_fg(self.input_cursor.bg().copied()) + // .set_bg(self.input_cursor.bg().copied()); + c } /// Create a new theme with the charm color scheme @@ -112,6 +161,12 @@ impl Theme { let mut blurred_button = make_color(normal); blurred_button.set_bg(Some(Color::Ansi256(238))); + // TODO: theme them + let mut cursor_style = ColorSpec::new(); + cursor_style + .set_fg(Some(Color::White)) + .set_bg(Some(Color::Black)); + Self { title, error_indicator: make_color(red), @@ -135,6 +190,11 @@ impl Theme { focused_button, blurred_button, + + // TODO: theme these + cursor_shape: CursorShape::Block, + cursor_style, + force_style: true, } } @@ -157,6 +217,12 @@ impl Theme { let mut blurred_button = make_color(foreground); blurred_button.set_bg(Some(background)); + // TODO: theme them + let mut cursor_style = ColorSpec::new(); + cursor_style + .set_fg(Some(Color::White)) + .set_bg(Some(Color::Black)); + Self { title, error_indicator: make_color(red), @@ -180,6 +246,11 @@ impl Theme { focused_button, blurred_button, + + // TODO: theme these + cursor_shape: CursorShape::Block, + cursor_style, + force_style: true, } } @@ -194,6 +265,12 @@ impl Theme { let mut blurred_button = make_color(Color::Ansi256(7)); blurred_button.set_bg(Some(Color::Ansi256(0))); + // TODO: theme them + let mut cursor_style = ColorSpec::new(); + cursor_style + .set_fg(Some(Color::White)) + .set_bg(Some(Color::Black)); + Self { title, error_indicator: make_color(Color::Ansi256(9)), @@ -217,6 +294,11 @@ impl Theme { focused_button, blurred_button, + + // TODO: theme these + cursor_shape: CursorShape::Block, + cursor_style, + force_style: true, } } @@ -242,6 +324,12 @@ impl Theme { let mut blurred_button = make_color(text); blurred_button.set_bg(Some(base)); + // TODO: theme them + let mut cursor_style = ColorSpec::new(); + cursor_style + .set_fg(Some(Color::White)) + .set_bg(Some(Color::Black)); + Self { title, error_indicator: make_color(red), @@ -265,6 +353,11 @@ impl Theme { focused_button, blurred_button, + + // TODO: theme these + cursor_shape: CursorShape::Block, + cursor_style, + force_style: true, } }