use crate::app::{App, TableModalState};
use crate::theme::Palette;
use crate::ui::table_render::{border_line, emit_row_lines, wrap_table_rows};
use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::UnicodeWidthChar;
pub fn draw(f: &mut Frame, app: &mut App) {
let Some(state) = &app.table_modal else {
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: crate::cast::u16_sat(content_height),
};
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 wrapped = wrap_table_rows(&state.headers, &state.rows, col_widths);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(border_line('┌', '─', '┬', '┐', col_widths, border_style));
let header_row = &wrapped[0];
lines.extend(emit_row_lines(
header_row,
col_widths,
&state.alignments,
border_style,
header_style,
));
lines.push(border_line('├', '─', '┼', '┤', col_widths, border_style));
for body_row in &wrapped[1..] {
lines.extend(emit_row_lines(
body_row,
col_widths,
&state.alignments,
border_style,
cell_style,
));
}
lines.push(border_line('└', '─', '┴', '┘', col_widths, border_style));
Text::from(lines)
}
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; crate::cast::u16_sat(total_table_width.saturating_sub(visible_width as usize))
}
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(crate::cast::u16_sat(acc));
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 crate::app::TableModalState;
use crate::markdown::CellSpans;
use crate::theme::{Palette, Theme};
use insta::assert_snapshot;
use pulldown_cmark::Alignment;
use ratatui::style::Color;
fn palette() -> Palette {
Palette::from_theme(Theme::Default)
}
fn plain(s: &str) -> CellSpans {
vec![Span::raw(s.to_string())]
}
fn styled(text: &str, fg: ratatui::style::Color) -> Span<'static> {
Span::styled(text.to_string(), Style::default().fg(fg))
}
fn make_modal_state(headers: &[&str], rows: &[&[&str]], widths: Vec<usize>) -> TableModalState {
TableModalState {
tab_id: crate::ui::tabs::TabId(0),
headers: headers.iter().map(|s| plain(s)).collect(),
rows: rows
.iter()
.map(|row| row.iter().map(|s| plain(s)).collect())
.collect(),
alignments: vec![Alignment::None; headers.len()],
natural_widths: widths,
v_scroll: 0,
h_scroll: 0,
}
}
#[test]
fn slice_line_at_preserves_per_span_styles() {
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);
}
#[test]
fn tbl_modal_5col_natural() {
let long = "description with multiple words that wrap";
let state = make_modal_state(
&["ID", "Name", "Value", "Description", "Status"],
&[
&["1", "Alice", "100", long, "active"],
&["2", "Bob", "200", "short", "inactive"],
],
vec![2, 5, 5, long.len(), 8],
);
let rendered = render_modal_table(&state, &palette());
let snap: String = rendered
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(snap);
}
}