分组动画改「抽拉卷尺」式刚性平移 + 悬浮组名折起一边滚动一边收起 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正
① 分组折起/展开动画重做为「抽拉卷尺」式刚性平移(推翻上一轮的“按行缩放”):
- 用户要求:折起时所有信号一起向上平移、逐条消失在组名栏里;展开时逐条从组名栏
向下冒出;卷尺上的元素相对位置不变。
- 旧实现按行高缩放 ⇒ 元素被压扁(相对位置改变),且每帧行高都在变、每帧都要重建
行集合。
- 新实现:整组作为一个刚性整体平移,只维护一个逐行平移量 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
@@ -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);
|
||||
}
|
||||
|
||||
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