diff --git a/src/draw_target.rs b/src/draw_target.rs index bac1ce58..78dba401 100644 --- a/src/draw_target.rs +++ b/src/draw_target.rs @@ -362,7 +362,7 @@ pub(crate) enum LineAdjust { pub(crate) struct DrawStateWrapper<'a> { state: &'a mut DrawState, - orphan_lines: Option<&'a mut Vec>, + orphan_lines: Option<&'a mut Vec>, } impl<'a> DrawStateWrapper<'a> { @@ -373,7 +373,7 @@ impl<'a> DrawStateWrapper<'a> { } } - pub(crate) fn for_multi(state: &'a mut DrawState, orphan_lines: &'a mut Vec) -> Self { + pub(crate) fn for_multi(state: &'a mut DrawState, orphan_lines: &'a mut Vec) -> Self { Self { state, orphan_lines: Some(orphan_lines), @@ -398,8 +398,13 @@ impl std::ops::DerefMut for DrawStateWrapper<'_> { impl Drop for DrawStateWrapper<'_> { fn drop(&mut self) { if let Some(orphaned) = &mut self.orphan_lines { - orphaned.extend(self.state.lines.drain(..self.state.orphan_lines_count)); - self.state.orphan_lines_count = 0; + orphaned.extend( + self.state + .lines + .iter() + .filter(|l| matches!(l, LineType::Text(_) | LineType::Empty)) + .cloned(), + ); } } } @@ -460,11 +465,7 @@ const MAX_BURST: u8 = 20; #[derive(Clone, Debug, Default)] pub(crate) struct DrawState { /// The lines to print (can contain ANSI codes) - pub(crate) lines: Vec, - /// The number [`Self::lines`] entries that shouldn't be reaped by the next tick. - /// - /// Note that this number may be different than the number of visual lines required to draw [`Self::lines`]. - pub(crate) orphan_lines_count: usize, + pub(crate) lines: Vec, /// True if we should move the cursor up when possible instead of clearing lines. pub(crate) move_cursor: bool, /// Controls how the multi progress is aligned if some of its progress bars get removed, default is `Top` @@ -472,10 +473,14 @@ pub(crate) struct DrawState { } impl DrawState { + /// Draw the current state to the terminal + /// We expect a few things: + /// - self.lines contains n lines of text/empty then m lines of bars + /// - None of those lines contain newlines fn draw_to_term( &mut self, term: &(impl TermLike + ?Sized), - last_line_count: &mut VisualLines, + bar_count: &mut VisualLines, // The number of dynamic lines printed at the previous tick ) -> io::Result<()> { if panicking() { return Ok(()); @@ -483,11 +488,11 @@ impl DrawState { if !self.lines.is_empty() && self.move_cursor { // Move up to first line (assuming the last line doesn't contain a '\n') and then move to then front of the line - term.move_cursor_up(last_line_count.as_usize().saturating_sub(1))?; + term.move_cursor_up(bar_count.as_usize().saturating_sub(1))?; term.write_str("\r")?; } else { // Fork of console::clear_last_lines that assumes that the last line doesn't contain a '\n' - let n = last_line_count.as_usize(); + let n = bar_count.as_usize(); term.move_cursor_up(n.saturating_sub(1))?; for i in 0..n { term.clear_line()?; @@ -498,11 +503,16 @@ impl DrawState { term.move_cursor_up(n.saturating_sub(1))?; } - let width = term.width() as usize; - let visual_lines = self.visual_line_count(.., width); + let term_width = term.width() as usize; + + // Here we calculate the terminal vertical real estate that the state requires + let full_height = self.visual_line_count(.., term_width); + let shift = match self.alignment { - MultiProgressAlignment::Bottom if visual_lines < *last_line_count => { - let shift = *last_line_count - visual_lines; + // If we align to the bottom and the new height is less than before, clear the lines + // that are not used by the new content. + MultiProgressAlignment::Bottom if full_height < *bar_count => { + let shift = *bar_count - full_height; for _ in 0..shift.as_usize() { term.write_line("")?; } @@ -511,66 +521,53 @@ impl DrawState { _ => VisualLines::default(), }; - let term_height = term.height() as usize; - let term_width = term.width() as usize; - let len = self.lines.len(); - debug_assert!(self.orphan_lines_count <= self.lines.len()); - let orphan_visual_line_count = - self.visual_line_count(..self.orphan_lines_count, term_width); - let mut real_len = VisualLines::default(); - let mut last_line_filler = 0; + // Accumulate the displayed height in here. This differs from `full_height` in that it will + // accurately reflect the number of lines that have been displayed on the terminal, if the + // full height exceeds the terminal height. + let mut real_height = VisualLines::default(); + for (idx, line) in self.lines.iter().enumerate() { - let line_width = console::measure_text_width(line); - let diff = if line.is_empty() { - // Empty line are new line - 1 - } else { - // Calculate real length based on terminal width - // This take in account linewrap from terminal - let terminal_len = (line_width as f64 / term_width as f64).ceil() as usize; - - // If the line is effectively empty (for example when it consists - // solely of ANSI color code sequences, count it the same as a - // new line. If the line is measured to be len = 0, we will - // subtract with overflow later. - usize::max(terminal_len, 1) - } - .into(); - // Have all orphan lines been drawn? - if self.orphan_lines_count <= idx { - // If so, then `real_len` should be at least `orphan_visual_line_count`. - debug_assert!(orphan_visual_line_count <= real_len); - // Don't consider orphan lines when comparing to terminal height. - if real_len - orphan_visual_line_count + diff > term_height.into() { + let line_height = line.wrapped_height(term_width); + + // Check here for bar lines that exceed the terminal height + if matches!(line, LineType::Bar(_)) { + // Stop here if printing this bar would exceed the terminal height + if real_height + line_height > term.height().into() { break; } + + real_height += line_height; } - real_len += diff; + + // Print a new line if this is not the first line printed this tick + // the first line will automatically wrap due to the filler below if idx != 0 { term.write_line("")?; } - term.write_str(line)?; - if idx + 1 == len { - // Keep the cursor on the right terminal side - // So that next user writes/prints will happen on the next line - last_line_filler = term_width.saturating_sub(line_width); + + term.write_str(line.as_ref())?; + + if idx + 1 == self.lines.len() { + // For the last line of the output, keep the cursor on the right terminal + // side so that next user writes/prints will happen on the next line + let last_line_filler = line_height.as_usize() * term_width - line.console_width(); + term.write_str(&" ".repeat(last_line_filler))?; } } - term.write_str(&" ".repeat(last_line_filler))?; term.flush()?; - *last_line_count = real_len - orphan_visual_line_count + shift; + *bar_count = real_height + shift; + Ok(()) } fn reset(&mut self) { self.lines.clear(); - self.orphan_lines_count = 0; } pub(crate) fn visual_line_count( &self, - range: impl SliceIndex<[String], Output = [String]>, + range: impl SliceIndex<[LineType], Output = [LineType]>, width: usize, ) -> VisualLines { visual_line_count(&self.lines[range], width) @@ -624,21 +621,55 @@ impl Sub for VisualLines { /// Calculate the number of visual lines in the given lines, after /// accounting for line wrapping and non-printable characters. -pub(crate) fn visual_line_count(lines: &[impl AsRef], width: usize) -> VisualLines { - let mut real_lines = 0; - for line in lines { - let effective_line_length = console::measure_text_width(line.as_ref()); - real_lines += usize::max( - (effective_line_length as f64 / width as f64).ceil() as usize, - 1, - ); +pub(crate) fn visual_line_count(lines: &[LineType], width: usize) -> VisualLines { + lines.iter().fold(VisualLines::default(), |acc, line| { + acc.saturating_add(line.wrapped_height(width)) + }) +} + +#[derive(Clone, Debug)] +pub(crate) enum LineType { + Text(String), + Bar(String), + Empty, +} + +impl LineType { + fn wrapped_height(&self, width: usize) -> VisualLines { + // Calculate real length based on terminal width + // This take in account linewrap from terminal + let terminal_len = (self.console_width() as f64 / width as f64).ceil() as usize; + + // If the line is effectively empty (for example when it consists + // solely of ANSI color code sequences, count it the same as a + // new line. If the line is measured to be len = 0, we will + // subtract with overflow later. + usize::max(terminal_len, 1).into() + } + + fn console_width(&self) -> usize { + console::measure_text_width(self.as_ref()) } +} - real_lines.into() +impl AsRef for LineType { + fn as_ref(&self) -> &str { + match self { + LineType::Text(s) | LineType::Bar(s) => s, + LineType::Empty => "", + } + } +} + +impl PartialEq for LineType { + fn eq(&self, other: &str) -> bool { + self.as_ref() == other + } } #[cfg(test)] mod tests { + use crate::draw_target::LineType; use crate::{MultiProgress, ProgressBar, ProgressDrawTarget}; #[test] @@ -732,7 +763,14 @@ mod tests { ]; for case in lines_and_expectations.iter() { - let result = super::visual_line_count(case.lines, case.width); + let result = super::visual_line_count( + &case + .lines + .iter() + .map(|s| LineType::Text(s.to_string())) + .collect::>(), + case.width, + ); assert_eq!(result, case.expectation.into(), "case: {:?}", case); } } diff --git a/src/multi.rs b/src/multi.rs index 90327166..3805d4c4 100644 --- a/src/multi.rs +++ b/src/multi.rs @@ -6,7 +6,8 @@ use std::thread::panicking; use std::time::Instant; use crate::draw_target::{ - visual_line_count, DrawState, DrawStateWrapper, LineAdjust, ProgressDrawTarget, VisualLines, + visual_line_count, DrawState, DrawStateWrapper, LineAdjust, LineType, ProgressDrawTarget, + VisualLines, }; use crate::progress_bar::ProgressBar; #[cfg(target_arch = "wasm32")] @@ -217,7 +218,7 @@ pub(crate) struct MultiState { alignment: MultiProgressAlignment, /// Lines to be drawn above everything else in the MultiProgress. These specifically come from /// calling `ProgressBar::println` on a pb that is connected to a `MultiProgress`. - orphan_lines: Vec, + orphan_lines: Vec, /// The count of currently visible zombie lines. zombie_lines_count: VisualLines, } @@ -267,7 +268,7 @@ impl MultiState { pub(crate) fn draw( &mut self, mut force_draw: bool, - extra_lines: Option>, + extra_lines: Option>, now: Instant, ) -> io::Result<()> { if panicking() { @@ -326,12 +327,10 @@ impl MultiState { }; let mut draw_state = drawable.state(); - draw_state.orphan_lines_count = self.orphan_lines.len(); draw_state.alignment = self.alignment; if let Some(extra_lines) = &extra_lines { draw_state.lines.extend_from_slice(extra_lines.as_slice()); - draw_state.orphan_lines_count += extra_lines.len(); } // Add lines from `ProgressBar::println` call. @@ -365,9 +364,9 @@ impl MultiState { let msg = msg.as_ref(); // If msg is "", make sure a line is still printed - let lines: Vec = match msg.is_empty() { - false => msg.lines().map(Into::into).collect(), - true => vec![String::new()], + let lines: Vec = match msg.is_empty() { + false => msg.lines().map(|l| LineType::Text(Into::into(l))).collect(), + true => vec![LineType::Empty], }; self.draw(true, Some(lines), now) diff --git a/src/state.rs b/src/state.rs index 136e2307..a7e28878 100644 --- a/src/state.rs +++ b/src/state.rs @@ -9,7 +9,7 @@ use std::time::Instant; use instant::Instant; use portable_atomic::{AtomicU64, AtomicU8, Ordering}; -use crate::draw_target::ProgressDrawTarget; +use crate::draw_target::{LineType, ProgressDrawTarget}; use crate::style::ProgressStyle; pub(crate) struct BarState { @@ -144,15 +144,14 @@ impl BarState { }; let mut draw_state = drawable.state(); - let lines: Vec = msg.lines().map(Into::into).collect(); + let lines: Vec = msg.lines().map(|l| LineType::Text(Into::into(l))).collect(); // Empty msg should trigger newline as we are in println if lines.is_empty() { - draw_state.lines.push(String::new()); + draw_state.lines.push(LineType::Empty); } else { draw_state.lines.extend(lines); } - draw_state.orphan_lines_count = draw_state.lines.len(); if let Some(width) = width { if !matches!(self.state.status, Status::DoneHidden) { self.style diff --git a/src/style.rs b/src/style.rs index 3fbf8879..8e377007 100644 --- a/src/style.rs +++ b/src/style.rs @@ -10,6 +10,7 @@ use instant::Instant; #[cfg(feature = "unicode-segmentation")] use unicode_segmentation::UnicodeSegmentation; +use crate::draw_target::LineType; use crate::format::{ BinaryBytes, DecimalBytes, FormattedDuration, HumanBytes, HumanCount, HumanDuration, HumanFloatCount, @@ -226,7 +227,7 @@ impl ProgressStyle { pub(crate) fn format_state( &self, state: &ProgressState, - lines: &mut Vec, + lines: &mut Vec, target_width: u16, ) { let mut cur = String::new(); @@ -374,9 +375,10 @@ impl ProgressStyle { } } + /// This is used exclusively to add the bars built above to the lines to print fn push_line( &self, - lines: &mut Vec, + lines: &mut Vec, cur: &mut String, state: &ProgressState, buf: &mut String, @@ -394,11 +396,11 @@ impl ProgressStyle { for (i, line) in expanded.split('\n').enumerate() { // No newlines found in this case if i == 0 && line.len() == expanded.len() { - lines.push(expanded); + lines.push(LineType::Bar(expanded)); break; } - lines.push(line.to_string()); + lines.push(LineType::Bar(line.to_string())); } } } diff --git a/tests/render.rs b/tests/render.rs index 9edfe1a6..b05af145 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -1288,7 +1288,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush Up(3) Clear @@ -1309,7 +1308,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush Up(3) Clear @@ -1330,7 +1328,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush Up(3) Clear @@ -1351,7 +1348,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush "# ); @@ -1387,7 +1383,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush "# ); @@ -1414,7 +1409,6 @@ Str("⠁ 3") Str("") NewLine Str("⠁ 4") -Str("") Flush Up(3) Clear @@ -1435,7 +1429,6 @@ Str("⠁ 4") Str("") NewLine Str("⠁ 5") -Str("") Flush Up(3) Clear @@ -1498,7 +1491,6 @@ Str("⠁ 6") Str(" ") Flush Clear -Str("") Flush "# ); @@ -1608,7 +1600,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush Up(3) Clear @@ -1629,7 +1620,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush Up(3) Clear @@ -1650,7 +1640,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush Up(3) Clear @@ -1671,7 +1660,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush "# ); @@ -1709,7 +1697,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush "# ); @@ -1746,7 +1733,6 @@ Str("⠁ 1") Str("") NewLine Str("⠁ 2") -Str("") Flush "# ); @@ -1778,7 +1764,6 @@ Str("⠁ 2") Str("") NewLine Str("⠁ 4") -Str("") Flush Up(3) Clear @@ -1918,3 +1903,29 @@ fn orphan_lines_message_above_progress_bar_test(pb: &ProgressBar, in_mem: &InMem pb.finish(); } + +/// Test proper wrapping of the text lines before a bar is added. #447 on github. +#[test] +fn barless_text_wrapping() { + let lorem: &str= "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec nec viverra massa. Nunc nisl lectus, auctor in lorem eu, maximus elementum est."; + + let in_mem = InMemoryTerm::new(40, 80); + let mp = indicatif::MultiProgress::with_draw_target(ProgressDrawTarget::term_like(Box::new( + in_mem.clone(), + ))); + assert_eq!(in_mem.contents(), String::new()); + + for _ in 0..=1 { + mp.println(lorem).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(100)); // This is primordial. The bug + // came from writing multiple text lines in a row on different ticks. + } + + assert_eq!( + in_mem.contents(), + r#"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec nec viverra massa +. Nunc nisl lectus, auctor in lorem eu, maximus elementum est. +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec nec viverra massa +. Nunc nisl lectus, auctor in lorem eu, maximus elementum est."# + ); +}