diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 2097b7a..f74925e 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -8630,6 +8630,57 @@ fn render_markdown_lines( base_style: Style, ) -> Vec> { let width = available_width.max(1); + let lines: Vec<&str> = if markdown.is_empty() { + Vec::new() + } else { + markdown.lines().collect() + }; + + if lines.is_empty() { + return render_markdown_text_block(markdown, indent, width, base_style); + } + + let mut output: Vec> = Vec::new(); + let mut buffer: Vec<&str> = Vec::new(); + let mut index = 0usize; + + while index < lines.len() { + if let Some((table, next_index)) = parse_markdown_table(&lines, index) { + if !buffer.is_empty() { + let block = buffer.join("\n"); + output.extend(render_markdown_text_block( + &block, indent, width, base_style, + )); + buffer.clear(); + } + output.extend(render_markdown_table(&table, indent, width, base_style)); + index = next_index; + } else { + buffer.push(lines[index]); + index += 1; + } + } + + if !buffer.is_empty() { + let block = buffer.join("\n"); + output.extend(render_markdown_text_block( + &block, indent, width, base_style, + )); + } + + if output.is_empty() { + output.extend(render_markdown_text_block("", indent, width, base_style)); + } + + output +} + +fn render_markdown_text_block( + markdown: &str, + indent: &str, + width: usize, + base_style: Style, +) -> Vec> { let mut text = from_str(markdown); let mut output: Vec> = Vec::new(); @@ -8652,17 +8703,684 @@ fn render_markdown_lines( } if output.is_empty() { + output.push(blank_line(indent, base_style)); + } + + output +} + +#[derive(Debug)] +struct ParsedTable { + headers: Vec, + rows: Vec>, + alignments: Vec, +} + +#[derive(Clone, Copy, Debug)] +enum TableAlignment { + Left, + Center, + Right, +} + +fn parse_markdown_table(lines: &[&str], start: usize) -> Option<(ParsedTable, usize)> { + if start + 1 >= lines.len() { + return None; + } + + let header_line = lines[start].trim(); + let alignment_line = lines[start + 1].trim(); + + if header_line.is_empty() || !header_line.contains('|') { + return None; + } + + let headers = split_table_row(header_line); + if headers.is_empty() { + return None; + } + + let alignments = parse_alignment_row(alignment_line, headers.len())?; + let mut rows = Vec::new(); + let mut index = start + 2; + + while index < lines.len() { + let raw = lines[index]; + let trimmed = raw.trim(); + + if trimmed.is_empty() || !trimmed.contains('|') { + break; + } + + if trimmed + .chars() + .all(|ch| matches!(ch, '-' | ':' | '|' | ' ')) + { + break; + } + + let mut cells = split_table_row(trimmed); + if cells.iter().all(|cell| cell.is_empty()) { + break; + } + + if cells.len() < headers.len() { + cells.resize(headers.len(), String::new()); + } else if cells.len() > headers.len() { + cells.truncate(headers.len()); + } + + rows.push(cells); + index += 1; + } + + Some(( + ParsedTable { + headers, + rows, + alignments, + }, + index, + )) +} + +fn split_table_row(line: &str) -> Vec { + let trimmed = line.trim(); + if trimmed.is_empty() { + return vec![String::new()]; + } + + let mut chars = trimmed.chars().peekable(); + // Discard a single leading pipe if present. + if matches!(chars.peek(), Some('|')) { + chars.next(); + } + + let mut cells = Vec::new(); + let mut current = String::new(); + let mut escape = false; + + for ch in chars { + if escape { + current.push(ch); + escape = false; + continue; + } + match ch { + '\\' => { + escape = true; + } + '|' => { + cells.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(ch), + } + } + + if escape { + current.push('\\'); + } + + if !trimmed.ends_with('|') || !current.trim().is_empty() { + cells.push(current.trim().to_string()); + } + + if cells.is_empty() { + cells.push(String::new()); + } + + cells +} + +fn parse_alignment_row(line: &str, expected_columns: usize) -> Option> { + let trimmed = line.trim(); + if trimmed.is_empty() || !trimmed.contains('-') { + return None; + } + + let raw_cells = split_table_row(trimmed); + if raw_cells.len() != expected_columns { + return None; + } + + let mut alignments = Vec::with_capacity(expected_columns); + for cell in raw_cells { + let cell_trimmed = cell.trim(); + if cell_trimmed.is_empty() { + alignments.push(TableAlignment::Left); + continue; + } + + if !cell_trimmed.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) { + return None; + } + + if !cell_trimmed.contains('-') { + return None; + } + + let left = cell_trimmed.starts_with(':'); + let right = cell_trimmed.ends_with(':'); + let alignment = match (left, right) { + (true, true) => TableAlignment::Center, + (false, true) => TableAlignment::Right, + _ => TableAlignment::Left, + }; + alignments.push(alignment); + } + + Some(alignments) +} + +fn render_markdown_table( + table: &ParsedTable, + indent: &str, + available_width: usize, + base_style: Style, +) -> Vec> { + const MIN_CELL_WIDTH: usize = 3; + + if table.headers.is_empty() { + return render_markdown_text_block("", indent, available_width.max(1), base_style); + } + + let indent_width = UnicodeWidthStr::width(indent); + let padding_cost = 1 + table.headers.len() * 3; + let available_content = available_width.saturating_sub(indent_width + padding_cost); + + if available_content < table.headers.len().saturating_mul(MIN_CELL_WIDTH) { + return render_markdown_table_stacked(table, indent, available_width, base_style); + } + + let mut desired_widths = vec![MIN_CELL_WIDTH; table.headers.len()]; + for (index, header) in table.headers.iter().enumerate() { + desired_widths[index] = desired_widths[index].max(cell_display_width(header)); + } + for row in &table.rows { + for (index, cell) in row.iter().enumerate().take(desired_widths.len()) { + desired_widths[index] = desired_widths[index].max(cell_display_width(cell)); + } + } + + let constrained = constrain_column_widths(&desired_widths, available_content, MIN_CELL_WIDTH) + .unwrap_or_else(|| vec![MIN_CELL_WIDTH; table.headers.len()]); + + if constrained.iter().sum::() > available_content { + return render_markdown_table_stacked(table, indent, available_width, base_style); + } + + render_markdown_table_grid(table, indent, available_width, base_style, &constrained) +} + +fn render_markdown_table_grid( + table: &ParsedTable, + indent: &str, + available_width: usize, + base_style: Style, + column_widths: &[usize], +) -> Vec> { + let mut output = Vec::new(); + output.extend(render_table_summary_lines( + table, + indent, + available_width, + base_style, + )); + output.push(blank_line(indent, base_style)); + + output.push(build_table_border_line( + '┌', + '┬', + '┐', + column_widths, + indent, + base_style, + )); + + let header_styles = vec![base_style.add_modifier(Modifier::BOLD); table.headers.len()]; + output.extend(render_table_row_lines( + &table.headers, + column_widths, + &table.alignments, + indent, + base_style, + &header_styles, + )); + + if table.rows.is_empty() { + output.push(build_table_border_line( + '└', + '┴', + '┘', + column_widths, + indent, + base_style, + )); + return output; + } + + output.push(build_table_border_line( + '├', + '┼', + '┤', + column_widths, + indent, + base_style, + )); + + let body_styles = vec![base_style; table.headers.len()]; + for (index, row) in table.rows.iter().enumerate() { + output.extend(render_table_row_lines( + row, + column_widths, + &table.alignments, + indent, + base_style, + &body_styles, + )); + + if index == table.rows.len() - 1 { + output.push(build_table_border_line( + '└', + '┴', + '┘', + column_widths, + indent, + base_style, + )); + } else { + output.push(build_table_border_line( + '├', + '┼', + '┤', + column_widths, + indent, + base_style, + )); + } + } + + output +} + +fn render_markdown_table_stacked( + table: &ParsedTable, + indent: &str, + available_width: usize, + base_style: Style, +) -> Vec> { + let mut output = Vec::new(); + output.extend(render_table_summary_lines( + table, + indent, + available_width, + base_style, + )); + + if table.rows.is_empty() { + output.push(blank_line(indent, base_style)); let mut spans = Vec::new(); if !indent.is_empty() { spans.push(Span::styled(indent.to_string(), base_style)); } - spans.push(Span::styled(String::new(), base_style)); + spans.push(Span::styled( + "(No rows)", + base_style.add_modifier(Modifier::ITALIC), + )); + output.push(Line::from(spans)); + return output; + } + + output.push(blank_line(indent, base_style)); + + let indent_width = UnicodeWidthStr::width(indent); + let available = available_width.saturating_sub(indent_width).max(1); + let header_style = base_style.add_modifier(Modifier::BOLD); + + for (row_index, row) in table.rows.iter().enumerate() { + if row_index > 0 { + output.push(blank_line(indent, base_style)); + } + + for (column_index, header) in table.headers.iter().enumerate() { + let value = row.get(column_index).map(String::as_str).unwrap_or(""); + let bullet_prefix = if column_index == 0 { + format!("• {}: ", header) + } else { + format!(" {}: ", header) + }; + let prefix_width = UnicodeWidthStr::width(bullet_prefix.as_str()); + let value_width = available.saturating_sub(prefix_width); + + if value_width == 0 { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + spans.push(Span::styled(bullet_prefix.clone(), header_style)); + output.push(Line::from(spans)); + + let wrapped_values = wrap_table_cell(value, available.max(1)); + for wrapped in wrapped_values { + let mut continuation = Vec::new(); + if !indent.is_empty() { + continuation.push(Span::styled(indent.to_string(), base_style)); + } + continuation.push(Span::styled(" ".repeat(prefix_width), header_style)); + continuation.push(Span::styled(wrapped, base_style)); + output.push(Line::from(continuation)); + } + continue; + } + + let wrapped_values = wrap_table_cell(value, value_width.max(1)); + for (line_index, wrapped) in wrapped_values.into_iter().enumerate() { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + if line_index == 0 { + spans.push(Span::styled(bullet_prefix.clone(), header_style)); + } else { + spans.push(Span::styled(" ".repeat(prefix_width), header_style)); + } + spans.push(Span::styled(wrapped, base_style)); + output.push(Line::from(spans)); + } + } + } + + output +} + +fn render_table_summary_lines( + table: &ParsedTable, + indent: &str, + available_width: usize, + base_style: Style, +) -> Vec> { + let mut output = Vec::new(); + let indent_width = UnicodeWidthStr::width(indent); + let summary_width = available_width.saturating_sub(indent_width).max(1); + + let column_list = if table.headers.is_empty() { + String::from("No columns") + } else { + table.headers.join(", ") + }; + + let row_count = table.rows.len(); + let prefix = if row_count == 0 { + "Table:".to_string() + } else if row_count == 1 { + "Table (1 row):".to_string() + } else { + format!("Table ({} rows):", row_count) + }; + + let prefix_width = UnicodeWidthStr::width(prefix.as_str()); + if summary_width <= prefix_width + 1 { + let mut combined = prefix.clone(); + if !column_list.is_empty() { + combined.push(' '); + combined.push_str(&column_list); + } + let wrapped = { + let mut result = wrap_unicode(combined.as_str(), summary_width); + if result.is_empty() { + result.push(String::new()); + } + result + }; + for (index, text) in wrapped.into_iter().enumerate() { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + let style = if index == 0 { + base_style.add_modifier(Modifier::BOLD) + } else { + base_style + }; + spans.push(Span::styled(text, style)); + output.push(Line::from(spans)); + } + return output; + } + + let rest_width = summary_width.saturating_sub(prefix_width + 1).max(1); + let mut rest_lines = if column_list.is_empty() { + vec![String::new()] + } else { + let mut wrapped = wrap_unicode(column_list.as_str(), rest_width); + if wrapped.is_empty() { + wrapped.push(String::new()); + } + wrapped + }; + + for (index, text) in rest_lines.drain(..).enumerate() { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + if index == 0 { + spans.push(Span::styled( + format!("{prefix} "), + base_style.add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled(text, base_style)); + } else { + spans.push(Span::styled(" ".repeat(prefix_width + 1), base_style)); + spans.push(Span::styled(text, base_style)); + } output.push(Line::from(spans)); } output } +fn build_table_border_line( + left: char, + mid: char, + right: char, + column_widths: &[usize], + indent: &str, + base_style: Style, +) -> Line<'static> { + let mut line = String::new(); + line.push(left); + for (index, width) in column_widths.iter().enumerate() { + line.push_str(&"─".repeat(width + 2)); + if index == column_widths.len() - 1 { + line.push(right); + } else { + line.push(mid); + } + } + + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + spans.push(Span::styled(line, base_style)); + Line::from(spans) +} + +fn render_table_row_lines( + cells: &[String], + column_widths: &[usize], + alignments: &[TableAlignment], + indent: &str, + border_style: Style, + cell_styles: &[Style], +) -> Vec> { + let column_count = column_widths.len(); + let mut column_lines: Vec> = Vec::with_capacity(column_count); + + for (index, width) in column_widths.iter().enumerate() { + let cell = cells.get(index).map(String::as_str).unwrap_or(""); + column_lines.push(wrap_table_cell(cell, (*width).max(1))); + } + + let max_height = column_lines + .iter() + .map(|lines| lines.len()) + .max() + .unwrap_or(1); + + let mut output = Vec::with_capacity(max_height); + for line_index in 0..max_height { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), border_style)); + } + spans.push(Span::styled("│".to_string(), border_style)); + + for (column_index, width) in column_widths.iter().enumerate() { + let content = column_lines + .get(column_index) + .and_then(|lines| lines.get(line_index)) + .map(|s| s.as_str()) + .unwrap_or(""); + let alignment = alignments + .get(column_index) + .copied() + .unwrap_or(TableAlignment::Left); + let aligned = align_cell_line(alignment, content, *width); + let cell_style = cell_styles + .get(column_index) + .copied() + .unwrap_or(border_style); + + spans.push(Span::styled(" ".to_string(), border_style)); + spans.push(Span::styled(aligned, cell_style)); + spans.push(Span::styled(" ".to_string(), border_style)); + spans.push(Span::styled("│".to_string(), border_style)); + } + + output.push(Line::from(spans)); + } + + output +} + +fn align_cell_line(alignment: TableAlignment, content: &str, width: usize) -> String { + let display_width = UnicodeWidthStr::width(content); + if display_width >= width { + return content.to_string(); + } + + let padding = width - display_width; + match alignment { + TableAlignment::Left => format!("{content}{}", " ".repeat(padding)), + TableAlignment::Right => format!("{}{}", " ".repeat(padding), content), + TableAlignment::Center => { + let left = padding / 2; + let right = padding - left; + format!("{}{}{}", " ".repeat(left), content, " ".repeat(right)) + } + } +} + +fn wrap_table_cell(content: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + + let mut lines = Vec::new(); + for segment in content.split('\n') { + let trimmed = segment.trim_end(); + if trimmed.is_empty() { + lines.push(String::new()); + continue; + } + + let options = Options::new(width) + .word_separator(WordSeparator::UnicodeBreakProperties) + .break_words(true); + let wrapped = wrap(trimmed, options); + + if wrapped.is_empty() { + lines.push(String::new()); + } else { + lines.extend(wrapped.into_iter().map(|line| line.into_owned())); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} + +fn constrain_column_widths( + desired: &[usize], + available: usize, + min_width: usize, +) -> Option> { + if desired.is_empty() { + return Some(Vec::new()); + } + if available < min_width.saturating_mul(desired.len()) { + return None; + } + + let mut widths: Vec = desired + .iter() + .map(|value| (*value).max(min_width)) + .collect(); + let mut total: usize = widths.iter().sum(); + + if total <= available { + return Some(widths); + } + + while total > available { + let mut changed = false; + for value in &mut widths { + if total <= available { + break; + } + if *value > min_width { + *value -= 1; + total -= 1; + changed = true; + } + } + + if !changed { + break; + } + } + + if total > available { + None + } else { + Some(widths) + } +} + +fn cell_display_width(value: &str) -> usize { + value + .split('\n') + .map(|segment| UnicodeWidthStr::width(segment.trim_end())) + .max() + .unwrap_or(0) + .max(1) +} + +fn blank_line(indent: &str, base_style: Style) -> Line<'static> { + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled(indent.to_string(), base_style)); + } + spans.push(Span::styled(String::new(), base_style)); + Line::from(spans) +} + fn wrap_markdown_spans( spans: Vec>, indent: &str, @@ -9112,7 +9830,63 @@ pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec { #[cfg(test)] mod tests { - use super::wrap_unicode; + use super::{render_markdown_lines, wrap_unicode}; + use ratatui::style::Style; + use ratatui::text::Line; + + fn lines_to_strings(lines: &[Line<'_>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() + } + + #[test] + fn render_markdown_table_draws_grid_when_width_allows() { + let lines = render_markdown_lines( + "| Name | Role |\n| --- | --- |\n| Alice | Developer |\n", + "", + 60, + Style::default(), + ); + let rendered = lines_to_strings(&lines); + assert!( + rendered.iter().any(|line| line.contains("Table (1 row):")), + "summary line should mention row count" + ); + assert!( + rendered.iter().any(|line| line.contains('┌')), + "grid border should be present when width permits" + ); + assert!( + rendered.iter().any(|line| line.contains("Alice")), + "table rows should include cell content" + ); + } + + #[test] + fn render_markdown_table_falls_back_when_narrow() { + let lines = render_markdown_lines( + "| Name | Role |\n| --- | --- |\n| Alice | Developer |\n", + "", + 12, + Style::default(), + ); + let rendered = lines_to_strings(&lines); + assert!( + rendered.iter().any(|line| line.contains("Name:")), + "stacked fallback should label headers inline" + ); + assert!( + rendered.iter().all(|line| !line.contains('┌')), + "narrow layout should avoid grid borders" + ); + } #[test] fn wrap_unicode_respects_cjk_display_width() {