Skip to content

Markdown ​

ui/markdown 包在原生 UI 中显示 Markdown:CommonMark、GitHub 表格、任务列表、删除线和自动链接、脚注与数学公式。它专为语言模型的回复构建——回复逐 token 流式到来:新追加的文本单独解析,写了一半的语法按其最终形态显示,文本平滑出现、淡入。

go
import "github.com/egoist/mygo/ui/markdown"

// 保存在应用状态里,供每个视图共用。
app.md = markdown.Options{}

// 在视图里:
markdown.View(c, app.readme, &app.md)

View 显示一个已完成的文档。每帧把源文本交给它,就像把字符串交给 Text 一样:它只重新解析自上一帧以来变化的部分,保留其余已生成的内容,包括各段落的文本布局。

流式回复 ​

Stream 显示仍在书写中的 Markdown。回复的 token 到来时追加到它的字符串上,完成后在同一位置改用 View 显示,使视图延续下去:

go
func (app *chat) message(c *ui.Context, m *Message) {
	if m.Done {
		markdown.View(c, m.Text, &app.md)
	} else {
		markdown.Stream(c, m.Text, &app.md)
	}
}

追加文本只解析其前面仍未闭合的最外层块;若没有任何打开的块,则只解析新增的行:一个 token 在长回复末尾的开销与在开头相同,已显示的块既不重新解析也不重新布局。

回复的末尾会显示其语法最可能变成的样子,如同聊天应用的展示方式,且不改动其文本:**bol 是粗体,未闭合的行内代码是代码,$$x_i(以及启用 ExtDollarMath 时的 $x_i)是数学公式,其中的 _ 和 * 是 TeX 而不是强调,[the docs](https://ex 是一个目标地址还在到来的链接(尚不可点击),未闭合的代码围栏显示为代码。那些仍可能变成别的东西的行会等待后面的内容:比如由几个标记组成的行(- 或 ==),它会把上面的段落变成标题;还有表格的表头,它等待自己的分隔行,以免先闪成一段竖线文本。已完成的块绝不会被改动,回复完成后用 View 显示,其效果与 CommonMark 的解释完全一致。

节奏与淡入 ​

token 成批到达。Stream 则以均匀的节奏显示文本,一次一个词,追上 0.25 秒内到来的内容,每个词淡入。Options.Reveal 可以改变方式:

go
app.md.Reveal = &markdown.Reveal{
	Unit:    markdown.ByLine,         // 一次一行,行完成后才显示
	CatchUp: 400 * time.Millisecond,  // 文本至多等待这么久
	Fade:    500 * time.Millisecond,  // 并在这段时间内淡入
}

ByWord(默认)、ByCharacter、BySentence、ByLine 和 ByParagraph 一次显示一个单位,后三者只在单位完成后才显示:句子以句号、后跟空格的问号或感叹号、中日韩句号或行尾结束。AtOnce 随到随显。负的 Fade 关闭淡入,桌面的"减弱动态效果"设置同样会关闭它。文本按其颜色淡入,块的其余部分(列表标记、代码块、表格的行)按其不透明度淡入,这只需绘制:不会为此重新布局。消息切换到 View 时尚未显示的部分仍会继续显现。

聊天 ​

把每条消息显示为一个跟随其末尾的列表的行:回复增长时列表让末尾保持在视野内,用 FollowAnimation 平滑滑动到末尾,直到用户向上滚动,滚回末尾后重新跟随。用 AtEnd 和 ScrollToEnd 可以做"回到最新"的按钮。

go
app.list.Key = func(i int) any { return app.messages[i].ID }
app.list.FollowEnd = true
app.list.FollowAnimation = &ui.Smooth
ui.List(c, &app.list, len(app.messages), func(i int) {
	app.message(c, app.messages[i])
}).Grow(1).Gap(16).Justify(ui.End)

聊天示例以这种方式流式显示预置的回复。

选择与复制 ​

视图的文本跨块构成一个选区,如同在浏览器中。复制会把选中文本放入剪贴板:段落之间留空行,列表项各占一行,表格单元格以制表符分隔;同时提供 HTML 形式,以便带格式粘贴到富文本编辑器、邮件或文字处理器;还提供 Markdown 形式(transfer.Markdown)。上下文菜单中的"复制为 Markdown"以及 Shift+Cmd+C(Linux 和 Windows 上为 Shift+Ctrl+C)复制选区的源文本,补全其切过的强调、代码和链接,未选中任何内容时复制整个文档。回复流式进入时,文本保持其选区。

ShareSelection 把选区交给视图外层的 Selectable 容器,比如一个横跨聊天所有消息的容器。

链接与图片 ​

指向 http、https 和 mailto 地址的链接(由 ui.SafeToOpen 判定,富文本编辑器也以同样的方式打开它们)像链接一样打开,并在工具提示中显示其地址;没有协议方案的路径会交给视图外层的 router;#a-heading 滚动到该锚点对应的标题(Slug);其他协议的链接(如 javascript:)不做任何事。OnLink 可以代替处理所有点击:

go
app.md.OnLink = func(url string) { app.open(url) }

图片在 LoadImage 加载它们之前显示其描述,加载在自己的 goroutine 上进行;没有它,图片就一直只是描述,因为视图自己从不从网络加载任何东西。FileImages(dir) 加载某个目录里的文件。整段只有图片时,按图片原始大小显示,最宽不超过视图宽度。

go
app.md.LoadImage = markdown.FileImages(filepath.Dir(app.path))

代码 ​

围栏代码按其语言用代码编辑器的分词器和颜色高亮,依据上一行的状态逐行进行,因此流式进入的块只高亮其新增的行;它的行分块布局,增长时只重新布局最后一块。块在代码上方显示其语言和复制按钮,代码横向滚动(设置 WrapCode 则改为折行)。复制按钮会等待正在书写中的块闭合。

CodeBlock 代替 DefaultCodeBlock 构建代码块,Code 构建它所列出语言的块,比如图表。两者都收到块的语言、代码和围栏是否仍未闭合,并可以使用默认实现的各部分:

go
app.md.Code = map[string]func(*ui.Context, *markdown.CodeBlockView){
	"mermaid": func(c *ui.Context, b *markdown.CodeBlockView) {
		if b.Open { // 仍在流式进入:先显示代码,直到闭合
			markdown.DefaultCodeBlock(c, b)
			return
		}
		ui.Image(c, app.diagram(b.Code()))
	},
}
app.md.CodeBlock = func(c *ui.Context, b *markdown.CodeBlockView) {
	ui.Column(c).Gap(4).Children(func() {
		ui.Text(c, b.Language).Bold()
		b.Body(c)       // 高亮、可选中的代码
		b.CopyButton(c) // 复制按钮
	})
}

数学公式与 HTML ​

$$ 与 $$、\[ 与 \] 或 \( 与 \) 之间的公式以其 TeX 原文用代码字体显示,如同语言模型写出的样子;启用 ExtDollarMath 扩展后 $…$ 同样如此,但美元符号读作金额的地方除外。Math 可以代替构建独立公式块,比如用你自己的渲染器;仍在流式进入的块对 Block 而言是 Open 的,它可以先显示 TeX,直到块闭合。

InlineMath 把文本中的公式变成段落的内联对象,输入其 TeX 和周围文本的大小:一个宽 Width、基线以上高 Height、基线以下深 Depth 的盒子,随文字一起换行,由 Draw 绘制。复制时,无论作为文本还是 Markdown,它都是其源文本,如 $x_i$。它返回 false 时,公式以 TeX 显示。段落每次变化它都会被询问,比如流式进入时每一帧都会,所以要缓存它生成的结果:

go
app.md.InlineMath = func(tex string, size float32) (markdown.InlineObject, bool) {
	f, err := app.formula(tex, size) // 只布局一次,按 TeX 和大小缓存
	if err != nil {
		return markdown.InlineObject{}, false
	}
	return markdown.InlineObject{Width: f.W, Height: f.Ascent, Depth: f.Descent, Draw: f.Draw}, true
}

原始 HTML 以文本显示,以免回复隐藏任何内容。在文本内部,模型常写的格式标签(<b>、<i>、<u>、<s>、<code>、<kbd>、<mark>、<sub>、<sup> 和 <br>)会生效;HTML 构建 HTML 块,比如 <details> 或 <think>。

样式 ​

视图的外观与窗口一致,使用窗口的字体和颜色。Theme 只设置要改变的部分,其余取窗口主题对应的 DefaultTheme 值,因此一个主题字段只改一件事:

go
app.md.Theme = &markdown.Theme{
	FontSize:     15,
	Link:         ui.Hex("#7c3aed"),
	CodeFont:     "JetBrains Mono",
	BlockSpacing: 1,
	Headings: [6]markdown.HeadingStyle{
		{Size: 1.8, Weight: 800, Rule: true},
		{Size: 1.4, Rule: true},
	},
}

主题设置正文字体、代码字体及其行高;正文、次要文本、链接、加粗文本和代码的颜色;代码大小;块与列表项之间的间距;各级标题的大小、字重、颜色、间距和下划线;列表缩进;引用块的竖条和文本;代码块的背景、边框、圆角、内边距和词法颜色(一个 editor.Theme);表格的线条、表头、条纹、内边距和最宽列;高亮文本;以及复选框。长度单位是 em,即字体大小的倍数,使主题随字体缩放。

Block 可以替换任何块的显示方式:每个块都会先询问它,它返回 true 时负责构建,数据来源是文档的块和内联元素(见解析器):

go
app.md.Block = func(c *ui.Context, d *parser.Document, i int) bool {
	b := d.Block(i)
	if b.Kind != parser.ThematicBreak {
		return false
	}
	ui.Text(c, "⁂").TextAlign(ui.Center).AlignSelf(ui.Stretch)
	return true
}

无障碍 ​

标题是相应级别的标题,列表及其项是列表和列表项,链接是链接,表格是由行、列头与单元格组成的表格,任务项是复选框(选中或未选中),图片是以其描述命名的图片,代码块是以其语言命名的组,带一个"复制代码"按钮。

解析器 ​

解析器是独立的包 ui/markdown/parser,不导入任何 ui 的内容,让只读 Markdown 的程序(导入它的富文本编辑器、渲染 HTML 的服务器)不必带上视图。Parse 读取字符串,Append 追加文本,Edit 修改某个范围,三者都只重新解析改动触及的部分。一次编辑从它前面的顶层块开始解析,直到某个块在没有任何未闭合结构的情况下重新开始——而它此前也是这样开始的,解析器保留该块原有的块:在长文档的预览里打字只解析少数几个块。在引用位置之后才定义的链接,会让使用它的块重新解析其内联元素,且只重新解析这些。

go
d := parser.Parse(src)
d.Append(token)
for i := range d.Children(0) { // 顶层块
	b := d.Block(i)
	if b.Kind == parser.CodeBlock {
		fmt.Println(b.Language(), d.Lines(i))
	}
}
html := d.HTML()

块和内联元素是按文档顺序排列的扁平切片,每个都带有其在源文本中的字节范围(Start 和 End)以及其子块之后的索引(Next)。Inlines 在第一次被询问时解析块的内部元素。New(ext) 读取 Default 之外的其他扩展组合,SetStreaming 像 Stream 那样补全末尾。

它通过 CommonMark 0.31.2 规范的全部 652 个示例和 GitHub 扩展的示例,对在其他解析器上表现很慢的输入也能以线性时间完成(与 cmark 一样),并且除标准库外不依赖任何东西。

性能 ​

在 Apple M5 上以 CGO_ENABLED=0 GOMAXPROCS=1 测量,对象是一条 50 KB、含标题、嵌套列表、代码、表格和链接的回复:

时间分配量
整体解析(含内联元素)0.3 ms0.8 MB,1300 次分配
追加 4 字节的 token(尾部的内联已解析)2 µs600 B,2 次分配
完整显示它的一帧,无任何变化1.3 ms16 KB,350 次分配
追加 16 字节 token 的一帧0.8 ms36 KB,350 次分配

和视图一样,每帧都会构建每个块的元素,而解析结果、段落的 span 及其文本布局都会保留:远长于聊天回复的文档最好分段显示。

运行基准测试与测试 ​

sh
CGO_ENABLED=0 GOMAXPROCS=1 go test ./ui/markdown -run '^$' -bench . -benchmem
go test ./ui/markdown -run 'TestCommonMark|TestGFM|TestAppend|TestEdits|TestPathological' -v