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

+7 -4
View File
@@ -472,8 +472,10 @@ namespace jdi
m_hintTimerActive = false; m_hintTimerActive = false;
} }
// 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。 // 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区):
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()) // 仅在动画进行中保持定时重绘。
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() ||
m_plot.LaneScrollAnimationActive())
{ {
if (!m_scrollTimerActive) if (!m_scrollTimerActive)
{ {
@@ -1401,12 +1403,13 @@ namespace jdi
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, FALSE);
return 0; return 0;
} }
// 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘 // 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」):
//(Render 会在动画结束后停表)。 // 推进一步并重绘(Render 会在动画结束后停表)。
if (wParam == kScrollTimerId) if (wParam == kScrollTimerId)
{ {
m_panel.AdvanceScrollAnimation(); m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations(); m_panel.AdvanceGroupAnimations();
m_plot.AdvanceLaneScrollAnimation();
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, FALSE);
return 0; return 0;
} }
+27 -2
View File
@@ -924,7 +924,7 @@ namespace jdi
bool TakePanelScrollRequest(SignalRef& ref); bool TakePanelScrollRequest(SignalRef& ref);
/** /**
* @brief 把指定网格纵向滚动到**完整可见**(网格不在可视区时自动调节滚动条)。 * @brief 把指定网格**平滑滚动**到完整可见(带动画,网格不在可视区时自动调节滚动条)。
* *
* 用户 2026-10-16 要求:信号很多时(例如把 MF4 的上百条信号各自放进独立网格) * 用户 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 网格下标。 * @param [IN] laneIndex 网格下标。
* @return 视图(网格滚动量)发生变化返回 true。 * @return 开始了滚动(动画或已到位)返回 true;已完整可见则返回 false。
*/ */
bool ScrollLaneIntoView(int laneIndex); bool ScrollLaneIntoView(int laneIndex);
/**
* @brief 推进一步网格滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceLaneScrollAnimation();
/** @brief 网格滚动动画是否正在进行。 */
bool LaneScrollAnimationActive() const { return m_laneScrollAnimActive; }
/**
* @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。
*/
void CancelLaneScrollAnimation() { m_laneScrollAnimActive = false; }
/** /**
* @brief 处理编辑态下的字符输入。 * @brief 处理编辑态下的字符输入。
* @param [IN] character 输入字符。 * @param [IN] character 输入字符。
@@ -2023,6 +2041,13 @@ namespace jdi
float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */ float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ 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; /**< 当前拖拽模式。 */ PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */
PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */ PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */
int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */ 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 contentTop = rect.Y - m_plotRect.Y + Tab().LaneScroll();
const float laneHeight = rect.Height; const float laneHeight = rect.Height;
const float previous = Tab().LaneScroll(); float desired = Tab().LaneScroll();
float scroll = previous; if (contentTop - desired < 0.0f)
if (contentTop - scroll < 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 else
{ {
return false; // 已经完整可见 return false; // 已经完整可见
} }
Tab().LaneScroll() = scroll; // **平滑滚动**(用户 2026-10-16 要求):与信号列表的“滚动到目标信号”同源,
ClampLaneScroll(); // 先用 ease-out cubic 过渡过去,而不是把视界瞬移过去。
if (std::fabs(Tab().LaneScroll() - previous) <= 0.01f) 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; 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(); BuildLaneVisuals();
return true; return true;
} }
@@ -1085,6 +1085,8 @@ namespace jdi
} }
const double travel = const double travel =
static_cast<double>(y - m_dragLastY) / static_cast<double>(m_plotRect.Height); static_cast<double>(y - m_dragLastY) / static_cast<double>(m_plotRect.Height);
// 用户自己拖滚动条 ⇒ 立即取消“点点信号后自动滚入可视区”的动画(否则两者会抢滚动位置)。
CancelLaneScrollAnimation();
Tab().LaneScroll() += static_cast<float>(travel * m_laneContentHeight); Tab().LaneScroll() += static_cast<float>(travel * m_laneContentHeight);
m_dragLastY = y; m_dragLastY = y;
m_dragMoved = true; m_dragMoved = true;
@@ -2094,6 +2096,8 @@ namespace jdi
return false; return false;
} }
// 用户自己滚轮滚动 ⇒ 立即取消自动滚入可视区的动画。
CancelLaneScrollAnimation();
Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale); Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale);
BuildLaneVisuals(); BuildLaneVisuals();
return true; return true;
@@ -64,6 +64,15 @@ namespace jdi
* 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。 * 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。
*/ */
inline const int kLayerRollQuantum = 16; 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 kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */ inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */ inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
@@ -1909,10 +1909,13 @@ namespace jdi
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
// ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格):
// 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。
// 这里限制高度(最多约占客户区高度的 62%):超出的部分可滚动翻页, // 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求):
// 而最后一个分隔线与它下面的「新建网格」**始终钉在菜单底部**(用户 2026-10-16 要求)。 // 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的
// 「新建网格」**始终固定不动**。
MenuLayoutConstraint constraint; MenuLayoutConstraint constraint;
constraint.maxHeight = std::max(180.0f, bounds.Height * 0.62f); constraint.maxScrollRows = kLaneMenuVisibleRows;
// 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。
constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f);
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint);
} }
@@ -57,6 +57,14 @@ namespace jdi
inline const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */ inline const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */
inline const float kRowTailGap = 6.0f; /**< 网格徽标与行尾 ✕ 之间的间距。 */ inline const float kRowTailGap = 6.0f; /**< 网格徽标与行尾 ✕ 之间的间距。 */
/**
* @brief 「所属网格」菜单里可滚动的网格条目**最多显示的行数**(用户 2026-10-16 要求)。
*
* 信号很多时(例如把 MF4 的上百条信号各自放进独立网格)该菜单会有上百个条目;
* 条目太多既超出屏幕、又让人难以选择,因此只显示 10 行,其余用滚动条 / 滚轮翻动。
*/
inline const int kLaneMenuVisibleRows = 10;
/** /**
* @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。 * @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。
* @param [IN] text 待匹配文本。 * @param [IN] text 待匹配文本。
+77
View File
@@ -2390,6 +2390,63 @@ Components/Shared/Src/UiControls.cpp
--- ---
### 9.39 「所属网格」菜单:标题固定 + 10 行上限 + 滚动条,以及网格滚动联动改为动态过渡(本轮,起源设备)
> 用户 2026-10-16 第五轮(起源设备)。
> 离屏验证:`.preflight/check` **95/95 通过**;**A/B**(回退顶部固定 + 回退 10 行上限 + 回退滚动动画)
> ⇒ **10 项如实失败**;恢复后 **95/95**;`make release` / `make debug` **零警告**。
#### 一、菜单滚动时标题不再跟着滚(用户报告)
- **现象**:网格数很多时,滚动「所属网格」菜单的条目区,最上方的“所属网格”**标题也一起被滚走**。
- **根因**:上一轮只实现了“**尾部**固定区”(最后一个分隔线 + 「新建网格」钉底),
滚动区是从**条目 0** 开始的 —— 标题自然跟着滚。
- **修法**:`BuildLayout` 新增 **`m_scrollFirst`**(= 前导 `HEADER` / `HEADER_ACTION` 的个数),
滚动区变为半开区间 **`[m_scrollFirst, m_pinnedIndex)`**:标题从菜单顶向下固定排(`headCursorY`),
滚动区条目才受 `m_scrollOffset` 影响,尾部固定区从菜单底向上对齐。
- 配套更新:`Paint` 的裁剪/跳过范围、`ItemAtPoint` 的“必须落在滚动视口内”判定、
`EnsureItemVisible` 的下界,都改用 `[m_scrollFirst, m_pinnedIndex)`。新增 `ScrollRegion(first, last)` 供查询/测试。
#### 二、菜单长度上限改为「鬃 10 条网格」+ 滚动条(用户要求)
- `MenuLayoutConstraint` 新增 **`int maxScrollRows`**(滚动区最多显示的标准行数)——
比 `maxHeight` 更直观(“保留 10 条网格的上限”)。两个约束同时给出时**取更严格者**:
`viewportLimit = min(maxHeight 换算的可用高, maxScrollRows × 标准行高)`。
- `SignalPanel::OpenLaneMenu` 现在传 `maxScrollRows = kLaneMenuVisibleRows(=10)`
(另外保留 `maxHeight = max(180, 客户区高 × 0.8)` 作为矮窗口的硬上限)。
- **新增滚动条**(用户要求):轨道 `m_scrollBarRect` 贴在**滚动条目区**的右内侧(不盖住标题与底部固定项),
滑块 `ScrollBarThumbRect()`(高度按视口/内容比,下限 `kScrollBarMinThumb=22`);
滚动区的条目宽度**让出** `kScrollBarWidth + kScrollBarGap`,文本与对勾不会压在滑块上。
- **滚动条可拖拽**:`OnLButtonDown` 命中滑块 → 开始拖拽(`m_scrollBarDragging`);
命中轨道空白 → 把滑块中心移过去;`OnMouseMove` 拖拽中只调整偏移、**不更新悬停**;
`OnLButtonUp` 结束拖拽且**不下发命令、菜单保持打开**。新增 `ScrollOffsetForThumbTop()` 反算偏移。
- ⚠️⚠️ **`BuildLayout` 绝不能复位 `m_scrollBarDragging`**:拖拽时每帧都会 `RebuildLayout()`,
在那里清零会把拖拽态**当场吃掉**(症状:按住滑块只能拖一下、随后就“脱手”)。
#### 三、网格滚动联动改为**动态过渡**(用户要求:旧实现“瞬移”太僵硬)
- **旧行为**:`PlotView::ScrollLaneIntoView` 直接 `Tab().LaneScroll() = desired` —— 视界瞬移过去。
- **现行**:与信号列表的“滚动到目标信号”**同源**的平滑过渡:
* `m_laneScrollAnimActive/From/To/StartMs/DurationMs`;
* 时长 = `clamp(距离 × 0.35ms, 130, 300)`,进度再过 **ease-out cubic**;
* 宿主 16ms 定时器(`kScrollTimerId`)调用 `AdvanceLaneScrollAnimation()`,与面板动画同一条定时器;
* `Render` 的“需要保持定时重绘”判定加入 `m_plot.LaneScrollAnimationActive()`;
* 每帧 `ClampLaneScroll()` + `BuildLaneVisuals()`(网格滚动量参与场景/图层指纹与各网格 rect)。
- ⚠️ **用户自己滚动网格(滚轮 / 拖滚动条)必须 `CancelLaneScrollAnimation()`**,
否则动画会与手动滚动抢滚动位置;`ScrollLaneIntoView` 对已完整可见的网格仍直接返回 false(不启动动画)。
- 实测:154 个网格、距离 12091px 时动画 **20~21 帧**完成(≈300ms),中间帧滚动量单调递增、
结束时**精确落到目标**且目标网格上下边界都在绘图区内。
#### 四、验证
- `.preflight/check` 新增断言:菜单**顶部标题在滚动时不动**、滚动视口高度恰为 **10 × 行高**、
滚动条轨道/滑块存在且随滚动移动、**按住滑块拖到底**(菜单不关、不下发命令、末条目入视口)、
滚轮回到顶部;`ScrollLaneIntoView` **不瞬移**(刚点击时几乎没动)、递进若干帧后精确到位、
已可见时**不启动动画**、滚轮/手动滚动会取消动画。
- **A/B**:回退三处(标题固定 / 10 行上限 / 滚动动画)⇒ **10 项如实失败**;恢复后 95/95。
---
## 10. 变更日志(本文件维护记录) ## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 | | 日期 | 设备/模型 | 摘要 |
@@ -2442,6 +2499,7 @@ Components/Shared/Src/UiControls.cpp
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ 新增 `Components/Shared` 组件 + 过时注释/命名大清理 + 二进制瘦身 24.6% 并完成多文件拆分(详见 §9.37 与本轮报告 `Documents/refactor_report_2026-10-16.md`)**。① **折起热区**(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签**(新增 `RowPart::GROUP_TITLE`)才折起。② **分组改名输入框不再吞掉「N 项」**:`RowNameRect` 右边界改为项数左缘;新增 `GroupLabelText`/`GroupStatsText` 作为文本与宽度的唯一来源。③ **`SignalData`/`SignalGroup` 只留一个名称字段**:删除 `displayName` 与 `Title()`(31 处改用 `name`),`SetSignalDisplayName`/`SetGroupDisplayName` → **`RenameSignal`/`RenameGroup`**(空名一律拒绝);删除死成员 `Application::m_loadingBytes`。④ **新增 `Components/Shared`**(用户要求):从 `PaintUtil` 迁出全部**控件**(图标/复选框/滑块/内联输入框/颜色样块/取值胶囊配色)到 `UiControls.hpp/.cpp`,`PaintUtil` 只留低层原语;依赖方向固定 `Components/* → Components/Shared → Utilities`。⑤ **过时注释/命名**:`文件下标`→`分组下标`、`文件分组`→`分组` 等一批;形参名 `file`→`axis`/`group`;`PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、`FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`;修复 `DataModel.hpp` 两处被压扁的注释块与 `NewTab` 重复文档块。⑥ **多文件拆分(本轮完成)**:`PlotView.cpp`(13 695 行)+ `SignalPanel.cpp` + `Application.cpp` + `DataModel.cpp` 四个大文件全部按功能拆成多个协作文件(私有内部头 + 具名命名空间 + 全 `inline` + 按名字分桶),最大单文件 544 KB → 276 KB。⑦ **二进制瘦身**:真因是 **ICO 里 256×256 未压缩 BMP 条目(270 KB,占整包 27%)**,改为 **PNG 存储**(观感零变化)⇒ `Target.exe` **1 013 248 → 764 416 B(−24.6%)**;实测“拆分不会减小机器码,反而 +1.4%”、`--gc-sections` 反而 +9 KB(已回退)。验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;**A/B** 回退两处 UI 修复 **10 项如实失败**;`make release`/`make debug` 零警告。⚠️ 该提交当时的待办(`PlotView.cpp` 等大文件多文件化拆分、`LanePanelState` 抽取、模态提示解耦)**已全部在紧随其后的两个提交里完成**:拆分 → 提交 **`e6b7bf6`**(详见 §9.37 第七节),`LanePanelState` + 模态解耦 → 提交 **`d2e6fdd`**(详见 §9.37 第八节)。**本行不再表示任何未完成项。** | | 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ 新增 `Components/Shared` 组件 + 过时注释/命名大清理 + 二进制瘦身 24.6% 并完成多文件拆分(详见 §9.37 与本轮报告 `Documents/refactor_report_2026-10-16.md`)**。① **折起热区**(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签**(新增 `RowPart::GROUP_TITLE`)才折起。② **分组改名输入框不再吞掉「N 项」**:`RowNameRect` 右边界改为项数左缘;新增 `GroupLabelText`/`GroupStatsText` 作为文本与宽度的唯一来源。③ **`SignalData`/`SignalGroup` 只留一个名称字段**:删除 `displayName` 与 `Title()`(31 处改用 `name`),`SetSignalDisplayName`/`SetGroupDisplayName` → **`RenameSignal`/`RenameGroup`**(空名一律拒绝);删除死成员 `Application::m_loadingBytes`。④ **新增 `Components/Shared`**(用户要求):从 `PaintUtil` 迁出全部**控件**(图标/复选框/滑块/内联输入框/颜色样块/取值胶囊配色)到 `UiControls.hpp/.cpp`,`PaintUtil` 只留低层原语;依赖方向固定 `Components/* → Components/Shared → Utilities`。⑤ **过时注释/命名**:`文件下标`→`分组下标`、`文件分组`→`分组` 等一批;形参名 `file`→`axis`/`group`;`PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、`FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`;修复 `DataModel.hpp` 两处被压扁的注释块与 `NewTab` 重复文档块。⑥ **多文件拆分(本轮完成)**:`PlotView.cpp`(13 695 行)+ `SignalPanel.cpp` + `Application.cpp` + `DataModel.cpp` 四个大文件全部按功能拆成多个协作文件(私有内部头 + 具名命名空间 + 全 `inline` + 按名字分桶),最大单文件 544 KB → 276 KB。⑦ **二进制瘦身**:真因是 **ICO 里 256×256 未压缩 BMP 条目(270 KB,占整包 27%)**,改为 **PNG 存储**(观感零变化)⇒ `Target.exe` **1 013 248 → 764 416 B(−24.6%)**;实测“拆分不会减小机器码,反而 +1.4%”、`--gc-sections` 反而 +9 KB(已回退)。验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;**A/B** 回退两处 UI 修复 **10 项如实失败**;`make release`/`make debug` 零警告。⚠️ 该提交当时的待办(`PlotView.cpp` 等大文件多文件化拆分、`LanePanelState` 抽取、模态提示解耦)**已全部在紧随其后的两个提交里完成**:拆分 → 提交 **`e6b7bf6`**(详见 §9.37 第七节),`LanePanelState` + 模态解耦 → 提交 **`d2e6fdd`**(详见 §9.37 第八节)。**本行不再表示任何未完成项。** |
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **收尾:`LanePanelState` 抽取 + 错误提示非模态化(把上一行的「未完成」清零,详见 §9.37 第八节与报告 `Documents/refactor_report_2026-10-16.md` §九)**。① **`Lane` 的 12 个平铺面板字段**(`edgePanelOpen/X/Y` × 4 类实验性功能)→ **4 个 `LanePanelState`**(`struct LanePanelState { bool open=true; double x=-1; double y=-1; }`),访问点全部改为 `lane.edgePanel.open` 形式;`RebuildLanes` 的 `LaneCarry` 同步收敛为 4 个 `LanePanelState`(逐字段赋值 → 整体赋值)。⚠️ 教训:**批量改名脚本不能对「声明」与「使用」跑同一组替换对**(会生成 `bool edgePanel.open = true;` 非法声明),声明处须单独重写。② **错误提示与模态阻塞解耦**:新增 `Application::SetModalErrorsEnabled(bool)`/`ModalErrorsEnabled()`(成员默认 `true`),`Initialize` **最先**执行 `SetModalErrorsEnabled(showCommand != SW_HIDE)`(隐藏窗口弹模态框 = 进程被钉死);`SetStatusMessage(err,true)` 允许模态时原样 `MessageBoxW`,否则走 `m_plot.ShowPersistentHint(text)`(绘图区底部非模态浮层);`PlotView` 新增公开 `ShowPersistentHint`。普通提示/空文本仍被忽略 ⇒ **交互行为不变**。③ **顺带修掉真实缺陷**:`PlotView::Paint` 在 `m_lanes.empty()` 时提前 `return`,提示浮层在其后绘制 ⇒ **未勾选任何信号时错误永远不可见**(正是"加载失败"场景),该分支补一次 `PaintHint(g)`。④ **新增探针 `.preflight/perf/errcheck` 19/19**:默认值/隐藏降级/宿主开关、**工作线程 + 5s 超时**判"不阻塞"(旧行为会永久阻塞 ⇒ 判失败并 `ExitProcess`,探针不挂)、`EnumWindows` 数本进程 `#32770` 顶层窗口数不变、提示文本/粘滞/需重绘/起始时刻、**像素级**"有/无提示"差异 3781 且全帧差异同为 3781。验证:`make release`/`make debug` 零警告,`Target.exe` **764 416 B**;`verify` 49/49、`datacheck` 69/69、`edgecheck` 46/46、`panelanim` 156/156。⑤ **新增结构检查** `Tools/structcheck.ps1` + `make structcheck`(**93/93**):[1] 四个巨型文件已按职责拆分(`PlotView.cpp` 13 695 行 → 705 行,`nm`:绘制符号只在 `PlotViewPaint.o`);[2] `Components/Shared` 16 个控件全部由 `UiControls.o` 定义、69 处调用跨 ≥3 个组件 + 分层方向;[3] 本轮三项用户要求的源码级断言(折起热区、改名框不吞项数、`SignalData` 单一 `name` 字段 + 注释核验)。**判别力 A/B**:回退两处 UI 修复 ⇒ `panelanim` 147/156(9 项如实失败,文案点名“group rename box stops before the item counter”“clicking the group name does not collapse the group”)、`structcheck` 90/93;恢复后 156/156 与 93/93。 | | 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **收尾:`LanePanelState` 抽取 + 错误提示非模态化(把上一行的「未完成」清零,详见 §9.37 第八节与报告 `Documents/refactor_report_2026-10-16.md` §九)**。① **`Lane` 的 12 个平铺面板字段**(`edgePanelOpen/X/Y` × 4 类实验性功能)→ **4 个 `LanePanelState`**(`struct LanePanelState { bool open=true; double x=-1; double y=-1; }`),访问点全部改为 `lane.edgePanel.open` 形式;`RebuildLanes` 的 `LaneCarry` 同步收敛为 4 个 `LanePanelState`(逐字段赋值 → 整体赋值)。⚠️ 教训:**批量改名脚本不能对「声明」与「使用」跑同一组替换对**(会生成 `bool edgePanel.open = true;` 非法声明),声明处须单独重写。② **错误提示与模态阻塞解耦**:新增 `Application::SetModalErrorsEnabled(bool)`/`ModalErrorsEnabled()`(成员默认 `true`),`Initialize` **最先**执行 `SetModalErrorsEnabled(showCommand != SW_HIDE)`(隐藏窗口弹模态框 = 进程被钉死);`SetStatusMessage(err,true)` 允许模态时原样 `MessageBoxW`,否则走 `m_plot.ShowPersistentHint(text)`(绘图区底部非模态浮层);`PlotView` 新增公开 `ShowPersistentHint`。普通提示/空文本仍被忽略 ⇒ **交互行为不变**。③ **顺带修掉真实缺陷**:`PlotView::Paint` 在 `m_lanes.empty()` 时提前 `return`,提示浮层在其后绘制 ⇒ **未勾选任何信号时错误永远不可见**(正是"加载失败"场景),该分支补一次 `PaintHint(g)`。④ **新增探针 `.preflight/perf/errcheck` 19/19**:默认值/隐藏降级/宿主开关、**工作线程 + 5s 超时**判"不阻塞"(旧行为会永久阻塞 ⇒ 判失败并 `ExitProcess`,探针不挂)、`EnumWindows` 数本进程 `#32770` 顶层窗口数不变、提示文本/粘滞/需重绘/起始时刻、**像素级**"有/无提示"差异 3781 且全帧差异同为 3781。验证:`make release`/`make debug` 零警告,`Target.exe` **764 416 B**;`verify` 49/49、`datacheck` 69/69、`edgecheck` 46/46、`panelanim` 156/156。⑤ **新增结构检查** `Tools/structcheck.ps1` + `make structcheck`(**93/93**):[1] 四个巨型文件已按职责拆分(`PlotView.cpp` 13 695 行 → 705 行,`nm`:绘制符号只在 `PlotViewPaint.o`);[2] `Components/Shared` 16 个控件全部由 `UiControls.o` 定义、69 处调用跨 ≥3 个组件 + 分层方向;[3] 本轮三项用户要求的源码级断言(折起热区、改名框不吞项数、`SignalData` 单一 `name` 字段 + 注释核验)。**判别力 A/B**:回退两处 UI 修复 ⇒ `panelanim` 147/156(9 项如实失败,文案点名“group rename box stops before the item counter”“clicking the group name does not collapse the group”)、`structcheck` 90/93;恢复后 156/156 与 93/93。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**起源设备**) | **五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(详见 §9.38)**。① **分组折起后再展开滚动条消失**:`AdvanceGroupAnimations` 只在折起结束时重建行集合,而展开结束那一帧 `m_contentHeight` 跳回展开态却无人重算滚动条可见性 ⇒ 滚动条停在“隐藏”。修法:任一方向结束都 `m_rowsDirty = true` + `BuildRows()`,并**每帧** `RefreshScrollBar(false)`(动画期间不夹取滚动偏移,避免打断 `scrollSynced`)。② **单点信号默认绘制方式改「散点」**(原「点线」),且信号面板「样式」行的「线段」「点线」对它**置灰且不可点**(新增 `ModeOptionEnabled`,也不参与悬停高亮);`UpdateAutoScatterStyles` 跳过单点信号,避免“自动点线”与置灰状态自相矛盾。③ **拖动线宽滑块完全不生效**(用户报告:拖动时不变、松手瞬间变了、再移动鼠标又变回去)——根因两个:**`AcquirePolyline` 的缓存键漏了 `lineWidth`**(而 `BuildPolyline` 把线宽烘进包络带几何:逐列上下包络各外扩半个线宽,「梳状」判定也依赖它),以及**样式补丁没有同步宽基底图层**(下一帧宽基底快路径把新样式覆盖回旧像素)。修法:`PolylineCacheEntry` 新增 `lineWidth` 并进比较;样式补丁后 `QueueLaneRebuild(patchLanes) + RenderPendingLaneRebuilds(1)`。实测常量信号列厚 `0.5px→1px / 5.3px→7px / 10px→12px` 且松手与移动鼠标后**保持 12px**。④ **「目标」行的 GX 标签复用「所属网格」菜单**(与列表行徽标同一份),并先给 `PopupMenu` 加**限高 + 条目区滚动 + 尾部固定项**(`MenuLayoutConstraint::maxHeight`;固定区 = 最后一个分隔线及其后的「新建网格」;滚轮滚动、键盘移动自动滚入可视区;滚动区文本用 `UiTextClipped` 并跳过不可见条目;`ItemAtPoint` 增加滚动视口判定)——顺手解决“上百个网格时菜单超长且交互卡顿”;GX 标签点击**不参与**联动滚动,悬停联动保留。⑤ **点选信号名 ⇒ 绘图区自动把目标网格滚到可视区内**(`RequestPlotLaneScroll` / `ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):在上方就上滚、在下方就下滚、已可见则不动;**悬停不滚动**。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**68/68**;**A/B** 五处修复全回退 ⇒ **17 项如实失败**(A 组需逐帧重绘驱动,单独回退 ⇒ 5 项);`make release` / `make debug` 零警告。⑥ 按要求**删除 `Tools/`(`structcheck`)并移除 `make structcheck` 目标**(§9.37/§11.2 第 96 条的“可复跑”描述自此失效,仅作历史证据)。 | | 2026-10-16 | DeepSeek-V4.1-Flash(**起源设备**) | **五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(详见 §9.38)**。① **分组折起后再展开滚动条消失**:`AdvanceGroupAnimations` 只在折起结束时重建行集合,而展开结束那一帧 `m_contentHeight` 跳回展开态却无人重算滚动条可见性 ⇒ 滚动条停在“隐藏”。修法:任一方向结束都 `m_rowsDirty = true` + `BuildRows()`,并**每帧** `RefreshScrollBar(false)`(动画期间不夹取滚动偏移,避免打断 `scrollSynced`)。② **单点信号默认绘制方式改「散点」**(原「点线」),且信号面板「样式」行的「线段」「点线」对它**置灰且不可点**(新增 `ModeOptionEnabled`,也不参与悬停高亮);`UpdateAutoScatterStyles` 跳过单点信号,避免“自动点线”与置灰状态自相矛盾。③ **拖动线宽滑块完全不生效**(用户报告:拖动时不变、松手瞬间变了、再移动鼠标又变回去)——根因两个:**`AcquirePolyline` 的缓存键漏了 `lineWidth`**(而 `BuildPolyline` 把线宽烘进包络带几何:逐列上下包络各外扩半个线宽,「梳状」判定也依赖它),以及**样式补丁没有同步宽基底图层**(下一帧宽基底快路径把新样式覆盖回旧像素)。修法:`PolylineCacheEntry` 新增 `lineWidth` 并进比较;样式补丁后 `QueueLaneRebuild(patchLanes) + RenderPendingLaneRebuilds(1)`。实测常量信号列厚 `0.5px→1px / 5.3px→7px / 10px→12px` 且松手与移动鼠标后**保持 12px**。④ **「目标」行的 GX 标签复用「所属网格」菜单**(与列表行徽标同一份),并先给 `PopupMenu` 加**限高 + 条目区滚动 + 尾部固定项**(`MenuLayoutConstraint::maxHeight`;固定区 = 最后一个分隔线及其后的「新建网格」;滚轮滚动、键盘移动自动滚入可视区;滚动区文本用 `UiTextClipped` 并跳过不可见条目;`ItemAtPoint` 增加滚动视口判定)——顺手解决“上百个网格时菜单超长且交互卡顿”;GX 标签点击**不参与**联动滚动,悬停联动保留。⑤ **点选信号名 ⇒ 绘图区自动把目标网格滚到可视区内**(`RequestPlotLaneScroll` / `ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):在上方就上滚、在下方就下滚、已可见则不动;**悬停不滚动**。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**68/68**;**A/B** 五处修复全回退 ⇒ **17 项如实失败**(A 组需逐帧重绘驱动,单独回退 ⇒ 5 项);`make release` / `make debug` 零警告。⑥ 按要求**删除 `Tools/`(`structcheck`)并移除 `make structcheck` 目标**(§9.37/§11.2 第 96 条的“可复跑”描述自此失效,仅作历史证据)。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**起源设备**) | **「所属网格」菜单标题固定 + 10 行上限 + 滚动条;网格滚动联动改为动态过渡(详见 §9.39)**。① **滚动时标题跟着滚掉**:上一轮只实现了尾部固定区(分隔线 + 「新建网格」钉底),滚动区从**条目 0** 开始 ⇒ 标题被一起滚走;修法:新增 `m_scrollFirst`(前导 `HEADER`/`HEADER_ACTION` 个数),滚动区变为 `[m_scrollFirst, m_pinnedIndex)`,标题从菜单顶向下固定排;`Paint` 裁剪/跳过、`ItemAtPoint` 视口判定、`EnsureItemVisible` 下界全部改用该区间,并新增 `ScrollRegion(first,last)` 查询。② **长度上限太大**:`MenuLayoutConstraint` 新增 `maxScrollRows`(**取 10 行**,与 `maxHeight` 取更严格者)⇒ 视口高恒为 10×行高;**新增滚动条**(轨道贴滚动区右内侧、滑块按视口比例、滚动区条目宽度让出滚动条宽度),且**可拖拽**(按下滑块/点击轨道开始拖、拖动只改偏移不改悬停、抬起结束且不下发命令、菜单保持打开),新增 `ScrollOffsetForThumbTop`。⚠️⚠️ 教训:**`BuildLayout` 绝不能复位 `m_scrollBarDragging`**(拖拽时每帧都 `RebuildLayout()`,清零会让拖拽“拖一下就脱手”)。③ **网格滚动联动旧实现是“瞬移”**:`ScrollLaneIntoView` 现在只**启动平滑动画**(`m_laneScrollAnimActive/From/To/StartMs/DurationMs`;时长 `clamp(距离×0.35ms,130,300)` + ease-out cubic,与信号列表同源),由宿主 16ms 定时器调用 `AdvanceLaneScrollAnimation()` 逐帧推进(`Render` 的定时器判定加入 `LaneScrollAnimationActive()`);用户滚轮/拖滚动条会 `CancelLaneScrollAnimation()`。实测 154 网格、距离 12091px 的动画约 20 帧完成并精确落位。验证:`.preflight/check` **95/95**;**A/B** 回退三处 ⇒ **10 项如实失败**;`make release`/`make debug` 零警告。 |
--- ---
@@ -2942,6 +3000,25 @@ Components/Shared/Src/UiControls.cpp
103. ⚠️ **交互类缺陷的离屏探针必须“每帧重绘”驱动**(动画 / 分帧推进 / 自持帧): 103. ⚠️ **交互类缺陷的离屏探针必须“每帧重绘”驱动**(动画 / 分帧推进 / 自持帧):
面板 `Paint` → `EnsureRows()`、绘图区 `Paint` → 预算推进,都会在重绘时**顺手修复**一部分 面板 `Paint` → `EnsureRows()`、绘图区 `Paint` → 预算推进,都会在重绘时**顺手修复**一部分
中间态;只在动作结束后才重绘会把“完成帧”的缺陷掩盖掉(本轮滚动条缺陷就因此假绿过一次)。 中间态;只在动作结束后才重绘会把“完成帧”的缺陷掩盖掉(本轮滚动条缺陷就因此假绿过一次)。
104. ⚠️ **可滚动菜单的固定区是“两端”**:`[0, m_scrollFirst)` = 前导标题(`HEADER` / `HEADER_ACTION`,
如「所属网格」)**钉在顶部不随滚动移动**;`[m_pinnedIndex, end)` = 最后一个分隔线及其之后的条目
(如「新建网格」)钉在底部;只有中间 `[m_scrollFirst, m_pinnedIndex)` 是滚动区。
裁剪(`Paint`)、命中(`ItemAtPoint` 要求条目同时落在滚动视口内)与 `EnsureItemVisible` 的边界
都必须用这个区间,否则标题会被滚走(用户 2026-10-16 报告)。
105. ⚠️ **菜单长度优先用「行数上限」表达**:`MenuLayoutConstraint::maxScrollRows`
(滚动区最多显示 N 个标准行;与 `maxHeight` 同时给出时取更严格者)。
当前「所属网格」菜单 = **10 行**(`kLaneMenuVisibleRows`)+ 右侧滚动条。
滚动条必须**可拖拽**(按下滑块/轨道开始拖、`OnMouseMove` 只改偏移不改悬停、抬起结束且不下发命令)。
⚠️⚠️ **`BuildLayout` 绝不能复位 `m_scrollBarDragging`**:拖拽时每帧都会 `RebuildLayout()`,
在那里清零会让拖拽“拖一下就脱手”。
106. ⚠️ **绘图区的“网格滚动到可视区”必须是平滑动画**(与信号列表同源:ease-out cubic、
时长 `clamp(距离×0.35ms, 130, 300)`、宿主 16ms 定时器驱动):
`ScrollLaneIntoView` 只负责**启动**动画(`m_laneScrollAnimActive`),`AdvanceLaneScrollAnimation`
逐帧推进,`Render` 的定时器判定要带上 `LaneScrollAnimationActive()`。
⚠️ 用户自己滚动网格(滚轮 / 拖滚动条 / 键盘)时必须 `CancelLaneScrollAnimation()`,否则两者抢滚动位置。
107. ⚠️ **判断“动画是否真的在动态过渡”必须看**刚触发时**的状态**:只断言“最终把目标滚进可视区”
对“瞬移”同样成立、毫无判别力。要断言:触发后立即仍几乎未动、中间帧单调递进、
收尾精确到位(本轮 A/B 就是靠这三条排除了“直接赋值”的实现)。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
+55 -8
View File
@@ -87,14 +87,21 @@ namespace jdi
/** /**
* @brief 菜单允许的最大高度(**客户区像素**,0 = 不限制)。 * @brief 菜单允许的最大高度(**客户区像素**,0 = 不限制)。
* *
* 超过该高度时菜单不再变高:**最后一个分隔线及其之后的条目固定在菜单底部** * 超过该高度时菜单不再变高:**前导标题钉在顶部、最后一个分隔线及其之后的条目固定在底部**,
* (典型用法:把「新建网格」钉在底部),其上方的内容成为可滚动的条目区 * 中间的内容成为可滚动的条目区(鼠标滚轮 / 键盘方向键 / 滚动条)。
*(鼠标滚轮 / 键盘方向键滚动)。
* *
* 用于「条目数不定」的菜单:例如加载含上百个信号的 MF4 并把每个信号都放进独立网格时, * 用于「条目数不定」的菜单:例如加载含上百个信号的 MF4 并把每个信号都放进独立网格时,
* 「所属网格」菜单会有上百个条目(既超出屏幕、绘制开销也很大)。 * 「所属网格」菜单会有上百个条目(既超出屏幕、绘制开销也很大)。
*/ */
float maxHeight = 0.0f; float maxHeight = 0.0f;
/**
* @brief 可滚动条目区**最多显示的标准行数**(0 = 不限制)。
*
* 比 `maxHeight` 更直观(用户要求“保留 10 条网格的上限”):无论条目多高,
* 滚动视口高度都不会超过该行数×标准行高。与 `maxHeight` 同时给出时**取更严格的那个**。
*/
int maxScrollRows = 0;
}; };
/** /**
@@ -217,11 +224,47 @@ namespace jdi
bool ContainsPoint(int x, int y) const; bool ContainsPoint(int x, int y) const;
/** /**
* @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、尾部分隔线之后固定到底部)。 * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。
* @return 启用滚动返回 true。 * @return 启用滚动返回 true。
*/ */
bool IsScrollable() const { return m_open && m_scrollEnabled; } bool IsScrollable() const { return m_open && m_scrollEnabled; }
/**
* @brief 滚动条**轨道**矩形(仅可滚动菜单有效,其余返回空矩形)。
*
* 同时也是“可滚动条目区”的依据:条目区内的条目只在该矩形内可点。
*/
const gd::RectF& ScrollBarRect() const { return m_scrollBarRect; }
/**
* @brief 滚动条**滑块**矩形(仅可滚动菜单有效)。
* @return 滑块矩形;无需滚动时返回空矩形。
*/
gd::RectF ScrollBarThumbRect() const;
/**
* @brief 由「滑块的顶部 Y」反推对应的滚动偏移(拖拽滚动条 / 点击轨道时用)。
* @param [IN] thumbTop 期望的滑块顶部 Y(客户区坐标)。
* @return 滚动偏移(已夹到合法范围)。
*/
float ScrollOffsetForThumbTop(float thumbTop) const;
/**
* @brief 条目区当前的滚动偏移(仅可滚动菜单有效)。
* @return 偏移量(像素,0 = 顶部)。
*/
float ScrollOffset() const { return m_scrollOffset; }
/**
* @brief 可滚动条目区在菜单条目中的下标范围(半开区间 `[first, last)`)。
*
* 前导标题(`HEADER` / `HEADER_ACTION`)与末尾分隔线及其之后的条目**不参与滚动**。
* @param [OUT] first 条目区首个条目下标。
* @param [OUT] last 条目区末尾(不含)下标。
* @return 菜单启用滚动返回 true;否则输出全区间。
*/
bool ScrollRegion(int& first, int& last) const;
/** /**
* @brief 处理鼠标滚轮:滚动可滚动菜单的条目区。 * @brief 处理鼠标滚轮:滚动可滚动菜单的条目区。
* @param [IN] delta 滚轮增量(`WM_MOUSEWHEEL` 的 `GET_WHEEL_DELTA_WPARAM`)。 * @param [IN] delta 滚轮增量(`WM_MOUSEWHEEL` 的 `GET_WHEEL_DELTA_WPARAM`)。
@@ -470,16 +513,20 @@ namespace jdi
/** 最近一次使用的布局约束(`RebuildLayout` 复用)。 */ /** 最近一次使用的布局约束(`RebuildLayout` 复用)。 */
MenuLayoutConstraint m_constraint; MenuLayoutConstraint m_constraint;
/** /**
* @brief 尾部固定区(钉在菜单底部)的**首个条目下标**;-1 表示没有固定项。 * @brief 可滚动条目区的下标范围(半开区间 `[m_scrollFirst, m_pinnedIndex)`)。
* *
* 由 `BuildLayout` 在启用滚动时设为最后一个分隔线的下标:该分隔线及其之后的条目 * `m_scrollFirst` = 前导固定条目数(**「所属网格」这类标题必须始终留在最上方、不参与滚动**);
* (典型:「新建网格」)始终画在菜单底部,其上方的内容在滚动区内。 * `m_pinnedIndex` = 末尾固定区起点(最后一个分隔线,典型「新建网格」钉在底部)。
*/ */
int m_scrollFirst = 0;
int m_pinnedIndex = -1; int m_pinnedIndex = -1;
bool m_scrollEnabled = false; /**< 条目区是否可滚动(内容超过 `maxHeight` 时启用)。 */ bool m_scrollEnabled = false; /**< 条目区是否可滚动(内容超过约束上限时启用)。 */
float m_scrollOffset = 0.0f; /**< 滚动区当前的滚动偏移(像素,0 = 顶部)。 */ float m_scrollOffset = 0.0f; /**< 滚动区当前的滚动偏移(像素,0 = 顶部)。 */
float m_scrollRegionHeight = 0.0f; /**< 滚动区的内容总高(像素)。 */ float m_scrollRegionHeight = 0.0f; /**< 滚动区的内容总高(像素)。 */
gd::RectF m_scrollViewport; /**< 滚动区的可视矩形(也是滚动区条目的裁剪矩形)。 */ gd::RectF m_scrollViewport; /**< 滚动区的可视矩形(也是滚动区条目的裁剪矩形)。 */
gd::RectF m_scrollBarRect; /**< 滚动条轨道矩形(仅可滚动菜单有效)。 */
bool m_scrollBarDragging = false; /**< 是否正在拖拽滚动条滑块。 */
float m_scrollBarGrabOffset = 0.0f; /**< 拖拽时鼠标相对滑块顶部的偏移(像素)。 */
bool m_subOpen = false; /**< 二级子菜单是否展开。 */ bool m_subOpen = false; /**< 二级子菜单是否展开。 */
int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */ int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */
+242 -51
View File
@@ -31,6 +31,9 @@ namespace jdi
const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */ const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */ const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */ const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
const float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */
const float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */
const float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */
/** /**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。 * @brief 绘制「还原/重置」图标(逆时针回环箭头)。
@@ -172,12 +175,15 @@ namespace jdi
const float width = contentWidth; const float width = contentWidth;
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 超长菜单:限制高度 + 条目区可滚动 + 尾部固定项(见 `MenuLayoutConstraint`)。 // 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。
// 固定区 = **最后一个分隔线及其之后的条目**(典型:「新建网格」), // * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)——
// 它始终钉在菜单底部,因此滚动不会把“新建网格”飘走。 // 用户 2026-10-16 要求:滚动时标题必须留在最上方不动;
// * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部;
// * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。
// ------------------------------------------------------------------ // ------------------------------------------------------------------
int pinnedFrom = -1; 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) for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
{ {
@@ -187,45 +193,77 @@ namespace jdi
break; 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 scrollRegionHeight = 0.0f;
float tailHeight = 0.0f; float tailHeight = 0.0f;
if (pinnedFrom > 0) 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)]; if (index >= pinnedFrom)
} {
for (int index = pinnedFrom; index < static_cast<int>(m_items.size()); ++index) tailHeight += heights[static_cast<std::size_t>(index)];
{ }
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; float height = totalHeight;
m_scrollEnabled = false; m_scrollEnabled = false;
m_scrollRegionHeight = 0.0f; m_scrollRegionHeight = 0.0f;
m_scrollFirst = 0;
m_pinnedIndex = -1; m_pinnedIndex = -1;
m_scrollViewport = gd::RectF(); m_scrollViewport = gd::RectF();
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发) m_scrollBarRect = gd::RectF();
// ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`,
// 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条)
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要** // 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
// 滚动时才复位(`Open` 里已经为每次打开重置过)。 // 滚动时才复位(`Open` 里已经为每次打开重置过)。
if (m_constraint.maxHeight > 0.0f) if (pinnedFrom > 0)
{ {
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”, // 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。 // `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。
float limit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale); float viewportLimit = 0.0f;
if (limit < 40.0f * m_scale) 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 (m_constraint.maxScrollRows > 0)
if (pinnedFrom > 0 && limit > 0.0f && totalHeight > limit &&
viewportHeight >= kRowHeight * m_scale)
{ {
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_scrollEnabled = true;
m_scrollFirst = scrollFirst;
m_pinnedIndex = pinnedFrom; m_pinnedIndex = pinnedFrom;
m_scrollRegionHeight = scrollRegionHeight; m_scrollRegionHeight = scrollRegionHeight;
m_scrollOffset = std::max( m_scrollOffset = std::max(
@@ -286,39 +324,52 @@ namespace jdi
m_rect = gd::RectF(left, top, width, height); m_rect = gd::RectF(left, top, width, height);
// 条目排布:滚动区的条目整体上移 `m_scrollOffset`;固定区从菜单底部往上对齐。 // 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`;
// 尾部固定区从菜单底部往上对齐。
const float bodyTop = top + kPadV * m_scale; 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_scrollViewport =
m_scrollEnabled m_scrollEnabled
? gd::RectF(left, bodyTop, width, ? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight,
std::max(0.0f, height - kPadV * 2.0f * m_scale - tailHeight)) std::max(0.0f, width - kPadH * 2.0f * m_scale),
std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight))
: gd::RectF(); : gd::RectF();
float cursorY = bodyTop; float cursorY = bodyTop;
float scrollCursorY = bodyTop - m_scrollOffset; float headCursorY = bodyTop;
float pinnedCursorY = (pinnedFrom > 0) ? pinnedTop : bodyTop; float scrollCursorY = bodyTop + headHeight - m_scrollOffset;
float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop;
for (std::size_t index = 0; index < m_items.size(); ++index) for (std::size_t index = 0; index < m_items.size(); ++index)
{ {
const MenuItem& item = m_items[index]; const MenuItem& item = m_items[index];
const float itemHeight = heights[index]; const float itemHeight = heights[index];
float rectY = cursorY; float rectY = cursorY;
float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale);
if (m_scrollEnabled) 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; rectY = scrollCursorY;
scrollCursorY += itemHeight; scrollCursorY += itemHeight;
// 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。
rectWidth = std::max(0.0f, rectWidth - scrollBarReserve);
} }
else else
{ {
rectY = pinnedCursorY; rectY = tailCursorY;
pinnedCursorY += itemHeight; tailCursorY += itemHeight;
} }
} }
m_itemRects[index] = gd::RectF(left + kPadH * m_scale, rectY, m_itemRects[index] =
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight); gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight);
if (item.kind == MenuItemKind::SWATCHES) if (item.kind == MenuItemKind::SWATCHES)
{ {
@@ -337,11 +388,78 @@ namespace jdi
cursorY += itemHeight; 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) 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; return false;
} }
@@ -795,6 +913,7 @@ namespace jdi
// 每次打开都从顶部开始(滚动位置不跨次沿用)。 // 每次打开都从顶部开始(滚动位置不跨次沿用)。
m_constraint = constraint; m_constraint = constraint;
m_scrollOffset = 0.0f; m_scrollOffset = 0.0f;
m_scrollBarDragging = false;
CloseSubmenu(); CloseSubmenu();
CloseEditState(); CloseEditState();
@@ -814,11 +933,15 @@ namespace jdi
m_pressedIndex = -1; m_pressedIndex = -1;
m_pressedSwatch = -1; m_pressedSwatch = -1;
m_constraint = MenuLayoutConstraint(); m_constraint = MenuLayoutConstraint();
m_scrollFirst = 0;
m_pinnedIndex = -1; m_pinnedIndex = -1;
m_scrollEnabled = false; m_scrollEnabled = false;
m_scrollOffset = 0.0f; m_scrollOffset = 0.0f;
m_scrollRegionHeight = 0.0f; m_scrollRegionHeight = 0.0f;
m_scrollViewport = gd::RectF(); m_scrollViewport = gd::RectF();
m_scrollBarRect = gd::RectF();
m_scrollBarDragging = false;
m_scrollBarGrabOffset = 0.0f;
CloseSubmenu(); CloseSubmenu();
CloseEditState(); 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; continue;
} }
@@ -978,6 +1102,15 @@ namespace jdi
const float fy = static_cast<float>(y); const float fy = static_cast<float>(y);
bool changed = false; 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)) if (m_subOpen && UiPointInRect(m_subRect, fx, fy))
{ {
@@ -1098,6 +1231,34 @@ namespace jdi
EndItemEdit(true, result); 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; int swatchIndex = -1;
m_pressedIndex = ItemAtPoint(x, y, swatchIndex); m_pressedIndex = ItemAtPoint(x, y, swatchIndex);
m_pressedSwatch = swatchIndex; m_pressedSwatch = swatchIndex;
@@ -1113,6 +1274,15 @@ namespace jdi
return false; return false;
} }
// 结束滚动条拖拽(不当作条目点击,菜单保持打开)。
if (m_scrollBarDragging)
{
m_scrollBarDragging = false;
m_pressedIndex = -1;
m_pressedSwatch = -1;
return true;
}
// 子菜单内的抬起:按下与抬起命中同一项才提交(菜单随之关闭)。 // 子菜单内的抬起:按下与抬起命中同一项才提交(菜单随之关闭)。
if (m_subOpen && m_subPressedIndex >= 0) if (m_subOpen && m_subPressedIndex >= 0)
{ {
@@ -1378,35 +1548,42 @@ namespace jdi
} }
}; };
// 可滚动菜单:条目 [0, pinnedFrom) 在滚动区(裁剪到滚动视口), // 可滚动菜单的条目分三段:
// 其余(最后一个分隔线及其之后的条目)钉在菜单底部、不参与滚动。 // * `[0, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**;
const int pinnedFrom = (m_scrollEnabled && m_pinnedIndex >= 0) // * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条);
? m_pinnedIndex // * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。
: static_cast<int>(m_items.size()); const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0;
const int clipState = m_scrollEnabled ? g.Save() : 0; const int scrollFirst = scrollable ? m_scrollFirst : static_cast<int>(m_items.size());
if (m_scrollEnabled) const int scrollLast = scrollable ? m_pinnedIndex : static_cast<int>(m_items.size());
{ int clipState = 0;
g.SetClip(m_scrollViewport, gd::CombineModeIntersect); bool clipActive = false;
}
for (std::size_t index = 0; index < m_items.size(); ++index) for (std::size_t index = 0; index < m_items.size(); ++index)
{ {
const MenuItem& item = m_items[index]; const MenuItem& item = m_items[index];
const gd::RectF& rect = m_itemRects[index]; const gd::RectF& rect = m_itemRects[index];
// 进入固定区:撤掉滚动区的裁剪(固定区永远完整可见)。 const int itemIndex = static_cast<int>(index);
if (m_scrollEnabled && static_cast<int>(index) == pinnedFrom) // 进入/离开滚动区:按需开启/撤销 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); 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 + rect.Height <= m_scrollViewport.Y ||
rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height)) rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height))
{ {
continue; continue;
} }
const bool clipped = m_scrollEnabled && inScrollRegion; const bool clipped = scrollable && inScrollRegion;
if (item.kind == MenuItemKind::SEPARATOR) if (item.kind == MenuItemKind::SEPARATOR)
{ {
@@ -1590,9 +1767,23 @@ namespace jdi
} }
// 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。 // 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。
if (m_scrollEnabled) if (clipActive)
{ {
g.Restore(clipState); 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);
}
} }
// 二级子菜单最后绘制,保证覆盖在主菜单之上。 // 二级子菜单最后绘制,保证覆盖在主菜单之上。