Skip to content

组合一个可换主题的选择器 ​

语言:English | 简体中文

本教程把核心组件契约扩展成可复用的界面,同时不把产品策略交给库。你将组合文本编辑器、 模糊过滤、布局、焦点、指针路由和适配终端的主题。

完整代码:examples/picker

开始之前 ​

请先完成构建第一个界面。本教程假定你已经理解 program.Run、Draw 和 Handle 如何配合。

在 core 之外添加组件模块:

sh
go get github.com/Tangerg/oolong/core@latest
go get github.com/Tangerg/oolong/components@latest

请同步升级这两个模块。Oolong 的模块共享同一个协调发布版本。

让每一层只决定一件事 ​

选择器是由较小的框架组件组合出的产品组件:

mermaid
flowchart TD
    picker["应用选择器"] --> composer["kit.Composer 外观"]
    picker --> filter["headless.Filter 行为"]
    composer --> editor["headless.Editor 行为"]
    picker --> layout["core/layout 几何"]

Filter 拥有匹配、选择和滚动,但不拥有查询编辑器或行的外观。应用必须拥有后两者, 因为只有应用知道查询应该放在哪里,以及选中一个项目意味着什么。

一次性解析终端事实 ​

从当前运行时所驱动的终端构造外观。不要在组件内读取进程环境变量,因为本地会话与 SSH 会话可能描述不同的终端。

go
func newPicker(runtime *program.Runtime, items []string) *picker {
    theme := kit.Suited(runtime.Environment().Ground())
    glyphs := kit.GlyphsFor(runtime.Environment().Locale())

    p := &picker{runtime: runtime, theme: theme}
    p.query = kit.Composer{
        Theme:  theme,
        Prompt: glyphs.Marker + " ",
    }
    p.query.Editor().Placeholder = "type to narrow"
    p.configureItems(items)
    return p
}

Theme 命名 Accent、Selection 和 Danger 等语义角色。Glyphs 回答另一个问题: 这个终端能够表示哪些字符。

让过滤器拥有过滤行为 ​

通过方法配置会改变状态的行为。SetItems 将值与“如何将值读成文本”的投影作为同一 数据源一起替换,SetPattern 再收窄该数据源。两者都会使缓存的排序失效,因此不存在 数据与投影不一致的中间状态,也不会让过时结果继续可见。 两种变更都会先完成匹配再发布。文本投影发生 panic 时,原有数据源、查询、匹配与光标 保持完整;投影必须是纯函数,不能重入该 Filter 的 SetItems 或 SetPattern。

go
func (p *picker) configureItems(items []string) {
    p.list = &headless.Filter[string]{
        Row: p.drawRow,
    }
    p.list.SetItems(items, func(item string) string { return item })
}

行回调是外观接缝。它会收到项目、模糊匹配偏移和选中状态。若要用另一种形状绘制同一 行为,请替换这个回调,而不是替换 Filter。

绘制被动图表 ​

图表是已完成内容,不是控制器。Sparkline 与 BarChart 直接绘制到 grid.View,实现 headless.Block,但不拥有选择、时钟或历史。应用继续持有测量数据,并决定它们何时 变化。

go
trend := kit.Sparkline{
    Theme: theme, Glyphs: glyphs,
    Values: []float64{0.12, 0.18, 0.15, 0.24, 0.31},
    Minimum: 0, Maximum: 1,
}

usage := kit.BarChart{
    Theme: theme, Glyphs: glyphs, Maximum: 100,
    Bars: []kit.Bar{
        {Label: "CPU", Value: 42, Text: "42%"},
        {Label: "RAM", Value: 68, Text: "68%"},
    },
}

Sparkline 使用能够放入视口的最新样本。有效的 Minimum/Maximum 组合会固定量程, 完成度与资源指标通常需要这种语义;两个字段取零时则从可见窗口推导刻度。BarChart 有意只做水平、非负图表:它解决带标签的仪表盘比较,而不引入坐标轴、画布或约束 求解器。任一图表都可以通过 headless.Static、转录区、面板正文或应用自己的布局进行组合。

划分已分配的区域 ​

无外观组件绘制到 headless.Frame。它既携带核心组件使用的裁剪后网格视图,也携带 输入几何所需的事务。

go
func (p *picker) Draw(frame headless.Frame) {
    rows := frame.Subs((layout.Flow{Axis: layout.Down}).Rects(frame.Bounds().Size(), []layout.Slot{
        {Size: layout.Fixed(1)},
        {Size: layout.Flex(1)},
        {Size: layout.Fixed(1)},
    }))
    p.query.Draw(rows[0])
    p.list.Draw(rows[1])
    kit.Label{
        Text:  fmt.Sprintf("%d of %d", p.list.Matched(), p.list.Len()),
        Style: p.theme.Subtle,
    }.Draw(rows[2].View)
}

应用决定纵向组合,两个子组件都不需要知道上下还有什么。把槽位改为 layout.Across 即可得到双栏界面,无需修改任何子组件。

按所有权路由事件 ​

把导航键交给列表,把文本输入交给编辑器。“已处理”与“已改变”是两个事实:在空编辑器 开头按 Backspace 会被处理,但不会改变任何内容。在输入前后比较 Editor.Revision,只在 语义内容真的变化时发布过滤模式,无需根据按键或 action 名称猜测。

子回调替换附件或改变外层焦点后,内置容器会消费被中断的旧按键或 action。 即使随后又回到原附件或原焦点,旧操作也不能重新取得冒泡权限。路由作用域未变时, 仍可正常拒绝输入。Root 使用已提交组件树的身份;只替换待绘制内容不会改变输入作用域。

go
func (p *picker) Handle(event input.Event) bool {
    if key, ok := event.(input.Key); ok && key.Down() {
        switch key.Code {
        case input.Enter:
            p.choose()
            return true
        case input.Up, input.Down, input.PageUp, input.PageDown:
            return p.list.Handle(event)
        }
    }
    before := p.query.Editor().Revision()
    handled := p.query.Handle(event)
    if p.query.Editor().Revision() != before {
        p.list.SetPattern(p.query.Editor().Text())
    }
    return handled
}

对于指针输入,请在 Draw 期间把子区域暂存到 headless.Snapshot,再在 Handle 中根据 Snapshot.Value 路由。快照只会在完整根帧成功后变为可见,所以事件永远不会看到一半 新、一半旧的布局。完整选择器演示了这一模式。

安装无外观根节点 ​

headless.NewRoot 是从实时无外观树到 program.Component 的唯一桥梁。它会原子交付 所有嵌套的呈现快照。

go
err := program.Run(context.Background(), program.Config{
    Root: func(runtime *program.Runtime) program.Component {
        return headless.NewRoot(newPicker(runtime, files()))
    },
    Terminal: term.Features{Probe: true, Mouse: true},
})

program.Config.Terminal 只接受可选的 term.Features。Root 已经表示拥有备用屏幕, Inline 已经表示使用终端普通屏幕,因此不存在需要保持一致的第二个 AltScreen 开关。 传输适配器在取得底层终端会话时使用 program.Config.TerminalConfig()。

已完成的 Markdown 文档等被动内容不需要该事务。只有在把它放进实时组件树时,才使用 headless.Static 适配。

在不改变行为的前提下换外观 ​

复制主题值并替换语义角色:

go
theme := kit.Suited(runtime.Environment().Ground())
theme.Accent = grid.Style{FG: grid.RGBColor(0xD7, 0x8B, 0xFF)}
theme.Selection = grid.Style{BG: grid.RGBColor(0x32, 0x27, 0x3B)}

把修改后的值传给 kit 组件,无外观状态不会改变。若要构建完全自定义的设计系统,请保留 components/headless,并用自己的包绘制每个外观接缝;headless 永远不会导入 kit。

明确值身份与导航意图 ​

受控字段通过 accessor 提交编辑。在安装选项前设置 Select.Same 或 MultiSelect.Same;普通可比较值可以使用 headless.Equal[T]。标签只负责显示, 翻译标签不会改变选中值的身份。Chosen、Taken 和 Ask 只投影状态。 Select 直接读取 owner,无需 Sync。Text.Sync 协调编辑模型;MultiSelect.Sync 在显式语义边界规范化调用方的选中集合。

通过 NewText(TextConfig{Value: ...})、NewSelect(SelectConfig[T]{Value: ...})、 NewMultiSelect(MultiSelectConfig[T]{Value: ...}) 或 NewConfirm(ConfirmConfig{Value: ...}) 构造受控字段。绑定引用保持私有,后续只通过 SetBinding 转移;它会取消旧键序列, 使旧校验失效。传入 nil 则把已接受的答案保留为本地状态。Accessor 可以包含函数、map 或切片,无需指针或可比较身份限制。

绑定转移先完成必要的读取和身份比较,再发布新 owner。异常退出保留原 owner、历史和校验。 替换没有未完成写入的 accessor 不读取已放弃的答案;交回本地状态时才读取旧 owner 的已接受答案。 Text 在转移所有权前结算尚未完成的编辑。

Accessor 回调返回后,Select 和 MultiSelect 只投影当前 owner,也支持交回本地状态。 受控 Select 和 MultiSelect 不保存可写的答案镜像;导航、Toggle 和 Reply 只向 accessor 提交独立提案。不可用的单选答案投影为首项;Validate 或替换已有选项 会向 owner 提交该项,被拒绝后仍属于无效绑定。调用方改变单选答案后,下一帧会显示其所在行。 MultiSelect 不会在 Set 尚未结束时再次提交同一规范化请求。如果规范化回调开始新语义操作, 旧 Do 或 Toggle 在分派前返回 false,Handle 消费中断的事件,Reply 则在应用旧答案前 返回 headless.ErrValidationSuperseded。 未完成的写入属于对应绑定的生命周期;切换绑定后,新 owner 可以立即完成自己的规范化。

字段先接收并转发焦点,再协调答案。Accessor 发布新答案不能拒绝父级已经发出的焦点通知。 只有仍有效的焦点转移可以启动失焦校验;后续转移,包括离开后又返回,会撤销旧转移 尚未启动的校验。重复通知相同焦点状态不会产生额外的失焦校验。校验一旦开始, 受检答案的有效性由校验自身拥有,与焦点独立。

校验结果的发布权属于字段。回调中开始新的校验、回复或改变受检答案时,旧 Validate 和 Reply 返回 headless.ErrValidationSuperseded,保留最近已发布的字段错误。 Form 的 Set、Add、Cancel 和新的 Submit 会撤销正在进行的旧提交; 旧提交返回 false,不再继续校验或调用 Done。替换或添加字段也会清除表单错误。 Field.Validation 返回最近成功校验的纯读取凭据;没有有效结果时返回 nil。 新的校验或不同的答案会使旧凭据失效。Form 保留凭据,直到后续字段和表单回调结束, 只有全部仍有效时才完成提交。自定义字段必须实现该契约;Form 不访问或拥有字段答案, 也不重试失效的校验。 Accessor 拒绝规范化并保留无效值时,校验返回 headless.ErrInvalidBinding。 单行显示、候选选项和过滤后的集合都不能代替应用实际接受的答案完成提交。

Text 直接提供文本、光标、遮罩、剪贴板、行旁装饰和光标样式。通过 SetText、 SetCursor、Handle 或 Do 编辑,使结果经过 accessor 的接受规则。 Text 不再暴露内部 Editor。kit.Composer 是编辑器的外观包装,仍然公开它的 Editor。

重入的 Text 操作先按 accessor 已接受的值结算正在提交的编辑,旧调用不能回滚新光标, 也不能再次提交新编辑。若同步期间出现新操作,Text.Do 会在分派前返回 false。 Accessor 写入发生 panic 或退出 goroutine 时,Text 按已接受的值结算草稿、revision 和历史。 若该值也无法读取,编辑会保留结算记录,直到下一次语义操作能够读取它。结算不吞掉异常退出。 Text 的 SetBinding 会重新开始历史并清除旧选区,undo 和尚未结束的 Cut 都不能 把旧 owner 的编辑写给新 owner。 剪贴板回调修改内容或选区后,Editor.Cut 返回 false;复制旧选区不能授权删除新选区。

列表选择变化和 Editor 光标导航都只请求显示一次。Scroll.Reveal 会保留到完整帧提交,中断绘制不会丢失请求。 后续手动滚动优先,Scroll.FollowingEnd 表示跟随末尾的策略。 kit.Transcript.Current 只改变匹配高亮,跳转请调用 RevealMatch(index)。 Editor 请求在下一帧的实际换行宽度下解析;已完成导航后的尺寸变化保留手动滚动位置。 Filter 查询重置会回到第一个结果。

Transcript 的按下与滚轮输入使用最后完整帧的可见单元格和目标;拖动延伸也只落在 这些可见单元格内。替换已呈现的 Content 或 Selection 会终止旧手势的坐标生命周期和连续点击, 即使后来切回原目标也不会恢复旧拖动;松开仍然结束原选择的拖动状态。 更换 Scroll 后,滚轮在替换帧提交时才转向新目标。

容器中的孩子需要移动时,应设置稳定 Key 并保留同一组件实例。FocusIndex 接受当前集合 索引;指针输入按已呈现的实例身份定位,移除或替换孩子后不会把旧点击或捕获交给新实例。 需要跨 Set 保持身份的组件应使用指针。

每个控件拥有自己的 Pointer。Draw 中调用 Stage(frame, area);事件处理中调用 Handle 和 Clicked(button)。绘制只读取 Over()、Pressing(),快速按下再松开 不需要中间绘制一帧。PointerRegion 根据孩子的连续呈现生命周期路由:孩子消失后重新 出现,也不会接回旧手势。Tabs 和 Viewport 使用同一规则。

输入转向其他目标时,路由器向旧目标发送 input.MouseLeave。自定义控件应清除悬停, 保留已经接受的捕获;Pointer.Handle 会自动处理。焦点和路由转移在调用回调前发布 新 owner,通知结束后重新检查目标是否仍然有效。

新按下替换旧手势时,路由器向旧接收者发送 input.MouseCancel,包括按下处理尚未 返回的接收者。自定义控件应结束手势且不产生点击,Pointer.Handle 会自动处理。 Stack 的 base 和弹层共享一个捕获拥有者。新输入使用提交帧的可见裁切区;已经接受 的拖动和释放仍可在区域外继续。

明确属于其他按钮的拖动不能推进已捕获的手势;input.Mouse.Drags 和 Mouse.Releases 提供统一的按钮匹配规则。字段包装层只转换坐标并转发生命周期事件, 内部 List 或 Editor 拥有已接受的手势和可见命中区域。答案区域外的释放、标签上的新按下 以及失焦都会到达这个拥有者。同一 pane 的多个 attachment 不会让它同时收到获焦和失焦通知。

明确属于其他按钮的释放不会结束当前捕获。input.ButtonNone 的释放没有指明按钮, 会结束当前按下。拖出区域再拖回,匹配的释放仍可完成点击;拖动是否禁止激活由应用决定。

List.SetItems 后,鼠标选择等待新集合完成绘制。 拖动必须先有可见行接受的主按钮按下;孤立拖动、被拒绝的按下后发生的拖动都不能改变 选择或绑定字段值。 List.Hit(point) 接受列表局部坐标,原点是列表的逻辑帧,不是可见裁切区。 它只查询最后提交帧,不改变选择或滚动。 它拒绝可见矩形四边以外、被裁切或空白的行,以及替换集合后尚未绘制 的命中。改变滚动但尚未绘制时,查询仍对应屏幕上的行。Filter.Hit(point) 返回匹配 结果中的索引,与 Selected 和行渲染回调一致;它直接使用内部 List 的查询。

自定义控件用 keymap.Matcher.Reset(change) 替换待定绑定所对应的状态:先撤销匹配, 完成整个状态变更后再取消旧 resolver,不会清除变更期间启动的新匹配。 返回 false 表示已被更新的匹配或绑定取代,输入处理应停止旧事件的后续派发。 Filter 只在 source 和 pattern 的 setter 中重建匹配。它与 Table 都在集合及游标变更 全部完成后取消旧按键 resolver;读取集合不能完成或发起另一条替换路径。 Table 比较回调必须纯净并定义严格弱序。行、游标与排序标记在比较成功后一起发布; 比较 panic 保留原行、比较函数与排序状态。比较中重入 SetItems、SetLess、SortBy 或 ClearSort 会在改变它们之前 panic。 Stack 键盘输入只属于焦点拥有者,并在该拥有者已呈现后才被接受。旧帧不能激活已失焦 的孩子,也不能关闭尚未呈现的替换层。指针输入使用绘制提交的几何。

自定义页签条用 Tabs.SelectPresented(index) 处理已提交页签条上的点击;Tabs.Select(index) 仍然用于当前集合中的程序导航。修改按键绑定会取消未完成序列;替换选项集合或模态层时, 新对象不会继承延迟动作。Settings 的值编辑始终属于开始该按键序列时的那一行。

Dialog 的成员身份由控制器拥有。使用 Show、Dismiss、Sync;把 Dialog.Content() 或嵌入它的包装对象交给 Stack.Push 会在插入前 panic。

Completion.Renderer 同时提供 DrawRow 与 Width,可以替换完整候选行布局。 Select.Row 和 MultiSelect.Row 可自定义单行选项。外部字段实现 ThemedField.DrawWith 后,就能通过与内置字段相同的通道接收该帧的 Form 外观。

若按键取消回调开始了新的候选提供或关闭操作,Completion.Dismiss 返回 false。 调用方必须停止推进旧候选;内置接受操作会检查该结果,再决定是否调用旧候选及 token 的回调。

kit.Paragraph 在 SetText 或 SetLinks 成功后一起发布正文和检测出的链接。 LinkConfig.Exists 可以检查文件系统、读取上一次完整发布的段落,但不能重入这两个设置方法。 链接检测 panic 时,原正文、链接策略、链接与排版保持不变。

保存结构化输入历史 ​

条目携带附件身份或其他结构化值时,使用 headless.History[T]。History 拥有有界序列、 历史游标和第一次成功 Back 时保存的草稿;应用决定空消息的含义,以及何时调用 Add。

在构造时配置值的所有权边界:

go
type promptEntry struct {
    Text          string
    AttachmentIDs []string
}

history := headless.NewHistory(headless.HistoryConfig[promptEntry]{
    Limit: 100,
    Clone: func(entry promptEntry) promptEntry {
        entry.AttachmentIDs = slices.Clone(entry.AttachmentIDs)
        return entry
    },
    Equal: func(a, b promptEntry) bool {
        return a.Text == b.Text && slices.Equal(a.AttachmentIDs, b.AttachmentIDs)
    },
})

Clone 必须复制值中的所有可变引用,包括条目包含的嵌套切片、map 或指针指向的数据。 它必须是观察上纯粹的复制,不修改入参,也不产生外部副作用。 History 在保存入参和返回已保存条目时使用它。示例中的字符串不可变,因此复制附件切片 即可。Clone: nil 使用普通赋值,适用于不可变值。Equal: nil 保留每个条目,包括连续 重复值;配置相等判断后只压制相邻的相等条目,不会删除更早的重复项或改变顺序。 相等判断必须是观察上纯粹的,只读借用入参。复制与相等策略在整个 History 生命周期内固定。

Back(current) 只在第一次成功后退时保存完整草稿。Forward() 越过最新条目时返回草稿, Cancel() 则立即返回草稿;两者结束遍历时都会释放 History 对草稿的引用。成功的 Add 会结束 当前遍历,即使新条目因相等判断而被去重。At(1) 读取最新条目,不移动游标。 SetLimit(0) 恢复默认的 1,000 条上限,负数上限会 panic。若缩容淘汰了当前浏览的条目, 下一次 Forward 会先到最早仍被保留的条目,再依次前进到较新条目和草稿。

新增条目和游标移动只在复制与相等判断成功后提交。回调发生 panic 或退出 goroutine 时, 条目、游标与保存的草稿保持不变,原始退出继续传播。

搜索使用独立于身份判断的显示投影:

go
matches := history.Recall("readme", func(entry promptEntry) string {
    return entry.Text
})

每个 Recalled[promptEntry] 包含条目快照、用于 At 的 Step 和投影文本中的匹配字节偏移。 结果保持从新到旧的顺序,相同显示文字仍可对应不同附件。投影只读借用条目。空查询返回 所有条目而不调用投影;非空查询配合 nil 投影不会匹配任何条目。搜索不改变当前遍历。

字符串调用方把 History 改为 History[string],使用 HistoryConfig[string]{Clone: strings.Clone, Equal: headless.Equal[string]} 构造, 并给 Recall 传入返回原字符串的投影。History 现在接受空值和空白文本,因此提交校验应 留在调用方。零值 History[T] 使用默认容量、普通赋值和不去重语义。通过指针保留 History, 在所属 goroutine 中使用;开始使用后不得复制,也不能并发访问。

composer 示例 把文本和粘贴内容放入同一个 History[draft],不再需要按显示文本建键的旁路 map,也不再 维护第二套草稿恢复状态。

搜索 transcript 快照 ​

headless.Search 在 worker 上扫描 transcript 快照。在 transcript 的事件拥有者上调用 Submit,在 worker 上接收 Results,再把结果应用派发回同一个拥有者。紧接着应用结果之前, 检查 result.Current(),Close 也在该拥有者上串行执行。Search 拥有提交身份,Query 只是显示文字。重复相同查询、改变模式 或语料、清空查询、提交 nil transcript 或调用 Close,都会撤销旧收据,包括已经取走的结果。 比较 Query 或在接收 worker 上检查收据,无法保护稍后执行的拥有者回调。Close 等待扫描器 结束并关闭 Results。

运行并验证这个切片 ​

sh
go run ./examples/picker
cd examples && go test ./picker

现在你已经掌握组件层组合模型。接下来可以阅读 渲染 Markdown、代码与数学公式以添加被动内容,或阅读 构建有界流式输出以连接后台工作。

基于 Apache 2.0 许可证发布。