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", "\x1B[J", );
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 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 #[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 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 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 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 if !current_whitespace.is_empty() {
405 if line_width < terminal_width {
406 current_line.push_str(¤t_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(¤t_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 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 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}