pub fn list_raw_col_to_rendered_col(
raw_text: &str,
line: &ratatui::text::Line<'_>,
raw_col: usize,
) -> Option<usize> {
let raw_total = raw_list_marker_char_width(raw_text)?;
let rendered_total = rendered_list_marker_char_width(line)?;
if raw_col <= raw_total {
Some(rendered_total)
} else {
Some(raw_col - raw_total + rendered_total)
}
}
pub fn list_rendered_col_to_raw_col_marker(
raw_total: usize,
rendered_total: usize,
rendered_col: usize,
) -> usize {
(rendered_col + raw_total)
.saturating_sub(rendered_total)
.min(raw_total)
}
pub fn raw_list_marker_char_width(raw_text: &str) -> Option<usize> {
let indent_chars = raw_text
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
let after_indent: String = raw_text.chars().skip(indent_chars).collect();
let rb = after_indent.as_bytes();
let marker_len = match rb.first() {
Some(b'-') | Some(b'*') | Some(b'+') if rb.get(1) == Some(&b' ') => 2,
_ => {
let digits = rb.iter().take_while(|b| b.is_ascii_digit()).count();
if digits > 0
&& matches!(rb.get(digits), Some(b'.') | Some(b')'))
&& rb.get(digits + 1) == Some(&b' ')
{
digits + 2
} else {
return None;
}
}
};
let after_marker = &after_indent[marker_len..];
let task_len = if after_marker.starts_with("[ ] ")
|| after_marker.starts_with("[x] ")
|| after_marker.starts_with("[X] ")
{
4
} else {
0
};
Some(indent_chars + marker_len + task_len)
}
pub fn rendered_list_marker_char_width(line: &ratatui::text::Line<'_>) -> Option<usize> {
let text: String = line.spans.iter().flat_map(|s| s.content.chars()).collect();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() && chars[i] == ' ' {
i += 1;
}
let after_bullet = if chars.get(i) == Some(&'•') && chars.get(i + 1) == Some(&' ') {
Some(i + 2)
} else {
let digits = chars[i..].iter().take_while(|c| c.is_ascii_digit()).count();
if digits > 0
&& matches!(chars.get(i + digits), Some('.') | Some(')'))
&& chars.get(i + digits + 1) == Some(&' ')
{
Some(i + digits + 2)
} else {
None
}
}?;
if chars.get(after_bullet) == Some(&'[')
&& matches!(chars.get(after_bullet + 1), Some(' ') | Some('✓'))
&& chars.get(after_bullet + 2) == Some(&']')
&& chars.get(after_bullet + 3) == Some(&' ')
{
Some(after_bullet + 4)
} else {
Some(after_bullet)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
#[test]
fn raw_list_marker_width_bullet() {
assert_eq!(raw_list_marker_char_width("- foo"), Some(2));
assert_eq!(raw_list_marker_char_width(" - foo"), Some(4));
}
#[test]
fn raw_list_marker_width_ordered() {
assert_eq!(raw_list_marker_char_width("1. foo"), Some(3));
assert_eq!(raw_list_marker_char_width("10. foo"), Some(4));
}
#[test]
fn raw_list_marker_width_task() {
assert_eq!(raw_list_marker_char_width("- [ ] foo"), Some(6));
assert_eq!(raw_list_marker_char_width("- [x] foo"), Some(6));
}
#[test]
fn rendered_marker_width_bullet() {
let line = Line::from(vec![Span::styled("• ", Style::default()), Span::raw("foo")]);
assert_eq!(rendered_list_marker_char_width(&line), Some(2));
}
#[test]
fn rendered_marker_width_ordered_padded() {
let line = Line::from(vec![
Span::styled(" 1. ", Style::default()),
Span::raw("foo"),
]);
assert_eq!(rendered_list_marker_char_width(&line), Some(4));
}
#[test]
fn rendered_marker_width_task() {
let line = Line::from(vec![
Span::styled("• ", Style::default()),
Span::styled("[ ] ", Style::default()),
Span::raw("foo"),
]);
assert_eq!(rendered_list_marker_char_width(&line), Some(6));
}
#[test]
fn rendered_marker_width_task_checked() {
let line = Line::from(vec![
Span::styled("• ", Style::default()),
Span::styled("[✓] ", Style::default()),
Span::raw("foo"),
]);
assert_eq!(rendered_list_marker_char_width(&line), Some(6));
}
#[test]
fn list_col_map_bullet_unchanged() {
let line = Line::from(vec![Span::styled("• ", Style::default()), Span::raw("foo")]);
assert_eq!(list_raw_col_to_rendered_col("- foo", &line, 2), Some(2));
assert_eq!(list_raw_col_to_rendered_col("- foo", &line, 4), Some(4));
}
#[test]
fn list_col_map_task_aligns_one_to_one() {
let line = Line::from(vec![
Span::styled("• ", Style::default()),
Span::styled("[ ] ", Style::default()),
Span::raw("foo"),
]);
assert_eq!(list_raw_col_to_rendered_col("- [ ] foo", &line, 6), Some(6));
assert_eq!(list_raw_col_to_rendered_col("- [ ] foo", &line, 7), Some(7));
}
#[test]
fn list_col_map_ordered_padded_shifts_right() {
let line = Line::from(vec![
Span::styled(" 1. ", Style::default()),
Span::raw("foo"),
]);
assert_eq!(list_raw_col_to_rendered_col("1. foo", &line, 3), Some(4));
assert_eq!(list_raw_col_to_rendered_col("1. foo", &line, 5), Some(6));
}
#[test]
fn inverse_marker_map_identity_when_widths_match() {
for col in 0..6 {
assert_eq!(list_rendered_col_to_raw_col_marker(6, 6, col), col);
}
}
#[test]
fn inverse_marker_map_right_aligns_padded_ordered() {
assert_eq!(list_rendered_col_to_raw_col_marker(3, 4, 0), 0);
assert_eq!(list_rendered_col_to_raw_col_marker(3, 4, 1), 0);
assert_eq!(list_rendered_col_to_raw_col_marker(3, 4, 3), 2);
}
#[test]
fn inverse_marker_map_right_aligns_nested_task_checkbox() {
assert_eq!(list_rendered_col_to_raw_col_marker(8, 10, 6), 4);
assert_eq!(list_rendered_col_to_raw_col_marker(8, 10, 8), 6);
}
#[test]
fn inverse_marker_map_clamps_to_raw_total() {
assert_eq!(list_rendered_col_to_raw_col_marker(3, 4, 10), 3);
}
#[test]
fn inverse_marker_map_round_trips_boundary() {
for (raw_text, rendered) in [
("- foo", "• foo"),
("1. foo", " 1. foo"),
("- [ ] foo", "• [ ] foo"),
(" - bar", " • bar"),
] {
let line = Line::from(vec![Span::raw(rendered.to_owned())]);
let raw_total = raw_list_marker_char_width(raw_text).unwrap();
let rendered_total = rendered_list_marker_char_width(&line).unwrap();
assert_eq!(
list_raw_col_to_rendered_col(raw_text, &line, raw_total),
Some(rendered_total)
);
assert!(
list_rendered_col_to_raw_col_marker(raw_total, rendered_total, rendered_total)
== raw_total
);
}
}
}