Compare commits

..
3 Commits
Author SHA1 Message Date
DeepSeek V4.1 Flash f845616938 回退 5b52eef 并修复「屏幕不刷新」根因:拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果
根因(代码级确认)
- Render() 把界面画进后备缓冲区后用 GetDC + BitBlt 回送窗口,5b52eef 把回送写成
  `if (fullFrame) { BitBlt(整窗) } else if (panelOnly) { BitBlt(本帧重绘区域并集) }`,没有兜底 else。
- 「鼠标只在绘图区内移动」的帧(WM_MOUSEMOVE 的 plotOnlyMove 路径只 InvalidatePlotRect)恰好
  fullFrame == false && panelOnly == false ⇒ 后备缓冲区被正确重绘,窗口 DC 一个像素都不回送。
- 这一个根因同时造成用户报告的三个症状:悬停光晕卡住(移开不消失)、拖动滚动条不跟手/非常卡顿、
  拖拽信号换网格看不到任何结果。

修法
- git reset --hard HEAD~1 撤销 5b52eef(保留 backup-bad-commit 标签供 A/B)。
- 只挑回该提交里与更新区域无关的功能改动(用户明确要求过、本身正确):
  * SignalPanel::ExpandGroupForSignal():联动滚动前立刻展开折起的分组(不播展开动画);
  * FitAll() / FitSinglePointCluster() 不再复位 Tab().LaneScroll()(适应视图不影响滚动条);
  * 右下角「回到最上方」浮动按钮 + 网格滚动到可视区的平滑动画。
- Application.cpp 只保留 3 处最小改动:Render 里把 panelAnimating 计入整帧脏、WM_TIMER 推进
  AdvanceBackToTopAnimation() 且保持整窗失效、定时器存活判定带 BackToTopAnimationActive()。
  Render 的绘制与 BitBlt 路径逐字节等同于 82b5ebe。
- 彻底删除 m_paintPlot / m_panelDirty / panelOnly / InvalidatePanelRect / 并集回送。

验证(全部离屏执行,不移动真实光标、不抢焦点、不截屏)
- .preflight/diag(新增离屏探针,19/19):窗口移到屏幕外 + WS_EX_TOOLWINDOW + SW_SHOWNOACTIVATE,
  保证有真实更新区域与 WM_PAINT 但不影响用户。断言滚动条拖动(滑块与绘图区都在后备缓冲区被重绘)、
  悬停光晕出现与消退(像素完全回到基线)、在曲线上按下进入 MOVE_SIGNAL 且拖到另一网格后归属正确。
- .preflight/check 137/137。
- .preflight/diag/verify_blit.ps1(新增源码级护栏,10/10):断言 Render() 的回送块必须有无条件 else、
  不得出现 else if、m_paintPlot/m_panelDirty/panelOnly 必须不存在、失效只允许整窗或仅绘图区。
  A/B:换回 backup-bad-commit ⇒ 4 项如实失败;换回修复版 ⇒ 10/10。
- make release 零错误零警告。

文档:Documents/agent_mem.md 新增 §9.41、§11.2 第 108–116 条(含 §9.40 与旧 108–114 条的作废标注)
与变更日志一行。

教训(写进记忆):① 离屏探针无法验证「窗口回送 / 合成」(屏幕外 DWM 不合成、窗口 DC 恒为空白)⇒
回送类约束必须有源码级护栏;② 离屏窗口 + TrackMouseEvent 会立刻收到 WM_MOUSELEAVE,探针须丢弃该消息;
③ 网格回收会让下标左移,断言要用锚信号。
2026-10-16 17:33:08 +08:00
DeepSeek V4.1 Flash 82b5ebe4f9 「所属网格」菜单:标题固定 + 10 行上限 + 滚动条;网格滚动联动改为动态过渡
1) 菜单滚动时最上方的「所属网格」标题不再跟着滚走
   - 根因:上一轮只实现了“尾部固定区”(最后一个分隔线 + 「新建网格」钉在底部),
     而滚动区是从条目 0 开始的,于是标题也被一起滚掉。
   - 修法:BuildLayout 新增 m_scrollFirst(= 前导 HEADER / HEADER_ACTION 的个数),
     滚动区收敛为半开区间 [m_scrollFirst, m_pinnedIndex):
     · 前导标题从菜单顶向下固定排(不参与滚动);
     · 滚动区条目受 m_scrollOffset 影响;
     · 尾部固定区从菜单底向上对齐。
   - 配套:Paint 的裁剪与“跳过不可见条目”范围、ItemAtPoint 的“必须落在滚动视口内”判定、
     EnsureItemVisible 的下界全部改用该区间;新增 ScrollRegion(first, last) 供查询/测试。

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

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

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

文档:Documents/agent_mem.md 新增 §9.39、§11.2 第 104–107 条与变更日志一行。
2026-10-16 14:12:42 +08:00
DeepSeek V4.1 Flash 2904699eae 修复五项交互缺陷:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区
1) 分组折起后再展开,信号列表的滚动条消失(筛选态与常规态均可复现)
   - 根因:AdvanceGroupAnimations 只在“折起完成”时重建行集合;而“展开完成”那一帧
     m_contentHeight 从收起态跳回展开态,却没有任何路径重算滚动条可见性 ⇒ 滚动条停在隐藏。
   - 修法:任一方向动画结束都 m_rowsDirty = true + BuildRows(),并每帧 RefreshScrollBar(false)。
     ⚠️ 动画期间不夹取滚动偏移,避免打断与滚动同步的折起动画(scrollSynced)。

2) 单点信号(finiteCount == 1)默认绘制方式由「点线」改为「散点」
   - ApplySinglePointDefaultStyle → DrawMode::SCATTER(仍取最大线宽)。
   - 新增 SignalPanel::ModeOptionEnabled:单点信号的「线段」「点线」置灰、不可点、
     且不参与悬停高亮(避免“看着能点其实点不动”);样式区的绘制与命中都用它。
   - PlotView::UpdateAutoScatterStyles 跳过单点信号,否则“放大自动点线”会把它又改回「点线」。

3) 拖动线宽滑块完全不生效(拖动时线宽不变、松手瞬间变了、再移动鼠标又变回去)
   - 根因一:AcquirePolyline 的折线缓存键没有 lineWidth,而 BuildPolyline 把线宽烘进
     包络带几何(每个像素列的上下包络各向外扩 max(1,线宽)*scale*0.5,「梳状」判定也依赖它)
     ⇒ 稠密信号(采样数 > 绘图区宽度)永远复用旧线宽的顶点与包络带。
   - 根因二:样式补丁只更新“静态场景缓存 + 后备缓冲区”,而每网格图层(宽基底)仍是旧样式像素,
     下一帧命中“宽基底快路径”时把新样式覆盖回旧像素。
   - 修法:PolylineCacheEntry 增加 lineWidth 并进比较;
     样式补丁之后 QueueLaneRebuild(受影响网格) + RenderPendingLaneRebuilds(1)(沿用每帧至多 1 个的预算)。

4) 「目标」行的 GX 标签复用「所属网格」菜单,并先给 PopupMenu 增加「限高 + 条目区滚动 + 尾部固定项」
   - MenuLayoutConstraint::maxHeight(OpenLaneMenu 传客户区高度的 62%);
   - 固定区 = 最后一个分隔线及其之后的条目(即「新建网格」)始终钉在菜单底部,其余条目可滚动;
   - 滚轮滚动条目区(Application 在菜单内滚动时不再关闭菜单,其余情形保持既有行为);
   - 键盘移动悬停项自动滚入可视区(EnsureItemVisible);ItemAtPoint 要求条目同时命中滚动视口;
   - 滚动区文本用 UiTextClipped(GDI 文本不遵循 GDI+ SetClip),并跳过完全不可见的条目(性能);
   - BuildLayout 不再无条件清零 m_scrollOffset(否则 RebuildLayout 会把滚轮位置吃掉);
   - 「目标」行 GX 标签点击**不参与**联动滚动(悬停联动保留)。

5) 点选信号名 ⇒ 绘图区自动把目标网格滚动到可视区内(上百个独立网格时旧有高亮无法定位)
   - SignalPanel::RequestPlotLaneScroll / TakePlotLaneScrollRequest;
     Application::ApplyPendingPlotLaneScroll(WM_LBUTTONDOWN 的面板分支);
     PlotView::ScrollLaneIntoView(在可视区上方就上滚、在下方就下滚、已完整可见则不动)。
   - 只有“点击信号名”触发;悬停绝不改变视图;勾选框 / GX 徽标 / 移除 ✕ 也不触发。

验证(全部离屏执行,窗口 SW_HIDE + 只注入合成消息 + 进程与线程 BELOW_NORMAL,
不移动真实光标、不抢焦点、不截屏):
- 新增 .preflight/check(不入库)68/68 通过;
- A/B:五处修复全部回退 ⇒ 17 项如实失败(B 4 / C 4 / D 7 / E 2);“折起后滚动条”需要
  逐帧重绘驱动动画才能复现,单独回退时 ⇒ 5 项如实失败;恢复后 68/68;
- make release / make debug 零警告。

其它:
- 按要求删除工作区 Tools/(structcheck 脚本)并移除 Makefile 的 structcheck 目标;
- Documents/agent_mem.md 同步:新增 §9.38、§11.2 第 97–103 条与变更日志一行。
2026-10-16 11:06:31 +08:00
22 changed files with 1611 additions and 348 deletions

No files matched your search

@@ -278,6 +278,17 @@ namespace jdi
*/
bool ApplyPendingPanelScroll();
/**
* @brief 应用信号面板提交的「把绘图区滚动到目标网格」的联动请求。
*
* 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时,
* 若该信号所在的网格不在绘图区可视区内,就自动调节绘图区的滚动条让该网格完整出现
*(上百个独立网格时,仅靠网格高亮无法定位到画面之外的网格)。悬停不触发。
*
* @return 存在请求并已应用返回 true。
*/
bool ApplyPendingPlotLaneScroll();
/**
* @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。
*/
+43 -6
View File
@@ -399,8 +399,17 @@ namespace jdi
// 工具栏/标签栏/面板各自带悬停态,内容只在鼠标位于其中或发生交互时改变。
// 绘图区内的拖拽(如双光标左右移动)期间这些区域完全静止,跳过其绘制,
// 每帧省下约 6-8ms;弹出菜单 / 拾色器 / 加载遮罩会覆盖到装饰区域,必须整帧重绘。
//
// ⚠️⚠️ **面板动画(滚动到目标信号 / 分组折起、展开)必须由动画状态本身驱动重绘**:
// 这两个动画每帧都在改变面板像素,而它们只由 `WM_TIMER` 驱动 —— 而 `WM_TIMER`
// **不会**置 `m_chromeDirty`(它刻意在排除列表里),于是旧实现完全依赖
// `WM_PAINT` 里“更新区域不在绘图区内 ⇒ 置脏”这个启发式:一旦某帧的更新区域恰好
// 不含面板(例如与鼠标消息或 `kMessagePlotDerivedReady` 的失效区域交错),
// 那一帧就**完全不重画面板** ⇒ 用户报告“过渡动画有时直接没了”。
// ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。
const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen();
const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible;
const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible || panelAnimating;
m_chromeDirty = false;
// 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。
const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation();
@@ -472,8 +481,10 @@ namespace jdi
m_hintTimerActive = false;
}
// 信号列表的动画(目标信号滚动 / 分组折起展开):仅在动画进行中保持定时重绘。
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive())
// 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区 /
//「回到最上方」按钮的淡入淡出):仅在动画进行中保持定时重绘。
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() ||
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive())
{
if (!m_scrollTimerActive)
{
@@ -603,9 +614,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 +995,8 @@ namespace jdi
m_activeTarget = Target::PANEL;
if (m_panel.OnLButtonDown(x, y))
{
// 点击信号名 ⇒ 让绘图区把目标网格滚动到可视区内(用户 2026-10-16 要求)。
ApplyPendingPlotLaneScroll();
InvalidateRect(hwnd, nullptr, TRUE);
}
return 0;
@@ -1381,12 +1412,18 @@ namespace jdi
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
// 信号列表动画(「滚动到目标信号」与「分组折起 / 展开」):推进一步并重绘
//(Render 会在动画结束后停表)。
// 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」/
//「回到最上方」按钮的淡入淡出):推进一步并整窗重绘(Render 会在动画结束后停表)。
// ⚠️ 这里**保持整窗失效**(不要改成“只失效动画所在的区域”):面板与绘图区的
// 回送范围由 `Render` 按 `fullFrame` 决定,任何“精确失效”都可能让屏幕上留下
// 未刷新的区域(历史上就因此让滚动条/时间轴不跟手);而整窗失效下动画帧
// 也只有 ~6-8ms(124 网格),完全在 16ms 预算内。
if (wParam == kScrollTimerId)
{
m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations();
m_plot.AdvanceLaneScrollAnimation();
m_plot.AdvanceBackToTopAnimation();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
@@ -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()
{
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
+105
View File
@@ -923,6 +923,62 @@ namespace jdi
*/
bool TakePanelScrollRequest(SignalRef& ref);
/**
* @brief 把指定网格**平滑滚动**到完整可见(带动画,网格不在可视区时自动调节滚动条)。
*
* 用户 2026-10-16 要求:信号很多时(例如把 MF4 的上百条信号各自放进独立网格)
* 每个网格只占绘图区的一小段,绝大多数网格都在可视区之外 —— 在信号列表里点选一条
* 信号后,用户无法从画面上的高亮看出它究竟在哪个网格。因此点选信号时:
* * 目标网格在可视区**上方** ⇒ 向上滚动直到它的上边界进入绘图区上沿;
* * 目标网格在可视区**下方** ⇒ 向下滚动直到它的下边界进入绘图区下沿;
* * 已经完整可见 ⇒ 不动(避免无意义地改变视图)。
*
* ⚠️ 过渡必须是**带动画的平滑滚动**(与信号列表的“滚动到目标信号”同源:ease-out cubic、
* 时长 `clamp(距离×0.35ms, 130, 300)`),不是直接把视界瞬移过去 —— 用户 2026-10-16 明确要求。
* 动画由宿主 16ms 定时器驱动(`AdvanceLaneScrollAnimation`)。
*
* ⚠️ 只在**点击**信号时调用(悬停只做高亮联动,绝不改变视图)。
*
* @param [IN] laneIndex 网格下标。
* @return 开始了滚动(动画或已到位)返回 true;已完整可见则返回 false。
*/
bool ScrollLaneIntoView(int laneIndex);
/**
* @brief 推进一步网格滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
*/
bool AdvanceLaneScrollAnimation();
/** @brief 网格滚动动画是否正在进行。 */
bool LaneScrollAnimationActive() const { return m_laneScrollAnimActive; }
/**
* @brief 把网格滚动**平滑地**回到最上方(供右下角浮动按钮调用)。
* @return 开始了动画(或已在上方)返回 true。
*/
bool ScrollLanesToTop();
/**
* @brief 右下角「回到最上方」浮动按钮是否可见(含淡入淡出过程中的可见性)。
* @return 可见返回 true。
*/
bool BackToTopVisible() const { return m_backToTopAlpha > 0.01f; }
/**
* @brief 推进一步「回到最上方」按钮的淡入/淡出过渡(由宿主定时器驱动)。
* @return 仍在过渡中返回 true(宿主应保持定时重绘)。
*/
bool AdvanceBackToTopAnimation();
/** @brief 「回到最上方」按钮是否正在淡入/淡出。 */
bool BackToTopAnimationActive() const { return m_backToTopAnimating; }
/**
* @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。
*/
void CancelLaneScrollAnimation() { m_laneScrollAnimActive = false; }
/**
* @brief 处理编辑态下的字符输入。
* @param [IN] character 输入字符。
@@ -1959,6 +2015,25 @@ namespace jdi
*/
void PaintScrollBar(gd::Graphics& g);
/**
* @brief 计算右下角「回到最上方」浮动按钮的矩形(客户区坐标)。
* @return 按钮矩形;当前不该显示时返回空矩形(`m_backToTopAlpha` 为 0 时仍返回几何,
* 便于命中测试与过渡动画)。
*/
gd::RectF BackToTopRect() const;
/**
* @brief 绘制右下角「回到最上方」浮动按钮(含按 `m_backToTopAlpha` 的淡入淡出)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintBackToTop(gd::Graphics& g);
/**
* @brief 依据当前滚动位置刷新「回到最上方」按钮的目标可见性(需要时启动过渡)。
* @return 视觉状态发生变化返回 true。
*/
bool UpdateBackToTopTarget();
/**
* @brief 绘制网格标题(信号图例)。
* @param [IN,OUT] g 绘图上下文。
@@ -2006,6 +2081,26 @@ 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; /**< 动画总时长(毫秒)。 */
/**
* @brief 右下角「回到最上方」浮动按钮(只在网格需要滚动条、且已向下滚动时出现)。
*
* 用户 2026-10-16 要求:参考长文档网页的常见做法,用一个向上箭头的浮动按钮;
* 仅在向下滚动时出现、回到上方时消失,并且**出现/消失都要有过渡动画**(不能突变)。
*/
float m_backToTopAlpha = 0.0f; /**< 当前不透明度(0 = 完全隐藏)。 */
bool m_backToTopFadeIn = false; /**< 当前过渡方向(true = 正在淡入)。 */
bool m_backToTopAnimating = false; /**< 是否正在淡入/淡出。 */
bool m_backToTopHover = false; /**< 鼠标是否悬停在按钮上。 */
bool m_backToTopPressed = false; /**< 是否正在按钮上按住左键。 */
std::uint64_t m_backToTopAnimStartMs = 0; /**< 本段过渡的起始时刻。 */
PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */
PlotRubberAxis m_rubberAxis = PlotRubberAxis::UNDECIDED; /**< 选区放大拖拽的轴向。 */
int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */
@@ -2345,6 +2440,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<gd::PointF> points; /**< 折线顶点。 */
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
+186
View File
@@ -180,6 +180,192 @@ namespace jdi
Tab().LaneScroll() = static_cast<float>(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset));
}
bool PlotView::ScrollLaneIntoView(int laneIndex)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(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<std::size_t>(laneIndex)].rect;
const float contentTop = rect.Y - m_plotRect.Y + Tab().LaneScroll();
const float laneHeight = rect.Height;
float desired = Tab().LaneScroll();
if (contentTop - desired < 0.0f)
{
// 目标网格在可视区上方:把它的上边界对齐到绘图区上沿。
desired = contentTop;
}
else if (contentTop + laneHeight - desired > m_plotRect.Height)
{
// 目标网格在可视区下方:把它的下边界对齐到绘图区下沿。
desired = contentTop + laneHeight - m_plotRect.Height;
}
else
{
return false; // 已经完整可见
}
// **平滑滚动**(用户 2026-10-16 要求):与信号列表的“滚动到目标信号”同源,
// 先用 ease-out cubic 过渡过去,而不是把视界瞬移过去。
const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height);
desired = static_cast<float>(ClampDouble(desired, 0.0, maxOffset));
const float distance = std::fabs(desired - Tab().LaneScroll());
if (distance < 1.0f)
{
// 差值极小:直接到位(不再为 1px 抖动做动画)。
Tab().LaneScroll() = desired;
ClampLaneScroll();
m_laneScrollAnimActive = false;
BuildLaneVisuals();
return true;
}
const float durationMs = std::max(
kLaneScrollAnimMinMs,
std::min(kLaneScrollAnimMaxMs, distance * kLaneScrollAnimMsPerPixel));
m_laneScrollAnimFrom = Tab().LaneScroll();
m_laneScrollAnimTo = desired;
m_laneScrollAnimStartMs = GetTickCount64();
m_laneScrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_laneScrollAnimActive = true;
return true;
}
bool PlotView::AdvanceLaneScrollAnimation()
{
if (!m_laneScrollAnimActive)
{
return false;
}
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed =
(now >= m_laneScrollAnimStartMs) ? (now - m_laneScrollAnimStartMs) : 0;
float progress = (m_laneScrollAnimDurationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(m_laneScrollAnimDurationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、靠近目标时减速(与信号列表完全一致的手感)。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
Tab().LaneScroll() = m_laneScrollAnimFrom + (m_laneScrollAnimTo - m_laneScrollAnimFrom) * eased;
if (progress >= 1.0f)
{
// 结束:精确落到终点并夹取(内容高度可能在动画期间变化过)。
Tab().LaneScroll() = m_laneScrollAnimTo;
m_laneScrollAnimActive = false;
ClampLaneScroll();
BuildLaneVisuals();
return false;
}
// 网格滚动量参与场景/图层指纹与各网格 `rect`,每帧重建可视几何。
ClampLaneScroll();
BuildLaneVisuals();
return true;
}
bool PlotView::ScrollLanesToTop()
{
// 已经在最上方:什么都不做(按钮此时也不该可见)。
if (Tab().LaneScroll() <= 0.5f)
{
Tab().LaneScroll() = 0.0f;
m_laneScrollAnimActive = false;
return true;
}
// 复用与「点选信号滚入可视区」完全相同的平滑过渡(ease-out cubic)。
const float distance = Tab().LaneScroll();
const float durationMs = std::max(
kLaneScrollAnimMinMs, std::min(kLaneScrollAnimMaxMs, distance * kLaneScrollAnimMsPerPixel));
m_laneScrollAnimFrom = Tab().LaneScroll();
m_laneScrollAnimTo = 0.0f;
m_laneScrollAnimStartMs = GetTickCount64();
m_laneScrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_laneScrollAnimActive = true;
return true;
}
bool PlotView::UpdateBackToTopTarget()
{
// 目标可见性 = 网格需要滚动条(说明网格多到需要滚动)且已经向下滚动了一段距离。
const bool want = m_scrollBarVisible &&
(Tab().LaneScroll() > kBackToTopShowOffset * m_scale);
if (want == m_backToTopFadeIn && !m_backToTopAnimating)
{
return false;
}
if (want == m_backToTopFadeIn)
{
return false; // 方向未变且正在过渡:让它继续
}
m_backToTopFadeIn = want;
m_backToTopAnimating = true;
m_backToTopAnimStartMs = GetTickCount64();
if (!want)
{
// 淡出开始时取消悬停/按下态(按钮即将消失)。
m_backToTopHover = false;
m_backToTopPressed = false;
}
return true;
}
bool PlotView::AdvanceBackToTopAnimation()
{
if (!m_backToTopAnimating)
{
return false;
}
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed =
(now >= m_backToTopAnimStartMs) ? (now - m_backToTopAnimStartMs) : 0;
float progress = (kBackToTopFadeMs > 0.0f)
? static_cast<float>(elapsed) / kBackToTopFadeMs
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:与其它过渡一致的手感(起步快、末尾慢)。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
const float from = m_backToTopFadeIn ? 0.0f : 1.0f;
const float to = m_backToTopFadeIn ? 1.0f : 0.0f;
m_backToTopAlpha = from + (to - from) * eased;
if (progress >= 1.0f)
{
m_backToTopAlpha = to;
m_backToTopAnimating = false;
return false;
}
return true;
}
gd::RectF PlotView::BackToTopRect() const
{
if (!m_scrollBarVisible)
{
return gd::RectF();
}
const float size = kBackToTopSize * m_scale;
const float margin = kBackToTopMargin * m_scale;
// ⚠️ 贴着**绘图区**右下角(`m_plotRect` 已经让出了右侧滚动条的位置,
// 因此按钮不会压到滚动条上)。
return gd::RectF(m_plotRect.X + m_plotRect.Width - margin - size,
m_plotRect.Y + m_plotRect.Height - margin - size, size, size);
}
bool PlotView::LaneSpansMultipleGroups(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
+17 -2
View File
@@ -163,7 +163,10 @@ namespace jdi
{
Tab().ViewInitialized() = true;
}
Tab().LaneScroll() = 0.0f;
// ⚠️ **不再复位网格纵向滚动(`Tab().LaneScroll()`)**:用户 2026-10-16 明确要求
// “适应视图不要影响滚动条” —— 加载了大量网格并向下翻动后点「适应视图」,
// 旧实现会把视界瞬移回最上方(用户报告)。适应视图只负责时间轴/幅值轴。
m_laneScrollAnimActive = false;
// 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态**
// (用户明确要求:点适应视图后仍保持已启用的缩放模式)。
m_rubberRect = gd::RectF();
@@ -359,7 +362,8 @@ namespace jdi
static_cast<double>(kTimeTickTarget) * kSinglePointClusterSecondsPerCell;
SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5);
Tab().ViewInitialized() = true;
Tab().LaneScroll() = 0.0f;
// ⚠️ 同 `FitAll()`:**不复位网格纵向滚动**(用户 2026-10-16 要求)。
m_laneScrollAnimActive = false;
// ⚠️ **不要**在这里复位 `ZoomMode()`:用户明确要求"点适应视图后仍保持已启用的
// 缩放模式"(`FitAll` 的常规路径同样只更新提示、不动缩放模式)。
m_rubberRect = gd::RectF();
@@ -2916,6 +2920,17 @@ namespace jdi
continue;
}
// ⚠️ **只有一个有效采样点**的信号**不参与**自动切换:
// 它们的初始样式固定为「散点」(见 `ApplySinglePointDefaultStyle`),而信号面板的
// 「线段」/「点线」按钮已对其置灰(用户 2026-10-16 要求)——
// 若这里再自动写成「点线」,用户会看到一个“已置灰却仍处于点线态”的矛盾状态。
if (data->finiteCount == 1)
{
style->autoScatterActive = false;
style->autoScatterUserOverride = false;
continue;
}
// ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」,
// 所以自动启用期间 mode 不等于「点线」只可能是用户改的。
// 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去**
@@ -847,6 +847,18 @@ namespace jdi
return LoadCursorW(nullptr, IDC_SIZENS);
}
// 右下角「回到最上方」浮动按钮:与其它可点击控件一致,用手型光标作为提示。
// ⚠️ 必须在信号悬停 / 图例 / 面板判定**之前**(它绘制在最上层)。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f &&
UiPointInRect(backToTop, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
// 实验性功能面板与横向光标优先于信号悬停提示判定(面板会盖住波形)。
// 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。
if (m_tab != nullptr)
@@ -982,6 +994,19 @@ namespace jdi
const float fx = static_cast<float>(x);
// 右下角「回到最上方」按钮的悬停高亮(仅未拖拽时;按住其它东西时不该抢)。
if (m_dragMode == PlotDragMode::NONE)
{
const gd::RectF backToTop = BackToTopRect();
const bool hot = m_backToTopAlpha > 0.01f && backToTop.Width > 0.0f &&
UiPointInRect(backToTop, fx, static_cast<float>(y));
if (hot != m_backToTopHover)
{
m_backToTopHover = hot;
return true;
}
}
switch (m_dragMode)
{
case PlotDragMode::PAN:
@@ -1085,6 +1110,8 @@ namespace jdi
}
const double travel =
static_cast<double>(y - m_dragLastY) / static_cast<double>(m_plotRect.Height);
// 用户自己拖滚动条 ⇒ 立即取消“点点信号后自动滚入可视区”的动画(否则两者会抢滚动位置)。
CancelLaneScrollAnimation();
Tab().LaneScroll() += static_cast<float>(travel * m_laneContentHeight);
m_dragLastY = y;
m_dragMoved = true;
@@ -1477,6 +1504,18 @@ namespace jdi
m_pendingPanelClose = false;
m_pendingPanelCloseLane = -1;
// 右下角「回到最上方」浮动按钮:按下只登记,**抬起**时仍命中才真正生效
//(与其它 ✕ / 按钮的「可取消」交互一致),且**不**进入任何拖拽模式。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy))
{
m_backToTopPressed = true;
return true;
}
}
if (m_scrollBarVisible &&
UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
@@ -1966,6 +2005,19 @@ namespace jdi
{
bool changed = (m_dragMode != PlotDragMode::NONE);
// 右下角「回到最上方」按钮:按下后仍在按钮上抬起 ⇒ 平滑滚回最上方。
if (m_backToTopPressed)
{
m_backToTopPressed = false;
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f &&
UiPointInRect(backToTop, static_cast<float>(x), static_cast<float>(y)))
{
ScrollLanesToTop();
}
return true;
}
// 实验性功能面板的 ✕:只有"按下后仍在同一个 ✕ 上抬起"才真正关闭该功能。
if (m_pendingPanelClose)
{
@@ -2094,6 +2146,8 @@ namespace jdi
return false;
}
// 用户自己滚轮滚动 ⇒ 立即取消自动滚入可视区的动画。
CancelLaneScrollAnimation();
Tab().LaneScroll() -= static_cast<float>(notches * 48.0 * m_scale);
BuildLaneVisuals();
return true;
@@ -64,6 +64,29 @@ 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; /**< 每像素距离增加的时长。 */
/**
* @brief 右下角「回到最上方」浮动按钮的几何与过渡参数。
*
* 用户 2026-10-16 要求:参考长文档网页的常见做法(向上箭头浮动按钮),
* 仅在向下滚动时出现、回到上方时消失,出现/消失都要有过渡动画。
*/
inline const float kBackToTopSize = 36.0f; /**< 按钮边长(逻辑像素,圆形直径)。 */
inline const float kBackToTopMargin = 12.0f; /**< 与绘图区右下角的边距。 */
inline const float kBackToTopFadeMs = 170.0f; /**< 淡入/淡出时长(毫秒)。 */
/** 向下滚动超过该距离(逻辑像素)才显示按钮(避免轻微滚动就弹出)。 */
inline const float kBackToTopShowOffset = 24.0f;
/** 向上箭头相对按钮尺寸的比例(箭头顶端到中心、半宽)。 */
inline const float kBackToTopArrowDepth = 0.24f;
inline const float kBackToTopArrowHalfWidth = 0.20f;
inline const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
+69 -1
View File
@@ -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);
@@ -657,6 +662,10 @@ namespace jdi
BuildLaneVisuals();
// 「回到最上方」浮动按钮:目标可见性由当前滚动位置决定(过渡由宿主定时器推进)。
// 在绘制前刷新一次,保证本帧就画出正确的过渡形态。
UpdateBackToTopTarget();
// 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明):
// 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高)
// 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告
@@ -730,6 +739,8 @@ namespace jdi
PaintEditField(g);
}
PaintScrollBar(g);
// 右下角「回到最上方」浮动按钮:在滚动条之上绘制(随 `m_backToTopAlpha` 淡入淡出)。
PaintBackToTop(g);
// 绘图区外框(比网格内部分界线更重,形成明确整体边界)。
// 用 GDI 画 4 条线:GDI+ 的 DrawRectangle 带抗锯齿时单帧要花掉 1ms 以上。
@@ -924,6 +935,17 @@ namespace jdi
m_fastFrame = true;
RepaintSceneCachePatch(g, patchLanes);
m_sceneDegraded = true;
// ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”,
// 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中
// “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** ——
// 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新,
// 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。
// 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。
for (const int laneIndex : patchLanes)
{
QueueLaneRebuild(laneIndex);
}
RenderPendingLaneRebuilds(1);
paintOverlays();
return;
}
@@ -6364,5 +6386,51 @@ namespace jdi
UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong);
}
void PlotView::PaintBackToTop(gd::Graphics& g)
{
if (m_backToTopAlpha <= 0.01f)
{
return;
}
const gd::RectF rect = BackToTopRect();
if (rect.Width <= 1.0f)
{
return;
}
// 过渡:整个按钮(底色/描边/箭头/投影)按 `m_backToTopAlpha` 淡入淡出。
const float alpha = std::min(1.0f, std::max(0.0f, m_backToTopAlpha));
const auto faded = [alpha](std::uint32_t color) -> std::uint32_t
{
const std::uint32_t a =
static_cast<std::uint32_t>(static_cast<float>((color >> 24) & 0xFFu) * alpha);
return (color & 0x00FFFFFFu) | ((a & 0xFFu) << 24);
};
const float radius = rect.Width * 0.5f; // 圆形按钮(与图例托底/GX 徽标同一套配色)
// 投影:两层递减透明度的圆角矩形(与弹出菜单/浮动面板一致,不引入系统阴影 API)。
UiFillRoundRect(g, gd::RectF(rect.X + 1.0f * m_scale, rect.Y + 2.0f * m_scale, rect.Width,
rect.Height),
radius, faded(0x1A000000u));
UiFillRoundRect(g, gd::RectF(rect.X, rect.Y + 1.0f * m_scale, rect.Width, rect.Height), radius,
faded(0x14000000u));
const bool hot = m_backToTopHover || m_backToTopPressed;
UiFillRoundRect(g, rect, radius, faded(hot ? m_theme->accentSoft : m_theme->cardBg));
UiDrawRoundRect(g, rect, radius, faded(hot ? m_theme->accent : m_theme->borderStrong),
hot ? 1.6f : 1.0f);
// 向上箭头:竖杆 + 两条斜线组成的箭头(自绘,不依赖字体)。
const float cx = rect.X + rect.Width * 0.5f;
const float cy = rect.Y + rect.Height * 0.5f;
const float depth = rect.Width * kBackToTopArrowDepth;
const float halfWidth = rect.Width * kBackToTopArrowHalfWidth;
const std::uint32_t ink = faded(hot ? m_theme->accent : m_theme->textStrong);
const float stroke = std::max(1.6f, 2.0f * m_scale);
UiDrawLine(g, cx, cy + depth, cx, cy - depth, ink, stroke);
UiDrawLine(g, cx, cy - depth, cx - halfWidth, cy - depth * 0.15f, ink, stroke);
UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke);
}
} // namespace jdi
+60 -1
View File
@@ -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 复制)。
@@ -321,6 +343,18 @@ namespace jdi
*/
void ScrollToSignalAnimated(const SignalRef& ref);
/**
* @brief 若目标信号所在分组处于**当前模式的折起状态**,自动把它展开。
*
* 折起的分组不会列出信号行 ⇒ 行集合里找不到目标信号,任何“滚动到该信号”都会落空
*(用户 2026-10-16 报告:点网格图例联动时,被折起的组不会自动展开、列表不动)。
* 展开是**立刻生效**的(不播放展开动画),因为随后的滚动目标依赖稳定的行位置。
*
* @param [IN] ref 目标信号。
* @return 发生了展开返回 true。
*/
bool ExpandGroupForSignal(const SignalRef& ref);
/**
* @brief 推进一步滚动动画(由宿主定时器驱动)。
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
@@ -644,8 +678,11 @@ namespace jdi
/**
* @brief 依据内容高度重算滚动条可见性与列表可用宽度。
* @param [IN] clampScroll 是否顺带把滚动偏移夹到合法范围。
* ⚠️ 分组折起/展开动画期间必须传 `false`:与滚动同步的折起动画正按自己的进度
* 推进 `m_scrollOffset`,此时夹取会打断它(折起后内容比列表还短时尤其明显)。
*/
void RefreshScrollBar();
void RefreshScrollBar(bool clampScroll = true);
/**
* @brief 限制滚动偏移量到合法范围。
@@ -992,6 +1029,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 +1198,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 待确认的「关闭类」按钮(✕)。
+42 -1
View File
@@ -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,8 +478,11 @@ namespace jdi
m_scrollBarRect = gd::RectF();
}
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)
@@ -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<std::ptrdiff_t>(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();
}
@@ -317,6 +325,14 @@ namespace jdi
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。
// 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。
// 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (!SignalScrollTarget(ref, desired))
{
@@ -326,6 +342,25 @@ namespace jdi
StartScrollAnimationTo(desired);
}
bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref)
{
if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex))
{
return false;
}
SetGroupCollapsed(ref.groupIndex, false);
// 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。
const int anim = FindGroupAnimation(ref.groupIndex);
if (anim >= 0)
{
m_groupAnims.erase(m_groupAnims.begin() + anim);
}
// 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。
m_rowsDirty = true;
EnsureRows();
return true;
}
bool SignalPanel::AdvanceScrollAnimation()
{
if (!m_scrollAnimActive)
@@ -1038,6 +1073,7 @@ namespace jdi
m_pendingFilterClear = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingTargetLaneMenu = false;
return changed;
}
@@ -1120,7 +1156,9 @@ namespace jdi
{
for (int index = 0; index < kModeOptionCount; ++index)
{
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
// 置灰的选项(单点信号的「线段」/「点线」)不参与悬停高亮 ——
// 它们点不动,高亮会让用户以为可以点击。
if (ModeOptionEnabled(index) && UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
{
hoverStyle = StyleHit::MODE;
hoverStyleIndex = index;
@@ -1255,6 +1293,7 @@ namespace jdi
m_pendingColorPicker = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingTargetLaneMenu = false;
// 点击当前输入框以外的位置时提交输入。
if (m_editField != StyleEditField::NONE &&
@@ -1274,18 +1313,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 +1362,11 @@ namespace jdi
{
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
{
// 单点信号的「线段」/「点线」两项置灰 ⇒ 点了不生效(也不做联动滚动)。
if (!ModeOptionEnabled(index))
{
return true;
}
style->mode = kModeOptions[index];
ScrollToSignalAnimated(SelectedRef());
return true;
@@ -1429,6 +1482,12 @@ namespace jdi
m_pendingLaneMenu = true;
m_pendingLaneRef = ref;
}
else
{
// 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内
//(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。
RequestPlotLaneScroll(ref);
}
return true;
}
@@ -1550,6 +1609,22 @@ namespace jdi
}
}
// 「目标」行的 GX 标签:与信号行的网格徽标同一套交互(抬起且仍在同一徽标上才展开)。
if (m_pendingTargetLaneMenu)
{
m_pendingTargetLaneMenu = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
UpdateTargetRowGeometry();
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy) &&
m_tab != nullptr && SelectedRef().IsValid())
{
OpenLaneMenu(SelectedRef(), static_cast<int>(m_targetBadgeRect.X),
static_cast<int>(m_targetBadgeRect.Y + m_targetBadgeRect.Height));
changed = true;
}
}
return changed;
}
@@ -1859,7 +1934,16 @@ 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 的全部信号各自放进独立网格):
// 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。
// 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求):
// 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的
// 「新建网格」**始终固定不动**。
MenuLayoutConstraint constraint;
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);
}
bool SignalPanel::OnMenuCommand(const MenuResult& result)
@@ -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 待匹配文本。
@@ -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<std::size_t>(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);
}
}
+344
View File
@@ -2288,6 +2288,249 @@ 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` 可复跑”的描述**自此失效**,
仅作为当时那一轮拆分的**历史证据**保留;后续若要再加结构检查,需要重新上传脚本。
---
### 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。
---
### 9.41 回退 `5b52eef` 并修复「屏幕不刷新」根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果)(本轮,本机)
> ⚠️⚠️ **本轮撤销提交 `5b52eef`(§9.40 随之作废,作废范围见下与 §11.2 的编号注记)。**
> 用户报告:以加载 `TestData/MF4-Record.MF4` 并显示全部信号为例 —— ① 拖动网格右侧滑动条非常卡顿、
> 几乎拖不动;② 悬停到某条信号触发高亮后,鼠标移开(不落到另一条曲线上)高亮**仍然卡住**;
> ③ 几乎无法正常拖拽信号到其它网格;④ 预期的流畅动画帧没有实现。用户要求「撤销上次的提交并着手定位并修复」。
#### 一、根因:`Render()` 的回送被写成「两个条件都不满足 ⇒ 一帧都不回送」(代码级确认)
`Application::Render()` 把整个界面画进后备缓冲区 `m_backDc`,最后 `GetDC` + `BitBlt` 回送窗口。
`5b52eef` 把它改成了:
```cpp
if (fullFrame) { BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); }
else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
// ← 再也没有兜底 else
```
而 `fullFrame = chromeDirty || overlaysVisible || m_plot.HintNeedsAnimation()`,
`panelOnly = !chromeDirty && (m_panelDirty || panelAnimating)`。
**「鼠标在绘图区内移动」这一类帧恰好满足 `fullFrame == false && panelOnly == false`**
(`WM_MOUSEMOVE` 的 `plotOnlyMove` 路径只 `InvalidatePlotRect`,既不动 `m_chromeDirty` 也不动 `m_panelDirty`)
⇒ **后备缓冲区被正确重绘,窗口 DC 一个像素都没更新。**
这**一个根因**同时解释了用户报告的三条症状:
* 悬停光晕已画进后备缓冲区却不回送 ⇒ 屏幕停在旧像素 ⇒ **“高亮卡住、移开不消失”**;
* 拖动滚动条时滑块与波形都不回送 ⇒ 只有偶尔整窗失效(定时器 / 装饰脏)那一帧才刷新
⇒ **“非常卡顿 / 拖不动”**;
* 拖拽信号换网格后不回送 ⇒ 用户看不到任何结果 ⇒ **“几乎无法拖拽”**。
(§9.40 想省下的“面板动画帧重画绘图区的 ~5ms”,代价就是这条正确性,得不偿失。)
#### 二、修法:整体撤销该机制,回到「整窗失效 + 无条件兜底回送」
- `git reset --hard HEAD~1` 撤销 `5b52eef`(先打标签 `backup-bad-commit` 供 A/B)。
- **只挑回该提交里“与更新区域无关”的**功能改动(它们本身是对的,用户也明确要求过):
- `SignalPanel::ExpandGroupForSignal()`(联动滚动前**立刻展开**折起的分组,不播展开动画);
- `FitAll()` / `FitSinglePointCluster()` **不再复位** `Tab().LaneScroll()`(“适应视图不影响滚动条”);
- 右下角「回到最上方」浮动按钮(`BackToTopRect` / 170ms 淡入淡出 / `ScrollLanesToTop` / `PaintBackToTop`)
与「网格滚动到可视区」的平滑动画(`ScrollLaneIntoView` 只启动、`AdvanceLaneScrollAnimation` 推进、
手动滚动时 `CancelLaneScrollAnimation()`)。
- 宿主 `Application.cpp` 只保留 **3 处最小改动**:`Render` 里把 `panelAnimating` 计入整帧脏;
`WM_TIMER` 推进 `AdvanceBackToTopAnimation()` 并**保持整窗失效**;定时器存活判定带上
`BackToTopAnimationActive()`。**`Render` 的绘制与 BitBlt 路径逐字节等同于 `82b5ebe`。**
- **彻底删除** `m_paintPlot` / `m_panelDirty` / `panelOnly` / `InvalidatePanelRect` / 并集回送。
#### 三、验证(全部离屏,不占用用户的鼠标 / 键盘 / 屏幕)
- **`.preflight/diag`(本轮新增,19/19 通过)**:把窗口 `MoveWindow` 到屏幕外(`-30000,-30000`)
+ `WS_EX_TOOLWINDOW` + `SW_SHOWNOACTIVATE` ⇒ **窗口真实可见、有真实更新区域与 `WM_PAINT`,
但完全不出现在用户屏幕上、不抢焦点、不进任务栏**。四项断言:
**[A]** 后备缓冲区可读;**[B]** 拖动网格滚动条 ⇒ 滚动量变化 + **滑块与绘图区都在后备缓冲区里被重绘**;
**[C]** 悬停曲线 ⇒ 进入悬停态且绘图区像素变化(光晕可见),移开 ⇒ 悬停态清除且**像素完全回到基线**;
**[D]** 在曲线上按下进入 `MOVE_SIGNAL`(**不是** `LANE_REORDER`)、拖到另一网格松手后该信号归属正确。
- **`.preflight/check` 137/137**(原 [J]「面板动画帧跳过绘图区」改写为「面板动画帧**必须**重绘、
且不把绘图区画坏」,并新增“只改绘图区的帧仍必须在后备缓冲区里更新”)。
- **`.preflight/diag/verify_blit.ps1`(本轮新增,源码级护栏,10/10 通过)**:断言 `Render()` 的回送块里
**必须有无条件 `else`**、**不得出现 `else if`**、`m_paintPlot`/`m_panelDirty`/`panelOnly` **必须不存在**、
失效方式只允许“整窗”或“仅绘图区”。
- **A/B**:把 `Application.cpp` 换回 `backup-bad-commit` ⇒ 护栏 **4 项如实失败**;换回修复版 ⇒ 10/10。
- `make release` **零错误零警告**。
#### 四、⚠️ 方法论教训(已写进 §11.2 第 108–116 条)
1. **离屏探针读不到“窗口回送”**:窗口在屏幕外时 DWM 不会合成,`GetDC` 读回的窗口 DC 恒为空白
⇒ “画进了后备缓冲区但没回送”的缺陷在离屏探针里**永远假绿**(本轮就是这样假绿的)。
凡是**回送 / 合成**相关的约束,必须有**源码级护栏**兜底。
2. **离屏窗口 + `TrackMouseEvent` 的陷阱**:产品在 `WM_MOUSEMOVE` 里注册 `TME_LEAVE`,
而离屏窗口上真实鼠标并不在它上面 ⇒ 系统**立刻**兑现一条 `WM_MOUSELEAVE`,刚设置的悬停态
马上被清掉、探针永远观察不到悬停效果。探针的消息泵必须**丢弃 `WM_MOUSELEAVE`**(仅限探针,不改产品)。
3. **对“网格会被回收 / 下标会左移”的断言必须换成锚信号**:`MoveSignalToLane` 后源网格若被移空会被回收,
其后所有网格下标**左移**(本轮实测 124 → 123)⇒ 「移动后下标 == 目标下标」会假失败。
4. **失败时先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效”“信号没换网格”,
实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些**优先级更高的热区**,
或用了错误的比较基准(拿滚动后的 rect 当比较区域)。
5. `Copy-Item` **保留时间戳** ⇒ 用备份覆盖源文件后必须 `(Get-Item f).LastWriteTime = Get-Date`
再 `make`,否则测的还是旧目标文件(本轮 A/B 又踩了一次,与 §9.40 同)。
6. 不要对**源码文件**做 PowerShell 文本往返(`Get-Content`/`Set-Content`):
PS 5.1 按 ANSI/GBK 解码 UTF-8,会把 C++ 源里的中文注释**整份写坏**并加上 BOM。
只能改用文件编辑工具改源文件(本轮就这样毁过一次 `.preflight/diag/main.cpp`)。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
@@ -2339,9 +2582,13 @@ 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 条的“可复跑”描述自此失效,仅作历史证据)。 |
| 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` 零警告。 |
---
| 2026-10-16 | DeepSeek-V4.1-Flash(**本机**) | **回退 5b52eef 并修复“屏幕不刷新”根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果,详见 §9.41;§9.40 随之作废)**。根因(代码级):`Render()` 的回送被写成 `if (fullFrame) … else if (panelOnly) …` 而**没有兜底 else**,而“鼠标只在绘图区内移动”的帧恰好两个条件都不满足 ⇒ 后备缓冲区画对了、窗口 DC 一个像素都不回送(悬停光晕 / 滑块 / 拖拽结果全部停在旧像素)。修法:`git reset --hard` 撤销该提交(留 `backup-bad-commit` 标签),只挑回与更新区域无关的功能改动(联动前展开折起组 / 适应视图不动滚动条 / 「回到最上方」按钮 / 网格滚动平滑动画),彻底删除 `m_paintPlot`/`m_panelDirty`/`panelOnly`/并集回送;宿主只保留 3 处最小改动且 `Render` 的绘制与 BitBlt 与 `82b5ebe` 逐字节相同。验证:`.preflight/diag` **19/19**(新增离屏可见窗口探针:滚动条拖动 / 悬停光晕出现与消退 / 信号拖拽换网格)、`.preflight/check` **137/137**、源码护栏 `.preflight/diag/verify_blit.ps1` **10/10**(A/B:换回坏版本 ⇒ 4 项如实失败)、`make release` 零警告。教训:① 离屏探针**测不到窗口回送**(屏幕外 DWM 不合成、窗口 DC 恒空白)⇒ 回送类约束必须有源码护栏;② 离屏窗口 + `TrackMouseEvent` 会**立刻**收到 `WM_MOUSELEAVE`,探针须丢弃该消息;③ 网格回收会让下标左移,断言要用锚信号。 |
## 11. 跨设备协作须知(起源设备视角)
> **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。
@@ -2808,6 +3055,103 @@ 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` → 预算推进,都会在重绘时**顺手修复**一部分
中间态;只在动作结束后才重绘会把“完成帧”的缺陷掩盖掉(本轮滚动条缺陷就因此假绿过一次)。
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 就是靠这三条排除了“直接赋值”的实现)。
> ⚠️⚠️ **编号注记(2026-10-16)**:提交 `5b52eef`(已于 **§9.41** 整体回退)曾以 **108–114** 号
> 写入下列规则,**它们全部作废**:108「`panelOnly = !chromeDirty && (m_panelDirty || panelAnimating)`」、
> 109「动画帧必须**精确失效**、禁止整窗失效(`InvalidatePanelRect`/`InvalidatePlotRect`)」、
> 110「`WM_PAINT` 辨认“更新区域完全在面板内”(`m_panelDirty`)+ `m_paintPlot` 跳过整个 `PlotView::Paint`
> + 回送范围改为“本帧重绘区域并集”」、111「联动滚动前先展开折起分组」、112「适应视图不得改变 `LaneScroll`」、
> 113「右下角回到最上方浮动按钮」、114「`Copy-Item` 保留时间戳」。
> **其中 111 / 112 / 113 / 114 的内容本身是对的,已并入下面的 111–113 条继续生效**;
> **108 的一半(“动画由动画状态本身驱动重绘”)正确、实现改为“计入整帧脏 + 整窗失效”;
> 109 / 110 属于被证伪的方案 —— 禁止再用。**
108. ⚠️⚠️ **`Render()` 的窗口回送必须存在“无条件兜底分支”**:
`if (fullFrame) { BitBlt(整窗) } else { BitBlt(绘图区) }` —— **绝不能**写成
`if (fullFrame) … else if (某条件) …`。否则存在“两个条件都不满足”的帧组合
(典型:`WM_MOUSEMOVE` 的 `plotOnlyMove` 路径只失效绘图区,既不动 `m_chromeDirty` 也不动面板脏),
这些帧会**一个像素都不回送** ⇒ 后备缓冲区正确、屏幕却停在旧画面
(用户表现:悬停高亮卡住、滚动条滑块跟不动、拖拽信号看不到结果)。
**回送区域永远“宁多勿少”**;省回送带宽的收益远小于一次屏幕不刷新的代价。
109. ⚠️⚠️ **禁止“按窗口更新区域决定本帧画什么 / 回送什么”的优化**(`m_paintPlot` / `m_panelDirty` /
`panelOnly` / 并集回送这一整套都已删除)。它把正确性绑在 `GetUpdateRect` 与各失效点“恰好配套”上,
一旦出现新的失效路径(如本轮“只失效绘图区”的鼠标移动)就会静默出错;
而且**它无法被离屏探针验证**(见第 113 条)。
110. ⚠️ **面板动画必须由“动画状态本身”驱动重绘**(正确洞见保留):`WM_TIMER` 不置 `m_chromeDirty`,
所以 `Render` 要把 `m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()` 计入整帧脏,
否则某些帧根本不重画面板(用户:“过渡动画有时直接没了”)。
**实现方式改为整窗失效**(不是“只失效面板矩形”)。
111. ⚠️ **联动滚动前必须先展开折起的分组**:折起的分组不列出信号行 ⇒ 行集合里找不到目标,
`SignalScrollTarget` 直接返回、列表不动。
✓ `SignalPanel::ExpandGroupForSignal()`(**立刻生效**、不播展开动画,因为滚动目标需要稳定行位置),
由 `ScrollToSignalAnimated()` 在算滚动目标前调用。
112. ⚠️ **「适应视图」不得改变网格纵向滚动**:`FitAll()` 与 `FitSinglePointCluster()` 里那两次
`Tab().LaneScroll() = 0` 已删除(用户明确要求“适应视图不要影响滚动条”),只取消滚动动画。
右下角「回到最上方」浮动按钮(仅 `m_scrollBarVisible` 且已下滚 `> kBackToTopShowOffset`(24)
时出现;170ms ease-out cubic 淡入淡出;按下只登记 `m_backToTopPressed`、**不进入拖拽**;
抬起仍在同一按钮上才 `ScrollLanesToTop()`)与「网格滚动到可视区」的平滑动画继续生效。
113. ⚠️⚠️ **离屏探针无法验证“窗口回送 / 合成”**:窗口在屏幕外时 DWM 不合成,`GetDC` 读回的窗口 DC
恒为空白 ⇒ “画进了后备缓冲区但没回送窗口”的缺陷在离屏探针里**永远假绿**(本轮就是这样)。
**回送 / 合成相关的约束必须有源码级护栏**(本机:`.preflight/diag/verify_blit.ps1`,断言回送块
存在无条件 `else`、无 `else if`、且 `m_paintPlot`/`m_panelDirty`/`panelOnly` 不存在)。
114. ⚠️ **离屏探针的消息泵必须丢弃 `WM_MOUSELEAVE`**:产品在 `WM_MOUSEMOVE` 里注册
`TrackMouseEvent(TME_LEAVE)`,而离屏窗口上真实鼠标不在它上面 ⇒ 系统**立刻**兑现一条
`WM_MOUSELEAVE`,刚设置的悬停态马上被清掉(表现为“探针永远测不到悬停”)。仅影响探针。
115. ⚠️ **涉及“网格会被回收 / 下标会左移”的断言必须用锚信号**:`MoveSignalToLane` 把源网格移空后
该网格被回收,其后所有网格下标**左移**(实测 124 → 123)⇒ 「移动后下标 == 目标下标」会假失败;
改为「移动后与**目标网格里的某个锚信号**同网格」。
116. ⚠️ **对“看起来坏了”的现象,先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效”
“信号没换网格”,实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些
**优先级更高的热区**,或用了错误的比较基准(拿滚动后的 rect 当比较区域)。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
-5
View File
@@ -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 $@
-275
View File
@@ -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
-13
View File
@@ -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 与磁盘上的文件**没有任何关系**
+3 -1
View File
@@ -38,7 +38,9 @@ namespace jdi
/**
* @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。
*
* 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。
* 单个数据点的初始绘制方式是「散点」,半径随线宽线性增长;取最大值才能看清那个点。
* ⚠️ 用户 2026-10-16 要求:初始绘制方式由「点线」改为「**散点**」,
* 同时信号面板「样式」行的「线段」/「点线」两个按钮对该信号置灰。
* ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式,
* 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制
* 已经覆盖了那个场景,无需再改默认样式。
+102 -1
View File
@@ -79,6 +79,31 @@ namespace jdi
bool keepOpen = false;
};
/**
* @brief 打开菜单时的附加布局约束。
*/
struct MenuLayoutConstraint
{
/**
* @brief 菜单允许的最大高度(**客户区像素**,0 = 不限制)。
*
* 超过该高度时菜单不再变高:**前导标题钉在顶部、最后一个分隔线及其之后的条目固定在底部**,
* 中间的内容成为可滚动的条目区(鼠标滚轮 / 键盘方向键 / 滚动条)。
*
* 用于「条目数不定」的菜单:例如加载含上百个信号的 MF4 并把每个信号都放进独立网格时,
* 「所属网格」菜单会有上百个条目(既超出屏幕、绘制开销也很大)。
*/
float maxHeight = 0.0f;
/**
* @brief 可滚动条目区**最多显示的标准行数**(0 = 不限制)。
*
* 比 `maxHeight` 更直观(用户要求“保留 10 条网格的上限”):无论条目多高,
* 滚动视口高度都不会超过该行数×标准行高。与 `maxHeight` 同时给出时**取更严格的那个**。
*/
int maxScrollRows = 0;
};
/**
* @brief 菜单选择结果。
*/
@@ -127,9 +152,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<MenuItem>& items, bool expandUp = false);
const std::vector<MenuItem>& items, bool expandUp = false,
const MenuLayoutConstraint& constraint = MenuLayoutConstraint());
/**
* @brief 关闭菜单。
@@ -196,6 +223,55 @@ namespace jdi
*/
bool ContainsPoint(int x, int y) const;
/**
* @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`)。
* @return 滚动位置发生变化返回 true。
*/
bool OnMouseWheel(int delta);
/**
* @brief 设置剪贴板所有者窗口句柄(供内联输入框复制/粘贴)。
* @param [IN] host 宿主窗口句柄,允许为空。
@@ -392,6 +468,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 +510,24 @@ namespace jdi
int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */
bool m_buildExpandUp = false; /**< 最近一次排布是否为“向上展开(锚点为左下角)”。 */
/** 最近一次使用的布局约束(`RebuildLayout` 复用)。 */
MenuLayoutConstraint m_constraint;
/**
* @brief 可滚动条目区的下标范围(半开区间 `[m_scrollFirst, m_pinnedIndex)`)。
*
* `m_scrollFirst` = 前导固定条目数(**「所属网格」这类标题必须始终留在最上方、不参与滚动**);
* `m_pinnedIndex` = 末尾固定区起点(最后一个分隔线,典型「新建网格」钉在底部)。
*/
int m_scrollFirst = 0;
int m_pinnedIndex = -1;
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; /**< 展开子菜单的父项下标。 */
gd::RectF m_subRect; /**< 子菜单整体矩形。 */
+7 -3
View File
@@ -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;
}
}
+418 -21
View File
@@ -31,6 +31,9 @@ namespace jdi
const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
const float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */
const float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */
const float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
@@ -125,10 +128,12 @@ namespace jdi
float contentWidth = kMinWidth * m_scale;
float totalHeight = kPadV * 2.0f * m_scale;
std::vector<float> 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 +173,111 @@ namespace jdi
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
const float width = contentWidth;
const float height = totalHeight;
// ------------------------------------------------------------------
// 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。
// * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)——
// 用户 2026-10-16 要求:滚动时标题必须留在最上方不动;
// * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部;
// * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。
// ------------------------------------------------------------------
int pinnedFrom = -1;
int scrollFirst = 0;
if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0)
{
for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
{
if (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SEPARATOR)
{
pinnedFrom = index;
break;
}
}
while (scrollFirst < pinnedFrom &&
(m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER ||
m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER_ACTION))
{
++scrollFirst; // 前导标题固定在顶部,不参与滚动
}
}
float headHeight = 0.0f;
float scrollRegionHeight = 0.0f;
float tailHeight = 0.0f;
if (pinnedFrom > 0)
{
for (int index = 0; index < static_cast<int>(m_items.size()); ++index)
{
if (index >= pinnedFrom)
{
tailHeight += heights[static_cast<std::size_t>(index)];
}
else if (index < scrollFirst)
{
headHeight += heights[static_cast<std::size_t>(index)];
}
else
{
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
}
}
}
float height = totalHeight;
m_scrollEnabled = false;
m_scrollRegionHeight = 0.0f;
m_scrollFirst = 0;
m_pinnedIndex = -1;
m_scrollViewport = gd::RectF();
m_scrollBarRect = gd::RectF();
// ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`,
// 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条)
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
// 滚动时才复位(`Open` 里已经为每次打开重置过)。
if (pinnedFrom > 0)
{
// 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与
// `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。
float viewportLimit = 0.0f;
if (m_constraint.maxHeight > 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);
}
}
if (m_constraint.maxScrollRows > 0)
{
const float rows =
static_cast<float>(m_constraint.maxScrollRows) * kRowHeight * m_scale;
viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows;
}
if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f)
{
const float viewportHeight = std::min(scrollRegionHeight, viewportLimit);
height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight;
m_scrollEnabled = true;
m_scrollFirst = scrollFirst;
m_pinnedIndex = pinnedFrom;
m_scrollRegionHeight = scrollRegionHeight;
m_scrollOffset = std::max(
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,20 +324,59 @@ 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 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 + 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 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 = ItemHeight(item);
m_itemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
const float itemHeight = heights[index];
float rectY = cursorY;
float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale);
if (m_scrollEnabled)
{
const int itemIndex = static_cast<int>(index);
if (itemIndex < m_scrollFirst)
{
rectY = headCursorY;
headCursorY += itemHeight;
}
else if (itemIndex < m_pinnedIndex)
{
rectY = scrollCursorY;
scrollCursorY += itemHeight;
// 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。
rectWidth = std::max(0.0f, rectWidth - scrollBarReserve);
}
else
{
rectY = tailCursorY;
tailCursorY += itemHeight;
}
}
m_itemRects[index] =
gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight);
if (item.kind == MenuItemKind::SWATCHES)
{
const int count = std::max(0, std::min(item.swatchCount, 16));
std::vector<gd::RectF>& rects = m_swatchRects[index];
rects.reserve(static_cast<std::size_t>(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)
{
@@ -240,6 +388,114 @@ namespace jdi
cursorY += itemHeight;
}
// 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。
if (m_scrollEnabled)
{
const float thickness = kScrollBarWidth * m_scale;
m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness,
m_scrollViewport.Y, thickness, m_scrollViewport.Height);
}
}
bool PopupMenu::ScrollRegion(int& first, int& last) const
{
if (!m_scrollEnabled || m_pinnedIndex < 0)
{
first = 0;
last = static_cast<int>(m_items.size());
return false;
}
first = m_scrollFirst;
last = m_pinnedIndex;
return true;
}
gd::RectF PopupMenu::ScrollBarThumbRect() const
{
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f)
{
return gd::RectF();
}
const float viewport = m_scrollViewport.Height;
if (viewport <= 0.0f)
{
return gd::RectF();
}
const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight);
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio);
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f;
const float thumbY =
m_scrollBarRect.Y +
std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight);
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
}
float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const
{
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f)
{
return m_scrollOffset;
}
const float viewport = m_scrollViewport.Height;
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
if (maxScroll <= 0.0f)
{
return 0.0f;
}
const float track = m_scrollBarRect.Height;
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight)));
const float travel = std::max(0.0f, track - thumbHeight);
if (travel <= 0.0f)
{
return 0.0f;
}
const float relative = thumbTop - m_scrollBarRect.Y;
const float progress = std::max(0.0f, std::min(1.0f, relative / travel));
return progress * maxScroll;
}
bool PopupMenu::EnsureItemVisible(int index)
{
if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex ||
m_scrollViewport.Height <= 0.0f)
{
return false;
}
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(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<float>(delta) / 120.0f * 54.0f * m_scale;
// 重新排布:条目矩形依赖滚动偏移,同时会夹取到合法范围。
RebuildLayout();
return std::fabs(m_scrollOffset - previous) > 0.01f;
}
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
@@ -633,7 +889,8 @@ namespace jdi
}
void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y,
const std::vector<MenuItem>& items, bool expandUp)
const std::vector<MenuItem>& items, bool expandUp,
const MenuLayoutConstraint& constraint)
{
m_items = items;
if (m_items.empty())
@@ -653,6 +910,10 @@ namespace jdi
m_buildX = x;
m_buildY = y;
m_buildExpandUp = expandUp;
// 每次打开都从顶部开始(滚动位置不跨次沿用)。
m_constraint = constraint;
m_scrollOffset = 0.0f;
m_scrollBarDragging = false;
CloseSubmenu();
CloseEditState();
@@ -671,6 +932,16 @@ namespace jdi
m_hoverSwatch = -1;
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();
}
@@ -720,7 +991,16 @@ namespace jdi
}
if (!UiPointInRect(m_itemRects[index], fx, fy))
{
continue; }
continue;
}
// 可滚动菜单:滚动区内的条目还必须落在**滚动视口**内 ——
// 夹在视口边界上、只有一部分可见的条目,只有落在视口内的那一部分可以点击。
// ⚠️ 前导标题与末尾固定项不在滚动区内,它们始终完整可见(不参与本判定)。
if (m_scrollEnabled && static_cast<int>(index) >= m_scrollFirst &&
static_cast<int>(index) < m_pinnedIndex && !UiPointInRect(m_scrollViewport, fx, fy))
{
continue;
}
if (item.kind == MenuItemKind::SWATCHES)
{
@@ -772,6 +1052,8 @@ namespace jdi
{
m_hoverIndex = index;
m_hoverSwatch = -1;
// 键盘移动时把目标条目滚入可视区(长菜单里按方向键不能“跳到看不见的条目”)。
EnsureItemVisible(index);
return;
}
}
@@ -820,6 +1102,15 @@ namespace jdi
const float fy = static_cast<float>(y);
bool changed = false;
// 正在拖拽滚动条滑块:忽略悬停,只跟随鼠标滚动条目区。
if (m_scrollBarDragging)
{
const float previous = m_scrollOffset;
m_scrollOffset = ScrollOffsetForThumbTop(fy - m_scrollBarGrabOffset);
RebuildLayout();
return std::fabs(m_scrollOffset - previous) > 0.01f;
}
// 鼠标在展开的子菜单内:只更新子菜单的悬停项(主菜单父项保持高亮)。
if (m_subOpen && UiPointInRect(m_subRect, fx, fy))
{
@@ -940,6 +1231,34 @@ namespace jdi
EndItemEdit(true, result);
}
// 可滚动菜单的滚动条:按下即开始拖拽(不是条目点击,菜单保持打开)。
if (m_scrollEnabled)
{
const gd::RectF thumb = ScrollBarThumbRect();
if (thumb.Width > 0.0f && UiPointInRect(thumb, static_cast<float>(x), static_cast<float>(y)))
{
const float previous = m_scrollOffset;
m_scrollBarDragging = true;
m_scrollBarGrabOffset = static_cast<float>(y) - thumb.Y;
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
(void)previous;
return true;
}
if (UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(y)))
{
// 点在轨道空白处:把滑块中心移到该处(翻页)。
m_scrollBarDragging = true;
m_scrollBarGrabOffset = thumb.Height * 0.5f;
m_scrollOffset = ScrollOffsetForThumbTop(static_cast<float>(y) - m_scrollBarGrabOffset);
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
return true;
}
}
int swatchIndex = -1;
m_pressedIndex = ItemAtPoint(x, y, swatchIndex);
m_pressedSwatch = swatchIndex;
@@ -955,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)
{
@@ -1204,10 +1532,58 @@ 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, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**;
// * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条);
// * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。
const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0;
const int scrollFirst = scrollable ? m_scrollFirst : static_cast<int>(m_items.size());
const int scrollLast = scrollable ? m_pinnedIndex : static_cast<int>(m_items.size());
int clipState = 0;
bool clipActive = false;
for (std::size_t index = 0; index < m_items.size(); ++index)
{
const MenuItem& item = m_items[index];
const gd::RectF& rect = m_itemRects[index];
const int itemIndex = static_cast<int>(index);
// 进入/离开滚动区:按需开启/撤销 GDI+ 裁剪(固定区永远完整可见)。
if (scrollable && itemIndex == scrollFirst && !clipActive)
{
clipState = g.Save();
g.SetClip(m_scrollViewport, gd::CombineModeIntersect);
clipActive = true;
}
else if (scrollable && itemIndex == scrollLast && clipActive)
{
g.Restore(clipState);
clipActive = false;
}
const bool inScrollRegion = itemIndex >= scrollFirst && itemIndex < scrollLast;
// 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。
if (scrollable && inScrollRegion &&
(rect.Y + rect.Height <= m_scrollViewport.Y ||
rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height))
{
continue;
}
const bool clipped = scrollable && inScrollRegion;
if (item.kind == MenuItemKind::SEPARATOR)
{
@@ -1220,9 +1596,9 @@ namespace jdi
if (item.kind == MenuItemKind::HEADER)
{
// 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。
UiText(g, item.label, m_fonts->uiSmallBold,
itemText(item.label, m_fonts->uiSmallBold,
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
m_theme->textMuted, TextAlign::LEFT, clipped);
continue;
}
@@ -1232,9 +1608,9 @@ namespace jdi
const gd::RectF icon = HeaderIconRect(static_cast<int>(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,
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);
m_theme->textMuted, TextAlign::LEFT, clipped);
if (icon.Width > 0.0f)
{
const bool iconHovered = (static_cast<int>(index) == m_hoverIconIndex);
@@ -1266,8 +1642,9 @@ namespace jdi
const float labelLeft = rect.X + kItemPad * m_scale;
const gd::RectF fieldRect = EditFieldRect(static_cast<int>(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<int>(index) == m_editIndex)
@@ -1287,12 +1664,12 @@ namespace jdi
if (suffix > 0.0f)
{
const float suffixLeft = textRect.X + textRect.Width;
UiText(g, item.editSuffix, m_fonts->uiSmall,
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);
m_theme->textMuted, TextAlign::RIGHT, clipped);
}
}
else
@@ -1301,10 +1678,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,
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);
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT, clipped);
}
continue;
}
@@ -1384,9 +1761,29 @@ 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,
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);
TextAlign::LEFT, clipped);
}
// 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。
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);
}
}
// 二级子菜单最后绘制,保证覆盖在主菜单之上。