用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(外部、底層). 外部實現時, 可以專門實現一個介面處理密碼的情況(甚至內部可以不知道是不是密碼模式).


推薦閱讀:
相关文章