diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 62fb569..752d0eb 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -278,6 +278,17 @@ namespace jdi */ bool ApplyPendingPanelScroll(); + /** + * @brief 应用信号面板提交的「把绘图区滚动到目标网格」的联动请求。 + * + * 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时, + * 若该信号所在的网格不在绘图区可视区内,就自动调节绘图区的滚动条让该网格完整出现 + *(上百个独立网格时,仅靠网格高亮无法定位到画面之外的网格)。悬停不触发。 + * + * @return 存在请求并已应用返回 true。 + */ + bool ApplyPendingPlotLaneScroll(); + /** * @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index a3600f7..aa4a2b0 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -603,9 +603,27 @@ namespace jdi } return 0; } + case WM_MOUSEWHEEL: + { + // 可滚动菜单(条目过多、按最大高度截断的菜单,如「所属网格」): + // 鼠标在菜单内时滚轮用来翻动条目区,**不关闭菜单**;其余情形仍是 + // “滚轮关闭菜单”(保持既有行为)。 + POINT wheelPoint = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + ScreenToClient(hwnd, &wheelPoint); + if (m_menu.IsScrollable() && m_menu.ContainsPoint(wheelPoint.x, wheelPoint.y)) + { + if (m_menu.OnMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam))) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + m_menu.Close(); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } case WM_RBUTTONDOWN: case WM_MBUTTONDOWN: - case WM_MOUSEWHEEL: m_menu.Close(); InvalidateRect(hwnd, nullptr, FALSE); return 0; @@ -966,6 +984,8 @@ namespace jdi m_activeTarget = Target::PANEL; if (m_panel.OnLButtonDown(x, y)) { + // 点击信号名 ⇒ 让绘图区把目标网格滚动到可视区内(用户 2026-10-16 要求)。 + ApplyPendingPlotLaneScroll(); InvalidateRect(hwnd, nullptr, TRUE); } return 0; diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index bb9bf01..601e633 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -794,6 +794,17 @@ namespace jdi return true; } + bool Application::ApplyPendingPlotLaneScroll() + { + int laneIndex = -1; + if (!m_panel.TakePlotLaneScrollRequest(laneIndex)) + { + return false; + } + // 目标网格不在绘图区可视区内时才真正滚动(已完整可见则不动视图)。 + return m_plot.ScrollLaneIntoView(laneIndex); + } + bool Application::ApplyPendingDerivedBuild() { // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 0f9646e..a23f170 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -923,6 +923,23 @@ namespace jdi */ bool TakePanelScrollRequest(SignalRef& ref); + /** + * @brief 把指定网格纵向滚动到**完整可见**(网格不在可视区时自动调节滚动条)。 + * + * 用户 2026-10-16 要求:信号很多时(例如把 MF4 的上百条信号各自放进独立网格) + * 每个网格只占绘图区的一小段,绝大多数网格都在可视区之外 —— 在信号列表里点选一条 + * 信号后,用户无法从画面上的高亮看出它究竟在哪个网格。因此点选信号时: + * * 目标网格在可视区**上方** ⇒ 向上滚动直到它的上边界进入绘图区上沿; + * * 目标网格在可视区**下方** ⇒ 向下滚动直到它的下边界进入绘图区下沿; + * * 已经完整可见 ⇒ 不动(避免无意义地改变视图)。 + * + * ⚠️ 只在**点击**信号时调用(悬停只做高亮联动,绝不改变视图)。 + * + * @param [IN] laneIndex 网格下标。 + * @return 视图(网格滚动量)发生变化返回 true。 + */ + bool ScrollLaneIntoView(int laneIndex); + /** * @brief 处理编辑态下的字符输入。 * @param [IN] character 输入字符。 @@ -2345,6 +2362,16 @@ namespace jdi bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */ float offsetY = 0.0f; /**< Y 轴偏移。 */ double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */ + /** + * @brief 线宽(逻辑像素)。 + * + * ⚠️ **必须进缓存键**:`BuildPolyline` 会把线宽**烘进包络带几何** + * (`strokeHalf = max(1, 线宽) * scale * 0.5`,逐列上下包络各向外扩这么多), + * 且「梳状」判定也依赖扩展后的列跨度。旧实现漏了这一项 ⇒ 改变线宽时 + * 稠密信号会命中旧条目、一直按**旧线宽**绘制(用户 2026-10-16 报告: + * 拖动线宽滑块时信号线宽不变,松开后再移动鼠标又变回去)。 + */ + float lineWidth = 1.0f; std::vector points; /**< 折线顶点。 */ std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 64b836b..7afa794 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -180,6 +180,48 @@ namespace jdi Tab().LaneScroll() = static_cast(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset)); } + bool PlotView::ScrollLaneIntoView(int laneIndex) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || m_plotRect.Height <= 0.0f) + { + return false; + } + + // ⚠️ 不能直接用 `laneIndex * 网格高`:网格之间虽然目前没有间距,但内容高度会随 + // “最小网格高”夹取而大于绘图区高,公式里已经含有 `- LaneScroll()`。 + // 从当前绘制位置反推**内容偏移**最稳妥:rect.Y = plotRect.Y + contentTop - LaneScroll()。 + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + 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) + { + // 目标网格在可视区上方:把它的上边界对齐到绘图区上沿。 + scroll = contentTop; + } + else if (contentTop + laneHeight - scroll > m_plotRect.Height) + { + // 目标网格在可视区下方:把它的下边界对齐到绘图区下沿。 + scroll = contentTop + laneHeight - m_plotRect.Height; + } + else + { + return false; // 已经完整可见 + } + + Tab().LaneScroll() = scroll; + ClampLaneScroll(); + if (std::fabs(Tab().LaneScroll() - previous) <= 0.01f) + { + return false; + } + // 网格滚动量参与场景/图层指纹,因此重建可视几何(各网格 rect 与 yMin/yMax 依赖滚动量)。 + BuildLaneVisuals(); + return true; + } + bool PlotView::LaneSpansMultipleGroups(int laneIndex) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index eaea630..7c398ee 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -2916,6 +2916,17 @@ namespace jdi continue; } + // ⚠️ **只有一个有效采样点**的信号**不参与**自动切换: + // 它们的初始样式固定为「散点」(见 `ApplySinglePointDefaultStyle`),而信号面板的 + // 「线段」/「点线」按钮已对其置灰(用户 2026-10-16 要求)—— + // 若这里再自动写成「点线」,用户会看到一个“已置灰却仍处于点线态”的矛盾状态。 + if (data->finiteCount == 1) + { + style->autoScatterActive = false; + style->autoScatterUserOverride = false; + continue; + } + // ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」, // 所以自动启用期间 mode 不等于「点线」只可能是用户改的。 // 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去** diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 323ad80..fcafad1 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -175,6 +175,9 @@ namespace jdi const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal; const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f; const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0; + // ⚠️ 线宽也必须进键:`BuildPolyline` 把它烘进包络带几何(逐列外扩半个线宽) + //(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。 + const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f; for (const PolylineCacheEntry& entry : m_polylineCache) { @@ -184,7 +187,8 @@ namespace jdi entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute && entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal && - entry.offsetY == offsetY && entry.offsetTime == offsetTime) + entry.offsetY == offsetY && entry.offsetTime == offsetTime && + entry.lineWidth == lineWidth) { return &entry; } @@ -215,6 +219,7 @@ namespace jdi entry.mirrorHorizontal = mirrorHorizontal; entry.offsetY = offsetY; entry.offsetTime = offsetTime; + entry.lineWidth = lineWidth; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, entry.bandMode, entry.bandSpiky); @@ -924,6 +929,17 @@ namespace jdi m_fastFrame = true; RepaintSceneCachePatch(g, patchLanes); m_sceneDegraded = true; + // ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”, + // 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中 + // “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** —— + // 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新, + // 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。 + // 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。 + for (const int laneIndex : patchLanes) + { + QueueLaneRebuild(laneIndex); + } + RenderPendingLaneRebuilds(1); paintOverlays(); return; } diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index b429aeb..b233c74 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -199,6 +199,28 @@ namespace jdi */ bool EndFilterEdit(); + /** + * @brief 取出并清除「请把绘图区滚动到该网格」的联动请求。 + * + * 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时, + * 若该信号所在网格不在绘图区可视范围内,则自动调节绘图区的滚动条让该网格完整出现。 + * 悬停时**不做**这个滚动(只保留既有的网格高亮联动)。 + * + * @param [OUT] laneIndex 需要滚动到可视区内的网格下标。 + * @return 存在待处理的请求返回 true。 + */ + bool TakePlotLaneScrollRequest(int& laneIndex) + { + if (!m_plotLaneRequestFlag) + { + return false; + } + m_plotLaneRequestFlag = false; + laneIndex = m_pendingPlotLane; + m_pendingPlotLane = -1; + return laneIndex >= 0; + } + /** * @brief 向当前输入框送入一个字符。 * @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。 @@ -644,8 +666,11 @@ namespace jdi /** * @brief 依据内容高度重算滚动条可见性与列表可用宽度。 + * @param [IN] clampScroll 是否顺带把滚动偏移夹到合法范围。 + * ⚠️ 分组折起/展开动画期间必须传 `false`:与滚动同步的折起动画正按自己的进度 + * 推进 `m_scrollOffset`,此时夹取会打断它(折起后内容比列表还短时尤其明显)。 */ - void RefreshScrollBar(); + void RefreshScrollBar(bool clampScroll = true); /** * @brief 限制滚动偏移量到合法范围。 @@ -992,6 +1017,23 @@ namespace jdi */ bool ApplyOpacityFromX(float x); + /** + * @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。 + * + * ⚠️ **只有一个有效采样点**的信号固定使用「散点」:单个数据点在「线段」下根本画不出来, + * 而「点线」与「放大自动点线」机制语义重叠,因此这两个选项对单点信号置灰 + *(用户 2026-10-16 要求)。 + * @param [IN] optionIndex 选项下标(与 `kModeOptions` 同序)。 + * @return 可用返回 true。 + */ + bool ModeOptionEnabled(int optionIndex) const; + + /** + * @brief 登记「点击信号 ⇒ 请宿主把绘图区滚动到该网格」的联动请求。 + * @param [IN] ref 被点击的信号。 + */ + void RequestPlotLaneScroll(const SignalRef& ref); + /** * @brief 依据线宽滑块横坐标调整当前信号线宽。 * @param [IN] x 客户区横坐标。 @@ -1144,6 +1186,11 @@ namespace jdi bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */ bool m_pendingGainMenu = false; /**< 左键抬起时是否弹出「系数」的二级面板。 */ bool m_pendingMirrorMenu = false; /**< 左键抬起时是否弹出「镜像」的二级面板。 */ + bool m_pendingTargetLaneMenu = false; /**< 左键抬起时是否弹出「目标」行 GX 标签的网格菜单。 */ + + /** 待宿主处理的「把绘图区滚动到该网格」请求(见 `TakePlotLaneScrollRequest`)。 */ + bool m_plotLaneRequestFlag = false; + int m_pendingPlotLane = -1; /** * @brief 待确认的「关闭类」按钮(✕)。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 48575c6..6634076 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -48,6 +48,9 @@ namespace jdi m_groupAnims.clear(); m_rowShift.clear(); m_scrollAnimActive = false; + m_pendingTargetLaneMenu = false; + m_plotLaneRequestFlag = false; + m_pendingPlotLane = -1; } void SignalPanel::SetScale(float scale) @@ -453,7 +456,7 @@ namespace jdi } } - void SignalPanel::RefreshScrollBar() + void SignalPanel::RefreshScrollBar(bool clampScroll) { if (m_listFullWidth <= 0.0f) { @@ -475,7 +478,10 @@ namespace jdi m_scrollBarRect = gd::RectF(); } - ClampScroll(); + if (clampScroll) + { + ClampScroll(); + } } void SignalPanel::ClampScroll() @@ -812,6 +818,41 @@ namespace jdi return m_tab->LaneOfSignal(SelectedRef()); } + bool SignalPanel::ModeOptionEnabled(int optionIndex) const + { + if (optionIndex < 0 || optionIndex >= kModeOptionCount) + { + return false; + } + // 单点信号固定用「散点」(见函数说明):其余两种绘制方式置灰且不可点。 + if (m_dataSet == nullptr || !SelectedRef().IsValid()) + { + return true; + } + const SignalData* signal = m_dataSet->Resolve(SelectedRef()); + if (signal == nullptr || signal->finiteCount != 1) + { + return true; + } + return kModeOptions[optionIndex] == DrawMode::SCATTER; + } + + void SignalPanel::RequestPlotLaneScroll(const SignalRef& ref) + { + if (m_tab == nullptr || !ref.IsValid()) + { + return; + } + // 未勾选绘制到网格时 `LaneOfSignal` 返回 -1 ⇒ 不产生请求(没什么好滚的)。 + const int lane = m_tab->LaneOfSignal(ref); + if (lane < 0) + { + return; + } + m_pendingPlotLane = lane; + m_plotLaneRequestFlag = true; + } + void SignalPanel::UpdateTargetRowGeometry() { if (m_fonts == nullptr || m_dataSet == nullptr) diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index 377959d..a0aba6f 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -105,7 +105,7 @@ namespace jdi } const std::uint64_t now = GetTickCount64(); - bool rowsBecameStale = false; + bool animationFinished = false; for (std::size_t index = 0; index < m_groupAnims.size();) { const GroupAnim& anim = m_groupAnims[index]; @@ -117,12 +117,7 @@ namespace jdi { m_scrollOffset = anim.scrollTo; } - // 折起完成后该组的信号行要**真正移除** ⇒ 行集合需重建 - //(展开完成后行集合本来就是展开态,无需重建;平移量归零即可)。 - if (anim.collapsing) - { - rowsBecameStale = true; - } + animationFinished = true; m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); } else @@ -131,13 +126,26 @@ namespace jdi } } - // ⚠️ **不设置 `m_rowsDirty`**:行高不随动画变化,只需重算逐行位移与内容高度 —— + // ⚠️ **动画结束(折起与展开都要)后必须重建行集合**: + // * 折起:该组的信号行要真正从行集合里移除; + // * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”, + // 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会 + // 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。 + // ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 —— // 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。 - UpdateAnimGeometry(); - if (rowsBecameStale) + if (animationFinished) { m_rowsDirty = true; + BuildRows(); // 内部会重算内容高度与逐行位移 } + else + { + UpdateAnimGeometry(); + } + // 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间 + // 列表宽度/滚动条与内容不匹配)。⚠️ 不夹取滚动偏移:与滚动同步的折起动画正按自己的进度 + // 推进 `m_scrollOffset`,此时夹取会打断它。 + RefreshScrollBar(false); return !m_groupAnims.empty(); } @@ -1038,6 +1046,7 @@ namespace jdi m_pendingFilterClear = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; + m_pendingTargetLaneMenu = false; return changed; } @@ -1120,7 +1129,9 @@ namespace jdi { for (int index = 0; index < kModeOptionCount; ++index) { - if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) + // 置灰的选项(单点信号的「线段」/「点线」)不参与悬停高亮 —— + // 它们点不动,高亮会让用户以为可以点击。 + if (ModeOptionEnabled(index) && UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { hoverStyle = StyleHit::MODE; hoverStyleIndex = index; @@ -1255,6 +1266,7 @@ namespace jdi m_pendingColorPicker = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; + m_pendingTargetLaneMenu = false; // 点击当前输入框以外的位置时提交输入。 if (m_editField != StyleEditField::NONE && @@ -1274,18 +1286,27 @@ namespace jdi // 样式编辑区。 if (SignalStyle* style = StyleOf(SelectedRef())) { - // ---- 顶层「目标」行:单击**不进入编辑**(用户明确要求),只做联动滚动 ---- - // 双击改名由 `OnDoubleClick` 处理;这里单击即把列表滚动到目标信号, - // 便于在长列表(上百条信号的 MF4)里定位正在编辑的那条信号。 + // ---- 顶层「目标」行 ---- + // 信号名:单击**不进入编辑**(用户明确要求),只做联动(列表滚动 + 绘图区滚动到该网格); + // GX 标签:单击展开「所属网格」二级菜单(与信号列表行结尾的 GX 徽标**同一份菜单**)。 + // ⚠️ GX 标签**不做**联动滚动(用户 2026-10-16 要求):点击即开菜单、保持原地, + // 但悬停仍然联动高亮(见 `OnMouseMove`)。 { UpdateTargetRowGeometry(); const bool onName = m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy); const bool onBadge = m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy); - if (onName || onBadge) + if (onBadge) + { + // 与网格徽标一致:按下只登记,**左键抬起**且指针仍在同一个徽标上时才展开菜单。 + m_pendingTargetLaneMenu = true; + return true; + } + if (onName) { ScrollToSignalAnimated(SelectedRef()); + RequestPlotLaneScroll(SelectedRef()); return true; } } @@ -1314,6 +1335,11 @@ namespace jdi { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { + // 单点信号的「线段」/「点线」两项置灰 ⇒ 点了不生效(也不做联动滚动)。 + if (!ModeOptionEnabled(index)) + { + return true; + } style->mode = kModeOptions[index]; ScrollToSignalAnimated(SelectedRef()); return true; @@ -1429,6 +1455,12 @@ namespace jdi m_pendingLaneMenu = true; m_pendingLaneRef = ref; } + else + { + // 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内 + //(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。 + RequestPlotLaneScroll(ref); + } return true; } @@ -1550,6 +1582,22 @@ namespace jdi } } + // 「目标」行的 GX 标签:与信号行的网格徽标同一套交互(抬起且仍在同一徽标上才展开)。 + if (m_pendingTargetLaneMenu) + { + m_pendingTargetLaneMenu = false; + const float fx = static_cast(x); + const float fy = static_cast(y); + UpdateTargetRowGeometry(); + if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy) && + m_tab != nullptr && SelectedRef().IsValid()) + { + OpenLaneMenu(SelectedRef(), static_cast(m_targetBadgeRect.X), + static_cast(m_targetBadgeRect.Y + m_targetBadgeRect.Height)); + changed = true; + } + } + return changed; } @@ -1859,7 +1907,13 @@ namespace jdi items.push_back(create); const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); + // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): + // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 + // 这里限制高度(最多约占客户区高度的 62%):超出的部分可滚动翻页, + // 而最后一个分隔线与它下面的「新建网格」**始终钉在菜单底部**(用户 2026-10-16 要求)。 + MenuLayoutConstraint constraint; + constraint.maxHeight = std::max(180.0f, bounds.Height * 0.62f); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint); } bool SignalPanel::OnMenuCommand(const MenuResult& result) diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index 9b5346d..a5e6230 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -539,20 +539,26 @@ namespace jdi } // ---- 第二行:样式(线段 / 散点 / 点线)---- + // ⚠️ 单点信号固定使用「散点」:「线段」在单个数据点上完全画不出来、「点线」与 + // 「放大自动点线」语义重叠,因此它们对单点信号**置灰且不可点**(用户 2026-10-16 要求)。 { paintRowLabel(1, L"样式"); for (int index = 0; index < kModeOptionCount; ++index) { const gd::RectF& rect = m_modeRects[static_cast(index)]; + const bool enabled = ModeOptionEnabled(index); const bool active = (style->mode == kModeOptions[index]); - const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index); + const bool hovered = + enabled && (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index); UiFillRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg)); UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f); UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect, - active ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); + !enabled ? m_theme->textMuted + : (active ? m_theme->textStrong : m_theme->text), + TextAlign::CENTER); } } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 5e5bf1d..ea5b510 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2288,6 +2288,108 @@ Components/Shared/Src/UiControls.cpp --- +### 9.38 五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(本轮,起源设备) + +> 用户 2026-10-16 第四轮(起源设备)。 +> 离屏验证:**`.preflight/check`(新建)68/68 通过**(`SW_HIDE` + 只注入合成消息 + 进程/线程 `BELOW_NORMAL`); +> **A/B**:五处修复全部回退 ⇒ **17 项如实失败**(B 4 / C 4 / D 7 / E 2), +> 其中「折起后滚动条」需要**逐帧重绘驱动动画**才能复现 ⇒ 单独回退时**5 项如实失败**; +> 恢复后 **68/68**;`make release` / `make debug` **零警告**。 +> 另按要求**删除 `Tools/`(`structcheck` 脚本)并移除 `make structcheck` 目标**(见本节七)。 + +#### 一、分组折起后再展开,信号列表的滚动条消失(用户报告) + +- **现象**:加载 MF4 → 折起分组 → 展开分组 ⇒ 列表右侧滚动条**消失且不再回来**(筛选态、普通态均可复现)。 +- **根因**(两处叠加): + * `AdvanceGroupAnimations` 只在**折起**完成时 `m_rowsDirty = true`(认为“展开后行集合本来就是展开态”); + * `RefreshScrollBar()` 只在 `EnsureRows()`(行集脏)/`Layout()` 里被调用 —— + 展开结束那一帧 `m_contentHeight` 从“收起态”跳回“展开态”,但**没有任何路径去重算滚动条可见性**。 + * 于是 `m_scrollBarVisible` 停在动画开始时的 `false`、`m_listRect.Width` 停在“无滚动条”的整宽, + 直到用户做别的动作(筛选 / 缩放窗口 / 勾选信号)才恢复。 +- **修法**:`AdvanceGroupAnimations` 里 **任一方向的动画结束都 `m_rowsDirty = true` 并 `BuildRows()`** + (内部重算内容高度与逐行位移),随后**每帧都调 `RefreshScrollBar(false)`** —— + 让滚动条可见性/列表可用宽度跟随动画中的内容高度实时更新。 +- ⚠️ `RefreshScrollBar` 新增 `clampScroll` 形参(默认 true);**动画期间必须传 false**: + 与滚动同步的折起动画(`scrollSynced`)正按自己的进度推进 `m_scrollOffset`,夹取会打断它。 +- ⚠️⚠️ **探针教训**:必须**逐帧重绘**(每帧 `Render()`)来驱动这个动画, + 否则 `EnsureRows()` 会在动画结束后顺手修好“完成帧的不一致”,把缺陷**掩盖**掉 + (第一版探针就是这样假绿的)。 + +#### 二、单点信号:默认绘制方式改「散点」+ 「线段」/「点线」置灰(用户要求) + +- `ApplySinglePointDefaultStyle`:`DrawMode::LINE_SCATTER` → **`DrawMode::SCATTER`**(仍取最大线宽)。 +- 新增 `SignalPanel::ModeOptionEnabled(optionIndex)`:**只有一个有效采样点**的信号只允许「散点」, + 「线段」「点线」**置灰**(弱色文字)**且不可点**、**不参与悬停高亮**(避免“看着能点其实点不动”)。 +- `PlotView::UpdateAutoScatterStyles` **跳过单点信号**(`finiteCount == 1`): + 否则“放大自动点线”会把它又改回「点线」,与置灰状态自相矛盾。 + +#### 三、拖动线宽滑块完全不生效(用户报告)—— **根因是折线缓存键漏了线宽** + +- **现象**:拖动线宽滑块时信号线宽**纹丝不动**;松开左键的瞬间变了;再移动鼠标又**变回初始线宽**。 +- **根因(两个,都要修)**: + 1. ⚠️⚠️ **`PlotView::AcquirePolyline` 的缓存键没有 `lineWidth`**,而 `BuildPolyline` 会把线宽 + **烘进包络带几何**(`strokeHalf = max(1, 线宽) * scale * 0.5`,逐列上下包络各向外扩这么多), + 「梳状」判定也依赖扩展后的列跨度 ⇒ **稠密信号(采样数 > 绘图区宽度)改变线宽时命中旧条目**、 + 永远按旧线宽绘制(顶点、包络带全部复用)。实测常量信号:`0.5px → 5.3px → 10px` 时 + 绘图区列厚度始终是 **1px**。 + 2. **样式补丁没有同步宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”, + 而每网格图层仍是旧样式像素;下一帧命中“宽基底快路径”时会把新样式**覆盖回旧像素** + ⇒ 这正是用户看到的“松手后变了、移动鼠标又变回去”。 +- **修法**: + * `PolylineCacheEntry` 新增 `lineWidth` 并进比较(凡是**被烘进几何**的样式都必须进键); + * 样式补丁分支在 `RepaintSceneCachePatch` 之后 **`QueueLaneRebuild(受影响网格) + RenderPendingLaneRebuilds(1)`** + —— 按既有预算机制(每帧至多 1 个网格)把宽基底同步到新样式,不在交互帧里做整批重建。 +- **实测**(`.preflight/check`,常量信号 + 逐帧测量信号色像素): + `0.5px` → 列厚 **1px**;`5.3px` → **7px**;`10px` → **12px**;松手后 **12px 保持**;再移动鼠标 **仍 12px**。 + +#### 四、「目标」行 GX 标签复用「所属网格」菜单 + 限高滚动 + 底部固定项(用户要求) + +- **入口**:「目标」行(样式区)的 GX 标签单击 ⇒ 展开与信号列表行末尾 GX 徽标**完全同一份**菜单 + (`SignalPanel::OpenLaneMenu`;按下只登记 `m_pendingTargetLaneMenu`,**抬起仍在同一徽标上**才展开)。 +- ⚠️ GX 标签点击**不做**联动滚动(只有信号名点击才滚动);**悬停联动仍然保留**(`StyleHit::TARGET_BADGE`)。 +- **`PopupMenu` 新增「限高 + 条目区滚动 + 尾部固定」能力**(`MenuLayoutConstraint::maxHeight`): + * 菜单最高 ≈ 客户区高度的 62%(`OpenLaneMenu` 传入); + * **固定区 = 最后一个分隔线及其之后的条目**(即「新建网格」+ 它上面的分隔线), + 始终钉在菜单底部(`m_pinnedIndex` / `m_pinnedTargetTop`);其余条目在滚动区内; + * 滚轮滚动(`PopupMenu::OnMouseWheel`;`Application` 在菜单内滚动时**不关闭菜单**), + 键盘方向键移动悬停项时自动把它滚入可视区(`EnsureItemVisible`); + * ⚠️ 滚动区文本必须用 **`UiTextClipped`**(GDI 文本不遵循 GDI+ 的 `SetClip`), + 并**跳过完全不可见的条目**(否则上百条逐条绘制照样卡顿); + * ⚠️ `ItemAtPoint` 要求滚动区条目**同时命中滚动视口**(夹在边界上的部分可见条目只有可视部分可点); + * ⚠️⚠️ **`BuildLayout` 不得无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发) + 会重走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”,实测踩到)。 + +#### 五、点选信号 ⇒ 绘图区自动把目标网格滚到可视区内(用户要求) + +- **背景**:把 MF4 的上百条信号各自放进独立网格时,绝大多数网格在可视区之外, + 仅靠网格高亮无法定位到画面之外的网格。 +- **链路**:`SignalPanel::RequestPlotLaneScroll(ref)`(→ `m_pendingPlotLane`) + → `Application::ApplyPendingPlotLaneScroll()`(`WM_LBUTTONDOWN` 的面板分支里调用) + → `PlotView::ScrollLaneIntoView(laneIndex)`。 +- **触发条件**:**点击信号名**(列表行主体 / 样式区「目标」行的信号名)。 + ⚠️ **悬停不滚动**(只保留既有网格高亮联动);勾选框 / GX 徽标 / 移除 ✕ 也不滚动。 +- **规则**:目标网格在可视区上方 ⇒ 向上滚到它的上边界贴绘图区上沿;在下方 ⇒ 向下滚到它的下边界贴下沿; + 已完整可见 ⇒ **不动视图**(避免无意义改动)。 + +#### 六、验证(全部离屏,不占用鼠标 / 键盘 / 屏幕) + +- 新增 `.preflight/check/{main.cpp,build.ps1}`(不入库):真实 `Application`(`SW_HIDE`)+ 合成消息注入, + 六个断言组:**[A]** 折起/展开与滚动条、**[B]** 单点信号样式与置灰(含“悬停置灰按钮像素零变化”)、 + **[C]** 线宽拖动逐帧像素度量、**[D]** 「目标」行 GX 菜单(限高 / 滚动 / 固定项 / 命中 / 复用换网格)、 + **[E]** 点选信号滚动绘图区(含“悬停不滚动”与“已可见不动”)、**[F]** 回归(列表行徽标菜单、向上滚动、筛选后滚动条)。 +- 结果:**68/68 通过**;`make release` / `make debug` **零警告**。 +- **A/B 反证**:五处修复全部回退 ⇒ **17 项如实失败**(B 4 / C 4 / D 7 / E 2); + A 组(滚动条)因探针需要**逐帧重绘**才能复现,单独回退时 ⇒ **5 项如实失败**。 + +#### 七、按要求移除远端协助设备的测试工具(用户明确要求) + +- 删除工作区 `Tools/`(`structcheck.ps1` / `structcheck_phrases.txt`)并移除 `Makefile` 的 `structcheck` 目标 + (该工具只是另一台设备上次为“多文件拆分”验证一次性上传的)。 +- ⚠️ §9.37 / §11.2 第 96 条里凡是“`make structcheck` 可复跑”的描述**自此失效**, + 仅作为当时那一轮拆分的**历史证据**保留;后续若要再加结构检查,需要重新上传脚本。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2339,6 +2441,7 @@ Components/Shared/Src/UiControls.cpp | 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **动画改「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(详见 §9.36)**。① **动画重做**(推翻上一轮的“按行缩放”):整组信号作为**刚性整体**平移,**行高永不变**、只维护一个逐行平移量 `m_rowShift` ⇒ 动画期间**无需重建行集合**(流畅关键);收起时逐条被组名栏吞掉、展开时逐条冒出;中途反向点击**从当前位移续接**不跳变;时长 `clamp(115+6×行数, 150, 320)` ms。② **藏进组名栏的裁剪**:信号行越过组名的**绘制**下边界即裁掉,且除 GDI 裁剪外**必须用 `g.SetClip(..., CombineModeIntersect)` 同时收紧 GDI+ 裁剪**(否则行内色块/复选框/徽标画到组名栏上)。③ **悬浮组名折起改为“一边滚动一边收起”**(推翻“先滚动到位再折起”):`scrollSynced` 让滚动与平移按同一进度推进 ⇒ 组名屏幕位置恒定;结束时滚动偏移恰好落在“组名正好在最上方”,并且**无跳变**;旧 `m_pendingGroupToggle` 机制删除。④ **筛选态折叠三角**:`PaintFileRow` 改读 `IsGroupCollapsed()`(筛选模式用的是独立的 `m_filterCollapsed`),修复“三角维持筛选前状态”。⑤ **「目标」行**:`PaintTargetRow` 误用列表裁剪版 `PaintRowText` ⇒ 名称与 GX 文本被完全裁掉(改用 `UiText`);GX 标签改为**右对齐到内容列右边缘**、名称无标签时占满内容列;`EditFieldRect()` 对目标行只返回名称矩形(否则改名输入框把 GX 标签包进去)。验证:离屏探针 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定;含逐帧断言组名栏像素零变化、`m_rowsDirty` 恒 false、滚动单调且恰好落位、筛选/普通态折叠三角逐像素 0 差异、目标行文本像素存在与两端对齐);**A/B** 五组(回退筛选三角+非刚性平移 / 回退目标行绘制与布局 / 关闭同步滚动 / 去掉 GDI+ 收紧裁剪 / 不续接位移)分别 **4 / 6 / 6 / 2 / 1 项如实失败**;`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 | | 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 条的“可复跑”描述自此失效,仅作历史证据)。 | --- @@ -2808,6 +2911,37 @@ Components/Shared/Src/UiControls.cpp 因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。 ⚠️ 宿主定时器同时驱动「折起/展开动画」(见第 77 条):`Render` 的判定必须是 `ScrollAnimationActive() || GroupAnimationActive()`。 +97. ⚠️ **分组折起/展开**动画结束的那一帧**必须重建行集合并按新内容高度刷新滚动条**: + `AdvanceGroupAnimations` 里任一方向结束都会 `m_rowsDirty = true` + `BuildRows()`(重算 + `m_contentHeight` 与逐行位移),随后**每帧** `RefreshScrollBar(false)`。 + 旧实现只在折起结束时重建 ⇒ **展开后滚动条消失且不再回来**(用户 2026-10-16 报告)。 + ⚠️ `RefreshScrollBar(clampScroll = false)`:动画期间不能夹取滚动偏移(会打断 `scrollSynced`)。 + ⚠️ 探针必须以**逐帧重绘**驱动动画,否则 `EnsureRows()` 会把缺陷掩盖掉。 +98. ⚠️ **单点信号(`finiteCount == 1`)固定使用「散点」**:`ApplySinglePointDefaultStyle` 写入 + `DrawMode::SCATTER` + 最大线宽;信号面板「样式」行的「线段」「点线」对它**置灰且不可点** + (`SignalPanel::ModeOptionEnabled`,同时不参与悬停高亮); + `PlotView::UpdateAutoScatterStyles` **必须跳过单点信号**(否则会又把它改回「点线」)。 +99. ⚠️⚠️ **折线顶点缓存(`PolylineCacheEntry`)的键必须包含所有“被烘进几何”的样式** —— + 目前是 **`lineWidth`**(`BuildPolyline` 把每个像素列的上下包络向外扩 `max(1, 线宽)*scale*0.5`, + 「梳状」判定也依赖它)。漏掉线宽会让**稠密信号**(采样数 > 绘图区宽)的线宽改动完全不生效 + (用户 2026-10-16 报告:拖动线宽滑块无变化、松手后变了、再移动鼠标又变回去)。 + 新增任何影响顶点/包络几何的样式时,必须同步加入缓存键。 +100. ⚠️ **“样式补丁”(`RepaintSceneCachePatch`)之后必须同步重建受影响的宽基底图层** + (`QueueLaneRebuild(patchLanes)` + `RenderPendingLaneRebuilds(1)`): + 补丁只更新“场景缓存 + 后备缓冲区”,而每网格图层(宽基底)里仍是旧样式像素, + 下一帧命中“宽基底快路径”时会把新样式**覆盖回旧像素**(同上一条的第二个根因)。 +101. ⚠️ **`PopupMenu` 的“限高 + 可滚动 + 尾部固定项”**(`MenuLayoutConstraint::maxHeight`): + 固定区 = **最后一个分隔线及其之后的条目**(如「新建网格」)钉在菜单底部,其余条目可滚动; + ⚠️ 滚动区文本必须用 `UiTextClipped`(GDI 不遵循 `SetClip`)、**跳过完全不可见的条目**(性能); + `ItemAtPoint` 要求滚动区条目同时命中**滚动视口**; + ⚠️⚠️ `BuildLayout` **不得无条件清零 `m_scrollOffset`**(`RebuildLayout` 会重走它 ⇒ 滚轮“滚不动”)。 + `Application` 在菜单内滚动滚轮时**不关闭菜单**(其余情形仍是“滚轮关菜单”)。 +102. ⚠️ **“点选信号 ⇒ 绘图区滚到目标网格”只在点击时生效**(`RequestPlotLaneScroll` / + `ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):**悬停绝不改变视图**; + 目标网格已完整可见时**不动**。“目标”行 GX 标签点击 = 打开网格菜单(不滚动),悬停仍联动高亮。 +103. ⚠️ **交互类缺陷的离屏探针必须“每帧重绘”驱动**(动画 / 分帧推进 / 自持帧): + 面板 `Paint` → `EnsureRows()`、绘图区 `Paint` → 预算推进,都会在重绘时**顺手修复**一部分 + 中间态;只在动作结束后才重绘会把“完成帧”的缺陷掩盖掉(本轮滚动条缺陷就因此假绿过一次)。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Makefile b/Makefile index d7e043f..01d79d3 100644 --- a/Makefile +++ b/Makefile @@ -188,11 +188,6 @@ debug: $(BUILD_DIR)/$(SUB_DIR_DEBUG)/$(TARGET_FILE_NAME).exe clean: $(BUILD_DIR) powershell rm -r $(BUILD_DIR) -# 结构检查任务:校验「巨型文件按职责拆分」与「Components/Shared 共享控件」两项结构约束 -# (需要先完成一次 release 构建,脚本会读取 Build/Release 下的目标文件) -structcheck: - powershell -ExecutionPolicy Bypass -File Tools/structcheck.ps1 - # 构建目录生成 $(BUILD_DIR): powershell mkdir $@ diff --git a/Tools/structcheck.ps1 b/Tools/structcheck.ps1 deleted file mode 100644 index 93e6102..0000000 --- a/Tools/structcheck.ps1 +++ /dev/null @@ -1,275 +0,0 @@ -# Structural verification (NOT part of the shipped app; under .preflight/, gitignored). -# -# Proves on disk -- not by prose -- that the two structural requirements are satisfied: -# [1] The giant implementation files were split by responsibility into sibling files -# under each component's own Src/ (and each new translation unit is compiled). -# [2] The common UI controls live in a real `Components/Shared` component, are compiled -# into the binary, and are consumed by the other components. -# -# Run: powershell -File .preflight\perf\structcheck.ps1 -# ASCII only (PowerShell 5.1 reads .ps1 as ANSI). - -$ErrorActionPreference = "Stop" -$root = Split-Path -Parent $PSScriptRoot -Set-Location $root - -$pass = 0 -$fail = 0 - -function Check([bool]$ok, [string]$what) { - if ($ok) { $script:pass++ ; Write-Host (" OK " + $what) } - else { $script:fail++ ; Write-Host (" FAIL " + $what) } -} - -function Lines([string]$path) { - if (-not (Test-Path $path)) { return -1 } - return (Get-Content $path | Measure-Object -Line).Lines -} - -Write-Host "== [1] split of the giant implementation files ==" - -# 1a. every expected file of the split exists and is a sibling under the component's Src/ -$split = @( - "Components/PlotView/Src/PlotView.cpp", - "Components/PlotView/Src/PlotViewPaint.cpp", - "Components/PlotView/Src/PlotViewInteraction.cpp", - "Components/PlotView/Src/PlotViewFeatures.cpp", - "Components/PlotView/Src/PlotViewInternal.hpp", - "Components/SignalPanel/Src/SignalPanel.cpp", - "Components/SignalPanel/Src/SignalPanelPaint.cpp", - "Components/SignalPanel/Src/SignalPanelInteraction.cpp", - "Components/SignalPanel/Src/SignalPanelInternal.hpp", - "Components/Application/Src/Application.cpp", - "Components/Application/Src/ApplicationCommands.cpp", - "Components/Application/Src/ApplicationLoading.cpp", - "Components/Application/Src/ApplicationInternal.hpp", - "Utilities/Src/DataModel.cpp", - "Utilities/Src/DataModelTab.cpp", - "Utilities/Src/DataModelInternal.hpp" -) -foreach ($f in $split) { - $n = Lines $f - Check ($n -gt 0) ("exists + non-empty: " + $f + " (" + $n + " lines)") -} - -# 1b. the core file of each split is a small fraction of the original monster file -Check ((Lines "Components/PlotView/Src/PlotView.cpp") -lt 900) "PlotView.cpp shrunk to < 900 lines (was 13 695)" -Check ((Lines "Components/PlotView/Src/PlotViewPaint.cpp") -gt 4000) "PlotViewPaint.cpp carries the rendering (> 4000 lines)" -Check ((Lines "Components/SignalPanel/Src/SignalPanel.cpp") -lt 1200) "SignalPanel.cpp shrunk to < 1200 lines (was 4 184)" -Check ((Lines "Components/Application/Src/Application.cpp") -lt 1500) "Application.cpp shrunk to < 1500 lines (was 3 135)" -Check ((Lines "Utilities/Src/DataModel.cpp") -lt 1600) "DataModel.cpp shrunk to < 1600 lines (was 2 615)" - -# 1c. responsibility really moved: each concern is defined in exactly one file -$concerns = @( - @{ symbol = "PlotView::Paint"; file = "PlotViewPaint.cpp" }, - @{ symbol = "PlotView::PaintLane"; file = "PlotViewPaint.cpp" }, - @{ symbol = "PlotView::PaintHint"; file = "PlotViewPaint.cpp" }, - @{ symbol = "PlotView::LaneAtY"; file = "PlotViewInteraction.cpp" }, - @{ symbol = "PlotView::CancelDrag"; file = "PlotViewInteraction.cpp" }, - @{ symbol = "PlotView::FitAll"; file = "PlotViewFeatures.cpp" }, - @{ symbol = "PlotView::SetHint"; file = "PlotViewFeatures.cpp" }, - @{ symbol = "PlotView::Layout"; file = "PlotView.cpp" }, - @{ symbol = "PlotView::BuildLaneVisuals"; file = "PlotView.cpp" }, - @{ symbol = "SignalPanel::Paint"; file = "SignalPanelPaint.cpp" }, - @{ symbol = "SignalPanel::OnLButtonDown"; file = "SignalPanelInteraction.cpp" }, - @{ symbol = "SignalPanel::EnsureRows"; file = "SignalPanel.cpp" } -) -foreach ($c in $concerns) { - $hits = @(Select-String -Path "Components/*/Src/*.cpp", "Utilities/Src/*.cpp" -Pattern ("^\s*\w[\w:<>, ]* " + [regex]::Escape($c.symbol) + "\(") | - ForEach-Object { Split-Path $_.Path -Leaf }) - $ok = ($hits.Count -eq 1) -and ($hits[0] -eq $c.file) - Check $ok ($c.symbol + " defined only in " + $c.file + " (found: " + ($hits -join ",") + ")") -} - -# 1d. the constant/tool block of each big file became a named internal header + using-declaration -$internal = @( - @{ file = "Components/PlotView/Src/PlotViewInternal.hpp"; ns = "pvdetail" }, - @{ file = "Components/SignalPanel/Src/SignalPanelInternal.hpp"; ns = "spdetail" }, - @{ file = "Components/Application/Src/ApplicationInternal.hpp"; ns = "appdetail" }, - @{ file = "Utilities/Src/DataModelInternal.hpp"; ns = "dmdetail" } -) -foreach ($i in $internal) { - $text = Get-Content $i.file -Raw - $hasNs = $text -match ("namespace\s+" + $i.ns) - $hasUsing = $text -match ("using namespace\s+" + $i.ns + "\s*;") - $inlineCount = ([regex]::Matches($text, "(?m)^\s*(?:\[\[[^\]]*\]\]\s*)?inline\s")).Count - Check $hasNs ($i.file + " declares namespace " + $i.ns) - Check $hasUsing ($i.file + " injects it via the using-declaration for " + $i.ns) - Check ($inlineCount -ge 4) ($i.file + " marks the shared helpers inline (" + $inlineCount + " definitions)") -} - -# 1e. the split files are compiled, and their objects hold the moved code -$objs = @("PlotView.o","PlotViewPaint.o","PlotViewInteraction.o","PlotViewFeatures.o", - "SignalPanel.o","SignalPanelPaint.o","SignalPanelInteraction.o", - "Application.o","ApplicationCommands.o","ApplicationLoading.o", - "DataModel.o","DataModelTab.o") -$missing = @() -foreach ($o in $objs) { if (-not (Test-Path ("Build/Release/" + $o))) { $missing += $o } } -Check ($missing.Count -eq 0) ("all split translation units were compiled (missing: " + ($missing -join ",") + ")") - -$nm = Get-Command nm -ErrorAction SilentlyContinue -if ($nm -ne $null) { - $paintInPaint = @(nm --defined-only -C "Build/Release/PlotViewPaint.o" 2>$null | Select-String "jdi::PlotView::Paint") - $paintInCore = @(nm --defined-only -C "Build/Release/PlotView.o" 2>$null | Select-String "jdi::PlotView::Paint") - Check ($paintInPaint.Count -gt 0) "nm: paint symbols live in PlotViewPaint.o" - Check ($paintInCore.Count -eq 0) "nm: no paint symbol left in PlotView.o" -} else { - Check $false "nm not available (cannot verify symbol placement)" -} - -# 1f. every implementation file of every component is registered in the Makefile -$srcFiles = @(Get-ChildItem "Components/*/Src/*.cpp", "Utilities/Src/*.cpp" | ForEach-Object { - $_.FullName.Substring($root.Length + 1).Replace("\", "/") -}) -$makefile = Get-Content "Makefile" -Raw -$unregistered = @() -foreach ($s in $srcFiles) { - $dir = $s.Substring(0, $s.LastIndexOf("/")) - if ($makefile -notmatch [regex]::Escape($dir)) { $unregistered += $s } -} -Check ($unregistered.Count -eq 0) ("every implementation directory is registered in the Makefile (missing: " + ($unregistered -join ",") + ")") -Check ($srcFiles.Count -ge 24) ("implementation files in the tree: " + $srcFiles.Count) - -Write-Host "" -Write-Host "== [2] Components/Shared ==" - -Check (Test-Path "Components/Shared/Inc/UiControls.hpp") "Components/Shared/Inc/UiControls.hpp exists" -Check (Test-Path "Components/Shared/Src/UiControls.cpp") "Components/Shared/Src/UiControls.cpp exists" -Check ((Lines "Components/Shared/Inc/UiControls.hpp") -gt 100) ("UiControls.hpp declares the control API (" + (Lines "Components/Shared/Inc/UiControls.hpp") + " lines)") - -# 2a. the shared header really owns the enumerated control families -$families = @("UiDrawPlusIcon","UiDrawCrossIcon","UiDrawTriangleIcon","UiDrawCheckBox", - "UiDrawSlider","UiDrawEditField","UiEditFieldMouseDown","UiDrawSwatch", - "UiSwatchNeedsOutline","UiContrastInk","UiSliderRatioFromX") -$header = Get-Content "Components/Shared/Inc/UiControls.hpp" -Raw -$missingFamilies = @() -foreach ($f in $families) { if ($header -notmatch ("\b" + $f + "\b")) { $missingFamilies += $f } } -Check ($missingFamilies.Count -eq 0) ("shared header exposes all control families (missing: " + ($missingFamilies -join ",") + ")") - -# 2b. symbol-level proof: every declared control is DEFINED by UiControls.o and by no other object. -# (Text matching cannot tell a definition from a call, so ask the linker's view instead.) -$declared = @([regex]::Matches($header, "(?m)^\s*[\w:<>, ]+\s+(Ui\w+)\s*\(") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) -Check ($declared.Count -ge 15) ("UiControls.hpp declares " + $declared.Count + " control functions") - -$definitionOwner = @{} -$objectFiles = @(Get-ChildItem "Build/Release/*.o" -ErrorAction SilentlyContinue) -if ($nm -eq $null) { - Check $false "nm not available (cannot verify control definitions)" -} else { - foreach ($o in $objectFiles) { - $syms = nm --defined-only -C $o.FullName 2>$null - foreach ($name in $declared) { - if ($syms | Select-String (" T jdi::" + [regex]::Escape($name) + "\(")) { - $definitionOwner[$name] = $o.Name - } - } - } - $wrongOwner = @($declared | Where-Object { -not $definitionOwner.ContainsKey($_) -or $definitionOwner[$_] -ne "UiControls.o" }) - Check ($wrongOwner.Count -eq 0) ("nm: all " + $declared.Count + " declared controls are defined in UiControls.o (wrong/undefined: " + ($wrongOwner -join ",") + ")") - $uiControlsSymbols = @(nm --defined-only -C "Build/Release/UiControls.o" 2>$null | Select-String " T jdi::Ui").Count - Check ($uiControlsSymbols -ge 15) ("nm: UiControls.o defines " + $uiControlsSymbols + " Ui* symbols") -} - -# 2c. other components actually consume them (>= 3 components, >= 30 call sites) -$consumers = @(Select-String -Path "Components/*/Src/*.cpp", "Components/*/Inc/*.hpp" -Pattern "UiDrawPlusIcon|UiDrawCrossIcon|UiDrawTriangleIcon|UiDrawCheckBox|UiDrawSlider|UiDrawEditField|UiEditFieldMouseDown|UiEditFieldMouseMove|UiDrawSwatch|UiSwatchNeedsOutline|UiContrastInk|UiSliderRatioFromX|UiEditTextLeft|UiEditOffsetAt" | - Where-Object { (Split-Path $_.Path -Leaf) -ne "UiControls.hpp" } | - Group-Object { ($_.Path.Substring($root.Length + 1) -split "[\\/]")[1] }) -$componentNames = @($consumers | ForEach-Object { $_.Name }) -$totalCalls = 0 -foreach ($g in $consumers) { $totalCalls += $g.Count } -Check ($componentNames.Count -ge 3) ("shared controls consumed by >= 3 components (" + ($componentNames -join ",") + ")") -Check ($totalCalls -ge 30) ("shared control call sites across components: " + $totalCalls) - -# 2d. layering, stated exactly as the code obeys it: -# Components/Shared -> Utilities only (it must not include any component header) -# Utilities/PaintUtil = base layer (must not include UiControls.hpp) -# Utilities must not include feature components (PlotView / SignalPanel / Toolbar / TabBar) -# NOTE: Utilities/Src/PopupMenu.cpp and Utilities/Src/ColorPickerPopup.cpp are hand-drawn UI -# widgets that live in the Utilities folder historically; they consume the shared controls -# (same direction as the components do) and are deliberately excluded from the ban below. -$sharedIncludes = @(Select-String -Path "Components/Shared/Inc/*.hpp", "Components/Shared/Src/*.cpp" -Pattern '^#include "' | - Where-Object { $_.Line -match '"(Application|PlotView|SignalPanel|Toolbar|TabBar|PopupMenu|ColorPickerPopup)\.hpp"' }) -Check ($sharedIncludes.Count -eq 0) "Components/Shared depends on Utilities only (no component header)" -$paintUtilIncludes = @(Select-String -Path "Utilities/Inc/PaintUtil.hpp", "Utilities/Src/PaintUtil.cpp" -Pattern '#include "UiControls.hpp"' -ErrorAction SilentlyContinue) -Check ($paintUtilIncludes.Count -eq 0) "Utilities/PaintUtil (base layer) does not depend on Components/Shared" -$badDir = @(Select-String -Path "Utilities/Src/*.cpp", "Utilities/Inc/*.hpp" -Pattern '#include "(PlotView|SignalPanel|Toolbar|TabBar)' -ErrorAction SilentlyContinue) -Check ($badDir.Count -eq 0) "Utilities never includes a feature component" - -Write-Host "" -Write-Host "== [3] user-requested behaviour invariants (source level) ==" - -$utf8 = New-Object System.Text.UTF8Encoding($false) -$panelFile = "Components/SignalPanel/Src/SignalPanel.cpp" -$panelIntFile = "Components/SignalPanel/Src/SignalPanelInteraction.cpp" -$modelFile = "Utilities/Inc/DataModel.hpp" - -$panelText = [System.IO.File]::ReadAllText($panelFile, $utf8) -$panelIntText = [System.IO.File]::ReadAllText($panelIntFile, $utf8) -$modelText = [System.IO.File]::ReadAllText($modelFile, $utf8) - -# 3a. collapsing a group needs the disclosure triangle OR the "Group N" label -- clicking the name -# itself must not collapse it. (User 2026-10-16.) -Check ($panelText -match "return RowPart::GROUP_TITLE;") "hit test yields a dedicated GROUP_TITLE part" -Check ($panelText -match "const gd::RectF labelRect\(labelLeft, rect\.Y, GroupLabelWidth\(row\), rect\.Height\);") "GROUP_TITLE hot zone is exactly the Group-N label rect" -$gate = @(Select-String -Path $panelIntFile -Pattern "^\s*if \(part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE\)") -Check ($gate.Count -eq 1) ("single-click toggle gate accepts only DISCLOSURE / GROUP_TITLE (found " + $gate.Count + ")") -$toggles = @(Select-String -Path $panelIntFile -Pattern "^\s*ToggleGroupFromRow\(item\.groupIndex, row\);") -Check ($toggles.Count -eq 2) ("ToggleGroupFromRow has exactly 2 entry points, single click + double click (found " + $toggles.Count + ")") -Check ($panelIntText -match "(?s)if \(part == RowPart::DISCLOSURE\)\s*\{\s*//[^\n]*\n(\s*//[^\n]*\n)*\s*ToggleGroupFromRow") "double click on the triangle still collapses" -Check ($panelIntText -match "(?s)if \(part != RowPart::BODY\)\s*\{\s*return false;\s*\}") "double click outside the name body never starts a rename" - -# 3b. the group rename box must stop before the "N items" counter. (User 2026-10-16.) -Check ($panelText -match "right = statsRight - statsWidth - 6\.0f \* m_scale;") "rename box right edge excludes the item counter" -Check ($panelText -notmatch "right = statsRight;") "rename box never extends over the counter" -Check ($panelText -match "const float statsWidth = UiMeasureTextWidth\(m_fonts->uiSmall, GroupStatsText\(row\)\);") "counter width comes from GroupStatsText" - -# 3c. SignalData / SignalGroup keep exactly ONE name field. (User 2026-10-16: drop displayName.) -$data = [regex]::Match($modelText, "(?s)struct SignalData\s*\{(.*?)\n\t\};") -Check ($data.Success) "SignalData struct found in the data model" -if ($data.Success) { - $body = $data.Groups[1].Value - $fields = @([regex]::Matches($body, "std::wstring\s+\w+;")) - Check ($fields.Count -eq 1) ("SignalData declares exactly one string field (found " + $fields.Count + ")") - Check ($body -match "std::wstring name;") "that single field is name" -} -$group = [regex]::Match($modelText, "(?s)struct SignalGroup\s*\{(.*?)\n\t\};") -Check ($group.Success) "SignalGroup struct found in the data model" -if ($group.Success) { - $gbody = $group.Groups[1].Value - $gfields = @([regex]::Matches($gbody, "std::wstring\s+\w+;")) - Check ($gfields.Count -eq 1) ("SignalGroup declares exactly one string field (found " + $gfields.Count + ")") -} -Check ($modelText -match "bool RenameSignal\(") "DataSet::RenameSignal replaces the old SetSignalDisplayName" -Check ($modelText -match "bool RenameGroup\(") "DataSet::RenameGroup replaces the old SetGroupDisplayName" -$staleNames = @(Select-String -Path "Components/*/Src/*.cpp","Components/*/Inc/*.hpp","Components/*/Src/*.hpp", - "Utilities/Src/*.cpp","Utilities/Inc/*.hpp" -Pattern "displayName|SetSignalDisplayName|SetGroupDisplayName|\.Title\(\)|->Title\(\)") -Check ($staleNames.Count -eq 0) ("no displayName / Title() left in any source (found " + $staleNames.Count + ")") - -# 3d. comment hygiene, driven by a UTF-8 data file (the .ps1 itself must stay ASCII for PowerShell 5.1) -$phraseFile = "Tools/structcheck_phrases.txt" -Check (Test-Path $phraseFile) "comment-hygiene phrase list exists" -if (Test-Path $phraseFile) { - $scanFiles = @(Get-ChildItem "Components/*/Src/*.cpp","Components/*/Src/*.hpp","Components/*/Inc/*.hpp", - "Utilities/Src/*.cpp","Utilities/Inc/*.hpp" | ForEach-Object { $_.FullName }) - $scanText = @{} - foreach ($f in $scanFiles) { $scanText[$f] = [System.IO.File]::ReadAllText($f, $utf8) } - foreach ($line in [System.IO.File]::ReadAllLines($phraseFile, $utf8)) { - if ([string]::IsNullOrWhiteSpace($line)) { continue } - $parts = $line -split "`t" - $mode = $parts[0] - if ($mode -eq "forbid") { - $phrase = $parts[1] - $offenders = @($scanFiles | Where-Object { $scanText[$_].Contains($phrase) }) - Check ($offenders.Count -eq 0) ("stale wording absent everywhere: " + $phrase + " (found in " + $offenders.Count + " files)") - } elseif ($mode -eq "require") { - $target = $parts[1] - $phrase = $parts[2] - Check ((Test-Path $target) -and ([System.IO.File]::ReadAllText($target, $utf8).Contains($phrase))) ("corrected comment present in " + $target + ": " + $phrase) - } - } -} - -Write-Host "" -Write-Host ("structcheck: " + $pass + " passed, " + $fail + " failed") -if ($fail -ne 0) { exit 1 } -exit 0 diff --git a/Tools/structcheck_phrases.txt b/Tools/structcheck_phrases.txt deleted file mode 100644 index d42e876..0000000 --- a/Tools/structcheck_phrases.txt +++ /dev/null @@ -1,13 +0,0 @@ -forbid 文件下标 -forbid 所属文件下标 -forbid 原信号所属文件下标 -forbid 文件分组 -forbid 原文件下标 -forbid 文件序号 -forbid displayName -forbid SetSignalDisplayName -forbid SetGroupDisplayName -require Components/Application/Inc/Application.hpp 原信号所属分组下标 -require Utilities/Inc/DataModel.hpp 信号名称(加载时取列名 / 通道名;用户可在界面上重命名) -require Utilities/Inc/DataModel.hpp 分组名(加载时取源文件名、含扩展名;用户可重命名) -require Utilities/Inc/DataModel.hpp 与磁盘上的文件**没有任何关系** diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 4eede8e..fa0d4d4 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -38,7 +38,9 @@ namespace jdi /** * @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。 * - * 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。 + * 单个数据点的初始绘制方式是「散点」,半径随线宽线性增长;取最大值才能看清那个点。 + * ⚠️ 用户 2026-10-16 要求:初始绘制方式由「点线」改为「**散点**」, + * 同时信号面板「样式」行的「线段」/「点线」两个按钮对该信号置灰。 * ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式, * 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制 * 已经覆盖了那个场景,无需再改默认样式。 diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index 8c20c43..7ef62e6 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -79,6 +79,24 @@ namespace jdi bool keepOpen = false; }; + /** + * @brief 打开菜单时的附加布局约束。 + */ + struct MenuLayoutConstraint + { + /** + * @brief 菜单允许的最大高度(**客户区像素**,0 = 不限制)。 + * + * 超过该高度时菜单不再变高:**最后一个分隔线及其之后的条目固定在菜单底部** + * (典型用法:把「新建网格」钉在底部),其上方的内容成为可滚动的条目区 + *(鼠标滚轮 / 键盘方向键滚动)。 + * + * 用于「条目数不定」的菜单:例如加载含上百个信号的 MF4 并把每个信号都放进独立网格时, + * 「所属网格」菜单会有上百个条目(既超出屏幕、绘制开销也很大)。 + */ + float maxHeight = 0.0f; + }; + /** * @brief 菜单选择结果。 */ @@ -127,9 +145,11 @@ namespace jdi * @param [IN] expandUp 为 true 时菜单**向上展开**,且 `(x, y)` 是菜单的**左下角** * (用于“二级面板贴在按钮左上方”这类锚定);为 false(默认)时向右下展开, * `(x, y)` 是菜单的左上角。 + * @param [IN] constraint 附加布局约束(最大高度 / 超长时可滚动),默认不限制。 */ void Open(MenuScope scope, const gd::RectF& bounds, int x, int y, - const std::vector& items, bool expandUp = false); + const std::vector& items, bool expandUp = false, + const MenuLayoutConstraint& constraint = MenuLayoutConstraint()); /** * @brief 关闭菜单。 @@ -196,6 +216,19 @@ namespace jdi */ bool ContainsPoint(int x, int y) const; + /** + * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、尾部分隔线之后固定到底部)。 + * @return 启用滚动返回 true。 + */ + bool IsScrollable() const { return m_open && m_scrollEnabled; } + + /** + * @brief 处理鼠标滚轮:滚动可滚动菜单的条目区。 + * @param [IN] delta 滚轮增量(`WM_MOUSEWHEEL` 的 `GET_WHEEL_DELTA_WPARAM`)。 + * @return 滚动位置发生变化返回 true。 + */ + bool OnMouseWheel(int delta); + /** * @brief 设置剪贴板所有者窗口句柄(供内联输入框复制/粘贴)。 * @param [IN] host 宿主窗口句柄,允许为空。 @@ -392,6 +425,13 @@ namespace jdi */ void PaintSubmenu(gd::Graphics& g); + /** + * @brief 键盘移动悬停项后,把该项滚动到可滚动区的可视范围内。 + * @param [IN] index 目标条目下标。 + * @return 滚动偏移发生变化返回 true。 + */ + bool EnsureItemVisible(int index); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ @@ -427,6 +467,20 @@ namespace jdi int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */ bool m_buildExpandUp = false; /**< 最近一次排布是否为“向上展开(锚点为左下角)”。 */ + /** 最近一次使用的布局约束(`RebuildLayout` 复用)。 */ + MenuLayoutConstraint m_constraint; + /** + * @brief 尾部固定区(钉在菜单底部)的**首个条目下标**;-1 表示没有固定项。 + * + * 由 `BuildLayout` 在启用滚动时设为最后一个分隔线的下标:该分隔线及其之后的条目 + * (典型:「新建网格」)始终画在菜单底部,其上方的内容在滚动区内。 + */ + int m_pinnedIndex = -1; + bool m_scrollEnabled = false; /**< 条目区是否可滚动(内容超过 `maxHeight` 时启用)。 */ + float m_scrollOffset = 0.0f; /**< 滚动区当前的滚动偏移(像素,0 = 顶部)。 */ + float m_scrollRegionHeight = 0.0f; /**< 滚动区的内容总高(像素)。 */ + gd::RectF m_scrollViewport; /**< 滚动区的可视矩形(也是滚动区条目的裁剪矩形)。 */ + bool m_subOpen = false; /**< 二级子菜单是否展开。 */ int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */ gd::RectF m_subRect; /**< 子菜单整体矩形。 */ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 666025e..c66eaaf 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -48,8 +48,12 @@ namespace jdi /** * @brief 为**只有一个有效采样点**的信号设置初始绘制样式。 * - * 单个数据点在「线段」模式下完全画不出来,因此初始就用「点线」并取**最大线宽** - * (点线模式下数据点是一个圆点,半径与线宽成正比)。 + * 单个数据点在「线段」模式下完全画不出来,因此初始就用「散点」并取**最大线宽** + * (散点半径随线宽线性增长,取最大值才能看清那个点)。 + * + * ⚠️ 用户 2026-10-16 要求:初始绘制方式由「点线」改为「**散点**」,并且信号面板 + * 「样式」行里的「线段」/「点线」两个按钮对该信号**置灰** + * (见 `SignalPanel::ModeOptionEnabled`)。 * * ⚠️ 曾经还对「有效采样点 2…16」的信号也改用「点线 + 2px」—— 该需求已**作废** *(用户 2026-10-15 明确取消:放大后自动切点线的机制已覆盖那个场景)。 @@ -63,7 +67,7 @@ namespace jdi { if (finiteCount == 1) { - style.mode = DrawMode::LINE_SCATTER; + style.mode = DrawMode::SCATTER; style.lineWidth = kSinglePointSignalLineWidth; } } diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 9776df6..2a2fe67 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -125,10 +125,12 @@ namespace jdi float contentWidth = kMinWidth * m_scale; float totalHeight = kPadV * 2.0f * m_scale; + std::vector heights(m_items.size(), 0.0f); for (std::size_t index = 0; index < m_items.size(); ++index) { const MenuItem& item = m_items[index]; - totalHeight += ItemHeight(item); + heights[index] = ItemHeight(item); + totalHeight += heights[index]; if (item.kind == MenuItemKind::SWATCHES) { @@ -168,7 +170,76 @@ namespace jdi contentWidth = std::min(contentWidth, kMaxWidth * m_scale); const float width = contentWidth; - const float height = totalHeight; + + // ------------------------------------------------------------------ + // 超长菜单:限制高度 + 条目区可滚动 + 尾部固定项(见 `MenuLayoutConstraint`)。 + // 固定区 = **最后一个分隔线及其之后的条目**(典型:「新建网格」), + // 它始终钉在菜单底部,因此滚动不会把“新建网格”飘走。 + // ------------------------------------------------------------------ + int pinnedFrom = -1; + if (m_constraint.maxHeight > 0.0f) + { + for (int index = static_cast(m_items.size()) - 1; index > 0; --index) + { + if (m_items[static_cast(index)].kind == MenuItemKind::SEPARATOR) + { + pinnedFrom = index; + break; + } + } + } + + float scrollRegionHeight = 0.0f; + float tailHeight = 0.0f; + if (pinnedFrom > 0) + { + for (int index = 0; index < pinnedFrom; ++index) + { + scrollRegionHeight += heights[static_cast(index)]; + } + for (int index = pinnedFrom; index < static_cast(m_items.size()); ++index) + { + tailHeight += heights[static_cast(index)]; + } + } + + float height = totalHeight; + m_scrollEnabled = false; + m_scrollRegionHeight = 0.0f; + m_pinnedIndex = -1; + m_scrollViewport = gd::RectF(); + // ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发) + // 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要** + // 滚动时才复位(`Open` 里已经为每次打开重置过)。 + if (m_constraint.maxHeight > 0.0f) + { + // 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”, + // 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。 + float limit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale); + if (limit < 40.0f * m_scale) + { + limit = 0.0f; + } + 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) + { + height = limit; + m_scrollEnabled = true; + m_pinnedIndex = pinnedFrom; + m_scrollRegionHeight = scrollRegionHeight; + m_scrollOffset = std::max( + 0.0f, std::min(m_scrollOffset, std::max(0.0f, scrollRegionHeight - viewportHeight))); + } + else + { + m_scrollOffset = 0.0f; + } + } + else + { + m_scrollOffset = 0.0f; + } // 带说明文字的 EDIT 项共用**同一个输入框宽度**:取其中最大的自然宽度, // 再按“给最宽的说明文字留位 / 不超过内容区 3/4”夹取。 @@ -215,12 +286,38 @@ namespace jdi m_rect = gd::RectF(left, top, width, height); - float cursorY = top + kPadV * m_scale; + // 条目排布:滚动区的条目整体上移 `m_scrollOffset`;固定区从菜单底部往上对齐。 + const float bodyTop = top + kPadV * m_scale; + const float pinnedTop = top + height - kPadV * m_scale - tailHeight; + m_scrollViewport = + m_scrollEnabled + ? gd::RectF(left, bodyTop, width, + std::max(0.0f, height - kPadV * 2.0f * m_scale - tailHeight)) + : gd::RectF(); + + float cursorY = bodyTop; + float scrollCursorY = bodyTop - m_scrollOffset; + float pinnedCursorY = (pinnedFrom > 0) ? pinnedTop : bodyTop; for (std::size_t index = 0; index < m_items.size(); ++index) { const MenuItem& item = m_items[index]; - const float itemHeight = ItemHeight(item); - m_itemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY, + const float itemHeight = heights[index]; + + float rectY = cursorY; + if (m_scrollEnabled) + { + if (static_cast(index) < m_pinnedIndex) + { + rectY = scrollCursorY; + scrollCursorY += itemHeight; + } + else + { + rectY = pinnedCursorY; + pinnedCursorY += itemHeight; + } + } + m_itemRects[index] = gd::RectF(left + kPadH * m_scale, rectY, std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight); if (item.kind == MenuItemKind::SWATCHES) @@ -228,7 +325,7 @@ namespace jdi const int count = std::max(0, std::min(item.swatchCount, 16)); std::vector& rects = m_swatchRects[index]; rects.reserve(static_cast(count)); - const float swatchY = cursorY + (itemHeight - kSwatchSize * m_scale) * 0.5f; + const float swatchY = rectY + (itemHeight - kSwatchSize * m_scale) * 0.5f; const float startX = left + (kPadH + kItemPad) * m_scale; for (int swatch = 0; swatch < count; ++swatch) { @@ -242,6 +339,47 @@ namespace jdi } } + bool PopupMenu::EnsureItemVisible(int index) + { + if (!m_scrollEnabled || index < 0 || index >= m_pinnedIndex || m_scrollViewport.Height <= 0.0f) + { + return false; + } + const gd::RectF& rect = m_itemRects[static_cast(index)]; + const float relative = rect.Y - m_scrollViewport.Y; + float delta = 0.0f; + if (relative < 0.0f) + { + delta = relative; + } + else if (relative + rect.Height > m_scrollViewport.Height) + { + delta = relative + rect.Height - m_scrollViewport.Height; + } + if (std::fabs(delta) < 0.5f) + { + return false; + } + m_scrollOffset += delta; + // 重新排布(内部会重新夹取 `m_scrollOffset`)。 + RebuildLayout(); + return true; + } + + bool PopupMenu::OnMouseWheel(int delta) + { + if (!m_open || !m_scrollEnabled || delta == 0) + { + return false; + } + const float previous = m_scrollOffset; + // 向上滚(`delta > 0`)= 看更靠前的条目 ⇒ 偏移减小。 + m_scrollOffset -= static_cast(delta) / 120.0f * 54.0f * m_scale; + // 重新排布:条目矩形依赖滚动偏移,同时会夹取到合法范围。 + RebuildLayout(); + return std::fabs(m_scrollOffset - previous) > 0.01f; + } + float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const { const std::wstring& widest = @@ -633,7 +771,8 @@ namespace jdi } void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y, - const std::vector& items, bool expandUp) + const std::vector& items, bool expandUp, + const MenuLayoutConstraint& constraint) { m_items = items; if (m_items.empty()) @@ -653,6 +792,9 @@ namespace jdi m_buildX = x; m_buildY = y; m_buildExpandUp = expandUp; + // 每次打开都从顶部开始(滚动位置不跨次沿用)。 + m_constraint = constraint; + m_scrollOffset = 0.0f; CloseSubmenu(); CloseEditState(); @@ -671,6 +813,12 @@ namespace jdi m_hoverSwatch = -1; m_pressedIndex = -1; m_pressedSwatch = -1; + m_constraint = MenuLayoutConstraint(); + m_pinnedIndex = -1; + m_scrollEnabled = false; + m_scrollOffset = 0.0f; + m_scrollRegionHeight = 0.0f; + m_scrollViewport = gd::RectF(); CloseSubmenu(); CloseEditState(); } @@ -720,7 +868,15 @@ namespace jdi } if (!UiPointInRect(m_itemRects[index], fx, fy)) { - continue; } + continue; + } + // 可滚动菜单:滚动区内的条目还必须落在**滚动视口**内 —— + // 夹在视口边界上、只有一部分可见的条目,只有落在视口内的那一部分可以点击。 + if (m_scrollEnabled && static_cast(index) < m_pinnedIndex && + !UiPointInRect(m_scrollViewport, fx, fy)) + { + continue; + } if (item.kind == MenuItemKind::SWATCHES) { @@ -772,6 +928,8 @@ namespace jdi { m_hoverIndex = index; m_hoverSwatch = -1; + // 键盘移动时把目标条目滚入可视区(长菜单里按方向键不能“跳到看不见的条目”)。 + EnsureItemVisible(index); return; } } @@ -1204,10 +1362,51 @@ namespace jdi UiFillRoundRect(g, m_rect, kCorner * m_scale, m_theme->cardBg); UiDrawRoundRect(g, m_rect, kCorner * m_scale, m_theme->borderStrong, 1.0f); + // 条目文字统一走本 lambda:可滚动区里的条目必须夹到**滚动视口** —— + // `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**,不显式裁剪就会画到固定区上。 + //(GDI+ 图元 —— 行底色 / 圆角 / 分隔线 / 对勾 / 子菜单三角 —— 受 `SetClip` 约束,无需额外处理。) + const auto itemText = [this, &g](const std::wstring& text, HFONT font, const gd::RectF& rect, + std::uint32_t argb, TextAlign align, bool clipped) + { + if (clipped) + { + UiTextClipped(g, text, font, rect, argb, align, m_scrollViewport); + } + else + { + UiText(g, text, font, rect, argb, align); + } + }; + + // 可滚动菜单:条目 [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); + } + 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) + { + g.Restore(clipState); + } + const bool inScrollRegion = static_cast(index) < pinnedFrom; + // 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。 + if (m_scrollEnabled && inScrollRegion && + (rect.Y + rect.Height <= m_scrollViewport.Y || + rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height)) + { + continue; + } + const bool clipped = m_scrollEnabled && inScrollRegion; if (item.kind == MenuItemKind::SEPARATOR) { @@ -1220,9 +1419,9 @@ namespace jdi if (item.kind == MenuItemKind::HEADER) { // 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。 - UiText(g, item.label, m_fonts->uiSmallBold, - gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), - m_theme->textMuted, TextAlign::LEFT); + itemText(item.label, m_fonts->uiSmallBold, + gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clipped); continue; } @@ -1232,9 +1431,9 @@ namespace jdi const gd::RectF icon = HeaderIconRect(static_cast(index)); const float labelWidth = std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale); - UiText(g, UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold, - gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT); + itemText(UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold, + gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clipped); if (icon.Width > 0.0f) { const bool iconHovered = (static_cast(index) == m_hoverIconIndex); @@ -1266,8 +1465,9 @@ namespace jdi const float labelLeft = rect.X + kItemPad * m_scale; const gd::RectF fieldRect = EditFieldRect(static_cast(index)); const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale); - UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall, - gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT); + itemText(UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall, + gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT, + clipped); const float suffix = EditSuffixWidth(item); if (static_cast(index) == m_editIndex) @@ -1287,12 +1487,12 @@ namespace jdi if (suffix > 0.0f) { const float suffixLeft = textRect.X + textRect.Width; - UiText(g, item.editSuffix, m_fonts->uiSmall, - gd::RectF(suffixLeft, fieldRect.Y, - std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft - - 4.0f * m_scale), - fieldRect.Height), - m_theme->textMuted, TextAlign::RIGHT); + itemText(item.editSuffix, m_fonts->uiSmall, + gd::RectF(suffixLeft, fieldRect.Y, + std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft - + 4.0f * m_scale), + fieldRect.Height), + m_theme->textMuted, TextAlign::RIGHT, clipped); } } else @@ -1301,10 +1501,10 @@ namespace jdi UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); const std::wstring text = UiEllipsize(item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale); - UiText(g, text, m_fonts->uiSmall, - gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y, - std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height), - item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT); + itemText(text, m_fonts->uiSmall, + gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y, + std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height), + item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT, clipped); } continue; } @@ -1384,9 +1584,15 @@ namespace jdi const float textLeft = rect.X + kItemPad * m_scale; const float textWidth = rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale; - UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall, - gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor, - TextAlign::LEFT); + itemText(UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall, + gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor, + TextAlign::LEFT, clipped); + } + + // 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。 + if (m_scrollEnabled) + { + g.Restore(clipState); } // 二级子菜单最后绘制,保证覆盖在主菜单之上。