分组动画改「抽拉卷尺」式刚性平移 + 悬浮组名折起一边滚动一边收起 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正
① 分组折起/展开动画重做为「抽拉卷尺」式刚性平移(推翻上一轮的“按行缩放”):
- 用户要求:折起时所有信号一起向上平移、逐条消失在组名栏里;展开时逐条从组名栏
向下冒出;卷尺上的元素相对位置不变。
- 旧实现按行高缩放 ⇒ 元素被压扁(相对位置改变),且每帧行高都在变、每帧都要重建
行集合。
- 新实现:整组作为一个刚性整体平移,只维护一个逐行平移量 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
+544
-240
No files matched your search
@@ -91,9 +91,9 @@ namespace jdi
|
||||
m_hoveredSignal = SignalRef();
|
||||
m_hoverRow = -1;
|
||||
m_hoverPart = RowPart::NONE;
|
||||
// 分组下标可能整体位移 ⇒ 折起动画与挂起的折起请求也不能继续沿用。
|
||||
// 分组下标可能整体位移 ⇒ 折起动画(含与滚动的同步)也不能继续沿用。
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
m_rowShift.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,18 +439,32 @@ namespace jdi
|
||||
/**
|
||||
* @brief 一个分组的折起/展开动画状态。
|
||||
*
|
||||
* 用户 2026-10-16 要求:折起分组时不能下一帧立刻切换布局,而要有一段过渡动画 ——
|
||||
* 被折起的信号向上收起、下方的分组随之上升;**离组名越远的成员收起越快**,
|
||||
* 随后整体减速直到收起完成;总时长控制在一个合理范围(百余毫秒到 300ms)。
|
||||
* 用户 2026-10-16 第二轮要求(**推翻**第一轮的按行缩放):“整个展开和收起的过程类似于
|
||||
* 抽拉卷尺,卷尺上的元素相对位置是不会变的” —— 即**整组信号作为一个刚性整体平移**:
|
||||
* 收起时所有信号一起向上平移、逐条消失在组名栏(越过组名下边界的部分被裁掉),
|
||||
* 展开时逐条从组名栏下方冒出来。因为相对位置不变,所以**行高不发生变化**
|
||||
* (无需每帧重建行集合),仅一个 `m_rowShift` 平移量在变 —— 这是动画流畅的关键。
|
||||
*
|
||||
* 动画期间 `BuildRows` 会按 `GroupRowScale()` 给出每行的收缩比例,
|
||||
* 因此列表内容高度连续变化、下方分组平滑上升。
|
||||
* 另外:**悬浮(sticky)组名折起时不再“先滚动到位再折起”**,而是把滚动偏移与
|
||||
* 平移**同步推进**(`scrollSynced`),保证悬浮组名始终留在列表最上方,
|
||||
* 动画结束时滚动偏移恰好落在“组名正好处于最上方”的位置。
|
||||
*/
|
||||
struct GroupAnim
|
||||
{
|
||||
int groupIndex = -1; /**< 目标分组下标。 */
|
||||
bool collapsing = false; /**< true = 正在折起,false = 正在展开。 */
|
||||
int rowCount = 0; /**< 该分组参与动画的信号行数。 */
|
||||
float blockHeight = 0.0f; /**< 该分组全部信号行的总高(展开态)。 */
|
||||
/**
|
||||
* @brief 动画起始时的平移量(0 = 全部露出,`blockHeight` = 全部藏进组名栏)。
|
||||
*
|
||||
* 一般是“干净的一端”(折起从 0、展开从 `blockHeight`);但若动画中途被反向
|
||||
* 点击(折起没播完就点展开),就从**当前位移**续接,因此不会出现跳变。
|
||||
*/
|
||||
float fromShift = 0.0f;
|
||||
bool scrollSynced = false; /**< 是否让滚动偏移随动画同步推进。 */
|
||||
float scrollFrom = 0.0f; /**< 动画起始滚动偏移。 */
|
||||
float scrollTo = 0.0f; /**< 动画结束滚动偏移(仅 `scrollSynced` 时生效)。 */
|
||||
std::uint64_t startMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */
|
||||
std::uint32_t durationMs = 0; /**< 动画总时长(毫秒)。 */
|
||||
};
|
||||
@@ -468,7 +482,10 @@ namespace jdi
|
||||
bool SignalScrollTarget(const SignalRef& ref, float& desired) const;
|
||||
|
||||
/**
|
||||
* @brief 列表内容中某一行顶部相对内容原点的纵向偏移(不包含滚动偏移)。
|
||||
* @brief 列表内容中某一行顶部相对内容原点的**自然**纵向偏移。
|
||||
*
|
||||
* 不含滚动偏移,也不含折起动画的刚性平移(动画只平移“动画分组之后”的行,
|
||||
* 而分组行之前的行不受影响)⇒ 该值就是滚动到“本行正好处于列表最上方”所需的偏移。
|
||||
* @param [IN] row 行下标。
|
||||
* @return 偏移量(像素)。
|
||||
*/
|
||||
@@ -477,9 +494,8 @@ namespace jdi
|
||||
/**
|
||||
* @brief 启动一段滚动动画。
|
||||
* @param [IN] desired 目标滚动偏移(内部会夹取到合法范围)。
|
||||
* @param [IN] pendingGroupToggle 滚动到位后要折起/展开的分组下标(-1 = 无)。
|
||||
*/
|
||||
void StartScrollAnimationTo(float desired, int pendingGroupToggle = -1);
|
||||
void StartScrollAnimationTo(float desired);
|
||||
|
||||
/**
|
||||
* @brief 判断某分组在**当前模式**(筛选 / 普通)下是否处于折起状态。
|
||||
@@ -513,37 +529,58 @@ namespace jdi
|
||||
int FindGroupAnimation(int groupIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 启动某分组的折起/展开动画。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] collapsing 是否折起。
|
||||
* @param [IN] rowCount 该分组参与动画的信号行数。
|
||||
* @brief 取动画的**缓动后进度**(0 → 1,ease-out cubic:起步快、末尾减速)。
|
||||
* @param [IN] anim 动画条目。
|
||||
* @return 缓动后的进度。
|
||||
*/
|
||||
void StartGroupAnimation(int groupIndex, bool collapsing, int rowCount);
|
||||
float AnimEasedProgress(const GroupAnim& anim) const;
|
||||
|
||||
/**
|
||||
* @brief 取某分组动画中第 ordinal 行的收缩比例。
|
||||
*
|
||||
* 规则(用户 2026-10-16 要求):**离组名越远的成员收起速度越快**,整体减速(ease-out)
|
||||
* 直到完成;因此越靠下的行越早消失,紧接着下方分组平滑上升。
|
||||
*
|
||||
* @brief 某个分组行在列表中的下标。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] ordinal 该行在分组内「参与列出」的序号(0 = 紧邻组名的第一行)。
|
||||
* @param [IN] rowCount 该分组参与动画的行数。
|
||||
* @return 1 = 完整高度,0 = 完全收起。
|
||||
* @return 行下标;不存在返回 -1。
|
||||
*/
|
||||
float GroupRowScale(int groupIndex, int ordinal, int rowCount) const;
|
||||
int GroupRowIndex(int groupIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 切换分组折起状态并播放动画(不处理“悬浮先滚动”的前置动作)。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @brief 全部行的自然总高度(不含折起动画的折减)。
|
||||
* @return 高度(像素)。
|
||||
*/
|
||||
void ToggleGroupCollapsedAnimated(int groupIndex);
|
||||
float NaturalContentHeight() const;
|
||||
|
||||
/**
|
||||
* @brief 点击分组行:若该组名此刻正悬浮在列表上方,先平滑滚动到顶部再播放折起动画。
|
||||
* @brief 某分组当前模式下会列出的信号行总高(展开态)。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @return 高度(像素)。
|
||||
*/
|
||||
float ShownBlockHeight(int groupIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 重新计算动画造成的“逐行刚性位移”与内容高度。
|
||||
*
|
||||
* 用户 2026-10-16 要求:悬浮状态下点击组名折起时,滚动条要先向上滚动,
|
||||
* 直到悬浮组名处于**真正完整显示**的位置(即它的自然位置),再执行折起动画。
|
||||
* 每帧(或行集合重建后)调用一次:把每个进行中的动画的位移量累加到它之后的每一行上,
|
||||
* 并据此得出 `m_contentHeight`;与滚动同步的动画同时推进 `m_scrollOffset`。
|
||||
* 行高本身不变,因此**不需要重建行集合**。
|
||||
*/
|
||||
void UpdateAnimGeometry();
|
||||
|
||||
/**
|
||||
* @brief 开始一个分组的折起/展开动画。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] collapse true = 折起,false = 展开。
|
||||
* @param [IN] scrollFrom 动画起始滚动偏移。
|
||||
* @param [IN] scrollTo 动画结束滚动偏移(仅 `syncScroll` 为 true 时生效)。
|
||||
* @param [IN] syncScroll 是否让滚动偏移随动画同步推进(悬浮组名折起时用)。
|
||||
* @return 已开始动画返回 true(行数为 0 时无需动画,返回 false)。
|
||||
*/
|
||||
bool StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo, bool syncScroll);
|
||||
|
||||
/**
|
||||
* @brief 点击分组行:折起/展开该分组(悬浮时与滚动同步推进)。
|
||||
*
|
||||
* 用户 2026-10-16 第二轮要求:悬浮组名折起时**不再**“先滚动到位再折起”,
|
||||
* 而是**一边向上滚动一边收起**,保证悬浮组名始终在最上方;动画完成时滚动偏移
|
||||
* 恰好落在“组名正好在最上方”的位置,且信号已经收起。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] row 分组行下标。
|
||||
*/
|
||||
@@ -558,6 +595,16 @@ namespace jdi
|
||||
*/
|
||||
RowPart RowHitPart(int row, float x, float y) const;
|
||||
|
||||
/**
|
||||
* @brief 折起/展开动画中,某信号行“被藏进组名栏”的上边界(组名的**绘制**下边界)。
|
||||
*
|
||||
* 仅对本行所属分组正在播放动画的信号行有效;其余情况返回一个很小的值(等价于不裁剪)。
|
||||
* @param [IN] row 行下标。
|
||||
* @param [IN] stickyRow 当前悬浮的分组行下标(`StickyGroupRowIndex()` 的结果)。
|
||||
* @return 裁剪上边界(屏幕 Y 坐标)。
|
||||
*/
|
||||
float AnimationHideTopForRow(int row, int stickyRow) const;
|
||||
|
||||
/**
|
||||
* @brief 重新计算「目标」行内信号名与 GX 标签的矩形。
|
||||
*
|
||||
@@ -576,9 +623,12 @@ namespace jdi
|
||||
void CancelScrollAnimation()
|
||||
{
|
||||
m_scrollAnimActive = false;
|
||||
// 挂起的“滚动到位后折起”也必须取消:用户已经自己滚动了,
|
||||
// 再补一次折起会让人莫名其妙。
|
||||
m_pendingGroupToggle = -1;
|
||||
// 与滚动同步的折起动画也不能再接管滚动偏移:用户已经自己滚动了,
|
||||
// 继续同步会“抢”滚动条。折起/展开本身继续播放(只是不驱动滚动)。
|
||||
for (GroupAnim& anim : m_groupAnims)
|
||||
{
|
||||
anim.scrollSynced = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -645,7 +695,8 @@ namespace jdi
|
||||
/**
|
||||
* @brief 把后续行内绘制裁剪到指定行矩形(与列表矩形相交)。
|
||||
*
|
||||
* 折起动画期间行高连续缩小,行内容必须裁到行矩形内,否则垂直居中的文本会溢出到相邻行。
|
||||
* 行内容必须裁到行矩形内:折起动画中信号行会滑到组名栏之下,
|
||||
* 不裁剪的话行内容会溢出到相邻行(或组名栏)上。
|
||||
* @param [IN] rowRect 行矩形。
|
||||
*/
|
||||
void SetRowClip(const gd::RectF& rowRect)
|
||||
@@ -655,6 +706,21 @@ namespace jdi
|
||||
m_rowClipRect.Intersect(m_listRect);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 把当前行裁剪矩形的**上边界**抬到给定值(折起动画中信号行“藏进组名栏”用)。
|
||||
* @param [IN] top 新的裁剪上边界(屏幕 Y 坐标)。
|
||||
*/
|
||||
void ClampRowClipTop(float top)
|
||||
{
|
||||
if (!m_rowClipActive || top <= m_rowClipRect.Y)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const float bottom = m_rowClipRect.Y + m_rowClipRect.Height;
|
||||
m_rowClipRect.Y = top;
|
||||
m_rowClipRect.Height = std::max(0.0f, bottom - top);
|
||||
}
|
||||
|
||||
/** @brief 结束行内裁剪(恢复为整个列表矩形)。 */
|
||||
void ClearRowClip() { m_rowClipActive = false; }
|
||||
|
||||
@@ -719,11 +785,13 @@ namespace jdi
|
||||
void PaintStickyGroupRow(gd::Graphics& g, int row);
|
||||
|
||||
/**
|
||||
* @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形**)。
|
||||
* @brief 在列表区内绘制行内文本(**显式裁剪到列表矩形 / 当前行矩形**)。
|
||||
*
|
||||
* ⚠️ `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**:列表需要滚动时,
|
||||
* 被滚出视口的行(顶部部分可见的两三行)会把组名/信号名画到上方筛选框上。
|
||||
* 因此行内文本一律经本函数绘制(见 `UiTextClipped`)。
|
||||
* 因此**列表行**内文本一律经本函数绘制(见 `UiTextClipped`)。
|
||||
* ⚠️ **样式区(筛选框上方之外的区域,如「目标」行)不得使用本函数** ——
|
||||
* 它会把文本裁到 `m_listRect`(列表矩形之外的内容全都画不出来)。
|
||||
*
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] text 文本内容。
|
||||
@@ -866,8 +934,10 @@ namespace jdi
|
||||
/**
|
||||
* @brief 计算当前输入框的矩形。
|
||||
* @return 输入框矩形,未处于编辑状态时返回空矩形。
|
||||
* @note 「目标」行编辑时需要按当前目标信号的名称 / GX 标签重算几何(名称宽度),
|
||||
* 因此不是 const。
|
||||
*/
|
||||
gd::RectF EditFieldRect() const;
|
||||
gd::RectF EditFieldRect();
|
||||
|
||||
/**
|
||||
* @brief 计算「筛选信号」输入框的内容矩形(编辑与显示共用,保证不跳动)。
|
||||
@@ -1001,17 +1071,19 @@ namespace jdi
|
||||
float m_scrollAnimTo = 0.0f;
|
||||
std::uint64_t m_scrollAnimStartMs = 0;
|
||||
std::uint32_t m_scrollAnimDurationMs = 0;
|
||||
/**
|
||||
* @brief 滚动到位后要折起/展开的分组下标(-1 = 无)。
|
||||
*
|
||||
* 悬浮组名上点击折叠时,必须先滚动到该组名完整可见,再播放折起动画
|
||||
*(用户 2026-10-16 要求)。
|
||||
*/
|
||||
int m_pendingGroupToggle = -1;
|
||||
|
||||
/** @brief 正在进行的分组折起/展开动画(可同时有多个分组)。 */
|
||||
std::vector<GroupAnim> m_groupAnims;
|
||||
|
||||
/**
|
||||
* @brief 动画造成的逐行刚性位移(与 `m_rows` 同长;内容坐标,绘制时从行顶部减去)。
|
||||
*
|
||||
* 只有“动画分组的组名行之后”的行会被平移,因此组名本身始终留在原处,
|
||||
* 信号行从它下面滑过去并被裁掉 —— 这就是“抽拉卷尺”观感的来源。
|
||||
* 行高不变 ⇒ 动画期间无需重建行集合(性能关键)。
|
||||
*/
|
||||
std::vector<float> m_rowShift;
|
||||
|
||||
/**
|
||||
* @brief 筛选模式下的分组折起状态(按分组下标)。
|
||||
*
|
||||
@@ -1022,11 +1094,11 @@ namespace jdi
|
||||
std::vector<bool> m_filterCollapsed;
|
||||
|
||||
/**
|
||||
* @brief 当前正在绘制的行的裁剪矩形(用于折起动画中的部分高度行)。
|
||||
* @brief 当前正在绘制的行的裁剪矩形(用于折起动画中“藏进组名栏”的信号行)。
|
||||
*
|
||||
* 行高在折起动画期间连续缩小,若不把行内容裁剪到行矩形内,
|
||||
* 文本会溢出到相邻行上(`UiText` 是 `DT_VCENTER` 垂直居中,
|
||||
* 在变矮的矩形里会被裁掉上下两半,这正是想要的“挤压”观感)。
|
||||
* `SetRowClip` 先把行内容裁到本行矩形(∩ 列表矩形),折起/展开动画中再用
|
||||
* `ClampRowClipTop` 把上边界抬到组名栏的下边界 ⇒ 越过的部分被裁掉,
|
||||
* 形成“滑进组名栏”的观感。
|
||||
*/
|
||||
bool m_rowClipActive = false;
|
||||
gd::RectF m_rowClipRect;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
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::NaturalContentHeight() const
|
||||
{
|
||||
float total = 0.0f;
|
||||
for (const ListRow& row : m_rows)
|
||||
{
|
||||
total += row.height;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const
|
||||
float SignalPanel::ShownBlockHeight(int groupIndex) const
|
||||
{
|
||||
const int animIndex = FindGroupAnimation(groupIndex);
|
||||
if (animIndex < 0)
|
||||
{
|
||||
// 没有动画:完全由折起状态决定(折起 = 不占高度)。
|
||||
return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f;
|
||||
return static_cast<float>(ShownSignalCount(groupIndex)) * kSignalRowHeight * m_scale;
|
||||
}
|
||||
|
||||
const GroupAnim& anim = m_groupAnims[static_cast<std::size_t>(animIndex)];
|
||||
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;
|
||||
}
|
||||
|
||||
// 「被收起的组最下方成员离得越远收起速度越快」:第 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:起步快、接近完成时明显减速(“快速展开 / 收起后慢慢减速”)。
|
||||
return 1.0f - std::pow(1.0f - progress, 3.0f);
|
||||
}
|
||||
// ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。
|
||||
const float eased = 1.0f - std::pow(1.0f - local, 3.0f);
|
||||
return anim.collapsing ? (1.0f - eased) : eased;
|
||||
|
||||
void SignalPanel::UpdateAnimGeometry()
|
||||
{
|
||||
m_rowShift.assign(m_rows.size(), 0.0f);
|
||||
|
||||
float totalShift = 0.0f;
|
||||
for (const GroupAnim& anim : m_groupAnims)
|
||||
{
|
||||
const float progress = AnimEasedProgress(anim);
|
||||
// 收起:从起始位移推到“全部藏进组名栏”(= blockHeight);
|
||||
// 展开:从起始位移推回 0(逐条从组名栏下方冒出)。
|
||||
// 起始位移在“中途反向点击”时就是当时的位移 ⇒ 不会跳变。
|
||||
const float target = anim.collapsing ? anim.blockHeight : 0.0f;
|
||||
const float shift = anim.fromShift + (target - anim.fromShift) * progress;
|
||||
|
||||
if (anim.scrollSynced)
|
||||
{
|
||||
// 与滚动同步推进:让悬浮组名始终留在列表最上方
|
||||
//(滚动偏移与平移量按同一进度推进,二者的合成让组名的屏幕位置恒定)。
|
||||
m_scrollOffset = anim.scrollFrom + (anim.scrollTo - anim.scrollFrom) * progress;
|
||||
}
|
||||
|
||||
const int headerRow = GroupRowIndex(anim.groupIndex);
|
||||
if (headerRow < 0 || shift <= 0.0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 平移只作用于**组名行之后**的行:组名自己是“卷尺的外壳”,始终不动,
|
||||
// 信号行从它下面滑过去并被裁掉。
|
||||
for (int row = headerRow + 1; row < static_cast<int>(m_rowShift.size()); ++row)
|
||||
{
|
||||
m_rowShift[static_cast<std::size_t>(row)] += shift;
|
||||
}
|
||||
totalShift += shift;
|
||||
}
|
||||
|
||||
m_contentHeight = std::max(0.0f, NaturalContentHeight() - totalShift);
|
||||
}
|
||||
|
||||
bool SignalPanel::StartGroupToggle(int groupIndex, bool collapse, float scrollFrom, float scrollTo,
|
||||
bool syncScroll)
|
||||
{
|
||||
if (groupIndex < 0 || m_tab == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 同一分组只保留一个动画条目:再次切换时移除旧的,并从**当前位移**续接
|
||||
//(用户可能在折起没播完时就点展开,此时不能跳回干净的一端)。
|
||||
float fromShift = 0.0f;
|
||||
const int existing = FindGroupAnimation(groupIndex);
|
||||
if (existing >= 0)
|
||||
{
|
||||
const GroupAnim& previous = m_groupAnims[static_cast<std::size_t>(existing)];
|
||||
const float previousProgress = AnimEasedProgress(previous);
|
||||
const float previousTarget = previous.collapsing ? previous.blockHeight : 0.0f;
|
||||
fromShift = previous.fromShift + (previousTarget - previous.fromShift) * previousProgress;
|
||||
m_groupAnims.erase(m_groupAnims.begin() + existing);
|
||||
}
|
||||
|
||||
const float blockHeight = ShownBlockHeight(groupIndex);
|
||||
if (blockHeight <= 0.5f)
|
||||
{
|
||||
// 没有信号行可动画(空分组):直接把状态定下来,并清掉可能的残留位移。
|
||||
UpdateAnimGeometry();
|
||||
m_rowsDirty = true;
|
||||
return false;
|
||||
}
|
||||
if (existing < 0)
|
||||
{
|
||||
// 没有正在播放的动画 ⇒ 从“干净的一端”出发(展开 = 全部藏在组名栏内)。
|
||||
fromShift = collapse ? 0.0f : blockHeight;
|
||||
}
|
||||
|
||||
GroupAnim anim;
|
||||
anim.groupIndex = groupIndex;
|
||||
anim.collapsing = collapse;
|
||||
anim.rowCount = ShownSignalCount(groupIndex);
|
||||
anim.blockHeight = blockHeight;
|
||||
anim.fromShift = ClampFloat(fromShift, 0.0f, blockHeight);
|
||||
anim.scrollFrom = scrollFrom;
|
||||
anim.scrollTo = scrollTo;
|
||||
anim.scrollSynced = syncScroll && (std::fabs(scrollTo - scrollFrom) > 0.5f);
|
||||
anim.startMs = GetTickCount64();
|
||||
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
|
||||
const float durationMs = std::max(
|
||||
kGroupAnimMinMs,
|
||||
std::min(kGroupAnimMaxMs,
|
||||
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(anim.rowCount)));
|
||||
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||||
|
||||
if (anim.scrollSynced)
|
||||
{
|
||||
// 立即接管滚动偏移(后续每帧由 `UpdateAnimGeometry` 推进)。
|
||||
m_scrollOffset = scrollFrom;
|
||||
}
|
||||
|
||||
m_groupAnims.push_back(anim);
|
||||
// 折起时该组的信号行必须**留在行集合里**(它们正在“滑进组名栏”),
|
||||
// 因此需要按“有动画”的规则重建一次行集合。
|
||||
m_rowsDirty = true;
|
||||
UpdateAnimGeometry();
|
||||
return true;
|
||||
}
|
||||
|
||||
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`**:行高不随动画变化,只需重算逐行位移与内容高度 ——
|
||||
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
|
||||
UpdateAnimGeometry();
|
||||
if (rowsBecameStale)
|
||||
{
|
||||
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()))
|
||||
if (m_tab == nullptr || groupIndex < 0)
|
||||
{
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。
|
||||
const float alignOffset = ContentOffsetBeforeRow(row);
|
||||
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)
|
||||
{
|
||||
// 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动,
|
||||
// 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。
|
||||
StartScrollAnimationTo(alignOffset, groupIndex);
|
||||
return;
|
||||
// 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短,
|
||||
// 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
// 折起/展开动画中已“藏进组名栏”的行不再参与命中(否则它会白白吸走一次点击)。
|
||||
if (rowBottom > AnimationHideTopForRow(rowIndex, stickyRow) + 0.5f)
|
||||
{
|
||||
part = RowHitPart(rowIndex, fx, fy);
|
||||
return rowIndex;
|
||||
}
|
||||
accumulated += row.height;
|
||||
return -1;
|
||||
}
|
||||
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:
|
||||
|
||||
+131
-19
@@ -1946,6 +1946,10 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
|
||||
### 9.35 分组折起/展开动画 + 筛选态可折起 + 悬浮组名可交互(含"先滚动再折起")+ 适应视图与 Y 轴自适应彻底同源(本轮,本机)
|
||||
|
||||
> ⚠️ **本节的「一」(按行缩放动画)与「三」里的“先滚动到位再折起”已被 §9.36 推翻/取代**
|
||||
> (用户 2026-10-16 第二轮明确要求“抽拉卷尺”式整体平移,且悬浮组名折起改为**一边滚动一边收起**)。
|
||||
> 仍成立的部分:二(筛选态折起 + 项数按筛选计数)、三(悬浮组名命中不得穿透)、四(自适应同源)。
|
||||
|
||||
> 本轮四项(用户 2026-10-16,本机 = 另一台设备)。离屏验证 `.preflight/perf/panelanim_main.cpp`
|
||||
> **94/94 通过**(连跑 3 次稳定);**A/B** 三组回退分别如实失败(4 / 13 / 8 项);
|
||||
> 既有 `verify.exe` **49/49**(顺带修正了 4 条与远端新规则不符的过期断言)、
|
||||
@@ -2039,6 +2043,92 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
(§9.21 已烘入数据);拖拽换位改为断言**被拖网格自身边界**判据的新目标值。
|
||||
⇒ `verify.exe` 现为 **49/49 全绿**。
|
||||
|
||||
### 9.36 动画改为「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(本轮,本机)
|
||||
|
||||
> 本轮四项(用户 2026-10-16 第二轮,本机 = 另一台设备)。
|
||||
> 离屏验证 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定);
|
||||
> **A/B** 五组回退分别如实失败(4 / 6 / 6 / 2 / 1 项);
|
||||
> 既有 `verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。
|
||||
|
||||
#### 一、分组动画重做为「抽拉卷尺」式**刚性平移**(推翻 §9.35 一)
|
||||
|
||||
- **用户原话要点**:折起时所有信号一起向上平移、逐条消失在组名栏里;展开时逐条从组名栏向下冒出;
|
||||
**卷尺上的元素相对位置不会变**;收起/展开要流畅(性能)。
|
||||
- **旧实现错在哪**:按「行高×比例」逐行缩放 —— 元素被压扁(相对位置改变),
|
||||
与“卷尺”完全不同;而且每帧行高都在变 ⇒ 每帧都要重建行集合(本可用性能)。
|
||||
- **新实现**(`SignalPanel::UpdateAnimGeometry` + `m_rowShift`):
|
||||
* 动画只产生**一个逐行平移量** `m_rowShift[row]`(内容坐标),**行高永不变**;
|
||||
平移只作用于**组名行之后**的行 ⇒ 组名是“卷尺外壳”(始终不动),信号行从它下面滑过。
|
||||
* 收起:`shift = fromShift + (blockHeight − fromShift) × eased(p)`(推到全部藏进组名栏);
|
||||
展开:推到 0(逐条冒出)。`eased` = ease-out cubic;时长 `clamp(115 + 6×行数, 150, 320)` ms。
|
||||
* **中途反向切换从当前位移续接**(`GroupAnim::fromShift`)⇒ 连点折叠三角不会跳变
|
||||
(旧实现会跳回“干净的一端”)。
|
||||
* `m_contentHeight = 自然高度 − Σ shift`;`RowRect()` 减去本行位移;
|
||||
`HitRow()` 改为**逐行比较实际绘制位置**(动画中行位置与“内容坐标”不再一一对应)。
|
||||
* ⚠️ **不得再改成“逐行缩放”**:方案的关键收益是**行高不变 ⇒ 动画期间无需重建行集合**
|
||||
(探针直接断言中间帧 `m_rowsDirty == false`)。
|
||||
- **“藏进组名栏”的裁剪**:信号行越过**组名绘制位置的下边界**即被裁掉(`AnimationHideTopForRow`)。
|
||||
已经完全越过的行整行跳过(省绘制)。
|
||||
⚠️⚠️ **除了 GDI 裁剪(`PaintRowText`/`FillRowRect` 用的 `m_rowClipRect`)外,必须同时用
|
||||
`g.SetClip(clip, CombineModeIntersect)` 收紧 GDI+ 裁剪** —— 行内的色块/复选框/网格徽标走 GDI+,
|
||||
只受 `SetClip` 约束;不收紧就会画到组名栏上面去(A/B 实测失败)。
|
||||
- **实测**:3 行折叠 ≈ 4–5 帧、平均 **~10.8ms/帧**(离屏 harness 含整帧重绘)、最坏 ~13ms。
|
||||
|
||||
#### 二、悬浮组名折起:一遗滚动一遗收起(推翻 §9.35 三 的“先滚动到位再折起”)
|
||||
|
||||
- **用户要求**:悬浮组名折起时不再先上滚再播放动画;而是**一遗向上滚动一遗收起**,
|
||||
保证悬浮组名始终在最上方;动画完成时滚动偏移恰好落在“组名正好在最上方”,且信号已经收起。
|
||||
- **实现**:`GroupAnim::scrollSynced` —— 滚动偏移与平移量按**同一进度**推进
|
||||
(`m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased`)。
|
||||
数学上这让组名的屏幕位置**恒定**(只要起始越界量 Δ0 ≤ blockHeight,即“组名确实悬浮”的常态)。
|
||||
目标 `scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)` —— 后者用于“折起后内容比列表还短”
|
||||
的情况(此时组名在物理上无法停到最上方,取可达值以避免结束时被强行夹取 ⇒ **无跳变**)。
|
||||
- 旧的 `m_pendingGroupToggle` / `PumpScroll` 两段式机制**已删除**;
|
||||
用户滚轮 / 拖滚动条 ⇒ `CancelScrollAnimation()` 仅解除同步(折叠/展开继续播放)。
|
||||
- **实测**:逐帧断言组名的**绘制位置**始终在列表上沿(<1.5px)、滚动单调上移、
|
||||
结束时 `m_scrollOffset == 组名的内容偏移`(<1px),且随后 `ClampScroll()` 不再移动(无跳变)。
|
||||
|
||||
#### 三、筛选态折叠三角状态修正
|
||||
|
||||
- **用户报告**:筛选时分组行的小三角维持在了筛选前的状态,展开/折起都不变化。
|
||||
- **根因**:`PaintFileRow` 直接读 `m_tab->IsCollapsed(...)`(普通模式的折叠状态),
|
||||
而筛选模式用的是**独立**的 `m_filterCollapsed`(§9.35 二)⇒ 筛选里折叠时三角形不变。
|
||||
- **修法**:改为统一的 `IsGroupCollapsed(groupIndex)`(按当前模式分派)。
|
||||
- **验证**:离屏像素对比 —— 「筛选态折叠」与「普通态折叠」的组名行(含三角)**逐像素 0 差异**;
|
||||
且筛选态折叠前后三角区域确实有变化(有判别力)。A/B 失败 2 项。
|
||||
|
||||
#### 四、样式区「目标」行:绘制与布局修正
|
||||
|
||||
- **用户报告**:目标行显示的信号名与 GX 标签都没有正确绘制;双击进入编辑时输入框把 GX 标签包了进去。
|
||||
- **根因①②**:
|
||||
* **没画出来**:`PaintTargetRow` 用了 `PaintRowText`,而它把文本裁到 `m_listRect`(列表矩形)——
|
||||
目标行在**样式区**、根本不在列表矩形内 ⇒ 名称与 GX 文本被**完全裁掉**。
|
||||
✓ 修法:目标行(以及样式区其它文本)一律用**不裁剪**的 `UiText`;
|
||||
并在 `PaintRowText` 的注释里写明“**样式区不得使用**”。
|
||||
* **输入框包住 GX 标签**:`EditFieldRect()` 对 `m_editRow < 0` 返回“内容列全宽”,
|
||||
而 GX 标签在列内(旧实现是紧跟名称)⇒ 输入框盖住标签。
|
||||
✓ 修法:返回 `m_targetNameRect`(只管名称那一块),并断言“输入框与名称矩形逐值相等”。
|
||||
- **布局对齐**(用户要求:“同时出现时左右宽度与其它控件对齐;不显示 GX 标签时名称可以更宽”):
|
||||
* 名称从**内容列左边缘**开始(与线宽滑轨/数值框同一左边界);
|
||||
* GX 标签**右对齐到内容列右边缘**(而不是紧跟在名称后面);
|
||||
* 名称可用宽度 = 到 GX 标签左侧;**无标签时 = 整个内容列**(因此更宽,但仍左对齐)。
|
||||
- **验证**:断言名称左缘 == `m_widthTrackRect.X`、GX 右缘 == `m_widthValueRect` 右缘、
|
||||
两者不重叠;并用 `PixelsNear` 断言名称与 GX 的**文本像素确实存在**(旧实现下名称为 0 像素);
|
||||
无 GX 标签时名称更宽且仍两端对齐。A/B 失败 6 项。
|
||||
- ⚠️ 注意:`PaintTargetRow` 里的**悬停/点击/双击**三种交互仍要在绘制前重建几何
|
||||
(`m_targetRowRect` 只依赖布局,而 `m_targetNameRect/m_targetBadgeRect` 依赖名称与网格归属)。
|
||||
|
||||
#### 五、本轮探针(`.preflight/perf/panelanim_main.cpp`,不入库)
|
||||
|
||||
- 新增/重写的断言组:`[A]` 刚性平移(行高不变、间距恒定、组名行不动、可见行数单调减少、
|
||||
**逐帧断言组名栏未被覆盖**、无行集合重建、帧耗时)、`[A3]` 中途反向切换续接不跳变、
|
||||
`[B]` 筛选态折起与计数、`[B2]` 筛选/普通态折叠三角逐像素一致、`[C]` 悬浮组名命中、
|
||||
`[D]/[D2]` 同步滚动(逐帧钉住 + 恰好落位 + 无跳变)、`[E]` 自适应同源、`[F]` 目标行绘制/布局/输入框。
|
||||
- ⚠️ **教训(已写入 §11.3)**:探针里直接 `app.Render()` 不会重绘面板(`m_chromeDirty` 已被上次清掉)
|
||||
⇒ 必须先 `app.m_chromeDirty = true`(`RenderFrame()`)。
|
||||
⚠️ 动画循环里**最后一帧**会移除行集合并把位移归零,断言必须在
|
||||
`if (!GroupAnimationActive()) break;` **之后**再继续,否则会把“正常收尾”误判为“位移倒退”。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志(本文件维护记录)
|
||||
@@ -2089,6 +2179,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **「适应视图」边界规则撤回重做:单值散点让出半径 + 单点簇视图(详见 §9.33)**。① **初次自适应没有发生**(用户报告):`FitAll()` 在**没有任何可见信号**时也把 `ViewInitialized()` 置 true ⇒ 随后勾选第一个信号时 `SynchronizeView` 认为已初始化、**跳过自适应**(实测 MF4 视界停在 42.386s,数据点被推到右边缘外);修法:只有存在可见信号才置位,并让初次自适应与工具栏「适应视图」**共用 `FitAll()` 一条路径**。② **单值散点被裁掉半个**(用户报告):旧规则"左右各留一小格"在"单值 + 连续信号"下既不准也不够(`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点,正好落在边界上);按用户要求**撤回旧策略**(删除 `FitSinglePointLanes`/`TimeGridCellSeconds`/`LaneValueGridCell`/`FitSinglePointSignal`),改为:**任何一条边界被单值散点顶住就让出该散点的半径(+1px)**,只在散点**会被裁切**时才扩展(内部散点不动视界),边界不是单值散点时边界就落在信号点上。③ **特例**:全部可见数据点都是单值信号且时刻相同 ⇒ 时间轴以该时刻居中、缩放到**一大格 = 1s**(视界 10s,`NiceStep(10,10)==1.0`),每个网格的幅值区间保证其数据点都能完整画出(退化成单值时跨度 1 居中)。验证:离屏 `.preflight/check` **62/62 通过**(连跑 3 次稳定;含**真实文件** `TestData/MF4-Record.MF4`:`WIN_Motor_StallCurrentVolBrk`(1 点) + `m_MOT_Current_DCom[0]`(8475 点) 复现用户场景,散点圆心离绘图区左边界 16.5px、半径 15px ⇒ 完整可见);**A/B** 回退四处规则后 **20 项如实失败**(含"散点正好压在绘图区右边缘"、"初次视界 42.386s");`make release` 零警告、链接通过。 |
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 |
|
||||
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起/展开动画 + 筛选态可折起(项数按筛选计数)+ 悬浮组名可交互与"先滚动再折起" + 「适应视图」与「Y 轴自适应」彻底同源(详见 §9.35)**。① **折起动画**:`SignalPanel::m_groupAnims` + `GroupRowScale()` —— 每行速度倍率 `1 + 1.2×ordinal/(行数−1)`(**离组名越远越快收尽**)叠加 ease-out cubic(末段减速),总时长 `clamp(115+14×行数, 140, 300)` ms;`BuildRows` 输出**非整行高**,低于 0.75 逻辑 px 的行不再产生;宿主 `WM_TIMER` 同时驱动滚动与折起动画(`Render` 判定改为二者取或);新增 `SetRowClip/ClearRowClip` 把行内容裁到本行(否则垂直居中文本溢出到相邻行)。② **筛选态折起**:筛选模式改用**独立**的 `m_filterCollapsed`(默认展开,不污染普通列表),`ListRow::displayCount` 承载"该组内符合筛选条件的个数",筛选文本/清空/数据变化即清空。③ **悬浮组名可交互**:根因是 `HitRow` 只按滚动偏移映射行 ⇒ 点击穿透到下层;修法:`HitRow` 先用 `StickyGroupRowIndex()`+`StickyGroupRowTop()` 判定悬浮行矩形并临时用 `m_rowYOverrideActive` + 新的 `RowHitPart()` 算行内区域,并跳过高度为 0 的行;新增 `ToggleGroupFromRow()`:正悬浮时 `StartScrollAnimationTo(行内容偏移, groupIndex)`(挂起请求 `m_pendingGroupToggle`),滚动到位后才启动折起动画。④ **单点信号上两种自适应不一致**:`FitSinglePointCluster` 曾另算"半径留白 + 跨度 1"且把 `fitYToSelection` 置回 false ⇒ 点「适应视图」既改区间又**关掉 Y 轴自适应**;修法:单点簇只负责时间轴(居中 + 一大格 1s),**逐网格幅值轴与常规路径/Y 轴自适应完全同源**(`fitYToSelection` → `AutoRangeLaneToView`,否则 `autoRange=true`,再接 `ApplySinglePointValueMargins`),且**绝不改写** `fitYToSelection`。验证:新增离屏探针 `.preflight/perf/panelanim_main.cpp`(真实 `Application` + `SW_HIDE`)**94/94 通过**(连跑 3 次稳定;覆盖折起/展开的分帧过渡、单调收敛、越靠下越矮、末态精确无残留、筛选计数与独立折起状态、悬浮四个可交互区域全部命中且下层行仍可点、点击后"先滚动到位再折起"、真实 MF4 单点信号两种自适应逐位一致/往返稳定);**A/B** 三组(回退绘图区修复 / 禁用悬浮命中优先 / 动画时长置 0)分别 **4 / 13 / 8 项如实失败**;既有 `verify` **49/49**(顺带修正 4 条与远端新规则不符的过期断言)、`datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。 |
|
||||
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **动画改「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(详见 §9.36)**。① **动画重做**(推翻上一轮的“按行缩放”):整组信号作为**刚性整体**平移,**行高永不变**、只维护一个逐行平移量 `m_rowShift` ⇒ 动画期间**无需重建行集合**(流畅关键);收起时逐条被组名栏吞掉、展开时逐条冒出;中途反向点击**从当前位移续接**不跳变;时长 `clamp(115+6×行数, 150, 320)` ms。② **藏进组名栏的裁剪**:信号行越过组名的**绘制**下边界即裁掉,且除 GDI 裁剪外**必须用 `g.SetClip(..., CombineModeIntersect)` 同时收紧 GDI+ 裁剪**(否则行内色块/复选框/徽标画到组名栏上)。③ **悬浮组名折起改为“一边滚动一边收起”**(推翻“先滚动到位再折起”):`scrollSynced` 让滚动与平移按同一进度推进 ⇒ 组名屏幕位置恒定;结束时滚动偏移恰好落在“组名正好在最上方”,并且**无跳变**;旧 `m_pendingGroupToggle` 机制删除。④ **筛选态折叠三角**:`PaintFileRow` 改读 `IsGroupCollapsed()`(筛选模式用的是独立的 `m_filterCollapsed`),修复“三角维持筛选前状态”。⑤ **「目标」行**:`PaintTargetRow` 误用列表裁剪版 `PaintRowText` ⇒ 名称与 GX 文本被完全裁掉(改用 `UiText`);GX 标签改为**右对齐到内容列右边缘**、名称无标签时占满内容列;`EditFieldRect()` 对目标行只返回名称矩形(否则改名输入框把 GX 标签包进去)。验证:离屏探针 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定;含逐帧断言组名栏像素零变化、`m_rowsDirty` 恒 false、滚动单调且恰好落位、筛选/普通态折叠三角逐像素 0 差异、目标行文本像素存在与两端对齐);**A/B** 五组(回退筛选三角+非刚性平移 / 回退目标行绘制与布局 / 关闭同步滚动 / 去掉 GDI+ 收紧裁剪 / 不续接位移)分别 **4 / 6 / 6 / 2 / 1 项如实失败**;`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -2394,34 +2485,53 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
(否则「适应视图」与「Y 轴自适应」会互相改变对方的结果)。
|
||||
⚠️ 且 **`FitAll` 绝不可写 `lane.fitYToSelection`**:用户开着「Y 轴自适应」时点一下
|
||||
「适应视图」会把它悄悄关掉(用户 2026-10-16 报告)。
|
||||
77. ⚠️ **信号面板「分组折起/展开」必须是动画过渡**(用户 2026-10-16 要求):
|
||||
被折起的信号**向上收起**、下方分组连续上浮;**离组名越远的成员收起越快**
|
||||
(`GroupRowScale`:第 ordinal 行速度倍率 = `1 + kGroupAnimDepthBoost × ordinal/(行数−1)`,
|
||||
配合 ease-out cubic)⇒ 最深一行最先收尽;总时长 = `clamp(115 + 14×行数, 140, 300)` ms。
|
||||
实现要点:`m_groupAnims`(可按分组并存)+ `AdvanceGroupAnimations()`(宿主 16ms 定时器
|
||||
与滚动动画共用 `kScrollTimerId`,⚠️ `Application::Render` 的判定要同时看
|
||||
`ScrollAnimationActive() || GroupAnimationActive()`);`BuildRows` 按比例给出
|
||||
**非整行高**;低于 `kGroupAnimMinRowHeight`(0.75 逻辑 px)的行不再产生(不绘制也不命中)。
|
||||
⚠️ 同一分组在动画中途被再次切换会从"干净的一端"重新开始(会有一次视觉跳变)—— 属已知取舍。
|
||||
78. ⚠️ **折起动画期间行内容必须裁剪到本行矩形**(`SetRowClip`/`ClearRowClip`,用于
|
||||
`PaintRowText` 与 `FillRowRect`):行高连续缩小时,`UiText` 是 `DT_VCENTER` 垂直居中,
|
||||
不裁剪就会把文字溢出到相邻行上。
|
||||
77. ⚠️ **信号面板「分组折起/展开」必须是「**抽拉卷尺**」式动画**(用户 2026-10-16 第二轮,
|
||||
推翻第一轮的“按行缩放”):整组信号作为一个**刚性整体**平移 —— 收起时所有信号一起向上移动、
|
||||
逐条消失(被组名栏吞掉),展开时逐条从组名栏下方冒出;**卷尺上的元素相对位置不变**
|
||||
⇒ **行高绝不改变**,只维护一个逐行平移量 `m_rowShift`(`UpdateAnimGeometry`)。
|
||||
收起:`shift = fromShift + (blockHeight − fromShift) × easeOutCubic(p)`;展开:推到 0;
|
||||
时长 = `clamp(115 + 6×行数, 150, 320)` ms。
|
||||
⚠️ **动画期间不得重建行集合**(`AdvanceGroupAnimations` 不能设 `m_rowsDirty`)——
|
||||
这正是该方案流畅的关键(探针直接断言中间帧 `m_rowsDirty == false`)。
|
||||
⚠️ 中途反向点击(折叠没播完就点展开)必须**从当前位移续接**(`GroupAnim::fromShift`),不许跳变。
|
||||
78. ⚠️ **平移只作用于“组名行之后”的行,并且必须双向裁剪**:组名是“卷尺外壳”始终不动。
|
||||
信号行越过**组名绘制位置的下边界**就要被裁掉(`AnimationHideTopForRow` + `ClampRowClipTop`),
|
||||
已经完全越过的行整行跳过。
|
||||
⚠️⚠️ 除了 GDI 裁剪(`PaintRowText`/`FillRowRect` 用的 `m_rowClipRect`),**必须同时用
|
||||
`g.SetClip(clip, CombineModeIntersect)` 收紧 GDI+ 裁剪** —— 行内的色块 / 复选框 / 网格徽标
|
||||
走 GDI+,只受 `SetClip` 约束,不收紧就会画到组名栏上面(A/B 实测会失败)。
|
||||
(探针逐帧断言“组名栏区域像素零变化”。)
|
||||
79. ⚠️ **筛选模式的分组折起状态与普通模式**分开**保存**(`m_filterCollapsed`,默认展开):
|
||||
筛选只是临时子集视图,不该污染普通列表的布局选择;筛选文本变化 / 清空筛选 / 数据变化时
|
||||
一律清空它以及进行中的折起动画(行数与分组可见性都变了)。
|
||||
组名后方的项数必须按当前模式显示:普通 = 信号总数,筛选 = 该组内**符合筛选条件**的个数
|
||||
(`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintFileRow` 只读它)。
|
||||
⚠️ 推论:**所有“读取折起状态”的地方都必须用 `IsGroupCollapsed()`(按模式分派)**,
|
||||
不能直接读 `m_tab->IsCollapsed()` —— 否则筛选态折叠时**折叠三角不会变**
|
||||
(用户 2026-10-16 报告;已用“筛选态 vs 普通态折叠后组名行逐像素 0 差异”锁定)。
|
||||
80. ⚠️ **悬浮(sticky)组名的命中判定必须最优先**:`HitRow` 要先用
|
||||
`StickyGroupRowIndex()` + `StickyGroupRowTop()` 判是否落在悬浮行矩形内,命中则临时打开
|
||||
`m_rowYOverrideActive` 并用 `RowHitPart()` 算行内区域 —— 否则点击会**穿透到它下面的那一行**
|
||||
(用户 2026-10-16 报告:悬浮组名的折叠三角/复选框/✕/名称全都点不动)。
|
||||
⚠️ `HitRow` 同时要跳过**高度为 0** 的行(折起动画中已收尽),否则空行会白白吸走一次点击。
|
||||
81. ⚠️ **点击悬浮组名折起时,必须先平滑滚动到"组名完整显示在列表顶部"再播放折起动画**
|
||||
(用户 2026-10-16 要求):`ToggleGroupFromRow()` 判 `m_scrollOffset > 组行内容偏移`
|
||||
(= 正悬浮)时调 `StartScrollAnimationTo(组行内容偏移, groupIndex)`,滚动完成后由
|
||||
`AdvanceScrollAnimation` 的结束分支取出 `m_pendingGroupToggle` 启动折起动画。
|
||||
⚠️ 用户滚轮 / 拖滚动条 / 筛选变化 / 切标签页都必须**清掉挂起请求**(`CancelScrollAnimation` /
|
||||
`ApplyFilterText` / `ClearFilter` / `InvalidateRows` / `SetContext`)。
|
||||
⚠️ `HitRow` 必须**逐行比较实际绘制位置**(`m_rowShift` 参与),不能用“滚动偏移→内容坐标”
|
||||
的一刀切映射;并且要跳过完全藏进组名栏的行(否则空行会白白吸走一次点击)。
|
||||
81. ⚠️ **悬浮组名折起:一边向上滚动一边收起**(用户 2026-10-16 第二轮,**推翻** §9.35 三的
|
||||
“先滚动到位再折起”):`GroupAnim::scrollSynced` 让滚动偏移与平移量按**同一进度**推进
|
||||
(`m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased`),
|
||||
从而**组名的屏幕位置恒定**(悬浮组名始终在最上方)。
|
||||
目标 `scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)`(后者处理“折起后内容比列表短”)。
|
||||
⚠️ `m_pendingGroupToggle` 两段式机制**已删除,不要再恢复**;
|
||||
用户滚轮 / 拖滚动条 ⇒ `CancelScrollAnimation()` **只解除同步**,折叠/展开继续播放。
|
||||
82. ⚠️ **`PaintRowText` 只能用于“列表行”**(它把文本裁到 `m_listRect`,见第 53 条)。
|
||||
样式区(如「目标」行)里的文本**必须用不裁剪的 `UiText`** —— 否则文本会被完全裁掉
|
||||
(用户 2026-10-16 报告:“目标”行的信号名与 GX 标签都没有正确绘制;A/B 实测该区域文本像素为 0)。
|
||||
83. ⚠️ **样式区「目标」行的布局**(用户 2026-10-16 要求):名称从**内容列左边缘**开始、
|
||||
GX 标签**右对齐到内容列右边缘**(与线宽滑轨/数值框同列宽同两端),两者不得重叠;
|
||||
**无 GX 标签时名称可用宽度 = 整个内容列**(因此更宽,仍左对齐)。
|
||||
⚠️ `EditFieldRect()` 对 `m_editRow < 0` 必须返回 `m_targetNameRect`(**只管名称那一块**),
|
||||
不能返回内容列全宽 —— 否则改名输入框会把 GX 标签包进去(用户报告)。
|
||||
84. ⚠️ **筛选态的折叠三角必须用 `IsGroupCollapsed()`**(见第 79 条推论):`PaintFileRow` 直接读
|
||||
`m_tab->IsCollapsed()` 时,筛选模式里折叠/展开三角**不会变化**(用户 2026-10-16 报告)。
|
||||
71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**:
|
||||
否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号,
|
||||
`Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,
|
||||
@@ -2452,6 +2562,8 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
(ease-out cubic;时长 = `clamp(距离×0.35ms, 130, 300)`;宿主 16ms 定时器驱动)。
|
||||
向上滚动时顶部要**让出一个组行高度**(悬浮组名只可能是目标自己那个分组的组名,
|
||||
因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。
|
||||
⚠️ 宿主定时器同时驱动「折起/展开动画」(见第 77 条):`Render` 的判定必须是
|
||||
`ScrollAnimationActive() || GroupAnimationActive()`。
|
||||
|
||||
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
|
||||
|
||||
|
||||
Reference in new issue
Block a user