console_static_text/
lib.rs

1use std::borrow::Cow;
2use std::io::Write;
3
4use ansi::strip_ansi_codes;
5use unicode_width::UnicodeWidthStr;
6use word::tokenize_words;
7use word::WordToken;
8
9pub mod ansi;
10#[cfg(feature = "sized")]
11mod console;
12mod word;
13
14const VTS_MOVE_TO_ZERO_COL: &str = "\x1B[0G";
15const VTS_CLEAR_CURSOR_DOWN: &str = concat!(
16  "\x1B[2K", // clear current line
17  "\x1B[J",  // clear cursor down
18);
19const VTS_CLEAR_UNTIL_NEWLINE: &str = "\x1B[K";
20
21fn vts_move_up(count: usize) -> String {
22  if count == 0 {
23    String::new()
24  } else {
25    format!("\x1B[{}A", count)
26  }
27}
28
29fn vts_move_down(count: usize) -> String {
30  if count == 0 {
31    String::new()
32  } else {
33    format!("\x1B[{}B", count)
34  }
35}
36
37pub enum TextItem<'a> {
38  Text(Cow<'a, str>),
39  HangingText { text: Cow<'a, str>, indent: u16 },
40}
41
42impl<'a> TextItem<'a> {
43  pub fn new(text: &'a str) -> Self {
44    Self::Text(Cow::Borrowed(text))
45  }
46
47  pub fn new_owned(text: String) -> Self {
48    Self::Text(Cow::Owned(text))
49  }
50
51  pub fn with_hanging_indent(text: &'a str, indent: u16) -> Self {
52    Self::HangingText {
53      text: Cow::Borrowed(text),
54      indent,
55    }
56  }
57
58  pub fn with_hanging_indent_owned(text: String, indent: u16) -> Self {
59    Self::HangingText {
60      text: Cow::Owned(text),
61      indent,
62    }
63  }
64}
65
66#[derive(Debug, PartialEq, Eq)]
67struct Line {
68  pub char_width: usize,
69  pub text: String,
70}
71
72impl Line {
73  pub fn new(text: String) -> Self {
74    Self {
75      // measure the line width each time in order to not include trailing whitespace
76      char_width: UnicodeWidthStr::width(strip_ansi_codes(&text).as_ref()),
77      text,
78    }
79  }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct ConsoleSize {
84  pub cols: Option<u16>,
85  pub rows: Option<u16>,
86}
87
88pub struct ConsoleStaticText {
89  console_size: Box<dyn (Fn() -> ConsoleSize) + Send + 'static>,
90  last_lines: Vec<Line>,
91  last_size: ConsoleSize,
92  keep_cursor_zero_column: bool,
93}
94
95impl std::fmt::Debug for ConsoleStaticText {
96  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97    f.debug_struct("StaticText")
98      .field("last_lines", &self.last_lines)
99      .field("last_size", &self.last_size)
100      .finish()
101  }
102}
103
104impl ConsoleStaticText {
105  pub fn new(
106    console_size: impl (Fn() -> ConsoleSize) + Send + 'static,
107  ) -> Self {
108    Self {
109      console_size: Box::new(console_size),
110      last_lines: Vec::new(),
111      last_size: ConsoleSize {
112        cols: None,
113        rows: None,
114      },
115      keep_cursor_zero_column: true,
116    }
117  }
118
119  /// Gets a `ConsoleStaticText` that knows how to get the console size.
120  ///
121  /// Returns `None` when stderr is not a tty or the console size can't be
122  /// retrieved from stderr.
123  #[cfg(feature = "sized")]
124  pub fn new_sized() -> Option<Self> {
125    if !atty::is(atty::Stream::Stderr) || console::size().is_none() {
126      None
127    } else {
128      Some(Self::new(|| {
129        let size = console::size();
130        ConsoleSize {
131          cols: size.map(|s| s.0 .0),
132          rows: size.map(|s| s.1 .0),
133        }
134      }))
135    }
136  }
137
138  /// Keeps the cursor at the zero column.
139  pub fn keep_cursor_zero_column(&mut self, value: bool) {
140    self.keep_cursor_zero_column = value;
141  }
142
143  pub fn console_size(&self) -> ConsoleSize {
144    (self.console_size)()
145  }
146
147  pub fn eprint_clear(&mut self) {
148    if let Some(text) = self.render_clear() {
149      std::io::stderr().write_all(text.as_bytes()).unwrap();
150    }
151  }
152
153  pub fn render_clear(&mut self) -> Option<String> {
154    let size = self.console_size();
155    self.render_clear_with_size(size)
156  }
157
158  pub fn render_clear_with_size(
159    &mut self,
160    size: ConsoleSize,
161  ) -> Option<String> {
162    let last_lines = self.get_last_lines(size);
163    if !last_lines.is_empty() {
164      let mut text = VTS_MOVE_TO_ZERO_COL.to_string();
165      let move_up_count = last_lines.len() - 1;
166      if move_up_count > 0 {
167        text.push_str(&vts_move_up(move_up_count));
168      }
169      text.push_str(VTS_CLEAR_CURSOR_DOWN);
170      Some(text)
171    } else {
172      None
173    }
174  }
175
176  pub fn eprint(&mut self, new_text: &str) {
177    if let Some(text) = self.render(new_text) {
178      std::io::stderr().write_all(text.as_bytes()).unwrap();
179    }
180  }
181
182  pub fn eprint_with_size(&mut self, new_text: &str, size: ConsoleSize) {
183    if let Some(text) = self.render_with_size(new_text, size) {
184      std::io::stderr().write_all(text.as_bytes()).unwrap();
185    }
186  }
187
188  pub fn render(&mut self, new_text: &str) -> Option<String> {
189    self.render_with_size(new_text, self.console_size())
190  }
191
192  pub fn render_with_size(
193    &mut self,
194    new_text: &str,
195    size: ConsoleSize,
196  ) -> Option<String> {
197    if new_text.is_empty() {
198      self.render_clear_with_size(size)
199    } else {
200      self.render_items_with_size([TextItem::new(new_text)].iter(), size)
201    }
202  }
203
204  pub fn eprint_items<'a>(
205    &mut self,
206    text_items: impl Iterator<Item = &'a TextItem<'a>>,
207  ) {
208    self.eprint_items_with_size(text_items, self.console_size())
209  }
210
211  pub fn eprint_items_with_size<'a>(
212    &mut self,
213    text_items: impl Iterator<Item = &'a TextItem<'a>>,
214    size: ConsoleSize,
215  ) {
216    if let Some(text) = self.render_items_with_size(text_items, size) {
217      std::io::stderr().write_all(text.as_bytes()).unwrap();
218    }
219  }
220
221  pub fn render_items<'a>(
222    &mut self,
223    text_items: impl Iterator<Item = &'a TextItem<'a>>,
224  ) -> Option<String> {
225    self.render_items_with_size(text_items, self.console_size())
226  }
227
228  pub fn render_items_with_size<'a>(
229    &mut self,
230    text_items: impl Iterator<Item = &'a TextItem<'a>>,
231    size: ConsoleSize,
232  ) -> Option<String> {
233    let is_terminal_different_size = size != self.last_size;
234    let last_lines = self.get_last_lines(size);
235    let new_lines = render_items(text_items, size);
236    let last_lines_for_new_lines = raw_render_last_items(
237      &new_lines
238        .iter()
239        .map(|l| l.text.as_str())
240        .collect::<Vec<_>>()
241        .join("\n"),
242      size,
243    );
244    let result =
245      if !are_collections_equal(&last_lines, &last_lines_for_new_lines) {
246        let mut text = String::new();
247        text.push_str(VTS_MOVE_TO_ZERO_COL);
248        if last_lines.len() > 1 {
249          text.push_str(&vts_move_up(last_lines.len() - 1));
250        }
251        if is_terminal_different_size {
252          text.push_str(VTS_CLEAR_CURSOR_DOWN);
253        }
254        for (i, new_line) in new_lines.iter().enumerate() {
255          if i > 0 {
256            text.push_str("\r\n");
257          }
258          text.push_str(&new_line.text);
259          if !is_terminal_different_size {
260            if let Some(last_line) = last_lines.get(i) {
261              if last_line.char_width > new_line.char_width {
262                text.push_str(VTS_CLEAR_UNTIL_NEWLINE);
263              }
264            }
265          }
266        }
267        if last_lines.len() > new_lines.len() {
268          text.push_str(&vts_move_down(1));
269          text.push_str(VTS_CLEAR_CURSOR_DOWN);
270          text.push_str(&vts_move_up(1));
271        }
272        if self.keep_cursor_zero_column {
273          text.push_str(VTS_MOVE_TO_ZERO_COL);
274        }
275        Some(text)
276      } else {
277        None
278      };
279    self.last_lines = last_lines_for_new_lines;
280    self.last_size = size;
281    result
282  }
283
284  fn get_last_lines(&mut self, size: ConsoleSize) -> Vec<Line> {
285    if size == self.last_size {
286      self.last_lines.drain(..).collect()
287    } else {
288      // render the last text with the current terminal width
289      let line_texts = self
290        .last_lines
291        .drain(..)
292        .map(|l| l.text)
293        .collect::<Vec<_>>();
294      let text = line_texts.join("\n");
295      raw_render_last_items(&text, size)
296    }
297  }
298}
299
300fn raw_render_last_items(text: &str, size: ConsoleSize) -> Vec<Line> {
301  let mut lines = Vec::new();
302  let text = strip_ansi_codes(text);
303  if let Some(terminal_width) = size.cols.map(|c| c as usize) {
304    for line in text.split('\n') {
305      if line.is_empty() {
306        lines.push(Line::new(String::new()));
307        continue;
308      }
309      let mut count = 0;
310      let mut current_line = String::new();
311      for c in line.chars() {
312        if let Some(width) = unicode_width::UnicodeWidthChar::width(c) {
313          if count + width > terminal_width {
314            lines.push(Line::new(current_line));
315            current_line = c.to_string();
316            count = width;
317          } else {
318            count += width;
319            current_line.push(c);
320          }
321        }
322      }
323      if !current_line.is_empty() {
324        lines.push(Line::new(current_line));
325      }
326    }
327  } else {
328    for line in text.split('\n') {
329      lines.push(Line::new(line.to_string()));
330    }
331  }
332  truncate_lines_height(lines, size)
333}
334
335fn render_items<'a>(
336  text_items: impl Iterator<Item = &'a TextItem<'a>>,
337  size: ConsoleSize,
338) -> Vec<Line> {
339  let mut lines = Vec::new();
340  let terminal_width = size.cols.map(|c| c as usize);
341  for item in text_items {
342    match item {
343      TextItem::Text(text) => {
344        lines.extend(render_text_to_lines(text, 0, terminal_width))
345      }
346      TextItem::HangingText { text, indent } => {
347        lines.extend(render_text_to_lines(
348          text,
349          *indent as usize,
350          terminal_width,
351        ));
352      }
353    }
354  }
355
356  let lines = truncate_lines_height(lines, size);
357  // ensure there's always 1 line
358  if lines.is_empty() {
359    vec![Line::new(String::new())]
360  } else {
361    lines
362  }
363}
364
365fn truncate_lines_height(lines: Vec<Line>, size: ConsoleSize) -> Vec<Line> {
366  match size.rows.map(|c| c as usize) {
367    Some(terminal_height) if lines.len() > terminal_height => {
368      let cutoff_index = lines.len() - terminal_height;
369      lines
370        .into_iter()
371        .enumerate()
372        .filter_map(|(index, line)| {
373          if index < cutoff_index {
374            None
375          } else {
376            Some(line)
377          }
378        })
379        .collect()
380    }
381    _ => lines,
382  }
383}
384
385fn render_text_to_lines(
386  text: &str,
387  hanging_indent: usize,
388  terminal_width: Option<usize>,
389) -> Vec<Line> {
390  let mut lines = Vec::new();
391  if let Some(terminal_width) = terminal_width {
392    let mut current_line = String::new();
393    let mut line_width = 0;
394    let mut current_whitespace = String::new();
395    for token in tokenize_words(text) {
396      match token {
397        WordToken::Word(word) => {
398          let word_width =
399            UnicodeWidthStr::width(strip_ansi_codes(word).as_ref());
400          let is_word_longer_than_half_line =
401            hanging_indent + word_width > (terminal_width / 2);
402          if is_word_longer_than_half_line {
403            // break it up onto multiple lines with indentation if able
404            if !current_whitespace.is_empty() {
405              if line_width < terminal_width {
406                current_line.push_str(&current_whitespace);
407              }
408              current_whitespace = String::new();
409            }
410            for ansi_token in ansi::tokenize(word) {
411              if ansi_token.is_escape {
412                current_line.push_str(&word[ansi_token.range]);
413              } else {
414                for c in word[ansi_token.range].chars() {
415                  if let Some(char_width) =
416                    unicode_width::UnicodeWidthChar::width(c)
417                  {
418                    if line_width + char_width > terminal_width {
419                      lines.push(Line::new(current_line));
420                      current_line = String::new();
421                      current_line.push_str(&" ".repeat(hanging_indent));
422                      line_width = hanging_indent;
423                    }
424                    current_line.push(c);
425                    line_width += char_width;
426                  } else {
427                    current_line.push(c);
428                  }
429                }
430              }
431            }
432          } else {
433            if line_width + word_width > terminal_width {
434              lines.push(Line::new(current_line));
435              current_line = String::new();
436              current_line.push_str(&" ".repeat(hanging_indent));
437              line_width = hanging_indent;
438              current_whitespace = String::new();
439            }
440            if !current_whitespace.is_empty() {
441              current_line.push_str(&current_whitespace);
442              current_whitespace = String::new();
443            }
444            current_line.push_str(word);
445            line_width += word_width;
446          }
447        }
448        WordToken::WhiteSpace(space_char) => {
449          current_whitespace.push(space_char);
450          line_width +=
451            unicode_width::UnicodeWidthChar::width(space_char).unwrap_or(1);
452        }
453        WordToken::LfNewLine | WordToken::CrlfNewLine => {
454          lines.push(Line::new(current_line));
455          current_line = String::new();
456          line_width = 0;
457        }
458      }
459    }
460    if !current_line.is_empty() {
461      lines.push(Line::new(current_line));
462    }
463  } else {
464    for line in text.split('\n') {
465      lines.push(Line::new(line.to_string()));
466    }
467  }
468  lines
469}
470
471fn are_collections_equal<T: PartialEq>(a: &[T], b: &[T]) -> bool {
472  a.len() == b.len() && a.iter().zip(b.iter()).all(|(a, b)| a == b)
473}
474
475#[cfg(test)]
476mod test {
477  use std::sync::Arc;
478  use std::sync::Mutex;
479
480  use crate::vts_move_down;
481  use crate::vts_move_up;
482  use crate::ConsoleSize;
483  use crate::ConsoleStaticText;
484  use crate::VTS_CLEAR_CURSOR_DOWN;
485  use crate::VTS_CLEAR_UNTIL_NEWLINE;
486  use crate::VTS_MOVE_TO_ZERO_COL;
487
488  fn test_mappings() -> Vec<(String, String)> {
489    let mut mappings = Vec::new();
490    for i in 1..10 {
491      mappings.push((format!("~CUP{}~", i), vts_move_up(i)));
492      mappings.push((format!("~CDOWN{}~", i), vts_move_down(i)));
493    }
494    mappings.push((
495      "~CLEAR_CDOWN~".to_string(),
496      VTS_CLEAR_CURSOR_DOWN.to_string(),
497    ));
498    mappings.push((
499      "~CLEAR_UNTIL_NEWLINE~".to_string(),
500      VTS_CLEAR_UNTIL_NEWLINE.to_string(),
501    ));
502    mappings.push(("~MOVE0~".to_string(), VTS_MOVE_TO_ZERO_COL.to_string()));
503    mappings
504  }
505
506  struct Tester {
507    inner: ConsoleStaticText,
508    size: Arc<Mutex<ConsoleSize>>,
509    mappings: Vec<(String, String)>,
510  }
511
512  impl Tester {
513    pub fn new() -> Self {
514      let size = Arc::new(Mutex::new(ConsoleSize {
515        cols: Some(10),
516        rows: Some(10),
517      }));
518      Self {
519        inner: {
520          let size = size.clone();
521          ConsoleStaticText::new(move || size.lock().unwrap().clone())
522        },
523        size,
524        mappings: test_mappings(),
525      }
526    }
527
528    pub fn set_cols(&self, cols: Option<u16>) {
529      self.size.lock().unwrap().cols = cols;
530    }
531
532    pub fn set_rows(&self, rows: Option<u16>) {
533      self.size.lock().unwrap().rows = rows;
534    }
535
536    /// Keeps the cursor displaying at the zero column (default).
537    ///
538    /// When set to `false`, this will keep the cursor at the end
539    /// of the line.
540    pub fn keep_cursor_zero_column(&mut self, value: bool) {
541      self.inner.keep_cursor_zero_column(value);
542    }
543
544    pub fn render(&mut self, text: &str) -> Option<String> {
545      self
546        .inner
547        .render(&self.map_text_to(text))
548        .map(|text| self.map_text_from(&text))
549    }
550
551    pub fn render_clear(&mut self) -> Option<String> {
552      self
553        .inner
554        .render_clear()
555        .map(|text| self.map_text_from(&text))
556    }
557
558    fn map_text_to(&self, text: &str) -> String {
559      let mut text = text.to_string();
560      for (from, to) in &self.mappings {
561        text = text.replace(from, to);
562      }
563      text
564    }
565
566    fn map_text_from(&self, text: &str) -> String {
567      let mut text = text.to_string();
568      for (to, from) in &self.mappings {
569        text = text.replace(from, to);
570      }
571      text
572    }
573  }
574
575  #[test]
576  fn renders() {
577    let mut tester = Tester::new();
578    let result = tester.render("01234567890123456").unwrap();
579    assert_eq!(result, "~MOVE0~~CLEAR_CDOWN~0123456789\r\n0123456~MOVE0~");
580    let result = tester.render("123").unwrap();
581    assert_eq!(
582      result,
583      "~MOVE0~~CUP1~123~CLEAR_UNTIL_NEWLINE~~CDOWN1~~CLEAR_CDOWN~~CUP1~~MOVE0~",
584    );
585    let result = tester.render_clear().unwrap();
586    assert_eq!(result, "~MOVE0~~CLEAR_CDOWN~");
587
588    let mut tester = Tester::new();
589    let result = tester.render("1").unwrap();
590    assert_eq!(result, "~MOVE0~~CLEAR_CDOWN~1~MOVE0~");
591    let result = tester.render("").unwrap();
592    assert_eq!(result, "~MOVE0~~CLEAR_CDOWN~");
593
594    // should not add a move0 here
595    tester.keep_cursor_zero_column(false);
596    let result = tester.render("1").unwrap();
597    assert_eq!(result, "~MOVE0~1");
598  }
599
600  #[test]
601  fn moves_long_text_multiple_lines() {
602    let mut tester = Tester::new();
603    let result = tester.render("012345 67890").unwrap();
604    assert_eq!(result, "~MOVE0~~CLEAR_CDOWN~012345\r\n67890~MOVE0~");
605    let result = tester.render("01234567890 67890").unwrap();
606    assert_eq!(result, "~MOVE0~~CUP1~0123456789\r\n0 67890~MOVE0~");
607  }
608
609  #[test]
610  fn text_with_blank_line() {
611    let mut tester = Tester::new();
612    let result = tester.render("012345\r\n\r\n67890").unwrap();
613    assert_eq!(result, "~MOVE0~~CLEAR_CDOWN~012345\r\n\r\n67890~MOVE0~");
614    let result = tester.render("123").unwrap();
615    assert_eq!(
616      result,
617      "~MOVE0~~CUP2~123~CLEAR_UNTIL_NEWLINE~~CDOWN1~~CLEAR_CDOWN~~CUP1~~MOVE0~"
618    );
619  }
620}