Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanel.cpp
T
DeepSeek V4.1 Flash 1d314ddc24 信号面板分组折起动画 + 筛选态折起(项数按筛选计数)+ 悬浮组名可交互与“先滚动再折起” + 适应视图与 Y 轴自适应同源
① 分组折起/展开改为动画过渡(用户要求:被折起的信号向上收起、下方组随之上升、
   离组名越远的成员收起越快、末段减速、总时长合理):
   - 新增 SignalPanel::m_groupAnims + GroupRowScale():每行速度倍率
     1 + 1.2×ordinal/(行数−1)(越靠下越快收尽),叠加 ease-out cubic;
     时长 = clamp(115 + 14×行数, 140, 300) ms。
   - BuildRows 按比例输出非整行高;低于 0.75 逻辑 px 的行不再产生(不绘制/不命中)。
   - 宿主 WM_TIMER(kScrollTimerId) 同时驱动滚动与折起动画;Render 的定时器判定改为
     ScrollAnimationActive() || GroupAnimationActive()。
   - 新增 SetRowClip/ClearRowClip:动画期间行内容裁到本行矩形(否则 DT_VCENTER
     垂直居中的文本会溢出到相邻行)。

② 筛选状态下的分组折起 + 项数按筛选结果计数:
   - 筛选模式改用独立的 m_filterCollapsed(默认展开),不再“筛选时忽略折叠状态”,
     也不再把筛选里的临时折起污染到普通列表的布局选择。
   - ListRow::displayCount 记录当前模式下列出的信号数;组名后方「N 项」只读它
     (筛选时 = 该组内符合筛选条件的信号个数)。
   - 筛选文本变化 / 清空筛选 / 数据变化 ⇒ 清空临时折起状态 + 进行中的动画 + 挂起请求。

③ 悬浮组名可正常交互 + 点击时“先滚动就位再折起”:
   - 根因:HitRow 只按滚动偏移把指针映射到内容位置,而悬浮组名是画在列表顶部的覆盖层
     (其自然位置已滚出列表)⇒ 点击穿透到它下面那一行(折叠三角 / 复选框 / ✕ /
     名称区域全都点不动)。
   - 修法:HitRow 先用 StickyGroupRowIndex() / StickyGroupRowTop() 判定是否落在悬浮行
     矩形内,命中则临时打开 m_rowYOverrideActive 并调用新的 RowHitPart() 算行内区域;
     同时跳过高度为 0 的行(折起动画中已收尽,否则空行会白白吸走一次点击)。
   - 新增 ToggleGroupFromRow():若该行正悬浮(滚动偏移大于该行的内容偏移),先
     StartScrollAnimationTo(该行内容偏移, groupIndex) 并把挂起请求记在
     m_pendingGroupToggle,滚动到位后才启动折起动画;分组行的单击与双击折叠三角都走
     这个入口。滚轮 / 拖滚动条 / 筛选变化 / 切标签页一律清掉挂起请求。

④ 「适应视图」与「Y 轴自适应」彻底同源(单数据点信号):
   - 根因:FitSinglePointCluster 给每个网格另算一套幅值区间(“散点半径留白 + 跨度 1”),
     而 AutoRangeLaneToView 走 PadValueRange ⇒ 同一视界下两者结果不同;且它显式写
     lane.fitYToSelection = false ⇒ 点「适应视图」会把「Y 轴自适应」悄悄关掉。
   - 修法:单点簇只负责时间轴(以该时刻居中 + 一大格 1s),逐网格幅值轴与常规路径 /
     Y 轴自适应完全同源(fitYToSelection → AutoRangeLaneToView,否则 autoRange = true,
     两者之后都接 ApplySinglePointValueMargins),并且绝不改写 fitYToSelection。
   - 有意的行为变化:单点信号「适应视图」的幅值跨度由旧的 1 变为 2×max(1,|值|×0.1)
     (即与 PadValueRange 一致);§9.33 用例 [A] 里“幅值跨度 1”的期望已作废,
     散点仍在网格正中且完整可见(ApplySinglePointValueMargins 兜底)。

验证(离屏 SW_HIDE,不占用鼠标 / 键盘 / 屏幕;进程与线程均 BELOW_NORMAL):
   - 新增 .preflight/perf/panelanim_main.cpp(panelanim.exe)94/94 通过(连跑 3 次稳定):
     折起/展开的分帧过渡与内容高度单调收敛、越靠下的行越矮、末态精确无残留、
     筛选计数与独立折起状态、悬浮组名四个可交互区域全部命中且下层行仍可点击、
     点击后“先滚动到位再折起”、真实 MF4-Record.MF4 单点信号下两种自适应逐位一致且往返稳定。
   - A/B 三组回退(回退绘图区修复 / 禁用悬浮命中优先 / 动画时长置 0)分别 4 / 13 / 8 项如实失败。
   - 既有 verify 49/49、datacheck 69/69、edgecheck 46/46;make clean && make all 零警告。
     (顺带修正本机 verify 探针 4 条与远端新规则不符的过期断言:幅值留白改为调用唯一的
     PadValueRange、派生信号 offsetTime 断言 0(§9.21 已烘入数据)、拖拽换位按“被拖网格
     自身边界”判据的新目标值。)

记忆:Documents/agent_mem.md 新增 §9.35 与 §11.2 第 76–81 条,并更新第 70 条与 §9.33 备注。
2026-10-16 01:25:54 +08:00

4034 lines
132 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "SignalPanel.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include "Utf8Util.hpp"
namespace jdi
{
namespace
{
const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */
const float kFileRowHeight = 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 = 14.0f; /**< 每多一行增加的时长。 */
const float kGroupAnimMinMs = 140.0f; /**< 最短时长。 */
const float kGroupAnimMaxMs = 300.0f; /**< 最长时长。 */
/**
* @brief 「离组名越远的成员收起越快」的深度增益。
*
* 第 ordinal 行的速度倍率 = `1 + 增益 × ordinal / (行数-1)`:最深的一行倍率最高,
* 因此最先收起(实测 1.2 时最深一行在总时长的约 45% 处就已经收完),
* 而紧邻组名的第一行最后收完,配合 ease-out 形成“向上收起 + 减速”的观感。
*/
const float kGroupAnimDepthBoost = 1.2f;
/**
* @brief 分组折起动画中“已被收成一条线”的行高下限(逻辑像素)。
*
* 低于该高度的行不再产生(既不绘制也不参与命中),避免出现高度接近 0
* 却仍能吸走一次点击的空行。
*/
const float kGroupAnimMinRowHeight = 0.75f;
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_pendingGroupToggle = -1;
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_pendingGroupToggle = -1;
// 清空后回到完整列表:重建行集合、回到顶部。
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_pendingGroupToggle = -1;
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
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.Title(), 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;
}
void SignalPanel::StartGroupAnimation(int groupIndex, bool collapsing, int rowCount)
{
if (groupIndex < 0 || rowCount <= 0)
{
return;
}
// 同一分组只保留一个动画条目:再次切换时移除旧的,新动画从“当前状态”出发 ——
// 折起与展开的起点本来就分别是干净的 1 与 0,因此重新开始即可。
const int existing = FindGroupAnimation(groupIndex);
if (existing >= 0)
{
m_groupAnims.erase(m_groupAnims.begin() + existing);
}
GroupAnim anim;
anim.groupIndex = groupIndex;
anim.collapsing = collapsing;
anim.rowCount = rowCount;
anim.startMs = GetTickCount64();
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
const float durationMs = std::max(
kGroupAnimMinMs,
std::min(kGroupAnimMaxMs,
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(rowCount)));
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_groupAnims.push_back(anim);
m_rowsDirty = true;
}
float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const
{
const int animIndex = FindGroupAnimation(groupIndex);
if (animIndex < 0)
{
// 没有动画:完全由折起状态决定(折起 = 不占高度)。
return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f;
}
const GroupAnim& anim = m_groupAnims[static_cast<std::size_t>(animIndex)];
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;
}
// 「被收起的组最下方成员离得越远收起速度越快」:第 ordinal 行的速度倍率
// 随它与组名的距离线性增大,因此最深的一行最先收起,紧邻组名的一行最后收完。
const float depth = (rowCount > 1)
? static_cast<float>(ordinal) / static_cast<float>(rowCount - 1)
: 0.0f;
const float speed = 1.0f + kGroupAnimDepthBoost * depth;
float local = progress * speed;
if (local > 1.0f)
{
local = 1.0f;
}
// ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。
const float eased = 1.0f - std::pow(1.0f - local, 3.0f);
return anim.collapsing ? (1.0f - eased) : eased;
}
bool SignalPanel::AdvanceGroupAnimations()
{
if (m_groupAnims.empty())
{
return false;
}
const std::uint64_t now = GetTickCount64();
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)
{
// 完成:移除条目,行高随即回到“折起状态”决定的最终值。
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
// 动画期间每一帧的行高都不同 ⇒ 必须重建行集合(否则画面静止不动)。
m_rowsDirty = true;
return !m_groupAnims.empty();
}
void SignalPanel::ToggleGroupCollapsedAnimated(int groupIndex)
{
if (m_tab == nullptr || groupIndex < 0)
{
return;
}
const bool collapse = !IsGroupCollapsed(groupIndex);
SetGroupCollapsed(groupIndex, collapse);
// 参与动画的行数 = 该组当前模式下会列出的信号数(筛选时只算匹配项)。
const int count = ShownSignalCount(groupIndex);
if (count > 0)
{
StartGroupAnimation(groupIndex, collapse, count);
}
else
{
m_rowsDirty = true;
}
}
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
{
EnsureRows();
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
ToggleGroupCollapsedAnimated(groupIndex);
return;
}
// 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。
const float alignOffset = ContentOffsetBeforeRow(row);
if (m_scrollOffset > alignOffset + 0.5f)
{
// 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动,
// 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。
StartScrollAnimationTo(alignOffset, groupIndex);
return;
}
ToggleGroupCollapsedAnimated(groupIndex);
}
void SignalPanel::BuildRows()
{
m_rows.clear();
m_rowsDirty = false;
if (m_dataSet == nullptr || m_tab == nullptr)
{
m_contentHeight = 0.0f;
return;
}
const float fileHeight = kFileRowHeight * 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 int animIndex = FindGroupAnimation(gi);
const GroupAnim* anim = nullptr;
if (animIndex >= 0 &&
m_groupAnims[static_cast<std::size_t>(animIndex)].rowCount == static_cast<int>(shown.size()))
{
anim = &m_groupAnims[static_cast<std::size_t>(animIndex)];
}
ListRow fileRow;
fileRow.kind = RowKind::GROUP;
fileRow.groupIndex = gi;
fileRow.height = fileHeight;
// 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。
fileRow.displayCount = static_cast<int>(shown.size());
m_rows.push_back(fileRow);
// 折起状态与动画共同决定每个信号行的实际高度:
// * 展开且无动画 ⇒ 完整高度;
// * 折起且无动画 ⇒ 不产生任何行;
// * 有动画 ⇒ 按 `GroupRowScale()` 连续缩小(折起)/ 放大(展开)。
const int rowCount = static_cast<int>(shown.size());
for (int ordinal = 0; ordinal < rowCount; ++ordinal)
{
float scale = collapsed ? 0.0f : 1.0f;
if (anim != nullptr)
{
scale = GroupRowScale(gi, ordinal, rowCount);
}
const float height = signalHeight * scale;
if (height < kGroupAnimMinRowHeight * m_scale)
{
// 已经收成一条线:不再产生可绘制 / 可命中的行。
continue;
}
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.groupIndex = gi;
signalRow.signalIndex = shown[static_cast<std::size_t>(ordinal)];
signalRow.height = height;
signalRow.ordinal = ordinal;
m_rows.push_back(signalRow);
}
}
m_contentHeight = 0.0f;
for (const ListRow& row : m_rows)
{
m_contentHeight += row.height;
}
}
void SignalPanel::EnsureRows()
{
if (m_rowsDirty)
{
BuildRows();
RefreshScrollBar();
}
}
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;
}
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);
PaintFileRow(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::FileCloseRect(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 isFile = (item.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 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 (isFile)
{
if (UiPointInRect(disclosureRect, fx, fy))
{
return RowPart::DISCLOSURE;
}
if (UiPointInRect(checkRect, fx, fy))
{
return RowPart::CHECK;
}
if (UiPointInRect(FileCloseRect(row), fx, fy))
{
return RowPart::CLOSE;
}
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 accumulated = 0.0f;
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const ListRow& row = m_rows[index];
// ⚠️ 高度为 0 的行(折起动画中已收尽)不参与命中,否则它会白白吸走一次点击。
if (row.height > 0.0f && local >= accumulated && local < accumulated + row.height)
{
const int rowIndex = static_cast<int>(index);
part = RowHitPart(rowIndex, fx, fy);
return rowIndex;
}
accumulated += row.height;
}
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, int pendingGroupToggle)
{
m_pendingGroupToggle = pendingGroupToggle;
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;
if (m_pendingGroupToggle >= 0)
{
const int groupIndex = m_pendingGroupToggle;
m_pendingGroupToggle = -1;
ToggleGroupCollapsedAnimated(groupIndex);
}
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->Title() : 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;
const bool badgeShown = (lane >= 0);
// 名称可用宽度:有 GX 标签时把它排在名称**后面**(用户要求“名称后方”),因此要预留宽度。
const float nameLimit = std::max(0.0f, contentWidth - (badgeShown ? badgeWidth + badgeGap : 0.0f));
const float measured = UiMeasureTextWidth(m_fonts->ui, title);
const float nameWidth = std::min(measured, nameLimit);
// 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。
m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height);
if (badgeShown)
{
m_targetBadgeRect = gd::RectF(contentLeft + nameWidth + badgeGap,
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;
}
// 行内容一律裁剪到**本行矩形**:折起动画期间行高连续缩小,
// 若不裁剪,垂直居中的文本会溢出到相邻行上。
SetRowClip(rect);
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintFileRow(g, row);
}
else
{
PaintSignalRow(g, row);
}
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::PaintFileRow(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 = m_tab->IsCollapsed(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 = FileCloseRect(row);
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1);
// 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数
//(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。
const std::wstring stats = std::to_wstring(item.displayCount) + L" 项";
const float groupWidth = FileGroupLabelWidth(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::FILE_NAME && m_editRow == row)
{
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale);
}
else
{
PaintRowText(g, UiEllipsize(group->Title(), 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->Title(), 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(g, UiEllipsize(signal->Title(), 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);
PaintRowText(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();
// 若这次滚动是为了“把悬浮组名滚到完整显示位置后再折起”,此刻滚动已到位,
// 立即开始折起动画(用户 2026-10-16 要求的两段式过渡)。
if (m_pendingGroupToggle >= 0)
{
const int groupIndex = m_pendingGroupToggle;
m_pendingGroupToggle = -1;
ToggleGroupCollapsedAnimated(groupIndex);
}
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 isFile = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale;
float left = 0.0f;
float right = 0.0f;
if (isFile)
{
// 分组名需要跳过左侧的“组X”标签与分隔线。
left = checkRight + 8.0f * m_scale + FileGroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale +
kGroupSeparatorWidth * m_scale;
right = FileCloseRect(row).X - 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::FileGroupLabelWidth(int row) const
{
if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return 0.0f;
}
const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast<std::size_t>(row)].groupIndex + 1);
return UiMeasureTextWidth(m_fonts->uiBold, label);
}
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->Title();
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->Title();
break;
}
case StyleEditField::FILE_NAME:
{
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->Title();
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::FILE_NAME);
// 不限长度:名称与数值都由各自的提交逻辑处理超长内容。
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->SetSignalDisplayName(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->SetSignalDisplayName(item.groupIndex, item.signalIndex, trimmed);
break;
}
case StyleEditField::FILE_NAME:
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr)
{
break;
}
m_dataSetWritable->SetGroupDisplayName(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() const
{
switch (m_editField)
{
case StyleEditField::WIDTH:
return m_widthValueRect;
case StyleEditField::OPACITY:
return m_opacityValueRect;
case StyleEditField::SIGNAL_NAME:
case StyleEditField::FILE_NAME:
// `row < 0` = 样式区「目标」行:输入框用内容列全宽(名称本身很短时也能舒适地编辑)。
if (m_editRow < 0)
{
const float labelWidth = kStyleLabelWidth * m_scale;
const float labelGap = kStyleLabelGap * m_scale;
const float left = m_targetRowRect.X + labelWidth + labelGap;
const float width = std::max(24.0f, m_targetRowRect.X + m_targetRowRect.Width - left);
return gd::RectF(left, m_targetRowRect.Y, width, m_targetRowRect.Height);
}
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;
}
// 折叠 / 展开分组走「带动画 + 悬浮时先滚动就位」的统一入口
//(用户 2026-10-16 要求)。
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::FILE_NAME, 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)
{
OpenFileMenu(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_menuTargetFile = -1;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = signal->Title();
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::OpenFileMenu(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_menuTargetFile = groupIndex;
m_menuTargetSignal = SignalRef();
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = group->Title();
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_menuTargetFile = -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_menuTargetFile >= 0)
{
m_tab->SetGroupVisible(m_menuTargetFile, true);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_GROUP_HIDE_ALL:
if (m_menuTargetFile >= 0)
{
m_tab->SetGroupVisible(m_menuTargetFile, false);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_GROUP_REMOVE:
if (m_menuTargetFile >= 0 && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetFile), 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