Keel GitHub

设计决策

记录影响全局的取舍:当时的选项、选了什么、代价是什么。推翻某条决策时,不要删掉它,在后面追加新的一条并注明取代关系。

选 Gio

决定:界面用 Gio 绘制,替换最初的 go-gui 实现。

目标:应用代码只写 Go,不写 HTML、CSS、JS,也不引入 WebView。

候选 为什么没选
Wails、WebView 类方案 和目标直接冲突:界面仍然是 HTML/CSS/JS
go-gui(原实现) 能用,但为了多窗口、生命周期、显示隐藏、快捷键,Keel 自己维护了约 940 行窗口管理和平台启动代码(不含测试),其中一部分是在绕开上游的行为(比如异步创建窗口没有失败回调)。换成 Gio 后,对应代码是 ui/ 里窗口相关的 235 行
giu(Dear ImGui) 依赖 C++ 的 ImGui,外观是工具、调试面板风格,做普通应用要大量改样式
Fyne 成熟,组件齐全。但它自带完整的应用生命周期、主题系统和组件体系,Keel 在上面只能再包一层薄壳,模块化后每一层都会和 Fyne 的对应概念重复

Gio 的好处:界面完全由 Go 绘制,组件只是普通 Go 结构体,即时模式很容易包成一层简单的有状态 API;自带离屏渲染(window.Screenshot 用它)和可编程的输入路由(ui/internal/uitest 用它)。

代价,都是选 Gio 时明确接受的:

  • 没有原生控件外观,所有组件的样子由 Keel 自己画。
  • 窗口不能隐藏再显示,只能关闭再重新打开。
  • 中文依赖系统字体回退,要固定字体优先级(theme.Face),还要微调文字垂直位置:el 按字体实测的字形位置把文字移到行框中间,并保证下伸部分(g、y)不出行框,见 ui/el/paint.go 的 textShift。
  • 组件要自己写。现在只有文字、按钮、链接、输入框、复选框,列表、下拉框、表格等都还没有。

有状态组件包在即时模式外面

决定:Gio 是即时模式,每帧重新描述界面。Keel 提供 widget.Button(...) 这样返回指针的有状态组件,用户拼一次组件树,之后只改状态。

理由:直接写 Gio 需要自己管理 widget.Clickable 等状态对象、每帧调用布局函数,每个页面的样板代码都很多。有状态组件让业务代码接近"声明界面 + 写回调"。

代价:动态结构(列表增删行、按条件显示)需要组件自己支持,目前还没有;想要完全灵活的布局时,用 core.Func 退回 Gio 写法。

一把全局帧锁

决定:所有窗口的渲染和所有回调都在 ui/internal/loop 的一把锁下串行执行,组件本身不加锁。其他 goroutine 用 core.Update 排队修改。

候选 为什么没选
每个组件自带锁 组件在 Layout 过程中读自己的状态,回调又会改别的组件,锁顺序很难保证,容易死锁;每个新组件的作者都要处理并发
每个窗口一把锁 回调经常跨窗口改组件(主窗口按钮改设置窗口的文字),还是要全局协调
所有操作都走 core.Update 回调里改组件也要包一层,写起来啰嗦

代价:

  • 窗口串行渲染;一个慢回调会让所有窗口一起卡住。
  • 持有锁时不能等主线程,否则会和其他窗口形成循环等待。这个问题真实出现过(多窗口下 ⌘+, 卡死),Raise、Close 因此改成了异步。见架构 · 不能在锁内等待主线程。
  • 文本排版器(text.Shaper)不是并发安全的,全局锁顺带保证了多个窗口共用一个排版器是安全的。这不算代价,但去掉这把锁时要记得处理它。

原生能力与界面分离,cgo 集中在一处

决定:native/ 不依赖任何界面模块;所有 Objective-C 和 cgo 代码放在 native/internal/sys,公开包只做参数校验。

理由:不需要窗口的工具(后台截图、全局快捷键)可以只用 native。cgo 代码集中在一个包里,平台桩函数也集中在 sys_other.go,漏写一个函数会直接编译失败,不会运行时才出错。

代价:新增一个原生能力要改 4 个文件(.m、.h、sys_darwin.go、sys_other.go),外加一个公开包。

按职责分层划分模块

决定:界面拆成 ui/core、theme、layout、widget、window 五个模块,依赖只往下走;系统能力拆成 native/permission、screen、input、hotkey 四个互不引用的模块。每个模块一个目录、一个 README,边界由 internal/deps 的测试强制。

这个结构是试了三次才定下来的,经过都记在这里,免得以后再绕一遍:

版本 结构 问题
第一版 顶层平铺 ui、theme、widget、box、app 名字看不出关系(box 是什么?),和 native 并列在顶层,看起来像五个互不相干的东西;担心以后顶层越来越多
第二版 界面全部合并成一个 ui 包,按文件划分 一个目录里 15 个文件,窗口、组件、主题、锁混在一起,看不出哪些是地基、哪些是上层,难理解
第三版(现在) ui/ 下分五个子模块,和 native/ 对称 业务代码要引入 2–3 个包

第三版选择的理由:

  • 顶层只有 ui 和 native 两组,一眼看出"界面"和"系统能力"两块。
  • 每个子模块职责单一,名字直接说明职责:core、theme、layout、widget、window。
  • 分层让依赖可读:先看 core 和 theme 就懂地基,window 不认识任何具体组件。理解一个模块只需要看它和它下面的模块。
  • 增长方式固定:新组件是 widget/ 下的新文件,新容器是 layout/ 下的新文件,不会冒出新目录。

代价:业务代码引入 layout、widget、window 三个包,比第二版多两行 import。layout 和 Gio 的 gioui.org/layout 同名,同一个文件里都要用时得起别名。

Agent 测试在内存里渲染,不驱动真实窗口

决定:KEEL_AUTOMATION 模式下,ui/window 给每个窗口配一个影子窗口:同一套组件、独立的 Gio 输入路由,Agent 的操作只送进影子窗口;cmd/keel-mcp 通过 socket 驱动它。可见模式下真实窗口照常显示,两边共享组件状态,用户能看着 Agent 操作;KEEL_HEADLESS=1 时只有影子窗口。

候选 为什么没选
用 native/input 移动真实鼠标、发真实按键,再截屏识别 需要辅助功能和屏幕录制权限;会抢走用户的鼠标键盘;窗口被遮挡或换了显示器就失败;"页面有什么"只能靠识图,拿不到元素的名字和状态
通过 macOS 辅助功能 API 读取窗口内容 Gio 在 macOS 上不向辅助功能 API 暴露控件,读不到
在真实窗口里注入事件 Gio 的 app.Window 不开放注入输入事件的接口

内存窗口的好处:确定性强,不需要任何权限,不打扰用户,一次完整流程约 1.5 秒;元素信息来自 Gio 的语义树,名字、状态、位置都是精确值。

代价:测不到系统窗口层面的问题,比如窗口位置、系统菜单、输入法,以及真实窗口之间与主线程相关的死锁。后者由 KEEL_DESKTOP=1 的真实窗口测试补上。组件必须自己声明语义信息,否则 Agent 看不见。

为什么是影子窗口,而不是把真实窗口的输入转发给 Keel 自己的路由器:后者要接管真实窗口的全部输入,包括中文输入法的候选和组字、剪贴板、光标形状、系统 Tab 焦点,等于重写 Gio 的输入层,最容易出问题的恰好是中文输入。影子窗口让用户这一侧的代码路径一行不改,代价是焦点在两边分开记录。

另一个选择:让应用自己当 MCP server。没选,因为测试经常要重启应用、换一个应用测,launcher 放在独立进程里才能做到;而且应用的标准输出不能被 MCP 协议占用。

在 Gio 上做 Go 版 GPUI(ui/el)

决定:新增 ui/el,按 GPUI 的思路在 Gio 之上加一层:链式样式 builder(Styled[T])、flexbox 布局引擎、按元素路径自动管理的元素状态。Gio 继续负责窗口、GPU 渲染、输入法、剪贴板。旧的 ui/layout + ui/widget 保留,逐个迁移。

为什么:写完表格、下拉框这批组件后,Gio 的成本很清楚了:每个交互组件都要自己声明状态变量(widget.Clickable 等),键盘焦点要注册过滤、登记处理者、执行焦点命令三步,布局要层层嵌套 layout.Flex{}.Layout(gtx, layout.Rigid(...)),Agent 语义要逐个手写;漏一步就出现"第一帧点不到""节点消失"这类问题。这些都应该由框架统一处理。

候选 为什么没选
继续在 Gio 上写组件 上面那些成本每个新组件都要再付一次
自己从头实现 GPUI(窗口、GPU、文字排版、输入法) 工作量最大的恰恰是这些平台层,Gio 已经做好了
照搬 GPUI 的 Entity/Context/cx.listener 这套机制很大程度是为了 Rust 的借用检查。Go 有闭包和 GC,视图做成普通 struct、回调直接捕获指针就够了;只保留 Go 里仍然需要的部分(cx.Shortcut,后台更新用 core.Update)

做法上的取舍:

  • 先分发事件、再渲染:点击的效果在同一帧就画出来,不像旧组件要等下一帧。
  • 元素状态的键是"树上的路径",每层取 ID,没有就取序号。静态结构不用写 ID;会变的列表要写。
  • 可交互、有语义或需要状态的元素才推入 Gio 的裁剪区域,其他 Div 不推,子元素可以溢出它。
  • 语义自动推断,Role/Name/Value/Selected 只用来补充或覆盖。

代价:布局是 flexbox 的子集(没有 wrap、grid、横向滚动、min-content),没有虚拟列表和动画;这些按需要补。过渡期两套写法并存,新人要知道先看 el。

迁移顺序:订单示例已经用 el 写了页面结构,表格、下拉框、单选、开关、对话框暂时用 el.Widget 嵌入。之后按使用频率迁移:按钮和表单控件 → 下拉框、标签页 → 表格(需要虚拟列表)→ 对话框(需要 el 的浮层)。每迁一个,examples/orders 的端到端测试都必须保持通过。

Markdown 流式渲染

决定:ui/markdown 用 goldmark 解析、chroma 高亮、Gio 扩展包的 richtext 画行内混排。源文本按"代码块外的空行"切块,每块缓存解析结果;流式输出时只重新解析最后一块,并临时补全它未闭合的行内语法。写完的块通过 el.Context.Cache 复用元素和布局。

为什么这样切块:AI 输出只在末尾追加。按顶层块缓存,追加的成本只和最后一块的大小有关,和整篇长度无关。代价是跨块的引用式链接不生效、空行分开的同一个列表会变成两个列表(有序列表的起始编号会保留)。AI 输出里这两种情况都少见。

为什么补全未闭合语法:不补的话,**加粗 在闭合之前显示成星号,闭合时整段突然重排,流式输出时会一直闪。补全只作用于最后一段,而且回答结束后按原文重新解析,不会影响最终结果。

性能上的三处改动,都是剖析出来的:

  1. richtext 排版很贵,而 el 每帧会测量同一个块好几次。富文本块缓存最近 8 次测量的结果(按约束区分)。
  2. el 的布局引擎原来对被拉伸、会伸展的子元素各排两次(先量自然尺寸再排最终尺寸)。改成容器宽度已知时直接按拉伸后的宽度排,Grow 改成 flex: 1 的语义(初始尺寸按 0 算),每个子元素每帧只排一次。这也让 cx.Cache 的布局复用能稳定命中。
  3. 滚动容器外的元素跳过绘制。

结果:流式输出时每帧从 5.0ms 降到 0.5ms(11KB 的文档)。

候选 为什么没选
每次追加都整篇解析、整篇排版 成本和文档长度成正比,长回答会越来越卡
用 WebView 渲染 Markdown 违背项目目标
自己写 Markdown 解析器 goldmark 是 CommonMark 标准实现,GFM 扩展完整,没必要

程序调用 SetXxx 不触发回调

决定:Field.SetValue、Check.SetValue 不触发 OnChange,只有用户操作触发。

理由:常见写法"A 变化时更新 B,B 变化时更新 A",如果程序调用也触发回调,会无限循环。Gio 的 Editor.SetText 本身会产生变化事件,Field 通过记录上次通知过的内容把它过滤掉了。

最后一个窗口关闭即退出

决定:最后一个窗口销毁后调用 os.Exit(0)。

理由:Gio 的 window.Main() 不会返回,而没有窗口的桌面程序在用户看来就是退出了。

代价:main 里 window.Main() 之后的代码和 defer 不会执行,清理工作要放进 OnClose。以后做托盘常驻时,需要改成可配置的退出策略。

不做的事

  • 不做 HTML/CSS 渲染,也不内嵌 WebView。 这是项目存在的理由。
  • 不模仿各平台原生控件外观。 所有平台一套外观,维护成本最低。
  • 暂不实现 Windows、Linux 的 native 能力。 当前只在 macOS 上用;接口已经按平台无关的方式设计,实现时只需要补 sys_windows.go 等文件。

新组件基于 el,ui/widget 冻结

决定:从 2026-10-01 起,ui/widget 只修 bug,不再增加组件或能力。新组件放在 ui/kit,基于 ui/el 实现。kit 直接依赖 core、theme、el,不依赖 widget、layout、window。这条决策取代前文“新组件放在 widget”和旧迁移顺序。

为什么:焦点、键盘、禁用、定时与浮层需要由 el 统一提供。在 widget 中继续实现新组件,会在迁移时重复实现这些机制。

代价:基础设施未完成前,相应的交互组件不能交付。过渡期两套组件并存,旧代码继续工作,新代码使用 kit。M0 只建立规则、模块边界和全局主题,组件从 M1 开始。

阶段 工作与完成标准
M0 组件规范、依赖登记、成功/警告/提示语义色、运行时浅深色切换;完成后 review
M1 Alert、Empty、Avatar、Tag 等展示组件;补焦点与按键、禁用、定时,再做 Spinner、Skeleton。焦点与按键接口完成后先 review,确认后继续依赖它们的组件
M2 Popover、Tooltip、Menu、DropdownButton、Dialog、Sheet、Notification;订单示例的对话框改用 kit
M3 迁移旧表单控件,再做 NumberInput、Combobox、Calendar、DatePicker、表单校验;订单示例的“新建订单”表单全部使用 kit
M4 虚拟列表、Tree、kit 表格、Command,提取聊天消息组件;示例不再引用 ui/widget
M5 应用外壳
M6 可视化;迁移完成后删除 ui/widget,并清理所有剩余依赖

每个组件单独实现、验证、提交。提交前执行 go build ./... && go vet ./ui/... && go test ./... -count=1;不使用测试结果缓存,避免端到端测试启动的示例源码变化未被缓存检测到。具体要求见kit 组件规范。

暂不做代码编辑器、HTML 富文本、完整 TeX、局部主题覆盖、Kbd 动作绑定查询。这些能力需要各自的模型与接口,等有实际需求时单独决策。

删除 ui/widget 和 ui/layout

决定:M6 完成后删除 ui/widget 和 ui/layout,Keel 只保留一套组件 ui/kit。这条决策取代前文"有状态组件 + 容器"和"旧的 ui/layout + ui/widget 保留"。

为什么:kit 已覆盖旧组件的全部功能,示例和测试都已迁移。两套组件并存,焦点、禁用、浮层、语义要维护两份,文档也要讲两种写法。

怎么做:Markdown 用到的图片加载、解码限制和占位移到 ui/internal/imageload,Markdown 公开 ImageLoader 和 DecodeImage。window.Options.Content 和 Overlay 仍接受任意 core.Widget,自己写的 Gio 代码照常可用。

代价:没有兼容层。使用旧组件的代码按 kit 组件 改写:widget.Xxx 一般对应 kit.Xxx,layout.Column / Row / Card 改用 el.Div。

框架文字集中到 ui/locale

决定:Keel 自己显示或报告给 Agent 的文字(确定、取消、复制、关闭、请选择、"36 行"等)全部放进 ui/locale。默认中文,提供英文预设,locale.Apply 在运行时切换,所有窗口重绘,cx.Cache 自动失效,做法与 theme 一致。

为什么:kit、当时的 widget 和 markdown 里一共写死了二十多处中文。应用切换到英文界面时,这些框架文字没法跟着换。组件越多,以后改的成本越高,所以趁 kit 还在早期集中处理。

范围:只管框架文字。应用自己的文案由应用负责,Keel 不做翻译系统;Current().Lang 告诉应用当前是什么语言,切换后界面会重新渲染。

约束:internal/deps 的测试禁止在框架代码里写中文字符串字面量,测量 CJK 行高用的字形探针"国""国Ag"除外。

代价:无障碍名称统一用"动作 + 空格 + 对象"拼接,"清空搜索"变成了"清空 搜索";Markdown 图片的替代文字从"[图片:x]"变成"[图片 x]"。

自定义标题栏暂不实现

决定:M5 不做 TitleBar(自定义窗口标题栏)。

为什么:自定义标题栏需要无边框窗口,标题栏区域还要能拖动窗口、双击最大化,macOS 上的红绿灯按钮也要保留在正确位置。这些都要 native 和 ui/window 配合实现(macOS 全尺寸内容视图、拖动区域登记),只靠 kit 画一个"看起来像标题栏"的组件,窗口是拖不动的。在没有具体需求之前,这部分原生工作投入大、收益不确定。

代价:应用只能使用系统标题栏。需要时另起一项:先在 native 增加无边框窗口和拖动区域,再在 kit 提供 TitleBar。

自定义标题栏:用 Gio 的无边框窗口实现

取代:上一条《自定义标题栏暂不实现》。

决定:window.Options.Frameless 打开 Gio 的无边框模式(app.Decorated(false)),kit.TitleBar 自己画标题栏和窗口按钮。拖动用 Gio 的 system.ActionInputOp(system.ActionMove) 登记区域,窗口按钮通过 ui/core 的 WindowControls 接口调用所在窗口。

为什么改判:上一条判断需要在 native 里新写无边框窗口和拖动区域。实际查看 Gio v0.10.3 后发现,这两样它都已经提供:macOS 上无边框模式会让内容延伸到标题栏、标题栏变透明;按下登记过 ActionMove 的区域时,由系统完成窗口拖动。所以不需要新增原生代码。

取舍:

  • Gio 在无边框模式下会隐藏 macOS 的红绿灯按钮,所以按钮由 kit 自己画,颜色沿用系统惯例。
  • kit 不能引用 ui/window,因此在 ui/core 放一个最小接口(Frameless、Minimize、ToggleMaximize、Maximized、Close)。窗口在布局期间登记自己为"当前窗口",组件在 Render 时拿到它,供回调使用。全部渲染都在同一把帧锁下串行进行,所以这样做是安全的。
  • Gio 判断一个位置是不是拖动区域时,只看那里有没有登记拖动,不管上面有没有按钮。所以拖动区域只能放在按钮旁边,不能包住按钮。TitleBar 的布局按这个约束设计:窗口按钮、应用内容和拖动区域并列排开。

代价:macOS 上双击标题栏不会缩放窗口,因为按下事件直接交给系统拖动,应用收不到双击;窗口失去焦点时,按钮也不会变灰。

自定义标题栏:跟随窗口焦点并接入 macOS 双击

决定:core.WindowControls 增加窗口焦点查询与标题拖动区域登记。window 从 Gio ConfigEvent 更新激活状态;TitleBar 每次绘制只登记不含按钮、插槽的中间区域,禁用与未绘制帧清空。

原因:Gio 的 macOS ActionMove 在按下时直接调用 AppKit 原生拖动,普通组件双击回调收不到第二次完整点击。window 使用当前 NSView 的本地事件监视器,只在已登记区域内截获双击,按系统的无操作/最小化/缩放偏好处理。头部控件继续接收普通输入。

线程与生命周期:传递 NSView 句柄时先 retain,异步提交到主线程后 release;原生登记表在视图更换或窗口关闭时移除。原生回调只向 Go 更新队列提交动作,不在主线程等待帧锁。UI 不引用 native,kit 不引用 window。