AnyBot/src/app/markdown_ui.rs

952 lines
44 KiB
Rust
Raw Normal View History

// Chat Markdown, modelled on the original's TranscriptMarkdown (react-markdown + remark-gfm):
// headings (h1 22 px, h2 17 px), single newlines kept as line breaks, **bold**, *italic*,
// ~~strikethrough~~, `inline code`, links, nested lists and task lists, quotes, tables (8 px
// cells, bottom borders, scroll sideways), code blocks (12 px corners, a border, scroll sideways,
// 複製程式碼 on hover), rules, and ```mermaid diagrams drawn once the reply is complete (click
// to view large; 「無法顯示此圖表」 above the source when one can't be drawn).
use super::*;
use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::hash::{Hash, Hasher};
#[derive(Clone, Default, Debug, PartialEq)]
pub struct Span {
pub text: String,
pub bold: bool,
pub italic: bool,
pub strike: bool,
pub code: bool,
pub link: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Block {
Para(Vec<Span>),
Heading(u8, Vec<Span>),
Code { lang: String, text: String },
Quote(Vec<Block>),
List { start: Option<u64>, items: Vec<ListItem> },
Table { aligns: Vec<Alignment>, head: Vec<Vec<Span>>, rows: Vec<Vec<Vec<Span>>> },
Rule,
Math(String),
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct ListItem {
pub task: Option<bool>,
pub blocks: Vec<Block>,
}
/// Before parsing (like the original): "•", "◦", "▪" bullets become Markdown list items.
fn prepare(text: &str) -> String {
text.lines()
.map(|l| {
let t = l.trim_start();
let indent = &l[..l.len() - t.len()];
for b in ['•', '◦', '▪'] {
if let Some(rest) = t.strip_prefix(b) {
return format!("{indent}- {}", rest.trim_start());
}
}
l.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
/// Markdown → blocks.
pub fn parse(text: &str) -> Vec<Block> {
let text = prepare(text);
let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS | Options::ENABLE_MATH;
// Containers being filled: (kind, blocks so far).
enum Frame {
Root(Vec<Block>),
Quote(Vec<Block>),
List(Option<u64>, Vec<ListItem>),
Item(ListItem),
}
let mut stack: Vec<Frame> = vec![Frame::Root(vec![])];
let mut spans: Vec<Span> = vec![];
let mut style = Span::default();
let mut links: Vec<String> = vec![];
let mut in_inline: Option<Option<u8>> = None; // Some(None)=paragraph, Some(Some(n))=heading
let mut code: Option<(String, String)> = None;
// Tables.
let mut table: Option<(Vec<Alignment>, Vec<Vec<Span>>, Vec<Vec<Vec<Span>>>, bool)> = None;
let mut row: Vec<Vec<Span>> = vec![];
fn push_block(stack: &mut [Frame], b: Block) {
match stack.last_mut() {
Some(Frame::Root(v)) | Some(Frame::Quote(v)) => v.push(b),
Some(Frame::Item(it)) => it.blocks.push(b),
Some(Frame::List(_, items)) => {
// Text directly in a tight list item arrives without an Item frame only in odd input.
items.push(ListItem { task: None, blocks: vec![b] });
}
None => {}
}
}
let flush = |stack: &mut Vec<Frame>, spans: &mut Vec<Span>, heading: Option<u8>| {
if spans.is_empty() {
return;
}
let s = std::mem::take(spans);
push_block(stack, match heading {
Some(n) => Block::Heading(n, s),
None => Block::Para(s),
});
};
let add_text = |spans: &mut Vec<Span>, style: &Span, links: &[String], t: &str, code: bool| {
let span = Span { text: t.to_string(), code, link: links.last().cloned(), ..style.clone() };
if let Some(last) = spans.last_mut() {
if (Span { text: String::new(), ..last.clone() }) == (Span { text: String::new(), ..span.clone() }) {
last.text.push_str(t);
return;
}
}
spans.push(span);
};
for ev in Parser::new_ext(&text, opts) {
if let Some((_, buf)) = code.as_mut() {
match ev {
Event::Text(t) => {
buf.push_str(&t);
continue;
}
Event::End(TagEnd::CodeBlock) => {
let (lang, text) = code.take().unwrap_or_default();
push_block(&mut stack, Block::Code { lang, text: text.trim_end_matches('\n').to_string() });
continue;
}
_ => continue,
}
}
match ev {
Event::Start(Tag::Paragraph) => in_inline = Some(None),
Event::End(TagEnd::Paragraph) => {
flush(&mut stack, &mut spans, None);
in_inline = None;
}
Event::Start(Tag::Heading { level, .. }) => {
in_inline = Some(Some(match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
_ => 4,
}))
}
Event::End(TagEnd::Heading(_)) => {
let h = in_inline.flatten();
flush(&mut stack, &mut spans, h.or(Some(4)));
in_inline = None;
}
Event::Start(Tag::CodeBlock(kind)) => {
flush(&mut stack, &mut spans, None);
let lang = match kind {
CodeBlockKind::Fenced(l) => l.split_whitespace().next().unwrap_or("").to_lowercase(),
CodeBlockKind::Indented => String::new(),
};
code = Some((lang, String::new()));
}
Event::Start(Tag::BlockQuote(_)) => {
flush(&mut stack, &mut spans, None);
stack.push(Frame::Quote(vec![]));
}
Event::End(TagEnd::BlockQuote(_)) => {
flush(&mut stack, &mut spans, None);
if let Some(Frame::Quote(v)) = stack.pop() {
push_block(&mut stack, Block::Quote(v));
}
}
Event::Start(Tag::List(start)) => {
flush(&mut stack, &mut spans, None);
stack.push(Frame::List(start, vec![]));
}
Event::End(TagEnd::List(_)) => {
if let Some(Frame::List(start, items)) = stack.pop() {
push_block(&mut stack, Block::List { start, items });
}
}
Event::Start(Tag::Item) => stack.push(Frame::Item(ListItem::default())),
Event::End(TagEnd::Item) => {
flush(&mut stack, &mut spans, None);
if let Some(Frame::Item(it)) = stack.pop() {
if let Some(Frame::List(_, items)) = stack.last_mut() {
items.push(it);
}
}
}
Event::TaskListMarker(done) => {
if let Some(Frame::Item(it)) = stack.last_mut() {
it.task = Some(done);
}
}
Event::Start(Tag::Table(aligns)) => {
flush(&mut stack, &mut spans, None);
table = Some((aligns, vec![], vec![], false));
}
Event::Start(Tag::TableHead) => {
if let Some(t) = table.as_mut() {
t.3 = true;
}
row.clear();
}
Event::End(TagEnd::TableHead) => {
if let Some(t) = table.as_mut() {
t.1 = std::mem::take(&mut row);
t.3 = false;
}
}
Event::Start(Tag::TableRow) => row.clear(),
Event::End(TagEnd::TableRow) => {
if let Some(t) = table.as_mut() {
t.2.push(std::mem::take(&mut row));
}
}
Event::Start(Tag::TableCell) => spans.clear(),
Event::End(TagEnd::TableCell) => row.push(std::mem::take(&mut spans)),
Event::End(TagEnd::Table) => {
if let Some((aligns, head, rows, _)) = table.take() {
push_block(&mut stack, Block::Table { aligns, head, rows });
}
}
Event::Start(Tag::Emphasis) => style.italic = true,
Event::End(TagEnd::Emphasis) => style.italic = false,
Event::Start(Tag::Strong) => style.bold = true,
Event::End(TagEnd::Strong) => style.bold = false,
Event::Start(Tag::Strikethrough) => style.strike = true,
Event::End(TagEnd::Strikethrough) => style.strike = false,
Event::Start(Tag::Link { dest_url, .. }) => links.push(dest_url.to_string()),
Event::End(TagEnd::Link) => {
links.pop();
}
Event::Start(Tag::Image { dest_url, .. }) => links.push(dest_url.to_string()),
Event::End(TagEnd::Image) => {
links.pop();
}
Event::Text(t) => add_text(&mut spans, &style, &links, &t, false),
Event::Code(t) => add_text(&mut spans, &style, &links, &t, true),
Event::InlineMath(t) => add_text(&mut spans, &style, &links, &t, true),
Event::DisplayMath(t) => {
flush(&mut stack, &mut spans, None);
push_block(&mut stack, Block::Math(t.trim().to_string()));
}
Event::SoftBreak | Event::HardBreak => add_text(&mut spans, &style, &links, "\n", false),
Event::Html(t) | Event::InlineHtml(t) => {
// Like the original: raw HTML shows as text, except a few harmless tags.
let s = t.trim();
let tag = s.trim_start_matches('<').trim_start_matches('/').trim_end_matches('>').trim_end_matches('/').trim().to_lowercase();
if ["br"].contains(&tag.as_str()) {
add_text(&mut spans, &style, &links, "\n", false);
} else if !["b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "sub", "sup", "mark"].contains(&tag.as_str()) {
add_text(&mut spans, &style, &links, &t, false);
}
}
Event::Rule => {
flush(&mut stack, &mut spans, None);
push_block(&mut stack, Block::Rule);
}
Event::FootnoteReference(t) => add_text(&mut spans, &style, &links, &format!("[{t}]"), false),
_ => {}
}
let _ = in_inline;
}
// Unclosed code fence while streaming: show what there is.
if let Some((lang, text)) = code.take() {
push_block(&mut stack, Block::Code { lang, text });
}
flush(&mut stack, &mut spans, None);
while stack.len() > 1 {
match stack.pop() {
Some(Frame::Quote(v)) => push_block(&mut stack, Block::Quote(v)),
Some(Frame::List(start, items)) => push_block(&mut stack, Block::List { start, items }),
Some(Frame::Item(it)) => {
if let Some(Frame::List(_, items)) = stack.last_mut() {
items.push(it);
}
}
_ => {}
}
}
match stack.pop() {
Some(Frame::Root(v)) => v,
_ => vec![],
}
}
/// A light syntax colouring for code blocks: comments, strings, numbers and common keywords.
fn highlight(code: &str, lang: &str, base: Color32, dark: bool) -> Vec<(String, Color32)> {
let (c_kw, c_str, c_num, c_com) = if dark {
(Color32::from_rgb(0xc6, 0x92, 0xff), Color32::from_rgb(0x8f, 0xd6, 0x8a), Color32::from_rgb(0xf0, 0xb2, 0x6b), Color32::from_gray(0x80))
} else {
(Color32::from_rgb(0x8a, 0x3f, 0xc9), Color32::from_rgb(0x1f, 0x7a, 0x3a), Color32::from_rgb(0xb3, 0x5c, 0x00), Color32::from_gray(0x8a))
};
const KW: &[&str] = &[
"fn", "let", "mut", "pub", "struct", "enum", "impl", "use", "mod", "match", "if", "else", "for", "while", "loop", "return", "break", "continue", "in", "as", "const", "static", "trait", "where",
"def", "class", "import", "from", "with", "try", "except", "finally", "lambda", "yield", "async", "await", "pass", "raise", "None", "True", "False", "self", "function", "var", "new", "this",
"export", "default", "extends", "interface", "type", "null", "undefined", "true", "false", "public", "private", "protected", "void", "int", "string", "bool", "func", "package", "go", "select",
"case", "switch", "echo", "then", "fi", "do", "done", "elif", "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "JOIN", "ORDER", "BY", "GROUP", "AND", "OR", "NOT", "AS", "ON", "INTO", "VALUES",
];
let hash_comment = matches!(lang, "python" | "py" | "sh" | "bash" | "zsh" | "shell" | "ruby" | "rb" | "yaml" | "yml" | "toml" | "r" | "perl" | "dockerfile" | "makefile" | "conf" | "ini");
let sql = matches!(lang, "sql");
let mut out: Vec<(String, Color32)> = vec![];
let push = |out: &mut Vec<(String, Color32)>, s: &str, c: Color32| {
if let Some(l) = out.last_mut() {
if l.1 == c {
l.0.push_str(s);
return;
}
}
out.push((s.to_string(), c));
};
let chars: Vec<char> = code.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let rest: String = chars[i..chars.len().min(i + 2)].iter().collect();
// Comments.
if rest == "//" || rest == "/*" || (hash_comment && c == '#') || (sql && rest == "--") {
let end = if rest == "/*" {
let s: String = chars[i..].iter().collect();
s.find("*/").map(|e| i + s[..e + 2].chars().count()).unwrap_or(chars.len())
} else {
chars[i..].iter().position(|x| *x == '\n').map(|e| i + e).unwrap_or(chars.len())
};
push(&mut out, &chars[i..end].iter().collect::<String>(), c_com);
i = end;
continue;
}
// Strings.
if c == '"' || c == '\'' || c == '`' {
let mut j = i + 1;
while j < chars.len() && chars[j] != c && !(chars[j] == '\n' && c != '`') {
if chars[j] == '\\' {
j += 1;
}
j += 1;
}
let end = (j + 1).min(chars.len());
push(&mut out, &chars[i..end].iter().collect::<String>(), c_str);
i = end;
continue;
}
if c.is_ascii_digit() {
let end = chars[i..].iter().position(|x| !(x.is_ascii_alphanumeric() || *x == '.' || *x == '_')).map(|e| i + e).unwrap_or(chars.len());
push(&mut out, &chars[i..end].iter().collect::<String>(), c_num);
i = end;
continue;
}
if c.is_alphabetic() || c == '_' {
let end = chars[i..].iter().position(|x| !(x.is_alphanumeric() || *x == '_')).map(|e| i + e).unwrap_or(chars.len());
let w: String = chars[i..end].iter().collect();
push(&mut out, &w, if KW.contains(&w.as_str()) { c_kw } else { base });
i = end;
continue;
}
push(&mut out, &c.to_string(), base);
i += 1;
}
out
}
/// What the chat keeps between frames: drawn diagrams, the copied-code check mark, the big view.
#[derive(Default)]
pub struct MdState {
diagrams: HashMap<u64, Diagram>,
copied: Option<(u64, Instant)>,
/// The diagram shown large: (key, zoom, pan).
viewer: Option<(u64, f32, egui::Vec2)>,
}
enum Diagram {
Drawing(Arc<Mutex<Option<Result<image::RgbaImage, String>>>>),
Ready(egui::TextureHandle, egui::Vec2),
Failed(String),
}
fn key_of(s: &str, dark: bool) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
dark.hash(&mut h);
h.finish()
}
/// `> [!NOTE] …` quotes: the kind, and the quote without the marker.
fn callout_of(inner: &[Block]) -> Option<(&'static str, Vec<Block>)> {
let Some(Block::Para(spans)) = inner.first() else { return None };
let first = spans.first()?;
// "[!NOTE]" may be parsed as text, or with the rest of the line after it.
let (kind, rest) = crate::pages::callout(&first.text)?;
let mut spans = spans.clone();
spans[0].text = rest.trim_start().to_string();
if spans[0].text.is_empty() {
spans.remove(0);
if let Some(s) = spans.first_mut() {
s.text = s.text.trim_start().to_string();
}
}
let mut out = vec![];
if !spans.is_empty() {
out.push(Block::Para(spans));
}
out.extend(inner[1..].iter().cloned());
Some((kind, out))
}
impl App {
/// A message's Markdown. `streaming`: the reply is still coming (diagrams wait for the end).
pub(super) fn markdown(&mut self, ui: &mut egui::Ui, text: &str, col: Color32, streaming: bool) {
let blocks = parse(text);
ui.spacing_mut().item_spacing.y = 0.0;
let n = blocks.len();
for (i, b) in blocks.iter().enumerate() {
self.md_block(ui, b, col, streaming, 0);
if i + 1 < n {
ui.add_space(10.0);
}
}
}
fn md_block(&mut self, ui: &mut egui::Ui, b: &Block, col: Color32, streaming: bool, depth: usize) {
let p = self.pal;
match b {
Block::Para(spans) => self.md_spans(ui, spans, TRANSCRIPT_FONT, col, false),
Block::Heading(level, spans) => {
let size = match level {
1 => 22.0,
2 => 17.0,
_ => TRANSCRIPT_FONT,
};
self.md_spans(ui, spans, size, col, true);
}
Block::Rule => {
ui.add_space(4.0);
let r = ui.available_rect_before_wrap();
ui.painter().hline(r.x_range(), r.top(), Stroke::new(1.0, p.border));
ui.add_space(4.0);
}
Block::Quote(inner) if callout_of(inner).is_some() => {
let (kind, inner) = callout_of(inner).unwrap();
self.md_callout(ui, kind, &inner, streaming, depth);
}
Block::Quote(inner) => {
let top = ui.cursor().top();
Frame::new().inner_margin(Margin { left: 14, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
for (i, b) in inner.iter().enumerate() {
if i > 0 {
ui.add_space(8.0);
}
self.md_block(ui, b, p.text2, streaming, depth);
}
});
let x = ui.min_rect().left() + 2.0;
ui.painter().vline(x, top..=ui.cursor().top(), Stroke::new(3.0, p.border));
}
Block::List { start, items } => {
for (i, it) in items.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
}
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let (mrect, _) = ui.allocate_exact_size(vec2(22.0, TRANSCRIPT_LINE_HEIGHT), egui::Sense::hover());
match (it.task, start) {
(Some(done), _) => {
let r = egui::Rect::from_center_size(mrect.center(), vec2(14.0, 14.0));
if done {
ui.painter().rect_filled(r, 4, p.primary);
crate::icons::paint(ui.painter(), r.shrink(2.0), crate::icons::Icon::Check, p.on_primary);
} else {
ui.painter().rect_stroke(r, 4, Stroke::new(1.3, p.text3), egui::StrokeKind::Inside);
}
}
(None, Some(s)) => {
ui.painter().text(mrect.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{}.", s + i as u64), egui::FontId::proportional(TRANSCRIPT_FONT), col);
}
(None, None) => {
let c = mrect.center() + vec2(-2.0, 0.0);
match depth % 3 {
0 => ui.painter().circle_filled(c, 2.6, col),
1 => ui.painter().circle_stroke(c, 2.6, Stroke::new(1.1, col)),
_ => ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(4.6, 4.6)), 0, col),
};
}
}
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
for (j, b) in it.blocks.iter().enumerate() {
if j > 0 {
ui.add_space(4.0);
}
let c = if it.task == Some(true) { p.text3 } else { col };
self.md_block(ui, b, c, streaming, depth + 1);
}
});
});
}
}
Block::Table { aligns, head, rows } => self.md_table(ui, aligns, head, rows, col),
Block::Math(t) => self.md_code(ui, "math", t, col),
Block::Code { lang, text } if lang == "mermaid" => self.md_mermaid(ui, text, col, streaming),
Block::Code { lang, text } if lang == "stats" && !crate::pages::stats(text).is_empty() => self.md_stats(ui, text),
Block::Code { lang, text } if lang == "steps" && !crate::pages::steps(text).is_empty() => self.md_steps(ui, text),
Block::Code { lang, text } => self.md_code(ui, lang, text, col),
}
}
/// One paragraph or heading: a single text layout (wraps like prose, selectable), with
/// links that open in the browser.
fn md_spans(&mut self, ui: &mut egui::Ui, spans: &[Span], size: f32, col: Color32, heading: bool) {
let p = self.pal;
let link_col = if p.pane == LIGHT.pane { Color32::from_rgb(0x0b, 0x6d, 0xd3) } else { Color32::from_rgb(0x5a, 0xa9, 0xff) };
let mut job = egui::text::LayoutJob::default();
// 粉圓體 has one weight: bold words (and headings) are drawn a second time half a pixel
// to the right, from a twin layout where everything else is transparent.
let mut bold_job = egui::text::LayoutJob::default();
let mut ranges: Vec<(std::ops::Range<usize>, String)> = vec![];
let mut strikes: Vec<std::ops::Range<usize>> = vec![];
let mut chars = 0usize;
for s in spans {
let mut fmt = egui::TextFormat {
font_id: if s.code { egui::FontId::monospace(size * 0.93) } else { egui::FontId::proportional(size) },
color: if s.link.is_some() { link_col } else { col },
line_height: Some(if size >= 22.0 { 28.0 } else if size > TRANSCRIPT_FONT { LINE_HEIGHT_XL } else if size < TRANSCRIPT_FONT { LINE_HEIGHT_SM } else { TRANSCRIPT_LINE_HEIGHT }),
italics: s.italic,
..Default::default()
};
if s.code {
fmt.background = p.hover;
}
if s.strike {
fmt.color = p.text3;
}
if s.link.is_some() {
fmt.underline = Stroke::new(1.0, link_col.gamma_multiply(0.5));
}
let n = s.text.chars().count();
if let Some(l) = &s.link {
ranges.push((chars..chars + n, l.clone()));
}
if s.strike {
strikes.push(chars..chars + n);
}
chars += n;
let mut twin = fmt.clone();
if !(heading || s.bold) {
twin.color = Color32::TRANSPARENT;
}
twin.background = Color32::TRANSPARENT;
twin.underline = Stroke::NONE;
twin.strikethrough = Stroke::NONE;
bold_job.append(&s.text, 0.0, twin);
job.append(&s.text, 0.0, fmt);
}
job.wrap.max_width = ui.available_width();
bold_job.wrap.max_width = job.wrap.max_width;
let any_bold = heading || spans.iter().any(|s| s.bold);
let galley = ui.fonts_mut(|f| f.layout_job(job.clone()));
let label = egui::Label::new(job).sense(if ranges.is_empty() { egui::Sense::hover() } else { egui::Sense::click() });
let resp = ui.add(label);
if any_bold {
let g2 = ui.fonts_mut(|f| f.layout_job(bold_job));
ui.painter().galley(resp.rect.min + vec2(0.5, 0.0), g2, col);
}
// ~~Strikethrough~~: a line through those characters (drawn here; the label doesn't).
if !strikes.is_empty() {
let mut idx = 0usize;
for row in &galley.rows {
let y = resp.rect.min.y + row.pos.y + row.row.size.y * 0.5;
for g in &row.row.glyphs {
if strikes.iter().any(|r| r.contains(&idx)) {
let x = resp.rect.min.x + row.pos.x + g.pos.x;
ui.painter().hline(x..=x + g.advance_width, y, Stroke::new(1.4, p.text2));
}
idx += 1;
}
if row.ends_with_newline {
idx += 1;
}
}
}
if ranges.is_empty() {
return;
}
if let Some(pos) = resp.hover_pos() {
let idx = galley.cursor_from_pos(pos - resp.rect.min).index.0;
if let Some((_, url)) = ranges.iter().find(|(r, _)| r.contains(&idx)) {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
let url = url.clone();
if let Some(pid) = crate::pages::link_id(&url) {
let resp = resp.on_hover_text(t("打開頁面"));
if resp.clicked() {
self.page_view = Some((pid, None));
}
} else {
let resp = resp.on_hover_text(&url);
if resp.clicked() {
let _ = crate::preview::system_open(&url).spawn();
}
}
}
}
}
/// ```stats: number tiles in rows (label above, the value large, a note below).
fn md_stats(&mut self, ui: &mut egui::Ui, src: &str) {
let p = self.pal;
let items = crate::pages::stats(src);
let gap = 8.0;
let w = ui.available_width();
let per_row = ((w + gap) / (150.0 + gap)).floor().clamp(1.0, 4.0) as usize;
let tile_w = (w - gap * (per_row as f32 - 1.0)) / per_row as f32;
for row in items.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (label, value, note) in row {
Frame::new().fill(p.hover).corner_radius(10).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| {
ui.set_width(tile_w - 24.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
ui.add(egui::Label::new(RichText::new(label).size(12.0).color(p.text2)).wrap());
ui.add(egui::Label::new(RichText::new(value).size(20.0).strong().color(p.text)).wrap());
if !note.is_empty() {
ui.add(egui::Label::new(RichText::new(note).size(11.5).color(p.text3)).wrap());
}
});
});
}
});
ui.add_space(gap);
}
}
/// ```steps: a vertical progress line — done ✓, now (a ring in the accent colour), to do.
fn md_steps(&mut self, ui: &mut egui::Ui, src: &str) {
let p = self.pal;
let items = crate::pages::steps(src);
let n = items.len();
let green = Color32::from_rgb(0x2e, 0xa0, 0x43);
for (i, (st, name, note)) in items.iter().enumerate() {
let top = ui.cursor().top();
let x = ui.cursor().left() + 9.0;
Frame::new().inner_margin(Margin { left: 28, right: 0, top: 0, bottom: if i + 1 < n { 12 } else { 0 } }).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
let c = match st {
crate::pages::Step::Todo => p.text3,
_ => p.text,
};
let mut t = RichText::new(name).size(TRANSCRIPT_FONT).color(c);
if *st == crate::pages::Step::Now {
t = t.strong();
}
ui.add(egui::Label::new(t).wrap());
if !note.is_empty() {
ui.add(egui::Label::new(RichText::new(note).size(12.5).color(p.text2)).wrap());
}
});
let bottom = ui.cursor().top();
let cy = top + 10.0;
let painter = ui.painter();
if i + 1 < n {
let line = if *st == crate::pages::Step::Done { green } else { p.border };
painter.vline(x, (cy + 9.0)..=(bottom + 1.0), Stroke::new(2.0, line));
}
let c = egui::pos2(x, cy);
match st {
crate::pages::Step::Done => {
painter.circle_filled(c, 8.0, green);
painter.line_segment([c + vec2(-3.5, 0.0), c + vec2(-1.0, 2.8)], Stroke::new(1.8, Color32::WHITE));
painter.line_segment([c + vec2(-1.0, 2.8), c + vec2(3.8, -2.6)], Stroke::new(1.8, Color32::WHITE));
}
crate::pages::Step::Now => {
painter.circle_filled(c, 8.0, p.pane);
painter.circle_stroke(c, 7.0, Stroke::new(2.5, p.accent));
painter.circle_filled(c, 3.0, p.accent);
}
crate::pages::Step::Todo => {
painter.circle_filled(c, 8.0, p.pane);
painter.circle_stroke(c, 7.0, Stroke::new(1.5, p.text3));
}
}
}
}
/// `> [!WARNING] …`: a tinted box with a coloured edge and a small title.
fn md_callout(&mut self, ui: &mut egui::Ui, kind: &str, inner: &[Block], streaming: bool, depth: usize) {
let p = self.pal;
let (edge, title) = match kind {
"warning" => (Color32::from_rgb(0xe0, 0x8a, 0x00), "注意"),
"important" => (Color32::from_rgb(0xd0, 0x3b, 0x3b), "重要"),
"tip" => (Color32::from_rgb(0x2e, 0xa0, 0x43), "提示"),
_ => (p.accent, "說明"),
};
let top = ui.cursor().top();
Frame::new().fill(edge.gamma_multiply(0.10)).corner_radius(8).inner_margin(Margin { left: 16, right: 12, top: 10, bottom: 10 }).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
ui.label(RichText::new(title).size(12.5).strong().color(edge));
ui.add_space(4.0);
for (i, b) in inner.iter().enumerate() {
if i > 0 {
ui.add_space(6.0);
}
self.md_block(ui, b, p.text, streaming, depth);
}
});
let r = ui.min_rect();
ui.painter().vline(r.left() + 1.5, (top + 2.0)..=(ui.cursor().top() - 2.0), Stroke::new(3.0, edge));
}
fn md_code(&mut self, ui: &mut egui::Ui, lang: &str, text: &str, col: Color32) {
let p = self.pal;
let dark = p.pane != LIGHT.pane;
let key = key_of(text, dark);
let resp = Frame::new().fill(p.hover.gamma_multiply(0.7)).stroke(Stroke::new(1.0, p.border)).corner_radius(12).inner_margin(Margin::symmetric(12, 10)).show(ui, |ui| {
ui.set_width(ui.available_width());
egui::ScrollArea::horizontal().id_salt(("code", key)).auto_shrink([false, true]).show(ui, |ui| {
let mut job = egui::text::LayoutJob::default();
for (s, c) in highlight(text, lang, col, dark) {
job.append(&s, 0.0, egui::TextFormat { font_id: egui::FontId::monospace(12.5), color: c, line_height: Some(LINE_HEIGHT_SM), ..Default::default() });
}
job.wrap.max_width = f32::INFINITY;
ui.add(egui::Label::new(job).extend());
});
});
// 複製程式碼 at the top right while hovered (a check for 2 s after copying).
let rect = resp.response.rect;
if ui.rect_contains_pointer(rect) || self.md.copied.map(|(k, _)| k == key).unwrap_or(false) {
let copied = self.md.copied.filter(|(k, at)| *k == key && at.elapsed().as_secs_f32() < 2.0).is_some();
let br = egui::Rect::from_min_size(rect.right_top() + vec2(-34.0, 6.0), vec2(28.0, 28.0));
let r = ui.interact(br, egui::Id::unique(("copy-code", key)), egui::Sense::click());
ui.painter().rect_filled(br, 8, if r.hovered() { p.active } else { p.elevated });
crate::icons::paint(ui.painter(), br.shrink(7.0), if copied { crate::icons::Icon::Check } else { crate::icons::Icon::Copy }, p.text2);
if r.on_hover_text(t(if copied { "已複製" } else { "複製程式碼" })).clicked() {
ui.ctx().copy_text(text.to_string());
self.md.copied = Some((key, Instant::now()));
}
if copied {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
}
}
}
fn md_table(&mut self, ui: &mut egui::Ui, aligns: &[Alignment], head: &[Vec<Span>], rows: &[Vec<Vec<Span>>], col: Color32) {
let ncols = head.len().max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
let plain = |c: &[Span]| c.iter().map(|s| s.text.as_str()).collect::<String>();
let key = key_of(&format!("{:?}{:?}", head.iter().map(|c| plain(c)).collect::<Vec<_>>(), rows.len()), false);
egui::ScrollArea::horizontal().id_salt(("table", key)).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new(("md-table", key)).spacing(vec2(0.0, 0.0)).min_col_width(40.0).show(ui, |ui| {
let cell = |this: &mut Self, ui: &mut egui::Ui, c: Option<&Vec<Span>>, i: usize, header: bool| {
let align = match aligns.get(i) {
Some(Alignment::Right) => Align::Max,
Some(Alignment::Center) => Align::Center,
_ => Align::Min,
};
Frame::new().inner_margin(Margin::same(8)).show(ui, |ui| {
ui.set_max_width(320.0);
ui.with_layout(Layout::top_down(align), |ui| {
if let Some(c) = c {
this.md_spans(ui, c, FONT_SM, col, header);
}
});
});
};
for i in 0..ncols {
cell(self, ui, head.get(i), i, true);
}
ui.end_row();
for r in rows {
for i in 0..ncols {
cell(self, ui, r.get(i), i, false);
}
ui.end_row();
}
});
});
}
fn md_mermaid(&mut self, ui: &mut egui::Ui, src: &str, col: Color32, streaming: bool) {
let p = self.pal;
// While the reply streams the diagram is still being written: show the source.
if streaming {
return self.md_code(ui, "mermaid", src, col);
}
let dark = p.pane != LIGHT.pane;
let key = key_of(src, dark);
if !self.md.diagrams.contains_key(&key) {
let slot = Arc::new(Mutex::new(None));
let (s2, src2) = (slot.clone(), src.to_string());
let ctx = ui.ctx().clone();
std::thread::spawn(move || {
*s2.lock().unwrap() = Some(crate::diagram::render(&src2, dark, 2.0));
ctx.request_repaint();
});
self.md.diagrams.insert(key, Diagram::Drawing(slot));
}
// A finished drawing becomes a texture.
let finished = match self.md.diagrams.get(&key) {
Some(Diagram::Drawing(slot)) => slot.lock().unwrap().take(),
_ => None,
};
{
if let Some(done) = finished {
let d = match done {
Ok(img) => {
let size = vec2(img.width() as f32 / 2.0, img.height() as f32 / 2.0);
let tex = ui.ctx().load_texture(format!("mermaid-{key}"), egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()), egui::TextureOptions::LINEAR);
Diagram::Ready(tex, size)
}
Err(e) => Diagram::Failed(e),
};
self.md.diagrams.insert(key, d);
}
}
match self.md.diagrams.get(&key) {
Some(Diagram::Ready(tex, size)) => {
let w = size.x.min(ui.available_width());
let shown = vec2(w, size.y * w / size.x.max(1.0));
let (tex, natural) = (tex.clone(), *size);
let r = ui.add(egui::Image::new((tex.id(), shown)).corner_radius(6).sense(egui::Sense::click()));
if r.on_hover_text(t("按一下放大檢視")).on_hover_cursor(egui::CursorIcon::ZoomIn).clicked() {
self.md.viewer = Some((key, 1.0, egui::Vec2::ZERO));
}
let _ = natural;
}
Some(Diagram::Failed(why)) => {
let why = why.clone();
crate::icons::label(ui, crate::icons::Icon::Alert, &t("無法顯示此圖表"), 12.5, p.text3).on_hover_text(why);
ui.add_space(4.0);
self.md_code(ui, "mermaid", src, col);
}
_ => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new(t("正在繪製圖表…")).size(FONT_SM).color(p.text3));
});
}
}
}
/// The diagram viewer: big, zoom with the buttons or ⌘ + scroll, drag to move, save as PNG.
pub(super) fn md_viewer(&mut self, ctx: &egui::Context) {
let p = self.pal;
let Some((key, mut zoom, mut pan)) = self.md.viewer else { return };
let Some(Diagram::Ready(tex, size)) = self.md.diagrams.get(&key) else {
self.md.viewer = None;
return;
};
let (tex, size) = (tex.clone(), *size);
let mut keep = true;
let w = (ctx.content_rect().width() - 80.0).max(320.0);
let h = (ctx.content_rect().height() - 160.0).max(240.0);
let close = dialog(ctx, "mermaid-view", "圖表", w, true, p, |ui| {
let (area, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), egui::Sense::click_and_drag());
if resp.dragged() {
pan += resp.drag_delta();
}
if resp.hovered() {
let scroll = ui.input(|i| i.zoom_delta());
if scroll != 1.0 {
zoom = (zoom * scroll).clamp(0.1, 8.0);
}
}
let fit = (area.width() / size.x).min(area.height() / size.y).min(2.0);
let shown = size * fit * zoom;
let rect = egui::Rect::from_center_size(area.center() + pan, shown);
let painter = ui.painter().with_clip_rect(area);
painter.image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
dialog_footer(ui, p, |ui| {
if ui.add(secondary_button("縮小")).clicked() {
zoom = (zoom / 1.4).max(0.1);
}
if ui.add(secondary_button("放大")).clicked() {
zoom = (zoom * 1.4).min(8.0);
}
if ui.add(secondary_button("重設")).clicked() {
zoom = 1.0;
pan = egui::Vec2::ZERO;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(primary_button("完成", p)).clicked() {
keep = false;
}
if ui.add(secondary_button("下載 PNG")).clicked() {
self.save_diagram_png(key);
}
});
});
});
self.md.viewer = if keep && !close { Some((key, zoom, pan)) } else { None };
}
fn save_diagram_png(&mut self, key: u64) {
// Draw it again at 2× (the texture can't be read back) and save to Downloads.
let Some(src) = self.state.bot(self.state.selected).and_then(|b| {
b.messages.iter().rev().flat_map(|m| parse(&m.text)).find_map(|blk| match blk {
Block::Code { lang, text } if lang == "mermaid" && (key_of(&text, true) == key || key_of(&text, false) == key) => Some(text),
_ => None,
})
}) else {
return;
};
let dark = self.pal.pane != LIGHT.pane;
if let Ok(img) = crate::diagram::render(&src, dark, 2.0) {
let dir = dirs::download_dir().unwrap_or_else(std::env::temp_dir);
let path = dir.join(format!("圖表-{}.png", chrono::Local::now().format("%Y%m%d-%H%M%S")));
if img.save(&path).is_ok() {
crate::preview::reveal(&path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_from_markdown() {
let b = parse("# 標題\n第一行\n第二行 **粗** 和 `code` [連結](https://x.com)\n\n- a\n - b\n- [x] 完成\n\n1. 一\n2. 二\n\n> 引用\n\n| A | B |\n|---|--:|\n| 1 | 2 |\n\n```rust\nfn main() {}\n```\n\n```mermaid\ngraph TD; A-->B\n```\n\n---\n• 項目");
assert!(matches!(&b[0], Block::Heading(1, s) if s[0].text == "標題"));
match &b[1] {
Block::Para(s) => {
let all: String = s.iter().map(|x| x.text.as_str()).collect();
assert_eq!(all, "第一行\n第二行 粗 和 code 連結");
assert!(s.iter().any(|x| x.bold && x.text == "粗"));
assert!(s.iter().any(|x| x.code && x.text == "code"));
assert!(s.iter().any(|x| x.link.as_deref() == Some("https://x.com")));
}
other => panic!("{other:?}"),
}
assert!(matches!(&b[2], Block::List { start: None, items } if items.len() == 2 && items[1].task == Some(true) && matches!(&items[0].blocks[1], Block::List { .. })));
assert!(matches!(&b[3], Block::List { start: Some(1), items } if items.len() == 2));
assert!(matches!(&b[4], Block::Quote(v) if v.len() == 1));
assert!(matches!(&b[5], Block::Table { head, rows, aligns } if head.len() == 2 && rows.len() == 1 && aligns[1] == Alignment::Right));
assert!(matches!(&b[6], Block::Code { lang, text } if lang == "rust" && text == "fn main() {}"));
assert!(matches!(&b[7], Block::Code { lang, .. } if lang == "mermaid"));
assert!(matches!(&b[8], Block::Rule));
assert!(matches!(&b[9], Block::List { .. }), "• bullets become a list");
// An unclosed fence (still streaming) keeps its text.
assert!(matches!(parse("看:\n```py\nprint(1)").last(), Some(Block::Code { text, .. }) if text.contains("print(1)")));
// Prices stay text (no single-$ math).
assert!(matches!(&parse("一杯 $5,兩杯 $10")[0], Block::Para(s) if s.iter().all(|x| !x.code)));
let hl = highlight("let x = \"a\"; // hi", "rust", Color32::WHITE, false);
assert!(hl.iter().any(|(s, _)| s == "let") && hl.iter().any(|(s, _)| s.starts_with("// hi")));
}
#[test]
fn call_outs_and_page_blocks() {
for src in ["> [!WARNING] 現在先別花錢", "> [!WARNING]\n> 現在先別花錢"] {
let b = parse(src);
let Block::Quote(inner) = &b[0] else { panic!("{b:?}") };
let (kind, rest) = callout_of(inner).unwrap_or_else(|| panic!("{src}: {inner:?}"));
assert_eq!(kind, "warning");
let text: String = rest.iter().map(|b| match b { Block::Para(s) => s.iter().map(|x| x.text.clone()).collect::<String>(), _ => String::new() }).collect();
assert!(text.contains("現在先別花錢") && !text.contains("[!"), "{src}: {text:?}");
}
assert!(callout_of(match &parse("> 一般的引言")[0] { Block::Quote(i) => i, _ => panic!() }).is_none());
assert!(matches!(&parse("```stats\n狀態 | 審查中\n```")[0], Block::Code { lang, .. } if lang == "stats"));
}
}