## 一、UI 交互修复(用户报告) ① 分组折起热区(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签** 才折起。RowPart 新增 GROUP_TITLE;RowHitPart() 为分组行判出三角 / 复选框 / 关闭 ✕ / 「组X」标签矩形 / 其余 BODY;OnLButtonDown 只在这两个热区调用 ToggleGroupFromRow()。 附带:双击「组X」不再进入改名(该区域现为折起热区),双击分组名仍可改名。 ② 分组改名输入框不再吞掉「N 项」:RowNameRect() 对分组行的右边界改为「项数左边缘 − 6*scale」; 新增 GroupLabelText()/GroupStatsText() 作为文本与宽度的唯一来源(PaintGroupRow 与 RowNameRect 共用),避免名称可用宽度与实际绘制文本漂移。 ## 二、数据模型去冗余(用户指出的重点) - SignalData / SignalGroup **只保留一个名称字段** name:删除 displayName、删除 Title() (31 处调用点改为直接用 name);DataSet::SetSignalDisplayName / SetGroupDisplayName → RenameSignal / RenameGroup,且**空串 / 纯空白一律拒绝**(名称不再有回退值)。 依据:信号靠 SignalRef 下标定位、从不按名字匹配 ⇒ 两个字段是同一语义的两份副本。 收益:每条信号 / 每个分组少一个 std::wstring(24B + 堆分配)与一条 Title() 分支。 - 删除 Application::m_loadingBytes(全仓既不读也不写的死成员,注释却称“用于展示体积”)。 ## 三、新增 Components/Shared 组件(通用 UI 控件) - 从 Utilities/PaintUtil.* 迁出全部「控件类」绘制/命中:图标(+ / ✕ / 三角)、复选框、 滑块(UiSliderRatioFromX / UiDrawSlider)、内联输入框(UiDrawEditField / UiDrawEditFieldContent / UiEditTextLeft / UiEditOffsetAt / UiEditFieldMouseDown / UiEditFieldMouseMove)、颜色样块(UiDrawSwatch / UiSwatchNeedsOutline)、 取值胶囊配色(UiInk / UiColorLuminance / UiContrastInk)。 - PaintUtil.* 只保留低层绘制原语(矩形 / 圆角 / 直线 / 批量实线 / 颜色混合 / 文本与测量)。 - 依赖方向固定:Components/* → Components/Shared → Utilities(不得反向,Utilities 不再含控件)。 - Makefile 与 .preflight/perf/build.ps1 已登记新目录;调用方改 include "UiControls.hpp"。 ## 四、过时注释 / 命名批量修正(承接上一轮未做完的部分) - 注释:文件下标→分组下标、文件内信号下标→组内信号下标、所属文件下标→所属分组下标; 措辞统一 文件分组→分组、移除该文件→移除该分组、打开 CSV 文件→打开数据文件 等一批。 - 文不对题的形参名:VisibleIndexRange(const TimeAxis& file,…) → axis; Application::LoadFileAsync(…, SignalGroup& file,…) → group。 - 函数名里的 File 语义(上一轮只改了 RowKind::FILE):PaintFileRow→PaintGroupRow、 FileCloseRect→GroupCloseRect、OpenFileMenu→OpenGroupMenu、FileGroupLabelWidth→GroupLabelWidth、 StyleEditField::FILE_NAME→GROUP_TITLE;局部变量 fileRow/fileHeight/isFile → groupRow/groupHeight/isGroup。 - 修复 DataModel.hpp 两处被压扁的注释块(/**<TAB><TAB>* @brief …)与 Application::NewTab 上方重复的文档块。 - ⚠️ 踩坑:GROUP_NAME 撞上 nb30.h(windows.h 引入)的 #define GROUP_NAME 0x80, 导致枚举编译失败,改名 GROUP_TITLE 解决。 ## 五、二进制瘦身:1 013 248 → 754 176 B(−25.6%,省 259 KB) - 真因不是 PlotView.cpp,而是 Resources/JustDataInspector.ico 里 **256×256@32bpp 以未压缩 BMP 存储的条目(270 376 B,占整包 27%)**。改为 PNG 存储(Vista+ 标准做法,观感零变化) ⇒ 该条目 270 376 → 11 280 B、整个 ico 302 430 → 43 334 B(小尺寸仍保留 BMP)。 - 实测结论:“拆分大文件 ⇒ 瘦身”不成立(同一批机器码仍要进二进制); -ffunction-sections/-fdata-sections/-Wl,--gc-sections 在本工程反而 +9 216 B(已回退)。 - ⚠️ .o 体积受节对齐/重定位表主导,判断“代码搬去哪了”要用 nm,不要看 .o 字节数。 ## 六、验证(全部离屏:SW_HIDE + 不注入真实输入 + 进程/线程 BELOW_NORMAL) - panelanim 156/156(连跑 5 次稳定;新增/更新:点分组名不折起也不滚动、扫掠定位「组X」热区、 筛选态与普通态折叠三角逐像素一致、改名输入框右边界早于项数文本);verify 49/49; datacheck 69/69(含“重命名写入唯一名称字段且空名被拒”);edgecheck 46/46。 - A/B:回退两处 UI 修复 ⇒ 10 项如实失败;恢复后全绿。 - make release / make debug 零警告。 ## 七、本轮未完成(已在报告与 agent_mem.md 中如实记录) - PlotView.cpp(13 695 行 / 544 KB)按功能拆分为多个协作文件:未执行。 前置改造是把第 13–888 行的匿名命名空间(~870 行常量与工具函数)改成具名命名空间并逐个 inline,再逐个文件搬移并反复验证;时间不足以安全完成,故主动不做(附可执行方案)。 - SignalPanel.cpp / Application.cpp / DataModel.cpp 的多文件化、Lane 抽出 LanePanelState、 SetStatusMessage 模态阻塞解耦:均未执行。 ## 八、文档 - 新增 Documents/refactor_report_2026-10-16.md(本轮详细报告); 删除 Documents/refactor_report_2026-10-15.md(有效内容已并入 agent_mem.md 与本报告)。 - Documents/agent_mem.md 新增 §9.37 与 §11.2 第 85–90 条;更新数据层架构摘要(单一名称字段); §12 历史附录加“时效声明”。
4185 lines
139 KiB
C++
4185 lines
139 KiB
C++
#include "SignalPanel.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstdlib>
|
||
|
||
#include "UiControls.hpp"
|
||
#include "Utf8Util.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
namespace
|
||
{
|
||
const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */
|
||
const float kGroupRowHeight = 30.0f; /**< 分组行高。 */
|
||
const float kSignalRowHeight = 27.0f; /**< 信号行高。 */
|
||
const float kPadding = 12.0f; /**< 通用内边距。 */
|
||
const float kCheckSize = 15.0f; /**< 复选框尺寸。 */
|
||
const float kSwatchSize = 12.0f; /**< 信号行颜色小方块尺寸。 */
|
||
const float kBadgeWidth = 30.0f; /**< 网格徽标宽度。 */
|
||
const float kBadgeHeight = 17.0f; /**< 网格徽标高度。 */
|
||
const float kCorner = 4.0f; /**< 通用圆角半径。 */
|
||
const float kDisclosureWidth = 16.0f; /**< 折叠三角占位宽度。 */
|
||
const float kIndent = 14.0f; /**< 信号行相对分组行的缩进。 */
|
||
const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */
|
||
const float kCloseButtonSize = 18.0f; /**< 行尾关闭(✕)按钮尺寸(分组行与信号行共用)。 */
|
||
const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */
|
||
const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */
|
||
const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */
|
||
const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */
|
||
const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */
|
||
const float kRowTailGap = 6.0f; /**< 网格徽标与行尾 ✕ 之间的间距。 */
|
||
|
||
/**
|
||
* @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。
|
||
* @param [IN] text 待匹配文本。
|
||
* @param [IN] pattern 模式(可含多个 `*`)。
|
||
* @return 匹配返回 true。
|
||
*/
|
||
bool WildcardMatchNoCase(const std::wstring& text, const std::wstring& pattern)
|
||
{
|
||
const auto fold = [](wchar_t character) -> wchar_t
|
||
{
|
||
// 仅对基本拉丁字母做大小写折叠(中文等其它字符原样比较)。
|
||
if (character >= L'A' && character <= L'Z')
|
||
{
|
||
return static_cast<wchar_t>(character - L'A' + L'a');
|
||
}
|
||
return character;
|
||
};
|
||
|
||
// 经典双指针回溯:`star` 记录最近一个 `*` 的位置,`resume` 记录当时尝试的文本位置。
|
||
std::size_t t = 0;
|
||
std::size_t p = 0;
|
||
std::size_t star = std::wstring::npos;
|
||
std::size_t resume = 0;
|
||
|
||
while (t < text.size())
|
||
{
|
||
if (p < pattern.size() && pattern[p] == L'*')
|
||
{
|
||
star = p;
|
||
resume = t;
|
||
++p;
|
||
}
|
||
else if (p < pattern.size() && fold(pattern[p]) == fold(text[t]))
|
||
{
|
||
++p;
|
||
++t;
|
||
}
|
||
else if (star != std::wstring::npos)
|
||
{
|
||
p = star + 1;
|
||
++resume;
|
||
t = resume;
|
||
}
|
||
else
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 文本已耗尽:模式剩余部分必须全是 `*`。
|
||
while (p < pattern.size() && pattern[p] == L'*')
|
||
{
|
||
++p;
|
||
}
|
||
return p == pattern.size();
|
||
}
|
||
|
||
// 样式编辑区:固定五行网格(颜色 / 样式 / 调整 / 线宽 / 透明度),
|
||
// 行高、间距与内容列宽度统一,保证面板宽度变化时各行控件始终对齐,且不会被无限制拉伸。
|
||
const float kStylePadH = 12.0f; /**< 样式区左右内边距。 */
|
||
const float kStylePadV = 12.0f; /**< 样式区上下内边距。 */
|
||
const float kStyleRowHeight = 26.0f; /**< 样式区单行高度。 */
|
||
const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */
|
||
const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */
|
||
const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */
|
||
const float kStyleRowCount = 5.0f; /**< 样式区固定行数(不含顶部「目标」行)。 */
|
||
const float kStyleTargetRowHeight = 26.0f; /**< 样式区顶部「目标」行高度(与其它行一致)。 */
|
||
/**
|
||
* @brief GX 网格标签与信号名之间的间距(逻辑像素)。
|
||
*
|
||
* 用户要求:目标信号已绘制到网格时,在信号名**后方**显示它所在的 GX 标签。
|
||
*/
|
||
const float kTargetBadgeGap = 8.0f;
|
||
/**
|
||
* @brief 平滑滚动的动画时长(毫秒)。
|
||
*
|
||
* 距离越远时长越长(但不会无限增长),靠近时由 ease-out 自动变慢 —— 用户要求
|
||
* “越远时越快、靠近时变慢”且“整个动画过程不要太久”。
|
||
*/
|
||
const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
|
||
const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
|
||
const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
|
||
|
||
// ---- 分组折起/展开动画(用户 2026-10-16 要求)----
|
||
// 整组信号作为一个**刚性整体**向上平移(收起)/ 向下平移(展开),
|
||
// 越过组名下边界的部分被裁掉 —— 即“抽拉卷尺”观感:元素相对位置不变。
|
||
// 总时长 = clamp(基础 + 行数×每行, 下限, 上限),保证“整个动画过程不会太久”。
|
||
const float kGroupAnimBaseMs = 115.0f; /**< 动画基础时长。 */
|
||
const float kGroupAnimPerRowMs = 6.0f; /**< 每多一行增加的时长。 */
|
||
const float kGroupAnimMinMs = 150.0f; /**< 最短时长。 */
|
||
const float kGroupAnimMaxMs = 320.0f; /**< 最长时长。 */
|
||
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
|
||
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
|
||
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
|
||
const float kSwatchGap = 4.0f; /**< 颜色色块最小间距。 */
|
||
const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */
|
||
const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */
|
||
const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */
|
||
const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */
|
||
const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */
|
||
const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */
|
||
|
||
/** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */
|
||
const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */
|
||
const int kAdjustMirror = 1; /**< 镜像(点击展开镜像二级面板)。 */
|
||
const int kAdjustAbsolute = 2; /**< 绝对值。 */
|
||
const int kAdjustCount = 3; /**< 按钮数量。 */
|
||
|
||
// 线宽范围与「稀疏信号默认线宽」共用 `DataModel.hpp` 的常量,避免两处漂移
|
||
//(稀疏信号初始化时用的最大线宽必须与这里滑块的上限完全一致)。
|
||
const float kWidthMin = kSignalLineWidthMin; /**< 允许的最小线宽(px)。 */
|
||
const float kWidthMax = kSignalLineWidthMax; /**< 允许的最大线宽(px)。 */
|
||
|
||
/** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */
|
||
const double kGainLimit = 10000.0;
|
||
|
||
/** @brief 系数为默认值 1(不做任何缩放)时按钮显示为未启用样式。 */
|
||
bool GainIsDefault(float gain)
|
||
{
|
||
return std::fabs(gain - 1.0f) < 1.0e-6f;
|
||
}
|
||
|
||
/** @brief 可选绘制样式顺序(不含阶梯方式)。 */
|
||
const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER};
|
||
const int kModeOptionCount = static_cast<int>(sizeof(kModeOptions) / sizeof(kModeOptions[0]));
|
||
|
||
/**
|
||
* @brief 计算滑块的便捷命中矩形(比轨道略大,便于拖拽)。
|
||
* @param [IN] track 轨道矩形。
|
||
* @param [IN] scale 缩放比例。
|
||
* @return 命中矩形。
|
||
*/
|
||
gd::RectF SliderHitRect(const gd::RectF& track, float scale)
|
||
{
|
||
return gd::RectF(track.X - 6.0f * scale, track.Y - 9.0f * scale, track.Width + 12.0f * scale,
|
||
track.Height + 18.0f * scale);
|
||
}
|
||
|
||
/**
|
||
* @brief 判断颜色是否偏暗(用于在色块之上选择可读的图标颜色)。
|
||
* @param [IN] color 0xAARRGGBB 颜色值。
|
||
* @return 相对亮度低于阈值返回 true。
|
||
*/
|
||
bool IsDarkColor(std::uint32_t color)
|
||
{
|
||
const double r = static_cast<double>((color >> 16) & 0xFFu);
|
||
const double g = static_cast<double>((color >> 8) & 0xFFu);
|
||
const double b = static_cast<double>(color & 0xFFu);
|
||
return (0.299 * r + 0.587 * g + 0.114 * b) < 140.0;
|
||
}
|
||
|
||
/**
|
||
* @brief 将数值限制到合法区间。
|
||
* @param [IN] value 输入值。
|
||
* @param [IN] low 下限。
|
||
* @param [IN] high 上限。
|
||
* @return 限制后的数值。
|
||
*/
|
||
float ClampFloat(float value, float low, float high)
|
||
{
|
||
return (value < low) ? low : ((value > high) ? high : value);
|
||
}
|
||
|
||
/**
|
||
* @brief 数值文本:**至少两位小数**,精度更高时按实际精度显示。
|
||
*
|
||
* 例:`1` → `1.00`、`2.5` → `2.50`、`0.125` → `0.125`、`1.2345678` → `1.2345678`。
|
||
* 实现:从 2 位小数开始递增位数,直到文本能精确表示该数值
|
||
*(容差 1e-7 相对量,与 float 精度匹配)。
|
||
*
|
||
* @param [IN] value 数值。
|
||
* @return 显示文本。
|
||
*/
|
||
std::wstring FormatNumberText(double value)
|
||
{
|
||
const double tolerance = std::max(1.0e-12, std::fabs(value) * 1.0e-7);
|
||
for (int decimals = 2; decimals <= 9; ++decimals)
|
||
{
|
||
const std::wstring text = jdi::FormatNumber(value, decimals);
|
||
const double parsed = std::wcstod(text.c_str(), nullptr);
|
||
if (std::fabs(parsed - value) <= tolerance)
|
||
{
|
||
return text;
|
||
}
|
||
}
|
||
return jdi::FormatNumber(value, 9);
|
||
}
|
||
|
||
/**
|
||
* @brief 带正负符号的数值文本(非编辑态显示偏移量时使用)。
|
||
*
|
||
* 规则(用户明确要求):**正数带 `+`、负数带 `-`、0 不带任何符号**。
|
||
* (旧实现 0 也会显示 `+0.00`,用户报告“偏移调整的 Y 轴输入框默认值为 0 时也显示了正号,这不正确”。)
|
||
* @param [IN] value 数值。
|
||
* @return 形如 `+1.50` / `-0.25` / `0.00` 的文本。
|
||
*/
|
||
std::wstring FormatSignedNumber(double value)
|
||
{
|
||
const std::wstring text = FormatNumberText(std::fabs(value));
|
||
if (value > 0.0)
|
||
{
|
||
return L"+" + text;
|
||
}
|
||
if (value < 0.0)
|
||
{
|
||
return L"-" + text;
|
||
}
|
||
return text; // 0:不带符号
|
||
}
|
||
|
||
/**
|
||
* @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则 + 显式正号)。
|
||
*
|
||
* 规则(用户明确要求):“系数调整”输入框**非编辑态**显示时,非 0 正值前面加 `+`,
|
||
* 负值按原样显示负号,0 不带符号。编辑态仍用不带符号的纯数值(见 `FormatNumberText`)。
|
||
* @param [IN] gain 系数。
|
||
* @return 显示文本。
|
||
*/
|
||
std::wstring FormatGainText(float gain)
|
||
{
|
||
const double value = static_cast<double>(gain);
|
||
if (value > 0.0)
|
||
{
|
||
return L"+" + FormatNumberText(value);
|
||
}
|
||
return FormatNumberText(value);
|
||
}
|
||
|
||
/**
|
||
* @brief 「时间轴」偏移的非编辑态显示文本(`Δt:` + 带正负号的复合单位间隔)。
|
||
*
|
||
* 规则(用户明确要求):在 `Δt:` 冒号后的时间显示值**前面加上正负号**,
|
||
* 只有值为 0 时不显示符号。示例:`Δt:+1h 1min 2s` / `Δt:-5s 60ms` / `Δt:0s`。
|
||
*(`FormatDurationCompound` 内部取绝对值,因此符号由这里补上。)
|
||
* @param [IN] seconds 时间偏移(秒,可为负)。
|
||
* @return 显示文本。
|
||
*/
|
||
std::wstring FormatOffsetTimeText(double seconds)
|
||
{
|
||
if (seconds > 0.0)
|
||
{
|
||
return L"Δt:+" + FormatDurationCompound(seconds);
|
||
}
|
||
if (seconds < 0.0)
|
||
{
|
||
return L"Δt:-" + FormatDurationCompound(seconds);
|
||
}
|
||
return L"Δt:" + FormatDurationCompound(0.0);
|
||
}
|
||
}
|
||
|
||
SignalPanel::SignalPanel()
|
||
{
|
||
m_colorRects.fill(gd::RectF());
|
||
m_modeRects.fill(gd::RectF());
|
||
m_adjustRects.fill(gd::RectF());
|
||
}
|
||
|
||
void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu,
|
||
ColorPickerPopup* colorPicker)
|
||
{
|
||
m_host = host;
|
||
m_fonts = fonts;
|
||
m_theme = theme;
|
||
m_menu = menu;
|
||
m_colorPicker = colorPicker;
|
||
}
|
||
|
||
void SignalPanel::SetContext(DataSet* dataSet, TabConfig* tab)
|
||
{
|
||
m_dataSet = dataSet;
|
||
m_dataSetWritable = dataSet;
|
||
m_tab = tab;
|
||
m_rowsDirty = true;
|
||
m_scrollOffset = 0.0f;
|
||
m_draggingOpacity = false;
|
||
m_draggingWidth = false;
|
||
m_edit.End();
|
||
m_editRow = -1;
|
||
m_editField = StyleEditField::NONE;
|
||
m_pendingLaneMenu = false;
|
||
m_pendingColorPicker = false;
|
||
m_pendingGainMenu = false;
|
||
m_pendingMirrorMenu = false;
|
||
m_pendingClosePart = RowPart::NONE;
|
||
m_pendingCloseRow = -1;
|
||
m_pendingFilterClear = false;
|
||
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与位移缓存一律作废。
|
||
m_groupAnims.clear();
|
||
m_rowShift.clear();
|
||
m_scrollAnimActive = false;
|
||
}
|
||
|
||
void SignalPanel::SetScale(float scale)
|
||
{
|
||
m_scale = (scale > 0.1f) ? scale : 1.0f;
|
||
}
|
||
|
||
bool SignalPanel::HitTest(int x, int y) const
|
||
{
|
||
return UiPointInRect(m_area, static_cast<float>(x), static_cast<float>(y));
|
||
}
|
||
|
||
int SignalPanel::TotalSignalCount() const
|
||
{
|
||
if (m_dataSet == nullptr)
|
||
{
|
||
return 0;
|
||
}
|
||
return static_cast<int>(m_dataSet->SignalCount());
|
||
}
|
||
|
||
float SignalPanel::DesiredStyleHeight() const
|
||
{
|
||
// ⚠️ 「目标」行也要计入:它是样式区最上方的一行(用户 2026-10-15 要求),
|
||
// 与其余五行同高、同间距,少算一行会导致列表与样式区重叠。
|
||
return kStylePadV * 2.0f + (kStyleRowCount + 1.0f) * kStyleRowHeight +
|
||
kStyleRowCount * kStyleRowGap;
|
||
}
|
||
|
||
bool SignalPanel::TakeLayoutDirty()
|
||
{
|
||
const bool dirty = m_layoutDirty;
|
||
m_layoutDirty = false;
|
||
return dirty;
|
||
}
|
||
|
||
bool SignalPanel::IsOnFilterBox(int x, int y) const
|
||
{
|
||
return UiPointInRect(m_filterRect, static_cast<float>(x), static_cast<float>(y));
|
||
}
|
||
|
||
void SignalPanel::ClearFilter()
|
||
{
|
||
if (m_filterText.empty() && !m_filterEditing)
|
||
{
|
||
return;
|
||
}
|
||
m_filterText.clear();
|
||
if (m_filterEditing)
|
||
{
|
||
// 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。
|
||
m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u);
|
||
}
|
||
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
|
||
m_filterCollapsed.clear();
|
||
m_groupAnims.clear();
|
||
m_rowShift.clear();
|
||
// 清空后回到完整列表:重建行集合、回到顶部。
|
||
m_rowsDirty = true;
|
||
m_scrollOffset = 0.0f;
|
||
m_layoutDirty = true;
|
||
}
|
||
|
||
HCURSOR SignalPanel::QueryCursor(int x, int y)
|
||
{
|
||
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
|
||
const gd::RectF clearRect = FilterClearRect();
|
||
if (clearRect.Width > 0.0f &&
|
||
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
return LoadCursorW(nullptr, IDC_HAND);
|
||
}
|
||
|
||
// 信号行左侧的「移除该信号」✕ 同理。
|
||
RowPart part = RowPart::NONE;
|
||
if (HitRow(x, y, part) >= 0 && part == RowPart::REMOVE_SIGNAL)
|
||
{
|
||
return LoadCursorW(nullptr, IDC_HAND);
|
||
}
|
||
|
||
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
|
||
UpdateTargetRowGeometry();
|
||
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
|
||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
|
||
{
|
||
return LoadCursorW(nullptr, IDC_HAND);
|
||
}
|
||
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
|
||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
|
||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
|
||
UiPointInRect(m_opacityValueRect, fx, fy))
|
||
{
|
||
return LoadCursorW(nullptr, IDC_HAND);
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
void SignalPanel::BeginFilterEdit()
|
||
{
|
||
if (m_filterEditing)
|
||
{
|
||
return;
|
||
}
|
||
m_filterEditing = true;
|
||
// 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。
|
||
m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u);
|
||
}
|
||
|
||
bool SignalPanel::EndFilterEdit()
|
||
{
|
||
if (!m_filterEditing)
|
||
{
|
||
return false;
|
||
}
|
||
// **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。
|
||
const std::wstring finalText = Trim(m_filterEdit.Text());
|
||
m_filterEditing = false;
|
||
m_filterEdit.End();
|
||
// 文本在编辑过程中已实时生效,这里只需把最终值再同步一次并收起插入符。
|
||
m_filterText = finalText;
|
||
return true;
|
||
}
|
||
|
||
void SignalPanel::ApplyFilterText(const std::wstring& text)
|
||
{
|
||
if (m_filterText == text)
|
||
{
|
||
return;
|
||
}
|
||
m_filterText = text;
|
||
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画全部作废
|
||
//(行数与分组可见性都会变,继续沿用会算出错误的行集合)。
|
||
m_filterCollapsed.clear();
|
||
m_groupAnims.clear();
|
||
m_rowShift.clear();
|
||
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
|
||
m_rowsDirty = true;
|
||
m_scrollOffset = 0.0f;
|
||
m_layoutDirty = true;
|
||
}
|
||
|
||
bool SignalPanel::FilterMatchesSignal(const SignalData& signal) const
|
||
{
|
||
if (m_filterText.empty())
|
||
{
|
||
return true;
|
||
}
|
||
// 以 `*` 为通配符、忽略大小写,匹配"信号名中是否出现过符合该模式的片段"。
|
||
// 两侧补 `*` 使模式等价于子串匹配,因此下面这些写法都能按直觉工作:
|
||
// `编码器` → 名称包含"编码器"
|
||
// `编码器*相` → 名称中出现以"编码器"开头、"相"结尾的片段
|
||
// `*x10` / `ABS*` → 尾部/首部片段匹配(无需用户自己补另一侧的 `*`)
|
||
std::wstring pattern = L"*";
|
||
pattern += m_filterText;
|
||
pattern += L'*';
|
||
return WildcardMatchNoCase(signal.name, pattern);
|
||
}
|
||
|
||
bool SignalPanel::IsGroupCollapsed(int groupIndex) const
|
||
{
|
||
if (groupIndex < 0)
|
||
{
|
||
return false;
|
||
}
|
||
// 筛选模式使用**独立**的折起状态:筛选只是临时子集视图,不应污染普通列表的布局选择;
|
||
// 且筛选结果默认展开(便于直接看到匹配项)。
|
||
if (!m_filterText.empty())
|
||
{
|
||
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
|
||
{
|
||
return false;
|
||
}
|
||
return m_filterCollapsed[static_cast<std::size_t>(groupIndex)];
|
||
}
|
||
return (m_tab != nullptr) && m_tab->IsCollapsed(groupIndex);
|
||
}
|
||
|
||
void SignalPanel::SetGroupCollapsed(int groupIndex, bool collapsed)
|
||
{
|
||
if (groupIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
if (!m_filterText.empty())
|
||
{
|
||
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
|
||
{
|
||
m_filterCollapsed.resize(static_cast<std::size_t>(groupIndex) + 1, false);
|
||
}
|
||
m_filterCollapsed[static_cast<std::size_t>(groupIndex)] = collapsed;
|
||
return;
|
||
}
|
||
if (m_tab != nullptr && m_tab->IsCollapsed(groupIndex) != collapsed)
|
||
{
|
||
m_tab->ToggleCollapsed(groupIndex);
|
||
}
|
||
}
|
||
|
||
int SignalPanel::ShownSignalCount(int groupIndex) const
|
||
{
|
||
if (m_dataSet == nullptr || groupIndex < 0)
|
||
{
|
||
return 0;
|
||
}
|
||
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
|
||
if (group == nullptr)
|
||
{
|
||
return 0;
|
||
}
|
||
if (m_filterText.empty())
|
||
{
|
||
return static_cast<int>(group->signals.size());
|
||
}
|
||
int count = 0;
|
||
for (const SignalData& signal : group->signals)
|
||
{
|
||
if (FilterMatchesSignal(signal))
|
||
{
|
||
++count;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
int SignalPanel::FindGroupAnimation(int groupIndex) const
|
||
{
|
||
for (std::size_t index = 0; index < m_groupAnims.size(); ++index)
|
||
{
|
||
if (m_groupAnims[index].groupIndex == groupIndex)
|
||
{
|
||
return static_cast<int>(index);
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
int SignalPanel::GroupRowIndex(int groupIndex) const
|
||
{
|
||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||
{
|
||
if (m_rows[index].kind == RowKind::GROUP && m_rows[index].groupIndex == groupIndex)
|
||
{
|
||
return static_cast<int>(index);
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
float SignalPanel::NaturalContentHeight() const
|
||
{
|
||
float total = 0.0f;
|
||
for (const ListRow& row : m_rows)
|
||
{
|
||
total += row.height;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
float SignalPanel::ShownBlockHeight(int groupIndex) const
|
||
{
|
||
return static_cast<float>(ShownSignalCount(groupIndex)) * kSignalRowHeight * m_scale;
|
||
}
|
||
|
||
float SignalPanel::AnimEasedProgress(const GroupAnim& anim) const
|
||
{
|
||
const std::uint64_t now = GetTickCount64();
|
||
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
|
||
float progress = (anim.durationMs > 0u)
|
||
? static_cast<float>(elapsed) / static_cast<float>(anim.durationMs)
|
||
: 1.0f;
|
||
if (progress > 1.0f)
|
||
{
|
||
progress = 1.0f;
|
||
}
|
||
// ease-out cubic:起步快、接近完成时明显减速(“快速展开 / 收起后慢慢减速”)。
|
||
return 1.0f - std::pow(1.0f - progress, 3.0f);
|
||
}
|
||
|
||
void SignalPanel::UpdateAnimGeometry()
|
||
{
|
||
m_rowShift.assign(m_rows.size(), 0.0f);
|
||
|
||
float totalShift = 0.0f;
|
||
for (const GroupAnim& anim : m_groupAnims)
|
||
{
|
||
const float progress = AnimEasedProgress(anim);
|
||
// 收起:从起始位移推到“全部藏进组名栏”(= blockHeight);
|
||
// 展开:从起始位移推回 0(逐条从组名栏下方冒出)。
|
||
// 起始位移在“中途反向点击”时就是当时的位移 ⇒ 不会跳变。
|
||
const float target = anim.collapsing ? anim.blockHeight : 0.0f;
|
||
const float shift = anim.fromShift + (target - anim.fromShift) * progress;
|
||
|
||
if (anim.scrollSynced)
|
||
{
|
||
// 与滚动同步推进:让悬浮组名始终留在列表最上方
|
||
//(滚动偏移与平移量按同一进度推进,二者的合成让组名的屏幕位置恒定)。
|
||
m_scrollOffset = anim.scrollFrom + (anim.scrollTo - anim.scrollFrom) * progress;
|
||
}
|
||
|
||
const int headerRow = GroupRowIndex(anim.groupIndex);
|
||
if (headerRow < 0 || shift <= 0.0f)
|
||
{
|
||
continue;
|
||
}
|
||
// ⚠️ 平移只作用于**组名行之后**的行:组名自己是“卷尺的外壳”,始终不动,
|
||
// 信号行从它下面滑过去并被裁掉。
|
||
for (int row = headerRow + 1; row < static_cast<int>(m_rowShift.size()); ++row)
|
||
{
|
||
m_rowShift[static_cast<std::size_t>(row)] += shift;
|
||
}
|
||
totalShift += shift;
|
||
}
|
||
|
||
m_contentHeight = std::max(0.0f, NaturalContentHeight() - totalShift);
|
||
}
|
||
|
||
bool SignalPanel::StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo,
|
||
bool syncScroll)
|
||
{
|
||
if (groupIndex < 0 || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 同一分组只保留一个动画条目:再次切换时移除旧的,并从**当前位移**续接
|
||
//(用户可能在折起没播完时就点展开,此时不能跳回干净的一端)。
|
||
float fromShift = 0.0f;
|
||
const int existing = FindGroupAnimation(groupIndex);
|
||
if (existing >= 0)
|
||
{
|
||
const GroupAnim& previous = m_groupAnims[static_cast<std::size_t>(existing)];
|
||
const float previousProgress = AnimEasedProgress(previous);
|
||
const float previousTarget = previous.collapsing ? previous.blockHeight : 0.0f;
|
||
fromShift = previous.fromShift + (previousTarget - previous.fromShift) * previousProgress;
|
||
m_groupAnims.erase(m_groupAnims.begin() + existing);
|
||
}
|
||
|
||
const float blockHeight = ShownBlockHeight(groupIndex);
|
||
if (blockHeight <= 0.5f)
|
||
{
|
||
// 没有信号行可动画(空分组):直接把状态定下来,并清掉可能的残留位移。
|
||
UpdateAnimGeometry();
|
||
m_rowsDirty = true;
|
||
return false;
|
||
}
|
||
if (existing < 0)
|
||
{
|
||
// 没有正在播放的动画 ⇒ 从“干净的一端”出发(展开 = 全部藏在组名栏内)。
|
||
fromShift = collapse ? 0.0f : blockHeight;
|
||
}
|
||
|
||
GroupAnim anim;
|
||
anim.groupIndex = groupIndex;
|
||
anim.collapsing = collapse;
|
||
anim.rowCount = ShownSignalCount(groupIndex);
|
||
anim.blockHeight = blockHeight;
|
||
anim.fromShift = ClampFloat(fromShift, 0.0f, blockHeight);
|
||
anim.scrollFrom = scrollFrom;
|
||
anim.scrollTo = scrollTo;
|
||
anim.scrollSynced = syncScroll && (std::fabs(scrollTo - scrollFrom) > 0.5f);
|
||
anim.startMs = GetTickCount64();
|
||
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
|
||
const float durationMs = std::max(
|
||
kGroupAnimMinMs,
|
||
std::min(kGroupAnimMaxMs,
|
||
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(anim.rowCount)));
|
||
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||
|
||
if (anim.scrollSynced)
|
||
{
|
||
// 立即接管滚动偏移(后续每帧由 `UpdateAnimGeometry` 推进)。
|
||
m_scrollOffset = scrollFrom;
|
||
}
|
||
|
||
m_groupAnims.push_back(anim);
|
||
// 折起时该组的信号行必须**留在行集合里**(它们正在“滑进组名栏”),
|
||
// 因此需要按“有动画”的规则重建一次行集合。
|
||
m_rowsDirty = true;
|
||
UpdateAnimGeometry();
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::AdvanceGroupAnimations()
|
||
{
|
||
if (m_groupAnims.empty())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const std::uint64_t now = GetTickCount64();
|
||
bool rowsBecameStale = false;
|
||
for (std::size_t index = 0; index < m_groupAnims.size();)
|
||
{
|
||
const GroupAnim& anim = m_groupAnims[index];
|
||
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
|
||
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
|
||
{
|
||
// 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。
|
||
if (anim.scrollSynced)
|
||
{
|
||
m_scrollOffset = anim.scrollTo;
|
||
}
|
||
// 折起完成后该组的信号行要**真正移除** ⇒ 行集合需重建
|
||
//(展开完成后行集合本来就是展开态,无需重建;平移量归零即可)。
|
||
if (anim.collapsing)
|
||
{
|
||
rowsBecameStale = true;
|
||
}
|
||
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
|
||
}
|
||
else
|
||
{
|
||
++index;
|
||
}
|
||
}
|
||
|
||
// ⚠️ **不设置 `m_rowsDirty`**:行高不随动画变化,只需重算逐行位移与内容高度 ——
|
||
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
|
||
UpdateAnimGeometry();
|
||
if (rowsBecameStale)
|
||
{
|
||
m_rowsDirty = true;
|
||
}
|
||
return !m_groupAnims.empty();
|
||
}
|
||
|
||
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
|
||
{
|
||
EnsureRows();
|
||
if (m_tab == nullptr || groupIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const int headerRow =
|
||
(row >= 0 && row < static_cast<int>(m_rows.size()) &&
|
||
m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP &&
|
||
m_rows[static_cast<std::size_t>(row)].groupIndex == groupIndex)
|
||
? row
|
||
: GroupRowIndex(groupIndex);
|
||
|
||
const bool collapse = !IsGroupCollapsed(groupIndex);
|
||
SetGroupCollapsed(groupIndex, collapse);
|
||
|
||
// 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**,
|
||
// 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。
|
||
//(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。)
|
||
float scrollFrom = m_scrollOffset;
|
||
float scrollTo = m_scrollOffset;
|
||
bool syncScroll = false;
|
||
if (collapse && headerRow >= 0)
|
||
{
|
||
const float alignOffset = ContentOffsetBeforeRow(headerRow);
|
||
if (m_scrollOffset > alignOffset + 0.5f)
|
||
{
|
||
// 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短,
|
||
// 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。
|
||
const float finalContent =
|
||
std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex));
|
||
const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height);
|
||
scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset));
|
||
syncScroll = true;
|
||
}
|
||
}
|
||
|
||
StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll);
|
||
}
|
||
|
||
float SignalPanel::AnimationHideTopForRow(int row, int stickyRow) const
|
||
{
|
||
if (m_groupAnims.empty() || row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return -1.0e9f;
|
||
}
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
if (item.kind != RowKind::SIGNAL || FindGroupAnimation(item.groupIndex) < 0)
|
||
{
|
||
return -1.0e9f;
|
||
}
|
||
const int headerRow = GroupRowIndex(item.groupIndex);
|
||
if (headerRow < 0)
|
||
{
|
||
return -1.0e9f;
|
||
}
|
||
// 组名的**绘制**下边界:悬浮时用悬浮位置,否则用自然位置。
|
||
// 信号行越过这条线即被裁掉 ⇒ 形成“滑进组名栏”的观感。
|
||
const float headerTop = (stickyRow == headerRow)
|
||
? StickyGroupRowTop(headerRow, static_cast<int>(m_rows.size()))
|
||
: RowRect(headerRow).Y;
|
||
return headerTop + m_rows[static_cast<std::size_t>(headerRow)].height;
|
||
}
|
||
|
||
void SignalPanel::BuildRows()
|
||
{
|
||
m_rows.clear();
|
||
m_rowsDirty = false;
|
||
|
||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||
{
|
||
m_contentHeight = 0.0f;
|
||
m_rowShift.clear();
|
||
return;
|
||
}
|
||
|
||
const float groupHeight = kGroupRowHeight * m_scale;
|
||
const float signalHeight = kSignalRowHeight * m_scale;
|
||
const bool filtering = !m_filterText.empty();
|
||
|
||
const std::vector<SignalGroup>& files = m_dataSet->Groups();
|
||
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
|
||
{
|
||
const int gi = static_cast<int>(groupIndex);
|
||
|
||
// 参与列出的信号:筛选模式只保留匹配项;整组都没有匹配则**不显示该组**。
|
||
std::vector<int> shown;
|
||
const std::size_t signalCount = files[groupIndex].signals.size();
|
||
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
|
||
{
|
||
if (!filtering || FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
|
||
{
|
||
shown.push_back(static_cast<int>(signalIndex));
|
||
}
|
||
}
|
||
if (filtering && shown.empty())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const bool collapsed = IsGroupCollapsed(gi);
|
||
const bool animating = (FindGroupAnimation(gi) >= 0);
|
||
|
||
ListRow groupRow;
|
||
groupRow.kind = RowKind::GROUP;
|
||
groupRow.groupIndex = gi;
|
||
groupRow.height = groupHeight;
|
||
// 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。
|
||
groupRow.displayCount = static_cast<int>(shown.size());
|
||
m_rows.push_back(groupRow);
|
||
|
||
// 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。
|
||
// ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移,
|
||
// 逐条滑进组名栏并被裁掉 —— 行高不变,因此动画期间无需重建行集合。
|
||
if (collapsed && !animating)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
for (int ordinal = 0; ordinal < static_cast<int>(shown.size()); ++ordinal)
|
||
{
|
||
ListRow signalRow;
|
||
signalRow.kind = RowKind::SIGNAL;
|
||
signalRow.groupIndex = gi;
|
||
signalRow.signalIndex = shown[static_cast<std::size_t>(ordinal)];
|
||
signalRow.height = signalHeight;
|
||
signalRow.ordinal = ordinal;
|
||
m_rows.push_back(signalRow);
|
||
}
|
||
}
|
||
|
||
// 内容高度与逐行位移由动画状态推出(无动画时即“自然高度”)。
|
||
UpdateAnimGeometry();
|
||
}
|
||
|
||
void SignalPanel::EnsureRows()
|
||
{
|
||
if (m_rowsDirty)
|
||
{
|
||
BuildRows();
|
||
RefreshScrollBar();
|
||
return;
|
||
}
|
||
if (m_rowShift.size() != m_rows.size())
|
||
{
|
||
// 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。
|
||
UpdateAnimGeometry();
|
||
}
|
||
}
|
||
|
||
void SignalPanel::RefreshScrollBar()
|
||
{
|
||
if (m_listFullWidth <= 0.0f)
|
||
{
|
||
m_scrollBarVisible = false;
|
||
return;
|
||
}
|
||
|
||
m_scrollBarVisible = (m_contentHeight > m_listRect.Height + 0.5f);
|
||
if (m_scrollBarVisible)
|
||
{
|
||
const float thickness = kScrollBarThickness * m_scale;
|
||
m_listRect.Width = std::max(0.0f, m_listFullWidth - thickness - 2.0f * m_scale);
|
||
m_scrollBarRect = gd::RectF(m_area.X + m_area.Width - thickness - 3.0f * m_scale, m_listRect.Y,
|
||
thickness, m_listRect.Height);
|
||
}
|
||
else
|
||
{
|
||
m_listRect.Width = m_listFullWidth;
|
||
m_scrollBarRect = gd::RectF();
|
||
}
|
||
|
||
ClampScroll();
|
||
}
|
||
|
||
void SignalPanel::ClampScroll()
|
||
{
|
||
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
|
||
m_scrollOffset = std::max(0.0f, std::min(m_scrollOffset, maxOffset));
|
||
}
|
||
|
||
gd::RectF SignalPanel::RowRect(int row) const
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
|
||
// 「组名悬浮」期间把分组行钉在列表顶部绘制(见 `PaintStickyGroupRow`):
|
||
// 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标,
|
||
// 名称框 / ✕ / 徽标等位置会自动跟随。
|
||
if (m_rowYOverrideActive)
|
||
{
|
||
return gd::RectF(m_listRect.X, m_rowYOverrideY, m_listRect.Width,
|
||
m_rows[static_cast<std::size_t>(row)].height);
|
||
}
|
||
|
||
float top = m_listRect.Y - m_scrollOffset;
|
||
for (int index = 0; index < row; ++index)
|
||
{
|
||
top += m_rows[static_cast<std::size_t>(index)].height;
|
||
}
|
||
// 折起/展开动画的刚性平移:组名行之后的行整组上移。
|
||
if (row < static_cast<int>(m_rowShift.size()))
|
||
{
|
||
top -= m_rowShift[static_cast<std::size_t>(row)];
|
||
}
|
||
return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
|
||
}
|
||
|
||
int SignalPanel::GroupLastRowIndex(int row, int rowCount) const
|
||
{
|
||
// 列表行按分组连续排列 ⇒ 本组的最后一行就是下一个分组行之前的那一行。
|
||
int last = row;
|
||
for (int next = row + 1; next < rowCount; ++next)
|
||
{
|
||
if (m_rows[static_cast<std::size_t>(next)].kind == RowKind::GROUP)
|
||
{
|
||
break;
|
||
}
|
||
last = next;
|
||
}
|
||
return last;
|
||
}
|
||
|
||
int SignalPanel::StickyGroupRowIndex(int rowCount) const
|
||
{
|
||
// 「组名悬浮」:分组行被滚出列表上边界、而该组内仍有行留在列表里时,
|
||
// 把分组行**钉在列表顶部**,直到该组全部行都滚过去为止。
|
||
// 用户要求:组名要一直可见,方便在长列表(例如上百个信号的 MF4)里辨认当前分组。
|
||
if (m_rowYOverrideActive || m_listRect.Height <= 0.0f)
|
||
{
|
||
return -1;
|
||
}
|
||
|
||
for (int row = 0; row < rowCount; ++row)
|
||
{
|
||
if (m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
|
||
{
|
||
continue;
|
||
}
|
||
if (RowRect(row).Y >= m_listRect.Y)
|
||
{
|
||
// 分组行本身仍在列表内(或更靠下):无需悬浮。
|
||
continue;
|
||
}
|
||
const int last = GroupLastRowIndex(row, rowCount);
|
||
if (RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height <= m_listRect.Y)
|
||
{
|
||
// 整组(含最后一行)已全部滚过去:组名跟随一起向上,不再出现。
|
||
continue;
|
||
}
|
||
return row;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
float SignalPanel::StickyGroupRowTop(int row, int rowCount) const
|
||
{
|
||
// 悬浮位置(用户 2026-10-15 要求“平滑过渡”,不要突然消失):
|
||
// * 平时钉在列表上边界(`m_listRect.Y`);
|
||
// * 当**本组最后一行的下边界**上移越过悬浮组名的下边界时,组名跟着一起上移
|
||
// ⇒ 随滚动条自然滚出列表,而不是跳到位置后突然消失。
|
||
// 该公式同时保证「悬浮组名的下边界永远不超过本组最后一行的下边界」,
|
||
// 而后者就是**下一个分组行的自然位置** —— 因此紧挨着的下一个组名不可能被覆盖
|
||
//(旧实现固定钉在 `m_listRect.Y`,会把下一个组名盖住,让它的信号看起来属于上一组)。
|
||
const int last = GroupLastRowIndex(row, rowCount);
|
||
const float lastBottom =
|
||
RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height;
|
||
return std::min(m_listRect.Y, lastBottom - m_rows[static_cast<std::size_t>(row)].height);
|
||
}
|
||
|
||
void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row)
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
|
||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const int rowCount = static_cast<int>(m_rows.size());
|
||
const float height = m_rows[static_cast<std::size_t>(row)].height;
|
||
const float naturalY = RowRect(row).Y;
|
||
const float stickyY = StickyGroupRowTop(row, rowCount);
|
||
if (stickyY + height <= m_listRect.Y + 0.5f)
|
||
{
|
||
return; // 已经整块滑出列表上边界
|
||
}
|
||
|
||
m_rowYOverrideActive = true;
|
||
m_rowYOverrideY = stickyY;
|
||
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
|
||
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
|
||
SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height));
|
||
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
|
||
PaintGroupRow(g, row);
|
||
ClearRowClip();
|
||
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
|
||
if (stickyY > naturalY + 0.5f)
|
||
{
|
||
UiDrawLine(g, m_listRect.X, stickyY + height - 0.5f, m_listRect.X + m_listRect.Width,
|
||
stickyY + height - 0.5f, m_theme->border, 1.0f);
|
||
}
|
||
m_rowYOverrideActive = false;
|
||
}
|
||
|
||
gd::RectF SignalPanel::GroupCloseRect(int row) const
|
||
{
|
||
const gd::RectF rect = RowRect(row);
|
||
const float size = kCloseButtonSize * m_scale;
|
||
return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size,
|
||
rect.Y + (rect.Height - size) * 0.5f, size, size);
|
||
}
|
||
|
||
gd::RectF SignalPanel::SignalBadgeRect(int row) const
|
||
{
|
||
const gd::RectF rect = RowRect(row);
|
||
const float width = kBadgeWidth * m_scale;
|
||
const float height = kBadgeHeight * m_scale;
|
||
// 行尾现在被「移除该信号」✕ 占用(与分组 ✕ 同尺寸、同列),网格徽标因此整体前移;
|
||
// 两者都恒在列表最右侧,且始终保留固定的间隔,不会互相挤压。
|
||
const float right = SignalRemoveRect(row).X - kRowTailGap * m_scale;
|
||
return gd::RectF(right - width, rect.Y + (rect.Height - height) * 0.5f, width, height);
|
||
}
|
||
|
||
gd::RectF SignalPanel::SignalRemoveRect(int row) const
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
|
||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::SIGNAL)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
|
||
// 与分组行的关闭 ✕ **完全一致**(同尺寸、同右边距、同行内垂直居中),
|
||
// 只是作用对象是本行信号:位于信号条目的最右端。
|
||
const gd::RectF rect = RowRect(row);
|
||
const float size = kCloseButtonSize * m_scale;
|
||
return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size,
|
||
rect.Y + (rect.Height - size) * 0.5f, size, size);
|
||
}
|
||
|
||
SignalPanel::RowPart SignalPanel::RowHitPart(int row, float fx, float fy) const
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return RowPart::NONE;
|
||
}
|
||
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
const gd::RectF rect = RowRect(row);
|
||
const bool isGroup = (item.kind == RowKind::GROUP);
|
||
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
|
||
|
||
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
|
||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
|
||
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
|
||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||
|
||
if (isGroup)
|
||
{
|
||
if (UiPointInRect(disclosureRect, fx, fy))
|
||
{
|
||
return RowPart::DISCLOSURE;
|
||
}
|
||
if (UiPointInRect(checkRect, fx, fy))
|
||
{
|
||
return RowPart::CHECK;
|
||
}
|
||
if (UiPointInRect(GroupCloseRect(row), fx, fy))
|
||
{
|
||
return RowPart::CLOSE;
|
||
}
|
||
// 「组X」标签:与折叠三角一样是“折起该分组”的热区
|
||
//(用户 2026-10-16 要求:只有点三角或「组X」才折起,点到组名本体不折起)。
|
||
const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
|
||
const gd::RectF labelRect(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height);
|
||
if (UiPointInRect(labelRect, fx, fy))
|
||
{
|
||
return RowPart::GROUP_TITLE;
|
||
}
|
||
return RowPart::BODY;
|
||
}
|
||
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
|
||
|
||
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
|
||
if (UiPointInRect(SignalRemoveRect(row), fx, fy))
|
||
{
|
||
return RowPart::REMOVE_SIGNAL;
|
||
}
|
||
if (UiPointInRect(checkRect, fx, fy))
|
||
{
|
||
return RowPart::CHECK;
|
||
}
|
||
if (badgeVisible && UiPointInRect(SignalBadgeRect(row), fx, fy))
|
||
{
|
||
return RowPart::LANE_BADGE;
|
||
}
|
||
return RowPart::BODY;
|
||
}
|
||
|
||
int SignalPanel::HitRow(int x, int y, RowPart& part)
|
||
{
|
||
part = RowPart::NONE;
|
||
EnsureRows();
|
||
|
||
if (m_listRect.Height <= 0.0f)
|
||
{
|
||
return -1;
|
||
}
|
||
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
if (fy < m_listRect.Y || fy > m_listRect.Y + m_listRect.Height)
|
||
{
|
||
return -1;
|
||
}
|
||
if (fx < m_listRect.X || fx > m_listRect.X + m_listRect.Width)
|
||
{
|
||
return -1;
|
||
}
|
||
|
||
// 「悬浮组名」绘制在最上层,因此命中判定也必须最优先 —— 否则点击会**穿透到
|
||
// 它下面的那一行**(用户 2026-10-16 报告:组名悬浮时它上面的折叠三角、复选框、
|
||
// 关闭 ✕、名称区域全都点不动,点击被下层信号行吃掉)。
|
||
const int rowCount = static_cast<int>(m_rows.size());
|
||
const int stickyRow = StickyGroupRowIndex(rowCount);
|
||
if (stickyRow >= 0)
|
||
{
|
||
const float stickyTop = StickyGroupRowTop(stickyRow, rowCount);
|
||
const float stickyHeight = m_rows[static_cast<std::size_t>(stickyRow)].height;
|
||
if (fy >= stickyTop && fy < stickyTop + stickyHeight)
|
||
{
|
||
// 用覆盖几何计算行内区域(`RowRect` 是行内所有派生的唯一几何来源)。
|
||
m_rowYOverrideActive = true;
|
||
m_rowYOverrideY = stickyTop;
|
||
part = RowHitPart(stickyRow, fx, fy);
|
||
m_rowYOverrideActive = false;
|
||
return stickyRow;
|
||
}
|
||
}
|
||
|
||
const float local = fy - m_listRect.Y + m_scrollOffset;
|
||
if (local < 0.0f)
|
||
{
|
||
return -1;
|
||
}
|
||
|
||
// 逐行比较“实际绘制位置”(含折起/展开动画的刚性平移),不能只看滚动偏移:
|
||
// 动画中信号行的位置与“自然内容坐标”不再一一对应,用后者会点错行。
|
||
float rowTop = m_listRect.Y - m_scrollOffset;
|
||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||
{
|
||
const float shift = (index < m_rowShift.size()) ? m_rowShift[index] : 0.0f;
|
||
const float previousShift =
|
||
(index > 0 && (index - 1) < m_rowShift.size()) ? m_rowShift[index - 1] : 0.0f;
|
||
rowTop -= (shift - previousShift);
|
||
|
||
const ListRow& row = m_rows[index];
|
||
const float rowBottom = rowTop + row.height;
|
||
if (row.height > 0.0f && fy >= rowTop && fy < rowBottom)
|
||
{
|
||
const int rowIndex = static_cast<int>(index);
|
||
// 折起/展开动画中已“藏进组名栏”的行不再参与命中(否则它会白白吸走一次点击)。
|
||
if (rowBottom > AnimationHideTopForRow(rowIndex, stickyRow) + 0.5f)
|
||
{
|
||
part = RowHitPart(rowIndex, fx, fy);
|
||
return rowIndex;
|
||
}
|
||
return -1;
|
||
}
|
||
rowTop = rowBottom;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
float SignalPanel::ContentOffsetBeforeRow(int row) const
|
||
{
|
||
float top = 0.0f;
|
||
const int count = std::min(row, static_cast<int>(m_rows.size()));
|
||
for (int index = 0; index < count; ++index)
|
||
{
|
||
top += m_rows[static_cast<std::size_t>(index)].height;
|
||
}
|
||
return top;
|
||
}
|
||
|
||
void SignalPanel::StartScrollAnimationTo(float desired)
|
||
{
|
||
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
|
||
desired = std::max(0.0f, std::min(desired, maxOffset));
|
||
const float distance = std::fabs(desired - m_scrollOffset);
|
||
|
||
if (distance < 1.0f)
|
||
{
|
||
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
|
||
m_scrollOffset = desired;
|
||
ClampScroll();
|
||
m_scrollAnimActive = false;
|
||
return;
|
||
}
|
||
|
||
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
|
||
// 因此"整个动画过程不会太久"(最长 300ms)。
|
||
const float durationMs =
|
||
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
|
||
m_scrollAnimFrom = m_scrollOffset;
|
||
m_scrollAnimTo = desired;
|
||
m_scrollAnimStartMs = GetTickCount64();
|
||
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||
m_scrollAnimActive = true;
|
||
}
|
||
|
||
void SignalPanel::ScrollToSignal(const SignalRef& ref)
|
||
{
|
||
EnsureRows();
|
||
if (!ref.IsValid())
|
||
{
|
||
return;
|
||
}
|
||
|
||
float top = 0.0f;
|
||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||
{
|
||
const ListRow& row = m_rows[index];
|
||
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
|
||
row.signalIndex == ref.signalIndex)
|
||
{
|
||
if (top < m_scrollOffset)
|
||
{
|
||
m_scrollOffset = top;
|
||
}
|
||
else if (top + row.height > m_scrollOffset + m_listRect.Height)
|
||
{
|
||
m_scrollOffset = top + row.height - m_listRect.Height;
|
||
}
|
||
ClampScroll();
|
||
return;
|
||
}
|
||
top += row.height;
|
||
}
|
||
}
|
||
|
||
void SignalPanel::LayoutStyleControls()
|
||
{
|
||
const float padH = kStylePadH * m_scale;
|
||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||
const float labelGap = kStyleLabelGap * m_scale;
|
||
const float rowHeight = kStyleRowHeight * m_scale;
|
||
const float rowStep = (kStyleRowHeight + kStyleRowGap) * m_scale;
|
||
const float firstTop = m_styleRect.Y + kStylePadV * m_scale;
|
||
|
||
// 内容列宽度带上下限:面板变窄时收缩,面板变宽时不再延伸,五行始终共用同一宽度。
|
||
const float available = std::max(40.0f, m_styleRect.Width - padH * 2.0f - labelWidth - labelGap);
|
||
const float contentWidth = std::min(available, kStyleContentMax * m_scale);
|
||
|
||
// 标签列 + 内容列整体靠左对齐(始终从左侧内边距开始),不随面板变宽而居中偏移。
|
||
const float groupWidth = labelWidth + labelGap + contentWidth;
|
||
const float groupLeft = m_styleRect.X + padH;
|
||
const float contentLeft = groupLeft + labelWidth + labelGap;
|
||
|
||
// ---- 顶部「目标」行:显示当前正在被编辑的目标信号(名称 + GX 标签)----
|
||
// 用户 2026-10-15 要求:位于样式自定义区最上方(预设颜色色块之上),
|
||
// 显示效果与交互效果与信号选择区域的信号行相同(悬停高亮、双击改名、单键滚动联动)。
|
||
m_targetRowRect = gd::RectF(groupLeft, firstTop, groupWidth, kStyleTargetRowHeight * m_scale);
|
||
|
||
for (int index = 0; index < static_cast<int>(kStyleRowCount); ++index)
|
||
{
|
||
m_styleRowRects[static_cast<std::size_t>(index)] =
|
||
gd::RectF(groupLeft, firstTop + static_cast<float>(index + 1) * rowStep, groupWidth,
|
||
rowHeight);
|
||
}
|
||
|
||
const gd::RectF& colorRow = m_styleRowRects[0];
|
||
const gd::RectF& modeRow = m_styleRowRects[1];
|
||
const gd::RectF& adjustRow = m_styleRowRects[2];
|
||
const gd::RectF& widthRow = m_styleRowRects[3];
|
||
const gd::RectF& opacityRow = m_styleRowRects[4];
|
||
|
||
// ---- 颜色行:预设色块 + 同尺寸自定义颜色入口(合计恰好铺满内容列宽度)----
|
||
int paletteCount = 0;
|
||
SignalPalette(paletteCount);
|
||
|
||
const float swatchGap = kSwatchGap * m_scale;
|
||
const float swatchMaxGap = kSwatchMaxGap * m_scale;
|
||
const float swatchMin = kSwatchMin * m_scale;
|
||
int count = std::min(paletteCount, static_cast<int>(m_colorRects.size()));
|
||
float swatchSize = kSwatchEdge * m_scale;
|
||
float gap = swatchGap;
|
||
while (count >= 1)
|
||
{
|
||
const float slots = static_cast<float>(count + 1);
|
||
const float fit = (contentWidth - swatchGap * (slots - 1.0f)) / slots;
|
||
if (count == 1 || fit >= swatchMin)
|
||
{
|
||
swatchSize = std::max(swatchMin, std::min(kSwatchEdge * m_scale, fit));
|
||
// 用实际色块尺寸反算间距,让颜色行与透明度、线宽、样式各行同宽。
|
||
gap = (contentWidth - swatchSize * slots) / static_cast<float>(count);
|
||
gap = std::max(swatchGap, std::min(gap, swatchMaxGap));
|
||
break;
|
||
}
|
||
--count;
|
||
}
|
||
m_colorCount = std::max(1, count);
|
||
|
||
// 颜色行与其余各行共用同一左边界(不再居中),保证五行纵向对齐。
|
||
const float colorRowLeft = contentLeft;
|
||
|
||
const float swatchTop = colorRow.Y + (rowHeight - swatchSize) * 0.5f;
|
||
for (int index = 0; index < m_colorCount; ++index)
|
||
{
|
||
m_colorRects[static_cast<std::size_t>(index)] =
|
||
gd::RectF(colorRowLeft + static_cast<float>(index) * (swatchSize + gap), swatchTop,
|
||
swatchSize, swatchSize);
|
||
}
|
||
m_customChipRect =
|
||
gd::RectF(colorRowLeft + static_cast<float>(m_colorCount) * (swatchSize + gap), swatchTop,
|
||
swatchSize, swatchSize);
|
||
|
||
// ---- 样式行:三个等宽按钮铺满内容列 ----
|
||
const float modeGap = 6.0f * m_scale;
|
||
const float modeWidth =
|
||
std::max(20.0f, (contentWidth - modeGap * static_cast<float>(kModeOptionCount - 1)) /
|
||
static_cast<float>(kModeOptionCount));
|
||
for (int index = 0; index < kModeOptionCount; ++index)
|
||
{
|
||
m_modeRects[static_cast<std::size_t>(index)] =
|
||
gd::RectF(contentLeft + static_cast<float>(index) * (modeWidth + modeGap), modeRow.Y,
|
||
modeWidth, rowHeight);
|
||
}
|
||
|
||
// ---- 「调整」行:系数调整 / 取绝对值 / 中心反转(三个铺满内容列的等宽按钮)----
|
||
const float adjustGap = kAdjustGap * m_scale;
|
||
const float adjustWidth =
|
||
std::max(20.0f, (contentWidth - adjustGap * static_cast<float>(kAdjustCount - 1)) /
|
||
static_cast<float>(kAdjustCount));
|
||
for (int index = 0; index < kAdjustCount; ++index)
|
||
{
|
||
m_adjustRects[static_cast<std::size_t>(index)] =
|
||
gd::RectF(contentLeft + static_cast<float>(index) * (adjustWidth + adjustGap), adjustRow.Y,
|
||
adjustWidth, rowHeight);
|
||
}
|
||
|
||
// ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)----
|
||
const float valueWidth = std::min(kSliderValueWidth * m_scale, contentWidth * 0.4f);
|
||
const float trackWidth = std::max(24.0f, contentWidth - valueWidth - kSliderValueGap * m_scale);
|
||
const float trackHeight = kTrackHeight * m_scale;
|
||
|
||
m_widthTrackRect =
|
||
gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight);
|
||
m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y,
|
||
valueWidth, rowHeight);
|
||
|
||
m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f,
|
||
trackWidth, trackHeight);
|
||
m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y,
|
||
valueWidth, rowHeight);
|
||
|
||
// 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化),
|
||
// 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。
|
||
m_targetGeomValid = false;
|
||
}
|
||
|
||
int SignalPanel::TargetLaneIndex() const
|
||
{
|
||
if (m_tab == nullptr || !SelectedRef().IsValid())
|
||
{
|
||
return -1;
|
||
}
|
||
// 未勾选绘制的信号不在任何网格的信号列表里,`LaneOfSignal` 自然返回 -1。
|
||
return m_tab->LaneOfSignal(SelectedRef());
|
||
}
|
||
|
||
void SignalPanel::UpdateTargetRowGeometry()
|
||
{
|
||
if (m_fonts == nullptr || m_dataSet == nullptr)
|
||
{
|
||
m_targetNameRect = gd::RectF();
|
||
m_targetBadgeRect = gd::RectF();
|
||
return;
|
||
}
|
||
|
||
const SignalRef& target = SelectedRef();
|
||
const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr;
|
||
const std::wstring title = (signal != nullptr) ? signal->name : std::wstring();
|
||
const int lane = TargetLaneIndex();
|
||
|
||
// 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。
|
||
if (m_targetGeomValid && m_targetGeomRef == target && m_targetGeomLane == lane &&
|
||
m_targetGeomTitle == title)
|
||
{
|
||
return;
|
||
}
|
||
m_targetGeomRef = target;
|
||
m_targetGeomLane = lane;
|
||
m_targetGeomTitle = title;
|
||
m_targetGeomValid = true;
|
||
|
||
m_targetNameRect = gd::RectF();
|
||
m_targetBadgeRect = gd::RectF();
|
||
if (signal == nullptr || m_targetRowRect.Width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||
const float labelGap = kStyleLabelGap * m_scale;
|
||
const float contentLeft = m_targetRowRect.X + labelWidth + labelGap;
|
||
const float contentWidth = std::max(0.0f, m_targetRowRect.Width - labelWidth - labelGap);
|
||
|
||
const float badgeWidth = kBadgeWidth * m_scale;
|
||
const float badgeHeight = kBadgeHeight * m_scale;
|
||
const float badgeGap = kTargetBadgeGap * m_scale;
|
||
// 只有**已绘制到网格**的信号才有 GX 标签(未勾选绘制时没有网格归属)。
|
||
const bool badgeShown = (lane >= 0) && (badgeWidth + badgeGap < contentWidth);
|
||
|
||
// 布局(用户 2026-10-16 要求“信号名与 GX 标签同时出现时左右宽度与其它控件对齐”):
|
||
// * 名称从**内容列左边缘**开始(与样式区其余行的控件同一左边界);
|
||
// * GX 标签**右对齐到内容列右边缘**(与滑轨/数值框等的右边界对齐),
|
||
// 而不是紧跟在名称后面(旧的“紧接名称”会让右边缘参差不齐);
|
||
// * 名称的可用宽度 = 到 GX 标签左侧(无标签时 = 整个内容列,因此名称可以更宽)。
|
||
const float badgeLeft = contentLeft + contentWidth - badgeWidth;
|
||
const float nameRight = badgeShown ? (badgeLeft - badgeGap) : (contentLeft + contentWidth);
|
||
const float nameWidth = std::max(0.0f, nameRight - contentLeft);
|
||
|
||
// 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。
|
||
m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height);
|
||
if (badgeShown)
|
||
{
|
||
m_targetBadgeRect = gd::RectF(badgeLeft,
|
||
m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f,
|
||
badgeWidth, badgeHeight);
|
||
}
|
||
}
|
||
|
||
void SignalPanel::Layout(const gd::RectF& area)
|
||
{
|
||
m_area = area;
|
||
|
||
m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale);
|
||
|
||
// 标题下方的「筛选信号」输入框(列表内容过滤)。
|
||
const float filterHeight = kFilterHeight * m_scale;
|
||
m_filterRect = gd::RectF(area.X + kPadding * m_scale,
|
||
m_headerRect.Y + m_headerRect.Height + kFilterPadV * m_scale,
|
||
std::max(20.0f, area.Width - kPadding * 2.0f * m_scale), filterHeight);
|
||
|
||
const float styleHeight = DesiredStyleHeight() * m_scale;
|
||
m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight);
|
||
const float listTop = m_filterRect.Y + m_filterRect.Height + kFilterPadV * m_scale;
|
||
m_listRect = gd::RectF(area.X, listTop, area.Width,
|
||
std::max(0.0f, m_styleRect.Y - listTop));
|
||
|
||
// 标题区右侧的“全部显示/隐藏”按钮。
|
||
const float toggleWidth = 68.0f * m_scale;
|
||
const float toggleHeight = 22.0f * m_scale;
|
||
m_toggleAllRect = gd::RectF(area.X + area.Width - kPadding * m_scale - toggleWidth,
|
||
m_headerRect.Y + (m_headerRect.Height - toggleHeight) * 0.5f,
|
||
toggleWidth, toggleHeight);
|
||
|
||
// 依据内容决定是否显示滚动条,并据此收缩列表可用宽度。
|
||
m_listFullWidth = m_listRect.Width;
|
||
EnsureRows();
|
||
RefreshScrollBar();
|
||
|
||
LayoutStyleControls();
|
||
}
|
||
|
||
void SignalPanel::Paint(gd::Graphics& g)
|
||
{
|
||
if (m_theme == nullptr || m_fonts == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
EnsureRows();
|
||
UiFillRect(g, m_area, m_theme->panelBg);
|
||
|
||
// 标题区。
|
||
{
|
||
std::wstring title = L"信号";
|
||
if (m_dataSet != nullptr && m_tab != nullptr)
|
||
{
|
||
title += L" · " + std::to_wstring(m_tab->VisibleCount()) + L"/" +
|
||
std::to_wstring(TotalSignalCount()) + L" 已选";
|
||
}
|
||
|
||
gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y,
|
||
std::max(20.0f, m_headerRect.Width - kPadding * 2.0f * m_scale -
|
||
m_toggleAllRect.Width - 8.0f * m_scale),
|
||
m_headerRect.Height);
|
||
UiText(g, UiEllipsize(title, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect,
|
||
m_theme->textStrong, TextAlign::LEFT);
|
||
|
||
const bool allVisible = AreAllSignalsVisible();
|
||
const std::wstring toggleLabel = allVisible ? L"全部隐藏" : L"全部显示";
|
||
const bool toggleHover = (m_hoverStyle == StyleHit::TOGGLE_ALL);
|
||
if (toggleHover)
|
||
{
|
||
UiFillRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->hover);
|
||
}
|
||
UiDrawRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->border, 1.0f);
|
||
const bool enabled = (m_dataSet != nullptr && !m_dataSet->Empty());
|
||
UiText(g, toggleLabel, m_fonts->uiSmall, m_toggleAllRect,
|
||
enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER);
|
||
}
|
||
|
||
// 「筛选信号」输入框:未输入内容时显示占位提示,单击进入编辑,输入实时过滤下方列表。
|
||
{
|
||
UiFillRoundRect(g, m_filterRect, kCorner * m_scale, m_theme->cardBg);
|
||
UiDrawRoundRect(g, m_filterRect, kCorner * m_scale,
|
||
m_filterEditing ? m_theme->accent : m_theme->border,
|
||
m_filterEditing ? 1.6f : 1.0f);
|
||
|
||
const float pad = kFilterTextPad * m_scale;
|
||
const gd::RectF clearRect = FilterClearRect();
|
||
// 有输入内容时在右侧留出 ✕ 的位置,文本不会压到按钮上。
|
||
const float textRight = (clearRect.Width > 0.0f) ? clearRect.X : (m_filterRect.X + m_filterRect.Width);
|
||
const float textWidth = std::max(8.0f, textRight - pad - (m_filterRect.X + pad));
|
||
const gd::RectF textRect(m_filterRect.X + pad, m_filterRect.Y, textWidth,
|
||
m_filterRect.Height);
|
||
|
||
if (m_filterEditing)
|
||
{
|
||
// 外框(m_filterRect)已经画好了底板与强调色边框,这里只叠加文本内容。
|
||
// 若改用 UiDrawEditField,会在外框内部再套一个带边框的圆角框,
|
||
// 空内容时只剩一圈内框十分突兀(用户明确要求去掉这层"内圈")。
|
||
gd::RectF fieldRect = FilterFieldRect();
|
||
fieldRect.Width = std::max(8.0f, textWidth + pad);
|
||
UiDrawEditFieldContent(g, fieldRect, m_filterEdit, m_fonts->uiSmall, m_scale);
|
||
}
|
||
else if (m_filterText.empty())
|
||
{
|
||
UiText(g, L"筛选信号", m_fonts->uiSmall, textRect, m_theme->textMuted, TextAlign::LEFT);
|
||
}
|
||
else
|
||
{
|
||
UiText(g, UiEllipsize(m_filterText, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
|
||
textRect, m_theme->text, TextAlign::LEFT);
|
||
}
|
||
|
||
// 清空按钮 ✕:仅在筛选框有内容时出现,悬停高亮(鼠标光标保持箭头)。
|
||
if (clearRect.Width > 0.0f)
|
||
{
|
||
const bool clearHot = (m_hoverStyle == StyleHit::FILTER_CLEAR);
|
||
if (clearHot)
|
||
{
|
||
UiFillRoundRect(g, clearRect, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33));
|
||
}
|
||
const std::uint32_t crossColor = clearHot ? m_theme->danger : m_theme->textMuted;
|
||
const float crossPad = clearRect.Width * 0.28f;
|
||
UiDrawLine(g, clearRect.X + crossPad, clearRect.Y + crossPad,
|
||
clearRect.X + clearRect.Width - crossPad,
|
||
clearRect.Y + clearRect.Height - crossPad, crossColor, 1.5f * m_scale);
|
||
UiDrawLine(g, clearRect.X + clearRect.Width - crossPad, clearRect.Y + crossPad,
|
||
clearRect.X + crossPad, clearRect.Y + clearRect.Height - crossPad, crossColor,
|
||
1.5f * m_scale);
|
||
}
|
||
}
|
||
|
||
// 列表区。
|
||
{
|
||
const int saveState = g.Save();
|
||
g.SetClip(gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, m_listRect.Height));
|
||
|
||
if (m_dataSet == nullptr || m_dataSet->Empty())
|
||
{
|
||
UiText(g, L"尚未加载数据,请打开数据文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
|
||
TextAlign::CENTER);
|
||
}
|
||
else if (m_rows.empty() && !m_filterText.empty())
|
||
{
|
||
// 筛选结果为空:明确提示"无匹配",而不是让人误以为数据丢失。
|
||
UiText(g, L"无匹配的筛选信号", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
|
||
TextAlign::CENTER);
|
||
}
|
||
else
|
||
{
|
||
const int rowCount = static_cast<int>(m_rows.size());
|
||
// 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制,
|
||
// 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。
|
||
const int stickyRow = StickyGroupRowIndex(rowCount);
|
||
for (int row = 0; row < rowCount; ++row)
|
||
{
|
||
if (row == stickyRow)
|
||
{
|
||
continue;
|
||
}
|
||
const gd::RectF rect = RowRect(row);
|
||
if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 折起/展开动画:信号行整组向上平移,越过组名下边界的部分要“消失(藏进组名栏)”。
|
||
// 已经完全越过的行整行跳过(省一次绘制),部分越过的行把裁剪上边界抬到组名下边界;
|
||
// ⚠️ 此时还必须把 **GDI+ 裁剪**也收紧到同一条线 —— 行内的色块 / 复选框 / 网格徽标
|
||
// 走的是 GDI+ 绘制(只受 `SetClip` 约束),不收紧就会画到组名栏上面去。
|
||
const int rowSave = g.Save();
|
||
SetRowClip(rect);
|
||
const float hideTop = AnimationHideTopForRow(row, stickyRow);
|
||
if (hideTop > rect.Y + 0.5f)
|
||
{
|
||
if (rect.Y + rect.Height <= hideTop + 0.5f)
|
||
{
|
||
g.Restore(rowSave);
|
||
ClearRowClip();
|
||
continue;
|
||
}
|
||
ClampRowClipTop(hideTop);
|
||
g.SetClip(m_rowClipRect, gd::CombineModeIntersect);
|
||
}
|
||
|
||
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
|
||
{
|
||
PaintGroupRow(g, row);
|
||
}
|
||
else
|
||
{
|
||
PaintSignalRow(g, row);
|
||
}
|
||
g.Restore(rowSave);
|
||
ClearRowClip();
|
||
}
|
||
|
||
PaintStickyGroupRow(g, stickyRow);
|
||
}
|
||
|
||
g.Restore(saveState);
|
||
}
|
||
|
||
PaintScrollBar(g);
|
||
PaintStyleSection(g);
|
||
|
||
// 分隔线。
|
||
UiDrawLine(g, m_area.X + m_area.Width - 1.0f, m_area.Y, m_area.X + m_area.Width - 1.0f,
|
||
m_area.Y + m_area.Height, m_theme->border, 1.0f);
|
||
UiDrawLine(g, m_headerRect.X, m_headerRect.Y + m_headerRect.Height - 1.0f,
|
||
m_headerRect.X + m_headerRect.Width, m_headerRect.Y + m_headerRect.Height - 1.0f,
|
||
m_theme->border, 1.0f);
|
||
if (m_styleRect.Height > 0.0f)
|
||
{
|
||
UiDrawLine(g, m_styleRect.X, m_styleRect.Y, m_styleRect.X + m_styleRect.Width, m_styleRect.Y,
|
||
m_theme->border, 1.0f);
|
||
}
|
||
}
|
||
|
||
void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font,
|
||
const gd::RectF& rect, std::uint32_t argb, TextAlign align)
|
||
{
|
||
// 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`,
|
||
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
|
||
// 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内,
|
||
// 否则垂直居中的文字会溢出到相邻行上。
|
||
UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect);
|
||
}
|
||
|
||
void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
|
||
{
|
||
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
|
||
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
|
||
gd::RectF clipped = rect;
|
||
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
|
||
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
UiFillRect(g, clipped, argb);
|
||
}
|
||
|
||
void SignalPanel::PaintGroupRow(gd::Graphics& g, int row)
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
|
||
if (group == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const gd::RectF rect = RowRect(row);
|
||
const bool hovered = (m_hoverRow == row);
|
||
if (hovered)
|
||
{
|
||
FillRowRect(g, rect, m_theme->hover);
|
||
}
|
||
FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height),
|
||
WithAlpha(m_theme->borderStrong, 0x90));
|
||
|
||
const float baseX = rect.X + kPadding * m_scale;
|
||
const float centerY = rect.Y + rect.Height * 0.5f;
|
||
const bool collapsed = IsGroupCollapsed(item.groupIndex);
|
||
|
||
// 折叠三角。
|
||
{
|
||
const float cx = baseX + kDisclosureWidth * m_scale * 0.5f;
|
||
const std::uint32_t color =
|
||
(hovered && m_hoverPart == RowPart::DISCLOSURE) ? m_theme->accent : m_theme->text;
|
||
UiDrawTriangleIcon(g, gd::RectF(cx - 6.0f * m_scale, centerY - 6.0f * m_scale, 12.0f * m_scale,
|
||
12.0f * m_scale),
|
||
color, collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
|
||
}
|
||
|
||
// 分组复选框(三态:全选 / 部分 / 未选)。
|
||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
|
||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
|
||
const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex);
|
||
UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale);
|
||
|
||
const gd::RectF closeRect = GroupCloseRect(row);
|
||
|
||
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
|
||
const std::wstring groupLabel = GroupLabelText(item.groupIndex);
|
||
// 项数文本与 `RowNameRect`(组名编辑框)共用同一来源,保证名称宽度与绘制不漂移。
|
||
const std::wstring stats = GroupStatsText(row);
|
||
const float groupWidth = GroupLabelWidth(row);
|
||
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
|
||
const float statsRight = closeRect.X - 6.0f * m_scale;
|
||
const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
|
||
const float nameAreaLeft =
|
||
nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale;
|
||
const float nameWidth =
|
||
std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft);
|
||
|
||
// 序号以强调色突出,便于在多组之间快速定位。
|
||
PaintRowText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height),
|
||
m_theme->accent, TextAlign::LEFT);
|
||
|
||
// 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。
|
||
FillRowRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale,
|
||
rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale,
|
||
rect.Height - 14.0f * m_scale),
|
||
WithAlpha(m_theme->borderStrong, 0xB0));
|
||
|
||
if (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row)
|
||
{
|
||
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale);
|
||
}
|
||
else
|
||
{
|
||
PaintRowText(g, UiEllipsize(group->name, m_fonts->uiBold, nameWidth), m_fonts->uiBold,
|
||
gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong,
|
||
TextAlign::LEFT);
|
||
}
|
||
|
||
PaintRowText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height),
|
||
m_theme->textMuted, TextAlign::RIGHT);
|
||
|
||
// 关闭按钮(从工作区移除该分组)。
|
||
const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE);
|
||
if (closeHot)
|
||
{
|
||
UiFillRoundRect(g, closeRect, 3.0f * m_scale, m_theme->pressed);
|
||
}
|
||
UiDrawCrossIcon(g, closeRect, closeHot ? m_theme->danger : m_theme->textMuted, 1.5f * m_scale);
|
||
}
|
||
|
||
void SignalPanel::PaintSignalRow(gd::Graphics& g, int row)
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return;
|
||
}
|
||
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||
const SignalStyle* style = StyleOf(ref);
|
||
if (signal == nullptr || style == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const gd::RectF rect = RowRect(row);
|
||
const bool selected = (SelectedRef() == ref);
|
||
const bool hovered = (m_hoverRow == row);
|
||
|
||
if (selected)
|
||
{
|
||
FillRowRect(g, rect, m_theme->selection);
|
||
FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent);
|
||
}
|
||
else if (hovered)
|
||
{
|
||
FillRowRect(g, rect, m_theme->hover);
|
||
}
|
||
|
||
const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale;
|
||
const float centerY = rect.Y + rect.Height * 0.5f;
|
||
|
||
// 复选框。
|
||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
|
||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||
// 可见性属于当前标签页,与全局共享的显示外观分开读取。
|
||
const bool visible = m_tab->IsVisible(ref);
|
||
UiDrawCheckBox(g, checkRect, visible ? 1 : 0, 3.0f * m_scale);
|
||
|
||
// 颜色块:叠加不透明度,透明度过低时自动补描边(与样式区色块规则一致)。
|
||
const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale,
|
||
centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale,
|
||
kSwatchSize * m_scale);
|
||
UiDrawSwatch(g, swatchRect, style->color, visible ? style->opacity : 0.3f, 2.5f * m_scale,
|
||
m_scale);
|
||
|
||
// 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。
|
||
const int laneIndex = m_tab->LaneOfSignal(ref);
|
||
const bool badgeVisible = (laneIndex >= 0) && visible;
|
||
const gd::RectF badgeRect = SignalBadgeRect(row);
|
||
const gd::RectF removeRect = SignalRemoveRect(row);
|
||
|
||
float nameRightLimit = removeRect.X - 6.0f * m_scale;
|
||
if (badgeVisible)
|
||
{
|
||
nameRightLimit = badgeRect.X - 6.0f * m_scale;
|
||
}
|
||
|
||
// 名称:完全保留文件内的列名(含 "[n]" 序号与括号描述),双击可重命名。
|
||
const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale;
|
||
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row)
|
||
{
|
||
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->ui, m_scale);
|
||
}
|
||
else
|
||
{
|
||
const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft);
|
||
const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted;
|
||
PaintRowText(g, UiEllipsize(signal->name, m_fonts->ui, nameWidth), m_fonts->ui,
|
||
gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT);
|
||
}
|
||
|
||
if (badgeVisible)
|
||
{
|
||
const bool badgeHot = hovered && (m_hoverPart == RowPart::LANE_BADGE);
|
||
UiFillRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
|
||
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
|
||
UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
|
||
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
|
||
PaintRowText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
|
||
badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||
}
|
||
|
||
// 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。
|
||
// 最后绘制,确保它永远压在最上层(与分组行 ✕ 的绘制顺序保持一致)。
|
||
{
|
||
const bool removeHot = hovered && (m_hoverPart == RowPart::REMOVE_SIGNAL);
|
||
if (removeHot)
|
||
{
|
||
UiFillRoundRect(g, removeRect, 3.0f * m_scale, m_theme->pressed);
|
||
}
|
||
UiDrawCrossIcon(g, removeRect, removeHot ? m_theme->danger : m_theme->textMuted,
|
||
1.5f * m_scale);
|
||
}
|
||
}
|
||
|
||
gd::RectF SignalPanel::ScrollThumbRect() const
|
||
{
|
||
if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height || m_listRect.Height <= 0.0f)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
|
||
const float ratio = std::min(1.0f, m_listRect.Height / m_contentHeight);
|
||
const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio);
|
||
const float maxScroll = m_contentHeight - m_listRect.Height;
|
||
const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f;
|
||
const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight);
|
||
|
||
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
|
||
}
|
||
|
||
void SignalPanel::PaintScrollBar(gd::Graphics& g)
|
||
{
|
||
if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height)
|
||
{
|
||
return;
|
||
}
|
||
|
||
UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak);
|
||
UiFillRoundRect(g, ScrollThumbRect(), kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong);
|
||
}
|
||
|
||
void SignalPanel::PaintStyleSection(gd::Graphics& g)
|
||
{
|
||
if (m_styleRect.Height <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
UiFillRect(g, m_styleRect, m_theme->panelBg);
|
||
|
||
// ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)----
|
||
PaintTargetRow(g);
|
||
|
||
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef());
|
||
if (style == nullptr || SelectedRef().IsValid() == false)
|
||
{
|
||
// 提示文本只占「目标」行以下的区域,避免与目标行重叠。
|
||
const float top = m_targetRowRect.Y + m_targetRowRect.Height;
|
||
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall,
|
||
gd::RectF(m_styleRect.X, top, m_styleRect.Width,
|
||
std::max(0.0f, m_styleRect.Y + m_styleRect.Height - top)),
|
||
m_theme->textMuted, TextAlign::CENTER);
|
||
return;
|
||
}
|
||
|
||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||
|
||
// 行标签统一以整行为基准垂直居中,确保与同行控件纵向对齐。
|
||
auto paintRowLabel = [&](int rowIndex, const wchar_t* text)
|
||
{
|
||
const gd::RectF& row = m_styleRowRects[static_cast<std::size_t>(rowIndex)];
|
||
UiText(g, text, m_fonts->uiSmall, gd::RectF(row.X, row.Y, labelWidth, row.Height),
|
||
m_theme->textMuted, TextAlign::LEFT);
|
||
};
|
||
|
||
// ---- 第一行:颜色(预设色块 + 自定义颜色入口)----
|
||
{
|
||
paintRowLabel(0, L"颜色");
|
||
|
||
int paletteCount = 0;
|
||
const std::uint32_t* palette = SignalPalette(paletteCount);
|
||
|
||
// 判定“当前是否在用自定义色”:必须同时满足
|
||
// 1) 显示颜色等于记忆的自定义色;
|
||
// 2) 该颜色不是任何一个可见的预设色。
|
||
// 否则一旦自定义色与某个预设色相同时,点击那个预设色块会把高亮错误地
|
||
// 挪到自定义色块上(用户报告的“点预设色直接跳到自定义色块”)。
|
||
bool presetMatches = false;
|
||
for (int index = 0; index < m_colorCount; ++index)
|
||
{
|
||
if (style->color == palette[index % paletteCount])
|
||
{
|
||
presetMatches = true;
|
||
break;
|
||
}
|
||
}
|
||
const bool customActive = (style->color == style->customColor) && !presetMatches;
|
||
for (int index = 0; index < m_colorCount; ++index)
|
||
{
|
||
const gd::RectF& rect = m_colorRects[static_cast<std::size_t>(index)];
|
||
const std::uint32_t color = palette[index % paletteCount];
|
||
const bool hovered = (m_hoverStyle == StyleHit::COLOR && m_hoverStyleIndex == index);
|
||
const bool active = !customActive && (style->color == color);
|
||
|
||
// 预设色块同样叠加不透明度;透明度过低时自动补描边以保证可辨识(与自定义色块一致)。
|
||
UiDrawSwatch(g, rect, color, style->opacity, kCorner * m_scale, m_scale);
|
||
if (active || hovered)
|
||
{
|
||
const gd::RectF ring(rect.X - 2.0f * m_scale, rect.Y - 2.0f * m_scale,
|
||
rect.Width + 4.0f * m_scale, rect.Height + 4.0f * m_scale);
|
||
UiDrawRoundRect(g, ring, 5.0f * m_scale,
|
||
active ? m_theme->textStrong : m_theme->borderStrong,
|
||
active ? 1.6f : 1.0f);
|
||
}
|
||
}
|
||
|
||
// 自定义颜色入口:与预设色块完全同尺寸、同外框规则的小方格,仅多一个加号图标。
|
||
// 方格始终显示记忆的自定义颜色,因此从任意预设色再次点击它都能立即回到上次的自定义色。
|
||
const bool chipHot = (m_hoverStyle == StyleHit::CUSTOM_CHIP);
|
||
const std::uint32_t chipColor =
|
||
UiDrawSwatch(g, m_customChipRect, style->customColor, style->opacity, kCorner * m_scale, m_scale);
|
||
const std::uint32_t iconColor = IsDarkColor(chipColor) ? 0xFFFDF6E3u : 0xFF073642u;
|
||
UiDrawPlusIcon(g, m_customChipRect, iconColor, 1.6f * m_scale);
|
||
|
||
// 当前正在使用自定义颜色、或鼠标悬停时,显示与预设色块一致的外框。
|
||
if (customActive || chipHot)
|
||
{
|
||
const gd::RectF& chip = m_customChipRect;
|
||
const gd::RectF ring(chip.X - 2.0f * m_scale, chip.Y - 2.0f * m_scale,
|
||
chip.Width + 4.0f * m_scale, chip.Height + 4.0f * m_scale);
|
||
UiDrawRoundRect(g, ring, 5.0f * m_scale,
|
||
customActive ? m_theme->textStrong : m_theme->borderStrong,
|
||
customActive ? 1.6f : 1.0f);
|
||
}
|
||
}
|
||
|
||
// ---- 第二行:样式(线段 / 散点 / 点线)----
|
||
{
|
||
paintRowLabel(1, L"样式");
|
||
|
||
for (int index = 0; index < kModeOptionCount; ++index)
|
||
{
|
||
const gd::RectF& rect = m_modeRects[static_cast<std::size_t>(index)];
|
||
const bool active = (style->mode == kModeOptions[index]);
|
||
const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index);
|
||
|
||
UiFillRoundRect(g, rect, kCorner * m_scale,
|
||
active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg));
|
||
UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f);
|
||
UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect,
|
||
active ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||
}
|
||
}
|
||
|
||
// ---- 第三行:调整(系数 / 镜像 / 绝对值,三个切换类按钮)----
|
||
// 结算顺序固定为「系数 → 绝对值 → 镜像」;「系数」「镜像」点击后展开二级面板。
|
||
{
|
||
paintRowLabel(2, L"调整");
|
||
|
||
const auto paintToggle = [&](int index, const wchar_t* label, bool checked, StyleHit target)
|
||
{
|
||
const gd::RectF& rect = m_adjustRects[static_cast<std::size_t>(index)];
|
||
const bool hot = (m_hoverStyle == target);
|
||
UiFillRoundRect(g, rect, kCorner * m_scale,
|
||
checked ? m_theme->accentSoft : (hot ? m_theme->hover : m_theme->cardBg));
|
||
UiDrawRoundRect(g, rect, kCorner * m_scale,
|
||
checked ? m_theme->accent : m_theme->border, 1.0f);
|
||
UiText(g, label, m_fonts->uiSmall, rect,
|
||
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||
};
|
||
// 系数/偏移:系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。
|
||
paintToggle(kAdjustGain, L"系数/偏移",
|
||
!GainIsDefault(style->gain) || style->Offset(), StyleHit::GAIN_ADJUST);
|
||
// 镜像:水平或垂直任一被勾选即显示为激活态。
|
||
paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST);
|
||
paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE);
|
||
}
|
||
|
||
// ---- 第四行:线宽(数值可点击输入)----
|
||
{
|
||
paintRowLabel(3, L"线宽");
|
||
|
||
const double ratio =
|
||
(static_cast<double>(style->lineWidth) - kWidthMin) / (kWidthMax - kWidthMin);
|
||
const bool active = m_draggingWidth || (m_hoverStyle == StyleHit::WIDTH_SLIDER);
|
||
UiDrawSlider(g, m_widthTrackRect, ratio, m_theme->accent, active, m_scale);
|
||
|
||
if (m_editField == StyleEditField::WIDTH)
|
||
{
|
||
UiDrawEditField(g, m_widthValueRect, m_edit, m_fonts->monoSmall, m_scale);
|
||
}
|
||
else
|
||
{
|
||
const std::wstring numberText = FormatNumber(style->lineWidth, 1);
|
||
const std::wstring fullText = numberText + L" px";
|
||
// 空间不足时省略单位,保证数值本身完整可读。
|
||
const std::wstring shown =
|
||
(UiMeasureTextWidth(m_fonts->monoSmall, fullText) >
|
||
m_widthValueRect.Width - 6.0f * m_scale)
|
||
? numberText
|
||
: fullText;
|
||
UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT);
|
||
}
|
||
}
|
||
|
||
// ---- 第五行:透明度(数值可点击输入)----
|
||
{
|
||
paintRowLabel(4, L"透明度");
|
||
|
||
const bool active = m_draggingOpacity || (m_hoverStyle == StyleHit::OPACITY_SLIDER);
|
||
UiDrawSlider(g, m_opacityTrackRect, static_cast<double>(style->opacity), m_theme->accent, active,
|
||
m_scale);
|
||
|
||
if (m_editField == StyleEditField::OPACITY)
|
||
{
|
||
UiDrawEditField(g, m_opacityValueRect, m_edit, m_fonts->monoSmall, m_scale);
|
||
}
|
||
else
|
||
{
|
||
const int percent = static_cast<int>(style->opacity * 100.0f + 0.5f);
|
||
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect,
|
||
m_theme->textStrong, TextAlign::RIGHT);
|
||
}
|
||
}
|
||
}
|
||
|
||
void SignalPanel::PaintTargetRow(gd::Graphics& g)
|
||
{
|
||
if (m_fonts == nullptr || m_theme == nullptr || m_targetRowRect.Width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
UpdateTargetRowGeometry();
|
||
|
||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||
// 行标签与前五行一致(同一标签列宽度、同一纵向居中基准)。
|
||
UiText(g, L"目标", m_fonts->uiSmall,
|
||
gd::RectF(m_targetRowRect.X, m_targetRowRect.Y, labelWidth, m_targetRowRect.Height),
|
||
m_theme->textMuted, TextAlign::LEFT);
|
||
|
||
const SignalRef& target = SelectedRef();
|
||
const SignalData* signal =
|
||
(m_dataSet != nullptr && target.IsValid()) ? m_dataSet->Resolve(target) : nullptr;
|
||
|
||
// 没有可编辑的目标信号:只显示占位提示(与样式区其余行的"请选择信号"提示一致)。
|
||
if (signal == nullptr)
|
||
{
|
||
const float contentLeft = m_targetRowRect.X + labelWidth + kStyleLabelGap * m_scale;
|
||
UiText(g, L"未选择信号", m_fonts->uiSmall,
|
||
gd::RectF(contentLeft, m_targetRowRect.Y,
|
||
std::max(0.0f, m_targetRowRect.X + m_targetRowRect.Width - contentLeft),
|
||
m_targetRowRect.Height),
|
||
m_theme->textMuted, TextAlign::LEFT);
|
||
return;
|
||
}
|
||
|
||
// 名称:悬停时给一层与信号列表行一致的背景高亮(用户要求"显示与交互效果相同")。
|
||
if (m_hoverStyle == StyleHit::TARGET_NAME && m_targetNameRect.Width > 0.0f)
|
||
{
|
||
UiFillRoundRect(g, m_targetNameRect, kCorner * m_scale, m_theme->hover);
|
||
}
|
||
|
||
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow < 0)
|
||
{
|
||
UiDrawEditField(g, EditFieldRect(), m_edit, m_fonts->ui, m_scale);
|
||
}
|
||
else
|
||
{
|
||
// 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。
|
||
const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted;
|
||
// ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、
|
||
// 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。
|
||
UiText(g, UiEllipsize(signal->name, m_fonts->ui, m_targetNameRect.Width), m_fonts->ui,
|
||
m_targetNameRect, nameColor, TextAlign::LEFT);
|
||
}
|
||
|
||
// GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求),
|
||
// 并与其它控件一样**右对齐到内容列右边缘**。
|
||
if (m_targetBadgeRect.Width > 0.0f)
|
||
{
|
||
const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE);
|
||
UiFillRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
|
||
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
|
||
UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
|
||
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
|
||
UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
|
||
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||
}
|
||
}
|
||
|
||
bool SignalPanel::SignalScrollTarget(const SignalRef& ref, float& desired) const
|
||
{
|
||
desired = m_scrollOffset;
|
||
if (!ref.IsValid() || m_rows.empty() || m_listRect.Height <= 0.0f)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
float top = 0.0f;
|
||
float rowTop = -1.0f;
|
||
float rowHeight = 0.0f;
|
||
int rowIndex = -1;
|
||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||
{
|
||
const ListRow& row = m_rows[index];
|
||
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
|
||
row.signalIndex == ref.signalIndex)
|
||
{
|
||
rowTop = top;
|
||
rowHeight = row.height;
|
||
rowIndex = static_cast<int>(index);
|
||
break;
|
||
}
|
||
top += row.height;
|
||
}
|
||
if (rowTop < 0.0f)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// ⚠️ **顶部避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
|
||
// 上行方向必须让目标行落在悬浮组名的下方。目前悬浮的组名只可能是目标信号**自己那个**
|
||
// 分组:分组行在其信号行之上、各分组连续排列,若目标行完整可见,则更靠下的分组行
|
||
// 不可能同时位于列表顶部(参见 `StickyGroupRowTop` 的公式)。
|
||
float topInset = 0.0f;
|
||
for (int index = rowIndex; index >= 0; --index)
|
||
{
|
||
if (m_rows[static_cast<std::size_t>(index)].kind == RowKind::GROUP)
|
||
{
|
||
topInset = m_rows[static_cast<std::size_t>(index)].height;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const float visibleTop = m_scrollOffset + topInset;
|
||
const float visibleBottom = m_scrollOffset + m_listRect.Height;
|
||
if (rowTop < visibleTop)
|
||
{
|
||
desired = rowTop - topInset; // 向上滚动,直到它完整出现在组名下方
|
||
}
|
||
else if (rowTop + rowHeight > visibleBottom)
|
||
{
|
||
desired = rowTop + rowHeight - m_listRect.Height; // 向下滚动,直到完整出现
|
||
}
|
||
else
|
||
{
|
||
desired = m_scrollOffset; // 已经完整可见:不动
|
||
}
|
||
desired = std::max(0.0f, std::min(desired, std::max(0.0f, m_contentHeight - m_listRect.Height)));
|
||
return true;
|
||
}
|
||
|
||
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
|
||
{
|
||
EnsureRows();
|
||
float desired = m_scrollOffset;
|
||
if (!SignalScrollTarget(ref, desired))
|
||
{
|
||
return;
|
||
}
|
||
// 把列表平滑滚动到目标信号完整可见。
|
||
StartScrollAnimationTo(desired);
|
||
}
|
||
|
||
bool SignalPanel::AdvanceScrollAnimation()
|
||
{
|
||
if (!m_scrollAnimActive)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const std::uint64_t now = GetTickCount64();
|
||
const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0;
|
||
float progress = (m_scrollAnimDurationMs > 0u)
|
||
? static_cast<float>(elapsed) / static_cast<float>(m_scrollAnimDurationMs)
|
||
: 1.0f;
|
||
if (progress > 1.0f)
|
||
{
|
||
progress = 1.0f;
|
||
}
|
||
|
||
// ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。
|
||
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
|
||
m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased;
|
||
|
||
if (progress >= 1.0f)
|
||
{
|
||
m_scrollOffset = m_scrollAnimTo;
|
||
m_scrollAnimActive = false;
|
||
ClampScroll();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::AreAllSignalsVisible() const
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty())
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->VisibleCount() == TotalSignalCount();
|
||
}
|
||
|
||
bool SignalPanel::ApplyWidthFromX(float x)
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const double ratio = UiSliderRatioFromX(m_widthTrackRect, x);
|
||
float value = static_cast<float>(kWidthMin + ratio * (kWidthMax - kWidthMin));
|
||
value = std::round(value * 10.0f) / 10.0f;
|
||
value = ClampFloat(value, kWidthMin, kWidthMax);
|
||
if (std::fabs(value - style->lineWidth) < 0.001f)
|
||
{
|
||
return false;
|
||
}
|
||
style->lineWidth = value;
|
||
return true;
|
||
}
|
||
|
||
gd::RectF SignalPanel::RowNameRect(int row) const
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
|
||
const gd::RectF rect = RowRect(row);
|
||
const bool isGroup = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP);
|
||
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
|
||
const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale;
|
||
|
||
float left = 0.0f;
|
||
float right = 0.0f;
|
||
if (isGroup)
|
||
{
|
||
// 分组名需要跳过左侧的“组X”标签与分隔线,并且**右侧让出“N 项”计数**:
|
||
// 双击进入编辑时,输入框不得把组内信号个数包进去(用户 2026-10-16 报告)。
|
||
left = checkRight + 8.0f * m_scale + GroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale +
|
||
kGroupSeparatorWidth * m_scale;
|
||
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
|
||
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row));
|
||
right = statsRight - statsWidth - 6.0f * m_scale;
|
||
}
|
||
else
|
||
{
|
||
left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale;
|
||
// 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。
|
||
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].groupIndex,
|
||
m_rows[static_cast<std::size_t>(row)].signalIndex};
|
||
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref) &&
|
||
(m_tab->LaneOfSignal(ref) >= 0);
|
||
right = (badgeVisible ? SignalBadgeRect(row).X : SignalRemoveRect(row).X) - 6.0f * m_scale;
|
||
}
|
||
|
||
if (right <= left)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale);
|
||
}
|
||
|
||
float SignalPanel::GroupLabelWidth(int row) const
|
||
{
|
||
if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 ||
|
||
row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return 0.0f;
|
||
}
|
||
return UiMeasureTextWidth(m_fonts->uiBold,
|
||
GroupLabelText(m_rows[static_cast<std::size_t>(row)].groupIndex));
|
||
}
|
||
|
||
std::wstring SignalPanel::GroupLabelText(int groupIndex)
|
||
{
|
||
return L"组" + std::to_wstring(groupIndex + 1);
|
||
}
|
||
|
||
std::wstring SignalPanel::GroupStatsText(int row) const
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return std::wstring();
|
||
}
|
||
// 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数
|
||
//(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。
|
||
return std::to_wstring(m_rows[static_cast<std::size_t>(row)].displayCount) + L" 项";
|
||
}
|
||
|
||
bool SignalPanel::BeginEdit(StyleEditField field, int row)
|
||
{
|
||
if (m_tab == nullptr || field == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
if (m_editField == field && m_editRow == row)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
std::wstring initial;
|
||
switch (field)
|
||
{
|
||
case StyleEditField::WIDTH:
|
||
{
|
||
const SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
initial = FormatNumber(style->lineWidth, 1);
|
||
break;
|
||
}
|
||
case StyleEditField::OPACITY:
|
||
{
|
||
const SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
initial = std::to_wstring(static_cast<int>(style->opacity * 100.0f + 0.5f));
|
||
break;
|
||
}
|
||
case StyleEditField::SIGNAL_NAME:
|
||
{
|
||
// `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。
|
||
if (row < 0)
|
||
{
|
||
const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr;
|
||
if (signal == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
initial = signal->name;
|
||
break;
|
||
}
|
||
if (row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return false;
|
||
}
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex});
|
||
if (signal == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
initial = signal->name;
|
||
break;
|
||
}
|
||
case StyleEditField::GROUP_TITLE:
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return false;
|
||
}
|
||
const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast<std::size_t>(row)].groupIndex);
|
||
if (group == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
initial = group->name;
|
||
break;
|
||
}
|
||
default:
|
||
return false;
|
||
}
|
||
|
||
m_editField = field;
|
||
m_editRow = row;
|
||
|
||
if (field == StyleEditField::SIGNAL_NAME && row < 0)
|
||
{
|
||
// 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。
|
||
UpdateTargetRowGeometry();
|
||
}
|
||
|
||
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
|
||
const bool nameField =
|
||
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE);
|
||
// 不限长度:名称与数值都由各自的提交逻辑处理超长内容。
|
||
m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::EndEdit(bool commit)
|
||
{
|
||
if (m_editField == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const StyleEditField field = m_editField;
|
||
const int row = m_editRow;
|
||
const std::wstring text = m_edit.Text();
|
||
|
||
m_editField = StyleEditField::NONE;
|
||
m_editRow = -1;
|
||
m_edit.End();
|
||
|
||
if (!commit || m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
const std::wstring trimmed = Trim(text);
|
||
switch (field)
|
||
{
|
||
case StyleEditField::WIDTH:
|
||
{
|
||
if (trimmed.empty())
|
||
{
|
||
break;
|
||
}
|
||
wchar_t* stop = nullptr;
|
||
const double parsed = std::wcstod(trimmed.c_str(), &stop);
|
||
if (stop == trimmed.c_str() || parsed != parsed)
|
||
{
|
||
break;
|
||
}
|
||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
const float value = ClampFloat(static_cast<float>(parsed), kWidthMin, kWidthMax);
|
||
style->lineWidth = std::round(value * 10.0f) / 10.0f;
|
||
}
|
||
break;
|
||
}
|
||
case StyleEditField::OPACITY:
|
||
{
|
||
if (trimmed.empty())
|
||
{
|
||
break;
|
||
}
|
||
wchar_t* stop = nullptr;
|
||
const double parsed = std::wcstod(trimmed.c_str(), &stop);
|
||
if (stop == trimmed.c_str() || parsed != parsed)
|
||
{
|
||
break;
|
||
}
|
||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
style->opacity = ClampFloat(static_cast<float>(parsed) / 100.0f, 0.0f, 1.0f);
|
||
}
|
||
break;
|
||
}
|
||
case StyleEditField::SIGNAL_NAME:
|
||
{
|
||
if (m_dataSetWritable == nullptr)
|
||
{
|
||
break;
|
||
}
|
||
if (row < 0)
|
||
{
|
||
// 样式区「目标」行:重命名当前选中的(目标)信号。
|
||
const SignalRef& target = SelectedRef();
|
||
if (target.IsValid())
|
||
{
|
||
m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed);
|
||
}
|
||
// 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。
|
||
m_targetGeomValid = false;
|
||
break;
|
||
}
|
||
if (row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
break;
|
||
}
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed);
|
||
break;
|
||
}
|
||
case StyleEditField::GROUP_TITLE:
|
||
{
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr)
|
||
{
|
||
break;
|
||
}
|
||
m_dataSetWritable->RenameGroup(m_rows[static_cast<std::size_t>(row)].groupIndex, trimmed);
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
gd::RectF SignalPanel::FilterFieldRect() const
|
||
{
|
||
// 输入框内容区:左右与上下使用**同一内缩**,避免编辑态出现"左右空隙明显更大"的观感;
|
||
// 占位提示与显示文本也用同一内缩,因此进入/退出编辑时文字不会跳动。
|
||
const float inset = kFilterTextPad * m_scale;
|
||
return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + inset,
|
||
std::max(8.0f, m_filterRect.Width - inset * 2.0f),
|
||
std::max(8.0f, m_filterRect.Height - inset * 2.0f));
|
||
}
|
||
|
||
gd::RectF SignalPanel::FilterClearRect() const
|
||
{
|
||
if (m_filterText.empty())
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
const float size = std::max(12.0f, m_filterRect.Height - kFilterTextPad * 2.0f * m_scale);
|
||
const float right = m_filterRect.X + m_filterRect.Width - kFilterTextPad * m_scale;
|
||
return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size);
|
||
}
|
||
|
||
gd::RectF SignalPanel::EditFieldRect()
|
||
{
|
||
switch (m_editField)
|
||
{
|
||
case StyleEditField::WIDTH:
|
||
return m_widthValueRect;
|
||
case StyleEditField::OPACITY:
|
||
return m_opacityValueRect;
|
||
case StyleEditField::SIGNAL_NAME:
|
||
case StyleEditField::GROUP_TITLE:
|
||
// `row < 0` = 样式区「目标」行:输入框只覆盖**信号名**那一块,
|
||
// 不得把名称后面的 GX 标签也包进去(用户 2026-10-16 报告)。
|
||
if (m_editRow < 0)
|
||
{
|
||
UpdateTargetRowGeometry();
|
||
return m_targetNameRect;
|
||
}
|
||
return RowNameRect(m_editRow);
|
||
case StyleEditField::NONE:
|
||
default:
|
||
return gd::RectF();
|
||
}
|
||
}
|
||
|
||
bool SignalPanel::OnEditChar(wchar_t character)
|
||
{
|
||
// 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。
|
||
if (m_filterEditing)
|
||
{
|
||
switch (character)
|
||
{
|
||
case 0x03: // Ctrl+C
|
||
m_filterEdit.CopyToClipboard(m_host);
|
||
return false;
|
||
case 0x18: // Ctrl+X
|
||
m_filterEdit.CutToClipboard(m_host);
|
||
ApplyFilterText(m_filterEdit.Text());
|
||
return true;
|
||
case 0x16: // Ctrl+V
|
||
m_filterEdit.PasteFromClipboard(m_host);
|
||
ApplyFilterText(m_filterEdit.Text());
|
||
return true;
|
||
case 0x01: // Ctrl+A
|
||
m_filterEdit.SelectAll();
|
||
return true;
|
||
default:
|
||
break;
|
||
}
|
||
if (character == L'\r' || character == L'\n')
|
||
{
|
||
return EndFilterEdit();
|
||
}
|
||
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,运里忽略控制字符避免重复删除。
|
||
if (character < 0x20 || character == 0x7F)
|
||
{
|
||
return false;
|
||
}
|
||
if (!m_filterEdit.Insert(character))
|
||
{
|
||
return false;
|
||
}
|
||
ApplyFilterText(m_filterEdit.Text());
|
||
return true;
|
||
}
|
||
|
||
if (m_editField == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
|
||
switch (character)
|
||
{
|
||
case 0x03: // Ctrl+C
|
||
m_edit.CopyToClipboard(m_host);
|
||
return false;
|
||
case 0x18: // Ctrl+X
|
||
return m_edit.CutToClipboard(m_host);
|
||
case 0x16: // Ctrl+V
|
||
return m_edit.PasteFromClipboard(m_host);
|
||
case 0x01: // Ctrl+A
|
||
m_edit.SelectAll();
|
||
return true;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (character == L'\r' || character == L'\n')
|
||
{
|
||
return EndEdit(true);
|
||
}
|
||
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符,
|
||
// 否则同一次按键会被处理两次(一次删除两个字符)。
|
||
if (character < 0x20 || character == 0x7F)
|
||
{
|
||
return false;
|
||
}
|
||
// 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。
|
||
if (m_editField == StyleEditField::OPACITY && character == L'%')
|
||
{
|
||
return false;
|
||
}
|
||
return m_edit.Insert(character);
|
||
}
|
||
|
||
bool SignalPanel::OnEditKey(WPARAM key)
|
||
{
|
||
// 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。
|
||
if (m_filterEditing)
|
||
{
|
||
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||
switch (key)
|
||
{
|
||
case VK_RETURN:
|
||
return EndFilterEdit();
|
||
case VK_ESCAPE:
|
||
{
|
||
const bool changed = m_filterEdit.Text() != m_filterText;
|
||
EndFilterEdit();
|
||
return changed;
|
||
}
|
||
case VK_BACK:
|
||
{
|
||
if (!m_filterEdit.Backspace())
|
||
{
|
||
return false;
|
||
}
|
||
ApplyFilterText(m_filterEdit.Text());
|
||
return true;
|
||
}
|
||
case VK_DELETE:
|
||
{
|
||
if (!m_filterEdit.DeleteForward())
|
||
{
|
||
return false;
|
||
}
|
||
ApplyFilterText(m_filterEdit.Text());
|
||
return true;
|
||
}
|
||
case VK_LEFT:
|
||
return m_filterEdit.MoveCaret(-1, shift);
|
||
case VK_RIGHT:
|
||
return m_filterEdit.MoveCaret(1, shift);
|
||
case VK_HOME:
|
||
return m_filterEdit.MoveHome(shift);
|
||
case VK_END:
|
||
return m_filterEdit.MoveEnd(shift);
|
||
case 'A':
|
||
if (control)
|
||
{
|
||
m_filterEdit.SelectAll();
|
||
return true;
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (m_editField == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||
|
||
switch (key)
|
||
{
|
||
case VK_RETURN:
|
||
return EndEdit(true);
|
||
case VK_ESCAPE:
|
||
return EndEdit(false);
|
||
case VK_BACK:
|
||
return m_edit.Backspace();
|
||
case VK_DELETE:
|
||
return m_edit.DeleteForward();
|
||
case VK_LEFT:
|
||
return m_edit.MoveCaret(-1, shift);
|
||
case VK_RIGHT:
|
||
return m_edit.MoveCaret(1, shift);
|
||
case VK_HOME:
|
||
return m_edit.MoveHome(shift);
|
||
case VK_END:
|
||
return m_edit.MoveEnd(shift);
|
||
case 'A':
|
||
if (control)
|
||
{
|
||
m_edit.SelectAll();
|
||
return true;
|
||
}
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::CollapseEditSelectionToEnd()
|
||
{
|
||
if (m_filterEditing)
|
||
{
|
||
return m_filterEdit.DeselectToEnd();
|
||
}
|
||
if (m_editField == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
return m_edit.DeselectToEnd();
|
||
}
|
||
|
||
bool SignalPanel::OnEditMouseDown(int x, int y)
|
||
{
|
||
if (m_filterEditing)
|
||
{
|
||
// 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理,
|
||
// 否则点击会被当成"把光标移到此处"而清空不了内容。
|
||
// 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。
|
||
const gd::RectF clearRect = FilterClearRect();
|
||
if (clearRect.Width > 0.0f &&
|
||
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
m_pendingFilterClear = true;
|
||
return true;
|
||
}
|
||
return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y);
|
||
}
|
||
if (m_editField == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
return UiEditFieldMouseDown(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x, y);
|
||
}
|
||
|
||
bool SignalPanel::OnEditMouseMove(int x)
|
||
{
|
||
if (m_filterEditing)
|
||
{
|
||
return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x);
|
||
}
|
||
if (m_editField == StyleEditField::NONE)
|
||
{
|
||
return false;
|
||
}
|
||
return UiEditFieldMouseMove(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x);
|
||
}
|
||
|
||
bool SignalPanel::OnEditMouseUp(int x, int y)
|
||
{
|
||
if (m_filterEditing)
|
||
{
|
||
// 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位),
|
||
// 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算:
|
||
// 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。
|
||
if (m_pendingFilterClear)
|
||
{
|
||
m_pendingFilterClear = false;
|
||
const gd::RectF clearRect = FilterClearRect();
|
||
if (clearRect.Width > 0.0f &&
|
||
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
|
||
{
|
||
ClearFilter();
|
||
return true;
|
||
}
|
||
}
|
||
if (!m_filterEdit.Dragging())
|
||
{
|
||
return false;
|
||
}
|
||
m_filterEdit.EndDrag();
|
||
return true;
|
||
}
|
||
if (!m_edit.Dragging())
|
||
{
|
||
return false;
|
||
}
|
||
m_edit.EndDrag();
|
||
return true;
|
||
}
|
||
|
||
const SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) const
|
||
{
|
||
return (m_dataSet != nullptr) ? m_dataSet->Style(ref) : nullptr;
|
||
}
|
||
|
||
SignalStyle* SignalPanel::StyleOf(const SignalRef& ref)
|
||
{
|
||
return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr;
|
||
}
|
||
|
||
const SignalRef& SignalPanel::SelectedRef() const
|
||
{
|
||
static const SignalRef fallback;
|
||
return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback;
|
||
}
|
||
|
||
void SignalPanel::SelectSignal(const SignalRef& ref)
|
||
{
|
||
if (m_dataSetWritable != nullptr)
|
||
{
|
||
m_dataSetWritable->SetSelected(ref);
|
||
}
|
||
}
|
||
|
||
bool SignalPanel::ApplyColor(std::uint32_t color)
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。
|
||
const bool changed = (style->color != color) || (style->customColor != color);
|
||
style->color = color;
|
||
style->customColor = color;
|
||
return changed;
|
||
}
|
||
|
||
bool SignalPanel::ApplyOpacityFromX(float x)
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const float value = static_cast<float>(UiSliderRatioFromX(m_opacityTrackRect, x));
|
||
if (std::fabs(value - style->opacity) < 0.005f)
|
||
{
|
||
return false;
|
||
}
|
||
style->opacity = value;
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::OpenColorPickerPopup(const SignalStyle& style)
|
||
{
|
||
if (m_colorPicker == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||
// 以记忆的自定义颜色作为初始取值,实现“再次打开即恢复上次取色”。
|
||
m_colorPicker->Open(bounds, m_customChipRect, style.customColor);
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::OpenGainMenu()
|
||
{
|
||
if (m_menu == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
const SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 二级面板:两张“标题(带还原图标)+ 输入框”的组合:
|
||
// 1. 「系数调整」:一整行系数输入框;
|
||
// 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
|
||
//(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。)
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem gainHeader;
|
||
gainHeader.kind = MenuItemKind::HEADER_ACTION;
|
||
gainHeader.label = L"系数调整";
|
||
gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1
|
||
items.push_back(gainHeader);
|
||
|
||
// 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。
|
||
const std::wstring gainText = FormatGainText(style->gain);
|
||
const std::wstring gainEditText = FormatNumberText(static_cast<double>(style->gain));
|
||
MenuItem gainEdit;
|
||
gainEdit.kind = MenuItemKind::EDIT;
|
||
gainEdit.label.clear(); // 不显示说明文字:输入框独占整行
|
||
gainEdit.id = CMD_GAIN_EDIT;
|
||
gainEdit.value = gainText;
|
||
gainEdit.editValue = gainEditText;
|
||
gainEdit.filter = EditFilter::NUMBER;
|
||
items.push_back(gainEdit);
|
||
|
||
MenuItem offsetHeader;
|
||
offsetHeader.kind = MenuItemKind::HEADER_ACTION;
|
||
offsetHeader.label = L"偏移调整";
|
||
offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值
|
||
items.push_back(offsetHeader);
|
||
|
||
// 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。
|
||
MenuItem offsetY;
|
||
offsetY.kind = MenuItemKind::EDIT;
|
||
offsetY.label = L"Y轴";
|
||
offsetY.id = CMD_OFFSET_Y;
|
||
offsetY.value = FormatSignedNumber(static_cast<double>(style->offsetY));
|
||
offsetY.editValue = FormatNumberText(static_cast<double>(style->offsetY));
|
||
offsetY.filter = EditFilter::NUMBER;
|
||
items.push_back(offsetY);
|
||
|
||
// 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。
|
||
// 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求);
|
||
// 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。
|
||
MenuItem offsetTime;
|
||
offsetTime.kind = MenuItemKind::EDIT;
|
||
offsetTime.label = L"时间轴";
|
||
offsetTime.id = CMD_OFFSET_TIME;
|
||
offsetTime.value = FormatOffsetTimeText(style->offsetTime);
|
||
offsetTime.editValue = FormatNumberText(style->offsetTime);
|
||
offsetTime.editSuffix = L"s";
|
||
offsetTime.filter = EditFilter::NUMBER;
|
||
items.push_back(offsetTime);
|
||
|
||
// 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角,
|
||
// 并再往上让出 `kAdjustMenuGap`(用户明确要求)。
|
||
//(旧实现在按钮下沿向右下展开,面板会盖住下方内容。)
|
||
const gd::RectF& button = m_adjustRects[kAdjustGain];
|
||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||
const int anchorY =
|
||
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
|
||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
|
||
true);
|
||
// ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE →
|
||
// 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。
|
||
ClearStyleHover();
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::OpenMirrorMenu()
|
||
{
|
||
if (m_menu == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
const SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem horizontal;
|
||
horizontal.kind = MenuItemKind::NORMAL;
|
||
horizontal.label = L"水平镜像";
|
||
horizontal.id = CMD_MIRROR_HORIZONTAL;
|
||
horizontal.checked = style->mirrorHorizontal;
|
||
horizontal.keepOpen = true;
|
||
items.push_back(horizontal);
|
||
|
||
MenuItem vertical;
|
||
vertical.kind = MenuItemKind::NORMAL;
|
||
vertical.label = L"垂直镜像";
|
||
vertical.id = CMD_MIRROR_VERTICAL;
|
||
vertical.checked = style->mirrorVertical;
|
||
vertical.keepOpen = true;
|
||
items.push_back(vertical);
|
||
|
||
const gd::RectF& button = m_adjustRects[kAdjustMirror];
|
||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||
// 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。
|
||
const int anchorY =
|
||
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
|
||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
|
||
true);
|
||
ClearStyleHover();
|
||
return true;
|
||
}
|
||
|
||
void SignalPanel::ClearStyleHover()
|
||
{
|
||
if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0)
|
||
{
|
||
return;
|
||
}
|
||
m_hoverStyle = StyleHit::NONE;
|
||
m_hoverStyleIndex = -1;
|
||
}
|
||
|
||
bool SignalPanel::CloseTransientUi()
|
||
{
|
||
bool changed = false;
|
||
if (m_editField != StyleEditField::NONE)
|
||
{
|
||
changed = EndEdit(true) || changed;
|
||
}
|
||
// 点击面板之外也要结束「筛选信号」的编辑状态。
|
||
if (m_filterEditing)
|
||
{
|
||
changed = EndFilterEdit() || changed;
|
||
}
|
||
if (m_colorPicker != nullptr && m_colorPicker->IsOpen())
|
||
{
|
||
m_colorPicker->Close();
|
||
changed = true;
|
||
}
|
||
// 面板外点击:取消尚未抬起确认的关闭操作。
|
||
m_pendingClosePart = RowPart::NONE;
|
||
m_pendingCloseRow = -1;
|
||
m_pendingFilterClear = false;
|
||
m_pendingGainMenu = false;
|
||
m_pendingMirrorMenu = false;
|
||
return changed;
|
||
}
|
||
|
||
bool SignalPanel::OnMouseMove(int x, int y)
|
||
{
|
||
// 拖拽优先级最高。
|
||
if (m_draggingOpacity)
|
||
{
|
||
return ApplyOpacityFromX(static_cast<float>(x));
|
||
}
|
||
if (m_draggingWidth)
|
||
{
|
||
return ApplyWidthFromX(static_cast<float>(x));
|
||
}
|
||
if (m_draggingScrollBar)
|
||
{
|
||
const gd::RectF thumb = ScrollThumbRect();
|
||
// ⚠️ 拖拽期间滑块高度不会变(内容高度与列表高度都没变),因此 `thumb.Height` 可安全
|
||
// 用于本次换算;但 `m_scrollBarRect.Y` 必须取**按下时**记录的值(局部刷新/重排可能
|
||
// 改掉它)。坐标一律用**客户区** y,因此鼠标移到面板外(如绘图区)也能继续跟随。
|
||
const float travel = static_cast<float>(y - m_scrollGrabOffset) - m_scrollBarRect.Y;
|
||
const float usable = m_scrollBarRect.Height - thumb.Height;
|
||
if (usable > 0.0f)
|
||
{
|
||
const float progress = std::max(0.0f, std::min(1.0f, travel / usable));
|
||
m_scrollOffset = progress * (m_contentHeight - m_listRect.Height);
|
||
ClampScroll();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
RowPart part = RowPart::NONE;
|
||
const int hoverRow = HitRow(x, y, part);
|
||
|
||
StyleHit hoverStyle = StyleHit::NONE;
|
||
int hoverStyleIndex = -1;
|
||
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
|
||
if (UiPointInRect(m_toggleAllRect, fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::TOGGLE_ALL;
|
||
}
|
||
else if (UiPointInRect(FilterClearRect(), fx, fy))
|
||
{
|
||
// 筛选框右侧的清空 ✕(仅在筛选框有内容时存在)。
|
||
hoverStyle = StyleHit::FILTER_CLEAR;
|
||
}
|
||
else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
|
||
{
|
||
// 「目标」行的信号名 / GX 标签(优先于其余样式控件:它们几何不重叠,
|
||
// 但显式放在最前更易读)。
|
||
UpdateTargetRowGeometry();
|
||
if (UiPointInRect(m_targetRowRect, fx, fy))
|
||
{
|
||
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::TARGET_BADGE;
|
||
}
|
||
else if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::TARGET_NAME;
|
||
}
|
||
}
|
||
for (int index = 0; hoverStyle == StyleHit::NONE && index < m_colorCount; ++index)
|
||
{
|
||
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::COLOR;
|
||
hoverStyleIndex = index;
|
||
break;
|
||
}
|
||
}
|
||
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_customChipRect, fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::CUSTOM_CHIP;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE)
|
||
{
|
||
for (int index = 0; index < kModeOptionCount; ++index)
|
||
{
|
||
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::MODE;
|
||
hoverStyleIndex = index;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (hoverStyle == StyleHit::NONE &&
|
||
UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::OPACITY_SLIDER;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE &&
|
||
UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::WIDTH_SLIDER;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_opacityValueRect, fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::OPACITY_VALUE;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthValueRect, fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::WIDTH_VALUE;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::GAIN_ADJUST;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::MIRROR_ADJUST;
|
||
}
|
||
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
|
||
{
|
||
hoverStyle = StyleHit::ABSOLUTE_TOGGLE;
|
||
}
|
||
}
|
||
|
||
if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex &&
|
||
part == m_hoverPart)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
m_hoverRow = hoverRow;
|
||
m_hoverPart = part;
|
||
m_hoverStyle = hoverStyle;
|
||
m_hoverStyleIndex = hoverStyleIndex;
|
||
|
||
// 供绘图区联动高亮的悬停信号(用户 2026-10-15 要求):
|
||
// * 列表里悬停某条信号行 → 高亮它所在的网格 + 该信号的曲线;
|
||
// * 样式区「目标」行的信号名 / GX 标签上悬停 → 同样联动高亮。
|
||
// ⚠️ ✕(移除该信号)不算"悬停到该信号":它是一条即将被删除的信号,
|
||
// 高亮它会让人误以为要保留它。
|
||
SignalRef hovered;
|
||
if (hoverRow >= 0 && hoverRow < static_cast<int>(m_rows.size()) &&
|
||
m_rows[static_cast<std::size_t>(hoverRow)].kind == RowKind::SIGNAL &&
|
||
part != RowPart::REMOVE_SIGNAL)
|
||
{
|
||
hovered = SignalRef{m_rows[static_cast<std::size_t>(hoverRow)].groupIndex,
|
||
m_rows[static_cast<std::size_t>(hoverRow)].signalIndex};
|
||
}
|
||
else if (hoverStyle == StyleHit::TARGET_NAME || hoverStyle == StyleHit::TARGET_BADGE)
|
||
{
|
||
hovered = SelectedRef();
|
||
}
|
||
m_hoveredSignal = hovered;
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::OnLButtonDown(int x, int y)
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
|
||
// 新的按下开始:上一轮留下的"待确认关闭"一律作废。
|
||
m_pendingClosePart = RowPart::NONE;
|
||
m_pendingCloseRow = -1;
|
||
m_pendingFilterClear = false;
|
||
|
||
// 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。
|
||
if (IsOnFilterBox(x, y))
|
||
{
|
||
// 右侧 ✕:**抬起**时才清空(不退出编辑态,便于继续输入);
|
||
// 按下后移开再松开视为放弃。
|
||
if (UiPointInRect(FilterClearRect(), fx, fy))
|
||
{
|
||
m_pendingFilterClear = true;
|
||
return true;
|
||
}
|
||
BeginFilterEdit();
|
||
return true;
|
||
}
|
||
if (m_filterEditing)
|
||
{
|
||
// 文本已实时生效,这里只需收起编辑态,随后继续处理本次点击。
|
||
EndFilterEdit();
|
||
}
|
||
|
||
// 滚动条拖拽优先于其它命中测试。
|
||
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
|
||
{
|
||
CancelScrollAnimation();
|
||
const gd::RectF thumb = ScrollThumbRect();
|
||
m_draggingScrollBar = true;
|
||
if (UiPointInRect(thumb, fx, fy))
|
||
{
|
||
m_scrollGrabOffset = y - static_cast<int>(thumb.Y);
|
||
}
|
||
else
|
||
{
|
||
m_scrollGrabOffset = static_cast<int>(thumb.Height * 0.5f);
|
||
const float travel = fy - m_scrollBarRect.Y - static_cast<float>(m_scrollGrabOffset);
|
||
const float usable = m_scrollBarRect.Height - thumb.Height;
|
||
if (usable > 0.0f)
|
||
{
|
||
const float progress = std::max(0.0f, std::min(1.0f, travel / usable));
|
||
m_scrollOffset = progress * (m_contentHeight - m_listRect.Height);
|
||
ClampScroll();
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
m_pendingLaneMenu = false;
|
||
m_pendingColorPicker = false;
|
||
m_pendingGainMenu = false;
|
||
m_pendingMirrorMenu = false;
|
||
|
||
// 点击当前输入框以外的位置时提交输入。
|
||
if (m_editField != StyleEditField::NONE &&
|
||
!UiPointInRect(EditFieldRect(), fx, fy))
|
||
{
|
||
EndEdit(true);
|
||
}
|
||
|
||
// 全部显示/隐藏。
|
||
if (UiPointInRect(m_toggleAllRect, fx, fy))
|
||
{
|
||
m_tab->SetAllVisible(!AreAllSignalsVisible());
|
||
m_rowsDirty = true;
|
||
return true;
|
||
}
|
||
|
||
// 样式编辑区。
|
||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
// ---- 顶层「目标」行:单击**不进入编辑**(用户明确要求),只做联动滚动 ----
|
||
// 双击改名由 `OnDoubleClick` 处理;这里单击即把列表滚动到目标信号,
|
||
// 便于在长列表(上百条信号的 MF4)里定位正在编辑的那条信号。
|
||
{
|
||
UpdateTargetRowGeometry();
|
||
const bool onName =
|
||
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
|
||
const bool onBadge =
|
||
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
|
||
if (onName || onBadge)
|
||
{
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
}
|
||
|
||
int paletteCount = 0;
|
||
const std::uint32_t* palette = SignalPalette(paletteCount);
|
||
for (int index = 0; index < m_colorCount; ++index)
|
||
{
|
||
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
|
||
{
|
||
style->color = palette[index % paletteCount];
|
||
// 联动:样式自定义区的任何交互都把列表滚动到正在编辑的目标信号。
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
}
|
||
if (UiPointInRect(m_customChipRect, fx, fy))
|
||
{
|
||
// 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。
|
||
style->color = style->customColor;
|
||
m_pendingColorPicker = true;
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
for (int index = 0; index < kModeOptionCount; ++index)
|
||
{
|
||
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
|
||
{
|
||
style->mode = kModeOptions[index];
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
}
|
||
if (UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy))
|
||
{
|
||
m_draggingOpacity = true;
|
||
ApplyOpacityFromX(fx);
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy))
|
||
{
|
||
m_draggingWidth = true;
|
||
ApplyWidthFromX(fx);
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
// 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。
|
||
if (UiPointInRect(m_widthValueRect, fx, fy))
|
||
{
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return BeginEdit(StyleEditField::WIDTH);
|
||
}
|
||
if (UiPointInRect(m_opacityValueRect, fx, fy))
|
||
{
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return BeginEdit(StyleEditField::OPACITY);
|
||
}
|
||
// 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板,
|
||
// 与自定义颜色入口同一套交互)、绝对值(单击即切换)。
|
||
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
|
||
{
|
||
m_pendingGainMenu = true;
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
|
||
{
|
||
m_pendingMirrorMenu = true;
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
|
||
{
|
||
style->absolute = !style->absolute;
|
||
ScrollToSignalAnimated(SelectedRef());
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 列表行。
|
||
RowPart part = RowPart::NONE;
|
||
const int row = HitRow(x, y, part);
|
||
if (row < 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const ListRow item = m_rows[static_cast<std::size_t>(row)];
|
||
|
||
if (item.kind == RowKind::GROUP)
|
||
{
|
||
if (part == RowPart::CHECK)
|
||
{
|
||
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
|
||
m_tab->SetGroupVisible(item.groupIndex, !allVisible);
|
||
m_rowsDirty = true;
|
||
return true;
|
||
}
|
||
if (part == RowPart::CLOSE)
|
||
{
|
||
// 关闭分组属于数据层操作,交由宿主窗口处理;但**抬起时才生效**,
|
||
// 这里只记录目标(按住后移开再松开视为放弃)。
|
||
m_pendingClosePart = RowPart::CLOSE;
|
||
m_pendingCloseRow = row;
|
||
return true;
|
||
}
|
||
// 折叠 / 展开分组:**只有**点到「折叠三角」或「组X」标签才生效
|
||
//(用户 2026-10-16 要求:点到分组名本体不折起)。
|
||
if (part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE)
|
||
{
|
||
return true;
|
||
}
|
||
ToggleGroupFromRow(item.groupIndex, row);
|
||
return true;
|
||
}
|
||
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
|
||
// ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。
|
||
// 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号;
|
||
// 且与其它 ✕ 一致,**抬起时才真正删除**(按住后移开再松开视为放弃)。
|
||
if (part == RowPart::REMOVE_SIGNAL)
|
||
{
|
||
m_pendingClosePart = RowPart::REMOVE_SIGNAL;
|
||
m_pendingCloseRow = row;
|
||
return true;
|
||
}
|
||
|
||
SelectSignal(ref);
|
||
|
||
if (part == RowPart::CHECK)
|
||
{
|
||
// 勾选/取消勾选会立即触发网格重新划分(取消后空网格随之消失)。
|
||
m_tab->ToggleVisible(ref);
|
||
m_rowsDirty = true;
|
||
}
|
||
else if (part == RowPart::LANE_BADGE)
|
||
{
|
||
// 在左键抬起时弹出,保证菜单不会随着按键松开而消失。
|
||
m_pendingLaneMenu = true;
|
||
m_pendingLaneRef = ref;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::OnLButtonUp(int x, int y)
|
||
{
|
||
bool changed = false;
|
||
|
||
// ---- 关闭类按钮(✕):按下时记录目标,抬起时若指针仍在同一个 ✕ 上才执行 ----
|
||
if (m_pendingFilterClear)
|
||
{
|
||
m_pendingFilterClear = false;
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
const gd::RectF clearRect = FilterClearRect();
|
||
if (clearRect.Width > 0.0f && UiPointInRect(clearRect, fx, fy))
|
||
{
|
||
ClearFilter();
|
||
changed = true;
|
||
}
|
||
}
|
||
if (m_pendingClosePart != RowPart::NONE)
|
||
{
|
||
const RowPart pendingPart = m_pendingClosePart;
|
||
const int pendingRow = m_pendingCloseRow;
|
||
m_pendingClosePart = RowPart::NONE;
|
||
m_pendingCloseRow = -1;
|
||
|
||
RowPart part = RowPart::NONE;
|
||
const int row = HitRow(x, y, part);
|
||
if (row == pendingRow && part == pendingPart && row >= 0)
|
||
{
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
if (m_host != nullptr)
|
||
{
|
||
if (pendingPart == RowPart::CLOSE)
|
||
{
|
||
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(item.groupIndex), 0);
|
||
}
|
||
else
|
||
{
|
||
// wParam = 分组下标,lParam = 组内信号下标(见 Application 的消息处理)。
|
||
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.groupIndex),
|
||
static_cast<LPARAM>(item.signalIndex));
|
||
}
|
||
}
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if (m_draggingOpacity)
|
||
{
|
||
m_draggingOpacity = false;
|
||
changed = true;
|
||
}
|
||
if (m_draggingWidth)
|
||
{
|
||
m_draggingWidth = false;
|
||
changed = true;
|
||
}
|
||
if (m_draggingScrollBar)
|
||
{
|
||
m_draggingScrollBar = false;
|
||
changed = true;
|
||
}
|
||
|
||
if (m_pendingColorPicker)
|
||
{
|
||
m_pendingColorPicker = false;
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
if (UiPointInRect(m_customChipRect, fx, fy) && m_tab != nullptr)
|
||
{
|
||
if (const SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
changed = OpenColorPickerPopup(*style) || changed;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 「系数」/「镜像」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板
|
||
//(与自定义颜色入口一致:按住后移开再松开视为放弃)。
|
||
if (m_pendingGainMenu)
|
||
{
|
||
m_pendingGainMenu = false;
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) && m_tab != nullptr)
|
||
{
|
||
changed = OpenGainMenu() || changed;
|
||
}
|
||
}
|
||
if (m_pendingMirrorMenu)
|
||
{
|
||
m_pendingMirrorMenu = false;
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) && m_tab != nullptr)
|
||
{
|
||
changed = OpenMirrorMenu() || changed;
|
||
}
|
||
}
|
||
|
||
if (m_pendingLaneMenu)
|
||
{
|
||
m_pendingLaneMenu = false;
|
||
RowPart part = RowPart::NONE;
|
||
const int row = HitRow(x, y, part);
|
||
if (row >= 0 && part == RowPart::LANE_BADGE)
|
||
{
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
if (ref == m_pendingLaneRef)
|
||
{
|
||
const gd::RectF badge = SignalBadgeRect(row);
|
||
OpenLaneMenu(ref, static_cast<int>(badge.X),
|
||
static_cast<int>(badge.Y + badge.Height));
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return changed;
|
||
}
|
||
|
||
bool SignalPanel::OnDoubleClick(int x, int y)
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const float fx = static_cast<float>(x);
|
||
const float fy = static_cast<float>(y);
|
||
|
||
// 样式区数值框:双击进入(或保持)输入状态。
|
||
if (UiPointInRect(m_widthValueRect, fx, fy))
|
||
{
|
||
return BeginEdit(StyleEditField::WIDTH);
|
||
}
|
||
if (UiPointInRect(m_opacityValueRect, fx, fy))
|
||
{
|
||
return BeginEdit(StyleEditField::OPACITY);
|
||
}
|
||
|
||
// 样式区顶部「目标」行:双击信号名(或它后面的 GX 标签)进入**改名**编辑态。
|
||
// ⚠️ 单击不进入编辑(见 `OnLButtonDown`),必须双击 —— 用户明确要求。
|
||
if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
|
||
{
|
||
UpdateTargetRowGeometry();
|
||
const bool onName =
|
||
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
|
||
const bool onBadge =
|
||
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
|
||
if (onName || onBadge)
|
||
{
|
||
// `row < 0` 表示编辑目标是"当前选中信号"(即目标行显示的信号)。
|
||
m_rowsDirty = true;
|
||
return BeginEdit(StyleEditField::SIGNAL_NAME, -1);
|
||
}
|
||
}
|
||
|
||
RowPart part = RowPart::NONE;
|
||
const int row = HitRow(x, y, part);
|
||
if (row < 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const ListRow item = m_rows[static_cast<std::size_t>(row)];
|
||
|
||
// 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。
|
||
if (item.kind == RowKind::GROUP)
|
||
{
|
||
if (part == RowPart::DISCLOSURE)
|
||
{
|
||
// 与单击分组行同一入口:悬浮时先滚动就位再播放折起动画。
|
||
ToggleGroupFromRow(item.groupIndex, row);
|
||
return true;
|
||
}
|
||
if (part != RowPart::BODY)
|
||
{
|
||
return false;
|
||
}
|
||
// 重命名分组会改变列表行高度以外的展示内容,重建行缓存以保证文字刷新。
|
||
m_rowsDirty = true;
|
||
return BeginEdit(StyleEditField::GROUP_TITLE, row);
|
||
}
|
||
|
||
// 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。
|
||
if (part == RowPart::CHECK || part == RowPart::LANE_BADGE || part == RowPart::REMOVE_SIGNAL)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
SelectSignal(ref);
|
||
m_rowsDirty = true;
|
||
return BeginEdit(StyleEditField::SIGNAL_NAME, row);
|
||
}
|
||
|
||
bool SignalPanel::OnRButtonDown(int x, int y)
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
RowPart part = RowPart::NONE;
|
||
const int row = HitRow(x, y, part);
|
||
if (row < 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
if (item.kind == RowKind::GROUP)
|
||
{
|
||
OpenGroupMenu(item.groupIndex, x, y);
|
||
return true;
|
||
}
|
||
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
SelectSignal(ref);
|
||
OpenSignalMenu(ref, x, y);
|
||
return true;
|
||
}
|
||
|
||
bool SignalPanel::OnMouseWheel(int delta)
|
||
{
|
||
// 用户主动滚动 ⇒ 立即取消正在进行的目标信号滚动动画(否则两者会互相打架)。
|
||
CancelScrollAnimation();
|
||
|
||
if (m_dataSet == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const float previous = m_scrollOffset;
|
||
m_scrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
|
||
ClampScroll();
|
||
return std::fabs(previous - m_scrollOffset) > 0.01f;
|
||
}
|
||
|
||
bool SignalPanel::OnMouseLeave()
|
||
{
|
||
const bool changed =
|
||
(m_hoverRow >= 0) || (m_hoverStyle != StyleHit::NONE) || m_hoveredSignal.IsValid();
|
||
m_hoverRow = -1;
|
||
m_hoverPart = RowPart::NONE;
|
||
m_hoverStyle = StyleHit::NONE;
|
||
m_hoverStyleIndex = -1;
|
||
// 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。
|
||
m_hoveredSignal = SignalRef();
|
||
return changed;
|
||
}
|
||
|
||
void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y)
|
||
{
|
||
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const SignalStyle* style = StyleOf(ref);
|
||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||
if (style == nullptr || signal == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_menuTargetSignal = ref;
|
||
m_menuTargetGroup = -1;
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = signal->name;
|
||
items.push_back(header);
|
||
|
||
MenuItem toggle;
|
||
toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号";
|
||
toggle.id = CMD_SIGNAL_TOGGLE;
|
||
items.push_back(toggle);
|
||
|
||
MenuItem solo;
|
||
solo.label = L"仅显示该信号";
|
||
solo.id = CMD_SIGNAL_SOLO;
|
||
items.push_back(solo);
|
||
|
||
MenuItem showAll;
|
||
showAll.label = L"显示全部信号";
|
||
showAll.id = CMD_SIGNAL_SHOW_ALL;
|
||
items.push_back(showAll);
|
||
|
||
MenuItem hideAll;
|
||
hideAll.label = L"隐藏全部信号";
|
||
hideAll.id = CMD_SIGNAL_HIDE_ALL;
|
||
items.push_back(hideAll);
|
||
|
||
MenuItem separator;
|
||
separator.kind = MenuItemKind::SEPARATOR;
|
||
items.push_back(separator);
|
||
|
||
int paletteCount = 0;
|
||
const std::uint32_t* palette = SignalPalette(paletteCount);
|
||
MenuItem colors;
|
||
colors.kind = MenuItemKind::SWATCHES;
|
||
colors.id = CMD_COLOR_BASE;
|
||
colors.swatchCount = std::min(paletteCount, 16);
|
||
colors.checkedSwatch = -1;
|
||
for (int index = 0; index < colors.swatchCount; ++index)
|
||
{
|
||
colors.swatches[index] = palette[index];
|
||
if (style->color == palette[index])
|
||
{
|
||
colors.checkedSwatch = index;
|
||
}
|
||
}
|
||
items.push_back(colors);
|
||
|
||
MenuItem modeHeader;
|
||
modeHeader.kind = MenuItemKind::HEADER;
|
||
modeHeader.label = L"样式";
|
||
items.push_back(modeHeader);
|
||
|
||
for (int index = 0; index < kModeOptionCount; ++index)
|
||
{
|
||
MenuItem mode;
|
||
mode.label = DrawModeName(kModeOptions[index]);
|
||
mode.id = CMD_MODE_BASE + index;
|
||
mode.checked = (style->mode == kModeOptions[index]);
|
||
items.push_back(mode);
|
||
}
|
||
|
||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
|
||
}
|
||
|
||
void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y)
|
||
{
|
||
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
|
||
if (group == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_menuTargetGroup = groupIndex;
|
||
m_menuTargetSignal = SignalRef();
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = group->name;
|
||
items.push_back(header);
|
||
|
||
MenuItem showAll;
|
||
showAll.label = L"选中该分组全部信号";
|
||
showAll.id = CMD_GROUP_SHOW_ALL;
|
||
items.push_back(showAll);
|
||
|
||
MenuItem hideAll;
|
||
hideAll.label = L"取消该分组全部信号";
|
||
hideAll.id = CMD_GROUP_HIDE_ALL;
|
||
items.push_back(hideAll);
|
||
|
||
MenuItem separator;
|
||
separator.kind = MenuItemKind::SEPARATOR;
|
||
items.push_back(separator);
|
||
|
||
MenuItem remove;
|
||
remove.label = L"关闭该分组(从工作区移除)";
|
||
remove.id = CMD_GROUP_REMOVE;
|
||
items.push_back(remove);
|
||
|
||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
|
||
}
|
||
|
||
void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y)
|
||
{
|
||
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||
if (signal == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
m_menuTargetSignal = ref;
|
||
m_menuTargetGroup = -1;
|
||
|
||
std::vector<MenuItem> items;
|
||
|
||
MenuItem header;
|
||
header.kind = MenuItemKind::HEADER;
|
||
header.label = L"所属网格";
|
||
items.push_back(header);
|
||
|
||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||
const int currentLane = m_tab->LaneOfSignal(ref);
|
||
for (std::size_t index = 0; index < lanes.size(); ++index)
|
||
{
|
||
MenuItem lane;
|
||
lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" +
|
||
std::to_wstring(lanes[index].signals.size()) + L" 条信号)";
|
||
lane.id = CMD_LANE_BASE + static_cast<int>(index);
|
||
lane.checked = (currentLane == static_cast<int>(index));
|
||
items.push_back(lane);
|
||
}
|
||
|
||
MenuItem separator;
|
||
separator.kind = MenuItemKind::SEPARATOR;
|
||
items.push_back(separator);
|
||
|
||
MenuItem create;
|
||
create.label = L"新建网格";
|
||
create.id = CMD_LANE_NEW;
|
||
items.push_back(create);
|
||
|
||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
|
||
}
|
||
|
||
bool SignalPanel::OnMenuCommand(const MenuResult& result)
|
||
{
|
||
if (m_tab == nullptr || m_dataSet == nullptr || !result.valid)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开
|
||
//(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。
|
||
if (result.editCommit)
|
||
{
|
||
SignalStyle* style = StyleOf(SelectedRef());
|
||
if (style != nullptr)
|
||
{
|
||
const std::wstring trimmed = Trim(result.editText);
|
||
wchar_t* stop = nullptr;
|
||
const double parsed =
|
||
trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop);
|
||
// 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。
|
||
const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed);
|
||
switch (result.id)
|
||
{
|
||
case CMD_GAIN_EDIT:
|
||
{
|
||
if (valid)
|
||
{
|
||
style->gain =
|
||
static_cast<float>(std::max(-kGainLimit, std::min(parsed, kGainLimit)));
|
||
}
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
|
||
FormatNumberText(static_cast<double>(style->gain)));
|
||
}
|
||
return false;
|
||
}
|
||
case CMD_OFFSET_Y:
|
||
{
|
||
if (valid)
|
||
{
|
||
style->offsetY = static_cast<float>(
|
||
std::max(-static_cast<double>(kGainLimit), std::min(parsed, kGainLimit)));
|
||
}
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemValue(
|
||
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
|
||
FormatNumberText(static_cast<double>(style->offsetY)));
|
||
}
|
||
return false;
|
||
}
|
||
case CMD_OFFSET_TIME:
|
||
{
|
||
if (valid)
|
||
{
|
||
style->offsetTime =
|
||
std::max(-kGainLimit, std::min(parsed, kGainLimit));
|
||
}
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemValue(CMD_OFFSET_TIME,
|
||
FormatOffsetTimeText(style->offsetTime),
|
||
FormatNumberText(style->offsetTime));
|
||
}
|
||
return false;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。
|
||
if (result.id == CMD_GAIN_RESET)
|
||
{
|
||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
style->gain = 1.0f;
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
|
||
FormatNumberText(static_cast<double>(style->gain)));
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。
|
||
if (result.id == CMD_OFFSET_RESET)
|
||
{
|
||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
style->offsetY = 0.0f;
|
||
style->offsetTime = 0.0;
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0),
|
||
FormatNumberText(0.0));
|
||
m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0),
|
||
FormatNumberText(0.0));
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。
|
||
if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL)
|
||
{
|
||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||
{
|
||
const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL);
|
||
bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical;
|
||
flag = !flag;
|
||
if (!horizontal)
|
||
{
|
||
// ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
|
||
// 使绘制等价于「先加上偏移再镜像」(用户明确要求)。
|
||
style->offsetY = -style->offsetY;
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemValue(
|
||
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
|
||
FormatNumberText(static_cast<double>(style->offsetY)));
|
||
}
|
||
}
|
||
if (m_menu != nullptr)
|
||
{
|
||
m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true);
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool needRelayout = false;
|
||
|
||
switch (result.id)
|
||
{
|
||
case CMD_SIGNAL_TOGGLE:
|
||
if (m_menuTargetSignal.IsValid())
|
||
{
|
||
m_tab->ToggleVisible(m_menuTargetSignal);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
}
|
||
break;
|
||
case CMD_SIGNAL_SOLO:
|
||
if (m_menuTargetSignal.IsValid())
|
||
{
|
||
m_tab->SoloSignal(m_menuTargetSignal);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
}
|
||
break;
|
||
case CMD_SIGNAL_SHOW_ALL:
|
||
m_tab->SetAllVisible(true);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
break;
|
||
case CMD_SIGNAL_HIDE_ALL:
|
||
m_tab->SetAllVisible(false);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
break;
|
||
case CMD_GROUP_SHOW_ALL:
|
||
if (m_menuTargetGroup >= 0)
|
||
{
|
||
m_tab->SetGroupVisible(m_menuTargetGroup, true);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
}
|
||
break;
|
||
case CMD_GROUP_HIDE_ALL:
|
||
if (m_menuTargetGroup >= 0)
|
||
{
|
||
m_tab->SetGroupVisible(m_menuTargetGroup, false);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
}
|
||
break;
|
||
case CMD_GROUP_REMOVE:
|
||
if (m_menuTargetGroup >= 0 && m_host != nullptr)
|
||
{
|
||
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetGroup), 0);
|
||
}
|
||
break;
|
||
case CMD_LANE_NEW:
|
||
if (m_menuTargetSignal.IsValid())
|
||
{
|
||
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
}
|
||
break;
|
||
default:
|
||
if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
|
||
{
|
||
if (m_menuTargetSignal.IsValid())
|
||
{
|
||
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE);
|
||
m_rowsDirty = true;
|
||
needRelayout = true;
|
||
}
|
||
}
|
||
else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0)
|
||
{
|
||
if (SignalStyle* style = StyleOf(m_menuTargetSignal))
|
||
{
|
||
int paletteCount = 0;
|
||
const std::uint32_t* palette = SignalPalette(paletteCount);
|
||
style->color = palette[result.swatchIndex % std::max(1, paletteCount)];
|
||
}
|
||
}
|
||
else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount)
|
||
{
|
||
if (SignalStyle* style = StyleOf(m_menuTargetSignal))
|
||
{
|
||
style->mode = kModeOptions[result.id - CMD_MODE_BASE];
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
return needRelayout;
|
||
}
|
||
|
||
} // namespace jdi
|