图形界面 X: 在多行文本框中选择文本 (集成构建 13)·进阶篇
📝

图形界面 X: 在多行文本框中选择文本 (集成构建 13)·进阶篇

(2/3)·逐个删字符太反人类?本篇把 Shift 系组合键和图像类封装进控件,一次框选整段

实战向 第 2/3 篇
在 MQL5 自定义面板里写多行输入,很多人还在用退格键一个字一个字删。给文本框加上 Shift 组合选择,操作手感才接近常用编辑器,也少写一堆临时变量。

「文本框光标的九向位移枚举」

在 MT5 自定义文本框控件里,文本光标的移动方向被固化成一个枚举,数值从 4 到 9 分别对应向上一行、向下一行、行首、行尾、首行开头、末行末尾,前面 1~3 通常留给左右字符与单词级移动。 CTextBox 私有方法 MoveTextCursor 接收一个 ENUM_MOVE_TEXT_CURSOR 参数,用 switch 分发到具体位移函数。例如 TO_END_LINE 会先取当前行 m_lines[y].m_symbol 的数组大小,再 SetTextCursor 到该长度;TO_END_LAST_LINE 则取 m_lines 总行数减一作为末行索引,同样用 ArraySize 拿字符数落光标。 开 MT5 新建一个继承 CElement 的面板类,把下面代码贴进私有段,就能在调试时直观看到光标跳行逻辑。外汇与贵金属图表挂这类控件属高风险操作,参数误用可能卡死界面,建议先在策略测试器里隔离验证。

MQL5 / C++
  TO_NEXT_UP_LINE      =class="num">4,
  TO_NEXT_DOWN_LINE    =class="num">5,
  TO_BEGIN_LINE        =class="num">6,
  TO_END_LINE          =class="num">7,
  TO_BEGIN_FIRST_LINE  =class="num">8,
  TO_END_LAST_LINE     =class="num">9
  };
class CTextBox : class="kw">public CElement
  {
class="kw">private:
  class=class="str">"cmt">//--- 按指定的方向移动文本光标
  class="type">void              MoveTextCursor(class="kw">const ENUM_MOVE_TEXT_CURSOR direction);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 按指定的方向移动文本光标                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CTextBox::MoveTextCursor(class="kw">const ENUM_MOVE_TEXT_CURSOR direction)
  {
  class="kw">switch(direction)
    {
    class=class="str">"cmt">//--- 光标向左移动一个字符
    case TO_NEXT_LEFT_SYMBOL   : MoveTextCursorToLeft();        class="kw">break;
    class=class="str">"cmt">//--- 光标向右移动一个字符
    case TO_NEXT_RIGHT_SYMBOL : MoveTextCursorToRight();        class="kw">break;
    class=class="str">"cmt">//--- 光标向左移动一个单词
    case TO_NEXT_LEFT_WORD     : MoveTextCursorToLeft(true);    class="kw">break;
    class=class="str">"cmt">//--- 光标向右移动一个单词
    case TO_NEXT_RIGHT_WORD    : MoveTextCursorToRight(true);   class="kw">break;
    class=class="str">"cmt">//--- 光标向上移动一行
    case TO_NEXT_UP_LINE       : MoveTextCursorToUp();          class="kw">break;
    class=class="str">"cmt">//--- 光标向下移动一行
    case TO_NEXT_DOWN_LINE     : MoveTextCursorToDown();        class="kw">break;
    class=class="str">"cmt">//--- 光标移动到当前行的开头
    case TO_BEGIN_LINE : SetTextCursor(class="num">0,m_text_cursor_y_pos); class="kw">break;
    class=class="str">"cmt">//--- 光标移动到当前行的末尾
    case TO_END_LINE :
      {
      class=class="str">"cmt">//--- 获取当前行中的字符数
      class="type">uint symbols_total=::ArraySize(m_lines[m_text_cursor_y_pos].m_symbol);
      class=class="str">"cmt">//--- 移动光标
      SetTextCursor(symbols_total,m_text_cursor_y_pos);
      class="kw">break;
      }
    class=class="str">"cmt">//--- 光标移动到首行的开头
    case TO_BEGIN_FIRST_LINE : SetTextCursor(class="num">0,class="num">0); class="kw">break;
    class=class="str">"cmt">//--- 光标移动到最后一行的末尾
    case TO_END_LAST_LINE :
      {
      class=class="str">"cmt">//--- 获取最后一行的行号和字符数
      class="type">uint lines_total   =::ArraySize(m_lines);
      class="type">uint symbols_total =::ArraySize(m_lines[lines_total-class="num">1].m_symbol);
      class=class="str">"cmt">//--- 移动光标
      SetTextCursor(symbols_total,lines_total-class="num">1);
      class="kw">break;
      }
    }
  }

◍ 文本框里左方向键的两种走法

在 MT5 自定义文本框控件里,单独按 Left 和按住 Shift 再按 Left,走的是两套完全不同的内部逻辑。前者只挪光标、清掉已有选区;后者在移动的同时持续拉长选区,方便交易者框选一段日志或参数串。

看 OnPressedKeyLeft:一进来先用 `key_code!=KEY_LEFTm_keys.KeyCtrlState()m_keys.KeyShiftState()` 把非左键、带 Ctrl、带 Shift 三种情况全部挡掉,返回 false 不处理。过了这关才 ResetSelectedText 清选区,再 MoveTextCursor(TO_NEXT_LEFT_SYMBOL) 左移一个字符,随后校正横竖滚动条并重绘。

OnPressedKeyShiftAndLeft 的拦截条件正好反过来——要求 Shift 必须处于按下状态(!m_keys.KeyShiftState() 时退出)。通过后先 SetStartSelectedTextIndexes 记选区起点,移完光标再 SetEndSelectedTextIndexes 补终点,等于每按一次就往选区右界扩一个字符。 底层 MoveTextCursor 有两个重载:带 with_highlighted_text 参数的版本里,false 分支会先 ResetSelectedText 再单纯移动;true 分支则走设置起止索引的选区流程。你在 EA 面板里做可编辑输入框时,照这套事件分发写,左键行为就不会和系统的文本选择打架。外汇与贵金属图表挂这类控件属高风险环境,实盘前请在策略测试器里把按键响应跑够 200 次以上。

MQL5 / C++
class="type">bool CTextBox::OnPressedKeyLeft(class="kw">const class="type">long key_code)
  {
  if(key_code!=KEY_LEFT || m_keys.KeyCtrlState() || m_keys.KeyShiftState())
     class="kw">return(class="kw">false);
  class=class="str">"cmt">//--- 重置选择
  ResetSelectedText();
  class=class="str">"cmt">//--- 文本光标向左移动一个字符
  MoveTextCursor(TO_NEXT_LEFT_SYMBOL);
  class=class="str">"cmt">//--- 调整滚动条
  CorrectingHorizontalScrollThumb();
  CorrectingVerticalScrollThumb();
  class=class="str">"cmt">//--- 更新文本框中的文本
  DrawTextAndCursor(true);
  class=class="str">"cmt">//--- 发送有关它的消息
  ::EventChartCustom(m_chart_id,ON_MOVE_TEXT_CURSOR,CElementBase::Id(),CElementBase::Index(),TextCursorInfo());
  class="kw">return(true);
  }

class="type">bool CTextBox::OnPressedKeyShiftAndLeft(class="kw">const class="type">long key_code)
  {
  if(key_code!=KEY_LEFT || m_keys.KeyCtrlState() || !m_keys.KeyShiftState())
     class="kw">return(class="kw">false);
  class=class="str">"cmt">//--- 设置选择文本的起始索引
  SetStartSelectedTextIndexes();
  class=class="str">"cmt">//--- 文本光标向左移动一个字符
  MoveTextCursor(TO_NEXT_LEFT_SYMBOL);
  class=class="str">"cmt">//--- 设置所选文本的结束行索引
  SetEndSelectedTextIndexes();
  class=class="str">"cmt">//--- 调整滚动条
  CorrectingHorizontalScrollThumb();
  CorrectingVerticalScrollThumb();
  class=class="str">"cmt">//--- 更新文本框中的文本
  DrawTextAndCursor(true);
  class=class="str">"cmt">//--- 发送有关它的消息
  ::EventChartCustom(m_chart_id,ON_MOVE_TEXT_CURSOR,CElementBase::Id(),CElementBase::Index(),TextCursorInfo());
  class="kw">return(true);
  }

class CTextBox : class="kw">public CElement
  {
class="kw">private:
  class=class="str">"cmt">//--- 按指定的方向移动文本光标
  class="type">void              MoveTextCursor(class="kw">const ENUM_MOVE_TEXT_CURSOR direction,class="kw">const class="type">bool with_highlighted_text);
  };

class="type">void CTextBox::MoveTextCursor(class="kw">const ENUM_MOVE_TEXT_CURSOR direction,class="kw">const class="type">bool with_highlighted_text)
  {
  if(!with_highlighted_text)
     {
     class=class="str">"cmt">//--- 重置选择
     ResetSelectedText();
     class=class="str">"cmt">//--- 光标移动到首行的开头
     MoveTextCursor(direction);
     }
  else
     {
     class=class="str">"cmt">//--- 设置选择文本的起始索引
     SetStartSelectedTextIndexes();
     class=class="str">"cmt">//--- 文本光标向左移动一个字符
     MoveTextCursor(direction);
     class=class="str">"cmt">//--- 设置所选文本的结束行索引
     SetEndSelectedTextIndexes();
     }
  }

文本框选区判定的代码落点

在 MT5 自定义文本框控件里,Shift 系组合键(Shift+Left/Right/Up/Down、Shift+Home/End,以及 Ctrl+Shift 变体)全部被拆成独立的私有方法挂在 CTextBox 类下,说明选区扩展逻辑是按按键原子化处理的,而不是在一个大分支里硬塞。 真正决定“某字符是否处于选中态”的,是 CheckSelectedText() 这个函数。它先以 m_selected_line_from==WRONG_VALUE 作为早期出口——没起选就直接返回 false,避免后续无意义的行列比较。 当起始行反而大于结束行(即向上或向左反选)时,判定规则翻转:末行且字符位置 >= m_selected_symbol_to 才标蓝。这种从“用户拖拽方向”反推索引边界的做法,开 MT5 把这段抄进自己的面板类,调 m_selected_line_from / m_selected_line_to 两个字段就能复现选区高亮。外汇与贵金属图表挂这类自定义控件需注意,高频重绘可能拖慢 tick 响应,属高风险定制。

MQL5 / C++
class="type">bool CTextBox::CheckSelectedText(class="kw">const class="type">uint line_index,class="kw">const class="type">uint symbol_index)
  {
   class="type">bool is_selected_text=class="kw">false;
class=class="str">"cmt">//--- 如果没有选择文本, 离开
   if(m_selected_line_from==WRONG_VALUE)
      class="kw">return(class="kw">false);
class=class="str">"cmt">//--- 如果初始索引在下面的行
   if(m_selected_line_from>m_selected_line_to)
     {
      class=class="str">"cmt">//--- 最后一行和字符在最后选择的右侧
      if((class="type">int)line_index==m_selected_line_to && (class="type">int)symbol_index>=m_selected_symbol_to)
        { is_selected_text=true; }
      class=class="str">"cmt">//--- 初始行和字符在最初选择的左侧

「文本框选区判定的三种拖拽方向」

在自绘文本框控件里,选中态的判定依赖起始行/列与结束行/列的相对位置。代码用 m_selected_line_from / m_selected_symbol_from 记录锚点,m_selected_line_to / m_selected_symbol_to 记录松开点,is_selected_text 作为逐字符的命中标记。 当锚点行在结束行下方(反向向下拖),若当前行等于锚点行且列号小于锚点列,或处于两行之间,该字符标记为选中。反之锚点在上、向下拖时,末行左侧与首行右侧及中间行全选,逻辑对称。 同一行内的选区看列区间:光标右移时命中 to 与 from 之间,左移时命中 from 与 to 之间,最终 return(is_selected_text) 交还布尔结果。 CTextBox 继承 CElement,私有方法 TextOut() 负责落盘:先 m_canvas.Erase(AreaColorCurrent()) 清画布,再取 m_lines 大小做越界钳制(m_text_cursor_y_pos 超过 lines_total 则归到末行减1),之后才逐字符绘制。外汇与贵金属图表叠加自绘控件属高风险操作,参数越界可能引发闪烁或崩溃,建议在 MT5 策略测试器里单步验证。

MQL5 / C++
else if((class="type">int)line_index==m_selected_line_from && (class="type">int)symbol_index<m_selected_symbol_from)
      { is_selected_text=true; }
class=class="str">"cmt">//--- 中间行 (选择所有字符)
      else if((class="type">int)line_index>m_selected_line_to && (class="type">int)line_index<m_selected_line_from)
         { is_selected_text=true; }
   }
class=class="str">"cmt">//--- 如果初始索引在上面
   else if(m_selected_line_from<m_selected_line_to)
   {
class=class="str">"cmt">//--- 最后一行和字符在最后选择的左侧
      if((class="type">int)line_index==m_selected_line_to && (class="type">int)symbol_index<m_selected_symbol_to)
         { is_selected_text=true; }
class=class="str">"cmt">//--- 初始行和字符在初始选择的右侧
      else if((class="type">int)line_index==m_selected_line_from && (class="type">int)symbol_index>=m_selected_symbol_from)
         { is_selected_text=true; }
class=class="str">"cmt">//--- 中间行 (选择所有字符)
      else if((class="type">int)line_index<m_selected_line_to && (class="type">int)line_index>m_selected_line_from)
         { is_selected_text=true; }
   }
class=class="str">"cmt">//--- 如果初始和最终索引在同一行
   else
   {
class=class="str">"cmt">//--- 查找已检查的行
      if((class="type">int)line_index>=m_selected_line_to && (class="type">int)line_index<=m_selected_line_from)
         {
class=class="str">"cmt">//--- 如果光标向右移动, 且字符在所选范围内
         if(m_selected_symbol_from>m_selected_symbol_to)
            {
            if((class="type">int)symbol_index>=m_selected_symbol_to && (class="type">int)symbol_index<m_selected_symbol_from)
               is_selected_text=true;
            }
class=class="str">"cmt">//--- 如果光标向左移动, 且字符在所选范围内
         else
            {
            if((class="type">int)symbol_index>=m_selected_symbol_from && (class="type">int)symbol_index<m_selected_symbol_to)
               is_selected_text=true;
            }
         }
   }
class=class="str">"cmt">//--- 返回结果
   class="kw">return(is_selected_text);
   }
class CTextBox : class="kw">public CElement
   {
class="kw">private:
class=class="str">"cmt">//--- 将文本输出到画布
   class="type">void             TextOut(class="type">void);
   };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 将文本输出到画布                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CTextBox::TextOut(class="type">void)
   {
class=class="str">"cmt">//--- 清洁画布
   m_canvas.Erase(AreaColorCurrent());
class=class="str">"cmt">//--- 获取行数组的大小
   class="type">uint lines_total=::ArraySize(m_lines);
class=class="str">"cmt">//--- 在超界的情况下, 调整大小
   m_text_cursor_y_pos=(m_text_cursor_y_pos>=lines_total)? lines_total-class="num">1 : m_text_cursor_y_pos;
class=class="str">"cmt">//--- 获取字符数组的大小

◍ 文本框里逐字符绘制的底层循环

在 MT5 的 Canvas 自定义控件里,文本不是一句 TextOut 就完事。多行模式开启、或当前行已有字符时,引擎会先取行宽 LineWidth 与行高 LineHeight,再按 lines_total 逐行扫描,每行内部再按字符数组 m_lines[i].m_symbol 逐个落笔。 逐字符绘制时,x 坐标从 m_text_x_offset 起算,每画一个字符就累加 m_lines[i].m_width[s],y 则按 i*line_height 向下偏移。若 CheckSelectedText(i,s) 命中,该字符前景色切到 m_selected_text_color,并用 FillRectangle 以 m_selected_back_color 填出选中背景——背景矩形右下角取 x+m_lines[i].m_width[s] 与 y+line_height-1,比字符略收 1 像素边距。 当多行模式关闭且首行无字符,分支走 else:仅当 m_default_text 非空,才在控件中心 (m_area_x_size/2, m_area_y_size/2) 以 TA_CENTER|TA_VCENTER 绘制省缺提示文字。外汇与贵金属图表挂这类自定义面板属高风险操作,参数误写可能导致 EA 在实时账户上绘制异常。 把下面这段直接贴进你的 Canvas 类方法,能在 MT5 策略测试器的可视化模式里验证选中高亮逻辑是否按预期走。

MQL5 / C++
  class="type">uint symbols_total=::ArraySize(m_lines[m_text_cursor_y_pos].m_symbol);
  class=class="str">"cmt">//--- 如果启用了多行模式, 或是字符数大于零
  if(m_multi_line_mode || symbols_total>class="num">0)
    {
      class=class="str">"cmt">//--- 获取行宽
      class="type">int line_width=(class="type">int)LineWidth(m_text_cursor_x_pos,m_text_cursor_y_pos);
      class=class="str">"cmt">//--- 获取行高, 并循环遍历所有行
      class="type">int line_height=(class="type">int)LineHeight();
      for(class="type">uint i=class="num">0; i<lines_total; i++)
        {
          class=class="str">"cmt">//--- 获取文本坐标
          class="type">int x=m_text_x_offset;
          class="type">int y=m_text_y_offset+((class="type">int)i*line_height);
          class=class="str">"cmt">//--- 获取字符串大小
          class="type">uint string_length=::ArraySize(m_lines[i].m_symbol);
          class=class="str">"cmt">//--- 绘制文本
          for(class="type">uint s=class="num">0; s<string_length; s++)
            {
              class="type">uint text_color=TextColorCurrent();
              class=class="str">"cmt">//--- 如果有已选择的文本, 确定其颜色和当前字符的背景颜色
              if(CheckSelectedText(i,s))
                {
                  class=class="str">"cmt">//--- 所选文字的颜色
                  text_color=::ColorToARGB(m_selected_text_color);
                  class=class="str">"cmt">//--- 计算绘制背景的坐标
                  class="type">int x2=x+m_lines[i].m_width[s];
                  class="type">int y2=y+line_height-class="num">1;
                  class=class="str">"cmt">//--- 绘制字符的背景颜色
                  m_canvas.FillRectangle(x,y,x2,y2,::ColorToARGB(m_selected_back_color));
                }
              class=class="str">"cmt">//--- 绘制字符
              m_canvas.TextOut(x,y,m_lines[i].m_symbol[s],text_color,TA_LEFT);
              class=class="str">"cmt">//--- 下一个字符的 X 坐标
              x+=m_lines[i].m_width[s];
            }
        }
    }
  class=class="str">"cmt">//--- 如果多行模式被禁用且没有字符, 则显示省缺文本
  else
    {
      class=class="str">"cmt">//--- 绘制文本, 如果指定
      if(m_default_text!="")
        m_canvas.TextOut(m_area_x_size/class="num">2,m_area_y_size/class="num">2,m_default_text,::ColorToARGB(m_default_text_color),TA_CENTER|TA_VCENTER);
    }

单行与多行选中文本的清除逻辑

文本框里删掉高亮文本不是一刀切,核心要看选区落在单行还是跨行。单行场景走 DeleteTextOnOneLine,先算要删几个字符:用 fabs(selected_symbol_from - selected_symbol_to) 取绝对值,这就是待清除长度。 若选区起点索引在右(to < from),从起点位置向右把后续字符前移覆盖,行字符数组同步缩减同样数量;若起点在左,文本光标先左移 symbols_to_delete 再搬字符。注意光标左移这步容易漏,漏了会导致下一次输入错位。 跨行删除交给 DeleteTextOnMultipleLines,算法重很多。先统计首末行字符总数、中间行数、首末行各自要删的字符数;再把跨行后保留的字符塞进临时动态数组,调接收行结构、按删除行数平移行、压缩行数组。选择方向向下(首行高于末行)时,光标要回跳到选区初始索引。 总入口 DeleteSelectedText 负责判断调哪个分支,删完重置首尾索引、重算文本框尺寸与最大行宽,并发光标移动消息;有选区返回 true,无选区 false。它不止响应 Backspace,输入新字符和回车时也先删选区再执行键动作。 别把正态当圣经:上面代码里 fabs 取字符差,若你改造成支持双向 RTL 文本,得先确认 m_selected_symbol_* 的语义是否仍成立,否则跨行复制会丢尾字符。

MQL5 / C++
class CTextBox : class="kw">public CElement
  {
class="kw">private:
   class=class="str">"cmt">//--- 删除一行中选择的文本
   class="type">void                DeleteTextOnOneLine(class="type">void);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 删除一行中选择的文本                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CTextBox::DeleteTextOnOneLine(class="type">void)
  {
  class="type">int symbols_total      =::ArraySize(m_lines[m_text_cursor_y_pos].m_symbol);
  class="type">int symbols_to_delete =::fabs(m_selected_symbol_from-m_selected_symbol_to);
class=class="str">"cmt">//--- 如果字符的初始索引在右侧
  if(m_selected_symbol_to<m_selected_symbol_from)
    {
     class=class="str">"cmt">//--- 将字符移动到当前行中的空余区域
     MoveSymbols(m_text_cursor_y_pos,m_selected_symbol_from,m_selected_symbol_to);
    }
class=class="str">"cmt">//--- 如果字符的初始索引在左侧
  else
    {
     class=class="str">"cmt">//--- 将文本光标向左移动要删除数量的字符
     m_text_cursor_x_pos-=symbols_to_delete;
     class=class="str">"cmt">//--- 将字符移动到当前行中的空余区域
     MoveSymbols(m_text_cursor_y_pos,m_selected_symbol_to,m_selected_symbol_from);
    }
class=class="str">"cmt">//--- 当前行的数组大小减少已提取的字符数
  ArraysResize(m_text_cursor_y_pos,symbols_total-symbols_to_delete);
  }
class CTextBox : class="kw">public CElement
  {
class="kw">private:
   class=class="str">"cmt">//--- 删除多行中选择的文本
   class="type">void                DeleteTextOnMultipleLines(class="type">void);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 删除多行中选择的文本                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CTextBox::DeleteTextOnMultipleLines(class="type">void)
  {
class=class="str">"cmt">//--- 初始和最后一行的字符总数
  class="type">uint symbols_total_line_from =::ArraySize(m_lines[m_selected_line_from].m_symbol);
  class="type">uint symbols_total_line_to   =::ArraySize(m_lines[m_selected_line_to].m_symbol);
class=class="str">"cmt">//--- 要被删除的中间行数
  class="type">uint lines_to_delete =::fabs(m_selected_line_from-m_selected_line_to);
class=class="str">"cmt">//--- 初始和最后一行要删除的字符数
  class="type">uint symbols_to_delete_in_line_from =::fabs(symbols_total_line_from-m_selected_symbol_from);
  class="type">uint symbols_to_delete_in_line_to   =::fabs(symbols_total_line_to-m_selected_symbol_to);
class=class="str">"cmt">//--- 如果初始行低于最后一行
  if(m_selected_line_from>m_selected_line_to)
    {
     class=class="str">"cmt">//--- 将要移动的字符复制到数组中
     class="type">class="kw">string array[];
     CopyWrapSymbols(m_selected_line_from,m_selected_symbol_from,symbols_to_delete_in_line_from,array);
     class=class="str">"cmt">//--- 调整接收行数组大小
把键盘状态判断交给小布
小布盯盘的 AIGC 已内置常见按键状态与画布重绘的诊断视图,打开对应品种页就能直接看控件层的事件流,不必自己埋 Print 调试。

常见问题

可在操控键盘的类中封装一个读取 TERMINAL_KEYSTATE_SHIFT 的方法,返回值低于零即视为 Shift 已按下,用于后续组合键分支。
可以,把控件事件接到小布盯盘的日志面板,就能回看 Shift、方向键的组合触发顺序,省去手动复现。
这两个组合只作用于当前行,从光标起点选到行首或行尾,不会自动扩展到上下行。
每个控件作为单独画布呈现,重绘时只需刷新对应层,后续做选中高亮和删除只动局部图像数据,效率更高。
若面板面向频繁文本编辑,按词选择会明显提速;纯参数输入场景可先省略,留接口即可。