在实现LongUI时, 一直为文本编辑框苦恼, 感觉需要自己实现一个简单的文本编辑控制项. 本篇就是记录其中相关细节的博客.
根据个人习惯, 将本项目命名为RichED (Riched-Text Edit Deployer). 地址位于:
本文备份地址: github
自己将这个称为简单文本布局, 与之对应的自然就是复杂文本布局(Complex text layout), 由于精力等各种原因, 不可能去实现一个CTL, 能够简单胜任文本编辑控制项的能力就行了.
什么东西都离不开单位, 但是文字的单位其实都可以看著相对单位, 所以在实现中使用unit_t作为单位, 可以是float, 也可以是uint32_t作为定点小数.
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), 不过本文并不涉及文字的最底层渲染, 不过相关概念依然可以借鉴.
这些都是参考值, 或者说相对值. 字体设计者并不会严格按照这些规矩设计字体. 比如一个文字大小是100px, 那么设计者会让这个字体看起来差不多就是100px大. 而升高+降高就是一个字体设计上占的高度, 对于我们非常重要.
文字与文字组合起来就成了我们需要的文本, 所以自然需要讨论字间距.
字间距首先是字体设计者所需要考虑的问题, 不过在具体显示时可能需要进行调整, 这里使用css作为参考:
对于我们汉字使用者来说, 需要区分这两个还真是有点烦, 实现较为繁琐, 这里指出是因为有一个css与之相关: text-align
text-align
一段文字可以左对齐, 右对齐, 居中对齐以及还有一个特殊的参数: justify: 两侧对齐. 这个参数最重要的用处就是——小学老师教过我们一行不能以逗号句号之类的标点符号开头(当然, 这个规矩对于像自己这种 作文全靠凑的人来说, 是一个毁灭性的打击).
justify
这个是实现的难点, 可以考虑不实现.
间距微调(kerning, 或者译作紧缩), 又是一个搞事的属性, 不过可以看具体API支不支持(以及字体本身支不支持)来确定使用, 而不必自己实现. 特殊文字配对情况下, 可以相互重叠部分, 例如AVATAR:
我们可以采用一个ax + b的方式描述一个固定数值, a是相对系数(相当于em), b是绝对系数(相当于px).
ax + b
a
b
现在我们可以进行基础排版了!...吗? 前面的Sphinx我们理所当然地认为: 基于基线排列, 从左到右.
css拥有一个vertical-align的属性, 拥有不少值, 其中就有baseline. 我们可以一直假定以基线排列, 让外部控制对齐方式:
vertical-align
baseline
减少核心部分的工作量也是一种思路.
从左到右, 这个就被称为阅读方向(Reading Direction), 一般地, 上下左右四个方向都有.
由于按照文字区分方向过于麻烦, 阅读方向是由外部初始化并固定. 特地这样其实是为了支持竖向排列的汉字:
与阅读方向对应的就是行流向(Flow Direction), 与阅读方向垂直(有些特殊的文字系统, 阅读一行就要把载体翻转90度那种除外), 竖向排列的汉栏位落流向就是从右到左.
注音(Ruby)的支持也是类似, 不过注音不一定就是注音, 也有可能是注释什么的. 目前自己接触到的注音大致分为两类: 类字母, 与类汉字.
横著些时一般没有问题, 竖著的时候就有些区别了, 其中汉语注音还有一点区别:
也就是ㄧ和丨, 不过各个地方标准也不一样, 这个还是无视掉算了(手动用ㄧ和丨区分).
说到换行(Line Feed, LF)就不得不提回车(Carriage Return, CR), Windows上默认保留了传统的回车换行 -CRLF, 其他的系统则是简化为 -LF.
作为编辑器自然是: 我全都要.
又是一个很烦人的东西, css对此也有非常多的控制属性:
不过大体可以分为:
同前面的字间距, 在处理中英文时比较麻烦. 汉字可以处处换行, 但是特殊标点符号不能. 对这些细节处理比较麻烦, 可以看自己的情况简化处理.
自然标题提到了富文本(Rich Text), 自然就是看看这个文本到底能多富. 一般地, 根据自己的需求划分为: 字体属性, 效果, 以及扩展内联对象.
如果为每一个字元赋予上面所述的一些属性, 性价比很低: 一个字元也就几个位元组, 属性需要的空间可能是其几十倍. 所以自己将一段文字划分成一个元胞(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个字元, 于是找个附近的整数.
LEN
前面提到长度是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方法.
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) { // 这里用 作为示范 if (line_mark) custom_append(output, u" ", 2); line_mark = cell.eol; custom_append(output, cell.buf, cell.len); } }
具体的换行符, 比如CRLF( ), 是不用保存在cell里面的. 这样目的是可以随时切换换行符模式.
屏幕渲染有一个优化手段叫做增量渲染, 或者脏渲染: 只需要渲染更新(脏)的区域. 将复杂度O(f(n))的n大幅度降低.
O(f(n))
n
同样我们可以推广到节点的更新上, 即增量更新, 或者说脏更新.(其实渲染不一定是指图像图像的渲染, 可以进行推广)
GUI程序很多是一帧内修改多次, 每次修改就重新计算是不明智的, 我们将计算延迟到显示时. 即拖延症患者的福音.
延迟计算很有用, 缺点的话就是很容易出BUG.
同样, 渲染一个cell可能需要计算并储存相关上下文, 例如可能会生成字体的离屏渲染用点阵图信息. 这些信息在不渲染时完全是占空间的, 所以如果长时间不渲染一个cell, 就可以让其进入休眠, 以节约空间.
属性 | 描述 --------|---------- 节点偏移 | 偏移理论坐标的偏移量 边界边框 | 整个cell渲染所需的位置/大小 布局宽度 | 逻辑上cell的宽度 升高降高 | 前面提到过, 用于处理对齐方向
引入偏移量主要是为了解决一些排版问题:
例如注音, 这个本来应该是父子关系, 不过由于这里简化为链表, 变成了兄弟关系. 不过反正是一家人, 当不了你爹, 做老大哥也行.
垂直排版的时候有些不同.
这条红线, 水平排版一般称为baseline(基线), 垂直排版一般称为vertical axis(纵轴), 几乎就在文字中央. 不过为了简化自然也称为基线.
排版
排版自然是最基础的功能, 可以使用矩阵实现cell的排列. 可以看具体实现, 垂直排版时width是表示水平量还是垂直量(使用不同的矩阵就行).
width
换行也同理:
关于坐标, 这里可以有两种解决方案: 一种是cell储存绝对的坐标; 一种是假定是经典排版的文档空间, 最后显示时再用矩阵乘. 自己权衡了大概3秒, 决定选后者.
文档空间将假定是阅读左到右, 流向上到下(符合屏幕坐标习惯).
定位
一般我们将文本输入中, 一闪一闪的符号称为插入符(caret), 或者称为键盘游标(cursor). 不过游标的话, 可能会和滑鼠游标混淆, 这里就称为插入符, 虽然本身是特指^符号.
^
我们滑鼠一点, 或者键盘按一下方向键肯定会提示具体位置, 所以我们需要进行定位.
前面提到我们文档结构是使用的链式结构, 查找效率是O(n), 这显然太慢了. 同时为了配合脏数据, 我们使用一个脏数组的简单数据结构.脏数组储存了一个视觉行(visual-line)的一些必要数据:
脏数组是因为只有一段区间是干净的, 脏的部分的数据是无效的. 脏数组不仅仅是储存行的相关信息, 并且是提供一个支持随机访问的介面.
左边的表示储存每行第一个的脏数组, 干净区域(蓝色数字)是[2, 6). 具体实现中, 起点总为0, 怎么处理, 待定.
[2, 6)
这里就是用二分查找法将O(n)复杂度降低至O(log(l) + m), 复杂度大大降低.
固定行高
行距是固定值的话会给人一种美感, 启用这个模式只需要除以固定行高就行.
自动换行
自动换行的话, 由于增量更新, 所以对应的脏数组长度是可能在文档本身没有修改时, 也会产生变化(文档高度自然也是):
黑框表示文档当前(预测的)大小, 蓝框表示当前显示的区域; 绿线表示干净的脏数组区域, 红色表示脏区域. 随著文档浏览, 自动换行可能会引起视觉行脏数组长度, 以及文档(预测)大小 的变化.
...
这里, 自己自动换行简化为四个等级:
当然还有很多自动换行的逻辑, 比如智能识别英语单词然后加上连字元-什么的. 这里简化处理, 将修改点局限在一个CELL中, 这样能够较大幅度简化程序, 并且在Release时(cell足够长)大概率不会出现排版BUG:
x + w >= W
1的条件允许是指一种优化手段, 可以后期考虑, 目的是减少无用的合并. 自己的条件如下:
插入
既然叫做插入符就是插入输入文本的:
对于富文本来说, 两者之间可能会出现特殊情况. 可以通过传递一个标志位来确定插入两者之间时插入哪个部分.
可以简单划分成4种:
当CELL是内联对象时, 只会出现在前面或者后面(因为长度必须是1).
如果插入的CELL足够插入新的字元串, 直接插入就行.其他情况再简化为三种
并将插入的字元串分成三部分:
然后执行插入即可. 插入文本后副作用有:
当行数变化时, 脏数组大小自然会变化, 并且插入行之后的全部脏了:
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 AAAAAAAAABBBB[EOL] 1: hello, world! CELL C[EOL] 2: ^
删除一行最后的EOL的过程:
{0, 13}, {1, 0}
{0, 13}
{1, 0}
修改, 可以是指文本的替换. 不过文本替换就是先删除再插入(或者相反). 这里是特指富文本属性的修改, 可以分为对排版有影响的属性与没有影响的.
后部缓存
在大部分情况都是修改视口中央的部分:
------ ------ ------ ------ <- 上面的在脏数组范围内 ABCDEF <- 修改文档 ------ <- 下面会丢失 ------ ------ ------ ------
其中逻辑行开始的数据是稳定的, 所以修改逻辑如下:
但是, 这这个和延迟计算格格不入, 这里插入就会要求重新计算, 所以可以单独将后面的的数据备份下来, 在具体排版时使用.
休眠
具体休眠演算法待定, 自己想到了两种:
引用计数+循环队列 休眠
时间 休眠
Out of memory这个东西真的很烦人, 自己的应对大致有4个:
longjmp
exit
根据目前的Unicode布局, CJK字元相关区间为:
以上的是用单个UTF-16字元能表示的部分, 其中部分小区间可以考虑舍弃(可以看出中日韩兼容表意文字这区与其他区格格不入). 其中, 汉字〇被放在了标点区. 如何处理, 待定.
以上的是用两个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.
推荐阅读: