use crate::div::FontWeight;
use crate::styled_text::{StyledLine, TextSpan};
use crate::text_measure::{measure_text_with_options, TextLayoutOptions};
#[derive(Clone, Debug)]
pub struct WrappedLine {
pub line: StyledLine,
pub source_start_col: usize,
pub source_end_col: usize,
}
pub fn wrap_styled_line(
line: &StyledLine,
max_width: f32,
font_size: f32,
weight: FontWeight,
italic: bool,
) -> Vec<StyledLine> {
wrap_styled_line_with_offsets(line, max_width, font_size, weight, italic)
.into_iter()
.map(|w| w.line)
.collect()
}
pub fn wrap_styled_line_with_offsets(
line: &StyledLine,
max_width: f32,
font_size: f32,
weight: FontWeight,
italic: bool,
) -> Vec<WrappedLine> {
if line.text.is_empty() {
return vec![WrappedLine {
line: line.clone(),
source_start_col: 0,
source_end_col: 0,
}];
}
if max_width <= 0.0 {
let total_chars = line.text.chars().count();
return vec![WrappedLine {
line: line.clone(),
source_start_col: 0,
source_end_col: total_chars,
}];
}
let options = base_options(weight, italic);
let total = measure_text_with_options(&line.text, font_size, &options).width;
if total <= max_width {
let total_chars = line.text.chars().count();
return vec![WrappedLine {
line: line.clone(),
source_start_col: 0,
source_end_col: total_chars,
}];
}
let words = collect_words(&line.text);
if words.is_empty() {
return vec![WrappedLine {
line: line.clone(),
source_start_col: 0,
source_end_col: line.text.chars().count(),
}];
}
let mut out: Vec<WrappedLine> = Vec::new();
let mut line_start: usize = words[0].start;
let mut line_end: usize = line_start;
for word in &words {
let candidate_end = word.end;
let candidate_text = &line.text[line_start..candidate_end];
let candidate_width = measure_text_with_options(candidate_text, font_size, &options).width;
if candidate_width > max_width && line_end > line_start {
out.push(make_wrapped(line, line_start..line_end));
line_start = word.start;
line_end = word.end_with_trailing;
} else {
line_end = word.end_with_trailing;
}
}
if line_end > line_start {
out.push(make_wrapped(line, line_start..line_end));
}
if out.is_empty() {
return vec![WrappedLine {
line: line.clone(),
source_start_col: 0,
source_end_col: line.text.chars().count(),
}];
}
out
}
fn make_wrapped(source: &StyledLine, range: std::ops::Range<usize>) -> WrappedLine {
let start_col = source.text[..range.start].chars().count();
let end_col = source.text[..range.end].chars().count();
let line = slice(source, range);
WrappedLine {
line,
source_start_col: start_col,
source_end_col: end_col,
}
}
struct Word {
start: usize,
end: usize,
end_with_trailing: usize,
}
fn collect_words(text: &str) -> Vec<Word> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut words = Vec::new();
let mut i = 0usize;
let initial_start = i;
while i < len {
let ch = char_at(text, i);
if !ch.is_whitespace() {
break;
}
i += ch.len_utf8();
}
while i < len {
let word_start = if words.is_empty() { initial_start } else { i };
let body_start = i;
while i < len {
let ch = char_at(text, i);
if ch.is_whitespace() {
break;
}
i += ch.len_utf8();
}
let word_end = i;
if word_end == body_start && words.is_empty() && initial_start == word_start {
break;
}
while i < len {
let ch = char_at(text, i);
if !ch.is_whitespace() {
break;
}
i += ch.len_utf8();
}
let trailing_end = i;
words.push(Word {
start: word_start,
end: word_end,
end_with_trailing: trailing_end,
});
}
words
}
fn char_at(text: &str, i: usize) -> char {
text[i..].chars().next().unwrap_or('\0')
}
fn slice(source: &StyledLine, range: std::ops::Range<usize>) -> StyledLine {
let text = source.text[range.clone()].to_string();
let mut spans = Vec::new();
for span in &source.spans {
let s = span.start.max(range.start);
let e = span.end.min(range.end);
if s >= e {
continue;
}
let local_start = s - range.start;
let local_end = e - range.start;
spans.push(TextSpan {
start: local_start,
end: local_end,
color: span.color,
bold: span.bold,
italic: span.italic,
underline: span.underline,
strikethrough: span.strikethrough,
code: span.code,
link_url: span.link_url.clone(),
token_type: span.token_type.clone(),
});
}
StyledLine { text, spans }
}
fn base_options(weight: FontWeight, italic: bool) -> TextLayoutOptions {
let mut options = TextLayoutOptions::new();
options.font_weight = weight.weight();
options.italic = italic;
options
}
#[cfg(test)]
mod tests {
use super::*;
use blinc_core::Color;
fn plain(text: &str) -> StyledLine {
StyledLine::plain(text, Color::WHITE)
}
#[test]
fn short_line_returns_unchanged() {
let line = plain("hello world");
let wrapped = wrap_styled_line(&line, 10000.0, 14.0, FontWeight::Normal, false);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0].text, "hello world");
}
#[test]
fn empty_line_returns_unchanged() {
let line = plain("");
let wrapped = wrap_styled_line(&line, 100.0, 14.0, FontWeight::Normal, false);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0].text, "");
}
#[test]
fn zero_max_width_disables_wrap() {
let line = plain("hello world");
let wrapped = wrap_styled_line(&line, 0.0, 14.0, FontWeight::Normal, false);
assert_eq!(wrapped.len(), 1);
}
#[test]
fn long_line_wraps_into_multiple_lines() {
let line = plain("one two three four five six seven eight nine ten");
let wrapped = wrap_styled_line(&line, 60.0, 14.0, FontWeight::Normal, false);
assert!(wrapped.len() > 1);
let joined: String = wrapped
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join(" ");
for word in [
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
] {
assert!(
joined.contains(word),
"missing word `{}` in `{}`",
word,
joined
);
}
}
#[test]
fn span_split_across_wrap_points() {
let mut line = plain("the quick brown fox jumps over the lazy dog");
line.spans = vec![TextSpan::new(0, line.text.len(), Color::WHITE, true)];
let wrapped = wrap_styled_line(&line, 60.0, 14.0, FontWeight::Normal, false);
assert!(wrapped.len() > 1);
for l in &wrapped {
assert_eq!(l.spans.len(), 1, "expected one span per line");
assert_eq!(l.spans[0].start, 0);
assert_eq!(l.spans[0].end, l.text.len());
assert!(l.spans[0].bold);
}
}
#[test]
fn very_long_word_emits_its_own_line() {
let line = plain("supercalifragilisticexpialidocious");
let wrapped = wrap_styled_line(&line, 30.0, 14.0, FontWeight::Normal, false);
let joined: String = wrapped.iter().map(|l| l.text.as_str()).collect();
assert!(joined.contains("supercalifragilisticexpialidocious"));
}
#[test]
fn span_only_on_first_word_stays_on_first_line() {
let mut line = plain("alpha beta gamma delta epsilon zeta");
line.spans = vec![
TextSpan::new(0, 5, Color::WHITE, true),
TextSpan::colored(5, line.text.len(), Color::WHITE),
];
let wrapped = wrap_styled_line(&line, 60.0, 14.0, FontWeight::Normal, false);
assert!(wrapped[0].spans.iter().any(|s| s.bold));
for l in &wrapped[1..] {
assert!(!l.spans.iter().any(|s| s.bold));
}
}
}