mirror of https://github.com/sigoden/aichat
refactor: use syntect for highlight, abandon mdcat (#26)
* refactor: use syntect for highlight, abandon mdcat * split src/render.rs to sub modules. embed a default theme * simulate typing effect. * fix formatpull/27/head
parent
c12ae02751
commit
1640456049
File diff suppressed because it is too large
Load Diff
@ -1,91 +0,0 @@
|
||||
use crate::{repl::ReplyStreamEvent, utils::dump};
|
||||
use anyhow::Result;
|
||||
use crossbeam::channel::Receiver;
|
||||
use mdcat::{
|
||||
push_tty,
|
||||
terminal::{TerminalProgram, TerminalSize},
|
||||
Environment, ResourceAccess, Settings,
|
||||
};
|
||||
use pulldown_cmark::Parser;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
pub fn render_stream(
|
||||
rx: Receiver<ReplyStreamEvent>,
|
||||
ctrlc: Arc<AtomicBool>,
|
||||
markdown_render: Arc<MarkdownRender>,
|
||||
) -> Result<()> {
|
||||
let mut buffer = String::new();
|
||||
let mut line_index = 0;
|
||||
loop {
|
||||
if ctrlc.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(evt) = rx.try_recv() {
|
||||
match evt {
|
||||
ReplyStreamEvent::Text(text) => {
|
||||
buffer.push_str(&text);
|
||||
if text.contains('\n') {
|
||||
let markdown = markdown_render.render(&buffer)?;
|
||||
let lines: Vec<&str> = markdown.lines().collect();
|
||||
let (_, print_lines) = lines.split_at(line_index);
|
||||
let mut print_lines = print_lines.to_vec();
|
||||
print_lines.pop();
|
||||
if !print_lines.is_empty() {
|
||||
line_index += print_lines.len();
|
||||
dump(print_lines.join("\n").to_string(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
ReplyStreamEvent::Done => {
|
||||
let markdown = markdown_render.render(&buffer)?;
|
||||
let tail = markdown
|
||||
.lines()
|
||||
.skip(line_index)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
dump(tail, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct MarkdownRender {
|
||||
env: Environment,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
impl MarkdownRender {
|
||||
pub fn init() -> Result<Self> {
|
||||
let terminal = TerminalProgram::detect();
|
||||
let env =
|
||||
Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
|
||||
let settings = Settings {
|
||||
resource_access: ResourceAccess::LocalOnly,
|
||||
syntax_set: SyntaxSet::load_defaults_newlines(),
|
||||
terminal_capabilities: terminal.capabilities(),
|
||||
terminal_size: TerminalSize::default(),
|
||||
};
|
||||
Ok(Self { env, settings })
|
||||
}
|
||||
|
||||
pub fn print(&self, input: &str) -> Result<()> {
|
||||
let markdown = self.render(input)?;
|
||||
dump(markdown, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn render(&self, input: &str) -> Result<String> {
|
||||
let source = Parser::new(input);
|
||||
let mut sink = Vec::new();
|
||||
push_tty(&self.settings, &self.env, &mut sink, source)?;
|
||||
Ok(String::from_utf8_lossy(&sink).into())
|
||||
}
|
||||
}
|
@ -0,0 +1,150 @@
|
||||
use syntect::highlighting::Theme;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::as_24_bit_terminal_escaped;
|
||||
use syntect::{easy::HighlightLines, parsing::SyntaxReference};
|
||||
|
||||
const THEME: &[u8] = include_bytes!("theme.yaml");
|
||||
|
||||
pub struct MarkdownRender {
|
||||
syntax_set: SyntaxSet,
|
||||
theme: Theme,
|
||||
md_syntax: SyntaxReference,
|
||||
txt_syntax: SyntaxReference,
|
||||
code_syntax: SyntaxReference,
|
||||
line_type: LineType,
|
||||
}
|
||||
|
||||
impl MarkdownRender {
|
||||
pub fn new() -> Self {
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme: Theme = serde_yaml::from_slice(THEME).unwrap();
|
||||
let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone();
|
||||
let txt_syntax = syntax_set.find_syntax_by_extension("txt").unwrap().clone();
|
||||
let code_syntax = txt_syntax.clone();
|
||||
let line_type = LineType::Normal;
|
||||
Self {
|
||||
syntax_set,
|
||||
theme,
|
||||
md_syntax,
|
||||
code_syntax,
|
||||
txt_syntax,
|
||||
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(&mut self, line: &str) -> Option<String> {
|
||||
if let Some(lang) = detect_code_block(line) {
|
||||
match self.line_type {
|
||||
LineType::Normal | LineType::CodeEnd => {
|
||||
self.line_type = LineType::CodeBegin;
|
||||
self.code_syntax = if lang.is_empty() {
|
||||
self.txt_syntax.clone()
|
||||
} else {
|
||||
self.find_syntax(&lang)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.txt_syntax.clone())
|
||||
};
|
||||
}
|
||||
LineType::CodeBegin | LineType::CodeInner => {
|
||||
self.line_type = LineType::CodeEnd;
|
||||
self.code_syntax = self.txt_syntax.clone();
|
||||
}
|
||||
}
|
||||
self.render_line_inner(line, &self.md_syntax)
|
||||
} else {
|
||||
match self.line_type {
|
||||
LineType::Normal => self.render_line_inner(line, &self.md_syntax),
|
||||
LineType::CodeEnd => {
|
||||
self.line_type = LineType::Normal;
|
||||
self.render_line_inner(line, &self.md_syntax)
|
||||
}
|
||||
LineType::CodeBegin => {
|
||||
self.line_type = LineType::CodeInner;
|
||||
self.render_line_inner(line, &self.code_syntax)
|
||||
}
|
||||
LineType::CodeInner => self.render_line_inner(line, &self.code_syntax),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_line_stateless(&self, line: &str) -> String {
|
||||
let output = if detect_code_block(line).is_some() {
|
||||
self.render_line_inner(line, &self.md_syntax)
|
||||
} else {
|
||||
match self.line_type {
|
||||
LineType::Normal | LineType::CodeEnd => {
|
||||
self.render_line_inner(line, &self.md_syntax)
|
||||
}
|
||||
_ => self.render_line_inner(line, &self.code_syntax),
|
||||
}
|
||||
};
|
||||
|
||||
output.unwrap_or_else(|| line.to_string())
|
||||
}
|
||||
|
||||
fn render_line_inner(&self, line: &str, syntax: &SyntaxReference) -> Option<String> {
|
||||
let mut highlighter = HighlightLines::new(syntax, &self.theme);
|
||||
let ranges = highlighter.highlight_line(line, &self.syntax_set).ok()?;
|
||||
Some(as_24_bit_terminal_escaped(&ranges[..], false))
|
||||
}
|
||||
|
||||
fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> {
|
||||
self.syntax_set.find_syntax_by_extension(lang).or_else(|| {
|
||||
LANGEGUATE_NAME_EXTS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == lang.to_lowercase())
|
||||
.and_then(|(_, ext)| self.syntax_set.find_syntax_by_extension(ext))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LineType {
|
||||
Normal,
|
||||
CodeBegin,
|
||||
CodeInner,
|
||||
CodeEnd,
|
||||
}
|
||||
|
||||
const LANGEGUATE_NAME_EXTS: [(&str, &str); 21] = [
|
||||
("asp", "asa"),
|
||||
("actionscript", "as"),
|
||||
("c#", "cs"),
|
||||
("clojure", "clj"),
|
||||
("erlang", "erl"),
|
||||
("haskell", "hs"),
|
||||
("javascript", "js"),
|
||||
("bibtex", "bib"),
|
||||
("latex", "tex"),
|
||||
("tex", "sty"),
|
||||
("ocaml", "ml"),
|
||||
("ocamllex", "mll"),
|
||||
("ocamlyacc", "mly"),
|
||||
("objective-c++", "mm"),
|
||||
("objective-c", "m"),
|
||||
("pascal", "pas"),
|
||||
("perl", "pl"),
|
||||
("python", "py"),
|
||||
("restructuredtext", "rst"),
|
||||
("ruby", "rb"),
|
||||
("rust", "rs"),
|
||||
];
|
||||
|
||||
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)
|
||||
}
|
@ -0,0 +1,120 @@
|
||||
mod markdown;
|
||||
|
||||
pub use self::markdown::MarkdownRender;
|
||||
use crate::repl::ReplyStreamEvent;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossbeam::channel::Receiver;
|
||||
use crossterm::{
|
||||
cursor,
|
||||
event::{self, Event, KeyCode, KeyModifiers},
|
||||
queue, style,
|
||||
terminal::{self, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use std::{
|
||||
io::{self, Stdout, Write},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub fn render_stream(rx: Receiver<ReplyStreamEvent>, ctrlc: Arc<AtomicBool>) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
queue!(stdout, event::DisableMouseCapture)?;
|
||||
|
||||
let ret = render_stream_inner(rx, ctrlc, &mut stdout);
|
||||
|
||||
queue!(stdout, event::DisableMouseCapture)?;
|
||||
disable_raw_mode()?;
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn render_stream_inner(
|
||||
rx: Receiver<ReplyStreamEvent>,
|
||||
ctrlc: Arc<AtomicBool>,
|
||||
writer: &mut Stdout,
|
||||
) -> Result<()> {
|
||||
let mut last_tick = Instant::now();
|
||||
let tick_rate = Duration::from_millis(200);
|
||||
let mut buffer = String::new();
|
||||
let mut markdown_render = MarkdownRender::new();
|
||||
let terminal_columns = terminal::size()?.0;
|
||||
loop {
|
||||
if ctrlc.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok(evt) = rx.try_recv() {
|
||||
recover_cursor(writer, terminal_columns, &buffer)?;
|
||||
|
||||
match evt {
|
||||
ReplyStreamEvent::Text(text) => {
|
||||
if text.contains('\n') {
|
||||
let text = format!("{buffer}{text}");
|
||||
let mut lines: Vec<&str> = text.split('\n').collect();
|
||||
buffer = lines.pop().unwrap_or_default().to_string();
|
||||
let output = markdown_render.render(&lines.join("\n"));
|
||||
queue!(
|
||||
writer,
|
||||
style::Print(output),
|
||||
style::Print("\n"),
|
||||
style::Print(&buffer),
|
||||
)?;
|
||||
} else {
|
||||
buffer = format!("{buffer}{text}");
|
||||
let output = markdown_render.render_line_stateless(&buffer);
|
||||
queue!(writer, style::Print(&output))?;
|
||||
}
|
||||
writer.flush()?;
|
||||
}
|
||||
ReplyStreamEvent::Done => {
|
||||
let output = markdown_render.render_line_stateless(&buffer);
|
||||
queue!(writer, style::Print(output), style::Print("\n"))?;
|
||||
writer.flush()?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let timeout = tick_rate
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
if crossterm::event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => {
|
||||
ctrlc.store(true, Ordering::SeqCst);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recover_cursor(writer: &mut Stdout, terminal_columns: u16, buffer: &str) -> Result<()> {
|
||||
let buffer_rows = (buffer.width() as u16 + terminal_columns - 1) / terminal_columns;
|
||||
let (_, row) = cursor::position()?;
|
||||
if row + 1 >= buffer_rows {
|
||||
queue!(writer, cursor::MoveTo(0, row + 1 - buffer_rows))?;
|
||||
} else {
|
||||
queue!(
|
||||
writer,
|
||||
terminal::ScrollUp(buffer_rows - 1 - row),
|
||||
cursor::MoveTo(0, 0)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue