Skip to content

Commit b05fce7

Browse files
authored
refactor: split long paragraphs for smoother stream output (#33)
1 parent ec1c4bc commit b05fce7

File tree

2 files changed

+267
-34
lines changed

2 files changed

+267
-34
lines changed

src/render/cmd.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ pub fn cmd_render_stream(rx: Receiver<ReplyStreamEvent>, abort: SharedAbortSigna
2323
dump(markdown_render.render(&output), 1);
2424
} else {
2525
buffer = format!("{buffer}{text}");
26+
if !(markdown_render.is_code_block()
27+
|| buffer.len() < 60
28+
|| buffer.starts_with('#')
29+
|| buffer.starts_with('>')
30+
|| buffer.starts_with('|'))
31+
{
32+
if let Some((output, remain)) = split_line(&buffer) {
33+
dump(markdown_render.render_line_stateless(&output), 0);
34+
buffer = remain
35+
}
36+
}
2637
}
2738
}
2839
ReplyStreamEvent::Done => {
@@ -35,3 +46,215 @@ pub fn cmd_render_stream(rx: Receiver<ReplyStreamEvent>, abort: SharedAbortSigna
3546
}
3647
Ok(())
3748
}
49+
50+
fn split_line(line: &str) -> Option<(String, String)> {
51+
let mut balance: Vec<Kind> = Vec::new();
52+
let chars: Vec<char> = line.chars().collect();
53+
let mut index = 0;
54+
let len = chars.len();
55+
while index < len - 1 {
56+
let ch = chars[index];
57+
if balance.is_empty()
58+
&& ((matches!(ch, ',' | '.' | ';') && chars[index + 1].is_whitespace())
59+
|| matches!(ch, ',' | '。' | ';'))
60+
{
61+
let (output, remain) = chars.split_at(index + 1);
62+
return Some((output.iter().collect(), remain.iter().collect()));
63+
}
64+
if index + 2 < len && do_balance(&mut balance, &chars[index..=index + 2]) {
65+
index += 3;
66+
continue;
67+
}
68+
if do_balance(&mut balance, &chars[index..=index + 1]) {
69+
index += 2;
70+
continue;
71+
}
72+
do_balance(&mut balance, &chars[index..index + 1]);
73+
index += 1
74+
}
75+
76+
None
77+
}
78+
79+
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
80+
enum Kind {
81+
ParentheseStart,
82+
ParentheseEnd,
83+
BracketStart,
84+
BracketEnd,
85+
Asterisk,
86+
Asterisk2,
87+
SingleQuota,
88+
DoubleQuota,
89+
Tilde,
90+
Tilde2,
91+
Backtick,
92+
Backtick3,
93+
}
94+
95+
impl Kind {
96+
fn from_chars(chars: &[char]) -> Option<Self> {
97+
let kind = match chars.len() {
98+
1 => match chars[0] {
99+
'(' => Kind::ParentheseStart,
100+
')' => Kind::ParentheseEnd,
101+
'[' => Kind::BracketStart,
102+
']' => Kind::BracketEnd,
103+
'*' => Kind::Asterisk,
104+
'\'' => Kind::SingleQuota,
105+
'"' => Kind::DoubleQuota,
106+
'~' => Kind::Tilde,
107+
'`' => Kind::Backtick,
108+
_ => return None,
109+
},
110+
2 if chars[0] == chars[1] => match chars[0] {
111+
'*' => Kind::Asterisk2,
112+
'~' => Kind::Tilde2,
113+
_ => return None,
114+
},
115+
3 => {
116+
if chars == ['`', '`', '`'] {
117+
Kind::Backtick3
118+
} else {
119+
return None;
120+
}
121+
}
122+
_ => return None,
123+
};
124+
Some(kind)
125+
}
126+
}
127+
128+
fn do_balance(balance: &mut Vec<Kind>, chars: &[char]) -> bool {
129+
if let Some(kind) = Kind::from_chars(chars) {
130+
let last = balance.last();
131+
match (kind, last) {
132+
(Kind::ParentheseStart | Kind::BracketStart, _) => {
133+
balance.push(kind);
134+
true
135+
}
136+
(Kind::ParentheseEnd, Some(&Kind::ParentheseStart)) => {
137+
balance.pop();
138+
true
139+
}
140+
(Kind::BracketEnd, Some(&Kind::BracketStart)) => {
141+
balance.pop();
142+
true
143+
}
144+
(Kind::Asterisk, Some(&Kind::Asterisk))
145+
| (Kind::Asterisk2, Some(&Kind::Asterisk2))
146+
| (Kind::SingleQuota, Some(&Kind::SingleQuota))
147+
| (Kind::DoubleQuota, Some(&Kind::DoubleQuota))
148+
| (Kind::Tilde, Some(&Kind::Tilde))
149+
| (Kind::Tilde2, Some(&Kind::Tilde2))
150+
| (Kind::Backtick, Some(&Kind::Backtick))
151+
| (Kind::Backtick3, Some(&Kind::Backtick3)) => {
152+
balance.pop();
153+
true
154+
}
155+
(Kind::Asterisk, _)
156+
| (Kind::Asterisk2, _)
157+
| (Kind::SingleQuota, _)
158+
| (Kind::DoubleQuota, _)
159+
| (Kind::Tilde, _)
160+
| (Kind::Tilde2, _)
161+
| (Kind::Backtick, _)
162+
| (Kind::Backtick3, _) => {
163+
balance.push(kind);
164+
true
165+
}
166+
_ => false,
167+
}
168+
} else {
169+
false
170+
}
171+
}
172+
173+
#[cfg(test)]
174+
mod tests {
175+
use super::*;
176+
177+
macro_rules! assert_split_line {
178+
($a:literal, $b:literal, true) => {
179+
assert_eq!(
180+
split_line(&format!("{}{}", $a, $b)),
181+
Some(($a.into(), $b.into()))
182+
);
183+
};
184+
($a:literal, $b:literal, false) => {
185+
assert_eq!(split_line(&format!("{}{}", $a, $b)), None);
186+
};
187+
}
188+
189+
#[test]
190+
fn test_split_line() {
191+
assert_split_line!(
192+
"Lorem ipsum dolor sit amet,",
193+
" consectetur adipiscing elit.",
194+
true
195+
);
196+
assert_split_line!(
197+
"Lorem ipsum dolor sit amet.",
198+
" consectetur adipiscing elit.",
199+
true
200+
);
201+
assert_split_line!("黃更室幼許刀知,", "波食小午足田世根候法。", true);
202+
assert_split_line!("黃更室幼許刀知。", "波食小午足田世根候法。", true);
203+
assert_split_line!("黃更室幼許刀知;", "波食小午足田世根候法。", true);
204+
assert_split_line!(
205+
"Lorem ipsum (dolor sit amet).",
206+
" consectetur adipiscing elit.",
207+
true
208+
);
209+
assert_split_line!(
210+
"Lorem ipsum dolor sit `amet,",
211+
" consectetur` adipiscing elit.",
212+
false
213+
);
214+
assert_split_line!(
215+
"Lorem ipsum dolor sit ```amet,",
216+
" consectetur``` adipiscing elit.",
217+
false
218+
);
219+
assert_split_line!(
220+
"Lorem ipsum dolor sit *amet,",
221+
" consectetur* adipiscing elit.",
222+
false
223+
);
224+
assert_split_line!(
225+
"Lorem ipsum dolor sit **amet,",
226+
" consectetur** adipiscing elit.",
227+
false
228+
);
229+
assert_split_line!(
230+
"Lorem ipsum dolor sit ~amet,",
231+
" consectetur~ adipiscing elit.",
232+
false
233+
);
234+
assert_split_line!(
235+
"Lorem ipsum dolor sit ~~amet,",
236+
" consectetur~~ adipiscing elit.",
237+
false
238+
);
239+
assert_split_line!(
240+
"Lorem ipsum dolor sit ``amet,",
241+
" consectetur`` adipiscing elit.",
242+
true
243+
);
244+
assert_split_line!(
245+
"Lorem ipsum dolor sit \"amet,",
246+
" consectetur\" adipiscing elit.",
247+
false
248+
);
249+
assert_split_line!(
250+
"Lorem ipsum dolor sit 'amet,",
251+
" consectetur' adipiscing elit.",
252+
false
253+
);
254+
assert_split_line!(
255+
"Lorem ipsum dolor sit amet.",
256+
"consectetur adipiscing elit.",
257+
false
258+
);
259+
}
260+
}

src/render/markdown.rs

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// use colored::{Color, Colorize};
21
use crossterm::style::{Color, Stylize};
32
use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme};
43
use syntect::parsing::SyntaxSet;
@@ -15,7 +14,7 @@ pub struct MarkdownRender {
1514
code_color: Color,
1615
md_syntax: SyntaxReference,
1716
code_syntax: Option<SyntaxReference>,
18-
line_type: LineType,
17+
prev_line_type: LineType,
1918
}
2019

2120
impl MarkdownRender {
@@ -32,7 +31,7 @@ impl MarkdownRender {
3231
code_color,
3332
md_syntax,
3433
code_syntax: None,
35-
line_type,
34+
prev_line_type: line_type,
3635
}
3736
}
3837

@@ -43,58 +42,63 @@ impl MarkdownRender {
4342
.join("\n")
4443
}
4544

46-
pub fn render_line(&mut self, line: &str) -> Option<String> {
45+
pub fn render_line_stateless(&self, line: &str) -> String {
46+
let output = if self.is_code_block() && detect_code_block(line).is_none() {
47+
self.render_code_line(line)
48+
} else {
49+
self.render_line_inner(line, &self.md_syntax)
50+
};
51+
output.unwrap_or_else(|| line.to_string())
52+
}
53+
54+
pub fn is_code_block(&self) -> bool {
55+
matches!(
56+
self.prev_line_type,
57+
LineType::CodeBegin | LineType::CodeInner
58+
)
59+
}
60+
61+
fn render_line(&mut self, line: &str) -> Option<String> {
4762
if let Some(lang) = detect_code_block(line) {
48-
match self.line_type {
63+
match self.prev_line_type {
4964
LineType::Normal | LineType::CodeEnd => {
50-
self.line_type = LineType::CodeBegin;
65+
self.prev_line_type = LineType::CodeBegin;
5166
self.code_syntax = if lang.is_empty() {
5267
None
5368
} else {
5469
self.find_syntax(&lang).cloned()
5570
};
5671
}
5772
LineType::CodeBegin | LineType::CodeInner => {
58-
self.line_type = LineType::CodeEnd;
73+
self.prev_line_type = LineType::CodeEnd;
5974
self.code_syntax = None;
6075
}
6176
}
6277
self.render_line_inner(line, &self.md_syntax)
6378
} else {
64-
match self.line_type {
79+
match self.prev_line_type {
6580
LineType::Normal => self.render_line_inner(line, &self.md_syntax),
6681
LineType::CodeEnd => {
67-
self.line_type = LineType::Normal;
82+
self.prev_line_type = LineType::Normal;
6883
self.render_line_inner(line, &self.md_syntax)
6984
}
7085
LineType::CodeBegin => {
71-
self.line_type = LineType::CodeInner;
86+
self.prev_line_type = LineType::CodeInner;
7287
self.render_code_line(line)
7388
}
7489
LineType::CodeInner => self.render_code_line(line),
7590
}
7691
}
7792
}
7893

79-
pub fn render_line_stateless(&self, line: &str) -> String {
80-
let output = if detect_code_block(line).is_some() {
81-
self.render_line_inner(line, &self.md_syntax)
82-
} else {
83-
match self.line_type {
84-
LineType::Normal | LineType::CodeEnd => {
85-
self.render_line_inner(line, &self.md_syntax)
86-
}
87-
_ => self.render_code_line(line),
88-
}
89-
};
90-
91-
output.unwrap_or_else(|| line.to_string())
92-
}
93-
9494
fn render_line_inner(&self, line: &str, syntax: &SyntaxReference) -> Option<String> {
95+
let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
96+
let trimed_line = &line[ws.len()..];
9597
let mut highlighter = HighlightLines::new(syntax, &self.md_theme);
96-
let ranges = highlighter.highlight_line(line, &self.syntax_set).ok()?;
97-
Some(as_terminal_escaped(&ranges))
98+
let ranges = highlighter
99+
.highlight_line(trimed_line, &self.syntax_set)
100+
.ok()?;
101+
Some(format!("{ws}{}", as_terminal_escaped(&ranges)))
98102
}
99103

100104
fn render_code_line(&self, line: &str) -> Option<String> {
@@ -112,7 +116,7 @@ impl MarkdownRender {
112116
}
113117

114118
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115-
enum LineType {
119+
pub enum LineType {
116120
Normal,
117121
CodeBegin,
118122
CodeInner,
@@ -184,10 +188,16 @@ fn get_code_color(theme: &Theme) -> Color {
184188
.unwrap_or_else(|| Color::Yellow)
185189
}
186190

187-
#[test]
188-
fn test_assets() {
189-
let syntax_set: SyntaxSet = bincode::deserialize_from(SYNTAXES).expect("invalid syntaxes.bin");
190-
assert!(syntax_set.find_syntax_by_extension("md").is_some());
191-
let md_theme: Theme = bincode::deserialize_from(MD_THEME).expect("invalid md_theme binary");
192-
assert_eq!(md_theme.name, Some("Monokai Extended".into()));
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
#[test]
196+
fn test_assets() {
197+
let syntax_set: SyntaxSet =
198+
bincode::deserialize_from(SYNTAXES).expect("invalid syntaxes.bin");
199+
assert!(syntax_set.find_syntax_by_extension("md").is_some());
200+
let md_theme: Theme = bincode::deserialize_from(MD_THEME).expect("invalid md_theme binary");
201+
assert_eq!(md_theme.name, Some("Monokai Extended".into()));
202+
}
193203
}

0 commit comments

Comments
 (0)