diff options
| author | sigoden <sigoden@gmail.com> | 2023-10-30 10:07:01 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-10-30 10:07:01 +0800 |
| commit | b276dfedd2d13de9918b9b87a3d543e7b5eca95e (patch) | |
| tree | 33e1f864ef38e45ebc297e15ce519ee8105b8757 /src/render/markdown.rs | |
| parent | 632a6fcc0bfb5b33858cd0fa3876e43ce2073cb3 (diff) | |
| download | aichat-b276dfedd2d13de9918b9b87a3d543e7b5eca95e.tar.gz | |
feat: support textwrap (#171)
* feat: support textwrap
* improve cmd_render_stream
* done cmd_render_stream
* add `-w` alias to `--wrap`
* done repl_render_stream
* add `config.wrap_code`
* remove cached config.wrap_width
* fix unxpected duplicate lines on kitty
* refactor markdown render
* improve render
* fix test
Diffstat (limited to 'src/render/markdown.rs')
| -rw-r--r-- | src/render/markdown.rs | 272 |
1 files changed, 213 insertions, 59 deletions
diff --git a/src/render/markdown.rs b/src/render/markdown.rs index 66bb601..4866f3d 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -1,4 +1,6 @@ +use anyhow::{anyhow, Context, Result}; use crossterm::style::{Color, Stylize}; +use crossterm::terminal; use lazy_static::lazy_static; use std::collections::HashMap; use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme}; @@ -23,115 +25,175 @@ lazy_static! { #[allow(clippy::module_name_repetitions)] pub struct MarkdownRender { + options: RenderOptions, syntax_set: SyntaxSet, - md_theme: Theme, - code_color: Color, + md_theme: Option<Theme>, + code_color: Option<Color>, md_syntax: SyntaxReference, code_syntax: Option<SyntaxReference>, prev_line_type: LineType, + wrap_width: Option<u16>, } impl MarkdownRender { - pub fn new(light_theme: bool) -> Self { - let syntax_set: SyntaxSet = - bincode::deserialize_from(SYNTAXES).expect("invalid syntaxes binary"); - let md_theme: Theme = if light_theme { - bincode::deserialize_from(MD_THEME_LIGHT).expect("invalid theme binary") - } else { - bincode::deserialize_from(MD_THEME).expect("invalid theme binary") + pub fn init(options: RenderOptions) -> Result<Self> { + let syntax_set: SyntaxSet = bincode::deserialize_from(SYNTAXES) + .with_context(|| "MarkdownRender: invalid syntaxes binary")?; + + let md_theme: Option<Theme> = match (options.highlight, options.light_theme) { + (false, _) => None, + (true, false) => Some( + bincode::deserialize_from(MD_THEME) + .with_context(|| "MarkdownRender: invalid theme binary")?, + ), + (true, true) => Some( + bincode::deserialize_from(MD_THEME_LIGHT) + .expect("MarkdownRender: invalid theme binary"), + ), }; - let code_color = get_code_color(&md_theme); + let code_color = md_theme.as_ref().map(get_code_color); let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone(); let line_type = LineType::Normal; - Self { + let wrap_width = match options.wrap.as_deref() { + None => None, + Some(value) => match terminal::size() { + Ok((columns, _)) => { + if value == "auto" { + Some(columns) + } else { + let value = value + .parse::<u16>() + .map_err(|_| anyhow!("Invalid wrap value"))?; + Some(columns.min(value)) + } + } + Err(_) => None, + }, + }; + Ok(Self { syntax_set, md_theme, code_color, md_syntax, code_syntax: None, prev_line_type: line_type, - } + wrap_width, + options, + }) + } + + pub(crate) const fn is_code(&self) -> bool { + matches!( + self.prev_line_type, + LineType::CodeBegin | LineType::CodeInner + ) } - pub fn render_block(&mut self, src: &str) -> String { - src.split('\n') - .map(|line| { - self.render_line_impl(line) - .unwrap_or_else(|| line.to_string()) - }) + pub fn render(&mut self, text: &str) -> String { + text.split('\n') + .map(|line| self.render_line_mut(line)) .collect::<Vec<String>>() .join("\n") } pub fn render_line(&self, line: &str) -> String { - let output = if self.is_code_block() && detect_code_block(line).is_none() { - self.render_code_line(line) + let (_, code_syntax, is_code) = self.check_line(line); + if is_code { + self.highlint_code_line(line, &code_syntax) } else { - self.render_line_inner(line, &self.md_syntax) - }; - output.unwrap_or_else(|| line.to_string()) + self.highligh_line(line, &self.md_syntax, false) + } } - pub const fn is_code_block(&self) -> bool { - matches!( - self.prev_line_type, - LineType::CodeBegin | LineType::CodeInner - ) + fn render_line_mut(&mut self, line: &str) -> String { + let (line_type, code_syntax, is_code) = self.check_line(line); + let output = if is_code { + self.highlint_code_line(line, &code_syntax) + } else { + self.highligh_line(line, &self.md_syntax, false) + }; + self.prev_line_type = line_type; + self.code_syntax = code_syntax; + output } - fn render_line_impl(&mut self, line: &str) -> Option<String> { + fn check_line(&self, line: &str) -> (LineType, Option<SyntaxReference>, bool) { + let mut line_type = self.prev_line_type; + let mut code_syntax = self.code_syntax.clone(); + let mut is_code = false; if let Some(lang) = detect_code_block(line) { - match self.prev_line_type { + match line_type { LineType::Normal | LineType::CodeEnd => { - self.prev_line_type = LineType::CodeBegin; - self.code_syntax = if lang.is_empty() { + line_type = LineType::CodeBegin; + code_syntax = if lang.is_empty() { None } else { self.find_syntax(&lang).cloned() }; } LineType::CodeBegin | LineType::CodeInner => { - self.prev_line_type = LineType::CodeEnd; - self.code_syntax = None; + line_type = LineType::CodeEnd; + code_syntax = None; } } - self.render_line_inner(line, &self.md_syntax) } else { - match self.prev_line_type { - LineType::Normal => self.render_line_inner(line, &self.md_syntax), + match line_type { + LineType::Normal => {} LineType::CodeEnd => { - self.prev_line_type = LineType::Normal; - self.render_line_inner(line, &self.md_syntax) + line_type = LineType::Normal; } LineType::CodeBegin => { - if self.code_syntax.is_none() { + if code_syntax.is_none() { if let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) { - self.code_syntax = Some(syntax.clone()); + code_syntax = Some(syntax.clone()); } } - self.prev_line_type = LineType::CodeInner; - self.render_code_line(line) + line_type = LineType::CodeInner; + is_code = true; + } + LineType::CodeInner => { + is_code = true; } - LineType::CodeInner => self.render_code_line(line), } } + (line_type, code_syntax, is_code) } - fn render_line_inner(&self, line: &str, syntax: &SyntaxReference) -> Option<String> { + fn highligh_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String { let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); - let trimed_line = &line[ws.len()..]; - let mut highlighter = HighlightLines::new(syntax, &self.md_theme); - let ranges = highlighter - .highlight_line(trimed_line, &self.syntax_set) - .ok()?; - Some(format!("{ws}{}", as_terminal_escaped(&ranges))) - } - - fn render_code_line(&self, line: &str) -> Option<String> { - self.code_syntax.as_ref().map_or_else( - || Some(format!("{}", line.with(self.code_color))), - |syntax| self.render_line_inner(line, syntax), - ) + let trimed_line: &str = &line[ws.len()..]; + let mut line_highlighted = None; + if let Some(theme) = &self.md_theme { + let mut highlighter = HighlightLines::new(syntax, theme); + if let Ok(ranges) = highlighter.highlight_line(trimed_line, &self.syntax_set) { + line_highlighted = Some(format!("{ws}{}", as_terminal_escaped(&ranges))) + } + } + let line = line_highlighted.unwrap_or_else(|| line.into()); + self.wrap_line(line, is_code) + } + + fn highlint_code_line(&self, line: &str, code_syntax: &Option<SyntaxReference>) -> String { + if let Some(syntax) = code_syntax { + self.highligh_line(line, syntax, true) + } else { + let line = match self.code_color { + Some(color) => line.with(color).to_string(), + None => line.to_string(), + }; + self.wrap_line(line, true) + } + } + + fn wrap_line(&self, line: String, is_code: bool) -> String { + if let Some(width) = self.wrap_width { + if is_code && !self.options.wrap_code { + return line; + } + textwrap::wrap(&line, width as usize).join("\n") + } else { + line + } } fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> { @@ -146,6 +208,30 @@ impl MarkdownRender { } } +#[derive(Debug, Clone, Default)] +pub struct RenderOptions { + pub highlight: bool, + pub light_theme: bool, + pub wrap: Option<String>, + pub wrap_code: bool, +} + +impl RenderOptions { + pub(crate) fn new( + highlight: bool, + light_theme: bool, + wrap: Option<String>, + wrap_code: bool, + ) -> Self { + Self { + highlight, + light_theme, + wrap, + wrap_code, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineType { Normal, @@ -222,6 +308,44 @@ fn get_code_color(theme: &Theme) -> Color { mod tests { use super::*; + const TEXT: &str = r#" +To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: + +```rust +use std::fs::File; + +fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> { + todo!() +} +``` +"#; + const TEXT_NO_WRAP_CODE: &str = r#" +To unzip a file in Rust, you can use the `zip` crate. Here's an example code +that shows how to unzip a file: + +```rust +use std::fs::File; + +fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> { + todo!() +} +``` +"#; + + const TEXT_WRAP_ALL: &str = r#" +To unzip a file in Rust, you can use the `zip` crate. Here's an example code +that shows how to unzip a file: + +```rust +use std::fs::File; + +fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn +std::error::Error>> { + todo!() +} +``` +"#; + #[test] fn test_assets() { let syntax_set: SyntaxSet = @@ -233,7 +357,37 @@ mod tests { #[test] fn test_render() { - let render = MarkdownRender::new(true); + let options = RenderOptions::default(); + let render = MarkdownRender::init(options).unwrap(); assert!(render.find_syntax("csharp").is_some()); } + + #[test] + fn no_theme() { + let options = RenderOptions::default(); + let mut render = MarkdownRender::init(options).unwrap(); + let output = render.render(TEXT); + assert_eq!(TEXT, output); + } + + #[test] + fn no_wrap_code() { + let options = RenderOptions::default(); + let mut render = MarkdownRender::init(options).unwrap(); + render.wrap_width = Some(80); + let output = render.render(TEXT); + assert_eq!(TEXT_NO_WRAP_CODE, output); + } + + #[test] + fn wrap_all() { + let options = RenderOptions { + wrap_code: true, + ..Default::default() + }; + let mut render = MarkdownRender::init(options).unwrap(); + render.wrap_width = Some(80); + let output = render.render(TEXT); + assert_eq!(TEXT_WRAP_ALL, output); + } } |
