use ratatui::text::Line;
use crate::editor::EditorState;
use crate::markdown::table_layout::{
raw_pipe_positions, rendered_pipe_positions, wrap_cell_with_indices, CellOverlay,
};
pub(super) fn compute_cell_chunk_overlay(
raw_row: &str,
rendered_line: &Line<'_>,
cursor_col_raw: usize,
) -> Option<CellOverlay> {
let raw_pipes = raw_pipe_positions(raw_row);
let rendered_pipes = rendered_pipe_positions(rendered_line);
if raw_pipes.len() < 2 || rendered_pipes.len() != raw_pipes.len() {
return None;
}
let col_count = raw_pipes.len() - 1;
let preceding = raw_pipes
.iter()
.take_while(|&&p| p < cursor_col_raw)
.count();
let cell_idx = preceding.saturating_sub(1).min(col_count - 1);
let raw_cell_start = raw_pipes[cell_idx] + 1;
let raw_cell_end = raw_pipes[cell_idx + 1];
let raw_cell_text: String = raw_row
.chars()
.skip(raw_cell_start)
.take(raw_cell_end - raw_cell_start)
.collect();
let rendered_start = rendered_pipes[cell_idx] + 1;
let rendered_end = rendered_pipes[cell_idx + 1];
let cell_width = rendered_end.saturating_sub(rendered_start);
if cell_width == 0 {
return None;
}
let raw_chars: Vec<char> = raw_cell_text.chars().collect();
if raw_chars.len() <= cell_width {
return None;
}
let cursor_in_cell = cursor_col_raw.saturating_sub(raw_cell_start);
let total_chunks = raw_chars.len().div_ceil(cell_width);
let max_chunk_idx = total_chunks.saturating_sub(1);
let chunk_idx = (cursor_in_cell / cell_width).min(max_chunk_idx);
let col_in_chunk = (cursor_in_cell - chunk_idx * cell_width).min(cell_width.saturating_sub(1));
let chunk_start_chars = chunk_idx * cell_width;
let chunk_end_chars = (chunk_start_chars + cell_width).min(raw_chars.len());
let chunk: String = raw_chars[chunk_start_chars..chunk_end_chars]
.iter()
.collect();
let chunk_byte_start = raw_row
.char_indices()
.nth(raw_cell_start + chunk_start_chars)
.map(|(b, _)| b)
.unwrap_or(raw_row.len());
Some(CellOverlay {
rendered_start,
rendered_end,
raw_text: chunk,
cursor_in_cell: Some(col_in_chunk),
raw_cell_byte_start: chunk_byte_start,
})
}
pub(super) struct WrappedCellOverlay {
pub(super) row_first_line_idx: usize,
pub(super) subs: Vec<CellOverlay>,
pub(super) cursor_sub: usize,
pub(super) visual_col: usize,
}
pub(super) fn compute_wrapped_cell_overlay(
editor: &EditorState,
block_lines_range: std::ops::Range<usize>,
data_row_idx: usize,
cursor_col_raw: usize,
raw_block_source: &str,
) -> Option<WrappedCellOverlay> {
use crate::ui::table_view::{classify_table_sub_lines, TableSubLineKind};
let block_lines = editor.parsed.lines.get(block_lines_range.clone())?;
let kinds = classify_table_sub_lines(block_lines);
let row_start_local = kinds.iter().position(|k| {
matches!(
k,
TableSubLineKind::DataRow { row, sub: 0 } if *row == data_row_idx
)
})?;
let row_height = kinds[row_start_local..]
.iter()
.take_while(|k| matches!(k, TableSubLineKind::DataRow { row, .. } if *row == data_row_idx))
.count();
let first_line = block_lines.get(row_start_local)?;
let rendered_pipes = rendered_pipe_positions(first_line);
let raw_row = raw_block_source.split('\n').nth(data_row_idx + 2)?;
let raw_pipes = raw_pipe_positions(raw_row);
if raw_pipes.len() < 2 || rendered_pipes.len() != raw_pipes.len() {
return None;
}
let col_count = raw_pipes.len() - 1;
let preceding = raw_pipes
.iter()
.take_while(|&&p| p < cursor_col_raw)
.count();
let cell_idx = preceding.saturating_sub(1).min(col_count - 1);
let raw_cell_start_char = raw_pipes[cell_idx] + 1;
let raw_cell_end_char = raw_pipes[cell_idx + 1];
let raw_cell_text: String = raw_row
.chars()
.skip(raw_cell_start_char)
.take(raw_cell_end_char - raw_cell_start_char)
.collect();
let cell_rendered_start = rendered_pipes[cell_idx] + 1;
let cell_rendered_end = rendered_pipes[cell_idx + 1];
let content_width = cell_rendered_end
.saturating_sub(cell_rendered_start)
.saturating_sub(2);
if content_width == 0 {
return None;
}
let raw_chars: Vec<char> = raw_cell_text.chars().collect();
let raw_leading = raw_chars.iter().take_while(|c| c.is_whitespace()).count();
let raw_trailing = raw_chars
.iter()
.rev()
.take_while(|c| c.is_whitespace())
.count();
let content_chars = raw_chars.len().saturating_sub(raw_leading + raw_trailing);
let trimmed: String = raw_chars[raw_leading..raw_leading + content_chars]
.iter()
.collect();
let wrapped = wrap_cell_with_indices(&trimmed, content_width);
if wrapped.is_empty() {
return None;
}
if row_height <= 1 {
return None;
}
let cursor_in_cell = cursor_col_raw.saturating_sub(raw_cell_start_char + raw_leading);
let last_idx = wrapped.len() - 1;
let mut cursor_sub = last_idx;
let mut cursor_col_in_chunk = wrapped[last_idx].1.chars().count();
for (i, (start_idx, row_text)) in wrapped.iter().enumerate() {
let next_start = wrapped.get(i + 1).map(|(s, _)| *s).unwrap_or(usize::MAX);
if cursor_in_cell < next_start {
cursor_sub = i;
let row_chars = row_text.chars().count();
let pos_in_row = cursor_in_cell.saturating_sub(*start_idx);
cursor_col_in_chunk = pos_in_row.min(row_chars);
break;
}
}
let window_start = cursor_sub.saturating_sub(row_height - 1);
let window = &wrapped[window_start..(window_start + row_height).min(wrapped.len())];
let cursor_sub = cursor_sub - window_start;
let raw_row_byte_at: Vec<usize> = raw_row
.char_indices()
.map(|(b, _)| b)
.chain(std::iter::once(raw_row.len()))
.collect();
let mut subs: Vec<CellOverlay> = Vec::with_capacity(window.len());
for (i, (start_in_cell, chunk_text)) in window.iter().enumerate() {
let painted_start = cell_rendered_start + 1;
let chunk_first_char_in_row = raw_cell_start_char + raw_leading + start_in_cell;
let raw_cell_byte_start = raw_row_byte_at
.get(chunk_first_char_in_row)
.copied()
.unwrap_or(raw_row.len());
let cursor_in_cell = if i == cursor_sub {
Some(cursor_col_in_chunk.min(chunk_text.chars().count()))
} else {
None
};
subs.push(CellOverlay {
rendered_start: painted_start,
rendered_end: cell_rendered_end,
raw_text: chunk_text.clone(),
cursor_in_cell,
raw_cell_byte_start,
});
}
let cell_end_byte = raw_row_byte_at
.get(raw_cell_end_char)
.copied()
.unwrap_or(raw_row.len());
while subs.len() < row_height {
subs.push(CellOverlay {
rendered_start: cell_rendered_start,
rendered_end: cell_rendered_end,
raw_text: String::new(),
cursor_in_cell: None,
raw_cell_byte_start: cell_end_byte,
});
}
let visual_col = subs[cursor_sub].rendered_start + cursor_col_in_chunk;
Some(WrappedCellOverlay {
row_first_line_idx: block_lines_range.start + row_start_local,
subs,
cursor_sub,
visual_col,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::document::Buffer;
#[test]
fn raw_wider_than_rendered_height_scrolls_vertically() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| x | `aa` `bb` `cc` `dd` `ee` |\n";
let mut state = crate::editor::EditorState::new(Buffer::from_str(src), theme);
state.set_viewport_width(18);
let lines_range = 0..state.parsed.lines.len();
let raw_row = "| x | `aa` `bb` `cc` `dd` `ee` |";
let cursor_col = raw_row.find("cc").unwrap();
let overlay = compute_wrapped_cell_overlay(&state, lines_range, 0, cursor_col, src)
.expect("multi-sub row must use the wrapped-cell overlay, not the chunk fallback");
assert!(overlay.subs.len() >= 2, "fixture row must wrap");
assert!(
overlay.subs.iter().all(|s| !s.raw_text.is_empty()),
"raw chunks must cover every sub-line: {:?}",
overlay.subs.iter().map(|s| &s.raw_text).collect::<Vec<_>>()
);
let joined: String = overlay.subs.iter().map(|s| s.raw_text.as_str()).collect();
assert!(
joined.contains('`'),
"overlay must reveal raw backticks: {joined:?}"
);
assert!(overlay.cursor_sub < overlay.subs.len());
}
#[test]
fn click_on_second_wrap_line_keeps_span_start_visible() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| x | `tracing-appender` |\n";
let mut state = crate::editor::EditorState::new(Buffer::from_str(src), theme);
state.set_viewport_width(17);
let lines_range = 0..state.parsed.lines.len();
let raw_row = "| x | `tracing-appender` |";
let cursor_col = raw_row.find("appender").unwrap();
let overlay = compute_wrapped_cell_overlay(&state, lines_range, 0, cursor_col, src)
.expect("wrapped code-span cell must use the multi-sub overlay");
assert_eq!(overlay.subs.len(), 2, "fixture row wraps to two sub-lines");
assert!(
overlay.cursor_sub > 0,
"cursor clicked on the second sub-line must stay below the first chunk"
);
assert!(
overlay.subs[0].raw_text.starts_with('`'),
"the span's start must stay visible on the first sub-line: {:?}",
overlay.subs.iter().map(|s| &s.raw_text).collect::<Vec<_>>()
);
assert!(
overlay.subs.iter().all(|s| !s.raw_text.is_empty()),
"no sub-line may be wasted on a pad-space chunk: {:?}",
overlay.subs.iter().map(|s| &s.raw_text).collect::<Vec<_>>()
);
}
}