1use crate::console::ANSI_REGEX_PATTERN;
3use crossterm::{
4 queue,
5 style::{
6 Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
7 },
8};
9use regex::Regex;
10use std::fmt::Write as _;
11use std::io::{self, Write};
12use std::sync::LazyLock;
13use syntect::easy::HighlightLines;
14use syntect::highlighting::{Theme, ThemeSet};
15use syntect::parsing::SyntaxSet;
16use unicode_width::UnicodeWidthStr;
17
18static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
20static THEME: LazyLock<Theme> = LazyLock::new(|| {
21 let ts = ThemeSet::load_defaults();
22 ts.themes
23 .get("base16-ocean.dark")
24 .or_else(|| ts.themes.values().next())
25 .expect("No themes found")
26 .clone()
27});
28
29static RE_CODE_FENCE: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r"^(\s*)([`~]{3,})(.*)$").unwrap());
32static RE_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*)").unwrap());
33static RE_HR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*[-*_]){3,}\s*$").unwrap());
34static RE_LIST: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"^(\s*)([-*+]|\d+\.)(?:(\s+)(.*)|$)").unwrap());
36static RE_BLOCKQUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*>\s?)(.*)").unwrap());
37
38static RE_TABLE_ROW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|(.*)\|\s*$").unwrap());
39static RE_TABLE_SEP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\s\|\-\:]+$").unwrap());
40
41static RE_MATH_BLOCK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\$\$\s*$").unwrap());
42
43static RE_INVISIBLE: LazyLock<Regex> =
45 LazyLock::new(|| Regex::new(&format!("({}|{})", OSC8_PATTERN, ANSI_REGEX_PATTERN)).unwrap());
46
47const OSC8_PATTERN: &str = r"\x1b]8;;.*?\x1b\\";
50
51static RE_LINK: LazyLock<Regex> =
52 LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^\)]+)\)").unwrap());
53static RE_OSC8: LazyLock<Regex> = LazyLock::new(|| Regex::new(OSC8_PATTERN).unwrap());
54
55static RE_TOKENIZER: LazyLock<Regex> = LazyLock::new(|| {
56 Regex::new(&format!(
57 r"({}|{}|`+|\\[\s\S]|\$[^\$\s](?:[^\$\n]*?[^\$\s])?\$|~~|~|\*\*\*|___|\*\*|__|\*|_|\$|[^~*_`$\\\x1b]+)",
58 OSC8_PATTERN, ANSI_REGEX_PATTERN
59 ))
60 .unwrap()
61});
62
63static RE_SPLIT_ANSI: LazyLock<Regex> = LazyLock::new(|| {
64 let pattern = format!(
65 "({}|{}|\\s+|[^\\s\\x1b]+)",
66 OSC8_PATTERN, ANSI_REGEX_PATTERN
67 );
68 Regex::new(&pattern).unwrap()
69});
70static RE_ANSI_PARTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[([0-9;]*)m").unwrap());
71
72struct InlinePart {
75 content: String,
76 is_delim: bool,
77 char: char,
78 len: usize,
79 can_open: bool,
80 can_close: bool,
81 pre_style: Vec<String>,
82 post_style: Vec<String>,
83}
84
85impl InlinePart {
86 fn text(content: String) -> Self {
87 Self {
88 content,
89 is_delim: false,
90 char: '\0',
91 len: 0,
92 can_open: false,
93 can_close: false,
94 pre_style: vec![],
95 post_style: vec![],
96 }
97 }
98}
99
100pub struct MarkdownStreamer {
101 active_fence: Option<(char, usize, usize)>, code_lang: String,
104
105 inline_code_ticks: Option<usize>,
107 inline_code_buffer: String,
108
109 in_math_block: bool,
111 math_buffer: String,
112
113 in_table: bool,
115 table_header_printed: bool,
116
117 highlighter: Option<HighlightLines<'static>>,
119 line_buffer: String,
120
121 margin: usize,
123 blockquote_depth: usize,
124 list_stack: Vec<(usize, bool, usize, usize)>, manual_width: Option<usize>,
128
129 scratch_buffer: String,
131}
132
133impl Default for MarkdownStreamer {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl MarkdownStreamer {
140 pub fn new() -> Self {
141 Self {
142 active_fence: None,
143 code_lang: "bash".to_string(),
144 inline_code_ticks: None,
145 inline_code_buffer: String::new(),
146 in_math_block: false,
147 math_buffer: String::new(),
148 in_table: false,
149 table_header_printed: false,
150 highlighter: None,
151 line_buffer: String::new(),
152 margin: 2,
153 blockquote_depth: 0,
154 list_stack: Vec::new(),
155 manual_width: None,
156 scratch_buffer: String::with_capacity(1024),
157 }
158 }
159
160 pub fn set_width(&mut self, width: usize) {
162 self.manual_width = Some(width);
163 }
164
165 pub fn set_margin(&mut self, margin: usize) {
167 self.margin = margin;
168 }
169
170 fn get_width(&self) -> usize {
171 self.manual_width
172 .unwrap_or_else(crate::console::get_terminal_width)
173 }
174
175 fn visible_width(&self, text: &str) -> usize {
176 UnicodeWidthStr::width(RE_INVISIBLE.replace_all(text, "").as_ref())
177 }
178
179 pub fn print_chunk<W: Write>(&mut self, writer: &mut W, text: &str) -> io::Result<()> {
181 self.line_buffer.push_str(text);
182 while let Some(pos) = self.line_buffer.find('\n') {
183 let line = self.line_buffer[..pos + 1].to_string();
184 self.line_buffer.drain(..pos + 1);
185 self.process_line(writer, &line)?;
186 }
187 Ok(())
188 }
189
190 pub fn flush<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
192 if !self.line_buffer.is_empty() {
193 let line = std::mem::take(&mut self.line_buffer);
194 self.process_line(writer, &line)?;
195 }
196
197 if let Some(ticks) = self.inline_code_ticks {
199 queue!(writer, Print("`".repeat(ticks)))?;
201 queue!(writer, Print(&self.inline_code_buffer))?;
203 self.inline_code_ticks = None;
204 self.inline_code_buffer.clear();
205 }
206
207 writer.flush()
208 }
209
210 fn process_line<W: Write>(&mut self, w: &mut W, raw_line: &str) -> io::Result<()> {
212 let expanded = raw_line.replace('\t', " ");
213 let trimmed = expanded.trim_end();
214
215 if self.try_handle_fence(w, &expanded, trimmed)? {
217 return Ok(());
218 }
219 if self.try_handle_math(w, trimmed)? {
220 return Ok(());
221 }
222 if self.try_handle_table(w, trimmed)? {
223 return Ok(());
224 }
225
226 let mut content = expanded.as_str();
228 self.blockquote_depth = 0;
229 while let Some(caps) = RE_BLOCKQUOTE.captures(content) {
230 self.blockquote_depth += 1;
231 content = caps.get(2).map_or("", |m| m.as_str());
232 }
233
234 let mut prefix = " ".repeat(self.margin);
235 if self.blockquote_depth > 0 {
236 prefix.push_str("\x1b[38;5;240m");
237 for _ in 0..self.blockquote_depth {
238 prefix.push_str("│ ");
239 }
240 prefix.push_str("\x1b[0m");
241 }
242
243 let term_width = self.get_width();
244 let prefix_width = self.margin + (self.blockquote_depth * 2);
245 let avail_width = term_width.saturating_sub(prefix_width + self.margin);
246
247 if self.inline_code_ticks.is_none() {
249 let clean = content.trim_end();
250 if self.try_handle_header(w, clean, &prefix, avail_width)? {
251 return Ok(());
252 }
253 if self.try_handle_list(w, clean, &prefix, avail_width)? {
254 return Ok(());
255 }
256 if self.try_handle_hr(w, clean, &prefix, avail_width)? {
257 return Ok(());
258 }
259 }
260
261 self.render_standard_text(w, content, &prefix, avail_width)
263 }
264
265 fn try_handle_fence<W: Write>(
268 &mut self,
269 w: &mut W,
270 full: &str,
271 trimmed: &str,
272 ) -> io::Result<bool> {
273 let match_data = RE_CODE_FENCE.captures(trimmed);
274
275 if let Some((f_char, min_len, _)) = self.active_fence {
277 if let Some(caps) = &match_data {
278 let fence = &caps[2];
279 if fence.starts_with(f_char) && fence.len() >= min_len && caps[3].trim().is_empty()
280 {
281 self.active_fence = None;
282 queue!(w, ResetColor, Print("\n"))?;
283 return Ok(true);
284 }
285 }
286 self.render_code_line(w, full)?;
287 return Ok(true);
288 }
289
290 if let Some(caps) = match_data {
292 let fence = &caps[2];
293 let indent_len = caps[1].len();
294 let info = caps[3].trim();
295 if let Some(f_char) = fence.chars().next()
296 && (f_char != '`' || !info.contains('`'))
297 {
298 while self
299 .list_stack
300 .last()
301 .is_some_and(|(d, _, _, _)| *d > indent_len)
302 {
303 self.list_stack.pop();
304 }
305 self.active_fence = Some((f_char, fence.len(), indent_len));
306 let lang = info.split_whitespace().next().unwrap_or("bash");
307 self.code_lang = lang.to_string();
308 self.start_highlighter(&self.code_lang.clone());
309 return Ok(true);
310 }
311 }
312 Ok(false)
313 }
314
315 fn try_handle_math<W: Write>(&mut self, w: &mut W, trimmed: &str) -> io::Result<bool> {
316 if RE_MATH_BLOCK.is_match(trimmed) {
317 if self.in_math_block {
318 self.in_math_block = false;
319 let converted = unicodeit::replace(&self.math_buffer);
320 let p_width = self.margin + (self.blockquote_depth * 2);
321 let avail = self.get_width().saturating_sub(p_width + self.margin);
322 let padding = avail.saturating_sub(self.visible_width(&converted)) / 2;
323
324 queue!(
325 w,
326 Print(" ".repeat(self.margin + padding)),
327 SetForegroundColor(Color::Cyan),
328 SetAttribute(Attribute::Italic),
329 Print(converted),
330 ResetColor,
331 SetAttribute(Attribute::Reset),
332 Print("\n")
333 )?;
334 self.math_buffer.clear();
335 } else {
336 self.reset_block_context();
337 self.in_math_block = true;
338 }
339 return Ok(true);
340 }
341 if self.in_math_block {
342 self.math_buffer.push_str(trimmed);
343 self.math_buffer.push(' ');
344 return Ok(true);
345 }
346 Ok(false)
347 }
348
349 fn try_handle_table<W: Write>(&mut self, w: &mut W, trimmed: &str) -> io::Result<bool> {
350 if self.in_table && RE_TABLE_SEP.is_match(trimmed) {
351 self.table_header_printed = true;
352 return Ok(true);
353 }
354 if RE_TABLE_ROW.is_match(trimmed) {
355 if !self.in_table {
356 self.reset_block_context();
357 self.in_table = true;
358 }
359 self.render_stream_table_row(w, trimmed)?;
360 return Ok(true);
361 }
362 self.in_table = false;
363 self.table_header_printed = false;
364 Ok(false)
365 }
366
367 fn try_handle_header<W: Write>(
368 &mut self,
369 w: &mut W,
370 clean: &str,
371 prefix: &str,
372 avail: usize,
373 ) -> io::Result<bool> {
374 if let Some(caps) = RE_HEADER.captures(clean) {
375 let level = caps.get(1).map_or(0, |m| m.len());
376 let text = caps.get(2).map_or("", |m| m.as_str());
377 self.reset_block_context();
378
379 queue!(w, Print(prefix))?;
380 if level <= 2 {
381 queue!(w, Print("\n"))?;
382 }
383
384 self.scratch_buffer.clear();
385 let style = match level {
386 1 => "\x1b[1m",
387 2 => "\x1b[1;94m",
388 3 => "\x1b[1;36m",
389 _ => "\x1b[1;33m",
390 };
391 self.render_inline(text, None, Some(style));
392
393 if level <= 2 {
394 let lines = self.wrap_ansi(&self.scratch_buffer, avail);
395 for line in lines {
396 let pad = avail.saturating_sub(self.visible_width(&line)) / 2;
397 queue!(
398 w,
399 Print(" ".repeat(pad)),
400 Print(format!("{}{}\x1b[0m", style, line)),
401 ResetColor,
402 Print("\n")
403 )?;
404 if level == 1 {
405 queue!(w, Print(prefix))?;
406 }
407 }
408 } else {
409 queue!(
410 w,
411 Print(style),
412 Print(&self.scratch_buffer),
413 Print("\x1b[0m\n")
414 )?;
415 }
416 return Ok(true);
417 }
418 Ok(false)
419 }
420
421 fn try_handle_list<W: Write>(
422 &mut self,
423 w: &mut W,
424 clean: &str,
425 prefix: &str,
426 avail: usize,
427 ) -> io::Result<bool> {
428 if let Some(caps) = RE_LIST.captures(clean) {
429 let indent = caps.get(1).map_or(0, |m| m.len());
430 let bullet = caps.get(2).map_or("-", |m| m.as_str());
431 let separator = caps.get(3).map_or(" ", |m| m.as_str());
432 let text = caps.get(4).map_or("", |m| m.as_str());
433
434 let is_ord = bullet.chars().any(|c| c.is_numeric());
435 let disp_bullet = if is_ord { bullet } else { "•" };
436 let marker_width = self.visible_width(disp_bullet) + separator.len();
437
438 let last_indent = self.list_stack.last().map(|(d, _, _, _)| *d).unwrap_or(0);
439 if self.list_stack.is_empty() || indent > last_indent {
440 self.list_stack.push((indent, is_ord, 0, marker_width));
441 } else if indent < last_indent {
442 while self
443 .list_stack
444 .last()
445 .is_some_and(|(d, _, _, _)| *d > indent)
446 {
447 self.list_stack.pop();
448 }
449 if self
450 .list_stack
451 .last()
452 .is_some_and(|(d, _, _, _)| *d != indent)
453 {
454 self.list_stack.push((indent, is_ord, 0, marker_width));
455 }
456 } else {
457 if let Some(last) = self.list_stack.last_mut() {
459 last.3 = marker_width;
460 }
461 }
462
463 let full_stack_width: usize = self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
464 let parent_width = full_stack_width.saturating_sub(marker_width);
465
466 let hang_indent = " ".repeat(full_stack_width);
467 let content_width = avail.saturating_sub(full_stack_width);
468
469 queue!(
470 w,
471 Print(prefix),
472 Print(" ".repeat(parent_width)),
473 SetForegroundColor(Color::Yellow),
474 Print(disp_bullet),
475 Print(separator),
476 ResetColor
477 )?;
478
479 if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
481 queue!(w, Print("\n"))?;
482
483 let fence_chars = &fcaps[2];
484 let info = fcaps[3].trim();
485
486 if let Some(f_char) = fence_chars.chars().next() {
487 self.active_fence = Some((f_char, fence_chars.len(), 0));
488
489 let lang = info.split_whitespace().next().unwrap_or("bash");
490 self.code_lang = lang.to_string();
491 self.start_highlighter(&self.code_lang.clone());
492 }
493 return Ok(true);
494 }
495
496 self.scratch_buffer.clear();
497 self.render_inline(text, None, None);
498 let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
499
500 if lines.is_empty() {
501 queue!(w, Print("\n"))?;
502 } else if let Some(first) = lines.first() {
503 queue!(w, Print(first), ResetColor, Print("\n"))?;
504 }
505 for line in lines.iter().skip(1) {
506 queue!(
507 w,
508 Print(prefix),
509 Print(&hang_indent),
510 Print(line),
511 ResetColor,
512 Print("\n")
513 )?;
514 }
515 return Ok(true);
516 }
517 Ok(false)
518 }
519
520 fn try_handle_hr<W: Write>(
521 &mut self,
522 w: &mut W,
523 clean: &str,
524 prefix: &str,
525 avail: usize,
526 ) -> io::Result<bool> {
527 if RE_HR.is_match(clean) {
528 queue!(
529 w,
530 Print(prefix),
531 SetForegroundColor(Color::DarkGrey),
532 Print("─".repeat(avail)),
533 ResetColor,
534 Print("\n")
535 )?;
536 self.reset_block_context();
537 return Ok(true);
538 }
539 Ok(false)
540 }
541
542 fn render_standard_text<W: Write>(
543 &mut self,
544 w: &mut W,
545 content: &str,
546 prefix: &str,
547 avail: usize,
548 ) -> io::Result<()> {
549 let mut line_content = content.trim_end_matches(['\n', '\r']);
550 if line_content.trim().is_empty() && content.ends_with('\n') {
551 self.reset_block_context();
552 queue!(
553 w,
554 Print(if self.blockquote_depth > 0 {
555 prefix
556 } else {
557 ""
558 }),
559 Print("\n")
560 )?;
561 return Ok(());
562 }
563
564 if !line_content.is_empty() || self.inline_code_ticks.is_some() {
565 let mut eff_prefix = prefix.to_string();
566 if !self.list_stack.is_empty() {
567 let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
568 if current_indent == 0 {
569 self.list_stack.clear();
570 } else {
571 while self
572 .list_stack
573 .last()
574 .is_some_and(|(d, _, _, _)| *d > current_indent)
575 {
576 self.list_stack.pop();
577 }
578 }
579
580 if !self.list_stack.is_empty() {
581 let structural_indent: usize =
582 self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
583 eff_prefix.push_str(&" ".repeat(structural_indent));
584
585 let skip = current_indent.min(structural_indent);
588 line_content = &line_content[skip..];
589 }
590 }
591
592 self.scratch_buffer.clear();
593 self.render_inline(line_content, None, None);
594 if self.inline_code_ticks.is_some() {
595 self.inline_code_buffer.push(' ');
596 }
597
598 let lines = self.wrap_ansi(&self.scratch_buffer, avail);
599 for line in lines {
600 queue!(
601 w,
602 ResetColor,
603 SetAttribute(Attribute::Reset),
604 Print(&eff_prefix),
605 Print(&line),
606 ResetColor,
607 Print("\n")
608 )?;
609 }
610 }
611 Ok(())
612 }
613
614 fn reset_block_context(&mut self) {
615 self.list_stack.clear();
616 self.table_header_printed = false;
617 }
618
619 fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
620 let mut lines = Vec::new();
621 let mut current_line = String::new();
622 let mut current_len = 0;
623 let mut active_codes: Vec<String> = Vec::new();
624
625 for caps in RE_SPLIT_ANSI.captures_iter(text) {
626 let token = caps.get(1).unwrap().as_str();
627 if token.starts_with("\x1b") {
628 current_line.push_str(token);
629 self.update_ansi_state(&mut active_codes, token);
633 } else {
634 let mut token_str = token;
635 let mut token_len = UnicodeWidthStr::width(token_str);
636
637 while current_len + token_len > width && width > 0 {
638 if current_len == 0 {
639 let mut split_idx = 0;
641 let mut split_len = 0;
642 for (idx, c) in token_str.char_indices() {
643 let c_w = UnicodeWidthStr::width(c.to_string().as_str());
644 if split_len + c_w > width {
645 break;
646 }
647 split_idx = idx + c.len_utf8();
648 split_len += c_w;
649 }
650 if split_idx == 0 {
651 split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
652 }
653 if split_idx == 0 {
654 break;
655 } current_line.push_str(&token_str[..split_idx]);
658 lines.push(current_line);
659 current_line = active_codes.join("");
660 token_str = &token_str[split_idx..];
661 token_len = UnicodeWidthStr::width(token_str);
662 current_len = 0;
663 } else if !token_str.trim().is_empty() {
664 lines.push(current_line);
665 current_line = active_codes.join("");
666 current_len = 0;
667 } else {
668 token_str = "";
669 token_len = 0;
670 }
671 }
672 if !token_str.is_empty() {
673 current_line.push_str(token_str);
674 current_len += token_len;
675 }
676 }
677 }
678 if !current_line.is_empty() {
679 lines.push(current_line);
680 }
681 lines
682 }
683
684 fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
685 if RE_OSC8.is_match(code) {
686 return;
687 }
688 if let Some(caps) = RE_ANSI_PARTS.captures(code) {
689 let content = caps.get(1).map_or("", |m| m.as_str());
690 if content == "0" || content.is_empty() {
691 state.clear();
692 return;
693 }
694
695 let num: i32 = content
696 .split(';')
697 .next()
698 .unwrap_or("0")
699 .parse()
700 .unwrap_or(0);
701 let category = match num {
702 1 | 22 => "bold",
703 3 | 23 => "italic",
704 4 | 24 => "underline",
705 30..=39 | 90..=97 => "fg",
706 40..=49 | 100..=107 => "bg",
707 _ => "other",
708 };
709 if category != "other" {
710 state.retain(|exist| {
711 let e_num: i32 = RE_ANSI_PARTS
712 .captures(exist)
713 .and_then(|c| c.get(1))
714 .map_or("0", |m| m.as_str())
715 .split(';')
716 .next()
717 .unwrap_or("0")
718 .parse()
719 .unwrap_or(0);
720 let e_cat = match e_num {
721 1 | 22 => "bold",
722 3 | 23 => "italic",
723 4 | 24 => "underline",
724 30..=39 | 90..=97 => "fg",
725 40..=49 | 100..=107 => "bg",
726 _ => "other",
727 };
728 e_cat != category
729 });
730 }
731 state.push(code.to_string());
732 }
733 }
734
735 fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
736 let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
737
738 let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
739
740 let mut chars = raw_line.chars();
742 let mut skipped = 0;
743 while skipped < fence_indent {
744 let as_str = chars.as_str();
745 if as_str.starts_with(' ') {
746 chars.next();
747 skipped += 1;
748 } else {
749 break;
750 }
751 }
752 let line_content = chars.as_str();
753
754 let mut prefix = " ".repeat(self.margin);
755 if !self.list_stack.is_empty() {
756 let indent_width: usize = self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
757 prefix.push_str(&" ".repeat(indent_width));
758 }
759
760 let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
761
762 let mut spans = Vec::new();
763 if let Some(h) = &mut self.highlighter {
764 if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
765 spans = ranges;
766 } else {
767 spans.push((syntect::highlighting::Style::default(), line_content));
768 }
769 } else {
770 spans.push((syntect::highlighting::Style::default(), line_content));
771 }
772
773 self.scratch_buffer.clear();
775 for (style, text) in spans {
776 let _ = write!(
777 self.scratch_buffer,
778 "\x1b[38;2;{};{};{}m{}",
779 style.foreground.r, style.foreground.g, style.foreground.b, text
780 );
781 }
782
783 let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
785
786 if wrapped_lines.is_empty() {
788 queue!(
789 w,
790 Print(&prefix),
791 SetBackgroundColor(Color::Rgb {
792 r: 30,
793 g: 30,
794 b: 30
795 }),
796 Print(" ".repeat(avail_width)),
797 ResetColor,
798 Print("\n")
799 )?;
800 } else {
801 for line in wrapped_lines {
802 let vis_len = self.visible_width(&line);
803 let pad = avail_width.saturating_sub(vis_len);
804
805 queue!(
806 w,
807 Print(&prefix),
808 SetBackgroundColor(Color::Rgb {
809 r: 30,
810 g: 30,
811 b: 30
812 }),
813 Print(&line),
814 Print(" ".repeat(pad)), ResetColor,
816 Print("\n")
817 )?;
818 }
819 }
820
821 Ok(())
822 }
823
824 fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
825 let term_width = self.get_width();
826 let cells: Vec<&str> = row_str.trim().trim_matches('|').split('|').collect();
827 if cells.is_empty() {
828 return Ok(());
829 }
830
831 let prefix_width = self.margin + (self.blockquote_depth * 2);
832 let avail = term_width.saturating_sub(prefix_width + self.margin + 1 + (cells.len() * 3));
833 if avail == 0 {
834 return Ok(());
835 }
836 let base_w = avail / cells.len();
837 let rem = avail % cells.len();
838
839 let bg = if !self.table_header_printed {
840 Color::Rgb {
841 r: 60,
842 g: 60,
843 b: 80,
844 }
845 } else {
846 Color::Rgb {
847 r: 30,
848 g: 30,
849 b: 30,
850 }
851 };
852 let mut wrapped_cells = Vec::new();
853 let mut max_h = 1;
854
855 for (i, cell) in cells.iter().enumerate() {
856 let width = std::cmp::max(
857 1,
858 if i == cells.len() - 1 {
859 base_w + rem
860 } else {
861 base_w
862 },
863 );
864 self.scratch_buffer.clear();
865 if !self.table_header_printed {
866 self.scratch_buffer.push_str("\x1b[1;33m");
867 }
868 self.render_inline(
869 cell.trim(),
870 Some(bg),
871 if !self.table_header_printed {
872 Some("\x1b[1;33m")
873 } else {
874 None
875 },
876 );
877 if !self.table_header_printed {
878 self.scratch_buffer.push_str("\x1b[0m");
879 }
880
881 let lines = self.wrap_ansi(&self.scratch_buffer, width);
882 if lines.len() > max_h {
883 max_h = lines.len();
884 }
885 wrapped_cells.push((lines, width));
886 }
887
888 let mut prefix = " ".repeat(self.margin);
889 if self.blockquote_depth > 0 {
890 prefix.push_str("\x1b[38;5;240m");
891 for _ in 0..self.blockquote_depth {
892 prefix.push_str("│ ");
893 }
894 prefix.push_str("\x1b[0m");
895 }
896
897 for i in 0..max_h {
898 queue!(w, Print(&prefix))?;
899 for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
900 let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
901 let pad = width.saturating_sub(self.visible_width(text));
902 queue!(
903 w,
904 SetBackgroundColor(bg),
905 Print(" "),
906 Print(text),
907 SetBackgroundColor(bg),
908 Print(" ".repeat(pad + 1)),
909 ResetColor
910 )?;
911 if col < cells.len() - 1 {
912 queue!(
913 w,
914 SetBackgroundColor(bg),
915 SetForegroundColor(Color::White),
916 Print("│"),
917 ResetColor
918 )?;
919 }
920 }
921 queue!(w, Print("\n"))?;
922 }
923 self.table_header_printed = true;
924 Ok(())
925 }
926
927 pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
928 let text_linked = RE_LINK.replace_all(text, |c: ®ex::Captures| {
930 format!(
931 "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
932 &c[2], &c[1]
933 )
934 });
935
936 let mut parts: Vec<InlinePart> = Vec::new();
937 let caps_iter = RE_TOKENIZER.captures_iter(&text_linked);
938 let tokens_raw: Vec<&str> = caps_iter.map(|c| c.get(1).unwrap().as_str()).collect();
939
940 for (i, tok) in tokens_raw.iter().enumerate() {
942 if self.inline_code_ticks.is_some() {
943 if tok.starts_with('`') {
944 if let Some(n) = self.inline_code_ticks {
945 if n == tok.len() {
946 let formatted = self.format_inline_code(def_bg, restore_fg);
947 parts.push(InlinePart::text(formatted));
948 self.inline_code_ticks = None;
949 self.inline_code_buffer.clear();
950 } else {
951 self.inline_code_buffer.push_str(tok);
952 }
953 }
954 } else {
955 self.inline_code_buffer.push_str(tok);
956 }
957 continue;
958 }
959
960 if tok.starts_with('`') {
961 self.inline_code_ticks = Some(tok.len());
962 self.inline_code_buffer.clear();
963 continue;
964 }
965
966 if tok.starts_with('\\') && tok.len() > 1 {
967 parts.push(InlinePart::text(tok[1..].to_string()));
968 continue;
969 }
970
971 if tok.starts_with('$') && tok.ends_with('$') && tok.len() > 1 {
972 parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
973 continue;
974 }
975
976 if let Some(c) = tok.chars().next()
977 && (c == '*' || c == '_' || c == '~')
978 {
979 let prev_char = if i > 0 {
980 tokens_raw[i - 1].chars().last().unwrap_or(' ')
981 } else {
982 ' '
983 };
984 let next_char = if i + 1 < tokens_raw.len() {
985 tokens_raw[i + 1].chars().next().unwrap_or(' ')
986 } else {
987 ' '
988 };
989
990 let is_ws_next = next_char.is_whitespace();
992 let is_ws_prev = prev_char.is_whitespace();
993 let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
994 let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
995 let left_flanking =
996 !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
997 let right_flanking =
998 !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
999
1000 let (can_open, can_close) = if c == '_' {
1001 (
1002 left_flanking && (!right_flanking || is_punct_prev),
1003 right_flanking && (!left_flanking || is_punct_next),
1004 )
1005 } else {
1006 (left_flanking, right_flanking)
1007 };
1008
1009 parts.push(InlinePart {
1010 content: tok.to_string(),
1011 is_delim: true,
1012 char: c,
1013 len: tok.len(),
1014 can_open,
1015 can_close,
1016 pre_style: vec![],
1017 post_style: vec![],
1018 });
1019 } else {
1020 parts.push(InlinePart::text(tok.to_string()));
1021 }
1022 }
1023
1024 self.resolve_delimiters(&mut parts);
1026
1027 for part in parts {
1029 for s in &part.pre_style {
1030 self.scratch_buffer.push_str(s);
1031 }
1032 self.scratch_buffer.push_str(&part.content);
1033 for s in &part.post_style {
1034 self.scratch_buffer.push_str(s);
1035 }
1036 }
1037 }
1038
1039 fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1040 let mut stack: Vec<usize> = Vec::new();
1041
1042 for i in 0..parts.len() {
1043 if !parts[i].is_delim {
1044 continue;
1045 }
1046
1047 if parts[i].can_close {
1048 let mut stack_idx = stack.len();
1050 while stack_idx > 0 {
1051 let open_pos = stack_idx - 1;
1052 let open_idx = stack[open_pos];
1053
1054 if parts[open_idx].char == parts[i].char && parts[open_idx].can_open {
1056 let use_len = if parts[i].len == 3 && parts[open_idx].len == 3 {
1060 1
1061 } else if parts[i].len >= 2 && parts[open_idx].len >= 2 {
1062 2
1063 } else {
1064 1
1065 };
1066
1067 let (style_on, style_off) = match (parts[open_idx].char, use_len) {
1068 ('~', _) => ("\x1b[9m", "\x1b[29m"),
1069 ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1070 (_, 1) => ("\x1b[3m", "\x1b[23m"),
1071 (_, 2) => ("\x1b[1m", "\x1b[22m"),
1072 _ => ("", ""),
1073 };
1074
1075 let char_str = parts[open_idx].char.to_string();
1076
1077 if use_len == 1 {
1085 parts[open_idx].pre_style.push(style_on.to_string());
1087 parts[i].post_style.push(style_off.to_string());
1088 } else {
1089 parts[open_idx].post_style.push(style_on.to_string());
1091 parts[i].pre_style.push(style_off.to_string());
1092 }
1093
1094 parts[open_idx].len -= use_len;
1096 parts[i].len -= use_len;
1097 parts[open_idx].content = char_str.repeat(parts[open_idx].len);
1098 parts[i].content = char_str.repeat(parts[i].len);
1099
1100 if parts[open_idx].len == 0 {
1102 stack.remove(open_pos);
1103 stack_idx -= 1;
1106 } else {
1107 }
1112
1113 if parts[i].len == 0 {
1114 break;
1115 }
1116 } else {
1120 stack_idx -= 1;
1121 }
1122 }
1123 }
1124
1125 if parts[i].len > 0 && parts[i].can_open {
1126 stack.push(i);
1127 }
1128 }
1129 }
1130
1131 fn push_style(&mut self, active: bool, on: &str, off: &str) {
1132 self.scratch_buffer.push_str(if active { on } else { off });
1133 }
1134
1135 fn format_inline_code(&self, def_bg: Option<Color>, restore_fg: Option<&str>) -> String {
1136 let mut out = String::new();
1138 let norm = if self.inline_code_buffer.len() >= 2
1139 && self.inline_code_buffer.starts_with(' ')
1140 && self.inline_code_buffer.ends_with(' ')
1141 && !self.inline_code_buffer.trim().is_empty()
1142 {
1143 &self.inline_code_buffer[1..self.inline_code_buffer.len() - 1]
1144 } else {
1145 &self.inline_code_buffer
1146 };
1147
1148 let _ = write!(out, "\x1b[48;2;60;60;60m\x1b[38;2;255;255;255m{}", norm);
1149 if let Some(Color::Rgb { r, g, b }) = def_bg {
1150 let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1151 } else {
1152 out.push_str("\x1b[49m");
1153 }
1154 out.push_str(restore_fg.unwrap_or("\x1b[39m"));
1155 out
1156 }
1157
1158 fn start_highlighter(&mut self, lang: &str) {
1159 let ss = &*SYNTAX_SET;
1160 let syntax = ss
1161 .find_syntax_by_token(lang)
1162 .unwrap_or_else(|| ss.find_syntax_plain_text());
1163 self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1164 }
1165}