## 1. 新功能:齿轮按钮 -> 应用设置菜单 -> 显示布局边界 - ToolbarAction::SETTINGS 位于「导入文件」左侧;工具栏布局改为 「左侧固定簇(kLeftClusterCount = 2:齿轮 + 导入文件)+ 右侧按钮组」。 - 齿轮是矢量化绘制(8 齿 + 外环 + 轴孔,全部按 half 的比例推导), 不用字体字符「⚙」——该字符在不同回退字体里大小与基线不一致,会忽大忽小、垂直不居中。 UiButtonWidget 新增 UiButtonIcon(NONE / GEAR)与 SetIcon。 - MenuScope::APP_SETTINGS(新增)+ 设置菜单:HEADER「调试」+ 勾选切换项「显示布局边界」 (keepOpen:点击后菜单保持打开,勾选态由 UpdateItemLabel 回写)。 锚点取 Toolbar::ButtonRect(SETTINGS) 的控件实测矩形,宿主不再自己推算按钮几何。 ## 2. 布局边界:基类给协议,组件自己上报 - UiComponent 新增 virtual PaintDebugBounds(g, scale),默认只画 Area()。 - Toolbar / TabBar 覆盖:跳过与区域重合的根控件,遍历 m_root->Children()(层级 1); TabBar 另上报每个 ✕(层级 2)。 - SignalPanel 覆盖:表头 / 筛选框 / 列表 / 样式区(1)+ 滚动条、样式行、当前可见列表行(2)。 - PlotView 覆盖:每个 LaneVisual::rect(1);不上报随数据变化的瞬态几何(曲线/胶囊/提示)。 - UiWidget 新增 PaintDebugBounds(g, scale, depth) 递归整棵控件树 —— 已上树的控件零成本获得边界。 - 宿主 PaintLayoutBounds 补客户区外框、分隔条、打开的菜单/拾色器。 视觉与实现约束(Android「Show layout bounds」风): - 只描边、不填充(几十个嵌套矩形叠加填充会把内容糊住); - 层级配色 青蓝(2px) / 品红 / 琥珀 / 绿(1px); - 线宽按设备像素固定(不乘 scale),临时 PixelOffsetModeNone + 关抗锯齿 + 整像素对齐, 保证 1px 就是 1px; - ⚠️ 只在 Render 最后一步叠加,绝不进入任何位图缓存(PlotView 4 层缓存 / SignalPanel 行位图缓存), 否则关掉开关后线条会残留; - ⚠️ fullFrame 并入 m_showLayoutBounds:开启时必须整帧回送,否则「只回送绘图区」的快路径 会让装饰区边界停在旧帧上。 ## 3. 可见收益优化(行为不变) - Toolbar::Layout:控件树改为按需重建(m_treeDirty),只在规格/字体/主题/缩放/动态文本 变化时重建。拖动面板分隔条时 Relayout 每帧调用一次 -> 不再每帧 10 次堆分配 + 8 次测量缓存失效。 - Toolbar::SetActionLabel:只写 m_labelOverrides(唯一权威)+ 置树脏,消除「覆盖表与控件文本 双写」可能不同步的问题。 - UiWidget::DispatchRedraw(event):新增;收敛 Toolbar / TabBar 里重复的 Dispatch(); return UiRectHasArea(TakeDirty());(TakeDirty 会清零,漏一次就把下一次真实变化 误判成「无变化」)。 ## 4. 验证 - make release / make debug:零 error 零 warning;TargetCheck.exe 897,024 B(< 900 KB)。 - 新增离屏探针 .preflight/lbcheck:21 / 21 PASS (齿轮在导入文件左侧/同高/更窄;开启后青蓝 24,930 px、品红 19,734 px;关闭后逐像素还原; 菜单 scope、锚定贴合按钮、宽度未被撑大、条目初始未勾选、点击翻转开关、菜单保持打开)。 - Tools/rendercheck:8 个场景的「工具栏带以下区域」哈希与改造前**逐一相同** (证明改动像素被完全限制在工具栏带内);整帧哈希因新增按钮整体变化,属预期。 FUNC 断言 7 / 7 PASS。 - Tools/uicheck:69 passed / 0 failed。 ## 5. 文档 Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第六阶段; Documents/agent_mem.md 同步变更日志与「2026-10-28 第八轮」硬约束与验证坑。
1304 lines
46 KiB
C++
1304 lines
46 KiB
C++
// SignalPanel 实现分片 —— 核心:状态、行集合构建、布局与几何、分组/信号可见性。
|
|
//
|
|
// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
|
|
// 六个分片按**职责**划分:
|
|
// SignalPanel.cpp 核心:状态、行集合、布局与几何、可见性(**本文件**)
|
|
// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条
|
|
// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状
|
|
// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度
|
|
// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号"
|
|
// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执
|
|
#include "SignalPanelInternal.hpp"
|
|
|
|
#include "UiDebugBounds.hpp"
|
|
#include "UiLayout.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));
|
|
}
|
|
|
|
void SignalPanel::PaintDebugBounds(gd::Graphics& g, float scale)
|
|
{
|
|
UiComponent::PaintDebugBounds(g, scale);
|
|
|
|
if (m_fonts == nullptr || !UiRectHasArea(m_area))
|
|
{
|
|
return; // 尚未完成布局:内部矩形全是空的,画出来只会误导。
|
|
}
|
|
|
|
// 层级 1:面板内部的功能分区。
|
|
UiDebugPaintBounds(g, m_headerRect, 1, scale);
|
|
UiDebugPaintBounds(g, m_filterRect, 1, scale);
|
|
UiDebugPaintBounds(g, m_listRect, 1, scale);
|
|
UiDebugPaintBounds(g, m_styleRect, 1, scale);
|
|
|
|
// 层级 2:滚动条与样式区的每一行。
|
|
UiDebugPaintBounds(g, m_scrollBarRect, 2, scale);
|
|
for (const gd::RectF& row : m_styleRowRects)
|
|
{
|
|
UiDebugPaintBounds(g, row, 2, scale);
|
|
}
|
|
|
|
// 层级 2:列表里**当前可见**的行(被滚出列表的行不画,否则边界会糊出列表区)。
|
|
const float listTop = m_listRect.Y;
|
|
const float listBottom = m_listRect.Y + m_listRect.Height;
|
|
const int rowCount = static_cast<int>(m_rows.size());
|
|
for (int row = 0; row < rowCount; ++row)
|
|
{
|
|
const gd::RectF rect = RowRect(row);
|
|
if (!UiRectHasArea(rect))
|
|
{
|
|
continue;
|
|
}
|
|
if (rect.Y + rect.Height <= listTop || rect.Y >= listBottom)
|
|
{
|
|
continue;
|
|
}
|
|
UiDebugPaintBounds(g, rect, 2, scale);
|
|
}
|
|
}
|
|
|
|
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
|
|
|