## 1. 边界描边按元素尺寸自适应(修复"小元素变成粗描边")
旧规则四角固定 3-4px:对 15px 的复选框来说包角占短边 20%,看起来就是一整圈粗边框。
新规则全部以**短边**为基准:
- 四边固定 1 设备像素;
- 包角粗细 = min(基础 4/3, 短边/10),再夹到 [1, 短边/4];
- 包角长度 = min(14, 短边/3),且不小于包角粗细;
- 短边 <= 12 只画四个 L 形角标记、**不画边**(一圈连续描边会让小方框显得"被框住");
- 短边 <= 5 完全不画(再画就盖住元素本身);
- 内缩只用于「层级 > 0 且短边 >= 10」,否则小元素会被缩没。
同时**删除文字标签**(含 UiDebugPaintStyle):黑底白字在小元素上必糊成一片,体积也省约 1.5KB。
## 2. 拖网格滚动条时的"大量绘制残留":三个独立成因
R1. PlotView::CollectDebugBoxes 读了 m_decorRects —— 那是**装饰层位图缓存**的登记矩形,
坐标处在位图/图层坐标系(带负 Y、超出绘图区),于是出现 (472,-27 1213x101) 这类
**横跨工具栏的幽灵巨框**。改为只用 LaneBadgeRect 与 m_legendHits(屏幕坐标)。
R2. Paint 的三条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁)只重画**变化的部分**,
而调试边框是画在整个窗口上的叠加内容 ⇒ 上一帧的边框残留在未被重画的像素上,
直到松开鼠标走整幅重绘才消失。
新增 PlotView::SetForceFullRepaint(bool):开启调试叠加层时禁用全部快路径。
R3. 组件内元素可能落在自己地盘之外(滚出可视区的 lane.rect 的 Y 为负),不裁剪就画到
工具栏/标签栏上。box 增加 clip:宿主按组件 Area() 设置,Add() 跳过无交集、
绘制时与 clip 求交。
另外把 Render 的 fullChrome 并入 m_showLayoutBounds,装饰区也保证完整一帧。
## 3. 层级:排序 + 挖洞(修复二级菜单被下层边界覆盖)
- UiDebugBoxList::PushLayer() + 按 (layer, depth) 稳定排序:后收集的内容压住先收集的。
- 但仅靠排序不够:调试线画在所有内容之后,下层组件的线仍会显示在不透明菜单上面。
新增 AddOccluder():菜单/拾色器把本体登记为遮挡矩形,绘制**更低层**的 box 时用
Region::Exclude 挖洞。实测菜单区与子菜单区的琥珀/绿色像素均为 0。
- 浮层**不设** clip = 本体矩形:二级子菜单会展开到本体之外,设裁剪会把它整个裁掉。
## 4. 验证
- make release / make debug 零 error 零 warning;TargetCheck.exe 906,752 B(885.5 KiB < 900 KiB)。
- 新增 .preflight/lbresid(MF4-Record.MF4 / 283 网格 / 拖滚动条)**5/5**:
toolbar-unchanged-while-scrolling、repaint-same-state-is-identical、
toolbar-restored-by-full-repaint、residual-never-above-plot-area、later-layer-wins-over-earlier。
- 新增 .preflight/lbzorder **4/4**:菜单与二级子菜单区内下层颜色像素 = 0。
- 回归:.preflight/lbcheck 21/21、Tools/uicheck 69/69、
Tools/rendercheck「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7。
注:判断"是否有绘制残留"不能用"拖拽帧 vs 松开后一帧"——两者图层预热进度不同会产生
几十万像素的**合法**差异;正确判据是**同一状态连续两帧必须逐像素相同**。
## 5. 文档
Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第八阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第十轮」硬约束与踩坑。
1416 lines
50 KiB
C++
1416 lines
50 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::CollectDebugBoxes(UiDebugBoxList& boxes)
|
||
{
|
||
UiComponent::CollectDebugBoxes(boxes);
|
||
|
||
if (m_fonts == nullptr || !UiRectHasArea(m_area))
|
||
{
|
||
return; // 尚未完成布局:内部矩形全是空的,登记出来只会误导。
|
||
}
|
||
|
||
// 行几何依赖行集合 ⇒ 先确保它是最新的(与绘制走同一个入口)。
|
||
EnsureRows();
|
||
|
||
// ── 层级 1:面板内部的四个功能分区 ────────────────────────────────
|
||
boxes.Add(m_headerRect, 1);
|
||
boxes.Add(m_filterRect, 1);
|
||
boxes.Add(m_listRect, 1);
|
||
boxes.Add(m_styleRect, 1);
|
||
|
||
// ── 层级 2:分区内部的固定控件 ────────────────────────────────────
|
||
boxes.Add(m_filterRect, 2); // 输入框外框(与分区边框区分)
|
||
boxes.Add(FilterFieldRect(), 3);
|
||
boxes.Add(FilterClearRect(), 3);
|
||
boxes.Add(m_toggleAllRect, 2);
|
||
|
||
boxes.Add(m_scrollBarRect, 2);
|
||
boxes.Add(ScrollThumbRect(), 3);
|
||
|
||
// ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ──────────────────
|
||
for (std::size_t index = 0; index < m_styleRowRects.size(); ++index)
|
||
{
|
||
boxes.Add(m_styleRowRects[index], 2);
|
||
}
|
||
for (const gd::RectF& swatch : m_colorRects)
|
||
{
|
||
boxes.Add(swatch, 3);
|
||
}
|
||
boxes.Add(m_customChipRect, 3);
|
||
for (const gd::RectF& mode : m_modeRects)
|
||
{
|
||
boxes.Add(mode, 3);
|
||
}
|
||
for (const gd::RectF& adjust : m_adjustRects)
|
||
{
|
||
boxes.Add(adjust, 3);
|
||
}
|
||
boxes.Add(m_widthTrackRect, 3);
|
||
boxes.Add(m_widthValueRect, 3);
|
||
boxes.Add(m_opacityTrackRect, 3);
|
||
boxes.Add(m_opacityValueRect, 3);
|
||
|
||
// ── 「目标」行(样式区顶部,展示当前选中信号) ────────────────────
|
||
boxes.Add(m_targetRowRect, 2);
|
||
boxes.Add(m_targetNameRect, 3);
|
||
boxes.Add(m_targetBadgeRect, 3);
|
||
|
||
// ── 列表里**当前可见**的行,以及每行内部的元素 ────────────────────
|
||
// 被滚出列表的行不登记,否则边界会糊到列表区之外。
|
||
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;
|
||
}
|
||
|
||
boxes.Add(rect, 2);
|
||
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
const bool isGroup = (item.kind == RowKind::GROUP);
|
||
// ⚠️ 行内子块的几何必须与 `RowHitPart` / `PaintRow` **完全同源**:
|
||
// 这里复用同一批常量与同一个 `RowRect`,不另写一套推算。
|
||
const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale);
|
||
const float centerY = rect.Y + rect.Height * 0.5f;
|
||
|
||
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
|
||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
|
||
centerY - kCheckSize * m_scale * 0.5f,
|
||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||
boxes.Add(disclosureRect, 3);
|
||
boxes.Add(checkRect, 3);
|
||
|
||
if (isGroup)
|
||
{
|
||
const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
|
||
boxes.Add(gd::RectF(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height), 3);
|
||
boxes.Add(GroupStatsRect(row), 3);
|
||
boxes.Add(GroupCloseRect(row), 3);
|
||
continue;
|
||
}
|
||
|
||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||
const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale,
|
||
centerY - kSwatchSize * m_scale * 0.5f,
|
||
kSwatchSize * m_scale, kSwatchSize * m_scale);
|
||
boxes.Add(swatchRect, 3);
|
||
boxes.Add(RowNameRect(row), 3);
|
||
if (SignalBadgeVisible(ref))
|
||
{
|
||
boxes.Add(SignalBadgeRect(row), 3);
|
||
}
|
||
boxes.Add(SignalRemoveRect(row), 3);
|
||
}
|
||
|
||
// ── 内联编辑框(正在重命名时才有意义) ───────────────────────────
|
||
if (m_editField != StyleEditField::NONE)
|
||
{
|
||
boxes.Add(EditFieldRect(), 4);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
// 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。
|
||
// 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。
|
||
m_rowTop.assign(m_rows.size() + 1, 0.0f);
|
||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||
{
|
||
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height;
|
||
}
|
||
|
||
// 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。
|
||
// 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。
|
||
m_rowTop.assign(m_rows.size() + 1, 0.0f);
|
||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||
{
|
||
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height;
|
||
}
|
||
|
||
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;
|
||
// O(1) 查表:前缀和由 `UpdateAnimGeometry()` 维护。
|
||
// ⚠️ 前缀和与行集合长度不一致只可能出现在"行集合刚变、几何还没更新"的极短窗口,
|
||
// 此时退回逐行累加以保证结果仍然正确(不追求速度)。
|
||
const std::size_t rowIndex = static_cast<std::size_t>(row);
|
||
if (m_rowTop.size() == m_rows.size() + 1)
|
||
{
|
||
top += m_rowTop[rowIndex];
|
||
}
|
||
else
|
||
{
|
||
for (std::size_t index = 0; index < rowIndex; ++index)
|
||
{
|
||
top += m_rows[index].height;
|
||
}
|
||
}
|
||
// 折起/展开动画的刚性平移:组名行之后的行整组上移。
|
||
if (rowIndex < m_rowShift.size())
|
||
{
|
||
top -= m_rowShift[rowIndex];
|
||
}
|
||
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width,
|
||
m_rows[rowIndex].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
|
||
{
|
||
// 与 `RowRect` 共用同一份前缀和(O(1)),不再逐行累加。
|
||
const int count = std::min(row, static_cast<int>(m_rows.size()));
|
||
if (count <= 0)
|
||
{
|
||
return 0.0f;
|
||
}
|
||
if (m_rowTop.size() == m_rows.size() + 1)
|
||
{
|
||
return m_rowTop[static_cast<std::size_t>(count)];
|
||
}
|
||
float top = 0.0f;
|
||
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 = PanelBounds();
|
||
// 以记忆的自定义颜色作为初始取值,实现“再次打开即恢复上次取色”。
|
||
m_colorPicker->Open(bounds, m_customChipRect, style.customColor);
|
||
return true;
|
||
}
|
||
|
||
|
||
} // namespace jdi
|
||
|