Files
DeepSeek V4.1 Flash 328a3b3b59 fix(ui): 选区截取面板空白真正根因、拖拽指示线层序、新建分组修复、避让动画更顺
一、选区截取面板「条目区与按钮之间的空白」(连续三轮未修好的那个)
根因不是内容区几何,而是**底部固定区高度算了两行**:footerHeight 用
`buttonHeight * 2 + 6 + pad` 预留,而布局把「截取为新信号 / 原始信号截取」
两个按钮并排画在同一行 ⇒ 内容区下边界与按钮上边界之间空出一整行。
改为按一行算(两处:高度算式分支 + 布局几何分支同步修改)。
数值核对(scale=1.25、1 条可见信号):GAP 53.8px → 8.8px(= 一个 pad 的预期留白)。

二、拖拽插入指示线不再盖住被拖条目
PaintSignalDragGhost 的绘制顺序改为「插入指示线 → 阴影 → 幽灵行 → 描边」,
指示线始终位于跟随鼠标的那部分之下。

三、「移动到…」二级菜单
* 条目文案改为「组号 | 组名」(不再是括号形式)。
* 修复「新建分组」点了没反应:DataSet::AddGroup 拒收「无信号且无时间轴」的空分组,
  旧实现「先建空组再搬信号」必然在建组那一步失败。新增
  DataSet::MoveSignalToNewGroup 把建组 + 搬运时间轴 + 搬运信号做成原子操作(失败回滚)。
* TabConfig::RemapAfterSignalMove 在目标分组是新建时自行扩展 m_visible
  (调用方不再先 SyncToDataSet —— 那次同步会按旧下标清理引用)。
* Application::MoveSignalCore 增加 newGroupName 参数并返回 bool,避免失败仍报"已移动"。

四、信号拖拽避让动画更流畅
* 插值系数按真实帧间隔计算:1 - exp(-dt/22ms),dt 夹在 1~60ms,帧间隔抖动时不再忽快忽慢。
* 鼠标移动事件里也推进一次插值,快速拖动时让位效果跟得上指针。

验证:删 .o 全量重编译零 error / 零 warning,产物 875,008 B(< 1MB 预警阈值)。
本轮未占用鼠标/键盘/屏幕,未运行任何 GUI 探针;面板空白改为纯数值算式核对。
规则已同步到 Documents/agent_mem.md(含"改面板几何必须数值验证"的教训条目)。
2026-11-03 20:09:01 +08:00

1654 lines
68 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef __SIGNAL_PANEL_HPP__
#define __SIGNAL_PANEL_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <array>
#include <cstdint>
#include <string>
#include <vector>
#include "ColorPickerPopup.hpp"
#include "DataModel.hpp"
#include "PaintUtil.hpp"
#include "PopupMenu.hpp"
#include "Theme.hpp"
#include "UiComponent.hpp"
namespace jdi
{
class UiBoxTable;
/**
* @brief 信号面板弹出菜单的命令标识。
*/
enum MenuCommand : int
{
CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */
CMD_SIGNAL_CLONE = 5, /**< 克隆该信号(原始数据,只沿用源信号的时间轴偏移)。 */
CMD_SIGNAL_CLONE_ADJUSTED = 6, /**< 克隆该信号(带调整):沿用源信号的完整「调整」参数,不烘值。 */
CMD_GROUP_SHOW_ALL = 10, /**< 选中该分组全部信号。 */
CMD_GROUP_HIDE_ALL = 11, /**< 取消该分组全部信号。 */
CMD_GROUP_REMOVE = 12, /**< 从工作区移除该分组。 */
CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */
CMD_LANE_NEW = 400, /**< 新建网格。 */
/**
* @brief 「移动到…」二级菜单里的分组条目基准(配合分组下标)。
*
* 与 `CMD_LANE_BASE` 同一套约定:`id - CMD_GROUP_MOVE_BASE` 即目标分组下标。
* 取值区间必须与 `CMD_LANE_BASE` 的区间(300–399)和 `CMD_LANE_NEW` 互不重叠。
*/
CMD_GROUP_MOVE_BASE = 600,
/**
* @brief 「移动到…」二级菜单底部:新建分组并把信号移进去。
*
* ⚠️ 与 `CMD_GROUP_MOVE_BASE` 之间留足空间(600–949 = 最多 350 个分组条目),
* 否则分组数量一多,`CMD_GROUP_MOVE_BASE + index` 就会撞上本命令。
*/
CMD_GROUP_MOVE_NEW = 950,
CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */
CMD_GAIN_RESET = 501, /**< 「系数调整」标题栏右侧的还原按钮:把系数重置为 1。 */
CMD_OFFSET_RESET = 502, /**< 「偏移调整」标题栏右侧的还原按钮:两个偏移都恢复为默认值。 */
CMD_OFFSET_Y = 503, /**< 「偏移调整」:Y 轴偏移输入框。 */
CMD_OFFSET_TIME = 504, /**< 「偏移调整」:时间轴偏移输入框。 */
CMD_MIRROR_HORIZONTAL = 510, /**< 「镜像」二级面板:水平镜像(数据反序)。 */
CMD_MIRROR_VERTICAL = 511, /**< 「镜像」二级面板:垂直镜像(按 Y=0 上下翻转)。 */
CMD_FORMULA_EDIT = 520, /**< 「公式映射」输入框(`Y = …`)。 */
CMD_FORMULA_RESET = 521 /**< 「公式映射」标题栏右侧的还原按钮:恢复为 `f(t)`。 */
};
/**
* @brief 左侧信号面板:按分组的信号树、可见性勾选与样式编辑。
*
* 面板只读取数据层(DataSet),全部显示配置读写均落在当前标签页(TabConfig)上,
* 因此不同标签页的信号勾选与样式互不影响。所有弹出菜单与拾色器均使用主题化自绘
* 浮动面板,不出现系统默认菜单样式。
*/
class SignalPanel : public UiComponent
{
public:
SignalPanel();
/**
* @brief 绑定宿主窗口、字体、主题与共用浮动控件。
* @param [IN] host 宿主窗口句柄。
* @param [IN] fonts 字体集合,需在面板生命周期内保持有效。
* @param [IN] theme 主题配色,需在面板生命周期内保持有效。
* @param [IN] menu 共用的主题化弹出菜单,需在面板生命周期内保持有效。
* @param [IN] colorPicker 共用的浮动拾色器,需在面板生命周期内保持有效。
*/
void Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu,
ColorPickerPopup* colorPicker);
/**
* @brief 绑定数据层与当前标签页配置。
* @param [IN] dataSet 数据集合(可写,用于重命名信号与分组)。
* @param [IN] tab 当前标签页配置,允许为空。
*/
void SetContext(DataSet* dataSet, TabConfig* tab);
/**
* @brief 设置宿主客户区矩形(用于约束浮动面板位置)。
* @param [IN] bounds 客户区矩形。
*/
void SetClientBounds(const gd::RectF& bounds) { m_clientBounds = bounds; }
/**
* @brief 标记列表结构失效(数据或折叠状态变化后调用)。
*/
void InvalidateRows()
{ m_rowsDirty = true; // 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废,
// 否则可能对一条已经不存在(或已被替换)的信号继续高亮它的网格。
m_hoveredSignal = SignalRef();
m_hoverRow = -1;
m_hoverPart = RowPart::NONE;
// 分组下标可能整体位移 ⇒ 折起动画(含与滚动的同步)也不能继续沿用。
m_groupAnims.clear();
m_rowShift.clear();
// ⚠️ **行下标也要一并作废**:`m_editRow` / `m_pendingCloseRow` / `m_groupNameClickRow`
// 长期保存着行下标,而行集合在编辑期间可能变化(加载文件 → `SyncTabsToDataSet()` →
// 本函数)。不清理的话,随后回车会把新名字写到**另一条**信号上(插入型变更会让
// 下标整体位移),✕ 也可能删错分组。
if (m_editField != StyleEditField::NONE)
{
m_edit.End();
m_editField = StyleEditField::NONE;
}
m_editRow = -1;
m_pendingCloseRow = -1;
m_pendingClosePart = RowPart::NONE;
m_pendingFilterClear = false;
m_groupNameClickRow = -1;
// 拖拽排序同样保存着行下标与逐行位移,行集合一变就必须整体作废。
m_signalDragPending = false;
m_signalDragging = false;
m_signalDragRow = -1;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
}
/**
* @brief 设置 DPI 缩放比例。
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
*/
void SetScale(float scale) override;
/**
* @brief 按给定区域重新排布面板内容。
* @param [IN] area 面板可用区域。
*/
void Layout(const gd::RectF& area) override;
/** @brief 面板占用的整块区域(与 `Layout` 同源)。 */
gd::RectF Area() const override { return m_area; }
/**
* @brief 绘制面板。
* @param [IN,OUT] g 绘图上下文。
*/
void Paint(gd::Graphics& g) override;
/**
* @brief 主题配色变化后作废全部**像素级**缓存。
*
* 行位图缓存里烘着主题色(底色 / 文字 / 描边),换主题后必须整体丢弃,
* 否则列表会停留在旧配色上(直到那些行因为别的原因被重画)。
*/
void OnThemeChanged()
{
InvalidateRowCache();
m_rowsDirty = true;
}
/**
* @brief 处理鼠标移动。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 悬停状态发生变化返回 true。
*/
bool OnMouseMove(int x, int y);
/**
* @brief 处理鼠标左键按下。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnLButtonDown(int x, int y);
/**
* @brief 处理鼠标左键抬起(网格徽标与自定义颜色入口在此刻触发展开)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 视觉状态发生变化返回 true。
*/
bool OnLButtonUp(int x, int y);
/**
* @brief 处理鼠标左键双击。
*
* 语义按落点区分:**线宽 / 透明度数值**进入输入状态;**分组名称 / 信号名称**进入重命名;
* 复选框、网格徽标、两处 ✕ 没有双击语义(由宿主按普通按下处理)。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnDoubleClick(int x, int y);
/**
* @brief 双击落点是否应**当作普通「按下」**处理(该位置没有双击语义)。
*
* ⚠️ 窗口类带 `CS_DBLCLKS`:快速连点同一个控件时,第二次(及之后)按下会被 Windows
* 合成为 `WM_LBUTTONDBLCLK`。若宿主把它交给 `OnDoubleClick`,而该位置本来只有
* 「按下 / 单击」语义(复选框、全部显示隐藏、绝对值、折叠三角、「组X」、✕、GX 徽标…),
* 那一次按下就**完全丢失** —— 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。
*
* ✓ 本函数为这些位置返回 true,宿主改走 `OnLButtonDown`(与单击完全相同的路径)。
* 只有真正有双击语义的位置(线宽/不透明度数值框、样式区「目标」行名称与 GX 标签、
* 分组 / 信号行的**名称区域**改名)返回 false。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 应当作普通按下返回 true。
*/
bool DoubleClickIsPlainPress(int x, int y);
/**
* @brief 处理鼠标右键按下(弹出上下文菜单)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。
*/
bool OnRButtonDown(int x, int y);
/**
* @brief 处理滚轮滚动(列表纵向滚动)。
* @param [IN] delta 滚轮增量。
* @return 需要重绘返回 true。
*/
bool OnMouseWheel(int delta);
/**
* @brief 处理鼠标离开面板。
* @return 视觉状态发生变化返回 true。
*/
bool OnMouseLeave() override;
/**
* @brief 处理弹出菜单的选择结果。
* @param [IN] result 菜单选择结果。
* @return 需要重新布局返回 true。
*/
bool OnMenuCommand(const MenuResult& result);
/**
* @brief 判断面板是否处于内联文本输入状态。
* @return 正在输入返回 true。
*/
bool IsEditing() const override { return m_editField != StyleEditField::NONE || m_filterEditing; }
/**
* @brief 判断坐标是否落在列表上方的「筛选信号」输入框内。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 落在输入框内返回 true。
*/
bool IsOnFilterBox(int x, int y) const;
/**
* @brief 进入筛选输入框的编辑状态(单击输入框时调用)。
*/
void BeginFilterEdit();
/**
* @brief 结束筛选输入框的编辑状态(点击别处或回车时调用)。
* @return 状态发生变化返回 true。
*/
bool EndFilterEdit();
/**
* @brief 取出并清除「请把绘图区滚动到该网格」的联动请求。
*
* 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时,
* 若该信号所在网格不在绘图区可视范围内,则自动调节绘图区的滚动条让该网格完整出现。
* 悬停时**不做**这个滚动(只保留既有的网格高亮联动)。
*
* @param [OUT] laneIndex 需要滚动到可视区内的网格下标。
* @return 存在待处理的请求返回 true。
*/
bool TakePlotLaneScrollRequest(int& laneIndex)
{
if (!m_plotLaneRequestFlag)
{
return false;
}
m_plotLaneRequestFlag = false;
laneIndex = m_pendingPlotLane;
m_pendingPlotLane = -1;
return laneIndex >= 0;
}
/**
* @brief 取出并清除「需要以非模态浮层提示给用户」的消息。
*
* 「公式映射」解析失败时用它把警告交给宿主:宿主调用
* `PlotView::ShowPersistentHint()` 画在绘图区底部(与"加载文件失败"同一套自绘提示,
* **不弹 Windows 消息框**)。
*
* @param [OUT] text 提示文本(取出后清空)。
* @return 存在待提示的消息返回 true。
*/
bool TakePendingHint(std::wstring& text)
{
if (m_pendingHint.empty())
{
return false;
}
text = m_pendingHint;
m_pendingHint.clear();
return true;
}
/**
* @brief 向当前输入框送入一个字符。
* @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。
* @return 界面状态发生变化返回 true。
*/
bool OnEditChar(wchar_t character);
/**
* @brief 向当前输入框送入一个按键。
* @param [IN] key 虚拟键码。
* @return 界面状态发生变化返回 true。
*/
bool OnEditKey(WPARAM key);
/**
* @brief 编辑态下按下鼠标右键:取消选区并把插入符移到文本末尾。
* @return 发生改变返回 true;不在编辑态返回 false。
*/
bool CollapseEditSelectionToEnd();
/**
* @brief 编辑态下在输入框内按下鼠标左键:定位插入符并开始拖拽框选。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 命中输入框返回 true。
*/
bool OnEditMouseDown(int x, int y);
/**
* @brief 编辑态下拖拽鼠标:扩展选区。
* @param [IN] x 客户区横坐标。
* @return 选区发生变化返回 true。
*/
bool OnEditMouseMove(int x);
/**
* @brief 编辑态下松开鼠标左键:结束拖拽框选。
* @return 之前处于拖拽中返回 true。
*/
bool OnEditMouseUp(int x, int y);
/**
* @brief 获取指定信号的显示外观(只读,全局共享)。
* @param [IN] ref 信号引用。
* @return 外观指针,引用无效时返回 nullptr。
*/
const SignalStyle* StyleOf(const SignalRef& ref) const;
/**
* @brief 获取指定信号的显示外观(可写,全局共享)。
* @param [IN] ref 信号引用。
* @return 外观指针,引用无效时返回 nullptr。
*/
SignalStyle* StyleOf(const SignalRef& ref);
/**
* @brief 当前是否存在**样式编辑目标**(选中信号且其样式可解析)。
*
* ⚠️ 样式区的色块 / 样式 / 调整 / 线宽 / 透明度这些控件的矩形是**无条件**由
* `LayoutStyleControls()` 算出来的(布局与"是否有目标"无关)。若不在使用处按本判据
* 门控,就会出现用户 2026-11-02 报告的现象:什么都没选中时,打开「显示布局边界」
* 能看到那一排控件的框,鼠标移到「系数/偏移 | 镜像 | 绝对值」上还会变成手型。
*
* 使用处:`BuildBoxes`(几何上报)、`QueryCursor`(手型光标)。
* 绘制与命中早已按 `StyleOf(SelectedRef()) != nullptr` 门控,这里只是补齐另外两处。
*/
bool HasStyleTarget() const;
/**
* @brief 获取当前选中信号(跨标签页共享,因此切换视图后选中项保持一致)。
* @return 信号引用。
*/
const SignalRef& SelectedRef() const;
/**
* @brief 设置当前选中信号(跨标签页共享)。
* @param [IN] ref 信号引用。
*/
void SelectSignal(const SignalRef& ref);
/**
* @brief 将浮动拾色器的取色结果应用到当前选中信号。
* @param [IN] color 0xAARRGGBB 颜色值。
* @return 应用成功返回 true。
*/
bool ApplyColor(std::uint32_t color);
/**
* @brief 关闭自定义颜色面板与线宽输入状态。
* @return 界面状态发生变化返回 true。
*/
bool CloseTransientUi();
/**
* @brief 取出并清除“需要重新布局”标记。
* @return 需要重新布局返回 true。
*/
bool TakeLayoutDirty();
/**
* @brief 判断坐标是否落在面板区域内。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 落在区域内返回 true。
*/
bool HitTest(int x, int y) const override;
/**
* @brief 查询该位置应使用的鼠标光标(✕ 类按钮使用手型)。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 需要特殊光标时返回光标句柄,否则返回 nullptr。
* @note 需要按需重建列表行缓存(查 ✕ 的命中),因此不是 const。
*/
HCURSOR QueryCursor(int x, int y) override;
/**
* @brief 上报面板区域与其内部结构(表头 / 筛选框 / 列表 / 样式区 / 可见行 / 行内元素)
* 的几何 box。
*
* 行内元素(复选框、色块、名称、GX 徽标、✕、折起三角、组计数)的几何与
* `RowHitPart` / `PaintRow` **同源**:都基于同一个 `RowRect` 与同一批常量,
* 因此"画出来的框"就是"能点到的区域"。
*
* 面板的行位图缓存与本函数**无关**:box 只交给宿主在最后一步统一处理(目前是描边),
* 不进任何缓存 ⇒ 关掉「显示布局边界」后不会留下残影。
*
* @param [IN,OUT] boxes 收集器。
*/
void BuildBoxes(UiBoxTable& boxes) override;
/**
* @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。
* @param [IN] ref 目标信号引用。
*/
void ScrollToSignal(const SignalRef& ref);
/**
* @brief 平滑滚动到指定信号(带动画)。
*
* 用户 2026-10-15 要求:触发联动机制(点击图例 / 信号曲线,或在样式自定义区与「目标」行
* 做任何交互)时,要把信号列表**滚动**到目标信号完整可见:
* * 目标在当前视界上方 ⇒ 向上滚动,直到它**完整露出且不被悬浮组名遮挡**;
* * 目标在当前视界下方 ⇒ 向下滚动直到它完整露出;
* * 滚动速度随距离变化(越远越快、靠近时变慢),但总时长有上限(不要拖得太久)。
*
* @param [IN] ref 目标信号引用。
*/
void ScrollToSignalAnimated(const SignalRef& ref);
/**
* @brief 若目标信号所在分组处于**当前模式的折起状态**,自动把它展开。
*
* 折起的分组不会列出信号行 ⇒ 行集合里找不到目标信号,任何“滚动到该信号”都会落空
*(用户 2026-10-16 报告:点网格图例联动时,被折起的组不会自动展开、列表不动)。
* 展开是**立刻生效**的(不播放展开动画),因为随后的滚动目标依赖稳定的行位置。
*
* @param [IN] ref 目标信号。
* @return 发生了展开返回 true。
*/
bool ExpandGroupForSignal(const SignalRef& ref);
/**
* @brief 推进一步滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceScrollAnimation();
/** @brief 滚动动画是否正在进行。 */
bool ScrollAnimationActive() const { return m_scrollAnimActive; }
/**
* @brief 推进一步分组折起/展开动画(由宿主定时器驱动)。
* @return 仍有分组动画在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceGroupAnimations();
/** @brief 分组折起/展开动画是否正在进行。 */
bool GroupAnimationActive() const { return !m_groupAnims.empty(); }
/**
* @brief 推进一步「信号拖拽避让」动画(由宿主定时器驱动)。
* @return 仍有行在移动 / 正在拖拽(宿主应保持定时重绘)返回 true。
*/
bool AdvanceSignalDragAnimation();
/** @brief 信号拖拽(含避让动画)是否仍在进行。 */
bool SignalDragAnimationActive() const
{
return m_signalDragPending || m_signalDragging;
}
/**
* @brief 供绘图区联动高亮的「悬停信号」。
*
* 来源:① 列表里鼠标悬停的信号行;② 样式区「目标」行的信号名 / GX 标签。
* 鼠标移开即返回无效引用。
* @return 悬停的信号引用。
*/
const SignalRef& HoveredSignal() const { return m_hoveredSignal; }
/**
* @brief 「目标」(正在编辑样式的)信号所在的网格下标。
* @return 网格下标;未勾选绘制到任何网格时返回 -1。
*/
int TargetLaneIndex() const;
private:
/**
* @brief 开始跟踪一次可能变成拖拽的按下(信号行主体)。
* @param [IN] ref 目标信号。
* @param [IN] row 该信号所在的行下标。
* @param [IN] x 按下点横坐标。 @param [IN] y 按下点纵坐标。
*/
void BeginSignalDrag(const SignalRef& ref, int row, int x, int y);
/**
* @brief 拖拽中:更新被拖条目的跟随位置与避让目标。
* @param [IN] x 鼠标横坐标。 @param [IN] y 鼠标纵坐标。
* @return 需要重绘返回 true。
*/
bool UpdateSignalDrag(int x, int y);
/**
* @brief 重新计算拖拽的落点(目标分组 / 组内下标)与各行的避让位移目标。
*/
void UpdateSignalDragTargets();
/**
* @brief 取消拖拽(不发任何命令)。
*/
void CancelSignalDrag();
/**
* @brief 结束拖拽并**提交**重排(落点与源位置不同才提交)。
*/
void FinishSignalDrag();
/**
* @brief 取某行的避让位移(拖拽中);未拖拽时返回 0。
* @param [IN] row 行下标。
* @return 纵向位移(像素,负值 = 上移)。
*/
float SignalDragRowShiftFor(int row) const;
/**
* @brief 绘制被拖拽条目的「浮起」副本(在行循环之后绘制,因此始终在最上层)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintSignalDragGhost(gd::Graphics& g);
/**
* @brief 样式区与列表行中可进行内联文本编辑的字段。
*/
enum class StyleEditField : int
{
NONE = 0, /**< 未处于编辑状态。 */
WIDTH = 1, /**< 线宽数值。 */
OPACITY = 2, /**< 不透明度数值(百分比)。 */
SIGNAL_NAME = 3, /**< 信号显示名称(列表行内;`row < 0` 表示样式区「目标」行)。 */
GROUP_TITLE = 4 /**< 分组显示名称(列表行内)。 */
};
/**
* @brief 列表行的类型。
*/
enum class RowKind : int
{
GROUP = 0, /**< 分组标题行。 */
SIGNAL = 1 /**< 信号行。 */
};
/**
* @brief 行的可交互区域。
*/
enum class RowPart : int
{
NONE = 0, /**< 未命中。 */
DISCLOSURE, /**< 折叠三角。 */
CHECK, /**< 复选框。 */
BODY, /**< 行主体(分组行的名称区域 / 信号行的名称区域)。 */
GROUP_TITLE, /**< 分组行的「组X」标签(与折叠三角一样可点击折起)。 */
GROUP_COUNT, /**< 分组行右侧的「N 项」计数(同样只折叠展开,**绝不**进入改名)。 */
CLOSE, /**< 分组行的关闭按钮。 */
LANE_BADGE, /**< 信号行的网格徽标。 */
REMOVE_SIGNAL /**< 信号行**行尾**的「移除该信号」✕ 按钮(与分组 ✕ 同列同尺寸)。 */
};
/**
* @brief 样式编辑区中的可交互条目类型。
*/
enum class StyleHit : int
{
NONE = 0, /**< 未命中。 */
COLOR, /**< 预设颜色色块。 */
CUSTOM_CHIP, /**< 自定义颜色入口。 */
MODE, /**< 绘制样式按钮。 */
OPACITY_SLIDER, /**< 不透明度滑块。 */
OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */
WIDTH_SLIDER, /**< 线宽滑块。 */
WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */
/**
* @brief 线宽行右侧的**复位按钮**(把线宽恢复为该信号的默认值)。
*
* 默认值:普通信号 = `kDefaultSignalLineWidth`(1.0 逻辑像素),
* 单数据点信号 = `kSinglePointSignalLineWidth`(最大线宽:散点半径随线宽增长)。
* 图标与「系数/偏移」二级菜单标题栏的还原图标**完全同款**(`UiPaintResetIcon`)。
*/
WIDTH_RESET,
/**
* @brief 透明度行右侧的**复位按钮**(把不透明度恢复为 100%)。
*
* 与 `WIDTH_RESET` 同一套图标与悬停观感,两者右边界与内容列右缘对齐。
*/
OPACITY_RESET,
GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */
MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */
ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */
TARGET_NAME, /**< 样式区「目标」行的信号名。 */
TARGET_BADGE, /**< 样式区「目标」行的 GX 网格标签。 */
TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */
FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */
};
/**
* @brief 列表中的一行(分组或信号)。
*/
struct ListRow
{
RowKind kind = RowKind::SIGNAL; /**< 行类型。 */
int groupIndex = -1; /**< 所属分组下标。 */
int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */
float height = 0.0f; /**< 行高(像素)。 */
/**
* @brief 分组行显示的项数(仅分组行有效)。
*
* 普通模式 = 该分组的信号总数;筛选模式 = 该分组内**符合筛选条件**的信号个数
*(用户 2026-10-16 要求:筛选时组名后方的项数按筛选结果计数)。
*/
int displayCount = 0;
/**
* @brief 该行所属分组内「参与列出」的序号(仅信号行有效)。
*
* 用于折起动画按「离组名越远越快」计算每行的收缩比例。
*/
int ordinal = 0;
};
/**
* @brief 一个分组的折起/展开动画状态。
*
* 用户 2026-10-16 第二轮要求(**推翻**第一轮的按行缩放):“整个展开和收起的过程类似于
* 抽拉卷尺,卷尺上的元素相对位置是不会变的” —— 即**整组信号作为一个刚性整体平移**:
* 收起时所有信号一起向上平移、逐条消失在组名栏(越过组名下边界的部分被裁掉),
* 展开时逐条从组名栏下方冒出来。因为相对位置不变,所以**行高不发生变化**
* (无需每帧重建行集合),仅一个 `m_rowShift` 平移量在变 —— 这是动画流畅的关键。
*
* 另外:**悬浮(sticky)组名折起时不再“先滚动到位再折起”**,而是把滚动偏移与
* 平移**同步推进**(`scrollSynced`),保证悬浮组名始终留在列表最上方,
* 动画结束时滚动偏移恰好落在“组名正好处于最上方”的位置。
*/
struct GroupAnim
{
int groupIndex = -1; /**< 目标分组下标。 */
bool collapsing = false; /**< true = 正在折起,false = 正在展开。 */
int rowCount = 0; /**< 该分组参与动画的信号行数。 */
float blockHeight = 0.0f; /**< 该分组全部信号行的总高(展开态)。 */
/**
* @brief 动画起始时的平移量(0 = 全部露出,`blockHeight` = 全部藏进组名栏)。
*
* 一般是“干净的一端”(折起从 0、展开从 `blockHeight`);但若动画中途被反向
* 点击(折起没播完就点展开),就从**当前位移**续接,因此不会出现跳变。
*/
float fromShift = 0.0f;
bool scrollSynced = false; /**< 是否让滚动偏移随动画同步推进。 */
float scrollFrom = 0.0f; /**< 动画起始滚动偏移。 */
float scrollTo = 0.0f; /**< 动画结束滚动偏移(仅 `scrollSynced` 时生效)。 */
std::uint64_t startMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */
std::uint32_t durationMs = 0; /**< 动画总时长(毫秒)。 */
};
/**
* @brief 计算「把某信号完整滚到可见」所需的滚动偏移。
*
* 顶部避让:当本组组名正在列表顶部悬浮时,列表顶部一段被组名占据,
* 上行方向必须让目标行落在组名的下方才算“完整可见且不被遮挡”。
*
* @param [IN] ref 目标信号。
* @param [OUT] desired 目标滚动偏移(未经 clamp)。
* @return 找到了该行返回 true。
*/
bool SignalScrollTarget(const SignalRef& ref, float& desired) const;
/**
* @brief 列表内容中某一行顶部相对内容原点的**自然**纵向偏移。
*
* 不含滚动偏移,也不含折起动画的刚性平移(动画只平移“动画分组之后”的行,
* 而分组行之前的行不受影响)⇒ 该值就是滚动到“本行正好处于列表最上方”所需的偏移。
* @param [IN] row 行下标。
* @return 偏移量(像素)。
*/
float ContentOffsetBeforeRow(int row) const;
/**
* @brief 启动一段滚动动画。
* @param [IN] desired 目标滚动偏移(内部会夹取到合法范围)。
*/
void StartScrollAnimationTo(float desired);
/**
* @brief 判断某分组在**当前模式**(筛选 / 普通)下是否处于折起状态。
* @param [IN] groupIndex 分组下标。
* @return 折起返回 true。
*/
bool IsGroupCollapsed(int groupIndex) const;
/**
* @brief 设置某分组在**当前模式**下的折起状态。
*
* ⚠️ 筛选模式与普通模式各有一套折起状态(`m_filterCollapsed` / `TabConfig`):
* 筛选只是临时的一个子集视图,不应污染用户在普通列表里的布局选择。
* @param [IN] groupIndex 分组下标。
* @param [IN] collapsed 是否折起。
*/
void SetGroupCollapsed(int groupIndex, bool collapsed);
/**
* @brief 某分组当前会列出的信号数量(筛选模式下为符合筛选条件的个数)。
* @param [IN] groupIndex 分组下标。
* @return 数量。
*/
int ShownSignalCount(int groupIndex) const;
/**
* @brief 查找某分组的折起动画条目下标。
* @param [IN] groupIndex 分组下标。
* @return 条目下标;不存在返回 -1。
*/
int FindGroupAnimation(int groupIndex) const;
/**
* @brief 取动画的**缓动后进度**(0 → 1,ease-out cubic:起步快、末尾减速)。
* @param [IN] anim 动画条目。
* @return 缓动后的进度。
*/
float AnimEasedProgress(const GroupAnim& anim) const;
/**
* @brief 某个分组行在列表中的下标。
* @param [IN] groupIndex 分组下标。
* @return 行下标;不存在返回 -1。
*/
int GroupRowIndex(int groupIndex) const;
/**
* @brief 全部行的自然总高度(不含折起动画的折减)。
* @return 高度(像素)。
*/
float NaturalContentHeight() const;
/**
* @brief 某分组当前模式下会列出的信号行总高(展开态)。
* @param [IN] groupIndex 分组下标。
* @return 高度(像素)。
*/
float ShownBlockHeight(int groupIndex) const;
/**
* @brief 重新计算动画造成的“逐行刚性位移”与内容高度。
*
* 每帧(或行集合重建后)调用一次:把每个进行中的动画的位移量累加到它之后的每一行上,
* 并据此得出 `m_contentHeight`;与滚动同步的动画同时推进 `m_scrollOffset`。
* 行高本身不变,因此**不需要重建行集合**。
*/
void UpdateAnimGeometry();
/**
* @brief 开始一个分组的折起/展开动画。
* @param [IN] groupIndex 分组下标。
* @param [IN] collapse true = 折起,false = 展开。
* @param [IN] scrollFrom 动画起始滚动偏移。
* @param [IN] scrollTo 动画结束滚动偏移(仅 `syncScroll` 为 true 时生效)。
* @param [IN] syncScroll 是否让滚动偏移随动画同步推进(悬浮组名折起时用)。
* @return 已开始动画返回 true(行数为 0 时无需动画,返回 false)。
*/
bool StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo, bool syncScroll);
/**
* @brief 点击分组行:折起/展开该分组(悬浮时与滚动同步推进)。
*
* 用户 2026-10-16 第二轮要求:悬浮组名折起时**不再**“先滚动到位再折起”,
* 而是**一边向上滚动一边收起**,保证悬浮组名始终在最上方;动画完成时滚动偏移
* 恰好落在“组名正好在最上方”的位置,且信号已经收起。
* @param [IN] groupIndex 分组下标。
* @param [IN] row 分组行下标。
*/
void ToggleGroupFromRow(int groupIndex, int row);
/**
* @brief 计算某行的可交互区域(使用 `RowRect` 的当前几何,含悬浮位置覆盖)。
* @param [IN] row 行下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 行内区域类型。
*/
RowPart RowHitPart(int row, float x, float y) const;
/**
* @brief 折起/展开动画中,某信号行“被藏进组名栏”的上边界(组名的**绘制**下边界)。
*
* 仅对本行所属分组正在播放动画的信号行有效;其余情况返回一个很小的值(等价于不裁剪)。
* @param [IN] row 行下标。
* @param [IN] stickyRow 当前悬浮的分组行下标(`StickyGroupRowIndex()` 的结果)。
* @return 裁剪上边界(屏幕 Y 坐标)。
*/
float AnimationHideTopForRow(int row, int stickyRow) const;
/**
* @brief 重新计算「目标」行内信号名与 GX 标签的矩形。
*
* 名称宽度依赖文本实际宽度、GX 标签是否出现依赖该信号是否已绘制到网格 —— 两者都可能在
* 不重新布局的情况下变化(切换选中、勾选/取消勾选),因此用一个小缓存键避免重算。
*/
void UpdateTargetRowGeometry();
/**
* @brief 绘制「目标」行(样式区最上方:名称 + GX 标签,可悬停 / 双击改名)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintTargetRow(gd::Graphics& g);
/** @brief 取消正在进行的滚动动画(用户主动滚动时调用)。 */
void CancelScrollAnimation()
{
m_scrollAnimActive = false;
// 与滚动同步的折起动画也不能再接管滚动偏移:用户已经自己滚动了,
// 继续同步会“抢”滚动条。折起/展开本身继续播放(只是不驱动滚动)。
for (GroupAnim& anim : m_groupAnims)
{
anim.scrollSynced = false;
}
}
/**
* @brief 重新构建列表行集合。
*/
void BuildRows();
/**
* @brief 行集合的“定义状态”签名(分组集合 / 每组可见项数 / 折起状态 / 是否有动画)。
*
* `EnsureRows` 用它做**自愈校验**:只要签名与“上一次建行集合时的签名”不一致,
* 就重建行集合。这样可以彻底堵住“改了折起状态却忘了置 `m_rowsDirty`”这类缺陷
*(用户 2026-10-30 报告:组名三角已显示展开,但下属信号行完全不出现)。
*/
std::uint64_t RowsSignature() const;
/**
* @brief 在需要时重建列表行集合。
*/
void EnsureRows();
/**
* @brief 依据内容高度重算滚动条可见性与列表可用宽度。
* @param [IN] clampScroll 是否顺带把滚动偏移夹到合法范围。
* ⚠️ 分组折起/展开动画期间必须传 `false`:与滚动同步的折起动画正按自己的进度
* 推进 `m_scrollOffset`,此时夹取会打断它(折起后内容比列表还短时尤其明显)。
*/
void RefreshScrollBar(bool clampScroll = true);
/**
* @brief 限制滚动偏移量到合法范围。
*/
void ClampScroll();
/**
* @brief 查询坐标命中的行下标与行内区域。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [OUT] part 行内区域类型。
* @return 行下标,未命中返回 -1。
*/
int HitRow(int x, int y, RowPart& part);
/**
* @brief 计算行的纵向矩形。
* @param [IN] row 行下标。
* @return 行矩形。
*/
gd::RectF RowRect(int row) const;
/**
* @brief 计算分组行关闭按钮矩形。
* @param [IN] row 行下标。
* @return 关闭按钮矩形。
*/
gd::RectF GroupCloseRect(int row) const;
/**
* @brief 计算分组行右侧「N 项」计数文本的矩形(**绘制与命中的唯一几何来源**)。
*
* ⚠️ 计数区与分组名相邻,但它**只有折叠/展开语义**(用户 2026-10-17 要求):
* 快速点击计数不得进入改名。命中判定必须用本矩形把 `RowPart::GROUP_COUNT`
* 从名称区域(`RowPart::BODY`)中切出来,否则两者会混在一起。
* @param [IN] row 行下标。
* @return 计数文本矩形;该行不是分组行时返回空矩形。
*/
gd::RectF GroupStatsRect(int row) const;
/**
* @brief 计算信号行网格徽标矩形。
* @param [IN] row 行下标。
* @return 徽标矩形。
*/
gd::RectF SignalBadgeRect(int row) const;
/**
* @brief 该信号是否**绘制**了网格徽标(唯一判据)。
*
* ⚠️ 绘制、命中测试、名称可用右界三处**必须共用本函数**:旧实现里命中只看
* "信号是否可见",而绘制还要求"已分配到网格" ⇒「可见但未分配网格」的信号在空白
* 徽标位置仍可点出「所属网格」菜单(点到的是不存在的徽标)。
*
* @param [IN] ref 信号引用。
* @return 已分配网格且在当前标签页可见时返回 true。
*/
bool SignalBadgeVisible(const SignalRef& ref) const;
/**
* @brief 计算信号行右侧「移除该信号」✕ 按钮的矩形。
*
* 位于信号条目的**最右端**,尺寸、样式与右边距与分组行的关闭 ✕ 完全一致,
* 并与它们在同一行内垂直居中(因此视觉上垂直对齐);网格徽标会让位到它的左侧。
*
* @param [IN] row 行下标。
* @return 按钮矩形;非信号行时返回空矩形。
*/
gd::RectF SignalRemoveRect(int row) const;
/**
* @brief 把后续行内绘制裁剪到指定行矩形(与列表矩形相交)。
*
* 行内容必须裁到行矩形内:折起动画中信号行会滑到组名栏之下,
* 不裁剪的话行内容会溢出到相邻行(或组名栏)上。
* @param [IN] rowRect 行矩形。
*/
void SetRowClip(const gd::RectF& rowRect)
{
m_rowClipActive = true;
m_rowClipRect = rowRect;
m_rowClipRect.Intersect(m_listRect);
}
/**
* @brief 把当前行裁剪矩形的**上边界**抬到给定值(折起动画中信号行“藏进组名栏”用)。
* @param [IN] top 新的裁剪上边界(屏幕 Y 坐标)。
*/
void ClampRowClipTop(float top)
{
if (!m_rowClipActive || top <= m_rowClipRect.Y)
{
return;
}
const float bottom = m_rowClipRect.Y + m_rowClipRect.Height;
m_rowClipRect.Y = top;
m_rowClipRect.Height = std::max(0.0f, bottom - top);
}
/** @brief 结束行内裁剪(恢复为整个列表矩形)。 */
void ClearRowClip() { m_rowClipActive = false; }
/**
* @brief 绘制分组标题行。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] row 行下标。
*/
void PaintGroupRow(gd::Graphics& g, int row);
/**
* @brief 绘制信号行。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] row 行下标。
*/
void PaintSignalRow(gd::Graphics& g, int row);
/**
* @brief 把一行(分组行 / 信号行)**绘制进离屏位图缓存**,再整块贴到列表上。
*
* ⚠️ 一行的绘制要调用十来个 GDI+ 原语(圆角托底 / 复选框 / 色块 / 两段文字…),
* 实测 **0.35ms/行**;列表一屏能放 35~40 行 ⇒ 单帧 **12~15ms** 全花在重复绘制
* **内容完全没变**的行上(分组折起/展开动画只是在整行平移)。
* 因此按"行的可见内容"做键缓存位图:动画帧只需把命中的位图按新位置贴出来。
*
* ⚠️ 缓存期间行的绘制**不能**裁到列表矩形(`m_rowClipRender`):位图是整行内容,
* 裁剪由贴图时的 GDI+ 裁剪区负责 —— 否则部分滚出上边界的行会永久丢掉顶部像素。
*
* @param [IN,OUT] g 绘图上下文(调用方已把裁剪区设为列表矩形)。
* @param [IN] row 行下标。
*/
void PaintRowCached(gd::Graphics& g, int row);
/**
* @brief 计算一行的缓存键(涵盖影响该行像素的全部状态)。
* @param [IN] row 行下标。
* @return 键值。
*/
std::uint64_t RowCacheKey(int row) const;
/** @brief 作废全部行位图缓存(行集合 / 字体 / 主题变化时调用)。 */
void InvalidateRowCache();
/** @brief 一行的离屏位图缓存项。 */
struct RowBitmapCache
{
/**
* @brief 该行的像素(DIB 内存 DC,已铺面板底色)。
*
* ⚠️ 必须是 **DIB + 内存 DC**,不能用 `gd::Bitmap`:GDI 在 GDI+ 位图的临时 HDC 上
* 会**关闭 ClearType**(退化为灰度抗锯齿),列表文字因此变细、观感与正文不一致
*(实测:整列文字丢失亚像素渲染)。DIB 内存 DC 上 ClearType 与屏幕一致。
*/
HDC dc = nullptr;
HBITMAP dib = nullptr;
std::uint64_t key = 0; /**< 内容键(不一致即重建)。 */
int width = 0; /**< 位图宽度(= 列表宽度)。 */
int height = 0; /**< 位图高度(= 行高)。 */
/**
* @brief 本行文本的**墨迹矩形**(位图坐标;调试用)。
*
* ⚠️ 必须缓存:命中缓存的帧根本不会调用 `UiText`,绘制期的文本收集器自然收不到
* 任何东西 —— 不缓存就会出现"第一帧有文本框、之后全部消失"。
*/
std::vector<gd::RectF> textRects;
};
std::vector<RowBitmapCache> m_rowBitmapCache; /**< 与 `m_rows` 同序的行位图缓存。 */
/**
* @brief 行位图缓存的**数量上限**(超过即整体作废重建)。
*
* 一行位图 ≈ 列表宽 × 行高 × 4B(430×30×4 ≈ 51KB)。用户滚动浏览上千行时,
* 缓存会按"画过的行下标"逐渐长大,必须有上限(这里 192 行 ≈ 10MB)。
*/
static constexpr std::size_t kRowBitmapCacheLimit = 192;
std::size_t m_rowBitmapLiveCount = 0; /**< 当前持有的行位图数量。 */
/** @brief 行绘制正被渲染进离屏位图(此时禁止按列表矩形裁剪)。 */
bool m_rowClipRender = false;
/**
* @brief 行位图缓存渲染期间的**几何平移量**(把整行搬到"位图坐标系")。
*
* ⚠️ 不能用 `gd::Graphics::TranslateTransform`:行内**文本走 GDI `DrawTextW`**,
* 而 GDI 文本**不遵循 GDI+ 的世界变换**(`Graphics::GetHDC` 拿到的 DC 只有裁剪区),
* 于是文字会被画到绝对坐标处、落到小位图之外(实测量产现象:行里只剩复选框/色块,
* 文字全部消失)。因此在**几何层**平移:`RowRect` 是行内一切矩形的唯一来源,
* 它统一减去该原点即可(`SignalBadgeRect` / ✕ / 名称框等全部自动跟随)。
*/
bool m_rowRenderActive = false;
float m_rowRenderOriginX = 0.0f; /**< 位图左上角(屏幕 x)。 */
float m_rowRenderOriginY = 0.0f; /**< 位图左上角(屏幕 y)。 */
/**
* @brief 判断哪个分组行需要「悬浮」在列表顶部(返回行下标,无需悬浮时返回 -1)。
*
* 判据:分组行已被滚出列表上边界(`RowRect(row).Y < m_listRect.Y`),
* 但该组内**仍有行留在列表里**(本组最后一行的下边界尚未越过列表上边界)。
* 两者同时成立时组名必须钉在最上方;整组都滚过去后组名跟随一起消失。
*
* @param [IN] rowCount 行总数。
* @return 需要悬浮的分组行下标;无需悬浮返回 -1。
*/
int StickyGroupRowIndex(int rowCount) const;
/**
* @brief 取某分组行的**最后一行**下标(列表行按分组连续排列)。
* @param [IN] row 分组行下标。
* @param [IN] rowCount 行总数。
* @return 本组最后一行下标(即下一个分组行的上一行)。
*/
int GroupLastRowIndex(int row, int rowCount) const;
/**
* @brief 计算分组行悬浮时应绘制到的**顶部坐标**(含平滑滚出)。
*
* 用户要求(2026-10-15):组名不能突然消失,也不能盖住下一个组名。公式:
* `min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒
* * 本组还有行在列表里时钉在列表上边界;
* * 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界;
* * 悬浮组名的下边界永远不超过下一个分组行的自然位置 ⇒ 不会遮挡下一组。
*
* @param [IN] row 分组行下标。
* @param [IN] rowCount 行总数。
* @return 悬浮绘制的顶部 Y 坐标。
*/
float StickyGroupRowTop(int row, int rowCount) const;
/**
* @brief 把分组行「按悬浮位置」绘制(组名跟随滚动,直到整组滚过去)。
*
* ⚠️ 必须在常规行循环**之后**调用(它要盖在信号行之上),并且常规循环要**跳过**
* `row` 行,否则该行会被画两次。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] row 需要悬浮的分组行下标(< 0 表示什么都不做)。
*/
void PaintStickyGroupRow(gd::Graphics& g, int row);
/**
* @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形 / 当前行矩形**)。
*
* ⚠️ `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**:列表需要滚动时,
* 被滚出视口的行(顶部部分可见的两三行)会把组名/信号名画到上方筛选框上。
* 因此**列表行**内文本一律经本函数绘制(见 `UiTextClipped`)。
* ⚠️ **样式区(筛选框上方之外的区域,如「目标」行)不得使用本函数** ——
* 它会把文本裁到 `m_listRect`(列表矩形之外的内容全都画不出来)。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] text 文本内容。
* @param [IN] font 字体句柄。
* @param [IN] rect 文本布局矩形。
* @param [IN] argb 文本颜色。
* @param [IN] align 水平对齐方式。
*/
void PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect,
std::uint32_t argb, TextAlign align);
/**
* @brief 在列表区内填充行内矩形(**夹取到列表矩形**)。
*
* ⚠️ `UiFillRect` 对大面积的**不透明**矩形会改走 GDI `FillRect`(同样不受 GDI+ 裁剪约束),
* 高 DPI 下行的面积足以超过该阈值 ⇒ 行背景/选中底色必须先把矩形夹到列表矩形。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 目标矩形。
* @param [IN] argb 填充颜色。
*/
void FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb);
/**
* @brief 绘制样式编辑区。
*
* 行序(下标从 0 起):「目标」/ 颜色 / 样式 / 调整 / 线宽 / 透明度,共 6 行;
* 各行控件共用同一内容列宽度与左边界。
*
* @param [IN,OUT] g 绘图上下文。
*/
void PaintStyleSection(gd::Graphics& g);
/**
* @brief 绘制列表滚动条。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintScrollBar(gd::Graphics& g);
/**
* @brief 计算滚动条滑块矩形。
* @return 滑块矩形,无需滚动时返回空矩形。
*/
gd::RectF ScrollThumbRect() const;
/**
* @brief 面板的**客户区矩形**(宿主已告知客户区时用它,否则退化为自身区域)。
*
* 弹出菜单 / 拾色器需要"限制在窗口内"的边界;此前这段三元表达式在 6 处重复,
* 一旦判据变化就要全改,故收敛成一个函数。
*
* @return 客户区矩形(客户区坐标)。
*/
gd::RectF PanelBounds() const
{
return (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
}
/**
* @brief 布置样式编辑区内各控件的矩形。
*/
void LayoutStyleControls();
/**
* @brief 取得样式编辑区固定所需高度。
* @return 逻辑高度(未乘缩放比例)。
*/
float DesiredStyleHeight() const;
/**
* @brief 弹出信号右键菜单。
* @param [IN] ref 目标信号引用。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void OpenSignalMenu(const SignalRef& ref, int x, int y);
/**
* @brief 弹出分组右键菜单。
* @param [IN] groupIndex 目标分组下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void OpenGroupMenu(int groupIndex, int x, int y);
/**
* @brief 弹出网格归属菜单(由信号行的网格徽标触发)。
* @param [IN] ref 目标信号引用。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void OpenLaneMenu(const SignalRef& ref, int x, int y);
/**
* @brief 打开浮动拾色器(由自定义颜色入口触发)。
* @param [IN] style 当前信号样式。
* @return 打开成功返回 true。
*/
bool OpenColorPickerPopup(const SignalStyle& style);
/**
* @brief 打开「系数」的**二级面板**(一个整行输入框 + 标题栏右侧的还原按钮)。
*
* 与自定义颜色入口同一套交互:点击按钮 → 抬起时展开浮动面板 → 面板内的输入框可编辑
* 绘制前乘上的系数(默认 1,至少显示两位小数),回车 / 点击其它位置提交,面板保持打开
*(与网格 Y 轴上下限的菜单内联输入框一致)。“还原”图标按钮把系数重置为 1。
*
* @return 面板已打开返回 true。
*/
bool OpenGainMenu();
/**
* @brief 打开「镜像」的**二级面板**(两行带复选框的选项:水平镜像 / 垂直镜像)。
*
* 两项都是“切换项”,点击后勾选状态立即生效且**菜单保持打开**,方便连续勾选;
* 只要任一项被勾选,「镜像」按钮即显示为激活态。
*
* @return 面板已打开返回 true。
*/
bool OpenMirrorMenu();
/**
* @brief 清除样式区的悬停高亮。
*
* 二级面板(弹出菜单)一打开就会接管鼠标事件,样式区不再收到 `WM_MOUSEMOVE`,
* 因此必须在打开面板时主动清掉悬停态,否则该按钮会一直保持"鼠标按住"般的高亮。
*/
void ClearStyleHover();
/**
* @brief 判断当前标签页中全部信号是否均为可见状态。
* @return 全部可见且至少有一条信号返回 true。
*/
bool AreAllSignalsVisible() const;
/**
* @brief 统计全部信号数量。
* @return 信号总数。
*/
int TotalSignalCount() const;
/**
* @brief 进入指定字段的内联输入状态。
* @param [IN] field 目标字段。
* @param [IN] row 列表行下标(仅行内字段有效)。
* @return 状态发生变化返回 true。
*/
bool BeginEdit(StyleEditField field, int row = -1);
/**
* @brief 结束内联输入状态。
* @param [IN] commit 是否提交输入值。
* @return 状态或取值发生变化返回 true。
*/
bool EndEdit(bool commit);
/**
* @brief 计算当前输入框的矩形。
* @return 输入框矩形,未处于编辑状态时返回空矩形。
* @note 「目标」行编辑时需要按当前目标信号的名称 / GX 标签重算几何(名称宽度),
* 因此不是 const。
*/
gd::RectF EditFieldRect();
/**
* @brief 当前输入框绘制与命中/插入符换算所使用的字体。
* @return 字体句柄(分组名用 `uiSmall`,其余用 `ui`)。
* @note ⚠️ 绘制(`PaintGroupRow` / `PaintSignalRow`)与鼠标定位(`UiEditOffsetAt`)
* **必须使用同一个字体**,否则点哪儿插入符就落到哪儿这个不变量会被破坏。
*/
HFONT EditFieldFont() const;
/**
* @brief 内联名称编辑时的「吸收区」:输入框横向范围 × 整行高度。
* @param [IN] field 输入框矩形。
* @return 吸收区矩形(未在编辑行内时返回 `field`)。
* @note ⚠️ 行高大于输入框高度(上下各留 3 逻辑像素)。若不吸收这块区域,
* 鼠标落在内边距上会穿透成行点击 ⇒ 分组被展开/收起(用户 2026-10-19 报告)。
* 横向仍限定在名称区域内,同一行的复选框 / 「N 项」/ 徽标 / ✕ 继续可点。
*/
gd::RectF NameEditAbsorbRect(const gd::RectF& field) const;
/**
* @brief 计算「筛选信号」输入框的内容矩形(编辑与显示共用,保证不跳动)。
* @return 内容矩形。
*/
gd::RectF FilterFieldRect() const;
/**
* @brief 计算「筛选信号」输入框右侧清空按钮 ✕ 的矩形(无输入时为空矩形)。
* @return 清空按钮矩形。
*/
gd::RectF FilterClearRect() const;
/**
* @brief 清空筛选内容(点击 ✕ 时调用),并刷新列表。
*/
void ClearFilter();
/**
* @brief 计算指定行中名称区域的矩形(列表行内联编辑使用)。
* @param [IN] row 行下标。
* @return 名称区域矩形。
*/
gd::RectF RowNameRect(int row) const;
/**
* @brief 计算分组行中“组X”标签的像素宽度。
* @param [IN] row 行下标。
* @return 标签宽度。
*/
float GroupLabelWidth(int row) const;
/**
* @brief 分组行左侧「组X」标签的文本(绘制、命中、宽度共用同一来源)。
* @param [IN] groupIndex 分组下标。
* @return 形如 `组1` 的文本。
*/
static std::wstring GroupLabelText(int groupIndex);
/**
* @brief 分组行右侧项数文本(绘制、命中、宽度共用同一来源)。
* @param [IN] row 行下标。
* @return 形如 `3 项` 的文本;行无效时返回空串。
*/
std::wstring GroupStatsText(int row) const;
/**
* @brief 调整当前信号不透明度。
* @param [IN] x 客户区横坐标。
* @return 取值发生变化返回 true。
*/
bool ApplyOpacityFromX(float x);
/**
* @brief 把当前目标信号的**线宽恢复为默认值**(「线宽」行右侧复位按钮)。
*
* 默认值与新建信号时同一来源(`DefaultSignalLineWidth(finiteCount)`):
* 普通信号 1.0 逻辑像素、**单数据点信号**取最大线宽(散点半径随线宽增长)。
* @return 取值发生变化返回 true。
*/
bool ResetStyleLineWidth();
/**
* @brief 把当前目标信号的**不透明度恢复为 100%**(「透明度」行右侧复位按钮)。
* @return 取值发生变化返回 true。
*/
bool ResetStyleOpacity();
/**
* @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。
*
* ⚠️ **只有一个有效采样点**的信号固定使用「散点」:单个数据点在「线段」下根本画不出来,
* 而「点线」与「放大自动点线」机制语义重叠,因此这两个选项对单点信号置灰
*(用户 2026-10-16 要求)。
* @param [IN] optionIndex 选项下标(与 `kModeOptions` 同序)。
* @return 可用返回 true。
*/
bool ModeOptionEnabled(int optionIndex) const;
/**
* @brief 登记「点击信号 ⇒ 请宿主把绘图区滚动到该网格」的联动请求。
* @param [IN] ref 被点击的信号。
*/
void RequestPlotLaneScroll(const SignalRef& ref);
/**
* @brief 依据线宽滑块横坐标调整当前信号线宽。
* @param [IN] x 客户区横坐标。
* @return 取值发生变化返回 true。
*/
bool ApplyWidthFromX(float x);
HWND m_host = nullptr; /**< 宿主窗口句柄。 */
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = nullptr; /**< 主题资源。 */
PopupMenu* m_menu = nullptr; /**< 共用弹出菜单。 */
ColorPickerPopup* m_colorPicker = nullptr; /**< 共用浮动拾色器。 */
const DataSet* m_dataSet = nullptr; /**< 数据集合(只读访问)。 */
DataSet* m_dataSetWritable = nullptr; /**< 数据集合(重命名等写入操作)。 */
TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */
float m_scale = 1.0f; /**< DPI 缩放比例。 */
gd::RectF m_clientBounds; /**< 宿主客户区矩形。 */
gd::RectF m_area; /**< 面板整体区域。 */
gd::RectF m_headerRect; /**< 标题区域。 */
gd::RectF m_filterRect; /**< 列表上方的「筛选信号」输入框区域。 */
gd::RectF m_listRect; /**< 列表区域。 */
gd::RectF m_styleRect; /**< 样式编辑区域。 */
gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */
gd::RectF m_scrollBarRect; /**< 滚动条区域。 */
std::array<gd::RectF, 12> m_colorRects; /**< 预设颜色色块矩形。 */
int m_colorCount = 0; /**< 实际可用的预设颜色数量。 */
gd::RectF m_customChipRect; /**< 自定义颜色入口矩形(与预设色块同尺寸)。 */
std::array<gd::RectF, 3> m_modeRects; /**< 绘制样式按钮矩形。 */
// 样式区固定五行(不含顶部的「目标」行):颜色 / 样式 / 调整 / 线宽 / 透明度。
// 各行控件共用同一内容列宽度与左边界。
std::array<gd::RectF, 5> m_styleRowRects; /**< 五行整体矩形(含标签列)。 */
gd::RectF m_opacityTrackRect; /**< 不透明度滑块轨道矩形。 */
gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */
gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */
gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */
/**
* @brief 线宽行右侧的**复位按钮**矩形(正方形,右边界与内容列右缘对齐)。
*
* 点击把该信号的线宽恢复为默认值(单数据点信号与普通信号的默认线宽不同)。
* 宽高为 0 表示当前不显示(没有目标信号)。
*/
gd::RectF m_widthResetRect;
/** @brief 透明度行右侧的**复位按钮**矩形(与 `m_widthResetRect` 同一列、同一尺寸)。 */
gd::RectF m_opacityResetRect;
/** 「调整」行的三个等宽按钮(下标 = 内部常量 `kAdjust*`):0 = 系数/偏移,1 = 镜像,2 = 绝对值。 */
std::array<gd::RectF, 3> m_adjustRects;
std::vector<ListRow> m_rows; /**< 列表行集合(惰性缓存)。 */
/**
* @brief 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行的高度之和(长度 = `m_rows.size() + 1`)。
*
* ⚠️ **性能硬约束**:`RowRect(row)` 原来每次调用都从头累加行高(O(row)),
* 而画一行要调它 5 次(✕ / 徽标 / 名称 / 组计数 / 折起三角各一次)。
* 200 行列表只看 30 行时每帧就是上万次加法,命中测试(每次鼠标移动)同样走这条路。
* 改成前缀和之后一次查表即可(O(1)),统一由 `UpdateAnimGeometry()` 重算。
*/
std::vector<float> m_rowTop;
bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */
std::uint64_t m_rowsSignature = 0; /**< 建行集合时的 `RowsSignature()`(用于自愈校验)。 */
float m_contentHeight = 0.0f; /**< 列表内容总高度。 */
float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */
float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */
/**
* @brief 「组名悬浮」绘制用的行顶部坐标覆盖。
*
* 置位时 `RowRect` 忽略滚动偏移、直接返回 `m_rowYOverrideY` 作为行顶部,
* 从而使分组行(以及它派生的名称框 / ✕ / 徽标)整体画在钉住的位置。
* 只在使用期间短暂置位,绘制完立即复位(见 `PaintStickyGroupRow`)。
*/
bool m_rowYOverrideActive = false;
float m_rowYOverrideY = 0.0f;
bool m_draggingScrollBar = false; /**< 是否正在拖拽滚动条。 */
int m_scrollGrabOffset = 0; /**< 拖拽时鼠标相对滑块顶部的偏移。 */
bool m_draggingOpacity = false; /**< 是否正在拖拽不透明度滑块。 */
bool m_draggingWidth = false; /**< 是否正在拖拽线宽滑块。 */
int m_hoverRow = -1; /**< 当前悬停行。 */
RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */
StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */
int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */
/**
* @brief 供绘图区联动高亮的悬停信号(列表信号行 / 样式区「目标」行)。
*
* 用户 2026-10-15 要求:鼠标悬停在信号列表里的某条信号(或样式区「目标」行的
* 信号名 / GX 标签)上时,若该信号已勾选绘制到某个网格,则该网格变色高亮、
* 且该信号曲线本身也高亮;鼠标移开即恢复。
*
* ⚠️ 不能用 `m_hoverRow` 现算:鼠标移出列表后它被清掉,而悬停可能仍在「目标」行上;
* 且列表行会在数据变化时重建,行下标会失效。
*/
SignalRef m_hoveredSignal;
/** 「目标」行(样式区最上方)的几何。 */
gd::RectF m_targetRowRect;
gd::RectF m_targetNameRect;
gd::RectF m_targetBadgeRect;
/** 「目标」行几何的缓存键(目标信号 + 所在网格 + 名称),避免每次鼠标移动都重算。 */
SignalRef m_targetGeomRef;
int m_targetGeomLane = -2; /**< -2 = 尚未计算过。 */
std::wstring m_targetGeomTitle;
bool m_targetGeomValid = false;
/** 滚动动画状态(见 `ScrollToSignalAnimated` / `AdvanceScrollAnimation`)。 */
bool m_scrollAnimActive = false;
float m_scrollAnimFrom = 0.0f;
float m_scrollAnimTo = 0.0f;
std::uint64_t m_scrollAnimStartMs = 0;
std::uint32_t m_scrollAnimDurationMs = 0;
/** @brief 正在进行的分组折起/展开动画(可同时有多个分组)。 */
std::vector<GroupAnim> m_groupAnims;
/**
* @brief 动画造成的逐行刚性位移(与 `m_rows` 同长;内容坐标,绘制时从行顶部减去)。
*
* 只有“动画分组的组名行之后”的行会被平移,因此组名本身始终留在原处,
* 信号行从它下面滑过去并被裁掉 —— 这就是“抽拉卷尺”观感的来源。
* 行高不变 ⇒ 动画期间无需重建行集合(性能关键)。
*/
std::vector<float> m_rowShift;
// ------------------------------------------------------------------
// 信号条目拖拽排序(用户 2026-11-03 要求):
// * 左键按住信号行主体 → 超过阈值后进入拖拽;
// * 被拖条目**脱离原位置**跟随鼠标绘制(半透明浮起 + 阴影);
// * 其它信号行 / 分组行按插入位置做**避让位移动画**(`m_signalDragShiftCur` 逐帧插值);
// * 松手时提交到宿主(`kMessageReorderSignal`),由数据层重排并修正全部引用。
// ------------------------------------------------------------------
bool m_signalDragPending = false; /**< 已按下信号行主体、尚未越过拖拽阈值。 */
bool m_signalDragging = false; /**< 正在拖拽信号条目。 */
SignalRef m_signalDragRef; /**< 被拖拽的信号。 */
int m_signalDragRow = -1; /**< 被拖拽行在 `m_rows` 中的下标。 */
int m_signalDragStartX = 0; /**< 按下点(阈值判定用)。 */
int m_signalDragStartY = 0;
int m_signalDragMouseY = 0; /**< 最近一次鼠标纵坐标(被拖条目跟随它)。 */
int m_signalDragDropGroup = -1; /**< 当前目标分组下标(-1 = 无效)。 */
int m_signalDragDropIndex = -1; /**< 目标分组内的插入下标。 */
int m_signalDragDropRow = -1; /**< 插入位置对应的行下标(插入指示线画在这里)。 */
bool m_signalDragDropBefore = false; /**< 插入点在该行的上方(否则下方)。 */
std::vector<float> m_signalDragShift; /**< 各行避让位移的**目标值**(像素)。 */
std::vector<float> m_signalDragShiftCur; /**< 各行避让位移的**当前值**(逐帧插值)。 */
/**
* @brief 避让插值上一次推进的时刻(`GetTickCount64`,0 = 尚未推进过)。
*
* 插值系数按**真实帧间隔**算(`1 - exp(-dt/τ)`),因此不论是 60fps 定时器帧
* 还是鼠标快速拖动带来的高频帧,观感一致;固定系数在帧间隔抖动时会显得僵硬。
*/
std::uint64_t m_signalDragAnimLastMs = 0;
/**
* @brief 筛选模式下的分组折起状态(按分组下标)。
*
* 用户 2026-10-16 要求筛选结果也能以组为单位折起,但筛选只是一个临时视图,
* 因此使用与普通列表**分开**的一套状态,避免污染用户在普通列表里的布局选择。
* 表长不足 / 越界 = 展开(筛选结果默认展开,便于直接看到匹配项)。
*/
std::vector<bool> m_filterCollapsed;
/**
* @brief 当前正在绘制的行的裁剪矩形(用于折起动画中“藏进组名栏”的信号行)。
*
* `SetRowClip` 先把行内容裁到本行矩形(∩ 列表矩形),折起/展开动画中再用
* `ClampRowClipTop` 把上边界抬到组名栏的下边界 ⇒ 越过的部分被裁掉,
* 形成“滑进组名栏”的观感。
*/
bool m_rowClipActive = false;
gd::RectF m_rowClipRect;
StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */
int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */
TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */
bool m_filterEditing = false; /**< 「筛选信号」输入框是否处于编辑状态。 */
std::wstring m_filterText; /**< 当前筛选文本(空串表示不过滤)。 */
TextEdit m_filterEdit; /**< 筛选输入框的编辑模型。 */
/**
* @brief 设置筛选文本并立即生效(重建行集合、回到顶部)。
* @param [IN] text 新的筛选文本(空串表示不过滤)。
*/
void ApplyFilterText(const std::wstring& text);
/**
* @brief 判断某信号的显示名是否匹配当前筛选表达式。
* @param [IN] signal 信号数据。支持 `*` 通配符,忽略大小写。
* @return 匹配(或未设置筛选)返回 true。
*/
bool FilterMatchesSignal(const SignalData& signal) const;
/**
* @brief 取得「筛选状态下该分组中可见(通过筛选)的信号」掩码。
*
* 用户 2026-11-03 明确要求:筛选时组名前的复选框**只对应组内被筛选出来的信号**。
* 因此全选 / 部分选择 / 未选判定,以及点击复选框后的写入范围,
* 都以本函数返回的掩码为准(未通过筛选的信号不受影响)。
*
* 未处于筛选状态时返回**空**掩码(调用方应退回 `GroupAllVisible` 等全组语义)。
*
* @param [IN] groupIndex 分组下标。
* @param [OUT] mask 逐信号掩码(长度 = 该组信号数),true 表示通过筛选。
* @return 处于筛选状态返回 true。
*/
bool BuildFilterMask(int groupIndex, std::vector<bool>& mask) const;
bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */
SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */
bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */
bool m_pendingGainMenu = false; /**< 左键抬起时是否弹出「系数」的二级面板。 */
bool m_pendingMirrorMenu = false; /**< 左键抬起时是否弹出「镜像」的二级面板。 */
bool m_pendingTargetLaneMenu = false; /**< 左键抬起时是否弹出「目标」行 GX 标签的网格菜单。 */
/** 待宿主处理的「把绘图区滚动到该网格」请求(见 `TakePlotLaneScrollRequest`)。 */
bool m_plotLaneRequestFlag = false;
int m_pendingPlotLane = -1;
/** 待宿主以非模态浮层提示的文本(见 `TakePendingHint`,例如公式解析失败)。 */
std::wstring m_pendingHint;
/**
* @brief 待确认的「关闭类」按钮(✕)。
*
* 关闭类按钮统一改为**左键抬起**时生效:按下只记录目标,抬起时若指针仍在同一个
* ✕ 上才真正执行;按住后移开再松开视为放弃(与常见"可取消按钮"交互一致)。
*/
RowPart m_pendingClosePart = RowPart::NONE; /**< 待确认的 ✕ 所在行内区域(CLOSE / REMOVE_SIGNAL)。 */
/**
* @brief 最近一次「点击分组行名称区域」的时刻 / 行 / 点击前的折叠状态。
*
* 用户 2026-10-17 要求:分组**名称区域**单击也展开/收起(恢复),但**只有快速双击**
* 才进入改名编辑(降低灵敏度:较慢但明显不是双击的点击频率只当两次单击)。
* 因此需要记住上一次单击的信息,以便在双击时**撤销那一次折叠切换**再进入编辑。
*/
std::uint64_t m_groupNameClickMs = 0;
int m_groupNameClickRow = -1;
bool m_groupNameClickWasCollapsed = false;
int m_pendingCloseRow = -1; /**< 待确认的 ✕ 所在行下标。 */
bool m_pendingFilterClear = false; /**< 待确认的筛选框清空 ✕。 */
bool m_layoutDirty = false; /**< 是否需要重新布局。 */
SignalRef m_menuTargetSignal; /**< 菜单操作的目标信号。 */
int m_menuTargetGroup = -1; /**< 菜单操作的目标分组。 */
};
} // namespace jdi
#endif // __SIGNAL_PANEL_HPP__