dustpg / BlogFM

Blog for Me
MIT License
155 stars 23 forks source link

用C++实现富文本控件(下): 高层 #54

Open dustpg opened 5 years ago

dustpg commented 5 years ago

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

本节是关于高层接口相关实现.

title

文档尺寸估计

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

文档高度:

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

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

文档宽度:

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

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

编辑控制

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

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

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

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

GUI级操作

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

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

选中区域

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

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

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

selection

值得注意的是: 截图可以看出, 不是最后一行的话会选中换行符号(一行是选不中的), 会略微长一点. 所以可以把除开最后一行加上一个空格的宽度, 或者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();
}

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

input

这里也可以体现出分低级接口与高级接口的好处: 特殊的功能可以通过组装低级接口实现. 特别地, 使用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的存在, 密码模式很特殊,因为选择的位置和逻辑位置不一定一致.

password

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

解决方法可以有:

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

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