「所属网格」菜单:标题固定 + 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

+242 -51
View File
@@ -31,6 +31,9 @@ namespace jdi
const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
const float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */
const float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */
const float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
@@ -172,12 +175,15 @@ namespace jdi
const float width = contentWidth;
// ------------------------------------------------------------------
// 超长菜单:限制高度 + 条目区可滚动 + 尾部固定项(见 `MenuLayoutConstraint`)。
// 固定区 = **最后一个分隔线及其之后的条目**(典型:「新建网格」),
// 它始终钉在菜单底部,因此滚动不会把“新建网格”飘走。
// 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。
// * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)——
// 用户 2026-10-16 要求:滚动时标题必须留在最上方不动;
// * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部;
// * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。
// ------------------------------------------------------------------
int pinnedFrom = -1;
if (m_constraint.maxHeight > 0.0f)
int scrollFirst = 0;
if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0)
{
for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
{
@@ -187,45 +193,77 @@ namespace jdi
break;
}
}
while (scrollFirst < pinnedFrom &&
(m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER ||
m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER_ACTION))
{
++scrollFirst; // 前导标题固定在顶部,不参与滚动
}
}
float headHeight = 0.0f;
float scrollRegionHeight = 0.0f;
float tailHeight = 0.0f;
if (pinnedFrom > 0)
{
for (int index = 0; index < pinnedFrom; ++index)
for (int index = 0; index < static_cast<int>(m_items.size()); ++index)
{
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
}
for (int index = pinnedFrom; index < static_cast<int>(m_items.size()); ++index)
{
tailHeight += heights[static_cast<std::size_t>(index)];
if (index >= pinnedFrom)
{
tailHeight += heights[static_cast<std::size_t>(index)];
}
else if (index < scrollFirst)
{
headHeight += heights[static_cast<std::size_t>(index)];
}
else
{
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
}
}
}
float height = totalHeight;
m_scrollEnabled = false;
m_scrollRegionHeight = 0.0f;
m_scrollFirst = 0;
m_pinnedIndex = -1;
m_scrollViewport = gd::RectF();
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发)
m_scrollBarRect = gd::RectF();
// ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`,
// 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条)
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
// 滚动时才复位(`Open` 里已经为每次打开重置过)。
if (m_constraint.maxHeight > 0.0f)
if (pinnedFrom > 0)
{
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”,
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。
float limit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale);
if (limit < 40.0f * m_scale)
// 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与
// `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。
float viewportLimit = 0.0f;
if (m_constraint.maxHeight > 0.0f)
{
limit = 0.0f;
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”,
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。
const float menuLimit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale);
if (menuLimit >= 40.0f * m_scale)
{
viewportLimit = std::max(
0.0f, menuLimit - kPadV * 2.0f * m_scale - headHeight - tailHeight);
}
}
const float viewportHeight = (limit > 0.0f) ? (limit - kPadV * 2.0f * m_scale - tailHeight) : 0.0f;
if (pinnedFrom > 0 && limit > 0.0f && totalHeight > limit &&
viewportHeight >= kRowHeight * m_scale)
if (m_constraint.maxScrollRows > 0)
{
height = limit;
const float rows =
static_cast<float>(m_constraint.maxScrollRows) * kRowHeight * m_scale;
viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows;
}
if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f)
{
const float viewportHeight = std::min(scrollRegionHeight, viewportLimit);
height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight;
m_scrollEnabled = true;
m_scrollFirst = scrollFirst;
m_pinnedIndex = pinnedFrom;
m_scrollRegionHeight = scrollRegionHeight;
m_scrollOffset = std::max(
@@ -286,39 +324,52 @@ namespace jdi
m_rect = gd::RectF(left, top, width, height);
// 条目排布:滚动区的条目整体上移 `m_scrollOffset`;固定区从菜单底部往上对齐。
// 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`;
// 尾部固定区从菜单底部往上对齐。
const float bodyTop = top + kPadV * m_scale;
const float pinnedTop = top + height - kPadV * m_scale - tailHeight;
const float tailTop = top + height - kPadV * m_scale - tailHeight;
const float scrollBarReserve = m_scrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
m_scrollViewport =
m_scrollEnabled
? gd::RectF(left, bodyTop, width,
std::max(0.0f, height - kPadV * 2.0f * m_scale - tailHeight))
? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight,
std::max(0.0f, width - kPadH * 2.0f * m_scale),
std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight))
: gd::RectF();
float cursorY = bodyTop;
float scrollCursorY = bodyTop - m_scrollOffset;
float pinnedCursorY = (pinnedFrom > 0) ? pinnedTop : bodyTop;
float headCursorY = bodyTop;
float scrollCursorY = bodyTop + headHeight - m_scrollOffset;
float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const float itemHeight = heights[index];
float rectY = cursorY;
float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale);
if (m_scrollEnabled)
{
if (static_cast<int>(index) < m_pinnedIndex)
const int itemIndex = static_cast<int>(index);
if (itemIndex < m_scrollFirst)
{
rectY = headCursorY;
headCursorY += itemHeight;
}
else if (itemIndex < m_pinnedIndex)
{
rectY = scrollCursorY;
scrollCursorY += itemHeight;
// 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。
rectWidth = std::max(0.0f, rectWidth - scrollBarReserve);
}
else
{
rectY = pinnedCursorY;
pinnedCursorY += itemHeight;
rectY = tailCursorY;
tailCursorY += itemHeight;
}
}
m_itemRects[index] = gd::RectF(left + kPadH * m_scale, rectY,
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
m_itemRects[index] =
gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight);
if (item.kind == MenuItemKind::SWATCHES)
{
@@ -337,11 +388,78 @@ namespace jdi
cursorY += itemHeight;
}
// 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。
if (m_scrollEnabled)
{
const float thickness = kScrollBarWidth * m_scale;
m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness,
m_scrollViewport.Y, thickness, m_scrollViewport.Height);
}
}
bool PopupMenu::ScrollRegion(int& first, int& last) const
{
if (!m_scrollEnabled || m_pinnedIndex < 0)
{
first = 0;
last = static_cast<int>(m_items.size());
return false;
}
first = m_scrollFirst;
last = m_pinnedIndex;
return true;
}
gd::RectF PopupMenu::ScrollBarThumbRect() const
{
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f)
{
return gd::RectF();
}
const float viewport = m_scrollViewport.Height;
if (viewport <= 0.0f)
{
return gd::RectF();
}
const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight);
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio);
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f;
const float thumbY =
m_scrollBarRect.Y +
std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight);
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
}
float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const
{
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f)
{
return m_scrollOffset;
}
const float viewport = m_scrollViewport.Height;
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
if (maxScroll <= 0.0f)
{
return 0.0f;
}
const float track = m_scrollBarRect.Height;
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight)));
const float travel = std::max(0.0f, track - thumbHeight);
if (travel <= 0.0f)
{
return 0.0f;
}
const float relative = thumbTop - m_scrollBarRect.Y;
const float progress = std::max(0.0f, std::min(1.0f, relative / travel));
return progress * maxScroll;
}
bool PopupMenu::EnsureItemVisible(int index)
{
if (!m_scrollEnabled || index < 0 || index >= m_pinnedIndex || m_scrollViewport.Height <= 0.0f)
if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex ||
m_scrollViewport.Height <= 0.0f)
{
return false;
}
@@ -795,6 +913,7 @@ namespace jdi
// 每次打开都从顶部开始(滚动位置不跨次沿用)。
m_constraint = constraint;
m_scrollOffset = 0.0f;
m_scrollBarDragging = false;
CloseSubmenu();
CloseEditState();
@@ -814,11 +933,15 @@ namespace jdi
m_pressedIndex = -1;
m_pressedSwatch = -1;
m_constraint = MenuLayoutConstraint();
m_scrollFirst = 0;
m_pinnedIndex = -1;
m_scrollEnabled = false;
m_scrollOffset = 0.0f;
m_scrollRegionHeight = 0.0f;
m_scrollViewport = gd::RectF();
m_scrollBarRect = gd::RectF();
m_scrollBarDragging = false;
m_scrollBarGrabOffset = 0.0f;
CloseSubmenu();
CloseEditState();
}
@@ -872,8 +995,9 @@ namespace jdi
}
// 可滚动菜单:滚动区内的条目还必须落在**滚动视口**内 ——
// 夹在视口边界上、只有一部分可见的条目,只有落在视口内的那一部分可以点击。
if (m_scrollEnabled && static_cast<int>(index) < m_pinnedIndex &&
!UiPointInRect(m_scrollViewport, fx, fy))
// ⚠️ 前导标题与末尾固定项不在滚动区内,它们始终完整可见(不参与本判定)。
if (m_scrollEnabled && static_cast<int>(index) >= m_scrollFirst &&
static_cast<int>(index) < m_pinnedIndex && !UiPointInRect(m_scrollViewport, fx, fy))
{
continue;
}
@@ -978,6 +1102,15 @@ namespace jdi
const float fy = static_cast<float>(y);
bool changed = false;
// 正在拖拽滚动条滑块:忽略悬停,只跟随鼠标滚动条目区。
if (m_scrollBarDragging)
{
const float previous = m_scrollOffset;
m_scrollOffset = ScrollOffsetForThumbTop(fy - m_scrollBarGrabOffset);
RebuildLayout();
return std::fabs(m_scrollOffset - previous) > 0.01f;
}
// 鼠标在展开的子菜单内:只更新子菜单的悬停项(主菜单父项保持高亮)。
if (m_subOpen && UiPointInRect(m_subRect, fx, fy))
{
@@ -1098,6 +1231,34 @@ namespace jdi
EndItemEdit(true, result);
}
// 可滚动菜单的滚动条:按下即开始拖拽(不是条目点击,菜单保持打开)。
if (m_scrollEnabled)
{
const gd::RectF thumb = ScrollBarThumbRect();
if (thumb.Width > 0.0f && UiPointInRect(thumb, static_cast<float>(x), static_cast<float>(y)))
{
const float previous = m_scrollOffset;
m_scrollBarDragging = true;
m_scrollBarGrabOffset = static_cast<float>(y) - thumb.Y;
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
(void)previous;
return true;
}
if (UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
// 点在轨道空白处:把滑块中心移到该处(翻页)。
m_scrollBarDragging = true;
m_scrollBarGrabOffset = thumb.Height * 0.5f;
m_scrollOffset = ScrollOffsetForThumbTop(static_cast<float>(y) - m_scrollBarGrabOffset);
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
return true;
}
}
int swatchIndex = -1;
m_pressedIndex = ItemAtPoint(x, y, swatchIndex);
m_pressedSwatch = swatchIndex;
@@ -1113,6 +1274,15 @@ namespace jdi
return false;
}
// 结束滚动条拖拽(不当作条目点击,菜单保持打开)。
if (m_scrollBarDragging)
{
m_scrollBarDragging = false;
m_pressedIndex = -1;
m_pressedSwatch = -1;
return true;
}
// 子菜单内的抬起:按下与抬起命中同一项才提交(菜单随之关闭)。
if (m_subOpen && m_subPressedIndex >= 0)
{
@@ -1378,35 +1548,42 @@ namespace jdi
}
};
// 可滚动菜单:条目 [0, pinnedFrom) 在滚动区(裁剪到滚动视口),
// 其余(最后一个分隔线及其之后的条目)钉在菜单底部、不参与滚动。
const int pinnedFrom = (m_scrollEnabled && m_pinnedIndex >= 0)
? m_pinnedIndex
: static_cast<int>(m_items.size());
const int clipState = m_scrollEnabled ? g.Save() : 0;
if (m_scrollEnabled)
{
g.SetClip(m_scrollViewport, gd::CombineModeIntersect);
}
// 可滚动菜单的条目分三段:
// * `[0, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**;
// * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条);
// * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。
const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0;
const int scrollFirst = scrollable ? m_scrollFirst : static_cast<int>(m_items.size());
const int scrollLast = scrollable ? m_pinnedIndex : static_cast<int>(m_items.size());
int clipState = 0;
bool clipActive = false;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const gd::RectF& rect = m_itemRects[index];
// 进入固定区:撤掉滚动区的裁剪(固定区永远完整可见)。
if (m_scrollEnabled && static_cast<int>(index) == pinnedFrom)
const int itemIndex = static_cast<int>(index);
// 进入/离开滚动区:按需开启/撤销 GDI+ 裁剪(固定区永远完整可见)。
if (scrollable && itemIndex == scrollFirst && !clipActive)
{
clipState = g.Save();
g.SetClip(m_scrollViewport, gd::CombineModeIntersect);
clipActive = true;
}
else if (scrollable && itemIndex == scrollLast && clipActive)
{
g.Restore(clipState);
clipActive = false;
}
const bool inScrollRegion = static_cast<int>(index) < pinnedFrom;
const bool inScrollRegion = itemIndex >= scrollFirst && itemIndex < scrollLast;
// 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。
if (m_scrollEnabled && inScrollRegion &&
if (scrollable && inScrollRegion &&
(rect.Y + rect.Height <= m_scrollViewport.Y ||
rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height))
{
continue;
}
const bool clipped = m_scrollEnabled && inScrollRegion;
const bool clipped = scrollable && inScrollRegion;
if (item.kind == MenuItemKind::SEPARATOR)
{
@@ -1590,9 +1767,23 @@ namespace jdi
}
// 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。
if (m_scrollEnabled)
if (clipActive)
{
g.Restore(clipState);
clipActive = false;
}
// 滚动条:轨道 + 滑块(画在条目之上,仅在可滚动菜单里出现)。
// 位置限制在可滚动条目区旁边,因此不会盖住顶部标题与底部「新建网格」。
if (scrollable && m_scrollBarRect.Width > 0.0f)
{
const float radius = kScrollBarWidth * m_scale * 0.5f;
UiFillRoundRect(g, m_scrollBarRect, radius, m_theme->gridWeak);
const gd::RectF thumb = ScrollBarThumbRect();
if (thumb.Width > 0.0f)
{
UiFillRoundRect(g, thumb, radius, m_theme->borderStrong);
}
}
// 二级子菜单最后绘制,保证覆盖在主菜单之上。