diff --git a/Cargo.lock b/Cargo.lock index a05cb12..d8e67de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,7 +516,7 @@ dependencies = [ "starbase", "starbase_shell", "starbase_utils", - "thiserror 2.0.4", + "thiserror 2.0.8", "tokio", "tracing", ] @@ -539,7 +539,7 @@ dependencies = [ "miette", "starbase", "starbase_console", - "thiserror 2.0.4", + "thiserror 2.0.8", "tokio", "tracing", ] @@ -1081,9 +1081,9 @@ dependencies = [ [[package]] name = "iocraft" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28afcf27882ff64c5270876fcbc6ceb702f1d5d543ed314d4934c978dceff9a4" +checksum = "6aa17731846833a741c47f7c4c7b319c8174f69c2c558dd916fe70495f804de7" dependencies = [ "any_key", "bitflags", @@ -1893,18 +1893,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.215" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", @@ -2122,7 +2122,7 @@ dependencies = [ "starbase_sandbox", "starbase_styles", "starbase_utils", - "thiserror 2.0.4", + "thiserror 2.0.8", "tracing", "xz2", "zip", @@ -2151,7 +2151,7 @@ dependencies = [ "async-trait", "miette", "starbase_macros", - "thiserror 2.0.4", + "thiserror 2.0.8", "tokio", ] @@ -2187,7 +2187,7 @@ dependencies = [ "serial_test", "starbase_sandbox", "sysinfo", - "thiserror 2.0.4", + "thiserror 2.0.8", "tracing", ] @@ -2220,7 +2220,7 @@ dependencies = [ "starbase_sandbox", "starbase_styles", "starbase_utils", - "thiserror 2.0.4", + "thiserror 2.0.8", "tokio", "toml", "tracing", @@ -2369,11 +2369,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.4" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" +checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" dependencies = [ - "thiserror-impl 2.0.4", + "thiserror-impl 2.0.8", ] [[package]] @@ -2389,9 +2389,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.4" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" +checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" dependencies = [ "proc-macro2", "quote", @@ -3229,7 +3229,7 @@ dependencies = [ "flate2", "indexmap", "memchr", - "thiserror 2.0.4", + "thiserror 2.0.8", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 29d02ba..93eb9da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,17 +6,18 @@ members = ["crates/*", "examples/*"] async-trait = "0.1.83" crossterm = "0.28.1" dirs = "5.0.1" -iocraft = "0.5.0" +iocraft = "0.5.1" +# iocraft = { git = "https://github.com/ccbrown/iocraft", branch = "main" } miette = "7.4.0" regex = { version = "1.11.1", default-features = false } relative-path = "1.9.3" reqwest = { version = "0.12.9", default-features = false } rustc-hash = "2.1.0" serial_test = "3.2.0" -serde = { version = "1.0.215", features = ["derive"] } +serde = { version = "1.0.216", features = ["derive"] } serde_json = "1.0.133" serde_yaml = "0.9.34" -thiserror = "2.0.4" +thiserror = "2.0.8" tokio = { version = "1.42.0", default-features = false, features = [ "io-util", "rt", diff --git a/crates/console/src/components/confirm.rs b/crates/console/src/components/confirm.rs index 42328c3..7246a63 100644 --- a/crates/console/src/components/confirm.rs +++ b/crates/console/src/components/confirm.rs @@ -1,5 +1,4 @@ use super::input_field::*; -use super::styled_text::*; use crate::ui::ConsoleTheme; use iocraft::prelude::*; @@ -12,7 +11,7 @@ pub struct ConfirmProps<'a> { pub no_char: char, pub yes_label: String, pub yes_char: char, - pub value: Option<&'a mut bool>, + pub on_confirm: Option<&'a mut bool>, } impl Default for ConfirmProps<'_> { @@ -25,7 +24,7 @@ impl Default for ConfirmProps<'_> { no_char: 'n', yes_label: "Yes".into(), yes_char: 'y', - value: None, + on_confirm: None, } } } @@ -74,9 +73,6 @@ pub fn Confirm<'a>(props: &mut ConfirmProps<'a>, mut hooks: Hooks) -> impl Into< error.set(Some(format!("Please press [{yes}] or [{no}] to confirm"))); } } - KeyCode::Esc => { - handle_confirm(false); - } KeyCode::Left | KeyCode::Up | KeyCode::BackTab => { set_focused(focused.get() - 1); } @@ -91,7 +87,7 @@ pub fn Confirm<'a>(props: &mut ConfirmProps<'a>, mut hooks: Hooks) -> impl Into< }); if should_exit.get() { - if let Some(outer_value) = &mut props.value { + if let Some(outer_value) = &mut props.on_confirm { **outer_value = confirmed.get(); } @@ -113,10 +109,11 @@ pub fn Confirm<'a>(props: &mut ConfirmProps<'a>, mut hooks: Hooks) -> impl Into< error: Some(error), footer: props.legend.then(|| { element! { - StyledText( - content: format!("{yes}/{no} confirm ⁃ ←/→ toggle ⁃ ent/spc select ⁃ esc cancel"), - style: Style::Muted - ) + InputLegend(legend: vec![ + (format!("{yes}/{no}"), "confirm".into()), + ("↔".into(), "toggle".into()), + ("↵".into(), "submit".into()), + ]) }.into_any() }) ) { @@ -162,5 +159,6 @@ pub fn Confirm<'a>(props: &mut ConfirmProps<'a>, mut hooks: Hooks) -> impl Into< } } } - }.into_any() + } + .into_any() } diff --git a/crates/console/src/components/input.rs b/crates/console/src/components/input.rs index 3abd4fb..6d673a6 100644 --- a/crates/console/src/components/input.rs +++ b/crates/console/src/components/input.rs @@ -10,7 +10,7 @@ pub struct InputProps<'a> { pub label: String, pub prefix_symbol: Option, pub validate: Validator<'static, String>, - pub value: Option<&'a mut String>, + pub on_value: Option<&'a mut String>, } #[component] @@ -18,7 +18,6 @@ pub fn Input<'a>(props: &mut InputProps<'a>, mut hooks: Hooks) -> impl Into(); let mut system = hooks.use_context_mut::(); let mut value = hooks.use_state(|| props.default_value.clone()); - let mut submitted = hooks.use_state(|| false); let mut should_exit = hooks.use_state(|| false); let mut error = hooks.use_state(|| None); @@ -26,34 +25,27 @@ pub fn Input<'a>(props: &mut InputProps<'a>, mut hooks: Hooks) -> impl Into { - match code { - KeyCode::Enter => { - if let Some(msg) = validate(value.to_string()) { - error.set(Some(msg)); - return; - } else { - error.set(None); - } - - submitted.set(true); - should_exit.set(true); - } - KeyCode::Esc => { - should_exit.set(true); - } - _ => {} + TerminalEvent::Key(KeyEvent { + code: KeyCode::Enter, + kind, + .. + }) if kind != KeyEventKind::Release => { + if let Some(msg) = validate(value.to_string()) { + error.set(Some(msg)); + return; + } else { + error.set(None); } + + should_exit.set(true); } _ => {} } }); if should_exit.get() { - if submitted.get() { - if let Some(outer_value) = &mut props.value { - **outer_value = value.to_string(); - } + if let Some(outer_value) = &mut props.on_value { + **outer_value = value.to_string(); } system.exit(); @@ -61,11 +53,7 @@ pub fn Input<'a>(props: &mut InputProps<'a>, mut hooks: Hooks) -> impl Into( } } } + +#[derive(Default, Props)] +pub struct InputLegendProps { + pub legend: Vec<(String, String)>, +} + +#[component] +pub fn InputLegend<'a>(props: &InputLegendProps) -> impl Into> { + element! { + StyledText( + content: props.legend + .iter() + .map(|(key, label)| format!("{key} {label}")) + .collect::>() + .join(" ⁃ "), + style: Style::Muted + ) + } +} diff --git a/crates/console/src/components/mod.rs b/crates/console/src/components/mod.rs index eb98ec0..61c3d16 100644 --- a/crates/console/src/components/mod.rs +++ b/crates/console/src/components/mod.rs @@ -8,6 +8,7 @@ mod map; mod notice; mod progress; mod section; +mod select; mod styled_text; mod table; @@ -20,6 +21,7 @@ pub use map::*; pub use notice::*; pub use progress::*; pub use section::*; +pub use select::*; pub use styled_text::*; pub use table::*; diff --git a/crates/console/src/components/progress.rs b/crates/console/src/components/progress.rs index a5a7c93..5349f51 100644 --- a/crates/console/src/components/progress.rs +++ b/crates/console/src/components/progress.rs @@ -1,6 +1,7 @@ use super::layout::Group; use super::styled_text::StyledText; use crate::ui::ConsoleTheme; +use crate::utils::estimator::Estimator; use crate::utils::formats::*; use flume::{Receiver, Sender}; use iocraft::prelude::*; @@ -8,12 +9,12 @@ use std::time::{Duration, Instant}; pub enum ProgressState { Exit, - Max(u32), + Max(u64), Message(String), Prefix(String), Suffix(String), Tick(Duration), - Value(u32), + Value(u64), } #[derive(Clone)] @@ -39,7 +40,7 @@ impl ProgressReporter { let _ = self.tx.send(state); } - pub fn set_max(&self, value: u32) { + pub fn set_max(&self, value: u64) { self.set(ProgressState::Max(value)); } @@ -59,7 +60,7 @@ impl ProgressReporter { self.set(ProgressState::Tick(value)); } - pub fn set_value(&self, value: u32) { + pub fn set_value(&self, value: u64) { self.set(ProgressState::Value(value)); } } @@ -67,13 +68,13 @@ impl ProgressReporter { #[derive(Props)] pub struct ProgressBarProps { pub bar_color: Option, - pub bar_width: i32, + pub bar_width: u32, pub char_filled: Option, pub char_position: Option, pub char_unfilled: Option, - pub default_max: i32, + pub default_max: u64, pub default_message: String, - pub default_value: i32, + pub default_value: u64, pub reporter: ProgressReporter, } @@ -103,8 +104,9 @@ pub fn ProgressBar<'a>( let mut prefix = hooks.use_state(String::new); let mut message = hooks.use_state(|| props.default_message.clone()); let mut suffix = hooks.use_state(String::new); - let mut max = hooks.use_state(|| props.default_max as u32); - let mut value = hooks.use_state(|| props.default_value as u32); + let mut max = hooks.use_state(|| props.default_max); + let mut value = hooks.use_state(|| props.default_value); + let mut estimator = hooks.use_state(Estimator::new); let mut should_exit = hooks.use_state(|| false); let started = hooks.use_state(Instant::now); @@ -143,12 +145,10 @@ pub fn ProgressBar<'a>( } }); - // This purely exists to trigger a re-render so that tokens within the - // message are dynamically updated with the latest information hooks.use_future(async move { loop { tokio::time::sleep(Duration::from_millis(150)).await; - max.set(max.get()); + estimator.write().record(value.get(), Instant::now()); } }); @@ -161,8 +161,8 @@ pub fn ProgressBar<'a>( .unwrap_or(theme.progress_bar_position_char); let bar_color = props.bar_color.unwrap_or(theme.progress_bar_color); let bar_percent = calculate_percent(value.get(), max.get()); - let bar_total_width = props.bar_width as u32; - let bar_filled_width = (bar_total_width as f32 * (bar_percent / 100.0)) as u32; + let bar_total_width = props.bar_width as u64; + let bar_filled_width = (bar_total_width as f64 * (bar_percent / 100.0)) as u64; let mut bar_unfilled_width = bar_total_width - bar_filled_width; // When theres a position to show, we need to reduce the unfilled bar by 1 @@ -178,7 +178,7 @@ pub fn ProgressBar<'a>( element! { Group(gap: 1) { - Box(width: Size::Length(bar_total_width)) { + Box(width: Size::Length(props.bar_width)) { Text( content: String::from(char_filled).repeat(bar_filled_width as usize), color: bar_color, @@ -204,7 +204,13 @@ pub fn ProgressBar<'a>( StyledText( content: format!( "{prefix}{}{suffix}", - get_message(message.read().as_str(), value.get(), max.get(), started.get()) + get_message(MessageData { + estimator: Some(estimator.read()), + max: max.get(), + message: message.read(), + started: started.get(), + value: value.get(), + }) ) ) } @@ -254,7 +260,7 @@ pub fn ProgressLoader<'a>( .loader_frames .clone() .unwrap_or_else(|| theme.progress_loader_frames.clone()); - let frames_total = frames.len() as u32; + let frames_total = frames.len(); hooks.use_future(async move { loop { @@ -284,14 +290,7 @@ pub fn ProgressLoader<'a>( hooks.use_future(async move { loop { tokio::time::sleep(tick_interval.get()).await; - - let next_index = frame_index.get() + 1; - - if next_index >= frames_total { - frame_index.set(0); - } else { - frame_index.set(next_index); - } + frame_index.set((frame_index + 1) % frames_total); } }); @@ -305,7 +304,7 @@ pub fn ProgressLoader<'a>( Group(gap: 1) { Box { Text( - content: &frames[frame_index.get() as usize], + content: &frames[frame_index.get()], color: props.loader_color.unwrap_or(theme.progress_loader_color), ) } @@ -313,12 +312,13 @@ pub fn ProgressLoader<'a>( StyledText( content: format!( "{prefix}{}{suffix}", - get_message( - message.read().as_str(), - frame_index.get(), - frames_total, - started.get() - ) + get_message(MessageData { + estimator: None, + max: frames_total as u64, + message: message.read(), + started: started.get(), + value: frame_index.get() as u64, + }) ) ) } @@ -327,85 +327,108 @@ pub fn ProgressLoader<'a>( .into_any() } -fn calculate_percent(value: u32, max: u32) -> f32 { - (max as f32 * (value as f32 / 100.0)).clamp(0.0, 100.0) +fn calculate_percent(value: u64, max: u64) -> f64 { + (max as f64 * (value as f64 / 100.0)).clamp(0.0, 100.0) } -// fn calculate_eta(value: u32, max: u32) -> Duration { -// let steps_per_second = 0.0; - -// if steps_per_second == 0.0 { -// return Duration::new(0, 0); -// } - -// let s = max.saturating_sub(value) as f64 / steps_per_second; -// let secs = s.trunc() as u64; -// let nanos = (s.fract() * 1_000_000_000f64) as u32; - -// Duration::new(secs, nanos) -// } +struct MessageData<'a> { + estimator: Option>, + max: u64, + message: StateRef<'a, String>, + started: Instant, + value: u64, +} -// TODO: eta, per_sec, duration -fn get_message(message: &str, value: u32, max: u32, started: Instant) -> String { - let mut message = message.to_owned(); +fn get_message(data: MessageData) -> String { + let mut message = data.message.to_owned(); if message.contains("{value}") { - message = message.replace("{value}", &value.to_string()); + message = message.replace("{value}", &data.value.to_string()); } if message.contains("{total}") { - message = message.replace("{total}", &max.to_string()); + message = message.replace("{total}", &data.max.to_string()); } if message.contains("{max}") { - message = message.replace("{max}", &max.to_string()); + message = message.replace("{max}", &data.max.to_string()); } if message.contains("{percent}") { - message = message.replace("{percent}", &calculate_percent(value, max).to_string()); + message = message.replace( + "{percent}", + &format_float(calculate_percent(data.value, data.max)), + ); } if message.contains("{bytes}") { - message = message.replace("{bytes}", &format_bytes_binary(value as u64)); + message = message.replace("{bytes}", &format_bytes_binary(data.value)); } if message.contains("{total_bytes}") { - message = message.replace("{total_bytes}", &format_bytes_binary(max as u64)); + message = message.replace("{total_bytes}", &format_bytes_binary(data.max)); } if message.contains("{binary_bytes}") { - message = message.replace("{binary_bytes}", &format_bytes_binary(value as u64)); + message = message.replace("{binary_bytes}", &format_bytes_binary(data.value)); } if message.contains("{binary_total_bytes}") { - message = message.replace("{binary_total_bytes}", &format_bytes_binary(max as u64)); + message = message.replace("{binary_total_bytes}", &format_bytes_binary(data.max)); } if message.contains("{decimal_bytes}") { - message = message.replace("{decimal_bytes}", &format_bytes_decimal(value as u64)); + message = message.replace("{decimal_bytes}", &format_bytes_decimal(data.value)); } if message.contains("{decimal_total_bytes}") { - message = message.replace("{decimal_total_bytes}", &format_bytes_decimal(max as u64)); + message = message.replace("{decimal_total_bytes}", &format_bytes_decimal(data.max)); } if message.contains("{elapsed}") { - message = message.replace("{elapsed}", &format_duration(started.elapsed(), true)); + message = message.replace("{elapsed}", &format_duration(data.started.elapsed(), true)); } - // if message.contains("{eta}") { - // message = message.replace("{eta}", &format_duration(calculate_eta(value, max), true)); - // } - - // if message.contains("{duration}") { - // message = message.replace( - // "{duration}", - // &format_duration( - // started.elapsed().saturating_add(calculate_eta(value, max)), - // true, - // ), - // ); - // } + if let Some(estimator) = data.estimator { + let eta = estimator.calculate_eta(data.value, data.max); + let sps = estimator.calculate_sps(); + + if message.contains("{eta}") { + message = message.replace("{eta}", &format_duration(eta, true)); + } + + if message.contains("{duration}") { + message = message.replace( + "{duration}", + &format_duration(data.started.elapsed().saturating_add(eta), true), + ); + } + + if message.contains("{per_sec}") { + message = message.replace("{per_sec}", &format!("{:.1}/s", sps)); + } + + if message.contains("{bytes_per_sec}") { + message = message.replace( + "{bytes_per_sec}", + &format!("{}/s", format_bytes_binary(sps as u64)), + ); + } + + if message.contains("{binary_bytes_per_sec}") { + message = message.replace( + "{binary_bytes_per_sec}", + &format!("{}/s", format_bytes_binary(sps as u64)), + ); + } + + if message.contains("{decimal_bytes_per_sec}") { + message = message.replace( + "{decimal_bytes_per_sec}", + &format!("{}/s", format_bytes_decimal(sps as u64)), + ); + } + } message } diff --git a/crates/console/src/components/select.rs b/crates/console/src/components/select.rs new file mode 100644 index 0000000..261058f --- /dev/null +++ b/crates/console/src/components/select.rs @@ -0,0 +1,260 @@ +use super::input_field::*; +use crate::ui::ConsoleTheme; +use iocraft::prelude::*; +use std::collections::HashSet; + +#[derive(Clone, Default)] +pub struct SelectOption { + pub disabled: bool, + pub label: String, + pub value: String, +} + +impl SelectOption { + pub fn new(value: impl AsRef) -> Self { + let value = value.as_ref(); + + Self { + disabled: false, + label: value.to_owned(), + value: value.to_owned(), + } + } + + pub fn disabled(self) -> Self { + Self { + disabled: true, + ..self + } + } + + pub fn label(self, label: impl AsRef) -> Self { + Self { + label: label.as_ref().to_owned(), + ..self + } + } +} + +#[derive(Props)] +pub struct SelectProps<'a> { + pub default_index: Option, + pub default_indexes: Vec, + pub description: Option, + pub label: String, + pub legend: bool, + pub multiple: bool, + pub options: Vec, + pub prefix_symbol: Option, + pub selected_symbol: Option, + pub on_index: Option<&'a mut usize>, + pub on_indexes: Option<&'a mut Vec>, +} + +impl Default for SelectProps<'_> { + fn default() -> Self { + Self { + default_index: None, + default_indexes: vec![], + description: None, + label: "".into(), + legend: true, + multiple: false, + options: vec![], + prefix_symbol: None, + selected_symbol: None, + on_index: None, + on_indexes: None, + } + } +} + +#[component] +pub fn Select<'a>(props: &mut SelectProps<'a>, mut hooks: Hooks) -> impl Into> { + let theme = hooks.use_context::(); + let mut system = hooks.use_context_mut::(); + let mut active_index = hooks.use_state(|| 0); + let mut selected_index = hooks.use_state(|| { + HashSet::::from_iter(if props.multiple { + props.default_indexes.clone() + } else { + props + .default_index + .map(|index| vec![index]) + .unwrap_or_default() + }) + }); + let mut should_exit = hooks.use_state(|| false); + let mut error = hooks.use_state(|| None); + + let multiple = props.multiple; + let options = props.options.clone(); + let option_last_index = options.len() - 1; + + let get_next_index = move |current: usize, step: isize| -> usize { + let next = current as isize - step; + + if next < 0 { + option_last_index + } else if next > option_last_index as isize { + 0 + } else { + next as usize + } + }; + + hooks.use_local_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => { + error.set(None); + + match code { + KeyCode::Char(' ') => { + let index = active_index.get(); + + if selected_index.read().contains(&index) { + selected_index.write().remove(&index); + } else { + if !multiple { + selected_index.write().clear(); + } + selected_index.write().insert(index); + } + } + KeyCode::Enter => { + if selected_index.read().is_empty() { + error.set(Some("Please select an option".into())); + } else { + should_exit.set(true); + } + } + KeyCode::Left | KeyCode::Up => { + let mut next_index = match code { + KeyCode::Left => 0, + KeyCode::Up => get_next_index(active_index.get(), 1), + _ => unimplemented!(), + }; + + while options.get(next_index).is_some_and(|opt| opt.disabled) { + next_index = get_next_index(next_index, 1); + } + + active_index.set(next_index); + } + KeyCode::Right | KeyCode::Down => { + let mut next_index = match code { + KeyCode::Right => option_last_index, + KeyCode::Down => get_next_index(active_index.get(), -1), + _ => unimplemented!(), + }; + + while options.get(next_index).is_some_and(|opt| opt.disabled) { + next_index = get_next_index(next_index, -1); + } + + active_index.set(next_index); + } + _ => {} + } + } + _ => {} + } + }); + + if should_exit.get() { + for index in selected_index.read().iter() { + if multiple { + if let Some(outer_indexes) = &mut props.on_indexes { + outer_indexes.push(*index); + } + } else { + if let Some(outer_index) = &mut props.on_index { + **outer_index = *index; + } + + break; + } + } + + system.exit(); + + return element! { + InputFieldValue( + label: &props.label, + value: selected_index.read() + .iter() + .map(|index| props.options[*index].value.clone()) + .collect::>() + .join(", ") + ) + } + .into_any(); + } + + element! { + InputField( + label: &props.label, + description: props.description.clone(), + error: Some(error), + footer: props.legend.then(|| { + element! { + InputLegend(legend: vec![ + ("⎵".into(), "select".into()), + ("↕".into(), "cycle".into()), + ("↵".into(), "submit".into()), + ]) + }.into_any() + }) + ) { + Box(flex_direction: FlexDirection::Column, margin_top: 1, margin_bottom: 1) { + #(props.options.iter().enumerate().map(|(index, opt)| { + let active = active_index.get() == index; + let selected = selected_index.read().contains(&index); + + element! { + Box { + Box(margin_right: 1) { + #(if active { + element! { + Text( + content: props.prefix_symbol.as_ref() + .unwrap_or(&theme.input_prefix_symbol), + color: theme.input_prefix_color, + ) + } + } else if selected { + element! { + Text( + content: props.selected_symbol.as_ref() + .unwrap_or(&theme.input_selected_symbol), + color: theme.input_selected_color + ) + } + } else { + element! { + Text(content: " ") + } + }) + } + Box { + Text( + content: &opt.label, + color: if opt.disabled { + Some(theme.style_muted_light_color) + } else if selected { + Some(theme.input_selected_color) + } else if active { + Some(theme.input_active_color) + } else { + None + } + ) + } + } + } + })) + } + } + } + .into_any() +} diff --git a/crates/console/src/theme.rs b/crates/console/src/theme.rs index 8f0b407..83f09ff 100644 --- a/crates/console/src/theme.rs +++ b/crates/console/src/theme.rs @@ -20,10 +20,11 @@ pub struct ConsoleTheme { pub form_success_symbol: String, // Inputs - pub input_prefix_symbol: String, + pub input_active_color: Color, pub input_prefix_color: Color, - pub input_prefix_active_color: Color, - pub input_prefix_selected_color: Color, + pub input_prefix_symbol: String, + pub input_selected_color: Color, + pub input_selected_symbol: String, // Layout pub layout_fallback_symbol: String, @@ -70,10 +71,11 @@ impl Default for ConsoleTheme { form_label_color: Color::White, form_failure_symbol: "✘".into(), form_success_symbol: "✔".into(), + input_active_color: Color::AnsiValue(NativeColor::Cyan as u8), + input_prefix_color: Color::White, input_prefix_symbol: "❯".into(), - input_prefix_color: Color::AnsiValue(NativeColor::Teal as u8), - input_prefix_active_color: Color::AnsiValue(NativeColor::Cyan as u8), - input_prefix_selected_color: Color::AnsiValue(NativeColor::Green as u8), + input_selected_color: Color::AnsiValue(NativeColor::Teal as u8), + input_selected_symbol: "✔".into(), layout_fallback_symbol: "—".into(), layout_list_bullet: "-".into(), layout_map_separator: "=".into(), @@ -108,6 +110,7 @@ impl ConsoleTheme { pub fn branded(color: Color) -> Self { Self { brand_color: color, + input_prefix_color: color, progress_bar_color: color, progress_loader_color: color, ..Self::default() diff --git a/crates/console/src/utils/estimator.rs b/crates/console/src/utils/estimator.rs new file mode 100644 index 0000000..3e8acc1 --- /dev/null +++ b/crates/console/src/utils/estimator.rs @@ -0,0 +1,165 @@ +// This code is copied from indicatif: https://github.com/console-rs/indicatif/blob/main/src/state.rs#L410 +// All code is copyright console-rs: https://github.com/console-rs/indicatif/blob/main/LICENSE + +use std::time::{Duration, Instant}; + +/// Double-smoothed exponentially weighted estimator +/// +/// This uses an exponentially weighted *time-based* estimator, meaning that it exponentially +/// downweights old data based on its age. The rate at which this occurs is currently a constant +/// value of 15 seconds for 90% weighting. This means that all data older than 15 seconds has a +/// collective weight of 0.1 in the estimate, and all data older than 30 seconds has a collective +/// weight of 0.01, and so on. +/// +/// The primary value exposed by `Estimator` is `steps_per_second`. This value is doubly-smoothed, +/// meaning that is the result of using an exponentially weighted estimator (as described above) to +/// estimate the value of another exponentially weighted estimator, which estimates the value of +/// the raw data. +/// +/// The purpose of this extra smoothing step is to reduce instantaneous fluctations in the estimate +/// when large updates are received. Without this, estimates might have a large spike followed by a +/// slow asymptotic approach to zero (until the next spike). +#[derive(Debug)] +pub struct Estimator { + smoothed_steps_per_sec: f64, + double_smoothed_steps_per_sec: f64, + prev_steps: u64, + prev_time: Instant, + start_time: Instant, +} + +impl Estimator { + pub fn new() -> Self { + let now = Instant::now(); + + Self { + smoothed_steps_per_sec: 0.0, + double_smoothed_steps_per_sec: 0.0, + prev_steps: 0, + prev_time: now, + start_time: now, + } + } + + pub fn record(&mut self, new_steps: u64, now: Instant) { + // sanity check: don't record data if time or steps have not advanced + if new_steps <= self.prev_steps || now <= self.prev_time { + // Reset on backwards seek to prevent breakage from seeking to the end for length determination + // See https://github.com/console-rs/indicatif/issues/480 + if new_steps < self.prev_steps { + self.prev_steps = new_steps; + self.reset(now); + } + return; + } + + let delta_steps = new_steps - self.prev_steps; + let delta_t = duration_to_secs(now - self.prev_time); + + // the rate of steps we saw in this update + let new_steps_per_second = delta_steps as f64 / delta_t; + + // update the estimate: a weighted average of the old estimate and new data + let weight = estimator_weight(delta_t); + self.smoothed_steps_per_sec = + self.smoothed_steps_per_sec * weight + new_steps_per_second * (1.0 - weight); + + // An iterative estimate like `smoothed_steps_per_sec` is supposed to be an exponentially + // weighted average from t=0 back to t=-inf; Since we initialize it to 0, we neglect the + // (non-existent) samples in the weighted average prior to the first one, so the resulting + // average must be normalized. We normalize the single estimate here in order to use it as + // a source for the double smoothed estimate. See comment on normalization in + // `steps_per_second` for details. + let delta_t_start = duration_to_secs(now - self.start_time); + let total_weight = 1.0 - estimator_weight(delta_t_start); + let normalized_smoothed_steps_per_sec = self.smoothed_steps_per_sec / total_weight; + + // determine the double smoothed value (EWA smoothing of the single EWA) + self.double_smoothed_steps_per_sec = self.double_smoothed_steps_per_sec * weight + + normalized_smoothed_steps_per_sec * (1.0 - weight); + + self.prev_steps = new_steps; + self.prev_time = now; + } + + /// Reset the state of the estimator. Once reset, estimates will not depend on any data prior + /// to `now`. This does not reset the stored position of the progress bar. + pub fn reset(&mut self, now: Instant) { + self.smoothed_steps_per_sec = 0.0; + self.double_smoothed_steps_per_sec = 0.0; + + // only reset prev_time, not prev_steps + self.prev_time = now; + self.start_time = now; + } + + /// Average time per step in seconds, using double exponential smoothing + pub fn steps_per_second(&self, now: Instant) -> f64 { + // Because the value stored in the Estimator is only updated when the Estimator receives an + // update, this value will become stuck if progress stalls. To return an accurate estimate, + // we determine how much time has passed since the last update, and treat this as a + // pseudo-update with 0 steps. + let delta_t = duration_to_secs(now - self.prev_time); + let reweight = estimator_weight(delta_t); + + // Normalization of estimates: + // + // The raw estimate is a single value (smoothed_steps_per_second) that is iteratively + // updated. At each update, the previous value of the estimate is downweighted according to + // its age, receiving the iterative weight W(t) = 0.1 ^ (t/15). + // + // Since W(Sum(t_n)) = Prod(W(t_n)), the total weight of a sample after a series of + // iterative steps is simply W(t_e) - W(t_b), where t_e is the time since the end of the + // sample, and t_b is the time since the beginning. The resulting estimate is therefore a + // weighted average with sample weights W(t_e) - W(t_b). + // + // Notice that the weighting function generates sample weights that sum to 1 only when the + // sample times span from t=0 to t=inf; but this is not the case. We have a first sample + // with finite, positive t_b = t_f. In the raw estimate, we handle times prior to t_f by + // setting an initial value of 0, meaning that these (non-existent) samples have no weight. + // + // Therefore, the raw estimate must be normalized by dividing it by the sum of the weights + // in the weighted average. This sum is just W(0) - W(t_f), where t_f is the time since the + // first sample, and W(0) = 1. + let delta_t_start = duration_to_secs(now - self.start_time); + let total_weight = 1.0 - estimator_weight(delta_t_start); + + // Generate updated values for `smoothed_steps_per_sec` and `double_smoothed_steps_per_sec` + // (sps and dsps) without storing them. Note that we normalize sps when using it as a + // source to update dsps, and then normalize dsps itself before returning it. + let sps = self.smoothed_steps_per_sec * reweight / total_weight; + let dsps = self.double_smoothed_steps_per_sec * reweight + sps * (1.0 - reweight); + + dsps / total_weight + } + + pub fn calculate_eta(&self, value: u64, max: u64) -> Duration { + let steps_per_second = self.steps_per_second(Instant::now()); + + if steps_per_second == 0.0 { + return Duration::new(0, 0); + } + + secs_to_duration(max.saturating_sub(value) as f64 / steps_per_second) + } + + pub fn calculate_sps(&self) -> f64 { + self.steps_per_second(Instant::now()) + } +} + +fn duration_to_secs(d: Duration) -> f64 { + d.as_secs() as f64 + f64::from(d.subsec_nanos()) / 1_000_000_000f64 +} + +fn secs_to_duration(s: f64) -> Duration { + let secs = s.trunc() as u64; + let nanos = (s.fract() * 1_000_000_000f64) as u32; + Duration::new(secs, nanos) +} + +const EXPONENTIAL_WEIGHTING_SECONDS: f64 = 15.0; + +fn estimator_weight(age: f64) -> f64 { + 0.1_f64.powf(age / EXPONENTIAL_WEIGHTING_SECONDS) +} diff --git a/crates/console/src/utils/formats.rs b/crates/console/src/utils/formats.rs index 7e45166..ca4eafe 100644 --- a/crates/console/src/utils/formats.rs +++ b/crates/console/src/utils/formats.rs @@ -1,5 +1,9 @@ use std::time::Duration; +pub fn format_float(value: f64) -> String { + format!("{value:.1}").replace(".0", "") +} + pub const DECIMAL_BYTE_UNITS: &[&str] = &["B", "kB", "MB", "GB", "TB", "PB"]; pub const BINARY_BYTE_UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; @@ -15,7 +19,7 @@ fn format_bytes(mut size: f64, kb: f64, units: &[&str]) -> String { prefix += 1; } - format!("{size:.1} {}", units[prefix - 1]).replace(".0", "") + format!("{} {}", format_float(size), units[prefix - 1]) } pub fn format_bytes_binary(size: u64) -> String { diff --git a/crates/console/src/utils/mod.rs b/crates/console/src/utils/mod.rs index 861238f..3eeeb58 100644 --- a/crates/console/src/utils/mod.rs +++ b/crates/console/src/utils/mod.rs @@ -1 +1,2 @@ +pub(crate) mod estimator; pub mod formats; diff --git a/examples/term/src/main.rs b/examples/term/src/main.rs index 1701636..2192c62 100644 --- a/examples/term/src/main.rs +++ b/examples/term/src/main.rs @@ -27,7 +27,7 @@ async fn render(session: TestSession, ui: String) { Confirm( label: "Are you sure?", description: "This operation cannot be undone!".to_owned(), - value: &mut value + on_confirm: &mut value ) }) .await @@ -108,7 +108,7 @@ async fn render(session: TestSession, ui: String) { con.render_interactive(element! { Input( label: "What is your name?", - value: &mut value, + on_value: &mut value, validate: |new_value: String| { if new_value.is_empty() { Some("Field is required".into()) @@ -191,8 +191,8 @@ async fn render(session: TestSession, ui: String) { ProgressBar( bar_color: Color::Cyan, default_message: "Filled - {bytes}/{total_bytes} - {decimal_bytes}/{decimal_total_bytes}".to_owned(), - default_max: 5432, - default_value: 5432 + default_max: 5432u64, + default_value: 5432u64 ) ProgressBar( bar_color: Color::Red, @@ -200,7 +200,45 @@ async fn render(session: TestSession, ui: String) { char_position: '╾', char_unfilled: '─', default_message: "Partially filled with custom bar - {percent}%".to_owned(), - default_value: 53 + default_value: 53u64 + ) + } + }) + .await + .unwrap(); + } + "progressreporter" => { + let reporter = ProgressReporter::default(); + let reporter_clone = reporter.clone(); + + tokio::task::spawn(async move { + let mut count = 0; + + loop { + if count >= 100 { + break; + } else if count == 50 { + reporter_clone.set_message( + "Loading {value}/{max} ({per_sec}) - {elapsed} - {duration} - {eta}", + ); + } else if count == 25 { + reporter_clone.set_prefix("[prefix] "); + } else if count == 75 { + reporter_clone.set_suffix(" [suffix]"); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + + count += 1; + reporter_clone.set_value(count); + } + }); + + con.render_loop(element! { + Container { + ProgressBar( + default_message: "Loading {value}/{max} ({per_sec})".to_owned(), + reporter ) } }) @@ -244,6 +282,51 @@ async fn render(session: TestSession, ui: String) { }) .unwrap(); } + "select" => { + let mut index = 0usize; + + con.render_interactive(element! { + Select( + default_index: 2, + label: "What is your favorite color?", + description: "Only choose 1 value.".to_owned(), + on_index: &mut index, + options: vec![ + SelectOption::new("red"), + SelectOption::new("blue").label("Blue").disabled(), + SelectOption::new("green"), + SelectOption::new("yellow").disabled(), + SelectOption::new("pink").label("Pink"), + ] + ) + }) + .await + .unwrap(); + } + "selectmulti" => { + let mut indexes = vec![]; + + con.render_interactive(element! { + Select( + default_indexes: vec![2, 4], + label: "What is your favorite color?", + description: "Can choose multiple values.".to_owned(), + multiple: true, + on_indexes: &mut indexes, + options: vec![ + SelectOption::new("red"), + SelectOption::new("blue").label("Blue").disabled(), + SelectOption::new("green"), + SelectOption::new("yellow").disabled(), + SelectOption::new("pink").label("Pink"), + SelectOption::new("black"), + SelectOption::new("white"), + ] + ) + }) + .await + .unwrap(); + } "styledtext" => { con.render(element! { Container {