use crate::app::{App, TableModalState};
use crate::markdown::CellSpans;
use crate::theme::Palette;
use pulldown_cmark::Alignment;
use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn draw(f: &mut Frame, app: &mut App) {
let state = match &app.table_modal {
Some(s) => s,
None => return,
};
let p = &app.palette;
let area = f.area();
let popup = centered_pct(90, 90, area);
app.table_modal_rect = Some(popup);
f.render_widget(Clear, popup);
let num_cols = state.natural_widths.len();
let title = format!(
" Table {} col{} h/l col H/L \u{00bd}pg q/Esc close ",
num_cols,
if num_cols == 1 { "" } else { "s" },
);
let block = Block::default()
.title(title)
.title_style(p.title_style())
.borders(Borders::ALL)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.background));
let inner = block.inner(popup);
f.render_widget(block, popup);
if inner.height == 0 || inner.width == 0 {
return;
}
let content_height = inner.height.saturating_sub(1) as usize;
let rendered = render_modal_table(state, p);
let v_scroll = state.v_scroll as usize;
let visible_lines: Vec<&Line<'static>> = rendered
.lines
.iter()
.skip(v_scroll)
.take(content_height)
.collect();
let h_scroll = state.h_scroll as usize;
let visible_width = inner.width as usize;
let sliced_lines: Vec<Line<'static>> = visible_lines
.iter()
.map(|line| slice_line_at(line, h_scroll, visible_width))
.collect();
let content_rect = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: content_height as u16,
};
f.render_widget(Paragraph::new(Text::from(sliced_lines)), content_rect);
let total_rendered = rendered.lines.len();
let footer_text = format!(
" row {}/{} \u{2502} col {}/{} \u{2502} j/k scroll d/u \u{00bd}pg g/G top/bot 0/$ h-pan h/l col ",
v_scroll.saturating_add(1).min(total_rendered),
total_rendered,
h_scroll,
state.natural_widths.iter().sum::<usize>(),
);
let footer_rect = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(footer_text, p.dim_style()))),
footer_rect,
);
}
fn render_modal_table(state: &TableModalState, p: &Palette) -> Text<'static> {
let border_style = Style::default().fg(p.table_border);
let header_style = Style::default()
.fg(p.table_header)
.add_modifier(Modifier::BOLD);
let cell_style = Style::default().fg(p.foreground);
let col_widths = &state.natural_widths;
let num_cols = col_widths.len();
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(modal_border_line(
'┌',
'─',
'┬',
'┐',
col_widths,
border_style,
));
emit_wrapped_row(
&state.headers,
col_widths,
&state.alignments,
border_style,
header_style,
num_cols,
&mut lines,
);
lines.push(modal_border_line(
'├',
'─',
'┼',
'┤',
col_widths,
border_style,
));
for row in &state.rows {
emit_wrapped_row(
row,
col_widths,
&state.alignments,
border_style,
cell_style,
num_cols,
&mut lines,
);
}
lines.push(modal_border_line(
'└',
'─',
'┴',
'┘',
col_widths,
border_style,
));
Text::from(lines)
}
fn emit_wrapped_row(
cells: &[CellSpans],
col_widths: &[usize],
alignments: &[Alignment],
border_style: Style,
cell_style: Style,
num_cols: usize,
out: &mut Vec<Line<'static>>,
) {
let wrapped: Vec<Vec<CellSpans>> = (0..num_cols)
.map(|i| {
let spans = cells.get(i).map(|s| s.as_slice()).unwrap_or(&[]);
let w = col_widths.get(i).copied().unwrap_or(1).max(1);
wrap_cell_spans(spans, w)
})
.collect();
let row_height = wrapped.iter().map(|c| c.len()).max().unwrap_or(1);
for sub in 0..row_height {
let mut spans: Vec<Span<'static>> = Vec::with_capacity(num_cols * 3 + 1);
spans.push(Span::styled("│".to_string(), border_style));
for (i, &w) in col_widths.iter().enumerate().take(num_cols) {
let alignment = alignments.get(i).copied().unwrap_or(Alignment::None);
let cell_line = wrapped[i].get(sub).map(|s| s.as_slice()).unwrap_or(&[]);
let cell_width: usize = cell_line
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let padding = w.saturating_sub(cell_width);
match alignment {
Alignment::Right => {
let pad_str = format!(" {}", " ".repeat(padding));
spans.push(Span::styled(pad_str, cell_style));
spans.extend(cell_line.iter().cloned());
spans.push(Span::styled(" │".to_string(), border_style));
}
Alignment::Center => {
let left = padding / 2;
let right = padding - left;
let pad_str = format!(" {}", " ".repeat(left));
spans.push(Span::styled(pad_str, cell_style));
spans.extend(cell_line.iter().cloned());
let trail = format!("{} │", " ".repeat(right));
spans.push(Span::styled(trail, border_style));
}
Alignment::Left | Alignment::None => {
spans.push(Span::styled(" ".to_string(), cell_style));
spans.extend(cell_line.iter().cloned());
let trail = format!("{} │", " ".repeat(padding));
spans.push(Span::styled(trail, border_style));
}
}
}
out.push(Line::from(spans));
}
}
struct StyledChar {
ch: char,
width: usize,
style: Style,
}
pub fn wrap_cell_spans(cell: &[Span<'static>], width: usize) -> Vec<CellSpans> {
if width == 0 {
return vec![vec![]];
}
let styled: Vec<StyledChar> = cell
.iter()
.flat_map(|span| {
span.content.chars().map(move |ch| StyledChar {
ch,
width: UnicodeWidthChar::width(ch).unwrap_or(0),
style: span.style,
})
})
.collect();
if styled.is_empty() {
return vec![vec![]];
}
let mut result: Vec<CellSpans> = Vec::new();
let mut line_start = 0;
while line_start <= styled.len() {
let line_end = styled[line_start..]
.iter()
.position(|sc| sc.ch == '\n')
.map(|p| line_start + p)
.unwrap_or(styled.len());
let hard_line = &styled[line_start..line_end];
emit_wrapped_hard_line(hard_line, width, &mut result);
if line_end >= styled.len() {
break;
}
line_start = line_end + 1;
}
if result.is_empty() {
result.push(vec![]);
}
result
}
fn emit_wrapped_hard_line(chars: &[StyledChar], width: usize, out: &mut Vec<CellSpans>) {
let mut words: Vec<&[StyledChar]> = Vec::new();
let mut word_start: Option<usize> = None;
for (i, sc) in chars.iter().enumerate() {
if sc.ch.is_whitespace() {
if let Some(start) = word_start.take() {
words.push(&chars[start..i]);
}
} else if word_start.is_none() {
word_start = Some(i);
}
}
if let Some(start) = word_start {
words.push(&chars[start..]);
}
if words.is_empty() {
out.push(vec![]);
return;
}
let mut line_buf: Vec<(char, Style)> = Vec::new();
let mut line_w = 0usize;
let flush = |buf: &mut Vec<(char, Style)>, out: &mut Vec<CellSpans>| {
out.push(merge_char_style_pairs(buf));
buf.clear();
};
for word in &words {
let word_w: usize = word.iter().map(|sc| sc.width).sum();
if word_w <= width {
if line_w > 0 && line_w + 1 + word_w > width {
flush(&mut line_buf, out);
line_w = 0;
}
if line_w > 0 {
let space_style = word.first().map(|sc| sc.style).unwrap_or_default();
line_buf.push((' ', space_style));
line_w += 1;
}
for sc in *word {
line_buf.push((sc.ch, sc.style));
}
line_w += word_w;
} else {
if line_w > 0 {
flush(&mut line_buf, out);
}
let mut chunk_w = 0usize;
for sc in *word {
if chunk_w + sc.width > width {
flush(&mut line_buf, out);
chunk_w = 0;
}
line_buf.push((sc.ch, sc.style));
chunk_w += sc.width;
}
line_w = chunk_w;
}
}
if !line_buf.is_empty() {
out.push(merge_char_style_pairs(&line_buf));
}
}
fn merge_char_style_pairs(pairs: &[(char, Style)]) -> CellSpans {
let mut spans: CellSpans = Vec::new();
for &(ch, style) in pairs {
if let Some(last) = spans.last_mut()
&& last.style == style
{
let mut s = last.content.to_string();
s.push(ch);
*last = Span::styled(s, style);
} else {
spans.push(Span::styled(ch.to_string(), style));
}
}
spans
}
fn modal_border_line(
left: char,
fill: char,
mid: char,
right: char,
col_widths: &[usize],
style: Style,
) -> Line<'static> {
let mut s = String::new();
s.push(left);
for (i, &w) in col_widths.iter().enumerate() {
for _ in 0..(w + 2) {
s.push(fill);
}
if i + 1 < col_widths.len() {
s.push(mid);
}
}
s.push(right);
Line::from(Span::styled(s, style))
}
pub fn slice_line_at(line: &Line<'static>, h_scroll: usize, visible_width: usize) -> Line<'static> {
if visible_width == 0 {
return Line::from("");
}
let mut out: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
let mut col = 0usize; let mut used = 0usize;
for span in &line.spans {
if used >= visible_width {
break;
}
let mut buf = String::new();
for ch in span.content.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + ch_w <= h_scroll {
col += ch_w;
continue;
}
if col < h_scroll {
if used + 1 > visible_width {
break;
}
buf.push(' ');
used += 1;
col = h_scroll + 1;
continue;
}
if used + ch_w > visible_width {
break;
}
buf.push(ch);
used += ch_w;
col += ch_w;
}
if !buf.is_empty() {
out.push(Span::styled(buf, span.style));
}
}
Line::from(out)
}
fn centered_pct(w_pct: u16, h_pct: u16, area: Rect) -> Rect {
let w = (area.width * w_pct / 100).max(10);
let h = (area.height * h_pct / 100).max(5);
let vertical = Layout::vertical([Constraint::Length(h)])
.flex(Flex::Center)
.split(area);
Layout::horizontal([Constraint::Length(w)])
.flex(Flex::Center)
.split(vertical[0])[0]
}
pub fn max_h_scroll(state: &TableModalState, visible_width: u16) -> u16 {
let total_table_width: usize = state.natural_widths.iter().sum::<usize>()
+ state.natural_widths.len() * 3 + 1; (total_table_width.saturating_sub(visible_width as usize)) as u16
}
fn col_boundaries(widths: &[usize]) -> Vec<u16> {
let mut offsets = Vec::with_capacity(widths.len());
let mut acc: usize = 0;
for &w in widths {
offsets.push(acc as u16);
acc += w + 3;
}
offsets
}
pub fn prev_col_boundary(widths: &[usize], h_scroll: u16) -> u16 {
col_boundaries(widths)
.into_iter()
.rfind(|&b| b < h_scroll)
.unwrap_or(0)
}
pub fn next_col_boundary(widths: &[usize], h_scroll: u16, max: u16) -> u16 {
col_boundaries(widths)
.into_iter()
.find(|&b| b > h_scroll)
.unwrap_or(max)
.min(max)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn plain(s: &str) -> CellSpans {
vec![Span::raw(s.to_string())]
}
fn styled_span(s: &str, style: Style) -> Span<'static> {
Span::styled(s.to_string(), style)
}
fn spans_text(spans: &CellSpans) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
fn lines_text(lines: &[CellSpans]) -> Vec<String> {
lines.iter().map(spans_text).collect()
}
#[test]
fn wrap_spans_short_fits_single_line() {
let cell = plain("hello world");
let result = wrap_cell_spans(&cell, 20);
assert_eq!(result.len(), 1);
assert_eq!(spans_text(&result[0]), "hello world");
}
#[test]
fn wrap_spans_long_wraps_on_word_boundary() {
let cell = plain("one two three four five");
let result = wrap_cell_spans(&cell, 10);
assert!(
result.len() > 1,
"should produce multiple lines: {result:?}"
);
for line in &result {
let w: usize = line
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert!(w <= 10, "line too wide: {w}");
}
let joined = lines_text(&result).join(" ");
assert!(joined.contains("one"));
assert!(joined.contains("five"));
}
#[test]
fn wrap_spans_style_preserved_across_wrap() {
let bold = Style::default().add_modifier(Modifier::BOLD);
let cell: CellSpans = vec![
styled_span("bold-word ", bold),
Span::raw("plain continues with more words"),
];
let result = wrap_cell_spans(&cell, 12);
assert!(result.len() > 1, "should wrap: {result:?}");
let first_line = &result[0];
let has_bold = first_line.iter().any(|s| s.style == bold);
assert!(
has_bold,
"first line should contain bold span: {first_line:?}"
);
}
#[test]
fn wrap_spans_bold_then_plain_splits_in_plain() {
let bold = Style::default().fg(Color::Red);
let cell: CellSpans = vec![styled_span("Bold", bold), Span::raw(" plain-text-here")];
let result = wrap_cell_spans(&cell, 8);
assert!(
result.len() > 1,
"should produce multiple lines: {result:?}"
);
let first_text = spans_text(&result[0]);
assert!(
first_text.contains("Bold"),
"first line should have bold: {first_text}"
);
}
#[test]
fn wrap_spans_word_longer_than_width_hard_splits() {
let cell = plain("abcdefghij");
let result = wrap_cell_spans(&cell, 4);
assert!(result.len() >= 2, "long word must hard-split: {result:?}");
for line in &result {
let w: usize = line
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert!(w <= 4, "hard-split chunk too wide: {w}");
}
let all_text: String = result
.iter()
.flat_map(|l| l.iter())
.map(|s| s.content.as_ref())
.collect();
assert_eq!(all_text, "abcdefghij");
}
#[test]
fn wrap_spans_empty_cell_single_empty_line() {
let result = wrap_cell_spans(&[], 10);
assert_eq!(result.len(), 1);
assert!(result[0].is_empty());
}
#[test]
fn wrap_spans_hard_newline_honored() {
let cell: CellSpans = vec![Span::raw("line one\nline two".to_string())];
let result = wrap_cell_spans(&cell, 40);
assert_eq!(result.len(), 2);
assert_eq!(spans_text(&result[0]), "line one");
assert_eq!(spans_text(&result[1]), "line two");
}
fn styled(text: &str, fg: ratatui::style::Color) -> Span<'static> {
Span::styled(text.to_string(), Style::default().fg(fg))
}
#[test]
fn slice_line_at_preserves_per_span_styles() {
use ratatui::style::Color;
let line = Line::from(vec![
styled("│", Color::Gray),
styled(" Name ", Color::Blue),
styled("│", Color::Gray),
styled(" Value ", Color::Blue),
styled("│", Color::Gray),
]);
let sliced = slice_line_at(&line, 0, 100);
let blue_count = sliced
.spans
.iter()
.filter(|s| s.style.fg == Some(Color::Blue))
.count();
assert!(
blue_count >= 2,
"expected at least 2 blue header spans, got {blue_count}: {:#?}",
sliced.spans,
);
}
#[test]
fn slice_line_at_ascii_mid_column() {
let line = Line::from(vec![Span::raw("abcdefghij".to_string())]);
let sliced = slice_line_at(&line, 3, 4);
let text: String = sliced.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "defg");
}
#[test]
fn slice_line_at_past_end_returns_empty() {
let line = Line::from(vec![Span::raw("short".to_string())]);
let sliced = slice_line_at(&line, 10, 5);
let text: String = sliced.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "");
}
#[test]
fn slice_line_at_double_width_straddle() {
let line = Line::from(vec![Span::raw("AB\u{30A2}CD".to_string())]);
let sliced = slice_line_at(&line, 3, 5);
let text: String = sliced.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.starts_with(' '),
"cut double-width char should be replaced with space: {text:?}",
);
assert!(text.contains('C'), "C should be visible: {text:?}");
}
#[test]
fn slice_line_at_exact_visible_width() {
let line = Line::from(vec![Span::raw("12345678".to_string())]);
let sliced = slice_line_at(&line, 2, 4);
let text: String = sliced.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "3456");
}
#[test]
fn slice_line_at_zero_width_returns_empty_line() {
let line = Line::from(vec![Span::raw("hello".to_string())]);
let sliced = slice_line_at(&line, 0, 0);
let text: String = sliced.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "");
}
#[test]
fn prev_col_boundary_jumps_to_start_of_current_column() {
let widths = [10usize, 20, 15];
assert_eq!(prev_col_boundary(&widths, 17), 13);
assert_eq!(prev_col_boundary(&widths, 13), 0);
assert_eq!(prev_col_boundary(&widths, 0), 0);
}
#[test]
fn next_col_boundary_jumps_past_current_column() {
let widths = [10usize, 20, 15];
assert_eq!(next_col_boundary(&widths, 0, 200), 13);
assert_eq!(next_col_boundary(&widths, 36, 100), 100);
assert_eq!(next_col_boundary(&widths, 13, 200), 36);
}
#[test]
fn boundary_helpers_handle_empty_widths() {
assert_eq!(prev_col_boundary(&[], 5), 0);
assert_eq!(next_col_boundary(&[], 5, 50), 50);
}
}