滚动动画根治(巨型信号不再影响滚动)+「N 项」计数点击语义 + 浮动面板首现右上角 + 开关/关闭面板卡顿

一、滚动过渡动画在巨型信号处卡顿 —— 真因是「退化成整幅重绘」
滚动到**从未构建过图层的区域**时,可见网格一个都贴不出图层像素 ⇒ BlitWideBuffer() 返回 false
⇒ 落到 Paint() 末尾的整幅重绘 ⇒ 把本帧全部可见网格的波形(含千万级采样的巨型信号)每帧重画一遍。
实测(MF4 124 网格 + VERY_BIG_DATA 5 网格,点选最后一个信号):单帧 20~35ms,且「可见的巨型信号
网格数」越多越慢(0 个 ≈20ms、5 个 ≈34ms);对照手动拖滚动条只要 ~6ms。
修法:BlitWideBuffer 只要还能画网格底衬(backing 非空)就返回 true,
只有 blits 与 backing 都为空时才交给整幅重绘 —— 这正是「没来得及画信号时就按只有网格叠底的状态继续滚动」。
实测:动画均值 23.0→11.2ms、最坏 35.6→13.1ms;含 5 个巨型信号网格的帧 33→10.7~12.3ms。

二、面板动画帧只重绘面板(额外 −2.7ms/帧)
新增 Application::m_panelDirty:滚动定时器在只有面板动画在跑时置位,Render 走 else if (panelOnly)
只重绘面板 + 分隔条(SignalPanel::Paint 会铺满自己的区域)。m_chromeDirty(真内容变化)仍走完整装饰重绘。
⚠️ 回送仍是 if (fullFrame) {整窗} else {绘图区} 的无条件分支(遵守 §9.41)。
实测帧耗时拆解:plot 4.8ms / plot+panel 11.4ms / plot+全装饰 13.9ms。

三、分组行「N 项」计数:只折叠展开,绝不进入改名
新增 RowPart::GROUP_COUNT 与 SignalPanel::GroupStatsRect(row)(绘制与命中的唯一几何来源,命中矩形在
文本左右各外扩 3 逻辑像素)。RowHitPart 在名称区域之前判定计数区:
- OnLButtonDown:折叠/展开;OnDoubleClick:只折叠/展开,任何点击频率都不进入改名;
- DoubleClickIsPlainPress 返回 true ⇒ 快速连点每一击都生效。
⚠️「快速双击进改名」的计时只对 RowPart::BODY 记录(否则“点计数、紧接着点名称”会被误判成双击)。

四、浮动面板首次出现的位置:右上角
旧默认位置 top = plotTop + 16 + laneIndex*28:上百个网格时 laneIndex 很大 ⇒ 被夹到右下角
(实测 plotH=1081 时 topMargin=964)。改为按「当前真正显示出来的面板中排在本面板之前的个数」错开
(固定遍历顺序:网格下标 → 面板种类),并把错开量夹到“面板下沿不超过绘图区 45% 高度”以内:
第一个面板错开 0 ⇒ 正好贴在上右角(实测 topMargin=24、rightMargin=24),6 个面板时最坏 topMargin=234。
⚠️ 判定「面板是否显示」不能只看 *Panel.open —— LanePanelState::open 默认就是 true,必须同时看功能启用。

五、开关实验性功能 / 关闭面板卡顿 —— RebuildWideBuffer() 整批重建
四个 SetLane* 末尾都调 RebuildWideBuffer()(注释说是“预建宽缓冲基底”),而它同步重渲染全部网格的图层:
129 个网格(含 5 个千万级采样的巨型信号)整批重建要几百毫秒 ⇒ 点一下功能开关 / 关闭浮动面板就会卡住。
修法:新增 PlotView::EnsureLaneLayersPrefetched() —— 只有「图层数组与网格数量不符」(首次绘制 / 增删网格)
才同步整批建立;其余只 RollWideBuffer() 登记缺失/过期网格,由既有预算(每帧最多一个)逐帧补齐。
实测:几百毫秒 → ≤22ms;巨型信号网格上开关均值滤波同样 15ms 量级。

验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/perf/lanescroll_main.cpp(lanescroll.exe)23/23:逐帧耗时 + 「可见巨型信号网格数」关联、
  「N 项」计数热区与双击不进改名、面板首现位置与多面板错开、开着巨型信号的开关/关闭面板耗时。
- 既有回归:verify 49/49、panelanim 158/158、datacheck 69/69、edgecheck 46/46、errcheck 19/19、
  gridanim 93/93。
- make release / make debug 零错误零警告(用 TARGET_FILE_NAME=TargetCheck/TargetCheckD 避免与运行中实例冲突)。

文档:Documents/agent_mem.md 新增 §9.44、§11.2 第 134–140 条与变更日志一行。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-17 09:56:45 +08:00
1 parent 436dcbfd52
commit 6b15575829
10 files changed
+352 -38

No files matched your search

@@ -468,6 +468,19 @@ namespace jdi
* false,从而每帧只重绘、只回送绘图区所在的矩形,避免整窗重绘的开销。
*/
bool m_chromeDirty = true;
/**
* @brief 「只有信号面板(与分隔条)需要重绘」。
*
* 面板滚动动画(「滚动到目标信号」)每帧只改变面板像素,而 `m_chromeDirty` 会
* 连**标签栏 / 工具栏 / 整窗底色**一起重绘 —— 实测 129 个网格时这一份装饰重绘
* 约 **3~4ms/帧**,会把动画挤出 15.6ms 的预算(用户 2026-10-17 报告:滚动过渡动画卡顿)。
*
* ✓ 由滚动定时器在**只有面板动画在跑**时置位:`Render` 只重绘面板 + 分隔条
*(标签栏/工具栏/整窗底色与上一帧逐像素相同,后备缓冲区里的旧像素本来就是对的)。
* ⚠️ 回送仍是 `if (fullFrame) {整窗} else {绘图区}` 的**无条件分支**,
* 与 §9.41 的教训一致(**不**按更新区域决定回送范围)。
*/
bool m_panelDirty = false;
bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */
};
+22 -2
View File
@@ -409,8 +409,16 @@ namespace jdi
// ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。
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 || panelAnimating;
// ⚠️ 两部分分开:
// * `fullChrome` —— 标签栏 / 工具栏 / 整窗底色 / 面板都要重绘(有真内容变化);
// * `panelOnly` —— **只有面板像素变了**(面板滚动动画),标签栏/工具栏/整窗底色
// 与上一帧逐像素相同,没必要重绘(实测 129 网格时约 3~4ms/帧)。
const bool fullChrome = m_chromeDirty || m_loading || overlaysVisible;
const bool panelOnly = !fullChrome && (panelAnimating || m_panelDirty);
m_chromeDirty = false;
m_panelDirty = false;
// 仍需要“重绘装饰区域”这个判定(决定回送整窗还是只回送绘图区)。
const bool chromeDirty = fullChrome || panelOnly;
// 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。
const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation();
@@ -426,7 +434,7 @@ namespace jdi
// 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。
m_plot.SetExternalHoverSignal(m_panel.HoveredSignal());
if (chromeDirty)
if (fullChrome)
{
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
m_theme.windowBg);
@@ -436,6 +444,12 @@ namespace jdi
m_panel.Paint(g);
UiFillRect(g, m_splitterRect, m_theme.border);
}
else if (panelOnly)
{
// 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。
m_panel.Paint(g);
UiFillRect(g, m_splitterRect, m_theme.border);
}
m_plot.Paint(g);
@@ -1514,6 +1528,12 @@ namespace jdi
m_plot.AdvanceBackToTopAnimation();
if (panelAnimating)
{
// 面板动画:只有面板像素会变(且通常绘图区动画同时也在跑)⇒
// 用 `m_panelDirty` 让 `Render` 只重画面板 + 分隔条,而**不是**把
// 标签栏/工具栏/整窗底色也重画一遍(约 3~4ms/帧)。
// ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘,
// 保证面板的最终位置被完整重绘并回送。
m_panelDirty = !m_chromeDirty;
InvalidateRect(hwnd, nullptr, FALSE);
}
else
+15
View File
@@ -3198,6 +3198,21 @@ namespace jdi
* 调用一次,把整批重建的代价摊到若干静止帧上;任何交互帧都不再承担它。
*/
void WarmLaneLayersStep();
/**
* @brief 预建「宽缓冲基底」:只为**尚未建立 / 已失效**的网格补图层。
*
* 用户 2026-10-17 报告:每次开/关一个实验性功能(以及关闭浮动面板)都会卡一下。
* 根因是旧实现直接 `RebuildWideBuffer()` —— 它**同步重渲染全部网格的图层**:
* “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号,
* 整批重建要**几百毫秒**。
*
* ✓ 现在:只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才同步整批建立;
* 其余只把缺失 / 过期的网格登记进待重画队列,由既有预算机制逐帧补齐。
* 这已经足够达成原本的目的(避免“首次拖动参数滑块时的一次性重建尖峰”):
* 拖动滑块时视界在变 ⇒ 本就不做重建、直接用现有基底。
*/
void EnsureLaneLayersPrefetched();
/** @brief 使全部图层失效。 */
void InvalidateWideBuffer(); /** @brief 释放全部图层位图。 */
void ReleaseWideBuffer();
+13 -4
View File
@@ -1294,7 +1294,8 @@ namespace jdi
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
RebuildWideBuffer();
// ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。
EnsureLaneLayersPrefetched();
return true;
}
@@ -1387,8 +1388,15 @@ namespace jdi
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」(不含派生曲线),使用户首次拖动该滑块时无需从零重建(避免一次性尖峰)。
RebuildWideBuffer();
// 预建「宽缓冲基底」(不含派生曲线):只为**尚未建立 / 已失效**的网格补图层,
// 避免用户首次拖动该滑块时一次性重建的尖峰。
// ⚠️⚠️ **绝不能在这里无条件重建【全部】网格**(旧实现是 `RebuildWideBuffer()`):
// “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号,
// 整批重渲染要**几百毫秒** ⇒ 用户点一下功能开关 / 关闭浮动面板就会“卡住一段时间”
//(用户 2026-10-17 报告:三明治加载后开关面板会卡、关面板要等一会才消失)。
// ✓ 只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才需要同步整批建立,
// 其余只登记进待重画队列,由既有预算机制(每帧最多一个网格)逐帧补齐。
EnsureLaneLayersPrefetched();
return true;
}
@@ -1477,7 +1485,8 @@ namespace jdi
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
RebuildWideBuffer();
// ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。
EnsureLaneLayersPrefetched();
return true;
}
+108 -23
View File
@@ -849,23 +849,27 @@ namespace jdi
//(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。
// ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示,
// 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。
//
// ⚠️ **只在视界稳定后才花预算做精细重绘**(视界仍在变化的每一帧只贴"旧跨度像素拉伸"
// 的占位图,O(1),把重建推迟到视界停下来之后 —— 用户要求"先画了再说,别卡住前台"),
// 且**仍按预算逐个补**(不用一次把全部过期网格塞进同一帧):实测同一台机器上 4 线程
// 真并行(3.98x),但**图层光栅化(GDI+)本身会被串行化**,并行重建 5 个网格 ≈ 逐个重建
//(1.03x)→ 一次性补齐只会把一帧从 ~10ms 抬到 ~31ms(更容易掉帧)。
// 仍按预算逐个补:不用一次把全部过期网格塞进同一帧(并行重建≈逐个重建)。
// ⚠️ **“是否再来一帧”的判据**(见 `AnyVisibleLanePending` / `PrefetchStepAllowed`):
// * 还有可视 / 预取带内的网格待补 ⇒ 立刻继续;
// * 只剩离屏网格 ⇒ 按 `kPrefetchMinIntervalMs` 限速继续(不会饿死动画定时器)。
if (viewStopped)
// ⚠️ **只在视界稳定后才花预算做精细重绘**:视界仍在变化的每一帧只贴现有像素,
// 把重建推迟到视界停下来之后(用户要求"先画了再说,别卡住前台")。
// ⚠️ 并行重建 5 个网格实测只有 1.03x(GDI+ 光栅化被串行化)⇒ 保持"每帧补一个"。
// ⚠️⚠️ **网格滚动过渡动画期间一律【不重建任何图层】**(用户 2026-10-17 明确要求):
// 滚动动画只移动视界,**与"网格内的波形有没有画好"无关**。旧实现在视界几乎停止
// 的那几帧(动画尾部里每帧只移动几个像素)放行 `RenderPendingLaneRebuilds(1)`,
// 于是恰好把**正在滚入视野的巨型信号网格**的图层重建排进了动画帧 —— 实测动画尾部
// 单帧 20ms → **34ms**(`VERY_BIG_DATA` 25M 采样的图层渲染本身就是十几毫秒量级)。
// 正确行为:动画期间未就绪的网格**只画网格底衬**(廉价、已在 `BlitWideBuffer` 里兜底),
// 波形等动画结束、视界真正停下之后再按预算逐个补。
const bool laneScrollAnimating = LaneScrollAnimationActive();
if (viewStopped && !laneScrollAnimating)
{
RenderPendingLaneRebuilds(1);
}
// 同理:动画期间不要再额外请求帧(定时器本来就在按 15.5ms 驱动重绘),
// 否则会与定时器帧叠加、把预算挤爆。
const bool morePending =
AnyVisibleLanePending() ||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed());
!laneScrollAnimating &&
(AnyVisibleLanePending() ||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed()));
// 绘图区底色(覆盖时间轴与侧边留白区域)。
UiFillRect(g, m_area, m_theme->plotBg);
// 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好:
@@ -906,8 +910,7 @@ namespace jdi
if (m_useDecorCache)
{
BlitDecorCache(g);
}
else
} else
{
// 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend):
// 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。
@@ -2984,6 +2987,23 @@ namespace jdi
return true;
}
void PlotView::EnsureLaneLayersPrefetched()
{
if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0))
{
return;
}
if (!LayersAllocated())
{
// 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。
RebuildWideBuffer();
return;
}
// ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画),
// 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。
RollWideBuffer();
}
void PlotView::WarmLaneLayersStep()
{
if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr)
@@ -3322,9 +3342,19 @@ namespace jdi
if (blits.empty())
{
// 一个网格都贴不出像素(典型:布局变化后所有图层都失效)→ 交给调用方走整幅重绘,
// 避免画面停留在"只有网格底衬、没有波形"的状态。
return false;
// ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘**
//(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。
// 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数
// 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的
// **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号)
// 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。
// 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补
//(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。
if (backing.empty())
{
// 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。
return false;
}
}
// ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** ——
@@ -4910,20 +4940,75 @@ namespace jdi
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
}
// 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。
// 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数**
// 依次错开,避免完全重叠。
// ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大,
// `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角**
//(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。
// 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区
// 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。
const double storedX = meanFilter ? lane.meanFilterPanel.x
: (weakPeak ? lane.weakPeakPanel.x
: (centerAxis ? lane.edgeCenterPanel.x : lane.edgePanel.x));
const double storedY = meanFilter ? lane.meanFilterPanel.y
: (weakPeak ? lane.weakPeakPanel.y
: (centerAxis ? lane.edgeCenterPanel.y : lane.edgePanel.y));
int openPanelOrdinal = 0;
{
// ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」:
// * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”);
// * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠;
// * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。
// ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角);
// ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用
// (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。
const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool
{
switch (panelKind)
{
case EdgePanelKind::COUNT:
return lane.edgeCount && lane.edgePanel.open;
case EdgePanelKind::CENTER:
return lane.edgeCenter && lane.edgeCenterPanel.open;
case EdgePanelKind::MEAN:
return lane.meanFilter && lane.meanFilterPanel.open;
case EdgePanelKind::PEAK:
return lane.weakPeak && lane.weakPeakPanel.open;
}
return false;
};
static const EdgePanelKind kOrder[4] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK};
int openSeen = 0;
bool found = false;
for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane)
{
const Lane& tabLane = Tab().Lanes()[lane];
for (const EdgePanelKind panelKind : kOrder)
{
if (!shown(tabLane, panelKind))
{
continue;
}
if (lane == static_cast<std::size_t>(laneIndex) && panelKind == kind)
{
found = true;
break;
}
++openSeen;
}
}
openPanelOrdinal = found ? openSeen : 0;
}
// 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。
const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale);
const float stagger =
std::min(static_cast<float>(openPanelOrdinal) * 28.0f * m_scale, maxStagger);
float left = (storedX >= 0.0)
? static_cast<float>(storedX)
: (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale);
float top = (storedY >= 0.0)
? static_cast<float>(storedY)
: (m_plotRect.Y + 16.0f * m_scale +
static_cast<float>(laneIndex) * 28.0f * m_scale);
float top = (storedY >= 0.0) ? static_cast<float>(storedY)
: (m_plotRect.Y + 16.0f * m_scale + stagger);
// 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。
left = std::max(m_plotRect.X + 2.0f * m_scale,
@@ -438,6 +438,7 @@ namespace jdi
CHECK, /**< 复选框。 */
BODY, /**< 行主体(分组行的名称区域 / 信号行的名称区域)。 */
GROUP_TITLE, /**< 分组行的「组X」标签(与折叠三角一样可点击折起)。 */
GROUP_COUNT, /**< 分组行右侧的「N 项」计数(同样只折叠展开,**绝不**进入改名)。 */
CLOSE, /**< 分组行的关闭按钮。 */
LANE_BADGE, /**< 信号行的网格徽标。 */
REMOVE_SIGNAL /**< 信号行左侧的「移除该信号」✕ 按钮。 */
@@ -730,6 +731,17 @@ namespace jdi
*/
gd::RectF GroupCloseRect(int row) const;
/**
* @brief 计算分组行右侧「N 项」计数文本的矩形(**绘制与命中的唯一几何来源**)。
*
* ⚠️ 计数区与分组名相邻,但它**只有折叠/展开语义**(用户 2026-10-17 要求):
* 快速点击计数不得进入改名。命中判定必须用本矩形把 `RowPart::GROUP_COUNT`
* 从名称区域(`RowPart::BODY`)中切出来,否则两者会混在一起。
* @param [IN] row 行下标。
* @return 计数文本矩形;该行不是分组行时返回空矩形。
*/
gd::RectF GroupStatsRect(int row) const;
/**
* @brief 计算信号行网格徽标矩形。
* @param [IN] row 行下标。
@@ -655,6 +655,12 @@ namespace jdi
{
return RowPart::GROUP_TITLE;
}
// 「N 项」计数:**只有折叠/展开语义**(用户 2026-10-17 要求快速点击计数不得进入改名)。
// ⚠️ 必须在名称区域之前判定:计数就紧贴在名称右侧、两者同属一行。
if (UiPointInRect(GroupStatsRect(row), fx, fy))
{
return RowPart::GROUP_COUNT;
}
return RowPart::BODY;
}
@@ -1077,6 +1083,28 @@ namespace jdi
return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale);
}
gd::RectF SignalPanel::GroupStatsRect(int row) const
{
if (m_fonts == nullptr || m_fonts->uiSmall == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
return gd::RectF();
}
const gd::RectF rect = RowRect(row);
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row));
if (!(statsWidth > 0.0f))
{
return gd::RectF();
}
// 命中矩形在文本左右各多给 3 逻辑像素(计数文字很短,多给一点更好点)。
// 左侧不会吃掉名称区域:`RowNameRect` 的右边界已经让出了 `6*scale`。
const gd::RectF textRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height);
const float pad = 3.0f * m_scale;
return gd::RectF(textRect.X - pad, textRect.Y, textRect.Width + pad * 2.0f, textRect.Height);
}
float SignalPanel::GroupLabelWidth(int row) const
{
if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 ||
@@ -1446,14 +1446,20 @@ namespace jdi
m_pendingCloseRow = row;
return true;
}
// 折叠 / 展开分组:三角、「组X」标签、**以及名称本体**都生效。
// 折叠 / 展开分组:三角、「组X」标签、**名称本体**、以及右侧的「N 项」计数都生效。
// 用户 2026-10-17 要求:名称本体恢复单击展开/收起(此前为了避开“误入改名”
// 而禁掉了它,结果只能点三角、很不方便);误入改名改由**收紧双击阈值**解决
//(见 `OnDoubleClick`:只有快速双击才改名)。
//(见 `OnDoubleClick`:只有快速双击才改名);
// 右侧的「N 项」计数**只折叠展开**,任何频率的点击都不进入改名(用户 2026-10-17 要求)。
{
m_groupNameClickMs = GetTickCount64();
m_groupNameClickRow = row;
m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex);
// 只有名称区域(BODY)参与“快速双击进改名”的计时;三角 /「组X」/ 计数都不参与,
// 否则“点一次计数、紧接着点一次名称”会被误判成双击。
if (part == RowPart::BODY)
{
m_groupNameClickMs = GetTickCount64();
m_groupNameClickRow = row;
m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex);
}
}
ToggleGroupFromRow(item.groupIndex, row);
return true;
@@ -1684,6 +1690,13 @@ namespace jdi
// 超过 `kGroupRenameDoubleClickMs` 的“慢双击”只当**又一次单击**(再切一次折叠)。
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::GROUP_COUNT)
{
// 「N 项」计数:**只折叠展开**,绝不进入改名(用户 2026-10-17 要求)。
// 按下那一 次已经切过折叠状态,这里再切一次 = 两次点击各自生效。
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
if (part == RowPart::DISCLOSURE)
{
// 与单击分组行同一入口(悬浮时先滚动就位再播放折起动画)。
@@ -291,11 +291,13 @@ namespace jdi
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
const std::wstring groupLabel = GroupLabelText(item.groupIndex);
// 项数文本与 `RowNameRect`(组名编辑框)共用同一来源,保证名称宽度与绘制不漂移。
// 项数文本与 `RowNameRect`(组名编辑框)、`GroupStatsRect`(命中区)共用同一来源,
// 保证“画的”与“点的”不漂移。
const std::wstring stats = GroupStatsText(row);
const float groupWidth = GroupLabelWidth(row);
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
const float statsRight = closeRect.X - 6.0f * m_scale;
const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale;
const gd::RectF statsTextRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height);
const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
const float nameAreaLeft =
nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale;
@@ -323,8 +325,8 @@ namespace jdi
TextAlign::LEFT);
}
PaintRowText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height),
m_theme->textMuted, TextAlign::RIGHT);
PaintRowText(g, stats, m_fonts->uiSmall, statsTextRect, m_theme->textMuted,
TextAlign::RIGHT);
// 关闭按钮(从工作区移除该分组)。
const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE);