滚动过渡动画根治 + 快速连点跟手 + 分组名单击展开 + 光标吸附同源 + 按钮淡出对齐 + 滚动位置复位

一、滚动过渡动画卡顿(用户报告:甚至不如手动拖滚动条)—— 三处独立根因
1) WM_TIMER(kScrollTimerId) 无条件整窗失效 ⇒ WM_PAINT 看到更新区域不在绘图区内 ⇒ 把
   m_chromeDirty 置真 ⇒ 每一帧都重绘标签栏/工具栏/面板。
   修法:用「推进之前」的面板动画状态决定失效范围 —— 面板动画(滚动到目标信号 / 分组折起展开)
   确实要改面板像素 ⇒ 整窗失效;只有绘图区动画(网格滚动到可视区 / 回到最上方)⇒ 只失效绘图区。
   实测:绘图区动画帧 13.6ms → 3.5ms;手动拖滚动条参照 4.2ms。
   ⚠️ 未改变 §9.41 的回送结构(仍是 if (fullFrame) {整窗} else {绘图区})。
2) 图层预热不足:上百个离屏网格长期不建图层,长距离滚动动画扫进去时每一帧都有十几个网格
   没有图层像素,只能靠「网格底衬兜底」重画(约 0.5ms/网格 ⇒ +8ms/帧)。
   修法:LaneVisuallyRelevant(laneIndex, prefetchScreens) 增加 ±1 屏预取带(AnyVisibleLanePending 用),
   并新增 PrefetchStepAllowed()(kPrefetchMinIntervalMs=32 ⇒ 约 31 次/秒)限速地继续请求重绘,
   让整批图层在静置期间逐步建好。
   实测:lane-only + VERY_BIG_DATA 帧 13.9ms → 5.3ms;signal-click + VERY_BIG_DATA 18.3ms → 10.3ms。
3) 网格滚动量原本参与「宽基底可用性」判据(上一轮已修:SceneContentHash(..., includeLaneScroll=false))。

二、光标吸附改用「放大自动点线」的同一判定(用户 2026-10-17 修订)
AutoPointLineNeeded 改名 MinorCellSparse(中性名,同一判定两处用途):任一小格内有效数据点 < 5 即为稀疏。
- 「放大自动切点线」与「光标吸附」共用它;判定完全不看绘制方式 ⇒ 即使用户手动改过显示样式也照样吸附。
- 鼠标下方网格内既有符合又有不符合的信号时,只有符合的那条的数据点参与吸附;没有符合的则自由移动。
- 删除旧的整屏判据 SignalSnapEnabled 与 kSnapVisibleCellsPerPoint(死代码)。

三、「回到最上方」按钮淡出与「浮动提示」一致
kBackToTopFadeOutMs = kHintFadeMs(500ms)+ 线性 alpha,到达顶部立即开始淡出(不停留)。
淡入保留 170ms ease-out。实测 50% 时刻 alpha = 0.500(线性),旧 ease-out 会是 0.875。

四、快速连点跟手(根因:CS_DBLCLKS 合成 WM_LBUTTONDBLCLK 导致按下丢失)
组件各自实现 DoubleClickIsPlainPress(x, y)(该落点有没有双击语义),宿主 WM_LBUTTONDBLCLK 据此
选择走 OnLButtonDown(当作按下)还是 OnDoubleClick:
- PlotView:浮动面板(含复选框/滑块/复位/✕/标题栏)、「回到最上方」按钮、绘图区滚动条、GX 徽标 ⇒ 按下;
  时间光标线/读数标签/图例条目名/绘图区空白 ⇒ 保留双击。
- SignalPanel:样式区数值框、「目标」行名称与 GX 标签、行名称区域 ⇒ 保留双击;其余全部 ⇒ 按下。
- 弹出菜单 / 拾色器打开时,WM_LBUTTONDBLCLK 也按各自 OnLButtonDown 走。

五、分组行:名称恢复单击展开收起 + 只有快速双击进入改名
- OnLButtonDown 分组分支不再排除 RowPart::BODY:三角 /「组X」/ 名称本体统一折叠展开。
- OnDoubleClick 名称区域:只有快速双击(kGroupRenameDoubleClickMs=300,比系统 500ms 紧)才进入改名;
  超阈值的「慢双击」只当又一次单击;快速双击前先撤销按下那一次的折叠切换,保证进编辑时状态与点击前一致。

六、信号重现时网格滚动回到最上方
“有过需滚动条的网格 → 没有任何信号显示 → 又出现需滚动条数量的网格”这一类情形必须从最上方开始看。
修法:Application::SynchronizeView 的「首次出现可见信号」分支里,在 FitAll() 之后 tab.LaneScroll() = 0。

验证(全部离屏,不占用鼠标/键盘/屏幕)
- grabdanim(.preflight/perf/gridanim_main.cpp)93/93:真实消息循环帧距 + 逐帧渲染耗时(与手动拖滚动条
  对比)、光标吸附新判定、按钮淡出线性、快速连点路由、分组名单击/双击/慢双击、滚动位置复位。
- 既有回归:verify 49/49、panelanim 158/158(按新分组名规则更新 8 条断言)、datacheck 69/69、
  edgecheck 46/46、errcheck 19/19。
- make release / make debug 零错误零警告(用 TARGET_FILE_NAME=TargetCheck/TargetCheckD 避免与正在运行的实例冲突)。

文档:Documents/agent_mem.md 新增 §9.43、§11.2 第 125–133 条与变更日志一行。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-17 03:17:42 +08:00
1 parent 34375481de
commit 436dcbfd52
12 files changed
+618 -182

No files matched your search

+115 -20
View File
@@ -1129,10 +1129,49 @@ namespace jdi
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
// ⚠️ **工具栏必须在这里也当作“按下”处理**:窗口类带 `CS_DBLCLKS`,快速连点同一个
// 按钮时,第二次(及之后的快速按下)会被 Windows 合成为 `WM_LBUTTONDBLCLK`。
// 工具栏没有双击语义,若这里不处理,那一次按下就完全丢失 ——
// 用户看到的正是“连点好几下、开关状态只切换了几次”。
// ⚠️⚠️ **没有双击语义的控件必须把这次「双击」当作一次普通按下**:
// 窗口类带 `CS_DBLCLKS`,快速连点同一个控件时第二次(及之后)按下会被 Windows
// 合成为 `WM_LBUTTONDBLCLK`。若这里不处理,那一次按下就完全丢失 ——
// 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。
// 之前只给工具栏打了这个补丁;本轮(用户 2026-10-17)扩展到:
// 弹出菜单 / 拾色器 / 信号面板(复选框、全部显示隐藏、绝对值、折叠三角、「组X」…)/
// 绘图区(浮动面板上的复选框与按钮、滚动条、GX 徽标)。
// 组件各自用 `DoubleClickIsPlainPress(x, y)` 判定“这个落点有没有双击语义”。
// 弹出菜单打开时接管输入:菜单条目只有单击语义。
if (m_menu.IsOpen())
{
if (m_menu.IsOnEditField(x, y))
{
m_menu.OnEditMouseDown(x, y);
SetCapture(hwnd);
m_activeTarget = Target::EDIT_FIELD;
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
MenuResult result;
m_menu.OnLButtonDown(x, y, result);
InvalidateRect(hwnd, nullptr, FALSE);
if (result.valid)
{
DispatchMenuResult(result);
}
return 0;
}
// 拾色器打开时接管输入(低一行于菜单、高于常规组件):其内部只有拖动语义。
if (m_colorPicker.IsOpen())
{
if (m_colorPicker.ContainsPoint(x, y))
{
m_colorPicker.OnLButtonDown(x, y);
SetCapture(hwnd);
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
}
// 工具栏没有双击语义,若这里不当作按下,那一次按下就完全丢失。
if (m_toolbar.HitTest(x, y))
{
SetFocus(hwnd);
@@ -1144,6 +1183,55 @@ namespace jdi
return 0;
}
// 信号面板 / 绘图区:只有“改名”等少数落点保留双击语义,其余当作按下。
if (m_panel.HitTest(x, y))
{
SetFocus(hwnd);
if (m_panel.DoubleClickIsPlainPress(x, y))
{
SetCapture(hwnd);
m_activeTarget = Target::PANEL;
if (m_panel.OnLButtonDown(x, y))
{
ApplyPendingPlotLaneScroll();
InvalidateRect(hwnd, nullptr, TRUE);
}
return 0;
}
bool changed = m_panel.OnDoubleClick(x, y);
if (changed)
{
InvalidateRect(hwnd, nullptr, TRUE);
}
ApplyPendingPanelScroll();
return 0;
}
if (m_plot.HitTest(x, y))
{
SetFocus(hwnd);
if (m_plot.DoubleClickIsPlainPress(x, y))
{
SetCapture(hwnd);
m_activeTarget = Target::PLOT;
if (m_plot.OnLButtonDown(x, y))
{
ApplyPendingRename();
ApplyPendingDerivedBuild();
InvalidateRect(hwnd, nullptr, FALSE);
}
ApplyPendingPanelScroll();
UpdateDragCursor(x, y);
return 0;
}
if (m_plot.OnDoubleClick(x, y))
{
InvalidateRect(hwnd, nullptr, TRUE);
}
ApplyPendingPanelScroll();
ApplyPendingRename();
return 0;
}
bool changed = false;
if (m_tabBar.HitTest(x, y))
{
@@ -1155,20 +1243,10 @@ namespace jdi
Relayout();
}
}
else if (m_panel.HitTest(x, y))
{
changed = m_panel.OnDoubleClick(x, y);
}
else if (m_plot.HitTest(x, y))
{
changed = m_plot.OnDoubleClick(x, y);
}
if (changed)
{
InvalidateRect(hwnd, nullptr, TRUE);
}
// 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。
ApplyPendingPanelScroll();
return 0;
}
case WM_RBUTTONDOWN:
@@ -1413,18 +1491,35 @@ namespace jdi
return 0;
}
// 信号列表与绘图区动画(「滚动到目标信号」/「分组折起、展开」/「网格滚动到可视区」/
//「回到最上方」按钮的淡入淡出):推进一步并整窗重绘(Render 会在动画结束后停表)。
// ⚠️ 这里**保持整窗失效**(不要改成“只失效动画所在的区域”):面板与绘图区的
// 回送范围由 `Render` 按 `fullFrame` 决定,任何“精确失效”都可能让屏幕上留下
// 未刷新的区域(历史上就因此让滚动条/时间轴不跟手);而整窗失效下动画帧
// 也只有 ~6-8ms(124 网格),完全在 16ms 预算内。
//「回到最上方」按钮的淡入淡出):推进一步并重绘(Render 会在动画结束后停表)。
// ⚠️ **失效范围必须与 `Render` 的绘制/回送判定完全配套**(用户 2026-10-17 报告:
// “滚动过渡动画甚至不如手动拖滚动条”)。旧实现无条件整窗失效 ⇒ `WM_PAINT` 看到
// 更新区域不在绘图区内 ⇒ 把 `m_chromeDirty` 置真 ⇒ **每一帧都重绘标签栏/工具栏/面板**
// (实测 +4.7ms/帧:纯绘图区帧 6.7ms → 11.7ms),动画因此挤不进 15.6ms 的定时器预算
// 而掉帧、位置跳动。
// 现在的判据:
// * 面板动画(滚动到目标信号 / 分组折起展开)**确实**要改面板像素 ⇒ 整窗失效;
// * 只有绘图区动画(网格滚动到可视区 / 回到最上方)⇒ **只失效绘图区**,
// `WM_PAINT` 判定为“更新区域在绘图区内” ⇒ `m_chromeDirty` 保持 false ⇒
// `Render` 只重绘并只回送绘图区(回送仍有无条件 else 兜底,未改 §9.41 的结构)。
// ⚠️ 用**推进之前**的面板动画状态判定:动画结束的那一帧仍整窗失效,
// 保证面板的最终位置被完整重绘并回送。
if (wParam == kScrollTimerId)
{
const bool panelAnimating =
m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations();
m_plot.AdvanceLaneScrollAnimation();
m_plot.AdvanceBackToTopAnimation();
InvalidateRect(hwnd, nullptr, FALSE);
if (panelAnimating)
{
InvalidateRect(hwnd, nullptr, FALSE);
}
else
{
InvalidatePlotRect(hwnd, m_plotRect);
}
return 0;
}
break;
@@ -120,6 +120,13 @@ namespace jdi
// 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由
// `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。
m_plot.FitAll();
// ⚠️ **网格纵向滚动也要回到最上方**(用户 2026-10-17 要求):
// “有过网格 → 没有信号显示 → 又加载了需要滚动条数量的网格”这一整类情形,
// 新出现的网格集必须从最上方开始看。旧实现保留了上一次的 `LaneScroll()`,
// 于是重新显示后会**停在与取消显示时相同的滚动位置**(用户报告)。
// 放在 `FitAll()` 之后:`FitAll` 本身不改变滚动量(§11.2 第 112 条),
// 这里显式归零即可;随后 `Relayout`/`RefreshLanes` 会重算几何。
tab.LaneScroll() = 0.0f;
// 用户 2026-10-17 要求:光标工具处于激活状态(单 / 双光标)而之前**没有任何信号绘制**时,
// 第一个信号出现后光标要按“光标出现的规则”重新出现一次,而不是停在旧的残留位置。
// 规则与 `CycleCursorMode()` 首次启用完全同源;光标工具未激活时本调用无副作用。
+80 -31
View File
@@ -251,6 +251,24 @@ namespace jdi
*/
bool OnDoubleClick(int x, int y);
/**
* @brief 双击落点是否应**当作普通「按下」**处理(该位置没有双击语义)。
*
* ⚠️ 窗口类带 `CS_DBLCLKS`:快速连点同一个控件时,第二次(及之后)按下会被 Windows
* 合成为 `WM_LBUTTONDBLCLK`。若宿主把它交给 `OnDoubleClick`,而该位置本来只有
* 「按下 / 单击」语义(浮动面板上的复选框与按钮、滚动条、GX 徽标…),那一次按下就
* **完全丢失** —— 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。
*
* ✓ 本函数为这些位置返回 true,宿主改走 `OnLButtonDown`(与单击完全相同的路径)。
* 只有真正有双击语义的位置(时间光标线 / 读数标签 / 图例条目名 / 绘图区空白处
* 的自适应)返回 false。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 应当作普通按下返回 true。
*/
bool DoubleClickIsPlainPress(int x, int y) const;
/**
* @brief 处理鼠标离开绘图区。
* @return 需要重绘返回 true。
@@ -314,8 +332,8 @@ namespace jdi
*
* 用户 2026-10-17 **重新定义判据**(推翻此前的“一屏内数据点少于 N×4”):
* 按**缩放后的横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数
* 少于 `kAutoPointLineMinPointsPerCell`(=5)** 就自动切到「点线」提示用户数据点的位置
*(见 `AutoPointLineNeeded`);一旦不再满足(适应视图、或把视界从稀疏区移到稠密区)
* 少于 `kSparseMinPointsPerCell`(=5)** 就自动切到「点线」提示用户数据点的位置
*(见 `MinorCellSparse`);一旦不再满足(适应视图、或把视界从稀疏区移到稠密区)
* 自动恢复**原来的**绘制方式。
*
* ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride`
@@ -337,40 +355,29 @@ namespace jdi
int GridMinorCellCount() const;
/**
* @brief 判断某信号在当前视界下是否**开启光标吸附**。
* @brief 判断某信号在当前视界下是否**按小格判定为稀疏**。
*
* 用户 2026-10-17 定义的判据(**推翻**此前的“一屏内数据点 < N×4”):
* 按缩放后的**横向时间轴网格**把小格切出来(`GridMinorCellCount()`),
* **只要存在任何一个小格内的有效数据点少于 `kSparseMinPointsPerCell`(=5)** 就返回 true。
*
* ⚠️ **这一个判定同时驱动两处**(用户 2026-10-17 明确要求“同样的状态判定”):
* * 「放大自动切点线」(`UpdateAutoScatterStyles`);
* * 「光标吸附」(`SnapTimeToSample`)—— 判定**完全不看绘制方式**,
* 因此即使用户手动改过显示样式,该吸附的信号照样吸附。
*
* 用户 2026-10-15 的定义:**当且仅当**该信号在“当前一屏”(当前视界)内的数据点
* 个数 `< N × 4`(N = `GridMinorCellCount()`)时开启吸附。
* 判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
*
* ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附
* 的对象、也没有可提示的内容(否则它会被判成“0 < N×4 稀疏”而白切一次「点线」)。
* ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附的
* 对象、也没有可提示的内容(否则它会被判成“小格全空”而白切一次「点线」)。
* ⚠️ 可见性一律按**显示时间**(`采样时刻 + timeShift`)判断 —— `timeShift` 就是该信号的
* `SignalStyle::offsetTime`(存在时间轴偏移时不能拿源时刻去比视界)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @return 开启吸附返回 true。
* @return 存在少于 `kSparseMinPointsPerCell` 个数据点的小格返回 true。
*/
bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, double timeShift) const;
/**
* @brief 判断某信号在当前视界下是否需要**自动切换到「点线」**(放大自动点线)。
*
* 用户 2026-10-17 **重新定义**的判据(推翻此前的“一屏内数据点 < N×4”):
* 按缩放后的**横向时间轴网格**把小格切出来,**只要存在任何一个小格内的数据点数
* 少于 `kAutoPointLineMinPointsPerCell`(=5)**,就返回 true。
*
* 其余规则不变:不再满足时恢复原绘制方式;用户手动改过绘制方式时不恢复
*(见 `UpdateAutoScatterStyles`)。光标吸附**仍用** `SignalSnapEnabled` 的整屏判据。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。
* @return 需要自动切换到「点线」返回 true。
*/
bool AutoPointLineNeeded(const TimeAxis& axis, const SignalData& signal, double timeShift) const;
bool MinorCellSparse(const TimeAxis& axis, const SignalData& signal, double timeShift) const;
/**
* @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。
@@ -417,10 +424,13 @@ namespace jdi
/**
* @brief 把时间吸附到距今最近的数据采样点。
*
* 判定与半径(用户 2026-10-15 统一后的定义):
* * **判定只针对鼠标所在的网格**:该网格内的信号各自按 `SignalSnapEnabled` 判定,
* 符合条件的信号的数据点全部参与吸附;网格内没有符合条件的信号时完全自由移动;
* * 同一网格内有多条信号时,所有符合条件的数据点都参与比较;
* 判定与半径(用户 2026-10-17 修订):
* * **判定只针对鼠标下方所在的网格**:该网格内的信号各自按 `MinorCellSparse`
* (“任一小格内数据点少于 5 个”)判定,**符合条件的信号的数据点全部参与吸附**;
* * 网格内**既有符合条件也有不符合条件的信号**时:只吸附符合条件那条信号的数据点
* (用户明确要求);
* * 网格内**没有**符合条件的信号时:光标完全自由移动(不吸附);
* * 判定**不看绘制方式** ⇒ 即使用户手动改了显示样式,该吸附的信号照样吸附;
* * 吸附半径 = **一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
* ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点与点之间仍可自由移动,
* 而不是被“死死吸住”(吸附只是提示性响应,不是卡住光标)。
@@ -3121,6 +3131,20 @@ namespace jdi
* @return 可视返回 true。
*/
bool LaneVisuallyRelevant(int laneIndex) const;
/**
* @brief 在「可视」基础上再多算 `prefetchScreens` 个绘图区高度的**预取带**。
*
* ⚠️ 预取带只用于决定「是否还要请求下一帧去补图层」(`AnyVisibleLanePending` / 预热):
* 网格滚动动画很可能在几百毫秒内扫过一整个屏高,若只预建「严格可见」的网格,
* 动画扫进去的那几帧会落在一堆**尚未建过图层**的网格上 ⇒ 每帧都要靠底衬兜底
* 画十几份网格(实测 ~8ms/帧)⇒ 动画明显卡顿(用户 2026-10-17 报告)。
* 预取带让静置期间就把附近网格的图层建好。
* ⚠️ **「结算」不能带预取带**(它要求的是“当前可见区域像素精确”)。
* @param [IN] laneIndex 网格下标。
* @param [IN] prefetchScreens 预取带宽度(倍绘图区高度,0 = 严格可见)。
* @return 可视(或在预取带内)返回 true。
*/
bool LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const;
/** @brief 全部**可视**网格图层是否都已就绪(用于判断能否做结算的整幅精确帧)。 */
bool LanesVisiblyReadyForView() const;
/**
@@ -3134,6 +3158,23 @@ namespace jdi
* @return 仍有可视网格待重画返回 true。
*/
bool AnyVisibleLanePending() const;
/**
* @brief 「图层预热」是否允许再请求一帧(**限速**)。
*
* ⚠️⚠️ 背景:待重画队列在“全部信号各自一块网格”的场景下可能有上百个**离屏**网格。
* 若“队列非空就每帧都请求重绘”,会持续一秒以上地高频重绘,而 `WM_TIMER` 的优先级
* **低于投递消息** ⇒ 动画定时器被饿死(实测 `frames=124 / 动画 tick=1`)。
* 若反过来“离屏的一律不问”,则用户快速滚动到那些网格时它们**还没建过图层**,
* 每帧要靠底衬兜底画十几份网格(实测 ~8ms/帧)⇒ 动画卡顿
*(用户 2026-10-17 报告:MF4 + VERY_BIG_DATA 时点选最后一个信号,滚动明显卡)。
*
* ✓ 折中:以约 `kPrefetchMinIntervalMs` 的间隔**慢速**继续请求,让整批图层在静置期间
* 逐步建好(对 130 个网格大约几秒),期间不会把动画的定时器挤出预算。
*
* @return 允许请求返回 true(并记录本次时刻)。
*/
bool PrefetchStepAllowed();
/** @brief 把某网格排进"待重画"队列(去重;视界/跨度后续变化时会在渲染时再次校验)。 */
void QueueLaneRebuild(int laneIndex);
/**
@@ -3250,6 +3291,14 @@ namespace jdi
std::uint64_t m_wideLastKey = 0;
bool m_wideLastValid = false;
/**
* @brief 上一次为「图层预热」请求宿主重绘的时刻(`GetTickCount64()`)。
*
* 用来给预热**限速**(见 `PrefetchStepAllowed`):既保证离屏网格最终都会建好图层,
* 又不会高频请求重绘把动画的 `WM_TIMER` 饿死。
*/
mutable std::uint64_t m_lastPrefetchRequestMs = 0;
/**
* @brief 实验性功能浮动面板的像素图层项。
*
+9 -5
View File
@@ -330,15 +330,19 @@ namespace jdi
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;
// ⚠️ 用户 2026-10-17 要求:**淡出与提示浮层(`PaintHint`)完全一致** ——
// 到达顶部后**立即开始**淡出(不停留),时长 = `kBackToTopFadeOutMs`(= `kHintFadeMs`),
// 且 alpha **线性**变化(提示浮层就是线性淡出,看起来比 ease-out 更"渐变")。
// 淡入(按钮出现)保留原来的 ease-out 手感,用户没有意见。
const bool fadingOut = !m_backToTopFadeIn;
const float durationMs = fadingOut ? kBackToTopFadeOutMs : kBackToTopFadeInMs;
float progress = (durationMs > 0.0f) ? (static_cast<float>(elapsed) / durationMs) : 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:与其它过渡一致的手感(起步快、末尾慢)。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
// 淡入:ease-out cubic(起步快、末尾慢);淡出:线性(与提示浮层同款)。
const float eased = fadingOut ? progress : (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;
+16 -82
View File
@@ -736,76 +736,8 @@ namespace jdi
return true;
}
bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal,
double timeShift) const
{
const int cells = GridMinorCellCount();
if (cells <= 0 || signal.finiteCount == 0)
{
return false;
}
const std::size_t threshold =
static_cast<std::size_t>(cells) * static_cast<std::size_t>(kSnapVisibleCellsPerPoint);
if (threshold == 0)
{
return false;
}
const std::size_t count = std::min(axis.time.size(), signal.values.size());
if (count == 0)
{
return false;
}
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(axis, count, begin, end, timeShift);
if (end <= begin)
{
return false;
}
// ⚠️ 视界内**至少要有一个可见数据点**:一个数据点都不在屏幕上的信号既没有可吸附的
// 对象、也没有可提示的内容。少了这一条,“数据点全都落在视界之外”的信号会被判成
// “稀疏”(0 < N×4)而被自动切成「点线」,属于无意义的状态抖动。
if (signal.finiteCount < threshold)
{
// 有效点总数本来就不足阈值 ⇒ 只需确认视界内确实有点(稀疏点已缓存时二分判定)。
return HasVisibleSample(axis, signal, begin, end, timeShift);
}
// 一般信号:统计**当前视界内**的有效点数量(攒够阈值立刻早退,代价有界)。
// 连续 NaN 不计入有效点,因此再给一个**有界**的扫描窗口,防止极端情况下长循环。
const std::size_t window =
std::min(std::min(end, signal.values.size()), begin + threshold * kSnapScanWindowFactor + 8u);
std::size_t seen = 0;
for (std::size_t index = begin; index < window; ++index)
{
if (!std::isfinite(signal.values[index]))
{
continue;
}
// ⚠️ 必须按**绘制时刻**判断可见性(`t + offsetTime`),否则存在时间轴偏移时
// 会把“已经移出视界”的采样点算进来(用户报告吸附到历史位置)。
const double time = axis.time[index];
if (!std::isfinite(time))
{
continue;
}
const double displayTime = time + timeShift;
if (displayTime < Tab().ViewMin() || displayTime > Tab().ViewMax())
{
continue;
}
if (++seen >= threshold)
{
return false; // 一屏内的数据点已经足够密:保持自由移动
}
}
return seen > 0;
}
bool PlotView::AutoPointLineNeeded(const TimeAxis& axis, const SignalData& signal,
double timeShift) const
bool PlotView::MinorCellSparse(const TimeAxis& axis, const SignalData& signal,
double timeShift) const
{
const int cells = GridMinorCellCount();
if (cells <= 0 || signal.finiteCount == 0)
@@ -839,7 +771,7 @@ namespace jdi
}
// 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。
const std::size_t need = static_cast<std::size_t>(cells) *
static_cast<std::size_t>(kAutoPointLineMinPointsPerCell);
static_cast<std::size_t>(kSparseMinPointsPerCell);
if (signal.finiteCount < need)
{
return true;
@@ -864,7 +796,7 @@ namespace jdi
std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin());
const std::size_t hi = static_cast<std::size_t>(
std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin());
const std::size_t cap = std::min(hi, lo + kAutoPointLineCellScanCap);
const std::size_t cap = std::min(hi, lo + kSparseCellScanCap);
std::size_t valid = 0;
for (std::size_t index = lo; index < cap; ++index)
{
@@ -872,12 +804,12 @@ namespace jdi
{
continue;
}
if (++valid >= static_cast<std::size_t>(kAutoPointLineMinPointsPerCell))
if (++valid >= static_cast<std::size_t>(kSparseMinPointsPerCell))
{
break;
}
}
if (valid < static_cast<std::size_t>(kAutoPointLineMinPointsPerCell))
if (valid < static_cast<std::size_t>(kSparseMinPointsPerCell))
{
return true; // 该小格稀疏 ⇒ 自动切「点线」
}
@@ -888,7 +820,7 @@ namespace jdi
// 非单调时间轴:一次有界扫描,按小格分桶计数。
std::vector<int> buckets(static_cast<std::size_t>(cells), 0);
const std::size_t scanLimit =
kAutoPointLineCellScanCap * static_cast<std::size_t>(cells);
kSparseCellScanCap * static_cast<std::size_t>(cells);
std::size_t scanned = 0;
for (std::size_t index = begin; index < end; ++index)
{
@@ -915,14 +847,14 @@ namespace jdi
cell = cells - 1;
}
const std::size_t slot = static_cast<std::size_t>(cell);
if (buckets[slot] < kAutoPointLineMinPointsPerCell)
if (buckets[slot] < kSparseMinPointsPerCell)
{
++buckets[slot];
}
}
for (int cell = 0; cell < cells; ++cell)
{
if (buckets[static_cast<std::size_t>(cell)] < kAutoPointLineMinPointsPerCell)
if (buckets[static_cast<std::size_t>(cell)] < kSparseMinPointsPerCell)
{
return true;
}
@@ -1073,9 +1005,11 @@ namespace jdi
// 少了这一步,信号一旦设置过「调整 → 时间轴偏移」,吸附就会落在**历史位置**
// (用户 2026-10-15 报告“存在时间轴偏移调整时无法吸附到正确位置”)。
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
// 只有“一屏内数据点少于 N×4”的信号参与吸附;判据随视界实时重算,
// 因此疏密变化的信号在稀疏区自动吸附、移到稠密区自动恢复自由移动。
if (!SignalSnapEnabled(*axis, *signal, timeShift))
// ⚠️ 判定与「放大自动切点线」**完全同源**(用户 2026-10-17 明确要求“同样的状态判定”):
// 只要存在任何一个小格内的数据点少于 5 个就参与吸附;
// 判定**不看绘制方式** ⇒ 即使用户手动改过显示样式,该吸附的照样吸附。
// 网格内既有符合又有不符合的信号时,只有符合的那条的数据点进入比较(用户要求)。
if (!MinorCellSparse(*axis, *signal, timeShift))
{
continue;
}
@@ -3106,8 +3040,8 @@ namespace jdi
// **只要存在任何一个小格内的数据点少于 5 个**就自动切「点线」(局部稀疏即提示);
// 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。
// ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`)。
// ⚠️ 「光标吸附」仍用整屏判据(`SignalSnapEnabled`),用户明确要求“其它规则不变”。
if (AutoPointLineNeeded(*axis, *data, style->offsetTime))
// ⚠️ 「光标吸附」用的是**同一个判定**(`MinorCellSparse`),用户要求两者“同样的状态判定”。
if (MinorCellSparse(*axis, *data, style->offsetTime))
{
if (!style->autoScatterActive)
{
@@ -2238,6 +2238,46 @@ namespace jdi
return true;
}
bool PlotView::DoubleClickIsPlainPress(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (!UiPointInRect(m_area, fx, fy))
{
return false;
}
// 实验性功能的浮动面板(含其上的复选框 / 滑块 / 复位按钮 / 关闭 ✕ / 标题栏):
// 只有「按下」语义 ⇒ 快速连点必须当作一次次按下(否则连点丢失)。
{
int panelLane = -1;
int panelKind = 0;
if (TopPanelAtPoint(fx, fy, panelLane, panelKind))
{
return true;
}
}
// 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。
if (m_backToTopAlpha > 0.01f)
{
const gd::RectF backToTop = BackToTopRect();
if (backToTop.Width > 0.0f && UiPointInRect(backToTop, fx, fy))
{
return true;
}
}
// 绘图区右侧滚动条:拖动语义。
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
{
return true;
}
// 网格 GX 徽标:按下即(抬起时)弹出「所属网格」菜单,双击没有额外含义。
if (LaneBadgeAtPoint(x, y) >= 0)
{
return true;
}
return false;
}
bool PlotView::OnMouseLeave()
{
bool changed = false;
+31 -27
View File
@@ -64,6 +64,17 @@ namespace jdi
* 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。
*/
inline const int kLayerRollQuantum = 16;
/**
* @brief 「图层预热」两次请求重绘之间的最小间隔(毫秒)。
*
* 上百个网格时待重画队列可能长期非空(每个网格的图层约 2~15ms)。若每帧都请求重绘会把
* 动画的 `WM_TIMER` 饿死(见 `PrefetchStepAllowed`);完全不请求则滚动到那些网格时
* 它们还没建过图层,每帧要靠底衬兜底重画十几份网格(~8ms/帧)而卡顿。
* 取 32ms ≈ 31 次/秒:既留足了动画定时器(15.5ms 周期)的预算,又能让 130 个网格
* 在大约 4 秒内置好。
*/
inline const std::uint64_t kPrefetchMinIntervalMs = 32;
/**
* @brief 「点选信号 ⇒ 把目标网格滚入可视区」的平滑过渡参数(与信号列表同源)。
*
@@ -81,7 +92,9 @@ namespace jdi
*/
inline const float kBackToTopSize = 36.0f; /**< 按钮边长(逻辑像素,圆形直径)。 */
inline const float kBackToTopMargin = 12.0f; /**< 与绘图区右下角的边距。 */
inline const float kBackToTopFadeMs = 170.0f; /**< 淡入/淡出时长(毫秒)。 */
/** 淡入时长(毫秒):出现时的 ease-out 手感保持原样。 */
inline const float kBackToTopFadeInMs = 170.0f;
// ⚠️ 淡出时长 = `kBackToTopFadeOutMs`(**与提示浮层一致**),见下方 `kHintFadeMs` 处。
/** 向下滚动超过该距离(逻辑像素)才显示按钮(避免轻微滚动就弹出)。 */
inline const float kBackToTopShowOffset = 24.0f;
/** 向上箭头相对按钮尺寸的比例(箭头顶端到中心、半宽)。 */
@@ -176,15 +189,6 @@ namespace jdi
g.FillPolygon(&brush, arrow, 3);
}
/**
* @brief 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。
*
* 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`),
* **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。
* 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
*/
inline const int kSnapVisibleCellsPerPoint = 4;
/**
* @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。
*
@@ -203,34 +207,26 @@ namespace jdi
inline const int kSnapSampleProbeSteps = 64;
/**
* @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。
*
* 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退;
* 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。
*/
inline const std::size_t kSnapScanWindowFactor = 64;
/**
* @brief 「放大后自动切点线」的新判据:**单个横向网格小格**内的最少数据点数。
* @brief 「按小格判定稀疏」的最少数据点数:**单个横向网格小格**内少于该值即视为稀疏。
*
* 用户 2026-10-17 重新定义(**推翻**此前的“一屏内数据点 < N×4”):
* 按缩放后的横向时间轴网格划分小格,**只要存在任何一个小格内的数据点少于 5 个**,
* 就自动切到「点线」(其余规则不变:不再满足时自动恢复,用户手动改过则不恢复)。
* 这比旧判据更贴近用户意图:**局部**稀疏(例如数据只覆盖视界的一部分、或中间有空洞)
* 也会提示出数据点的位置,而不是等整屏都变稀疏。
* 就视为稀疏。这比旧判据更贴近用户意图:**局部**稀疏(例如数据只覆盖视界的一部分、
* 或中间有空洞)也会提示出数据点的位置,而不是等整屏都变稀疏。
*
* ⚠️ 该判据**只用于**「放大自动切点线」(`UpdateAutoScatterStyles`);
* 光标吸附仍用 `kSnapVisibleCellsPerPoint` 的整屏判据(用户明确要求“其它规则不变”)。
* ⚠️ 该判据是**同一个判定**的两处用途(用户 2026-10-17 明确要求“同样的状态判定”):
* * 「放大自动切点线」(`UpdateAutoScatterStyles`);
* * 「光标吸附」(`SnapTimeToSample`)—— 即使用户手动改过显示样式,也照样吸附。
*/
inline const int kAutoPointLineMinPointsPerCell = 5;
inline const int kSparseMinPointsPerCell = 5;
/**
* @brief 单个小格内“判定为稠密”所需的扫描上限(样本数)。
*
* 新判据需要逐小格数点,但只需知道“是否 ≥ `kAutoPointLineMinPointsPerCell`”
* 新判据需要逐小格数点,但只需知道“是否 ≥ `kSparseMinPointsPerCell`”
* ⇒ 累计到该值即早退;这里再给一个上限,保证含大量 NaN 的极端小格也不会长循环。
*/
inline const std::size_t kAutoPointLineCellScanCap = 512;
inline const std::size_t kSparseCellScanCap = 512;
/**
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。
@@ -674,6 +670,14 @@ namespace jdi
inline const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */
inline const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */
/**
* @brief 「回到最上方」浮动按钮的**淡出**时长(毫秒)—— **与提示浮层完全一致**。
*
* 用户 2026-10-17 明确要求(参考「选区放大框选过程中的浮动提示淡出」):
* 到达顶部后按钮**立即开始**淡出(**不停留**,不像提示浮层那样先停 `kHintHoldMs`),
* 且淡出时长与 alpha 曲线(**线性**)都跟 `PaintHint` 的淡出一模一样。
*/
inline const float kBackToTopFadeOutMs = static_cast<float>(kHintFadeMs);
/**
* @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。
+38 -9
View File
@@ -856,13 +856,16 @@ namespace jdi
// 真并行(3.98x),但**图层光栅化(GDI+)本身会被串行化**,并行重建 5 个网格 ≈ 逐个重建
//(1.03x)→ 一次性补齐只会把一帧从 ~10ms 抬到 ~31ms(更容易掉帧)。
// 仍按预算逐个补:不用一次把全部过期网格塞进同一帧(并行重建≈逐个重建)。
// ⚠️ **“是否再来一帧”只看还有没有【可视】网格待补**:上百个离屏网格若每帧都
// 请求重绘,会形成持续一秒以上的高频重绘,把动画定时器饿死(见 `AnyVisibleLanePending`)。
// ⚠️ **“是否再来一帧”的判据**(见 `AnyVisibleLanePending` / `PrefetchStepAllowed`):
// * 还有可视 / 预取带内的网格待补 ⇒ 立刻继续;
// * 只剩离屏网格 ⇒ 按 `kPrefetchMinIntervalMs` 限速继续(不会饿死动画定时器)。
if (viewStopped)
{
RenderPendingLaneRebuilds(1);
}
const bool morePending = AnyVisibleLanePending();
const bool morePending =
AnyVisibleLanePending() ||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed());
// 绘图区底色(覆盖时间轴与侧边留白区域)。
UiFillRect(g, m_area, m_theme->plotBg);
// 时间轴(宽缓冲不含它,前台按当前视图绘制)—— 必须在贴图**之前**算好:
@@ -2441,6 +2444,11 @@ namespace jdi
}
bool PlotView::LaneVisuallyRelevant(int laneIndex) const
{
return LaneVisuallyRelevant(laneIndex, 0);
}
bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
@@ -2451,8 +2459,10 @@ namespace jdi
return false;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
// 用一个网格高的余量:即将滚入视区的网格也算相关,避免滚动时出现一帧空档。
const float margin = std::max(rect.Height, 0.0f);
// 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关);
// 预取带在其上再按屏高放大(见头文件说明)。
const float margin = std::max(rect.Height, 0.0f) +
static_cast<float>(std::max(0, prefetchScreens)) * m_plotRect.Height;
return (rect.Y + rect.Height + margin > m_plotRect.Y) &&
(rect.Y - margin < m_plotRect.Y + m_plotRect.Height);
}
@@ -2481,9 +2491,12 @@ namespace jdi
bool PlotView::AnyVisibleLanePending() const
{
// ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高,
// 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格
// (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。
for (const int laneIndex : m_layerRebuildPending)
{
if (LaneVisuallyRelevant(laneIndex))
if (LaneVisuallyRelevant(laneIndex, 1))
{
return true;
}
@@ -2959,6 +2972,18 @@ namespace jdi
return ok;
}
bool PlotView::PrefetchStepAllowed()
{
const std::uint64_t now = GetTickCount64();
if (now >= m_lastPrefetchRequestMs &&
(now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs)
{
return false;
}
m_lastPrefetchRequestMs = now;
return true;
}
void PlotView::WarmLaneLayersStep()
{
if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr)
@@ -2989,9 +3014,13 @@ namespace jdi
// 也不会走"位移超出画布 → 立刻整块重画"那条分支。
RollWideBuffer();
RenderPendingLaneRebuilds(1);
// ⚠️ 只在还有**可视**网格待补时才请求再送一帧:离屏网格的数量可能上百,
// 若按“队列非空”每帧请求重绘,会持续一秒以上地高频重绘并饿死动画(见 `AnyVisibleLanePending`)。
const bool morePending = AnyVisibleLanePending();
// ⚠️ 是否继续请求下一帧:
// * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`);
// * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热,
// 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。
const bool morePending =
AnyVisibleLanePending() ||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed());
if (morePending)
{
const HWND host = m_host;
@@ -146,6 +146,24 @@ namespace jdi
*/
bool OnDoubleClick(int x, int y);
/**
* @brief 双击落点是否应**当作普通「按下」**处理(该位置没有双击语义)。
*
* ⚠️ 窗口类带 `CS_DBLCLKS`:快速连点同一个控件时,第二次(及之后)按下会被 Windows
* 合成为 `WM_LBUTTONDBLCLK`。若宿主把它交给 `OnDoubleClick`,而该位置本来只有
* 「按下 / 单击」语义(复选框、全部显示隐藏、绝对值、折叠三角、「组X」、✕、GX 徽标…),
* 那一次按下就**完全丢失** —— 用户看到的正是“连点好几下、状态只切换了几次”(不跟手)。
*
* ✓ 本函数为这些位置返回 true,宿主改走 `OnLButtonDown`(与单击完全相同的路径)。
* 只有真正有双击语义的位置(线宽/不透明度数值框、样式区「目标」行名称与 GX 标签、
* 分组 / 信号行的**名称区域**改名)返回 false。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @return 应当作普通按下返回 true。
*/
bool DoubleClickIsPlainPress(int x, int y);
/**
* @brief 处理鼠标右键按下(弹出上下文菜单)。
* @param [IN] x 客户区横坐标。
@@ -1211,6 +1229,17 @@ namespace jdi
* ✕ 上才真正执行;按住后移开再松开视为放弃(与常见"可取消按钮"交互一致)。
*/
RowPart m_pendingClosePart = RowPart::NONE; /**< 待确认的 ✕ 所在行内区域(CLOSE / REMOVE_SIGNAL)。 */
/**
* @brief 最近一次「点击分组行名称区域」的时刻 / 行 / 点击前的折叠状态。
*
* 用户 2026-10-17 要求:分组**名称区域**单击也展开/收起(恢复),但**只有快速双击**
* 才进入改名编辑(降低灵敏度:较慢但明显不是双击的点击频率只当两次单击)。
* 因此需要记住上一次单击的信息,以便在双击时**撤销那一次折叠切换**再进入编辑。
*/
std::uint64_t m_groupNameClickMs = 0;
int m_groupNameClickRow = -1;
bool m_groupNameClickWasCollapsed = false;
int m_pendingCloseRow = -1; /**< 待确认的 ✕ 所在行下标。 */
bool m_pendingFilterClear = false; /**< 待确认的筛选框清空 ✕。 */
@@ -1446,11 +1446,14 @@ namespace jdi
m_pendingCloseRow = row;
return true;
}
// 折叠 / 展开分组:**只有**点到「折叠三角」或「组X」标签才生效
//(用户 2026-10-16 要求:点到分组名本体不折起)。
if (part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE)
// 折叠 / 展开分组:三角、「组X」标签、**以及名称本体**都生效。
// 用户 2026-10-17 要求:名称本体恢复单击展开/收起(此前为了避开“误入改名”
// 而禁掉了它,结果只能点三角、很不方便);误入改名改由**收紧双击阈值**解决
//(见 `OnDoubleClick`:只有快速双击才改名)。
{
return true;
m_groupNameClickMs = GetTickCount64();
m_groupNameClickRow = row;
m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex);
}
ToggleGroupFromRow(item.groupIndex, row);
return true;
@@ -1674,12 +1677,16 @@ namespace jdi
const ListRow item = m_rows[static_cast<std::size_t>(row)];
// 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。
// 分组行:双击折叠三角用于折叠;双击**名称区域**进入重命名 ——
// ⚠️ 但只有**快速双击**才算(用户 2026-10-17 要求降低灵敏度):
// 按下那一次已经把分组展开/收起了(`OnLButtonDown` 统一处理三角/「组X」/名称),
// 因此这里要把它**撤销**回点击前的状态,然后才进入改名;
// 超过 `kGroupRenameDoubleClickMs` 的“慢双击”只当**又一次单击**(再切一次折叠)。
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::DISCLOSURE)
{
// 与单击分组行同一入口:悬浮时先滚动就位再播放折起动画。
// 与单击分组行同一入口(悬浮时先滚动就位再播放折起动画)。
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
@@ -1687,9 +1694,27 @@ namespace jdi
{
return false;
}
// 重命名分组会改变列表行高度以外的展示内容,重建行缓存以保证文字刷新。
// 名称区域:判定“快速双击”。
const std::uint64_t now = GetTickCount64();
const bool fastDoubleClick =
(m_groupNameClickRow == row) && (now >= m_groupNameClickMs) &&
(now - m_groupNameClickMs <= kGroupRenameDoubleClickMs);
if (!fastDoubleClick)
{
// 慢双击 / 两次无关点击:只当另一次单击(切换折叠)。
m_groupNameClickMs = now;
m_groupNameClickRow = row;
m_groupNameClickWasCollapsed = IsGroupCollapsed(item.groupIndex);
ToggleGroupFromRow(item.groupIndex, row);
return true;
}
// 快速双击 ⇒ 改名:先撤销“按下”那一次对折叠状态的切换,再进入编辑。
m_groupNameClickMs = 0;
m_groupNameClickRow = -1;
SetGroupCollapsed(item.groupIndex, m_groupNameClickWasCollapsed);
m_rowsDirty = true;
return BeginEdit(StyleEditField::GROUP_TITLE, row);
EnsureRows();
return BeginEdit(StyleEditField::GROUP_TITLE, GroupRowIndex(item.groupIndex));
}
// 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。
@@ -1704,6 +1729,54 @@ namespace jdi
return BeginEdit(StyleEditField::SIGNAL_NAME, row);
}
bool SignalPanel::DoubleClickIsPlainPress(int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 真正有双击语义的位置(保持双击行为):
// * 线宽 / 不透明度数值框:双击进入(或保持)输入状态;
// * 样式区「目标」行的名称与 GX 标签:双击进入改名;
// * 内联输入框自身:双击用于选词 / 定位插入符。
if (m_editField != StyleEditField::NONE && UiPointInRect(EditFieldRect(), fx, fy))
{
return false;
}
if (UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy))
{
return false;
}
UpdateTargetRowGeometry();
if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
{
return false;
}
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))
{
return false;
}
RowPart part = RowPart::NONE;
const int row = HitRow(x, y, part);
if (row < 0)
{
// 列表之外(样式区按钮 / 筛选框 / 面板空白):全部只有单击语义。
return true;
}
// 行的**名称区域**(分组 / 信号)都靠双击改名 ⇒ 保留双击语义。
if (part == RowPart::BODY)
{
return false;
}
// 其余全部(复选框、折叠三角、「组X」、✕、GX 徽标、全部显示隐藏、色块、绘制方式、
// 滑块、「调整」按钮、筛选框…)都只有单击语义 ⇒ 快速连点必须当作一次次按下。
return true;
}
bool SignalPanel::OnRButtonDown(int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr)
@@ -156,6 +156,17 @@ namespace jdi
inline const float kGroupAnimPerRowMs = 6.0f; /**< 每多一行增加的时长。 */
inline const float kGroupAnimMinMs = 150.0f; /**< 最短时长。 */
inline const float kGroupAnimMaxMs = 320.0f; /**< 最长时长。 */
/**
* @brief 分组**名称区域**进入改名编辑所需的「快速双击」时间上限(毫秒)。
*
* 用户 2026-10-17 要求:名称区域单击 = 展开/收起(恢复),**只有快速双击**才进入编辑框;
* “较慢但明显不是双击的点击频率”必须只当两次单击。
* Windows 的双击判定上限通常是 500ms(`GetDoubleClickTime()`),太宽松 ——
* 用户快速连点两次想“展开看一眼再收起”时会误入编辑态。这里收紧到 300ms:
* 真双击(一般 <250ms)照常进入编辑,稍慢的两次单击则各自触发展开/收起。
*/
inline const std::uint64_t kGroupRenameDoubleClickMs = 300;
inline const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
inline const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
inline const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */