信号面板分组折起动画 + 筛选态折起(项数按筛选计数)+ 悬浮组名可交互与“先滚动再折起” + 适应视图与 Y 轴自适应同源

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

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

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

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

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

记忆:Documents/agent_mem.md 新增 §9.35 与 §11.2 第 76–81 条,并更新第 70 条与 §9.33 备注。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-16 01:25:54 +08:00
1 parent cb9d3e4b8f
commit 1d314ddc24
5 files changed
+801 -161

No files matched your search

+446 -118
View File
@@ -113,6 +113,29 @@ namespace jdi
const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
// ---- 分组折起/展开动画(用户 2026-10-16 要求)----
// 总时长 = clamp(基础 + 行数×每行, 下限, 上限);上限与滚动动画同一量级,
// 保证“整个动画过程不会太久”。
const float kGroupAnimBaseMs = 115.0f; /**< 动画基础时长。 */
const float kGroupAnimPerRowMs = 14.0f; /**< 每多一行增加的时长。 */
const float kGroupAnimMinMs = 140.0f; /**< 最短时长。 */
const float kGroupAnimMaxMs = 300.0f; /**< 最长时长。 */
/**
* @brief 「离组名越远的成员收起越快」的深度增益。
*
* 第 ordinal 行的速度倍率 = `1 + 增益 × ordinal / (行数-1)`:最深的一行倍率最高,
* 因此最先收起(实测 1.2 时最深一行在总时长的约 45% 处就已经收完),
* 而紧邻组名的第一行最后收完,配合 ease-out 形成“向上收起 + 减速”的观感。
*/
const float kGroupAnimDepthBoost = 1.2f;
/**
* @brief 分组折起动画中“已被收成一条线”的行高下限(逻辑像素)。
*
* 低于该高度的行不再产生(既不绘制也不参与命中),避免出现高度接近 0
* 却仍能吸走一次点击的空行。
*/
const float kGroupAnimMinRowHeight = 0.75f;
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
@@ -309,6 +332,10 @@ namespace jdi
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与挂起请求一律作废。
m_groupAnims.clear();
m_pendingGroupToggle = -1;
m_scrollAnimActive = false;
}
void SignalPanel::SetScale(float scale)
@@ -362,6 +389,10 @@ namespace jdi
// 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。
m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u);
}
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
m_filterCollapsed.clear();
m_groupAnims.clear();
m_pendingGroupToggle = -1;
// 清空后回到完整列表:重建行集合、回到顶部。
m_rowsDirty = true;
m_scrollOffset = 0.0f;
@@ -438,6 +469,11 @@ namespace jdi
return;
}
m_filterText = text;
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画与挂起请求全部作废
//(行数与分组可见性都会变,继续沿用会算出错误的行集合)。
m_filterCollapsed.clear();
m_groupAnims.clear();
m_pendingGroupToggle = -1;
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
m_rowsDirty = true;
m_scrollOffset = 0.0f;
@@ -461,8 +497,221 @@ namespace jdi
return WildcardMatchNoCase(signal.Title(), pattern);
}
bool SignalPanel::IsGroupCollapsed(int groupIndex) const
{
if (groupIndex < 0)
{
return false;
}
// 筛选模式使用**独立**的折起状态:筛选只是临时子集视图,不应污染普通列表的布局选择;
// 且筛选结果默认展开(便于直接看到匹配项)。
if (!m_filterText.empty())
{
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
{
return false;
}
return m_filterCollapsed[static_cast<std::size_t>(groupIndex)];
}
return (m_tab != nullptr) && m_tab->IsCollapsed(groupIndex);
}
void SignalPanel::SetGroupCollapsed(int groupIndex, bool collapsed)
{
if (groupIndex < 0)
{
return;
}
if (!m_filterText.empty())
{
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
{
m_filterCollapsed.resize(static_cast<std::size_t>(groupIndex) + 1, false);
}
m_filterCollapsed[static_cast<std::size_t>(groupIndex)] = collapsed;
return;
}
if (m_tab != nullptr && m_tab->IsCollapsed(groupIndex) != collapsed)
{
m_tab->ToggleCollapsed(groupIndex);
}
}
int SignalPanel::ShownSignalCount(int groupIndex) const
{
if (m_dataSet == nullptr || groupIndex < 0)
{
return 0;
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return 0;
}
if (m_filterText.empty())
{
return static_cast<int>(group->signals.size());
}
int count = 0;
for (const SignalData& signal : group->signals)
{
if (FilterMatchesSignal(signal))
{
++count;
}
}
return count;
}
int SignalPanel::FindGroupAnimation(int groupIndex) const
{
for (std::size_t index = 0; index < m_groupAnims.size(); ++index)
{
if (m_groupAnims[index].groupIndex == groupIndex)
{
return static_cast<int>(index);
}
}
return -1;
}
void SignalPanel::StartGroupAnimation(int groupIndex, bool collapsing, int rowCount)
{
if (groupIndex < 0 || rowCount <= 0)
{
return;
}
// 同一分组只保留一个动画条目:再次切换时移除旧的,新动画从“当前状态”出发 ——
// 折起与展开的起点本来就分别是干净的 1 与 0,因此重新开始即可。
const int existing = FindGroupAnimation(groupIndex);
if (existing >= 0)
{
m_groupAnims.erase(m_groupAnims.begin() + existing);
}
GroupAnim anim;
anim.groupIndex = groupIndex;
anim.collapsing = collapsing;
anim.rowCount = rowCount;
anim.startMs = GetTickCount64();
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
const float durationMs = std::max(
kGroupAnimMinMs,
std::min(kGroupAnimMaxMs,
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(rowCount)));
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_groupAnims.push_back(anim);
m_rowsDirty = true;
}
float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const
{
const int animIndex = FindGroupAnimation(groupIndex);
if (animIndex < 0)
{
// 没有动画:完全由折起状态决定(折起 = 不占高度)。
return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f;
}
const GroupAnim& anim = m_groupAnims[static_cast<std::size_t>(animIndex)];
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
float progress = (anim.durationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(anim.durationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// 「被收起的组最下方成员离得越远收起速度越快」:第 ordinal 行的速度倍率
// 随它与组名的距离线性增大,因此最深的一行最先收起,紧邻组名的一行最后收完。
const float depth = (rowCount > 1)
? static_cast<float>(ordinal) / static_cast<float>(rowCount - 1)
: 0.0f;
const float speed = 1.0f + kGroupAnimDepthBoost * depth;
float local = progress * speed;
if (local > 1.0f)
{
local = 1.0f;
}
// ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。
const float eased = 1.0f - std::pow(1.0f - local, 3.0f);
return anim.collapsing ? (1.0f - eased) : eased;
}
bool SignalPanel::AdvanceGroupAnimations()
{
if (m_groupAnims.empty())
{
return false;
}
const std::uint64_t now = GetTickCount64();
for (std::size_t index = 0; index < m_groupAnims.size();)
{
const GroupAnim& anim = m_groupAnims[index];
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
{
// 完成:移除条目,行高随即回到“折起状态”决定的最终值。
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
// 动画期间每一帧的行高都不同 ⇒ 必须重建行集合(否则画面静止不动)。
m_rowsDirty = true;
return !m_groupAnims.empty();
}
void SignalPanel::ToggleGroupCollapsedAnimated(int groupIndex)
{
if (m_tab == nullptr || groupIndex < 0)
{
return;
}
const bool collapse = !IsGroupCollapsed(groupIndex);
SetGroupCollapsed(groupIndex, collapse);
// 参与动画的行数 = 该组当前模式下会列出的信号数(筛选时只算匹配项)。
const int count = ShownSignalCount(groupIndex);
if (count > 0)
{
StartGroupAnimation(groupIndex, collapse, count);
}
else
{
m_rowsDirty = true;
}
}
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
{
EnsureRows();
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
ToggleGroupCollapsedAnimated(groupIndex);
return;
}
// 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。
const float alignOffset = ContentOffsetBeforeRow(row);
if (m_scrollOffset > alignOffset + 0.5f)
{
// 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动,
// 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。
StartScrollAnimationTo(alignOffset, groupIndex);
return;
}
ToggleGroupCollapsedAnimated(groupIndex);
}
void SignalPanel::BuildRows()
{ m_rows.clear();
{
m_rows.clear();
m_rowsDirty = false;
if (m_dataSet == nullptr || m_tab == nullptr)
@@ -478,58 +727,65 @@ namespace jdi
const std::vector<SignalGroup>& files = m_dataSet->Groups();
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
{
// 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。
std::vector<int> matched;
if (filtering)
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)
{
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]))
{
if (FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
{
matched.push_back(static_cast<int>(signalIndex));
}
}
if (matched.empty())
{
continue;
shown.push_back(static_cast<int>(signalIndex));
}
}
if (filtering && shown.empty())
{
continue;
}
const bool collapsed = IsGroupCollapsed(gi);
const int animIndex = FindGroupAnimation(gi);
const GroupAnim* anim = nullptr;
if (animIndex >= 0 &&
m_groupAnims[static_cast<std::size_t>(animIndex)].rowCount == static_cast<int>(shown.size()))
{
anim = &m_groupAnims[static_cast<std::size_t>(animIndex)];
}
ListRow fileRow;
fileRow.kind = RowKind::GROUP;
fileRow.groupIndex = static_cast<int>(groupIndex);
fileRow.groupIndex = gi;
fileRow.height = fileHeight;
// 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。
fileRow.displayCount = static_cast<int>(shown.size());
m_rows.push_back(fileRow);
// 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。
if (!filtering && m_tab->IsCollapsed(static_cast<int>(groupIndex)))
// 折起状态与动画共同决定每个信号行的实际高度:
// * 展开且无动画 ⇒ 完整高度;
// * 折起且无动画 ⇒ 不产生任何行;
// * 有动画 ⇒ 按 `GroupRowScale()` 连续缩小(折起)/ 放大(展开)。
const int rowCount = static_cast<int>(shown.size());
for (int ordinal = 0; ordinal < rowCount; ++ordinal)
{
continue;
}
if (filtering)
{
for (const int signalIndex : matched)
float scale = collapsed ? 0.0f : 1.0f;
if (anim != nullptr)
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.groupIndex = static_cast<int>(groupIndex);
signalRow.signalIndex = signalIndex;
signalRow.height = signalHeight;
m_rows.push_back(signalRow);
scale = GroupRowScale(gi, ordinal, rowCount);
}
const float height = signalHeight * scale;
if (height < kGroupAnimMinRowHeight * m_scale)
{
// 已经收成一条线:不再产生可绘制 / 可命中的行。
continue;
}
continue;
}
const std::size_t signalCount = files[groupIndex].signals.size();
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
{
ListRow signalRow;
signalRow.kind = RowKind::SIGNAL;
signalRow.groupIndex = static_cast<int>(groupIndex);
signalRow.signalIndex = static_cast<int>(signalIndex);
signalRow.height = signalHeight;
signalRow.groupIndex = gi;
signalRow.signalIndex = shown[static_cast<std::size_t>(ordinal)];
signalRow.height = height;
signalRow.ordinal = ordinal;
m_rows.push_back(signalRow);
}
}
@@ -688,8 +944,10 @@ namespace jdi
m_rowYOverrideY = stickyY;
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height));
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
PaintFileRow(g, row);
ClearRowClip();
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
if (stickyY > naturalY + 0.5f)
{
@@ -734,6 +992,59 @@ namespace jdi
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
SignalPanel::RowPart SignalPanel::RowHitPart(int row, float fx, float fy) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return RowPart::NONE;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const gd::RectF rect = RowRect(row);
const bool isFile = (item.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
if (isFile)
{
if (UiPointInRect(disclosureRect, fx, fy))
{
return RowPart::DISCLOSURE;
}
if (UiPointInRect(checkRect, fx, fy))
{
return RowPart::CHECK;
}
if (UiPointInRect(FileCloseRect(row), fx, fy))
{
return RowPart::CLOSE;
}
return RowPart::BODY;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
if (UiPointInRect(SignalRemoveRect(row), fx, fy))
{
return RowPart::REMOVE_SIGNAL;
}
if (UiPointInRect(checkRect, fx, fy))
{
return RowPart::CHECK;
}
if (badgeVisible && UiPointInRect(SignalBadgeRect(row), fx, fy))
{
return RowPart::LANE_BADGE;
}
return RowPart::BODY;
}
int SignalPanel::HitRow(int x, int y, RowPart& part)
{
part = RowPart::NONE;
@@ -755,6 +1066,26 @@ namespace jdi
return -1;
}
// 「悬浮组名」绘制在最上层,因此命中判定也必须最优先 —— 否则点击会**穿透到
// 它下面的那一行**(用户 2026-10-16 报告:组名悬浮时它上面的折叠三角、复选框、
// 关闭 ✕、名称区域全都点不动,点击被下层信号行吃掉)。
const int rowCount = static_cast<int>(m_rows.size());
const int stickyRow = StickyGroupRowIndex(rowCount);
if (stickyRow >= 0)
{
const float stickyTop = StickyGroupRowTop(stickyRow, rowCount);
const float stickyHeight = m_rows[static_cast<std::size_t>(stickyRow)].height;
if (fy >= stickyTop && fy < stickyTop + stickyHeight)
{
// 用覆盖几何计算行内区域(`RowRect` 是行内所有派生的唯一几何来源)。
m_rowYOverrideActive = true;
m_rowYOverrideY = stickyTop;
part = RowHitPart(stickyRow, fx, fy);
m_rowYOverrideActive = false;
return stickyRow;
}
}
const float local = fy - m_listRect.Y + m_scrollOffset;
if (local < 0.0f)
{
@@ -765,60 +1096,11 @@ namespace jdi
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
const ListRow& row = m_rows[index];
if (local >= accumulated && local < accumulated + row.height)
// ⚠️ 高度为 0 的行(折起动画中已收尽)不参与命中,否则它会白白吸走一次点击。
if (row.height > 0.0f && local >= accumulated && local < accumulated + row.height)
{
const int rowIndex = static_cast<int>(index);
const gd::RectF rect = RowRect(rowIndex);
const bool isFile = (row.kind == RowKind::GROUP);
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
if (isFile)
{
if (UiPointInRect(disclosureRect, fx, fy))
{
part = RowPart::DISCLOSURE;
}
else if (UiPointInRect(checkRect, fx, fy))
{
part = RowPart::CHECK;
}
else if (UiPointInRect(FileCloseRect(rowIndex), fx, fy))
{
part = RowPart::CLOSE;
}
else
{
part = RowPart::BODY;
}
}
else
{
const SignalRef ref{row.groupIndex, row.signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
if (UiPointInRect(SignalRemoveRect(rowIndex), fx, fy))
{
part = RowPart::REMOVE_SIGNAL;
}
else if (UiPointInRect(checkRect, fx, fy))
{
part = RowPart::CHECK;
}
else if (badgeVisible && UiPointInRect(SignalBadgeRect(rowIndex), fx, fy))
{
part = RowPart::LANE_BADGE;
}
else
{
part = RowPart::BODY;
}
}
part = RowHitPart(rowIndex, fx, fy);
return rowIndex;
}
accumulated += row.height;
@@ -826,6 +1108,52 @@ namespace jdi
return -1;
}
float SignalPanel::ContentOffsetBeforeRow(int row) const
{
float top = 0.0f;
const int count = std::min(row, static_cast<int>(m_rows.size()));
for (int index = 0; index < count; ++index)
{
top += m_rows[static_cast<std::size_t>(index)].height;
}
return top;
}
void SignalPanel::StartScrollAnimationTo(float desired, int pendingGroupToggle)
{
m_pendingGroupToggle = pendingGroupToggle;
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
desired = std::max(0.0f, std::min(desired, maxOffset));
const float distance = std::fabs(desired - m_scrollOffset);
if (distance < 1.0f)
{
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平),
// 挂起的折起请求立即执行。
m_scrollOffset = desired;
ClampScroll();
m_scrollAnimActive = false;
if (m_pendingGroupToggle >= 0)
{
const int groupIndex = m_pendingGroupToggle;
m_pendingGroupToggle = -1;
ToggleGroupCollapsedAnimated(groupIndex);
}
return;
}
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
// 因此"整个动画过程不会太久"(最长 300ms)。
const float durationMs =
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
m_scrollAnimFrom = m_scrollOffset;
m_scrollAnimTo = desired;
m_scrollAnimStartMs = GetTickCount64();
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_scrollAnimActive = true;
}
void SignalPanel::ScrollToSignal(const SignalRef& ref)
{
EnsureRows();
@@ -1201,6 +1529,9 @@ namespace jdi
{
continue;
}
// 行内容一律裁剪到**本行矩形**:折起动画期间行高连续缩小,
// 若不裁剪,垂直居中的文本会溢出到相邻行上。
SetRowClip(rect);
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintFileRow(g, row);
@@ -1209,6 +1540,7 @@ namespace jdi
{
PaintSignalRow(g, row);
}
ClearRowClip();
}
PaintStickyGroupRow(g, stickyRow);
@@ -1236,9 +1568,11 @@ namespace jdi
void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font,
const gd::RectF& rect, std::uint32_t argb, TextAlign align)
{
// 行内文本必须显式裁剪到列表矩形:GDI 文本不遵循 GDI+ 的 `SetClip`,
// 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`,
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
UiTextClipped(g, text, font, rect, argb, align, m_listRect);
// 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内,
// 否则垂直居中的文字会溢出到相邻行上。
UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect);
}
void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
@@ -1246,7 +1580,7 @@ namespace jdi
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
gd::RectF clipped = rect;
clipped.Intersect(m_listRect);
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
{
return;
@@ -1302,7 +1636,9 @@ namespace jdi
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1);
const std::wstring stats = std::to_wstring(group->signals.size()) + L" 项";
// 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数
//(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。
const std::wstring stats = std::to_wstring(item.displayCount) + L" 项";
const float groupWidth = FileGroupLabelWidth(row);
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
const float statsRight = closeRect.X - 6.0f * m_scale;
@@ -1786,25 +2122,8 @@ namespace jdi
{
return;
}
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;
// 目标信号滚动与“悬浮组名折起”的前置滚动共用同一套动画(后者会带挂起请求)。
StartScrollAnimationTo(desired);
}
bool SignalPanel::AdvanceScrollAnimation()
@@ -1833,6 +2152,14 @@ namespace jdi
m_scrollOffset = m_scrollAnimTo;
m_scrollAnimActive = false;
ClampScroll();
// 若这次滚动是为了“把悬浮组名滚到完整显示位置后再折起”,此刻滚动已到位,
// 立即开始折起动画(用户 2026-10-16 要求的两段式过渡)。
if (m_pendingGroupToggle >= 0)
{
const int groupIndex = m_pendingGroupToggle;
m_pendingGroupToggle = -1;
ToggleGroupCollapsedAnimated(groupIndex);
}
return false;
}
return true;
@@ -2990,7 +3317,7 @@ namespace jdi
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const ListRow item = m_rows[static_cast<std::size_t>(row)];
if (item.kind == RowKind::GROUP)
{
@@ -3009,8 +3336,9 @@ namespace jdi
m_pendingCloseRow = row;
return true;
}
m_tab->ToggleCollapsed(item.groupIndex);
m_rowsDirty = true;
// 折叠 / 展开分组走「带动画 + 悬浮时先滚动就位」的统一入口
//(用户 2026-10-16 要求)。
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
@@ -3208,15 +3536,15 @@ namespace jdi
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const ListRow item = m_rows[static_cast<std::size_t>(row)];
// 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::DISCLOSURE)
{
m_tab->ToggleCollapsed(item.groupIndex);
m_rowsDirty = true;
// 与单击分组行同一入口:悬浮时先滚动就位再播放折起动画。
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
if (part != RowPart::BODY)