use unicode_width::UnicodeWidthChar;
use crate::config::schema::LayoutConfig;
pub const MAX_LINE_SPACING: u16 = 4;
pub struct Layout {
pub measure: u16,
pub margin_left: u16,
pub top: u16,
pub height: u16,
pub number_gutter: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RowSource {
Buffer(usize),
Embedded { line: usize, index: usize },
Spacer(usize),
}
#[derive(Clone, Copy, Debug)]
pub struct VisualRow {
pub source: RowSource,
pub start_col: usize,
pub end_col: usize,
pub hanging: u16,
pub editable: bool,
}
impl VisualRow {
pub fn line(&self) -> usize {
match self.source {
RowSource::Buffer(l) => l,
RowSource::Embedded { line, .. } => line,
RowSource::Spacer(line) => line,
}
}
pub fn embedded(&self) -> Option<usize> {
match self.source {
RowSource::Embedded { index, .. } => Some(index),
RowSource::Buffer(_) | RowSource::Spacer(_) => None,
}
}
pub fn is_spacer(&self) -> bool {
matches!(self.source, RowSource::Spacer(_))
}
}
impl Layout {
pub fn compute(cfg: &LayoutConfig, term_cols: u16, term_rows: u16, reserved_rows: u16) -> Self {
let available = term_cols.saturating_sub(4).max(8);
let measure = cfg.measure.clamp(8, available);
let margin_left = if cfg.align.trim() == "left" {
2
} else {
term_cols.saturating_sub(measure) / 2
};
let pad_top = cfg.padding_top.min(term_rows / 4);
let pad_bottom = cfg.padding_bottom.min(term_rows / 4);
let height = term_rows
.saturating_sub(pad_top)
.saturating_sub(pad_bottom)
.saturating_sub(reserved_rows)
.max(1);
Layout {
measure,
margin_left,
top: pad_top,
height,
number_gutter: 0,
}
}
}
pub fn wrap_hanging(text: &str, measure: u16, hang: u16) -> Vec<(usize, usize)> {
if hang == 0 {
return wrap_line(text, measure);
}
let hang = hang.min(measure.saturating_sub(MIN_WRAP_WIDTH));
let first = wrap_line(text, measure);
if first.len() < 2 || hang == 0 {
return first;
}
let (start, end) = first[0];
let mut out = vec![(start, end)];
let chars: Vec<char> = text.chars().collect();
let rest: String = chars[end..].iter().collect();
for (a, b) in wrap_line(&rest, measure - hang) {
out.push((end + a, end + b));
}
out
}
const MIN_WRAP_WIDTH: u16 = 8;
pub fn content_column(text: &str, tab_width: u16) -> u16 {
let mut width = 0u16;
let mut chars = text.chars().peekable();
while let Some(c) = chars.peek() {
match c {
' ' => width += 1,
'\t' => width += tab_width.max(1),
_ => break,
}
chars.next();
}
let mut marker = 0u16;
if chars.peek().is_some_and(|c| c.is_ascii_digit()) {
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
chars.next();
marker += 1;
}
if chars.peek().is_some_and(|c| matches!(c, '.' | ')')) {
chars.next();
marker += 1;
}
} else if let Some(c) = chars.next() {
marker += UnicodeWidthChar::width(c).unwrap_or(1) as u16;
}
let rest: String = chars.clone().collect();
let boxed = rest.strip_prefix(' ').filter(|r| r.starts_with("[")).is_some()
&& rest.chars().nth(3) == Some(']');
if boxed {
for _ in 0..4 {
chars.next();
}
marker += 4;
}
let mut gap = 0u16;
while chars.peek() == Some(&' ') {
chars.next();
gap += 1;
}
if chars.peek().is_none() {
return 0;
}
width + marker + gap
}
pub fn wrap_line(text: &str, measure: u16) -> Vec<(usize, usize)> {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return vec![(0, 0)];
}
let max = measure.max(1) as usize;
let mut out = Vec::new();
let mut start = 0usize;
let mut width = 0usize;
let mut last_space: Option<usize> = None;
let mut i = 0usize;
while i < chars.len() {
let w = UnicodeWidthChar::width(chars[i]).unwrap_or(0);
if width + w > max && i > start {
let brk = match last_space {
Some(s) if s > start => s + 1,
_ => i,
};
out.push((start, brk));
start = brk;
width = 0;
last_space = None;
i = brk;
continue;
}
if chars[i] == ' ' {
last_space = Some(i);
}
width += w;
i += 1;
}
out.push((start, chars.len()));
out
}
pub fn no_wrap(text: &str) -> Vec<(usize, usize)> {
vec![(0, text.chars().count())]
}
pub fn display_width(text: &str) -> u16 {
text.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum::<usize>() as u16
}
pub fn scroll_offset(
cursor_row: usize,
total_rows: usize,
layout: &Layout,
cfg: &LayoutConfig,
scroll_off: u16,
previous: usize,
) -> usize {
let height = layout.height.max(1) as usize;
if cfg.typewriter {
let anchor = (height as f32 * cfg.typewriter_anchor).round() as usize;
return cursor_row.saturating_sub(anchor.min(height.saturating_sub(1)));
}
let pad = (scroll_off as usize).min(height.saturating_sub(1) / 2);
let mut top = previous.min(total_rows.saturating_sub(1));
if cursor_row < top + pad {
top = cursor_row.saturating_sub(pad);
}
let bottom = top + height;
if cursor_row + pad >= bottom {
top = cursor_row + pad + 1 - height;
}
top.min(total_rows.saturating_sub(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_column_measures_the_marker_run() {
assert_eq!(content_column("- item", 4), 2, "bullet plus its space");
assert_eq!(content_column("• item", 4), 2, "…concealed is the same width");
assert_eq!(content_column("12. item", 4), 4, "an ordinal is as wide as it reads");
assert_eq!(content_column("- [ ] task", 4), 6, "the raw checkbox prefix");
assert_eq!(content_column("☐ task", 4), 2, "…and the concealed one");
assert_eq!(content_column(" - nested", 4), 4, "indent counts toward it");
assert_eq!(content_column("\t- tabbed", 4), 6, "a tab is tab_width cells");
assert_eq!(content_column("- ", 4), 0, "a marker with no content is not a hang");
}
#[test]
fn wrap_hanging_narrows_only_the_continuations() {
let text = "- aaaa bbbb cccc dddd";
let flat = wrap_line(text, 12);
let hung = wrap_hanging(text, 12, 2);
assert_eq!(hung[0], flat[0], "the first row keeps the full measure");
for (a, b) in &hung[1..] {
let row: String = text.chars().skip(*a).take(b - a).collect();
assert!(row.trim_end().chars().count() <= 10, "narrowed: {row:?}");
}
let joined: String = hung
.iter()
.map(|(a, b)| text.chars().skip(*a).take(b - a).collect::<String>())
.collect();
assert_eq!(joined, text, "wrapping loses nothing");
}
#[test]
fn wrap_hanging_keeps_a_usable_width() {
let rows = wrap_hanging("- aaaa bbbb cccc dddd eeee", 10, 9);
assert!(rows.len() > 1);
assert!(rows.windows(2).all(|w| w[1].1 > w[0].1), "always makes progress");
}
}