Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanel.cpp
T
DeepSeek V4.1 Flash 53b981a2a9 全项目大检查:修复数据层/UI 真 BUG、清理死代码与过期注释、并评估二进制体积
按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查,
四个只读子审查交叉核对后逐条回到源码确认再改。

一、数据层真 BUG
- MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个
  64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree
  的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存,
  否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。
- Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后
  block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数",
  下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。
- DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位
  再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。
- cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。
- CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。
- MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。
- Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。

二、界面 BUG
- 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。
- 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉
  => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。
- GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在
  WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。
- WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。
- WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。
- TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。
- SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。
- EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。
- 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。
- 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。
- ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。
- RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。
- 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。
- SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。

三、PlotView
- FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段,
  另一个网格同时改"调整"时带着旧像素被当成最新)。
- 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。
- PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。
- 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。
- PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。
- 内联输入框命中字体与绘制统一为 monoSmall。
- 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。
- EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。

四、清理
- 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、
  Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、
  PaintUtil 里孤立的注释块。
- 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、
  均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、
  RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的
  "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。

五、二进制体积评估(按要求只评估,未做压缩修改)
- release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、
  .rsrc 20KB、.idata 11.5KB。
- 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益
  (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。
- 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。
- 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能),
  需要用户决策,本轮未动。

验证
- editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。
- check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有
  不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。
- 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、
  带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束
与文末本轮小节(含体积评估数据)。
2026-10-20 16:10:29 +08:00

1254 lines
44 KiB
C++

// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
#include "SignalPanelInternal.hpp"
namespace jdi
{
SignalPanel::SignalPanel()
{
m_colorRects.fill(gd::RectF());
m_modeRects.fill(gd::RectF());
m_adjustRects.fill(gd::RectF());
}
void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu,
ColorPickerPopup* colorPicker)
{
m_host = host;
m_fonts = fonts;
m_theme = theme;
m_menu = menu;
m_colorPicker = colorPicker;
}
void SignalPanel::SetContext(DataSet* dataSet, TabConfig* tab)
{
m_dataSet = dataSet;
m_dataSetWritable = dataSet;
m_tab = tab;
m_rowsDirty = true;
m_scrollOffset = 0.0f;
m_draggingOpacity = false;
m_draggingWidth = false;
m_edit.End();
m_editRow = -1;
m_editField = StyleEditField::NONE;
m_pendingLaneMenu = false;
m_pendingColorPicker = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与位移缓存一律作废。
m_groupAnims.clear();
m_rowShift.clear();
m_scrollAnimActive = false;
m_pendingTargetLaneMenu = false;
m_plotLaneRequestFlag = false;
m_pendingPlotLane = -1;
}
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;
}
void SignalPanel::ApplyFilterText(const std::wstring& text)
{
if (m_filterText == text)
{
return;
}
m_filterText = text;
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画全部作废
//(行数与分组可见性都会变,继续沿用会算出错误的行集合)。
m_filterCollapsed.clear();
m_groupAnims.clear();
m_rowShift.clear();
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
m_rowsDirty = true;
m_scrollOffset = 0.0f;
m_layoutDirty = true;
}
bool SignalPanel::FilterMatchesSignal(const SignalData& signal) const
{
if (m_filterText.empty())
{
return true;
}
// 以 `*` 为通配符、忽略大小写,匹配"信号名中是否出现过符合该模式的片段"。
// 两侧补 `*` 使模式等价于子串匹配,因此下面这些写法都能按直觉工作:
// `编码器` → 名称包含"编码器"
// `编码器*相` → 名称中出现以"编码器"开头、"相"结尾的片段
// `*x10` / `ABS*` → 尾部/首部片段匹配(无需用户自己补另一侧的 `*`)
std::wstring pattern = L"*";
pattern += m_filterText;
pattern += L'*';
return WildcardMatchNoCase(signal.name, pattern);
}
bool SignalPanel::IsGroupCollapsed(int groupIndex) const
{
if (groupIndex < 0)
{
return false;
}
// 筛选模式使用**独立**的折起状态:筛选只是临时子集视图,不应污染普通列表的布局选择;
// 且筛选结果默认展开(便于直接看到匹配项)。
if (!m_filterText.empty())
{
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
{
return false;
}
return m_filterCollapsed[static_cast<std::size_t>(groupIndex)];
}
return (m_tab != nullptr) && m_tab->IsCollapsed(groupIndex);
}
void SignalPanel::SetGroupCollapsed(int groupIndex, bool collapsed)
{
if (groupIndex < 0)
{
return;
}
if (!m_filterText.empty())
{
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
{
m_filterCollapsed.resize(static_cast<std::size_t>(groupIndex) + 1, false);
}
m_filterCollapsed[static_cast<std::size_t>(groupIndex)] = collapsed;
return;
}
if (m_tab != nullptr && m_tab->IsCollapsed(groupIndex) != collapsed)
{
m_tab->ToggleCollapsed(groupIndex);
}
}
int SignalPanel::ShownSignalCount(int groupIndex) const
{
if (m_dataSet == nullptr || groupIndex < 0)
{
return 0;
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return 0;
}
if (m_filterText.empty())
{
return static_cast<int>(group->signals.size());
}
int count = 0;
for (const SignalData& signal : group->signals)
{
if (FilterMatchesSignal(signal))
{
++count;
}
}
return count;
}
int SignalPanel::FindGroupAnimation(int groupIndex) const
{
for (std::size_t index = 0; index < m_groupAnims.size(); ++index)
{
if (m_groupAnims[index].groupIndex == groupIndex)
{
return static_cast<int>(index);
}
}
return -1;
}
int SignalPanel::GroupRowIndex(int groupIndex) const
{
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
if (m_rows[index].kind == RowKind::GROUP && m_rows[index].groupIndex == groupIndex)
{
return static_cast<int>(index);
}
}
return -1;
}
float SignalPanel::NaturalContentHeight() const
{
float total = 0.0f;
for (const ListRow& row : m_rows)
{
total += row.height;
}
return total;
}
float SignalPanel::ShownBlockHeight(int groupIndex) const
{
return static_cast<float>(ShownSignalCount(groupIndex)) * kSignalRowHeight * m_scale;
}
float SignalPanel::AnimEasedProgress(const GroupAnim& anim) const
{
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
float progress = (anim.durationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(anim.durationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、接近完成时明显减速(“快速展开 / 收起后慢慢减速”)。
return 1.0f - std::pow(1.0f - progress, 3.0f);
}
void SignalPanel::UpdateAnimGeometry()
{
m_rowShift.assign(m_rows.size(), 0.0f);
float totalShift = 0.0f;
for (const GroupAnim& anim : m_groupAnims)
{
const float progress = AnimEasedProgress(anim);
// 收起:从起始位移推到“全部藏进组名栏”(= blockHeight);
// 展开:从起始位移推回 0(逐条从组名栏下方冒出)。
// 起始位移在“中途反向点击”时就是当时的位移 ⇒ 不会跳变。
const float target = anim.collapsing ? anim.blockHeight : 0.0f;
const float shift = anim.fromShift + (target - anim.fromShift) * progress;
if (anim.scrollSynced)
{
// 与滚动同步推进:让悬浮组名始终留在列表最上方
//(滚动偏移与平移量按同一进度推进,二者的合成让组名的屏幕位置恒定)。
m_scrollOffset = anim.scrollFrom + (anim.scrollTo - anim.scrollFrom) * progress;
}
const int headerRow = GroupRowIndex(anim.groupIndex);
if (headerRow < 0 || shift <= 0.0f)
{
continue;
}
// ⚠️ 平移只作用于**组名行之后**的行:组名自己是“卷尺的外壳”,始终不动,
// 信号行从它下面滑过去并被裁掉。
for (int row = headerRow + 1; row < static_cast<int>(m_rowShift.size()); ++row)
{
m_rowShift[static_cast<std::size_t>(row)] += shift;
}
totalShift += shift;
}
m_contentHeight = std::max(0.0f, NaturalContentHeight() - totalShift);
}
bool SignalPanel::StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo,
bool syncScroll)
{
if (groupIndex < 0 || m_tab == nullptr)
{
return false;
}
// 同一分组只保留一个动画条目:再次切换时移除旧的,并从**当前位移**续接
//(用户可能在折起没播完时就点展开,此时不能跳回干净的一端)。
float fromShift = 0.0f;
const int existing = FindGroupAnimation(groupIndex);
if (existing >= 0)
{
const GroupAnim& previous = m_groupAnims[static_cast<std::size_t>(existing)];
const float previousProgress = AnimEasedProgress(previous);
const float previousTarget = previous.collapsing ? previous.blockHeight : 0.0f;
fromShift = previous.fromShift + (previousTarget - previous.fromShift) * previousProgress;
m_groupAnims.erase(m_groupAnims.begin() + existing);
}
const float blockHeight = ShownBlockHeight(groupIndex);
if (blockHeight <= 0.5f)
{
// 没有信号行可动画(空分组):直接把状态定下来,并清掉可能的残留位移。
UpdateAnimGeometry();
m_rowsDirty = true;
return false;
}
if (existing < 0)
{
// 没有正在播放的动画 ⇒ 从“干净的一端”出发(展开 = 全部藏在组名栏内)。
fromShift = collapse ? 0.0f : blockHeight;
}
GroupAnim anim;
anim.groupIndex = groupIndex;
anim.collapsing = collapse;
anim.rowCount = ShownSignalCount(groupIndex);
anim.blockHeight = blockHeight;
anim.fromShift = ClampFloat(fromShift, 0.0f, blockHeight);
anim.scrollFrom = scrollFrom;
anim.scrollTo = scrollTo;
anim.scrollSynced = syncScroll && (std::fabs(scrollTo - scrollFrom) > 0.5f);
anim.startMs = GetTickCount64();
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
const float durationMs = std::max(
kGroupAnimMinMs,
std::min(kGroupAnimMaxMs,
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(anim.rowCount)));
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
if (anim.scrollSynced)
{
// 立即接管滚动偏移(后续每帧由 `UpdateAnimGeometry` 推进)。
m_scrollOffset = scrollFrom;
}
m_groupAnims.push_back(anim);
// 折起时该组的信号行必须**留在行集合里**(它们正在“滑进组名栏”),
// 因此需要按“有动画”的规则重建一次行集合。
m_rowsDirty = true;
UpdateAnimGeometry();
return true;
}
float SignalPanel::AnimationHideTopForRow(int row, int stickyRow) const
{
if (m_groupAnims.empty() || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return -1.0e9f;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind != RowKind::SIGNAL || FindGroupAnimation(item.groupIndex) < 0)
{
return -1.0e9f;
}
const int headerRow = GroupRowIndex(item.groupIndex);
if (headerRow < 0)
{
return -1.0e9f;
}
// 组名的**绘制**下边界:悬浮时用悬浮位置,否则用自然位置。
// 信号行越过这条线即被裁掉 ⇒ 形成“滑进组名栏”的观感。
const float headerTop = (stickyRow == headerRow)
? StickyGroupRowTop(headerRow, static_cast<int>(m_rows.size()))
: RowRect(headerRow).Y;
return headerTop + m_rows[static_cast<std::size_t>(headerRow)].height;
}
void SignalPanel::BuildRows()
{
m_rows.clear();
m_rowsDirty = false;
// 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。
// 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。
InvalidateRowCache();
if (m_dataSet == nullptr || m_tab == nullptr)
{
m_contentHeight = 0.0f;
m_rowShift.clear();
return;
}
const float groupHeight = kGroupRowHeight * m_scale;
const float signalHeight = kSignalRowHeight * m_scale;
const bool filtering = !m_filterText.empty();
const std::vector<SignalGroup>& files = m_dataSet->Groups();
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
{
const int gi = static_cast<int>(groupIndex);
// 参与列出的信号:筛选模式只保留匹配项;整组都没有匹配则**不显示该组**。
std::vector<int> shown;
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
if (!filtering || FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
{
shown.push_back(static_cast<int>(signalIndex));
}
}
if (filtering && shown.empty())
{
continue;
}
const bool collapsed = IsGroupCollapsed(gi);
const bool animating = (FindGroupAnimation(gi) >= 0);
ListRow groupRow;
groupRow.kind = RowKind::GROUP;
groupRow.groupIndex = gi;
groupRow.height = groupHeight;
// 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。
groupRow.displayCount = static_cast<int>(shown.size());
m_rows.push_back(groupRow);
// 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。
// ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移,
// 逐条滑进组名栏并被裁掉 —— 行高不变,因此动画期间无需重建行集合。
if (collapsed && !animating)
{
continue;
}
for (int ordinal = 0; ordinal < static_cast<int>(shown.size()); ++ordinal)
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.groupIndex = gi;
signalRow.signalIndex = shown[static_cast<std::size_t>(ordinal)];
signalRow.height = signalHeight;
signalRow.ordinal = ordinal;
m_rows.push_back(signalRow);
}
}
// 内容高度与逐行位移由动画状态推出(无动画时即“自然高度”)。
UpdateAnimGeometry();
}
void SignalPanel::EnsureRows()
{
if (m_rowsDirty)
{
BuildRows();
RefreshScrollBar();
return;
}
if (m_rowShift.size() != m_rows.size())
{
// 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。
UpdateAnimGeometry();
}
}
void SignalPanel::RefreshScrollBar(bool clampScroll)
{
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();
}
if (clampScroll)
{
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`):
// 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标,
// 名称框 / ✕ / 徽标等位置会自动跟随。
//
// ⚠️ 行位图缓存渲染时整体平移到"位图坐标系"(见 `m_rowRenderActive` 的说明:
// GDI 文本不认 GDI+ 变换,必须在几何层平移)。
const float renderOffsetX = m_rowRenderActive ? -m_rowRenderOriginX : 0.0f;
const float renderOffsetY = m_rowRenderActive ? -m_rowRenderOriginY : 0.0f;
if (m_rowYOverrideActive)
{
return gd::RectF(m_listRect.X + renderOffsetX, m_rowYOverrideY + renderOffsetY,
m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
}
float top = m_listRect.Y - m_scrollOffset;
for (int index = 0; index < row; ++index)
{
top += m_rows[static_cast<std::size_t>(index)].height;
}
// 折起/展开动画的刚性平移:组名行之后的行整组上移。
if (row < static_cast<int>(m_rowShift.size()))
{
top -= m_rowShift[static_cast<std::size_t>(row)];
}
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, 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);
}
gd::RectF SignalPanel::GroupCloseRect(int row) const
{
const gd::RectF rect = RowRect(row);
const float size = kCloseButtonSize * m_scale;
return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size,
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
gd::RectF SignalPanel::SignalBadgeRect(int row) const
{
const gd::RectF rect = RowRect(row);
const float width = kBadgeWidth * m_scale;
const float height = kBadgeHeight * m_scale;
// 行尾现在被「移除该信号」✕ 占用(与分组 ✕ 同尺寸、同列),网格徽标因此整体前移;
// 两者都恒在列表最右侧,且始终保留固定的间隔,不会互相挤压。
const float right = SignalRemoveRect(row).X - kRowTailGap * m_scale;
return gd::RectF(right - width, rect.Y + (rect.Height - height) * 0.5f, width, height);
}
bool SignalPanel::SignalBadgeVisible(const SignalRef& ref) const
{
// 「已分配到网格」与「当前标签页可见」**同时**成立时徽标才存在(才绘制、才可点)。
return (m_tab != nullptr) && m_tab->IsVisible(ref) && (m_tab->LaneOfSignal(ref) >= 0);
}
gd::RectF SignalPanel::SignalRemoveRect(int row) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::SIGNAL)
{
return gd::RectF();
}
// 与分组行的关闭 ✕ **完全一致**(同尺寸、同右边距、同行内垂直居中),
// 只是作用对象是本行信号:位于信号条目的最右端。
const gd::RectF rect = RowRect(row);
const float size = kCloseButtonSize * m_scale;
return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size,
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
SignalPanel::RowPart SignalPanel::RowHitPart(int row, float fx, float fy) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return RowPart::NONE;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const gd::RectF rect = RowRect(row);
const bool isGroup = (item.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
if (isGroup)
{
if (UiPointInRect(disclosureRect, fx, fy))
{
return RowPart::DISCLOSURE;
}
if (UiPointInRect(checkRect, fx, fy))
{
return RowPart::CHECK;
}
if (UiPointInRect(GroupCloseRect(row), fx, fy))
{
return RowPart::CLOSE;
}
// 「组X」标签:与折叠三角一样是“折起该分组”的热区
//(用户 2026-10-16 要求:只有点三角或「组X」才折起,点到组名本体不折起)。
const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
const gd::RectF labelRect(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height);
if (UiPointInRect(labelRect, fx, fy))
{
return RowPart::GROUP_TITLE;
}
// 「N 项」计数:**只有折叠/展开语义**(用户 2026-10-17 要求快速点击计数不得进入改名)。
// ⚠️ 必须在名称区域之前判定:计数就紧贴在名称右侧、两者同属一行。
if (UiPointInRect(GroupStatsRect(row), fx, fy))
{
return RowPart::GROUP_COUNT;
}
return RowPart::BODY;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
const bool badgeVisible = SignalBadgeVisible(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;
}
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::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());
}
bool SignalPanel::ModeOptionEnabled(int optionIndex) const
{
if (optionIndex < 0 || optionIndex >= kModeOptionCount)
{
return false;
}
// 单点信号固定用「散点」(见函数说明):其余两种绘制方式置灰且不可点。
if (m_dataSet == nullptr || !SelectedRef().IsValid())
{
return true;
}
const SignalData* signal = m_dataSet->Resolve(SelectedRef());
if (signal == nullptr || signal->finiteCount != 1)
{
return true;
}
return kModeOptions[optionIndex] == DrawMode::SCATTER;
}
void SignalPanel::RequestPlotLaneScroll(const SignalRef& ref)
{
if (m_tab == nullptr || !ref.IsValid())
{
return;
}
// 未勾选绘制到网格时 `LaneOfSignal` 返回 -1 ⇒ 不产生请求(没什么好滚的)。
const int lane = m_tab->LaneOfSignal(ref);
if (lane < 0)
{
return;
}
m_pendingPlotLane = lane;
m_plotLaneRequestFlag = true;
}
void SignalPanel::UpdateTargetRowGeometry()
{
if (m_fonts == nullptr || m_dataSet == nullptr)
{
m_targetNameRect = gd::RectF();
m_targetBadgeRect = gd::RectF();
return;
}
const SignalRef& target = SelectedRef();
const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr;
const std::wstring title = (signal != nullptr) ? signal->name : std::wstring();
const int lane = TargetLaneIndex();
// 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。
if (m_targetGeomValid && m_targetGeomRef == target && m_targetGeomLane == lane &&
m_targetGeomTitle == title)
{
return;
}
m_targetGeomRef = target;
m_targetGeomLane = lane;
m_targetGeomTitle = title;
m_targetGeomValid = true;
m_targetNameRect = gd::RectF();
m_targetBadgeRect = gd::RectF();
if (signal == nullptr || m_targetRowRect.Width <= 0.0f)
{
return;
}
const float labelWidth = kStyleLabelWidth * m_scale;
const float labelGap = kStyleLabelGap * m_scale;
const float contentLeft = m_targetRowRect.X + labelWidth + labelGap;
const float contentWidth = std::max(0.0f, m_targetRowRect.Width - labelWidth - labelGap);
const float badgeWidth = kBadgeWidth * m_scale;
const float badgeHeight = kBadgeHeight * m_scale;
const float badgeGap = kTargetBadgeGap * m_scale;
// 只有**已绘制到网格**的信号才有 GX 标签(未勾选绘制时没有网格归属)。
const bool badgeShown = (lane >= 0) && (badgeWidth + badgeGap < contentWidth);
// 布局(用户 2026-10-16 要求“信号名与 GX 标签同时出现时左右宽度与其它控件对齐”):
// * 名称从**内容列左边缘**开始(与样式区其余行的控件同一左边界);
// * GX 标签**右对齐到内容列右边缘**(与滑轨/数值框等的右边界对齐),
// 而不是紧跟在名称后面(旧的“紧接名称”会让右边缘参差不齐);
// * 名称的可用宽度 = 到 GX 标签左侧(无标签时 = 整个内容列,因此名称可以更宽)。
const float badgeLeft = contentLeft + contentWidth - badgeWidth;
const float nameRight = badgeShown ? (badgeLeft - badgeGap) : (contentLeft + contentWidth);
const float nameWidth = std::max(0.0f, nameRight - contentLeft);
// 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。
m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height);
if (badgeShown)
{
m_targetBadgeRect = gd::RectF(badgeLeft,
m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f,
badgeWidth, badgeHeight);
}
}
void SignalPanel::Layout(const gd::RectF& area)
{
m_area = area;
m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale);
// 标题下方的「筛选信号」输入框(列表内容过滤)。
const float filterHeight = kFilterHeight * m_scale;
m_filterRect = gd::RectF(area.X + kPadding * m_scale,
m_headerRect.Y + m_headerRect.Height + kFilterPadV * m_scale,
std::max(20.0f, area.Width - kPadding * 2.0f * m_scale), filterHeight);
const float styleHeight = DesiredStyleHeight() * m_scale;
m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight);
const float listTop = m_filterRect.Y + m_filterRect.Height + kFilterPadV * m_scale;
m_listRect = gd::RectF(area.X, listTop, area.Width,
std::max(0.0f, m_styleRect.Y - listTop));
// 标题区右侧的“全部显示/隐藏”按钮。
const float toggleWidth = 68.0f * m_scale;
const float toggleHeight = 22.0f * m_scale;
m_toggleAllRect = gd::RectF(area.X + area.Width - kPadding * m_scale - toggleWidth,
m_headerRect.Y + (m_headerRect.Height - toggleHeight) * 0.5f,
toggleWidth, toggleHeight);
// 依据内容决定是否显示滚动条,并据此收缩列表可用宽度。
m_listFullWidth = m_listRect.Width;
EnsureRows();
RefreshScrollBar();
LayoutStyleControls();
}
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;
// 进度必须夹到 [0,1]:任何路径漏掉一次 `ClampScroll()` 都会把滑块画到轨道之外。
const float progress =
(maxScroll > 0.0f) ? std::max(0.0f, std::min(1.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);
}
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;
}
bool SignalPanel::AreAllSignalsVisible() const
{
if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty())
{
return false;
}
return m_tab->VisibleCount() == TotalSignalCount();
}
gd::RectF SignalPanel::RowNameRect(int row) const
{
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return gd::RectF();
}
const gd::RectF rect = RowRect(row);
const bool isGroup = (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale;
float left = 0.0f;
float right = 0.0f;
if (isGroup)
{
// 分组名需要跳过左侧的“组X”标签与分隔线,并且**右侧让出“N 项”计数**:
// 双击进入编辑时,输入框不得把组内信号个数包进去(用户 2026-10-16 报告)。
left = checkRight + 8.0f * m_scale + GroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale +
kGroupSeparatorWidth * m_scale;
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row));
right = statsRight - statsWidth - 6.0f * m_scale;
}
else
{
left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale;
// 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。
const SignalRef ref{m_rows[static_cast<std::size_t>(row)].groupIndex,
m_rows[static_cast<std::size_t>(row)].signalIndex};
const bool badgeVisible = SignalBadgeVisible(ref);
right = (badgeVisible ? SignalBadgeRect(row).X : SignalRemoveRect(row).X) - 6.0f * m_scale;
}
// 与 `PaintGroupRow` / `PaintSignalRow` 使用**同一最小宽度**(16 逻辑像素起步):
// 旧实现在这里因 `right <= left` 返回空矩形,而绘制侧用 `max(16, …)` 仍会画名字
// ⇒ 名称看得见、双击得到的输入框却是 0 宽(组名很长 + “N 项”很宽时可复现)。
const float width = std::max(16.0f * m_scale, right - left);
return gd::RectF(left, rect.Y + 3.0f * m_scale, width, rect.Height - 6.0f * m_scale);
}
gd::RectF SignalPanel::GroupStatsRect(int row) const
{
if (m_fonts == nullptr || m_fonts->uiSmall == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
return gd::RectF();
}
const gd::RectF rect = RowRect(row);
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row));
if (!(statsWidth > 0.0f))
{
return gd::RectF();
}
// 命中矩形在文本左右各多给 3 逻辑像素(计数文字很短,多给一点更好点)。
// 左侧不会吃掉名称区域:`RowNameRect` 的右边界已经让出了 `6*scale`。
const gd::RectF textRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height);
const float pad = 3.0f * m_scale;
return gd::RectF(textRect.X - pad, textRect.Y, textRect.Width + pad * 2.0f, textRect.Height);
}
float SignalPanel::GroupLabelWidth(int row) const
{
if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return 0.0f;
}
return UiMeasureTextWidth(m_fonts->uiBold,
GroupLabelText(m_rows[static_cast<std::size_t>(row)].groupIndex));
}
std::wstring SignalPanel::GroupLabelText(int groupIndex)
{
return L"组" + std::to_wstring(groupIndex + 1);
}
std::wstring SignalPanel::GroupStatsText(int row) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return std::wstring();
}
// 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数
//(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。
return std::to_wstring(m_rows[static_cast<std::size_t>(row)].displayCount) + L" 项";
}
gd::RectF SignalPanel::FilterFieldRect() const
{
// 输入框内容区:左右与上下使用**同一内缩**,避免编辑态出现"左右空隙明显更大"的观感;
// 占位提示与显示文本也用同一内缩,因此进入/退出编辑时文字不会跳动。
const float inset = kFilterTextPad * m_scale;
return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + inset,
std::max(8.0f, m_filterRect.Width - inset * 2.0f),
std::max(8.0f, m_filterRect.Height - inset * 2.0f));
}
gd::RectF SignalPanel::FilterClearRect() const
{
if (m_filterText.empty())
{
return gd::RectF();
}
const float size = std::max(12.0f, m_filterRect.Height - kFilterTextPad * 2.0f * m_scale);
const float right = m_filterRect.X + m_filterRect.Width - kFilterTextPad * m_scale;
return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size);
}
gd::RectF SignalPanel::EditFieldRect()
{
switch (m_editField)
{
case StyleEditField::WIDTH:
return m_widthValueRect;
case StyleEditField::OPACITY:
return m_opacityValueRect;
case StyleEditField::SIGNAL_NAME:
case StyleEditField::GROUP_TITLE:
// `row < 0` = 样式区「目标」行:输入框只覆盖**信号名**那一块,
// 不得把名称后面的 GX 标签也包进去(用户 2026-10-16 报告)。
if (m_editRow < 0)
{
UpdateTargetRowGeometry();
return m_targetNameRect;
}
return RowNameRect(m_editRow);
case StyleEditField::NONE:
default:
return gd::RectF();
}
}
HFONT SignalPanel::EditFieldFont() const
{
if (m_fonts == nullptr)
{
return nullptr;
}
// 分组名的编辑框在行内是用 `uiSmall` 绘制的(分组行字号更小),其余输入框用 `ui`。
// 绘制、命中、插入符换算三处必须一致,否则鼠标落点与插入符位置会错开。
return (m_editField == StyleEditField::GROUP_TITLE) ? m_fonts->uiSmall : m_fonts->ui;
}
gd::RectF SignalPanel::NameEditAbsorbRect(const gd::RectF& field) const
{
if (field.Width <= 0.0f || m_editRow < 0 || m_editRow >= static_cast<int>(m_rows.size()))
{
return field;
}
// 与被编辑行同高:输入框上下各留 3 逻辑像素的行内边距,那一段点下去也必须算文本操作。
const gd::RectF row = RowRect(m_editRow);
if (row.Height <= 0.0f)
{
return field;
}
return gd::RectF(field.X, row.Y, field.Width, row.Height);
}
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;
}
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;
}
} // namespace jdi