外观
文本输入底层原语(Text-input primitives)
TextInputBase 和 TextAreaBase 提供无外观的普通可编辑字符串控件。若控件需要自己的存储、选区、渲染或编辑策略,可为元素附加一个 TextInputClient:
go
ui.Box(c).
HandleTextInput(app.input).
HandleInput(app.input.HandleInput).
Draw(app.input.Draw).
Label("Document").
Role(ui.RoleTextField)该原语把你的状态与平台的输入法和文本查询连接起来。你的客户端拥有自己的缓冲区、选区模型、标记文本(marked text)和撤销历史。它可以使用 rope(绳状结构)、持久化树或增量段落存储;MyGo 不会在每次编辑时复制整个文档,也不会替你决定格式化行为。现有的 HandleInput、TextCaret、TextInput 和 TextArea API 仍然可用。TextCaret 适用于终端及其他需要候选窗口位置但不提供文本文档的控件。InputMethod(false) 会为二者关闭输入法,按键仍会输入文本,此时文本不经组合直接到达 ReplaceText,正如模态编辑器普通模式所需;浏览器中无法关闭输入法。
内置的 TextInput 和 TextArea 也实现了这一客户端契约。它们的 *string 绑定在控件构建时发布已提交的文本;原生查询在该帧之前就已能看到排队中的导航和编辑。预编辑文本(preedit)在提交之前是对原生文档的一次虚拟插入,其替换与提交共享同一个撤销事务。密码字段不向原生查询提供已提交文本;只读字段会禁用输入。段落索引包含 UTF-16 偏移,因此对大文档末尾附近的原生查询无需扫描其前缀。
字符串绑定在每次提交编辑时仍会产生整个字符串。若需索引存储,请将 TextBuffer 与 TextInputBuffer 或 TextAreaBuffer 一起使用:
go
// 跨帧保留缓冲区;其零值也可使用。
document := ui.NewTextBuffer(initialText)
// 在视图中:
input := ui.TextAreaBuffer(c, document).Fill().Font("monospace")
if input.Changed() {
// 长度/版本查询读取树的摘要,无需展平文本。
version := document.Version()
_ = version
}TextInputBufferBase 和 TextAreaBufferBase 提供无样式变体。它们与字符串控件共享选区、样式、剪贴板、IME 和撤销行为。原生修改会立即更新缓冲区,Changed 在控件构建时报告这些变化。以编程方式编辑缓冲区会刷新控件并清除其过期的选区/撤销状态;请使用 Window.Update 来调度此类更改并使视图失效。
TextBuffer 是一棵由有界、独占所有的 UTF-8 块组成的平衡树。它按字节、rune、UTF-16 单元和换行计数建立索引。小的编辑只复制受影响的块和树路径;不会复制整个文档,也不会移动其后每个段落的绝对偏移。Snapshot 共享未变化的块,并在编辑后依然有效;Restore 以常数时间采用某个快照。缓冲区方法可从任意 goroutine 安全调用,快照可与编辑并发读取。
Replace(start, end, text) 和 Slice(start, end) 以 rune 计数,与 Element.TextSelection 一致。ReplaceUTF16、TextForRange、UTF16Offset 和 RuneOffset 打通原生范围并保持代理对完整。快照还提供 Line、LineRange 和 LineAt,供自定义编辑器布局使用。
String 会显式物化整个文档。对于文件导出,WriteTo 将块以流式写入 io.Writer。普通的多行编辑和有界的 IME 查询不会展平文档;显式的全文档查询或当前的无障碍 Value 协议可能请求其完整文本。布局仍然一次排版一个逻辑段落。行数的变化会更新控件的段落/高度元数据。
text-buffer 示例展示了一个 10 万行的可编辑文档。自定义编辑器可以通过其 TextInputClient 使用同一个缓冲区,并自行选择选区、历史和渲染策略。
text-input 示例展示了一个由应用自己拥有的小型文本字段。它演示了组合输入、指针选择、"编辑"菜单命令和自定义渲染。它是一个原语示例,不是编辑器实现。
客户端契约
传入一个跨帧保留的客户端指针。HandleTextInput(nil)(包括带类型的 nil 指针)会将其断开。元素可获得焦点并显示文本光标;其大小、外观和其他输入行为由你决定。
| 方法 | 用途 |
|---|---|
TextForRange(range) | 返回所请求的文本以及实际被钳制/调整后的范围。支持全文档查询;没有固定的周边窗口范围限制。 |
Selection() | 报告主原生选区及其方向。额外光标或不连续范围由应用状态自行维护。 |
MarkedRange() | 报告当前文档中的预编辑文本,不存在时返回 false。 |
ReplaceText(range, text) | 在显式范围内提交文本;range 为 nil 时,若有预编辑文本则替换它,否则替换选区。按你的编辑策略清除预编辑文本。 |
SetMarkedText(range, text, selected) | 替换预编辑文本并保留其标记范围。selected 相对于新标记的文本。 |
UnmarkText() | 清除标记状态但保留文档文本。在失去焦点、客户端更换或元素被释放时也会调用。 |
BoundsForRange(range) | 返回相对于元素的文本/插入符几何信息、实际表示的范围,以及它是否可用。对不可用或屏幕外的文本返回 false。当元素被滚入视野(如获取焦点时那样),若插入符的边界可用,则显示插入符的边界而非其所在框。 |
IndexForPoint(point) | 对相对元素的点做命中测试,返回文档偏移,或返回 false。 |
范围以 UTF-16 代码单元计数,与原生文本服务和 GPUI 的输入处理程序契约一致。这是一个互操作的坐标系,而非存储格式。ui.UTF16Len 和 ui.UTF16ByteOffset 帮助简单的 UTF-8 缓冲区在边界处完成转换;索引化缓冲区可以自行提供更快的转换。代理对必须保持完整,你的编辑策略应尊重 Unicode 字素(grapheme)边界。TextForRange 返回其调整后的范围,平台因此知道它收到了什么文本。
回调在 UI 线程上同步运行,先于下一帧。它们不得等待需要该线程的工作。客户端负责与其他 goroutine 的同步;应用状态的更改应像其他原生 UI 视图那样通过 Window.Update 调度。原生修改之后,MyGo 会请求一帧,并在下一个输入事件之前读取最新的选区/插入符。过期的原生引用无法修改已失去焦点、已被更换或已释放的客户端。
在 macOS 上,客户端提供 NSTextInputClient 的选区、标记范围、子串查询、范围边界和点命中测试。GTK 使用有界的周边文本,保留 UTF-8/rune/UTF-16 转换,并转发周边删除和预编辑/提交回调。Windows IMM32 使用有界的文档馈送/重新转换上下文,并转发组合和提交范围。更换客户端会重置原生预编辑文本,使候选词不会转移到新获得焦点的字段。平台回调在启动时一次性分配。
键盘导航、指针选择、剪贴板策略和"编辑"菜单命令都通过 HandleInput 传来。它们属于你的控件。这使编辑器可以选择多重选区、自定义键位映射、分组和历史记录,而不必覆盖框架自带编辑器的内置行为。
保留文本几何
ShapeText(text, font, width) 和 ShapeRichText(spans, font, width) 使用 Core Text、Pango 或 DirectWrite 生成 TextLayout。width 为正数时文本换行;为零时只在换行符处断行。Font.LineHeight 以字号的倍数设置行高,如同 Element.LineHeight 之于 Text(约 1.8 适合中日韩散文);为 0 时保留字体自身的行高。请按段落或行缓存布局,只替换受编辑影响的那些。
go
layout := ui.ShapeText(paragraph, ui.Font{Size: 16}, availableWidth)
caret := layout.Caret(offset)
offset = layout.IndexAt(ui.Point{X: pointerX, Y: pointerY})
rects := layout.SelectionRects(selectedRanges...)
// 在 Draw 中:
p.TextLayout(layout, originX, originY, color)布局偏移同样以 UTF-16 计。Caret 和 IndexAt 会吸附到完整的字素;PreviousBoundary 和 NextBoundary 支持逻辑导航/删除。SelectionRects 接受多个范围,并保留双向(bidi)选区各自独立的视觉片段。它只提供几何信息,不臆造单范围选区模型,也不替编辑器决定导航策略。尺寸和几何查询可从任意 goroutine 安全调用;绘制则是 UI 帧的一部分。
ShapeRichTextWith 按 TextShape 的规定排版各个 span,如同富文本编辑器或书籍中的段落:
Align在Width内对齐各行:Start、Center或End。Justify将Width换行出的每一行都拉伸到Width(每个段落的最后一行除外),加宽词与词之间的空格以及汉字和假名之后的空隙。Align负责放置最后一行,以及没有可拉伸空间的行(如跨行断开的长词)。行尾的空白会悬挂超出Width,布局会保留它。Indent将文本首行从其段落起始一侧向内移动指定数量的 DIP,并相应地缩窄其换行宽度,如同 CSS 的text-indent;换行符之后的行不缩进。NoBreakWords让宽度超过Width的词溢出而不是断开。
go
layout := ui.ShapeRichTextWith(spans, ui.Font{Size: 17, LineHeight: 1.5},
ui.TextShape{Width: column, Justify: true, Indent: 17 * 1.2})布局引擎基于系统引擎断出的各行自行完成上述一切,因此段落在每个平台上的换行、拉伸和缩进表现一致。
任何文本中的软连字符(U+00AD)都是行可以断开的位置,许多 EPUB 都带有它:在行于此处断开时它显示为连字符,在其他地方则不显示、不占空间,如同浏览器中的表现。以软连字符结尾的行会为其连字符留出空间,必要时更早断行。MyGo 本身不会自动进行断词连字符化。
Lines 从顶部开始返回布局的各个行:每行的框(Rect,其 X 已包含对齐和缩进)、基线、上伸部和下延部,它所涵盖的 UTF-16 偏移(Start 和 End,末尾的换行符不计入),以及它是否从右向左阅读。需要分页的阅读器可以找出一个段落有多少行能放进页面底部以上,以及下一页从哪个偏移开始:
go
for _, line := range layout.Lines() {
if top+line.Rect.Y+line.Rect.H > pageBottom {
nextPageStarts = line.Start
break
}
}带有 ObjectWidth 的 span 会将其对象替换字符(U+FFFC)变为内联对象:尺寸为 ObjectWidth×ObjectHeight 的框立于基线之上,并可再向下延伸 ObjectDescent,行会绕开它们断开,而它们本身不绘制任何内容;TextLayout.Objects 给出这些对象的框,编辑器在其中绘制图片,阅读器绘制内联图片,数学渲染器绘制公式。span 的 BaselineShift 会将其文本或其对象抬升到基线上方相应数量的 DIP,为负数时则下移,如同上标和下标:行会为其腾出空间,其下划线也随之移动。
go
ui.ShapeRichText([]ui.Span{
{Text: "as Melville wrote"},
{Text: "12", Size: 11, BaselineShift: 6},
{Text: ", the whale"},
}, ui.Font{Size: 17}, column)进行视觉导航时,请保留 TextCaretPosition,包括其 Affinity。TextDownstream 选择后续逻辑文本的边缘;TextUpstream 选择其前一个逻辑文本的边缘。在双向边界或换行处,两个边缘可能不同。CaretAt 绘制该边缘,PositionAt 在命中测试时保留它,MoveCaret(position, direction) 向左(负数)或向右(正数)移动一个视觉字素。SelectionRanges(anchor, caret) 将视觉手势映射为逻辑 UTF-16 范围,在混合方向的文本中这些范围可能有间隙:
go
caret = layout.MoveCaret(caret, 1)
ranges := layout.SelectionRanges(anchor, caret)
rects := layout.SelectionRects(ranges...)
// 在缓冲区中对复制、删除和替换使用同一组范围。内置控件也使用这些视觉边缘和范围集合。复制按逻辑顺序拼接选中的片段;替换会保留间隙中的文本;撤销不仅恢复文本,也恢复视觉选区。