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 format
pull/27/head
sigoden 1 year ago committed by GitHub
parent c12ae02751
commit 1640456049
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

918
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -25,18 +25,17 @@ serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
serde_yaml = "0.9.17"
tokio = { version = "1.26.0", features = ["full"] }
mdcat = { version = "1.1.0", default-features = false, features =["static"] }
pulldown-cmark = { version = "0.9.2", default-features = false, features = ['simd'] }
crossbeam = "0.8.2"
crossterm = "0.26.1"
copypasta = "0.8.2"
chrono = "0.4.23"
atty = "0.2.14"
unicode-width = "0.1.10"
[dependencies.syntect]
version = "5.0.0"
default-features = false
features = ["parsing", "regex-fancy", "default-themes", "default-syntaxes"]
features = ["parsing", "regex-fancy", "default-syntaxes"]
[profile.release]
lto = true

@ -68,8 +68,8 @@ fn start_directive(client: ChatGptClient, config: SharedConfig, input: &str) ->
let output = client.send_message(input, prompt)?;
let output = output.trim();
if config.borrow().highlight && stdout().is_terminal() {
let markdown_render = MarkdownRender::init()?;
markdown_render.print(output)?;
let mut markdown_render = MarkdownRender::new();
markdown_render.render(output);
} else {
println!("{output}");
}

@ -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

@ -1,6 +1,6 @@
use crate::client::ChatGptClient;
use crate::config::SharedConfig;
use crate::render::{self, MarkdownRender};
use crate::render::render_stream;
use crate::utils::dump;
use anyhow::Result;
@ -26,7 +26,6 @@ pub struct ReplCmdHandler {
config: SharedConfig,
state: RefCell<ReplCmdHandlerState>,
ctrlc: Arc<AtomicBool>,
render: Arc<MarkdownRender>,
}
pub struct ReplCmdHandlerState {
@ -36,7 +35,6 @@ pub struct ReplCmdHandlerState {
impl ReplCmdHandler {
pub fn init(client: ChatGptClient, config: SharedConfig) -> Result<Self> {
let render = Arc::new(MarkdownRender::init()?);
let save_file = config.as_ref().borrow().open_message_file()?;
let ctrlc = Arc::new(AtomicBool::new(false));
let state = RefCell::new(ReplCmdHandlerState {
@ -48,7 +46,6 @@ impl ReplCmdHandler {
config,
state,
ctrlc,
render,
})
}
@ -66,9 +63,8 @@ impl ReplCmdHandler {
let (tx, rx) = unbounded();
let ctrlc = self.ctrlc.clone();
let wg = wg.clone();
let render = self.render.clone();
spawn(move || {
let _ = render::render_stream(rx, ctrlc, render);
let _ = render_stream(rx, ctrlc);
drop(wg);
});
ReplyStreamHandler::new(Some(tx), self.ctrlc.clone())

Loading…
Cancel
Save