Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelScroll.cpp
T
DeepSeek V4.1 Flash 0ec8055e95 重构(UI): 统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + 剥离 Tools
## 1. 新增 UiComponent 顶层组件协议(不是负优化)

Components/Shared/Inc/UiComponent.hpp:纯接口、无状态,8 个方法
SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor。

Toolbar / TabBar / SignalPanel / PlotView 本来就各自拥有完全同名同签名的成员,
因此改造 = 加 override + 补 Area()(PlotView 另加一个非 const 的 QueryCursor 转发,
因为它自己的查询会惰性建立行缓存),实现代码零改动、静态派发、体积无增长。

刻意不把 UiWidget 当作它的基类:UiWidget 是"树节点"(父给尺寸、父下探脏区、
命中沿树链派发),而 PlotView(4 层位图缓存)/ SignalPanel(行位图缓存)是
"地盘宿主"。强行上树会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过
"后台渲染覆盖平移结果""命中矩形错位"的事故。头文件里已写明这条理由。
Toolbar/TabBar 是两者的交集:对外实现 UiComponent,对内持有一棵 UiWidget 树。

## 2. 宿主侧:四条手写链 -> 组件表

Application 新增 ComponentEntry{ component, target, chrome } 与 m_components
(底->顶:plot -> panel -> toolbar -> tabBar),BuildComponentTable() 装配一次。

- ApplyDpi        6 行逐个 SetScale  ->  1 个循环
- Relayout        手算三列 x 坐标    ->  UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO})
- WM_MOUSELEAVE   逐个 OnMouseLeave   ->  循环累积 chromeChanged / plotChanged
- WM_SETCURSOR    if/else if 链      ->  从表尾向前找首个命中者,取其 QueryCursor

顺带兑现历史缺口:UiLayout::UiStackRects 自引入以来第一次有了生产调用点。

有意保留显式的三条链(WM_LBUTTONDOWN / WM_LBUTTONUP / WM_MOUSEMOVE):它们各自带
组件专属后置动作(提交重命名、构建派生信号、面板联动滚动、更新拖拽光标、
plotOnlyMove 决定局部失效还是整帧失效)。塞进表里只能靠 lambda 字段,
可读性反而下降 —— 属于用户明确禁止的"过度抽象",因此不做。

## 3. 大文件按职责拆分(纯整函数搬移,逐像素 0 差异)

SignalPanelInteraction.cpp 2329 -> 831 行,拆出:
  SignalPanelEdit.cpp   616  内联编辑(筛选框、组名/信号名、数值、颜色与透明度)
  SignalPanelScroll.cpp 205  动画与滚动(折起、联动滚动、滚动到目标信号)
  SignalPanelMenu.cpp   527  弹出菜单(系数/偏移、镜像、右键菜单与命令回执)

Shared/PopupMenu.cpp 1901 -> 706 行,拆出:
  PopupMenuLayout.cpp 753  尺寸测量、项矩形排版、滚动可见性
  PopupMenuPaint.cpp  397  背景/阴影/项绘制
  Inc/PopupMenuInternal.hpp 103   pmdetail 常量 + inline 辅助(跨 TU 安全)

拆分判据是"职责"而非行数;共享常量一律收敛到 *Internal.hpp。

## 4. 代码审查(修 5 处)

- SignalPanelInteraction.cpp 头注释仍写"4 片",实际 6 片 -> 改为完整清单
- PopupMenuLayout.cpp 机械搬移时把构造函数一起搬走 -> 重复定义,只保留在交互分片
- PopupMenu.cpp 的 #include "PopupMenuInternal.hpp" 落进 namespace jdi 内 -> 移到文件头
- PopupMenuPaint.cpp 缺 UiControls.hpp(UiDrawEditFieldContent 未声明)-> 补 include
- 全仓复查历史删除符号残留引用(m_paintPlot / ClampCursorsAfterPan /
  PaintFreeMarkerTooltip / CaptureDecorCache / FitSinglePointSignal / CsvTable /
  DataFile / .preflight / structcheck)-> 零命中

## 5. Tools/ 剥离

git rm -r --cached Tools + .gitignore 新增 Tools/(本地文件保留,可随时复跑)。
ui_refactor 分支为全本地分支,另做 filter-branch 清除历史中的该目录。

## 6. 验证

- make release / make debug:零 error 零 warning
- rendercheck:A-H 八个场景哈希与改造前逐一相同
  (A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
   E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113)
  FUNC 7/7 PASS
- uicheck:69 passed, 0 failed
- TargetCheck.exe 891,392 B(< 900 KB 阈值)

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md
新增第五阶段章节;Documents/agent_mem.md 同步变更日志与「第七轮」硬约束。
2026-10-28 14:35:32 +08:00

227 lines
8.3 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SignalPanel 实现分片 —— 动画与滚动(分组折起、联动滚动、"滚动到目标信号")。
//
// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。
// ⚠️ 折起动画是"抽拉卷尺"式的刚性平移:只改 `m_rowShift`,**行高永不变**,
// 动画期间不得重建行集合(见 agent_mem.md 的硬约束)。
#include "SignalPanelInternal.hpp"
namespace jdi
{
bool SignalPanel::AdvanceGroupAnimations()
{
if (m_groupAnims.empty())
{
return false;
}
const std::uint64_t now = GetTickCount64();
bool animationFinished = false;
for (std::size_t index = 0; index < m_groupAnims.size();)
{
const GroupAnim& anim = m_groupAnims[index];
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
{
// 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。
if (anim.scrollSynced)
{
m_scrollOffset = anim.scrollTo;
}
animationFinished = true;
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
// ⚠️ **动画结束(折起与展开都要)后必须重建行集合**:
// * 折起:该组的信号行要真正从行集合里移除;
// * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”,
// 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会
// 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。
// ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 ——
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
if (animationFinished)
{
m_rowsDirty = true;
BuildRows(); // 内部会重算内容高度与逐行位移
}
else
{
UpdateAnimGeometry();
}
// 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间
// 列表宽度/滚动条与内容不匹配)。
// ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进
// `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则
// 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条,
// 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。
RefreshScrollBar(animationFinished);
return !m_groupAnims.empty();
}
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
{
EnsureRows();
if (m_tab == nullptr || groupIndex < 0)
{
return;
}
const int headerRow =
(row >= 0 && row < static_cast<int>(m_rows.size()) &&
m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP &&
m_rows[static_cast<std::size_t>(row)].groupIndex == groupIndex)
? row
: GroupRowIndex(groupIndex);
const bool collapse = !IsGroupCollapsed(groupIndex);
SetGroupCollapsed(groupIndex, collapse);
// 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**,
// 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。
//(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。)
float scrollFrom = m_scrollOffset;
float scrollTo = m_scrollOffset;
bool syncScroll = false;
if (collapse && headerRow >= 0)
{
const float alignOffset = ContentOffsetBeforeRow(headerRow);
if (m_scrollOffset > alignOffset + 0.5f)
{
// 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短,
// 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。
const float finalContent =
std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex));
const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height);
scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset));
syncScroll = true;
}
}
StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll);
}
void SignalPanel::StartScrollAnimationTo(float desired)
{
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
desired = std::max(0.0f, std::min(desired, maxOffset));
const float distance = std::fabs(desired - m_scrollOffset);
if (distance < 1.0f)
{
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
m_scrollOffset = desired;
ClampScroll();
m_scrollAnimActive = false;
return;
}
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
// 因此"整个动画过程不会太久"(最长 300ms)。
const float durationMs =
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
m_scrollAnimFrom = m_scrollOffset;
m_scrollAnimTo = desired;
m_scrollAnimStartMs = GetTickCount64();
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_scrollAnimActive = true;
}
void SignalPanel::ScrollToSignal(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到
// 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后
// 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。
// ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (SignalScrollTarget(ref, desired))
{
m_scrollOffset = desired;
ClampScroll();
}
m_scrollAnimActive = false;
}
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。
// 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。
// 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (!SignalScrollTarget(ref, desired))
{
return;
}
// 把列表平滑滚动到目标信号完整可见。
StartScrollAnimationTo(desired);
}
bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref)
{
if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex))
{
return false;
}
SetGroupCollapsed(ref.groupIndex, false);
// 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。
const int anim = FindGroupAnimation(ref.groupIndex);
if (anim >= 0)
{
m_groupAnims.erase(m_groupAnims.begin() + anim);
}
// 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。
m_rowsDirty = true;
EnsureRows();
return true;
}
bool SignalPanel::AdvanceScrollAnimation()
{
if (!m_scrollAnimActive)
{
return false;
}
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0;
float progress = (m_scrollAnimDurationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(m_scrollAnimDurationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased;
if (progress >= 1.0f)
{
m_scrollOffset = m_scrollAnimTo;
m_scrollAnimActive = false;
ClampScroll();
return false;
}
return true;
}
} // namespace jdi