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] 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/benchmark.md b/benchmark.md new file mode 100644 index 0000000..5ec3b7e --- /dev/null +++ b/benchmark.md @@ -0,0 +1,257 @@ +# 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 wrapping 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. diff --git a/src/app.rs b/src/app.rs index 32a9e60..daf3df0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,8 +22,8 @@ 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}, }; const STATUS_MESSAGE_TTL: Duration = Duration::from_secs(3); @@ -1224,6 +1224,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 +1234,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_layout.wrap_line(line_num, text, width) } else { concealed_wrap_line(text, width) } @@ -1847,8 +1850,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_layout.wrap_line(line, trimmed, width) } else { concealed_wrap_line(trimmed, width) }; 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 = 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..8baf89f --- /dev/null +++ b/src/markdown/table.rs @@ -0,0 +1,761 @@ +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_segment( + &self, + line_number: usize, + source: &str, + available_width: usize, + theme: Theme, + wrap_index: usize, + ) -> Option { + self.render_row_lines(line_number, source, available_width, theme)? + .into_iter() + .nth(wrap_index) + } + + 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); + } + + 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); + let separator_height = usize::from(block.has_body_row_after(line_number)); + + ( + 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( + &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'])); + + if is_delimiter { + return Some(vec![render_delimiter_row(block, &widths, theme)]); + } + + let empty_cell = TableCell { + text: String::new(), + source_indices: Vec::new(), + }; + 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, + )); + } + if block.has_body_row_after(line_number) { + rows.push(render_delimiter_row(block, &widths, theme)); + } + + Some(rows) + } +} + +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 + 1; + + 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 + } + + 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 { + 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>, +} + +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 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() +} + +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 { + 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 { + 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 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>, + 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_segment(2, "| Ada | 12 |", 80, Theme::monochrome_for_tests(), 0) + .expect("rendered table row"); + + 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_segment(1, "| --- | ---: |", 80, Theme::monochrome_for_tests(), 0) + .expect("rendered table delimiter"); + + 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 = + buffer_with("| Name | Description |\n| --- | --- |\n| Ada | long description |\n"); + let layout = TableLayout::new(&buffer); + 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 │"); + } + + #[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 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_segment( + 2, + "| Ada | long description |", + 18, + Theme::monochrome_for_tests(), + 1, + ) + .expect("rendered table row segment"); + + 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 d611cf3..322a631 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, + }, }; 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_layout.wrap_line(line_num, text, w) } else { concealed_wrap_line(text, w) } @@ -68,82 +74,98 @@ 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 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 height = page.height as usize; + let mut lines = Vec::new(); + 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 !app.search.query.is_empty() { - let ranges = search_ranges_for_row( - &app.search.matches, + 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 { + (rendered.line, Some(rendered.source_map)) + } else { + ( + render_markdown_segment_with_completion( + &row.full_text, row.source_start, row.source_end, - ); - line = highlight_search_ranges(line, &ranges, theme); - } + theme, + active, + row.wrap_index, + row.completed && row.wrap_index > 0, + ), + None, + ) + }; - if let Some(selection) = app.text_selection { - let ranges = selection_ranges_for_row( - selection, - row.line_number, - row.source_start, - row.source_end, - ); - line = highlight_search_ranges(line, &ranges, theme); + 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 row.continuation_indent > 0 { - let indent = Span::raw(" ".repeat(row.continuation_indent)); - line.spans.insert(0, indent); + 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 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 row.continuation_indent > 0 { + let indent = Span::raw(" ".repeat(row.continuation_indent)); + line.spans.insert(0, indent); + } - 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 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 is_cursor_row && !cursor_found { - cursor_visual_y = i; - cursor_found = true; - } + line = add_gutter( + line, + gutter_width, + Some((row.line_number, row.wrap_index)), + app, + theme, + ); - line - }) - .collect::>(); + if is_cursor_row && !cursor_found && lines.len() < height { + cursor_visual_y = lines.len(); + cursor_found = true; + } + + push_line(&mut lines, line, height); + } let paragraph = Paragraph::new(Text::from(lines)) .style(Style::default().bg(theme.background).fg(theme.text)); @@ -171,6 +193,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)], @@ -294,6 +349,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 +461,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)] + ); + } }