外观
文档
每个窗口显示一个文件的应用,例如文本编辑器、绘图应用或笔记应用,是基于文档的应用。mygo.NewDocuments 在所有平台上为它们提供 AppKit 的 NSDocument 和 SwiftUI 的 DocumentGroup 赋予 Mac 应用的能力:
- 每个文档一个窗口,标题中显示其名称;在 macOS 上,标题栏中还有文件图标,有未保存更改时关闭按钮上有一个圆点;
- File 菜单中的新建、打开、打开最近文档、保存、另存为、创建副本、重命名、移动到和复原;
- 自动保存,并且只在无法自动保存的地方才询问是否保存;
- 为每一处未保存的更改保留恢复副本,崩溃或退出都不会丢失内容;
- 从 Finder、资源管理器、Dock 和跳转列表打开文档;
- 在打开期间被其他应用修改的文件。
一个用原生 UI编写的笔记编辑器,整个应用:
go
func main() {
mygo.NewDocuments(mygo.DocumentsOptions[string]{
Types: []mygo.DocumentType{{Name: "Markdown Document", Extensions: []string{"md"}}},
Read: func(r io.Reader) (string, error) {
b, err := io.ReadAll(r)
return string(b), err
},
Write: func(w io.Writer, text string) error {
_, err := io.WriteString(w, text)
return err
},
Equal: func(a, b string) bool { return a == b },
Window: func(doc *mygo.Document[string]) *mygo.Window {
text := doc.Content()
var win *mygo.Window
win = mygo.NewWindow(mygo.WindowOptions{Content: ui.View(func(c *ui.Context) {
ui.TextArea(c, &text).Fill().OnChange(func() { doc.Set(text) })
})})
doc.OnReload(func() { text = doc.Content(); win.Update(func() {}) })
return win
},
})
if err := mygo.App.Run(); err != nil {
log.Fatal(err)
}
}examples/documents 就是这个编辑器的完整版,带有状态栏以及处理其他应用修改文件的提示条:
sh
go run ./cmd/mygo dev examples/documents文档及其内容
DocumentsOptions[T] 描述一种文档,其内容为 T:
| 字段 | |
|---|---|
Types | 文件的种类,由名称和扩展名描述,例如 {Name: "Markdown Document", Extensions: []string{"md", "markdown"}}。打开对话框会显示它们;新文档以第一个扩展名保存 |
Read | 在 goroutine 上从文件读取内容 |
Write | 在 goroutine 上把内容写入文件 |
Window | 在主线程上为文档创建窗口 |
New | 新文档的内容;未提供时使用 T 的零值 |
Equal | 比较内容,使得撤销回到文件当前内容后文档恢复为未编辑状态 |
Snapshot | 在写入之前复制可变内容 |
Autosave | AutosaveInPlace(默认)或 AutosaveOff |
AutosaveDelay | 最后一次编辑后多久进行自动保存:5 秒 |
AskOnQuit | 退出时询问是否保存,而不是把文档保留到下次启动 |
NoUntitled | 应用启动时没有可打开的内容,也不创建未命名文档 |
请在 App.Run 之前调用 NewDocuments。应用启动时,会打开随应用启动而传入的文件、崩溃或退出时留有未保存更改的文档,否则新建一个未命名文档。一个应用可以创建多种文档;第一种就是 File > New 新建的文档。
Document[T] 是一个已打开的文档。它的方法可以从任意 goroutine 安全调用:
go
doc.Content() // 内容
doc.Set(v) // 替换内容,即编辑文档
doc.State() // 名称、路径、是否已编辑、只读、冲突、已删除
doc.Window() // 它的窗口
doc.Save() // 相当于 File > Save值与可变内容
内容最好是应用用 Set 替换的值:字符串、由值组成的结构体,或持久化快照,例如 ui.TextSnapshot 或 editor.Snapshot,它们共享未改变的部分。保存时,在 goroutine 上写入保存开始时的内容,用户可同时继续编辑,因此不被任何代码修改的值本身就是快照。
可变内容,例如应用就地修改的模型指针,需要提供 Snapshot,它在应用修改内容的主线程上复制内容,并用 Changed 报告变更:
go
mygo.DocumentsOptions[*Drawing]{
Snapshot: func(d *Drawing) *Drawing { return d.Clone() },
// …
}
drawing.Shapes = append(drawing.Shapes, shape)
doc.Changed()已编辑状态
当文档的内容与其文件不同时,文档处于已编辑状态:
Set和Changed会将其标记为已编辑。提供Equal后,用Set设置成文件当前的内容会使其恢复未编辑状态——撤销回到文件内容,或输入一个字符再删除,正是如此:文本控件的撤销开箱即用。SetEdited可直接设置该状态,适用于更有数的应用,例如自行维护撤销历史的应用:设为false表示内容与文件一致,但不写入文件。- 保存会使其变为未编辑,除非内容在写入期间又发生了变化:这些改动仍未保存。
OnStateChange 在主线程上通知状态变化。
窗口
文档的窗口显示其名称:文件的名称,或按应用显示的语言显示"未命名"、"未命名 2"……。文档窗口的标题在 macOS 上就是文档名称,在 Linux 和 Windows 上则是名称加上应用名,如同记事本和 gedit 的显示方式。页面的 <title> 不会改变它。
文档有未保存更改时,macOS 在窗口关闭按钮上显示一个圆点,Linux 和 Windows 在标题前显示 *。在 macOS 上,标题栏还显示文件图标,用户可以把它拖到另一个应用;Command 点击标题会列出文件所在的各级文件夹。
任何应用的窗口都可以通过 Window.SetRepresentedFile 和 Window.SetDocumentEdited 实现同样的显示。Window.Title 返回不含 * 的标题。
保存
Save() | 把文档写入其文件。未命名文档会询问保存位置,只读文档会索取另一个文件,文件被其他应用修改过的文档会询问是否覆盖。ErrCanceled 表示用户取消了操作 |
SaveAs(path) | 把它写入另一个文件,文档此后即为该文件;传 "" 则询问位置 |
SaveAnyway() | 即使其他应用修改了其文件也照常写入 |
Duplicate() | 打开一个未命名副本,例如名为"Notes copy" |
Rename(name) | 在其所在文件夹中重命名文件;传 "" 则询问 |
MoveTo(dir) | 把文件移动到另一个文件夹;传 "" 则询问 |
Revert() | 重新读取文件,丢弃未保存的更改 |
Close() | 关闭其窗口,必要时先询问是否保存 |
文件的写入方式与 NSDocument 和 GIO 相同:先写入文档旁边的新文件,刷入磁盘后再替换原文件,因此崩溃或磁盘写满绝不会留下半个文件。文件保留其元数据:macOS 上通过 NSFileManager,保留权限、创建日期、Finder 标签及其他扩展属性、ACL 以及文件身份标识,因此替身(alias)仍能指向它;Windows 上通过 ReplaceFileW,保留安全描述符、属性、备用数据流和创建时间,并为被其他程序占用的文件稍作等待;Linux 上保留权限、所有者和组。符号链接会替换其目标;有其他硬链接的文件,或位于用户无法创建文件的文件夹中的文件,则就地写入。当 ReplaceFileW 在删除原文件后中途失败时,新文件会被移到原位置;若连这一步也失败,新文件会留在文档原位置旁边,保存的错误信息会指明它。
写入之前,会把文件与文档上次读取或写入时的状态(修改时间和大小)进行比较:当其他应用修改了它时,Save 会询问,自动保存则什么都不写。
自动保存
默认情况下(AutosaveInPlace),文档会在用户停止编辑片刻后(AutosaveDelay,用户持续编辑时至少每 30 秒一次)、窗口失去焦点时以及关闭时保存到其文件,与 Mac 应用一致。已保存的文档从不询问是否保存。
无法就地保存的文档不会自动保存:未命名文档,文件为只读、已被删除或被其他应用修改过的文档。它们会在关闭时询问。
使用 AutosaveOff 时,文档只在用户保存时保存,并在关闭时询问,如同 Windows 和 Linux 应用的传统做法。
恢复副本
每个有未保存更改且自动保存不写入的文档都会保留一份恢复副本,在用户停止编辑一秒后创建,如同 VS Code 的备份和 Zed 的数据库所做的那样:未命名文档、无自动保存应用的文档、处于冲突状态的文档。副本位于 PathUserData 下的 Recovery/<kind> 中,由 Write 写入,并记录文件的修改时间和大小;当文档被保存、复原或关闭且不保存时,副本随之删除。
应用启动时,崩溃或强制退出留下的文档会恢复回来,并标记为已编辑。其间文件被修改过的文档处于冲突状态。应用的每个运行实例都在自己的会话中保存副本,运行期间加锁,因此其他实例绝不会打开它们。
关闭与退出
| 询问 | |
|---|---|
| 关闭未编辑的文档 | 不询问 |
| 带自动保存时,关闭已保存过的已编辑文档 | 不询问:会先保存 |
| 带自动保存时,关闭已编辑的未命名文档 | "要保留这个新文档"未命名"吗?"保存……、删除、取消 |
| 关闭无自动保存的已编辑文档,或无法就地保存的文档 | "要保存对文档"Notes.md"所做的更改吗?"保存、不保存、取消 |
| 退出 | 不询问:文档就地保存或保留,下次启动时以已编辑状态恢复 |
使用 AskOnQuit 退出,且有多份文档 | macOS:"你有 3 份文档存在未保存的更改。要在退出前查看这些更改吗?"查看更改……、放弃更改、取消,然后每份文档各一个表单。Linux 和 Windows:一个列出它们名称的对话框,全部保存、不保存、取消 |
这些提问、它们的按钮以及未命名文档的名称都是 MyGo 自身的字符串,以应用显示的语言呈现。在 macOS 上,提问以文档窗口的表单(sheet)显示。需要询问的关闭会让窗口保持打开,直到用户回答;退出流程会等待文档处理完毕再退出,选择取消则所有窗口都保持打开。此时退出的 OnBeforeQuit 监听器会再次运行。
未经询问就被销毁的窗口(Window.Destroy、App.Exit),其文档未保存更改的恢复副本会保留,并在下次启动时恢复。
被其他应用修改的文件
文档会监视自己的文件(见监视文件),并在窗口获得焦点时检查文件,如同 VS Code 和 Notepad++ 的做法,因为监视可能漏掉更改:
- 没有未保存更改的文档会重新读取其文件,
OnReload通知应用显示新内容; - 有未保存更改的文档进入冲突状态:其状态会标明这一点,保存时询问保留自己的版本还是其他应用的版本("仍要保存"、"复原");
- 其他应用在其所在文件夹中重命名文件时,文档会跟随新文件;
- 文件被删除后,文档保持打开,其状态中
Deleted为真,保存时会重新写入文件。
File 菜单
文档相关的菜单角色作用于获得焦点窗口的文档,并针对它启用:
| 角色 | macOS | Linux、Windows |
|---|---|---|
RoleNew | 新建 ⌘N | 新建 Ctrl+N |
RoleOpen | 打开… ⌘O | 打开… Ctrl+O |
RoleOpenRecent | 打开最近文档 | 打开最近文档 |
RoleSave | 保存 ⌘S | 保存 Ctrl+S |
RoleSaveAs | 另存为… ⌥⇧⌘S | 另存为… Ctrl+Shift+S |
RoleDuplicate | 创建副本 ⇧⌘S | 创建副本 |
RoleRename | 重命名… | 重命名… |
RoleMoveTo | 移动到… | 移动到… |
RoleRevert | 复原到已保存版本 | 复原 |
当应用有文档时,RoleFileMenu 包含各平台的 File 菜单:macOS 上是 DocumentGroup 的样式(新建、打开、打开最近文档、关闭、保存、创建副本、重命名、移动到、复原到已保存版本),其他平台是记事本和 KDE 的样式(新建、打开、打开最近文档、保存、另存为、复原、关闭、退出)。有文档的应用在 Linux 和 Windows 上也会获得菜单栏,包含 File 和 Edit 菜单,直到它们设置自己的菜单。
打开最近文档列出应用最近打开和保存的文档,最新的在前,即 App.RecentDocuments 返回的内容:文档用 App.AddRecentDocument 记录其文件,同时也会记录到系统中(macOS 上的 Dock 菜单、Windows 上的跳转列表、Linux 上最近使用的文件),其"清除菜单"项调用 App.ClearRecentDocuments。见最近使用的文档。
打开文件
文档会打开系统要求应用打开的(App.OnOpenFile)、属于其扩展名的文件:来自 Finder 或资源管理器、Dock 或跳转列表,或命令行。请在配置的 fileAssociations 中列出这些类型,系统便会为这些文件提供该应用:
json
"fileAssociations": [
{ "ext": ["md", "markdown"], "name": "Markdown Document", "mimeType": "text/markdown" }
]在 Linux 和 Windows 上,每次打开文件都会重新启动应用:请调用 App.RequestSingleInstanceLock,让第一个实例来打开它们。
已经打开的文件会聚焦其所在窗口。在 macOS 上,应用没有窗口时仍保持运行,如同文档应用的传统,点击其 Dock 图标会新建一个未命名文档。
Web 页面
文档窗口的页面通过生成的 TypeScript 客户端获取其文档:把文档服务以客户端调用它所用的名称绑定,
go
notes := mygo.NewDocuments(mygo.DocumentsOptions[Note]{ /* … */ })
mygo.BindAs("Note", notes.Service())页面便可读取并替换其窗口文档的内容,并跟踪其状态:
ts
import { Note } from "./mygo"; // generated
import { onDocumentReload, onDocumentState } from "mygo-runtime";
editor.value = (await Note.content()).body;
editor.oninput = () => Note.setContent({ body: editor.value });
onDocumentState(({ name, edited }) => { status.textContent = edited ? "Edited" : "Saved" });
onDocumentReload(async () => { editor.value = (await Note.content()).body });该服务提供 content、setContent、state、save、saveAs、duplicate、rename、moveTo 和 revert,作用于调用它们的窗口。页面在每次按键时发送全部内容,适合笔记和小文档;大文档则应在用户停顿时发送。
平台差异
| macOS | Linux | Windows | |
|---|---|---|---|
| 已编辑标记 | 关闭按钮上的圆点 | 标题前的 * | 标题前的 * |
| 标题栏中的文件 | 代理图标,Command 点击显示其所在文件夹 | 无 | 无 |
| 标题 | 名称 | 名称 - 应用名 | 名称 - 应用名 |
| 替换文件 | NSFileManager 的 replaceItemAtURL: | 重命名,保留模式、所有者和组 | ReplaceFileW |
| 提问 | 窗口的表单(sheet) | 对话框 | 对话框 |
| 无窗口时 | 应用保持运行;Dock 图标可新建文档 | 应用退出 | 应用退出 |
未包含的功能
以下功能只有 macOS 上真正的 NSDocument 才具备,暂未包含:在标题栏的弹出面板中重命名、标题旁的"— 已编辑"、浏览版本及还原到某个版本、锁定文件,以及 iCloud 草稿。以文件夹形式存在的文档(包)尚不支持,浏览器也没有可打开的文件。