mirror of https://github.com/sigoden/aichat
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
235 lines
7.6 KiB
Rust
235 lines
7.6 KiB
Rust
use crossterm::style::{Color, Stylize};
|
|
use lazy_static::lazy_static;
|
|
use std::collections::HashMap;
|
|
use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme};
|
|
use syntect::parsing::SyntaxSet;
|
|
use syntect::{easy::HighlightLines, parsing::SyntaxReference};
|
|
|
|
/// Monokai Extended
|
|
const MD_THEME: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bin");
|
|
const MD_THEME_LIGHT: &[u8] = include_bytes!("../../assets/monokai-extended-light.theme.bin");
|
|
/// Comes from https://github.com/sharkdp/bat/raw/5e77ca37e89c873e4490b42ff556370dc5c6ba4f/assets/syntaxes.bin
|
|
const SYNTAXES: &[u8] = include_bytes!("../../assets/syntaxes.bin");
|
|
|
|
lazy_static! {
|
|
static ref LANGE_MAPS: HashMap<String, String> = {
|
|
let mut m = HashMap::new();
|
|
m.insert("csharp".into(), "C#".into());
|
|
m.insert("php".into(), "PHP Source".into());
|
|
m
|
|
};
|
|
}
|
|
|
|
pub struct MarkdownRender {
|
|
syntax_set: SyntaxSet,
|
|
md_theme: Theme,
|
|
code_color: Color,
|
|
md_syntax: SyntaxReference,
|
|
code_syntax: Option<SyntaxReference>,
|
|
prev_line_type: LineType,
|
|
}
|
|
|
|
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")
|
|
};
|
|
let code_color = get_code_color(&md_theme);
|
|
let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone();
|
|
let line_type = LineType::Normal;
|
|
Self {
|
|
syntax_set,
|
|
md_theme,
|
|
code_color,
|
|
md_syntax,
|
|
code_syntax: None,
|
|
prev_line_type: line_type,
|
|
}
|
|
}
|
|
|
|
pub fn render(&mut self, src: &str) -> String {
|
|
src.split('\n')
|
|
.map(|line| self.render_line(line).unwrap_or_else(|| line.to_string()))
|
|
.collect::<Vec<String>>()
|
|
.join("\n")
|
|
}
|
|
|
|
pub fn render_line_stateless(&self, line: &str) -> String {
|
|
let output = if self.is_code_block() && detect_code_block(line).is_none() {
|
|
self.render_code_line(line)
|
|
} else {
|
|
self.render_line_inner(line, &self.md_syntax)
|
|
};
|
|
output.unwrap_or_else(|| line.to_string())
|
|
}
|
|
|
|
pub fn is_code_block(&self) -> bool {
|
|
matches!(
|
|
self.prev_line_type,
|
|
LineType::CodeBegin | LineType::CodeInner
|
|
)
|
|
}
|
|
|
|
fn render_line(&mut self, line: &str) -> Option<String> {
|
|
if let Some(lang) = detect_code_block(line) {
|
|
match self.prev_line_type {
|
|
LineType::Normal | LineType::CodeEnd => {
|
|
self.prev_line_type = LineType::CodeBegin;
|
|
self.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;
|
|
}
|
|
}
|
|
self.render_line_inner(line, &self.md_syntax)
|
|
} else {
|
|
match self.prev_line_type {
|
|
LineType::Normal => self.render_line_inner(line, &self.md_syntax),
|
|
LineType::CodeEnd => {
|
|
self.prev_line_type = LineType::Normal;
|
|
self.render_line_inner(line, &self.md_syntax)
|
|
}
|
|
LineType::CodeBegin => {
|
|
if self.code_syntax.is_none() {
|
|
if let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) {
|
|
self.code_syntax = Some(syntax.clone());
|
|
}
|
|
}
|
|
self.prev_line_type = LineType::CodeInner;
|
|
self.render_code_line(line)
|
|
}
|
|
LineType::CodeInner => self.render_code_line(line),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_line_inner(&self, line: &str, syntax: &SyntaxReference) -> Option<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(|syntax| self.render_line_inner(line, syntax))
|
|
.unwrap_or_else(|| Some(format!("{}", line.with(self.code_color))))
|
|
}
|
|
|
|
fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> {
|
|
if let Some(new_lang) = LANGE_MAPS.get(&lang.to_ascii_lowercase()) {
|
|
self.syntax_set.find_syntax_by_name(new_lang)
|
|
} else {
|
|
self.syntax_set
|
|
.find_syntax_by_token(lang)
|
|
.or_else(|| self.syntax_set.find_syntax_by_extension(lang))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LineType {
|
|
Normal,
|
|
CodeBegin,
|
|
CodeInner,
|
|
CodeEnd,
|
|
}
|
|
|
|
fn as_terminal_escaped(ranges: &[(Style, &str)]) -> String {
|
|
let mut output = String::new();
|
|
for (style, text) in ranges {
|
|
let fg = blend_fg_color(style.foreground, style.background);
|
|
let mut text = text.with(convert_color(fg));
|
|
if style.font_style.contains(FontStyle::BOLD) {
|
|
text = text.bold()
|
|
}
|
|
if style.font_style.contains(FontStyle::UNDERLINE) {
|
|
text = text.underlined()
|
|
}
|
|
output.push_str(&text.to_string());
|
|
}
|
|
output
|
|
}
|
|
|
|
fn convert_color(c: SyntectColor) -> Color {
|
|
Color::Rgb {
|
|
r: c.r,
|
|
g: c.g,
|
|
b: c.b,
|
|
}
|
|
}
|
|
|
|
fn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor {
|
|
if fg.a == 0xff {
|
|
return fg;
|
|
}
|
|
let ratio = fg.a as u32;
|
|
let r = (fg.r as u32 * ratio + bg.r as u32 * (255 - ratio)) / 255;
|
|
let g = (fg.g as u32 * ratio + bg.g as u32 * (255 - ratio)) / 255;
|
|
let b = (fg.b as u32 * ratio + bg.b as u32 * (255 - ratio)) / 255;
|
|
SyntectColor {
|
|
r: r as u8,
|
|
g: g as u8,
|
|
b: b as u8,
|
|
a: 255,
|
|
}
|
|
}
|
|
|
|
fn detect_code_block(line: &str) -> Option<String> {
|
|
if !line.starts_with("```") {
|
|
return None;
|
|
}
|
|
let lang = line
|
|
.chars()
|
|
.skip(3)
|
|
.take_while(|v| v.is_alphanumeric())
|
|
.collect();
|
|
Some(lang)
|
|
}
|
|
|
|
fn get_code_color(theme: &Theme) -> Color {
|
|
let scope = theme.scopes.iter().find(|v| {
|
|
v.scope
|
|
.selectors
|
|
.iter()
|
|
.any(|v| v.path.scopes.iter().any(|v| v.to_string() == "string"))
|
|
});
|
|
scope
|
|
.and_then(|v| v.style.foreground)
|
|
.map(convert_color)
|
|
.unwrap_or_else(|| Color::Yellow)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_assets() {
|
|
let syntax_set: SyntaxSet =
|
|
bincode::deserialize_from(SYNTAXES).expect("invalid syntaxes.bin");
|
|
assert!(syntax_set.find_syntax_by_extension("md").is_some());
|
|
let md_theme: Theme = bincode::deserialize_from(MD_THEME).expect("invalid md_theme binary");
|
|
assert_eq!(md_theme.name, Some("Monokai Extended".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_render() {
|
|
let render = MarkdownRender::new(true);
|
|
assert!(render.find_syntax("csharp").is_some());
|
|
}
|
|
}
|