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 {