「所属网格」菜单:标题固定 + 10 行上限 + 滚动条;网格滚动联动改为动态过渡

1) 菜单滚动时最上方的「所属网格」标题不再跟着滚走
   - 根因:上一轮只实现了“尾部固定区”(最后一个分隔线 + 「新建网格」钉在底部),
     而滚动区是从条目 0 开始的,于是标题也被一起滚掉。
   - 修法:BuildLayout 新增 m_scrollFirst(= 前导 HEADER / HEADER_ACTION 的个数),
     滚动区收敛为半开区间 [m_scrollFirst, m_pinnedIndex):
     · 前导标题从菜单顶向下固定排(不参与滚动);
     · 滚动区条目受 m_scrollOffset 影响;
     · 尾部固定区从菜单底向上对齐。
   - 配套:Paint 的裁剪与“跳过不可见条目”范围、ItemAtPoint 的“必须落在滚动视口内”判定、
     EnsureItemVisible 的下界全部改用该区间;新增 ScrollRegion(first, last) 供查询/测试。

2) 菜单长度上限改为「最多 10 条网格」并加滚动条
   - MenuLayoutConstraint 新增 maxScrollRows(滚动区最多显示的标准行数;与 maxHeight
     同时给出时取更严格者);OpenLaneMenu 传 10(kLaneMenuVisibleRows),
     另保留 maxHeight = max(180, 客户区高 × 0.8) 作为矮窗口的硬上限。
   - 新增滚动条:轨道贴滚动条目区右内侧(不盖住标题与底部固定项),滑块高度按视口/内容比
     (下限 22 逻辑像素);滚动区条目宽度让出 kScrollBarWidth + kScrollBarGap,
     文本与对勾不会压在滑块上。
   - 滚动条可拖拽:按下滑块 → 拖动(只改偏移、不更新悬停);点击轨道空白 → 滑块中心移过去;
     松开结束拖拽且不下发命令、菜单保持打开。新增 ScrollOffsetForThumbTop 反算偏移。
   - ⚠️ BuildLayout 绝不能复位 m_scrollBarDragging:拖拽时每帧都会 RebuildLayout(),
     在那里清零会让拖拽“拖一下就脱手”(该状态只在 Open / Close 里复位)。

3) 网格滚动联动改为动态过渡(旧实现直接把视界瞬移过去,过于僵硬)
   - PlotView::ScrollLaneIntoView 只负责启动平滑动画:与信号列表的“滚动到目标信号”同源
     (ease-out cubic;时长 clamp(距离×0.35ms, 130, 300)),新增
     m_laneScrollAnimActive/From/To/StartMs/DurationMs。
   - 宿主 16ms 定时器(kScrollTimerId)调用 AdvanceLaneScrollAnimation() 逐帧推进,
     与信号面板动画共用同一条定时器;Render 的“需要保持定时重绘”判定加入
     LaneScrollAnimationActive()。
   - 每帧 ClampLaneScroll() + BuildLaneVisuals()(网格滚动量参与场景/图层指纹与各网格 rect);
     结束时精确落到目标。已完整可见的网格仍然不启动动画。
   - ⚠️ 用户自己滚动网格(滚轮 / 拖滚动条)会 CancelLaneScrollAnimation(),避免与动画抢滚动位置。

验证(全部离屏执行:窗口 SW_HIDE + 只注入合成消息 + 进程与线程 BELOW_NORMAL,
不移动真实光标、不抢焦点、不截屏):
- .preflight/check 扩到 95 项,95/95 通过;新增断言:
  · 菜单顶部标题在滚动时不动(Y 坐标不变)、滚动视口高度恰为 10 × 行高;
  · 滚动条轨道/滑块存在、随滚动移动、按住滑块拖到底(菜单不关、不下发命令、末条目入视口)、
    滚轮回到顶部;
  · ScrollLaneIntoView 不瞬移(刚触发时几乎未动)、中间帧单调递进、收尾精确到位、
    已可见时不启动动画、滚轮/手动滚动会取消动画。
- A/B:回退三处(标题固定 / 10 行上限 / 滚动动画)⇒ 10 项如实失败;恢复后 95/95。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 新增 §9.39、§11.2 第 104–107 条与变更日志一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-16 14:12:42 +08:00
1 parent 2904699eae
commit 82b5ebe4f9
10 files changed
+496 -78

No files matched your search

+27 -2
View File
@@ -924,7 +924,7 @@ namespace jdi
bool TakePanelScrollRequest(SignalRef& ref);
/**
* @brief 把指定网格纵向滚动到**完整可见**(网格不在可视区时自动调节滚动条)。
* @brief 把指定网格**平滑滚动**到完整可见(带动画,网格不在可视区时自动调节滚动条)。
*
* 用户 2026-10-16 要求:信号很多时(例如把 MF4 的上百条信号各自放进独立网格)
* 每个网格只占绘图区的一小段,绝大多数网格都在可视区之外 —— 在信号列表里点选一条
@@ -933,13 +933,31 @@ namespace jdi
* * 目标网格在可视区**下方** ⇒ 向下滚动直到它的下边界进入绘图区下沿;
* * 已经完整可见 ⇒ 不动(避免无意义地改变视图)。
*
* ⚠️ 过渡必须是**带动画的平滑滚动**(与信号列表的“滚动到目标信号”同源:ease-out cubic、
* 时长 `clamp(距离×0.35ms, 130, 300)`),不是直接把视界瞬移过去 —— 用户 2026-10-16 明确要求。
* 动画由宿主 16ms 定时器驱动(`AdvanceLaneScrollAnimation`)。
*
* ⚠️ 只在**点击**信号时调用(悬停只做高亮联动,绝不改变视图)。
*
* @param [IN] laneIndex 网格下标。
* @return 视图(网格滚动量)发生变化返回 true。
* @return 开始了滚动(动画或已到位)返回 true;已完整可见则返回 false。
*/
bool ScrollLaneIntoView(int laneIndex);
/**
* @brief 推进一步网格滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceLaneScrollAnimation();
/** @brief 网格滚动动画是否正在进行。 */
bool LaneScrollAnimationActive() const { return m_laneScrollAnimActive; }
/**
* @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。
*/
void CancelLaneScrollAnimation() { m_laneScrollAnimActive = false; }
/**
* @brief 处理编辑态下的字符输入。
* @param [IN] character 输入字符。
@@ -2023,6 +2041,13 @@ namespace jdi
float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */
/** 网格滚动(“点选信号 ⇒ 把目标网格滚入可视区”)的平滑过渡状态。 */
bool m_laneScrollAnimActive = false; /**< 动画是否正在进行。 */
float m_laneScrollAnimFrom = 0.0f; /**< 动画起始滚动偏移。 */
float m_laneScrollAnimTo = 0.0f; /**< 动画目标滚动偏移。 */
std::uint64_t m_laneScrollAnimStartMs = 0; /**< 动画起始时刻(`GetTickCount64()`)。 */
std::uint32_t m_laneScrollAnimDurationMs = 0; /**< 动画总时长(毫秒)。 */
PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */
PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */
int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */
+61 -10
View File
@@ -194,30 +194,81 @@ namespace jdi
const float contentTop = rect.Y - m_plotRect.Y + Tab().LaneScroll();
const float laneHeight = rect.Height;
const float previous = Tab().LaneScroll();
float scroll = previous;
if (contentTop - scroll < 0.0f)
float desired = Tab().LaneScroll();
if (contentTop - desired < 0.0f)
{
// 目标网格在可视区上方:把它的上边界对齐到绘图区上沿。
scroll = contentTop;
desired = contentTop;
}
else if (contentTop + laneHeight - scroll > m_plotRect.Height)
else if (contentTop + laneHeight - desired > m_plotRect.Height)
{
// 目标网格在可视区下方:把它的下边界对齐到绘图区下沿。
scroll = contentTop + laneHeight - m_plotRect.Height;
desired = contentTop + laneHeight - m_plotRect.Height;
}
else
{
return false; // 已经完整可见
}
Tab().LaneScroll() = scroll;
ClampLaneScroll();
if (std::fabs(Tab().LaneScroll() - previous) <= 0.01f)
// **平滑滚动**(用户 2026-10-16 要求):与信号列表的“滚动到目标信号”同源,
// 先用 ease-out cubic 过渡过去,而不是把视界瞬移过去。
const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height);
desired = static_cast<float>(ClampDouble(desired, 0.0, maxOffset));
const float distance = std::fabs(desired - Tab().LaneScroll());
if (distance < 1.0f)
{
// 差值极小:直接到位(不再为 1px 抖动做动画)。
Tab().LaneScroll() = desired;
ClampLaneScroll();
m_laneScrollAnimActive = false;
BuildLaneVisuals();
return true;
}
const float durationMs = std::max(
kLaneScrollAnimMinMs,
std::min(kLaneScrollAnimMaxMs, distance * kLaneScrollAnimMsPerPixel));
m_laneScrollAnimFrom = Tab().LaneScroll();
m_laneScrollAnimTo = desired;
m_laneScrollAnimStartMs = GetTickCount64();
m_laneScrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_laneScrollAnimActive = true;
return true;
}
bool PlotView::AdvanceLaneScrollAnimation()
{
if (!m_laneScrollAnimActive)
{
return false;
}
// 网格滚动量参与场景/图层指纹,因此重建可视几何(各网格 rect 与 yMin/yMax 依赖滚动量)。
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed =
(now >= m_laneScrollAnimStartMs) ? (now - m_laneScrollAnimStartMs) : 0;
float progress = (m_laneScrollAnimDurationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(m_laneScrollAnimDurationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、靠近目标时减速(与信号列表完全一致的手感)。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
Tab().LaneScroll() = m_laneScrollAnimFrom + (m_laneScrollAnimTo - m_laneScrollAnimFrom) * eased;
if (progress >= 1.0f)
{
// 结束:精确落到终点并夹取(内容高度可能在动画期间变化过)。
Tab().LaneScroll() = m_laneScrollAnimTo;
m_laneScrollAnimActive = false;
ClampLaneScroll();
BuildLaneVisuals();
return false;
}
// 网格滚动量参与场景/图层指纹与各网格 `rect`,每帧重建可视几何。
ClampLaneScroll();
BuildLaneVisuals();
return true;
}
@@ -1085,6 +1085,8 @@ namespace jdi
}
const double travel =
static_cast<double>(y - m_dragLastY) / static_cast<double>(m_plotRect.Height);
// 用户自己拖滚动条 ⇒ 立即取消“点点信号后自动滚入可视区”的动画(否则两者会抢滚动位置)。
CancelLaneScrollAnimation();
Tab().LaneScroll() += static_cast<float>(travel * m_laneContentHeight);
m_dragLastY = y;
m_dragMoved = true;
@@ -2094,6 +2096,8 @@ namespace jdi
return false;
}
// 用户自己滚轮滚动 ⇒ 立即取消自动滚入可视区的动画。
CancelLaneScrollAnimation();
Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale);
BuildLaneVisuals();
return true;
@@ -64,6 +64,15 @@ namespace jdi
* 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。
*/
inline const int kLayerRollQuantum = 16;
/**
* @brief 「点选信号 ⇒ 把目标网格滚入可视区」的平滑过渡参数(与信号列表同源)。
*
* 时长 = `clamp(距离 × kLaneScrollAnimMsPerPixel, kLaneScrollAnimMinMs, kLaneScrollAnimMaxMs)`,
* 进度再过 ease-out cubic(起步快、靠近目标时减速)⇒ 用户要求的“动态过渡而不是瞬移”。
*/
inline const float kLaneScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
inline const float kLaneScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
inline const float kLaneScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
inline const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */