用C++实现富文本控制项: 高层

本节是关于高层介面相关实现.

项目地址: Github-RichED

本文备份地址: github

文档尺寸估计

由于脏数组的存在, 文档的(视觉)尺寸是无法准确计算的(除非完全缓存完毕), 所以只有一个估计尺寸:

文档高度:

文档高度自然对应垂直滚动条(水平阅读方向下)需要的数据: 干净区域高度 * 已知行数 / 干净行数

或者: 干净区域高度 + 脏区域行 * 默认行高

文档宽度:

文档宽度则是对应水平滚动条(水平阅读方向下)需要的数据. 宽度和高度略有区别, 自己的处理方法如下:

  1. 估计宽度初始为0
  2. 每次渲染时, 将估计宽度 = [估计宽度, 当前显示最大宽度].max
  3. 每次修改时, 如果修改行与估计宽度一致, 则 估计宽度 = 0

编辑控制

上面实现了几乎所有功能, 但是终端用户并不是程序猿而是一般用户, 需要暴露出GUI编辑控制介面给上级, 这里就列出常用的操作(以Windows作为例子):

按键 | 基础功能 | +CTRL | +SHIFT
---------|----------------|---------------|--------
UP | 插入符上移一行 | 文档整体下移一行 | 插入符上移一行, 选中之前与现在插入符之间的区域
DOWN | 插入符下移一行 | 文档整体上移一行 | 插入符下移一行, 选中之前与现在插入符之间的区域
LEFT | 插入符左移一字元| 插入符左移一个单词|插入符左移一字元, 选中之前与现在插入符之间的区域
RIGHT | 插入符右移一字元| 插入符右移一个单词|插入符右移一字元, 选中之前与现在插入符之间的区域
INSERT | (插入符与替换符切换) | (随意) | 粘贴数据
DELETE | 删除插入符后一字元 | 删除插入符后一单词 | 删除当前行
BACK | 删除插入符前一字元 | 删除插入符前一单词 | (随意)
HOME | 插入符移动到当前行开始 | 插入符移动到文档开始 | 插入符移动到当前行开始, 选中之前与现在插入符之间的区域
END | 插入符移动到当前行结束 | 插入符移动到文档结束 | 插入符移动到当前行结束, 选中之前与现在插入符之间的区域
PGUP | 插入符移动到上一页 | (随意) | 插入符移动到上一页, 选中之前与现在插入符之间的区域
PGDN | 插入符移动到下一页 | (随意) | 插入符移动到下一页, 选中之前与现在插入符之间的区域

其中也有配合ALT键的扩展输入, 看情况可以给予支持. 当然还有很多快捷输入, 例如:

  • CTRL+A/Z/X/C/V/Y
  • 滑鼠双击
  • 滑鼠拖拽

具体实现中, 很多操作分为逻辑和视觉. 例如上、下、HOME、END之类的, 可以根据自身情况实现.

GUI级操作

比如只读文档, GUI基本的操作是无法修改的, 但是低级介面肯定必须能修改(不然都没有文档看). 所以针对上述的GUI操作需要提供一系列GUI基本的函数.

同样, GUI操作函数应该返回一个bool表示是否成功操作. 这个个底层的错误不一样, 是终端用户层次的错误, 需要提示用户, 最简单的就是播放错误音效.

选中区域

算是基础功能, 一般来说我们可以把选择区两端用锚点(anchor)和插入符(caret)区分, 锚点可能在插入符的前(向后选择)、后(向前选择)以及一样(没有选择). 选择方式一般有:

  • 滑鼠双击可以选中滑鼠指向的一段字元
  • 滑鼠按住不放划出一段区域
  • 按住shift移动插入符

如果选择区只有一行则是: 开始点-结束点. 多行则是:

  1. 开始点-开始点行末
  2. 中间行(可能没有)
  3. 结束行首-结束点

值得注意的是: 截图可以看出, 不是最后一行的话会选中换行符号(一行是选不中的), 会略微长一点. 所以可以把除开最后一行加上一个空格的宽度, 或者x字高(估计值, 用字体大小除以2足矣). 不过, 视觉行的末尾就没有(EOL).

所以综合起来, 减少分支后, 过程为:

  1. 设置第一行末尾位置, 最后一行行首位置
  2. 设置第一行行首位置, 最后一行末尾位置, 最后一行行高
  3. 中间行设置上一行末尾, 这一行行首位置, 上一行行高
  4. 设置行尾需要确认EOL.

// 1. 设置第一行末尾位置, 最后一行行首位置
set_end(first, *line0);
set_start(last, *line1);
// 2. 设置第一行行首位置, 最后一行末尾位置
first.left = cell0->metrics.pos + cm0.offset;
last.right = cell1->metrics.pos + cm1.offset;
set_height(last, *line1);
// 3. 中间行设置上一行末尾(需确认EOL), 这一行行首位置
auto box_itr = &first;
std::for_each(line0, line1, [=](const VisualLine& vl) mutable noexcept {
set_end(*box_itr, vl);
set_height(*box_itr, vl);
++box_itr;
set_start(*box_itr, vl);
});

可能第一行、最后一行是空的(视觉行末尾开始选择, 矩形宽度为0), 如何处理, 待定.

插入符移动

表中列出了不少方式, 其中如果往文档坐标系上移动的话, 可以用模拟用滑鼠点击当前插入符号坐标上部偏移一段具体. 下移动同理.

左右移动则需要注意UTF-16的双字编码字元, 以及特殊的内联对象, 避免插入符移动到非法位置.

按住CTRL的字元簇移动可以实现为: 将字元分为符号与非符号, 插入符移动到前后的区域. 滑鼠双击就可以实现为: 分别向前与向后查找, 分别设置为锚点与插入符.

文本输入

文本输入就简单, 如果存在选择区, 则删除选择区. 然后再在插入符处插入文本:

void on_text(const char text[]){
begin_op();
remove_text(get_selection());
insert_text(caret, text);
end_op();
}

这是一般情况, 但是特殊地, 例如代码编辑器: 存在配对符号的, 这种情况则是在两端增加.

这里也可以体现出分低级介面与高级介面的好处: 特殊的功能可以通过组装低级介面实现. 特别地, 使用begin/end_op包裹实现一个撤销栈操作的优点可以大幅度放大!

void on_text_ex(const char text[]) {
begin_op();
s = get_selection();
if (s.begin != s.end && is_ex_text(text)){
insert_text(s.end, ex_text_back(text));
insert_text(s.begin, ex_text_front(text));
}
else {
remove_text(get_selection());
insert_text(caret, text);
}
end_op();
}

删除

删除文本的控制键有: 退格键与删除键, 当存在选择区时, 这两个键的功能应当是一致的. 否则一个删除插入符前面的一个逻辑字元, 一个删除插入符后面的.

从右往左的阅读方向文字(为方便可以汉字测试, 毕竟以前是右往左)时, 退格键与删除键如何处理方向, 待定.

这里假定退格键就是退格——逻辑左移动

void on_back() {
s = get_selection();
begin_op();
// 没有选择区
if (s.begin == s.end) {
s.end = caret;
s.begin = logic_left_move(caret);
}
remove_text(s);
caret = s.begin;
end_op();
}
void on_delete() {
s = get_selection();
begin_op();
// 没有选择区
if (s.begin == s.end) {
s.begin = caret;
s.end = logic_right_move(caret);
}
remove_text(s);
end_op();
}

文档视口移动

有很多操作处理后, 需要移动视口. 当插入符移动时, 移出视口的情况, 视口应该跟随插入符. 而插入符位置固定, 主动移动视口(滑鼠滚轮, CTRL+上下键)则仅仅移动视口.

其中有一个比较特殊的操作: 滑鼠选中文本后, 将滑鼠指针移动到视口外部. 特殊点在于: 滑鼠移出后, 不移动的情况怎么处理.

  1. windows自带的记事本处理很简单: 必须保持滑鼠移动, 视口再移动. 滑鼠一旦停下来, 就不再移动.(一对一)
  2. 而不少其余编辑器, 即使停下来, 也会以一定的频率移动视口. 并且, 离得越远频率越高.(一对多)

这里就选择windows自带记事本的处理方法, 操作与结果一对一. 如果外部需要模拟第二种, 也能直接调用函数模拟.

事件消息

文档需要对数据修改做出反应, 给出消息提示. 比如text_changed消息提示文本修改了.

但是给出这些提示是为了方便、即时地获取最新的信息:

void on_text_changed(doc_t& doc) {
string_t str;
doc.gen_text(str);
}

可以看出, 触发text_changed消息时, 必须保证文本可以完整获取. 其次, 没有必要一帧触发多次, 只需要触发最后一次就行.

当然, 几乎没有办法获取自己是不是是最后一次修改, 所以进行延迟处理. 例如要求每帧调用不管渲不渲染, 都要在渲染前调用update()函数用来检测修改信息.

单行模式

不少文本框只能输入单行, 输入回车会发出一个确定的消息. 当然对于多行的文本输入, CTRL+RETRUN也可以发出一个确定的消息.

密码模式

文本输入框自然需要有一个密码模式. 不过, 由于UTF-16的存在, 密码模式很特殊,因为选择的位置和逻辑位置不一定一致.

这里明明只有4个圆圈, 但是有6个UTF-16字元. 选择区间[2, 4)只有一个字.

解决方法可以有:

  1. 密码模式下, 所有文本限制到UCS2字符集. 也就是单个char16_t就能表示
  2. 密码模式肯定文本长度不会太长, 可以使用O(n)的暴力遍历处理. 这个时候可能需要限制密码模式的相关功能, 比如不能使用撤销(简化处理).
  3. 底层实现包含密码处理: 比如虽然文本API选中区间[2, 3)但是返回区间[2, 4)

方法1自然很简单, 完全不用管; 在考虑密码字元是UCS4字符集的情况, 比如密码字元是emoji??: 剩下两个一个是内部一个是外部处理, 最好的应该是3(外部、底层). 外部实现时, 可以专门实现一个介面处理密码的情况(甚至内部可以不知道是不是密码模式).


推荐阅读:
相关文章