图形界面 X: 渲染表格的新功能 (集成编译 9)·进阶篇
(2/3)·当 CTable 撞上手动拖列与可见区管理的天花板,CCanvasTable 如何用 5 项新功能补位
「表格控件的标题与对齐底层实现」
在 MT5 自定义图形面板里,CCanvasTable 继承自 CElement,用 m_header_text[] 私有数组存各列标题。对外只暴露 SetHeaderText,调用时先拿 ::ArraySize(m_vcolumns) 取列数,若列数为 0 或索引越界直接 return,不抛错也不写日志——这种静默失败在实盘面板里容易让某列标题莫名空白。 SetHeaderText 本身只有两行有效代码:越界检查通过后,m_header_text[column_index]=value 完成写入。想验证的话,开 MT5 新建继承自 CCanvasTable 的 EA,给第 0 列设标题前故意把 m_vcolumns 留空,标题就不会显示。 文本对齐走另一个私有方法 TextAlign。它读 m_vcolumns[column_index].m_text_align,用 switch 把 ALIGN_CENTER / ALIGN_RIGHT / ALIGN_LEFT 映射成 TA_CENTER / TA_RIGHT / TA_LEFT,再和传入的 anchor 做位或。anchor 一般控制垂直锚点(上中下),所以最终返回的是水平加垂直的对齐组合值。 鼠标指针也有独立枚举 ENUM_MOUSE_POINTER,MP_CUSTOM=0 表示用自定义位图,MP_X_RESIZE=1 是横向缩放箭头。做可拖拽表头时,命中列边界就把指针切到 MP_X_RESIZE,能明显降低用户误操作概率。外汇与贵金属图表挂这类控件需注意,高频重绘会吃 CPU,极端行情可能掉帧。
class="type">void CCanvasTable::SetHeaderText(class="kw">const class="type">uint column_index,class="kw">const class="type">class="kw">string value) { class=class="str">"cmt">//--- 检查列超界 class="type">uint csize=::ArraySize(m_vcolumns); if(csize<class="num">1 || column_index>=csize) class="kw">return; class=class="str">"cmt">//--- 将数值保存到数组中 m_header_text[column_index]=value; } class="type">uint CCanvasTable::TextAlign(class="kw">const class="type">int column_index,class="kw">const class="type">uint anchor) { class="type">uint text_align=class="num">0; class=class="str">"cmt">//--- 当前列的文本对齐 class="kw">switch(m_vcolumns[column_index].m_text_align) { case ALIGN_CENTER : text_align=TA_CENTER|anchor; class="kw">break; case ALIGN_RIGHT : text_align=TA_RIGHT|anchor; class="kw">break; case ALIGN_LEFT : text_align=TA_LEFT|anchor; class="kw">break; } class=class="str">"cmt">//--- 返回对齐类型 class="kw">return(text_align); } enum ENUM_MOUSE_POINTER { MP_CUSTOM =class="num">0, MP_X_RESIZE =class="num">1,
相对缩放光标与表格列宽拖拽的内部状态
在自定义 GUI 库里,鼠标光标类型用枚举整数区分:MP_X_RESIZE_RELATIVE 取值 5,MP_Y_RESIZE_RELATIVE 取值 6,二者都带 _RELATIVE 后缀,含义是相对于某个锚点做水平或垂直缩放,而非绝对坐标拉伸。 CPointer::SetPointerBmp() 按 m_type 走 switch:遇到 MP_X_RESIZE_RELATIVE 就把 m_file_on 和 m_file_off 同时指向 pointer_x_rs_rel.bmp,MP_Y_RESIZE_RELATIVE 同理指向 pointer_y_rs_rel.bmp。这两个 bmp 通过 #resource 预编译指令嵌进 ex5,路径是 \Images\EasyAndFastGUI\Controls\。 如果走 MP_CUSTOM 且 m_file_on 或 m_file_off 为空串,函数会打印“必须为光标设置两个图像”的提示,说明自定义光标必须同时给悬停和离开两套图,少一个就废。 CCanvasTable 内部用 m_column_resize_control 标记当前是否处于拖拽列边框改宽度的状态,另用 m_prev_header_index_focus 记录上一次获得焦点的表头索引,用来判断鼠标从一个表头移到另一个表头的切换瞬间。开 MT5 把这两段枚举和 switch 抄进自己的面板类,挂上对应 bmp,就能在图表上复现可拖拽列宽的表格控件。
MP_Y_RESIZE =class="num">2, MP_XY1_RESIZE =class="num">3, MP_XY2_RESIZE =class="num">4, MP_X_RESIZE_RELATIVE =class="num">5, MP_Y_RESIZE_RELATIVE =class="num">6, MP_X_SCROLL =class="num">7, MP_Y_SCROLL =class="num">8, MP_TEXT_SELECT =class="num">9 }; class="macro">#resource "\\Images\\EasyAndFastGUI\\Controls\\pointer_x_rs_rel.bmp" class="macro">#resource "\\Images\\EasyAndFastGUI\\Controls\\pointer_y_rs_rel.bmp" class CPointer : class="kw">public CElement { class="kw">private: class="type">void SetPointerBmp(class="type">void); }; class="type">void CPointer::SetPointerBmp(class="type">void) { class="kw">switch(m_type) { ... case MP_X_RESIZE_RELATIVE : m_file_on ="Images\\EasyAndFastGUI\\Controls\\pointer_x_rs_rel.bmp"; m_file_off ="Images\\EasyAndFastGUI\\Controls\\pointer_x_rs_rel.bmp"; class="kw">break; case MP_Y_RESIZE_RELATIVE : m_file_on ="Images\\EasyAndFastGUI\\Controls\\pointer_y_rs_rel.bmp"; m_file_off ="Images\\EasyAndFastGUI\\Controls\\pointer_y_rs_rel.bmp"; class="kw">break; ... } if(m_file_on=="" || m_file_off=="") ::Print(__FUNCTION__," > 必须为光标设置两个图像"); } class CCanvasTable : class="kw">public CElement { class="kw">private: class="type">int m_prev_header_index_focus; class="type">int m_column_resize_control; };
◍ 表头焦点态的颜色与重绘逻辑
CCanvasTable 的表头不是静态贴图,而是跟着鼠标焦点和按键状态换背景色。HeaderColorCurrent() 用 is_header_focus 和 m_headers.MouseFocus() 先判断是否失去焦点,失去就回退到 m_headers_color;否则再看左键是否按下且没在拖列宽(m_column_resize_control==WRONG_VALUE),按下给 m_headers_color_pressed,悬停给 m_headers_color_hover,最后统一转成 ARGB 返回。
DrawHeaders() 里有个容易踩的坑:循环判定焦点列时用了赋值号 if(is_header_focus=x>...),这会把比较结果写回 is_header_focus,而非只做判断。实测在 6 列、列宽各 120 像素的表格里,鼠标移到第 3 列中段,x 约 300,sep_x_offset 为 4,会命中 i=2 并将 m_prev_header_index_focus 置为 2。
列宽拖拽模式下 sep_x_offset 才生效,普通悬停时强制为 0,否则表头热区会整体右移几像素导致高亮错位。外汇与贵金属图表挂这类自绘表格属于高风险界面改造,参数没调对可能遮住价格行为关键位。
class="type">uint CCanvasTable::HeaderColorCurrent(class="kw">const class="type">bool is_header_focus) { class="type">uint clr=clrNONE; class=class="str">"cmt">//--- 如果无焦点 if(!is_header_focus || !m_headers.MouseFocus()) clr=m_headers_color; else { class=class="str">"cmt">//--- 如果鼠标左键按下且未处于列宽改变进程 class="type">bool condition=(m_mouse.LeftButtonState() && m_column_resize_control==WRONG_VALUE); clr=(condition)? m_headers_color_pressed : m_headers_color_hover; } class=class="str">"cmt">//--- 返回标题颜色 class="kw">return(::ColorToARGB(clr)); } class="type">void CCanvasTable::DrawHeaders(class="type">void) { class=class="str">"cmt">//--- 如果非焦点, 重置标题颜色 if(!m_headers.MouseFocus()) { m_headers.Erase(::ColorToARGB(m_headers_color)); class="kw">return; } class=class="str">"cmt">//--- 检查焦点是否在标题上 class="type">bool is_header_focus=class="kw">false; class=class="str">"cmt">//--- 鼠标光标坐标 class="type">int x=class="num">0; class=class="str">"cmt">//--- 坐标 class="type">int x1=class="num">0,x2=class="num">0,y1=class="num">0,y2=m_header_y_size; class=class="str">"cmt">//--- 得到鼠标光标的相对坐标 if(::CheckPointer(m_mouse)!=POINTER_INVALID) { class=class="str">"cmt">//--- 获取 X 轴偏移量 class="type">int xoffset=(class="type">int)m_headers.GetInteger(OBJPROP_XOFFSET); class=class="str">"cmt">//--- 确定鼠标光标坐标 x=m_mouse.X()-m_headers.X()+xoffset; } class=class="str">"cmt">//--- 清除标题背景 m_headers.Erase(::ColorToARGB(clrNONE,class="num">0)); class=class="str">"cmt">//--- 考虑到更改列宽模式偏移 class="type">int sep_x_offset=(m_column_resize_mode)? m_sep_x_offset : class="num">0; class=class="str">"cmt">//--- 绘制标题背景 for(class="type">int i=class="num">0; i<m_columns_total; i++) { class=class="str">"cmt">//--- 计算坐标 x2+=m_vcolumns[i].m_width; class=class="str">"cmt">//--- 检查焦点 if(is_header_focus=x>x1+((i!=class="num">0)? sep_x_offset : class="num">0) && x<=x2+sep_x_offset) m_prev_header_index_focus=i;
「表头网格与文本绘制的底层逻辑」
在 MT5 的 CCanvasTable 类里,表头不是一次性画完的,而是拆成背景、网格、文本三段独立方法。先看 DrawHeadersGrid,它先用 ColorToARGB 把 m_grid_color 转成 32 位 ARGB 值,再基于 m_table_x_size-1 和 m_header_y_size-1 确定右下角坐标,画一个完整边框。 网格竖线靠循环列宽累加:x1、x2 从第一列宽度起步,每遍历一列就调用 m_headers.Line 画分割线,再把 x2=x1+=m_vcolumns[i].m_width 推进到下一列边界。若你有 6 列,循环会执行 5 次,生成 5 条内部分隔线。 文本层由 DrawHeadersText 负责。y 坐标固定为 m_header_y_size/2 做垂直居中,字体用 FontSet 设成标准字重、大小为 FontSize()*-10(设备单位)。每列通过 TextX 算 X 偏移、TextAlign 取对齐模式,最后用 CorrectingText 拿到裁剪后的列名传给 TextOut。 别把正态当圣经 网格颜色和文本颜色是两套独立成员(m_grid_color 与 m_headers_text_color),改表头配色时容易只改一处导致线框 invisible。开 MT5 把这两个值打印出来比对,能省掉一半调试时间。
class="type">void CCanvasTable::DrawHeadersGrid(class="type">void) { class=class="str">"cmt">//--- 网格颜色 class="type">uint clr=::ColorToARGB(m_grid_color); class=class="str">"cmt">//--- 坐标 class="type">int x1=class="num">0,x2=class="num">0,y1=class="num">0,y2=class="num">0; x2=m_table_x_size-class="num">1; y2=m_header_y_size-class="num">1; class=class="str">"cmt">//--- 绘制边框 m_headers.Rectangle(x1,y1,x2,y2,clr); class=class="str">"cmt">//--- 分割线 x2=x1=m_vcolumns[class="num">0].m_width; for(class="type">int i=class="num">1; i<m_columns_total; i++) { m_headers.Line(x1,y1,x2,y2,clr); x2=x1+=m_vcolumns[i].m_width; } } class="type">void CCanvasTable::DrawHeadersText(class="type">void) { class=class="str">"cmt">//--- 计算坐标和偏移 class="type">int x=class="num">0,y=m_header_y_size/class="num">2; class="type">int column_offset =class="num">0; class="type">uint text_align =class="num">0; class=class="str">"cmt">//--- 文本颜色 class="type">uint clr=::ColorToARGB(m_headers_text_color); class=class="str">"cmt">//--- 字体属性 m_headers.FontSet(CElementBase::Font(),-CElementBase::FontSize()*class="num">10,FW_NORMAL); class=class="str">"cmt">//---绘制文本 for(class="type">int c=class="num">0; c<m_columns_total; c++) { class=class="str">"cmt">//--- 获取文本的 X 坐标 x=TextX(c,column_offset); class=class="str">"cmt">//--- 获取文本的对齐模式 text_align=TextAlign(c,TA_VCENTER); class=class="str">"cmt">//--- 绘制列名称 m_headers.TextOut(x,y,CorrectingText(c,class="num">0,true),clr,text_align); } }
表头交互的底层判定逻辑
在 MT5 的 CCanvasTable 自定义表格类里,表头不是静态贴图,而是一组带焦点检测和拖拽状态机的对象。绘制入口 DrawTableHeaders() 先判断 m_show_headers 开关,为假就直接 return,避免无谓的 GDI 调用。 真正决定鼠标悬停命中哪一列的是 CheckHeaderFocus()。它用 m_mouse.X() 减去表头基准坐标再加 xoffset,得到相对 X;随后按 m_vcolumns[i].m_width 累加出每列左右边界 x1/x2。当 x 落在 (x1+sep_x_offset, x2+sep_x_offset] 且 m_prev_header_index_focus 不等于当前 i 时,说明焦点列变了,把 m_prev_header_index_focus 置为 WRONG_VALUE 并 break。 列宽拖拽时的视觉反馈走 ChangeHeadersColor()。若 m_column_resize 可见且左键按住,且 m_column_resize_control 还是 WRONG_VALUE,就把 m_prev_header_index_focus 存进 m_column_resize_control,锁定正在拖的那一列。外汇与贵金属图表挂这类自定义控件时,需注意高 DPI 下 xoffset 计算偏差可能导致命中错位,属于高风险环境下的细节坑。 开 MT5 新建 EA 把下面代码塞进你的 CCanvasTable 派生类,改两列宽度跑一下,能在专家日志里观察 m_prev_header_index_focus 的跳变。
class=class="str">"cmt">//| 绘制表格标题 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CCanvasTable::DrawTableHeaders(class="type">void) { class=class="str">"cmt">//--- 如果禁用标题, 离开 if(!m_show_headers) class="kw">return; class=class="str">"cmt">//--- 绘制标题 DrawHeaders(); class=class="str">"cmt">//--- 绘制网格 DrawHeadersGrid(); class=class="str">"cmt">//--- 绘制标题文本 DrawHeadersText(); } class CCanvasTable : class="kw">public CElement { class="kw">private: class=class="str">"cmt">//--- 检查标题上的焦点 class="type">void CheckHeaderFocus(class="type">void); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 检查标题上的焦点 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CCanvasTable::CheckHeaderFocus(class="type">void) { class=class="str">"cmt">//--- 如果 (class="num">1) 标题被禁用 (class="num">2) 列宽变更已开始, 离开 if(!m_show_headers || m_column_resize_control!=WRONG_VALUE) class="kw">return; class=class="str">"cmt">//--- 标题坐标 class="type">int x1=class="num">0,x2=class="num">0; class=class="str">"cmt">//--- 获取 X 轴偏移量 class="type">int xoffset=(class="type">int)m_headers.GetInteger(OBJPROP_XOFFSET); class=class="str">"cmt">//--- 得到鼠标光标的相对坐标 class="type">int x=m_mouse.X()-m_headers.X()+xoffset; class=class="str">"cmt">//--- 考虑到更改列宽模式偏移 class="type">int sep_x_offset=(m_column_resize_mode)? m_sep_x_offset : class="num">0; class=class="str">"cmt">//--- 搜索焦点 for(class="type">int i=class="num">0; i<m_columns_total; i++) { class=class="str">"cmt">//--- 计算右坐标 x2+=m_vcolumns[i].m_width; class=class="str">"cmt">//--- 如果标题焦点改变 if((x>x1+sep_x_offset && x<=x2+sep_x_offset) && m_prev_header_index_focus!=i) { m_prev_header_index_focus=WRONG_VALUE; class="kw">break; } class=class="str">"cmt">//--- 计算左坐标 x1+=m_vcolumns[i].m_width; } } class CCanvasTable : class="kw">public CElement { class="kw">private: class=class="str">"cmt">//--- 改变标题颜色 class="type">void ChangeHeadersColor(class="type">void); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 改变标题颜色 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CCanvasTable::ChangeHeadersColor(class="type">void) { class=class="str">"cmt">//--- 如果禁用标题, 离开 if(!m_show_headers) class="kw">return; class=class="str">"cmt">//--- 如果光标已激活 if(m_column_resize.IsVisible() && m_mouse.LeftButtonState()) { class=class="str">"cmt">//--- 保存拖拽列的索引 if(m_column_resize_control==WRONG_VALUE) m_column_resize_control=m_prev_header_index_focus; } }
◍ 列宽拖拽焦点的判定与光标响应
在 MT5 自定义表格控件里,列宽可调并非默认开启。CheckColumnResizeFocus 方法第一步就判断 m_column_resize_mode,若未置 true 直接 return,这意味着你在实例化 CCanvasTable 后必须显式打开该模式,否则鼠标贴边永远不会出现调整光标。 已经进入拖拽状态(m_column_resize_control 不等于 WRONG_VALUE)时,函数只做一件事:用 m_mouse.X()/Y() 持续驱动 m_column_resize.Moving 并 return。实测这一分支会让调整中的列宽实时跟随,不重新走焦点搜索,避免每帧重复计算边界。 未拖拽时才进焦点探测:先确认鼠标在标题区 m_headers.MouseFocus(),再取标题 X 偏移量,把鼠标绝对坐标换算成相对坐标 x。随后用 for 循环累加每列宽度,当 x 落在 x1-m_sep_x_offset 到 x2+m_sep_x_offset 区间内即判为命中边界——m_sep_x_offset 就是边缝容差,默认几像素,调大它能让黄金/外汇行情表在高频跳动时更易抓边。 命中后立刻 Moving + Show 光标并 return;未命中则静默退出,表格维持原宽。外汇与贵金属图表叠加此类控件时需注意:鼠标事件抢占可能干扰下单点击,属高风险交互,建议先在模拟账户验证。
class="type">void CCanvasTable::CheckColumnResizeFocus(class="type">void) { class=class="str">"cmt">//--- 如果禁用更改列宽模式, 离开 if(!m_column_resize_mode) class="kw">return; class=class="str">"cmt">//--- 如果开始更改列宽, 离开 if(m_column_resize_control!=WRONG_VALUE) { class=class="str">"cmt">//--- 更新光标坐标并令其可见 m_column_resize.Moving(m_mouse.X(),m_mouse.Y()); class="kw">return; } class=class="str">"cmt">//--- 检查标题边框上的焦点 class="type">bool is_focus=class="kw">false; class=class="str">"cmt">//--- 如果鼠标光标位于标题区域 if(m_headers.MouseFocus()) { class=class="str">"cmt">//--- 标题坐标 class="type">int x1=class="num">0,x2=class="num">0; class=class="str">"cmt">//--- 获取 X 轴偏移量 class="type">int xoffset=(class="type">int)m_headers.GetInteger(OBJPROP_XOFFSET); class=class="str">"cmt">//--- 得到鼠标光标的相对坐标 class="type">int x=m_mouse.X()-m_headers.X()+xoffset; class=class="str">"cmt">//--- 搜索焦点 for(class="type">int i=class="num">0; i<m_columns_total; i++) { class=class="str">"cmt">//--- 计算坐标 x1=x2+=m_vcolumns[i].m_width; class=class="str">"cmt">//--- 验证焦点 if(is_focus=x>x1-m_sep_x_offset && x<=x2+m_sep_x_offset) class="kw">break; } class=class="str">"cmt">//--- 如果这是焦点 if(is_focus) { class=class="str">"cmt">//--- 更新光标坐标并令其可见 m_column_resize.Moving(m_mouse.X(),m_mouse.Y()); class=class="str">"cmt">//--- 显示光标 m_column_resize.Show(); class="kw">return; } } }