From bf6636970d373e40c54ce0e6070382dcfb7d8c76 Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:23:44 +0100 Subject: [PATCH 1/8] fix: improve quote and bullet rendering --- src/markdown/highlight.rs | 133 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 7 deletions(-) diff --git a/src/markdown/highlight.rs b/src/markdown/highlight.rs index 6ec8c65..e362388 100644 --- a/src/markdown/highlight.rs +++ b/src/markdown/highlight.rs @@ -61,12 +61,15 @@ pub fn render_markdown_line_with_completion( ]); } - if allow_block_element && let Some(quote_text) = trimmed.strip_prefix("> ") { - let mut spans = vec![ - Span::raw(leading.to_string()), - Span::styled("│ ".to_string(), theme.quote), - ]; - spans.extend(conceal_inline(quote_text, theme, theme.quote)); + if allow_block_element && let Some(quote) = quote_prefix(trimmed) { + let mut spans = vec![Span::raw(leading.to_string())]; + spans.extend(render_quote_segment( + trimmed, + quote, + 0, + trimmed.chars().count(), + theme, + )); return Line::from(spans); } @@ -111,7 +114,7 @@ pub fn render_markdown_line_with_completion( let marker_len = trimmed.len() - item_text.len(); let mut spans = vec![ Span::raw(leading.to_string()), - Span::styled("• ".to_string(), theme.list_marker), + Span::styled(bullet_marker(leading_width).to_string(), theme.list_marker), ]; spans.extend(conceal_inline( &trimmed[marker_len..], @@ -147,6 +150,16 @@ pub fn render_markdown_segment_with_completion( return highlight_source_segment(source, segment_start, segment_end, theme, wrap_index); } + if let Some(quote) = quote_prefix(source) { + return Line::from(render_quote_segment( + source, + quote, + segment_start, + segment_end, + theme, + )); + } + if has_split_concealed_inline(source, segment_start, segment_end) { let spans = render_concealed_segment(source, segment_start, segment_end, theme, completed); return Line::from(spans); @@ -340,6 +353,21 @@ pub fn concealed_wrap_segments(source: &str, width: usize) -> Vec<(usize, usize) } pub fn concealed_wrap_line(text: &str, width: usize) -> (Vec<(usize, usize)>, usize) { + if let Some(quote) = quote_prefix(text) { + let marker_width = quote.visual_marker.chars().count(); + let content_width = width.saturating_sub(marker_width).max(1); + let content = &text[quote.source_len..]; + if content.is_empty() { + return (vec![(0, text.chars().count())], 0); + } + + let segments = concealed_wrap_segments(content, content_width) + .into_iter() + .map(|(start, end)| (quote.source_len + start, quote.source_len + end)) + .collect(); + return (segments, 0); + } + let marker_len = detect_list_marker(text); if marker_len == 0 || marker_len >= width { @@ -650,6 +678,54 @@ fn numbered_list_prefix(trimmed: &str) -> Option<(&str, &str)> { } } +#[derive(Debug, Clone)] +struct QuotePrefix { + source_len: usize, + visual_marker: String, +} + +fn quote_prefix(source: &str) -> Option { + let chars = source.chars().collect::>(); + let mut index = 0; + let mut depth = 0; + + while chars.get(index) == Some(&'>') { + depth += 1; + index += 1; + if chars.get(index) == Some(&' ') { + index += 1; + } + } + + (depth > 0).then(|| QuotePrefix { + source_len: index, + visual_marker: "│ ".repeat(depth), + }) +} + +fn render_quote_segment( + source: &str, + quote: QuotePrefix, + segment_start: usize, + segment_end: usize, + theme: Theme, +) -> Vec> { + let source_len = source.chars().count(); + let content_start = segment_start.max(quote.source_len).min(source_len); + let content_end = segment_end.min(source_len).max(content_start); + let mut spans = vec![Span::styled(quote.visual_marker, theme.quote)]; + if content_start < content_end { + let content = slice_chars(source, content_start, content_end); + spans.extend(conceal_inline(&content, theme, theme.quote)); + } + spans +} + +fn bullet_marker(leading_width: usize) -> &'static str { + let level = leading_width / 2; + if level % 2 == 0 { "• " } else { "◦ " } +} + fn list_item_text(trimmed: &str) -> Option<&str> { trimmed .strip_prefix("- ") @@ -1109,6 +1185,36 @@ mod tests { ); } + #[test] + fn wrapped_quote_segments_keep_quote_marker() { + let source = + "> A longer quote should wrap without dropping the quote marker on continuation rows"; + let (segments, _) = concealed_wrap_line(source, 28); + assert!(segments.len() > 1); + + for (index, (start, end)) in segments.into_iter().enumerate() { + let line = render_markdown_segment_with_completion( + source, + start, + end, + Theme::monochrome_for_tests(), + false, + index, + false, + ); + + assert!(line_text(&line).starts_with("│ ")); + assert_eq!(line.spans[0].style, Theme::monochrome_for_tests().quote); + } + } + + #[test] + fn nested_blockquote_renders_nested_markers() { + let line = render_markdown_line("> > Inner quote", Theme::monochrome_for_tests(), false, 0); + + assert_eq!(line_text(&line), "│ │ Inner quote"); + } + #[test] fn inactive_checkbox_renders_full_marker() { let line = render_markdown_line("- [ ] todo", Theme::monochrome_for_tests(), false, 0); @@ -1198,6 +1304,19 @@ mod tests { assert_eq!(line_text(&line), "10. tenth item"); } + #[test] + fn nested_bullets_alternate_marker_shape() { + let top = render_markdown_line("- top", Theme::monochrome_for_tests(), false, 0); + let nested = render_markdown_line(" - nested", Theme::monochrome_for_tests(), false, 0); + + assert_eq!(line_text(&top), "• top"); + assert_eq!(line_text(&nested), " ◦ nested"); + assert_eq!( + nested.spans[1].style, + Theme::monochrome_for_tests().list_marker + ); + } + #[test] fn wrapped_markdown_link_first_segment_keeps_link_style() { let source = From 35851b12b6e8dcd2ba9046187ed397d7bdf4eb62 Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:09:11 +0100 Subject: [PATCH 2/8] feat: render markdown tables --- ISSUES.md | 2 +- src/app.rs | 11 +- src/markdown/mod.rs | 1 + src/markdown/table.rs | 505 ++++++++++++++++++++++++++++++++++++++++++ src/ui/editor.rs | 103 +++++++-- 5 files changed, 607 insertions(+), 15 deletions(-) create mode 100644 src/markdown/table.rs diff --git a/ISSUES.md b/ISSUES.md index 7c52514..aed206e 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -5,7 +5,7 @@ Bug & feature log for glass. ## Rendering - [ ] **Fenced code blocks**: highlight the language identifier (e.g., `rust` in ` ```rust `). -- [ ] **Tables**: render Markdown tables. +- [x] **Tables**: render Markdown tables. - [ ] **Strikethrough**: render `~~text~~`, including inside list items. - [ ] **URL display**: strip unnecessary parts (e.g., `https://`) from bare URLs that lack pretty titles. - [ ] **Link expansion**: only expand URLs to their real Markdown form on hover, not whenever their line is active in Normal mode. diff --git a/src/app.rs b/src/app.rs index 32a9e60..ca3f4a7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,8 +22,11 @@ use crate::{ render::{visible_rows, visual_line_bounds, wrap_index_for_column, wrap_line}, }, fs::tree::FileTree, - markdown::highlight::concealed_wrap_line, markdown::inline::{LinkKind, link_at_column}, + markdown::{ + highlight::concealed_wrap_line, + table::{TableLayout, table_wrap_line}, + }, }; const STATUS_MESSAGE_TTL: Duration = Duration::from_secs(3); @@ -1224,6 +1227,7 @@ impl App { let gutter = (self.buffer.line_count().to_string().len() + 1) as usize; let text_x = local_x.saturating_sub(gutter); let width = self.wrap_width(); + let table_layout = TableLayout::new(&self.buffer); let rows = visible_rows( &self.buffer, self.viewport.top_line, @@ -1233,6 +1237,8 @@ impl App { |line_num, text, width| { if line_num == self.cursor.line { wrap_line(text, width) + } else if table_layout.is_table_row(line_num) { + table_wrap_line(text, width) } else { concealed_wrap_line(text, width) } @@ -1847,8 +1853,11 @@ impl App { fn line_wrap_count(&self, line: usize, width: usize) -> usize { let line_text = self.buffer.line(line); let trimmed = line_text.trim_end_matches(['\r', '\n']); + let table_layout = TableLayout::new(&self.buffer); let (segments, _) = if line == self.cursor.line { wrap_line(trimmed, width) + } else if table_layout.is_table_row(line) { + table_wrap_line(trimmed, width) } else { concealed_wrap_line(trimmed, width) }; diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index 674ab47..1c9b25d 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -3,3 +3,4 @@ pub mod highlight; pub mod inline; pub mod mapping; pub mod parse; +pub mod table; diff --git a/src/markdown/table.rs b/src/markdown/table.rs new file mode 100644 index 0000000..9848894 --- /dev/null +++ b/src/markdown/table.rs @@ -0,0 +1,505 @@ +use ratatui::{ + style::{Modifier, Style}, + text::{Line, Span}, +}; + +use crate::{config::theme::Theme, editor::buffer::DocumentBuffer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableAlignment { + Left, + Center, + Right, +} + +impl Default for TableAlignment { + fn default() -> Self { + Self::Left + } +} + +#[derive(Debug, Clone)] +pub struct TableCell { + pub text: String, + pub source_indices: Vec, +} + +#[derive(Debug, Clone)] +pub struct TableBlock { + pub start_line: usize, + pub delimiter_line: usize, + pub end_line: usize, + pub alignments: Vec, + pub widths: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct TableLayout { + blocks: Vec, +} + +#[derive(Debug, Clone)] +pub struct RenderedTableLine { + pub line: Line<'static>, + pub source_map: Vec>, +} + +impl TableLayout { + pub fn new(buffer: &DocumentBuffer) -> Self { + let mut blocks = Vec::new(); + let mut line = 0; + + while line + 1 < buffer.line_count() { + let header = trimmed_line(buffer, line); + let delimiter = trimmed_line(buffer, line + 1); + + let header_cells = parse_table_cells(&header); + let Some(delimiter_alignments) = parse_delimiter_row(&delimiter) else { + line += 1; + continue; + }; + + if header_cells.len() < 2 || delimiter_alignments.len() < 2 { + line += 1; + continue; + } + + let start_line = line; + let delimiter_line = line + 1; + let mut end_line = delimiter_line + 1; + while end_line < buffer.line_count() { + let row = trimmed_line(buffer, end_line); + if parse_table_cells(&row).len() < 2 { + break; + } + end_line += 1; + } + + let column_count = column_count(buffer, start_line, end_line) + .max(header_cells.len()) + .max(delimiter_alignments.len()); + let mut alignments = delimiter_alignments; + alignments.resize(column_count, TableAlignment::Left); + let widths = column_widths(buffer, start_line, delimiter_line, end_line, column_count); + + blocks.push(TableBlock { + start_line, + delimiter_line, + end_line, + alignments, + widths, + }); + line = end_line; + } + + Self { blocks } + } + + pub fn block_for_line(&self, line: usize) -> Option<&TableBlock> { + self.blocks + .iter() + .find(|block| line >= block.start_line && line < block.end_line) + } + + pub fn is_table_row(&self, line: usize) -> bool { + self.block_for_line(line).is_some() + } + + pub fn render_row( + &self, + line_number: usize, + source: &str, + available_width: usize, + theme: Theme, + ) -> Option { + let block = self.block_for_line(line_number)?; + let widths = block.fitted_widths(available_width); + let is_delimiter = line_number == block.delimiter_line; + let is_header = line_number == block.start_line; + let cells = parse_table_cells(source.trim_end_matches(['\r', '\n'])); + let mut spans = Vec::new(); + let mut source_map = Vec::new(); + + for column in 0..block.column_count() { + if column > 0 { + append_span( + &mut spans, + &mut source_map, + " │ ".to_string(), + Style::default().fg(theme.muted), + std::iter::repeat_n(None, 3), + ); + } + + if is_delimiter { + let separator = format!(" {} ", "─".repeat(widths[column])); + append_span( + &mut spans, + &mut source_map, + separator.clone(), + Style::default().fg(theme.muted), + std::iter::repeat_n(None, separator.chars().count()), + ); + continue; + } + + let alignment = block.alignments[column]; + let style = if is_header { + theme.heading.add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + let empty_cell = TableCell { + text: String::new(), + source_indices: Vec::new(), + }; + let cell = cells.get(column).unwrap_or(&empty_cell); + let fitted = fit_cell(cell, widths[column], alignment); + + append_span( + &mut spans, + &mut source_map, + " ".to_string(), + style, + std::iter::once(None), + ); + append_span( + &mut spans, + &mut source_map, + fitted.text, + style, + fitted.source_map.into_iter(), + ); + append_span( + &mut spans, + &mut source_map, + " ".to_string(), + style, + std::iter::once(None), + ); + } + + Some(RenderedTableLine { + line: Line::from(spans), + source_map, + }) + } +} + +impl TableBlock { + fn column_count(&self) -> usize { + self.widths.len() + } + + fn fitted_widths(&self, available_width: usize) -> Vec { + let column_count = self.column_count(); + if column_count == 0 { + return Vec::new(); + } + + let mut widths = self.widths.clone(); + let fixed_width = column_count * 2 + column_count.saturating_sub(1) * 3; + + while fixed_width + widths.iter().sum::() > available_width + && widths.iter().any(|width| *width > 1) + { + if let Some((index, _)) = widths.iter().enumerate().max_by_key(|(_, width)| **width) { + widths[index] = widths[index].saturating_sub(1).max(1); + } + } + + widths + } +} + +#[derive(Debug, Clone)] +struct FittedCell { + text: String, + source_map: Vec>, +} + +pub fn table_wrap_line(text: &str, _width: usize) -> (Vec<(usize, usize)>, usize) { + (vec![(0, text.chars().count())], 0) +} + +fn column_count(buffer: &DocumentBuffer, start: usize, end: usize) -> usize { + (start..end) + .map(|line| parse_table_cells(&trimmed_line(buffer, line)).len()) + .max() + .unwrap_or_default() +} + +fn column_widths( + buffer: &DocumentBuffer, + start: usize, + delimiter: usize, + end: usize, + column_count: usize, +) -> Vec { + let mut widths = vec![3; column_count]; + for line in start..end { + if line == delimiter { + continue; + } + + for (index, cell) in parse_table_cells(&trimmed_line(buffer, line)) + .into_iter() + .enumerate() + .take(column_count) + { + widths[index] = widths[index].max(cell.text.chars().count()); + } + } + widths +} + +fn trimmed_line(buffer: &DocumentBuffer, line: usize) -> String { + buffer.line(line).trim_end_matches(['\r', '\n']).to_string() +} + +fn parse_delimiter_row(source: &str) -> Option> { + let cells = parse_table_cells(source); + if cells.is_empty() { + return None; + } + + cells + .iter() + .map(|cell| parse_delimiter_cell(&cell.text)) + .collect() +} + +fn parse_delimiter_cell(source: &str) -> Option { + let value = source.trim(); + let left = value.starts_with(':'); + let right = value.ends_with(':'); + let dashes = value.trim_matches(':'); + + if dashes.len() < 3 || !dashes.chars().all(|ch| ch == '-') { + return None; + } + + Some(match (left, right) { + (true, true) => TableAlignment::Center, + (false, true) => TableAlignment::Right, + _ => TableAlignment::Left, + }) +} + +fn parse_table_cells(source: &str) -> Vec { + let chars = source.chars().collect::>(); + if chars.is_empty() { + return Vec::new(); + } + + let pipe_indices = chars + .iter() + .enumerate() + .filter_map(|(index, ch)| (*ch == '|' && !is_escaped(&chars, index)).then_some(index)) + .collect::>(); + if pipe_indices.is_empty() { + return Vec::new(); + } + + let mut ranges = Vec::new(); + let mut start = 0; + for pipe in pipe_indices { + ranges.push((start, pipe)); + start = pipe + 1; + } + ranges.push((start, chars.len())); + + if ranges + .first() + .is_some_and(|(start, end)| chars[*start..*end].iter().all(|ch| ch.is_whitespace())) + { + ranges.remove(0); + } + if ranges + .last() + .is_some_and(|(start, end)| chars[*start..*end].iter().all(|ch| ch.is_whitespace())) + { + ranges.pop(); + } + + ranges + .into_iter() + .map(|(start, end)| parse_cell(&chars, start, end)) + .collect() +} + +fn parse_cell(chars: &[char], mut start: usize, mut end: usize) -> TableCell { + while start < end && chars[start].is_whitespace() { + start += 1; + } + while end > start && chars[end - 1].is_whitespace() { + end -= 1; + } + + let mut text = String::new(); + let mut source_indices = Vec::new(); + let mut index = start; + while index < end { + if chars[index] == '\\' && index + 1 < end && chars[index + 1] == '|' { + text.push('|'); + source_indices.push(index + 1); + index += 2; + continue; + } + + text.push(chars[index]); + source_indices.push(index); + index += 1; + } + + TableCell { + text, + source_indices, + } +} + +fn is_escaped(chars: &[char], index: usize) -> bool { + let mut backslashes = 0; + let mut cursor = index; + while cursor > 0 { + cursor -= 1; + if chars[cursor] != '\\' { + break; + } + backslashes += 1; + } + backslashes % 2 == 1 +} + +fn fit_cell(cell: &TableCell, width: usize, alignment: TableAlignment) -> FittedCell { + let mut chars = cell.text.chars().collect::>(); + let mut source_map = cell + .source_indices + .iter() + .copied() + .map(Some) + .collect::>(); + + if chars.len() > width { + if width == 1 { + chars = vec!['…']; + source_map = vec![None]; + } else { + chars.truncate(width - 1); + source_map.truncate(width - 1); + chars.push('…'); + source_map.push(None); + } + } + + let content_width = chars.len(); + let padding = width.saturating_sub(content_width); + let (left_padding, right_padding) = match alignment { + TableAlignment::Left => (0, padding), + TableAlignment::Right => (padding, 0), + TableAlignment::Center => (padding / 2, padding - padding / 2), + }; + + let mut text = String::new(); + let mut fitted_map = Vec::new(); + text.push_str(&" ".repeat(left_padding)); + fitted_map.extend(std::iter::repeat_n(None, left_padding)); + text.extend(chars); + fitted_map.extend(source_map); + text.push_str(&" ".repeat(right_padding)); + fitted_map.extend(std::iter::repeat_n(None, right_padding)); + + FittedCell { + text, + source_map: fitted_map, + } +} + +fn append_span( + spans: &mut Vec>, + source_map: &mut Vec>, + text: String, + style: Style, + map: impl IntoIterator>, +) { + source_map.extend(map); + spans.push(Span::styled(text, style)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::editor::{buffer::DocumentBuffer, cursor::Cursor}; + + fn buffer_with(source: &str) -> DocumentBuffer { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, source); + buffer + } + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn detects_table_blocks_and_alignment() { + let buffer = + buffer_with("| Name | Count | Note |\n| :--- | ---: | :---: |\n| Ada | 12 | ok |\n"); + let layout = TableLayout::new(&buffer); + let block = layout.block_for_line(0).expect("table block"); + + assert_eq!(block.start_line, 0); + assert_eq!(block.end_line, 3); + assert_eq!( + block.alignments, + vec![ + TableAlignment::Left, + TableAlignment::Right, + TableAlignment::Center + ] + ); + } + + #[test] + fn escaped_pipes_stay_inside_cells() { + let cells = parse_table_cells(r"| Name | A \| B |"); + + assert_eq!(cells.len(), 2); + assert_eq!(cells[1].text, "A | B"); + assert_eq!(cells[1].source_indices, vec![9, 10, 12, 13, 14]); + } + + #[test] + fn renders_inactive_rows_as_aligned_table() { + let buffer = buffer_with("| Name | Count |\n| --- | ---: |\n| Ada | 12 |\n"); + let layout = TableLayout::new(&buffer); + let rendered = layout + .render_row(2, "| Ada | 12 |", 80, Theme::monochrome_for_tests()) + .expect("rendered table row"); + + assert_eq!(line_text(&rendered.line), " Ada │ 12 "); + assert_eq!(rendered.source_map[1], Some(2)); + assert_eq!(rendered.source_map[13], Some(8)); + } + + #[test] + fn narrows_wide_columns_with_ellipsis() { + let buffer = + buffer_with("| Name | Description |\n| --- | --- |\n| Ada | long description |\n"); + let layout = TableLayout::new(&buffer); + let rendered = layout + .render_row( + 2, + "| Ada | long description |", + 18, + Theme::monochrome_for_tests(), + ) + .expect("rendered table row"); + + assert_eq!(line_text(&rendered.line), " Ada │ long d… "); + } +} diff --git a/src/ui/editor.rs b/src/ui/editor.rs index d611cf3..d61fd6d 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -12,7 +12,10 @@ use crate::{ editor::render::{ column_in_wrap_segment, detect_list_marker, visible_rows, wrap_index_for_column, wrap_line, }, - markdown::highlight::{concealed_wrap_line, render_markdown_segment_with_completion}, + markdown::{ + highlight::{concealed_wrap_line, render_markdown_segment_with_completion}, + table::{TableLayout, table_wrap_line}, + }, }; const ARTICLE_WIDTH: u16 = 82; @@ -32,6 +35,7 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { let line_count = app.buffer.line_count(); let gutter_width: u16 = (line_count.to_string().len() + 1) as u16; let text_width = page.width.saturating_sub(gutter_width).max(1) as usize; + let table_layout = TableLayout::new(&app.buffer); frame.render_widget( ratatui::widgets::Clear, @@ -51,6 +55,8 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { |line_num, text, w| { if line_num == app.cursor.line { wrap_line(text, w) + } else if table_layout.is_table_row(line_num) { + table_wrap_line(text, w) } else { concealed_wrap_line(text, w) } @@ -75,34 +81,52 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { let is_cursor_row = row.line_number == app.cursor.line && row.wrap_index == wrap_index_of_cursor; let active = row.line_number == app.cursor.line; - - let mut line = render_markdown_segment_with_completion( - &row.full_text, - row.source_start, - row.source_end, - theme, - active, - row.wrap_index, - row.completed && row.wrap_index > 0, - ); + let table_row = (!active && row.wrap_index == 0) + .then(|| { + table_layout.render_row(row.line_number, &row.full_text, text_width, theme) + }) + .flatten(); + + let (mut line, source_map) = if let Some(rendered) = table_row { + (rendered.line, Some(rendered.source_map)) + } else { + ( + render_markdown_segment_with_completion( + &row.full_text, + row.source_start, + row.source_end, + theme, + active, + row.wrap_index, + row.completed && row.wrap_index > 0, + ), + None, + ) + }; if !app.search.query.is_empty() { - let ranges = search_ranges_for_row( + let mut ranges = search_ranges_for_row( &app.search.matches, row.line_number, row.source_start, row.source_end, ); + if let Some(source_map) = &source_map { + ranges = source_ranges_to_visual_ranges(source_map, row.source_start, &ranges); + } line = highlight_search_ranges(line, &ranges, theme); } if let Some(selection) = app.text_selection { - let ranges = selection_ranges_for_row( + let mut ranges = selection_ranges_for_row( selection, row.line_number, row.source_start, row.source_end, ); + if let Some(source_map) = &source_map { + ranges = source_ranges_to_visual_ranges(source_map, row.source_start, &ranges); + } line = highlight_search_ranges(line, &ranges, theme); } @@ -294,6 +318,40 @@ fn selection_ranges_for_row( } } +fn source_ranges_to_visual_ranges( + source_map: &[Option], + source_start: usize, + source_ranges: &[(usize, usize)], +) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut active_start = None; + + for (visual_index, source_index) in source_map.iter().copied().enumerate() { + let selected = source_index + .and_then(|source_index| source_index.checked_sub(source_start)) + .is_some_and(|local_source| { + source_ranges + .iter() + .any(|(start, end)| local_source >= *start && local_source < *end) + }); + + match (active_start, selected) { + (None, true) => active_start = Some(visual_index), + (Some(start), false) => { + ranges.push((start, visual_index)); + active_start = None; + } + _ => {} + } + } + + if let Some(start) = active_start { + ranges.push((start, source_map.len())); + } + + ranges +} + fn merged_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> { let mut ranges = ranges .iter() @@ -372,4 +430,23 @@ mod tests { assert_eq!(selection_ranges_for_row(selection, 0, 0, 6), vec![(2, 6)]); assert_eq!(selection_ranges_for_row(selection, 0, 6, 12), vec![(0, 3)]); } + + #[test] + fn source_ranges_map_to_table_visual_ranges() { + let source_map = vec![ + None, + Some(2), + Some(3), + None, + None, + Some(8), + Some(9), + Some(10), + ]; + + assert_eq!( + source_ranges_to_visual_ranges(&source_map, 0, &[(2, 4), (9, 11)]), + vec![(1, 3), (6, 8)] + ); + } } From 9e9f8c5ff6778f4f4e11ef651cb746f938d6979b Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:13:48 +0100 Subject: [PATCH 3/8] docs: add markdown benchmark --- benchmark.md | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 benchmark.md diff --git a/benchmark.md b/benchmark.md new file mode 100644 index 0000000..fe7fac3 --- /dev/null +++ b/benchmark.md @@ -0,0 +1,273 @@ +# Glass Markdown Benchmark + +This file is a visual and interaction benchmark for Glass. It intentionally mixes +Markdown that Glass supports today with Markdown that should stay readable even +before dedicated rendering support exists. + +Use it to check: + +- inactive-line Markdown concealment +- active-line raw Markdown editing +- wrapped-line cursor movement +- search highlighting with `/`, `n`, and `N` +- mouse click navigation and drag selection +- link navigation with `gf`, Enter, and Command-click +- table rendering added on the `table-rendering` branch + +## Headings + +# Heading level 1 +## Heading level 2 +### Heading level 3 +#### Heading level 4 currently falls back to plain Markdown + +Indented headings should stay plain: + + # This is indented, so it should not render as a heading + +## Paragraphs And Wrapping + +This paragraph is intentionally long so it wraps across several visual rows in a +normal terminal width. It includes plain words, punctuation, and a useful search +target: glass benchmark needle. Cursor movement should preserve the intended +visual column when moving through this wrapped paragraph, and selection should +copy the selected text immediately. + +This query is split across a physical line break for search testing: +multi +line needle + +## Inline Formatting + +Plain text with *emphasis*, _alternate emphasis_, **strong text**, and +`inline code`. Glass conceals some inline syntax on inactive rows while keeping +the active row editable as source Markdown. + +Wrapped inline formatting should not leak delimiters between visual rows: +This sentence has **bold text that keeps going for a while so the wrapped row +still looks clean** and then returns to normal text. + +Known gap: ~~strikethrough is not rendered yet~~. + +## Links + +Markdown links: + +- [Glass repository](https://github.com/pacificcodeinc/glass) +- [Relative note](README.md) +- [Nested path](docs/example.md) + +Bare URLs: + +- https://example.com +- https://example.com/wiki/Foo_(bar) +- https://example.com/some/really/long/path/that/should/wrap/without/leaking/url/fragments?query=glass + +Autolink: + + + +Wiki links: + +- [[README]] +- [[ISSUES.md]] +- [[Projects/Glass Benchmark]] + +Known gap: wiki links are navigable, but their visual treatment is still not as +distinct as it should be. + +## Blockquotes + +> A simple quote should render with a quiet quote marker. + +> A longer quote should wrap without turning into noisy syntax. It should keep +> the quote style across wrapped rows and still feel like a calm reading surface. + +Nested blockquote benchmark: + +> Outer quote +> > Inner quote currently falls back toward plain Markdown behavior + +## Lists + +Bullets: + +- First bullet item +- Second bullet item with `inline code` +- Third bullet item with [a link](README.md) + - Nested bullet item + - Another nested bullet item that wraps for a while so continuation indentation + can be checked visually + +Alternate bullet markers: + +* Star bullet ++ Plus bullet + +Numbered lists: + +1. First numbered item +2. Second numbered item +10. Multi-digit marker should align cleanly + +Task lists: + +- [ ] Unchecked task +- [x] Checked task +- [ ] Task with [a relative link](ISSUES.md) +- [x] Completed task with `inline code` + +Marker-only task row: + +- [ ] + +## Tables + +Basic table: + +| Name | Role | Status | +| --- | --- | --- | +| Ada | Editor core | Done | +| Linus | Terminal polish | In progress | +| Grace | Rendering | Planned | + +Aligned table: + +| Item | Count | Ratio | Notes | +| :--- | ---: | :---: | --- | +| Tables | 1 | 100% | Newly rendered | +| Links | 3 | 75% | Markdown, bare URL, wiki | +| Motions | 42 | 80% | More Vim parity needed | + +Narrow-width pressure table: + +| Column | Long content | Number | +| --- | --- | ---: | +| Alpha | This cell is intentionally long enough to force fitting or truncation in a narrow terminal | 1200 | +| Beta | Short value | 7 | + +Escaped pipe table: + +| Pattern | Meaning | +| --- | --- | +| `A \| B` | Escaped pipe should stay inside the cell | +| `x \| y \| z` | Multiple escaped pipes | + +Markdown inside table cells: + +| Cell type | Example | +| --- | --- | +| Inline code | `cargo test --locked` | +| Link | [README](README.md) | +| Emphasis | **bold** and *italic* | + +Known gap: inline Markdown inside inactive table cells is aligned, but not yet +fully concealed or styled per cell. + +## Code + +Inline command: `cargo test --locked` + +Fenced Rust code: + +```rust +fn main() { + let message = "glass benchmark"; + println!("{message}"); +} +``` + +Fenced shell code: + +```bash +cargo fmt --all -- --check +cargo test --locked +cargo build --release --locked +``` + +Known gap: fenced code blocks render as code fences, but the language marker is +not specially highlighted yet. + +## Rules And Separators + +Horizontal rules should remain readable, even if they are not custom-rendered: + +--- + +*** + +___ + +## Images And HTML + +Image syntax: + +![Alt text for a local image](assets/example.png) + +Inline HTML: + +Esc exits insert mode. + +
+HTML details summary + +This is HTML content that should remain readable as source. + +
+ +Known gap: images and raw HTML are not rendered as rich elements. + +## Footnotes And References + +Footnote reference[^one] and another reference[^two]. + +[^one]: Footnote definitions are not specially rendered yet. +[^two]: This is here to make sure the source stays readable. + +Reference link: + +[reference-style link][glass] + +[glass]: https://github.com/pacificcodeinc/glass + +## Definition Lists + +Glass +: A terminal Markdown editor focused on feel. + +Benchmark +: A file that catches visual and interaction regressions. + +Known gap: definition lists are not custom-rendered yet. + +## Command And Search Words + +Use these repeated words to test search result counts: + +needle alpha +needle beta +needle gamma + +Try these command-ish strings without accidentally executing them while editing: + +:w +:q +:e benchmark.md +/needle + +## Mixed Stress Section + +> Quote with [a link](README.md), `inline code`, and **strong text** inside it. + +- [ ] A task with a bare URL https://example.com/todo +- [x] A completed task with a wiki link [[ISSUES.md]] + +| Mixed | Example | Result | +| --- | --- | --- | +| Link | [README](README.md) | should align | +| Code | `glass benchmark.md` | should align | +| Text | long plain text that needs fitting in smaller windows | should not break the table | + +Final long wrapped line with many constructs: **bold words**, `inline code`, +[a link](README.md), a bare URL https://example.com/final-check, and enough +plain text to wrap several times in a narrow viewport. From 31789c67000eaa4dc1ed5181394276b872e601cb Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:25:55 +0100 Subject: [PATCH 4/8] fix: box table rendering benchmark --- benchmark.md | 36 ++++++++-------------------- src/markdown/table.rs | 56 ++++++++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/benchmark.md b/benchmark.md index fe7fac3..1f7b60f 100644 --- a/benchmark.md +++ b/benchmark.md @@ -1,8 +1,6 @@ # Glass Markdown Benchmark -This file is a visual and interaction benchmark for Glass. It intentionally mixes -Markdown that Glass supports today with Markdown that should stay readable even -before dedicated rendering support exists. +This file is a visual and interaction benchmark for Glass. It intentionally mixes Markdown that Glass supports today with Markdown that should stay readable even before dedicated rendering support exists. Use it to check: @@ -27,11 +25,7 @@ Indented headings should stay plain: ## Paragraphs And Wrapping -This paragraph is intentionally long so it wraps across several visual rows in a -normal terminal width. It includes plain words, punctuation, and a useful search -target: glass benchmark needle. Cursor movement should preserve the intended -visual column when moving through this wrapped paragraph, and selection should -copy the selected text immediately. +This paragraph is intentionally long so it wraps across several visual rows in a normal terminal width. It includes plain words, punctuation, and a useful search target: glass benchmark needle. Cursor movement should preserve the intended visual column when moving through this wrapped paragraph, and selection should copy the selected text immediately. This query is split across a physical line break for search testing: multi @@ -39,13 +33,10 @@ line needle ## Inline Formatting -Plain text with *emphasis*, _alternate emphasis_, **strong text**, and -`inline code`. Glass conceals some inline syntax on inactive rows while keeping -the active row editable as source Markdown. +Plain text with *emphasis*, _alternate emphasis_, **strong text**, and `inline code`. Glass conceals some inline syntax on inactive rows while keeping the active row editable as source Markdown. Wrapped inline formatting should not leak delimiters between visual rows: -This sentence has **bold text that keeps going for a while so the wrapped row -still looks clean** and then returns to normal text. +This sentence has **bold text that keeps going for a while so the wrapped row still looks clean** and then returns to normal text. Known gap: ~~strikethrough is not rendered yet~~. @@ -73,15 +64,13 @@ Wiki links: - [[ISSUES.md]] - [[Projects/Glass Benchmark]] -Known gap: wiki links are navigable, but their visual treatment is still not as -distinct as it should be. +Known gap: wiki links are navigable, but their visual treatment is still not as distinct as it should be. ## Blockquotes > A simple quote should render with a quiet quote marker. -> A longer quote should wrap without turning into noisy syntax. It should keep -> the quote style across wrapped rows and still feel like a calm reading surface. +> A longer quote should wrap without turning into noisy syntax. It should keep the quote style across wrapped rows and still feel like a calm reading surface. Nested blockquote benchmark: @@ -96,8 +85,7 @@ Bullets: - Second bullet item with `inline code` - Third bullet item with [a link](README.md) - Nested bullet item - - Another nested bullet item that wraps for a while so continuation indentation - can be checked visually + - Another nested bullet item that wraps for a while so continuation indentation can be checked visually Alternate bullet markers: @@ -161,8 +149,7 @@ Markdown inside table cells: | Link | [README](README.md) | | Emphasis | **bold** and *italic* | -Known gap: inline Markdown inside inactive table cells is aligned, but not yet -fully concealed or styled per cell. +Known gap: inline Markdown inside inactive table cells is aligned, but not yet fully concealed or styled per cell. ## Code @@ -185,8 +172,7 @@ cargo test --locked cargo build --release --locked ``` -Known gap: fenced code blocks render as code fences, but the language marker is -not specially highlighted yet. +Known gap: fenced code blocks render as code fences, but the language marker is not specially highlighted yet. ## Rules And Separators @@ -268,6 +254,4 @@ Try these command-ish strings without accidentally executing them while editing: | Code | `glass benchmark.md` | should align | | Text | long plain text that needs fitting in smaller windows | should not break the table | -Final long wrapped line with many constructs: **bold words**, `inline code`, -[a link](README.md), a bare URL https://example.com/final-check, and enough -plain text to wrap several times in a narrow viewport. +Final long wrapped line with many constructs: **bold words**, `inline code`, [a link](README.md), a bare URL https://example.com/final-check, and enough plain text to wrap several times in a narrow viewport. diff --git a/src/markdown/table.rs b/src/markdown/table.rs index 9848894..b3d0d11 100644 --- a/src/markdown/table.rs +++ b/src/markdown/table.rs @@ -120,25 +120,35 @@ impl TableLayout { let mut spans = Vec::new(); let mut source_map = Vec::new(); + append_span( + &mut spans, + &mut source_map, + if is_delimiter { "├" } else { "│" }.to_string(), + Style::default().fg(theme.muted), + std::iter::once(None), + ); + for column in 0..block.column_count() { - if column > 0 { + if is_delimiter { + let separator = "─".repeat(widths[column] + 2); append_span( &mut spans, &mut source_map, - " │ ".to_string(), + separator.clone(), Style::default().fg(theme.muted), - std::iter::repeat_n(None, 3), + std::iter::repeat_n(None, separator.chars().count()), ); - } - - if is_delimiter { - let separator = format!(" {} ", "─".repeat(widths[column])); + let joint = if column + 1 == block.column_count() { + "┤" + } else { + "┼" + }; append_span( &mut spans, &mut source_map, - separator.clone(), + joint.to_string(), Style::default().fg(theme.muted), - std::iter::repeat_n(None, separator.chars().count()), + std::iter::once(None), ); continue; } @@ -177,6 +187,13 @@ impl TableLayout { style, std::iter::once(None), ); + append_span( + &mut spans, + &mut source_map, + "│".to_string(), + Style::default().fg(theme.muted), + std::iter::once(None), + ); } Some(RenderedTableLine { @@ -198,7 +215,7 @@ impl TableBlock { } let mut widths = self.widths.clone(); - let fixed_width = column_count * 2 + column_count.saturating_sub(1) * 3; + let fixed_width = column_count * 2 + column_count + 1; while fixed_width + widths.iter().sum::() > available_width && widths.iter().any(|width| *width > 1) @@ -481,9 +498,20 @@ mod tests { .render_row(2, "| Ada | 12 |", 80, Theme::monochrome_for_tests()) .expect("rendered table row"); - assert_eq!(line_text(&rendered.line), " Ada │ 12 "); - assert_eq!(rendered.source_map[1], Some(2)); - assert_eq!(rendered.source_map[13], Some(8)); + assert_eq!(line_text(&rendered.line), "│ Ada │ 12 │"); + assert_eq!(rendered.source_map[2], Some(2)); + assert_eq!(rendered.source_map[12], Some(8)); + } + + #[test] + fn renders_delimiter_as_box_separator() { + let buffer = buffer_with("| Name | Count |\n| --- | ---: |\n| Ada | 12 |\n"); + let layout = TableLayout::new(&buffer); + let rendered = layout + .render_row(1, "| --- | ---: |", 80, Theme::monochrome_for_tests()) + .expect("rendered table delimiter"); + + assert_eq!(line_text(&rendered.line), "├──────┼───────┤"); } #[test] @@ -500,6 +528,6 @@ mod tests { ) .expect("rendered table row"); - assert_eq!(line_text(&rendered.line), " Ada │ long d… "); + assert_eq!(line_text(&rendered.line), "│ Ada │ long d… │"); } } From 3bd95eb4d3ee8b051debe62859038c828c4bb19d Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:32:15 +0100 Subject: [PATCH 5/8] fix: add table top and bottom borders --- src/markdown/table.rs | 64 ++++++++++++ src/ui/editor.rs | 226 ++++++++++++++++++++++++++---------------- 2 files changed, 205 insertions(+), 85 deletions(-) diff --git a/src/markdown/table.rs b/src/markdown/table.rs index b3d0d11..5809d45 100644 --- a/src/markdown/table.rs +++ b/src/markdown/table.rs @@ -44,6 +44,12 @@ pub struct RenderedTableLine { pub source_map: Vec>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableBorder { + Top, + Bottom, +} + impl TableLayout { pub fn new(buffer: &DocumentBuffer) -> Self { let mut blocks = Vec::new(); @@ -201,6 +207,34 @@ impl TableLayout { source_map, }) } + + pub fn render_border_for_line( + &self, + line_number: usize, + available_width: usize, + theme: Theme, + border: TableBorder, + ) -> Option { + let block = self.block_for_line(line_number)?; + let is_target_line = match border { + TableBorder::Top => line_number == block.start_line, + TableBorder::Bottom => line_number + 1 == block.end_line, + }; + if !is_target_line { + return None; + } + + let widths = block.fitted_widths(available_width); + let text = match border { + TableBorder::Top => border_line(&widths, "┌", "┬", "┐"), + TableBorder::Bottom => border_line(&widths, "└", "┴", "┘"), + }; + let source_map = std::iter::repeat_n(None, text.chars().count()).collect(); + Some(RenderedTableLine { + line: Line::from(Span::styled(text, Style::default().fg(theme.muted))), + source_map, + }) + } } impl TableBlock { @@ -443,6 +477,19 @@ fn append_span( spans.push(Span::styled(text, style)); } +fn border_line(widths: &[usize], left: &str, joint: &str, right: &str) -> String { + let mut text = String::from(left); + for (index, width) in widths.iter().enumerate() { + text.push_str(&"─".repeat(width + 2)); + if index + 1 == widths.len() { + text.push_str(right); + } else { + text.push_str(joint); + } + } + text +} + #[cfg(test)] mod tests { use super::*; @@ -514,6 +561,23 @@ mod tests { assert_eq!(line_text(&rendered.line), "├──────┼───────┤"); } + #[test] + fn renders_top_and_bottom_borders() { + let buffer = buffer_with("| Name | Count |\n| --- | ---: |\n| Ada | 12 |\n"); + let layout = TableLayout::new(&buffer); + let top = layout + .render_border_for_line(0, 80, Theme::monochrome_for_tests(), TableBorder::Top) + .expect("top border"); + let bottom = layout + .render_border_for_line(2, 80, Theme::monochrome_for_tests(), TableBorder::Bottom) + .expect("bottom border"); + + assert_eq!(line_text(&top.line), "┌──────┬───────┐"); + assert_eq!(line_text(&bottom.line), "└──────┴───────┘"); + assert!(top.source_map.iter().all(Option::is_none)); + assert!(bottom.source_map.iter().all(Option::is_none)); + } + #[test] fn narrows_wide_columns_with_ellipsis() { let buffer = diff --git a/src/ui/editor.rs b/src/ui/editor.rs index d61fd6d..d5a49bb 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -14,7 +14,7 @@ use crate::{ }, markdown::{ highlight::{concealed_wrap_line, render_markdown_segment_with_completion}, - table::{TableLayout, table_wrap_line}, + table::{TableBorder, TableLayout, table_wrap_line}, }, }; @@ -74,100 +74,123 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { let mut cursor_visual_y: usize = 0; let mut cursor_found = false; - let lines = rows - .iter() - .enumerate() - .map(|(i, row)| { - let is_cursor_row = - row.line_number == app.cursor.line && row.wrap_index == wrap_index_of_cursor; - let active = row.line_number == app.cursor.line; - let table_row = (!active && row.wrap_index == 0) - .then(|| { - table_layout.render_row(row.line_number, &row.full_text, text_width, theme) - }) - .flatten(); - - let (mut line, source_map) = if let Some(rendered) = table_row { - (rendered.line, Some(rendered.source_map)) - } else { - ( - render_markdown_segment_with_completion( - &row.full_text, - row.source_start, - row.source_end, - theme, - active, - row.wrap_index, - row.completed && row.wrap_index > 0, - ), - None, - ) - }; - - if !app.search.query.is_empty() { - let mut ranges = search_ranges_for_row( - &app.search.matches, - row.line_number, - row.source_start, - row.source_end, - ); - if let Some(source_map) = &source_map { - ranges = source_ranges_to_visual_ranges(source_map, row.source_start, &ranges); - } - line = highlight_search_ranges(line, &ranges, theme); - } + let height = page.height as usize; + let mut lines = Vec::new(); + for (index, row) in rows.iter().enumerate() { + let is_cursor_row = + row.line_number == app.cursor.line && row.wrap_index == wrap_index_of_cursor; + let active = row.line_number == app.cursor.line; + + if row.wrap_index == 0 + && let Some(rendered) = table_layout.render_border_for_line( + row.line_number, + text_width, + theme, + TableBorder::Top, + ) + { + push_line( + &mut lines, + add_gutter(rendered.line, gutter_width, None, app, theme), + height, + ); + } + + let table_row = (!active && row.wrap_index == 0) + .then(|| table_layout.render_row(row.line_number, &row.full_text, text_width, theme)) + .flatten(); - if let Some(selection) = app.text_selection { - let mut ranges = selection_ranges_for_row( - selection, - row.line_number, + let (mut line, source_map) = if let Some(rendered) = table_row { + (rendered.line, Some(rendered.source_map)) + } else { + ( + render_markdown_segment_with_completion( + &row.full_text, row.source_start, row.source_end, - ); - if let Some(source_map) = &source_map { - ranges = source_ranges_to_visual_ranges(source_map, row.source_start, &ranges); - } - line = highlight_search_ranges(line, &ranges, theme); - } + theme, + active, + row.wrap_index, + row.completed && row.wrap_index > 0, + ), + None, + ) + }; - if row.continuation_indent > 0 { - let indent = Span::raw(" ".repeat(row.continuation_indent)); - line.spans.insert(0, indent); + if !app.search.query.is_empty() { + let mut ranges = search_ranges_for_row( + &app.search.matches, + row.line_number, + row.source_start, + row.source_end, + ); + if let Some(source_map) = &source_map { + ranges = source_ranges_to_visual_ranges(source_map, row.source_start, &ranges); } + line = highlight_search_ranges(line, &ranges, theme); + } - if visual_range - .as_ref() - .is_some_and(|range| range.contains(&row.line_number)) - { - line = selected_line(line, theme); - } - if is_cursor_row && app.mode != Mode::Visual { - line.style = line.style.bg(theme.background); + if let Some(selection) = app.text_selection { + let mut ranges = selection_ranges_for_row( + selection, + row.line_number, + row.source_start, + row.source_end, + ); + if let Some(source_map) = &source_map { + ranges = source_ranges_to_visual_ranges(source_map, row.source_start, &ranges); } + line = highlight_search_ranges(line, &ranges, theme); + } - if gutter_width > 0 { - let gutter = if row.wrap_index == 0 && app.mode == Mode::Visual { - format!( - "{:>w$} ", - row.line_number + 1, - w = gutter_width as usize - 1 - ) - } else { - " ".repeat(gutter_width as usize) - }; - let mut spans = vec![Span::styled(gutter, Style::default().fg(theme.muted))]; - spans.extend(line.spans); - line = Line::from(spans); - } + if row.continuation_indent > 0 { + let indent = Span::raw(" ".repeat(row.continuation_indent)); + line.spans.insert(0, indent); + } - if is_cursor_row && !cursor_found { - cursor_visual_y = i; - cursor_found = true; - } + if visual_range + .as_ref() + .is_some_and(|range| range.contains(&row.line_number)) + { + line = selected_line(line, theme); + } + if is_cursor_row && app.mode != Mode::Visual { + line.style = line.style.bg(theme.background); + } - line - }) - .collect::>(); + line = add_gutter( + line, + gutter_width, + Some((row.line_number, row.wrap_index)), + app, + theme, + ); + + if is_cursor_row && !cursor_found && lines.len() < height { + cursor_visual_y = lines.len(); + cursor_found = true; + } + + let row_was_visible = push_line(&mut lines, line, height); + let is_last_wrap_for_source_line = rows + .get(index + 1) + .is_none_or(|next| next.line_number != row.line_number); + if row_was_visible + && is_last_wrap_for_source_line + && let Some(rendered) = table_layout.render_border_for_line( + row.line_number, + text_width, + theme, + TableBorder::Bottom, + ) + { + push_line( + &mut lines, + add_gutter(rendered.line, gutter_width, None, app, theme), + height, + ); + } + } let paragraph = Paragraph::new(Text::from(lines)) .style(Style::default().bg(theme.background).fg(theme.text)); @@ -195,6 +218,39 @@ fn selected_line(mut line: Line<'static>, theme: Theme) -> Line<'static> { line } +fn push_line(lines: &mut Vec>, line: Line<'static>, height: usize) -> bool { + if lines.len() >= height { + return false; + } + + lines.push(line); + true +} + +fn add_gutter( + line: Line<'static>, + gutter_width: u16, + source_position: Option<(usize, usize)>, + app: &App, + theme: Theme, +) -> Line<'static> { + if gutter_width == 0 { + return line; + } + + let show_visual_line_number = + source_position.is_some_and(|(_, wrap_index)| wrap_index == 0 && app.mode == Mode::Visual); + let gutter = if show_visual_line_number { + let (line_number, _) = source_position.expect("source position checked above"); + format!("{:>w$} ", line_number + 1, w = gutter_width as usize - 1) + } else { + " ".repeat(gutter_width as usize) + }; + let mut spans = vec![Span::styled(gutter, Style::default().fg(theme.muted))]; + spans.extend(line.spans); + Line::from(spans) +} + fn highlight_search_ranges( mut line: Line<'static>, ranges: &[(usize, usize)], From 7de2c7617d0091aeb94b355c9a1915aeb52a85b7 Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:40:58 +0100 Subject: [PATCH 6/8] fix: wrap table cells without outer borders --- benchmark.md | 2 +- src/app.rs | 9 +- src/markdown/table.rs | 457 +++++++++++++++++++++++++++--------------- src/ui/editor.rs | 53 ++--- 4 files changed, 314 insertions(+), 207 deletions(-) diff --git a/benchmark.md b/benchmark.md index 1f7b60f..5ec3b7e 100644 --- a/benchmark.md +++ b/benchmark.md @@ -131,7 +131,7 @@ Narrow-width pressure table: | Column | Long content | Number | | --- | --- | ---: | -| Alpha | This cell is intentionally long enough to force fitting or truncation in a narrow terminal | 1200 | +| Alpha | This cell is intentionally long enough to force wrapping in a narrow terminal | 1200 | | Beta | Short value | 7 | Escaped pipe table: diff --git a/src/app.rs b/src/app.rs index ca3f4a7..daf3df0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,10 +23,7 @@ use crate::{ }, fs::tree::FileTree, markdown::inline::{LinkKind, link_at_column}, - markdown::{ - highlight::concealed_wrap_line, - table::{TableLayout, table_wrap_line}, - }, + markdown::{highlight::concealed_wrap_line, table::TableLayout}, }; const STATUS_MESSAGE_TTL: Duration = Duration::from_secs(3); @@ -1238,7 +1235,7 @@ impl App { if line_num == self.cursor.line { wrap_line(text, width) } else if table_layout.is_table_row(line_num) { - table_wrap_line(text, width) + table_layout.wrap_line(line_num, text, width) } else { concealed_wrap_line(text, width) } @@ -1857,7 +1854,7 @@ impl App { let (segments, _) = if line == self.cursor.line { wrap_line(trimmed, width) } else if table_layout.is_table_row(line) { - table_wrap_line(trimmed, width) + table_layout.wrap_line(line, trimmed, width) } else { concealed_wrap_line(trimmed, width) }; diff --git a/src/markdown/table.rs b/src/markdown/table.rs index 5809d45..2d55169 100644 --- a/src/markdown/table.rs +++ b/src/markdown/table.rs @@ -44,12 +44,6 @@ pub struct RenderedTableLine { pub source_map: Vec>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TableBorder { - Top, - Bottom, -} - impl TableLayout { pub fn new(buffer: &DocumentBuffer) -> Self { let mut blocks = Vec::new(); @@ -111,129 +105,97 @@ impl TableLayout { self.block_for_line(line).is_some() } - pub fn render_row( + pub fn render_row_segment( &self, line_number: usize, source: &str, available_width: usize, theme: Theme, + wrap_index: usize, ) -> Option { - let block = self.block_for_line(line_number)?; - let widths = block.fitted_widths(available_width); - let is_delimiter = line_number == block.delimiter_line; - let is_header = line_number == block.start_line; - let cells = parse_table_cells(source.trim_end_matches(['\r', '\n'])); - let mut spans = Vec::new(); - let mut source_map = Vec::new(); - - append_span( - &mut spans, - &mut source_map, - if is_delimiter { "├" } else { "│" }.to_string(), - Style::default().fg(theme.muted), - std::iter::once(None), - ); - - for column in 0..block.column_count() { - if is_delimiter { - let separator = "─".repeat(widths[column] + 2); - append_span( - &mut spans, - &mut source_map, - separator.clone(), - Style::default().fg(theme.muted), - std::iter::repeat_n(None, separator.chars().count()), - ); - let joint = if column + 1 == block.column_count() { - "┤" - } else { - "┼" - }; - append_span( - &mut spans, - &mut source_map, - joint.to_string(), - Style::default().fg(theme.muted), - std::iter::once(None), - ); - continue; - } - - let alignment = block.alignments[column]; - let style = if is_header { - theme.heading.add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.text) - }; - let empty_cell = TableCell { - text: String::new(), - source_indices: Vec::new(), - }; - let cell = cells.get(column).unwrap_or(&empty_cell); - let fitted = fit_cell(cell, widths[column], alignment); + self.render_row_lines(line_number, source, available_width, theme)? + .into_iter() + .nth(wrap_index) + } - append_span( - &mut spans, - &mut source_map, - " ".to_string(), - style, - std::iter::once(None), - ); - append_span( - &mut spans, - &mut source_map, - fitted.text, - style, - fitted.source_map.into_iter(), - ); - append_span( - &mut spans, - &mut source_map, - " ".to_string(), - style, - std::iter::once(None), - ); - append_span( - &mut spans, - &mut source_map, - "│".to_string(), - Style::default().fg(theme.muted), - std::iter::once(None), - ); + pub fn wrap_line( + &self, + line_number: usize, + source: &str, + available_width: usize, + ) -> (Vec<(usize, usize)>, usize) { + let line_len = source.chars().count(); + let Some(block) = self.block_for_line(line_number) else { + return (vec![(0, line_len)], 0); + }; + if line_number == block.delimiter_line { + return (vec![(0, line_len)], 0); } - Some(RenderedTableLine { - line: Line::from(spans), - source_map, - }) + let widths = block.fitted_widths(available_width); + let cells = parse_table_cells(source.trim_end_matches(['\r', '\n'])); + let empty_cell = TableCell { + text: String::new(), + source_indices: Vec::new(), + }; + let row_height = (0..block.column_count()) + .map(|column| { + let cell = cells.get(column).unwrap_or(&empty_cell); + wrap_cell(cell, widths[column], block.alignments[column]).len() + }) + .max() + .unwrap_or(1); + + (std::iter::repeat_n((0, line_len), row_height).collect(), 0) } - pub fn render_border_for_line( + fn render_row_lines( &self, line_number: usize, + source: &str, available_width: usize, theme: Theme, - border: TableBorder, - ) -> Option { + ) -> Option> { let block = self.block_for_line(line_number)?; - let is_target_line = match border { - TableBorder::Top => line_number == block.start_line, - TableBorder::Bottom => line_number + 1 == block.end_line, - }; - if !is_target_line { - return None; + let widths = block.fitted_widths(available_width); + let is_delimiter = line_number == block.delimiter_line; + let is_header = line_number == block.start_line; + let cells = parse_table_cells(source.trim_end_matches(['\r', '\n'])); + + if is_delimiter { + return Some(vec![render_delimiter_row(block, &widths, theme)]); } - let widths = block.fitted_widths(available_width); - let text = match border { - TableBorder::Top => border_line(&widths, "┌", "┬", "┐"), - TableBorder::Bottom => border_line(&widths, "└", "┴", "┘"), + let empty_cell = TableCell { + text: String::new(), + source_indices: Vec::new(), }; - let source_map = std::iter::repeat_n(None, text.chars().count()).collect(); - Some(RenderedTableLine { - line: Line::from(Span::styled(text, Style::default().fg(theme.muted))), - source_map, - }) + let wrapped_cells = (0..block.column_count()) + .map(|column| { + let cell = cells.get(column).unwrap_or(&empty_cell); + wrap_cell(cell, widths[column], block.alignments[column]) + }) + .collect::>(); + let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1).max(1); + let style = if is_header { + theme.heading.add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + + let mut rows = Vec::new(); + for visual_row in 0..row_height { + rows.push(render_content_row( + block, + &widths, + &wrapped_cells, + visual_row, + style, + theme, + )); + } + + Some(rows) } } @@ -263,16 +225,115 @@ impl TableBlock { } } +fn render_delimiter_row(block: &TableBlock, widths: &[usize], theme: Theme) -> RenderedTableLine { + let mut spans = Vec::new(); + let mut source_map = Vec::new(); + + append_span( + &mut spans, + &mut source_map, + "├".to_string(), + Style::default().fg(theme.muted), + std::iter::once(None), + ); + + for (column, width) in widths.iter().enumerate().take(block.column_count()) { + let separator = "─".repeat(width + 2); + append_span( + &mut spans, + &mut source_map, + separator.clone(), + Style::default().fg(theme.muted), + std::iter::repeat_n(None, separator.chars().count()), + ); + let joint = if column + 1 == block.column_count() { + "┤" + } else { + "┼" + }; + append_span( + &mut spans, + &mut source_map, + joint.to_string(), + Style::default().fg(theme.muted), + std::iter::once(None), + ); + } + + RenderedTableLine { + line: Line::from(spans), + source_map, + } +} + +fn render_content_row( + block: &TableBlock, + widths: &[usize], + wrapped_cells: &[Vec], + visual_row: usize, + style: Style, + theme: Theme, +) -> RenderedTableLine { + let mut spans = Vec::new(); + let mut source_map = Vec::new(); + + append_span( + &mut spans, + &mut source_map, + "│".to_string(), + Style::default().fg(theme.muted), + std::iter::once(None), + ); + + for column in 0..block.column_count() { + let fitted = wrapped_cells + .get(column) + .and_then(|cell_lines| cell_lines.get(visual_row)) + .cloned() + .unwrap_or_else(|| blank_cell(widths[column])); + + append_span( + &mut spans, + &mut source_map, + " ".to_string(), + style, + std::iter::once(None), + ); + append_span( + &mut spans, + &mut source_map, + fitted.text, + style, + fitted.source_map.into_iter(), + ); + append_span( + &mut spans, + &mut source_map, + " ".to_string(), + style, + std::iter::once(None), + ); + append_span( + &mut spans, + &mut source_map, + "│".to_string(), + Style::default().fg(theme.muted), + std::iter::once(None), + ); + } + + RenderedTableLine { + line: Line::from(spans), + source_map, + } +} + #[derive(Debug, Clone)] struct FittedCell { text: String, source_map: Vec>, } -pub fn table_wrap_line(text: &str, _width: usize) -> (Vec<(usize, usize)>, usize) { - (vec![(0, text.chars().count())], 0) -} - fn column_count(buffer: &DocumentBuffer, start: usize, end: usize) -> usize { (start..end) .map(|line| parse_table_cells(&trimmed_line(buffer, line)).len()) @@ -422,27 +483,70 @@ fn is_escaped(chars: &[char], index: usize) -> bool { backslashes % 2 == 1 } -fn fit_cell(cell: &TableCell, width: usize, alignment: TableAlignment) -> FittedCell { - let mut chars = cell.text.chars().collect::>(); - let mut source_map = cell - .source_indices - .iter() - .copied() - .map(Some) - .collect::>(); +fn wrap_cell(cell: &TableCell, width: usize, alignment: TableAlignment) -> Vec { + cell_wrap_segments(cell, width.max(1)) + .into_iter() + .map(|(start, end)| { + let chars = cell.text.chars().skip(start).take(end - start); + let source_map = cell.source_indices[start..end] + .iter() + .copied() + .map(Some) + .collect::>(); + fit_cell_segment(chars, source_map, width, alignment) + }) + .collect() +} - if chars.len() > width { - if width == 1 { - chars = vec!['…']; - source_map = vec![None]; +fn cell_wrap_segments(cell: &TableCell, width: usize) -> Vec<(usize, usize)> { + let chars = cell.text.chars().collect::>(); + if chars.is_empty() { + return vec![(0, 0)]; + } + + let mut segments = Vec::new(); + let mut pos = 0; + while pos < chars.len() { + while pos < chars.len() && chars[pos].is_whitespace() { + pos += 1; + } + if pos >= chars.len() { + break; + } + + let end = (pos + width).min(chars.len()); + if end >= chars.len() { + segments.push((pos, chars.len())); + break; + } + + let slice = &chars[pos..end]; + if let Some(rel_pos) = slice.iter().rposition(|ch| ch.is_whitespace()) + && rel_pos > 0 + { + let break_at = pos + rel_pos; + segments.push((pos, break_at)); + pos = break_at + 1; } else { - chars.truncate(width - 1); - source_map.truncate(width - 1); - chars.push('…'); - source_map.push(None); + segments.push((pos, end)); + pos = end; } } + if segments.is_empty() { + vec![(0, 0)] + } else { + segments + } +} + +fn fit_cell_segment( + chars: impl IntoIterator, + source_map: Vec>, + width: usize, + alignment: TableAlignment, +) -> FittedCell { + let chars = chars.into_iter().collect::>(); let content_width = chars.len(); let padding = width.saturating_sub(content_width); let (left_padding, right_padding) = match alignment { @@ -466,6 +570,13 @@ fn fit_cell(cell: &TableCell, width: usize, alignment: TableAlignment) -> Fitted } } +fn blank_cell(width: usize) -> FittedCell { + FittedCell { + text: " ".repeat(width), + source_map: std::iter::repeat_n(None, width).collect(), + } +} + fn append_span( spans: &mut Vec>, source_map: &mut Vec>, @@ -477,19 +588,6 @@ fn append_span( spans.push(Span::styled(text, style)); } -fn border_line(widths: &[usize], left: &str, joint: &str, right: &str) -> String { - let mut text = String::from(left); - for (index, width) in widths.iter().enumerate() { - text.push_str(&"─".repeat(width + 2)); - if index + 1 == widths.len() { - text.push_str(right); - } else { - text.push_str(joint); - } - } - text -} - #[cfg(test)] mod tests { use super::*; @@ -542,7 +640,7 @@ mod tests { let buffer = buffer_with("| Name | Count |\n| --- | ---: |\n| Ada | 12 |\n"); let layout = TableLayout::new(&buffer); let rendered = layout - .render_row(2, "| Ada | 12 |", 80, Theme::monochrome_for_tests()) + .render_row_segment(2, "| Ada | 12 |", 80, Theme::monochrome_for_tests(), 0) .expect("rendered table row"); assert_eq!(line_text(&rendered.line), "│ Ada │ 12 │"); @@ -555,43 +653,80 @@ mod tests { let buffer = buffer_with("| Name | Count |\n| --- | ---: |\n| Ada | 12 |\n"); let layout = TableLayout::new(&buffer); let rendered = layout - .render_row(1, "| --- | ---: |", 80, Theme::monochrome_for_tests()) + .render_row_segment(1, "| --- | ---: |", 80, Theme::monochrome_for_tests(), 0) .expect("rendered table delimiter"); assert_eq!(line_text(&rendered.line), "├──────┼───────┤"); } #[test] - fn renders_top_and_bottom_borders() { - let buffer = buffer_with("| Name | Count |\n| --- | ---: |\n| Ada | 12 |\n"); + fn wraps_wide_cells_across_row_segments() { + let buffer = + buffer_with("| Name | Description |\n| --- | --- |\n| Ada | long description |\n"); let layout = TableLayout::new(&buffer); - let top = layout - .render_border_for_line(0, 80, Theme::monochrome_for_tests(), TableBorder::Top) - .expect("top border"); - let bottom = layout - .render_border_for_line(2, 80, Theme::monochrome_for_tests(), TableBorder::Bottom) - .expect("bottom border"); + let (segments, _) = layout.wrap_line(2, "| Ada | long description |", 18); + let rendered = (0..segments.len()) + .map(|wrap_index| { + layout + .render_row_segment( + 2, + "| Ada | long description |", + 18, + Theme::monochrome_for_tests(), + wrap_index, + ) + .expect("rendered table row segment") + }) + .collect::>(); + + assert_eq!(segments.len(), 3); + assert_eq!(line_text(&rendered[0].line), "│ Ada │ long │"); + assert_eq!(line_text(&rendered[1].line), "│ │ descrip │"); + assert_eq!(line_text(&rendered[2].line), "│ │ tion │"); + } - assert_eq!(line_text(&top.line), "┌──────┬───────┐"); - assert_eq!(line_text(&bottom.line), "└──────┴───────┘"); - assert!(top.source_map.iter().all(Option::is_none)); - assert!(bottom.source_map.iter().all(Option::is_none)); + #[test] + fn wraps_wide_cells_inside_single_character_columns() { + let buffer = buffer_with("| A | B |\n| --- | --- |\n| x | abc |\n"); + let layout = TableLayout::new(&buffer); + let (segments, _) = layout.wrap_line(2, "| x | abc |", 7); + let rendered = (0..segments.len()) + .map(|wrap_index| { + layout + .render_row_segment( + 2, + "| x | abc |", + 7, + Theme::monochrome_for_tests(), + wrap_index, + ) + .expect("rendered narrow table row segment") + }) + .collect::>(); + + assert_eq!(segments.len(), 3); + assert_eq!(line_text(&rendered[0].line), "│ x │ a │"); + assert_eq!(line_text(&rendered[1].line), "│ │ b │"); + assert_eq!(line_text(&rendered[2].line), "│ │ c │"); } #[test] - fn narrows_wide_columns_with_ellipsis() { + fn keeps_source_maps_on_wrapped_cell_segments() { let buffer = buffer_with("| Name | Description |\n| --- | --- |\n| Ada | long description |\n"); let layout = TableLayout::new(&buffer); let rendered = layout - .render_row( + .render_row_segment( 2, "| Ada | long description |", 18, Theme::monochrome_for_tests(), + 1, ) - .expect("rendered table row"); + .expect("rendered table row segment"); - assert_eq!(line_text(&rendered.line), "│ Ada │ long d… │"); + assert_eq!(line_text(&rendered.line), "│ │ descrip │"); + assert!(rendered.source_map.contains(&Some(13))); + assert!(!line_text(&rendered.line).contains('…')); } } diff --git a/src/ui/editor.rs b/src/ui/editor.rs index d5a49bb..322a631 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -14,7 +14,7 @@ use crate::{ }, markdown::{ highlight::{concealed_wrap_line, render_markdown_segment_with_completion}, - table::{TableBorder, TableLayout, table_wrap_line}, + table::TableLayout, }, }; @@ -56,7 +56,7 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { if line_num == app.cursor.line { wrap_line(text, w) } else if table_layout.is_table_row(line_num) { - table_wrap_line(text, w) + table_layout.wrap_line(line_num, text, w) } else { concealed_wrap_line(text, w) } @@ -76,28 +76,21 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { let height = page.height as usize; let mut lines = Vec::new(); - for (index, row) in rows.iter().enumerate() { + for row in &rows { let is_cursor_row = row.line_number == app.cursor.line && row.wrap_index == wrap_index_of_cursor; let active = row.line_number == app.cursor.line; - if row.wrap_index == 0 - && let Some(rendered) = table_layout.render_border_for_line( - row.line_number, - text_width, - theme, - TableBorder::Top, - ) - { - push_line( - &mut lines, - add_gutter(rendered.line, gutter_width, None, app, theme), - height, - ); - } - - let table_row = (!active && row.wrap_index == 0) - .then(|| table_layout.render_row(row.line_number, &row.full_text, text_width, theme)) + let table_row = (!active) + .then(|| { + table_layout.render_row_segment( + row.line_number, + &row.full_text, + text_width, + theme, + row.wrap_index, + ) + }) .flatten(); let (mut line, source_map) = if let Some(rendered) = table_row { @@ -171,25 +164,7 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { cursor_found = true; } - let row_was_visible = push_line(&mut lines, line, height); - let is_last_wrap_for_source_line = rows - .get(index + 1) - .is_none_or(|next| next.line_number != row.line_number); - if row_was_visible - && is_last_wrap_for_source_line - && let Some(rendered) = table_layout.render_border_for_line( - row.line_number, - text_width, - theme, - TableBorder::Bottom, - ) - { - push_line( - &mut lines, - add_gutter(rendered.line, gutter_width, None, app, theme), - height, - ); - } + push_line(&mut lines, line, height); } let paragraph = Paragraph::new(Text::from(lines)) From 6ad57fbe7d8bc63e9e761ee1a9143d512ebfc853 Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:47:10 +0100 Subject: [PATCH 7/8] fix: separate table body rows --- src/markdown/table.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/markdown/table.rs b/src/markdown/table.rs index 2d55169..8baf89f 100644 --- a/src/markdown/table.rs +++ b/src/markdown/table.rs @@ -145,8 +145,14 @@ impl TableLayout { }) .max() .unwrap_or(1); + let separator_height = usize::from(block.has_body_row_after(line_number)); - (std::iter::repeat_n((0, line_len), row_height).collect(), 0) + ( + std::iter::repeat_n((0, line_len), row_height) + .chain(std::iter::repeat_n((line_len, line_len), separator_height)) + .collect(), + 0, + ) } fn render_row_lines( @@ -194,6 +200,9 @@ impl TableLayout { theme, )); } + if block.has_body_row_after(line_number) { + rows.push(render_delimiter_row(block, &widths, theme)); + } Some(rows) } @@ -223,6 +232,10 @@ impl TableBlock { widths } + + fn has_body_row_after(&self, line_number: usize) -> bool { + line_number > self.delimiter_line && line_number + 1 < self.end_line + } } fn render_delimiter_row(block: &TableBlock, widths: &[usize], theme: Theme) -> RenderedTableLine { @@ -659,6 +672,22 @@ mod tests { assert_eq!(line_text(&rendered.line), "├──────┼───────┤"); } + #[test] + fn renders_internal_separators_between_body_rows() { + let buffer = buffer_with("| A | B |\n| --- | --- |\n| x | y |\n| z | q |\n"); + let layout = TableLayout::new(&buffer); + let (first_row_segments, _) = layout.wrap_line(2, "| x | y |", 80); + let (last_row_segments, _) = layout.wrap_line(3, "| z | q |", 80); + let separator = layout + .render_row_segment(2, "| x | y |", 80, Theme::monochrome_for_tests(), 1) + .expect("rendered table row separator"); + + assert_eq!(first_row_segments.len(), 2); + assert_eq!(last_row_segments.len(), 1); + assert_eq!(line_text(&separator.line), "├─────┼─────┤"); + assert!(separator.source_map.iter().all(Option::is_none)); + } + #[test] fn wraps_wide_cells_across_row_segments() { let buffer = From ebf9f918b9b43d2d697395655da3e8b7fbdd8c2e Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Mon, 18 May 2026 23:48:01 +0100 Subject: [PATCH 8/8] chore: release v0.1.7 --- CHANGELOG.md | 26 +++++++++++++++++++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 435c241..0484728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7] - 2026-05-18 + +Full commit range: [`v0.1.6...v0.1.7`](https://github.com/pacificcodeinc/glass/compare/v0.1.6...v0.1.7) + +### Added + +- Markdown table rendering with aligned columns, styled headers, escaped pipe support, and source mapping for search and selection highlights. +- Table cells wrap into additional visual rows instead of truncating long content. +- A broad `benchmark.md` fixture covering implemented Markdown behavior, known gaps, and renderer stress cases. +- GitHub Actions CI for formatting, tests, and release build checks. + +### Changed + +- Table body rows now use internal separators so wrapped rows remain visually distinct without adding an outside top or bottom border. +- Wrapped blockquotes keep their quiet quote marker and styling across visual rows. +- Nested bullets alternate between filled and hollow markers by indentation level. + +### Fixed + +- Nested blockquotes render repeated quote markers instead of falling back toward plain Markdown source. +- Long inactive table cells no longer collapse into ellipsized text in narrow article widths. +- Long benchmark prose now exercises the renderer's wrapping behavior instead of relying on manual hard wraps in the fixture. + ## [0.1.6] - 2026-05-18 Full commit range: [`v0.1.5...v0.1.6`](https://github.com/pacificcodeinc/glass/compare/v0.1.5...v0.1.6) @@ -134,7 +157,8 @@ Full commit range: [`v0.1.0...v0.1.1`](https://github.com/pacificcodeinc/glass/c - Live Markdown rendering with concealed syntax markers. - Checkbox (`- [ ]` / `- [x]`) rendering. -[Unreleased]: https://github.com/pacificcodeinc/glass/compare/v0.1.6...HEAD +[Unreleased]: https://github.com/pacificcodeinc/glass/compare/v0.1.7...HEAD +[0.1.7]: https://github.com/pacificcodeinc/glass/compare/v0.1.6...v0.1.7 [0.1.6]: https://github.com/pacificcodeinc/glass/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/pacificcodeinc/glass/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/pacificcodeinc/glass/compare/v0.1.3...v0.1.4 diff --git a/Cargo.lock b/Cargo.lock index 52fb4cc..f1dce01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,7 +476,7 @@ dependencies = [ [[package]] name = "glass" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 7d55899..4bf00fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "glass" -version = "0.1.6" +version = "0.1.7" edition = "2024" [dependencies]