「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件

一、「调整」类数值的符号(SignalPanel)
1. FormatSignedNumber:0 不再显示 `+`(旧实现恒带符号 → `+0.00`),Y 轴偏移默认值显示 0.00。
2. FormatGainText:非 0 正值带 `+`、负值带 `-`、0 不带符号;仅用于非编辑态显示,
   进入编辑的初始文本仍为无符号纯数值。
3. 新增 FormatOffsetTimeText:`Δt:` 后按正负带符号(Δt:+5s 60ms / Δt:-5s 60ms / Δt:0s),
   打开面板 / CMD_OFFSET_TIME 提交 / CMD_OFFSET_RESET 三处统一走它。

二、边沿统计:按帧预算 + 每状态公平配额
(用户报告:按住横向光标不动时统计停在“统计中…”不更新;改中心轴参数回车后不动鼠标时停在部分值)
根因:预算按 GetTickCount64() 分桶,而系统时钟粒度约 15.6ms;RequestEdgeScanFrame 的
PostMessage 自持重绘把帧率拉高 ⇒ 同一 tick 内连画多帧却完全不推进。
1. Paint 开头 ++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;(按帧重置),
   删除 m_edgeScanTick。
2. 每个状态各自持有每帧配额(pumpedFrame/pumpedPairs):perState = max(60000, 1.2M/share),
   share = max(kEdgeScanFairShare=6, m_edgeScans.size())。
   注意:不能直接用 size() 分摊 —— 状态在同一帧内逐个创建,第一个那一刻 size()==1 会独占整额,
   同帧后面新建的状态全部被截断为 0 配额(即“只有第一条信号推进”)。
实测(合成 5 信号 × 200 万采样、同一网格):首帧 5 个状态全部推进;9 帧内全部扫完(期间不注入
任何鼠标消息);数值与独立参考实现逐位一致;阈值变化后就地重置、随后“停住不动”仍精确扫完。

三、浮动面板排版
1. 信号名宽度上限 kEdgePanelNameMaxWidth=150 逻辑像素:宽度计算与绘制都用 min(实测, 上限) +
   UiEllipsize(300 字符名字只比单字符宽 44px,旧实现要多 5100px)。
2. 统计信息两列等宽(新增 EdgeRisingText/EdgeFallingText/EdgeCountsPending):面板宽度按
   2×max(两列文本宽)+kEdgePanelStatColumnGap 预留,绘制时两列各占 (可用宽−间距)/2 ⇒ 多条信号的
   “上升沿/下降沿”列起点固定、严格垂直对齐。
3. 未完成时 rising 与 falling 两个数字后面都带 `…`(旧实现只在整行末尾追加一次,
   视觉上只跟在“下降沿”的数字后面)。

四、弱峰调整面板
1. 每行滑块右侧新增复位控件(复用还原图标绘制 PaintPanelResetIcon):悬停高亮 + IDC_HAND +
   一次性提示;点击恢复幅度 0 / 迭代 1 且 maxIterations 回到初始上限 10。
   EdgePanelLayout 新增 rowLevelResets/rowIterResets(并进 PanelSlotKey),两行按钮共用同一 X。
2. 迭代次数上限输入框的空内容提示改为“输入上限(当前N)”。
3. 滑块标签去掉多余的 %/N(kWeakPeakLevelLabel=调整幅度;新增 kWeakPeakIterationsSliderLabel=
   迭代次数;MEAN / CENTER 输入框的“迭代次数 N”保持不变)。

五、验证(全部离屏;进程/线程 BELOW_NORMAL、窗口 SW_HIDE、不注入真实鼠标键盘)
- 新增 .preflight/perf/panelcheck_main.cpp:46/46 通过(符号规则走真实菜单路径;按帧推进 /
  不饿死 / 精确性 / 自持驱动;两列等宽预留(含未完成时两处 …);名字上限;
  弱峰复位控件位置 / 对齐 / 点击恢复默认值与上限)。
- 既有 verify_main.cpp:47/47(另 2 条为本机 .preflight 未随远端同步的旧规则断言)。
- make release 零警告。

规则同步:Documents/agent_mem.md 新增 §9.26,§11.2 新增第 43–48 条硬约束,变更日志补一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-15 01:35:47 +08:00
1 parent 65f7625dce
commit fba40ee1a2
4 files changed
+564 -80

No files matched your search

+358 -60
View File
@@ -73,7 +73,56 @@ namespace jdi
return gd::RectF(left, top, 0.0f, 0.0f);
}
return gd::RectF(left, top, right - left, bottom - top);
} const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
}
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
*
* 与 `PopupMenu` 里「系数 / 偏移」二级面板标题栏右侧的还原图标**同一套笔画**
*(用户要求:“可以直接挪用信号『调整』功能中『系数/偏移』二级菜单内的复位控件”),
* 因此弱峰调整面板的复位控件与它视觉完全一致。不依赖字体或资源,任意 DPI 都清晰。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 图标外接矩形(正方形)。
* @param [IN] color 笔画颜色(0xAARRGGBB)。
* @param [IN] scale DPI 缩放比例。
*/
void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color,
float scale)
{
const float inset = 2.5f * scale;
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
rect.Height - inset * 2.0f);
if (box.Width <= 1.0f || box.Height <= 1.0f)
{
return;
}
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
color & 0xFFu),
std::max(1.0f, 1.5f * scale));
pen.SetStartCap(gd::LineCapRound);
pen.SetEndCap(gd::LineCapRound);
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
const float cx = box.X + box.Width * 0.5f;
const float cy = box.Y + box.Height * 0.5f;
const float radius = box.Width * 0.5f;
const float angle = 300.0f * 3.14159265f / 180.0f;
const float tipX = cx + std::cos(angle) * radius;
const float tipY = cy + std::sin(angle) * radius;
const float size = std::max(2.5f, 3.2f * scale);
gd::PointF arrow[3];
arrow[0] = gd::PointF(tipX, tipY);
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu));
g.FillPolygon(&brush, arrow, 3);
}
const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
@@ -87,6 +136,8 @@ namespace jdi
const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */
const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */
const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */
const float kEdgePanelNameMaxWidth = 150.0f; /**< 面板内信号名的宽度上限(超过则省略;避免超长信号名把面板顶得很长)。 */
const float kEdgePanelStatColumnGap = 14.0f; /**< 「上升沿 / 下降沿」两列之间的间距(保证多信号的文字垂直对齐)。 */
const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */
const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */
const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */
@@ -96,6 +147,15 @@ namespace jdi
const float kWeakPeakSliderRowHeight = 26.0f; /**< 弱峰调整面板中每个滑块行的高度。 */
const float kWeakPeakTrackHeight = 6.0f; /**< 弱峰调整滑块的轨道高度。 */
const float kWeakPeakTrackMinWidth = 120.0f; /**< 弱峰调整滑块轨道的最小宽度。 */
/**
* @brief 「弱峰调整」滑块行右侧**复位控件**的边长(逻辑像素)。
*
* 每行滑块都有一个复位按钮(与「系数/偏移」二级面板里的「还原」图标同一套绘制),
* 点击把该行的参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
*/
const float kWeakPeakResetButtonSize = 16.0f;
/** 复位控件与其左侧数值列之间的间距(逻辑像素)。 */
const float kWeakPeakResetGap = 4.0f;
const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */
/** 迭代次数滑块「自定义上限」的允许范围(与算法内部的阶数夹取保持一致)。 */
const int kWeakPeakIterationsMaxLimit = 1000;
@@ -144,8 +204,10 @@ namespace jdi
* 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。
*/
const std::size_t kHugeSignalPreviewSamples = 4000000ull;
const wchar_t* const kWeakPeakLevelLabel = L"调整幅度 %"; /**< 逐条目面板里「幅度」滑块行的标签。 */
const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */
const wchar_t* const kWeakPeakLevelLabel = L"调整幅度"; /**< 逐条目面板里「幅度」滑块行的标签(用户要求:滑动条前不再单独显示 `%`)。 */
const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< MEAN / CENTER 面板里「迭代次数」输入框的标签。 */
/** 「弱峰调整」迭代次数滑块行的标签(用户要求:滑动条前不再单独显示 `N`)。 */
const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数";
const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
/**
@@ -167,27 +229,43 @@ namespace jdi
*/
const std::size_t kEdgeBlockSamples = 8192;
/**
* @brief 每个 tick(约一帧)允许「边沿计数」扫描的采样对总数。
* @brief 每一帧允许「边沿计数」扫描的采样对总数(跨全部累积状态共享)。
*
* 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms,
* 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上,
* 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。
*
* ⚠️ **必须按帧重置**(在 `Paint` 开头):旧实现按 `GetTickCount64()` 分桶,
* 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高),
* 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”,
* “回车确认参数后不移动鼠标就一直停在部分值”)。
*/
const std::size_t kEdgeScanPairsPerTick = 1200000;
const std::size_t kEdgeScanPairsPerFrame = 1200000;
/**
* @brief 「边沿计数」结果的显示文本。
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
* @param [IN] complete 是否为该阈值的完整精确值。
* @return 面板/宽度测量共用的文本(未算完时带省略号,完全未开始时显示“统计中…”)。
* @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。
*
* 过去所有状态共享一个“本 tick 总预算”,而推进总是从列表中的第一个状态开始
* —— 5 条信号时只有第一条有进展,其余 4 条长期显示“统计中…”(用户报告
* “很多项都停在了统计中不更新”)。现在每个状态各自持有每帧配额,互不饿死。
*/
std::wstring EdgeCountsText(std::size_t rising, std::size_t falling, bool complete)
const std::size_t kEdgeScanPairsPerStateMin = 60000;
/**
* @brief 计算每状态配额时假定的“最小并发状态数”。
*
* ⚠️ 不能直接用 `m_edgeScans.size()`:状态是在**同一帧内逐个创建**的,第一个状态的
* 那一刻 `size() == 1`,会拿走整额预算 → 同帧后面新建的状态全部被截断为 0 配额
*(首帧只有第一条信号推进)。按一个典型并发量(普通 + 中心轴 × 网格内信号数)
* 下限分摊,可保证同一帧里所有状态都得到推进。
*/
const std::size_t kEdgeScanFairShare = 6;
/**
* @brief 「边沿计数」结果里的**上升沿**部分文本。
* @param [IN] rising 上升沿数量。 @param [IN] complete 是否为完整精确值。
* @return 形如 "上升沿 123"(未算完时追加 " …")的文本。
*/
std::wstring EdgeRisingText(std::size_t rising, bool complete)
{
if (!complete && rising == 0 && falling == 0)
{
return L"统计中…";
}
std::wstring text = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling);
std::wstring text = L"上升沿 " + std::to_wstring(rising);
if (!complete)
{
text += L" …";
@@ -195,6 +273,48 @@ namespace jdi
return text;
}
/**
* @brief 「边沿计数」结果里的**下降沿**部分文本。
* @param [IN] falling 下降沿数量。 @param [IN] complete 是否为完整精确值。
* @return 形如 "下降沿 456"(未算完时追加 " …")的文本。
*/
std::wstring EdgeFallingText(std::size_t falling, bool complete)
{
std::wstring text = L"下降沿 " + std::to_wstring(falling);
if (!complete)
{
text += L" …";
}
return text;
}
/**
* @brief 「边沿计数」是否处于“完全未算出”状态(用于显示“统计中…”)。
*/
bool EdgeCountsPending(std::size_t rising, std::size_t falling, bool complete)
{
return (!complete && rising == 0 && falling == 0);
}
/**
* @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。
*
* ⚠️ 未完成时**两个数字后面都要带 `…`**(旧实现只在整行末尾追加一个 `…`,
* 视觉上只跟在“下降沿”的数字后面,用户明确要求“为上升沿也加上这个尾缀”)。
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
* @param [IN] complete 是否为该阈值的完整精确值。
* @return 面板/宽度测量共用的文本。
*/
[[maybe_unused]] std::wstring EdgeCountsText(std::size_t rising, std::size_t falling,
bool complete)
{
if (EdgeCountsPending(rising, falling, complete))
{
return L"统计中…";
}
return EdgeRisingText(rising, complete) + L" " + EdgeFallingText(falling, complete);
}
/**
* @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。
*
@@ -237,7 +357,7 @@ namespace jdi
float WeakPeakLabelColumnWidth(HFONT uiSmall)
{
return std::max(UiMeasureTextWidth(uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(uiSmall, kWeakPeakIterationsLabel));
UiMeasureTextWidth(uiSmall, kWeakPeakIterationsSliderLabel));
}
/**
@@ -1391,7 +1511,6 @@ namespace jdi
// 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。
m_edgeScans.clear();
m_edgeScanBudget = 0;
m_edgeScanTick = 0;
m_meanCurveCache.clear();
m_weakPeakCurveCache.clear();
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
@@ -3238,19 +3357,28 @@ namespace jdi
return std::isfinite(drawnCenter) ? (drawn - drawnCenter) : nan;
};
// ---- 推进(受每 tick 预算限制)------------------------------------------
// ---- 推进(受「每帧总量 + 每状态配额」双重限制)--------------------------
// ⚠️ 预算在 `Paint` 开头**按帧**重置(不再按 `GetTickCount64()` 分桶):系统时钟
// 粒度约 15.6ms,帧率一高就会在同一 tick 内连画多帧而完全不推进 —— 用户报告的
// “按住横向光标不动时统计卡在统计中”“回车确认参数后不移动鼠标就一直停在部分值”
// 就是这个原因。
// ⚠️ 同时**每个状态各自持有配额**:否则推进总从列表第一个状态扫起,多条信号时
// 只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。
const bool suppressPartial = state->justReset && !state->complete;
if (!state->complete && !state->blockRising.empty())
{
// 扫描预算按 tick 共享:同一帧里本函数会被调用多次(面板宽度测量 + 绘制 + 多条信号),
// 若每次都各扫一份,单帧总开销就会翻好几倍(实测整段 25.8M 采样 ≈115ms)。
const std::uint64_t tick = GetTickCount64();
if (tick != m_edgeScanTick)
const std::size_t activeStates = std::max<std::size_t>(1, m_edgeScans.size());
const std::size_t share = std::max<std::size_t>(kEdgeScanFairShare, activeStates);
const std::size_t perState =
std::max<std::size_t>(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / share);
if (state->pumpedFrame != m_edgeScanFrame)
{
m_edgeScanTick = tick;
m_edgeScanBudget = kEdgeScanPairsPerTick;
state->pumpedFrame = m_edgeScanFrame;
state->pumpedPairs = 0;
}
if (m_edgeScanBudget > 0)
std::size_t quota = (perState > state->pumpedPairs) ? (perState - state->pumpedPairs) : 0;
quota = std::min(quota, m_edgeScanBudget);
if (quota > 0)
{
state->justReset = false;
// 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。
@@ -3260,12 +3388,13 @@ namespace jdi
std::size_t scanned = 0;
std::size_t pair = state->scannedPair;
std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0;
while (pair < count && scanned < m_edgeScanBudget)
while (pair < count && scanned < quota && m_edgeScanBudget > 0)
{
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples);
const std::size_t from = std::max(pair, blockFirst);
const std::size_t to = std::min(blockLast, from + (m_edgeScanBudget - scanned) - 1);
const std::size_t avail = std::min(quota - scanned, m_edgeScanBudget);
const std::size_t to = std::min(blockLast, from + avail - 1);
if (to < from)
{
break;
@@ -3340,6 +3469,7 @@ namespace jdi
state->localEnd = 0;
state->localValid = false;
}
state->pumpedPairs += scanned;
m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned);
}
}
@@ -3454,7 +3584,12 @@ namespace jdi
kEdgePanelTitleHeight * m_scale;
// 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。
// 用 UiMeasureTextWidth(无省略号)测量,保证数字完整显示。
// ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长,
// 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。
// ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。
float nameColumnWidth = 0.0f;
float statRisingWidth = 0.0f;
float statFallingWidth = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -3462,7 +3597,10 @@ namespace jdi
{
continue;
}
const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title());
const float nameWidth =
std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()),
kEdgePanelNameMaxWidth * m_scale);
nameColumnWidth = std::max(nameColumnWidth, nameWidth);
if (itemized)
{
// 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。
@@ -3492,11 +3630,25 @@ namespace jdi
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“统计中…”/省略号),
// 否则推进过程中数字变长会被截断(统计数字必须完整显示)。
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountsText(rising, falling, complete));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth));
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
// 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。
const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…");
statRisingWidth = std::max(
statRisingWidth,
std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)),
pendingWidth));
statFallingWidth =
std::max(statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete)));
}
if (statRisingWidth > 0.0f || statFallingWidth > 0.0f)
{
// 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定,
// 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。
const float maxColumn = std::max(statRisingWidth, statFallingWidth);
const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
std::max(nameColumnWidth, countsWidth));
}
if (centerAxis || meanFilter)
@@ -3515,10 +3667,11 @@ namespace jdi
if (weakPeak)
{
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列」(等级与迭代次数两行共用同一列宽)。
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。
const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale +
kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale +
WeakPeakValueColumnWidth(m_fonts->monoSmall);
WeakPeakValueColumnWidth(m_fonts->monoSmall) +
kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
@@ -4616,6 +4769,21 @@ namespace jdi
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 两行滑块右侧的**复位控件**:与其它可点击图标一致,用手型光标提示可点击。
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
@@ -5029,10 +5197,43 @@ namespace jdi
// 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。
bool overEdgePanel = false;
int panelLane = -1;
int hoverPanelKind = 0;
{
int hoverKind = 0;
overEdgePanel = TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane,
hoverKind);
hoverPanelKind);
}
// 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。
// 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。
bool overPeakReset = false;
if (overEdgePanel && panelLane >= 0 && m_tab != nullptr &&
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK)
{
const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK);
for (std::size_t rowIndex = 0; rowIndex < resetLayout.rowLevelResets.size(); ++rowIndex)
{
const gd::RectF& level = resetLayout.rowLevelResets[rowIndex];
if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast<float>(y)))
{
overPeakReset = true;
break;
}
if (rowIndex < resetLayout.rowIterResets.size())
{
const gd::RectF& iter = resetLayout.rowIterResets[rowIndex];
if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast<float>(y)))
{
overPeakReset = true;
break;
}
}
}
}
if (overPeakReset != m_peakResetHover)
{
m_peakResetHover = overPeakReset;
SetHint(overPeakReset ? std::wstring(L"点击恢复该参数的默认值") : std::wstring());
changed = true;
}
// 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。
@@ -5320,6 +5521,28 @@ namespace jdi
continue;
}
Lane::WeakPeakItem& item = tabLane.EnsureWeakPeakItem(ref);
// 两行滑块右侧的**复位控件**:点击恢复该行参数的默认值
//(幅度 → 0;迭代次数 → 1 且上限回到初始上限 `kWeakPeakIterationsDefaultMax`)。
// 与「系数/偏移」二级面板里的还原图标同一交互语义(面板保持打开、就地回显)。
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
{
item.amountPercent = 0;
m_weakPeakCurveCache.clear();
m_dragMode = PlotDragMode::NONE;
return true;
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], fx, fy))
{
item.iterations = kWeakPeakIterationsMin;
item.maxIterations = kWeakPeakIterationsDefaultMax;
m_weakPeakCurveCache.clear();
m_dragMode = PlotDragMode::NONE;
return true;
}
// 迭代次数行右侧的**数值文本**:点击后整行变成输入框以修改滑块上限。
// ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。
if (layout.rowIterValues[rowIndex].Width > 0.0f &&
@@ -6382,6 +6605,13 @@ namespace jdi
BuildLaneVisuals();
// 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明):
// 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高)
// 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告
// “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。
++m_edgeScanFrame;
m_edgeScanBudget = kEdgeScanPairsPerFrame;
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear();
@@ -10799,11 +11029,15 @@ namespace jdi
// 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。
const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall);
const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall);
const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale;
const float weakPeakResetGap = kWeakPeakResetGap * m_scale;
const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale;
const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale;
const float weakPeakTrackWidth =
std::max(10.0f * m_scale, (left + width - pad - weakPeakValueWidth - 6.0f * m_scale) -
weakPeakTrackLeft);
std::max(10.0f * m_scale,
(left + width - pad - weakPeakValueWidth - weakPeakResetGap -
weakPeakResetSize - 6.0f * m_scale) -
weakPeakTrackLeft);
float rowY = top + titleHeight + pad;
for (const SignalRef& ref : lane.signals)
{
@@ -10843,13 +11077,24 @@ namespace jdi
layout.rowIterValues.push_back(gd::RectF(
iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop,
weakPeakValueWidth, weakPeakSliderRow));
// 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。
const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale +
weakPeakValueWidth + weakPeakResetGap;
layout.rowLevelResets.push_back(
gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
layout.rowIterResets.push_back(
gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
}
else
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
}
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
@@ -11585,6 +11830,15 @@ namespace jdi
{
mixRect(rect);
}
// 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。
for (const gd::RectF& rect : layout.rowLevelResets)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterResets)
{
mixRect(rect);
}
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
const float margin = 6.0f * m_scale;
@@ -11709,6 +11963,27 @@ namespace jdi
// 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画,
// 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms,
// 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。
// 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。
// ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次,
// 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。
const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising,
std::size_t falling, bool complete)
{
if (EdgeCountsPending(rising, falling, complete))
{
UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT);
return;
}
const float gap = kEdgePanelStatColumnGap * m_scale;
const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f);
UiText(g, EdgeRisingText(rising, complete), m_fonts->monoSmall,
gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted,
TextAlign::LEFT);
UiText(g, EdgeFallingText(falling, complete), m_fonts->monoSmall,
gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
};
std::vector<PanelSlot> nextSlots;
std::vector<gd::RectF> dirtyRects;
const std::uint64_t panelGlobalKey = PanelGlobalKey();
@@ -11873,9 +12148,10 @@ namespace jdi
: (weakPeak ? tabLane.WeakPeakEnabled(ref)
: tabLane.EdgeCenterEnabled(ref));
// 名称:第一排左侧,避开右侧复选框。
// 名称:第一排左侧,避开右侧复选框(超长时省略)。
const float nameWidth =
std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale);
std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale),
kEdgePanelNameMaxWidth * m_scale);
UiText(g, UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth,
@@ -11930,11 +12206,34 @@ namespace jdi
track.Height + 16.0f * m_scale),
m_theme->textStrong, TextAlign::LEFT);
};
// 每行滑块右侧的**复位控件**(与「系数/偏移」二级面板里的还原图标同一套绘制):
// 悬停时高亮 + 手型光标,点击把该行参数恢复为默认值(幅度 0 / 迭代 1 且上限回初始值)。
const auto paintSliderReset = [&](const gd::RectF& rect)
{
if (rect.Width <= 0.0f)
{
return;
}
const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(rect, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
if (hot)
{
UiFillRoundRect(
g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale,
rect.Width + 2.0f * m_scale,
rect.Height + 2.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
}
PaintPanelResetIcon(g, rect,
hot ? m_theme->accent : m_theme->textMuted, m_scale);
};
const gd::RectF& levelTrack = layout.rowLevelTracks[rowIndex];
const gd::RectF& iterTrack = layout.rowIterTracks[rowIndex];
paintSliderLabel(levelTrack, kWeakPeakLevelLabel);
paintSliderLabel(iterTrack, kWeakPeakIterationsLabel);
paintSliderLabel(iterTrack, kWeakPeakIterationsSliderLabel);
const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) &&
(m_peakDragLane == static_cast<int>(index)) &&
@@ -11950,6 +12249,7 @@ namespace jdi
(peakItem->amountPercent > 0 ? L"+" : L"") +
std::to_wstring(peakItem->amountPercent) + L"%",
levelActive);
paintSliderReset(layout.rowLevelResets[rowIndex]);
// 迭代次数滑块:量程 = 1 … 该信号自己的上限。
const int peakMax = std::max(kWeakPeakIterationsMin,
@@ -11972,6 +12272,7 @@ namespace jdi
{
paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter),
iterActive);
paintSliderReset(layout.rowIterResets[rowIndex]);
}
}
}
@@ -12042,10 +12343,8 @@ namespace jdi
statsPoints, statsOrder, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling,
statsComplete);
const std::wstring counts =
EdgeCountsText(rising, falling, statsComplete);
UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted,
TextAlign::LEFT);
// 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。
paintCounts(statsRect, rising, falling, statsComplete);
}
}
}
@@ -12065,10 +12364,12 @@ namespace jdi
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const float textWidth = panel.Width - pad * 2.0f;
// 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。
UiText(g, resolved.data->Title(), m_fonts->uiSmall,
gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor,
TextAlign::LEFT);
// 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。
UiText(g,
UiEllipsize(resolved.data->Title(), m_fonts->uiSmall,
std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)),
m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight),
signalColor, TextAlign::LEFT);
std::size_t rising = 0;
std::size_t falling = 0;
@@ -12076,10 +12377,8 @@ namespace jdi
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
const std::wstring counts = EdgeCountsText(rising, falling, complete);
UiText(g, counts, m_fonts->monoSmall,
gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight),
m_theme->textMuted, TextAlign::LEFT);
paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising,
falling, complete);
rowY += rowHeight * 2.0f;
}
@@ -12155,14 +12454,13 @@ namespace jdi
// 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值
//(用户明确要求:"在未输入任何内容时,输入框内提示当前上限值")。
// 提示文本比插入符稍微右移几像素,避免插入符压在上面看不清数字。
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。
// 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。
if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty())
{
const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax
: kWeakPeakIterationsDefaultMax;
const std::wstring hint = std::to_wstring(currentMax);
const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")";
const float hintLeft = fieldRect.X + 11.0f * m_scale;
UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft,
gd::RectF(hintLeft, fieldRect.Y,