From 2fa706566b1e363c089e247649ee245253b49040 Mon Sep 17 00:00:00 2001 From: PeterJFB Date: Sun, 13 Oct 2024 10:28:01 +0200 Subject: [PATCH] Add table balancing Support (relatively) huge and empty cells Combine and improve word-wrapping in paragraph and table Fix separation of links Lint and formatting --- examples/demo.rs | 29 ++-- src/boxes/searchbox.rs | 2 +- src/md.pest | 2 +- src/nodes/root.rs | 3 +- src/nodes/textcomponent.rs | 252 +++++++++++++++++++++++++++++---- src/nodes/word.rs | 9 +- src/pages/markdown_renderer.rs | 169 +++++++++++++++------- src/parser.rs | 32 +++-- 8 files changed, 381 insertions(+), 117 deletions(-) diff --git a/examples/demo.rs b/examples/demo.rs index 5e26f39..b2390cd 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -1,6 +1,5 @@ use std::time::{Duration, Instant}; -use crossterm; use crossterm::event::{Event, KeyCode, KeyModifiers}; use crossterm::terminal; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; @@ -54,7 +53,7 @@ impl App { fn scroll_down(&mut self) -> bool { if let Some(markdown) = &self.markdown { - let len = markdown.content().len() as u16; + let len = markdown.height(); if self.area.height > len { self.scroll = 0; } else { @@ -75,11 +74,7 @@ impl App { fn draw(&mut self, frame: &mut Frame) { self.area = frame.area(); - self.markdown = Some(parser::parse_markdown( - None, - &CONTENT.to_string(), - self.area.width, - )); + self.markdown = Some(parser::parse_markdown(None, CONTENT, self.area.width)); if let Some(markdown) = &mut self.markdown { markdown.set_scroll(self.scroll); @@ -92,19 +87,15 @@ impl App { }; for child in markdown.children() { - match child { - Component::TextComponent(comp) => { - if comp.y_offset().saturating_sub(comp.scroll_offset()) >= area.height - || (comp.y_offset() + comp.height()) - .saturating_sub(comp.scroll_offset()) - == 0 - { - continue; - } - - frame.render_widget(comp.clone(), area); + if let Component::TextComponent(comp) = child { + if comp.y_offset().saturating_sub(comp.scroll_offset()) >= area.height + || (comp.y_offset() + comp.height()).saturating_sub(comp.scroll_offset()) + == 0 + { + continue; } - _ => {} + + frame.render_widget(comp.clone(), area); } } } diff --git a/src/boxes/searchbox.rs b/src/boxes/searchbox.rs index f4799a1..d4126fb 100644 --- a/src/boxes/searchbox.rs +++ b/src/boxes/searchbox.rs @@ -27,7 +27,7 @@ impl SearchBox { } pub fn insert(&mut self, c: char) { - self.text.push_str(&c.to_string()); + self.text.push(c); self.cursor += 1; } diff --git a/src/md.pest b/src/md.pest index 5ea9b3c..d99592c 100644 --- a/src/md.pest +++ b/src/md.pest @@ -82,7 +82,7 @@ italic = { sentence = _{ (latex | code | link | bold_italic | italic | bold | strikethrough | normal+)+ } t_sentence = _{ (!"|" ~ (latex | code | link | italic | bold | strikethrough | t_normal))+ } -table_cell = { "|" ~ WHITESPACE_S* ~ t_sentence+ ~ WHITESPACE_S* ~ ("|" ~ " "* ~ NEWLINE)? } +table_cell = { "|" ~ WHITESPACE_S* ~ t_sentence* ~ WHITESPACE_S* ~ ("|" ~ " "* ~ NEWLINE)? } table_seperator = { ("|"? ~ (WHITESPACE_S | ":")* ~ "-"+ ~ (WHITESPACE_S | ":")* ~ "|") } u_list = { indent ~ ("-" | "*") ~ WHITESPACE_S ~ sentence+ } diff --git a/src/nodes/root.rs b/src/nodes/root.rs index 0ae1b4b..f7d3461 100644 --- a/src/nodes/root.rs +++ b/src/nodes/root.rs @@ -105,7 +105,8 @@ impl ComponentRoot { Component::TextComponent(comp) => Some(comp), Component::Image(_) => None, }) { - if index - count < comp.num_links() { + let link_inside_comp = index - count < comp.num_links(); + if link_inside_comp { comp.visually_select(index - count)?; return Ok(comp.y_offset()); } diff --git a/src/nodes/textcomponent.rs b/src/nodes/textcomponent.rs index 3057fcf..3e17b2b 100644 --- a/src/nodes/textcomponent.rs +++ b/src/nodes/textcomponent.rs @@ -12,7 +12,7 @@ use crate::{ use super::word::{Word, WordType}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum TextNode { Image, Paragraph, @@ -20,7 +20,8 @@ pub enum TextNode { Heading, Task, List, - Table, + /// (widths_by_column, heights_by_row) + Table(Vec, Vec), CodeBlock, Quote, HorizontalSeperator, @@ -86,7 +87,7 @@ impl TextComponent { } pub fn kind(&self) -> TextNode { - self.kind + self.kind.clone() } pub fn content(&self) -> &Vec> { @@ -94,7 +95,7 @@ impl TextComponent { } pub fn content_as_lines(&self) -> Vec { - if self.kind == TextNode::Table { + if let TextNode::Table(_, _) = self.kind { let column_count = self.meta_info.len(); let moved_content = self.content.chunks(column_count).collect::>(); @@ -234,7 +235,7 @@ impl TextComponent { pub fn selected_heights(&self) -> Vec { let mut heights = Vec::new(); - if self.kind() == TextNode::Table { + if let TextNode::Table(_, _) = self.kind() { let column_count = self.meta_info.len(); let iter = self.content.chunks(column_count).enumerate(); @@ -276,10 +277,8 @@ impl TextComponent { TextNode::LineBreak => { self.height = 1; } - TextNode::Table => { - self.content.retain(|c| !c.is_empty()); - let height = (self.content.len() / cmp::max(self.meta_info().len(), 1)) as u16; - self.height = height; + TextNode::Table(_, _) => { + transform_table(self, width); } TextNode::HorizontalSeperator => self.height = 1, TextNode::Image => unreachable!("Image should not be transformed"), @@ -287,42 +286,95 @@ impl TextComponent { } } -fn transform_paragraph(component: &mut TextComponent, width: u16) { - let width = match component.kind { - TextNode::Paragraph => width as usize, - TextNode::Task => width as usize - 4, - TextNode::Quote => width as usize - 2, - _ => unreachable!(), - }; - let mut len = 0; +fn word_wrapping<'a>( + words: impl IntoIterator, + width: usize, + allow_hyphen: bool, +) -> Vec> { + let enable_hyphen = allow_hyphen && width > 4; + let mut lines = Vec::new(); let mut line = Vec::new(); - if component.kind() == TextNode::Quote && component.meta_info().is_empty() { - let filler = Word::new(" ".to_string(), WordType::Normal); - line.push(filler); - } - let iter = component.content.iter().flatten(); - for word in iter { - if word.content().len() + len < width { - len += word.content().len(); + let mut line_len = 0; + for word in words { + let word_len = word.content().len(); + if line_len + word_len <= width { + line_len += word_len; line.push(word.clone()); - } else { + } else if word_len <= width { lines.push(line); - len = word.content().len() + 1; let mut word = word.clone(); let content = word.content().trim_start().to_owned(); word.set_content(content); - if component.kind() == TextNode::Quote { - let filler = Word::new(" ".to_string(), WordType::Normal); - line = vec![filler, word]; + + line_len = word.content().len(); + line = vec![word]; + } else { + let mut content = word.content().to_owned(); + + if width - line_len < 4 { + line_len = 0; + lines.push(line); + line = Vec::new(); + } + + let mut newline_content = content.split_off(width - line_len - 1); + if enable_hyphen && !content.ends_with("-") { + newline_content.insert(0, content.pop().unwrap()); + content.push('-'); + } + + line.push(Word::new(content, word.kind())); + lines.push(line); + + while newline_content.len() > width { + let mut next_newline_content = newline_content.split_off(width - 1); + if enable_hyphen && !newline_content.ends_with("-") { + next_newline_content.insert(0, newline_content.pop().unwrap()); + newline_content.push('-'); + } + + line = vec![Word::new(newline_content, word.kind())]; + lines.push(line); + + newline_content = next_newline_content + } + + if !newline_content.is_empty() { + line_len = newline_content.len(); + line = vec![Word::new(newline_content, word.kind())]; } else { - line = vec![word]; + line_len = 0; + line = Vec::new(); } } } + if !line.is_empty() { lines.push(line); } + + lines +} + +fn transform_paragraph(component: &mut TextComponent, width: u16) { + let width = match component.kind { + TextNode::Paragraph => width as usize, + TextNode::Task => width as usize - 4, + TextNode::Quote => width as usize - 2, + _ => unreachable!(), + }; + + let mut lines = word_wrapping(component.content.iter().flatten(), width, true); + + if component.kind() == TextNode::Quote { + let is_special_quote = !component.meta_info.is_empty(); + + for line in lines.iter_mut().skip(if is_special_quote { 1 } else { 0 }) { + line.insert(0, Word::new(" ".to_string(), WordType::Normal)); + } + } + component.height = lines.len() as u16; component.content = lines; } @@ -560,3 +612,141 @@ fn transform_list(component: &mut TextComponent, width: u16) { component.height = lines.len() as u16; component.content = lines; } + +fn transform_table(component: &mut TextComponent, width: u16) { + let content = &mut component.content; + + let column_count = component + .meta_info + .iter() + .filter(|w| w.kind() == WordType::MetaInfo(MetaData::ColumnsCount)) + .count(); + + assert!( + content.len() % column_count == 0, + "Invalid table cell distribution: content.len() = {}, column_count = {}", + content.len(), + column_count + ); + + let row_count = content.len() / column_count; + + /////////////////////////// + // Find unbalanced width // + /////////////////////////// + let widths = { + let mut widths = vec![0; column_count]; + content.chunks(column_count).for_each(|row| { + row.iter().enumerate().for_each(|(col_i, entry)| { + let len = content_entry_len(entry); + if len > widths[col_i] as usize { + widths[col_i] = len as u16; + } + }); + }); + + widths + }; + + let styling_width = column_count as u16; + let unbalanced_cells_width = widths.iter().sum::(); + + ///////////////////////////////////// + // Return if unbalanced width fits // + ///////////////////////////////////// + if width >= unbalanced_cells_width + styling_width { + component.height = (content.len() / column_count) as u16; + component.kind = TextNode::Table(widths, vec![1; component.height as usize]); + return; + } + + ////////////////////////////// + // Find overflowing columns // + ////////////////////////////// + let overflow_threshold = (width - styling_width) / column_count as u16; + let mut overflowing_columns = vec![]; + + let (overflowing_width, non_overflowing_width) = { + let mut overflowing_width = 0; + let mut non_overflowing_width = 0; + + for (column_i, column_width) in widths.iter().enumerate() { + if *column_width > overflow_threshold { + overflowing_columns.push((column_i, column_width)); + + overflowing_width += column_width; + } else { + non_overflowing_width += column_width; + } + } + + (overflowing_width, non_overflowing_width) + }; + + assert!( + !overflowing_columns.is_empty(), + "table overflow should not be handled when there are no overflowing columns" + ); + + ///////////////////////////////////////////// + // Assign new width to overflowing columns // + ///////////////////////////////////////////// + let mut available_balanced_width = width - non_overflowing_width - styling_width; + let mut available_overflowing_width = overflowing_width; + + let overflowing_column_min_width = + (available_balanced_width / (2 * overflowing_columns.len() as u16)).max(1); + + let mut widths_balanced: Vec = widths.clone(); + for (column_i, old_column_width) in overflowing_columns + .iter() + // Sorting ensures the smallest overflowing cells receive minimum area without the + // need for recalculating the larger cells + .sorted_by(|a, b| Ord::cmp(a.1, b.1)) + { + // Ensure the longest cell gets the most amount of area + let ratio = (**old_column_width as f32) / (available_overflowing_width as f32); + let mut balanced_column_width = (ratio * available_balanced_width as f32).floor() as u16; + + if balanced_column_width < overflowing_column_min_width { + balanced_column_width = overflowing_column_min_width; + available_overflowing_width -= **old_column_width; + available_balanced_width -= balanced_column_width; + } + + widths_balanced[*column_i] = balanced_column_width; + } + + //////////////////////////////////////// + // Wrap words based on balanced width // + //////////////////////////////////////// + let mut heights = vec![1; row_count]; + for (row_i, row) in content + .iter_mut() + .chunks(column_count) + .into_iter() + .enumerate() + { + for (column_i, entry) in row.into_iter().enumerate() { + let lines = word_wrapping( + entry.drain(..).as_ref(), + widths_balanced[column_i] as usize, + true, + ); + + if heights[row_i] < lines.len() as u16 { + heights[row_i] = lines.len() as u16; + } + + let _drop = std::mem::replace(entry, lines.into_iter().flatten().collect()); + } + } + + component.height = heights.iter().cloned().sum::(); + + component.kind = TextNode::Table(widths_balanced, heights); +} + +pub fn content_entry_len(words: &[Word]) -> usize { + words.iter().map(|word| word.content().len()).sum() +} diff --git a/src/nodes/word.rs b/src/nodes/word.rs index f3172d1..67995a5 100644 --- a/src/nodes/word.rs +++ b/src/nodes/word.rs @@ -57,7 +57,6 @@ impl From for WordType { | MdParseEnum::AltText | MdParseEnum::Quote | MdParseEnum::Sentence - | MdParseEnum::TableRow | MdParseEnum::Word => WordType::Normal, MdParseEnum::LinkData => WordType::LinkData, @@ -134,4 +133,12 @@ impl Word { pub fn is_renderable(&self) -> bool { !matches!(self.kind(), WordType::MetaInfo(_) | WordType::LinkData) } + + pub fn split_off(&mut self, at: usize) -> Word { + Word { + content: self.content.split_off(at), + word_type: self.word_type, + previous_type: self.previous_type, + } + } } diff --git a/src/pages/markdown_renderer.rs b/src/pages/markdown_renderer.rs index 41eb9c1..0a3ed90 100644 --- a/src/pages/markdown_renderer.rs +++ b/src/pages/markdown_renderer.rs @@ -2,7 +2,7 @@ use std::cmp; use ratatui::{ buffer::Buffer, - layout::{Alignment, Constraint, Rect}, + layout::{Alignment, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Cell, List, ListItem, Paragraph, Row, Table, Widget}, @@ -77,8 +77,6 @@ impl Widget for TextComponent { .cloned() .unwrap_or_else(|| Word::new("".to_string(), WordType::Normal)); - let table_meta = self.meta_info().to_owned(); - let area = Rect { height, y, ..area }; match kind { @@ -87,7 +85,9 @@ impl Widget for TextComponent { TextNode::Task => render_task(area, buf, self, clips, &meta_info), TextNode::List => render_list(area, buf, self, clips), TextNode::CodeBlock => render_code_block(area, buf, self, clips), - TextNode::Table => render_table(area, buf, self, clips, table_meta), + TextNode::Table(widths, heights) => { + render_table(area, buf, self, clips, widths, heights) + } TextNode::Quote => render_quote(area, buf, self, clips), TextNode::LineBreak => (), TextNode::HorizontalSeperator => render_horizontal_seperator(area, buf), @@ -376,72 +376,133 @@ fn render_table( buf: &mut Buffer, component: TextComponent, clip: Clipping, - meta_info: Vec, + widths: Vec, + heights: Vec, ) { - let column_count = meta_info.len(); + let scroll_offset = component.scroll_offset(); + let y_offset = component.y_offset(); + let height = component.height(); - let content = component.content(); + let column_count = widths.len(); + let content = component.content_owned(); let titles = content.chunks(column_count).next().unwrap().to_vec(); - - let mut widths = vec![0; column_count]; - - content.chunks(column_count).for_each(|c| { - c.iter().enumerate().for_each(|(i, c)| { - let len = c.iter().map(|c| c.content().len()).sum::() + 1; - if len > widths[i] as usize { - widths[i] = len as u16; - } - }); - }); - - let widths = widths - .into_iter() - .map(Constraint::Length) - .collect::>(); - let moved_content = content.chunks(column_count).skip(1).collect::>(); + let (start_i, stop_i) = match clip { + Clipping::Both => { + let top = scroll_offset - y_offset; + (top, top + area.height.saturating_sub(1)) + } + Clipping::Upper => { + let offset = height.saturating_sub(area.height); + (offset, height) + } + Clipping::Lower => (0, height.min(area.height).saturating_sub(1)), + Clipping::None => (0, height.saturating_sub(1)), + }; + let (start_i, stop_i) = (start_i as usize, stop_i as usize); + let header = Row::new( titles .iter() - .map(|c| Cell::from(Line::from(c.iter().map(style_word).collect::>()))) + .enumerate() + .map(|(column_i, entry)| { + let mut line = vec![]; + let mut lines = vec![]; + let mut line_len = 0; + for word in entry.iter() { + let word_len = word.content().len() as u16; + line_len += word_len; + if line_len <= widths[column_i] { + line.push(word); + } else { + lines.push(Line::from( + line.into_iter().map(style_word).collect::>(), + )); + line = vec![word]; + line_len -= widths[column_i] + } + } + + lines.push(Line::from( + line.into_iter().map(style_word).collect::>(), + )); + + Cell::from(lines) + }) .collect::>(), - ); + ) + .height(heights[0]); - let mut rows = moved_content + let mut line_i = 0; + let rows = moved_content .iter() - .map(|c| { - Row::new( - c.iter() - .map(|i| Cell::from(Line::from(i.iter().map(style_word).collect::>()))) - .collect::>(), + .enumerate() + .filter_map(|(row_i, c)| { + if (line_i + heights[row_i + 1] as usize) <= start_i { + line_i += heights[row_i + 1] as usize; + return None; + } else if stop_i <= line_i { + return None; + } + + let start_cell_line_i = start_i.saturating_sub(line_i); + let stop_cell_line_i = stop_i.saturating_sub(line_i); + let n_cell_lines = (heights[row_i + 1] as usize) + .min(stop_cell_line_i) + .saturating_sub(start_cell_line_i); + + line_i += heights[row_i + 1] as usize; + + Some( + Row::new( + c.iter() + .enumerate() + .map(|(column_i, entry)| { + let mut acc = vec![]; + let mut lines = vec![]; + let mut line_len = 0; + for word in entry.iter() { + let word_len = word.content().len() as u16; + line_len += word_len; + if line_len <= widths[column_i] { + acc.push(word); + } else { + lines.push(Line::from( + acc.into_iter().map(style_word).collect::>(), + )); + line_len = word_len; + acc = vec![word]; + } + } + + lines.push(Line::from( + acc.into_iter().map(style_word).collect::>(), + )); + + lines.append(&mut vec![ + Line::from(""); + (start_cell_line_i + n_cell_lines) + .saturating_sub(lines.len()) + ]); + + Cell::from( + lines + .splice( + start_cell_line_i..(start_cell_line_i + n_cell_lines), + vec![], + ) + .collect::>(), + ) + }) + .collect::>(), + ) + .height(n_cell_lines as u16), ) }) .collect::>(); - match clip { - Clipping::Both => { - let top = component.scroll_offset() - component.y_offset(); - rows.drain(0..top as usize); - rows.drain(area.height as usize..); - } - Clipping::Upper => { - let len = rows.len(); - let height = area.height as usize; - let offset = len.saturating_sub(height) + 1; - // panic!("offset: {}, height: {}, len: {}", offset, height, len); - if offset < len { - rows.drain(0..offset); - } - } - Clipping::Lower => { - let drain_area = cmp::min(area.height, rows.len() as u16); - rows.drain(drain_area as usize..); - } - Clipping::None => (), - } - let table = Table::new(rows, widths) .header( header.style( diff --git a/src/parser.rs b/src/parser.rs index 864663c..1da508f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -311,16 +311,22 @@ fn parse_component(parse_node: ParseNode) -> Component { MdParseEnum::Table => { let mut words = Vec::new(); - for row in parse_node.children_owned() { - if row.kind() == MdParseEnum::TableSeperator { + for cell in parse_node.children_owned() { + if cell.kind() == MdParseEnum::TableSeperator { words.push(vec![Word::new( - row.content().to_owned(), + cell.content().to_owned(), WordType::MetaInfo(MetaData::ColumnsCount), )]); continue; } let mut inner_words = Vec::new(); - for word in get_leaf_nodes(row) { + + if cell.children().is_empty() { + words.push(inner_words); + continue; + } + + for word in get_leaf_nodes(cell) { let word_type = WordType::from(word.kind()); let mut content = word.content().to_owned(); @@ -339,7 +345,10 @@ fn parse_component(parse_node: ParseNode) -> Component { } words.push(inner_words); } - Component::TextComponent(TextComponent::new_formatted(TextNode::Table, words)) + Component::TextComponent(TextComponent::new_formatted( + TextNode::Table(vec![], vec![]), + words, + )) } MdParseEnum::BlockSeperator => { @@ -355,8 +364,14 @@ fn parse_component(parse_node: ParseNode) -> Component { fn get_leaf_nodes(node: ParseNode) -> Vec { let mut leaf_nodes = Vec::new(); - if node.kind() == MdParseEnum::Link && node.content().starts_with(' ') { - let comp = ParseNode::new(MdParseEnum::Word, " ".to_owned()); + + // Insert separator information between links + if node.kind() == MdParseEnum::Link { + let comp = if node.content().starts_with(' ') { + ParseNode::new(MdParseEnum::Word, " ".to_owned()) + } else { + ParseNode::new(MdParseEnum::Word, "".to_owned()) + }; leaf_nodes.push(comp); } @@ -505,7 +520,6 @@ pub enum MdParseEnum { StrikethroughStr, Table, TableCell, - TableRow, TableSeperator, Task, TaskClosed, @@ -541,7 +555,7 @@ impl From for MdParseEnum { Rule::task_complete => Self::TaskClosed, Rule::code_line => Self::CodeBlockStr, Rule::sentence | Rule::t_sentence => Self::Sentence, - Rule::table_cell => Self::TableRow, + Rule::table_cell => Self::TableCell, Rule::table_seperator => Self::TableSeperator, Rule::u_list => Self::UnorderedList, Rule::o_list => Self::OrderedList,