From dda4de0677cb4065cad5d5e803af3f7ef80cc3ad Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 08:08:55 +0900 Subject: [PATCH 01/19] fix: use workspace tokio --- promkit-derive/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 } From 0ebb066a0b8b729d0881dfff1690d38b81ce55a2 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 18:04:44 +0900 Subject: [PATCH 02/19] chore: init spinner --- promkit-widgets/Cargo.toml | 11 +++++--- promkit-widgets/src/lib.rs | 4 +++ promkit-widgets/src/spinner.rs | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 promkit-widgets/src/spinner.rs diff --git a/promkit-widgets/Cargo.toml b/promkit-widgets/Cargo.toml index 20ee9fac..5d44d632 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", "dep:async-trait"] text = [] texteditor = [] tree = [] @@ -21,9 +22,13 @@ tree = [] [dependencies] anyhow = { workspace = true } promkit-core = { path = "../promkit-core", version = "=0.2.0" } + +# Optional dependencies +async-trait = { workspace = true, optional = true } +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..b8221ca2 --- /dev/null +++ b/promkit-widgets/src/spinner.rs @@ -0,0 +1,47 @@ +use tokio::time::Duration; + +use crate::core::{Pane, grapheme::StyledGraphemes, render::SharedRenderer}; + +#[async_trait::async_trait] +pub trait State { + async fn is_idle(&self) -> bool; +} + +/// Spawn a background task that shows a spinner while the state is active. +pub async fn run( + spinner: &[String], + suffix: &str, + duration: Duration, + 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(duration); + + loop { + interval.tick().await; + + if !state.is_idle().await { + frame_index = (frame_index + 1) % spinner.len(); + + renderer + .update([( + index.clone(), + Pane::new( + vec![StyledGraphemes::from(format!( + "{} {}", + spinner[frame_index], suffix + ))], + 0, + ), + )]) + .render() + .await?; + } + } +} From 40555fbbcbe1f9626fed7c018646c3abf9469a10 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 18:11:09 +0900 Subject: [PATCH 03/19] example: Bring Your Own Prompt --- examples/byop/Cargo.toml | 14 ++++++++++++++ examples/byop/src/byop.rs | 4 ++++ 2 files changed, 18 insertions(+) create mode 100644 examples/byop/Cargo.toml create mode 100644 examples/byop/src/byop.rs diff --git a/examples/byop/Cargo.toml b/examples/byop/Cargo.toml new file mode 100644 index 00000000..81954baf --- /dev/null +++ b/examples/byop/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "byop" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = { workspace = true } +promkit-widgets = { path = "../../promkit-widgets", features = ["texteditor"] } +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..3239eee8 --- /dev/null +++ b/examples/byop/src/byop.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + Ok(()) +} From 1a2694b03728520475aabbe6d89fa22a1622ba26 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 20:26:41 +0900 Subject: [PATCH 04/19] chore: define frames --- examples/byop/Cargo.toml | 2 +- promkit-widgets/src/spinner.rs | 5 ++++- promkit-widgets/src/spinner/frame.rs | 31 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 promkit-widgets/src/spinner/frame.rs diff --git a/examples/byop/Cargo.toml b/examples/byop/Cargo.toml index 81954baf..2e895168 100644 --- a/examples/byop/Cargo.toml +++ b/examples/byop/Cargo.toml @@ -6,7 +6,7 @@ publish = false [dependencies] anyhow = { workspace = true } -promkit-widgets = { path = "../../promkit-widgets", features = ["texteditor"] } +promkit-widgets = { path = "../../promkit-widgets", features = ["spinner", "texteditor"] } tokio = { workspace = true } [[bin]] diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs index b8221ca2..066a29dd 100644 --- a/promkit-widgets/src/spinner.rs +++ b/promkit-widgets/src/spinner.rs @@ -2,6 +2,9 @@ use tokio::time::Duration; use crate::core::{Pane, grapheme::StyledGraphemes, render::SharedRenderer}; +pub mod frame; +use frame::Frame; + #[async_trait::async_trait] pub trait State { async fn is_idle(&self) -> bool; @@ -9,7 +12,7 @@ pub trait State { /// Spawn a background task that shows a spinner while the state is active. pub async fn run( - spinner: &[String], + spinner: Frame, suffix: &str, duration: Duration, state: S, diff --git a/promkit-widgets/src/spinner/frame.rs b/promkit-widgets/src/spinner/frame.rs new file mode 100644 index 00000000..afad6f1c --- /dev/null +++ b/promkit-widgets/src/spinner/frame.rs @@ -0,0 +1,31 @@ +use std::sync::LazyLock; + +pub type Frame = Vec<&'static str>; + +pub static DOTS: LazyLock = + LazyLock::new(|| vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + +pub const HAMBURGER: LazyLock = LazyLock::new(|| vec!["☱", "☲", "☴"]); + +pub const ARC: LazyLock = LazyLock::new(|| vec!["◜", "◠", "◝", "◞", "◡", "◟"]); + +pub const CIRCLE: LazyLock = LazyLock::new(|| vec!["◡", "⊙", "◠"]); + +pub const SQUARE_CORNERS: LazyLock = LazyLock::new(|| vec!["◰", "◳", "◲", "◱"]); + +pub const CIRCLE_QUARTERS: LazyLock = LazyLock::new(|| vec!["◴", "◷", "◶", "◵"]); + +pub const CIRCLE_HALVES: LazyLock = LazyLock::new(|| vec!["◐", "◓", "◑", "◒"]); + +pub const TOGGLE: LazyLock = LazyLock::new(|| vec!["⊶", "⊷"]); + +pub const CLOCK: LazyLock = LazyLock::new(|| { + vec![ + "🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", + ] +}); + +pub const EARTH: LazyLock = LazyLock::new(|| vec!["🌍", "🌎", "🌏"]); + +pub const MOON: LazyLock = + LazyLock::new(|| vec!["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"]); From 23205de680536e961fab0652e4a7a417488d9975 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 20:43:07 +0900 Subject: [PATCH 05/19] fix: order derive and docstring --- promkit/src/preset/readline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/promkit/src/preset/readline.rs b/promkit/src/preset/readline.rs index 8ecd6bca..a6d91e1b 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, From fd53b86e2eae92d21ffd23f49c7687c0efb45f13 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 20:57:58 +0900 Subject: [PATCH 06/19] fix: remove redundant space --- promkit/src/preset/readline.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/promkit/src/preset/readline.rs b/promkit/src/preset/readline.rs index a6d91e1b..9ef3c001 100644 --- a/promkit/src/preset/readline.rs +++ b/promkit/src/preset/readline.rs @@ -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() From df847b8ea0a11708d153598cd444d2b30b6324cd Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 22:01:01 +0900 Subject: [PATCH 07/19] chore: complete byop --- examples/byop/Cargo.toml | 2 +- examples/byop/src/byop.rs | 301 ++++++++++++++++++++++++++++++++- promkit-widgets/src/spinner.rs | 1 - promkit/src/lib.rs | 1 + 4 files changed, 302 insertions(+), 3 deletions(-) diff --git a/examples/byop/Cargo.toml b/examples/byop/Cargo.toml index 2e895168..08247680 100644 --- a/examples/byop/Cargo.toml +++ b/examples/byop/Cargo.toml @@ -6,7 +6,7 @@ publish = false [dependencies] anyhow = { workspace = true } -promkit-widgets = { path = "../../promkit-widgets", features = ["spinner", "texteditor"] } +promkit = { path = "../../promkit" } tokio = { workspace = true } [[bin]] diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 3239eee8..c18b0555 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -1,4 +1,303 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use promkit::{ + async_trait, + core::crossterm::{self, style::Color}, + widgets::{ + core::{ + crossterm::{ + event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + style::ContentStyle, + }, + render::{Renderer, SharedRenderer}, + PaneFactory, + }, + spinner, text, text_editor, + }, + Prompt, Signal, +}; +use tokio::sync::RwLock; + +/// BYOP (Bring Your Own Preset) example for promkit. + +/// Represents the indices of various components in BYOP. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Index { + Readline = 0, + Spinner = 1, + Result = 2, +} + +/// Represents the state of the task. +#[derive(Clone, PartialEq, Eq)] +enum TaskState { + Idle, + Running, +} + +/// Shared state for the task, allowing concurrent access. +#[derive(Clone)] +struct SharedTaskState(Arc>); + +impl spinner::State for SharedTaskState { + async fn is_idle(&self) -> bool { + *self.0.read().await == TaskState::Idle + } +} + +/// Bring Your Own Prompt +struct BYOP { + renderer: SharedRenderer, + task_state: SharedTaskState, + readline: text_editor::State, + result: text::State, +} + +#[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(); + + // 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 result = text::State::default(); + + Ok(Self { + renderer: SharedRenderer::new( + Renderer::try_new_with_panes( + [ + (Index::Readline, readline.create_pane(size.0, size.1)), + (Index::Result, result.create_pane(size.0, size.1)), + ], + true, + ) + .await?, + ), + task_state: SharedTaskState(Arc::new(RwLock::new(TaskState::Idle))), + readline, + result, + }) + } + + async fn heavy_task(&self) -> anyhow::Result<()> { + { + let mut task_state = self.task_state.0.write().await; + *task_state = TaskState::Running; + }; + // NOTE: Simulating a heavy task with a sleep. + tokio::time::sleep(Duration::from_secs(5)).await; + { + let mut task_state = self.task_state.0.write().await; + *task_state = TaskState::Idle; + }; + 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?; + } + + // Run the heavy task. + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => { + self.heavy_task().await?; + // For representing the end of the prompt, + // reset the style of the cursor to default. + self.readline.active_char_style = ContentStyle::default(); + return Ok(Signal::Quit); + } + + // 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)), + (Index::Result, self.result.create_pane(width, height)), + ]) + .render() + .await + } + + async fn spawn(&mut self) -> anyhow::Result<()> { + let task_state = self.task_state.clone(); + let renderer = self.renderer.clone(); + let spinner_task = tokio::spawn(async move { + spinner::run( + spinner::frame::DOTS.clone(), + "Executing...", + Duration::from_millis(100), + task_state.clone(), + Index::Spinner, + renderer.clone(), + ) + .await + }); + + let ret = self.run().await; + spinner_task.abort(); + ret?; + Ok(()) + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { - Ok(()) + loop { + BYOP::try_default().await?.spawn().await? + } } diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs index 066a29dd..032997cd 100644 --- a/promkit-widgets/src/spinner.rs +++ b/promkit-widgets/src/spinner.rs @@ -5,7 +5,6 @@ use crate::core::{Pane, grapheme::StyledGraphemes, render::SharedRenderer}; pub mod frame; use frame::Frame; -#[async_trait::async_trait] pub trait State { async fn is_idle(&self) -> bool; } diff --git a/promkit/src/lib.rs b/promkit/src/lib.rs index 9a0e0d68..939eb1e7 100644 --- a/promkit/src/lib.rs +++ b/promkit/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] +pub use async_trait; pub use promkit_widgets as widgets; pub use promkit_widgets::core; From b8cbd79edbaabb8b143083b4e9e75a5fa93e45b5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 22:10:26 +0900 Subject: [PATCH 08/19] chore: output result --- examples/byop/src/byop.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index c18b0555..3542bb9a 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -12,7 +12,7 @@ use promkit::{ render::{Renderer, SharedRenderer}, PaneFactory, }, - spinner, text, text_editor, + spinner, text::{self, Text}, text_editor, }, Prompt, Signal, }; @@ -115,13 +115,21 @@ impl BYOP { }) } - async fn heavy_task(&self) -> anyhow::Result<()> { + async fn heavy_task(&mut self) -> anyhow::Result<()> { { let mut task_state = self.task_state.0.write().await; *task_state = TaskState::Running; }; + // NOTE: Simulating a heavy task with a sleep. tokio::time::sleep(Duration::from_secs(5)).await; + + // Update the result state after the task is done. + self.result.text = Text::from(format!( + "result: {}", + self.readline.texteditor.text_without_cursor() + )); + { let mut task_state = self.task_state.0.write().await; *task_state = TaskState::Idle; From 1d6a873cdf2d68ed39d6a48a57a053260c97bb51 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 22:53:11 +0900 Subject: [PATCH 09/19] chore: complete byop (re) --- examples/byop/src/byop.rs | 118 +++++++++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 35 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 3542bb9a..81fd3819 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -1,8 +1,14 @@ use std::{collections::HashSet, sync::Arc, time::Duration}; +use anyhow::Result; use promkit::{ async_trait, - core::crossterm::{self, style::Color}, + core::{ + crossterm::{self, style::Color}, + grapheme::StyledGraphemes, + pane::EMPTY_PANE, + Pane, + }, widgets::{ core::{ crossterm::{ @@ -12,7 +18,7 @@ use promkit::{ render::{Renderer, SharedRenderer}, PaneFactory, }, - spinner, text::{self, Text}, text_editor, + spinner, text_editor, }, Prompt, Signal, }; @@ -50,7 +56,7 @@ struct BYOP { renderer: SharedRenderer, task_state: SharedTaskState, readline: text_editor::State, - result: text::State, + task_handle: Option>>, } #[async_trait::async_trait] @@ -71,6 +77,11 @@ impl Prompt for BYOP { fn finalize(&mut self) -> anyhow::Result { let ret = self.readline.texteditor.text_without_cursor().to_string(); + // Clean up any running task + if let Some(handle) = &self.task_handle { + handle.abort(); + } + // Reset the text editor state for the next prompt. self.readline.texteditor.erase_all(); @@ -96,44 +107,70 @@ impl BYOP { ..Default::default() }; - let result = text::State::default(); - Ok(Self { renderer: SharedRenderer::new( Renderer::try_new_with_panes( - [ - (Index::Readline, readline.create_pane(size.0, size.1)), - (Index::Result, result.create_pane(size.0, size.1)), - ], + [(Index::Readline, readline.create_pane(size.0, size.1))], true, ) .await?, ), task_state: SharedTaskState(Arc::new(RwLock::new(TaskState::Idle))), readline, - result, + task_handle: None, }) } - async fn heavy_task(&mut self) -> anyhow::Result<()> { - { - let mut task_state = self.task_state.0.write().await; - *task_state = TaskState::Running; - }; + async fn start_heavy_task(&mut self) -> anyhow::Result<()> { + // Check if task is already running + if *self.task_state.0.read().await == TaskState::Running { + return Ok(()); + } - // NOTE: Simulating a heavy task with a sleep. - tokio::time::sleep(Duration::from_secs(5)).await; + // Clear previous result and show spinner + self.renderer + .update([(Index::Result, EMPTY_PANE.clone())]) + .render() + .await?; - // Update the result state after the task is done. - self.result.text = Text::from(format!( - "result: {}", - self.readline.texteditor.text_without_cursor() - )); + let input_text = self.readline.texteditor.text_without_cursor().to_string(); + let task_state = self.task_state.clone(); + let renderer = self.renderer.clone(); { - let mut task_state = self.task_state.0.write().await; - *task_state = TaskState::Idle; - }; + let mut state = task_state.0.write().await; + *state = TaskState::Running; + } + + let handle = tokio::spawn(async move { + // NOTE: Simulating a heavy task with a sleep. + tokio::time::sleep(Duration::from_secs(5)).await; + + // Set task state to idle + { + let mut state = task_state.0.write().await; + *state = TaskState::Idle; + } + + // Trigger a render to show the result + renderer + .update([ + (Index::Spinner, EMPTY_PANE.clone()), + ( + Index::Result, + Pane::new( + vec![StyledGraphemes::from(format!("result: {}", input_text,))], + 0, + ), + ), + ]) + .render() + .await?; + + Ok(input_text) + }); + + self.task_handle = Some(handle); Ok(()) } @@ -144,18 +181,32 @@ impl BYOP { self.render(*width, *height).await?; } - // Run the heavy task. + // Handle Enter key based on task state Event::Key(KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) => { - self.heavy_task().await?; - // For representing the end of the prompt, - // reset the style of the cursor to default. - self.readline.active_char_style = ContentStyle::default(); - return Ok(Signal::Quit); + let current_state = self.task_state.0.read().await.clone(); + match current_state { + TaskState::Idle => { + // Start the heavy task in background + self.start_heavy_task().await?; + } + TaskState::Running => { + self.renderer + .update([( + Index::Result, + Pane::new( + vec![StyledGraphemes::from("Task is currently running...")], + 0, + ), + )]) + .render() + .await?; + } + } } // Quit @@ -273,10 +324,7 @@ impl BYOP { async fn render(&mut self, width: u16, height: u16) -> anyhow::Result<()> { self.renderer - .update([ - (Index::Readline, self.readline.create_pane(width, height)), - (Index::Result, self.result.create_pane(width, height)), - ]) + .update([(Index::Readline, self.readline.create_pane(width, height))]) .render() .await } From d92b133d19351f29274dc70c72ba168d78cd2d5e Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 23:09:47 +0900 Subject: [PATCH 10/19] example: remove TaskState --- examples/byop/src/byop.rs | 84 +++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 48 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 81fd3819..ae318d82 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -34,29 +34,21 @@ enum Index { Result = 2, } -/// Represents the state of the task. -#[derive(Clone, PartialEq, Eq)] -enum TaskState { - Idle, - Running, -} - -/// Shared state for the task, allowing concurrent access. +/// Shared task handle for managing task state. #[derive(Clone)] -struct SharedTaskState(Arc>); +struct SharedTaskHandle(Arc>>>>); -impl spinner::State for SharedTaskState { +impl spinner::State for SharedTaskHandle { async fn is_idle(&self) -> bool { - *self.0.read().await == TaskState::Idle + self.0.read().await.is_none() } } /// Bring Your Own Prompt struct BYOP { renderer: SharedRenderer, - task_state: SharedTaskState, + shared_task_handle: SharedTaskHandle, readline: text_editor::State, - task_handle: Option>>, } #[async_trait::async_trait] @@ -78,7 +70,7 @@ impl Prompt for BYOP { let ret = self.readline.texteditor.text_without_cursor().to_string(); // Clean up any running task - if let Some(handle) = &self.task_handle { + if let Some(handle) = self.shared_task_handle.0.blocking_read().as_ref() { handle.abort(); } @@ -115,15 +107,14 @@ impl BYOP { ) .await?, ), - task_state: SharedTaskState(Arc::new(RwLock::new(TaskState::Idle))), + shared_task_handle: SharedTaskHandle(Arc::new(RwLock::new(None))), readline, - task_handle: None, }) } async fn start_heavy_task(&mut self) -> anyhow::Result<()> { // Check if task is already running - if *self.task_state.0.read().await == TaskState::Running { + if self.shared_task_handle.0.read().await.is_some() { return Ok(()); } @@ -134,22 +125,17 @@ impl BYOP { .await?; let input_text = self.readline.texteditor.text_without_cursor().to_string(); - let task_state = self.task_state.clone(); + let shared_task_handle = self.shared_task_handle.clone(); let renderer = self.renderer.clone(); - { - let mut state = task_state.0.write().await; - *state = TaskState::Running; - } - let handle = tokio::spawn(async move { // NOTE: Simulating a heavy task with a sleep. - tokio::time::sleep(Duration::from_secs(5)).await; + tokio::time::sleep(Duration::from_secs(3)).await; - // Set task state to idle + // Clear the task handle to indicate completion { - let mut state = task_state.0.write().await; - *state = TaskState::Idle; + let mut handle_guard = shared_task_handle.0.write().await; + *handle_guard = None; } // Trigger a render to show the result @@ -170,7 +156,12 @@ impl BYOP { Ok(input_text) }); - self.task_handle = Some(handle); + // Store the handle + { + let mut handle_guard = self.shared_task_handle.0.write().await; + *handle_guard = Some(handle); + } + Ok(()) } @@ -188,24 +179,21 @@ impl BYOP { kind: KeyEventKind::Press, state: KeyEventState::NONE, }) => { - let current_state = self.task_state.0.read().await.clone(); - match current_state { - TaskState::Idle => { - // Start the heavy task in background - self.start_heavy_task().await?; - } - TaskState::Running => { - self.renderer - .update([( - Index::Result, - Pane::new( - vec![StyledGraphemes::from("Task is currently running...")], - 0, - ), - )]) - .render() - .await?; - } + let is_running = self.shared_task_handle.0.read().await.is_some(); + 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?; } } @@ -330,14 +318,14 @@ impl BYOP { } async fn spawn(&mut self) -> anyhow::Result<()> { - let task_state = self.task_state.clone(); + let shared_task_handle = self.shared_task_handle.clone(); let renderer = self.renderer.clone(); let spinner_task = tokio::spawn(async move { spinner::run( spinner::frame::DOTS.clone(), "Executing...", Duration::from_millis(100), - task_state.clone(), + shared_task_handle.clone(), Index::Spinner, renderer.clone(), ) From b8fd49ad6258f7411e294d28ebd5e4dd3f449cd5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 14 Jul 2025 23:13:18 +0900 Subject: [PATCH 11/19] chore: remove async-trait from spinner --- promkit-widgets/Cargo.toml | 3 +-- promkit-widgets/src/spinner.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/promkit-widgets/Cargo.toml b/promkit-widgets/Cargo.toml index 5d44d632..0c057178 100644 --- a/promkit-widgets/Cargo.toml +++ b/promkit-widgets/Cargo.toml @@ -14,7 +14,7 @@ all = ["checkbox", "jsonstream", "listbox", "spinner", "text", "texteditor", "tr checkbox = ["listbox"] jsonstream = ["dep:serde", "dep:serde_json", "dep:rayon"] listbox = [] -spinner = ["dep:tokio", "dep:async-trait"] +spinner = ["dep:tokio"] text = [] texteditor = [] tree = [] @@ -24,7 +24,6 @@ anyhow = { workspace = true } promkit-core = { path = "../promkit-core", version = "=0.2.0" } # Optional dependencies -async-trait = { workspace = true, optional = true } rayon = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs index 032997cd..bd551ea8 100644 --- a/promkit-widgets/src/spinner.rs +++ b/promkit-widgets/src/spinner.rs @@ -1,4 +1,4 @@ -use tokio::time::Duration; +use std::time::Duration; use crate::core::{Pane, grapheme::StyledGraphemes, render::SharedRenderer}; @@ -6,7 +6,7 @@ pub mod frame; use frame::Frame; pub trait State { - async fn is_idle(&self) -> bool; + fn is_idle(&self) -> impl Future + Send; } /// Spawn a background task that shows a spinner while the state is active. From a773c336ff384c7e2665941eb4402012fb0e83cf Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 00:02:26 +0900 Subject: [PATCH 12/19] chore: define monitor to monitor the spawned task --- examples/byop/src/byop.rs | 149 ++++++++++++++++++++++++++++---------- 1 file changed, 112 insertions(+), 37 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index ae318d82..3adb1d69 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -22,7 +22,10 @@ use promkit::{ }, Prompt, Signal, }; -use tokio::sync::RwLock; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; /// BYOP (Bring Your Own Preset) example for promkit. @@ -34,9 +37,92 @@ enum Index { Result = 2, } +/// Task events for monitor +#[derive(Debug)] +enum TaskEvent { + TaskStarted { handle: JoinHandle> }, + TaskCompleted { result: Result }, +} + +/// Task monitor daemon for managing background tasks +struct TaskMonitor { + event_sender: mpsc::UnboundedSender, + _monitor_handle: JoinHandle<()>, +} + +impl TaskMonitor { + fn new(renderer: SharedRenderer, shared_task_handle: SharedTaskHandle) -> Self { + let (event_sender, mut event_receiver) = mpsc::unbounded_channel(); + + // 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 spinner state management + { + let mut handle_guard = shared_task_handle.0.write().await; + *handle_guard = Some(handle); + } + } + TaskEvent::TaskCompleted { result } => { + // Clear the task handle to indicate completion + { + let mut handle_guard = shared_task_handle.0.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: monitor_handle, + } + } +} + /// Shared task handle for managing task state. #[derive(Clone)] -struct SharedTaskHandle(Arc>>>>); +struct SharedTaskHandle(Arc>>>>); impl spinner::State for SharedTaskHandle { async fn is_idle(&self) -> bool { @@ -48,6 +134,7 @@ impl spinner::State for SharedTaskHandle { struct BYOP { renderer: SharedRenderer, shared_task_handle: SharedTaskHandle, + task_monitor: TaskMonitor, readline: text_editor::State, } @@ -99,15 +186,21 @@ impl BYOP { ..Default::default() }; + let renderer = SharedRenderer::new( + Renderer::try_new_with_panes( + [(Index::Readline, readline.create_pane(size.0, size.1))], + true, + ) + .await?, + ); + + let shared_task_handle = SharedTaskHandle(Arc::new(RwLock::new(None))); + let task_monitor = TaskMonitor::new(renderer.clone(), shared_task_handle.clone()); + Ok(Self { - renderer: SharedRenderer::new( - Renderer::try_new_with_panes( - [(Index::Readline, readline.create_pane(size.0, size.1))], - true, - ) - .await?, - ), - shared_task_handle: SharedTaskHandle(Arc::new(RwLock::new(None))), + renderer, + shared_task_handle, + task_monitor, readline, }) } @@ -125,42 +218,24 @@ impl BYOP { .await?; let input_text = self.readline.texteditor.text_without_cursor().to_string(); - let shared_task_handle = self.shared_task_handle.clone(); - let renderer = self.renderer.clone(); + 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; - // Clear the task handle to indicate completion - { - let mut handle_guard = shared_task_handle.0.write().await; - *handle_guard = None; - } - - // Trigger a render to show the result - renderer - .update([ - (Index::Spinner, EMPTY_PANE.clone()), - ( - Index::Result, - Pane::new( - vec![StyledGraphemes::from(format!("result: {}", input_text,))], - 0, - ), - ), - ]) - .render() - .await?; + // Notify completion through the task monitor + let result = Ok(input_text.clone()); + let _ = task_monitor.send(TaskEvent::TaskCompleted { result }); Ok(input_text) }); - // Store the handle - { - let mut handle_guard = self.shared_task_handle.0.write().await; - *handle_guard = Some(handle); - } + // Send task started event to monitor for handle management + let _ = self + .task_monitor + .event_sender + .send(TaskEvent::TaskStarted { handle }); Ok(()) } From 1f870e34a8c2f9aa714a9edf3015251dde781341 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 00:12:20 +0900 Subject: [PATCH 13/19] chore: more simple for SharedTaskHandle --- examples/byop/src/byop.rs | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 3adb1d69..7b2e9ef3 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -48,29 +48,42 @@ enum TaskEvent { struct TaskMonitor { event_sender: mpsc::UnboundedSender, _monitor_handle: JoinHandle<()>, + _task_handle: Arc>>>>, } impl TaskMonitor { fn new(renderer: SharedRenderer, shared_task_handle: SharedTaskHandle) -> Self { let (event_sender, mut event_receiver) = mpsc::unbounded_channel(); + let task_handle = Arc::new(RwLock::new(None)); + let task_handle_clone = 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 spinner state management + // Store the handle for task management { - let mut handle_guard = shared_task_handle.0.write().await; + let mut handle_guard = task_handle_clone.write().await; *handle_guard = Some(handle); } + // Update shared state to indicate task is running + { + let mut running_guard = shared_task_handle.0.write().await; + *running_guard = true; + } } TaskEvent::TaskCompleted { result } => { - // Clear the task handle to indicate completion + // Clear the task handle { - let mut handle_guard = shared_task_handle.0.write().await; + let mut handle_guard = task_handle_clone.write().await; *handle_guard = None; } + // Update shared state to indicate task is idle + { + let mut running_guard = shared_task_handle.0.write().await; + *running_guard = false; + } // Update UI based on result match result { @@ -116,17 +129,30 @@ impl TaskMonitor { Self { event_sender, _monitor_handle: monitor_handle, + _task_handle: task_handle, + } + } + + fn abort_task(&self) { + if let Some(handle) = self._task_handle.blocking_read().as_ref() { + handle.abort(); } } } -/// Shared task handle for managing task state. +/// Shared task state for managing task execution status. #[derive(Clone)] -struct SharedTaskHandle(Arc>>>>); +struct SharedTaskHandle(Arc>); + +impl SharedTaskHandle { + fn new() -> Self { + Self(Arc::new(RwLock::new(false))) + } +} impl spinner::State for SharedTaskHandle { async fn is_idle(&self) -> bool { - self.0.read().await.is_none() + !*self.0.read().await } } @@ -157,9 +183,7 @@ impl Prompt for BYOP { let ret = self.readline.texteditor.text_without_cursor().to_string(); // Clean up any running task - if let Some(handle) = self.shared_task_handle.0.blocking_read().as_ref() { - handle.abort(); - } + self.task_monitor.abort_task(); // Reset the text editor state for the next prompt. self.readline.texteditor.erase_all(); @@ -194,7 +218,7 @@ impl BYOP { .await?, ); - let shared_task_handle = SharedTaskHandle(Arc::new(RwLock::new(None))); + let shared_task_handle = SharedTaskHandle::new(); let task_monitor = TaskMonitor::new(renderer.clone(), shared_task_handle.clone()); Ok(Self { @@ -207,7 +231,7 @@ impl BYOP { async fn start_heavy_task(&mut self) -> anyhow::Result<()> { // Check if task is already running - if self.shared_task_handle.0.read().await.is_some() { + if *self.shared_task_handle.0.read().await { return Ok(()); } @@ -254,7 +278,7 @@ impl BYOP { kind: KeyEventKind::Press, state: KeyEventState::NONE, }) => { - let is_running = self.shared_task_handle.0.read().await.is_some(); + let is_running = *self.shared_task_handle.0.read().await; if !is_running { // Start the heavy task in background self.start_heavy_task().await?; From 9dc3a2c9d49c6d049759c78c0bc3b4124b33ea73 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 01:00:41 +0900 Subject: [PATCH 14/19] chore: share TaskMonitor between prompt and spinner --- examples/byop/src/byop.rs | 95 +++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 7b2e9ef3..b250f279 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -1,3 +1,4 @@ +/// BYOP (Bring Your Own Preset) example for promkit. use std::{collections::HashSet, sync::Arc, time::Duration}; use anyhow::Result; @@ -18,7 +19,8 @@ use promkit::{ render::{Renderer, SharedRenderer}, PaneFactory, }, - spinner, text_editor, + spinner::{self, State}, + text_editor, }, Prompt, Signal, }; @@ -27,8 +29,6 @@ use tokio::{ task::JoinHandle, }; -/// BYOP (Bring Your Own Preset) example for promkit. - /// Represents the indices of various components in BYOP. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] enum Index { @@ -47,15 +47,30 @@ enum TaskEvent { /// Task monitor daemon for managing background tasks struct TaskMonitor { event_sender: mpsc::UnboundedSender, - _monitor_handle: JoinHandle<()>, - _task_handle: Arc>>>>, + 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, shared_task_handle: SharedTaskHandle) -> Self { + 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_clone = task_handle.clone(); + let task_handle_internal = task_handle.clone(); // Event handling daemon let monitor_handle = tokio::spawn(async move { @@ -64,26 +79,16 @@ impl TaskMonitor { TaskEvent::TaskStarted { handle } => { // Store the handle for task management { - let mut handle_guard = task_handle_clone.write().await; + let mut handle_guard = task_handle_internal.write().await; *handle_guard = Some(handle); } - // Update shared state to indicate task is running - { - let mut running_guard = shared_task_handle.0.write().await; - *running_guard = true; - } } TaskEvent::TaskCompleted { result } => { // Clear the task handle { - let mut handle_guard = task_handle_clone.write().await; + let mut handle_guard = task_handle_internal.write().await; *handle_guard = None; } - // Update shared state to indicate task is idle - { - let mut running_guard = shared_task_handle.0.write().await; - *running_guard = false; - } // Update UI based on result match result { @@ -128,39 +133,27 @@ impl TaskMonitor { Self { event_sender, - _monitor_handle: monitor_handle, - _task_handle: task_handle, + 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() { + if let Some(handle) = self.task_handle.blocking_read().as_ref() { handle.abort(); } } } -/// Shared task state for managing task execution status. -#[derive(Clone)] -struct SharedTaskHandle(Arc>); - -impl SharedTaskHandle { - fn new() -> Self { - Self(Arc::new(RwLock::new(false))) - } -} - -impl spinner::State for SharedTaskHandle { - async fn is_idle(&self) -> bool { - !*self.0.read().await - } -} - /// Bring Your Own Prompt struct BYOP { renderer: SharedRenderer, - shared_task_handle: SharedTaskHandle, - task_monitor: TaskMonitor, + task_monitor: Arc, readline: text_editor::State, } @@ -182,8 +175,8 @@ impl Prompt for BYOP { fn finalize(&mut self) -> anyhow::Result { let ret = self.readline.texteditor.text_without_cursor().to_string(); - // Clean up any running task - self.task_monitor.abort_task(); + // 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(); @@ -218,20 +211,16 @@ impl BYOP { .await?, ); - let shared_task_handle = SharedTaskHandle::new(); - let task_monitor = TaskMonitor::new(renderer.clone(), shared_task_handle.clone()); - Ok(Self { + task_monitor: Arc::new(TaskMonitor::new(renderer.clone())), renderer, - shared_task_handle, - task_monitor, readline, }) } async fn start_heavy_task(&mut self) -> anyhow::Result<()> { // Check if task is already running - if *self.shared_task_handle.0.read().await { + if !self.task_monitor.is_idle().await { return Ok(()); } @@ -278,7 +267,7 @@ impl BYOP { kind: KeyEventKind::Press, state: KeyEventState::NONE, }) => { - let is_running = *self.shared_task_handle.0.read().await; + let is_running = !self.task_monitor.is_idle().await; if !is_running { // Start the heavy task in background self.start_heavy_task().await?; @@ -417,14 +406,14 @@ impl BYOP { } async fn spawn(&mut self) -> anyhow::Result<()> { - let shared_task_handle = self.shared_task_handle.clone(); let renderer = self.renderer.clone(); + let task_monitor = Arc::clone(&self.task_monitor); let spinner_task = tokio::spawn(async move { spinner::run( spinner::frame::DOTS.clone(), "Executing...", Duration::from_millis(100), - shared_task_handle.clone(), + task_monitor.as_ref(), Index::Spinner, renderer.clone(), ) @@ -440,7 +429,5 @@ impl BYOP { #[tokio::main] async fn main() -> anyhow::Result<()> { - loop { - BYOP::try_default().await?.spawn().await? - } + BYOP::try_default().await?.spawn().await } From f7fa8075365b0a7b4ba9eaa6f4750158b60751a3 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 01:17:45 +0900 Subject: [PATCH 15/19] docs: add spinner and byop sections --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0e5b36..4abfd615 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 (Bring 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 From 45d1c5487ba052d984935ab5511ea19860cbe702 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 01:24:13 +0900 Subject: [PATCH 16/19] docs: docstring about BYOP --- CHANGELOG.md | 2 +- examples/byop/src/byop.rs | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abfd615..18818624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 (Bring Your Own Preset) example**: Custom prompt implementation example +- **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 diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index b250f279..4ea75342 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -1,4 +1,18 @@ -/// BYOP (Bring Your Own Preset) example for promkit. +/// 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 anyhow::Result; @@ -150,7 +164,7 @@ impl TaskMonitor { } } -/// Bring Your Own Prompt +/// Build Your Own Prompt struct BYOP { renderer: SharedRenderer, task_monitor: Arc, From fc0b83723cbd6db579e6ec4b7c37737198153b4c Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 02:07:06 +0900 Subject: [PATCH 17/19] chore: define Spinner to set spinner configs --- examples/byop/src/byop.rs | 15 +++++++--- promkit-widgets/src/spinner.rs | 52 ++++++++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 4ea75342..9d43bf2a 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -169,6 +169,7 @@ struct BYOP { renderer: SharedRenderer, task_monitor: Arc, readline: text_editor::State, + spinner: Arc, } #[async_trait::async_trait] @@ -229,6 +230,12 @@ impl BYOP { task_monitor: Arc::new(TaskMonitor::new(renderer.clone())), renderer, readline, + spinner: Arc::new( + spinner::Spinner::default() + .frames(spinner::frame::DOTS.clone()) + .suffix("Executing...") + .duration(Duration::from_millis(100)), + ), }) } @@ -422,14 +429,14 @@ impl BYOP { 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::frame::DOTS.clone(), - "Executing...", - Duration::from_millis(100), + spinner.as_ref(), task_monitor.as_ref(), Index::Spinner, - renderer.clone(), + renderer, ) .await }); diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs index bd551ea8..14f10a61 100644 --- a/promkit-widgets/src/spinner.rs +++ b/promkit-widgets/src/spinner.rs @@ -5,15 +5,55 @@ 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.clone(), + 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: Frame, - suffix: &str, - duration: Duration, + spinner: &Spinner, state: S, index: I, renderer: SharedRenderer, @@ -23,13 +63,13 @@ where I: Clone + Ord + Send, { let mut frame_index = 0; - let mut interval = tokio::time::interval(duration); + let mut interval = tokio::time::interval(spinner.duration); loop { interval.tick().await; if !state.is_idle().await { - frame_index = (frame_index + 1) % spinner.len(); + frame_index = (frame_index + 1) % spinner.frames.len(); renderer .update([( @@ -37,7 +77,7 @@ where Pane::new( vec![StyledGraphemes::from(format!( "{} {}", - spinner[frame_index], suffix + spinner.frames[frame_index], spinner.suffix ))], 0, ), From 0765cbfdf06d23dcf92d3590c815da8c4bf1af33 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 02:32:17 +0900 Subject: [PATCH 18/19] chore: define Frame as &[] instead of LazyLock --- examples/byop/src/byop.rs | 2 +- promkit-widgets/src/spinner.rs | 2 +- promkit-widgets/src/spinner/frame.rs | 34 ++++++++++++---------------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 9d43bf2a..08520437 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -232,7 +232,7 @@ impl BYOP { readline, spinner: Arc::new( spinner::Spinner::default() - .frames(spinner::frame::DOTS.clone()) + .frames(spinner::frame::DOTS) .suffix("Executing...") .duration(Duration::from_millis(100)), ), diff --git a/promkit-widgets/src/spinner.rs b/promkit-widgets/src/spinner.rs index 14f10a61..8ced5181 100644 --- a/promkit-widgets/src/spinner.rs +++ b/promkit-widgets/src/spinner.rs @@ -24,7 +24,7 @@ pub struct Spinner { impl Default for Spinner { fn default() -> Self { Self { - frames: frame::DOTS.clone(), + frames: frame::DOTS, suffix: String::new(), duration: Duration::from_millis(100), } diff --git a/promkit-widgets/src/spinner/frame.rs b/promkit-widgets/src/spinner/frame.rs index afad6f1c..5292c076 100644 --- a/promkit-widgets/src/spinner/frame.rs +++ b/promkit-widgets/src/spinner/frame.rs @@ -1,31 +1,25 @@ -use std::sync::LazyLock; +pub type Frame = &'static [&'static str]; -pub type Frame = Vec<&'static str>; +pub const DOTS: Frame = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -pub static DOTS: LazyLock = - LazyLock::new(|| vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); +pub const HAMBURGER: Frame = &["☱", "☲", "☴"]; -pub const HAMBURGER: LazyLock = LazyLock::new(|| vec!["☱", "☲", "☴"]); +pub const ARC: Frame = &["◜", "◠", "◝", "◞", "◡", "◟"]; -pub const ARC: LazyLock = LazyLock::new(|| vec!["◜", "◠", "◝", "◞", "◡", "◟"]); +pub const CIRCLE: Frame = &["◡", "⊙", "◠"]; -pub const CIRCLE: LazyLock = LazyLock::new(|| vec!["◡", "⊙", "◠"]); +pub const SQUARE_CORNERS: Frame = &["◰", "◳", "◲", "◱"]; -pub const SQUARE_CORNERS: LazyLock = LazyLock::new(|| vec!["◰", "◳", "◲", "◱"]); +pub const CIRCLE_QUARTERS: Frame = &["◴", "◷", "◶", "◵"]; -pub const CIRCLE_QUARTERS: LazyLock = LazyLock::new(|| vec!["◴", "◷", "◶", "◵"]); +pub const CIRCLE_HALVES: Frame = &["◐", "◓", "◑", "◒"]; -pub const CIRCLE_HALVES: LazyLock = LazyLock::new(|| vec!["◐", "◓", "◑", "◒"]); +pub const TOGGLE: Frame = &["⊶", "⊷"]; -pub const TOGGLE: LazyLock = LazyLock::new(|| vec!["⊶", "⊷"]); +pub const CLOCK: Frame = &[ + "🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", +]; -pub const CLOCK: LazyLock = LazyLock::new(|| { - vec![ - "🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", - ] -}); +pub const EARTH: Frame = &["🌍", "🌎", "🌏"]; -pub const EARTH: LazyLock = LazyLock::new(|| vec!["🌍", "🌎", "🌏"]); - -pub const MOON: LazyLock = - LazyLock::new(|| vec!["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"]); +pub const MOON: Frame = &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"]; From c58a1c009299940ea3c3b7cb889601f27f24855b Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 15 Jul 2025 02:54:22 +0900 Subject: [PATCH 19/19] chore: export anyhow crate from promkit --- examples/byop/Cargo.toml | 1 - examples/byop/src/byop.rs | 13 ++++++++----- promkit/src/lib.rs | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/byop/Cargo.toml b/examples/byop/Cargo.toml index 08247680..22e40727 100644 --- a/examples/byop/Cargo.toml +++ b/examples/byop/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" publish = false [dependencies] -anyhow = { workspace = true } promkit = { path = "../../promkit" } tokio = { workspace = true } diff --git a/examples/byop/src/byop.rs b/examples/byop/src/byop.rs index 08520437..76e39892 100644 --- a/examples/byop/src/byop.rs +++ b/examples/byop/src/byop.rs @@ -15,9 +15,8 @@ /// Enter to execute tasks. Use Ctrl+C to exit the prompt. use std::{collections::HashSet, sync::Arc, time::Duration}; -use anyhow::Result; use promkit::{ - async_trait, + anyhow, async_trait, core::{ crossterm::{self, style::Color}, grapheme::StyledGraphemes, @@ -54,15 +53,19 @@ enum Index { /// Task events for monitor #[derive(Debug)] enum TaskEvent { - TaskStarted { handle: JoinHandle> }, - TaskCompleted { result: Result }, + 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>>>>, + task_handle: Arc>>>>, } impl spinner::State for TaskMonitor { diff --git a/promkit/src/lib.rs b/promkit/src/lib.rs index 939eb1e7..91f1c795 100644 --- a/promkit/src/lib.rs +++ b/promkit/src/lib.rs @@ -1,6 +1,7 @@ #![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;