use super::{
elements::{Line, Text},
text_style::TextStyle,
};
use std::{fmt, mem};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct WeightedLine {
text: Vec<WeightedText>,
width: usize,
font_size: u8,
}
impl WeightedLine {
pub(crate) fn split(&self, max_length: usize) -> SplitTextIter<'_> {
SplitTextIter::new(&self.text, max_length)
}
pub(crate) fn width(&self) -> usize {
self.width
}
pub(crate) fn font_size(&self) -> u8 {
self.font_size
}
}
impl From<Line> for WeightedLine {
fn from(block: Line) -> Self {
block.0.into()
}
}
impl From<Vec<Text>> for WeightedLine {
fn from(mut texts: Vec<Text>) -> Self {
let mut output = Vec::new();
let mut index = 0;
let mut width = 0;
let mut font_size = 1;
while index < texts.len() {
let mut target = mem::replace(&mut texts[index], Text::from(""));
let mut current = index + 1;
while current < texts.len() && texts[current].style == target.style {
let current_content = mem::take(&mut texts[current].content);
target.content.push_str(¤t_content);
current += 1;
}
let size = target.style.size.max(1);
width += target.content.width() * size as usize;
output.push(target.into());
index = current;
font_size = font_size.max(size);
}
Self { text: output, width, font_size }
}
}
impl From<String> for WeightedLine {
fn from(text: String) -> Self {
let width = text.width();
let text = vec![WeightedText::from(text)];
Self { text, width, font_size: 1 }
}
}
impl From<&str> for WeightedLine {
fn from(text: &str) -> Self {
Self::from(text.to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CharAccumulator {
width: usize,
bytes: usize,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct WeightedText {
text: Text,
accumulators: Vec<CharAccumulator>,
}
impl WeightedText {
fn to_ref(&self) -> WeightedTextRef<'_> {
WeightedTextRef { text: &self.text.content, accumulators: &self.accumulators, style: self.text.style }
}
pub(crate) fn width(&self) -> usize {
self.to_ref().width()
}
pub(crate) fn text(&self) -> &Text {
&self.text
}
}
impl<S: Into<String>> From<S> for WeightedText {
fn from(text: S) -> Self {
Self::from(Text::from(text.into()))
}
}
impl From<Text> for WeightedText {
fn from(text: Text) -> Self {
let mut accumulators = Vec::new();
let mut width = 0;
let mut bytes = 0;
for c in text.content.chars() {
accumulators.push(CharAccumulator { width, bytes });
width += c.width().unwrap_or(0);
bytes += c.len_utf8();
}
accumulators.push(CharAccumulator { width, bytes });
Self { text, accumulators }
}
}
impl fmt::Debug for WeightedText {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WeightedText").field("text", &self.text).finish()
}
}
pub(crate) struct SplitTextIter<'a> {
texts: &'a [WeightedText],
max_length: usize,
current: Option<WeightedTextRef<'a>>,
}
impl<'a> SplitTextIter<'a> {
fn new(texts: &'a [WeightedText], max_length: usize) -> Self {
Self { texts, max_length, current: texts.first().map(WeightedText::to_ref) }
}
}
impl<'a> Iterator for SplitTextIter<'a> {
type Item = Vec<WeightedTextRef<'a>>;
fn next(&mut self) -> Option<Self::Item> {
self.current.as_ref()?;
let mut elements = Vec::new();
let mut remaining = self.max_length as i64;
while let Some(current) = self.current.take() {
let (head, rest) = current.word_split_at_length(remaining as usize);
if !rest.text.is_empty() && !rest.text.starts_with(' ') && !elements.is_empty() {
self.current = Some(current);
break;
}
let head_width = head.width();
remaining -= head_width as i64;
elements.push(head);
if !rest.text.is_empty() {
self.current = Some(rest.trim_start());
break;
}
self.texts = &self.texts[1..];
self.current = self.texts.first().map(WeightedText::to_ref);
}
Some(elements)
}
}
#[derive(Clone, Debug)]
pub(crate) struct WeightedTextRef<'a> {
text: &'a str,
accumulators: &'a [CharAccumulator],
style: TextStyle,
}
impl<'a> WeightedTextRef<'a> {
pub(crate) fn into_parts(self) -> (&'a str, TextStyle) {
(self.text, self.style)
}
fn word_split_at_length(&self, max_length: usize) -> (Self, Self) {
if self.width() <= max_length {
return (self.make_ref(0, self.text.len()), self.make_ref(0, 0));
}
let max_length = (max_length / self.style.size as usize).max(1);
let target_chunk = self.substr(max_length + 1);
let output_chunk = match target_chunk.rsplit_once(' ') {
Some((before, _)) => before,
None => self.substr(max_length),
};
(self.make_ref(0, output_chunk.len()), self.make_ref(output_chunk.len(), self.text.len()))
}
fn substr(&self, max_length: usize) -> &'a str {
let last_index = self.bytes_until(max_length);
&self.text[0..last_index]
}
fn make_ref(&self, from: usize, to: usize) -> Self {
let text = &self.text[from..to];
let leading_char_count = self.text[0..from].chars().count();
let output_char_count = text.chars().count();
let character_lengths = &self.accumulators[leading_char_count..leading_char_count + output_char_count + 1];
WeightedTextRef { text, accumulators: character_lengths, style: self.style }
}
fn trim_start(self) -> Self {
let text = self.text.trim_start();
let trimmed = self.text.chars().count() - text.chars().count();
let accumulators = &self.accumulators[trimmed..];
Self { text, accumulators, style: self.style }
}
pub(crate) fn width(&self) -> usize {
let last_width = self.accumulators.last().map(|a| a.width).unwrap_or(0);
let first_width = self.accumulators.first().map(|a| a.width).unwrap_or(0);
(last_width - first_width) * self.style.size as usize
}
fn bytes_until(&self, index: usize) -> usize {
let last_bytes =
self.accumulators.get(index).or_else(|| self.accumulators.last()).map(|a| a.bytes).unwrap_or(0);
let first_bytes = self.accumulators.first().map(|a| a.bytes).unwrap_or(0);
last_bytes - first_bytes
}
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
fn join_lines<'a>(lines: impl Iterator<Item = Vec<WeightedTextRef<'a>>>) -> Vec<String> {
lines.map(|l| l.iter().map(|weighted| weighted.text).collect::<Vec<_>>().join(" ")).collect()
}
#[test]
fn text_creation() {
let text = WeightedText::from("hello world");
let text_ref = text.to_ref();
assert_eq!(text_ref.width(), 11);
}
#[test]
fn text_creation_utf8() {
let text = WeightedText::from("█████");
let text_ref = text.to_ref();
assert_eq!(text_ref.width(), 5);
assert_eq!(text_ref.bytes_until(0), 0);
assert_eq!(text_ref.bytes_until(1), 3);
assert_eq!(text_ref.bytes_until(2), 6);
assert_eq!(text_ref.bytes_until(3), 9);
assert_eq!(text_ref.bytes_until(4), 12);
let text_ref = text_ref.make_ref(3, 12);
assert_eq!(text_ref.width(), 3);
assert_eq!(text_ref.bytes_until(0), 0);
assert_eq!(text_ref.bytes_until(1), 3);
assert_eq!(text_ref.bytes_until(2), 6);
let text_ref = text_ref.make_ref(0, 9);
assert_eq!(text_ref.width(), 3);
assert_eq!(text_ref.bytes_until(0), 0);
assert_eq!(text_ref.bytes_until(1), 3);
assert_eq!(text_ref.bytes_until(2), 6);
}
#[test]
fn minimal_split() {
let text = WeightedText::from("█████");
let text_ref = text.to_ref();
let (head, rest) = text_ref.word_split_at_length(1);
assert_eq!(head.width(), 1);
assert_eq!(rest.width(), 4);
}
#[test]
fn no_spaces_split() {
let text = WeightedText::from("█████");
let text_ref = text.to_ref();
let (head, rest) = text_ref.word_split_at_length(2);
assert_eq!(head.width(), 2);
assert_eq!(rest.width(), 3);
}
#[test]
fn font_size_split() {
let text = WeightedText::from(Text::new("█████", TextStyle::default().size(2)));
let text_ref = text.to_ref();
let (head, rest) = text_ref.word_split_at_length(3);
assert_eq!(head.width(), 2);
assert_eq!(rest.width(), 8);
}
#[test]
fn make_ref() {
let text = WeightedText::from("hello world");
let text_ref = text.to_ref();
let head = text_ref.make_ref(0, 1);
assert_eq!(head.text, "h");
assert_eq!(head.width(), 1);
let rest = text_ref.make_ref(1, 11);
assert_eq!(rest.text, "ello world");
assert_eq!(rest.width(), 10);
}
#[test]
fn word_split() {
let text = WeightedText::from("short string");
let (head, rest) = text.to_ref().word_split_at_length(7);
assert_eq!(head.text, "short");
assert_eq!(rest.text, " string");
}
#[test]
fn split_at_full_length() {
let text = WeightedLine::from("hello world");
let lines = join_lines(text.split(11));
let expected = vec!["hello world"];
assert_eq!(lines, expected);
}
#[test]
fn no_split_necessary() {
let text = WeightedLine {
text: vec![WeightedText::from("short"), WeightedText::from("text")],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(50));
let expected = vec!["short text"];
assert_eq!(lines, expected);
}
#[test]
fn split_lines_single() {
let text =
WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, font_size: 1 };
let lines = join_lines(text.split(6));
let expected = vec!["this", "is a", "slight", "ly", "long", "line"];
assert_eq!(lines, expected);
}
#[test]
fn split_lines_multi() {
let text = WeightedLine {
text: vec![
WeightedText::from("this is a slightly long line"),
WeightedText::from("another chunk"),
WeightedText::from("yet some other piece"),
],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(10));
let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"];
assert_eq!(lines, expected);
}
#[test]
fn long_splits() {
let text = WeightedLine {
text: vec![
WeightedText::from("this is a slightly long line"),
WeightedText::from("another chunk"),
WeightedText::from("yet some other piece"),
],
width: 0,
font_size: 1,
};
let lines = join_lines(text.split(50));
let expected = vec!["this is a slightly long line another chunk yet some", "other piece"];
assert_eq!(lines, expected);
}
#[test]
fn prefixed_by_whitespace() {
let text = WeightedLine::from(" * bullet");
let lines = join_lines(text.split(50));
let expected = vec![" * bullet"];
assert_eq!(lines, expected);
}
#[test]
fn utf8_character() {
let text = WeightedLine::from("• A");
let lines = join_lines(text.split(50));
let expected = vec!["• A"];
assert_eq!(lines, expected);
}
#[test]
fn many_utf8_characters() {
let content = "█████ ██";
let text = WeightedLine::from(content);
let lines = join_lines(text.split(3));
let expected = vec!["███", "██", "██"];
assert_eq!(lines, expected);
}
#[test]
fn no_whitespaces_ascii() {
let content = "X".repeat(10);
let text = WeightedLine::from(content);
let lines = join_lines(text.split(3));
let expected = vec!["XXX", "XXX", "XXX", "X"];
assert_eq!(lines, expected);
}
#[test]
fn no_whitespaces_utf8() {
let content = "─".repeat(10);
let text = WeightedLine::from(content);
let lines = join_lines(text.split(3));
let expected = vec!["───", "───", "───", "─"];
assert_eq!(lines, expected);
}
#[test]
fn wide_characters() {
let content = "Hello world";
let text = WeightedLine::from(content);
let lines = join_lines(text.split(10));
let expected = vec!["Hello", "world"];
assert_eq!(lines, expected);
}
#[rstest]
#[case::single(&["hello".into()], 1)]
#[case::two(&["hello".into(), " world".into()], 1)]
#[case::three(&["hello".into(), " ".into(), "world".into()], 1)]
#[case::split(&["hello".into(), Text::new(" ", TextStyle::default().bold()), "world".into()], 3)]
#[case::split_merged(&["hello".into(), Text::new(" ", TextStyle::default().bold()), Text::new("w", TextStyle::default().bold()), "orld".into()], 3)]
fn compaction(#[case] texts: &[Text], #[case] expected: usize) {
let block = WeightedLine::from(texts.to_vec());
assert_eq!(block.text.len(), expected);
}
}