use std::collections::{HashSet, VecDeque};
use figlet_rs::FIGfont;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::style::Style;
pub(crate) fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
#[derive(Clone)]
pub(crate) struct Line {
pub(crate) shown: String,
pub(crate) len: usize,
}
impl Line {
fn new(shown: String, len: usize) -> Self {
Line { shown, len }
}
}
pub(crate) struct Options<'a> {
pub(crate) width: usize,
pub(crate) gap: usize,
pub(crate) style: &'a Style,
pub(crate) rubrics: &'a HashSet<String>,
pub(crate) justify: bool,
pub(crate) hyphenate: bool,
pub(crate) fillers: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Lead {
None,
Word, Line, }
fn rubric_key(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn is_rubric(word: &str, rubrics: &HashSet<String>) -> bool {
let key = rubric_key(word);
!key.is_empty() && rubrics.contains(&key)
}
fn colorize_word(word: &str, style: &Style, rubrics: &HashSet<String>) -> String {
if word == "¶" {
return style.pilcrow(word);
}
if is_rubric(word, rubrics) {
style.rubric(word)
} else {
word.to_string()
}
}
fn fill_lines(
words: &mut VecDeque<String>,
width: usize,
max_lines: usize,
opts: &Options,
lead: Lead,
) -> Vec<Line> {
let width = width.max(1);
let style = opts.style;
let mut lines = Vec::new();
while !words.is_empty() && lines.len() < max_lines {
let mut parts: Vec<String> = Vec::new();
let mut content_w = 0usize;
let mut packed_w = 0usize;
while let Some(front) = words.front() {
let wlen = display_width(front);
if wlen > width {
if packed_w > 0 {
break; }
let word = words.pop_front().unwrap();
let budget = if opts.hyphenate {
width.saturating_sub(1).max(1)
} else {
width
};
let (mut head, mut head_w, tail) = split_at_width(&word, budget);
if !tail.is_empty() {
if opts.hyphenate {
head.push('-');
head_w += 1;
}
words.push_front(tail);
}
parts.push(head);
content_w += head_w;
break; }
let needed = if packed_w == 0 { wlen } else { wlen + 1 };
if packed_w + needed > width {
break;
}
let word = words.pop_front().unwrap();
let force = word != "¶"
&& lines.is_empty()
&& match lead {
Lead::None => false,
Lead::Word => parts.is_empty(),
Lead::Line => true,
};
let shown = if force {
style.rubric(&word)
} else {
colorize_word(&word, style, opts.rubrics)
};
parts.push(shown);
content_w += wlen;
packed_w += needed;
}
let justify = opts.justify && !words.is_empty();
let (shown, len) = assemble_line(&parts, content_w, width, justify);
lines.push(Line::new(shown, len));
}
lines
}
fn assemble_line(
parts: &[String],
content_w: usize,
width: usize,
justify: bool,
) -> (String, usize) {
let n = parts.len();
if n == 0 {
return (String::new(), 0);
}
let packed = content_w + (n - 1);
if !justify || n == 1 || packed >= width {
return (parts.join(" "), packed);
}
let slack = width - content_w;
let gaps = n - 1;
let base = slack / gaps;
let extra = slack % gaps; let mut shown = String::new();
for (i, part) in parts.iter().enumerate() {
shown.push_str(part);
if i < gaps {
shown.push_str(&" ".repeat(base + usize::from(i < extra)));
}
}
(shown, width)
}
fn fill_slack(line: &mut Line, width: usize, style: &Style) {
if line.len + 2 > width {
return;
}
let mut deco = String::from(" ");
let mut w = line.len + 1;
while w < width {
deco.push('❧');
w += 1;
if w < width {
deco.push(' ');
w += 1;
}
}
line.shown.push_str(&style.border(&deco));
line.len = width;
}
fn tokenize(text: &str) -> VecDeque<String> {
text.split_whitespace().map(|s| s.to_string()).collect()
}
fn split_at_width(word: &str, width: usize) -> (String, usize, String) {
let mut head = String::new();
let mut head_w = 0;
let mut chars = word.chars();
for c in chars.by_ref() {
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if head_w + cw > width && !head.is_empty() {
let tail: String = std::iter::once(c).chain(chars).collect();
return (head, head_w, tail);
}
head.push(c);
head_w += cw;
}
(head, head_w, String::new())
}
pub(crate) fn merge_columns(
margin: &[Line],
body: &[Line],
margin_w: usize,
body_w: usize,
sep: &str,
sep_w: usize,
) -> Vec<Line> {
let rows = margin.len().max(body.len());
let blank = Line {
shown: String::new(),
len: 0,
};
let mut out = Vec::with_capacity(rows);
for i in 0..rows {
let m = margin.get(i).unwrap_or(&blank);
let b = body.get(i).unwrap_or(&blank);
let m_pad = " ".repeat(margin_w.saturating_sub(m.len));
let b_pad = " ".repeat(body_w.saturating_sub(b.len));
let shown = format!("{}{}{}{}{}", m.shown, m_pad, sep, b.shown, b_pad);
out.push(Line {
shown,
len: margin_w + sep_w + body_w,
});
}
out
}
pub(crate) fn lay_in_columns(
content: &[Line],
columns: usize,
col_w: usize,
gutter: usize,
) -> Vec<Line> {
let columns = columns.max(1);
let height = content.len().div_ceil(columns);
let total = columns * col_w + columns.saturating_sub(1) * gutter;
let gap = " ".repeat(gutter);
let mut out = Vec::with_capacity(height);
for row in 0..height {
let mut shown = String::new();
for c in 0..columns {
if c > 0 {
shown.push_str(&gap);
}
match content.get(c * height + row) {
Some(line) => {
shown.push_str(&line.shown);
shown.push_str(&" ".repeat(col_w.saturating_sub(line.len)));
}
None => shown.push_str(&" ".repeat(col_w)),
}
}
out.push(Line { shown, len: total });
}
out
}
fn render_glyph(ch: char, font: &FIGfont) -> Vec<String> {
let upper: String = ch.to_uppercase().collect();
let figure = match font.convert(&upper) {
Some(f) => f.to_string(),
None => return vec![ch.to_string()],
};
let mut lines: Vec<String> = figure.lines().map(|l| l.trim_end().to_string()).collect();
while lines.first().is_some_and(|l| l.is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
if lines.is_empty() {
return vec![ch.to_string()];
}
let w = lines.iter().map(|l| display_width(l)).max().unwrap_or(0);
for l in &mut lines {
let deficit = w - display_width(l);
if deficit > 0 {
l.push_str(&" ".repeat(deficit));
}
}
lines
}
pub(crate) fn illuminate_paragraph(
text: &str,
drop_cap: bool,
incipit: bool,
font: &FIGfont,
opts: &Options,
) -> Vec<Line> {
let text = text.trim();
if text.is_empty() {
return Vec::new();
}
let line_lead = if incipit { Lead::Line } else { Lead::None };
let mut out = if !drop_cap {
let mut words = tokenize(text);
fill_lines(&mut words, opts.width, usize::MAX, opts, line_lead)
} else {
illuminate_with_drop_cap(text, incipit, font, opts)
};
if opts.fillers {
if let Some(last) = out.last_mut() {
fill_slack(last, opts.width, opts.style);
}
}
out
}
fn illuminate_with_drop_cap(
text: &str,
incipit: bool,
font: &FIGfont,
opts: &Options,
) -> Vec<Line> {
let mut chars = text.chars();
let initial = chars.next().unwrap();
let rest: String = chars.collect();
let glyph = render_glyph(initial, font);
let height = glyph.len();
let glyph_w = glyph.iter().map(|l| display_width(l)).max().unwrap_or(0);
let narrow = opts.width.saturating_sub(glyph_w + opts.gap);
if narrow < 8 {
let lead = if incipit { Lead::Line } else { Lead::None };
let mut words = tokenize(text);
return fill_lines(&mut words, opts.width, usize::MAX, opts, lead);
}
let first_word = text.split_whitespace().next().unwrap_or("");
let lead = if incipit {
Lead::Line
} else if is_rubric(first_word, opts.rubrics) {
Lead::Word
} else {
Lead::None
};
let mut words = tokenize(&rest);
let beside = fill_lines(&mut words, narrow, height, opts, lead);
let below = fill_lines(&mut words, opts.width, usize::MAX, opts, Lead::None);
let gap = " ".repeat(opts.gap);
let mut out = Vec::with_capacity(height + below.len());
for (i, glyph_row) in glyph.iter().enumerate() {
let glyph_line = opts.style.initial(glyph_row);
let (body, body_len) = match beside.get(i) {
Some(l) => (l.shown.clone(), l.len),
None => (String::new(), 0),
};
let body_pad = " ".repeat(narrow - body_len);
let shown = format!("{glyph_line}{gap}{body}{body_pad}");
out.push(Line::new(shown, glyph_w + opts.gap + narrow));
}
out.extend(below);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Theme;
fn no_rubrics() -> HashSet<String> {
HashSet::new()
}
fn shown(lines: &[Line]) -> Vec<String> {
lines.iter().map(|l| l.shown.clone()).collect()
}
fn opts<'a>(width: usize, style: &'a Style, rubrics: &'a HashSet<String>) -> Options<'a> {
Options {
width,
gap: 1,
style,
rubrics,
justify: false,
hyphenate: false,
fillers: false,
}
}
#[test]
fn display_width_counts_columns_not_chars() {
assert_eq!(display_width("abc"), 3);
assert_eq!(display_width("私"), 2); assert_eq!(display_width("私は"), 4);
assert_eq!(display_width("e\u{0301}"), 1); }
#[test]
fn split_at_width_breaks_on_a_char_boundary() {
assert_eq!(
split_at_width("hello", 3),
("hel".to_string(), 3, "lo".to_string())
);
}
#[test]
fn split_at_width_respects_wide_characters() {
assert_eq!(
split_at_width("私A", 2),
("私".to_string(), 2, "A".to_string())
);
}
#[test]
fn split_at_width_always_makes_progress() {
let (head, head_w, tail) = split_at_width("私A", 1);
assert_eq!(head, "私");
assert_eq!(head_w, 2);
assert_eq!(tail, "A");
}
#[test]
fn fill_lines_wraps_greedily() {
let style = Style::new(false, Theme::Gold);
let rub = no_rubrics();
let mut words = tokenize("the quick brown fox");
let lines = fill_lines(
&mut words,
9,
usize::MAX,
&opts(9, &style, &rub),
Lead::None,
);
assert_eq!(shown(&lines), vec!["the quick", "brown fox"]);
assert!(lines.iter().all(|l| l.len <= 9));
assert!(words.is_empty());
}
#[test]
fn fill_lines_honours_max_lines_and_leaves_a_remainder() {
let style = Style::new(false, Theme::Gold);
let rub = no_rubrics();
let mut words = tokenize("alpha beta gamma delta");
let lines = fill_lines(&mut words, 5, 1, &opts(5, &style, &rub), Lead::None);
assert_eq!(shown(&lines), vec!["alpha"]);
assert_eq!(words.front().map(String::as_str), Some("beta"));
}
#[test]
fn fill_lines_hard_breaks_an_over_long_word() {
let style = Style::new(false, Theme::Gold);
let rub = no_rubrics();
let mut words = tokenize("supercalifragilistic");
let lines = fill_lines(
&mut words,
8,
usize::MAX,
&opts(8, &style, &rub),
Lead::None,
);
assert!(lines.iter().all(|l| l.len <= 8));
let joined: String = lines.iter().map(|l| l.shown.as_str()).collect();
assert_eq!(joined, "supercalifragilistic");
}
#[test]
fn fill_lines_hyphenates_a_hard_break_when_asked() {
let style = Style::new(false, Theme::Gold);
let rub = no_rubrics();
let mut o = opts(8, &style, &rub);
o.hyphenate = true;
let mut words = tokenize("supercalifragilistic");
let lines = fill_lines(&mut words, 8, usize::MAX, &o, Lead::None);
assert!(lines.iter().all(|l| l.len <= 8));
for l in &lines[..lines.len() - 1] {
assert!(l.shown.ends_with('-'), "expected a hyphen on {:?}", l.shown);
}
let joined: String = lines
.iter()
.map(|l| l.shown.trim_end_matches('-'))
.collect();
assert_eq!(joined, "supercalifragilistic");
}
#[test]
fn fill_lines_justifies_interior_lines() {
let style = Style::new(false, Theme::Gold);
let rub = no_rubrics();
let mut o = opts(20, &style, &rub);
o.justify = true;
let mut words = tokenize("the quick brown fox jumps over");
let lines = fill_lines(&mut words, 20, usize::MAX, &o, Lead::None);
assert_eq!(lines[0].len, 20);
assert!(lines[0].shown.contains(" "), "expected padded gaps");
let last = lines.last().unwrap();
assert!(last.len <= 20);
assert!(!last.shown.contains(" "));
}
#[test]
fn fill_lines_measures_wide_characters_for_layout() {
let style = Style::new(false, Theme::Gold);
let rub = no_rubrics();
let mut words = tokenize("私 は 猫");
let lines = fill_lines(
&mut words,
5,
usize::MAX,
&opts(5, &style, &rub),
Lead::None,
);
assert_eq!(shown(&lines), vec!["私 は", "猫"]);
assert_eq!(lines[0].len, 5);
assert_eq!(lines[1].len, 2);
}
#[test]
fn fill_lines_rubricates_without_inflating_the_width() {
let style = Style::new(true, Theme::Gold);
let mut rubrics = HashSet::new();
rubrics.insert("gold".to_string());
let mut words = tokenize("the gold leaf");
let lines = fill_lines(
&mut words,
40,
usize::MAX,
&opts(40, &style, &rubrics),
Lead::None,
);
assert!(lines[0].shown.contains('\u{1b}'));
assert_eq!(lines[0].len, "the gold leaf".len());
}
#[test]
fn drop_cap_rows_are_exactly_the_page_width() {
let font = FIGfont::standard().unwrap();
let style = Style::new(false, Theme::Mono);
let rub = no_rubrics();
let lines = illuminate_paragraph(
"Hello world this is a reasonably long paragraph for the drop cap",
true,
false,
&font,
&opts(40, &style, &rub),
);
assert!(!lines.is_empty());
assert_eq!(lines[0].len, 40);
}
#[test]
fn tiny_width_falls_back_to_a_plain_paragraph() {
let font = FIGfont::standard().unwrap();
let style = Style::new(false, Theme::Mono);
let rub = no_rubrics();
let text = "Hello world foo bar baz";
let got = shown(&illuminate_paragraph(
text,
true,
false,
&font,
&opts(10, &style, &rub),
));
let mut words = tokenize(text);
let expected = shown(&fill_lines(
&mut words,
10,
usize::MAX,
&opts(10, &style, &rub),
Lead::None,
));
assert_eq!(got, expected);
}
#[test]
fn merge_columns_yields_uniform_width() {
let margin = vec![Line {
shown: "ab".to_string(),
len: 2,
}];
let body = vec![
Line {
shown: "hello".to_string(),
len: 5,
},
Line {
shown: "x".to_string(),
len: 1,
},
];
let out = merge_columns(&margin, &body, 4, 6, " | ", 3);
assert_eq!(out.len(), 2);
for line in &out {
assert_eq!(line.len, 4 + 3 + 6);
}
}
#[test]
fn merge_columns_handles_empty_columns() {
let out = merge_columns(&[], &[], 4, 6, " | ", 3);
assert!(out.is_empty());
}
use proptest::prelude::*;
proptest! {
#[test]
fn wrapping_respects_width_and_preserves_words(
text in "[a-z]{1,6}( [a-z]{1,6}){0,40}",
width in 8usize..40,
) {
let style = Style::new(false, Theme::Mono);
let rub = no_rubrics();
let mut words = tokenize(&text);
let original: Vec<String> = words.iter().cloned().collect();
let lines = fill_lines(&mut words, width, usize::MAX, &opts(width, &style, &rub), Lead::None);
for line in &lines {
prop_assert!(line.len <= width, "line {:?} wider than {width}", line.shown);
}
let roundtrip: Vec<String> = lines
.iter()
.flat_map(|l| l.shown.split_whitespace().map(str::to_string))
.collect();
prop_assert_eq!(roundtrip, original);
}
#[test]
fn justified_interior_lines_are_flush(
text in "[a-z]{1,4}( [a-z]{1,4}){8,40}",
width in 12usize..40,
) {
let style = Style::new(false, Theme::Mono);
let rub = no_rubrics();
let mut o = opts(width, &style, &rub);
o.justify = true;
let mut words = tokenize(&text);
let lines = fill_lines(&mut words, width, usize::MAX, &o, Lead::None);
prop_assume!(lines.len() >= 2);
for line in &lines[..lines.len() - 1] {
prop_assert_eq!(line.len, width);
}
}
}
}