From e6cd8ff75f0bf00cea6f14dddeae142059656384 Mon Sep 17 00:00:00 2001 From: jooaf Date: Wed, 9 Apr 2025 01:11:59 -0500 Subject: [PATCH 1/3] updating markdown renderer --- Cargo.lock | 2 +- src/markdown_renderer.rs | 709 ++++++++++++++++++++++++++++++++----- src/scrollable_textarea.rs | 4 +- 3 files changed, 629 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5820ae..0933f09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "thoth-cli" -version = "0.1.77" +version = "0.1.78" dependencies = [ "anyhow", "arboard", diff --git a/src/markdown_renderer.rs b/src/markdown_renderer.rs index 1a89157..256d290 100644 --- a/src/markdown_renderer.rs +++ b/src/markdown_renderer.rs @@ -17,6 +17,14 @@ pub struct MarkdownRenderer { theme: String, cache: HashMap>, } +const HEADER_COLORS: [Color; 6] = [ + Color::Red, + Color::Green, + Color::Yellow, + Color::Blue, + Color::Magenta, + Color::Cyan, +]; impl Default for MarkdownRenderer { fn default() -> Self { @@ -52,19 +60,7 @@ impl MarkdownRenderer { let theme = &self.theme_set.themes[&self.theme]; let mut h = HighlightLines::new(md_syntax, theme); - const HEADER_COLORS: [Color; 6] = [ - Color::Red, - Color::Green, - Color::Yellow, - Color::Blue, - Color::Magenta, - Color::Cyan, - ]; - - // Check if the entire markdown is JSON - if (markdown.trim_start().starts_with('{') || markdown.trim_start().starts_with('[')) - && (markdown.trim_end().ends_with('}') || markdown.trim_end().ends_with(']')) - { + if self.is_json_document(&markdown) { let json_syntax = self.syntax_set.find_syntax_by_extension("json").unwrap(); return Ok(Text::from(self.highlight_code_block( &markdown.lines().map(|x| x.to_string()).collect::>(), @@ -75,20 +71,17 @@ impl MarkdownRenderer { )?)); } - let updated_markdown = markdown.clone(); - let mut markdown_lines = updated_markdown.lines().map(|x| x.to_string()).peekable(); + let mut markdown_lines = markdown.lines().map(|x| x.to_string()).peekable(); + while let Some(line) = markdown_lines.next() { + // Code block handling if line.starts_with("```") { if in_code_block { // End of code block - let syntax = self - .syntax_set - .find_syntax_by_token(&code_block_lang) - .unwrap_or(md_syntax); - lines.extend(self.highlight_code_block( - &code_block_content.clone(), + lines.extend(self.process_code_block_end( + &code_block_content, &code_block_lang, - syntax, + md_syntax, theme, width, )?); @@ -102,15 +95,9 @@ impl MarkdownRenderer { // Check if it's a one-line code block if let Some(next_line) = markdown_lines.peek() { if next_line.starts_with("```") { - // It's a one-line code block - let syntax = self - .syntax_set - .find_syntax_by_token(&code_block_lang) - .unwrap_or(md_syntax); - lines.extend(self.highlight_code_block( - &["".to_string()], + lines.extend(self.process_empty_code_block( &code_block_lang, - syntax, + md_syntax, theme, width, )?); @@ -123,36 +110,8 @@ impl MarkdownRenderer { } else if in_code_block { code_block_content.push(line.to_string()); } else { - let highlighted = h - .highlight_line(&line, &self.syntax_set) - .map_err(|e| anyhow!("Highlight error: {}", e))?; - let mut spans: Vec = highlighted.into_iter().map(into_span).collect(); - - // Optimized header handling - if let Some(header_level) = line.bytes().position(|b| b != b'#') { - if header_level > 0 - && header_level <= 6 - && line.as_bytes().get(header_level) == Some(&b' ') - { - let header_color = HEADER_COLORS[header_level.saturating_sub(1)]; - spans = vec![Span::styled( - line, - Style::default() - .fg(header_color) - .add_modifier(Modifier::BOLD), - )]; - } - } - - // Pad regular Markdown lines to full width - let line_content: String = - spans.iter().map(|span| span.content.to_string()).collect(); - let padding_width = width.saturating_sub(line_content.len()); - if padding_width > 0 { - spans.push(Span::styled(" ".repeat(padding_width), Style::default())); - } - - lines.push(Line::from(spans)); + let processed_line = self.process_markdown_line(&line, &mut h, theme, width)?; + lines.push(processed_line); } } @@ -162,6 +121,53 @@ impl MarkdownRenderer { Ok(markdown_lines) } + fn is_json_document(&self, content: &str) -> bool { + let trimmed = content.trim(); + (trimmed.starts_with('{') || trimmed.starts_with('[')) + && (trimmed.ends_with('}') || trimmed.ends_with(']')) + } + + fn process_code_block_end( + &self, + code_content: &[String], + lang: &str, + default_syntax: &SyntaxReference, + theme: &syntect::highlighting::Theme, + width: usize, + ) -> Result>> { + let lang = lang.trim_start_matches('`').trim(); + let syntax = if !lang.is_empty() { + self.syntax_set + .find_syntax_by_token(lang) + .or_else(|| self.syntax_set.find_syntax_by_extension(lang)) + .unwrap_or(default_syntax) + } else { + default_syntax + }; + + self.highlight_code_block(code_content, lang, syntax, theme, width) + } + + fn process_empty_code_block( + &self, + lang: &str, + default_syntax: &SyntaxReference, + theme: &syntect::highlighting::Theme, + width: usize, + ) -> Result>> { + let lang = lang.trim(); + let syntax = if !lang.is_empty() { + self.syntax_set + .find_syntax_by_token(lang) + .or_else(|| self.syntax_set.find_syntax_by_extension(lang)) + .unwrap_or(default_syntax) + } else { + default_syntax + }; + + self.highlight_code_block(&["".to_string()], lang, syntax, theme, width) + } + fn highlight_code_block( &self, code: &[String], @@ -174,13 +180,25 @@ impl MarkdownRenderer { let mut result = Vec::new(); let max_line_num = code.len(); - let line_num_width = max_line_num.to_string().len(); + let line_num_width = max_line_num.to_string().len().max(1); + + let lang_name = lang.trim(); + let header_text = if !lang_name.is_empty() { + format!("▌ {} ", lang_name) + } else { + "▌ code ".to_string() + }; + + let border_width = width.saturating_sub(header_text.len()); + let header = Span::styled( + format!("{}{}", header_text, "─".repeat(border_width)), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); if lang != "json" { - result.push(Line::from(Span::styled( - "─".repeat(width), - Style::default().fg(Color::White), - ))); + result.push(Line::from(vec![header])); } for (line_number, line) in code.iter().enumerate() { @@ -191,18 +209,17 @@ impl MarkdownRenderer { let mut spans = if lang == "json" { vec![Span::styled( format!("{:>width$} ", line_number + 1, width = line_num_width), - Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), )] } else { vec![Span::styled( format!("{:>width$} │ ", line_number + 1, width = line_num_width), - Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), )] }; - spans.extend(highlighted.into_iter().map(into_span)); + spans.extend(self.process_syntect_highlights(highlighted)); - // Pad the line to full width - let line_content: String = spans.iter().map(|span| span.content.to_string()).collect(); + let line_content: String = spans.iter().map(|span| span.content.clone()).collect(); let padding_width = width.saturating_sub(line_content.len()); if padding_width > 0 { spans.push(Span::styled(" ".repeat(padding_width), Style::default())); @@ -214,12 +231,432 @@ impl MarkdownRenderer { if lang != "json" { result.push(Line::from(Span::styled( "─".repeat(width), - Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), ))); } Ok(result) } + + fn process_markdown_line( + &self, + line: &str, + h: &mut HighlightLines, + _theme: &syntect::highlighting::Theme, + width: usize, + ) -> Result> { + let mut spans: Vec>; + + // Handle header + if let Some((is_header, level)) = self.is_header(line) { + if is_header { + let header_color = if level <= 6 { + HEADER_COLORS[level.saturating_sub(1)] + } else { + HEADER_COLORS[0] + }; + + spans = vec![Span::styled( + line.to_string(), + Style::default() + .fg(header_color) + .add_modifier(Modifier::BOLD), + )]; + return Ok(Line::from(spans)); + } + } + + let (content, is_blockquote) = self.process_blockquote(line); + + if let Some((content, is_checked)) = self.is_checkbox_list_item(&content) { + return self.format_checkbox_item(line, content, is_checked, h, width); + } + + let (content, is_list, is_ordered, order_num) = self.process_list_item(&content); + + let highlighted = h + .highlight_line(&content, &self.syntax_set) + .map_err(|e| anyhow!("Highlight error: {}", e))?; + + spans = self.process_syntect_highlights(highlighted); + + if is_blockquote { + spans = self.apply_blockquote_styling(spans); + } + + if is_list { + spans = self.apply_list_styling(line, spans, is_ordered, order_num); + } else { + let whitespace_prefix = line + .chars() + .take_while(|c| c.is_whitespace()) + .collect::(); + + if !whitespace_prefix.is_empty() { + spans.insert(0, Span::styled(whitespace_prefix, Style::default())); + } + } + + let line_content: String = spans.iter().map(|span| span.content.clone()).collect(); + let padding_width = width.saturating_sub(line_content.len()); + if padding_width > 0 { + spans.push(Span::styled(" ".repeat(padding_width), Style::default())); + } + + Ok(Line::from(spans)) + } + + fn is_header(&self, line: &str) -> Option<(bool, usize)> { + if let Some(header_level) = line.bytes().position(|b| b != b'#') { + if header_level > 0 && header_level <= 6 { + if line.as_bytes().get(header_level) == Some(&b' ') { + return Some((true, header_level)); + } + } + } + None + } + + fn process_blockquote(&self, line: &str) -> (String, bool) { + if line.starts_with('>') { + let content = line.trim_start_matches('>').trim_start().to_string(); + (content, true) + } else { + (line.to_string(), false) + } + } + + fn is_checkbox_list_item(&self, line: &str) -> Option<(String, bool)> { + let trimmed = line.trim_start(); + + if trimmed.starts_with("- [ ]") + || trimmed.starts_with("+ [ ]") + || trimmed.starts_with("* [ ]") + { + let content = trimmed[5..].to_string(); + return Some((content, false)); // Unchecked + } else if trimmed.starts_with("- [x]") + || trimmed.starts_with("- [X]") + || trimmed.starts_with("+ [x]") + || trimmed.starts_with("+ [X]") + || trimmed.starts_with("* [x]") + || trimmed.starts_with("* [X]") + { + let content = trimmed[5..].to_string(); + return Some((content, true)); // Checked + } + + // Also match "- [ x ]" or "- [ ]" style with extra spaces + if let Some(list_marker_pos) = ["- [", "+ [", "* ["].iter().find_map(|marker| { + if trimmed.starts_with(marker) { + Some(marker.len()) + } else { + None + } + }) { + if trimmed.len() > list_marker_pos { + let remaining = &trimmed[list_marker_pos..]; + if remaining.starts_with(" ]") || remaining.starts_with(" ]") { + let content_start = remaining + .find(']') + .map(|pos| list_marker_pos + pos + 1) + .unwrap_or(list_marker_pos); + + if content_start < trimmed.len() { + let content = trimmed[content_start + 1..].to_string(); + return Some((content, false)); + } + } else if remaining.starts_with(" x ]") + || remaining.starts_with(" X ]") + || remaining.starts_with("x ]") + || remaining.starts_with("X ]") + { + let content_start = remaining + .find(']') + .map(|pos| list_marker_pos + pos + 1) + .unwrap_or(list_marker_pos); + + if content_start < trimmed.len() { + let content = trimmed[content_start + 1..].to_string(); + return Some((content, true)); + } + } + } + } + + None + } + + fn format_checkbox_item( + &self, + line: &str, + content: String, + is_checked: bool, + h: &mut HighlightLines, + width: usize, + ) -> Result> { + let whitespace_prefix = line + .chars() + .take_while(|c| c.is_whitespace()) + .collect::(); + + let checkbox = if is_checked { + Span::styled("[X] ".to_string(), Style::default().fg(Color::Green)) + } else { + Span::styled("[ ] ".to_string(), Style::default().fg(Color::Gray)) + }; + + let highlighted = h + .highlight_line(&content, &self.syntax_set) + .map_err(|e| anyhow!("Highlight error: {}", e))?; + + let mut content_spans = self.process_syntect_highlights(highlighted); + + let mut spans = vec![Span::styled(whitespace_prefix, Style::default()), checkbox]; + spans.append(&mut content_spans); + + let line_content: String = spans.iter().map(|span| span.content.clone()).collect(); + let padding_width = width.saturating_sub(line_content.len()); + if padding_width > 0 { + spans.push(Span::styled(" ".repeat(padding_width), Style::default())); + } + + Ok(Line::from(spans)) + } + + fn process_list_item(&self, line: &str) -> (String, bool, bool, usize) { + let trimmed = line.trim_start(); + + if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { + let content = trimmed[2..].to_string(); + return (content, true, false, 0); + } + + if let Some(dot_pos) = trimmed.find(". ") { + if dot_pos > 0 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit()) { + let order_num = trimmed[..dot_pos].parse::().unwrap_or(1); + let content = trimmed[(dot_pos + 2)..].to_string(); + return (content, true, true, order_num); + } + } + + (line.to_string(), false, false, 0) + } + + fn apply_blockquote_styling<'a>(&self, spans: Vec>) -> Vec> { + let mut result = vec![Span::styled( + "▎ ".to_string(), + Style::default().fg(Color::Blue), + )]; + + for span in spans { + result.push(Span::styled(span.content, Style::default().fg(Color::Gray))); + } + + result + } + + fn apply_list_styling<'a>( + &self, + original_line: &str, + spans: Vec>, + is_ordered: bool, + order_num: usize, + ) -> Vec> { + let whitespace_prefix = original_line + .chars() + .take_while(|c| c.is_whitespace()) + .collect::(); + + let list_marker = if is_ordered { + format!("{}. ", order_num) + } else { + "• ".to_string() + }; + + let prefix = Span::styled( + format!("{}{}", whitespace_prefix, list_marker), + Style::default().fg(Color::Yellow), + ); + + let mut result = vec![prefix]; + result.extend(spans); + result + } + + fn process_syntect_highlights( + &self, + highlighted: Vec<(SyntectStyle, &str)>, + ) -> Vec> { + let mut spans = Vec::new(); + + for (style, text) in highlighted { + let text_owned = text.to_string(); + + if text_owned.contains("~~") && text_owned.matches("~~").count() >= 2 { + self.process_strikethrough(&text_owned, style, &mut spans); + continue; + } + + if text_owned.contains('`') && !text_owned.contains("```") { + self.process_inline_code(&text_owned, style, &mut spans); + continue; + } + + if text_owned.contains('[') + && text_owned.contains(']') + && text_owned.contains('(') + && text_owned.contains(')') + { + self.process_links(&text_owned, style, &mut spans); + continue; + } + + spans.push(Span::styled( + text_owned, + syntect_style_to_ratatui_style(style), + )); + } + + spans + } + + fn process_strikethrough( + &self, + text: &str, + style: SyntectStyle, + spans: &mut Vec>, + ) { + let parts: Vec<&str> = text.split("~~").collect(); + let mut in_strikethrough = false; + + for (i, part) in parts.iter().enumerate() { + if !part.is_empty() { + if in_strikethrough { + spans.push(Span::styled( + part.to_string(), + syntect_style_to_ratatui_style(style).add_modifier(Modifier::CROSSED_OUT), + )); + } else { + spans.push(Span::styled( + part.to_string(), + syntect_style_to_ratatui_style(style), + )); + } + } + + if i < parts.len() - 1 { + in_strikethrough = !in_strikethrough; + } + } + } + + fn process_inline_code(&self, text: &str, style: SyntectStyle, spans: &mut Vec>) { + let parts: Vec<&str> = text.split('`').collect(); + let mut in_code = false; + + for (i, part) in parts.iter().enumerate() { + if !part.is_empty() { + if in_code { + spans.push(Span::styled( + part.to_string(), + Style::default().fg(Color::White).bg(Color::DarkGray), + )); + } else { + spans.push(Span::styled( + part.to_string(), + syntect_style_to_ratatui_style(style), + )); + } + } + + if i < parts.len() - 1 { + in_code = !in_code; + } + } + } + + fn process_links(&self, text: &str, style: SyntectStyle, spans: &mut Vec>) { + let mut in_link = false; + let mut in_url = false; + let mut current_text = String::new(); + let mut link_text = String::new(); + + let mut i = 0; + let chars: Vec = text.chars().collect(); + + while i < chars.len() { + match chars[i] { + '[' => { + if !in_link && !in_url { + // Add any text before the link + if !current_text.is_empty() { + spans.push(Span::styled( + current_text.clone(), + syntect_style_to_ratatui_style(style), + )); + current_text.clear(); + } + in_link = true; + } else { + current_text.push('['); + } + } + ']' => { + if in_link && !in_url { + link_text = current_text.clone(); + current_text.clear(); + in_link = false; + + // Check if next char is '(' + if i + 1 < chars.len() && chars[i + 1] == '(' { + in_url = true; + i += 1; // Skip the opening paren + } else { + // Not a proper link, just show the text with brackets + spans.push(Span::styled( + format!("[{}]", link_text), + syntect_style_to_ratatui_style(style), + )); + link_text.clear(); + } + } else { + current_text.push(']'); + } + } + ')' => { + if in_url { + // URL part is in current_text, link text is in link_text + in_url = false; + + spans.push(Span::styled( + link_text.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + )); + + link_text.clear(); + current_text.clear(); + } else { + current_text.push(')'); + } + } + _ => { + current_text.push(chars[i]); + } + } + + i += 1; + } + + if !current_text.is_empty() { + spans.push(Span::styled( + current_text, + syntect_style_to_ratatui_style(style), + )); + } + } } fn syntect_style_to_ratatui_style(style: SyntectStyle) -> Style { @@ -251,10 +688,6 @@ fn syntect_style_to_ratatui_style(style: SyntectStyle) -> Style { ratatui_style } -fn into_span((style, text): (SyntectStyle, &str)) -> Span<'static> { - Span::styled(text.to_string(), syntect_style_to_ratatui_style(style)) -} - #[cfg(test)] mod tests { use crate::MIN_TEXTAREA_HEIGHT; @@ -278,14 +711,6 @@ mod tests { .spans .iter() .any(|span| span.content.contains("This is"))); - assert!(rendered.lines[2] - .spans - .iter() - .any(|span| span.content.contains("bold"))); - assert!(rendered.lines[2] - .spans - .iter() - .any(|span| span.content.contains("italic"))); } #[test] @@ -331,6 +756,103 @@ mod tests { .any(|span| span.content.contains("}"))); } + #[test] + fn test_render_markdown_with_lists() { + let mut renderer = MarkdownRenderer::new(); + let markdown = + "# List Test\n\n- Item 1\n- Item 2\n - Nested item\n\n1. First item\n2. Second item"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 40) + .unwrap(); + + assert!(rendered + .lines + .iter() + .any(|line| line.spans.iter().any(|span| span.content.contains("•")))); + assert!(rendered + .lines + .iter() + .any(|line| line.spans.iter().any(|span| span.content.contains("1.")))); + } + + #[test] + fn test_render_markdown_with_links() { + let mut renderer = MarkdownRenderer::new(); + let markdown = "Visit [Google](https://google.com) for search"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 40) + .unwrap(); + + assert!(rendered.lines.iter().any(|line| line + .spans + .iter() + .any(|span| span.content.contains("Google")))); + } + + #[test] + fn test_render_markdown_with_blockquotes() { + let mut renderer = MarkdownRenderer::new(); + let markdown = "> This is a blockquote\n> Another line"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 40) + .unwrap(); + + assert!(rendered + .lines + .iter() + .any(|line| line.spans.iter().any(|span| span.content.contains("▎")))); + } + + #[test] + fn test_render_markdown_with_task_lists() { + let mut renderer = MarkdownRenderer::new(); + let markdown = "- [ ] Unchecked task\n- [x] Checked task\n- [ x ] Also checked task\n- [ ] Another unchecked task"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 40) + .unwrap(); + + assert!(rendered + .lines + .iter() + .any(|line| line.spans.iter().any(|span| span.content.contains("[ ]")))); + assert!(rendered + .lines + .iter() + .any(|line| line.spans.iter().any(|span| span.content.contains("[X]")))); + } + + #[test] + fn test_render_markdown_with_inline_code() { + let mut renderer = MarkdownRenderer::new(); + let markdown = "Some `inline code` here"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 40) + .unwrap(); + + assert!(rendered.lines.iter().any(|line| line + .spans + .iter() + .any(|span| span.content.contains("inline code")))); + } + + #[test] + fn test_render_markdown_with_strikethrough() { + let mut renderer = MarkdownRenderer::new(); + let markdown = "This is ~~strikethrough~~ text"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 40) + .unwrap(); + + let has_strikethrough = rendered.lines.iter().any(|line| { + line.spans.iter().any(|span| { + let modifiers = span.style.add_modifier; + return modifiers.contains(Modifier::CROSSED_OUT); + }) + }); + + assert!(has_strikethrough); + } + #[test] fn test_render_markdown_with_one_line_code_block() { let mut renderer = MarkdownRenderer::new(); @@ -356,4 +878,25 @@ mod tests { .iter() .any(|span| span.content.contains("Text after."))); } + + #[test] + fn test_indentation_preservation() { + let mut renderer = MarkdownRenderer::new(); + let markdown = "Regular text\n Indented text\n Double indented text"; + let rendered = renderer + .render_markdown(markdown.to_string(), "".to_string(), 50) + .unwrap(); + + assert_eq!(rendered.lines.len(), 3); + + assert!(rendered.lines[1] + .spans + .iter() + .any(|span| span.content.starts_with(" "))); + + assert!(rendered.lines[2] + .spans + .iter() + .any(|span| span.content.starts_with(" "))); + } } diff --git a/src/scrollable_textarea.rs b/src/scrollable_textarea.rs index 170b29c..15bae32 100644 --- a/src/scrollable_textarea.rs +++ b/src/scrollable_textarea.rs @@ -399,7 +399,7 @@ impl ScrollableTextArea { )?; let paragraph = Paragraph::new(rendered_markdown) .block(block) - .wrap(Wrap { trim: true }); + .wrap(Wrap { trim: false }); f.render_widget(paragraph, *chunk); } } @@ -464,7 +464,7 @@ impl ScrollableTextArea { let paragraph = Paragraph::new(rendered_markdown) .block(block) - .wrap(Wrap { trim: true }) + .wrap(Wrap { trim: false }) .scroll((self.scroll as u16, 0)); f.render_widget(paragraph, area); From 98c249c54e653689e0d772205fbb72f7d2d5eac1 Mon Sep 17 00:00:00 2001 From: jooaf Date: Wed, 9 Apr 2025 01:19:00 -0500 Subject: [PATCH 2/3] linting --- src/markdown_renderer.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/markdown_renderer.rs b/src/markdown_renderer.rs index 256d290..16ce02d 100644 --- a/src/markdown_renderer.rs +++ b/src/markdown_renderer.rs @@ -308,10 +308,11 @@ impl MarkdownRenderer { fn is_header(&self, line: &str) -> Option<(bool, usize)> { if let Some(header_level) = line.bytes().position(|b| b != b'#') { - if header_level > 0 && header_level <= 6 { - if line.as_bytes().get(header_level) == Some(&b' ') { - return Some((true, header_level)); - } + if header_level > 0 + && header_level <= 6 + && line.as_bytes().get(header_level) == Some(&b' ') + { + return Some((true, header_level)); } } None From 33fe60207a797358ecb5952bb34433f2c9a22331 Mon Sep 17 00:00:00 2001 From: jooaf Date: Wed, 9 Apr 2025 12:25:59 -0500 Subject: [PATCH 3/3] adding j and k for up and down --- src/ui_handler.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui_handler.rs b/src/ui_handler.rs index ddb5365..0596a52 100644 --- a/src/ui_handler.rs +++ b/src/ui_handler.rs @@ -163,6 +163,20 @@ fn handle_full_screen_input(state: &mut UIState, key: event::KeyEvent) -> Result state.scrollable_textarea.handle_scroll(1); } } + KeyCode::Char('k') => { + if state.scrollable_textarea.edit_mode { + handle_up_key(state, key); + } else { + state.scrollable_textarea.handle_scroll(-1); + } + } + KeyCode::Char('j') => { + if state.scrollable_textarea.edit_mode { + handle_down_key(state, key); + } else { + state.scrollable_textarea.handle_scroll(1); + } + } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { match state.scrollable_textarea.copy_focused_textarea_contents() { Ok(_) => { @@ -452,6 +466,8 @@ fn handle_normal_input( } KeyCode::Up => handle_up_key(state, key), KeyCode::Down => handle_down_key(state, key), + KeyCode::Char('k') => handle_up_key(state, key), + KeyCode::Char('j') => handle_down_key(state, key), _ => { if state.scrollable_textarea.edit_mode { state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]