use ratatui::style::Style;
use ratatui::text::Line;
use unicode_width::UnicodeWidthChar;
use crate::markdown::table_layout::{self, is_no_break_char, Joint};
use crate::markdown::RowOrigin;
pub fn char_cells(ch: char) -> usize {
UnicodeWidthChar::width(ch).unwrap_or(0)
}
pub fn char_idx_at_cell_col<I>(iter: I, target_cell: usize, indent: usize) -> usize
where
I: IntoIterator<Item = char>,
{
if target_cell <= indent {
return 0;
}
let mut acc = indent;
let mut count = 0;
let mut chars = iter.into_iter().peekable();
while let Some(ch) = chars.next() {
let w = char_cells(ch);
if acc + w > target_cell {
if acc == target_cell {
return count;
}
count += 1;
while chars.next_if(|&c| char_cells(c) == 0).is_some() {
count += 1;
}
return count;
}
acc += w;
count += 1;
}
count
}
pub fn cell_col_at_char_idx<I>(iter: I, char_idx: usize, indent: usize) -> usize
where
I: IntoIterator<Item = char>,
{
let mut acc = indent;
for (i, ch) in iter.into_iter().enumerate() {
if i >= char_idx {
break;
}
acc += char_cells(ch);
}
acc
}
fn chars_within_cell_budget(chars: &[(char, Style)], start: usize, cell_budget: usize) -> usize {
let mut total = 0usize;
let mut count = 0usize;
for (ch, _) in &chars[start..] {
let w = char_cells(*ch);
if count > 0 && total + w > cell_budget {
break;
}
total += w;
count += 1;
if total >= cell_budget {
break;
}
}
count
}
fn absorbed_next_start(chars: &[(char, Style)], end: usize) -> usize {
let lone_space = chars.get(end).is_some_and(|(c, _)| *c == ' ')
&& end + 1 < chars.len()
&& chars[end + 1].0 != ' ';
if lone_space {
end + 1
} else {
end
}
}
fn joints_of(chars: &[(char, Style)]) -> Option<Vec<Joint>> {
if chars.iter().all(|(ch, _)| ch.is_ascii()) {
return None;
}
let text: String = chars.iter().map(|&(ch, _)| ch).collect();
Some(table_layout::joints_in_str(&text, chars.len()))
}
fn joint_at(joints: Option<&[Joint]>, i: usize) -> Joint {
joints.map_or(Joint::Cluster, |j| {
j.get(i).copied().unwrap_or(Joint::Cluster)
})
}
fn is_cluster_boundary(joints: Option<&[Joint]>, i: usize) -> bool {
joint_at(joints, i) != Joint::Inside
}
fn snap_to_cluster_boundary(joints: Option<&[Joint]>, start: usize, end: usize) -> usize {
let mut snapped = end;
while snapped > start && !is_cluster_boundary(joints, snapped) {
snapped -= 1;
}
if snapped == start {
end
} else {
snapped
}
}
fn is_intra_word_punctuation(ch: char) -> bool {
matches!(ch, '.' | ',' | ':' | '\'' | '\u{2019}' | '_')
}
fn is_break_after(chars: &[(char, Style)], i: usize, joints: Option<&[Joint]>) -> bool {
let joint = joint_at(joints, i + 1);
if joint == Joint::Inside {
return false;
}
let ch = chars[i].0;
let next = chars.get(i + 1).map(|&(c, _)| c);
if is_no_break_char(ch) || next.is_some_and(is_no_break_char) {
return false;
}
if next.is_some_and(table_layout::no_break_before) {
return false;
}
if table_layout::no_break_after(ch) && next.is_some_and(|c| !c.is_whitespace()) {
return false;
}
if joint == Joint::Glyph {
return true;
}
if ch.is_alphanumeric() {
return false;
}
let prev_alnum = i > 0 && chars[i - 1].0.is_alphanumeric();
let next_alnum = next.is_some_and(char::is_alphanumeric);
if table_layout::is_ambiguous_quote(ch) && next_alnum && !prev_alnum {
return false;
}
if is_intra_word_punctuation(ch) && prev_alnum && next_alnum {
return false;
}
true
}
fn ends_with_lone_word(chars: &[(char, Style)], start: usize, break_at: usize) -> bool {
if !chars[break_at].0.is_whitespace() || break_at < start + 2 {
return false;
}
let word = break_at - 1;
chars[word].0.is_alphanumeric() && word > start && chars[word - 1].0.is_whitespace()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Indent {
pub lead: usize,
pub hang: usize,
}
impl Indent {
pub const NONE: Indent = Indent { lead: 0, hang: 0 };
pub const fn hanging(hang: usize) -> Self {
Indent { lead: 0, hang }
}
pub fn of_row(origin: Option<&RowOrigin>) -> Self {
origin.map_or(Indent::NONE, |o| Indent::hanging(o.hang as usize))
}
pub fn at(self, width: usize) -> Self {
Indent {
lead: effective_indent(self.lead, width),
hang: effective_indent(self.hang, width),
}
}
pub fn row(self, sub_row: usize) -> usize {
if sub_row == 0 {
self.lead
} else {
self.hang
}
}
}
pub fn visual_rows_of_chars(
chars: &[(char, Style)],
width: usize,
indent: Indent,
) -> Vec<(usize, usize, usize)> {
let mut rows = Vec::new();
if width == 0 {
rows.push((0, chars.len(), chars.len()));
return rows;
}
let indent = indent.at(width);
let joints = joints_of(chars);
let joints = joints.as_deref();
let mut start = 0;
let mut row_idx = 0usize;
loop {
if start >= chars.len() {
if rows.is_empty() {
rows.push((0, 0, 0));
}
break;
}
let row_width = width.saturating_sub(indent.row(row_idx)).max(1);
let n_chars = chars_within_cell_budget(chars, start, row_width);
let remaining = chars.len() - start;
let (row_end, next_start) = if n_chars >= remaining {
(chars.len(), chars.len())
} else {
let window_end = snap_to_cluster_boundary(joints, start, start + n_chars);
let ink = (start..window_end).find(|&i| !chars[i].0.is_whitespace());
let edge_space = chars[window_end].0 == ' '
&& chars[start..window_end]
.iter()
.rev()
.find(|&&(c, _)| char_cells(c) > 0)
.is_some_and(|&(c, _)| char_cells(c) > 1);
let break_at = if edge_space {
None
} else {
ink.and_then(|ink| {
(ink..window_end)
.rev()
.find(|&i| is_break_after(chars, i, joints))
})
};
let end = match break_at {
Some(bp) => {
let bp = if ends_with_lone_word(chars, start, bp) {
(ink.unwrap_or(start)..bp - 1)
.rev()
.find(|&i| is_break_after(chars, i, joints))
.unwrap_or(bp)
} else {
bp
};
bp + 1
}
None => window_end,
};
(end, absorbed_next_start(chars, end))
};
rows.push((start, row_end, next_start));
if next_start >= chars.len() || next_start == start {
break;
}
start = next_start;
row_idx += 1;
}
if rows.is_empty() {
rows.push((0, 0, 0));
}
rows
}
pub fn visual_rows_of_str(text: &str, width: usize) -> Vec<(usize, usize, usize)> {
let chars: Vec<(char, Style)> = text.chars().map(|c| (c, Style::default())).collect();
visual_rows_of_chars(&chars, width, Indent::NONE)
}
pub fn revealed_rows_of_str(
text: &str,
indent: Indent,
width: usize,
) -> (Vec<(usize, usize, usize)>, Indent) {
let width = width.max(1);
let chars: Vec<(char, Style)> = text.chars().map(|c| (c, Style::default())).collect();
(
visual_rows_of_chars(&chars, width, indent),
indent.at(width),
)
}
pub fn revealed_row_count(text: &str, indent: Indent, width: usize) -> usize {
revealed_rows_of_str(text, indent, width).0.len().max(1)
}
pub fn visual_rows_for_line(line: &Line<'_>, indent: Indent, width: usize) -> usize {
PaintedRows::new(line, indent, width).rows.len()
}
pub fn effective_indent(indent: usize, width: usize) -> usize {
if indent + 1 >= width {
0
} else {
indent
}
}
pub struct PaintedRows {
pub chars: Vec<(char, Style)>,
pub rows: Vec<(usize, usize, usize)>,
pub indent: Indent,
}
impl PaintedRows {
pub fn new(line: &Line<'_>, indent: Indent, width: usize) -> Self {
let chars: Vec<(char, Style)> = line
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |c| (c, span.style)))
.collect();
let rows = visual_rows_of_chars(&chars, width, indent);
Self {
chars,
rows,
indent: indent.at(width),
}
}
pub fn cell_of(&self, sub_row: usize, char_idx: usize) -> usize {
self.indent.row(sub_row)
+ self.chars[self.rows[sub_row].0..char_idx]
.iter()
.map(|&(c, _)| char_cells(c))
.sum::<usize>()
}
pub fn unwrapped_cell_positions(&self) -> Vec<Option<(usize, usize)>> {
let mut out = Vec::new();
for (sub_row, &(start, end, next_start)) in self.rows.iter().enumerate() {
let mut x = self.indent.row(sub_row);
for &(c, _) in &self.chars[start..end] {
let w = char_cells(c);
out.extend((x..x + w).map(|cell| Some((sub_row, cell))));
x += w;
}
for &(c, _) in &self.chars[end..next_start] {
out.extend(std::iter::repeat_n(None, char_cells(c)));
}
}
out
}
}
pub fn char_cells_at(
line: &Line<'_>,
indent: Indent,
width: usize,
char_idx: usize,
) -> Option<(usize, std::ops::Range<usize>)> {
if width == 0 {
return None;
}
let painted = PaintedRows::new(line, indent, width);
let sub_row = painted
.rows
.iter()
.position(|&(start, end, _)| (start..end).contains(&char_idx))?;
let x = painted.cell_of(sub_row, char_idx);
Some((sub_row, x..x + char_cells(painted.chars[char_idx].0)))
}
pub fn sub_row_end_cell(line: &Line<'_>, indent: Indent, width: usize, sub_row: usize) -> usize {
if width == 0 {
return 0;
}
let painted = PaintedRows::new(line, indent, width);
painted
.rows
.get(sub_row)
.map_or(0, |&(_, end, _)| painted.cell_of(sub_row, end))
}
pub fn last_col_in_row(row: (usize, usize, usize), is_last_row: bool) -> usize {
let (start, end, _) = row;
if is_last_row {
end
} else {
end.saturating_sub(1).max(start)
}
}
pub fn sub_line_of_col(rows: &[(usize, usize, usize)], raw_col: usize) -> (usize, usize) {
for (i, &(s, e, n)) in rows.iter().enumerate() {
if raw_col < n {
if raw_col >= e && i + 1 < rows.len() {
return (i + 1, 0);
}
let row_width = e - s;
let visual_col = raw_col.saturating_sub(s).min(row_width);
return (i, visual_col);
}
}
if let Some(&(s, e, _)) = rows.last() {
let last_idx = rows.len() - 1;
let row_width = e - s;
let visual_col = raw_col.saturating_sub(s).min(row_width);
return (last_idx, visual_col);
}
(0, 0)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
#[test]
fn unwrapped_cell_positions_follow_the_wrap() {
let painted = PaintedRows::new(&Line::from("abc 日 de"), Indent::hanging(1), 5);
assert_eq!(
painted.rows,
vec![(0, 4, 4), (4, 6, 6), (6, 8, 8)],
"`abc ` / ` 日 ` / ` de`"
);
assert_eq!(
painted.unwrapped_cell_positions(),
vec![
Some((0, 0)),
Some((0, 1)),
Some((0, 2)),
Some((0, 3)),
Some((1, 1)),
Some((1, 2)),
Some((1, 3)),
Some((2, 1)),
Some((2, 2)),
]
);
let absorbed = PaintedRows::new(&Line::from("abcd ef"), Indent::NONE, 4);
assert_eq!(absorbed.rows, vec![(0, 4, 5), (5, 7, 7)]);
assert_eq!(absorbed.unwrapped_cell_positions()[4], None);
assert_eq!(absorbed.unwrapped_cell_positions()[5], Some((1, 0)));
}
#[test]
fn visual_rows_short_line() {
let rows = visual_rows_of_str("hello", 10);
assert_eq!(rows, vec![(0, 5, 5)]);
}
#[test]
fn visual_rows_wraps_at_space() {
let rows = visual_rows_of_str("hello world foo", 10);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 6, 6)); assert_eq!(rows[1], (6, 15, 15)); }
#[test]
fn visual_rows_force_break_on_long_word() {
let rows = visual_rows_of_str("abcdefghijklmnop", 8);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 8, 8));
assert_eq!(rows[1], (8, 16, 16));
}
#[test]
fn visual_rows_empty_string() {
let rows = visual_rows_of_str("", 10);
assert_eq!(rows, vec![(0, 0, 0)]);
}
#[test]
fn visual_rows_with_indent_word_aligned() {
let chars: Vec<(char, Style)> = "• hello world foo"
.chars()
.map(|c| (c, Style::default()))
.collect();
let rows = visual_rows_of_chars(&chars, 10, Indent::hanging(2));
assert_eq!(rows[0].0, 0);
assert!(rows.len() >= 2);
assert_eq!(rows.last().map(|r| r.1), Some(17));
}
#[test]
fn visual_rows_with_indent_zero_matches_flat() {
let s = "hello world foo";
let chars: Vec<(char, Style)> = s.chars().map(|c| (c, Style::default())).collect();
let with_indent = visual_rows_of_chars(&chars, 10, Indent::NONE);
let flat = visual_rows_of_str(s, 10);
assert_eq!(with_indent, flat);
}
#[test]
fn visual_rows_for_line_counts_indent_extra_rows() {
let line = Line::from(vec![Span::raw("• "), Span::raw("hello world foo bar baz")]);
let with_marker = visual_rows_for_line(&line, Indent::hanging(2), 10);
let flat = visual_rows_for_line(&line, Indent::NONE, 10);
assert!(with_marker >= flat);
}
#[test]
fn the_indent_comes_from_the_caller_not_the_text() {
let text = "1. alpha bravo charlie delta";
let flat = revealed_rows_of_str(text, Indent::NONE, 15).0;
let hung = revealed_rows_of_str(text, Indent::hanging(3), 15).0;
assert_eq!(flat, visual_rows_of_str(text, 15));
assert_eq!((flat.len(), hung.len()), (2, 3));
}
#[test]
fn a_lead_narrows_and_shifts_the_first_row_only() {
let line = Line::from("ab cd ef gh");
let indent = Indent { lead: 2, hang: 4 };
let painted = PaintedRows::new(&line, indent, 8);
assert_eq!(painted.rows[0], (0, 6, 6));
assert_eq!(painted.cell_of(0, 0), 2);
assert_eq!(painted.cell_of(1, painted.rows[1].0), 4);
}
#[test]
fn an_indent_too_wide_for_the_row_collapses() {
let indent = Indent { lead: 9, hang: 3 };
assert_eq!(indent.at(10), Indent { lead: 0, hang: 3 });
assert_eq!(indent.at(4), Indent { lead: 0, hang: 0 });
assert_eq!(indent.at(80), indent);
}
#[test]
fn visual_rows_preserves_interior_whitespace_across_wrap() {
let rows = visual_rows_of_str("a b", 5);
assert_eq!(rows[0], (0, 5, 5));
assert_eq!(rows[1], (5, 10, 10));
assert_eq!(rows[2], (10, 15, 15));
assert_eq!(rows[3], (15, 16, 16));
}
#[test]
fn contraction_apostrophe_is_not_a_break_point() {
let rows = visual_rows_of_str("when they're here", 12);
assert_eq!(rows[0], (0, 5, 5)); assert_eq!(rows[1], (5, 17, 17)); }
#[test]
fn smart_apostrophe_is_not_a_break_point() {
let rows = visual_rows_of_str("when they\u{2019}re here", 12);
assert_eq!(rows[0], (0, 5, 5));
assert_eq!(rows[1], (5, 17, 17));
}
#[test]
fn intra_word_punctuation_keeps_tokens_whole() {
for text in ["value 3.14159 x", "count 1,000,00 x", "meet 12:30:00 x"] {
let rows = visual_rows_of_str(text, 12);
assert_eq!(
rows[0].1,
text.find(' ').unwrap() + 1,
"{text} broke inside its token"
);
}
}
#[test]
fn url_still_breaks_after_the_scheme_slashes() {
let rows = visual_rows_of_str("see https://example.com/x", 20);
assert_eq!(rows[0], (0, 12, 12)); }
#[test]
fn a_url_path_breaks_at_a_slash_rather_than_mid_segment() {
let text = "at github.com/user/repo/blob/main/x";
let rows = visual_rows_of_str(text, 20);
let chars: Vec<char> = text.chars().collect();
for &(start, end, _) in &rows {
let row: String = chars[start..end].iter().collect();
assert!(
row.ends_with('/') || end == chars.len(),
"row {row:?} broke mid-segment instead of after a slash"
);
}
}
#[test]
fn no_break_after_an_opening_delimiter() {
let rows = visual_rows_of_str("a note (remark) here", 11);
assert_eq!(rows[0], (0, 7, 7)); }
#[test]
fn nbsp_is_never_a_break_point() {
let text = "aa\u{a0}bb cc";
let rows = visual_rows_of_str(text, 5);
assert_eq!(rows[0].1, 5);
}
#[test]
fn one_letter_word_is_carried_down_to_its_noun() {
let rows = visual_rows_of_str("tell them a story", 14);
assert_eq!(rows[0], (0, 10, 10)); assert_eq!(rows[1], (10, 17, 17)); }
#[test]
fn a_lone_word_starting_the_row_is_left_alone() {
let rows = visual_rows_of_str("a xyzzyplugh", 3);
assert_eq!(rows[0], (0, 2, 2)); }
#[test]
fn hard_break_absorbs_the_following_space() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(rows[0], (0, 10, 11));
assert_eq!(rows[1], (11, 14, 14));
}
#[test]
fn absorbed_space_maps_the_cursor_to_the_next_row_start() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(sub_line_of_col(&rows, 10), (1, 0));
assert_eq!(sub_line_of_col(&rows, 11), (1, 0));
}
#[test]
fn a_run_of_spaces_at_a_hard_break_is_preserved() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(rows[0], (0, 10, 10));
}
#[test]
fn a_trailing_space_at_a_hard_break_is_not_absorbed() {
let rows = visual_rows_of_str("abcdefghij ", 10);
assert_eq!(rows[0], (0, 10, 10));
assert_eq!(rows[1], (10, 11, 11));
}
#[test]
fn last_col_in_row_clamps_against_end_not_next_start() {
let rows = visual_rows_of_str("abcdefghij klm", 10);
assert_eq!(rows[0], (0, 10, 11));
assert_eq!(last_col_in_row(rows[0], false), 9);
assert_eq!(last_col_in_row(rows[1], true), 14);
assert_eq!(last_col_in_row((7, 8, 8), false), 7);
}
#[test]
fn a_soft_break_on_punctuation_absorbs_the_space_after_it() {
let rows = visual_rows_of_str("abcde. fgh", 6);
assert_eq!(rows[0], (0, 6, 7));
assert_eq!(rows[1], (7, 10, 10));
}
#[test]
fn a_zwj_sequence_is_never_split_across_rows() {
let text = "a \u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466} family here";
let rows = visual_rows_of_str(text, 8);
let chars: Vec<char> = text.chars().collect();
for &(start, end, _) in &rows {
let row: String = chars[start..end].iter().collect();
assert!(
!row.starts_with('\u{200d}') && !row.ends_with('\u{200d}'),
"row {row:?} ends or starts inside the cluster"
);
}
}
#[test]
fn a_combining_mark_stays_with_its_base_char() {
let text = "cafe\u{301} au lait";
let rows = visual_rows_of_str(text, 5);
let chars: Vec<char> = text.chars().collect();
for &(_, end, _) in &rows {
assert_ne!(
chars.get(end),
Some(&'\u{301}'),
"row ended between the base char and its combining mark"
);
}
}
#[test]
fn a_regional_indicator_pair_stays_whole() {
let text = "go \u{1f1ef}\u{1f1f5} now";
let rows = visual_rows_of_str(text, 5);
let chars: Vec<char> = text.chars().collect();
for &(_, end, _) in &rows {
assert_ne!(
chars.get(end),
Some(&'\u{1f1f5}'),
"row ended between the two halves of the flag"
);
}
}
#[test]
fn cjk_breaks_between_any_two_glyphs() {
let rows = visual_rows_of_str("- 日本語のリスト項目", 8);
assert_eq!(rows[0], (0, 5, 5), "`- 日本語` fills the first row");
assert_eq!(rows[1], (5, 9, 9));
}
#[test]
fn cjk_punctuation_stays_with_the_glyph_it_belongs_to() {
let text = "あいうえお。かきくけこ「さしすせ」たちつてと";
let chars: Vec<char> = text.chars().collect();
for width in 4..=12 {
for (start, end, _) in visual_rows_of_str(text, width) {
let row: String = chars[start..end].iter().collect();
assert!(
!row.starts_with(['。', '」']) && !row.ends_with('「'),
"row {row:?} at width {width}"
);
}
}
}
#[test]
fn a_word_joiner_between_wide_glyphs_is_never_a_break() {
for joiner in ['\u{2060}', '\u{feff}'] {
let text = format!("日本{joiner}語本");
let rows = visual_rows_of_str(&text, 4);
let chars: Vec<char> = text.chars().collect();
assert!(
rows.iter().all(|&(s, e, _)| {
!chars[s..e].starts_with(&[joiner]) && !chars[s..e].ends_with(&[joiner])
}),
"{rows:?} in {text:?}"
);
}
}
#[test]
fn quotes_stay_with_the_text_beside_them() {
let rows = visual_rows_of_str("'日本語'", 7);
assert_eq!(rows, vec![(0, 3, 3), (3, 5, 5)], "`'日本` / `語'`");
let rows = visual_rows_of_str("あいう ’90s", 8);
assert_eq!(rows, vec![(0, 4, 4), (4, 8, 8)], "`あいう ` / `’90s`");
}
#[test]
fn a_wide_glyph_at_the_edge_before_a_space_keeps_its_row() {
let rows = visual_rows_of_str("一二三四五 klm", 10);
assert_eq!(rows[0], (0, 5, 6));
}
#[test]
fn a_leading_pad_is_never_a_row_of_its_own() {
let rows = visual_rows_of_str(" abcdefghij", 5);
assert_eq!(rows[0], (0, 5, 5));
let rows = visual_rows_of_str(" 日本語のコードは", 8);
assert_eq!(rows[0], (0, 4, 4), "` 日本語` fills the first row");
}
#[test]
fn a_cluster_wider_than_the_row_still_makes_progress() {
let text = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}";
let rows = visual_rows_of_str(text, 3);
assert!(rows.len() > 1);
assert!(rows.iter().all(|&(s, e, _)| e > s));
}
#[test]
fn ascii_text_takes_the_no_segmentation_fast_path() {
let chars: Vec<(char, Style)> = "hello world foo bar"
.chars()
.map(|c| (c, Style::default()))
.collect();
assert!(joints_of(&chars).is_none());
}
#[test]
fn wrap_budget_is_cells_not_chars_for_wide_chars() {
let chars: Vec<(char, Style)> = "🥇🥇🥇".chars().map(|c| (c, Style::default())).collect();
let rows = visual_rows_of_chars(&chars, 4, Indent::NONE);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 2, 2));
assert_eq!(rows[1], (2, 3, 3));
}
#[test]
fn wrap_force_breaks_when_single_wide_char_exceeds_width() {
let chars: Vec<(char, Style)> = "🥇🥇".chars().map(|c| (c, Style::default())).collect();
let rows = visual_rows_of_chars(&chars, 1, Indent::NONE);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0], (0, 1, 1));
assert_eq!(rows[1], (1, 2, 2));
}
#[test]
fn char_idx_at_cell_col_forbidden_indent_zone_snaps_to_row_start() {
let chars = ['x', 'y', 'z'];
for cell in 0..=2 {
assert_eq!(
char_idx_at_cell_col(chars.iter().copied(), cell, 2),
0,
"indent zone cell {cell} did not snap to row start",
);
}
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 3, 2), 1);
}
#[test]
fn char_idx_at_cell_col_snaps_past_wide_char() {
let chars = ['🥇', 'B'];
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 0, 0), 0);
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 1, 0), 1);
assert_eq!(char_idx_at_cell_col(chars.iter().copied(), 2, 0), 1);
}
#[test]
fn char_idx_at_cell_col_snaps_past_a_wide_glyphs_marks() {
let chars = [
'\u{231A}', '\u{FE0E}', '\u{1112}', '\u{1161}', '\u{11AB}', 'x',
];
let at = |cell| char_idx_at_cell_col(chars.iter().copied(), cell, 0);
assert_eq!([at(0), at(1), at(2), at(3), at(4)], [0, 2, 2, 5, 5]);
}
#[test]
fn cell_col_at_char_idx_round_trips_with_wide_chars() {
let chars = ['A', '🥇', 'B'];
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 0, 0), 0);
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 1, 0), 1);
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 2, 0), 3);
assert_eq!(cell_col_at_char_idx(chars.iter().copied(), 3, 0), 4);
}
}