1use std::cmp;
2
3use itertools::Itertools;
4use mermaid_text::render_with_width;
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7use ratatui::style::Color;
8use tree_sitter_highlight::HighlightEvent;
9
10use crate::{
11 highlight::{COLOR_MAP, HighlightInfo, highlight_code},
12 nodes::word::MetaData,
13 util::general::GENERAL_CONFIG,
14};
15
16use super::word::{Word, WordType};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TextNode {
20 Image,
21 Paragraph,
22 LineBreak,
23 Heading,
24 Task,
25 List,
26 Footnote,
27 Table(Vec<u16>, Vec<u16>),
29 CodeBlock,
30 Quote,
31 HorizontalSeparator,
32}
33
34pub(crate) const TABLE_CELL_PADDING: u16 = 1;
35
36#[derive(Debug, Clone)]
37pub struct TextComponent {
38 kind: TextNode,
39 content: Vec<Vec<Word>>,
40 meta_info: Vec<Word>,
41 height: u16,
42 offset: u16,
43 scroll_offset: u16,
44 focused: bool,
45 focused_index: usize,
46}
47
48impl TextComponent {
49 #[must_use]
50 pub fn new(kind: TextNode, content: Vec<Word>) -> Self {
51 let meta_info: Vec<Word> = content
52 .iter()
53 .filter(|c| !c.is_renderable() || c.kind() == WordType::FootnoteInline)
54 .cloned()
55 .collect();
56
57 let content = content.into_iter().filter(Word::is_renderable).collect();
58
59 Self {
60 kind,
61 content: vec![content],
62 meta_info,
63 height: 0,
64 offset: 0,
65 scroll_offset: 0,
66 focused: false,
67 focused_index: 0,
68 }
69 }
70
71 #[must_use]
72 pub fn new_formatted(kind: TextNode, content: Vec<Vec<Word>>) -> Self {
73 Self::new_formatted_with_meta(kind, content, Vec::new())
74 }
75
76 #[must_use]
77 pub fn new_formatted_with_meta(
78 kind: TextNode,
79 content: Vec<Vec<Word>>,
80 mut meta_info: Vec<Word>,
81 ) -> Self {
82 meta_info.extend(
83 content
84 .iter()
85 .flatten()
86 .filter(|c| !c.is_renderable())
87 .cloned(),
88 );
89
90 let content: Vec<Vec<Word>> = content
91 .into_iter()
92 .map(|c| c.into_iter().filter(Word::is_renderable).collect())
93 .collect();
94
95 Self {
96 kind,
97 height: content.len() as u16,
98 meta_info,
99 content,
100 offset: 0,
101 scroll_offset: 0,
102 focused: false,
103 focused_index: 0,
104 }
105 }
106
107 #[must_use]
108 pub fn kind(&self) -> TextNode {
109 self.kind.clone()
110 }
111
112 #[must_use]
113 pub fn content(&self) -> &Vec<Vec<Word>> {
114 &self.content
115 }
116
117 #[must_use]
118 pub fn content_as_lines(&self) -> Vec<String> {
119 if let TextNode::Table(widths, _) = self.kind() {
120 let column_count = widths.len();
121
122 let moved_content = self.content.chunks(column_count).collect::<Vec<_>>();
123
124 let mut lines = Vec::new();
125
126 moved_content.iter().for_each(|line| {
127 let temp = line
128 .iter()
129 .map(|c| c.iter().map(Word::content).join(""))
130 .join(" ");
131 lines.push(temp);
132 });
133
134 lines
135 } else {
136 self.content
137 .iter()
138 .map(|c| c.iter().map(Word::content).collect::<Vec<_>>().join(""))
139 .collect()
140 }
141 }
142
143 #[must_use]
144 pub fn content_as_bytes(&self) -> Vec<u8> {
145 match self.kind() {
146 TextNode::CodeBlock => self.content_as_lines().join("").as_bytes().to_vec(),
147 _ => {
148 let strings = self.content_as_lines();
149 let string = strings.join("\n");
150 string.as_bytes().to_vec()
151 }
152 }
153 }
154
155 #[must_use]
156 pub fn content_owned(self) -> Vec<Vec<Word>> {
157 self.content
158 }
159
160 #[must_use]
161 pub fn meta_info(&self) -> &Vec<Word> {
162 &self.meta_info
163 }
164
165 #[must_use]
166 pub fn height(&self) -> u16 {
167 self.height
168 }
169
170 #[must_use]
171 pub fn y_offset(&self) -> u16 {
172 self.offset
173 }
174
175 #[must_use]
176 pub fn scroll_offset(&self) -> u16 {
177 self.scroll_offset
178 }
179
180 pub fn set_y_offset(&mut self, y_offset: u16) {
181 self.offset = y_offset;
182 }
183
184 pub fn set_scroll_offset(&mut self, offset: u16) {
185 self.scroll_offset = offset;
186 }
187
188 #[must_use]
189 pub fn is_focused(&self) -> bool {
190 self.focused
191 }
192
193 pub fn deselect(&mut self) {
194 self.focused = false;
195 self.focused_index = 0;
196 self.content
197 .iter_mut()
198 .flatten()
199 .filter(|c| c.kind() == WordType::Selected)
200 .for_each(|c| {
201 c.clear_kind();
202 });
203 }
204
205 pub fn visually_select(&mut self, index: usize) -> Result<(), String> {
206 self.focused = true;
207 self.focused_index = index;
208
209 if index >= self.num_links() {
210 return Err(format!(
211 "Index out of bounds: {} >= {}",
212 index,
213 self.num_links()
214 ));
215 }
216
217 self.link_words_mut()
219 .get_mut(index)
220 .ok_or("index out of bounds")?
221 .iter_mut()
222 .for_each(|c| {
223 c.set_kind(WordType::Selected);
224 });
225 Ok(())
226 }
227
228 fn link_words_mut(&mut self) -> Vec<Vec<&mut Word>> {
229 let mut selection: Vec<Vec<&mut Word>> = Vec::new();
230 let mut iter = self.content.iter_mut().flatten().peekable();
231 while let Some(e) = iter.peek() {
232 if matches!(e.kind(), WordType::Link | WordType::FootnoteInline) {
233 selection.push(
234 iter.by_ref()
235 .take_while(|c| {
236 matches!(c.kind(), WordType::Link | WordType::FootnoteInline)
237 })
238 .collect(),
239 );
240 } else {
241 iter.next();
242 }
243 }
244 selection
245 }
246
247 #[must_use]
248 pub fn get_footnote(&self, search: &str) -> String {
249 self.content()
250 .iter()
251 .flatten()
252 .skip_while(|c| c.kind() != WordType::FootnoteData && c.content() != search)
253 .take_while(|c| c.kind() == WordType::Footnote)
254 .map(Word::content)
255 .collect()
256 }
257
258 pub fn highlight_link(&self) -> Result<&str, String> {
259 Ok(self
260 .meta_info()
261 .iter()
262 .filter(|c| matches!(c.kind(), WordType::LinkData | WordType::FootnoteInline))
263 .nth(self.focused_index)
264 .ok_or("index out of bounds")?
265 .content())
266 }
267
268 #[must_use]
269 pub fn num_links(&self) -> usize {
270 self.meta_info
271 .iter()
272 .filter(|c| matches!(c.kind(), WordType::LinkData | WordType::FootnoteInline))
273 .count()
274 }
275
276 #[must_use]
277 pub fn selected_heights(&self) -> Vec<usize> {
278 let mut heights = Vec::new();
279
280 if let TextNode::Table(widths, row_heights) = self.kind() {
281 let column_count = widths.len();
282 let iter = self.content.chunks(column_count).enumerate();
283
284 for (i, line) in iter {
285 if line
286 .iter()
287 .flatten()
288 .any(|c| c.kind() == WordType::Selected)
289 {
290 let offset = 1
291 + row_heights.iter().take(i).copied().sum::<u16>() as usize
292 + usize::from(i > 0);
293 heights.push(offset);
294 }
295 }
296 return heights;
297 }
298
299 for (i, line) in self.content.iter().enumerate() {
300 if line.iter().any(|c| c.kind() == WordType::Selected) {
301 heights.push(i);
302 }
303 }
304 heights
305 }
306
307 pub fn words_mut(&mut self) -> Vec<&mut Word> {
308 self.content.iter_mut().flatten().collect()
309 }
310
311 pub fn transform(&mut self, width: u16) {
312 match self.kind {
313 TextNode::List => {
314 transform_list(self, width);
315 }
316 TextNode::CodeBlock => {
317 transform_codeblock(self);
318 }
319 TextNode::Paragraph | TextNode::Task | TextNode::Quote => {
320 transform_paragraph(self, width);
321 }
322 TextNode::LineBreak | TextNode::Heading => {
323 self.height = 1;
324 }
325 TextNode::Table(_, _) => {
326 transform_table(self, width);
327 }
328 TextNode::HorizontalSeparator => self.height = 1,
329 TextNode::Image => unreachable!("Image should not be transformed"),
330 TextNode::Footnote => self.height = 0,
331 }
332 }
333}
334
335pub(crate) fn word_wrapping<'a>(
336 words: impl IntoIterator<Item = &'a Word>,
337 width: usize,
338 allow_hyphen: bool,
339) -> Vec<Vec<Word>> {
340 let enable_hyphen = allow_hyphen && width > 4;
341
342 let mut lines = Vec::new();
343 let mut line = Vec::new();
344 let mut line_len = 0;
345 for word in words {
346 let word_len = display_width(word.content());
347 if line_len + word_len <= width {
348 line_len += word_len;
349 line.push(word.clone());
350 } else if word_len <= width {
351 lines.push(line);
352 let mut word = word.clone();
353 let content = word.content().trim_start().to_owned();
354 word.set_content(content);
355
356 line_len = display_width(word.content());
357 line = vec![word];
358 } else {
359 let content = word.content().to_owned();
360
361 if width - line_len < 4 {
362 line_len = 0;
363 lines.push(line);
364 line = Vec::new();
365 }
366
367 let split_width = if enable_hyphen && !content.ends_with('-') {
368 width - line_len - 1
369 } else {
370 width - line_len
371 };
372
373 let (mut content, mut newline_content) = split_by_width(&content, split_width);
374 if enable_hyphen && !content.ends_with('-') && !content.is_empty() {
375 if let Some(last_char) = content.pop() {
376 newline_content.insert(0, last_char);
377 }
378 content.push('-');
379 }
380
381 line.push(Word::new(content, word.kind()));
382 lines.push(line);
383
384 while display_width(&newline_content) > width {
385 let split_width = if enable_hyphen && !newline_content.ends_with('-') {
386 width - 1
387 } else {
388 width
389 };
390 let (mut content, mut next_newline_content) =
391 split_by_width(&newline_content, split_width);
392 if enable_hyphen && !newline_content.ends_with('-') && !content.is_empty() {
393 if let Some(last_char) = content.pop() {
394 next_newline_content.insert(0, last_char);
395 }
396 content.push('-');
397 }
398
399 line = vec![Word::new(content, word.kind())];
400 lines.push(line);
401 newline_content = next_newline_content;
402 }
403
404 if newline_content.is_empty() {
405 line_len = 0;
406 line = Vec::new();
407 } else {
408 line_len = display_width(&newline_content);
409 line = vec![Word::new(newline_content, word.kind())];
410 }
411 }
412 }
413
414 if !line.is_empty() {
415 lines.push(line);
416 }
417
418 lines
419}
420
421fn display_width(text: &str) -> usize {
422 UnicodeWidthStr::width(text)
423}
424
425fn split_by_width(text: &str, max_width: usize) -> (String, String) {
426 if max_width == 0 {
427 return (String::new(), text.to_string());
428 }
429
430 let mut width = 0;
431 let mut split_idx = 0;
432 for (i, c) in text.char_indices() {
434 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
435 if width + char_width > max_width {
436 if split_idx == 0 {
437 split_idx = i + c.len_utf8();
438 }
439 break;
440 }
441 width += char_width;
442 split_idx = i + c.len_utf8();
443 if width == max_width {
444 break;
445 }
446 }
447
448 let (head, tail) = text.split_at(split_idx);
449 (head.to_string(), tail.to_string())
450}
451
452fn transform_paragraph(component: &mut TextComponent, width: u16) {
453 let width = match component.kind {
454 TextNode::Paragraph => width as usize - 1,
455 TextNode::Task => width as usize - 4,
456 TextNode::Quote => width as usize - 2,
457 _ => unreachable!(),
458 };
459
460 let mut lines = word_wrapping(component.content.iter().flatten(), width, true);
461
462 if component.kind() == TextNode::Quote {
463 let is_special_quote = !component.meta_info.is_empty();
464
465 for line in lines.iter_mut().skip(usize::from(is_special_quote)) {
466 line.insert(0, Word::new(" ".to_string(), WordType::Normal));
467 }
468 }
469
470 component.height = lines.len() as u16;
471 component.content = lines;
472}
473
474fn transform_codeblock(component: &mut TextComponent) {
475 let language = if let Some(word) = component.meta_info().first() {
476 word.content()
477 } else {
478 ""
479 };
480
481 let highlight = highlight_code(language, &component.content_as_bytes());
482
483 let content = component.content_as_lines().join("");
484
485 let mut new_content = Vec::new();
486
487 if language.is_empty() {
488 component.content.insert(
489 0,
490 vec![Word::new(String::new(), WordType::CodeBlock(Color::Reset))],
491 );
492 }
493 match highlight {
494 HighlightInfo::Highlighted(e) => {
495 let mut color = Color::Reset;
496 for event in e {
497 match event {
498 HighlightEvent::Source { start, end } => {
499 let word =
500 Word::new(content[start..end].to_string(), WordType::CodeBlock(color));
501 new_content.push(word);
502 }
503 HighlightEvent::HighlightStart(index) => {
504 color = COLOR_MAP[index.0];
505 }
506 HighlightEvent::HighlightEnd => color = Color::Reset,
507 }
508 }
509
510 let mut final_content = Vec::new();
512 let mut inner_content = Vec::new();
513 for word in new_content {
514 if word.content().contains('\n') {
515 let mut start = 0;
516 let mut end;
517 for (i, c) in word.content().char_indices() {
518 if c == '\n' {
519 end = i;
520 let new_word =
521 Word::new(word.content()[start..end].to_string(), word.kind());
522 inner_content.push(new_word);
523 start = i + 1;
524 final_content.push(inner_content);
525 inner_content = Vec::new();
526 } else if i == word.content().len() - 1 {
527 let new_word =
528 Word::new(word.content()[start..].to_string(), word.kind());
529 inner_content.push(new_word);
530 }
531 }
532 } else {
533 inner_content.push(word);
534 }
535 }
536
537 final_content.push(vec![Word::new(String::new(), WordType::CodeBlock(color))]);
538
539 component.content = final_content;
540 }
541 HighlightInfo::Unhighlighted => (),
542 HighlightInfo::Mermaid => {
543 let Ok(output) = render_with_width(&content, Some(GENERAL_CONFIG.width as usize - 5))
544 else {
545 return;
546 };
547
548 let mut final_content = Vec::new();
549
550 final_content.push(vec![Word::new(String::new(), WordType::Normal)]);
551
552 for line in output.lines() {
553 final_content.push(vec![Word::new(line.to_owned(), WordType::Normal)]);
554 }
555
556 final_content.push(vec![Word::new(String::new(), WordType::Normal)]);
557
558 component.content = final_content;
559 }
560 }
561
562 let max_line_len = component
565 .content()
566 .iter()
567 .map(|inner| inner.iter().fold(0, |acc, x| acc + x.content().width()))
568 .max()
569 .unwrap_or(0);
570
571 let height = component.content.len() as u16;
572 component.height = height;
573 component.meta_info.push(Word::new(
574 String::new(),
575 WordType::MetaInfo(MetaData::LineLength(max_line_len as u16)),
576 ));
577}
578
579fn transform_list(component: &mut TextComponent, width: u16) {
580 let mut len = 0;
581 let mut lines = Vec::new();
582 let mut line = Vec::new();
583 let indent_iter = component
584 .meta_info
585 .iter()
586 .filter(|c| c.content().trim() == "");
587 let list_type_iter = component.meta_info.iter().filter(|c| {
588 matches!(
589 c.kind(),
590 WordType::MetaInfo(MetaData::OList | MetaData::UList)
591 )
592 });
593
594 let mut zip_iter = indent_iter.zip(list_type_iter);
595
596 let mut o_list_counter_stack = vec![0];
597 let mut max_stack_len = 1;
598 let mut indent = 0;
599 let mut extra_indent = 0;
600 let mut tmp = indent;
601 for word in component.content.iter_mut().flatten() {
602 let word_len = display_width(word.content());
603 if word_len + len < width as usize && word.kind() != WordType::ListMarker {
604 len += word_len;
605 line.push(word.clone());
606 } else {
607 let filler_content = if word.kind() == WordType::ListMarker {
608 indent = if let Some((meta, list_type)) = zip_iter.next() {
609 match tmp.cmp(&display_width(meta.content())) {
610 cmp::Ordering::Less => {
611 o_list_counter_stack.push(0);
612 max_stack_len += 1;
613 }
614 cmp::Ordering::Greater => {
615 o_list_counter_stack.pop();
616 }
617 cmp::Ordering::Equal => (),
618 }
619 if list_type.kind() == WordType::MetaInfo(MetaData::OList) {
620 let counter = o_list_counter_stack
621 .last_mut()
622 .expect("List parse error. Stack is empty");
623
624 *counter += 1;
625
626 word.set_content(format!("{counter}. "));
627
628 extra_indent = 1; } else {
630 extra_indent = 0;
631 }
632 tmp = display_width(meta.content());
633 tmp
634 } else {
635 0
636 };
637
638 " ".repeat(indent)
639 } else {
640 " ".repeat(indent + 2 + extra_indent)
641 };
642
643 let filler = Word::new(filler_content, WordType::Normal);
644
645 lines.push(line);
646 let content = word.content().trim_start().to_owned();
647 word.set_content(content);
648 len = display_width(word.content()) + display_width(filler.content());
649 line = vec![filler, word.to_owned()];
650 }
651 }
652 lines.push(line);
653 lines.retain(|l| l.iter().any(|c| c.content() != ""));
655
656 let mut indent_correction = vec![0; max_stack_len];
659 let mut indent_index: u32 = 0;
660 let mut indent_len = 0;
661
662 for line in &lines {
663 if !line[1]
664 .content()
665 .strip_prefix(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
666 .is_some_and(|c| c.ends_with(". "))
667 {
668 continue;
669 }
670
671 match indent_len.cmp(&display_width(line[0].content())) {
672 cmp::Ordering::Less => {
673 indent_index += 1;
674 indent_len = display_width(line[0].content());
675 }
676 cmp::Ordering::Greater => {
677 indent_index = indent_index.saturating_sub(1);
678 indent_len = display_width(line[0].content());
679 }
680 cmp::Ordering::Equal => (),
681 }
682
683 indent_correction[indent_index as usize] = cmp::max(
684 indent_correction[indent_index as usize],
685 display_width(line[1].content()),
686 );
687 }
688
689 indent_index = 0;
693 indent_len = 0;
694 let mut unordered_list_skip = true; for line in &mut lines {
697 if line[1]
698 .content()
699 .strip_prefix(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
700 .is_some_and(|c| c.ends_with(". "))
701 {
702 unordered_list_skip = false;
703 }
704
705 if line[1].content() == "• " || unordered_list_skip {
706 unordered_list_skip = true;
707 continue;
708 }
709
710 let amount = if line[1]
711 .content()
712 .strip_prefix(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
713 .is_some_and(|c| c.ends_with(". "))
714 {
715 match indent_len.cmp(&display_width(line[0].content())) {
716 cmp::Ordering::Less => {
717 indent_index += 1;
718 indent_len = display_width(line[0].content());
719 }
720 cmp::Ordering::Greater => {
721 indent_index = indent_index.saturating_sub(1);
722 indent_len = display_width(line[0].content());
723 }
724 cmp::Ordering::Equal => (),
725 }
726 indent_correction[indent_index as usize]
727 .saturating_sub(display_width(line[1].content()))
728 + display_width(line[0].content())
729 } else {
730 (indent_correction[indent_index as usize] + display_width(line[0].content()))
732 .saturating_sub(3)
733 };
734
735 line[0].set_content(" ".repeat(amount));
736 }
737
738 component.height = lines.len() as u16;
739 component.content = lines;
740}
741
742fn table_styling_width(column_count: usize) -> u16 {
743 1 + column_count as u16 * (TABLE_CELL_PADDING * 2 + 1)
744}
745
746fn transform_table(component: &mut TextComponent, width: u16) {
747 let width = width.saturating_sub(1);
749 let content = &mut component.content;
750
751 let column_count = component
752 .meta_info
753 .iter()
754 .filter(|w| w.kind() == WordType::MetaInfo(MetaData::ColumnsCount))
755 .count();
756
757 if !content.len().is_multiple_of(column_count) || column_count == 0 {
758 component.height = 1;
759 component.kind = TextNode::Table(vec![], vec![]);
760 return;
761 }
762
763 assert!(
764 content.len().is_multiple_of(column_count),
765 "Invalid table cell distribution: content.len() = {}, column_count = {}",
766 content.len(),
767 column_count
768 );
769
770 let row_count = content.len() / column_count;
771
772 let widths = {
776 let mut widths = vec![0; column_count];
777 content.chunks(column_count).for_each(|row| {
778 row.iter().enumerate().for_each(|(col_i, entry)| {
779 let len = content_entry_len(entry);
780 if len > widths[col_i] as usize {
781 widths[col_i] = len as u16;
782 }
783 });
784 });
785
786 widths
787 };
788
789 let styling_width = table_styling_width(column_count);
790 let unbalanced_cells_width = widths.iter().sum::<u16>();
791
792 if width >= unbalanced_cells_width + styling_width {
796 component.height = row_count as u16 + 3;
797 component.kind = TextNode::Table(widths, vec![1; row_count]);
798 return;
799 }
800
801 let overflow_threshold = width.saturating_sub(styling_width) / column_count as u16;
805 let mut overflowing_columns = vec![];
806
807 let (overflowing_width, non_overflowing_width) = {
808 let mut overflowing_width = 0;
809 let mut non_overflowing_width = 0;
810
811 for (column_i, column_width) in widths.iter().enumerate() {
812 if *column_width > overflow_threshold {
813 overflowing_columns.push((column_i, column_width));
814
815 overflowing_width += column_width;
816 } else {
817 non_overflowing_width += column_width;
818 }
819 }
820
821 (overflowing_width, non_overflowing_width)
822 };
823
824 if overflowing_columns.is_empty() {
825 component.height = row_count as u16 + 3;
826 component.kind = TextNode::Table(widths, vec![1; row_count]);
827 return;
828 }
829
830 let mut available_balanced_width = width.saturating_sub(non_overflowing_width + styling_width);
834 let mut available_overflowing_width = overflowing_width;
835
836 let overflowing_column_min_width =
837 (available_balanced_width / (2 * overflowing_columns.len() as u16)).max(1);
838
839 let mut widths_balanced: Vec<u16> = widths.clone();
840 for (column_i, old_column_width) in overflowing_columns
841 .iter()
842 .sorted_by(|a, b| Ord::cmp(a.1, b.1))
845 {
846 let ratio = f32::from(**old_column_width) / f32::from(available_overflowing_width);
848 let mut balanced_column_width =
849 (ratio * f32::from(available_balanced_width)).floor() as u16;
850
851 if balanced_column_width < overflowing_column_min_width {
852 balanced_column_width = overflowing_column_min_width;
853 available_overflowing_width -= **old_column_width;
854 available_balanced_width =
855 available_balanced_width.saturating_sub(balanced_column_width);
856 }
857
858 widths_balanced[*column_i] = balanced_column_width;
859 }
860
861 let mut heights = vec![1; row_count];
865 for (row_i, row) in content
866 .iter_mut()
867 .chunks(column_count)
868 .into_iter()
869 .enumerate()
870 {
871 for (column_i, entry) in row.into_iter().enumerate() {
872 let lines = word_wrapping(
873 entry.drain(..).as_ref(),
874 widths_balanced[column_i] as usize,
875 true,
876 );
877
878 if heights[row_i] < lines.len() as u16 {
879 heights[row_i] = lines.len() as u16;
880 }
881
882 let _drop = std::mem::replace(entry, lines.into_iter().flatten().collect());
883 }
884 }
885
886 component.height = heights.iter().copied().sum::<u16>() + 3;
887
888 component.kind = TextNode::Table(widths_balanced, heights);
889}
890
891#[must_use]
892pub fn content_entry_len(words: &[Word]) -> usize {
893 words.iter().map(|word| display_width(word.content())).sum()
894}