AnyBot/src/app/markdown_ui.rs

960 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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());
// A long value (a phrase, not a number) a size smaller, so it doesn't fill the tile.
let size = if value.chars().count() > 10 { 15.0 } else { 20.0 };
ui.add(egui::Label::new(RichText::new(value).size(size).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) {
// Keep a long chat from growing forever: every diagram is a GPU texture. Past a
// roomy cap, drop the whole cache — the diagrams shown again redraw on a worker
// thread (a brief spinner beats slowly eating hundreds of MBs).
if self.md.diagrams.len() >= 64 {
self.md.diagrams.clear();
}
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"));
}
}