diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index aa4a2b0..681c709 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -472,8 +472,10 @@ namespace jdi m_hintTimerActive = false; } - // 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。 - if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()) + // 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区): + // 仅在动画进行中保持定时重绘。 + if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() || + m_plot.LaneScrollAnimationActive()) { if (!m_scrollTimerActive) { @@ -1401,12 +1403,13 @@ namespace jdi InvalidateRect(hwnd, nullptr, FALSE); return 0; } - // 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘 - //(Render 会在动画结束后停表)。 + // 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」): + // 推进一步并重绘(Render 会在动画结束后停表)。 if (wParam == kScrollTimerId) { m_panel.AdvanceScrollAnimation(); m_panel.AdvanceGroupAnimations(); + m_plot.AdvanceLaneScrollAnimation(); InvalidateRect(hwnd, nullptr, FALSE); return 0; } diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index a23f170..1d53529 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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; /**< 拖拽所作用的网格下标。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 7afa794..8c6c69e 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -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(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(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(elapsed) / static_cast(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; } diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 64b8a0a..a4b74a4 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1085,6 +1085,8 @@ namespace jdi } const double travel = static_cast(y - m_dragLastY) / static_cast(m_plotRect.Height); + // 用户自己拖滚动条 ⇒ 立即取消“点点信号后自动滚入可视区”的动画(否则两者会抢滚动位置)。 + CancelLaneScrollAnimation(); Tab().LaneScroll() += static_cast(travel * m_laneContentHeight); m_dragLastY = y; m_dragMoved = true; @@ -2094,6 +2096,8 @@ namespace jdi return false; } + // 用户自己滚轮滚动 ⇒ 立即取消自动滚入可视区的动画。 + CancelLaneScrollAnimation(); Tab().LaneScroll() -= static_cast(notches * 48.0 * m_scale); BuildLaneVisuals(); return true; diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 9209abe..f4a947e 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -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; /**< 数据点越界贴边时距网格可见边界的间距。 */ diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index a0aba6f..bfbc33b 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -1909,10 +1909,13 @@ namespace jdi const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 - // 这里限制高度(最多约占客户区高度的 62%):超出的部分可滚动翻页, - // 而最后一个分隔线与它下面的「新建网格」**始终钉在菜单底部**(用户 2026-10-16 要求)。 + // 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求): + // 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的 + // 「新建网格」**始终固定不动**。 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); } diff --git a/Components/SignalPanel/Src/SignalPanelInternal.hpp b/Components/SignalPanel/Src/SignalPanelInternal.hpp index 204b2fb..fea620a 100644 --- a/Components/SignalPanel/Src/SignalPanelInternal.hpp +++ b/Components/SignalPanel/Src/SignalPanelInternal.hpp @@ -57,6 +57,14 @@ namespace jdi inline const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */ inline const float kRowTailGap = 6.0f; /**< 网格徽标与行尾 ✕ 之间的间距。 */ + /** + * @brief 「所属网格」菜单里可滚动的网格条目**最多显示的行数**(用户 2026-10-16 要求)。 + * + * 信号很多时(例如把 MF4 的上百条信号各自放进独立网格)该菜单会有上百个条目; + * 条目太多既超出屏幕、又让人难以选择,因此只显示 10 行,其余用滚动条 / 滚轮翻动。 + */ + inline const int kLaneMenuVisibleRows = 10; + /** * @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。 * @param [IN] text 待匹配文本。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index ea5b510..92a4f8d 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -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(**另一台设备**) | **收尾:`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(**起源设备**) | **「所属网格」菜单标题固定 + 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. ⚠️ **交互类缺陷的离屏探针必须“每帧重绘”驱动**(动画 / 分帧推进 / 自持帧): 面板 `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 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index 7ef62e6..16a7581 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -87,14 +87,21 @@ namespace jdi /** * @brief 菜单允许的最大高度(**客户区像素**,0 = 不限制)。 * - * 超过该高度时菜单不再变高:**最后一个分隔线及其之后的条目固定在菜单底部** - * (典型用法:把「新建网格」钉在底部),其上方的内容成为可滚动的条目区 - *(鼠标滚轮 / 键盘方向键滚动)。 + * 超过该高度时菜单不再变高:**前导标题钉在顶部、最后一个分隔线及其之后的条目固定在底部**, + * 中间的内容成为可滚动的条目区(鼠标滚轮 / 键盘方向键 / 滚动条)。 * * 用于「条目数不定」的菜单:例如加载含上百个信号的 MF4 并把每个信号都放进独立网格时, * 「所属网格」菜单会有上百个条目(既超出屏幕、绘制开销也很大)。 */ 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; /** - * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、尾部分隔线之后固定到底部)。 + * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。 * @return 启用滚动返回 true。 */ 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 处理鼠标滚轮:滚动可滚动菜单的条目区。 * @param [IN] delta 滚轮增量(`WM_MOUSEWHEEL` 的 `GET_WHEEL_DELTA_WPARAM`)。 @@ -470,16 +513,20 @@ namespace jdi /** 最近一次使用的布局约束(`RebuildLayout` 复用)。 */ MenuLayoutConstraint m_constraint; /** - * @brief 尾部固定区(钉在菜单底部)的**首个条目下标**;-1 表示没有固定项。 + * @brief 可滚动条目区的下标范围(半开区间 `[m_scrollFirst, m_pinnedIndex)`)。 * - * 由 `BuildLayout` 在启用滚动时设为最后一个分隔线的下标:该分隔线及其之后的条目 - * (典型:「新建网格」)始终画在菜单底部,其上方的内容在滚动区内。 + * `m_scrollFirst` = 前导固定条目数(**「所属网格」这类标题必须始终留在最上方、不参与滚动**); + * `m_pinnedIndex` = 末尾固定区起点(最后一个分隔线,典型「新建网格」钉在底部)。 */ + int m_scrollFirst = 0; int m_pinnedIndex = -1; - bool m_scrollEnabled = false; /**< 条目区是否可滚动(内容超过 `maxHeight` 时启用)。 */ + bool m_scrollEnabled = false; /**< 条目区是否可滚动(内容超过约束上限时启用)。 */ float m_scrollOffset = 0.0f; /**< 滚动区当前的滚动偏移(像素,0 = 顶部)。 */ float m_scrollRegionHeight = 0.0f; /**< 滚动区的内容总高(像素)。 */ gd::RectF m_scrollViewport; /**< 滚动区的可视矩形(也是滚动区条目的裁剪矩形)。 */ + gd::RectF m_scrollBarRect; /**< 滚动条轨道矩形(仅可滚动菜单有效)。 */ + bool m_scrollBarDragging = false; /**< 是否正在拖拽滚动条滑块。 */ + float m_scrollBarGrabOffset = 0.0f; /**< 拖拽时鼠标相对滑块顶部的偏移(像素)。 */ bool m_subOpen = false; /**< 二级子菜单是否展开。 */ int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */ diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 2a2fe67..075e5c6 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -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(m_items.size()) - 1; index > 0; --index) { @@ -187,45 +193,77 @@ namespace jdi break; } } + while (scrollFirst < pinnedFrom && + (m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER || + m_items[static_cast(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(m_items.size()); ++index) { - scrollRegionHeight += heights[static_cast(index)]; - } - for (int index = pinnedFrom; index < static_cast(m_items.size()); ++index) - { - tailHeight += heights[static_cast(index)]; + if (index >= pinnedFrom) + { + tailHeight += heights[static_cast(index)]; + } + else if (index < scrollFirst) + { + headHeight += heights[static_cast(index)]; + } + else + { + scrollRegionHeight += heights[static_cast(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(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(index) < m_pinnedIndex) + const int itemIndex = static_cast(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(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(index) < m_pinnedIndex && - !UiPointInRect(m_scrollViewport, fx, fy)) + // ⚠️ 前导标题与末尾固定项不在滚动区内,它们始终完整可见(不参与本判定)。 + if (m_scrollEnabled && static_cast(index) >= m_scrollFirst && + static_cast(index) < m_pinnedIndex && !UiPointInRect(m_scrollViewport, fx, fy)) { continue; } @@ -978,6 +1102,15 @@ namespace jdi const float fy = static_cast(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(x), static_cast(y))) + { + const float previous = m_scrollOffset; + m_scrollBarDragging = true; + m_scrollBarGrabOffset = static_cast(y) - thumb.Y; + m_pressedIndex = -1; + m_pressedSwatch = -1; + RebuildLayout(); + (void)previous; + return true; + } + if (UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) + { + // 点在轨道空白处:把滑块中心移到该处(翻页)。 + m_scrollBarDragging = true; + m_scrollBarGrabOffset = thumb.Height * 0.5f; + m_scrollOffset = ScrollOffsetForThumbTop(static_cast(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(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(m_items.size()); + const int scrollLast = scrollable ? m_pinnedIndex : static_cast(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(index) == pinnedFrom) + const int itemIndex = static_cast(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(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); + } } // 二级子菜单最后绘制,保证覆盖在主菜单之上。