C++实现富文本控件: 核心
在实现LongUI时, 一直为文本编辑框苦恼, 感觉需要自己实现一个简单的文本编辑控件. 本篇就是记录其中相关细节的博客.
根据个人习惯, 将本项目命名为RichED (Riched-Text Edit Deployer). 地址位于:
dustpg/RichEDgithub.com本文备份地址: github
简单文本布局
自己将这个称为'简单文本布局', 与之对应的自然就是'复杂文本布局'(Complex text layout), 由于精力等各种原因, 不可能去实现一个CTL, 能够简单胜任'文本编辑控件'的能力就行了.
单位
什么东西都离不开单位, 但是文字的单位其实都可以看着相对单位, 所以在实现中使用unit_t
作为单位, 可以是float
, 也可以是uint32_t
作为定点小数.
这里默认使用float
作为基础单位, 不过实际上定点小数更好一点(误差是固定的).
编码
编码上自然是建议UTF系列. 可以选择UTF-8, UTF-16 以及UTF-32(UCS4). 不过, 用4字节编码似乎太奢侈了, UTF-8感觉比较麻烦, 这里选择UTF-16. 不过这又涉及到大小端, 不过作为编辑器不用担心, 内部使用CPU的当前大小端就行.
当然, 这就会出现两个数字表示字符串的长度: 字符串长度, 逻辑字符个数. 这里就用length与count区分.
文字
首先说的自然是文字的度量(Metrics), 在底层还有更基础的一层'字形度量'(Glyph Metrics), 不过本文并不涉及文字的最底层渲染, 不过相关概念依然可以借鉴.
- x-height, x字高, 一般来说就是字母'x'的高度
- cap height, 大写字母高度.
- ascender height, 升高, 一般比大写字母高度要高一点
- descender height, 降高
- baseline, 基线, 文字以这根线作为基础参考线.
这些都是参考值, 或者说相对值. 字体设计者并不会严格按照这些规矩设计字体. 比如一个文字大小是100px, 那么设计者会让这个字体看起来差不多就是100px大. 而升高+降高就是一个字体设计上占的高度, 对于我们非常重要.
间距
文字与文字组合起来就成了我们需要的文本, 所以自然需要讨论字间距.
字间距首先是字体设计者所需要考虑的问题, 不过在具体显示时可能需要进行调整, 这里使用css作为参考:
- letter-spacing, 控制字母间距
- word-spacing, 控制单词间距
对于我们汉字使用者来说, 需要区分这两个还真是有点烦, 实现较为繁琐, 这里指出是因为有一个css与之相关: text-align
一段文字可以左对齐, 右对齐, 居中对齐以及还有一个特殊的参数: justify
: 两侧对齐. 这个参数最重要的用处就是——小学老师教过我们一行不能以逗号句号之类的标点符号开头(当然, 这个规矩对于像自己这种 作文全靠凑的人来说, 是一个毁灭性的打击).
这个是实现的难点, 可以考虑不实现.
间距微调(kerning, 或者译作'紧缩'), 又是一个搞事的属性, 不过可以看具体API支不支持(以及字体本身支不支持)来确定使用, 而不必自己实现. 特殊文字配对情况下, 可以相互重叠部分, 例如AVATAR:
固定数值
我们可以采用一个ax + b
的方式描述一个固定数值, a
是相对系数(相当于em), b
是绝对系数(相当于px).
行
现在我们可以进行基础排版了!...吗? 前面的'Sphinx'我们理所当然地认为: 基于'基线'排列, 从左到右.
css拥有一个vertical-align
的属性, 拥有不少值, 其中就有baseline
. 我们可以一直假定以基线排列, 让外部控制对齐方式:
- 假如升高2, 降高1
- 基线对齐, 测量器返回: 升高2, 降高1
- 上对齐, 测量器返回: 升高0, 降高3
- 下对齐, 测量器返回: 升高3, 降高0
减少核心部分的工作量也是一种思路.
'从左到右', 这个就被称为阅读方向(Reading Direction), 一般地, 上下左右四个方向都有.
由于按照文字区分方向过于麻烦, 阅读方向是由外部初始化并固定. 特地这样其实是为了支持竖向排列的汉字:
与阅读方向对应的就是行流向(Flow Direction), 与阅读方向垂直(有些特殊的文字系统, 阅读一行就要把载体翻转90度那种除外), 竖向排列的汉字段落流向就是从右到左.
注音(Ruby)的支持也是类似, 不过注音不一定就是注音, 也有可能是注释什么的. 目前自己接触到的注音大致分为两类: 类字母, 与类汉字.
横着些时一般没有问题, 竖着的时候就有些区别了, 其中汉语注音还有一点区别:
也就是'ㄧ'和'丨', 不过各个地方标准也不一样, 这个还是无视掉算了(手动用'ㄧ'和'丨'区分).
换行
说到换行(Line Feed, LF)就不得不提'回车'(Carriage Return, CR), Windows上默认保留了传统的回车换行'rn'-CRLF, 其他的系统则是简化为'n'-LF.
作为编辑器自然是: '我全都要'.
自动换行
又是一个很烦人的东西, css对此也有非常多的控制属性:
- white-space
- overflow-wrap
- word-break
- 等等等
不过大体可以分为:
- 没有自动换行
- 单词为基础的自动换行
- 字母为基础的自动换行
同前面的字间距, 在处理中英文时比较麻烦. 汉字可以处处换行, 但是特殊标点符号不能. 对这些细节处理比较麻烦, 可以看自己的情况简化处理.
富文本
自然标题提到了富文本(Rich Text), 自然就是看看这个文本到底能多富. 一般地, 根据自己的需求划分为: 字体属性, 效果, 以及扩展内联对象.
- 字体属性, 会影响排版的属性. 例如字体大小, 名称之类的.
- 效果, 不影响排版的属性. 例如下划线, 字体阴影
- 扩展对象, 这个是必不可少的. 一开始可能就是代指图片之类的, 后期用于扩展.
- 内联对象长度必须是1个逻辑字符(方便处理)
元胞
如果为每一个字符赋予上面所述的一些属性, '性价比'很低: 一个字符也就几个字节, 属性需要的空间可能是其几十倍. 所以自己将一段文字划分成一个元胞(Cell), 或者说'块'(Block). 根据个人习惯, 还是称为Cell.
长度这里设置为定长, 有很多优势. 比如方便在栈上处理:
struct Cell {
Node node;
Attr attr;
char16_t buffer[LEN];
};
void foo(Cell& cell) {
char32_t cover[LEN];
utf16_to_utf32(cover, LEN, cell);
bar(cover);
}
或者用于对象缓存减少内存申请(对象大小固定, 很好, 很好).
这个LEN
选多少呢? 自己选择的是: debug-9, release-63. debug自然是需要少一点方便调试. 而release选择63(1保留, 原因后述)是因为很多终端一行能够显示80个字符, 于是找个附近的'整数'.
保留字符
前面提到长度是64, 但是使用了63个. 1作为保留, 是不是认为是处理NUL字符?
答案是否定的, cell中不储存NUL字符, 因为有长度信息了没必要使用NUL. 作为保留是因为UTF-16是使用1~2个UTF-16字符表示一个真正的逻辑字符. 保留一个这样保证使用2个UTF-16字符表示的字符不会分成两个cell储存.
同理, 如果使用UTF-8的话, 由于目前使用1~4个, 所以应该保留3个UTF-8字符. 当然, 这都是建立在Unicode官方不变卦的情况下, 毕竟是有可能打脸的:
UTF-8使用一至六个字节为每个字符编码(尽管如此,2003年11月UTF-8被RFC 3629重新规范,只能使用原来Unicode定义的区域,U+0000到U+10FFFF,也就是说最多四个字节。
链式结构
为了方便处理, 我们将cell使用链表串起来, 而不是树. 这是为了简化程序, 方便实现. 同时因为使用的是固定长度的cell, 所以cell对象缓存实现非常简单: cell释放后直接加入缓存栈/队列.
我们把文本分割成一块一块的cell, 最后会合成字符串. 作为一个'库', 使用者可能使用各种形式的字符串. 可能是std::string
甚至my::string
之类的. 这里为了方便描述, 拥有一个外部确定的custom_append
方法.
这里假设每个cell为5个utf16字符:
我们为每行最后一个cell打上EOL(END OF LINE)标记. 可以看出除了最后一个cell, 每个EOL会有一个换行字符串:
void custom_append(string_t& str, const char16_t buf[], size_t len);
void gen_string(string_t& output) {
// 使用一个bool以延迟一下, 让最后一个cell不添加换行符
bool line_mark = false;
for (auto& cell : cells) {
// 这里用rn作为示范
if (line_mark) custom_append(output, u"rn", 2);
line_mark = cell.eol;
custom_append(output, cell.buf, cell.len);
}
}
具体的换行符, 比如CRLF(rn), 是不用保存在cell里面的. 这样目的是可以随时切换换行符模式.
增量渲染
屏幕渲染有一个优化手段叫做'增量渲染', 或者'脏渲染': 只需要渲染更新(脏)的区域. 将复杂度O(f(n))
的n
大幅度降低.
同样我们可以推广到节点的更新上, 即'增量更新', 或者说'脏更新'.(其实'渲染'不一定是指图像图像的渲染, 可以进行推广)
拖延症患者福音
GUI程序很多是一帧内修改多次, 每次修改就重新计算是不明智的, 我们将计算延迟到显示时. 即拖延症患者的福音.
延迟计算很有用, 缺点的话就是很容易出BUG.
元胞休眠
同样, 渲染一个cell可能需要计算并储存相关'上下文', 例如可能会生成字体的离屏渲染用位图信息. 这些信息在不渲染时完全是占空间的, 所以如果长时间不渲染一个cell, 就可以让其进入'休眠', 以节约空间.
元胞相关属性
- 富有属性, 看起来很有钱的属性(具体有哪些可以后期补充)
- 排版/度量信息, 包含cell所需要的相关排版/度量信息
属性 | 描述
--------|----------
节点偏移 | 偏移理论坐标的偏移量
边界边框 | 整个cell渲染所需的位置/大小
布局宽度 | 逻辑上cell的宽度
升高降高 | 前面提到过, 用于处理对齐方向
- 元信息: 保存一些基础信息, 这里就包含了类型、EOL之类的
引入偏移量主要是为了解决一些排版问题:
例如注音, 这个本来应该是'父子'关系, 不过由于这里简化为链表, 变成了'兄弟关系'. 不过反正是一家人, 当不了你爹, 做老大哥也行.
垂直排版的时候有些不同.
这条红线, 水平排版一般称为'baseline(基线)', 垂直排版一般称为'vertical axis(纵轴)', 几乎就在文字中央. 不过为了简化自然也称为'基线'.
相关功能
排版
排版自然是最基础的功能, 可以使用矩阵实现cell的排列. 可以看具体实现, 垂直排版时width
是表示水平量还是垂直量(使用不同的矩阵就行).
换行也同理:
关于坐标, 这里可以有两种解决方案: 一种是cell储存绝对的坐标; 一种是假定是'经典排版'的文档空间, 最后显示时再用矩阵乘. 自己权衡了大概3秒, 决定选后者.
文档空间将假定是阅读左到右, 流向上到下(符合屏幕坐标习惯).
定位
一般我们将文本输入中, 一闪一闪的符号称为插入符(caret), 或者称为键盘光标(cursor). 不过光标的话, 可能会和鼠标光标混淆, 这里就称为插入符, 虽然本身是特指^
符号.
我们鼠标一点, 或者键盘按一下方向键肯定会提示具体位置, 所以我们需要进行定位.
前面提到我们文档结构是使用的链式结构, 查找效率是O(n), 这显然太慢了. 同时为了配合'脏数据', 我们使用一个'脏数组'的简单数据结构.脏数组储存了一个视觉行(visual-line)的一些必要数据:
- 该行首节点
- 该行偏移量
- 等等
'脏数组'是因为只有一段区间是干净的, 脏的部分的数据是无效的. 脏数组不仅仅是储存行的相关信息, 并且是提供一个支持随机访问的接口.
左边的表示储存每行第一个的脏数组, 干净区域(蓝色数字)是[2, 6)
. 具体实现中, 起点总为0, 怎么处理, 待定.
- 确定位置在干净区内, 否则扩展干净区域
- 利用二分查找法定位到指定行
- 遍历该行所有cell找到指定cell
- 利用cell内部信息计算得出具体位置
这里就是用二分查找法将O(n)复杂度降低至O(log(l) + m), 复杂度大大降低.
固定行高
行距是固定值的话会给人一种'美感', 启用这个模式只需要除以固定行高就行.
自动换行
自动换行的话, 由于增量更新, 所以对应的'脏数组'长度是可能在文档本身没有修改时, 也会产生变化(文档高度自然也是):
黑框表示文档当前(预测的)大小, 蓝框表示当前显示的区域; 绿线表示干净的'脏数组'区域, 红色表示脏区域. 随着文档浏览, 自动换行可能会引起视觉行脏数组长度, 以及文档(预测)大小 的变化.
...
这里, 自己自动换行简化为四个等级:
- 不换行, 这个最简单
- 仅空格换行
- CJK处处可换行, 或者空格换行
- 处处可换行
当然还有很多自动换行的逻辑, 比如智能识别英语单词然后加上连字符'-'什么的. 这里简化处理, 将修改点局限在一个CELL中, 这样能够较大幅度简化程序, 并且在Release时(cell足够长)大概率不会出现排版BUG:
- 在'条件允许'下尝试合并后面的CELL
- 遍历需要换行CELL处(
x + w >= W
) - (a.)如果是内联之类的特殊CELL, 或者换行处足够靠前: 要么换行, 要么保留(视觉行第一个)
- (b.)查询(先从插入点往前, 没有找到再往后),可换行处 并分裂为A, B: B换行
'1'的'条件允许'是指一种优化手段, 可以后期考虑, 目的是减少无用的合并. 自己的条件如下:
- 分奇偶帧, 奇数帧才允许合并
- 该cell最末尾的位置必须足够靠前才允许合并
- 后面的cell必须足够短才允许合并
插入
既然叫做'插入符'就是插入输入文本的:
- 仅前面
- 仅内部
- 两者之间
- 仅后面
对于富文本来说, 两者之间可能会出现特殊情况. 可以通过传递一个标志位来确定插入两者之间时插入哪个部分.
可以简单划分成4种:
- 插入空的CELL的内部, 行首或者特殊情况
- 插入非空CELL的前面, 行首或者特殊情况
- 插入非空CELL的中间
- 插入非空CELL的后面
当CELL是内联对象时, 只会出现在前面或者后面(因为长度必须是1).
如果插入的CELL足够插入新的字符串, 直接插入就行.其他情况再简化为三种
- 插入前面: 前创建新CELL[A], 也可以视为分裂
- 插入中间: 分裂为两个[A, B]
- 插入后面: 后创建新CELL[B], 也可以视为分裂
- 通用: 更新行首CELL信息
并将插入的字符串分成三部分:
- 字符串(前面)够能插入A的部分
- 字符串(后面)能够插入B的部分(可能与1部分重叠, 需要消除重叠)
- 中间剩余的部分(可能不存在)
然后执行插入即可. 插入文本后副作用有:
- 整体文档尺寸可能改变
- 脏数组干净区域可能大幅度变化
当行数变化时, 脏数组大小自然会变化, 并且插入行之后的全部脏了:
void on_line_inserted(uint32_t line) {
if (line >= clean_begin && line < clean_end)
clean_end = line;
else
clean_begin = clean_end = 0;
}
在使用自动换行中, 可能插入一个字符会造成翻天覆地的变化, 不过变化也是局限在该逻辑行中. 视觉行变化和逻辑行的分开处理.
删除, 或者说移除(remove).
- 找到删除的两个位置所在的cell
- 两个cell是一个时: 删除文本, 删空则去掉该cell
- 不一致时: 删除中间文本, 前面一个一定不是EOL, 最后一个cell是EOL则保留
- 检查最后那个cell那行, 没有cell的话插入一个空的cell作为EOL
比如有两个行文本, 第二行是空的:
CELL AAAAAAAAABBBB[EOL]
1: hello, world!
CELL C[EOL]
2:
^
删除一行最后的EOL的过程:
- 删除范围:
{0, 13}, {1, 0}
- 找到
{0, 13}
的cell-b - 找到
{1, 0}
的cell-c - 删除cell-b的后面文本(实际没有)
- 取消cell-b的EOL标志
- 删除cell-c的文本, 由于删完了, 释放cell-c
- 由于cell-c是EOL, 将cell-c前面的cell-b再次设置EOL
修改, 可以是指文本的替换. 不过文本替换就是先删除再插入(或者相反). 这里是特指富文本属性的修改, 可以分为对排版有影响的属性与没有影响的.
- 找到起点与终点cell, 根据位置分裂或者标记成A,B - C,D
- 其中,因为起点和终点cell相同, 所以应该先分裂后者
- [B, D)区间的cell属性修改至指定属性
- 脏数组也进行区间修改, 如果是效果之类不修改的, 可以针对ABCD这几个进行增量排版
- 没有影响的(比如颜色, 下划线等'效果')到此结束
- [B, D)区间的cell标记为脏
优化点
后部缓存
在大部分情况都是修改视口中央的部分:
------
------
------
------ <- 上面的在脏数组范围内
ABCDEF <- 修改文档
------ <- 下面会丢失
------
------
------
------
其中逻辑行开始的数据是稳定的, 所以修改逻辑如下:
- 找到修改点所在行, 获取下一行开始节点A
- 备份节点A所在行 到 脏数组的行信息
- 修改文档
- 重构脏数组, 直到: (1). 遍历到A (2). 或者重构到视口最下方
- 2的场合中断处理
- 1的场合, 将备份的数据简单修改后直接添加至脏数组后面
但是, 这这个和延迟计算'格格不入', 这里插入就会要求重新计算, 所以可以单独将后面的的数据备份下来, 在具体排版时使用.
休眠
具体休眠算法待定, 自己想到了两种:
引用计数+循环队列 休眠
- 方便循环队列的实现, 队列长度可以是2的幂次方.
- 队列长度是动态的, 当全部可视的cell数量过长时扩张队列长度
- 当显示区域越大, 说明这个编辑器越重要, 使用更长的休眠队列很合适
- 当一个cell进入显示区, 引用计数+1, 入队
- 当队列长度超过一个阈值(比如满了), 最后一个出队, 引用计数-1
时间 休眠
- 当一个cell由可视转为不可视, 重置时间并进入准休眠状态
- 时间归零则进入休眠
- 上述的'时间'不一定是真正的时间, 还可以是修改点——长时间不修改没准儿是AFK
- 比如文档被修改5次了, 进入休眠状态
OOM处理
Out of memory这个东西真的很烦人, 自己的应对大致有4个:
- 等待恢复. 类似于强异常安全保证, OOM时返回错误码并将现场恢复至调用前. 等待有新的空间就可以继续处理.
- 将就着用. 类似与弱异常安全保证, OOM时返回错误码但是没有能力恢复现场. 能够继续安全使用, 但是信息已经丢失. 如果保存的是重要信息, 这时候应该尽可能快地退出程序.
- 当场重试. 当场重试比等待恢复要简单得多, 当然不是直接再试. 在之前可以释放一些不必要的缓存, 或者等待系统其他程序关闭.
- 强制中断. 使用
longjmp
或者exit
之类不会返回的函数处理
CJK字符检测
根据目前的Unicode布局, CJK字符相关区间为:
- 2E80-2EFF,中日韩汉字部首补充,CJK Radicals Supplement
- 3000-303F,中日韩符号和标点,CJK Symbols and Punctuation
- 31C0-31EF,中日韩笔画,CJK Strokes
- 3200-32FF,带圈的CJK字符及月份,Enclosed CJK Letters and Months
- 3300-33FF,中日韩兼容字符,CJK Compatibility
- 3400-4DBF,中日韩统一表意文字扩展区A,CJK Unified Ideographs Extension A
- 4DC0-4DFF, 这里空缺的是64卦, 可以算进CJK
- 4E00-9FFF,中日韩统一表意文字,CJK Unified Ideographs
- F900-FAFF,中日韩兼容表意文字,CJK Compatibility Ideographs
- FE30-FE4F,中日韩兼容形式,CJK Compatibility Forms
以上的是用单个UTF-16字符能表示的部分, 其中部分小区间可以考虑舍弃(可以看出'中日韩兼容表意文字'这区与其他区格格不入). 其中, 汉字'〇'被放在了标点区. 如何处理, 待定.
- 20000-2A6DF,中日韩统一表意文字扩展B区,CJK Unified Ideographs Extension B
- 2A700-2B73F,中日韩统一表意文字扩展C区,CJK Unified Ideographs Extension C
- 2B740-2B81F,中日韩统一表意文字扩展D区,CJK Unified Ideographs Extension D
- 2B820-2CEAF,中日韩统一表意文字扩展E区,CJK Unified Ideographs Extension E
- 2CEB0-2EBEF,中日韩统一表意文字扩展F区,CJK Unified Ideographs Extension F
- 2F800-2FA1F,中日韩兼容表意文字增补,CJK Compatibility Ideographs Supplement
以上的是用两个UTF-16字符编码的CJK. 这些区间足够分散, 可以实现为查表, 并且简化为只有4个平面(目前是17个, 很多未使用), 再允许一些错误(减少LUT大小以提高缓存命中率):
// 简化为:
// [3200 - 9fff]
// [f900 - faff]
// [20000 - 2ebff]
// [2f800 - 2faff]
// x86 的缓存行是64字节对齐, c++17可以使用
// std::hardware_destructive_interference_size获取
alignas(64) uint32_t cjk_lut[0x400 / 32] = { /*xxx*/ };
uint32_t is_cjk(uint32_t ch) {
// 假定区域以0x100对齐
const uint32_t ch2 = (ch & 0x3ffff) >> 8;
const uint32_t index = ch2 >> 5;
const uint32_t mask = 1 << (ch2 & 0x1f);
return cjk_lut[index] & mask;
}
这部分是随着Unicode更新而更新的, 比如目前准备加入第三辅助平面的小篆、甲骨文. 这里判断CJK主要是为了处理自动换行与垂直排版. 这个前提下, 这些显然属于CJK.
REF
- FreeType Glyph Conventions / III
- Glyphs and Glyph Runs
- x-height
- 注音符號
- unicode org