信号面板分组折起动画 + 筛选态折起(项数按筛选计数)+ 悬浮组名可交互与“先滚动再折起” + 适应视图与 Y 轴自适应同源
① 分组折起/展开改为动画过渡(用户要求:被折起的信号向上收起、下方组随之上升、
离组名越远的成员收起越快、末段减速、总时长合理):
- 新增 SignalPanel::m_groupAnims + GroupRowScale():每行速度倍率
1 + 1.2×ordinal/(行数−1)(越靠下越快收尽),叠加 ease-out cubic;
时长 = clamp(115 + 14×行数, 140, 300) ms。
- BuildRows 按比例输出非整行高;低于 0.75 逻辑 px 的行不再产生(不绘制/不命中)。
- 宿主 WM_TIMER(kScrollTimerId) 同时驱动滚动与折起动画;Render 的定时器判定改为
ScrollAnimationActive() || GroupAnimationActive()。
- 新增 SetRowClip/ClearRowClip:动画期间行内容裁到本行矩形(否则 DT_VCENTER
垂直居中的文本会溢出到相邻行)。
② 筛选状态下的分组折起 + 项数按筛选结果计数:
- 筛选模式改用独立的 m_filterCollapsed(默认展开),不再“筛选时忽略折叠状态”,
也不再把筛选里的临时折起污染到普通列表的布局选择。
- ListRow::displayCount 记录当前模式下列出的信号数;组名后方「N 项」只读它
(筛选时 = 该组内符合筛选条件的信号个数)。
- 筛选文本变化 / 清空筛选 / 数据变化 ⇒ 清空临时折起状态 + 进行中的动画 + 挂起请求。
③ 悬浮组名可正常交互 + 点击时“先滚动就位再折起”:
- 根因:HitRow 只按滚动偏移把指针映射到内容位置,而悬浮组名是画在列表顶部的覆盖层
(其自然位置已滚出列表)⇒ 点击穿透到它下面那一行(折叠三角 / 复选框 / ✕ /
名称区域全都点不动)。
- 修法:HitRow 先用 StickyGroupRowIndex() / StickyGroupRowTop() 判定是否落在悬浮行
矩形内,命中则临时打开 m_rowYOverrideActive 并调用新的 RowHitPart() 算行内区域;
同时跳过高度为 0 的行(折起动画中已收尽,否则空行会白白吸走一次点击)。
- 新增 ToggleGroupFromRow():若该行正悬浮(滚动偏移大于该行的内容偏移),先
StartScrollAnimationTo(该行内容偏移, groupIndex) 并把挂起请求记在
m_pendingGroupToggle,滚动到位后才启动折起动画;分组行的单击与双击折叠三角都走
这个入口。滚轮 / 拖滚动条 / 筛选变化 / 切标签页一律清掉挂起请求。
④ 「适应视图」与「Y 轴自适应」彻底同源(单数据点信号):
- 根因:FitSinglePointCluster 给每个网格另算一套幅值区间(“散点半径留白 + 跨度 1”),
而 AutoRangeLaneToView 走 PadValueRange ⇒ 同一视界下两者结果不同;且它显式写
lane.fitYToSelection = false ⇒ 点「适应视图」会把「Y 轴自适应」悄悄关掉。
- 修法:单点簇只负责时间轴(以该时刻居中 + 一大格 1s),逐网格幅值轴与常规路径 /
Y 轴自适应完全同源(fitYToSelection → AutoRangeLaneToView,否则 autoRange = true,
两者之后都接 ApplySinglePointValueMargins),并且绝不改写 fitYToSelection。
- 有意的行为变化:单点信号「适应视图」的幅值跨度由旧的 1 变为 2×max(1,|值|×0.1)
(即与 PadValueRange 一致);§9.33 用例 [A] 里“幅值跨度 1”的期望已作废,
散点仍在网格正中且完整可见(ApplySinglePointValueMargins 兜底)。
验证(离屏 SW_HIDE,不占用鼠标 / 键盘 / 屏幕;进程与线程均 BELOW_NORMAL):
- 新增 .preflight/perf/panelanim_main.cpp(panelanim.exe)94/94 通过(连跑 3 次稳定):
折起/展开的分帧过渡与内容高度单调收敛、越靠下的行越矮、末态精确无残留、
筛选计数与独立折起状态、悬浮组名四个可交互区域全部命中且下层行仍可点击、
点击后“先滚动到位再折起”、真实 MF4-Record.MF4 单点信号下两种自适应逐位一致且往返稳定。
- A/B 三组回退(回退绘图区修复 / 禁用悬浮命中优先 / 动画时长置 0)分别 4 / 13 / 8 项如实失败。
- 既有 verify 49/49、datacheck 69/69、edgecheck 46/46;make clean && make all 零警告。
(顺带修正本机 verify 探针 4 条与远端新规则不符的过期断言:幅值留白改为调用唯一的
PadValueRange、派生信号 offsetTime 断言 0(§9.21 已烘入数据)、拖拽换位按“被拖网格
自身边界”判据的新目标值。)
记忆:Documents/agent_mem.md 新增 §9.35 与 §11.2 第 76–81 条,并更新第 70 条与 §9.33 备注。
This commit is contained in:
1 parent
cb9d3e4b8f
commit
1d314ddc24
5 files changed
+801
-161
No files matched your search
@@ -959,8 +959,8 @@ namespace jdi
|
||||
m_hintTimerActive = false;
|
||||
}
|
||||
|
||||
// 信号列表的目标信号滚动动画:仅在动画进行中保持定时重绘。
|
||||
if (m_panel.ScrollAnimationActive())
|
||||
// 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。
|
||||
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive())
|
||||
{
|
||||
if (!m_scrollTimerActive)
|
||||
{
|
||||
@@ -3080,10 +3080,12 @@ namespace jdi
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
}
|
||||
// 信号列表「滚动到目标信号」动画:推进一步并重绘(Render 会在结束后停表)。
|
||||
// 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘
|
||||
//(Render 会在动画结束后停表)。
|
||||
if (wParam == kScrollTimerId)
|
||||
{
|
||||
m_panel.AdvanceScrollAnimation();
|
||||
m_panel.AdvanceGroupAnimations();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1684,6 +1684,11 @@ namespace jdi
|
||||
double sharedTime)
|
||||
{
|
||||
// 时间轴:该时刻居中、**一大格 = 1s**(视界 = 大格数 × 1s)。
|
||||
//
|
||||
// 幅值轴与其余场景**共用同一段逻辑**(见函数末尾):`fitYToSelection` 的网格走
|
||||
// `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`),两者都只由
|
||||
// `PadValueRange` 提供留白。这样「适应视图」与「Y 轴自适应」互为幂等 ——
|
||||
// 同一视界下反复切换两者不会改变区间,也不会关掉 Y 轴自适应开关(用户 2026-10-16)。
|
||||
const double span =
|
||||
static_cast<double>(kTimeTickTarget) * kSinglePointClusterSecondsPerCell;
|
||||
SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5);
|
||||
@@ -1701,50 +1706,30 @@ namespace jdi
|
||||
? std::wstring()
|
||||
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
|
||||
|
||||
// ---- 逐网格的幅值轴 ----
|
||||
// ⚠️ **必须与 `FitAll()` 的常规路径、以及「Y 轴自适应」(`AutoRangeLaneToView`)同源**:
|
||||
// 旧实现在这条特例里按“散点半径留白 + 跨度 1”另算一套区间,而 Y 轴自适应走
|
||||
// `PadValueRange`,于是对**同一个视界**两者会算出不同的幅值范围
|
||||
//(用户 2026-10-16 报告:单数据点信号上「适应视图」与「Y 轴自适应」的范围不一致);
|
||||
// 并且旧实现还把 `fitYToSelection` 置回 false —— 原本开着「Y 轴自适应」时点一下
|
||||
// 「适应视图」会把这个开关**悄悄关掉**。
|
||||
// 现在两者共用同一段逻辑:留白只来自 `PadValueRange`,且**不改动** Y 轴自适应开关。
|
||||
const float laneHeight = FitLaneHeight();
|
||||
for (std::size_t index = 0; index < perLane.size() && index < m_tab->Lanes().size(); ++index)
|
||||
{
|
||||
const std::vector<FitContribution>& items = perLane[index];
|
||||
double valueLow = 0.0;
|
||||
double valueHigh = 0.0;
|
||||
float radius = 0.0f;
|
||||
bool any = false;
|
||||
for (const FitContribution& item : items)
|
||||
const int laneIndex = static_cast<int>(index);
|
||||
if (m_tab->Lanes()[index].fitYToSelection)
|
||||
{
|
||||
if (!item.singlePoint || !std::isfinite(item.pointValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
valueLow = any ? std::min(valueLow, item.pointValue) : item.pointValue;
|
||||
valueHigh = any ? std::max(valueHigh, item.pointValue) : item.pointValue;
|
||||
radius = std::max(radius, item.dotRadius);
|
||||
any = true;
|
||||
// 已开启「选区 Y 轴自适应」的网格按当前(刚设好的)时间视图重新匹配 ——
|
||||
// 与工具栏开关打开时的算法完全一致,因此两种操作互为幂等。
|
||||
AutoRangeLaneToView(laneIndex);
|
||||
}
|
||||
if (!any)
|
||||
else
|
||||
{
|
||||
continue; // 该网格没有可见单值信号:保持原样
|
||||
m_tab->Lanes()[index].autoRange = true;
|
||||
}
|
||||
|
||||
// 幅值跨度:以容纳"网格内的全部数据点"为下限(退化成单值时取 1,
|
||||
// 与"单值信号居中显示"的观感一致),并保证**每个散点都完整在网格内**。
|
||||
double range = std::max(1.0, valueHigh - valueLow);
|
||||
if (laneHeight > 1.0f && radius > 0.0f)
|
||||
{
|
||||
for (int pass = 0; pass < 2; ++pass)
|
||||
{
|
||||
const double margin =
|
||||
(static_cast<double>(radius) + kSinglePointBoundaryPadPixels) * range /
|
||||
static_cast<double>(laneHeight);
|
||||
range = std::max(1.0, valueHigh - valueLow + 2.0 * margin);
|
||||
}
|
||||
}
|
||||
const double center = (valueLow + valueHigh) * 0.5;
|
||||
|
||||
Lane& lane = m_tab->Lanes()[index];
|
||||
lane.autoRange = false;
|
||||
lane.fitYToSelection = false;
|
||||
lane.yMin = center - range * 0.5;
|
||||
lane.yMax = center + range * 0.5;
|
||||
// 补齐“被单值散点顶住的边界”(散点本来就完整可见时保持不变)。
|
||||
ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight);
|
||||
}
|
||||
|
||||
InvalidateSceneCache();
|
||||
|
||||
@@ -91,6 +91,9 @@ namespace jdi
|
||||
m_hoveredSignal = SignalRef();
|
||||
m_hoverRow = -1;
|
||||
m_hoverPart = RowPart::NONE;
|
||||
// 分组下标可能整体位移 ⇒ 折起动画与挂起的折起请求也不能继续沿用。
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,6 +330,15 @@ namespace jdi
|
||||
/** @brief 滚动动画是否正在进行。 */
|
||||
bool ScrollAnimationActive() const { return m_scrollAnimActive; }
|
||||
|
||||
/**
|
||||
* @brief 推进一步分组折起/展开动画(由宿主定时器驱动)。
|
||||
* @return 仍有分组动画在进行返回 true(宿主应保持定时重绘)。
|
||||
*/
|
||||
bool AdvanceGroupAnimations();
|
||||
|
||||
/** @brief 分组折起/展开动画是否正在进行。 */
|
||||
bool GroupAnimationActive() const { return !m_groupAnims.empty(); }
|
||||
|
||||
/**
|
||||
* @brief 供绘图区联动高亮的「悬停信号」。
|
||||
*
|
||||
@@ -409,6 +421,38 @@ namespace jdi
|
||||
int groupIndex = -1; /**< 所属文件下标。 */
|
||||
int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */
|
||||
float height = 0.0f; /**< 行高(像素)。 */
|
||||
/**
|
||||
* @brief 分组行显示的项数(仅分组行有效)。
|
||||
*
|
||||
* 普通模式 = 该分组的信号总数;筛选模式 = 该分组内**符合筛选条件**的信号个数
|
||||
*(用户 2026-10-16 要求:筛选时组名后方的项数按筛选结果计数)。
|
||||
*/
|
||||
int displayCount = 0;
|
||||
/**
|
||||
* @brief 该行所属分组内「参与列出」的序号(仅信号行有效)。
|
||||
*
|
||||
* 用于折起动画按「离组名越远越快」计算每行的收缩比例。
|
||||
*/
|
||||
int ordinal = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 一个分组的折起/展开动画状态。
|
||||
*
|
||||
* 用户 2026-10-16 要求:折起分组时不能下一帧立刻切换布局,而要有一段过渡动画 ——
|
||||
* 被折起的信号向上收起、下方的分组随之上升;**离组名越远的成员收起越快**,
|
||||
* 随后整体减速直到收起完成;总时长控制在一个合理范围(百余毫秒到 300ms)。
|
||||
*
|
||||
* 动画期间 `BuildRows` 会按 `GroupRowScale()` 给出每行的收缩比例,
|
||||
* 因此列表内容高度连续变化、下方分组平滑上升。
|
||||
*/
|
||||
struct GroupAnim
|
||||
{
|
||||
int groupIndex = -1; /**< 目标分组下标。 */
|
||||
bool collapsing = false; /**< true = 正在折起,false = 正在展开。 */
|
||||
int rowCount = 0; /**< 该分组参与动画的信号行数。 */
|
||||
std::uint64_t startMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */
|
||||
std::uint32_t durationMs = 0; /**< 动画总时长(毫秒)。 */
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -423,6 +467,97 @@ namespace jdi
|
||||
*/
|
||||
bool SignalScrollTarget(const SignalRef& ref, float& desired) const;
|
||||
|
||||
/**
|
||||
* @brief 列表内容中某一行顶部相对内容原点的纵向偏移(不包含滚动偏移)。
|
||||
* @param [IN] row 行下标。
|
||||
* @return 偏移量(像素)。
|
||||
*/
|
||||
float ContentOffsetBeforeRow(int row) const;
|
||||
|
||||
/**
|
||||
* @brief 启动一段滚动动画。
|
||||
* @param [IN] desired 目标滚动偏移(内部会夹取到合法范围)。
|
||||
* @param [IN] pendingGroupToggle 滚动到位后要折起/展开的分组下标(-1 = 无)。
|
||||
*/
|
||||
void StartScrollAnimationTo(float desired, int pendingGroupToggle = -1);
|
||||
|
||||
/**
|
||||
* @brief 判断某分组在**当前模式**(筛选 / 普通)下是否处于折起状态。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @return 折起返回 true。
|
||||
*/
|
||||
bool IsGroupCollapsed(int groupIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 设置某分组在**当前模式**下的折起状态。
|
||||
*
|
||||
* ⚠️ 筛选模式与普通模式各有一套折起状态(`m_filterCollapsed` / `TabConfig`):
|
||||
* 筛选只是临时的一个子集视图,不应污染用户在普通列表里的布局选择。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] collapsed 是否折起。
|
||||
*/
|
||||
void SetGroupCollapsed(int groupIndex, bool collapsed);
|
||||
|
||||
/**
|
||||
* @brief 某分组当前会列出的信号数量(筛选模式下为符合筛选条件的个数)。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @return 数量。
|
||||
*/
|
||||
int ShownSignalCount(int groupIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 查找某分组的折起动画条目下标。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @return 条目下标;不存在返回 -1。
|
||||
*/
|
||||
int FindGroupAnimation(int groupIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 启动某分组的折起/展开动画。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] collapsing 是否折起。
|
||||
* @param [IN] rowCount 该分组参与动画的信号行数。
|
||||
*/
|
||||
void StartGroupAnimation(int groupIndex, bool collapsing, int rowCount);
|
||||
|
||||
/**
|
||||
* @brief 取某分组动画中第 ordinal 行的收缩比例。
|
||||
*
|
||||
* 规则(用户 2026-10-16 要求):**离组名越远的成员收起速度越快**,整体减速(ease-out)
|
||||
* 直到完成;因此越靠下的行越早消失,紧接着下方分组平滑上升。
|
||||
*
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] ordinal 该行在分组内「参与列出」的序号(0 = 紧邻组名的第一行)。
|
||||
* @param [IN] rowCount 该分组参与动画的行数。
|
||||
* @return 1 = 完整高度,0 = 完全收起。
|
||||
*/
|
||||
float GroupRowScale(int groupIndex, int ordinal, int rowCount) const;
|
||||
|
||||
/**
|
||||
* @brief 切换分组折起状态并播放动画(不处理“悬浮先滚动”的前置动作)。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
*/
|
||||
void ToggleGroupCollapsedAnimated(int groupIndex);
|
||||
|
||||
/**
|
||||
* @brief 点击分组行:若该组名此刻正悬浮在列表上方,先平滑滚动到顶部再播放折起动画。
|
||||
*
|
||||
* 用户 2026-10-16 要求:悬浮状态下点击组名折起时,滚动条要先向上滚动,
|
||||
* 直到悬浮组名处于**真正完整显示**的位置(即它的自然位置),再执行折起动画。
|
||||
* @param [IN] groupIndex 分组下标。
|
||||
* @param [IN] row 分组行下标。
|
||||
*/
|
||||
void ToggleGroupFromRow(int groupIndex, int row);
|
||||
|
||||
/**
|
||||
* @brief 计算某行的可交互区域(使用 `RowRect` 的当前几何,含悬浮位置覆盖)。
|
||||
* @param [IN] row 行下标。
|
||||
* @param [IN] x 客户区横坐标。
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 行内区域类型。
|
||||
*/
|
||||
RowPart RowHitPart(int row, float x, float y) const;
|
||||
|
||||
/**
|
||||
* @brief 重新计算「目标」行内信号名与 GX 标签的矩形。
|
||||
*
|
||||
@@ -438,7 +573,13 @@ namespace jdi
|
||||
void PaintTargetRow(gd::Graphics& g);
|
||||
|
||||
/** @brief 取消正在进行的滚动动画(用户主动滚动时调用)。 */
|
||||
void CancelScrollAnimation() { m_scrollAnimActive = false; }
|
||||
void CancelScrollAnimation()
|
||||
{
|
||||
m_scrollAnimActive = false;
|
||||
// 挂起的“滚动到位后折起”也必须取消:用户已经自己滚动了,
|
||||
// 再补一次折起会让人莫名其妙。
|
||||
m_pendingGroupToggle = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 重新构建列表行集合。
|
||||
@@ -501,6 +642,22 @@ namespace jdi
|
||||
*/
|
||||
gd::RectF SignalRemoveRect(int row) const;
|
||||
|
||||
/**
|
||||
* @brief 把后续行内绘制裁剪到指定行矩形(与列表矩形相交)。
|
||||
*
|
||||
* 折起动画期间行高连续缩小,行内容必须裁到行矩形内,否则垂直居中的文本会溢出到相邻行。
|
||||
* @param [IN] rowRect 行矩形。
|
||||
*/
|
||||
void SetRowClip(const gd::RectF& rowRect)
|
||||
{
|
||||
m_rowClipActive = true;
|
||||
m_rowClipRect = rowRect;
|
||||
m_rowClipRect.Intersect(m_listRect);
|
||||
}
|
||||
|
||||
/** @brief 结束行内裁剪(恢复为整个列表矩形)。 */
|
||||
void ClearRowClip() { m_rowClipActive = false; }
|
||||
|
||||
/**
|
||||
* @brief 绘制文件分组标题行。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
@@ -844,6 +1001,35 @@ 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 筛选模式下的分组折起状态(按分组下标)。
|
||||
*
|
||||
* 用户 2026-10-16 要求筛选结果也能以组为单位折起,但筛选只是一个临时视图,
|
||||
* 因此使用与普通列表**分开**的一套状态,避免污染用户在普通列表里的布局选择。
|
||||
* 表长不足 / 越界 = 展开(筛选结果默认展开,便于直接看到匹配项)。
|
||||
*/
|
||||
std::vector<bool> m_filterCollapsed;
|
||||
|
||||
/**
|
||||
* @brief 当前正在绘制的行的裁剪矩形(用于折起动画中的部分高度行)。
|
||||
*
|
||||
* 行高在折起动画期间连续缩小,若不把行内容裁剪到行矩形内,
|
||||
* 文本会溢出到相邻行上(`UiText` 是 `DT_VCENTER` 垂直居中,
|
||||
* 在变矮的矩形里会被裁掉上下两半,这正是想要的“挤压”观感)。
|
||||
*/
|
||||
bool m_rowClipActive = false;
|
||||
gd::RectF m_rowClipRect;
|
||||
|
||||
StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */
|
||||
int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */
|
||||
|
||||
@@ -113,6 +113,29 @@ namespace jdi
|
||||
const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
|
||||
const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
|
||||
const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
|
||||
|
||||
// ---- 分组折起/展开动画(用户 2026-10-16 要求)----
|
||||
// 总时长 = clamp(基础 + 行数×每行, 下限, 上限);上限与滚动动画同一量级,
|
||||
// 保证“整个动画过程不会太久”。
|
||||
const float kGroupAnimBaseMs = 115.0f; /**< 动画基础时长。 */
|
||||
const float kGroupAnimPerRowMs = 14.0f; /**< 每多一行增加的时长。 */
|
||||
const float kGroupAnimMinMs = 140.0f; /**< 最短时长。 */
|
||||
const float kGroupAnimMaxMs = 300.0f; /**< 最长时长。 */
|
||||
/**
|
||||
* @brief 「离组名越远的成员收起越快」的深度增益。
|
||||
*
|
||||
* 第 ordinal 行的速度倍率 = `1 + 增益 × ordinal / (行数-1)`:最深的一行倍率最高,
|
||||
* 因此最先收起(实测 1.2 时最深一行在总时长的约 45% 处就已经收完),
|
||||
* 而紧邻组名的第一行最后收完,配合 ease-out 形成“向上收起 + 减速”的观感。
|
||||
*/
|
||||
const float kGroupAnimDepthBoost = 1.2f;
|
||||
/**
|
||||
* @brief 分组折起动画中“已被收成一条线”的行高下限(逻辑像素)。
|
||||
*
|
||||
* 低于该高度的行不再产生(既不绘制也不参与命中),避免出现高度接近 0
|
||||
* 却仍能吸走一次点击的空行。
|
||||
*/
|
||||
const float kGroupAnimMinRowHeight = 0.75f;
|
||||
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
|
||||
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
|
||||
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
|
||||
@@ -309,6 +332,10 @@ namespace jdi
|
||||
m_pendingClosePart = RowPart::NONE;
|
||||
m_pendingCloseRow = -1;
|
||||
m_pendingFilterClear = false;
|
||||
// 切换标签页 / 数据集合后,分组下标与滚动位置都变了:动画与挂起请求一律作废。
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
m_scrollAnimActive = false;
|
||||
}
|
||||
|
||||
void SignalPanel::SetScale(float scale)
|
||||
@@ -362,6 +389,10 @@ namespace jdi
|
||||
// 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。
|
||||
m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u);
|
||||
}
|
||||
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
|
||||
m_filterCollapsed.clear();
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
// 清空后回到完整列表:重建行集合、回到顶部。
|
||||
m_rowsDirty = true;
|
||||
m_scrollOffset = 0.0f;
|
||||
@@ -438,6 +469,11 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
m_filterText = text;
|
||||
// 筛选内容变化 ⇒ 筛选结果整体重建:临时折起状态、进行中的折起动画与挂起请求全部作废
|
||||
//(行数与分组可见性都会变,继续沿用会算出错误的行集合)。
|
||||
m_filterCollapsed.clear();
|
||||
m_groupAnims.clear();
|
||||
m_pendingGroupToggle = -1;
|
||||
// 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。
|
||||
m_rowsDirty = true;
|
||||
m_scrollOffset = 0.0f;
|
||||
@@ -461,8 +497,221 @@ namespace jdi
|
||||
return WildcardMatchNoCase(signal.Title(), pattern);
|
||||
}
|
||||
|
||||
bool SignalPanel::IsGroupCollapsed(int groupIndex) const
|
||||
{
|
||||
if (groupIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 筛选模式使用**独立**的折起状态:筛选只是临时子集视图,不应污染普通列表的布局选择;
|
||||
// 且筛选结果默认展开(便于直接看到匹配项)。
|
||||
if (!m_filterText.empty())
|
||||
{
|
||||
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return m_filterCollapsed[static_cast<std::size_t>(groupIndex)];
|
||||
}
|
||||
return (m_tab != nullptr) && m_tab->IsCollapsed(groupIndex);
|
||||
}
|
||||
|
||||
void SignalPanel::SetGroupCollapsed(int groupIndex, bool collapsed)
|
||||
{
|
||||
if (groupIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!m_filterText.empty())
|
||||
{
|
||||
if (static_cast<std::size_t>(groupIndex) >= m_filterCollapsed.size())
|
||||
{
|
||||
m_filterCollapsed.resize(static_cast<std::size_t>(groupIndex) + 1, false);
|
||||
}
|
||||
m_filterCollapsed[static_cast<std::size_t>(groupIndex)] = collapsed;
|
||||
return;
|
||||
}
|
||||
if (m_tab != nullptr && m_tab->IsCollapsed(groupIndex) != collapsed)
|
||||
{
|
||||
m_tab->ToggleCollapsed(groupIndex);
|
||||
}
|
||||
}
|
||||
|
||||
int SignalPanel::ShownSignalCount(int groupIndex) const
|
||||
{
|
||||
if (m_dataSet == nullptr || groupIndex < 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
|
||||
if (group == nullptr)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (m_filterText.empty())
|
||||
{
|
||||
return static_cast<int>(group->signals.size());
|
||||
}
|
||||
int count = 0;
|
||||
for (const SignalData& signal : group->signals)
|
||||
{
|
||||
if (FilterMatchesSignal(signal))
|
||||
{
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int SignalPanel::FindGroupAnimation(int groupIndex) const
|
||||
{
|
||||
for (std::size_t index = 0; index < m_groupAnims.size(); ++index)
|
||||
{
|
||||
if (m_groupAnims[index].groupIndex == groupIndex)
|
||||
{
|
||||
return static_cast<int>(index);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void SignalPanel::StartGroupAnimation(int groupIndex, bool collapsing, int rowCount)
|
||||
{
|
||||
if (groupIndex < 0 || rowCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 同一分组只保留一个动画条目:再次切换时移除旧的,新动画从“当前状态”出发 ——
|
||||
// 折起与展开的起点本来就分别是干净的 1 与 0,因此重新开始即可。
|
||||
const int existing = FindGroupAnimation(groupIndex);
|
||||
if (existing >= 0)
|
||||
{
|
||||
m_groupAnims.erase(m_groupAnims.begin() + existing);
|
||||
}
|
||||
|
||||
GroupAnim anim;
|
||||
anim.groupIndex = groupIndex;
|
||||
anim.collapsing = collapsing;
|
||||
anim.rowCount = rowCount;
|
||||
anim.startMs = GetTickCount64();
|
||||
// 时长随行数增长,但有上下限 ⇒ “整个动画过程不会太久”。
|
||||
const float durationMs = std::max(
|
||||
kGroupAnimMinMs,
|
||||
std::min(kGroupAnimMaxMs,
|
||||
kGroupAnimBaseMs + kGroupAnimPerRowMs * static_cast<float>(rowCount)));
|
||||
anim.durationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||||
m_groupAnims.push_back(anim);
|
||||
m_rowsDirty = true;
|
||||
}
|
||||
|
||||
float SignalPanel::GroupRowScale(int groupIndex, int ordinal, int rowCount) const
|
||||
{
|
||||
const int animIndex = FindGroupAnimation(groupIndex);
|
||||
if (animIndex < 0)
|
||||
{
|
||||
// 没有动画:完全由折起状态决定(折起 = 不占高度)。
|
||||
return IsGroupCollapsed(groupIndex) ? 0.0f : 1.0f;
|
||||
}
|
||||
|
||||
const GroupAnim& anim = m_groupAnims[static_cast<std::size_t>(animIndex)];
|
||||
const std::uint64_t now = GetTickCount64();
|
||||
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
|
||||
float progress = (anim.durationMs > 0u)
|
||||
? static_cast<float>(elapsed) / static_cast<float>(anim.durationMs)
|
||||
: 1.0f;
|
||||
if (progress > 1.0f)
|
||||
{
|
||||
progress = 1.0f;
|
||||
}
|
||||
|
||||
// 「被收起的组最下方成员离得越远收起速度越快」:第 ordinal 行的速度倍率
|
||||
// 随它与组名的距离线性增大,因此最深的一行最先收起,紧邻组名的一行最后收完。
|
||||
const float depth = (rowCount > 1)
|
||||
? static_cast<float>(ordinal) / static_cast<float>(rowCount - 1)
|
||||
: 0.0f;
|
||||
const float speed = 1.0f + kGroupAnimDepthBoost * depth;
|
||||
float local = progress * speed;
|
||||
if (local > 1.0f)
|
||||
{
|
||||
local = 1.0f;
|
||||
}
|
||||
// ease-out cubic:起步快、接近完成时明显减速(“然后慢慢减速直到收起完成”)。
|
||||
const float eased = 1.0f - std::pow(1.0f - local, 3.0f);
|
||||
return anim.collapsing ? (1.0f - eased) : eased;
|
||||
}
|
||||
|
||||
bool SignalPanel::AdvanceGroupAnimations()
|
||||
{
|
||||
if (m_groupAnims.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint64_t now = GetTickCount64();
|
||||
for (std::size_t index = 0; index < m_groupAnims.size();)
|
||||
{
|
||||
const GroupAnim& anim = m_groupAnims[index];
|
||||
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
|
||||
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
|
||||
{
|
||||
// 完成:移除条目,行高随即回到“折起状态”决定的最终值。
|
||||
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
}
|
||||
else
|
||||
{
|
||||
++index;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画期间每一帧的行高都不同 ⇒ 必须重建行集合(否则画面静止不动)。
|
||||
m_rowsDirty = true;
|
||||
return !m_groupAnims.empty();
|
||||
}
|
||||
|
||||
void SignalPanel::ToggleGroupCollapsedAnimated(int groupIndex)
|
||||
{
|
||||
if (m_tab == nullptr || groupIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const bool collapse = !IsGroupCollapsed(groupIndex);
|
||||
SetGroupCollapsed(groupIndex, collapse);
|
||||
// 参与动画的行数 = 该组当前模式下会列出的信号数(筛选时只算匹配项)。
|
||||
const int count = ShownSignalCount(groupIndex);
|
||||
if (count > 0)
|
||||
{
|
||||
StartGroupAnimation(groupIndex, collapse, count);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_rowsDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
|
||||
{
|
||||
EnsureRows();
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 该分组行的自然位置(内容坐标)恰好就是“让它完整显示在列表顶部”所需的滚动偏移。
|
||||
const float alignOffset = ContentOffsetBeforeRow(row);
|
||||
if (m_scrollOffset > alignOffset + 0.5f)
|
||||
{
|
||||
// 此刻组名正悬浮在列表上方(已部分滚出):用户 2026-10-16 要求先平滑滚动,
|
||||
// 直到悬浮组名处于真正完整显示的位置,滚动到位后再播放折起动画。
|
||||
StartScrollAnimationTo(alignOffset, groupIndex);
|
||||
return;
|
||||
}
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
}
|
||||
|
||||
void SignalPanel::BuildRows()
|
||||
{ m_rows.clear();
|
||||
{
|
||||
m_rows.clear();
|
||||
m_rowsDirty = false;
|
||||
|
||||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||||
@@ -478,58 +727,65 @@ namespace jdi
|
||||
const std::vector<SignalGroup>& files = m_dataSet->Groups();
|
||||
for (std::size_t groupIndex = 0; groupIndex < files.size(); ++groupIndex)
|
||||
{
|
||||
// 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。
|
||||
std::vector<int> matched;
|
||||
if (filtering)
|
||||
const int gi = static_cast<int>(groupIndex);
|
||||
|
||||
// 参与列出的信号:筛选模式只保留匹配项;整组都没有匹配则**不显示该组**。
|
||||
std::vector<int> shown;
|
||||
const std::size_t signalCount = files[groupIndex].signals.size();
|
||||
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
|
||||
{
|
||||
const std::size_t signalCount = files[groupIndex].signals.size();
|
||||
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
|
||||
if (!filtering || FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
|
||||
{
|
||||
if (FilterMatchesSignal(files[groupIndex].signals[signalIndex]))
|
||||
{
|
||||
matched.push_back(static_cast<int>(signalIndex));
|
||||
}
|
||||
}
|
||||
if (matched.empty())
|
||||
{
|
||||
continue;
|
||||
shown.push_back(static_cast<int>(signalIndex));
|
||||
}
|
||||
}
|
||||
if (filtering && shown.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool collapsed = IsGroupCollapsed(gi);
|
||||
const int animIndex = FindGroupAnimation(gi);
|
||||
const GroupAnim* anim = nullptr;
|
||||
if (animIndex >= 0 &&
|
||||
m_groupAnims[static_cast<std::size_t>(animIndex)].rowCount == static_cast<int>(shown.size()))
|
||||
{
|
||||
anim = &m_groupAnims[static_cast<std::size_t>(animIndex)];
|
||||
}
|
||||
|
||||
ListRow fileRow;
|
||||
fileRow.kind = RowKind::GROUP;
|
||||
fileRow.groupIndex = static_cast<int>(groupIndex);
|
||||
fileRow.groupIndex = gi;
|
||||
fileRow.height = fileHeight;
|
||||
// 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。
|
||||
fileRow.displayCount = static_cast<int>(shown.size());
|
||||
m_rows.push_back(fileRow);
|
||||
|
||||
// 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。
|
||||
if (!filtering && m_tab->IsCollapsed(static_cast<int>(groupIndex)))
|
||||
// 折起状态与动画共同决定每个信号行的实际高度:
|
||||
// * 展开且无动画 ⇒ 完整高度;
|
||||
// * 折起且无动画 ⇒ 不产生任何行;
|
||||
// * 有动画 ⇒ 按 `GroupRowScale()` 连续缩小(折起)/ 放大(展开)。
|
||||
const int rowCount = static_cast<int>(shown.size());
|
||||
for (int ordinal = 0; ordinal < rowCount; ++ordinal)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filtering)
|
||||
{
|
||||
for (const int signalIndex : matched)
|
||||
float scale = collapsed ? 0.0f : 1.0f;
|
||||
if (anim != nullptr)
|
||||
{
|
||||
ListRow signalRow;
|
||||
signalRow.kind = RowKind::SIGNAL;
|
||||
signalRow.groupIndex = static_cast<int>(groupIndex);
|
||||
signalRow.signalIndex = signalIndex;
|
||||
signalRow.height = signalHeight;
|
||||
m_rows.push_back(signalRow);
|
||||
scale = GroupRowScale(gi, ordinal, rowCount);
|
||||
}
|
||||
const float height = signalHeight * scale;
|
||||
if (height < kGroupAnimMinRowHeight * m_scale)
|
||||
{
|
||||
// 已经收成一条线:不再产生可绘制 / 可命中的行。
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::size_t signalCount = files[groupIndex].signals.size();
|
||||
for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex)
|
||||
{
|
||||
ListRow signalRow;
|
||||
signalRow.kind = RowKind::SIGNAL;
|
||||
signalRow.groupIndex = static_cast<int>(groupIndex);
|
||||
signalRow.signalIndex = static_cast<int>(signalIndex);
|
||||
signalRow.height = signalHeight;
|
||||
signalRow.groupIndex = gi;
|
||||
signalRow.signalIndex = shown[static_cast<std::size_t>(ordinal)];
|
||||
signalRow.height = height;
|
||||
signalRow.ordinal = ordinal;
|
||||
m_rows.push_back(signalRow);
|
||||
}
|
||||
}
|
||||
@@ -688,8 +944,10 @@ namespace jdi
|
||||
m_rowYOverrideY = stickyY;
|
||||
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
|
||||
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
|
||||
SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height));
|
||||
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
|
||||
PaintFileRow(g, row);
|
||||
ClearRowClip();
|
||||
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
|
||||
if (stickyY > naturalY + 0.5f)
|
||||
{
|
||||
@@ -734,6 +992,59 @@ namespace jdi
|
||||
rect.Y + (rect.Height - size) * 0.5f, size, size);
|
||||
}
|
||||
|
||||
SignalPanel::RowPart SignalPanel::RowHitPart(int row, float fx, float fy) const
|
||||
{
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
return RowPart::NONE;
|
||||
}
|
||||
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
const gd::RectF rect = RowRect(row);
|
||||
const bool isFile = (item.kind == RowKind::GROUP);
|
||||
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
|
||||
|
||||
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
|
||||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
|
||||
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
|
||||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||||
|
||||
if (isFile)
|
||||
{
|
||||
if (UiPointInRect(disclosureRect, fx, fy))
|
||||
{
|
||||
return RowPart::DISCLOSURE;
|
||||
}
|
||||
if (UiPointInRect(checkRect, fx, fy))
|
||||
{
|
||||
return RowPart::CHECK;
|
||||
}
|
||||
if (UiPointInRect(FileCloseRect(row), fx, fy))
|
||||
{
|
||||
return RowPart::CLOSE;
|
||||
}
|
||||
return RowPart::BODY;
|
||||
}
|
||||
|
||||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||||
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
|
||||
|
||||
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
|
||||
if (UiPointInRect(SignalRemoveRect(row), fx, fy))
|
||||
{
|
||||
return RowPart::REMOVE_SIGNAL;
|
||||
}
|
||||
if (UiPointInRect(checkRect, fx, fy))
|
||||
{
|
||||
return RowPart::CHECK;
|
||||
}
|
||||
if (badgeVisible && UiPointInRect(SignalBadgeRect(row), fx, fy))
|
||||
{
|
||||
return RowPart::LANE_BADGE;
|
||||
}
|
||||
return RowPart::BODY;
|
||||
}
|
||||
|
||||
int SignalPanel::HitRow(int x, int y, RowPart& part)
|
||||
{
|
||||
part = RowPart::NONE;
|
||||
@@ -755,6 +1066,26 @@ namespace jdi
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 「悬浮组名」绘制在最上层,因此命中判定也必须最优先 —— 否则点击会**穿透到
|
||||
// 它下面的那一行**(用户 2026-10-16 报告:组名悬浮时它上面的折叠三角、复选框、
|
||||
// 关闭 ✕、名称区域全都点不动,点击被下层信号行吃掉)。
|
||||
const int rowCount = static_cast<int>(m_rows.size());
|
||||
const int stickyRow = StickyGroupRowIndex(rowCount);
|
||||
if (stickyRow >= 0)
|
||||
{
|
||||
const float stickyTop = StickyGroupRowTop(stickyRow, rowCount);
|
||||
const float stickyHeight = m_rows[static_cast<std::size_t>(stickyRow)].height;
|
||||
if (fy >= stickyTop && fy < stickyTop + stickyHeight)
|
||||
{
|
||||
// 用覆盖几何计算行内区域(`RowRect` 是行内所有派生的唯一几何来源)。
|
||||
m_rowYOverrideActive = true;
|
||||
m_rowYOverrideY = stickyTop;
|
||||
part = RowHitPart(stickyRow, fx, fy);
|
||||
m_rowYOverrideActive = false;
|
||||
return stickyRow;
|
||||
}
|
||||
}
|
||||
|
||||
const float local = fy - m_listRect.Y + m_scrollOffset;
|
||||
if (local < 0.0f)
|
||||
{
|
||||
@@ -765,60 +1096,11 @@ namespace jdi
|
||||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||||
{
|
||||
const ListRow& row = m_rows[index];
|
||||
if (local >= accumulated && local < accumulated + row.height)
|
||||
// ⚠️ 高度为 0 的行(折起动画中已收尽)不参与命中,否则它会白白吸走一次点击。
|
||||
if (row.height > 0.0f && local >= accumulated && local < accumulated + row.height)
|
||||
{
|
||||
const int rowIndex = static_cast<int>(index);
|
||||
const gd::RectF rect = RowRect(rowIndex);
|
||||
const bool isFile = (row.kind == RowKind::GROUP);
|
||||
const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale);
|
||||
|
||||
const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height);
|
||||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale,
|
||||
rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f,
|
||||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||||
|
||||
if (isFile)
|
||||
{
|
||||
if (UiPointInRect(disclosureRect, fx, fy))
|
||||
{
|
||||
part = RowPart::DISCLOSURE;
|
||||
}
|
||||
else if (UiPointInRect(checkRect, fx, fy))
|
||||
{
|
||||
part = RowPart::CHECK;
|
||||
}
|
||||
else if (UiPointInRect(FileCloseRect(rowIndex), fx, fy))
|
||||
{
|
||||
part = RowPart::CLOSE;
|
||||
}
|
||||
else
|
||||
{
|
||||
part = RowPart::BODY;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const SignalRef ref{row.groupIndex, row.signalIndex};
|
||||
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
|
||||
|
||||
// ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
|
||||
if (UiPointInRect(SignalRemoveRect(rowIndex), fx, fy))
|
||||
{
|
||||
part = RowPart::REMOVE_SIGNAL;
|
||||
}
|
||||
else if (UiPointInRect(checkRect, fx, fy))
|
||||
{
|
||||
part = RowPart::CHECK;
|
||||
}
|
||||
else if (badgeVisible && UiPointInRect(SignalBadgeRect(rowIndex), fx, fy))
|
||||
{
|
||||
part = RowPart::LANE_BADGE;
|
||||
}
|
||||
else
|
||||
{
|
||||
part = RowPart::BODY;
|
||||
}
|
||||
}
|
||||
part = RowHitPart(rowIndex, fx, fy);
|
||||
return rowIndex;
|
||||
}
|
||||
accumulated += row.height;
|
||||
@@ -826,6 +1108,52 @@ namespace jdi
|
||||
return -1;
|
||||
}
|
||||
|
||||
float SignalPanel::ContentOffsetBeforeRow(int row) const
|
||||
{
|
||||
float top = 0.0f;
|
||||
const int count = std::min(row, static_cast<int>(m_rows.size()));
|
||||
for (int index = 0; index < count; ++index)
|
||||
{
|
||||
top += m_rows[static_cast<std::size_t>(index)].height;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
void SignalPanel::StartScrollAnimationTo(float desired, int pendingGroupToggle)
|
||||
{
|
||||
m_pendingGroupToggle = pendingGroupToggle;
|
||||
|
||||
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
|
||||
desired = std::max(0.0f, std::min(desired, maxOffset));
|
||||
const float distance = std::fabs(desired - m_scrollOffset);
|
||||
|
||||
if (distance < 1.0f)
|
||||
{
|
||||
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平),
|
||||
// 挂起的折起请求立即执行。
|
||||
m_scrollOffset = desired;
|
||||
ClampScroll();
|
||||
m_scrollAnimActive = false;
|
||||
if (m_pendingGroupToggle >= 0)
|
||||
{
|
||||
const int groupIndex = m_pendingGroupToggle;
|
||||
m_pendingGroupToggle = -1;
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
|
||||
// 因此"整个动画过程不会太久"(最长 300ms)。
|
||||
const float durationMs =
|
||||
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
|
||||
m_scrollAnimFrom = m_scrollOffset;
|
||||
m_scrollAnimTo = desired;
|
||||
m_scrollAnimStartMs = GetTickCount64();
|
||||
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||||
m_scrollAnimActive = true;
|
||||
}
|
||||
|
||||
void SignalPanel::ScrollToSignal(const SignalRef& ref)
|
||||
{
|
||||
EnsureRows();
|
||||
@@ -1201,6 +1529,9 @@ namespace jdi
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 行内容一律裁剪到**本行矩形**:折起动画期间行高连续缩小,
|
||||
// 若不裁剪,垂直居中的文本会溢出到相邻行上。
|
||||
SetRowClip(rect);
|
||||
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
|
||||
{
|
||||
PaintFileRow(g, row);
|
||||
@@ -1209,6 +1540,7 @@ namespace jdi
|
||||
{
|
||||
PaintSignalRow(g, row);
|
||||
}
|
||||
ClearRowClip();
|
||||
}
|
||||
|
||||
PaintStickyGroupRow(g, stickyRow);
|
||||
@@ -1236,9 +1568,11 @@ namespace jdi
|
||||
void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font,
|
||||
const gd::RectF& rect, std::uint32_t argb, TextAlign align)
|
||||
{
|
||||
// 行内文本必须显式裁剪到列表矩形:GDI 文本不遵循 GDI+ 的 `SetClip`,
|
||||
// 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`,
|
||||
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
|
||||
UiTextClipped(g, text, font, rect, argb, align, m_listRect);
|
||||
// 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内,
|
||||
// 否则垂直居中的文字会溢出到相邻行上。
|
||||
UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect);
|
||||
}
|
||||
|
||||
void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
|
||||
@@ -1246,7 +1580,7 @@ namespace jdi
|
||||
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
|
||||
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
|
||||
gd::RectF clipped = rect;
|
||||
clipped.Intersect(m_listRect);
|
||||
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
|
||||
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
@@ -1302,7 +1636,9 @@ namespace jdi
|
||||
|
||||
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
|
||||
const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1);
|
||||
const std::wstring stats = std::to_wstring(group->signals.size()) + L" 项";
|
||||
// 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数
|
||||
//(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。
|
||||
const std::wstring stats = std::to_wstring(item.displayCount) + L" 项";
|
||||
const float groupWidth = FileGroupLabelWidth(row);
|
||||
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
|
||||
const float statsRight = closeRect.X - 6.0f * m_scale;
|
||||
@@ -1786,25 +2122,8 @@ namespace jdi
|
||||
{
|
||||
return;
|
||||
}
|
||||
const float distance = std::fabs(desired - m_scrollOffset);
|
||||
if (distance < 1.0f)
|
||||
{
|
||||
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
|
||||
m_scrollOffset = desired;
|
||||
ClampScroll();
|
||||
m_scrollAnimActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
|
||||
// 因此"整个动画过程不会太久"(最长 300ms)。
|
||||
const float durationMs =
|
||||
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
|
||||
m_scrollAnimFrom = m_scrollOffset;
|
||||
m_scrollAnimTo = desired;
|
||||
m_scrollAnimStartMs = GetTickCount64();
|
||||
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||||
m_scrollAnimActive = true;
|
||||
// 目标信号滚动与“悬浮组名折起”的前置滚动共用同一套动画(后者会带挂起请求)。
|
||||
StartScrollAnimationTo(desired);
|
||||
}
|
||||
|
||||
bool SignalPanel::AdvanceScrollAnimation()
|
||||
@@ -1833,6 +2152,14 @@ namespace jdi
|
||||
m_scrollOffset = m_scrollAnimTo;
|
||||
m_scrollAnimActive = false;
|
||||
ClampScroll();
|
||||
// 若这次滚动是为了“把悬浮组名滚到完整显示位置后再折起”,此刻滚动已到位,
|
||||
// 立即开始折起动画(用户 2026-10-16 要求的两段式过渡)。
|
||||
if (m_pendingGroupToggle >= 0)
|
||||
{
|
||||
const int groupIndex = m_pendingGroupToggle;
|
||||
m_pendingGroupToggle = -1;
|
||||
ToggleGroupCollapsedAnimated(groupIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -2990,7 +3317,7 @@ namespace jdi
|
||||
return false;
|
||||
}
|
||||
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
const ListRow item = m_rows[static_cast<std::size_t>(row)];
|
||||
|
||||
if (item.kind == RowKind::GROUP)
|
||||
{
|
||||
@@ -3009,8 +3336,9 @@ namespace jdi
|
||||
m_pendingCloseRow = row;
|
||||
return true;
|
||||
}
|
||||
m_tab->ToggleCollapsed(item.groupIndex);
|
||||
m_rowsDirty = true;
|
||||
// 折叠 / 展开分组走「带动画 + 悬浮时先滚动就位」的统一入口
|
||||
//(用户 2026-10-16 要求)。
|
||||
ToggleGroupFromRow(item.groupIndex, row);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3208,15 +3536,15 @@ namespace jdi
|
||||
return false;
|
||||
}
|
||||
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
const ListRow item = m_rows[static_cast<std::size_t>(row)];
|
||||
|
||||
// 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。
|
||||
if (item.kind == RowKind::GROUP)
|
||||
{
|
||||
if (part == RowPart::DISCLOSURE)
|
||||
{
|
||||
m_tab->ToggleCollapsed(item.groupIndex);
|
||||
m_rowsDirty = true;
|
||||
// 与单击分组行同一入口:悬浮时先滚动就位再播放折起动画。
|
||||
ToggleGroupFromRow(item.groupIndex, row);
|
||||
return true;
|
||||
}
|
||||
if (part != RowPart::BODY)
|
||||
|
||||
+141
-2
@@ -1848,6 +1848,8 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
|
||||
- `[A]` 无信号时连点 3 次"适应视图" ⇒ `ViewInitialized()` 仍为 false;再勾选单值信号 ⇒
|
||||
初次自适应发生(时间跨度 **10**、以该点居中、幅值跨度 **1** 居中、散点落在绘图区正中)。
|
||||
⚠️ 其中"幅值跨度 **1**"的期望**已被 §9.35 取代**:单点簇的幅值轴现在与常规路径/「Y 轴自适应」
|
||||
完全同源(只由 `PadValueRange` 提供留白,退化时跨度 = `2×max(1,|值|×0.1)`)。
|
||||
- `[B]` 时间边界:只有普通信号 ⇒ 视界**正好**等于数据范围;内部单值点 ⇒ 视界不变;
|
||||
左/右边界处的单值点 ⇒ 边界外扩,且**外扩量 = 半径 + 1px**(实测 0.013408s),散点完整可见。
|
||||
- `[C]` 幅值边界:单值点顶住某网格上边界 ⇒ 该网格上边界外扩、散点完整可见;
|
||||
@@ -1942,6 +1944,103 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
|
||||
---
|
||||
|
||||
### 9.35 分组折起/展开动画 + 筛选态可折起 + 悬浮组名可交互(含"先滚动再折起")+ 适应视图与 Y 轴自适应彻底同源(本轮,本机)
|
||||
|
||||
> 本轮四项(用户 2026-10-16,本机 = 另一台设备)。离屏验证 `.preflight/perf/panelanim_main.cpp`
|
||||
> **94/94 通过**(连跑 3 次稳定);**A/B** 三组回退分别如实失败(4 / 13 / 8 项);
|
||||
> 既有 `verify.exe` **49/49**(顺带修正了 4 条与远端新规则不符的过期断言)、
|
||||
> `datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。
|
||||
|
||||
#### 一、分组折起 / 展开改为动画过渡(`SignalPanel::GroupAnim`)
|
||||
|
||||
- **需求**(用户):折起时被折起的信号**向上收起**、下方分组随之上升;
|
||||
**被折起的组中最下方成员离得越远收起速度越快**,随后**慢慢减速**直到完成;
|
||||
总时长要在合理范围。
|
||||
- **实现**:新增 `m_groupAnims`(每分组一条,可并存)+ `GroupRowScale(group, ordinal, rowCount)`:
|
||||
* 每行速度倍率 = `1 + kGroupAnimDepthBoost(1.2) × ordinal/(行数−1)`,`ordinal` = 该行在分组内
|
||||
参与列出的序号(0 = 紧邻组名)⇒ **越靠下越快收尽**;
|
||||
* 每行进度再过 **ease-out cubic** ⇒ 起步快、末段显著减速;
|
||||
* 总时长 = `clamp(115 + 14×行数, 140, 300)` ms(3 行 ≈157ms;40 行 = 300ms)。
|
||||
- **接入点**:`BuildRows()` 把每个信号行的高度乘上比例(不再是非 0 即满高);低于
|
||||
`kGroupAnimMinRowHeight`(0.75 逻辑 px) 的行**不产生**;宿主 `WM_TIMER(kScrollTimerId)`
|
||||
同时调用 `AdvanceScrollAnimation()` 与 `AdvanceGroupAnimations()`,
|
||||
`Render` 的定时器判定改为 `ScrollAnimationActive() || GroupAnimationActive()`。
|
||||
- **行内容裁剪**:新增 `SetRowClip/ClearRowClip`,`PaintRowText`/`FillRowRect` 在动画期间
|
||||
裁到**本行矩形**(否则 `DT_VCENTER` 的文字会溢出到相邻行)。
|
||||
- **实测**(`panelanim.exe`):折起 3 行 = 12 帧 / 169ms,内容高度**单调下降**、末态 = 只剩组行;
|
||||
展开回到**逐像素级精确**的原始行高(无残留);中间帧可见"越靠下的行越矮"。
|
||||
- **已知取舍**:同一分组在动画中途被再次切换(快速连点折叠三角)会从"干净的一端"重新开始
|
||||
⇒ 会有一次视觉跳变(未做跨方向插值)。
|
||||
|
||||
#### 二、筛选态下分组也能折起 + 项数按筛选结果计数
|
||||
|
||||
- **需求**(用户):筛选状态下筛选结果要**能以组为单位折起**,且组名后方的项数显示为
|
||||
该组内**符合筛选条件**的信号个数;动画与上一条相同。
|
||||
- **实现**:
|
||||
* 折起状态分模式保存:普通模式 = `TabConfig::IsCollapsed`(原有),筛选模式 = 新增
|
||||
`m_filterCollapsed`(默认**展开**)。`IsGroupCollapsed()/SetGroupCollapsed()` 按当前模式分派。
|
||||
⇒ 筛选里折起不会污染普通列表的布局,清空筛选即恢复。
|
||||
* `BuildRows()` 不再"筛选时忽略折叠状态";`ListRow::displayCount` 记录当前模式下列出的
|
||||
信号数,`PaintFileRow` 的「N 项」只读它。
|
||||
* 筛选文本变化 / 清空筛选 / 数据变化 ⇒ 清空 `m_filterCollapsed` + `m_groupAnims` + 挂起请求。
|
||||
- **实测**:筛选 `sig_b*` ⇒ 只列匹配组、项数 = 2、无匹配的组不出现;筛选里折起 A 组后
|
||||
`TabConfig::IsCollapsed(A)` 保持 false;清空筛选后 A 组仍为展开且项数回到 3。
|
||||
|
||||
#### 三、悬浮组名可正常交互 + 点击时"先滚动就位再折起"
|
||||
|
||||
- **需求①②**(用户):组名悬浮在列表顶部时,它上面的所有元素都应能交互(此前点击会
|
||||
**穿透到下层行**);且悬浮状态下点击组名折起时,滚动条要**先向上滚动到组名真正完整显示
|
||||
的位置**,再播放折起动画(筛选状态下同样适用)。
|
||||
- **根因**:`HitRow` 只按"滚动偏移把指针映射到内容位置"找行,而悬浮组名是**画在列表顶部**的
|
||||
覆盖层(其自然位置已滚出列表)⇒ 点击它实际命中的是**它下面那一行**。
|
||||
- **修法**:
|
||||
* `HitRow` 先用 `StickyGroupRowIndex()` + `StickyGroupRowTop()` 判定是否落在悬浮行矩形内,
|
||||
命中就临时打开 `m_rowYOverrideActive` 并通过新的 `RowHitPart()` 算行内区域(折叠三角 /
|
||||
复选框 / ✕ / 名称全部正确);同时跳过**高度为 0** 的行。
|
||||
* 新增 `ToggleGroupFromRow(group, row)`:若 `m_scrollOffset > 该行内容偏移`(= 正悬浮)⇒
|
||||
`StartScrollAnimationTo(该行内容偏移, groupIndex)`(把挂起请求记在 `m_pendingGroupToggle`),
|
||||
滚动到位后由 `AdvanceScrollAnimation` 的结束分支启动折起动画;否则直接切换并播放动画。
|
||||
分组行的单击与"双击折叠三角"都走这个入口。
|
||||
- **实测**:悬浮组名的四个可交互区域全部命中该组行(A/B 回退后 13 项如实失败);
|
||||
点击组名后 `ScrollAnimationActive()==true` 且 `GroupAnimationActive()==false`(**先滚动**),
|
||||
滚动结束后 `m_scrollOffset == 该行的内容偏移`(组名恰好落在列表顶部)、随后才开始折起动画与
|
||||
折叠生效。
|
||||
|
||||
#### 四、「适应视图」与「Y 轴自适应」彻底同源(单数据点信号)
|
||||
|
||||
- **需求**(用户):以 `TestData/MF4-Record.MF4` 为例加载并只显示**一个单数据点信号**时,
|
||||
点「适应视图」与「Y 轴自适应」得到的**范围不一致**;且若原本开着 Y 轴自适应,
|
||||
点「适应视图」后 Y 视界会变、并且**「Y 轴自适应」被解除**。
|
||||
- **根因**(都在 `PlotView::FitSinglePointCluster`,即"全部可见数据点都是单值且同刻"的特例路径):
|
||||
* 它给每个网格另算了一套幅值区间("散点半径留白 + **跨度 1**"),而 `AutoRangeLaneToView`
|
||||
走 `PadValueRange`(退化时跨度 = `2×max(1,|v|×0.1)`)⇒ 两者对**同一视界**结果不同;
|
||||
* 它还显式写了 `lane.fitYToSelection = false` ⇒ 点「适应视图」就把 Y 轴自适应**关掉**了。
|
||||
- **修法**:时间轴部分仍是"以该时刻居中 + 一大格 1s",但**逐网格幅值轴改为与常规路径、
|
||||
与 Y 轴自适应完全同源**:`fitYToSelection` 的网格 → `AutoRangeLaneToView()`;否则 →
|
||||
`autoRange = true`;两者之后都接 `ApplySinglePointValueMargins()`。**不再改写
|
||||
`fitYToSelection`**。
|
||||
- **实测**(真实 MF4,`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点):
|
||||
适应视图的区间 == `PadValueRange(单点绘制值)`(逐位相同);打开 Y 轴自适应后区间**完全不变**;
|
||||
再点适应视图后区间不变、`fitYToSelection` 仍为 true、时间视界逐位不变;
|
||||
「适应视图 ↔ Y 轴自适应」反复往返**逐位稳定**。单点 + 连续信号同网格(常规路径)同样成立。
|
||||
- **行为变化(有意)**:单点信号的「适应视图」幅值跨度从旧的 **1** 变为
|
||||
`2×max(1,|值|×0.1)`(即与 `PadValueRange` 一致)。§9.33 用例 [A] 里"幅值跨度 1"的期望
|
||||
**已作废**;散点仍在网格正中且完整可见(`ApplySinglePointValueMargins` 兜底)。
|
||||
|
||||
#### 五、本机探针修正(`.preflight`,不入库)
|
||||
|
||||
- 新增 `.preflight/perf/panelanim_main.cpp`(`panelanim.exe`):用真实 `Application`
|
||||
(`SW_HIDE`)驱动 `SignalPanel` 做上述 [A]–[D],并用真实 `MF4-Record.MF4` 做 [E]。
|
||||
- ⚠️ **教训**:直接调 `Application::Render()` **不会**重绘面板(`m_chromeDirty` 已在上一次
|
||||
`Render` 里被清掉)⇒ 探针里必须 `app.m_chromeDirty = true` 再 Render(本轮的"动画模型"辅助函数
|
||||
`RenderFrame()`),否则行高不会随动画刷新,"看起来动画没生效"。
|
||||
- 顺带修正本机 `verify_main.cpp` 里 4 条与远端新规则不符的过期断言:
|
||||
两处幅值留白改为调用 **`PadValueRange`**(唯一来源);派生信号 `offsetTime` 改为断言 **0**
|
||||
(§9.21 已烘入数据);拖拽换位改为断言**被拖网格自身边界**判据的新目标值。
|
||||
⇒ `verify.exe` 现为 **49/49 全绿**。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志(本文件维护记录)
|
||||
|
||||
| 日期 | 设备/模型 | 摘要 |
|
||||
@@ -1989,6 +2088,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(详见 §9.34)**。① **浮动面板上双击穿透到网格**:`PlotView::OnDoubleClick` 在所有下层命中都不成立时无条件 `FitAll()` ⇒ 在面板上双击会让网格莫名其妙自适应一次;修法:**最前面**用 `TopPanelAtPoint()` 判定并消费。② **网格变色高亮的触发来源重新定义**(旧行为"鼠标在网格里移动就高亮"**作废**):改为**信号列表 / 样式区「目标」行悬停到某条已绘制信号** ⇒ 该信号所在网格变色高亮**且该信号曲线本身也高亮**(新增 `m_externalHoverSignal`,与 `m_hoverSignal` 分开保存,由 `Render()` 每帧同步;新增 `EffectiveHoverLane()`)。③ **样式区「目标」行**(样式自定义区最上方):显示当前编辑的目标信号名称 + (已绘制时)名称**后方**的 GX 标签,悬停高亮并联动网格/曲线高亮,**单击不编辑、双击改名**。④ **联动平滑滚动**:绘图区选中信号(点图例 / 点曲线 / 双击图例)与样式区任何交互都触发 `ScrollToSignalAnimated()`(ease-out cubic、时长 `clamp(距离×0.35ms,130,300)`、**上行让出组行高度避免被悬浮组名遮挡**、滚轮/拖滚动条即取消)。验证:离屏 `.preflight/check` **73/73 通过**(连跑 3 次稳定;含浮动面板双击不移视界 vs 空白处双击必自适应的对照组、未勾选信号悬停不高亮、目标行 GX 标签位置与单击/双击区分、长列表 122 行平滑滚动与遮挡检查);**A/B** 回退六处实现 ⇒ **15 项如实失败**;`make release` 零警告、链接通过。 |
|
||||
| 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` 零警告。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -2281,8 +2381,47 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
常共用同一通道组(同一条轴),用轴判断会误判。
|
||||
70. ⚠️ **特例:全部可见数据点都是单值信号且时刻完全相同**(`FitSinglePointCluster`):
|
||||
时间轴**以该时刻居中**、缩放到 **一大格 = 1s**(视界 = `kTimeTickTarget × 1s` = 10s,
|
||||
此时 `NiceStep(10,10) == 1.0`);每个网格的幅值区间以“能完整画出该网格内的全部数据点”
|
||||
为准(退化成单值时以**跨度 1 居中**)。旧规则“跨度 1s 居中”“各留一小格”**已作废**。
|
||||
此时 `NiceStep(10,10) == 1.0`);**幅值轴与其余场景完全同源**(见第 76 条):
|
||||
`fitYToSelection` 的网格走 `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`),
|
||||
两者都只由 `PadValueRange` 提供留白 —— 退化(单值)时的区间就是
|
||||
`[v − max(1,|v|×0.1), v + max(1,|v|×0.1)]`(跨度 = 2×留白)。
|
||||
⚠️ 旧版曾在这里用"半径留白 + **跨度 1**"另算一套,已作废(会导致
|
||||
「适应视图」与「Y 轴自适应」对同一视界算出**不同区间**)。
|
||||
旧规则"跨度 1s 居中""各留一小格"同样已作废。
|
||||
76. ⚠️ **`FitAll()` 的两条路径(常规 / 单点簇)必须共用同一段逐网格幅值逻辑**:
|
||||
`fitYToSelection` 的网格 → `AutoRangeLaneToView()`;否则 → `autoRange = true`;
|
||||
两者之后都接 `ApplySinglePointValueMargins()`。**不得**任何一条路径里另写一套区间
|
||||
(否则「适应视图」与「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` 垂直居中,
|
||||
不裁剪就会把文字溢出到相邻行上。
|
||||
79. ⚠️ **筛选模式的分组折起状态与普通模式**分开**保存**(`m_filterCollapsed`,默认展开):
|
||||
筛选只是临时子集视图,不该污染普通列表的布局选择;筛选文本变化 / 清空筛选 / 数据变化时
|
||||
一律清空它以及进行中的折起动画(行数与分组可见性都变了)。
|
||||
组名后方的项数必须按当前模式显示:普通 = 信号总数,筛选 = 该组内**符合筛选条件**的个数
|
||||
(`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintFileRow` 只读它)。
|
||||
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`)。
|
||||
71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**:
|
||||
否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号,
|
||||
`Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,
|
||||
|
||||
Reference in new issue
Block a user