use unicode_segmentation::UnicodeSegmentation;
use crossterm::style::Color;
use crate::{
graphics::{FontStyle, grapheme_width},
html::css_parse::{OverflowWrap, WhiteSpace, WordBreak},
layout::InlineRun,
tdom::node::NodeId,
};
#[derive(Debug, Clone)]
enum Token {
Space { width: usize, _count: usize },
Newline,
Break,
Word {
text: String,
width: usize,
style: FontStyle,
fg: Option<Color>,
bg: Option<Color>,
node_id: Option<NodeId>,
letter_spacing: usize,
word_spacing: isize,
},
}
fn is_horizontal_ws(g: &str) -> bool {
matches!(g, " " | "\t")
}
pub fn is_cjk_break_opportunity(c: char) -> bool {
let cp = c as u32;
matches!(cp,
0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0x20000..=0x2A6DF | 0x2A700..=0x2B73F | 0x2B740..=0x2B81F | 0x2B820..=0x2CEAF | 0x2EBF0..=0x2EE5D | 0xF900..=0xFAFF | 0x2F800..=0x2FA1F | 0x3040..=0x309F | 0x30A0..=0x30FF | 0xAC00..=0xD7AF | 0x3000..=0x303F | 0xFF00..=0xFFEF | 0x2E80..=0x2EFF | 0x31C0..=0x31EF | 0x2F00..=0x2FDF | 0x17000..=0x187FF | 0x1B170..=0x1B2FF | 0x18B00..=0x18CFF )
}
fn is_forbidden_line_start(c: char) -> bool {
matches!(
c,
'、' | '。'
| ','
| '.'
| ':'
| ';'
| '?'
| '!'
| '・'
| 'ー'
| '」'
| '』'
| ')'
| ']'
| '}'
| '〉'
| '》'
| '】'
| '〗'
| '〙'
| '〛'
| '’'
| '”'
| '…'
| '~'
| '%'
| '‰'
| '°'
| '゛'
| '゜'
| 'ぁ'
| 'ぃ'
| 'ぅ'
| 'ぇ'
| 'ぉ'
| 'っ'
| 'ゃ'
| 'ゅ'
| 'ょ'
| 'ゎ'
| 'ァ'
| 'ィ'
| 'ゥ'
| 'ェ'
| 'ォ'
| 'ッ'
| 'ャ'
| 'ュ'
| 'ョ'
| 'ヮ'
)
}
fn tokenize_run(
run: &InlineRun,
font_size_cells: usize,
word_break: WordBreak,
out: &mut Vec<Token>,
) {
let font = font_size_cells.max(1);
let mut pending_kind: Option<u8> = None;
let mut pending_text = String::new();
let mut pending_count: usize = 0;
let mut pending_width: usize = 0;
let flush = |kind: Option<u8>,
text: &mut String,
count: &mut usize,
width: &mut usize,
run: &InlineRun,
out: &mut Vec<Token>| {
if let Some(k) = kind {
if *count == 0 || text.is_empty() {
return;
}
if k == 0 {
out.push(Token::Space {
width: *width,
_count: *count,
});
text.clear();
} else {
out.push(Token::Word {
text: std::mem::take(text),
width: *width,
style: run.style,
fg: run.fg,
bg: run.bg,
node_id: run.node_id,
letter_spacing: run.letter_spacing,
word_spacing: run.word_spacing,
});
}
}
*count = 0;
*width = 0;
};
for g in run.text.graphemes(true) {
if g == "\n" {
flush(
pending_kind,
&mut pending_text,
&mut pending_count,
&mut pending_width,
run,
out,
);
pending_kind = None;
out.push(if run.force_break {
Token::Break
} else {
Token::Newline
});
continue;
}
let w = run.grapheme_advance(g, font);
if is_horizontal_ws(g) {
if pending_kind != Some(0) {
flush(
pending_kind,
&mut pending_text,
&mut pending_count,
&mut pending_width,
run,
out,
);
pending_kind = Some(0);
}
pending_text.push_str(g);
pending_count += 1;
pending_width += w;
} else if word_break != WordBreak::KeepAll
&& g.chars()
.next()
.is_some_and(|c| is_cjk_break_opportunity(c) && !is_forbidden_line_start(c))
{
flush(
pending_kind,
&mut pending_text,
&mut pending_count,
&mut pending_width,
run,
out,
);
pending_kind = None;
out.push(Token::Word {
text: g.to_string(),
width: w,
style: run.style,
fg: run.fg,
bg: run.bg,
node_id: run.node_id,
letter_spacing: run.letter_spacing,
word_spacing: run.word_spacing,
});
} else if word_break != WordBreak::KeepAll
&& g.chars()
.next()
.is_some_and(|c| is_cjk_break_opportunity(c) && is_forbidden_line_start(c))
{
let merged = if let Some(Token::Word {
text,
width: tok_width,
style: tok_style,
fg: tok_fg,
bg: tok_bg,
node_id: tok_node,
letter_spacing: tok_ls,
word_spacing: tok_ws,
}) = out.last_mut()
{
if *tok_style == run.style
&& *tok_fg == run.fg
&& *tok_bg == run.bg
&& *tok_node == run.node_id
&& *tok_ls == run.letter_spacing
&& *tok_ws == run.word_spacing
{
text.push_str(g);
*tok_width += w;
true
} else {
false
}
} else {
false
};
if !merged {
if pending_kind != Some(1) {
flush(
pending_kind,
&mut pending_text,
&mut pending_count,
&mut pending_width,
run,
out,
);
pending_kind = Some(1);
}
pending_text.push_str(g);
pending_count += 1;
pending_width += w;
}
} else {
if pending_kind != Some(1) {
flush(
pending_kind,
&mut pending_text,
&mut pending_count,
&mut pending_width,
run,
out,
);
pending_kind = Some(1);
}
pending_text.push_str(g);
pending_count += 1;
pending_width += w;
}
}
flush(
pending_kind,
&mut pending_text,
&mut pending_count,
&mut pending_width,
run,
out,
);
}
fn apply_white_space(
tokens: Vec<Token>,
ws: WhiteSpace,
_font_size_cells: usize,
) -> Vec<Vec<Token>> {
if ws.preserves_whitespace() {
let mut lines: Vec<Vec<Token>> = vec![Vec::new()];
for t in tokens {
if matches!(t, Token::Newline | Token::Break) {
lines.push(Vec::new());
} else if let Some(line) = lines.last_mut() {
line.push(t);
}
}
return lines;
}
let mut lines: Vec<Vec<Token>> = vec![Vec::new()];
for t in tokens {
match t {
Token::Newline => {
if matches!(ws, WhiteSpace::PreLine) {
lines.push(Vec::new());
}
}
Token::Break => {
lines.push(Vec::new());
}
Token::Space { width, _count } => {
if let Some(line) = lines.last_mut() {
let last_is_space = matches!(line.last(), Some(Token::Space { .. }));
if !last_is_space {
let per_space = width.div_ceil(_count.max(1));
line.push(Token::Space {
width: per_space,
_count: 1,
});
}
}
}
Token::Word { .. } => {
if let Some(line) = lines.last_mut() {
line.push(t);
}
}
}
}
lines
}
fn wrap_logical_line(
logical: Vec<Token>,
max_width: usize,
font_size_cells: usize,
word_break: WordBreak,
overflow_wrap: OverflowWrap,
first_indent: usize,
out: &mut Vec<Vec<InlineRun>>,
) {
let mut current_line: Vec<InlineRun> = Vec::new();
let mut current_width: usize = first_indent.min(max_width);
let mut last_run_style: (Option<Color>, Option<Color>, FontStyle) =
(None, None, FontStyle::empty());
let mut last_run_node_id: Option<NodeId> = None;
let mut last_run_spacing: (usize, isize) = (0, 0);
for token in logical {
match token {
Token::Newline => unreachable!("Newline should have split logical lines"),
Token::Break => unreachable!("Break should have split logical lines"),
Token::Space { width, .. } => {
if current_line.is_empty() {
continue; }
if current_width + width <= max_width {
let (fg, bg, style) = last_run_style;
let (ls, ws) = last_run_spacing;
let mut space_run = InlineRun::owned(" ", style, fg, bg, last_run_node_id);
space_run.letter_spacing = ls;
space_run.word_spacing = ws;
current_line.push(space_run);
current_width += width;
} else {
out.push(std::mem::take(&mut current_line));
current_width = 0;
}
}
Token::Word {
text,
width,
style,
fg,
bg,
node_id,
letter_spacing,
word_spacing,
} => {
last_run_style = (fg, bg, style);
last_run_node_id = node_id;
last_run_spacing = (letter_spacing, word_spacing);
let make_run = |text: String| {
let mut r = InlineRun::owned(text, style, fg, bg, node_id);
r.letter_spacing = letter_spacing;
r.word_spacing = word_spacing;
r
};
if width + current_width <= max_width {
current_line.push(make_run(text));
current_width += width;
} else if width <= max_width && !current_line.is_empty() {
out.push(std::mem::take(&mut current_line));
current_line.push(make_run(text));
current_width = width;
} else if width <= max_width {
current_line.push(make_run(text));
current_width = width;
} else {
let is_purely_cjk = text.chars().all(is_cjk_break_opportunity);
let should_split = match (word_break, overflow_wrap) {
(WordBreak::BreakAll, _) => true,
(WordBreak::KeepAll, _) if is_purely_cjk => false,
(WordBreak::KeepAll, _) => true,
(WordBreak::Normal, OverflowWrap::Anywhere) => true,
(WordBreak::Normal, OverflowWrap::BreakWord) => true,
(WordBreak::Normal, OverflowWrap::Normal) => false,
};
if !should_split {
current_line.push(make_run(text));
current_width += width;
continue;
}
if !current_line.is_empty() {
out.push(std::mem::take(&mut current_line));
current_width = 0;
}
let font = font_size_cells.max(1);
let graphemes: Vec<&str> = text.graphemes(true).collect();
let mut buf = String::new();
let mut buf_width: usize = 0;
let split_run =
|text: String, style, fg, bg, node_id, letter_spacing, word_spacing| {
let mut r = InlineRun::owned(text, style, fg, bg, node_id);
r.letter_spacing = letter_spacing;
r.word_spacing = word_spacing;
r
};
for g in graphemes {
let w = grapheme_width(g).max(font) + letter_spacing;
if !buf.is_empty() && buf_width + w > max_width {
current_line.push(split_run(
std::mem::take(&mut buf),
style,
fg,
bg,
node_id,
letter_spacing,
word_spacing,
));
out.push(std::mem::take(&mut current_line));
buf_width = 0;
}
buf.push_str(g);
buf_width += w;
}
if !buf.is_empty() {
current_line.push(split_run(
buf,
style,
fg,
bg,
node_id,
letter_spacing,
word_spacing,
));
current_width = buf_width;
}
}
}
}
}
out.push(current_line);
}
fn emit_preserving_newlines(runs: &[InlineRun]) -> Vec<Vec<InlineRun>> {
let mut lines: Vec<Vec<InlineRun>> = Vec::new();
let mut line: Vec<InlineRun> = Vec::new();
let mut buf = String::new();
let mut cur_fg: Option<Color> = None;
let mut cur_bg: Option<Color> = None;
let mut cur_style = FontStyle::empty();
let mut cur_node_id: Option<NodeId> = None;
let mut cur_spacing: (usize, isize) = (0, 0);
let mut buffering = false;
for run in runs {
if run.is_empty() {
continue;
}
for g in run.text.graphemes(true) {
if g == "\n" {
if buffering {
let mut flushed = InlineRun::owned(
std::mem::take(&mut buf),
cur_style,
cur_fg,
cur_bg,
cur_node_id,
);
flushed.letter_spacing = cur_spacing.0;
flushed.word_spacing = cur_spacing.1;
line.push(flushed);
buffering = false;
}
lines.push(std::mem::take(&mut line));
continue;
}
if buffering
&& (cur_fg != run.fg
|| cur_bg != run.bg
|| cur_style != run.style
|| cur_node_id != run.node_id
|| cur_spacing != (run.letter_spacing, run.word_spacing))
{
line.push(InlineRun::owned(
std::mem::take(&mut buf),
cur_style,
cur_fg,
cur_bg,
cur_node_id,
));
if let Some(last) = line.last_mut() {
last.letter_spacing = cur_spacing.0;
last.word_spacing = cur_spacing.1;
}
buffering = false;
}
if !buffering {
cur_fg = run.fg;
cur_bg = run.bg;
cur_style = run.style;
cur_node_id = run.node_id;
cur_spacing = (run.letter_spacing, run.word_spacing);
buffering = true;
}
buf.push_str(g);
}
}
if buffering {
let mut tail = InlineRun::owned(buf, cur_style, cur_fg, cur_bg, cur_node_id);
tail.letter_spacing = cur_spacing.0;
tail.word_spacing = cur_spacing.1;
line.push(tail);
}
lines.push(line);
if lines.is_empty() {
lines.push(Vec::new());
}
lines
}
pub fn wrap_runs(
runs: &[InlineRun],
max_width: usize,
font_size_cells: usize,
white_space: WhiteSpace,
word_break: WordBreak,
overflow_wrap: OverflowWrap,
first_line_indent: usize,
) -> Vec<Vec<InlineRun>> {
if runs.is_empty() {
return vec![Vec::new()];
}
if !white_space.allows_wrap() {
if matches!(white_space, WhiteSpace::Pre) {
return emit_preserving_newlines(runs);
}
let mut out: Vec<Vec<InlineRun>> = vec![Vec::new()];
for r in runs {
if r.force_break {
out.push(Vec::new());
} else if !r.is_empty()
&& let Some(line) = out.last_mut()
{
line.push(r.clone());
}
}
return out;
}
if max_width == 0 {
let mut out: Vec<Vec<InlineRun>> = Vec::new();
for r in runs {
if !r.is_empty() {
out.push(vec![r.clone()]);
}
}
if out.is_empty() {
out.push(Vec::new());
}
return out;
}
let mut tokens: Vec<Token> = Vec::new();
for r in runs {
tokenize_run(r, font_size_cells, word_break, &mut tokens);
}
let logical_lines = apply_white_space(tokens, white_space, font_size_cells);
let mut visual_lines: Vec<Vec<InlineRun>> = Vec::new();
for (idx, logical) in logical_lines.into_iter().enumerate() {
let indent = if idx == 0 { first_line_indent } else { 0 };
wrap_logical_line(
logical,
max_width,
font_size_cells,
word_break,
overflow_wrap,
indent,
&mut visual_lines,
);
}
if visual_lines.is_empty() || visual_lines.iter().all(|l| l.is_empty()) {
return Vec::new();
}
visual_lines
}
pub fn apply_ellipsis(
lines: Vec<Vec<InlineRun>>,
max_width: usize,
font_size_cells: usize,
) -> Vec<Vec<InlineRun>> {
lines
.into_iter()
.map(|line| {
let total: usize = line.iter().map(|r| r.width_at(font_size_cells)).sum();
if total <= max_width || line.is_empty() {
return line;
}
let budget = max_width.saturating_sub(1);
let mut out: Vec<InlineRun> = Vec::new();
let mut used = 0usize;
for run in line {
let w = run.width_at(font_size_cells);
if used + w <= budget {
out.push(run);
used += w;
continue;
}
let remaining = budget.saturating_sub(used);
let mut text = String::new();
let mut acc = 0usize;
for g in run.text.graphemes(true) {
let gw = run.grapheme_advance(g, font_size_cells);
if acc + gw > remaining {
break;
}
text.push_str(g);
acc += gw;
}
if !text.is_empty() {
out.push(InlineRun::owned_spaced(
text,
run.style,
run.fg,
run.bg,
run.node_id,
run.letter_spacing,
run.word_spacing,
));
}
break;
}
let tail = out.last().map(|r| {
(
r.style,
r.fg,
r.bg,
r.node_id,
r.letter_spacing,
r.word_spacing,
)
});
match tail {
Some((style, fg, bg, node_id, ls, ws)) => {
out.push(InlineRun::owned_spaced("…", style, fg, bg, node_id, ls, ws));
}
None => out.push(InlineRun::new("…", FontStyle::empty(), None, None)),
}
out
})
.collect()
}
pub fn wrapped_size(
runs: &[InlineRun],
max_width: usize,
font_size_cells: usize,
white_space: WhiteSpace,
word_break: WordBreak,
overflow_wrap: OverflowWrap,
first_line_indent: usize,
) -> (usize, usize) {
let lines = wrap_runs(
runs,
max_width,
font_size_cells,
white_space,
word_break,
overflow_wrap,
first_line_indent,
);
let font = font_size_cells.max(1);
let max_line_w = lines
.iter()
.enumerate()
.map(|(idx, l)| {
let base = l.iter().map(|r| r.width_at(font)).sum::<usize>();
let indent = if idx == 0 { first_line_indent } else { 0 };
base + indent
})
.max()
.unwrap_or(0);
let total_rows = lines.len().saturating_mul(font);
(max_line_w, total_rows)
}