diff --git a/src/progress_bar.rs b/src/progress_bar.rs index b5a3915c..8c1c1335 100644 --- a/src/progress_bar.rs +++ b/src/progress_bar.rs @@ -3,13 +3,13 @@ use std::borrow::Cow; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex, MutexGuard, Weak}; use std::time::{Duration, Instant}; -use std::{fmt, io, mem, thread}; +use std::{fmt, io, thread}; #[cfg(test)] use once_cell::sync::Lazy; use crate::draw_target::ProgressDrawTarget; -use crate::state::{AtomicPosition, BarState, ProgressFinish, Reset}; +use crate::state::{AtomicPosition, BarState, ProgressFinish, Reset, TabExpandedString}; use crate::style::ProgressStyle; use crate::{ProgressBarIter, ProgressIterator, ProgressState}; @@ -69,15 +69,25 @@ impl ProgressBar { self } + /// A convenience builder-like function for a progress bar with a given tab width + pub fn with_tab_width(self, tab_width: usize) -> ProgressBar { + self.state().set_tab_width(tab_width); + self + } + /// A convenience builder-like function for a progress bar with a given prefix pub fn with_prefix(self, prefix: impl Into>) -> ProgressBar { - self.state().style.prefix = prefix.into(); + let mut state = self.state(); + state.state.prefix = TabExpandedString::new(prefix.into(), state.tab_width); + drop(state); self } /// A convenience builder-like function for a progress bar with a given message pub fn with_message(self, message: impl Into>) -> ProgressBar { - self.state().style.message = message.into(); + let mut state = self.state(); + state.state.message = TabExpandedString::new(message.into(), state.tab_width); + drop(state); self } @@ -121,11 +131,15 @@ impl ProgressBar { /// Overrides the stored style /// /// This does not redraw the bar. Call [`ProgressBar::tick()`] to force it. - pub fn set_style(&self, mut style: ProgressStyle) { + pub fn set_style(&self, style: ProgressStyle) { + self.state().set_style(style); + } + + /// Sets the tab width (default: 8). All tabs will be expanded to this many spaces. + pub fn set_tab_width(&mut self, tab_width: usize) { let mut state = self.state(); - mem::swap(&mut state.style.message, &mut style.message); - mem::swap(&mut state.style.prefix, &mut style.prefix); - state.style = style; + state.set_tab_width(tab_width); + state.draw(true, Instant::now()).unwrap(); } /// Spawns a background thread to tick the progress bar @@ -246,7 +260,9 @@ impl ProgressBar { /// For the prefix to be visible, the `{prefix}` placeholder must be present in the template /// (see [`ProgressStyle`]). pub fn set_prefix(&self, prefix: impl Into>) { - self.state().set_prefix(Instant::now(), prefix.into()); + let mut state = self.state(); + state.state.prefix = TabExpandedString::new(prefix.into(), state.tab_width); + state.update_estimate_and_draw(Instant::now()); } /// Sets the current message of the progress bar @@ -254,7 +270,9 @@ impl ProgressBar { /// For the message to be visible, the `{msg}` placeholder must be present in the template (see /// [`ProgressStyle`]). pub fn set_message(&self, msg: impl Into>) { - self.state().set_message(Instant::now(), msg.into()) + let mut state = self.state(); + state.state.message = TabExpandedString::new(msg.into(), state.tab_width); + state.update_estimate_and_draw(Instant::now()); } /// Creates a new weak reference to this `ProgressBar` @@ -517,6 +535,16 @@ impl ProgressBar { self.state().draw_target.remote().map(|(_, idx)| idx) } + /// Current message + pub fn message(&self) -> String { + self.state().state.message.expanded().to_string() + } + + /// Current prefix + pub fn prefix(&self) -> String { + self.state().state.prefix.expanded().to_string() + } + #[inline] pub(crate) fn state(&self) -> MutexGuard<'_, BarState> { self.state.lock().unwrap() diff --git a/src/state.rs b/src/state.rs index 964d98f8..d18ac5be 100644 --- a/src/state.rs +++ b/src/state.rs @@ -12,6 +12,7 @@ pub(crate) struct BarState { pub(crate) on_finish: ProgressFinish, pub(crate) style: ProgressStyle, pub(crate) state: ProgressState, + pub(crate) tab_width: usize, } impl BarState { @@ -25,6 +26,7 @@ impl BarState { on_finish: ProgressFinish::default(), style: ProgressStyle::default_bar(), state: ProgressState::new(len, pos), + tab_width: DEFAULT_TAB_WIDTH, } } @@ -42,7 +44,7 @@ impl BarState { if let Some(len) = self.state.len { self.state.pos.set(len); } - self.style.message = msg; + self.state.message = TabExpandedString::new(msg, self.tab_width); } ProgressFinish::AndClear => { if let Some(len) = self.state.len { @@ -51,7 +53,9 @@ impl BarState { self.state.status = Status::DoneHidden; } ProgressFinish::Abandon => {} - ProgressFinish::AbandonWithMessage(msg) => self.style.message = msg, + ProgressFinish::AbandonWithMessage(msg) => { + self.state.message = TabExpandedString::new(msg, self.tab_width) + } } // There's no need to update the estimate here; once the `status` is no longer @@ -92,14 +96,16 @@ impl BarState { self.update_estimate_and_draw(now); } - pub(crate) fn set_message(&mut self, now: Instant, msg: Cow<'static, str>) { - self.style.message = msg; - self.update_estimate_and_draw(now); + pub(crate) fn set_tab_width(&mut self, tab_width: usize) { + self.tab_width = tab_width; + self.state.message.set_tab_width(tab_width); + self.state.prefix.set_tab_width(tab_width); + self.style.set_tab_width(tab_width); } - pub(crate) fn set_prefix(&mut self, now: Instant, prefix: Cow<'static, str>) { - self.style.prefix = prefix; - self.update_estimate_and_draw(now); + pub(crate) fn set_style(&mut self, style: ProgressStyle) { + self.style = style; + self.style.set_tab_width(self.tab_width); } pub(crate) fn tick(&mut self, now: Instant) { @@ -107,7 +113,7 @@ impl BarState { self.update_estimate_and_draw(now); } - fn update_estimate_and_draw(&mut self, now: Instant) { + pub(crate) fn update_estimate_and_draw(&mut self, now: Instant) { let pos = self.state.pos.pos.load(Ordering::Relaxed); self.state.est.record(pos, now); let _ = self.draw(false, now); @@ -190,6 +196,8 @@ pub struct ProgressState { pub(crate) started: Instant, status: Status, est: Estimator, + pub(crate) message: TabExpandedString, + pub(crate) prefix: TabExpandedString, } impl ProgressState { @@ -201,6 +209,8 @@ impl ProgressState { status: Status::InProgress, started: Instant::now(), est: Estimator::new(Instant::now()), + message: TabExpandedString::NoTabs("".into()), + prefix: TabExpandedString::NoTabs("".into()), } } @@ -285,6 +295,55 @@ impl ProgressState { } } +#[derive(Debug, PartialEq, Eq, Clone)] +pub(crate) enum TabExpandedString { + NoTabs(Cow<'static, str>), + WithTabs { + original: Cow<'static, str>, + expanded: String, + tab_width: usize, + }, +} + +impl TabExpandedString { + pub(crate) fn new(s: Cow<'static, str>, tab_width: usize) -> Self { + let expanded = s.replace('\t', &" ".repeat(tab_width)); + if s == expanded { + Self::NoTabs(s) + } else { + Self::WithTabs { + original: s, + expanded, + tab_width, + } + } + } + + pub(crate) fn expanded(&self) -> &str { + match &self { + Self::NoTabs(s) => { + debug_assert!(!s.contains('\t')); + s + } + Self::WithTabs { expanded, .. } => expanded, + } + } + + pub(crate) fn set_tab_width(&mut self, new_tab_width: usize) { + if let TabExpandedString::WithTabs { + original, + expanded, + tab_width, + } = self + { + if *tab_width != new_tab_width { + *tab_width = new_tab_width; + *expanded = original.replace('\t', &" ".repeat(new_tab_width)); + } + } + } +} + /// Estimate the number of seconds per step /// /// Ring buffer with constant capacity. Used by `ProgressBar`s to display `{eta}`, @@ -482,6 +541,8 @@ pub(crate) enum Status { DoneHidden, } +pub(crate) const DEFAULT_TAB_WIDTH: usize = 8; + #[cfg(test)] mod tests { use super::*; diff --git a/src/style.rs b/src/style.rs index e8cd0860..56cc07cb 100644 --- a/src/style.rs +++ b/src/style.rs @@ -1,4 +1,3 @@ -use std::borrow::Cow; use std::collections::HashMap; use std::fmt::{self, Write}; use std::mem; @@ -10,19 +9,18 @@ use unicode_segmentation::UnicodeSegmentation; use crate::format::{ BinaryBytes, DecimalBytes, FormattedDuration, HumanBytes, HumanCount, HumanDuration, }; -use crate::state::ProgressState; +use crate::state::{ProgressState, TabExpandedString, DEFAULT_TAB_WIDTH}; /// Controls the rendering style of progress bars #[derive(Clone)] pub struct ProgressStyle { - pub(crate) message: Cow<'static, str>, - pub(crate) prefix: Cow<'static, str>, tick_strings: Vec>, progress_chars: Vec>, template: Template, // how unicode-big each char in progress_chars is char_width: usize, format_map: HashMap<&'static str, fn(&ProgressState) -> String>, + tab_width: usize, } #[cfg(feature = "unicode-segmentation")] @@ -77,12 +75,15 @@ impl ProgressStyle { Ok(Self::new(Template::from_str(template)?)) } + pub(crate) fn set_tab_width(&mut self, new_tab_width: usize) { + self.tab_width = new_tab_width; + self.template.set_tab_width(new_tab_width); + } + fn new(template: Template) -> Self { let progress_chars = segment("█░"); let char_width = width(&progress_chars); Self { - message: "".into(), - prefix: "".into(), tick_strings: "⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈ " .chars() .map(|c| c.to_string().into()) @@ -91,6 +92,7 @@ impl ProgressStyle { char_width, template, format_map: HashMap::default(), + tab_width: DEFAULT_TAB_WIDTH, } } @@ -236,7 +238,7 @@ impl ProgressStyle { } => { buf.clear(); if let Some(formatter) = self.format_map.get(key.as_str()) { - buf.push_str(&formatter(state)); + buf.push_str(&formatter(state).replace('\t', &" ".repeat(self.tab_width))); } else { match key.as_str() { "wide_bar" => { @@ -258,8 +260,8 @@ impl ProgressStyle { wide = Some(WideElement::Message { align }); buf.push('\x00'); } - "msg" => buf.push_str(&self.message), - "prefix" => buf.push_str(&self.prefix), + "msg" => buf.push_str(state.message.expanded()), + "prefix" => buf.push_str(state.prefix.expanded()), "pos" => buf.write_fmt(format_args!("{}", pos)).unwrap(), "human_pos" => { buf.write_fmt(format_args!("{}", HumanCount(pos))).unwrap() @@ -342,7 +344,7 @@ impl ProgressStyle { }, } } - TemplatePart::Literal(s) => cur.push_str(s), + TemplatePart::Literal(s) => cur.push_str(s.expanded()), TemplatePart::NewLine => lines.push(match wide { Some(inner) => { inner.expand(mem::take(&mut cur), self, state, &mut buf, target_width) @@ -392,7 +394,7 @@ impl<'a> WideElement<'a> { buf.write_fmt(format_args!( "{}", PaddedStringDisplay { - str: &style.message, + str: state.message.expanded(), width: left, align: *align, truncate: true, @@ -417,7 +419,7 @@ struct Template { } impl Template { - fn from_str(s: &str) -> Result { + fn from_str_with_tab_width(s: &str, tab_width: usize) -> Result { use State::*; let (mut state, mut parts, mut buf) = (Literal, vec![], String::new()); for c in s.chars() { @@ -425,7 +427,10 @@ impl Template { (Literal, '{') => (MaybeOpen, None), (Literal, '\n') => { if !buf.is_empty() { - parts.push(TemplatePart::Literal(mem::take(&mut buf))); + parts.push(TemplatePart::Literal(TabExpandedString::new( + mem::take(&mut buf).into(), + tab_width, + ))); } parts.push(TemplatePart::NewLine); (Literal, None) @@ -441,7 +446,10 @@ impl Template { let mut new = String::from("{"); new.push_str(&buf); buf.clear(); - parts.push(TemplatePart::Literal(new)); + parts.push(TemplatePart::Literal(TabExpandedString::new( + new.into(), + tab_width, + ))); (Literal, None) } (MaybeOpen, c) if c != '}' && c != ':' => (Key, Some(c)), @@ -492,9 +500,9 @@ impl Template { }; match (state, new.0) { - (MaybeOpen, Key) if !buf.is_empty() => { - parts.push(TemplatePart::Literal(mem::take(&mut buf))) - } + (MaybeOpen, Key) if !buf.is_empty() => parts.push(TemplatePart::Literal( + TabExpandedString::new(mem::take(&mut buf).into(), tab_width), + )), (Key, Align) | (Key, Literal) if !buf.is_empty() => { parts.push(TemplatePart::Placeholder { key: mem::take(&mut buf), @@ -533,11 +541,26 @@ impl Template { } if matches!(state, Literal | DoubleClose) && !buf.is_empty() { - parts.push(TemplatePart::Literal(buf)); + parts.push(TemplatePart::Literal(TabExpandedString::new( + buf.into(), + tab_width, + ))); } Ok(Self { parts }) } + + fn from_str(s: &str) -> Result { + Self::from_str_with_tab_width(s, DEFAULT_TAB_WIDTH) + } + + fn set_tab_width(&mut self, new_tab_width: usize) { + for part in self.parts.iter_mut() { + if let TemplatePart::Literal(s) = part { + s.set_tab_width(new_tab_width) + } + } + } } #[derive(Debug)] @@ -558,9 +581,9 @@ impl fmt::Display for TemplateError { impl std::error::Error for TemplateError {} -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] enum TemplatePart { - Literal(String), + Literal(TabExpandedString), Placeholder { key: String, align: Alignment, @@ -572,7 +595,7 @@ enum TemplatePart { NewLine, } -#[derive(Copy, Clone, Debug, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] enum State { Literal, MaybeOpen, @@ -673,7 +696,7 @@ mod tests { use std::sync::Arc; use super::*; - use crate::state::{AtomicPosition, ProgressState}; + use crate::state::{AtomicPosition, ProgressState, TabExpandedString}; #[test] fn test_expand_template() { @@ -733,23 +756,23 @@ mod tests { fn align_truncation() { const WIDTH: u16 = 10; let pos = Arc::new(AtomicPosition::new()); - let state = ProgressState::new(Some(10), pos); + let mut state = ProgressState::new(Some(10), pos); let mut buf = Vec::new(); - let mut style = ProgressStyle::with_template("{wide_msg}").unwrap(); - style.message = "abcdefghijklmnopqrst".into(); + let style = ProgressStyle::with_template("{wide_msg}").unwrap(); + state.message = TabExpandedString::NoTabs("abcdefghijklmnopqrst".into()); style.format_state(&state, &mut buf, WIDTH); assert_eq!(&buf[0], "abcdefghij"); buf.clear(); - let mut style = ProgressStyle::with_template("{wide_msg:>}").unwrap(); - style.message = "abcdefghijklmnopqrst".into(); + let style = ProgressStyle::with_template("{wide_msg:>}").unwrap(); + state.message = TabExpandedString::NoTabs("abcdefghijklmnopqrst".into()); style.format_state(&state, &mut buf, WIDTH); assert_eq!(&buf[0], "klmnopqrst"); buf.clear(); - let mut style = ProgressStyle::with_template("{wide_msg:^}").unwrap(); - style.message = "abcdefghijklmnopqrst".into(); + let style = ProgressStyle::with_template("{wide_msg:^}").unwrap(); + state.message = TabExpandedString::NoTabs("abcdefghijklmnopqrst".into()); style.format_state(&state, &mut buf, WIDTH); assert_eq!(&buf[0], "fghijklmno"); } @@ -761,7 +784,7 @@ mod tests { let pos = Arc::new(AtomicPosition::new()); // half finished pos.set(2); - let state = ProgressState::new(Some(4), pos); + let mut state = ProgressState::new(Some(4), pos); let mut buf = Vec::new(); let style = ProgressStyle::with_template("{wide_bar}") @@ -781,8 +804,8 @@ mod tests { ); buf.clear(); - let mut style = ProgressStyle::with_template("{wide_msg:^.red.on_blue}").unwrap(); - style.message = "foobar".into(); + let style = ProgressStyle::with_template("{wide_msg:^.red.on_blue}").unwrap(); + state.message = TabExpandedString::NoTabs("foobar".into()); style.format_state(&state, &mut buf, WIDTH); assert_eq!(&buf[0], "\u{1b}[31m\u{1b}[44m foobar \u{1b}[0m"); } diff --git a/tests/render.rs b/tests/render.rs index 45416a5b..e696519d 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -534,3 +534,63 @@ don't erase me either assert_eq!(in_mem.contents(), "don't erase me either"); } + +#[test] +fn basic_tab_expansion() { + let in_mem = InMemoryTerm::new(10, 80); + let mp = + MultiProgress::with_draw_target(ProgressDrawTarget::term_like(Box::new(in_mem.clone()))); + + let mut spinner = mp.add(ProgressBar::new_spinner().with_message("Test\t:)")); + spinner.tick(); + + // 8 is the default number of spaces + assert_eq!(in_mem.contents(), "⠁ Test :)"); + + spinner.set_tab_width(4); + assert_eq!(in_mem.contents(), "⠁ Test :)"); +} + +#[test] +fn tab_expansion_in_template() { + let in_mem = InMemoryTerm::new(10, 80); + let mp = + MultiProgress::with_draw_target(ProgressDrawTarget::term_like(Box::new(in_mem.clone()))); + + let mut spinner = mp.add( + ProgressBar::new_spinner() + .with_message("Test\t:)") + .with_prefix("Pre\tfix!") + .with_style(ProgressStyle::with_template("{spinner}{prefix}\t{msg}").unwrap()), + ); + + spinner.tick(); + assert_eq!(in_mem.contents(), "⠁Pre fix! Test :)"); + + spinner.set_tab_width(4); + assert_eq!(in_mem.contents(), "⠁Pre fix! Test :)"); + + spinner.set_tab_width(2); + assert_eq!(in_mem.contents(), "⠁Pre fix! Test :)"); +} + +#[test] +fn progress_style_tab_width_unification() { + let in_mem = InMemoryTerm::new(10, 80); + let mp = + MultiProgress::with_draw_target(ProgressDrawTarget::term_like(Box::new(in_mem.clone()))); + + // Style will have default of 8 spaces for tabs + let style = ProgressStyle::with_template("{msg}\t{msg}").unwrap(); + + let spinner = mp.add( + ProgressBar::new_spinner() + .with_message("OK") + .with_tab_width(4), + ); + + // Setting the spinner's style to |style| should override the style's tab width with that of bar + spinner.set_style(style); + spinner.tick(); + assert_eq!(in_mem.contents(), "OK OK"); +}