分组动画改「抽拉卷尺」式刚性平移 + 悬浮组名折起一边滚动一边收起 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正
① 分组折起/展开动画重做为「抽拉卷尺」式刚性平移(推翻上一轮的“按行缩放”):
- 用户要求:折起时所有信号一起向上平移、逐条消失在组名栏里;展开时逐条从组名栏
向下冒出;卷尺上的元素相对位置不变。
- 旧实现按行高缩放 ⇒ 元素被压扁(相对位置改变),且每帧行高都在变、每帧都要重建
行集合。
- 新实现:整组作为一个刚性整体平移,只维护一个逐行平移量 m_rowShift;**行高永不改变**,
平移只作用于“组名行之后”的行(组名是卷尺外壳,始终不动)。
收起 = shift 从 fromShift 推到 blockHeight(全部藏进组名栏);展开 = 推到 0。
ease-out cubic;时长 = clamp(115 + 6×行数, 150, 320) ms。
- 动画期间**不重建行集合**(AdvanceGroupAnimations 不再设 m_rowsDirty)——这是流畅的关键。
- 中途反向点击(折叠没播完就点展开)从**当前位移续接**(GroupAnim::fromShift),不再跳变。
- m_contentHeight = 自然高度 − Σ位移;RowRect() 减去本行位移;
HitRow() 改为逐行比较实际绘制位置(动画中行位置与内容坐标不再一一对应)。
② “藏进组名栏”的裁剪(新增 AnimationHideTopForRow / ClampRowClipTop):
- 信号行越过组名**绘制位置**的下边界即被裁掉;已经完全越过的行整行跳过。
- 除 GDI 裁剪(PaintRowText / FillRowRect 用的 m_rowClipRect)外,还必须用
g.SetClip(clip, CombineModeIntersect) 收紧 GDI+ 裁剪 —— 行内的色块 / 复选框 /
网格徽标走 GDI+、只受 SetClip 约束,不收紧就会画到组名栏上面去。
③ 悬浮组名折起改为“一边向上滚动一边收起”(推翻上一轮的“先滚动到位再折起”):
- 用户要求:悬浮组名既然在最上方,就一边向上滚动一边收起,保证它始终在最上方;
动画完成时滚动偏移恰好落在“组名正好在最上方”且信号已经收起。
- 实现:GroupAnim::scrollSynced —— 滚动偏移与平移量按同一进度推进
(m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased),二者合成让组名的
屏幕位置恒定;目标 scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)
(后者用于“折起后内容比列表还短”的情形,避免结束时被强行夹取 ⇒ 无跳变)。
- 旧的 m_pendingGroupToggle 两段式机制与 StartScrollAnimationTo 的挂起参数已删除;
用户滚轮 / 拖滚动条 ⇒ CancelScrollAnimation() 只解除同步,折叠/展开继续播放。
④ 筛选状态下折叠三角状态修正:
- 根因:PaintFileRow 直接读 m_tab->IsCollapsed()(普通模式的折叠状态),而筛选模式用的是
独立的 m_filterCollapsed ⇒ 筛选里折叠/展开时三角不变化。
- 修法:改读统一的 IsGroupCollapsed()(按当前模式分派)。
⑤ 样式区「目标」行的绘制与布局修正:
- 绘制:PaintTargetRow 误用列表裁剪版 PaintRowText(文本被裁到 m_listRect),而目标行在
样式区、根本不在列表矩形内 ⇒ 信号名与 GX 标签文本被完全裁掉。改用不裁剪的 UiText,
并在 PaintRowText 注释里写明“样式区不得使用”。
- 布局:名称从内容列左边缘开始、GX 标签**右对齐到内容列右边缘**(与线宽滑轨/数值框同列宽
同两端),两者不重叠;无 GX 标签时名称可用宽度 = 整个内容列(因此更宽,仍左对齐)。
- 双击改名:EditFieldRect() 对目标行只返回 m_targetNameRect,不再返回内容列全宽
(旧实现会把 GX 标签包进输入框)。
验证(全程离屏 SW_HIDE,不占用鼠标 / 键盘 / 屏幕;进程与线程均 BELOW_NORMAL):
- .preflight/perf/panelanim_main.cpp 147/147 通过(连跑 3 次稳定):
刚性平移(行高不变 / 间距恒定 / 组名行不动 / 可见行数单调减少 / 逐帧断言组名栏像素零变化 /
中间帧无行集合重建 / 帧耗时)、中途反向切换续接不跳变、筛选态折起与项数、
筛选态与普通态折叠三角逐像素 0 差异、悬浮组名命中(不穿透)、
同步滚动(逐帧钉住 + 恰好落位 + 无跳变)、真实 MF4 单点信号两种自适应逐位一致、
目标行文本像素存在 + 两端对齐 + 输入框不覆盖 GX 标签 + 无标签时名称更宽。
- A/B 五组回退分别 4 / 6 / 6 / 2 / 1 项如实失败。
- 既有 verify 49/49、datacheck 69/69、edgecheck 46/46;make clean && make all 零警告。
记忆:Documents/agent_mem.md 新增 §9.36、改写 §11.2 第 77–81 条并新增 82–84 条,
标注 §9.35 的“按行缩放动画”与“先滚动再折起”已被推翻。
This commit is contained in:
1 parent
1d314ddc24
commit
5831a0ea0d
3 files changed
+547
-243
No files matched your search
@@ -115,27 +115,13 @@ namespace jdi
|
||||
const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
|
||||
|
||||
// ---- 分组折起/展开动画(用户 2026-10-16 要求)----
|
||||
// 总时长 = clamp(基础 + 行数×每行, 下限, 上限);上限与滚动动画同一量级,
|
||||
// 保证“整个动画过程不会太久”。
|
||||
// 整组信号作为一个**刚性整体**向上平移(收起)/ 向下平移(展开),
|
||||
// 越过组名下边界的部分被裁掉 —— 即“抽拉卷尺”观感:元素相对位置不变。
|
||||
// 总时长 = 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 kGroupAnimPerRowMs = 6.0f; /**< 每多一行增加的时长。 */
|
||||
const float kGroupAnimMinMs = 150.0f; /**< 最短时长。 */
|
||||
const float kGroupAnimMaxMs = 320.0f; /**< 最长时长。 */
|
||||
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
|
||||
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
|
||||
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
|
||||
@@ -332,9 +318,9 @@ namespace jdi
|
||||
m_pendingClosePart = RowPart::NONE;
|
||||
m_pendingCloseRow = -1;
|
||||
m_pendingFilterClear = false;
|
||||
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与挂起请求一律作废。
|
||||
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与位移缓存一律作废。
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
m_rowShift.clear();
|
||||
m_scrollAnimActive = false;
|
||||
}
|
||||
|
||||
@@ -392,7 +378,7 @@ namespace jdi
|
||||
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
|
||||
m_filterCollapsed.clear();
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
m_rowShift.clear();
|
||||
// 清空后回到完整列表:重建行集合、回到顶部。
|
||||
m_rowsDirty = true;
|
||||
m_scrollOffset = 0.0f;
|
||||
@@ -469,11 +455,11 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
m_filterText = text;
|
||||
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画与挂起请求全部作废
|
||||
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画全部作废
|
||||
//(行数与分组可见性都会变,继续沿用会算出错误的行集合)。
|
||||
m_filterCollapsed.clear();
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
m_rowShift.clear();
|
||||
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
|
||||
m_rowsDirty = true;
|
||||
m_scrollOffset = 0.0f;
|
||||
@@ -575,45 +561,35 @@ namespace jdi
|
||||
return -1;
|
||||
}
|
||||
|
||||
void SignalPanel::StartGroupAnimation(int groupIndex, bool collapsing, int rowCount)
|
||||
int SignalPanel::GroupRowIndex(int groupIndex) const
|
||||
{
|
||||
if (groupIndex < 0 || rowCount <= 0)
|
||||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||||
{
|
||||
return;
|
||||
if (m_rows[index].kind == RowKind::GROUP && m_rows[index].groupIndex == groupIndex)
|
||||
{
|
||||
return static_cast<int>(index);
|
||||
}
|
||||
}
|
||||
// 同一分组只保留一个动画条目:再次切换时移除旧的,新动画从“当前状态”出发 ——
|
||||
// 折起与展开的起点本来就分别是干净的 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;
|
||||
return -1;
|
||||
}
|
||||
|
||||
float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const
|
||||
float SignalPanel::NaturalContentHeight() const
|
||||
{
|
||||
const int animIndex = FindGroupAnimation(groupIndex);
|
||||
if (animIndex < 0)
|
||||
float total = 0.0f;
|
||||
for (const ListRow& row : m_rows)
|
||||
{
|
||||
// 没有动画:完全由折起状态决定(折起 = 不占高度)。
|
||||
return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f;
|
||||
total += row.height;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const GroupAnim& anim = m_groupAnims[static_cast<std::size_t>(animIndex)];
|
||||
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)
|
||||
@@ -623,21 +599,112 @@ namespace jdi
|
||||
{
|
||||
progress = 1.0f;
|
||||
}
|
||||
// ease-out cubic:起步快、接近完成时明显减速(“快速展开 / 收起后慢慢减速”)。
|
||||
return 1.0f - std::pow(1.0f - progress, 3.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)
|
||||
void SignalPanel::UpdateAnimGeometry()
|
||||
{
|
||||
m_rowShift.assign(m_rows.size(), 0.0f);
|
||||
|
||||
float totalShift = 0.0f;
|
||||
for (const GroupAnim& anim : m_groupAnims)
|
||||
{
|
||||
local = 1.0f;
|
||||
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;
|
||||
}
|
||||
// ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。
|
||||
const float eased = 1.0f - std::pow(1.0f - local, 3.0f);
|
||||
return anim.collapsing ? (1.0f - eased) : eased;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool SignalPanel::AdvanceGroupAnimations()
|
||||
@@ -648,13 +715,24 @@ namespace jdi
|
||||
}
|
||||
|
||||
const std::uint64_t now = GetTickCount64();
|
||||
bool rowsBecameStale = 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;
|
||||
}
|
||||
// 折起完成后该组的信号行要**真正移除** ⇒ 行集合需重建
|
||||
//(展开完成后行集合本来就是展开态,无需重建;平移量归零即可)。
|
||||
if (anim.collapsing)
|
||||
{
|
||||
rowsBecameStale = true;
|
||||
}
|
||||
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
}
|
||||
else
|
||||
@@ -663,50 +741,80 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
// 动画期间每一帧的行高都不同 ⇒ 必须重建行集合(否则画面静止不动)。
|
||||
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`**:行高不随动画变化,只需重算逐行位移与内容高度 ——
|
||||
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
|
||||
UpdateAnimGeometry();
|
||||
if (rowsBecameStale)
|
||||
{
|
||||
m_rowsDirty = true;
|
||||
}
|
||||
return !m_groupAnims.empty();
|
||||
}
|
||||
|
||||
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
|
||||
{
|
||||
EnsureRows();
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
if (m_tab == nullptr || groupIndex < 0)
|
||||
{
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。
|
||||
const float alignOffset = ContentOffsetBeforeRow(row);
|
||||
if (m_scrollOffset > alignOffset + 0.5f)
|
||||
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)
|
||||
{
|
||||
// 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动,
|
||||
// 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。
|
||||
StartScrollAnimationTo(alignOffset, groupIndex);
|
||||
return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
|
||||
StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll);
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -717,6 +825,7 @@ namespace jdi
|
||||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||||
{
|
||||
m_contentHeight = 0.0f;
|
||||
m_rowShift.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,13 +854,7 @@ namespace jdi
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
const bool animating = (FindGroupAnimation(gi) >= 0);
|
||||
|
||||
ListRow fileRow;
|
||||
fileRow.kind = RowKind::GROUP;
|
||||
@@ -761,40 +864,28 @@ namespace jdi
|
||||
fileRow.displayCount = static_cast<int>(shown.size());
|
||||
m_rows.push_back(fileRow);
|
||||
|
||||
// 折起状态与动画共同决定每个信号行的实际高度:
|
||||
// * 展开且无动画 ⇒ 完整高度;
|
||||
// * 折起且无动画 ⇒ 不产生任何行;
|
||||
// * 有动画 ⇒ 按 `GroupRowScale()` 连续缩小(折起)/ 放大(展开)。
|
||||
const int rowCount = static_cast<int>(shown.size());
|
||||
for (int ordinal = 0; ordinal < rowCount; ++ordinal)
|
||||
// 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。
|
||||
// ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移,
|
||||
// 逐条滑进组名栏并被裁掉 —— 行高不变,因此动画期间无需重建行集合。
|
||||
if (collapsed && !animating)
|
||||
{
|
||||
float scale = collapsed ? 0.0f : 1.0f;
|
||||
if (anim != nullptr)
|
||||
{
|
||||
scale = GroupRowScale(gi, ordinal, rowCount);
|
||||
}
|
||||
const float height = signalHeight * scale;
|
||||
if (height < kGroupAnimMinRowHeight * m_scale)
|
||||
{
|
||||
// 已经收成一条线:不再产生可绘制 / 可命中的行。
|
||||
continue;
|
||||
}
|
||||
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 = height;
|
||||
signalRow.height = signalHeight;
|
||||
signalRow.ordinal = ordinal;
|
||||
m_rows.push_back(signalRow);
|
||||
}
|
||||
}
|
||||
|
||||
m_contentHeight = 0.0f;
|
||||
for (const ListRow& row : m_rows)
|
||||
{
|
||||
m_contentHeight += row.height;
|
||||
}
|
||||
// 内容高度与逐行位移由动画状态推出(无动画时即“自然高度”)。
|
||||
UpdateAnimGeometry();
|
||||
}
|
||||
|
||||
void SignalPanel::EnsureRows()
|
||||
@@ -803,6 +894,12 @@ namespace jdi
|
||||
{
|
||||
BuildRows();
|
||||
RefreshScrollBar();
|
||||
return;
|
||||
}
|
||||
if (m_rowShift.size() != m_rows.size())
|
||||
{
|
||||
// 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。
|
||||
UpdateAnimGeometry();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +955,11 @@ namespace jdi
|
||||
{
|
||||
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, top, m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
|
||||
}
|
||||
|
||||
@@ -1092,18 +1194,30 @@ namespace jdi
|
||||
return -1;
|
||||
}
|
||||
|
||||
float accumulated = 0.0f;
|
||||
// 逐行比较“实际绘制位置”(含折起/展开动画的刚性平移),不能只看滚动偏移:
|
||||
// 动画中信号行的位置与“自然内容坐标”不再一一对应,用后者会点错行。
|
||||
float rowTop = m_listRect.Y - m_scrollOffset;
|
||||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||||
{
|
||||
const float shift = (index < m_rowShift.size()) ? m_rowShift[index] : 0.0f;
|
||||
const float previousShift =
|
||||
(index > 0 && (index - 1) < m_rowShift.size()) ? m_rowShift[index - 1] : 0.0f;
|
||||
rowTop -= (shift - previousShift);
|
||||
|
||||
const ListRow& row = m_rows[index];
|
||||
// ⚠️ 高度为 0 的行(折起动画中已收尽)不参与命中,否则它会白白吸走一次点击。
|
||||
if (row.height > 0.0f && local >= accumulated && local < accumulated + row.height)
|
||||
const float rowBottom = rowTop + row.height;
|
||||
if (row.height > 0.0f && fy >= rowTop && fy < rowBottom)
|
||||
{
|
||||
const int rowIndex = static_cast<int>(index);
|
||||
part = RowHitPart(rowIndex, fx, fy);
|
||||
return rowIndex;
|
||||
// 折起/展开动画中已“藏进组名栏”的行不再参与命中(否则它会白白吸走一次点击)。
|
||||
if (rowBottom > AnimationHideTopForRow(rowIndex, stickyRow) + 0.5f)
|
||||
{
|
||||
part = RowHitPart(rowIndex, fx, fy);
|
||||
return rowIndex;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
accumulated += row.height;
|
||||
rowTop = rowBottom;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -1119,27 +1233,18 @@ namespace jdi
|
||||
return top;
|
||||
}
|
||||
|
||||
void SignalPanel::StartScrollAnimationTo(float desired, int pendingGroupToggle)
|
||||
void SignalPanel::StartScrollAnimationTo(float desired)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1354,18 +1459,23 @@ namespace jdi
|
||||
const float badgeWidth = kBadgeWidth * m_scale;
|
||||
const float badgeHeight = kBadgeHeight * m_scale;
|
||||
const float badgeGap = kTargetBadgeGap * m_scale;
|
||||
const bool badgeShown = (lane >= 0);
|
||||
// 只有**已绘制到网格**的信号才有 GX 标签(未勾选绘制时没有网格归属)。
|
||||
const bool badgeShown = (lane >= 0) && (badgeWidth + badgeGap < contentWidth);
|
||||
|
||||
// 名称可用宽度:有 GX 标签时把它排在名称**后面**(用户要求“名称后方”),因此要预留宽度。
|
||||
const float nameLimit = std::max(0.0f, contentWidth - (badgeShown ? badgeWidth + badgeGap : 0.0f));
|
||||
const float measured = UiMeasureTextWidth(m_fonts->ui, title);
|
||||
const float nameWidth = std::min(measured, nameLimit);
|
||||
// 布局(用户 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(contentLeft + nameWidth + badgeGap,
|
||||
m_targetBadgeRect = gd::RectF(badgeLeft,
|
||||
m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f,
|
||||
badgeWidth, badgeHeight);
|
||||
}
|
||||
@@ -1529,9 +1639,26 @@ namespace jdi
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 行内容一律裁剪到**本行矩形**:折起动画期间行高连续缩小,
|
||||
// 若不裁剪,垂直居中的文本会溢出到相邻行上。
|
||||
|
||||
// 折起/展开动画:信号行整组向上平移,越过组名下边界的部分要“消失(藏进组名栏)”。
|
||||
// 已经完全越过的行整行跳过(省一次绘制),部分越过的行把裁剪上边界抬到组名下边界;
|
||||
// ⚠️ 此时还必须把 **GDI+ 裁剪**也收紧到同一条线 —— 行内的色块 / 复选框 / 网格徽标
|
||||
// 走的是 GDI+ 绘制(只受 `SetClip` 约束),不收紧就会画到组名栏上面去。
|
||||
const int rowSave = g.Save();
|
||||
SetRowClip(rect);
|
||||
const float hideTop = AnimationHideTopForRow(row, stickyRow);
|
||||
if (hideTop > rect.Y + 0.5f)
|
||||
{
|
||||
if (rect.Y + rect.Height <= hideTop + 0.5f)
|
||||
{
|
||||
g.Restore(rowSave);
|
||||
ClearRowClip();
|
||||
continue;
|
||||
}
|
||||
ClampRowClipTop(hideTop);
|
||||
g.SetClip(m_rowClipRect, gd::CombineModeIntersect);
|
||||
}
|
||||
|
||||
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
|
||||
{
|
||||
PaintFileRow(g, row);
|
||||
@@ -1540,6 +1667,7 @@ namespace jdi
|
||||
{
|
||||
PaintSignalRow(g, row);
|
||||
}
|
||||
g.Restore(rowSave);
|
||||
ClearRowClip();
|
||||
}
|
||||
|
||||
@@ -1613,7 +1741,7 @@ namespace jdi
|
||||
|
||||
const float baseX = rect.X + kPadding * m_scale;
|
||||
const float centerY = rect.Y + rect.Height * 0.5f;
|
||||
const bool collapsed = m_tab->IsCollapsed(item.groupIndex);
|
||||
const bool collapsed = IsGroupCollapsed(item.groupIndex);
|
||||
|
||||
// 折叠三角。
|
||||
{
|
||||
@@ -2034,11 +2162,14 @@ namespace jdi
|
||||
{
|
||||
// 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。
|
||||
const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted;
|
||||
PaintRowText(g, UiEllipsize(signal->Title(), m_fonts->ui, m_targetNameRect.Width),
|
||||
m_fonts->ui, m_targetNameRect, nameColor, TextAlign::LEFT);
|
||||
// ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、
|
||||
// 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。
|
||||
UiText(g, UiEllipsize(signal->Title(), m_fonts->ui, m_targetNameRect.Width), m_fonts->ui,
|
||||
m_targetNameRect, nameColor, TextAlign::LEFT);
|
||||
}
|
||||
|
||||
// GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求)。
|
||||
// GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求),
|
||||
// 并与其它控件一样**右对齐到内容列右边缘**。
|
||||
if (m_targetBadgeRect.Width > 0.0f)
|
||||
{
|
||||
const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE);
|
||||
@@ -2046,9 +2177,8 @@ namespace jdi
|
||||
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
|
||||
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
|
||||
PaintRowText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
|
||||
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text,
|
||||
TextAlign::CENTER);
|
||||
UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
|
||||
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2122,7 +2252,7 @@ namespace jdi
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 目标信号滚动与“悬浮组名折起”的前置滚动共用同一套动画(后者会带挂起请求)。
|
||||
// 把列表平滑滚动到目标信号完整可见。
|
||||
StartScrollAnimationTo(desired);
|
||||
}
|
||||
|
||||
@@ -2152,14 +2282,6 @@ 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;
|
||||
@@ -2465,7 +2587,7 @@ namespace jdi
|
||||
return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size);
|
||||
}
|
||||
|
||||
gd::RectF SignalPanel::EditFieldRect() const
|
||||
gd::RectF SignalPanel::EditFieldRect()
|
||||
{
|
||||
switch (m_editField)
|
||||
{
|
||||
@@ -2475,14 +2597,12 @@ namespace jdi
|
||||
return m_opacityValueRect;
|
||||
case StyleEditField::SIGNAL_NAME:
|
||||
case StyleEditField::FILE_NAME:
|
||||
// `row < 0` = 样式区「目标」行:输入框用内容列全宽(名称本身很短时也能舒适地编辑)。
|
||||
// `row < 0` = 样式区「目标」行:输入框只覆盖**信号名**那一块,
|
||||
// 不得把名称后面的 GX 标签也包进去(用户 2026-10-16 报告)。
|
||||
if (m_editRow < 0)
|
||||
{
|
||||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||||
const float labelGap = kStyleLabelGap * m_scale;
|
||||
const float left = m_targetRowRect.X + labelWidth + labelGap;
|
||||
const float width = std::max(24.0f, m_targetRowRect.X + m_targetRowRect.Width - left);
|
||||
return gd::RectF(left, m_targetRowRect.Y, width, m_targetRowRect.Height);
|
||||
UpdateTargetRowGeometry();
|
||||
return m_targetNameRect;
|
||||
}
|
||||
return RowNameRect(m_editRow);
|
||||
case StyleEditField::NONE:
|
||||
|
||||
Reference in new issue
Block a user