Skip to content

文本输入底层原语(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...)
// 在缓冲区中对复制、删除和替换使用同一组范围。

内置控件也使用这些视觉边缘和范围集合。复制按逻辑顺序拼接选中的片段;替换会保留间隙中的文本;撤销不仅恢复文本,也恢复视觉选区。