外观
代码编辑器
ui/editor 包是一个代码编辑器,也是原生 UI 的差异查看器,仿照 Zed 设计:语法高亮、多个光标与选区、软换行、折叠、查找与替换、补全、悬停信息、来自应用或语言服务器的诊断与内联提示,以及代码编辑器的各种命令和按键。它只为可见的行布局和绘制,因此百万行的文件滚动和编辑起来与十行的文件一样快。
go
import "github.com/egoist/mygo/ui/editor"
// 与应用的其他状态一样,由应用自己保存。
buf := editor.NewBuffer(source)
buf.SetLanguage(editor.LanguageFor("main.go"))
app.code = &editor.State{Buffer: buf, WrapGuides: []int{100}}
// 在视图里:
editor.View(c, app.code).Grow(1).FillWidth()State 是编辑器的状态,像文本输入框的字符串一样跨帧保存:包括它的缓冲区、它的选项,以及编辑器为自己保存的内容,如选区、折叠和滚动位置。它的选项是在编辑器构建之前设置的字段;零值即默认值,也就是 Zed 的默认值。View 根据一个状态构建编辑器,它和其他元素一样可以获取焦点:给它一个大小,再用 AutoFocus 就可以立即在其中输入。
代码编辑器示例可以编辑多种语言的文件,带有基于文件内单词的补全、悬停信息、TODO 注释的诊断、装订线中的改动及其差异。
缓冲区
Buffer 是编辑器编辑的文本,连同它的撤销历史和语言。多个编辑器以及差异的两侧都可以显示同一个缓冲区:在一处编辑会在其他处显示,它们的光标随着周围的文本一起移动,而撤销会收回缓冲区的最后一次编辑,无论它是哪个编辑器做出的。
go
buf.Edit(editor.Edit{Start: 0, End: 0, Text: "// Generated.\n"})
buf.Transact(func() { // 撤销时算作一步
buf.Edit(editor.Edit{Start: a, End: b, Text: name})
buf.Edit(editor.Edit{Start: c, End: d, Text: name})
})
if buf.Modified() {
_, err := buf.WriteTo(file)
if err == nil {
buf.MarkSaved()
}
}偏移量按 UTF-8 字节计数,Pos 是行和行内的字节数,都从 0 开始:Pos 和 Offset 在两者间转换,Line、Slice 和 String 读取文本。Set 作为一次编辑替换文本,可被撤销收回;Reset 则作为新文档,不带历史。
行以 \n 结尾,与 Zed 相同:使用 \r\n 换行的文本会按 \n 保存,LineEnding 说明它原本用的是哪种,WriteTo 会按原样写出,使文件保存后和原来一致;SetLineEnding 可以更改它。
文本是分块构成的持久化树,像 Zed 的 rope 一样:无论文档多大,一次编辑只复制几千字节和树上的一条路径,每个版本都与前一个版本共享其余部分。撤销保存的是版本而不是副本,而 Snapshot 是在缓冲区变化时保持不变的版本,其他 goroutine 无需加锁即可读取。缓冲区的方法对任何 goroutine 都安全;修改它们时要用 Window.Update,以便窗口显示变化。
编辑
编辑器的编辑方式与 Zed 相同,命令绑定在相同的按键上(在 Linux 和 Windows 上用 Control 代替 Command,如同 Visual Studio Code):
- 多个光标。 Cmd+D 选中当前选区的下一个出现位置,或光标处的单词;Cmd+Shift+L 选中全部;Cmd+Alt+Up 和 Down 在上一行或下一行添加光标;Alt+点击添加一个光标,Alt+Shift+拖动按列选择。输入、删除和每个命令都在每个光标处生效,Escape 恢复为单个光标。Cmd+U 撤销对选区的更改。
- 按结构选择。 Ctrl+Shift+Right 先选中单词,再选中它外面的字符串或括号,并继续向外扩展;Ctrl+Shift+Left 逐级返回。
- 行。 Alt+Up 和 Down 移动行,Alt+Shift+Up 和 Down 复制行,Cmd+Shift+K 删除行,Ctrl+J 合并行,Cmd+/ 注释或取消注释,Cmd+] 和 Cmd+[ 缩进;Cmd+Enter 在下方插入一行。
- 输入。 括号和引号在键入时自动闭合,并包裹选区;Enter 保持缩进,在开括号之后和成对符号之间增加缩进,并延续行注释;键入闭括号或像
end这样的单词时,当前行会相应减少缩进。撤销按一段一段的输入来收回。 - 折叠。 装订线上的标记折叠某行之下缩进的行,直到它的闭括号,或折叠连续的一段 import 及其间的空行;Cmd+Alt+[ 和 ] 折叠与展开,Cmd+K Cmd+0 折叠全部,Cmd+K Cmd+J 展开全部,Cmd+K Cmd+1 到 9 按层级折叠。
- 查找。 Cmd+F 打开查找栏,支持区分大小写、整词、正则表达式和在选区内查找;Cmd+Alt+F 进行替换。Cmd+G 跳到下一个匹配项,Ctrl+G 跳到某一行。
- 指针。 双击选中一个单词,三击选中一行,拖动按此扩展选择;在边缘附近拖动会滚动;Cmd+点击跳转到定义。
Run 运行一个命令,如同菜单项或应用自己的按键所做的那样;Bindings 绑定按键:先给 Mods 和 Key,组合键(如 Cmd+K Cmd+0)再给 ThenMods 和 ThenKey。应用的绑定排在默认绑定(DefaultBindings)之前,绑定到 NoCommand 会把某个按键从编辑器手里拿走,留给应用自己的快捷键。CommandNamed 按键图给命令起的名字找到它,如 "selectNext"。
go
app.code.Bindings = []editor.Binding{
{Mods: ui.Cmd, Key: ui.KeyP, Command: editor.NoCommand}, // 留给应用自己的快捷键
{Mods: ui.Cmd | ui.Shift, Key: ui.KeyD, Command: editor.DuplicateSelection},
}
if ui.Button(c, "Format").Clicked() {
app.code.Run(editor.SortLines)
}Selections、SetSelections、Select、Cursor 和 CursorPos 读取和设置光标与选区,Insert 在每个光标处输入,GoTo 把光标移到某个位置,ScrollTo 显示某一行。Find 带着一个查询打开查找栏,FindMatches 统计它的匹配数;Fold、Unfold 和 Folded 折叠行,FoldImports 折叠第一段连续的 import,就像编辑器在打开文件时可能做的那样;Focused 报告编辑器是否拥有键盘焦点,VisibleLines 报告上一帧显示的第一行和最后一行,Vim 式的半页翻动以此为步长。ScrollY 和 SetScrollY 以 DIP 为单位读取和设置行滚动的距离,LineTop 给出一行相对于第一行的起始位置:并排的编辑器可以据此同步滚动,应用也可以画出连接两侧行的内容,比如合并视图两个窗格之间的连接带。
OnInput 在编辑器之前看到每个输入事件:按键、输入的文本、编辑命令和指针。返回 true 会拿走事件,编辑器就不再处理它;其余事件照常进行,就像没有这个回调一样。应用自己的编辑模式(如 Vim 的普通模式)可以这样接管按键,而编辑器仍然显示和移动光标。在光标处键入的字母以 InputText 形式到来:在某些系统上,它们的按键也会先以 InputKeyDown 到来,而拿走按键并不会拿走它的文本,所以模式要从 InputText 读取字母。输入法组合、上屏或替换的内容(比如 macOS 长按产生的重音替换光标前的字母)只交给编辑器。Bindings 组合键(如 Cmd+K Cmd+0)中第一个之后的按键属于这个组合键,如同 VS Code 会先解析组合键再交给扩展的按键。
NoInputMethod 在编辑器拥有键盘时关闭系统的输入法,如同 Neovide 在 Vim 普通模式中的做法:按键直接键出键盘布局上的字符,以 InputText 传给 OnInput,不做任何组合,因此即使开着中文输入法,j 也还是 j。在把字母当作命令读取的模式里设置它,在插入模式里清除它。浏览器无法关闭输入法:在那里输入法仍会在文本中组合。
go
app.code.CursorShape = editor.CursorBlock // 普通模式
app.code.NoInputMethod = true
app.code.OnInput = func(ev ui.InputEvent) bool {
if ev.Kind == ui.InputText {
return app.vim.normalKeys(ev.Text) // j, k, dd, …
}
return false
}选项
SoftWrap在编辑器宽度处折行,或当WrapColumn更窄时在其列数处折行;折出的各行保留该行的缩进。LineNumbers显示装订线中的行号,可以是绝对行号、相对于光标所在行的行号,或完全不显示;GutterMarks在行旁放上某种颜色的圆点,如断点;OnGutterClick报告对行号的点击。LineBackgrounds从First到Last为行填充一种颜色,位于文本和选区之下,比如某个按键将要执行的语句;装订线保留自己的背景,被折叠隐藏的行不占要填充的空间。与诊断和内联提示不同,它们在文本被编辑时保持原有行号不动:应在缓冲区的Version变化时重新计算它们。TextBackgrounds为文本的某个范围(从字节Start到End)填充颜色,位于文本和选区之下、行背景之上,比如合并改动所在行内被改的单词。它们跟随编辑器的布局:一行折出的各行、制表符、宽字符和横向滚动;被折叠隐藏的行不显示。同行背景一样,它们在文本编辑时留在原处,直到在缓冲区的Version变化、重新计算之前都被截到文本末尾。TabSize和Indent设置缩进:用制表符还是空格;为零时采用该语言文件通常的写法(Go 用制表符)。Whitespace把空格和制表符显示为点和箭头:默认只在选区内显示,也可处处显示、仅在行尾显示,或完全不显示。WrapGuides在指定列(如 80 和 100)画出参考线;NoIndentGuides隐藏缩进参考线。ReadOnly允许选中和复制文本,但不允许编辑。FitContent让编辑器与其行等高而不再滚动它们,以便在滚动视图或列表中展示代码。Font和LineHeight设置文本:默认是系统 13 DIP 的等宽字体,行高为其 1.5 倍。NoAutoClose、NoFolding、NoActiveLine和NoScrollPastEnd关闭对应的行为。Placeholder在缓冲区为空时显示。CursorShape把光标画成竖条(CursorBar)、盖在光标后字符上的方块(CursorBlock,如 Vim 普通模式那样,半透明以露出字符,并在编辑器没有键盘焦点时画成描边),或下划线(CursorUnderline,如 Vim 等待中的操作符那样)。DiffBase是装订线中显示缓冲区改动所依据的文本,比如 Git 中的文件:竖条标记新增和修改的行,删除处只留一个标记。Cmd+F8 跳到下一处改动,Cmd+Alt+Z 撤销光标处的改动。ContextMenu在编辑器自身的项之后,向编辑器的上下文菜单添加项。
语言与高亮
LanguageFor 按文件名找到文件的语言,LanguageNamed 按语言自己的名字找到它;Languages 列出所有语言。编辑器认识 Go、JavaScript、TypeScript、JSON、Python、Rust、C、C++、Java、Kotlin、Scala、Swift、Dart、C#、shell 脚本、YAML、TOML、SQL、R、Ruby、Lua、PHP、CSS、HTML、XML、Markdown、diff、Dockerfile、Makefile、Protocol Buffers、GraphQL 和 Zig。Language 说明如何对其文本进行高亮、注释、括号匹配和缩进;再加上 Imports——一条 import 行的模式(Go 是 ^import\b)——决定哪些行可作为连续一段来折叠:可以为另一种语言构建一个,或复制一个再修改。
默认情况下,语言用 Tokenizer 高亮其文本:它是一个函数,输入一行以及上一行结束时的状态,输出该行的词法和它结束时的状态,如同 TextMate 语法和 Monaco 的分词器的工作方式。编辑器只对显示的行分词;编辑之后,只处理其下的行,直到某一行以其编辑前相同的状态开始为止,每次用一帧的时间预算。
go
// 以 # 开头的行是注释。
lang := &editor.Language{
Name: "Notes",
Extensions: []string{".notes"},
LineComment: "#",
Tokenizer: editor.TokenizerFunc(func(line string, state uint64, tokens []editor.Token) ([]editor.Token, uint64) {
kind := editor.TokenText
if strings.HasPrefix(strings.TrimSpace(line), "#") {
kind = editor.TokenComment
}
return append(tokens, editor.Token{End: len(line), Kind: kind}), 0
}),
}一个词法(token)是行内一段同属一个 TokenKind 的内容,主题为其着色:关键字、类型、函数、字符串、注释及其他。
高亮器
语言也可以自带一个高亮器,比如来自 tree-sitter 这类解析器的高亮器:Language.Highlighter 为该语言的每个缓冲区创建 Highlighter,编辑器用它替代分词器。分词器本身也是一种高亮器(NewTokenHighlighter),因此高亮器可以即插即用,编辑器无需改动。
go
type Highlighter interface {
// Edit 跟随一次文本编辑:old 和 new 是编辑前后的文本,
// changes 按顺序给出被替换的内容。
Edit(old, new editor.Snapshot, changes []editor.Change)
// Highlight 朝第 last 行及之前各行的词法推进,至多花费 budget,
// 并报告它们是否已就绪。
Highlight(last int, budget time.Duration) bool
// Tokens 把第 i 行的词法追加到 dst,并报告它们是最终结果
// 还是在 Highlight 完成之前做出的猜测。
Tokens(i int, text string, dst []editor.Token) ([]editor.Token, bool)
}编辑器在主线程上调用这些方法:Edit 在缓冲区变化时调用;Highlight 在绘制之前、带着要显示的行调用,若它报告 false,则在后续帧里继续调用;Tokens 对每一要绘制的行调用。Change 给出一次编辑在旧文本和新文本中被替换的范围,增量解析器正是用它来更新自己的语法树。解析器也可以在后台工作:快照对任何 goroutine 都安全可读,Chunks 可以分块读取;而创建高亮器时传入的 invalidate 函数,会在解析完成后让显示该缓冲区的编辑器重新绘制。
了解文本结构的高亮器还可以通过实现下列任意接口告诉编辑器更多信息:
SyntaxSelector:Enclosing返回包含的范围大于给定范围的最小节点,Select Larger(扩大选择)会选中它,而不是围绕该范围的单词、字符串或括号。Folder:FoldEnd返回折叠某行时隐藏的最后一条行,而不是由缩进和括号推断。BracketMatcher:EnclosingBrackets返回围绕某个范围的最内层一对括号,编辑器会高亮它们并在其间跳转,如do和end。Indenter:IndentAfter返回在某行后按 Enter 新开的行应多缩进几层。
补全、悬停与诊断
编辑器显示应用或语言服务器对代码的了解;应用响应它的请求,可以立即响应,也可以稍后在另一个 goroutine 里通过 Window.Update 响应:
go
app.code.OnCompletion = func(r editor.CompletionRequest) {
go func() {
items := app.lsp.Complete(r.Offset) // 语言服务器返回的
app.win.Update(func() { app.code.ShowCompletions(r.Offset-len(r.Prefix), items) })
}()
}
app.code.OnHover = func(r editor.HoverRequest) {
if doc, start, end, ok := app.lsp.Hover(r.Offset); ok {
app.code.ShowHover(start, end, doc, nil)
}
}
app.code.SetDiagnostics(buf.Version(), app.lsp.Diagnostics())OnCompletion在键入单词或按下 Ctrl+Space 时请求补全项。ShowCompletions在光标下方列出它们,并随键入按已输入的单词过滤和排序,带上各自的类别、详情和文档;Enter 或 Tab 插入选中的一项。OnHover在指针停留于文本上,或在光标处按下 Cmd+K Cmd+I 时询问要显示什么。ShowHover在该范围上方的弹出面板里显示文本,或显示由函数在其间构建的内容。指针可以移入悬停面板,只要指针在里面它就会停留,以便选中和复制其中的文本;指针离开文本和面板片刻后,悬停面板消失,Escape 可立即关闭它。SetDiagnostics用对应严重级别的波浪线标出问题,在悬停面板中显示其消息,并在滚动条上做出标记。版本号是诊断所对应的缓冲区版本:编辑器会随其后发生的编辑移动它们。SetInlays显示内联提示,即行内不属于文本本身的内容,如变量的类型;SetSuggestion把光标之后的文本显示为建议,按 Tab 接受,比如来自 AI 的建议。OnDefinition对某个单词 Cmd+点击或按 F12 时被调用。
样式
主题
Theme 为编辑器着色:背景、文本、装订线、选区、光标、参考线、诊断、改动以及每种词法的颜色。留为零值的颜色采用 DefaultTheme 的颜色,它跟随窗口的主题与外观(浅色或深色)。
go
th := editor.DefaultTheme(c.Theme())
th.Tokens[editor.TokenKeyword] = editor.TokenStyle{Color: ui.RGB(0xc6, 0x78, 0xdd), Bold: true}
app.code.Theme = th栏与弹出层
查找栏、前往行栏、补全列表和悬停面板都是应用可以自己构建的部分,如同为 ui 的基础控件定制样式:RenderFindBar、RenderGoToLine、RenderCompletions 和 RenderHover 各自收到编辑器放置好的元素(右上角之上的工具栏、顶部的一条栏、光标下方的列表、悬停文本上方的卡片),该元素会挡住其下方文本的按压,并把键盘放在该部分需要的地方(补全显示时在编辑器里,选其文本时在悬停面板里),同时收到一个描述该部分显示与操作的句柄。为元素定制样式并构建其内容;它的位置也可以调整。编辑器自带的外观是 DefaultFindBar、DefaultGoToLine、DefaultCompletions 和 DefaultHover,渲染函数可以先调用它们再做重新修饰。
go
app.code.RenderFindBar = func(c *ui.Context, bar ui.Element, f *editor.FindBar) {
t := c.Theme()
bar.Row().Gap(8).Padding(6).Background(t.Surface).Radius(12).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.2))
bar.Children(func() {
f.QueryInput(ui.TextInputBase(c, f.Query()).Placeholder("Search").Width(180))
ui.Toggle(c, f.Regexp(), ".*").KeepFocus()
ui.Text(c, f.Status()).TextColor(t.TextMuted)
if ui.Button(c, "Next").KeepFocus().Clicked() {
f.Next()
}
})
}
app.code.RenderHover = func(c *ui.Context, panel ui.Element, h *editor.HoverCard) {
editor.DefaultHover(c, panel, h) // 编辑器自带的外观,换一种背景
panel.Background(app.hoverColor)
}FindBar绑定输入框和开关:Query和Replacement,以及CaseSensitive、WholeWord、Regexp和InSelection,都是指针,匹配结果随它们的变化而变化。QueryInput和ReplacementInput给输入框赋予该栏的按键:栏打开时查询框获得焦点,Enter 和 Shift+Enter 在匹配项间前后移动,Alt+Enter 选中全部,替换框中的 Enter 和 Cmd+Enter 替换一个或全部,Escape 关闭。Matches和Status统计匹配数,Next、Previous、SelectAll、Replace、ReplaceAll和Close执行操作;Replacing说明该栏是否也进行替换,如 Cmd+Alt+F 打开时那样。GoToLineBar的输入框是Text,Input赋予它该栏的按键,Cursor、LineCount和Target说明它将前往何处,Go和Close执行操作。CompletionMenu有显示的Items(最佳者在前)和当前Selected的一项。Item(i)生成某一项的那一行:随方向键移动而选中,被点击时滚动到视野内并插入该项;请为其定制样式并填入内容。Font、LineHeight和Theme让代码以与编辑器一致的方式显示,CompletionKind有一个Icon和一个Token,其颜色为图标着色。HoverCard有悬停文本的Diagnostics、应用传给ShowHover的Text,以及Content,它构建其函数所构建的内容。
无障碍
辅助技术看到的是一个多行文本字段,由 Label 命名,带有缓冲区的文本和最新的选区,并可移动选区;补全列表是一个列表,查找栏是一个报告匹配数量的工具栏。编辑器的菜单、查找栏与前往行栏及其名称都使用窗口的语言(MyGo 自身的字符串)。输入法在编辑器中像在别的文本字段里一样进行组合,候选窗口出现在光标处。