From a1b12108d42bd6fdfc7a76ddf0e77ce3e4838933 Mon Sep 17 00:00:00 2001 From: Roland Schaer Date: Tue, 17 Dec 2024 18:14:45 +0100 Subject: [PATCH] fix: ctrl-c doesn't restore cursor --- Cargo.toml | 1 + examples/confirm.rs | 4 +- examples/dialog.rs | 4 +- examples/input-password.rs | 4 +- examples/input.rs | 4 +- examples/list.rs | 2 +- examples/multiselect.rs | 4 +- examples/multiselect_huge.rs | 4 +- examples/select.rs | 4 +- examples/themes.rs | 20 ++++++- src/confirm.rs | 12 +++- src/ctrlc.rs | 110 +++++++++++++++++++++++++++++++++++ src/dialog.rs | 11 +++- src/input.rs | 9 ++- src/lib.rs | 1 + src/list.rs | 8 ++- src/multiselect.rs | 8 ++- src/select.rs | 8 ++- src/spinner.rs | 8 ++- 19 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 src/ctrlc.rs diff --git a/Cargo.toml b/Cargo.toml index 1db5e2b..71a4cf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ console = "0.15" fuzzy-matcher = "0.3" itertools = "0.13" once_cell = "1" +signal-hook = "0.3" termcolor = "1" [dev-dependencies] diff --git a/examples/confirm.rs b/examples/confirm.rs index c910e12..cce4a4f 100644 --- a/examples/confirm.rs +++ b/examples/confirm.rs @@ -5,11 +5,11 @@ fn main() { .description("This will do a thing.") .affirmative("Yes!") .negative("No."); - let _ = match confirm.run() { + match confirm.run() { Ok(confirm) => confirm, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Dialog was cancelled"); + println!("{}", e); false } else { panic!("Error: {}", e); diff --git a/examples/dialog.rs b/examples/dialog.rs index 13d3453..c8085c6 100644 --- a/examples/dialog.rs +++ b/examples/dialog.rs @@ -9,11 +9,11 @@ fn main() { DialogButton::new("Cancel"), ]) .selected_button(1); - let _ = match dialog.run() { + match dialog.run() { Ok(value) => value, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Dialog was cancelled"); + println!("{}", e); return; } else { panic!("Error: {}", e); diff --git a/examples/input-password.rs b/examples/input-password.rs index 6bb8897..b5657a1 100644 --- a/examples/input-password.rs +++ b/examples/input-password.rs @@ -5,11 +5,11 @@ fn main() { .placeholder("Enter password") .prompt("Password: ") .password(true); - let _ = match input.run() { + match input.run() { Ok(value) => value, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Input cancelled"); + println!("{}", e); return; } else { panic!("Error: {}", e); diff --git a/examples/input.rs b/examples/input.rs index de00192..0542fa4 100644 --- a/examples/input.rs +++ b/examples/input.rs @@ -26,11 +26,11 @@ fn main() { "Zack Snyder", ]) .validation(notempty_minlen); - let _ = match input.run() { + match input.run() { Ok(value) => value, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Input cancelled"); + println!("{}", e); return; } else { panic!("Error: {}", e); diff --git a/examples/list.rs b/examples/list.rs index 0fd2bf4..7a33a09 100644 --- a/examples/list.rs +++ b/examples/list.rs @@ -71,7 +71,7 @@ fn main() { Ok(_) => {} Err(e) => { if e.kind() == io::ErrorKind::Interrupted { - println!("Input cancelled"); + println!("{}", e); } else { panic!("Error: {}", e); } diff --git a/examples/multiselect.rs b/examples/multiselect.rs index 23d43eb..76a5790 100644 --- a/examples/multiselect.rs +++ b/examples/multiselect.rs @@ -13,11 +13,11 @@ fn main() { .option(DemandOption::new("Cheese")) .option(DemandOption::new("Vegan Cheese")) .option(DemandOption::new("Nutella")); - let _ = match multiselect.run() { + match multiselect.run() { Ok(toppings) => toppings, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Input cancelled"); + println!("{}", e); return; } else { panic!("Error: {}", e); diff --git a/examples/multiselect_huge.rs b/examples/multiselect_huge.rs index 197c09a..08941d7 100644 --- a/examples/multiselect_huge.rs +++ b/examples/multiselect_huge.rs @@ -67,11 +67,11 @@ fn main() { .option(DemandOption::new("Starburst")) .option(DemandOption::new("Twizzlers")) .option(DemandOption::new("Milk Duds")); - let _ = match multiselect.run() { + match multiselect.run() { Ok(value) => value, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Input cancelled"); + println!("{}", e); return; } else { panic!("Error: {}", e); diff --git a/examples/select.rs b/examples/select.rs index 32c9269..779ef0e 100644 --- a/examples/select.rs +++ b/examples/select.rs @@ -47,11 +47,11 @@ fn main() { .option(DemandOption::new("EG").label("Egypt")) .option(DemandOption::new("SA").label("Saudi Arabia")) .option(DemandOption::new("AE").label("United Arab Emirates")); - let _ = match ms.run() { + match ms.run() { Ok(value) => value, Err(e) => { if e.kind() == std::io::ErrorKind::Interrupted { - println!("Input cancelled"); + println!("{}", e); return; } else { panic!("Error: {}", e); diff --git a/examples/themes.rs b/examples/themes.rs index 1ab93a5..113a606 100644 --- a/examples/themes.rs +++ b/examples/themes.rs @@ -2,6 +2,20 @@ use std::env::args; use demand::{Confirm, DemandOption, Input, MultiSelect, Theme}; +fn handle_run(result: Result) -> T { + match result { + Ok(value) => value, + Err(e) => { + if e.kind() == std::io::ErrorKind::Interrupted { + println!("{}", e); + std::process::exit(0); + } else { + panic!("Error: {}", e); + } + } + } +} + fn main() { let theme = match args().nth(1).unwrap_or_default().as_str() { "base16" => Theme::base16(), @@ -16,7 +30,7 @@ fn main() { .description("Please enter your e-mail address.") .placeholder("john.doe@acme.com") .theme(&theme); - i.run().expect("error running input"); + handle_run(i.run()); let ms = MultiSelect::new("Interests") .description("Select your interests") @@ -31,12 +45,12 @@ fn main() { .option(DemandOption::new("Travel")) .option(DemandOption::new("Sports")) .theme(&theme); - ms.run().expect("error running multi select"); + handle_run(ms.run()); let c = Confirm::new("Confirm privacy policy") .description("Do you accept the privacy policy?") .affirmative("Yes") .negative("No") .theme(&theme); - c.run().expect("error running confirm"); + handle_run(c.run()); } diff --git a/src/confirm.rs b/src/confirm.rs index 049b9bd..097f52d 100644 --- a/src/confirm.rs +++ b/src/confirm.rs @@ -4,8 +4,8 @@ use std::io::Write; use console::{Key, Term}; use termcolor::{Buffer, WriteColor}; -use crate::theme; use crate::theme::Theme; +use crate::{ctrlc, theme}; /// Select multiple options from a list /// @@ -95,9 +95,12 @@ impl<'a> Confirm<'a> { /// This function will block until the user submits the input. If the user cancels the input, /// an error of type `io::ErrorKind::Interrupted` is returned. pub fn run(mut self) -> io::Result { + let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?; + let affirmative_char = self.affirmative.to_lowercase().chars().next().unwrap(); let negative_char = self.negative.to_lowercase().chars().next().unwrap(); self.term.clear_line()?; + self.term.hide_cursor()?; loop { self.clear()?; let output = self.render()?; @@ -109,17 +112,21 @@ impl<'a> Confirm<'a> { Key::ArrowRight | Key::Char('l') => self.handle_right(), Key::Char(c) if c == affirmative_char => { self.selected = true; + ctrlc_handle.close(); return self.handle_submit(); } Key::Char(c) if c == negative_char => { self.selected = false; + ctrlc_handle.close(); return self.handle_submit(); } Key::Enter => { + ctrlc_handle.close(); return self.handle_submit(); } Key::Escape => { - self.clear()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return Err(io::Error::new(io::ErrorKind::Interrupted, "user cancelled")); } _ => {} @@ -130,6 +137,7 @@ impl<'a> Confirm<'a> { fn handle_submit(mut self) -> io::Result { self.term.clear_to_end_of_screen()?; self.clear()?; + self.term.show_cursor()?; let output = self.render_success()?; self.term.write_all(output.as_bytes())?; Ok(self.selected) diff --git a/src/ctrlc.rs b/src/ctrlc.rs new file mode 100644 index 0000000..a675ec2 --- /dev/null +++ b/src/ctrlc.rs @@ -0,0 +1,110 @@ +use console::Term; +use once_cell::sync::Lazy; +use signal_hook::{ + consts::SIGINT, + iterator::{Handle, Signals}, +}; +use std::{ + io::Error, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, RwLock, + }, + thread, +}; + +static MUTEX: Mutex<()> = Mutex::new(()); +static INIT: AtomicBool = AtomicBool::new(false); +static HANDLE: Lazy> = Lazy::new(|| RwLock::new(CtrlcHandle(None))); + +#[derive(Clone)] +pub struct CtrlcHandle(Option); + +impl CtrlcHandle { + pub fn close(&self) { + if cfg!(not(target_os = "windows")) { + if let Some(handle) = &self.0 { + handle.close(); + } + } + } +} + +/// Show cursor after Ctrl+C is pressed +/// +/// The caller should call the close method of the returned handle to release the resources +/// +/// # Arguments +/// +/// * `term` - The terminal to show the cursor +/// +/// # Returns +/// +/// * `CtrlcHandle` - The handle to release the resources +/// +/// # Errors +/// +/// * `Error` - If failed to set the Ctrl+C handler +/// +pub fn show_cursor_after_ctrlc(term: &Term) -> Result { + if cfg!(not(target_os = "windows")) { + let t = term.clone(); + set_ctrlc_handler(move || { + let _ = t.show_cursor(); + }) + } else { + Ok(CtrlcHandle(None)) + } +} + +/// Set Ctrl+C handler +/// +/// The caller should call the close method of the returned handle to release the resources +/// +/// # Arguments +/// +/// * `handler` - The handler to be called when Ctrl+C is pressed +/// +/// # Returns +/// +/// * `Result, Error>` - The handle to release the resources +/// +/// # Errors +/// * `Error` - If failed to set the Ctrl+C handler +/// +#[cfg(not(target_os = "windows"))] +pub fn set_ctrlc_handler(handler: F) -> Result +where + F: FnMut() + 'static + Send, +{ + let _mutex = MUTEX.lock(); + if INIT.load(Ordering::Relaxed) { + let handle_guard = HANDLE.read().unwrap(); + return Ok(handle_guard.clone()); + } + INIT.store(true, Ordering::Relaxed); + + let handle = set_ctrlc_handler_internal(handler)?; + { + let mut handle_guard = HANDLE.write().unwrap(); + *handle_guard = CtrlcHandle(Some(handle.clone())); + } + Ok(CtrlcHandle(Some(handle))) +} + +#[cfg(not(target_os = "windows"))] +fn set_ctrlc_handler_internal(mut handler: F) -> Result +where + F: FnMut() + 'static + Send, +{ + let mut signals = Signals::new([SIGINT])?; + let handle = signals.handle(); + thread::Builder::new() + .name("ctrl-c".into()) + .spawn(move || { + for _ in signals.forever() { + handler(); + } + })?; + Ok(handle) +} diff --git a/src/dialog.rs b/src/dialog.rs index 09201e1..7c95a78 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -4,8 +4,8 @@ use std::io::Write; use console::{Key, Term}; use termcolor::{Buffer, WriteColor}; -use crate::theme; use crate::theme::Theme; +use crate::{ctrlc, theme}; #[derive(Clone, Debug, Default, PartialEq)] /// A button to select in a dialog @@ -131,6 +131,9 @@ impl<'a> Dialog<'a> { /// This function will block until the user submits the input. If the user cancels the input, /// an error of type `io::ErrorKind::Interrupted` is returned. pub fn run(mut self) -> io::Result { + let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?; + + self.term.hide_cursor()?; loop { self.clear()?; let output = self.render()?; @@ -143,13 +146,16 @@ impl<'a> Dialog<'a> { 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(); + ctrlc_handle.close(); return self.handle_submit(); } Key::Enter => { + ctrlc_handle.close(); return self.handle_submit(); } Key::Escape => { - self.clear()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return Err(io::Error::new(io::ErrorKind::Interrupted, "user cancelled")); } _ => {} @@ -159,6 +165,7 @@ impl<'a> Dialog<'a> { fn handle_submit(mut self) -> io::Result { self.clear()?; + self.term.show_cursor()?; let output = self.render_success()?; self.term.write_all(output.as_bytes())?; let result = if !self.buttons.is_empty() { diff --git a/src/input.rs b/src/input.rs index 86a3d32..8f5b12d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -6,6 +6,7 @@ use std::{ use console::{measure_text_width, Key, Term}; use termcolor::{Buffer, WriteColor}; +use crate::ctrlc; use crate::{theme, Theme}; /// Single line text input @@ -153,6 +154,8 @@ impl<'a> Input<'a> { /// This function will block until the user submits the input. If the user cancels the input, /// an error of type `io::ErrorKind::Interrupted` is returned. pub fn run(mut self) -> io::Result { + let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?; + self.term.hide_cursor()?; loop { self.clear()?; @@ -177,14 +180,16 @@ impl<'a> Input<'a> { self.clear_err()?; self.validate()?; if self.err.is_none() { - self.term.show_cursor()?; self.term.clear_to_end_of_screen()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return self.handle_submit(); } } Key::Tab => self.handle_tab()?, Key::Escape => { - self.clear()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return Err(io::Error::new(io::ErrorKind::Interrupted, "user cancelled")); } _ => {} diff --git a/src/lib.rs b/src/lib.rs index 6b4933e..fa3b4a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub use spinner::SpinnerStyle; pub use theme::Theme; mod confirm; +mod ctrlc; mod dialog; mod input; mod list; diff --git a/src/list.rs b/src/list.rs index c8348ed..6f637dd 100644 --- a/src/list.rs +++ b/src/list.rs @@ -4,7 +4,7 @@ use console::{Key, Term}; use std::io::Write; use termcolor::{Buffer, WriteColor}; -use crate::{theme, Theme}; +use crate::{ctrlc, theme, Theme}; /// Display a list of options /// @@ -125,6 +125,8 @@ impl<'a> List<'a> { /// This function will block until the user submits the input. If the user cancels the input, /// an error of type `io::ErrorKind::Interrupted` is returned. pub fn run(mut self) -> Result<(), io::Error> { + let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?; + loop { self.clear()?; let output = self.render()?; @@ -148,12 +150,14 @@ impl<'a> List<'a> { Key::ArrowRight | Key::Char('l') => self.handle_right()?, Key::Char('/') if self.filterable => self.handle_start_filtering(), Key::Escape => { - self.clear()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return Err(io::Error::new(io::ErrorKind::Interrupted, "user cancelled")); } Key::Enter => { self.clear()?; self.term.show_cursor()?; + ctrlc_handle.close(); let output = self.render_success()?; self.term.write_all(output.as_bytes())?; return Ok(()); diff --git a/src/multiselect.rs b/src/multiselect.rs index 1314938..2632f76 100644 --- a/src/multiselect.rs +++ b/src/multiselect.rs @@ -6,7 +6,7 @@ use console::{Key, Term}; use termcolor::{Buffer, WriteColor}; use crate::theme::Theme; -use crate::{theme, DemandOption}; +use crate::{ctrlc, theme, DemandOption}; /// Select multiple options from a list /// @@ -141,6 +141,8 @@ impl<'a, T> MultiSelect<'a, T> { /// This function will block until the user submits the input. If the user cancels the input, /// an error of type `io::ErrorKind::Interrupted` is returned. pub fn run(mut self) -> io::Result> { + let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?; + self.max = self.max.min(self.options.len()); self.min = self.min.min(self.max); @@ -170,7 +172,8 @@ impl<'a, T> MultiSelect<'a, T> { Key::Char('/') if self.filterable => self.handle_start_filtering(), Key::Escape => { if self.filter.is_empty() { - self.clear()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return Err(io::Error::new( io::ErrorKind::Interrupted, "user cancelled", @@ -205,6 +208,7 @@ impl<'a, T> MultiSelect<'a, T> { } self.clear()?; self.term.show_cursor()?; + ctrlc_handle.close(); let output = self.render_success(&selected)?; self.term.write_all(output.as_bytes())?; let selected = self diff --git a/src/select.rs b/src/select.rs index 979c0a3..1239d73 100644 --- a/src/select.rs +++ b/src/select.rs @@ -2,7 +2,7 @@ use std::io; use std::io::Write; use crate::theme::Theme; -use crate::{theme, DemandOption}; +use crate::{ctrlc, theme, DemandOption}; use console::{Alignment, Key, Term}; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; @@ -131,6 +131,8 @@ impl<'a, T> Select<'a, T> { /// This function will block until the user submits the input. If the user cancels the input, /// an error of type `io::ErrorKind::Interrupted` is returned. pub fn run(mut self) -> io::Result { + let ctrlc_handle = ctrlc::show_cursor_after_ctrlc(&self.term)?; + loop { self.clear()?; let output = self.render()?; @@ -171,7 +173,8 @@ impl<'a, T> Select<'a, T> { Key::Char('/') if self.filterable => self.handle_start_filtering(), Key::Escape => { if self.filter.is_empty() { - self.clear()?; + self.term.show_cursor()?; + ctrlc_handle.close(); return Err(io::Error::new( io::ErrorKind::Interrupted, "user cancelled", @@ -180,6 +183,7 @@ impl<'a, T> Select<'a, T> { self.handle_stop_filtering(false)?; } Key::Enter => { + ctrlc_handle.close(); return enter(self); } _ => {} diff --git a/src/spinner.rs b/src/spinner.rs index db2782e..2a80d67 100644 --- a/src/spinner.rs +++ b/src/spinner.rs @@ -10,7 +10,7 @@ use console::Term; use once_cell::sync::Lazy; use termcolor::{Buffer, WriteColor}; -use crate::{theme, Theme}; +use crate::{ctrlc, theme, Theme}; /// tell a prompt to do something while running /// currently its only useful for spinner @@ -129,6 +129,12 @@ impl<'a> Spinner<'a> { F: FnOnce(&mut SpinnerActionRunner<'spinner>) -> T + Send + 'scope, T: Send + 'scope, { + let t = self.term.clone(); + let _ctrlc_handle = ctrlc::set_ctrlc_handler(move || { + t.show_cursor().unwrap(); + std::process::exit(130); + })?; + std::thread::scope(|s| { let (sender, receiver) = mpsc::channel(); let handle = s.spawn(move || {