diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0e5b36..18818624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `evaluate()`: Called for each event, returns `Signal::Continue` or `Signal::Quit` - `finalize()`: Called after loop exit to produce the final result - Singleton `EVENT_STREAM` prevents cursor position read errors across multiple prompts +- **Spinner widget**: New widget for displaying spinner animations during async task execution + - `spinner::State` trait: Interface for checking idle state asynchronously + - `spinner::run` function: Executes frame-based spinner animations + - `spinner::frame` module: Provides various spinner frame patterns +- **BYOP (Build Your Own Preset) example**: Custom prompt implementation example + - Integration demo of spinner and text editor + - UI state management during async task execution + - Task start, completion, and cancellation functionality ### Changed - Migrated to async/await pattern throughout the codebase @@ -50,6 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Better thread safety with Arc-based renderer sharing - More efficient pane management using SkipMap data structure - Clearer application lifecycle with distinct phases +- Better patterns and best practices for async task management +- Enhanced user experience with spinner animations ### Technical Details - Introduced `Arc>` for thread-safe renderer sharing diff --git a/examples/byop/Cargo.toml b/examples/byop/Cargo.toml new file mode 100644 index 00000000..22e40727 --- /dev/null +++ b/examples/byop/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "byop" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +promkit = { path = "../../promkit" } +tokio = { workspace = true } + +[[bin]] +name = "byop" +path = "src/byop.rs" diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs new file mode 100644 index 00000000..76e39892 --- /dev/null +++ b/examples/byop/src/byop.rs @@ -0,0 +1,457 @@ +/// BYOP (Build Your Own Preset) example for promkit. +/// +/// This example demonstrates how to create a custom prompt using the `promkit` library. +/// It includes a text editor for input, a spinner for async task execution, and a task +/// monitor for managing background tasks. +/// The prompt allows users to enter text, start a heavy task (actually a simulated delay), +/// and see the results (actually show the input text) or errors +/// displayed in the UI. The example showcases the integration of various widgets and state +/// management techniques to create a responsive and interactive command-line application. +/// +/// # Example Usage +/// To run this example, ensure you have the `promkit` library and its dependencies set up in +/// your Rust project. Then, execute the main function which initializes the BYOP prompt and +/// starts the event loop. You can interact with the prompt by typing commands and pressing +/// Enter to execute tasks. Use Ctrl+C to exit the prompt. +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use promkit::{ + anyhow, async_trait, + core::{ + crossterm::{self, style::Color}, + grapheme::StyledGraphemes, + pane::EMPTY_PANE, + Pane, + }, + widgets::{ + core::{ + crossterm::{ + event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + style::ContentStyle, + }, + render::{Renderer, SharedRenderer}, + PaneFactory, + }, + spinner::{self, State}, + text_editor, + }, + Prompt, Signal, +}; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; + +/// Represents the indices of various components in BYOP. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Index { + Readline = 0, + Spinner = 1, + Result = 2, +} + +/// Task events for monitor +#[derive(Debug)] +enum TaskEvent { + TaskStarted { + handle: JoinHandle>, + }, + TaskCompleted { + result: anyhow::Result, + }, +} + +/// Task monitor daemon for managing background tasks +struct TaskMonitor { + event_sender: mpsc::UnboundedSender, + monitor_handle: JoinHandle<()>, + task_handle: Arc>>>>, +} + +impl spinner::State for TaskMonitor { + async fn is_idle(&self) -> bool { + // Check if the task is currently running + let running = self.task_handle.read().await; + running.is_none() || running.as_ref().map_or(true, |handle| handle.is_finished()) + } +} + +impl spinner::State for &TaskMonitor { + async fn is_idle(&self) -> bool { + // Delegate to the TaskMonitor + (*self).is_idle().await + } +} + +impl TaskMonitor { + fn new(renderer: SharedRenderer) -> Self { + let (event_sender, mut event_receiver) = mpsc::unbounded_channel(); + let task_handle = Arc::new(RwLock::new(None)); + let task_handle_internal = task_handle.clone(); + + // Event handling daemon + let monitor_handle = tokio::spawn(async move { + while let Some(event) = event_receiver.recv().await { + match event { + TaskEvent::TaskStarted { handle } => { + // Store the handle for task management + { + let mut handle_guard = task_handle_internal.write().await; + *handle_guard = Some(handle); + } + } + TaskEvent::TaskCompleted { result } => { + // Clear the task handle + { + let mut handle_guard = task_handle_internal.write().await; + *handle_guard = None; + } + + // Update UI based on result + match result { + Ok(input_text) => { + let _ = renderer + .update([ + (Index::Spinner, EMPTY_PANE.clone()), + ( + Index::Result, + Pane::new( + vec![StyledGraphemes::from(format!( + "result: {}", + input_text + ))], + 0, + ), + ), + ]) + .render() + .await; + } + Err(_) => { + let _ = renderer + .update([ + (Index::Spinner, EMPTY_PANE.clone()), + ( + Index::Result, + Pane::new( + vec![StyledGraphemes::from("Task failed")], + 0, + ), + ), + ]) + .render() + .await; + } + } + } + } + } + }); + + Self { + event_sender, + monitor_handle, + task_handle, + } + } + + fn abort_all(&self) { + self.monitor_handle.abort(); + self.abort_task(); + } + + fn abort_task(&self) { + if let Some(handle) = self.task_handle.blocking_read().as_ref() { + handle.abort(); + } + } +} + +/// Build Your Own Prompt +struct BYOP { + renderer: SharedRenderer, + task_monitor: Arc, + readline: text_editor::State, + spinner: Arc, +} + +#[async_trait::async_trait] +impl Prompt for BYOP { + async fn initialize(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn evaluate(&mut self, event: &Event) -> anyhow::Result { + let ret = self.evaluate_internal(event).await; + let size = crossterm::terminal::size()?; + self.render(size.0, size.1).await?; + ret + } + + type Return = String; + + fn finalize(&mut self) -> anyhow::Result { + let ret = self.readline.texteditor.text_without_cursor().to_string(); + + // Abort any running tasks and clear the task monitor + self.task_monitor.abort_all(); + + // Reset the text editor state for the next prompt. + self.readline.texteditor.erase_all(); + + Ok(ret) + } +} + +impl BYOP { + async fn try_default() -> anyhow::Result { + let size = crossterm::terminal::size()?; + + let readline = text_editor::State { + prefix: String::from("❯❯ "), + prefix_style: ContentStyle { + foreground_color: Some(Color::DarkGreen), + ..Default::default() + }, + active_char_style: ContentStyle { + background_color: Some(Color::DarkCyan), + ..Default::default() + }, + word_break_chars: HashSet::from([' ']), + ..Default::default() + }; + + let renderer = SharedRenderer::new( + Renderer::try_new_with_panes( + [(Index::Readline, readline.create_pane(size.0, size.1))], + true, + ) + .await?, + ); + + Ok(Self { + task_monitor: Arc::new(TaskMonitor::new(renderer.clone())), + renderer, + readline, + spinner: Arc::new( + spinner::Spinner::default() + .frames(spinner::frame::DOTS) + .suffix("Executing...") + .duration(Duration::from_millis(100)), + ), + }) + } + + async fn start_heavy_task(&mut self) -> anyhow::Result<()> { + // Check if task is already running + if !self.task_monitor.is_idle().await { + return Ok(()); + } + + // Clear previous result and show spinner + self.renderer + .update([(Index::Result, EMPTY_PANE.clone())]) + .render() + .await?; + + let input_text = self.readline.texteditor.text_without_cursor().to_string(); + let task_monitor = self.task_monitor.event_sender.clone(); + + let handle = tokio::spawn(async move { + // NOTE: Simulating a heavy task with a sleep. + tokio::time::sleep(Duration::from_secs(3)).await; + + // Notify completion through the task monitor + let result = Ok(input_text.clone()); + let _ = task_monitor.send(TaskEvent::TaskCompleted { result }); + + Ok(input_text) + }); + + // Send task started event to monitor for handle management + let _ = self + .task_monitor + .event_sender + .send(TaskEvent::TaskStarted { handle }); + + Ok(()) + } + + async fn evaluate_internal(&mut self, event: &Event) -> anyhow::Result { + match event { + // Render for refreshing prompt on resize. + Event::Resize(width, height) => { + self.render(*width, *height).await?; + } + + // Handle Enter key based on task state + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + let is_running = !self.task_monitor.is_idle().await; + if !is_running { + // Start the heavy task in background + self.start_heavy_task().await?; + } else { + self.renderer + .update([( + Index::Result, + Pane::new( + vec![StyledGraphemes::from("Task is currently running...")], + 0, + ), + )]) + .render() + .await?; + } + } + + // Quit + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => return Err(anyhow::anyhow!("ctrl+c")), + + // Move cursor. + Event::Key(KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + self.readline.texteditor.backward(); + } + Event::Key(KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + self.readline.texteditor.forward(); + } + Event::Key(KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self.readline.texteditor.move_to_head(), + Event::Key(KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self.readline.texteditor.move_to_tail(), + + // Move cursor to the nearest character. + Event::Key(KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self + .readline + .texteditor + .move_to_previous_nearest(&self.readline.word_break_chars), + + Event::Key(KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self + .readline + .texteditor + .move_to_next_nearest(&self.readline.word_break_chars), + + // Erase char(s). + Event::Key(KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self.readline.texteditor.erase(), + Event::Key(KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self.readline.texteditor.erase_all(), + + // Erase to the nearest character. + Event::Key(KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self + .readline + .texteditor + .erase_to_previous_nearest(&self.readline.word_break_chars), + + Event::Key(KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::ALT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self + .readline + .texteditor + .erase_to_next_nearest(&self.readline.word_break_chars), + + // Input char. + Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => self.readline.texteditor.insert(*ch), + + _ => (), + } + Ok(Signal::Continue) + } + + async fn render(&mut self, width: u16, height: u16) -> anyhow::Result<()> { + self.renderer + .update([(Index::Readline, self.readline.create_pane(width, height))]) + .render() + .await + } + + async fn spawn(&mut self) -> anyhow::Result<()> { + let renderer = self.renderer.clone(); + let task_monitor = Arc::clone(&self.task_monitor); + let spinner = Arc::clone(&self.spinner); + + let spinner_task = tokio::spawn(async move { + spinner::run( + spinner.as_ref(), + task_monitor.as_ref(), + Index::Spinner, + renderer, + ) + .await + }); + + let ret = self.run().await; + spinner_task.abort(); + ret?; + Ok(()) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + BYOP::try_default().await?.spawn().await +} diff --git a/promkit-derive/Cargo.toml b/promkit-derive/Cargo.toml index 25e69865..4a67b6ca 100644 --- a/promkit-derive/Cargo.toml +++ b/promkit-derive/Cargo.toml @@ -18,4 +18,4 @@ proc-macro2 = "1.0" promkit = { path = "../promkit", version = "=0.10.0", features = ["form"] } [dev-dependencies] -tokio = { version = "1.46.1", features = ["full"] } +tokio = { workspace = true } diff --git a/promkit-widgets/Cargo.toml b/promkit-widgets/Cargo.toml index 20ee9fac..0c057178 100644 --- a/promkit-widgets/Cargo.toml +++ b/promkit-widgets/Cargo.toml @@ -9,11 +9,12 @@ license = "MIT" readme = "README.md" [features] -default = [] -all = ["checkbox", "jsonstream", "listbox", "text", "texteditor", "tree"] +default = ["all"] +all = ["checkbox", "jsonstream", "listbox", "spinner", "text", "texteditor", "tree"] checkbox = ["listbox"] jsonstream = ["dep:serde", "dep:serde_json", "dep:rayon"] listbox = [] +spinner = ["dep:tokio"] text = [] texteditor = [] tree = [] @@ -21,9 +22,12 @@ tree = [] [dependencies] anyhow = { workspace = true } promkit-core = { path = "../promkit-core", version = "=0.2.0" } + +# Optional dependencies +rayon = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } -rayon = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } [dev-dependencies] # Enable `jsonstream` feature when `jsonz` testing diff --git a/promkit-widgets/src/lib.rs b/promkit-widgets/src/lib.rs index 76576c0d..0c1307c8 100644 --- a/promkit-widgets/src/lib.rs +++ b/promkit-widgets/src/lib.rs @@ -30,3 +30,7 @@ pub mod text_editor; #[cfg(feature = "tree")] #[cfg_attr(docsrs, doc(cfg(feature = "tree")))] pub mod tree; + +#[cfg(feature = "spinner")] +#[cfg_attr(docsrs, doc(cfg(feature = "spinner")))] +pub mod spinner; diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs new file mode 100644 index 00000000..8ced5181 --- /dev/null +++ b/promkit-widgets/src/spinner.rs @@ -0,0 +1,89 @@ +use std::time::Duration; + +use crate::core::{Pane, grapheme::StyledGraphemes, render::SharedRenderer}; + +pub mod frame; +use frame::Frame; + +/// Trait to define the state of the spinner. +pub trait State { + fn is_idle(&self) -> impl Future + Send; +} + +/// A spinner that can be used to indicate loading or processing states. +#[derive(Clone, Debug)] +pub struct Spinner { + /// The frames of the spinner, which are the characters that will be displayed in a rotating manner. + pub frames: Frame, + /// A suffix that will be displayed alongside the spinner. + pub suffix: String, + /// The duration between frame updates. + pub duration: Duration, +} + +impl Default for Spinner { + fn default() -> Self { + Self { + frames: frame::DOTS, + suffix: String::new(), + duration: Duration::from_millis(100), + } + } +} + +impl Spinner { + /// Set frames for the spinner. + pub fn frames(mut self, frames: Frame) -> Self { + self.frames = frames; + self + } + + /// Set a suffix for the spinner. + pub fn suffix(mut self, suffix: impl Into) -> Self { + self.suffix = suffix.into(); + self + } + + /// Set the duration between frame updates. + pub fn duration(mut self, duration: Duration) -> Self { + self.duration = duration; + self + } +} + +/// Spawn a background task that shows a spinner while the state is active. +pub async fn run( + spinner: &Spinner, + state: S, + index: I, + renderer: SharedRenderer, +) -> anyhow::Result<()> +where + S: State, + I: Clone + Ord + Send, +{ + let mut frame_index = 0; + let mut interval = tokio::time::interval(spinner.duration); + + loop { + interval.tick().await; + + if !state.is_idle().await { + frame_index = (frame_index + 1) % spinner.frames.len(); + + renderer + .update([( + index.clone(), + Pane::new( + vec![StyledGraphemes::from(format!( + "{} {}", + spinner.frames[frame_index], spinner.suffix + ))], + 0, + ), + )]) + .render() + .await?; + } + } +} diff --git a/promkit-widgets/src/spinner/frame.rs b/promkit-widgets/src/spinner/frame.rs new file mode 100644 index 00000000..5292c076 --- /dev/null +++ b/promkit-widgets/src/spinner/frame.rs @@ -0,0 +1,25 @@ +pub type Frame = &'static [&'static str]; + +pub const DOTS: Frame = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub const HAMBURGER: Frame = &["☱", "☲", "☴"]; + +pub const ARC: Frame = &["◜", "◠", "◝", "◞", "◡", "◟"]; + +pub const CIRCLE: Frame = &["◡", "⊙", "◠"]; + +pub const SQUARE_CORNERS: Frame = &["◰", "◳", "◲", "◱"]; + +pub const CIRCLE_QUARTERS: Frame = &["◴", "◷", "◶", "◵"]; + +pub const CIRCLE_HALVES: Frame = &["◐", "◓", "◑", "◒"]; + +pub const TOGGLE: Frame = &["⊶", "⊷"]; + +pub const CLOCK: Frame = &[ + "🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", +]; + +pub const EARTH: Frame = &["🌍", "🌎", "🌏"]; + +pub const MOON: Frame = &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"]; diff --git a/promkit/src/lib.rs b/promkit/src/lib.rs index 9a0e0d68..91f1c795 100644 --- a/promkit/src/lib.rs +++ b/promkit/src/lib.rs @@ -1,6 +1,8 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] +pub use anyhow; +pub use async_trait; pub use promkit_widgets as widgets; pub use promkit_widgets::core; diff --git a/promkit/src/preset/readline.rs b/promkit/src/preset/readline.rs index 8ecd6bca..9ef3c001 100644 --- a/promkit/src/preset/readline.rs +++ b/promkit/src/preset/readline.rs @@ -25,8 +25,8 @@ use crate::{ pub mod evaluate; +/// Represents the indices of various components in the readline preset. #[derive(PartialEq, Eq, PartialOrd, Ord)] -/// Represent the indices of various components in the readline preset. pub enum Index { Title = 0, Readline = 1, @@ -88,7 +88,6 @@ impl Default for Readline { foreground_color: Some(Color::DarkGreen), ..Default::default() }, - active_char_style: ContentStyle { background_color: Some(Color::DarkCyan), ..Default::default()