diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 730ee8a..f56b00b 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -18,7 +18,7 @@ #include "Theme.hpp" #include "Toolbar.hpp" #include "UiComponent.hpp" -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" #include "UiSurface.hpp" namespace jdi @@ -323,6 +323,35 @@ namespace jdi */ bool BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible); + /** + * @brief 把一条信号插入到「源信号」的下方,并同步各标签页的引用与可见性。 + * + * 派生(均值滤波 / 弱峰调整)与克隆共用这一段:插入 → 各标签页下标重映射 → + * 在当前标签页勾选并把它所在的网格挪到源信号网格下方。**不做** `SyncTabsToDataSet`, + * 好让调用方在同步之前再改新信号的外观。 + * + * @param [IN] groupIndex 分组下标。 + * @param [IN] sourceIndex 源信号在组内的下标。 + * @param [IN] signal 要插入的信号(移动语义)。 + * @param [IN] makeVisible 是否在当前标签页中勾选新信号。 + * @param [OUT] newIndex 新信号在组内的下标。 + * @return 插入成功返回 true。 + */ + bool InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, + bool makeVisible, int& newIndex); + + /** + * @brief 克隆一条信号:把它的「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘焙进采样值, + * 时间轴偏移保留在样式上,名称前置 `*`,插入到原信号下方。 + * + * 烘值规则与「构建为新信号」完全一致,因此克隆曲线与原信号重合;区别只是 + * 克隆**保留原外观**(颜色 / 透明度 / 线宽 / 绘制样式),便于“冻住当前效果继续调”。 + * + * @param [IN] sourceRef 源信号引用。 + * @return 克隆成功返回 true。 + */ + bool CloneSignal(const SignalRef& sourceRef); + /** * @brief 弹出“打开数据文件”对话框并加载所选文件。 */ @@ -396,6 +425,16 @@ namespace jdi */ void OpenSettingsMenu(); + /** + * @brief 切换主题配色,并作废全部**像素级**缓存后整窗重绘。 + * + * 主题色被烘进了多处位图缓存(场景缓存、每网格宽基底图层、装饰层、浮动面板层、 + * 信号列表行位图),换主题后必须整体作废,否则界面会停留在旧配色上。 + * + * @param [IN] kind 目标主题。 + */ + void ApplyTheme(ThemeKind kind); + /** * @brief 绘制全部 UI 控件的布局边界(调试开关开启时在 Render 的最后一步调用)。 * @@ -411,6 +450,43 @@ namespace jdi */ void PaintLayoutBounds(gd::Graphics& g, int width, int height); + /** + * @brief 取某个组件的**绘制层号**(越小越靠下)。 + * + * 层号 = 组件在 `m_components` 里的下标 × `kLayersPerComponent`,因此 + * “文本收集层”与 `PaintLayoutBounds` 里给几何 box 分配的层号**完全同源**。 + * + * ⚠️ **为什么要乘以间隔而不是直接用下标**:组件内部可能还需要再压一层 + *(`PlotView` 的浮动面板就在自己之上再加一层)。若直接用下标, + * 那个子层会与“下一个组件的基层”撞号,遮挡比较就失去意义。 + * + * @param [IN] component 组件实例。 + * @return 层号;不在组件表里返回 0。 + */ + int ComponentLayer(const UiComponent* component) const; + + /** + * @brief 每个组件占用的层号个数。 + * + * * `+0`:组件自己的基层(网格 / 悬浮构件 / 光标读数…); + * * `+1`:组件内部的不透明浮层(`PlotView` 的实验性功能浮动面板); + * * `+2`:绘制在那些浮层**之上**的叠加层(滚动条 / 回到最上方 / 内联输入框)。 + */ + static constexpr int kLayersPerComponent = 3; + + /** + * @brief 取**不属于组件表**的浮层 / 装饰的绘制层号。 + * + * 它们接在组件表之后:`offset = 0` = 面板分隔条,`1` = 弹出菜单,`2` = 浮动拾色器。 + * @param [IN] offset 相对组件表末尾的偏移。 + * @return 层号。 + */ + int OverlayLayer(int offset) const + { + return static_cast(m_components.size()) * kLayersPerComponent + + offset * kLayersPerComponent; + } + /** * @brief 处理客户区鼠标移动。 * @param [IN] x 客户区横坐标。 @@ -439,6 +515,13 @@ namespace jdi bool m_gdiplusReady = false; /**< GDI+ 是否初始化成功。 */ Theme m_theme; /**< 主题配色。 */ + /** + * @brief 当前选中的主题种类(设置菜单 → 主题配色里的勾选项)。 + * + * 默认值是 **Solarised Light**:这是项目历史默认,用户 2026-10-29 明确要求 + * 新增其它主题时"默认的主题色维持现状"。 + */ + ThemeKind m_themeKind = ThemeKind::LIGHT; UiFonts m_fonts; /**< 界面字体。 */ PopupMenu m_menu; /**< 与主题一致的自绘弹出菜单。 */ ColorPickerPopup m_colorPicker; /**< 与主题一致的浮动拾色器。 */ @@ -488,7 +571,7 @@ namespace jdi * 作为成员而不是局部变量:避免开关打开时每帧重新分配, * 也让它能直接充当 `PaintUtil` 的文本收集器(`UiTextRectSink`)。 */ - UiDebugBoxList m_debugBoxes; + UiBoxTable m_debugBoxes; /** @brief 填充组件表(`Initialize` 里 `Setup` 之后调用一次)。 */ void BuildComponentTable(); @@ -563,6 +646,28 @@ namespace jdi */ bool m_panelDirty = false; bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */ + + /** + * @brief 本次 `WM_PAINT` 是不是「面板动画帧」(由滚动定时器置位)。 + * + * 面板动画每帧都会把**整个窗口**标脏,而 `WM_PAINT` 里"更新区域不在绘图区内 ⇒ + * 置 `m_chromeDirty`"这条启发式会因此把整帧判定为脏 ⇒ 每帧都重绘标签栏/工具栏/ + * **绘图区**。绘图区一帧要 12~46ms(283 个信号同网格、双光标时实测 46ms), + * 面板动画于是被拖到 43ms/帧(用户 2026-10-30 报告:组名展开/收起非常卡顿, + * 而网格里根本没有任何需要重绘的变化)。 + * ✓ 定时器置位后,`WM_PAINT` 在"没有其它任何原因要求整帧脏"时**不**再置 `m_chromeDirty`。 + */ + bool m_panelAnimationFrame = false; + + /** + * @brief 后备缓冲区里的**绘图区像素是否仍然有效**(可跳过重绘)。 + * + * 只有「面板动画帧」且该标志为真时才跳过 `PlotView::Paint`:此时面板不覆盖绘图区, + * 上一帧画好的绘图区像素原样保留在缓冲区里,回送整窗也不会错。 + * 任何"绘图区需要重绘"的来源都必须把它置假(见 `InvalidatePlotRect` 的各调用点、 + * `WM_PAINT` 里"更新区域就在绘图区内"的分支、以及后备缓冲区重建)。 + */ + bool m_plotPaintValid = false; }; } // namespace jdi diff --git a/Components/Application/Inc/ApplicationInternal.hpp b/Components/Application/Inc/ApplicationInternal.hpp index 172696d..84a6ae6 100644 --- a/Components/Application/Inc/ApplicationInternal.hpp +++ b/Components/Application/Inc/ApplicationInternal.hpp @@ -30,6 +30,7 @@ #include "Application.hpp" #include "DataLoader.hpp" +#include "MemProbe.hpp" #include "ResourceIds.h" #include "Utf8Util.hpp" @@ -62,6 +63,7 @@ namespace jdi inline const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ inline const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除分组的自定义消息。 */ inline const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */ + inline const UINT kMessageCloneSignal = WM_APP + 5; /**< 面板请求克隆单个信号的自定义消息。 */ inline const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ inline const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ inline const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */ @@ -153,9 +155,12 @@ namespace jdi * * @param [IN] hwnd 窗口句柄。 * @param [IN] plotRect 绘图区矩形(客户区坐标)。 + * @param [OUT] plotPaintValid `Application::m_plotPaintValid`:置假,表示后备缓冲区里的 + * 绘图区像素已不可信(面板动画帧不得跳过绘图区重绘)。 */ - inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect) + inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect, bool& plotPaintValid) { + plotPaintValid = false; RECT invalid = {}; invalid.left = static_cast(std::floor(plotRect.X)); invalid.top = static_cast(std::floor(plotRect.Y)); @@ -178,6 +183,13 @@ namespace jdi /** @brief 应用设置菜单(工具栏齿轮)命令 ID。 */ inline const int kAppCmdShowLayoutBounds = 1; + /** + * @brief 「主题配色」二级菜单的命令 ID 基准。 + * + * 第 i 个主题的 ID = `kAppCmdThemeBase + i`,`i` 即 `ThemeKind` 的整型值 + * (顺序就是菜单里的显示顺序:Light / Solarised Light / Dark / Solarised Dark)。 + */ + inline const int kAppCmdThemeBase = 100; /** * @brief 从应用资源中按指定尺寸加载图标。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 019cad9..fc6b971 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -53,6 +53,12 @@ namespace jdi { m_instance = instance; + // ⚠️ 本项目以 `-fno-exceptions` 构建(省 `.xdata`+`.pdata` 约 40~60KB): + // 任何遗漏的分配失败都会走到 `std::terminate`(默认行为是静默 abort,用户只看到程序 + // 突然消失)。这里安装一个兜底处理器:弹一次带建议的模态提示后干净退出。 + // 真正的大块分配已在各处通过 `MemReserveIfRoom()` 预检验(见 `MemProbe.hpp`)。 + MemInstallTerminateHandler(); + // 隐藏窗口启动(自动化/后台)时没人能点掉模态错误框,会自动降级为非模态提示。 SetModalErrorsEnabled(showCommand != SW_HIDE); @@ -68,7 +74,11 @@ namespace jdi } m_gdiplusReady = true; - m_theme = SolarizedLight(); + m_theme = ThemeFor(m_themeKind); + // ⚠️ 把**宿主自己那个** `Theme` 实例的地址交给全局指针:这样切换主题只需改 + // `m_theme` 的内容,所有持有 `const Theme*` 的组件会自动看到新配色, + // 而 `UiControls` 里那些没有 `Theme` 形参的原语也能跟随(见 `CurrentTheme`)。 + SetCurrentTheme(&m_theme); if (!m_fonts.Create(m_dpi)) { m_gdiplusReady = false; @@ -335,6 +345,8 @@ namespace jdi // 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。 m_chromeDirty = true; + // 绘图区矩形也变了 ⇒ 后备缓冲区里的绘图区像素不再是本布局的内容。 + m_plotPaintValid = false; } void Application::EnsureBackBuffer(int width, int height) @@ -348,6 +360,7 @@ namespace jdi // 新建的后备位图内容未定义,必须整帧重绘一次。 m_chromeDirty = true; + m_plotPaintValid = false; HDC screenDc = GetDC(m_hwnd); if (screenDc == nullptr) @@ -529,7 +542,6 @@ namespace jdi if (m_showLayoutBounds) { m_debugBoxes.Clear(); - m_debugBoxes.SetLayer(2); // 基础内容层(工具栏/面板/绘图区) UiSetTextRectSink(&m_debugBoxes); } else if (!m_debugBoxes.Empty()) @@ -553,34 +565,55 @@ namespace jdi UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), m_theme.windowBg); + // ⚠️ 文本必须记到**与它所在组件相同的层**: + // `UiDebugPaintBoxList` 按 `(layer, depth)` 排序并拿更高的层当遮挡矩形, + // 若把所有文本都固定记到某一层,就会出现“胶囊文字框穿透浮动面板” + //(文本层比面板层低 ⇒ 被挖洞,或被当成上层直接画在面板上)。 + // 这里逐组件下发,`ComponentLayer` 与 `PaintLayoutBounds` 里的层号完全同源。 + UiSetTextLayer(ComponentLayer(&m_tabBar)); m_tabBar.Paint(g); + UiSetTextLayer(ComponentLayer(&m_toolbar)); m_toolbar.Paint(g); + UiSetTextLayer(ComponentLayer(&m_panel)); m_panel.Paint(g); UiFillRect(g, m_splitterRect, m_theme.border); } else if (panelOnly) { // 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。 + UiSetTextLayer(ComponentLayer(&m_panel)); m_panel.Paint(g); - UiFillRect(g, m_splitterRect, m_theme.border); + // ⚠️ 分隔条的右边界必须**夹到绘图区左边界取整之前**:分隔条矩形是浮点的 + //(例如 457.5…462.5),GDI+ 会把最后一列(x=462)按 50% 覆盖率混进去; + // 整帧重绘时绘图区随后会把它整列盖掉,但**面板动画帧不重绘绘图区**, + // 这一列就会残留成一条 1px 接缝(实测 847 像素、整列)。 + gd::RectF splitter = m_splitterRect; + const float splitterLimit = std::floor(m_plotRect.X); + if (splitter.X + splitter.Width > splitterLimit) + { + splitter.Width = std::max(0.0f, splitterLimit - splitter.X); + } + UiFillRect(g, splitter, m_theme.border); } - m_plot.Paint(g); + UiSetTextLayer(ComponentLayer(&m_plot)); + // ⚠️ 「面板动画帧」且绘图区像素仍然有效时**跳过绘图区重绘**: + // 面板不覆盖绘图区,上一帧的像素原样留在后备缓冲区里(随后整窗回送也不出错), + // 而绘图区一帧要 12~46ms(283 个信号同网格 + 双光标实测 46ms)—— + // 这正是“组名展开/收起非常卡顿、网格里却没有任何需要重绘的变化”的真因。 + if (!panelOnly || !m_plotPaintValid) + { + m_plot.Paint(g); + m_plotPaintValid = true; + } // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 // ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层, // 否则会被自己登记到遮挡矩形里的"挖洞"逻辑裁掉。 - if (m_showLayoutBounds) - { - m_debugBoxes.SetLayer(5); - } + UiSetTextLayer(OverlayLayer(1)); m_menu.Paint(g); - if (m_showLayoutBounds) - { - m_debugBoxes.SetLayer(6); - } + UiSetTextLayer(OverlayLayer(2)); m_colorPicker.Paint(g); - // 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。 PaintLoadingOverlay(g, width, height); @@ -629,8 +662,14 @@ namespace jdi // 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区 / //「回到最上方」按钮的淡入淡出):仅在动画进行中保持定时重绘。 + // ⚠️ 另外还要覆盖「面板缓存键被冻结」的情形(用户 2026-11-02 报告:拖完横向光标 / + // 参数滑块并松开后,统计面板一直停在“统计中…”,直到鼠标移到面板上或做其它操作): + // 交互期间面板缓存键被冻结(复用旧像素),而**鼠标停下后不会再有重绘请求** ⇒ + // 永远看不到最终数字。`PanelRefreshPending()` 为真时保持定时器,等 + // `kEdgeScanInteractiveIdleMs` 过去、某个非交互帧把面板按最新数字重画一次。 if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() || - m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive()) + m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive() || + m_plot.PanelRefreshPending()) { if (!m_scrollTimerActive) { @@ -924,7 +963,7 @@ namespace jdi { case kMessagePlotDerivedReady: // 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。 - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); return 0; case kMessagePlotLowMemory: // 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算, @@ -932,7 +971,7 @@ namespace jdi SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n" L"请关闭部分分组或信号后重试。", true); - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); return 0; case WM_SIZE: { @@ -971,6 +1010,9 @@ namespace jdi // 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。 RECT update = {}; const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE; + // 面板动画帧标记是一次性的(只对紧随其后的这一帧生效)。 + const bool panelFrame = m_panelAnimationFrame; + m_panelAnimationFrame = false; if (hasUpdate) { const bool insidePlot = @@ -979,8 +1021,15 @@ namespace jdi update.right <= static_cast(std::ceil(m_plotRect.X + m_plotRect.Width)) && update.bottom <= static_cast(std::ceil(m_plotRect.Y + m_plotRect.Height)) && update.right > update.left && update.bottom > update.top; - if (!insidePlot) + if (insidePlot) { + // 本次更新的就是绘图区 ⇒ 绘图区像素不再可信,必须重绘。 + m_plotPaintValid = false; + } + else if (!panelFrame || m_chromeDirty) + { + // ⚠️ `panelFrame` 只是“本次失效来自面板动画”的标志:若其它任何消息 + // 已经把装饰区标脏(`m_chromeDirty`),仍按原来的规则走整帧重绘。 m_chromeDirty = true; } } @@ -1031,7 +1080,7 @@ namespace jdi if (plotOnlyMove && !m_chromeDirty) { // 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。 - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); } else { @@ -1078,7 +1127,7 @@ namespace jdi if (plotChanged && !chromeChanged && !m_chromeDirty) { - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); } else if (chromeChanged || plotChanged) { @@ -1688,6 +1737,15 @@ namespace jdi RemoveSignal(static_cast(wParam), static_cast(static_cast(lParam))); return 0; } + case kMessageCloneSignal: + { + // wParam = 分组下标,lParam = 组内信号下标。 + SignalRef ref; + ref.groupIndex = static_cast(wParam); + ref.signalIndex = static_cast(static_cast(lParam)); + CloneSignal(ref); + return 0; + } case WM_TIMER: { // 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。 @@ -1720,6 +1778,10 @@ namespace jdi { const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive(); + // 绘图区自己的动画(网格滚动到可视区 / 「回到最上方」淡入淡出)也会改像素: + // 它跟面板动画同时跑时,本帧**不能**跳过绘图区重绘(否则绘图区动画会冻结)。 + const bool plotAnimating = + m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive(); m_panel.AdvanceScrollAnimation(); m_panel.AdvanceGroupAnimations(); m_plot.AdvanceLaneScrollAnimation(); @@ -1732,11 +1794,20 @@ namespace jdi // ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘, // 保证面板的最终位置被完整重绘并回送。 m_panelDirty = !m_chromeDirty; + // 标记本帧是「面板动画帧」:`WM_PAINT` 里不再把整窗失效当成 + // “装饰+绘图区都脏”,否则每帧都会重绘绘图区(283 信号 + 双光标时 + // 实测 46ms/帧,用户 2026-10-30 报告的卡顿真因)。 + m_panelAnimationFrame = true; + if (plotAnimating) + { + // 绘图区本帧有变化 ⇒ 作废“绘图区像素仍然有效”的假定。 + m_plotPaintValid = false; + } InvalidateRect(hwnd, nullptr, FALSE); } else { - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); } return 0; } diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 8965cb2..ef4d51a 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -22,6 +22,12 @@ namespace jdi tab.SetAllCollapsed(true); } + // ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求): + // 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会 + // 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白” + // 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。 + m_dataSet.SetSelected(SignalRef()); + m_tabs.push_back(std::move(tab)); SetActiveTab(static_cast(m_tabs.size()) - 1); } @@ -34,7 +40,14 @@ namespace jdi } if (m_tabs.size() <= 1) { - SetStatusMessage(L"至少需要保留一个标签页。", true); + // ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的 + // 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。 + // ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。 + // erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向 + // 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。 + m_tabs.erase(m_tabs.begin() + index); + m_activeTab = 0; + NewTab(true); return; } @@ -650,7 +663,6 @@ namespace jdi return false; } const SignalData& source = group->signals[static_cast(request.signalIndex)]; - const TimeAxis& axis = group->AxisOf(source); const std::wstring baseName = source.name; // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, @@ -663,10 +675,13 @@ namespace jdi // * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序) // 全部作用在派生采样值上; // * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。 - // * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。 - // 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样 - // 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。 - // 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。 + // * ⚠️ **只有「数值域」的变换烘进采样值;时间轴偏移留在样式上**(见 3b)。 + // 曾经把 `offsetTime` 也按 `time[i] - offsetTime` 重采样烘进采样值("新信号 + // 不启用任何调整"),但那是**有损**的:平移出时间轴范围的那一段只能写成 NaN, + // 偏移稍大(≥ 数据跨度)就连一个有效采样都不剩 —— 用户点「构建为新信号」 + // 得到的是一条**空信号**(2026-10-29 报告)。派生信号与源信号共用同一条文件 + // 时间轴,无法用采样值表达超出轴范围的平移,因此时间偏移只能由样式表达。 + // 预览曲线本来也是用源样式的 `offsetTime` 绘制的,两条路径**显示完全一致**。 SignalStyle sourceTransform; bool hasSourceStyle = false; { @@ -726,73 +741,28 @@ namespace jdi } } - // 2c. 时间轴偏移:把 `offsetTime` 也**重采样烘进采样值**。 + // 2c.(已删除)时间轴偏移**不再**按 `time[i] - offsetTime` 重采样烘进采样值。 // - // 预览曲线是按「源信号样式」绘制的,它的时间轴偏移会把曲线整体横向平移。 - // 派生信号与源信号**共用同一个文件时间轴**,所以时间平移本无法写进"时间列"; - // 但用户明确要求:**新信号不启用任何「调整」也要与预览逐点重合** - //(新信号的「调整」面板必须是默认值,不允许靠"继承 offsetTime"来表达)。 - // 因此这里把平移直接写进数据: - // * 新信号第 i 个采样 = 预览曲线在 `time[i]` 时刻显示的值 - // = 已变换的曲线在 `time[i] - offsetTime` 处的取值(取最近的采样点); - // * 平移后落在数据范围之外的采样置 NaN —— 与预览"该处没有曲线"完全一致。 - // ⚠️ 一次整段重采样需要一份等长的临时缓冲(千万级采样 ≈ 数十 MB),用完立即释放; - // 这是"构建为新信号"的一次性操作,不在交互路径上。 - // ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承 - // `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。 - bool timeShiftBaked = false; - if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && axis.monotonic) - { - const std::vector& times = axis.time; - const std::size_t total = derived.values.size(); - if (total > 1 && times.size() >= total) - { - const double shift = sourceTransform.offsetTime; - std::vector shifted(total, std::numeric_limits::quiet_NaN()); - // 目标时刻 `times[index] - shift` 随 index 单调不减 → 游标只前进,整体 O(n)。 - std::size_t probe = 0; - for (std::size_t index = 0; index < total; ++index) - { - const double target = times[index] - shift; - if (!std::isfinite(target) || target < times[0] || target > times[total - 1]) - { - continue; // 平移后越界:该处没有曲线 - } - while (probe + 1 < total && times[probe + 1] < target) - { - ++probe; - } - std::size_t nearest = probe; - if (probe + 1 < total && - std::fabs(times[probe + 1] - target) < std::fabs(times[probe] - target)) - { - nearest = probe + 1; - } - shifted[index] = derived.values[nearest]; - } - derived.values.swap(shifted); - timeShiftBaked = true; - } - } + // 旧实现为了让"新信号的「调整」面板保持默认"而把平移烘进数据,代价是**有损**: + // 平移出时间轴范围的那一段只能写成 NaN,偏移 ≥ 数据跨度时**整条信号全变 NaN** + //(用户 2026-10-29 报告的"派生出来是一条空信号");即便偏移很小,也会在两端 + // 留下与实际曲线不符的空洞。 + // 派生信号与源信号共用同一条文件时间轴,无法用采样值表达超出轴范围的平移, + // 因此时间偏移改由**新信号样式**表达(见 3b)——预览曲线本来就用源样式的 + // `offsetTime` 绘制,所以两条路径的**显示完全一致**,且数据一点都不会丢。 // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; - // 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。 - m_plot.PauseDerivedAsync(); - if (!m_dataSet.InsertDerivedSignal(request.groupIndex, request.signalIndex, std::move(derived), - newIndex)) + if (!InsertSignalBelowSource(request.groupIndex, request.signalIndex, std::move(derived), + makeVisible, newIndex)) { return false; } - for (TabConfig& tab : m_tabs) - { - tab.RemapAfterSignalInsert(request.groupIndex, newIndex); - } - // 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。 - // ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把 - // `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。 - if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked) + // 3b. 时间轴偏移:**只**写进新信号的样式(不再烘进采样值,见 2c 的说明)。 + // 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线 + // 逐点重合,同时采样数据保持完整(不会因为平移越界而变成 NaN)。 + if (hasSourceStyle && sourceTransform.offsetTime != 0.0) { if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex})) { @@ -800,22 +770,110 @@ namespace jdi } } - // 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 - if (makeVisible && !m_tabs.empty()) - { - SignalRef derivedRef; - derivedRef.groupIndex = request.groupIndex; - derivedRef.signalIndex = newIndex; - ActiveTab().SetVisible(derivedRef, true); + // 4. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 + SyncTabsToDataSet(); + return true; + } - SignalRef sourceRef; - sourceRef.groupIndex = request.groupIndex; - sourceRef.signalIndex = request.signalIndex; - ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef); + bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, + bool makeVisible, int& newIndex) + { + newIndex = -1; + // 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。 + m_plot.PauseDerivedAsync(); + if (!m_dataSet.InsertDerivedSignal(groupIndex, sourceIndex, std::move(signal), newIndex)) + { + return false; + } + for (TabConfig& tab : m_tabs) + { + tab.RemapAfterSignalInsert(groupIndex, newIndex); + } + + // 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 + if (makeVisible && !m_tabs.empty()) + { + const SignalRef insertedRef{groupIndex, newIndex}; + ActiveTab().SetVisible(insertedRef, true); + const SignalRef sourceRef{groupIndex, sourceIndex}; + ActiveTab().MoveLaneOfSignalBelow(insertedRef, sourceRef); + } + return true; + } + + bool Application::CloneSignal(const SignalRef& sourceRef) + { + const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex); + if (group == nullptr || sourceRef.signalIndex < 0 || + sourceRef.signalIndex >= static_cast(group->signals.size())) + { + return false; + } + const SignalData& source = group->signals[static_cast(sourceRef.signalIndex)]; + + // 源信号的「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。 + SignalStyle transform; + bool hasStyle = false; + if (const SignalStyle* style = m_dataSet.Style(sourceRef)) + { + transform = *style; + hasStyle = true; + } + + // 克隆 = 把「调整」烘进采样值(**时间轴偏移除外**,它留在样式上)。 + // 与「构建为新信号」完全同一套烘值规则,因此克隆出来的曲线与原信号**逐像素重合**, + // 但它的「调整」面板回到默认值,可以继续在此基础上继续叠加新调整。 + SignalData clone; + clone.name = L"*" + source.name; + clone.axisIndex = source.axisIndex; + clone.values = source.values; + if (hasStyle) + { + const bool valueTransform = (transform.gain != 1.0f || transform.absolute || + transform.mirrorVertical || transform.offsetY != 0.0f); + if (valueTransform) + { + for (double& value : clone.values) + { + value = ApplyDrawTransform(transform, value); + } + } + if (transform.mirrorHorizontal) + { + std::reverse(clone.values.begin(), clone.values.end()); + } + } + + int newIndex = -1; + if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone), + true, newIndex)) + { + return false; + } + + // 外观:克隆要**看起来与原信号一样**(颜色 / 透明度 / 线宽 / 绘制样式), + // 只把被烘掉的「调整」复位为默认值,并保留时间轴偏移。 + if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex})) + { + if (hasStyle) + { + cloned->color = transform.color; + cloned->customColor = transform.customColor; + cloned->opacity = transform.opacity; + cloned->lineWidth = transform.lineWidth; + cloned->mode = transform.mode; + cloned->offsetTime = transform.offsetTime; + } + // ⚠️ 单点信号默认会改成「散点 + 最大线宽」(`InsertDerivedSignal` 里的规则), + // 克隆应该用**原信号的样式**,因此上面几行把它覆盖回去了。 } - // 5. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 SyncTabsToDataSet(); + SetStatusMessage(L"已克隆信号:" + m_dataSet.GroupAt(sourceRef.groupIndex) + ->signals[static_cast(newIndex)] + .name, + false); + InvalidateRect(m_hwnd, nullptr, TRUE); return true; } @@ -1117,6 +1175,27 @@ namespace jdi m_plot.AllLanesAutoFitToSelection(), true); } + void Application::ApplyTheme(ThemeKind kind) + { + if (kind == m_themeKind && m_theme.windowBg == ThemeFor(kind).windowBg) + { + return; // 幂等:同一个主题重复点击不必作废缓存 + } + m_themeKind = kind; + m_theme = ThemeFor(kind); + SetCurrentTheme(&m_theme); + + // 主题色烘进了多处位图缓存,必须整体作废(只作废场景缓存是不够的: + // 宽基底图层、装饰层、浮动面板层、信号列表行位图里都存着旧配色的像素)。 + m_plot.OnThemeChanged(); + m_panel.OnThemeChanged(); + + // 装饰区(标签栏 / 工具栏 / 面板)全部重画,并整窗回送。 + m_chromeDirty = true; + m_panelDirty = true; + InvalidateRect(m_hwnd, nullptr, TRUE); + } + void Application::OpenSettingsMenu() { // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 @@ -1143,6 +1222,25 @@ namespace jdi boundsItem.keepOpen = true; items.push_back(boundsItem); + // ── 主题配色(二级菜单:鼠标悬停展开,带勾选态)────────────── + // 顺序固定为 Light / Solarised Light / Dark / Solarised Dark(用户 2026-10-29 指定)。 + // `keepOpen` 让点击主题后菜单不关闭,勾选态由宿主回写 —— 便于连续预览几种配色。 + MenuItem themeItem; + themeItem.kind = MenuItemKind::SUBMENU; + themeItem.label = L"主题配色"; + for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) + { + const ThemeKind kind = static_cast(index); + MenuItem option; + option.kind = MenuItemKind::NORMAL; + option.label = ThemeName(kind); + option.id = kAppCmdThemeBase + index; + option.checked = (m_themeKind == kind); + option.keepOpen = true; + themeItem.submenu.push_back(option); + } + items.push_back(themeItem); + RECT client = {}; GetClientRect(m_hwnd, &client); const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), @@ -1154,10 +1252,30 @@ namespace jdi m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items); } + int Application::ComponentLayer(const UiComponent* component) const + { + if (component == nullptr) + { + return 0; + } + for (std::size_t index = 0; index < m_components.size(); ++index) + { + if (m_components[index].component == component) + { + return static_cast(index) * kLayersPerComponent; + } + } + return 0; + } + void Application::PaintLayoutBounds(gd::Graphics& g, int width, int height) { // ⚠️ 复用成员容器(不是局部变量):文本几何已经在**绘制期间**由 `PaintUtil` // 通过 `UiSetTextRectSink(&m_debugBoxes)` 收进来了,这里只负责补组件结构。 + // + // 层号与 `Render` 里下发的"文本层"**完全同源**(组件表下标 / `OverlayLayer`): + // 只有两者一致,遮挡矩形才能正确地把"浮层下面的内容"(含文字框)挖掉, + // 而不是反过来让下层文字穿透到浮层之上(用户 2026-10-29 报告的缺陷)。 m_debugBoxes.SetLayer(0); // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 @@ -1165,31 +1283,35 @@ namespace jdi static_cast(height)), 0); // 组件区域(底 → 顶;组件自己负责补齐内部结构)。 - // ⚠️ 两点都很重要: + // ⚠️ 三点都很重要: // 1. `SetClip(组件区域)`:组件内部的元素可能落在自己地盘之外(典型:滚出可视区的 // 网格 `lane.rect` 的 Y 为负数),不裁剪会把边框画到工具栏/标签栏上。 - // 2. 每收集完一个组件就 `PushLayer()`:浮层压在本体之上(右键二级菜单压在行上面), - // 只有按收集顺序分层,后收集的内容才画在上面。 - for (const ComponentEntry& entry : m_components) + // 2. 每个组件收集前**显式**把层号设成它在组件表里的下标:不能依赖 `PushLayer()` + // 累加 —— 组件内部可能为了自己的浮层临时提层(`PlotView` 的浮动面板), + // 一旦它没有复位,后面所有组件的层号会被整体推高,遮挡关系全乱。 + // 3. 层号必须与 `Render` 里给文本下发的层号同源(见 `ComponentLayer`)。 + for (std::size_t index = 0; index < m_components.size(); ++index) { + const ComponentEntry& entry = m_components[index]; + m_debugBoxes.SetLayer(static_cast(index) * kLayersPerComponent); if (entry.component != nullptr) { m_debugBoxes.SetClip(entry.component->Area()); - entry.component->CollectDebugBoxes(m_debugBoxes); + entry.component->BuildBoxes(m_debugBoxes); } - m_debugBoxes.PushLayer(); } // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。 // ⚠️ 浮层**不设裁剪矩形**:菜单的二级子菜单会展开到菜单本体之外,设裁剪会把它裁掉; - // 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::CollectDebugBoxes`), + // 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::BuildBoxes`), // 由绘制阶段把更低层的 box 从浮层区域里挖掉。 m_debugBoxes.SetClip(gd::RectF()); + m_debugBoxes.SetLayer(OverlayLayer(0)); m_debugBoxes.Add(m_splitterRect, 0); - m_debugBoxes.PushLayer(); - m_menu.CollectDebugBoxes(m_debugBoxes); - m_debugBoxes.PushLayer(); - m_colorPicker.CollectDebugBoxes(m_debugBoxes); + m_debugBoxes.SetLayer(OverlayLayer(1)); + m_menu.BuildBoxes(m_debugBoxes); + m_debugBoxes.SetLayer(OverlayLayer(2)); + m_colorPicker.BuildBoxes(m_debugBoxes); UiDebugPaintBoxList(g, m_debugBoxes); } @@ -1214,6 +1336,19 @@ namespace jdi // 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。 m_chromeDirty = true; } + else if (result.id >= kAppCmdThemeBase && + result.id <= kAppCmdThemeBase + static_cast(ThemeKind::SOLARIZED_DARK)) + { + const ThemeKind kind = static_cast(result.id - kAppCmdThemeBase); + ApplyTheme(kind); + // 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。 + for (int index = 0; index <= static_cast(ThemeKind::SOLARIZED_DARK); ++index) + { + const ThemeKind option = static_cast(index); + m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option), + m_themeKind == option); + } + } InvalidateRect(m_hwnd, nullptr, TRUE); break; } diff --git a/Components/Application/Src/ApplicationLoading.cpp b/Components/Application/Src/ApplicationLoading.cpp index 4025c6e..5b6da30 100644 --- a/Components/Application/Src/ApplicationLoading.cpp +++ b/Components/Application/Src/ApplicationLoading.cpp @@ -101,6 +101,10 @@ namespace jdi m_loadingProgress = 0.0f; m_loadingBytesRead = 0; m_loadingTotalBytes = 0; + // ⚠️ 告知绘图区“正在加载”:暂停边沿统计推进与统计面板绘制。 + // 本函数的主循环会不断 `UpdateWindow` 触发整帧绘制,若面板继续扫统计 + 重绘上百行, + // 加载进度会被拖得很慢(用户报告“统计没算完就无法开始加载文件”)。 + m_plot.SetLoadingBusy(true); const std::size_t slashPos = path.find_last_of(L"\\/"); m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1); @@ -150,6 +154,7 @@ namespace jdi m_loadingBytesRead = m_loadingTotalBytes; worker.join(); m_loading = false; + m_plot.SetLoadingBusy(false); if (state.cancelled.load(std::memory_order_relaxed)) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 9a525e3..10df503 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -25,7 +25,7 @@ namespace jdi { - class UiDebugBoxList; + class UiBoxTable; /** * @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。 @@ -62,7 +62,10 @@ namespace jdi PEAK_ITERATIONS, /**< 拖动「弱峰调整」某信号的迭代次数滑块(1 … 该信号自定义上限)。 */ LANE_REORDER, /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ MARKER_OPACITY, /**< 拖动「自由标记」面板的「标记透明度」滑块。 */ - MARKER_SCROLL /**< 拖动「自由标记」面板内容的滚动条滑块。 */ + MARKER_SCROLL, /**< 拖动「自由标记」面板内容的滚动条滑块。 */ + MEAN_POINTS, /**< 拖动「均值滤波」某信号的「均值点数」滑块(0 … 该信号自定义上限)。 */ + MEAN_ITERATIONS,/**< 拖动「均值滤波」某信号的「迭代次数」滑块(1 … 该信号自定义上限)。 */ + MEAN_SCROLL /**< 拖动「均值滤波」面板内容的滚动条滑块。 */ }; /** @@ -91,6 +94,15 @@ namespace jdi MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */ MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */ PEAK_ITERATIONS_MAX, /**< 编辑「弱峰调整」某信号「迭代次数 N」滑块的**上限**(正整数)。 */ + /** + * @brief 编辑「均值滤波」某信号「均值点数」滑块的**上限**(正整数)。 + * + * 与 `PEAK_ITERATIONS_MAX` 同一交互语义:点击滑块右侧数值 → 整行变成输入框, + * 输入框以空内容开始、并显示“输入上限(当前N)”提示。 + */ + MEAN_POINTS_MAX, + /** @brief 编辑「均值滤波」某信号「迭代次数」滑块的**上限**(正整数)。 */ + MEAN_ITERATIONS_MAX, FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */ FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */ }; @@ -207,17 +219,17 @@ namespace jdi void Paint(gd::Graphics& g) override; /** - * @brief 收集绘图区区域、**每个网格**、GX 徽标、图例条目与浮动面板的布局边界(调试用)。 + * @brief 上报绘图区区域、**每个网格**、GX 徽标、图例条目与浮动面板的几何 box。 * * 只上报“布局结构”,**不上报**曲线折线、取值胶囊、悬停提示这类随数据/鼠标逐帧变化的 * 瞬态几何 —— 它们每帧都不同,叠上去只是噪声。 * * @param [IN,OUT] boxes 收集器。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes) override; + void BuildBoxes(UiBoxTable& boxes) override; /** - * @brief 强制走「整幅重绘」路径(用于调试叠加层)。 + * @brief 强制走「整幅重绘」路径(供调试叠加层使用)。 * * 平时 `Paint` 会用三条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁)只重画变化部分, * 这在拖动网格滚动条时会**跳过一部分像素**。而调试叠加层(「显示布局边界」)是画在 @@ -226,6 +238,11 @@ namespace jdi * * 因此调试叠加层开启时必须打开本开关:牺牲帧率,换取"每帧都是完整一帧"。 * + * ⚠️ 保留而不优化的理由(2026-10-29 评估):把它改成“脏矩形 + 独立叠加位图”需要让 + * `Paint` 接受宿主下发的 damage(否则持久后备缓冲区里上一帧的边框像素无处抹除), + * 而这会穿透 `PlotView` 全部四条快路径与两层缓存。收益只是“调试开关打开时的帧率”、 + * 风险却是用户反复报过的拖拽错位/贴图残留一类事故 —— 故**暂不实施**,仅保留此开关。 + * * @param [IN] force 为 true 时禁用全部快路径且不作画质降级。 */ void SetForceFullRepaint(bool force) { m_forceFullRepaint = force; } @@ -539,10 +556,10 @@ namespace jdi struct FitContribution { SignalRef ref; /**< 信号引用。 */ - double minTime = 0.0; /**< 时间轴下限(源时刻,与 `ComputeVisibleTimeBounds` 同源)。 */ - double maxTime = 0.0; /**< 时间轴上限。 */ + double minTime = 0.0; /**< **显示时间**下限(= 时间轴下限 + 该信号的 `offsetTime`)。 */ + double maxTime = 0.0; /**< **显示时间**上限。 */ bool singlePoint = false; /**< 是否只有一个有效数据点(画出来就是一个圆点)。 */ - double pointTime = 0.0; /**< 单值时:数据点的源时刻。 */ + double pointTime = 0.0; /**< 单值时:数据点的**显示时刻**(源时刻 + `offsetTime`)。 */ double pointValue = 0.0; /**< 单值时:**绘制变换后**的取值。 */ float dotRadius = 0.0f; /**< 单值时:散点半径(设备像素)。 */ }; @@ -663,6 +680,14 @@ namespace jdi */ void InvalidateDerivedCaches(); + /** + * @brief 主题配色变化后作废全部**像素级**缓存。 + * + * 主题色被烘进了场景缓存位图、每网格宽基底图层、装饰层、光晕层与浮动面板层 —— + * 换主题后必须整体丢弃,否则这些区域会停留在旧配色上(直到因为别的原因重画)。 + */ + void OnThemeChanged(); + /** * @brief 释放本组件持有的全部绘制资源(位图 / 后台线程)。 * @@ -1066,6 +1091,33 @@ namespace jdi */ bool SetLaneFreeMarkerScroll(int laneIndex, float scroll); + /** + * @brief 设置某网格「均值滤波」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneMeanFilterScroll(int laneIndex, float scroll); + + /** + * @brief 设置某网格「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneEdgeCenterScroll(int laneIndex, float scroll); + + /** + * @brief 设置某网格「弱峰调整」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneWeakPeakScroll(int laneIndex, float scroll); + + /** + * @brief 设置某网格「边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。 + * + * 该面板仍是旧式「每信号两行」排版,但内容可以滚动:上百个信号合并到同一网格时 + * 内容高达上万像素,不限高会让面板远超绘图区(面板像素缓存因此完全失效)。 + * @return 偏移真的发生变化返回 true。 + */ + bool SetLaneEdgeCountScroll(int laneIndex, float scroll); + /** * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 * @return 切换后为「已收起」返回 true。 @@ -1219,6 +1271,27 @@ namespace jdi /** @brief 「回到最上方」按钮是否正在淡入/淡出。 */ bool BackToTopAnimationActive() const { return m_backToTopAnimating; } + /** + * @brief 上一条绘制帧是否**冻结了实验性功能面板的缓存键**(交互帧)。 + * + * 冻结期间面板显示的是旧像素(统计数字停在“统计中…”),而鼠标停下后 + * **不会再有重绘请求** ⇒ 面板永远停在旧数字。宿主应在为真时武装短周期定时器, + * 直到某个非交互帧把面板按最新数字重画一次(见 `Application::Render`)。 + */ + bool PanelRefreshPending() const { return m_panelKeysFrozen; } + + /** + * @brief 告知绘图区「宿主正在加载文件」。 + * + * 加载期间主线程会不断 `UpdateWindow` 触发整帧绘制,而面板绘制含统计推进与上百行 + * GDI+ 原语 ⇒ 加载进度被拖得很慢(用户报告"统计没算完就无法开始加载")。 + * 加载遮罩会盖住整个界面,因此置真时直接跳过面板绘制与统计推进。 + * + * @param [IN] busy true = 正在加载。 + */ + void SetLoadingBusy(bool busy) { m_loadingBusy = busy; } + + /** * @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。 */ @@ -1331,6 +1404,9 @@ namespace jdi /** * @brief 计算当前视图应使用的时间范围。 + * + * 返回的是**显示时间**(采样实际画在 `t + SignalStyle::offsetTime` 处), + * 因此"适应视图"与视图裁剪都会把时间轴偏移算进去。 * @param [OUT] low 时间下限。 * @param [OUT] high 时间上限。 */ @@ -1675,6 +1751,32 @@ namespace jdi */ void PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const; + /** + * @brief 绘制全部派生预览曲线(可只画悬停高亮的那一条)。 + * + * `PaintLaneDerivedOverlays`(基底/缓存路径,不带高亮)与 + * `PaintDerivedHoverHighlight`(前景高亮)共用本函数,保证两者的几何完全一致。 + * + * ⚠️ **高亮绝不能画进静态场景缓存**(用户 2026-11-02 报告“悬停时预览曲线的高亮丢失”): + * 悬停状态不参与 `SceneContentHash`,命中场景缓存时叠加层根本不会被重画 —— + * 于是“悬停 → 高亮”永远看不到;而一旦参与哈希,又会退化成“每动一下鼠标就整幅重绘”。 + * 正确做法:缓存里只画普通曲线,高亮那一帧在前景(贴图之后)单独叠加。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] hoverOnly true = 只画 `m_meanRowHoverRef` 在 `m_meanRowHoverKind` 面板里的那条曲线 + * (并带高亮);false = 画全部曲线且不带高亮。 + */ + void PaintDerivedCurvesFor(gd::Graphics& g, int laneIndex, const LaneVisual& lane, + bool hoverOnly) const; + + /** + * @brief 在**前景**叠加绘制“鼠标所在面板条目栏”对应的预览曲线高亮。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintDerivedHoverHighlight(gd::Graphics& g) const; + /** * @brief 绘制底部时间轴。 * @param [IN,OUT] g 绘图上下文。 @@ -1751,6 +1853,23 @@ namespace jdi gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ + + /** @brief 表格的一列(左边界 + 宽度,客户区坐标)。 */ + struct TableColumn + { + float left = 0.0f; + float width = 0.0f; + }; + /** + * @brief 「中心轴边沿计数」表格的**表头行**(固定不滚动;内容区在它下面)。 + * + * 该面板自 2026-11-02 起改为表格:表头三列 = 信号名 / 上升沿计数 / 下降沿计数, + * 下面每条**已勾选**的信号占一行,行底色 = 该信号的配色,三列与表头严格对齐。 + * 单击行(非复选框)展开该信号自己的两行参数滑块。 + */ + gd::RectF tableHeader; + /** @brief 表格三列的几何(`0` 信号名 / `1` 上升沿计数 / `2` 下降沿计数)。 */ + std::array tableColumns{}; /** * @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。 * @@ -1783,6 +1902,15 @@ namespace jdi */ std::vector rowLevelResets; std::vector rowIterResets; + /** + * @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。 + * + * 排版与「自由标记」面板的信号分组栏一致(左侧展开三角 + 信号名), + * 但右端是**复选框**(`rowChecks`)而不是 ✕。未勾选时标题栏仍显示(用于勾选)。 + */ + std::vector rowHeaders; + /** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */ + std::vector rowTriangles; std::vector rowRefs; /** * @brief 「自由标记」面板的一行(一个固定点)。 @@ -1827,6 +1955,13 @@ namespace jdi gd::RectF tableHeader; gd::RectF triangle; /**< 展开/收起三角的命中矩形。 */ gd::RectF close; /**< 右侧✕的命中矩形。 */ + /** + * @brief 右侧**复选框**的命中矩形(仅「均值滤波」面板使用;「自由标记」为空)。 + * + * 均值滤波面板的组头右端是"是否预览该信号"的复选框(用户 2026-10-30 要求), + * 只有它被勾选时左侧的展开三角才出现、才能展开参数行。 + */ + gd::RectF check; bool collapsed = false; /** * @brief 该分组栏是否处于**吸顶**(滚动时被钉在内容区顶部)。 @@ -1846,6 +1981,14 @@ namespace jdi gd::RectF content; /** 「自由标记」面板的**内容总高度**(含被滚动出去的条目),用于换算滚动比例。 */ float markerContentHeight = 0.0f; + /** + * @brief 内容区当前生效的**滚动偏移**(像素,≥ 0)。 + * + * 「自由标记」与「均值滤波」两个面板的内容区都可能需要滚动;取值来源不同 + * (`Lane::freeMarkerScroll` / `Lane::meanFilterScroll`),但布局之后一律从这里读, + * 绘制与命中共用同一份偏移(避免两处各自夹取导致"画的和点的不一致")。 + */ + float scrollOffset = 0.0f; gd::RectF scrollTrack; /**< 内容区右侧的滚动条轨道(不需要滚动时为空矩形)。 */ gd::RectF scrollThumb; /**< 滚动条滑块。 */ gd::RectF opacityTrack; /**< 「标记透明度」滑动条轨道(总是显示,与选取模式无关)。 */ @@ -1929,7 +2072,15 @@ namespace jdi bool cursorMode = false; /**< 该网格是否处于「自由标记点选取」(决定是否画纵向光标)。 */ int laneIndex = -1; /**< 该网格下标。 */ double time = 0.0; /**< 标注时刻(显示时刻;吸附时 = 采样时刻)。 */ - float x = 0.0f; /**< 该时刻的屏幕横坐标。 */ + float x = 0.0f; /**< 纵向光标的横坐标(= 鼠标位置,跟随鼠标)。 */ + /** + * @brief 被标注的那个**数据点**的屏幕横坐标。 + * + * 与 `x`(鼠标位置)分开:纵向光标画在 `x`,而取值圆点必须落在**真实采样点**上 + *(`TimeToX(time)`)—— 稀疏信号下两者可能相差十几个像素。 + * 用户 2026-10-30 明确要求"光标跟着鼠标走,提示靠纵向最近的数据点"。 + */ + float pointX = 0.0f; SignalRef ref; /**< 就近信号(网格内没有可用信号时无效)。 */ double value = 0.0; /**< 已叠加「调整」变换的取值(用于标注与增量)。 */ double enumValue = 0.0; /**< 采样原值(用于查枚举表)。 */ @@ -2175,9 +2326,12 @@ namespace jdi * @param [IN] lane 网格绘制信息。 * @param [IN] curve 曲线数值数组(缺失处为 NaN)。 * @param [IN] curveBegin `curve[0]` 对应的采样下标。 + * @param [IN] highlight 是否高亮(鼠标悬停在对应的面板条目上时): + * 先画一圈半透明的"光晕"再把曲线本身画得更粗更亮,让用户一眼看出这条预览曲线对应哪个条目。 */ void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& curve, std::size_t curveBegin) const; + const std::vector& curve, std::size_t curveBegin, + bool highlight = false) const; /** * @brief 取得某个采样区间内的派生曲线(带缓存)。 @@ -2204,13 +2358,22 @@ namespace jdi * 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。 * 仅供**预览绘制**传 true;需要精确取值的统计(中心轴边沿计数、取值胶囊) * 传 false,走同步精确计算。 + * @param [IN] computeIfMissing 缓存未命中时是否**进行计算**并发布新条目。 + * + * ⚠️ 光标取值胶囊必须传 false(用户 2026-11-02 反馈“拖光标仍有明显卡顿”): + * 胶囊只要“光标所在的那两个采样”,而缓存键含请求窗口、窗口随光标每帧变化 ⇒ + * 每帧都未命中 ⇒ 每帧同步计算 + 发布。15 信号 × 3 种曲线 = 每帧 45 次发布, + * 条目列表与线性搜索同步膨胀,实测拖动光标单帧 200ms(其中约 150ms 在这里)。 + * 传 false 后:命中就取(绘制路径发布的窗口带保护带、覆盖整个可见区间, + * 正常情况必然命中),未命中则退到同 (信号, 种类) 的最新曲线,再无则本帧不标注。 * @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);暂无可显示曲线时返回 nullptr。 */ const std::vector* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, int points, int iterations, int amountPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, - bool allowAsyncCompute = false) const; + bool allowAsyncCompute = false, + bool computeIfMissing = true) const; /** * @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。 @@ -2399,9 +2562,10 @@ namespace jdi * @param [IN] lane 网格绘制信息。 * @param [IN] points 均值点数 X(滑平半径)。 * @param [IN] iterations 迭代次数 N(至少 1)。 + * @param [IN] highlight 是否高亮(鼠标悬停在该网格均值滤波面板的对应条目上)。 */ void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - int points, int iterations) const; + int points, int iterations, bool highlight = false) const; /** * @brief 绘制某条信号的弱峰调整曲线(与均值滤波曲线同一配色派生规则)。 @@ -2410,9 +2574,10 @@ namespace jdi * @param [IN] lane 网格绘制信息。 * @param [IN] amountPercent 弱峰调整幅度(-400–400)。 * @param [IN] iterations 迭代次数 N(1–10)。 + * @param [IN] highlight 是否高亮(鼠标悬停在该网格弱峰调整面板的对应条目上)。 */ void PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - int amountPercent, int iterations) const; + int amountPercent, int iterations, bool highlight = false) const; /** * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 @@ -2734,6 +2899,33 @@ namespace jdi bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */ SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ + int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */ + SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */ + /** + * @brief 当前「列表式面板」参数滑块 / 滚动条拖拽的目标面板种类。 + * + * 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套**可滚动列表** + * (信号色叠底条目栏 + 右侧复选框 + 展开后的参数滑块)与同一套拖拽模式 + *(`MEAN_POINTS` / `MEAN_ITERATIONS` / `MEAN_SCROLL`);本字段说明它们的写入目标是 + * `Lane::meanFilterItems` / `edgeCenterItems` / `weakPeakItems` 中的哪一个。 + * 「边沿计数」面板(旧式两行排版)也用它区分滚动目标。 + */ + EdgePanelKind m_meanDragKind = EdgePanelKind::MEAN; + /** + * @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。 + * + * 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。 + */ + int m_meanEditCurrentMax = 0; + + /** + * @brief 正在编辑「滑块上限」的目标面板种类(均值滤波 / 中心轴边沿计数)。 + * + * 与 `m_meanDragKind` 同一思路:两者共用 `MEAN_POINTS_MAX` / `MEAN_ITERATIONS_MAX` + * 两个编辑目标,本字段说明提交时要写到 `Lane::edgeCenterItems` 还是 `meanFilterItems`。 + * 「弱峰调整」仍用自己的 `PEAK_ITERATIONS_MAX` 与 `m_peakEditCurrentMax`。 + */ + EdgePanelKind m_editMeanKind = EdgePanelKind::MEAN; /** * @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。 * @@ -2788,6 +2980,66 @@ namespace jdi /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ bool m_peakResetHover = false; + /** + * @brief 最近一次鼠标活动(移动 / 按下 / 滚轮)的时间戳(`GetTickCount64()`)。 + * + * 用途:**边沿统计的每帧预算**要区分“交互中”和“静止”两种状态(见 + * `kEdgeScanPairsPerFrameInteractive`)。交互期间压小预算保住跟手;鼠标停下 + * `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算,把统计快速补齐。 + * 初值 0 ⇒ 启动后第一帧就算作“静止”(可以全速推进统计)。 + */ + std::uint64_t m_lastInputActivityMs = 0; + + /** + * @brief 当前是否处于「交互中」(正在拖拽,或鼠标刚刚移动过)。 + * + * 两个用途(都用它保证同一个判定口径): + * * `Paint` 决定边沿统计本帧的扫描预算(交互 ⇒ 小预算,保住跟手); + * * `PanelGlobalKey` / `PanelSlotKey` 决定**是否把边沿统计数字与光标时间计入面板像素缓存键**: + * 交互期间不计入 ⇒ 键保持稳定 ⇒ 面板直接复用上一帧像素、**不做**每帧数百次统计查询 + * 与整块重绘。 + * + * ⚠️ 用户 2026-11-02 报告:288 个信号合并到同一网格、开启中心轴边沿计数后, + * 拖动横向光标/移动鼠标每帧要 30ms。根因正是「每帧 × 每个信号 × 一次 + * `EdgeCenterCountsFor` 区间汇总」被塞进了缓存键计算里,且结果每帧都让键变化 + * ⇒ 面板缓存永远不命中、每帧重画整块面板。 + */ + bool InputIsInteractive() const; + + /** + * @brief 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。 + * + * 用途(用户 2026-10-30 要求):悬停某条信号的条目栏时,把该信号的**预览曲线** + * 高亮出来(画一圈光晕 + 加粗),这样"列表里这一行 ↔ 网格里那条曲线"一眼对应。 + * + * 与 `m_freeMarkerRowHover*` 同一套路:只在悬停状态**变化**时返回 `changed`, + * 避免每帧都请求重绘。 + */ + int m_meanRowHoverLane = -1; + SignalRef m_meanRowHoverRef; + /** + * @brief 悬停所在的**面板种类**(MEAN / CENTER / PEAK)。 + * + * ⚠️ 必须区分:同一条信号可能在多个面板里各有一行(均值滤波、中心轴边沿计数、 + * 弱峰调整都用同一套条目栏),只比 `(lane, ref)` 会把**所有**面板的同名行一起点亮, + * 并且无法判断“该高亮哪一条预览曲线”。 + * + * 用户 2026-11-02 报告:只开弱峰调整 + 中心轴边沿计数时悬停**毫无反应**; + * 一旦再开均值滤波,三个面板悬停都只高亮均值滤波曲线 —— + * 根因就是绘制侧只给 MEAN 分支传了 `highlight`,而悬停状态没有记录面板种类。 + */ + EdgePanelKind m_meanRowHoverKind = EdgePanelKind::MEAN; + + /** + * @brief 上一帧绘制面板时是否使用了「冻结的缓存键」(交互期间)。 + * + * 用途:冻结期间面板显示的是上一帧像素(统计数字停留在“统计中…”), + * 而**鼠标停下后不会再有重绘请求** ⇒ 面板永远停在旧数字 + *(用户 2026-11-02 报告:“松开左键后一直统计中…,动一下鼠标或悬停面板才更新”)。 + * 宿主每帧检查它:为真就武装一个短周期定时器,直到某个非交互帧重画面板为止。 + */ + mutable bool m_panelKeysFrozen = false; + // ---------------------------------------------------------------- 自由标记 /** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */ @@ -2922,9 +3174,59 @@ namespace jdi * 用户看到的就是“按住光标不动时统计卡住、要动鼠标才更新”。 */ mutable std::uint64_t m_edgeScanFrame = 0; + /** + * @brief 上一次“请求下一帧”发生在哪一帧(收敛成**每帧最多一次** `PostMessage`)。 + * + * ⚠️ 旧实现在 `EdgeScanFor` 里按“未完成状态”逐个 Post:288 条信号时单帧 288 次, + * 把消息队列和重绘打满,`WM_TIMER`(分组折起动画)因此被饿死(用户 2026-11-02 报告)。 + */ + mutable std::uint64_t m_edgeScanRequestFrame = static_cast(-1); /** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */ mutable std::size_t m_edgeScanLocalCurveFails = 0; + /** + * @brief **统计数据版本号**:任何累积状态推进(`scannedPair` 前进)或扫完都会自增。 + * + * 用途:面板像素缓存键不能再逐个信号查询统计数字(288 个信号 ⇒ 单帧 288 次查询, + * 且中间数字每帧都变 ⇒ 缓存永不命中 ⇒ 每帧整块重画上百行)。改为混入"把 + * `m_edgeScanRevision` 按节流规则转成的 `m_panelStatsVersion`"(O(1)、零查询): + * 统计推进 ⇒ 版本号变化 ⇒ 面板重绘;没推进 ⇒ 键稳定 ⇒ 贴回缓存。 + */ + mutable std::uint64_t m_edgeScanRevision = 0; + /** @brief 已反映到面板缓存键里的统计版本号(见 `m_edgeScanRevision`)。 */ + mutable std::uint64_t m_panelStatsVersion = 0; + /** @brief 上一次同步时看到的 `m_edgeScanRevision`(用于判断"有新进展")。 */ + mutable std::uint64_t m_panelStatsSeen = 0; + + /** + * @brief 本帧统计扫描的**时间预算截止点**(QPC 计数)。 + * + * 旧实现用"每帧采样对配额"限制开销,但同样的配额在稀疏/稠密、普通/中心轴之间 + * 的实际耗时能差 10 倍:配额小 ⇒ 大文件统计极慢(用户 2026-11-02 报告 + * "VERY_BIG_DATA 统计太慢");配额大 ⇒ 中心轴(每个采样对还要算窗口均值)单帧卡住。 + * 改为**按时间切**:扫描循环每处理一个块检查一次是否超时,既不会卡帧又能把 + * "便宜"的普通计数一次性推进得很快。 + */ + mutable std::int64_t m_edgeScanDeadline = 0; + + /** + * @brief 宿主正在加载文件(模态遮罩期间):暂停统计推进与面板绘制。 + * + * ⚠️ 加载线程的主循环会不断 `UpdateWindow` 触发 `Render()`,而面板绘制里含统计推进 + * 与上百行 GDI+ 原语 ⇒ 加载进度被拖慢(用户报告"统计没算完就无法加载文件")。 + * 加载期间画布被遮罩覆盖,面板本来也看不见,因此直接跳过。 + */ + bool m_loadingBusy = false; + + /** + * @brief 累积状态上限(随可见信号总数缩放)。 + * + * 每个信号最多两类状态(普通边沿计数 / 中心轴边沿计数),因此上限 ≥ `2 × 信号数`。 + * 固定上限(旧 64)在 288 条信号合并到同一网格时会让所有状态互相淘汰 ⇒ + * 面板永远停在“统计中…”(用户 2026-11-02 报告)。 + */ + std::size_t EdgeScanStateLimit() const; + /** * @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。 * @@ -3063,6 +3365,16 @@ namespace jdi { SignalRef ref; /**< 信号引用。 */ const SignalGroup* group = nullptr; /**< 所属分组(缓存键的一部分)。 */ + /** + * @brief 全部键字段的 64 位哈希(FNV-1a)。 + * + * ⚠️ **缓存查找只比这一个值**:旧实现逐条比较下面十几个字段, + * 而"上百个信号合并到同一网格"时(例如 MF4-Record 的 283 个信号)每次查找 + * 都要扫过整张表 ⇒ 绘制与 `ProbeFreeMarker` 每帧做数万次字段比较, + * 鼠标在网格内移动会明显卡顿(用户 2026-10-30 报告)。 + * 命中哈希后再校验 `ref` / `sampleCount` / 视界三项(极低成本),可忽略碰撞风险。 + */ + std::uint64_t key = 0; std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */ double viewMin = 0.0; /**< 时间轴显示下界。 */ double viewMax = 0.0; /**< 时间轴显示上界。 */ @@ -3129,6 +3441,14 @@ namespace jdi const LaneVisual& lane) const; mutable std::deque m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ + /** + * @brief 折线顶点缓存的**容量上限**(条目数)。 + * + * 由 `BuildLaneVisuals` 按"当前全部网格的信号总数 × 2"刷新(下限 `kPolylineCacheLimit`)。 + * ⚠️ 固定 64 条在"多信号合并网格"下会**持续抖动**(装满即整表清空 ⇒ 下一帧全部重建), + * 这正是"上百个信号合并到同一网格时自由标记非常卡"的根因之一。 + */ + mutable std::size_t m_polylineCacheCapacity = 64; /** * @brief 图层**横向补条**渲染期间的折线裁剪范围(省掉整段折线的重建与描边)。 @@ -3641,6 +3961,69 @@ namespace jdi */ mutable std::vector m_decorCapturedRects; + // ---- 「派生预览曲线」像素缓存(均值滤波 / 中心轴边沿计数 / 弱峰调整的叠加曲线)---- + // + // 为什么需要单独一层像素缓存(用户 2026-11-02 反馈“极端工况下所有交互都卡”): + // 每条预览曲线都要按「逐像素列包络带」填充,代价 ∝ **覆盖像素数**。15 个信号 × + // 3 个功能 = 45 条曲线、哪怕单条只要 2ms,一帧也要 90ms —— 而其中**绝大多数帧 + // 画面根本没变**(拖动光标、悬停、静止)。因此与装饰层同一套思路: + // 曲线画进一张带 alpha 的 32bpp DIB,正常帧只做一次 `AlphaBlend`(约 1~2ms)。 + // + // 平移复用(关键):视界**只平移不改跨度**时,画面内容只是横向平移 —— 于是把 DIB + // 的像素整体 memmove 一个 dx,再只重画**新露出的那条竖带**即可(与宽缓冲区 + // 的滚动逻辑同构),因此拖动视界的每帧代价与信号数无关。 + // + // ⚠️ 与装饰层的区别:本层的**内容随视界变化**,因此必须记录渲染时的视界 + // (`m_derivedViewMin/Max`);跨度一变就必须整块重画。 + /** + * @brief 确保派生曲线缓存位图尺寸正确(不足则重建为 32bpp DIB 段)。 + * @param [IN] width 位图宽度。 @param [IN] height 位图高度。 + * @return 可用返回 true。 + */ + bool EnsureDerivedCache(int width, int height); + /** @brief 释放派生曲线缓存位图并作废缓存。 */ + void ReleaseDerivedCache(); + /** + * @brief 把全部派生预览曲线绘制进透明缓存位图。 + * + * `m_derivedStripActive == true` 时只重画 `[m_derivedStripX0, m_derivedStripX1)` 这段竖带 + * (平移后的“新露出”区域),否则整块重画。 + * @return 成功返回 true。 + */ + bool RenderDerivedCache(); + /** + * @brief 把派生曲线缓存按 `AlphaBlend` 贴回后备缓冲区(仅绘图区)。 + * @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。 + */ + bool BlitDerivedCache(gd::Graphics& g); + /** + * @brief 派生曲线缓存的内容指纹(**不含视界**:视界单独比对,用于判断能否滚动复用)。 + * @return 指纹。 + */ + std::uint64_t DerivedOverlayKey() const; + /** + * @brief 按当前视界/内容刷新派生曲线缓存,并把结果贴回后备缓冲区。 + * + * 三种情形:内容与视界都相同 → 只贴图;内容相同且跨度不变(纯平移)→ 滚动 + 补条带; + * 其余 → 整块重画。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintDerivedOverlayLayer(gd::Graphics& g); + + mutable HDC m_derivedDc = nullptr; /**< 派生曲线缓存设备上下文。 */ + mutable HBITMAP m_derivedBitmap = nullptr; /**< 派生曲线缓存位图。 */ + mutable HGDIOBJ m_derivedOldBitmap = nullptr; /**< DC 原选位图。 */ + mutable void* m_derivedBits = nullptr; /**< 位图像素(32bpp DIB 段)。 */ + mutable int m_derivedW = 0; /**< 缓存宽度。 */ + mutable int m_derivedH = 0; /**< 缓存高度。 */ + mutable bool m_derivedValid = false; /**< 缓存内容是否有效。 */ + mutable std::uint64_t m_derivedContentKey = 0; /**< 缓存对应的内容指纹(不含视界)。 */ + mutable double m_derivedViewMin = 0.0; /**< 缓存渲染时的视界下限。 */ + mutable double m_derivedViewMax = 0.0; /**< 缓存渲染时的视界上限。 */ + mutable bool m_derivedStripActive = false; /**< 当前是否只重画一条竖带。 */ + mutable float m_derivedStripX0 = 0.0f; /**< 竖带左边界(客户区像素)。 */ + mutable float m_derivedStripX1 = 0.0f; /**< 竖带右边界(客户区像素)。 */ + // ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ---- // // 平移只改变绘制坐标、不改变画面内容。因此**每个网格各自维护一块离屏位图** @@ -4003,6 +4386,15 @@ namespace jdi int y0 = 0; /**< 上。 */ int x1 = 0; /**< 右(不含)。 */ int y1 = 0; /**< 下(不含)。 */ + /** + * @brief 该面板内**全部文本的墨迹矩形**(客户区坐标)。 + * + * 面板是"先画进图层缓存、再贴图"的:命中缓存时不会真的画文字, + * 于是调试用的文本框这一帧就**凭空消失** —— 观感是"拖动视界时浮动面板上 + * 的布局边界不停闪烁"(用户 2026-10-29 报告)。 + * 因此渲染时把文本框一并存下来,贴图时按原坐标回放,几何与是否命中缓存无关。 + */ + std::vector textRects; }; HBITMAP m_panelBitmap = nullptr; /**< 面板图层位图(原点与客户区一致)。 */ diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 8a39d2f..87bdf34 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -29,6 +29,7 @@ #include #include "PlotView.hpp" +#include "MemProbe.hpp" #include "UiControls.hpp" #include "Utf8Util.hpp" @@ -56,6 +57,32 @@ namespace jdi inline const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */ inline const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */ inline const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ + /** + * @brief 派生预览曲线走「逐像素列包络带」时,每列抽样几个采样。 + * + * 稠密视界(可见采样数 > 像素列数)下预览曲线若按抽稀折线绘制,相邻顶点的 y + * 会整幅跳变 ⇒ GDI+ 描边代价 ∝ 覆盖像素数,实测单条曲线 76~128ms。 + * 改为逐列记录上下包络后一次 FillPath,单条曲线降到约 2ms(约 60 倍)。 + * 每列抽样 4 个点即可得到饱满的包络(再多只影响饱满度,不影响代价量级)。 + */ + inline const size_t kDerivedCurveSamplesPerColumn = 4; + /** + * @brief 派生预览曲线包络带的**列密度分母**:每 N 个设备像素一列。 + * + * 包络带的填充代价主要取决于**边数**与**覆盖面积**(实测同一形状 1600 列 ≈ 24ms、 + * 400 列 ≈ 6.4ms):预览曲线是半透明叠加层、且本来就是“逐列 min/max”的聚合结果, + * 横向 4px 的分辨率损失在稠密视界下不可辨(尖峰仍不会丢:每列取的是该列的极值)。 + * 稀疏区间仍走逐点折线(见 `PaintDerivedCurve`),不受影响。 + */ + inline const size_t kDerivedCurveColumnDivisor = 4; + /** + * @brief 判定“极密视界”的采样密度阈值(采样/设备像素),超过时包络带列密度降到 + * `kDerivedCurveColumnDivisor`,否则用 2 像素一列。 + * + * 中等密度(1~8 采样/像素)下预览曲线形状本身还有细节,用 2 像素一列即可保住观感; + * 极密视界下每列内部就是一条满幅噪声带,4 像素一列肉眼无差别、代价再降一半。 + */ + inline const size_t kDerivedCurveVeryDenseSamplesPerPixel = 8; /** * @brief 图层**纵向滚动**的量化阈值(位图像素)。 * @@ -199,36 +226,8 @@ namespace jdi inline 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); + // 唯一实现见 `PaintUtil.hpp`(弹出菜单的还原图标、信号样式区的复位按钮共用同一套笔画)。 + UiPaintResetIcon(g, rect, color, scale); } /** @@ -411,6 +410,15 @@ namespace jdi */ inline const float kEdgePanelDisclosureWidth = 20.0f; + /** + * @brief 面板内**条目行 / 分组栏**左侧展开三角的边长(逻辑像素)。 + * + * ⚠️ 与标题栏的 `kEdgePanelDisclosureWidth`(20)**刻意不同**:行高只有 22 逻辑像素, + * 20 的三角会把整行顶满、显得又大又笨(用户 2026-10-30 报告)。 + * 「均值滤波」的条目栏与「自由标记」的分组栏共用本值(12),观感完全一致。 + */ + inline const float kEdgePanelRowTriangleSize = 12.0f; + // ⚠️ 这里曾有 `kFreeMarkerHoverTolerance = 8`(旧的非光标模式“纵向贴近”容差)。 // 用户 2026-10-22 要求把自由标记的触发范围与「常规状态下触发信号曲线高亮」**统一**, // 因此现在直接复用 `kSignalHitTolerance`(6 逻辑像素)并改判“到折线的垂直距离” @@ -578,6 +586,34 @@ namespace jdi inline const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数"; inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ inline const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ + + // ---- 「均值滤波」面板的两行参数滑块(用户 2026-10-30 要求)---- + /** + * 标签一律**不带 `X` / `N`**:滑块的量程本身已经说明是数值,符号只会让标签变长。 + * (信号样式区与菜单里仍保留 `均值点数 X` / `迭代次数 N` 的写法,那是输入框语义。) + */ + inline const wchar_t* const kMeanFilterPointsSliderLabel = L"均值点数"; + inline const wchar_t* const kMeanFilterIterationsSliderLabel = L"迭代次数"; + /** + * @brief 「均值点数」下限。 + * + * ⚠️ **必须是 1**(用户 2026-10-30 要求):窗口 = `2X+1`,`X = 0` 等于"不做任何平滑", + * 滑块拖到最左端时预览曲线与原始波形完全重合 —— 那不像"滤波",用户会以为功能坏了。 + * 下限取 1 ⇒ 最左端是 3 点均值,始终有可见的平滑效果。 + */ + inline const int kMeanFilterPointsMin = 1; + /** 「均值点数」滑块的默认上限(用户可点击数值改成其它正整数)。 */ + inline const int kMeanFilterPointsDefaultMax = 50; + /** 「均值点数」上限允许的最大值(防止输入一个天文数字把滤波窗口撑爆)。 */ + inline const int kMeanFilterPointsMaxLimit = 100000; + /** 新建条目时的默认均值点数(与 `Lane::MeanFilterItem::points` 初值一致)。 */ + inline const int kMeanFilterPointsDefault = 5; + /** 「迭代次数」下限。 */ + inline const int kMeanFilterIterationsMin = 1; + /** 「迭代次数」滑块的默认上限。 */ + inline const int kMeanFilterIterationsDefaultMax = 10; + /** 「迭代次数」上限允许的最大值。 */ + inline const int kMeanFilterIterationsMaxLimit = 1000; /** 「自由标记」面板底部「自由标记点选取」切换按钮文案(激活态 = 光标模式开启)。 */ inline const wchar_t* const kFreeMarkerCursorModeLabel = L"自由标记点选取"; /** 「自由标记」面板底部「显示增量」切换按钮文案(只影响已固定点的显示)。 */ @@ -615,8 +651,43 @@ namespace jdi * 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高), * 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”, * “回车确认参数后不移动鼠标就一直停在部分值”)。 + * + * ⚠️⚠️ **这是「非交互帧」的预算**:用户 2026-11-02 报告——288 个信号合并到同一网格、 + * 开启中心轴边沿计数后,拖动横向光标每帧要 30ms(实测),界面完全跟不上手。 + * 原因就是本预算太大:中心轴每个采样对还要按“均值点数 × 迭代次数”算一次均值窗口, + * 1200000 对 × 11 次浮点运算 ≈ 3 位千万级运算/帧。 + * 现在交互期间改用 `kEdgeScanPairsPerFrameInteractive`(见 `Paint` 开头)。 */ - inline const std::size_t kEdgeScanPairsPerFrame = 1200000; + inline const std::size_t kEdgeScanPairsPerFrame = 8000000; + /** + * @brief **交互帧**(正在拖拽 / 鼠标刚移动过)允许的边沿统计**采样对**配额。 + * + * 真正限制开销的是下面的「时间预算」(`m_edgeScanDeadline`):同样的采样对数在 + * 普通计数与中心轴(每个对还要算窗口均值)之间能差一个量级,因此**按时间切**: + * 采样对配额只负责“公平分摊”(不让第一个状态吃光全帧)。 + */ + inline const std::size_t kEdgeScanPairsPerFrameInteractive = 2000000; + /** + * @brief 非交互帧的统计扫描**时间预算**(毫秒)。 + * + * ⚠️ 用户 2026-11-02 报告“统计太慢(VERY_BIG_DATA)”:旧的固定采样对配额在 + * 普通计数(1~2ns/对)下把一帧的工作量限得太死,而中心轴(≈7 倍成本)又嫌太大。 + * 改成按时间切之后,便宜的计数能一次性推进数倍,贵的也绝不会卡住一帧。 + */ + inline const float kEdgeScanTimeBudgetMs = 6.0f; + /** @brief 交互帧的统计扫描时间预算(毫秒):优先保证界面跟手。 */ + inline const float kEdgeScanTimeBudgetInteractiveMs = 1.5f; + /** + * @brief 鼠标停止移动多久之后恢复高统计预算(毫秒)。 + */ + inline const std::uint64_t kEdgeScanInteractiveIdleMs = 140; + /** + * @brief 统计数据版本号在**交互期间**的刷新间隔(帧数)。 + * + * 交互帧里统计会继续推进,但每推进一次就重画上百行的面板太贵;节流后 + * “鼠标一直动、面板一直停在旧数字”的缺陷也修掉了(最慢 ~4 帧 ≈ 60ms 内刷新一次)。 + */ + inline const std::uint64_t kEdgeScanStatsRefreshFrames = 4; /** * @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。 * @@ -672,6 +743,37 @@ namespace jdi return (!complete && rising == 0 && falling == 0); } + /** + * @brief 表格单元格里的**纯数字**(「边沿计数」表格用)。 + * + * 表头已经写了「上升沿计数 / 下降沿计数」,单元格里再写「上升沿 N」就重复了; + * 未算完时在数字后追加 `…`(与列表式排版的语义一致)。 + */ + inline std::wstring EdgeCountCellText(std::size_t value, bool complete) + { + std::wstring text = std::to_wstring(value); + if (!complete) + { + text += L" …"; + } + return text; + } + + /** @brief 「边沿计数」表格的表头文本(信号名 / 上升沿计数 / 下降沿计数)。 */ + inline const wchar_t* EdgeCountTableHeader(int column) + { + switch (column) + { + case 0: + return L"信号名"; + case 1: + return L"上升沿计数"; + case 2: + default: + return L"下降沿计数"; + } + } + /** * @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。 * @@ -1234,39 +1336,77 @@ namespace jdi } /** - * @brief 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。 + * @brief 派生预览曲线相对底色的**最低对比度**(WCAG 比值)。 * - * 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上, - * 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移, - * 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧, - * 保证曲线既能与波形区分、又能压在底图上看得清。 - * - * 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。 - * - * @param [IN] color 原始信号色(0xAARRGGBB)。 - * @param [IN] background 绘图区底色,用于评估可见度。 - * @return 派生出的高对比度颜色(alpha 与输入一致,由调用方再覆盖)。 + * 用比值而不是"亮度差":同样的亮度差在很亮的底色上(白)几乎看不出来, + * 在深色底上却很显眼 —— 比值才是一致可见性的正确度量。 */ - inline std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background = 0xFFFDF6E3u) - { - const auto squaredDistance = [](std::uint32_t a, std::uint32_t b) - { - const double dr = - static_cast((a >> 16) & 0xFFu) - static_cast((b >> 16) & 0xFFu); - const double dg = - static_cast((a >> 8) & 0xFFu) - static_cast((b >> 8) & 0xFFu); - const double db = static_cast(a & 0xFFu) - static_cast(b & 0xFFu); - return dr * dr + dg * dg + db * db; - }; + inline const double kDerivedPreviewContrast = 3.2; - const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72); - const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72); - // 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。 - const double scoreDarker = - squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background); - const double scoreLighter = - squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background); - return (scoreDarker >= scoreLighter) ? darker : lighter; + /** + * @brief 派生预览曲线与**源信号色**之间还要拉开的亮度差。 + * + * 源信号本身已经与底色有足够对比时,预览曲线再往外走这么多,避免"两条线同色叠在一起"。 + */ + inline const double kDerivedPreviewGap = 0.18; + + /** + * @brief 由信号色派生「派生预览曲线」的颜色(与底色、与源信号同时保持足够对比)。 + * + * 规则(**连续**,不含任何"临界点切换"): + * 1. 方向:沿"信号色相对底色的那一侧"继续远离底色 + *(深底上的信号往更亮走、浅底上的信号往更暗走); + * 2. 先求"与底色对比度达到 `kDerivedPreviewContrast` 的亮度"(`EnsureContrast`), + * 再与"信号亮度 + `kDerivedPreviewGap`"取**更远离**底色的那个; + * 3. 用 `MixToLuminance` 把信号色沿亮度轴平滑推到该目标(保持色相)。 + * + * 步 2 的两个候选都是输入的**连续函数**(取 max/min),因此拖拾色器 / 换预设色块 / + * 切主题时派生色**连续变化**,不会像旧实现那样"混 72% 黑还是白"按评分在阈值处跳变。 + * + * @param [IN] color 源信号色(0xAARRGGBB,alpha 被忽略)。 + * @param [IN] background 网格**底色**(填充 `ThemeLaneBackground`)。 + * @return 派生颜色(不透明;透明度由调用方按信号不透明度重新叠加)。 + */ + inline std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background) + { + const std::uint32_t base = color | 0xFF000000u; + const std::uint32_t back = background | 0xFF000000u; + const double backgroundLuminance = SrgbLuminance(back); + const double signalLuminance = SrgbLuminance(base); + + // 方向:沿信号所在的"那一侧"继续远离底色。信号与底色几乎同亮度时 + // 按底色的明暗选一侧(此时源曲线本身几乎看不见,方向没有更好选择)。 + double direction = 0.0; + if (signalLuminance > backgroundLuminance + 1.0e-4) + { + direction = 1.0; + } + else if (signalLuminance < backgroundLuminance - 1.0e-4) + { + direction = -1.0; + } + else + { + direction = (backgroundLuminance < 0.5) ? 1.0 : -1.0; + } + + // 候选一:与底色对比度达到阈值的那个颜色(已达标时就是原色)。 + const std::uint32_t required = EnsureContrast(back, base, kDerivedPreviewContrast); + const double requiredLuminance = SrgbLuminance(required); + // 候选二:在信号亮度的基础上再拉开一段。 + const double gapLuminance = signalLuminance + direction * kDerivedPreviewGap; + + double target = (direction > 0.0) ? std::max(requiredLuminance, gapLuminance) + : std::min(requiredLuminance, gapLuminance); + if (target > 1.0) + { + target = 1.0; + } + if (target < 0.0) + { + target = 0.0; + } + return MixToLuminance(base, target); } /** @brief FNV-1a 混合(面板像素缓存键共用)。 */ inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value) diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 00c4acd..e485d16 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -60,6 +60,17 @@ namespace jdi InvalidateWideBuffer(); } + void PlotView::OnThemeChanged() + { + // 折线/统计等纯数据缓存与主题无关,但它们的**像素化结果**全都与主题有关: + // 场景缓存位图、每网格宽基底图层、装饰层、光晕层、浮动面板层里都烘着主题色。 + InvalidateDerivedCaches(); // 内部已经包含场景缓存作废 + InvalidateWideBuffer(); + ReleaseDecorCache(); + ReleaseHaloLayer(); + ReleasePanelCache(); + } + bool PlotView::HasDrawableContent() const { return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible(); @@ -425,6 +436,19 @@ namespace jdi return; } + // 折线缓存容量 = 全部信号数 × 2(下限 `kPolylineCacheLimit`)。 + // ⚠️ 必须随信号总数增长:固定 64 条在"上百个信号合并到一个网格"时会持续清表重建, + // 使绘制与自由标记探测每帧重算全部折线(用户 2026-10-30 报告的卡顿)。 + { + std::size_t totalSignals = 0; + for (const Lane& lane : lanes) + { + totalSignals += lane.signals.size(); + } + const std::size_t wanted = totalSignals * 2ull; + m_polylineCacheCapacity = (wanted > kPolylineCacheLimit) ? wanted : kPolylineCacheLimit; + } + const float gap = 0.0f; float laneHeight = m_plotRect.Height / static_cast(count); if (laneHeight < m_minLaneHeight * m_scale) @@ -850,30 +874,10 @@ namespace jdi std::size_t PlotView::AvailablePhysicalBytes() { - // ⚠️ 结果按 200ms 缓存:本函数在绘制路径上每次请求曲线都会调用, - // `GlobalMemoryStatusEx` 是系统调用,逐帧多次调用没有必要(内存变化远慢于 200ms)。 - static std::atomic cachedAt{0}; - static std::atomic cachedBytes{0}; - const std::uint64_t now = GetTickCount64(); - const std::uint64_t last = cachedAt.load(std::memory_order_relaxed); - if (last != 0 && (now - last) < 200) - { - const std::size_t value = cachedBytes.load(std::memory_order_relaxed); - if (value != 0) - { - return value; - } - } - MEMORYSTATUSEX status = {}; - status.dwLength = sizeof(status); - std::size_t available = std::numeric_limits::max(); - if (GlobalMemoryStatusEx(&status) != FALSE) - { - available = static_cast(status.ullAvailPhys); - } - cachedBytes.store(available, std::memory_order_relaxed); - cachedAt.store(now, std::memory_order_relaxed); - return available; + // 统一走 `MemProbe` 的实现:它自带 200ms 缓存,且与"分配前预检验" + // (`MemCanAllocate` / `MemReserveIfRoom`)共用同一份数据 —— + // 历史上这里有一份完全重复的 `GlobalMemoryStatusEx` + 缓存实现。 + return MemAvailablePhysical(); } void PlotView::ShiftLaneRange(int laneIndex, double deltaValue) diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp index 9effdbc..51a562f 100644 --- a/Components/PlotView/Src/PlotViewCacheKey.cpp +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -458,7 +458,7 @@ namespace jdi if (cardBottom > cardTop) { UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), - m_theme->cardBg); + ThemeLaneBackground(*m_theme)); } const double viewMin = Tab().ViewMin(); diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 236ab36..e639399 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -224,12 +224,19 @@ namespace jdi continue; } + // ⚠️ 该信号自己的时间轴偏移:采样画在 `t + offsetTime` 处, + // 因此"这段数据在屏幕上占用的时间范围"与"单值数据点的时刻"都要加上它。 + // 少了这一项,「适应视图」在信号被时间偏移后仍按原始时间范围取景 + // (用户 2026-10-29 报告的缺陷)。 + const SignalStyle* style = m_dataSet->Style(ref); + const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; + FitContribution item; item.ref = ref; // 与 `ComputeVisibleTimeBounds` 同源:时间边界用**时间轴**的范围 //(一个分组的多条信号可能共用一条轴,轴范围才是"这一段数据"的边界)。 - item.minTime = axis->minTime; - item.maxTime = axis->maxTime; + item.minTime = axis->minTime + timeShift; + item.maxTime = axis->maxTime + timeShift; item.singlePoint = (data->finiteCount == 1); if (item.singlePoint) { @@ -241,8 +248,8 @@ namespace jdi } else { - const SignalStyle* style = m_dataSet->Style(ref); - item.pointTime = time; + // 同样是**显示时刻**(`time + offsetTime`):视界的居中与边界让位都以它为准。 + item.pointTime = time + timeShift; item.pointValue = (style != nullptr) ? ApplyDrawTransform(*style, value) : value; item.dotRadius = SinglePointDotRadius(ResolveSignal(ref)); @@ -637,6 +644,9 @@ namespace jdi m_hoverLegend = SignalRef(); m_dragSignal = SignalRef(); m_dragTargetLane = -1; + // 面板条目栏的悬停同样按引用保存(均值滤波 / 弱峰调整的预览曲线高亮)。 + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); InvalidateSceneCache(); } @@ -706,8 +716,11 @@ namespace jdi { continue; } - low = std::min(low, axis->minTime); - high = std::max(high, axis->maxTime); + // 与 `TimeBounds` 同源:叠加该信号自己的时间轴偏移(采样画在 t + offsetTime 处)。 + const SignalStyle* style = m_dataSet->Style(ref); + const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; + low = std::min(low, axis->minTime + timeShift); + high = std::max(high, axis->maxTime + timeShift); } if (!std::isfinite(low) || !std::isfinite(high) || !(high > low)) @@ -826,7 +839,13 @@ namespace jdi } dataMin = std::max(dataMin, viewMin); dataMax = std::min(dataMax, viewMax); - if (!(dataMax > dataMin)) + // ⚠️ **`dataMax == dataMin` 是合法且常见的情形**(视界内只有 1 个有效采样点, + // 典型:单数据点信号)。旧实现在这里直接 `return false`,等于把"只有一个点" + // 的信号判成"不稀疏" ⇒ 光标不会吸附到那个点上 + // (用户 2026-10-29 报告"单数据点信号的吸附没了")。 + // 正确做法是让它走下面"覆盖那唯一一个小格"的路径: + // `firstCell == lastCell`,`need = 5`,而 `finiteCount == 1 < 5` ⇒ 判为稀疏。 + if (dataMax < dataMin) { return false; } @@ -1395,6 +1414,8 @@ namespace jdi if (lane.EdgeCenterEnabled(ref)) { lane.RemoveEdgeCenterItem(ref); + // 该信号的累积统计状态一并释放(否则会占着 `kEdgeScanStateLimit` 的名额)。 + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); return false; } lane.EnsureEdgeCenterItem(ref); @@ -1773,6 +1794,74 @@ namespace jdi return true; } + bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.meanFilterScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.meanFilterScroll = clamped; + return true; + } + + bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.edgeCenterScroll = clamped; + return true; + } + + bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.weakPeakScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.weakPeakScroll = clamped; + return true; + } + + bool PlotView::SetLaneEdgeCountScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.edgeCountScroll - clamped) < 0.5f) + { + return false; + } + // 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。 + lane.edgeCountScroll = clamped; + return true; + } + bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -1931,7 +2020,8 @@ namespace jdi int points, int iterations, int amountPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, - bool allowAsyncCompute) const + bool allowAsyncCompute, + bool computeIfMissing) const { spanBegin = 0; const std::size_t count = signal.values.size(); @@ -1969,6 +2059,34 @@ namespace jdi return &hit->values; } + // 1b) 「只读」模式(光标取值胶囊):**绝不新建计算任务 / 条目**。 + if (!computeIfMissing) + { + if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) + { + return &stale->values; + } + return nullptr; + } + + // 1b) “只读”模式(光标取值胶囊):**绝不新建计算任务/条目**。 + // + // ⚠️⚠️ 为什么必须这么做(用户 2026-11-02 反馈“拖光标时有明显卡顿”): + // 胶囊只为“光标所在的那两个采样”取值,请求窗口只有几个采样 —— 但缓存键里含窗口, + // 而窗口随光标每帧都在变 ⇒ 每帧都未命中 ⇒ 每帧同步计算并 `PublishCurve` 一次。 + // 15 信号 × 3 种曲线 = 每帧 45 次发布,条目列表不断变长,`Acquire*` 的线性搜索 + // 也随之变慢(实测拖动光标单帧 200ms,其中约 150ms 来自这 45 次重算 + 发布)。 + // 现在:命中就取(绘制路径发布的窗口**带保护带**、覆盖整个可见区间,正常情况一定命中), + // 没命中就退到同 (信号, 种类) 的最新曲线,再没有就本帧不标注。 + if (!computeIfMissing) + { + if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) + { + return &stale->values; + } + return nullptr; + } + // 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 // 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。 // @@ -2554,6 +2672,20 @@ namespace jdi // 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中, // 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。 // 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。 + // + // ⚠️⚠️ **每个绘制帧最多 Post 一次**(用户 2026-11-02 报告“上百条信号时所有交互都卡”): + // 本函数在 `EdgeScanFor` 里按“未完成的状态”逐个调用,288 条信号时单帧会 Post 288 次! + // 大量 `WM_MSG`+重绘会把优先级更低的 `WM_TIMER` 饿死(分组折起动画因此永远结束不了), + // 同时把 CPU 占满、拖慢所有交互。用一个“本帧已请求”标志收敛成一次。 + if (m_loadingBusy) + { + return; // 加载中:不推进、不自请求(否则加载进度被统计重绘拖慢) + } + if (m_edgeScanRequestFrame == m_edgeScanFrame) + { + return; + } + m_edgeScanRequestFrame = m_edgeScanFrame; const HWND host = m_host; if (host != nullptr) { @@ -2561,9 +2693,22 @@ namespace jdi } } - void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly) + std::size_t PlotView::EdgeScanStateLimit() const { - // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算, + // 每个信号最多两类累积状态(普通边沿计数 / 中心轴边沿计数),另留一点余量。 + // ⚠️ 不能用“当前网格内信号数”:边缘面板可能同时开在多个网格上, + // 而这里用全仓可见信号总数更省心(多出来的容量只是几个几十 KB 的块和数组)。 + std::size_t signalCount = 0; + if (m_dataSet != nullptr) + { + signalCount = m_dataSet->SignalCount(); + } + const std::size_t scaled = signalCount * 2u + 16u; + return std::max(kEdgeScanStateLimit, scaled); + } + + void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly) + { // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算, // 其它信号的键没有变化、累计的块和依然有效 → 必须原样保留。 // 否则(过去用 `m_edgeScans.clear()`)会连**普通边沿计数**与**其它信号的中心轴统计** // 一起丢掉,于是两个浮动面板里的每一个数字都要从头重新扫描: @@ -2593,6 +2738,14 @@ namespace jdi falling = 0; complete = false; + // ⚠️ **宿主正在加载文件时不推进**:加载线程的主循环不断 `UpdateWindow` 触发整帧绘制, + // 若这里继续扫,加载会被统计抢走 CPU(用户报告“统计没算完就无法开始加载”)。 + // 加载完成后数据层结构变化会整体作废累积状态,本来就要重算。 + if (m_loadingBusy) + { + return; + } + const std::size_t count = signal.values.size(); if (count < 2) { @@ -2643,7 +2796,13 @@ namespace jdi } if (state == nullptr) { - if (m_edgeScans.size() >= kEdgeScanStateLimit) + // ⚠️⚠️ **累积状态上限必须随“信号数”缩放**(用户 2026-11-02 报告:288 条信号合并到 + // 同一网格、开启「边沿计数」后**全部停在“统计中…”**且永不更新): + // 旧上限固定 64,而面板会在一帧内为**每条信号**各创建一个状态(288 条 > 64)⇒ + // 新建状态时总是把队首(尚未扫完的)淘汰 ⇒ 没有任何状态能活到扫完 ⇒ 永远重头开始。 + // 每个信号最多两类状态(普通 / 中心轴),因此上限取 `2×信号数 + 余量`。 + const std::size_t stateLimit = EdgeScanStateLimit(); + if (m_edgeScans.size() >= stateLimit) { // ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变), // 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫 @@ -2682,6 +2841,7 @@ namespace jdi created.justReset = true; m_edgeScans.push_back(std::move(created)); state = &m_edgeScans.back(); + ++m_edgeScanRevision; // 新状态:让面板重算(可能刚从“统计中…”切换) } else if (!centre && state->threshold != threshold) { @@ -2696,6 +2856,7 @@ namespace jdi state->justReset = true; std::fill(state->blockRising.begin(), state->blockRising.end(), 0); std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0); + ++m_edgeScanRevision; } // ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 --------------------- @@ -2836,10 +2997,24 @@ namespace jdi const bool suppressPartial = state->justReset && !state->complete; if (!state->complete && !state->blockRising.empty()) { - const std::size_t activeStates = std::max(1, m_edgeScans.size()); + // ⚠️ 分母只数**还没扫完**的状态:已完成的状态不再消耗配额,若把它们也算进分母, + // 上百条短信号里夹着几条千万级长信号时,长信号每帧只能分到 1/300 的预算 + // (实测要 20 分钟才出结果)。按“未完成数”分摊后,短信号一完成就把预算让出来。 + std::size_t activeStates = 0; + for (const EdgeScanState& candidate : m_edgeScans) + { + if (!candidate.complete) + { + ++activeStates; + } + } + activeStates = std::max(1, activeStates); const std::size_t share = std::max(kEdgeScanFairShare, activeStates); - const std::size_t perState = - std::max(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / share); + // ⚠️ **不要给每状态一个固定下限**(旧实现是 `max(60000, budget/share)`): + // 状态数一多,下限就让**前几个状态**(恰好是网格里最前面的信号)吃掉整帧预算, + // 后面的信号永远分不到配额 ⇒ 一直显示“统计中…”(用户 2026-11-02 报告的 + // 288 信号场景)。改为纯按未完成状态数平分本帧预算(至多 1)。 + const std::size_t perState = std::max(1, m_edgeScanBudget / share); if (state->pumpedFrame != m_edgeScanFrame) { state->pumpedFrame = m_edgeScanFrame; @@ -2857,6 +3032,17 @@ namespace jdi std::size_t scanned = 0; std::size_t pair = state->scannedPair; std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0; + /** @brief 本帧的**时间预算**是否已用完(每块检查一次,QPC 开销可忽略)。 */ + const auto timeUp = [this]() -> bool + { + if (m_edgeScanDeadline <= 0) + { + return false; + } + LARGE_INTEGER now = {}; + QueryPerformanceCounter(&now); + return now.QuadPart >= m_edgeScanDeadline; + }; while (pair < count && scanned < quota && m_edgeScanBudget > 0) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; @@ -2925,6 +3111,10 @@ namespace jdi { ++block; } + if (timeUp()) + { + break; // 本帧的时间预算用完:剩下的帧接着扫 + } } state->scannedPair = pair; state->complete = (pair >= count); @@ -2940,6 +3130,11 @@ namespace jdi } state->pumpedPairs += scanned; m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); + if (scanned > 0) + { + // 数字(或“是否扫完”)变了 ⇒ 面板缓存键要跟着变,否则会一直贴旧像素。 + ++m_edgeScanRevision; + } } } diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index d5c8c7f..2d8fd76 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -389,9 +389,11 @@ namespace jdi m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS || m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS || - m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) + m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) { - // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「迭代次数 N 上限」(弱峰调整): + // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「滑块上限」(弱峰调整 / 均值滤波): // 输入框都落在对应的字段/滑块行矩形上。 if (m_editFieldRect.Width <= 0.0f) { @@ -641,6 +643,55 @@ namespace jdi return true; } + // 「均值滤波」两行滑块的**上限**:与「弱峰调整 → 迭代次数上限」同一交互语义 + //(点击滑块右侧数值 → 整行变成输入框,输入正整数作为新的量程上限)。 + // 规则:空内容 / 非法值(低于下限或超出允许范围)→ 保持原上限不变; + // 生效后若当前值超过新上限,自动夹到新上限。 + if (target == PlotEditTarget::MEAN_POINTS_MAX || + target == PlotEditTarget::MEAN_ITERATIONS_MAX) + { + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const bool centerEdit = (m_editMeanKind == EdgePanelKind::CENTER); + const Lane::MeanFilterItem* existing = centerEdit + ? lane.FindEdgeCenterItem(m_editMeanRef) + : lane.FindMeanFilterItem(m_editMeanRef); + if (existing == nullptr) + { + return true; + } + if (parsedOk) + { + const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX); + const int minimum = + isPoints ? (centerEdit ? 0 : kMeanFilterPointsMin) : kMeanFilterIterationsMin; + const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit; + const long long value = static_cast(std::llround(parsed)); + if (value >= minimum && value <= limit) + { + Lane::MeanFilterItem& item = centerEdit + ? lane.EnsureEdgeCenterItem(m_editMeanRef) + : lane.EnsureMeanFilterItem(m_editMeanRef); + if (isPoints) + { + item.maxPoints = static_cast(value); + item.points = std::max(minimum, std::min(item.maxPoints, item.points)); + } + else + { + item.maxIterations = static_cast(value); + item.iterations = + std::max(minimum, std::min(item.maxIterations, item.iterations)); + } + m_meanCurveCache.clear(); + if (centerEdit) + { + InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true); + } + } + } + return true; + } + // 「均值点数 X」/「迭代次数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。 const bool centerTarget = @@ -659,8 +710,11 @@ namespace jdi } const int maximum = isPoints ? 1000000 : 1000; int value = static_cast(std::lround(parsed)); - // 迭代次数至少为 1(1 次迭代等于不做滤波);均值点数可以为 0。 - value = isPoints ? std::max(0, std::min(value, maximum)) + // 迭代次数至少为 1(1 次迭代等于不做滤波)。 + // 「均值滤波」的**均值点数下限为 1**(用户 2026-10-30 明确要求:不能为 0); + // 「中心轴边沿计数」沿用旧语义(0 = 不做平滑),因此两者下限分开取。 + const int pointsMin = centerTarget ? 0 : kMeanFilterPointsMin; + value = isPoints ? std::max(pointsMin, std::min(value, maximum)) : std::max(1, std::min(value, maximum)); Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef) @@ -882,7 +936,9 @@ namespace jdi return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::PEAK_LEVEL: case PlotDragMode::PEAK_ITERATIONS: - // 「弱峰调整」滑块拖拽 → 左右箭头。 + case PlotDragMode::MEAN_POINTS: + case PlotDragMode::MEAN_ITERATIONS: + // 「弱峰调整 / 均值滤波」参数滑块拖拽 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: // 拖动信号(参考 Photoshop 平移元素的抓握光标)。 @@ -1015,46 +1071,102 @@ namespace jdi } } } - // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 - if (static_cast(panelKind) == EdgePanelKind::PEAK) + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」可滚动列表: + // * 条目栏:**只有该信号已被勾选**时才给手型(未勾选时点击毫无效果, + // 用户 2026-11-02 明确要求此时不要改变鼠标指针形状); + // * 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型; + // * 内容区滚动条 → 上下箭头。 { - for (std::size_t rowIndex = 0; rowIndex < layout.rowIterValues.size(); ++rowIndex) + const EdgePanelKind cursorKind = static_cast(panelKind); + const bool listCursor = (cursorKind == EdgePanelKind::MEAN || + cursorKind == EdgePanelKind::CENTER || + cursorKind == EdgePanelKind::PEAK); + const Lane& cursorLane = + Tab().Lanes()[static_cast(panelLane)]; + if (listCursor) { - if (layout.rowIterValues[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterValues[rowIndex], static_cast(x), - static_cast(y))) + for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex) { - return LoadCursorW(nullptr, IDC_HAND); - } - // 两行滑块右侧的**复位控件**:与其它可点击图标一致,用手型光标提示可点击。 - if (rowIndex < layout.rowLevelResets.size() && - layout.rowLevelResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowLevelResets[rowIndex], static_cast(x), - static_cast(y))) - { - return LoadCursorW(nullptr, IDC_HAND); - } - if (rowIndex < layout.rowIterResets.size() && - layout.rowIterResets[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterResets[rowIndex], static_cast(x), - static_cast(y))) - { - return LoadCursorW(nullptr, IDC_HAND); - } - if (rowIndex < layout.rowIterTracks.size() && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, - static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_SIZEWE); - } - if (rowIndex < layout.rowLevelTracks.size() && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, - static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_SIZEWE); + if (layout.rowHeaders[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowHeaders[rowIndex], static_cast(x), + static_cast(y))) + { + bool checked = false; + if (cursorKind == EdgePanelKind::PEAK) + { + checked = cursorLane.FindWeakPeakItem( + layout.rowRefs[rowIndex]) != nullptr; + } + else if (cursorKind == EdgePanelKind::CENTER) + { + checked = cursorLane.FindEdgeCenterItem( + layout.rowRefs[rowIndex]) != nullptr; + } + else + { + checked = cursorLane.FindMeanFilterItem( + layout.rowRefs[rowIndex]) != nullptr; + } + return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr; + } + const bool hasParams = + rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f; + if (!hasParams) + { + continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件 + } + // 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。 + if (cursorKind != EdgePanelKind::PEAK && + rowIndex < layout.rowLevelValues.size() && + layout.rowLevelValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelValues[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowIterValues.size() && + layout.rowIterValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterValues[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowLevelResets.size() && + layout.rowLevelResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelResets[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowIterResets.size() && + layout.rowIterResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterResets[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowLevelTracks.size() && + WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, + static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } + if (rowIndex < layout.rowIterTracks.size() && + WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, + static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEWE); + } } } + if (layout.scrollThumb.Width > 0.0f && + UiPointInRect(layout.scrollThumb, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } } + // 面板主体(含底部编辑行与参数输入框):保持原始箭头。 return nullptr; } @@ -1119,11 +1231,32 @@ namespace jdi return nullptr; } + bool PlotView::InputIsInteractive() const + { + if (m_dragMode != PlotDragMode::NONE) + { + return true; + } + if (m_lastInputActivityMs == 0) + { + return false; + } + const std::uint64_t now = GetTickCount64(); + return (now >= m_lastInputActivityMs) && + ((now - m_lastInputActivityMs) < kEdgeScanInteractiveIdleMs); + } + bool PlotView::OnMouseMove(int x, int y) { const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY); m_mouseX = x; m_mouseY = y; + // 交互活动时间戳:`Paint` 用它决定边沿统计本帧用「交互预算」还是「静止预算」。 + // 只有真正的移动才更新 —— 悬停不动时统计应当全速推进。 + if (mouseMoved) + { + m_lastInputActivityMs = GetTickCount64(); + } if (!HasDrawableContent()) { @@ -1393,6 +1526,84 @@ namespace jdi } return true; } + case PlotDragMode::MEAN_POINTS: + case PlotDragMode::MEAN_ITERATIONS: + { + // 「均值滤波 / 中心轴边沿计数」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。 + if (m_tab == nullptr || m_meanDragLane < 0 || + m_meanDragLane >= static_cast(m_tab->Lanes().size())) + { + break; + } + Lane& lane = m_tab->Lanes()[static_cast(m_meanDragLane)]; + const Lane::MeanFilterItem* existingItem = (m_meanDragKind == EdgePanelKind::CENTER) + ? lane.FindEdgeCenterItem(m_meanDragRef) + : lane.FindMeanFilterItem(m_meanDragRef); + if (existingItem == nullptr) + { + break; + } + Lane::MeanFilterItem& item = (m_meanDragKind == EdgePanelKind::CENTER) + ? lane.EnsureEdgeCenterItem(m_meanDragRef) + : lane.EnsureMeanFilterItem(m_meanDragRef); + const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS); + const EdgePanelLayout layout = EdgePanelLayoutFor( + m_meanDragLane, m_meanDragKind); + int rowIndex = -1; + for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) + { + if (layout.rowRefs[candidate] == m_meanDragRef) + { + rowIndex = static_cast(candidate); + break; + } + } + if (rowIndex < 0) + { + break; + } + const std::vector& tracks = + pointsMode ? layout.rowLevelTracks : layout.rowIterTracks; + if (static_cast(rowIndex) >= tracks.size() || + tracks[static_cast(rowIndex)].Width <= 0.0f) + { + break; + } + const double ratio = + UiSliderRatioFromX(tracks[static_cast(rowIndex)], fx); + if (pointsMode) + { + // ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。 + const int floorValue = (m_meanDragKind == EdgePanelKind::CENTER) ? 0 : kMeanFilterPointsMin; + const int maximum = + std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); + item.points = + std::max(floorValue, + std::min(maximum, + floorValue + + static_cast(std::lround(ratio * (maximum - floorValue))))); + } + else + { + const int maximum = + std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item.maxIterations)); + item.iterations = std::max( + kMeanFilterIterationsMin, + std::min(maximum, + kMeanFilterIterationsMin + + static_cast(std::lround( + ratio * (maximum - kMeanFilterIterationsMin))))); + } + m_meanCurveCache.clear(); + // 中心轴统计依赖参数 ⇒ 参数一变必须定向作废该信号的累积状态。 + if ((m_meanDragKind == EdgePanelKind::CENTER)) + { + InvalidateEdgeScansFor(ResolveSignal(m_meanDragRef).data, true); + } + m_dragMoved = true; + return true; + } case PlotDragMode::LANE_REORDER: { // 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。 @@ -1526,6 +1737,52 @@ namespace jdi } return false; } + case PlotDragMode::MEAN_SCROLL: + { + // 「均值滤波 / 中心轴边沿计数」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。 + if (m_tab == nullptr || m_markerScrollDragLane < 0 || + m_markerScrollDragLane >= static_cast(m_tab->Lanes().size())) + { + break; + } + const EdgePanelLayout layout = EdgePanelLayoutFor( + m_markerScrollDragLane, m_meanDragKind); + const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (layout.scrollTrack.Width <= 0.0f || travel <= 0.0f || maxScroll <= 0.0f) + { + break; + } + const float thumbTop = + std::max(layout.scrollTrack.Y, + std::min(static_cast(y) - m_markerScrollGrabOffset, + layout.scrollTrack.Y + travel)); + const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; + const float target = ratio * maxScroll; + bool scrolled = false; + switch (m_meanDragKind) + { + case EdgePanelKind::COUNT: + scrolled = SetLaneEdgeCountScroll(m_markerScrollDragLane, target); + break; + case EdgePanelKind::CENTER: + scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target); + break; + case EdgePanelKind::PEAK: + scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target); + break; + default: + scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target); + break; + } + if (scrolled) + { + m_dragMoved = true; + return true; + } + return false; + } case PlotDragMode::NONE: default: break; @@ -1687,6 +1944,50 @@ namespace jdi hoverPanelKind); } + // 「均值滤波 / 弱峰调整 / 中心轴边沿计数」面板**条目栏**的悬停 → 高亮该条目 + //(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示"; + // 中心轴边沿计数自 2026-11-02 起同样有条目栏,条目本身也会叠一层强调色)。 + { + int hoverLane = -1; + SignalRef hoverRef; + EdgePanelKind hoverKind = + static_cast(hoverPanelKind); + if (overEdgePanel && m_tab != nullptr && panelLane >= 0 && + (hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK || + hoverKind == EdgePanelKind::CENTER)) + { + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind); + if (UiPointInRect(layout.content, static_cast(x), static_cast(y))) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + if (rowIndex >= layout.rowHeaders.size()) + { + break; + } + const gd::RectF& headerRect = layout.rowHeaders[rowIndex]; + // 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示, + // 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。 + // 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。 + if (UiPointInRect(headerRect, static_cast(x), static_cast(y))) + { + hoverLane = panelLane; + hoverRef = layout.rowRefs[rowIndex]; + break; + } + } + } + } + if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef) || + (hoverLane >= 0 && hoverKind != m_meanRowHoverKind)) + { + m_meanRowHoverLane = hoverLane; + m_meanRowHoverRef = hoverRef; + m_meanRowHoverKind = hoverKind; + changed = true; + } + } + // 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。 // 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。 bool overPeakReset = false; @@ -2049,163 +2350,366 @@ namespace jdi // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目 // (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || - kind == EdgePanelKind::PEAK) && + kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT) && m_tab != nullptr) { const bool meanMode = (kind == EdgePanelKind::MEAN); + const bool centerMode = (kind == EdgePanelKind::CENTER); const bool boostMode = (kind == EdgePanelKind::PEAK); - Lane& tabLane = m_tab->Lanes()[static_cast(index)]; - - // 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。 - if (boostMode) + // 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套 + //**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的两行参数滑块)。 + const bool listMode = (meanMode || centerMode || boostMode); + // 「边沿计数」= **表格**(表头 + 每信号一行),只有滚动条可拖。 + if (kind == EdgePanelKind::COUNT) { + if (layout.scrollThumb.Width > 0.0f && + UiPointInRect(layout.scrollThumb, fx, fy)) + { + m_dragMode = PlotDragMode::MEAN_SCROLL; + m_markerScrollDragLane = index; + m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; + m_meanDragKind = EdgePanelKind::COUNT; + return true; + } + } + // 「列表式面板」:组头三角(展开 / 收起参数行)+ 两行参数滑块 + //(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。 + // ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形, + // 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。 + if (listMode) + { + Lane& meanLane = m_tab->Lanes()[static_cast(index)]; + // 本面板的条目查找 / 创建(两者共用 `Lane::MeanFilterItem`)。 + const auto findItem = [&meanLane, centerMode](const SignalRef& ref) + -> const Lane::MeanFilterItem* { + return centerMode ? meanLane.FindEdgeCenterItem(ref) + : meanLane.FindMeanFilterItem(ref); + }; + const auto ensureItem = [&meanLane, centerMode](const SignalRef& ref) + -> Lane::MeanFilterItem& { + return centerMode ? meanLane.EnsureEdgeCenterItem(ref) + : meanLane.EnsureMeanFilterItem(ref); + }; + // 内容区右侧的滚动条滑块:按下即进入拖动(与「自由标记」面板同一交互)。 + if (layout.scrollThumb.Width > 0.0f && + UiPointInRect(layout.scrollThumb, fx, fy)) + { + m_dragMode = PlotDragMode::MEAN_SCROLL; + m_markerScrollDragLane = index; + m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; + m_meanDragKind = kind; + return true; + } + const bool insideContent = + UiPointInRect(layout.content, fx, fy); + // 「弱峰调整」:两行滑块 = 调整幅度(-400% … +400%)/ 迭代次数(1 … 自定上限)。 + // 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。 + if (boostMode) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (!insideContent) + { + break; + } + const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref); + const bool hasParams = + peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f; + if (!hasParams) + { + continue; + } + Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref); + // 复位:调整幅度 → 0% + 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; + } + // 复位:迭代次数 → 1(上限恢复为默认上限)。 + 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 (rowIndex < layout.rowIterValues.size() && + layout.rowIterValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_peakEditCurrentMax = item.maxIterations; + const gd::RectF& track = layout.rowIterTracks[rowIndex]; + const gd::RectF& value = layout.rowIterValues[rowIndex]; + const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, + value.Height); + BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect); + m_edit.Begin(L"", false, EditFilter::BYTE, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 拖动「迭代次数」滑块。 + if (rowIndex < layout.rowIterTracks.size() && + WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) + { + m_dragMode = PlotDragMode::PEAK_ITERATIONS; + m_peakDragLane = index; + m_peakDragRef = ref; + const double ratio = + UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); + const int peakMax = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); + item.iterations = std::max( + kWeakPeakIterationsMin, + std::min(peakMax, + kWeakPeakIterationsMin + + static_cast(std::lround( + ratio * (peakMax - kWeakPeakIterationsMin))))); + m_weakPeakCurveCache.clear(); + return true; + } + // 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。 + if (rowIndex < layout.rowLevelTracks.size() && + WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) + { + m_dragMode = PlotDragMode::PEAK_LEVEL; + m_peakDragLane = index; + m_peakDragRef = ref; + item.amountPercent = static_cast(std::lround( + (UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) * + 100.0 * kWeakPeakSliderScale)); + item.amountPercent = std::max( + kWeakPeakAmountMin, + std::min(kWeakPeakAmountMax, item.amountPercent)); + m_weakPeakCurveCache.clear(); + return true; + } + } + } + else + { for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; - const bool hasLevel = rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f; - if (!hasLevel) + if (!insideContent) { - continue; + break; } - if (tabLane.FindWeakPeakItem(ref) == nullptr) + // 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区; + // 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。 + const bool hasParams = + rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f && + findItem(ref) != nullptr; + if (hasParams) { - 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 && - UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) - { - m_editMeanRef = ref; - m_peakEditCurrentMax = item.maxIterations; - // 输入框以**空内容**开始:空内容时显示当前上限作为灰色提示。 - // 输入框覆盖"整条滑块行"(从轨道左端到数值文本右端)。 - const gd::RectF& track = layout.rowIterTracks[rowIndex]; - const gd::RectF& value = layout.rowIterValues[rowIndex]; - const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, - value.Height); - BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect); - // `BeginEdit` 默认用 NUMBER 过滤(允许负号/小数点);这里改为**纯数字**, - // 从源头保证"只允许输入正整数"(负数、小数根本无法键入,粘贴同样被清洗)。 - m_edit.Begin(L"", false, EditFilter::BYTE, 0u); - m_dragMode = PlotDragMode::NONE; - return true; - } - if (layout.rowIterTracks[rowIndex].Width > 0.0f && - WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) - { - m_dragMode = PlotDragMode::PEAK_ITERATIONS; - m_peakDragLane = index; - m_peakDragRef = ref; - const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); - const int peakMax = std::max(kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, - item.maxIterations)); - item.iterations = std::max( - kWeakPeakIterationsMin, - std::min(peakMax, kWeakPeakIterationsMin + - static_cast(std::lround( - ratio * (peakMax - kWeakPeakIterationsMin))))); - m_weakPeakCurveCache.clear(); - return true; - } - if (layout.rowLevelTracks[rowIndex].Width > 0.0f && - WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) - { - m_dragMode = PlotDragMode::PEAK_LEVEL; - m_peakDragLane = index; - m_peakDragRef = ref; - // 单击轨道:直接写入该处对应的幅度(-400% … +400%,0 在正中间)。 - item.amountPercent = static_cast(std::lround( - (UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) * - 100.0 * kWeakPeakSliderScale)); - item.amountPercent = std::max( - kWeakPeakAmountMin, - std::min(kWeakPeakAmountMax, item.amountPercent)); - m_weakPeakCurveCache.clear(); - return true; + Lane::MeanFilterItem& item = ensureItem(ref); + // 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。 + if (rowIndex < layout.rowLevelResets.size() && + layout.rowLevelResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy)) + { + item.points = kMeanFilterPointsDefault; + item.maxPoints = kMeanFilterPointsDefaultMax; + m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } + m_dragMode = PlotDragMode::NONE; + return true; + } + // 复位:迭代次数 → 1(上限恢复为默认上限)。 + if (rowIndex < layout.rowIterResets.size() && + layout.rowIterResets[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterResets[rowIndex], fx, fy)) + { + item.iterations = kMeanFilterIterationsMin; + item.maxIterations = kMeanFilterIterationsDefaultMax; + m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } + m_dragMode = PlotDragMode::NONE; + return true; + } + // 点击「均值点数」右侧数值 → 输入滑块上限。 + if (rowIndex < layout.rowLevelValues.size() && + layout.rowLevelValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowLevelValues[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_meanEditCurrentMax = item.maxPoints; + m_editMeanKind = kind; + const gd::RectF& track = layout.rowLevelTracks[rowIndex]; + const gd::RectF& value = layout.rowLevelValues[rowIndex]; + const gd::RectF rowRect(track.X, value.Y, + value.X + value.Width - track.X, + value.Height); + BeginEdit(PlotEditTarget::MEAN_POINTS_MAX, index, L"", rowRect); + m_edit.Begin(L"", false, EditFilter::BYTE, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 点击「迭代次数」右侧数值 → 输入滑块上限。 + if (rowIndex < layout.rowIterValues.size() && + layout.rowIterValues[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIterValues[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_meanEditCurrentMax = item.maxIterations; + m_editMeanKind = kind; + const gd::RectF& track = layout.rowIterTracks[rowIndex]; + const gd::RectF& value = layout.rowIterValues[rowIndex]; + const gd::RectF rowRect(track.X, value.Y, + value.X + value.Width - track.X, + value.Height); + BeginEdit(PlotEditTarget::MEAN_ITERATIONS_MAX, index, L"", rowRect); + m_edit.Begin(L"", false, EditFilter::BYTE, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 拖动「均值点数」滑块。 + if (rowIndex < layout.rowLevelTracks.size() && + WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) + { + const int floorValue = centerMode ? 0 : kMeanFilterPointsMin; + const int maximum = + std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); + m_dragMode = PlotDragMode::MEAN_POINTS; + m_meanDragLane = index; + m_meanDragRef = ref; + m_meanDragKind = kind; + const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); + item.points = std::max( + floorValue, + std::min(maximum, + floorValue + static_cast(std::lround(ratio * (maximum - floorValue))))); + m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } + return true; + } + // 拖动「迭代次数」滑块。 + if (rowIndex < layout.rowIterTracks.size() && + WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy)) + { + const int maximum = std::max( + kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item.maxIterations)); + m_dragMode = PlotDragMode::MEAN_ITERATIONS; + m_meanDragLane = index; + m_meanDragRef = ref; + m_meanDragKind = kind; + const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); + item.iterations = std::max( + kMeanFilterIterationsMin, + std::min(maximum, + kMeanFilterIterationsMin + + static_cast(std::lround( + ratio * (maximum - kMeanFilterIterationsMin))))); + m_meanCurveCache.clear(); + if (centerMode) + { + InvalidateEdgeScansFor(ResolveSignal(ref).data, true); + } + return true; + } } } } + // 条目栏(信号色叠底那一整行): + // * 点在右端复选框上 → 切换「是否启用该信号的预览」; + // * 已启用 → 整行切换参数行的收起 / 展开; + // * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏 + // 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。 + // 仍然消费这次点击(不穿透到底层的平移 / 选区等)。 for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { - const SignalRef& ref = layout.rowRefs[rowIndex]; - // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 - if (layout.rowChecks[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) + if (!insideContent) { - if (meanMode) - { - ToggleLaneMeanFilterSignal(index, ref); - } - else if (boostMode) + break; + } + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (rowIndex >= layout.rowHeaders.size() || + layout.rowHeaders[rowIndex].Width <= 0.0f || + !UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) + { + continue; + } + const bool onCheckBox = + rowIndex < layout.rowChecks.size() && + layout.rowChecks[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowChecks[rowIndex], fx, fy); + bool checked = false; + bool collapsed = false; + if (boostMode) + { + const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref); + checked = (peakItem != nullptr); + collapsed = (peakItem != nullptr && peakItem->collapsed); + } + else + { + const Lane::MeanFilterItem* meanItem = findItem(ref); + checked = (meanItem != nullptr); + collapsed = (meanItem != nullptr && meanItem->collapsed); + } + if (onCheckBox) + { + if (boostMode) { ToggleLaneWeakPeakSignal(index, ref); } - else + else if (centerMode) { ToggleLaneEdgeCenterSignal(index, ref); } - // 勾选变化只改变该信号“是否显示”,它的统计状态(与参数相关)依然有效, - // 其它信号更是完全无关 ⇒ **不要**清任何缓存(清掉就会让所有统计重算)。 - // 这里**不**整体作废场景缓存(交给 `m_sceneDerived` 快照驱动局部重画)。 - m_dragMode = PlotDragMode::NONE; - return true; + else + { + ToggleLaneMeanFilterSignal(index, ref); + } + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); } - // 第二排的两个输入框 → 就地编辑该信号的参数(仅 MEAN / CENTER 面板; - // 弱峰调整用的是滑块,已在上面处理)。 - if (layout.rowPoints[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) + else if (checked) { - m_editMeanRef = ref; - const Lane::MeanFilterItem* item = - meanMode ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref); - BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS, - index, std::to_wstring(item != nullptr ? item->points : 5), - layout.rowPoints[rowIndex]); - return true; - } - if (layout.rowIterations[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowIterations[rowIndex], fx, fy)) - { - m_editMeanRef = ref; - const Lane::MeanFilterItem* item = - meanMode ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref); - BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS - : PlotEditTarget::EDGE_CENTER_ITERATIONS, - index, std::to_wstring(item != nullptr ? item->iterations : 1), - layout.rowIterations[rowIndex]); - return true; + // 已启用:整行切换参数行的收起 / 展开。 + if (boostMode) + { + Lane::WeakPeakItem* item = + const_cast(meanLane.FindWeakPeakItem(ref)); + item->collapsed = !collapsed; + } + else + { + Lane::MeanFilterItem* item = + const_cast(findItem(ref)); + item->collapsed = !collapsed; + } } + m_dragMode = PlotDragMode::NONE; + return true; } + } + if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { @@ -2272,8 +2776,9 @@ namespace jdi MeanFilterBuild build; build.groupIndex = ref.groupIndex; build.signalIndex = ref.signalIndex; - build.points = item->points; - build.iterations = item->iterations; + // 与预览一致:点数下限 1、迭代次数下限 1(用户 2026-10-30 要求)。 + build.points = std::max(kMeanFilterPointsMin, item->points); + build.iterations = std::max(kMeanFilterIterationsMin, item->iterations); m_pendingMeanFilterItems.push_back(build); } if (!m_pendingMeanFilterItems.empty()) @@ -2707,6 +3212,10 @@ namespace jdi // 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。 m_peakDragLane = -1; m_peakDragRef = SignalRef(); + // 「均值滤波 / 中心轴边沿计数」参数滑块拖拽结束:同样清掉目标。 + m_meanDragLane = -1; + m_meanDragRef = SignalRef(); + m_meanDragKind = EdgePanelKind::MEAN; // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 m_markerOpacityDragLane = -1; m_markerScrollDragLane = -1; @@ -2755,24 +3264,72 @@ namespace jdi int panelLane = -1; int panelKind = 0; if (TopPanelAtPoint(static_cast(m_mouseX), static_cast(m_mouseY), - panelLane, panelKind) && - static_cast(panelKind) == EdgePanelKind::MARK) + panelLane, panelKind)) { - const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); - if (layout.content.Width > 0.0f && - UiPointInRect(layout.content, static_cast(m_mouseX), - static_cast(m_mouseY))) + const EdgePanelKind hoveredKind = static_cast(panelKind); + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数」面板:内容区超高时用滚轮翻页 + //(上百个信号合并时必备)。 + if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER || + hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT) { - const float maxScroll = - std::max(0.0f, layout.markerContentHeight - layout.content.Height); - if (maxScroll > 0.0f) + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind); + if (layout.content.Width > 0.0f && + UiPointInRect(layout.content, static_cast(m_mouseX), + static_cast(m_mouseY))) { - const float next = static_cast(Tab().Lanes()[static_cast( - panelLane)] - .freeMarkerScroll - - notches * 40.0 * m_scale); - SetLaneFreeMarkerScroll(panelLane, std::max(0.0f, std::min(next, maxScroll))); - return true; + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (maxScroll > 0.0f) + { + const Lane& wheelLane = + Tab().Lanes()[static_cast(panelLane)]; + const float current = (hoveredKind == EdgePanelKind::CENTER) + ? wheelLane.edgeCenterScroll + : (hoveredKind == EdgePanelKind::PEAK) + ? wheelLane.weakPeakScroll + : (hoveredKind == EdgePanelKind::COUNT) + ? wheelLane.edgeCountScroll + : wheelLane.meanFilterScroll; + const float next = current - static_cast(notches * 40.0 * m_scale); + const float target = std::max(0.0f, std::min(next, maxScroll)); + switch (hoveredKind) + { + case EdgePanelKind::CENTER: + SetLaneEdgeCenterScroll(panelLane, target); + break; + case EdgePanelKind::PEAK: + SetLaneWeakPeakScroll(panelLane, target); + break; + case EdgePanelKind::COUNT: + SetLaneEdgeCountScroll(panelLane, target); + break; + default: + SetLaneMeanFilterScroll(panelLane, target); + break; + } + return true; + } + } + } + if (hoveredKind == EdgePanelKind::MARK) + { + const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MARK); + if (layout.content.Width > 0.0f && + UiPointInRect(layout.content, static_cast(m_mouseX), + static_cast(m_mouseY))) + { + const float maxScroll = + std::max(0.0f, layout.markerContentHeight - layout.content.Height); + if (maxScroll > 0.0f) + { + const float next = static_cast(Tab().Lanes()[static_cast( + panelLane)] + .freeMarkerScroll - + notches * 40.0 * m_scale); + SetLaneFreeMarkerScroll(panelLane, + std::max(0.0f, std::min(next, maxScroll))); + return true; + } } } } @@ -3078,6 +3635,13 @@ namespace jdi m_freeMarkerRowHoverItem = static_cast(-1); changed = true; } + // 「均值滤波 / 弱峰调整」条目栏的悬停高亮同样随鼠标离开清除(否则预览曲线一直亮着)。 + if (m_meanRowHoverLane != -1 || m_meanRowHoverRef.IsValid()) + { + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); + changed = true; + } if (m_cursorHover) { m_cursorHover = false; @@ -3626,5 +4190,4 @@ namespace jdi } -} // namespace jdi - +} // namespace jdi \ No newline at end of file diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 8dd2c42..c3d1f48 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -18,12 +18,63 @@ // ⚠️ 新增函数按**职责**放入对应分片;`PlotView::Paint/PaintLane` 仍只允许定义在本文件。 #include "PlotViewInternal.hpp" -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" #include "UiLayout.hpp" namespace jdi { + namespace + { + /** + * @brief 面板渲染期间的文本几何收集器:**记录 + 转发**给外层收集器。 + * + * 为什么需要它:浮动面板是"先画进像素图层、命中缓存时直接贴图"的。 + * 贴图那一帧不会真的画字 —— 若文本框只靠"画的时候顺手收集",它在那一帧就会 + * **凭空消失**,观感是"拖动视界时面板上的布局边界不停闪烁"(用户 2026-10-29 报告)。 + * + * 所以渲染面板时用本收集器:一边把矩形转发给外层(保持正常行为), + * 一边存一份进 `PanelSlot::textRects`;命中缓存贴图时再把存下的矩形回放出去。 + * + * `OnTextLayer` 也必须转发:外层收集器靠它把文本记到正确的绘制层 + * (面板层比网格层高一层),否则面板文字会被自己登记的遮挡矩形挖掉。 + */ + class PanelTextCollector : public UiTextRectSink + { + public: + explicit PanelTextCollector(UiTextRectSink* outer) : m_outer(outer) {} + + void OnTextRect(const gd::RectF& rect) override + { + m_rects.push_back(rect); + if (m_outer != nullptr) + { + m_outer->OnTextRect(rect); + } + } + + void OnTextLayer(int layer) override + { + if (m_outer != nullptr) + { + m_outer->OnTextLayer(layer); + } + } + + int TextLayer() const override { return (m_outer != nullptr) ? m_outer->TextLayer() : 0; } + + /** @brief 外层收集器(面板画完后要还原它)。 */ + UiTextRectSink* Outer() const { return m_outer; } + + /** @brief 本次渲染收集到的文本框。 */ + const std::vector& Rects() const { return m_rects; } + + private: + UiTextRectSink* m_outer = nullptr; + std::vector m_rects; + }; + } // namespace + const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, @@ -62,29 +113,65 @@ namespace jdi //(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。 const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f; + // 全部键字段的 64 位哈希(顺序固定;只用于**查找**,命中后再做廉价校验)。 + std::uint64_t key = 1469598103934665603ull; + const auto mixHash = [&key](std::uint64_t value) + { + key ^= value; + key *= 1099511628211ull; + }; + const auto mixHashFloat = [&mixHash](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mixHash(static_cast(bits)); + }; + const auto mixHashDouble = [&mixHash](double value) + { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mixHash(bits); + }; + mixHash(static_cast(static_cast(ref.groupIndex)) + 2ull); + mixHash(static_cast(static_cast(ref.signalIndex)) + 2ull); + mixHash(reinterpret_cast(resolved.group)); + mixHash(static_cast(sampleCount)); + mixHashDouble(viewMin); + mixHashDouble(viewMax); + mixHashFloat(m_plotRect.X); + mixHashFloat(m_plotRect.Width); + mixHashFloat(lane.rect.Y); + mixHashFloat(lane.rect.Height); + mixHashDouble(lane.yMin); + mixHashDouble(lane.yMax); + mixHashFloat(gain); + mixHash((absolute ? 1ull : 0ull) | (mirrorVertical ? 2ull : 0ull) | + (mirrorHorizontal ? 4ull : 0ull)); + mixHashFloat(offsetY); + mixHashDouble(offsetTime); + mixHashFloat(lineWidth); + for (const PolylineCacheEntry& entry : m_polylineCache) { - if (entry.ref == ref && entry.group == resolved.group && entry.sampleCount == sampleCount && - entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && - entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && - 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.lineWidth == lineWidth) + // 快路径:只比哈希。命中后再校验三项最可能变化的字段(碰撞代价可忽略)。 + if (entry.key != key || entry.sampleCount != sampleCount || !(entry.ref == ref) || + entry.viewMin != viewMin || entry.viewMax != viewMax) { - return &entry; + continue; } + return &entry; } // 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。 - // 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。 - if (m_polylineCache.size() >= kPolylineCacheLimit) + // ⚠️ 容量必须**覆盖所有网格的全部信号**:旧实现固定 64 条,283 个信号合并到 + // 同一网格时会不断"清空重建"⇒ 绘制与自由标记探测每帧重算全部折线(严重卡顿)。 + if (m_polylineCache.size() >= m_polylineCacheCapacity) { m_polylineCache.clear(); } m_polylineCache.push_back(PolylineCacheEntry()); PolylineCacheEntry& entry = m_polylineCache.back(); + entry.key = key; entry.ref = ref; entry.group = resolved.group; entry.sampleCount = sampleCount; @@ -563,9 +650,13 @@ namespace jdi UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } - void PlotView::CollectDebugBoxes(UiDebugBoxList& boxes) + void PlotView::BuildBoxes(UiBoxTable& boxes) { - UiComponent::CollectDebugBoxes(boxes); + UiComponent::BuildBoxes(boxes); + + // 本组件的基础绘制层(= 宿主组件表里的下标)。**必须显式记录并复位**: + // 下面为浮动面板临时提了一层,若不复位,宿主后续组件的层号会被整体推高。 + const int baseLayer = boxes.Layer(); // 层级 1:每个网格的波形区;层级 2:该网格的 GX 编号徽标。 // ⚠️ **绝不能读 `m_decorRects`**:那里存的是"装饰层位图缓存"的登记矩形, @@ -591,73 +682,133 @@ namespace jdi // 层级 1/2:绘图区里**不属于任何网格**的固定构件。 // 这些曾经全部漏掉(调试时"看得见却没框")—— 它们都已经有成员变量,直接读即可。 + // ⚠️ 这些构件都绘制在**浮动面板之前**,因此与网格同层(`baseLayer`)。 + boxes.SetLayer(baseLayer); boxes.Add(m_timeAxisRect, 1); - boxes.Add(m_scrollBarRect, 2); - boxes.Add(BackToTopRect(), 2); // 层级 2/3:时间光标读数标签(避让后的实际矩形,绘制时已登记)。 boxes.Add(m_cursorSpanRect, 2); boxes.AddAll(m_cursorLabelRects, 3); - // 层级 3:内联输入框(正在编辑时才有意义)。 - boxes.Add(EditFieldRect(), 3); + // 下面这三样在 `paintOverlays` 里绘制在**浮动面板之后**(压在面板之上): + // 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。 + // ⇒ 它们的层号在下面「面板循环」之后统一给(比最上面板再高一层), + // 否则与实际像素的层序相反。这里先复位到组件基层。 + boxes.SetLayer(baseLayer); + + // ⚠️⚠️ **浮动面板必须另起绘制层,并把面板矩形登记为遮挡矩形**: + // 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。 + // 若与网格同层,网格的矩形边线会横穿面板 —— 用户 2026-10-29 报告的 + // "网格的布局边界穿透显示在浮动面板上"。`layer` 决定谁压谁, + // `AddOccluder` 才真正把下层 box 从面板区域里**挖掉**。 // 层级 2/3/4:5 类实验性功能浮动面板的内部控件。 // 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。 - for (std::size_t index = 0; index < m_lanes.size(); ++index) + // + // ⚠️⚠️ **每个面板各占一个绘制层,且顺序必须是 `m_panelOrder`(底 → 顶)**: + // 旧实现把所有面板都放在 `baseLayer + 1` 一层,于是同一层内只能按 `depth` 排序 —— + // 下层面板的"行/文本"(depth 4)会画在**上层**面板的本体(depth 2/3)之上, + // 表现为"被遮挡的下层面板的边框/文字穿透到上层面板上"(用户 2026-10-30 报告)。 + // 层号与 `paintOverlays` 里 `UiSetTextLayer` 的推进**完全同源**。 + // + // 说明(已知边界):面板层号从 `baseLayer + 1` 起逐面板递增,因此**同时显示 15 个以上** + // 面板时会撞上浮层层号(`OverlayLayer(1)` = 组件数 × 3 + 3)。那时只有"菜单不再挖掉 + // 面板边框"这一条调试观感受影响;实际布局下同时可见的面板是几个到十几个。 + SyncPanelOrder(); + const std::size_t panelCount = m_panelOrder.size(); + const int topOverlayLayer = baseLayer + 1 + static_cast(panelCount); + + // ⚠️ **先用最便宜的判据跳过"没有面板"的网格**:`EdgePanelLayoutFor` 内部要取 + // 标签页配置、逐项判断种类与开关,单次约 2~3µs;283 个网格 × 5 种 = 1415 次调用 + // 实测约 **4ms/帧**(这是"开调试后卡"的主要来源之一)。 + // 现在只对**层序表里真正显示出来的面板**算布局(层序表本来就只含展开且启用的面板)。 + for (std::size_t ordinal = 0; ordinal < panelCount; ++ordinal) { - const int laneIndex = static_cast(index); - for (int kindValue = static_cast(EdgePanelKind::COUNT); - kindValue <= static_cast(EdgePanelKind::MARK); ++kindValue) + const PanelRef& panelRef = m_panelOrder[ordinal]; + const EdgePanelLayout layout = + EdgePanelLayoutFor(panelRef.laneIndex, static_cast(panelRef.kind)); + if (!UiRectHasArea(layout.panel)) { - const EdgePanelLayout layout = - EdgePanelLayoutFor(laneIndex, static_cast(kindValue)); - if (!UiRectHasArea(layout.panel)) - { - continue; - } - boxes.Add(layout.panel, 2); - boxes.Add(layout.title, 3); - boxes.Add(layout.close, 3); - boxes.Add(layout.disclosure, 3); - boxes.Add(layout.badge, 3); - boxes.Add(layout.edit, 3); - boxes.Add(layout.button, 3); - boxes.Add(layout.content, 3); - boxes.Add(layout.scrollTrack, 3); - boxes.Add(layout.scrollThumb, 4); - boxes.Add(layout.opacityTrack, 3); - boxes.Add(layout.opacityValue, 3); - boxes.Add(layout.showDelta, 3); - boxes.Add(layout.showEnum, 3); + continue; + } + // 本面板独占一层(层号越大越靠上)。 + boxes.SetLayer(baseLayer + 1 + static_cast(ordinal)); + boxes.AddOccluder(layout.panel); + // 面板内部元素的调试框同样裁到面板本身:面板滚动的内容区里, + // 滚出可视区的行其矩形仍在(与弹出菜单的条目同一类问题)。 + boxes.SetClip(layout.panel); + boxes.Add(layout.panel, 2); + boxes.Add(layout.title, 3); + boxes.Add(layout.close, 3); + boxes.Add(layout.disclosure, 3); + boxes.Add(layout.badge, 3); + boxes.Add(layout.edit, 3); + boxes.Add(layout.button, 3); + boxes.Add(layout.content, 3); + boxes.Add(layout.tableHeader, 3); + for (const EdgePanelLayout::TableColumn& column : layout.tableColumns) + { + boxes.Add(gd::RectF(column.left, layout.tableHeader.Y, column.width, + layout.tableHeader.Height), + 4); + } + boxes.Add(layout.scrollTrack, 3); + boxes.Add(layout.scrollThumb, 4); + boxes.Add(layout.opacityTrack, 3); + boxes.Add(layout.opacityValue, 3); + boxes.Add(layout.showDelta, 3); + boxes.Add(layout.showEnum, 3); - boxes.AddAll(layout.rowChecks, 4); - boxes.AddAll(layout.rowPoints, 4); - boxes.AddAll(layout.rowIterations, 4); - boxes.AddAll(layout.rowStats, 4); - boxes.AddAll(layout.rowLevelTracks, 4); - boxes.AddAll(layout.rowLevelValues, 4); - boxes.AddAll(layout.rowIterTracks, 4); - boxes.AddAll(layout.rowIterValues, 4); - boxes.AddAll(layout.rowLevelResets, 4); - boxes.AddAll(layout.rowIterResets, 4); - boxes.AddAll(layout.rowNameRects, 4); - boxes.AddAll(layout.rowNameCloses, 4); + boxes.AddAll(layout.rowChecks, 4); + boxes.AddAll(layout.rowPoints, 4); + boxes.AddAll(layout.rowIterations, 4); + boxes.AddAll(layout.rowStats, 4); + boxes.AddAll(layout.rowLevelTracks, 4); + boxes.AddAll(layout.rowLevelValues, 4); + boxes.AddAll(layout.rowIterTracks, 4); + boxes.AddAll(layout.rowIterValues, 4); + boxes.AddAll(layout.rowLevelResets, 4); + boxes.AddAll(layout.rowIterResets, 4); + boxes.AddAll(layout.rowNameRects, 4); + boxes.AddAll(layout.rowNameCloses, 4); + boxes.AddAll(layout.rowTriangles, 4); + boxes.AddAll(layout.rowHeaders, 4); - for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) - { - boxes.Add(group.header, 3); - boxes.Add(group.tableHeader, 3); - boxes.Add(group.triangle, 4); - boxes.Add(group.close, 4); - } - for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) - { - boxes.Add(row.rect, 4); - boxes.Add(row.close, 4); - } + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + boxes.Add(group.header, 3); + boxes.Add(group.tableHeader, 3); + boxes.Add(group.triangle, 4); + boxes.Add(group.close, 4); + boxes.Add(group.check, 4); + } + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + boxes.Add(row.rect, 4); + boxes.Add(row.close, 4); } } + // 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。 + // 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)必须比**最上面板**再高一层。 + // ⚠️ **只在它们真的会被绘制时上报**(用户 2026-11-02 报告:关闭分组后仍能看到大量 + // 残留的布局边界框):这些矩形在 `Layout()` 里就无条件算好,但绘制时会按可见性 / + // 编辑态门控。旧实现照样上报 ⇒ 空绘图区里同时出现“不存在的滚动条 / 回到最上方 / + // 输入框”的框。 + boxes.SetClip(gd::RectF()); + boxes.SetLayer(topOverlayLayer); + if (m_scrollBarVisible) + { + boxes.Add(m_scrollBarRect, 5); + } + if (BackToTopVisible()) + { + boxes.Add(BackToTopRect(), 5); + } + if (m_editTarget != PlotEditTarget::NONE) + { + boxes.Add(EditFieldRect(), 5); + } + boxes.SetLayer(baseLayer); } void PlotView::Paint(gd::Graphics& g) @@ -684,8 +835,40 @@ namespace jdi // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 // “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。 ++m_edgeScanFrame; - m_edgeScanBudget = kEdgeScanPairsPerFrame; - + // 统计数据版本号 → 面板缓存键(见 `m_edgeScanRevision`): + // * 非交互帧:**立刻**同步(统计一推进就重画面板,用户看到数字实时变化); + // * 交互帧:按 `kEdgeScanStatsRefreshFrames` 节流 —— 否则鼠标一直移动时 + // 面板每帧重画上百行(正是“悬停在信号上持续移动、面板一直不更新”的反面: + // 以前是冻结永不更新,现在是有节制地刷新)。 + if (m_edgeScanRevision != m_panelStatsSeen && + (!InputIsInteractive() || + (m_edgeScanFrame % kEdgeScanStatsRefreshFrames) == 0u)) + { + m_panelStatsVersion = m_edgeScanRevision; + m_panelStatsSeen = m_edgeScanRevision; + } + // ⚠️⚠️ **交互帧用小预算 + 时间上限**:288 个信号合并到同一网格、开启中心轴边沿计数时, + // 拖动横向光标每帧要 30ms(实测),界面完全跟不上手。现在: + // * 采样对配额只做“公平分摊”(不让第一个状态吃光全帧); + // * 真正的总开销由 `m_edgeScanDeadline`(时间)限制 —— 普通计数因此能一次推进得很快, + // 中心轴也不会卡住一帧; + // * 加载中(`m_loadingBusy`)完全不推进,避免把文件加载拖慢。 + const bool interactive = InputIsInteractive(); + m_edgeScanBudget = m_loadingBusy ? 0 + : (interactive ? kEdgeScanPairsPerFrameInteractive + : kEdgeScanPairsPerFrame); + { + LARGE_INTEGER frequency = {}; + LARGE_INTEGER now = {}; + QueryPerformanceFrequency(&frequency); + QueryPerformanceCounter(&now); + const double budgetMs = m_loadingBusy ? 0.0 + : (interactive ? kEdgeScanTimeBudgetInteractiveMs + : kEdgeScanTimeBudgetMs); + m_edgeScanDeadline = + now.QuadPart + static_cast(static_cast(frequency.QuadPart) * + budgetMs / 1000.0); + } // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 m_curvePins.clear(); @@ -714,6 +897,19 @@ namespace jdi if (m_lanes.empty()) { InvalidateSceneCache(); + // ⚠️⚠️ **必须清掉上一帧登记的叠加层/命中几何**(用户 2026-11-02 报告:关闭整个分组后 + // 仍能看到大量残留的布局边界框):本分支直接 return,`PaintLaneLegend` / + // `PaintCursor` / `PaintFreeMarker` 都不会跑,于是 `m_legendHits` / + // `m_cursorLabelRects` / `m_cursorSpanRect` / `m_freeMarkerHits` 里的**旧矩形** + // 会一直保留(布局边界会继续把它们画出来,甚至命中测试也会命中已不存在的元素)。 + m_legendHits.clear(); + m_cursorLabelRects.clear(); + m_cursorSpanRect = gd::RectF(); + m_freeMarkerHits.clear(); + m_decorRects.clear(); + m_panelOrder.clear(); + m_panelSlots.clear(); + m_panelValid = false; PaintEmptyState(g); // ⚠️ 空状态(尚未勾选任何信号)也必须绘制提示浮层:加载失败 / 解析异常正是 // **最需要**非模态提示的场景,若在此提前返回,宿主的错误信息将永远不可见。 @@ -736,13 +932,28 @@ namespace jdi // 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。 const auto paintOverlays = [this, &g]() { + // 文本几何的层号必须跟着**真实绘制顺序**走: + // 这一段前半部分的文字(光标取值胶囊、读数标签…)在浮动面板**之下**(同组件层), + // 而 `PaintEdgeCursor` 之后的一切(面板、输入框、滚动条、回到最上方)在面板之上。 + // 少了这一步,胶囊里的文字框会记在比面板更低的层,然后被面板的遮挡矩形挖掉 —— + // 或者反过来(旧实现在所有组件之上统一按固定层收集)穿透到面板表面 + //(用户 2026-10-29 报告的两处现象)。 + const int textLayer = UiGetTextLayer(); PaintHoverHighlight(g); + // 实验性功能面板条目栏的悬停 → 对应**预览曲线**的高亮。 + // ⚠️ 必须在贴图之后画(见 `PaintDerivedCurvesFor` 的说明):静态场景缓存里 + // 只有普通曲线,高亮随鼠标变化,烘进缓存就会“悬停看不到 / 移开不消失”。 + PaintDerivedHoverHighlight(g); PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); // 「自由标记」的跟随光标与已固定数据点(在统计面板**之下**绘制, // 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。 PaintFreeMarker(g, m_lanes); + // 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。 + // ⚠️ `PaintEdgeCursor` 内部会**逐面板**再把层号 +1(每个面板独占一层), + // 并在返回前把层号推到**最上面板之上**,因此这里不再写死 `+2`。 + UiSetTextLayer(textLayer + 1); PaintEdgeCursor(g, m_lanes); PaintSignalInteraction(g); PaintHint(g); @@ -750,7 +961,9 @@ namespace jdi //(后者覆盖在浮动面板的滑块行之上)。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN || - m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) + m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) { PaintEditField(g); } @@ -771,6 +984,8 @@ namespace jdi m_plotRect.Height + 4.0f), border, 4, m_theme->axisLine | 0xFF000000u, 2); } + // 复原文本层(宿主按组件下发,回收成进入时的值最保险)。 + UiSetTextLayer(textLayer); }; // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 @@ -908,11 +1123,8 @@ namespace jdi // 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走), // 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。 PaintLaneDividers(g); - // 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。 - for (std::size_t i = 0; i < m_lanes.size(); ++i) - { - PaintLaneDerivedOverlays(g, static_cast(i), m_lanes[i]); - } + // 派生预览曲线:基底不含它们(走像素缓存层 + 平移滚动,见 `PaintDerivedOverlayLayer`)。 + PaintDerivedOverlayLayer(g); // 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。 // 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制); // 否则先把装饰单独绘制进透明缓存位图,再贴回。 @@ -956,9 +1168,27 @@ namespace jdi // 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、 // 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格, // 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。) + // + // ⚠️⚠️ **开启了派生预览曲线时不再走这条补丁路径**(用户 2026-11-02 反馈“改颜色极卡”): + // 派生曲线已经改由独立的像素缓存层绘制(`PaintDerivedOverlayLayer`),而补丁是 + // “贴回场景缓存 → 就地重画受影响网格 → 写回缓存”,它会把缓存里**已经烘进去的曲线像素** + // 覆盖掉(曲线随即消失),并且它内部 `PaintLane` 也不再画曲线。实测 15 信号 × 3 功能时 + // 这条路径每帧还要同步重建一个网格图层(宽基底 = 2 倍宽 × 2 倍高,AA 全质量) + // ⇒ **单帧 2.2 秒**;而直接走整幅重绘只要 ~0.4 秒(其中约 0.06–0.09 秒是曲线重画)。 + // 因此这里要求“没有任何派生功能开启”才允许用补丁路径。 + bool derivedActive = false; + for (const Lane& lane : Tab().Lanes()) + { + if (lane.edgeCenter || lane.meanFilter || lane.weakPeak) + { + derivedActive = true; + break; + } + } { std::vector patchLanes; - if (!m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) + if (!derivedActive && !m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) && + BlitSceneCache(g)) { // ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。 // 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带 @@ -968,17 +1198,16 @@ namespace jdi m_fastFrame = true; RepaintSceneCachePatch(g, patchLanes); m_sceneDegraded = true; - // ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”, - // 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中 - // “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** —— - // 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新, - // 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。 - // 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。 + // ⚠️ 网格图层(宽基底)里仍是**旧样式**的像素,必须让它失效。 + // 但**不要在本帧同步重建**:宽基底是“2 倍宽 × 2 倍高”的整块重绘 + //(15 条稠密信号实测 2.2 秒),同步重建等于把整帧拖到几秒 + //(用户 2026-11-02 报告“改颜色时卡到无法操作”的直接原因)。 + // 只登记待重建:图层指纹(`LaneLayerHash` 含颜色/线宽/粗细…)会让宽基底快路径 + // 自动跳过它(用底板兜底),等视界真正需要时再由既有预算机制补齐。 for (const int laneIndex : patchLanes) { QueueLaneRebuild(laneIndex); } - RenderPendingLaneRebuilds(1); paintOverlays(); return; } @@ -995,7 +1224,9 @@ namespace jdi UiFillRect(g, m_area, m_theme->plotBg); // 波形区域底色。 - UiFillRect(g, m_plotRect, m_theme->cardBg); + // ⚠️ 「网格叠底」必须用 `ThemeLaneBackground()`:主题的 `cardBg` 与 `plotBg` 过于接近时 + // (深色主题)它会自动拉开一点点,保证网格这一层结构在任何主题下都看得见。 + UiFillRect(g, m_plotRect, ThemeLaneBackground(*m_theme)); // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); @@ -1012,6 +1243,10 @@ namespace jdi PaintTimeAxis(g, timeTicks, timeStep); + // 派生预览曲线(像素缓存层):必须在装饰层**之前** —— 曲线要垫在 + // Y 轴刻度胶囊 / 图例下面(与改造前的层序一致)。 + PaintDerivedOverlayLayer(g); + // 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。 if (!m_decorValid || m_decorKey != SceneDecorHash()) { @@ -1470,7 +1705,8 @@ namespace jdi const gd::RectF gridClip = laneClip; /** 标尺数值文本的裁剪矩形(GDI 文本需要显式裁剪,否则会越过绘图区)。 */ const gd::RectF clipRectForLabels = gridClip; - const std::uint32_t minorLineColor = UiBlendOver(m_theme->cardBg, WithAlpha(m_theme->gridWeak, kMinorLineAlpha)); + const std::uint32_t laneBackground = ThemeLaneBackground(*m_theme); + const std::uint32_t minorLineColor = UiBlendOver(laneBackground, WithAlpha(m_theme->gridWeak, kMinorLineAlpha)); const std::uint32_t weakLineColor = m_theme->gridWeak | 0xFF000000u; const std::uint32_t strongLineColor = m_theme->gridStrong | 0xFF000000u; @@ -1743,17 +1979,14 @@ namespace jdi } } - // 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。 - // 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰), - // 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`), - // 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。 - // ⚠️ `m_renderDecorOnly`(把装饰单独画进透明缓存位图)时**也必须跳过**: - // 派生曲线是"随时间视界变化"的内容,若烘进装饰位图,贴回时会把旧视界的 - // 预览曲线永久留在画面上。 - if (!m_renderWide && !m_renderDecorOnly) - { - PaintLaneDerivedOverlays(g, laneIndex, lane); - } + // 第二遍:派生预览曲线**不再**在这里绘制。 + // + // ⚠️ 旧实现把派生曲线画进网格绘制流程(因此也烘进了静态场景缓存与宽缓冲图层), + // 代价是“每帧重画全部曲线”(15 信号 × 3 功能 = 45 条,每条按像素列包络带填充 + // ≈2ms,单帧 90ms)。现在统一由**派生曲线像素缓存层**绘制: + // `PaintDerivedOverlayLayer` 在两条快路径里贴图,平移时只滚动像素 + 补一条竖带 + //(用户 2026-11-02 要求“视界移动 / 光标动画 / 样式修改都要跟手”)。 + // 因此这里(以及 `m_renderWide` 的图层渲染)都不再画曲线。 // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 g.Restore(waveSave); @@ -1897,7 +2130,28 @@ namespace jdi } } - void PlotView::PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const + void PlotView::PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, + const LaneVisual& lane) const + { + PaintDerivedCurvesFor(g, laneIndex, lane, false); + } + + void PlotView::PaintDerivedHoverHighlight(gd::Graphics& g) const + { + if (m_loadingBusy || m_meanRowHoverLane < 0 || !m_meanRowHoverRef.IsValid()) + { + return; + } + const std::size_t laneIndex = static_cast(m_meanRowHoverLane); + if (laneIndex >= m_lanes.size()) + { + return; + } + PaintDerivedCurvesFor(g, m_meanRowHoverLane, m_lanes[laneIndex], true); + } + + void PlotView::PaintDerivedCurvesFor(gd::Graphics& g, int laneIndex, const LaneVisual& lane, + bool hoverOnly) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -1912,21 +2166,47 @@ namespace jdi { return; } + if (hoverOnly && !(centerOn || meanFilterOn || weakPeakOn)) + { + return; + } const int saveState = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); + // 面板条目栏的悬停高亮(用户 2026-10-30 要求):鼠标停在哪一行,就把那条**预览曲线** + // 画一圈光晕 + 加粗。`m_meanRowHoverLane/Ref/Kind` 由 `OnMouseMove` 维护。 + // + // ⚠️⚠️ **必须按面板种类分别判定**(用户 2026-11-02 报告):三个面板(均值滤波 / + // 中心轴边沿计数 / 弱峰调整)都有“信号条目栏”,同一条信号可能同时在三个面板里各有一行。 + // 只给 MEAN 分支传 `highlight` ⇒ 只开弱峰 + 中心轴时悬停毫无反应;三个全开时 + // 悬停任何一个面板都只高亮均值滤波那条。 + // + // ⚠️⚠️⚠️ **高亮改由前景单独绘制**(用户 2026-11-02 报告“悬停高亮丢失”): + // 悬停状态不参与 `SceneContentHash`,命中静态场景缓存时本函数**根本不会被调用**, + // 因此把高亮画在这里等于“只有整幅重绘的那一帧能看到”。现在: + // * `hoverOnly == false`(基底 / 场景缓存路径)→ 只画普通曲线(进入缓存,永远可见); + // * `hoverOnly == true`(前景叠加)→ 只画悬停那一条并带光晕。 + const bool hoverActive = (m_meanRowHoverLane == laneIndex) && m_meanRowHoverRef.IsValid(); for (const SignalRef& ref : tabLane.signals) { + if (hoverOnly && !(hoverActive && (ref == m_meanRowHoverRef))) + { + continue; + } const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { continue; } + const bool hoveredThisRow = hoverActive && (ref == m_meanRowHoverRef); if (centerOn) { const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref); if (centerItem != nullptr) { - PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations); + const bool highlight = + hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::CENTER); + PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations, + highlight); } } if (meanFilterOn) @@ -1934,7 +2214,9 @@ namespace jdi const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); if (item != nullptr) { - PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations); + const bool highlight = + hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::MEAN); + PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, highlight); } } if (weakPeakOn) @@ -1948,7 +2230,9 @@ namespace jdi const int boostIterations = std::max( kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item->iterations)); - PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations); + const bool highlight = + hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::PEAK); + PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, highlight); } } } @@ -2700,7 +2984,7 @@ namespace jdi std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor(kind, *resolved.data, points, iterations, amountPercent, begin, - end, spanBegin, true); + end, spanBegin, true, false); if (curvePtr == nullptr) { return; @@ -3009,7 +3293,7 @@ namespace jdi void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, const std::vector& curve, - std::size_t curveBegin) const + std::size_t curveBegin, bool highlight) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { @@ -3035,12 +3319,14 @@ namespace jdi const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; - // 派生曲线的颜色:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比 - // (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的);透明度与原信号一致。 + // 派生曲线的颜色:与原信号**同色相**、亮度按**当前网格底色**自适应拉开, + // 使其与原始波形、与背景都形成足够对比(原信号改颜色时曲线随之改变,因为它是从信号色 + // 派生出来的);透明度与原信号一致。 const unsigned int baseAlpha = static_cast( std::max(0.0, std::min(1.0, static_cast(resolved.style->opacity))) * 255.0 + 0.5); - const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), - baseAlpha); + const std::uint32_t derived = WithAlpha( + HighContrastDerived(StyleColor(*resolved.style), ThemeLaneBackground(*m_theme)), + baseAlpha); // 顶点预算与波形一致(每像素列两个顶点)。 // 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱峰调整), @@ -3056,6 +3342,168 @@ namespace jdi } const std::size_t stride = std::max(1, visibleCount / std::max(1, budget)); + // ⭐⭐ **稠密区间改用「逐像素列包络带」**(2026-11-02 性能修复): + // 旧实现按 `stride` 抽稀成折线后 `DrawLines`。视界覆盖百万级采样时,相邻抽稀点的 + // y 会**整幅跳变**(几乎每个线段都是竖直满幅),而 GDI+ 描边的代价 ∝ **覆盖的像素数**: + // 实测 3200 个顶点的满幅锯齿折线要 **128ms**(抗锯齿)/ **31ms**(无抗锯齿), + // 同样画面的「逐列 min/max 包络带」一次 FillPath 只要 **1.9ms**(约 60 倍), + // 而且**不会漏掉尖峰**(等间隔抽样会整段跳过)。 + // 稀疏区间(可见采样数 ≤ 像素列数)继续走逐点折线:点数本来就少,深放大下也更圆滑。 + // 列密度:中等密度用「每 2 像素一列」,极密(> 8 采样/像素)时降到 4 像素一列。 + // 列数直接决定包络带的边数与填充面积 —— 稠密视界下这是唯一有效的加速手段。 + const float columnDivisor = + (visibleCount > + static_cast(std::max(1.0f, m_plotRect.Width)) * + kDerivedCurveVeryDenseSamplesPerPixel) + ? static_cast(kDerivedCurveColumnDivisor) + : 2.0f; + const int columnCount = static_cast( + std::max(1.0f, std::floor(m_plotRect.Width / columnDivisor))); + const float columnWidth = m_plotRect.Width / static_cast(columnCount); + const bool denseBand = resolved.axis->monotonic && (columnWidth > 0.0f) && + (visibleCount > static_cast(columnCount)); + if (denseBand) + { + // 每列取「等间隔抽样」的若干个采样记录上下包络:抽样密度只影响包络的饱满程度, + // 却把逐点扫描压到「像素列数 × 少量采样」量级。 + const std::size_t columnStride = std::max( + 1, visibleCount / + (static_cast(columnCount) * kDerivedCurveSamplesPerColumn)); + std::vector columnsData; + std::vector segmentBreaks; + columnsData.reserve(static_cast(columnCount) + 1); + bool inSegment = false; + int lastColumn = -1; + for (std::size_t index = begin; index < end; index += columnStride) + { + // 水平镜像 = 数据反序:遍历下标映射到源下标后再取曲线值。 + const std::size_t sourceIndex = SourceIndexFor(*resolved.style, count, index); + if (sourceIndex < curveBegin || (sourceIndex - curveBegin) >= curve.size()) + { + inSegment = false; + continue; + } + const float sampled = curve[sourceIndex - curveBegin]; + if (!std::isfinite(sampled)) + { + inSegment = false; + continue; + } + float y = + ValueToY(ApplyDrawTransform(*resolved.style, static_cast(sampled)), lane); + y = static_cast(ClampDouble(y, limitTop, limitBottom)); + // 时间偏移:该采样画在 `t + offsetTime` 处。 + const float x = TimeToX(time[index] + resolved.style->offsetTime); + int column = static_cast(std::floor((x - m_plotRect.X) / columnWidth)); + column = std::max(0, std::min(columnCount - 1, column)); + if (!inSegment) + { + segmentBreaks.push_back(columnsData.size()); + BandColumn fresh; + fresh.x = x; + fresh.top = y; + fresh.bottom = y; + columnsData.push_back(fresh); + lastColumn = column; + inSegment = true; + continue; + } + if (column == lastColumn) + { + BandColumn& back = columnsData.back(); + back.top = std::min(back.top, y); + back.bottom = std::max(back.bottom, y); + continue; + } + BandColumn next; + next.x = x; + next.top = y; + next.bottom = y; + columnsData.push_back(next); + lastColumn = column; + } + if (columnsData.empty()) + { + return; + } + // 线宽补偿与波形基底同一套(`EmitEnvelopeBand` 做纵向外扩 + 横向膨胀), + // 因此“把线宽拖大”时预览曲线的竖直沿同样会变粗。 + const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f; + const auto buildBand = [&](float halfWidth, std::vector& outBand, + std::vector& outBreaks, bool& outSpiky) + { + outBand.clear(); + outBreaks.clear(); + outSpiky = false; + for (std::size_t s = 0; s < segmentBreaks.size(); ++s) + { + const std::size_t start = segmentBreaks[s]; + const std::size_t stop = + (s + 1 < segmentBreaks.size()) ? segmentBreaks[s + 1] : columnsData.size(); + if (stop < start + 2) + { + continue; + } + std::vector part(columnsData.begin() + static_cast(start), + columnsData.begin() + static_cast(stop)); + EmitEnvelopeBand(part, outBand, outBreaks, outSpiky, lane.rect.Height, halfWidth); + } + }; + const auto fillBand = [&](const std::vector& bandPoints, + const std::vector& bandBreaks, std::uint32_t color) + { + gd::GraphicsPath path; + // 包络带会自交(相邻列的上下包络交叉),必须用缠绕规则,否则交叠处被挖空。 + path.SetFillMode(gd::FillModeWinding); + for (std::size_t k = 0; k < bandBreaks.size(); ++k) + { + const std::size_t start = bandBreaks[k]; + const std::size_t stop = + (k + 1 < bandBreaks.size()) ? bandBreaks[k + 1] : bandPoints.size(); + const std::size_t total = stop - start; + if (total < 4 || (total % 2) != 0) + { + continue; + } + const std::size_t half = total / 2; + path.StartFigure(); + path.AddLines(bandPoints.data() + start, static_cast(half)); + path.AddLines(bandPoints.data() + start + half, static_cast(half)); + path.CloseFigure(); + } + if (path.GetPointCount() > 0) + { + gd::SolidBrush brush(ToGdiColor(color)); + g.FillPath(&brush, &path); + } + }; + std::vector bandPoints; + std::vector bandBreaks; + bool spiky = false; + buildBand(strokeHalf, bandPoints, bandBreaks, spiky); + if (bandBreaks.empty()) + { + return; + } + const int savedSmoothing = g.GetSmoothingMode(); + if (spiky || m_fastFrame) + { + g.SetSmoothingMode(gd::SmoothingModeNone); + } + // 悬停高亮:先用**更粗的同一几何**铺一层半透明光晕(垫在曲线下面),再画曲线本身。 + if (highlight) + { + std::vector haloSpan; + std::vector haloBreaks; + bool haloSpiky = false; + buildBand(strokeHalf + 2.5f * m_scale, haloSpan, haloBreaks, haloSpiky); + fillBand(haloSpan, haloBreaks, WithAlpha(derived, 0x55u)); + } + fillBand(bandPoints, bandBreaks, derived); + g.SetSmoothingMode(static_cast(savedSmoothing)); + return; + } + std::vector points; std::vector breaks; points.reserve(std::min(visibleCount, budget) + 1); @@ -3122,6 +3570,27 @@ namespace jdi pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); + // 悬停高亮(用户 2026-10-30 要求):先沿同一批顶点画一遍**加粗的半透明光晕**, + // 再把曲线本身画粗一点。光晕垫在曲线下面 ⇒ 不遮蔽曲线本身、也不影响它的颜色。 + if (highlight) + { + gd::Pen halo(ToGdiColor(WithAlpha(derived, 0x55u)), + std::max(1.0f, resolved.style->lineWidth) * m_scale + 5.0f * m_scale); + halo.SetLineJoin(gd::LineJoinRound); + halo.SetStartCap(gd::LineCapRound); + halo.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); + } + pen.SetWidth(std::max(1.0f, resolved.style->lineWidth) * m_scale + 1.6f * m_scale); + } for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; @@ -3136,7 +3605,8 @@ namespace jdi } void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, int points, int iterations) const + const LaneVisual& lane, int points, int iterations, + bool highlight) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { @@ -3154,6 +3624,10 @@ namespace jdi // **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。 // 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替, // 因此不会阻塞交互;小信号同步计算。 + // ⚠️ 「均值点数」的下限是 `kMeanFilterPointsMin`(=1,用户 2026-10-30 明确要求不能为 0): + // 即使条目里的值被外部写成了 0/负数,预览也不能按“不平滑”画出来。 + points = std::max(kMeanFilterPointsMin, points); + iterations = std::max(kMeanFilterIterationsMin, iterations); std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, points, iterations, 0, 0, count, spanBegin, true); @@ -3161,11 +3635,12 @@ namespace jdi { return; } - PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); + PaintDerivedCurve(g, resolved, lane, *curve, spanBegin, highlight); } void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, int amountPercent, int iterations) const + const LaneVisual& lane, int amountPercent, int iterations, + bool highlight) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { @@ -3301,33 +3776,95 @@ namespace jdi mix(static_cast(m_laneDragFrom + 2)); mix(static_cast(m_laneDragTarget + 2)); mix(static_cast(m_laneDragMouseY + 4096)); - mix(static_cast(m_hoverLane + 2)); - mix(m_cursorHover ? 1ull : 0ull); - mix(static_cast(m_hoverCursor + 2)); + // ⚠️⚠️ **交互期间冻结全部“随鼠标变化”的项**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”): + // 本键是**全部**面板像素缓存的总开关(`panelGlobalSame`)—— 只要它每帧变,所有面板都会 + // 重画。鼠标在绘图区移动时 `m_hoverSignal`(甚至 `m_hoverLane` / `m_hoverCursor`)会不停 + // 变化,于是“移动鼠标 → 每帧重画全部面板(288 个信号的中心轴面板 22ms)”成了死循环。 + // 这些叠加层都画在**面板之下**、面板本身不透明且贴回时只贴圆角形状之内,因此交互期间 + // 直接复用面板像素是安全的;鼠标停下后键自动恢复,面板重画一次即可。 + const bool interactivePanelKey = InputIsInteractive(); + // 供宿主判断“冻结过后需要补一帧”(冻结期间面板显示的是旧像素,鼠标停下后 + // 不会再有重绘请求 ⇒ 统计数字会永远停在“统计中…”)。 + m_panelKeysFrozen = interactivePanelKey; + if (interactivePanelKey) + { + mix(0x9E3779B9ull); + mix(0x9E3779B9ull); + } + else + { + mix(static_cast(m_hoverLane + 2)); + mix(m_cursorHover ? 1ull : 0ull); + mix(static_cast(m_hoverCursor + 2)); + } mix(static_cast(m_activeCursor)); mix(m_dragMoved ? 1ull : 0ull); mixRect(m_rubberRect); - mixRef(m_hoverSignal); - mixRef(m_hoverLegend); + if (interactivePanelKey) + { + mix(0x9E3779B9ull); + mix(0x9E3779B9ull); + } + else + { + mixRef(m_hoverSignal); + mixRef(m_hoverLegend); + } mixRef(m_dragSignal); mixRef(SelectedRef()); mix(static_cast(m_edgePanelDragLane + 2)); mix(static_cast(m_edgePanelDragKind)); + // ⚠️ 「均值滤波 / 中心轴边沿计数」**条目栏的悬停高亮**必须**始终**参与全局键: + // 它直接改变面板像素(行头叠一层强调色),而且只在鼠标真正进入/离开某一行时才变化。 + // 若跟着上面那些项一起在交互期间冻结,拖动鼠标到条目栏上高亮就不会出现(用户要求 + // “鼠标处于条目栏上方时对应曲线/条目需要高亮提示”)。 + mix(static_cast(m_meanRowHoverLane + 2)); + mixRef(m_meanRowHoverRef); + // 悬停所在的**面板种类**同样改变面板像素:同一条信号可能在多个面板里各有一行, + // 只有“这一行属于本面板”时该面板才叠强调色(否则悬停一个面板会把其它面板的同名行一起点亮)。 + mix(static_cast(m_meanRowHoverKind) + 2); // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 + // ⚠️⚠️ **交互期间必须用固定占位值**:拖动光标时时间每帧都在变,键一变就要重画全部面板。 mix(static_cast(Tab().CursorMode())); - mixDouble(Tab().CursorTime()); - mixDouble(Tab().CursorTimeAlt()); + if (interactivePanelKey) + { + mixDouble(0.0); + mixDouble(0.0); + } + else + { + mixDouble(Tab().CursorTime()); + mixDouble(Tab().CursorTimeAlt()); + } mix(EdgeRangeActive() ? 1ull : 0ull); - mixDouble(EdgeRangeMin()); - mixDouble(EdgeRangeMax()); + if (interactivePanelKey) + { + // 统计区间来自选区时不随鼠标变化;但若它来自光标就会每帧变 —— 一并冻结。 + mixDouble(0.0); + mixDouble(0.0); + } + else + { + mixDouble(EdgeRangeMin()); + mixDouble(EdgeRangeMax()); + } // 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变: // 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。 + // + // ⚠️⚠️ **交互期间必须忽略它**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”): + // 鼠标在绘图区里移动时 `m_hoverSignal` 常常有效(悬停在某条曲线上),这里就会把 + // **鼠标坐标**混进全局键 ⇒ 键每帧都变 ⇒ 全部面板每帧重画。288 个信号的中心轴面板 + // 一块就要 ~23ms(实测:面板开 27.6ms / 面板关 4.3ms)。 + // 而悬停光晕、拖拽虚影都画在**面板之下**,面板本身不透明且贴回时只贴圆角形状之内 + // (见下面的 `CreateRoundRectRgn` 裁剪 + 每帧重画投影),因此交互期间直接复用面板像素 + // 是安全的;鼠标停下后键自动恢复,面板重画一次即可。 const bool mouseDrivenOverlay = - m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || - m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || - HintNeedsAnimation(); + !interactivePanelKey && + (m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || + m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || + HintNeedsAnimation()); mix(mouseDrivenOverlay ? 1ull : 0ull); if (mouseDrivenOverlay) { @@ -3370,6 +3907,10 @@ namespace jdi mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull); mix(lane.edgeValueValid ? 1ull : 0ull); mixDouble(lane.edgeValue); + // 「自由标记」面板内容区的滚动偏移(历史行为:计入全局键)。 + // ⚠️ 「均值滤波」的滚动偏移只进**该面板自己的**槽键(见 `PanelSlotKey`), + // 否则滚动一个面板会让屏幕上所有面板一起重画。 + mixFloat(lane.freeMarkerScroll); } } @@ -3413,6 +3954,12 @@ namespace jdi mixRect(layout.showDelta); mixRect(layout.showEnum); mixRect(layout.content); + mixRect(layout.tableHeader); + for (const EdgePanelLayout::TableColumn& column : layout.tableColumns) + { + mixFloat(column.left); + mixFloat(column.width); + } mixRect(layout.scrollTrack); mixRect(layout.scrollThumb); mixRect(layout.opacityTrack); @@ -3463,6 +4010,8 @@ namespace jdi mixRect(group.tableHeader); mixRect(group.triangle); mixRect(group.close); + // 「均值滤波」的组头右端是复选框(「自由标记」为空矩形,不影响它的键)。 + mixRect(group.check); mix(group.collapsed ? 1ull : 0ull); mix(group.pinned ? 1ull : 0ull); mix(static_cast(group.ref.groupIndex + 2)); @@ -3506,6 +4055,11 @@ namespace jdi const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref); mix(static_cast(meanItem != nullptr ? meanItem->points + 2 : 0)); mix(static_cast(meanItem != nullptr ? meanItem->iterations + 2 : 0)); + // 「均值滤波」改为滑块后,**滑块上限**决定圆钮位置、**收起状态**决定是否显示参数行 + // ⇒ 两者都直接改变面板像素,必须进键(否则拖完滑块仍会贴回旧像素)。 + mix(static_cast(meanItem != nullptr ? meanItem->maxPoints + 2 : 0)); + mix(static_cast(meanItem != nullptr ? meanItem->maxIterations + 2 : 0)); + mix((meanItem != nullptr && meanItem->collapsed) ? 1ull : 0ull); const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); mix(static_cast(centerItem != nullptr ? centerItem->points + 2 : 0)); mix(static_cast(centerItem != nullptr ? centerItem->iterations + 2 : 0)); @@ -3515,63 +4069,29 @@ namespace jdi mix(static_cast(peakItem != nullptr ? peakItem->amountPercent + 2 : 0)); mix(static_cast(peakItem != nullptr ? peakItem->iterations + 2 : 0)); mix(static_cast(peakItem != nullptr ? peakItem->maxIterations + 2 : 0)); + // 「弱峰调整」的条目栏三角(展开 / 收起)直接改变面板像素 ⇒ 必须进键。 + mix((peakItem != nullptr && peakItem->collapsed) ? 1ull : 0ull); } - // ⚠️⚠️ **边沿统计的数字本身就是面板内容,必须进缓存键**(用户报告的缺陷根因): - // 面板像素图层按本键缓存,而界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果 —— - // 若这些数字不进键,则「统计推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 - // ⇒ 用户不动鼠标时统计永远停在“统计中…”或某个中间值,**只有移动鼠标**(改变别的键) - // 才会显示出早已算完的正确结果。 - // ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒ - // 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。 - if (kind == EdgePanelKind::COUNT) + // ⚠️⚠️ **边沿统计的数字本身就是面板内容,数字变化必须能让面板重绘**: + // 界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果,若数字变化不改变键, + // 面板就会一直贴旧像素 ⇒ 用户看到"永远统计中…"(只有移动鼠标/悬停面板才更新)。 + // + // 但**不能**在这里为每个信号调一次 `EdgeCountsFor` / `EdgeCenterCountsFor`: + // * 288 个信号时单帧 288 次查询(每次还要线性查找累积状态); + // * 中间数字每帧都在变 ⇒ 键每帧都变 ⇒ 面板像素缓存**永远不命中**, + // 于是每帧都要把整块面板(上百行 × 十来个 GDI+ 原语)重画一遍 ⇒ 实测 30ms/帧。 + // + // ⇒ 改为混合一个**统计数据版本号**(O(1)、零查询): + // `EdgeScanFor` 每推进/完成一次就自增 `m_edgeScanRevision`;`Paint` 开头把它 + // 转成 `m_panelStatsVersion`(非交互帧立刻同步;交互帧节流,避免每帧重画)。 + // 于是"统计推进 ⇒ 版本号变化 ⇒ 面板重绘",而"没推进 ⇒ 键稳定 ⇒ 贴回缓存"。 + if (kind == EdgePanelKind::COUNT || kind == EdgePanelKind::CENTER) { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; // 与绘制时的可见性判定保持一致 - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - mix(static_cast(rising)); - mix(static_cast(falling)); - mix(complete ? 1ull : 0ull); - } - } - else if (kind == EdgePanelKind::CENTER) - { - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - if (rowIndex >= layout.rowStats.size() || layout.rowStats[rowIndex].Width <= 0.0f) - { - continue; // 与绘制时的条件一致:只有这一排才显示统计数字 - } - const SignalRef& ref = layout.rowRefs[rowIndex]; - const ResolvedSignal resolved = ResolveSignal(ref); - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - item == nullptr) - { - continue; - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling, complete); - mix(static_cast(rising)); - mix(static_cast(falling)); - mix(complete ? 1ull : 0ull); - } + mix(m_panelStatsVersion); } + // 弱峰调整的滑块行矩形(每信号两行)。 for (const gd::RectF& rect : layout.rowLevelTracks) { @@ -3594,6 +4114,17 @@ namespace jdi { mixRect(rect); } + // 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键 + //(滚动改变的是面板内部像素,位置与尺寸不变)。 + for (const gd::RectF& rect : layout.rowHeaders) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowTriangles) + { + mixRect(rect); + } + mixFloat(layout.scrollOffset); // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 const float margin = 6.0f * m_scale; @@ -3957,12 +4488,23 @@ namespace jdi //(与"可拖拽"的光晕区分:这里只是单点圆环,不描整条曲线)。 const float radius = 3.4f * m_scale; gd::SolidBrush dot(ToGdiColor(signalColor)); - g.FillEllipse(&dot, x - radius, probe.y - radius, radius * 2.0f, radius * 2.0f); + // 取值圆点与"已吸附"圆环都画在**数据点本身**的横坐标上(`pointX`), + // 而纵向光标画在鼠标位置(`x`)。稀疏信号下两者会分开 —— 这是刻意的: + // 光标跟随鼠标、提示落在真实采样点上(用户 2026-10-30 要求)。 + float pointX = probe.pointX; + if (!(pointX > 0.0f)) + { + pointX = x; // 兼容:未填 `pointX` 时退回光标位置 + } + g.FillEllipse(&dot, pointX - radius, probe.y - radius, radius * 2.0f, + radius * 2.0f); if (probe.snapped) { gd::Pen ring(ToGdiColor(lineColor), 1.2f * m_scale); - g.DrawEllipse(&ring, x - radius - 3.0f * m_scale, probe.y - radius - 3.0f * m_scale, - (radius + 3.0f * m_scale) * 2.0f, (radius + 3.0f * m_scale) * 2.0f); + g.DrawEllipse(&ring, pointX - radius - 3.0f * m_scale, + probe.y - radius - 3.0f * m_scale, + (radius + 3.0f * m_scale) * 2.0f, + (radius + 3.0f * m_scale) * 2.0f); } } } @@ -3979,11 +4521,23 @@ namespace jdi { return; } + // ⚠️ **加载中跳过全部统计面板绘制**:加载线程主循环不断 `UpdateWindow` 触发整帧绘制, + // 而这里含统计推进与上百行 GDI+ 原语 ⇒ 加载进度被拖慢(用户报告“统计没算完就无法 + // 加载文件”)。加载遮罩会盖住整个界面,面板本来也看不见。 + if (m_loadingBusy) + { + m_panelSlots.clear(); + m_panelValid = false; + return; + } std::vector& tabLanes = m_tab->Lanes(); const std::size_t count = std::min(lanes.size(), tabLanes.size()); const std::uint32_t color = CursorColor(); const UiInk ink = UiContrastInk(color); + // 进入本函数时的文本层号 = 宿主给本组件分配的面板层起始(`baseLayer + 1`)。 + // 每个面板在循环里各用 `panelTextLayer + 面板序数`,函数结束前把层号推到**最上面板之上**。 + const int panelTextLayer = UiGetTextLayer(); // ---- 横向光标(线与两端把手 + 左侧幅值读数)---- for (std::size_t index = 0; index < count; ++index) @@ -4087,21 +4641,25 @@ namespace jdi // ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次, // 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。 const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, - std::size_t falling, bool complete) + std::size_t falling, bool complete, const gd::RectF& clip) { + // ⚠️ **必须用 `UiTextClipped`**:GDI 文本不受 GDI+ `SetClip` 约束, + // 内容区滚动时统计文字会画到面板标题栏 / 面板之外 + //(用户 2026-11-02 报告“滚动时绘制超出了浮动面板”的直接原因)。 if (EdgeCountsPending(rising, falling, complete)) { - UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT); + UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, + TextAlign::LEFT, clip); 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); + UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, + gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, + TextAlign::LEFT, clip); + UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, + gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clip); }; std::vector nextSlots; @@ -4112,6 +4670,9 @@ namespace jdi (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); const bool panelGlobalSame = panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; + // 交互帧(拖动 / 鼠标刚移动)的标志:缓存命中的贴图分支据此跳过昂贵的投影重画 + //(见下面 `UiFillRoundRect` 的说明)。 + const bool interactiveFrame = InputIsInteractive(); // ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致): // `m_panelOrder` 队尾 = 最上层 = 最后绘制。 SyncPanelOrder(); @@ -4173,12 +4734,19 @@ namespace jdi const PanelRef& panelRef = m_panelOrder[panelOrdinal]; const EdgePanelKind kind = static_cast(panelRef.kind); const std::size_t index = static_cast(panelRef.laneIndex); + // 文本几何层号:**每个面板独占一层**,与 `BuildBoxes` 完全同源 + //(`baseLayer + 1 + 面板序数`,面板序数按 `m_panelOrder` 底 → 顶)。 + // 少了这一步,下层面板的文字框会记在比上层面板更低的层里被反复挖洞/穿透。 + UiSetTextLayer(panelTextLayer + static_cast(panelOrdinal)); const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。 - const bool itemized = (centerAxis || meanFilter || weakPeak); + // ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表(条目栏 + 展开后的参数行); + // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。 + // 它们都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上。 + const bool listStyle = (meanFilter || centerAxis || weakPeak); + const bool countTable = (kind == EdgePanelKind::COUNT); const bool hasButton = (meanFilter || weakPeak); { if (index >= count) @@ -4226,7 +4794,6 @@ namespace jdi } std::uint64_t slotKey = 0; - const std::size_t slotIndex = nextSlots.size(); if (panelCacheReady) { slotKey = PanelSlotKey(static_cast(index), kind, layout); @@ -4244,25 +4811,78 @@ namespace jdi } if (hit != nullptr && hit->key != 0 && hit->key == slotKey) { + // ⚠️⚠️ **只贴「面板圆角形状之内」的像素**(用户 2026-10-30 报告: + // 中键移动视界时面板周围带着一圈**旧视界的波形像素**)。 + // 原因是缓存位图按 `panel ± cacheMargin` 取块,块的边缘那几像素是 + // **上一帧的场景**(波形/网格线);`PanelGlobalKey` 刻意不含视界 + // (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。 + // 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存); + // ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。 + // ⚠️⚠️ **投影不能每帧重画**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”): + // 它是一块面积与面板同大的**半透明抗锯齿圆角矩形**(中心轴面板高近 950px、 + // 宽 400px),GDI+ 软件渲染这一块实测要 **20ms 以上** —— 而缓存命中的 + // 贴图本身只要零点几毫秒。交互期间(拖动 / 鼠标刚移动)直接跳过投影: + // 面板圆角之外保留上一帧的像素(此时那块区域本来也是静止的场景), + // 鼠标停下后键恢复、面板重画一次即补上投影。 + if (!m_fastFrame && !interactiveFrame) + { + UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, + panel.Y + 2.0f * m_scale, panel.Width, + panel.Height), + 5.0f * m_scale, 0x14000000u); + } HDC target = g.GetHDC(); if (target != nullptr) { - BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0, - m_panelDc, hit->x0, hit->y0, SRCCOPY); + const int bx0 = static_cast(std::floor(panel.X)) - 1; + const int by0 = static_cast(std::floor(panel.Y)) - 1; + const int bx1 = static_cast(std::ceil(panel.X + panel.Width)) + 1; + const int by1 = static_cast(std::ceil(panel.Y + panel.Height)) + 1; + HRGN rounded = CreateRoundRectRgn( + bx0, by0, bx1 + 1, by1 + 1, + static_cast(std::lround(10.0f * m_scale)), + static_cast(std::lround(10.0f * m_scale))); + const int savedDc = SaveDC(target); + if (rounded != nullptr) + { + ExtSelectClipRgn(target, rounded, RGN_AND); + } + BitBlt(target, bx0, by0, bx1 - bx0, by1 - by0, m_panelDc, bx0, by0, + SRCCOPY); + if (rounded != nullptr) + { + DeleteObject(rounded); + } + RestoreDC(target, savedDc); g.ReleaseHDC(target); } + // 贴图不会真的画字 ⇒ 必须把上一帧渲染时存下的文本框**回放**给 + // 外层收集器,否则调试框这一帧凭空消失(= 面板上边界闪烁)。 + if (UiTextRectSink* const outerSink = UiGetTextRectSink()) + { + for (const gd::RectF& textRect : hit->textRects) + { + outerSink->OnTextRect(textRect); + } + } nextSlots.push_back(*hit); continue; } } } + const std::size_t storedIndex = nextSlots.size(); nextSlots.push_back(PanelSlot()); dirtyRects.push_back(cachedRect); + // 本面板渲染期间把文本框收集到本地容器(同时**转发**给外层收集器), + // 渲染完再存进 slot,供命中缓存时回放。详见 `PanelSlot::textRects`。 + PanelTextCollector panelTextBoxes(UiGetTextRectSink()); const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; + // 以下整段(面板底色 → 标题栏 → 各控件 → 行内容)的画字都要走本地收集器。 + UiSetTextRectSink(&panelTextBoxes); + // 面板主体 + 标题栏。 // 投影响用两张大面积的半透明圆角矩形模拟,代价不低;交互期间(快速帧)省略它, // 让拖动参数滑块时面板重绘不再被投影拖累(松手后的全质量帧会补回投影)。 @@ -4356,226 +4976,487 @@ namespace jdi // 折起的面板只保留标题栏:内容区(条目 / 分组栏 / 按钮 / 滚动条)全部省略。 if (!layout.collapsed) { - // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板都采用「逐条目」排布: - // 第一排 = 名称 + 复选框,第二排 = 两个参数输入框(仅勾选的条目, - // 弱峰调整没有参数输入框),中心轴再多第三排 = 对应的统计结果。 - if (itemized) + + // 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)**固定不滚动**, + // 下面每条可见信号一行,行底色 = 该信号的配色,三列与表头严格对齐。 + // 内容超高时右侧出现滚动条(只占内容区,不覆盖表头)。 + if (countTable) { + // ---- 表头 ---- + { + UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f); + for (int column = 0; column < 3; ++column) + { + const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const float textLeft = + (column == 0) ? (cell.left + 4.0f * m_scale) : cell.left; + UiTextClipped(g, EdgeCountTableHeader(column), m_fonts->uiSmall, + gd::RectF(textLeft, layout.tableHeader.Y, cell.width, + layout.tableHeader.Height), + m_theme->textMuted, + (column == 0) ? TextAlign::LEFT : TextAlign::CENTER, + layout.tableHeader); + } + } + + const int tableSave = g.Save(); + // 行内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowNameRects.size()) + { + break; + } const SignalRef& ref = layout.rowRefs[rowIndex]; const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } + const gd::RectF& row = layout.rowHeaders[rowIndex]; + if (row.Y + row.Height <= layout.content.Y || + row.Y >= layout.content.Y + layout.content.Height) + { + continue; + } const std::uint32_t signalColor = StyleColor(*resolved.style); - const gd::RectF& checkRect = layout.rowChecks[rowIndex]; - const bool checked = meanFilter ? tabLane.MeanFilterEnabled(ref) - : (weakPeak ? tabLane.WeakPeakEnabled(ref) - : tabLane.EdgeCenterEnabled(ref)); + const UiInk ink = UiContrastInk(signalColor); + // 行底色 = 该信号的配色(用户 2026-11-02 要求)。 + UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - // 名称:第一排左侧,避开右侧复选框(超长时省略)。 - const float nameWidth = - std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale), - kEdgePanelNameMaxWidth * m_scale); - UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, - checkRect.Height + 6.0f * m_scale), - signalColor, TextAlign::LEFT); - UiDrawCheckBox(g, checkRect, checked ? 1 : 0, 3.0f * m_scale); + // 信号名(第一列):超出列宽时省略。 + const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, + std::max(8.0f, nameRect.Width)), + m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT, + layout.content); + // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, + tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + } + for (int column = 1; column < 3; ++column) + { + const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column]; + if (cell.width <= 0.0f) + { + continue; + } + const std::wstring text = + EdgeCountCellText((column == 1) ? rising : falling, complete); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text, + TextAlign::CENTER, layout.content); + } + } + + g.Restore(tableSave); + + // 内容滚动条(只在内容超高时出现;表头不参与滚动)。 + if (layout.scrollTrack.Width > 0.0f) + { + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(m_theme->border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, + m_theme->borderStrong); + } + } + + // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 + // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 + if (listStyle) + { + const int meanSave = g.Save(); + // 内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + + // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 + const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, + const UiInk& ink, + std::uint32_t background) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float radius = 2.5f * m_scale; if (!checked) + { + UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); + return; + } + UiFillRoundRect(g, rect, radius, ink.text); + // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 + const float left = rect.X + rect.Width * 0.22f; + const float middleX = rect.X + rect.Width * 0.44f; + const float right = rect.X + rect.Width * 0.78f; + const float highY = rect.Y + rect.Height * 0.30f; + const float lowY = rect.Y + rect.Height * 0.72f; + UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, + 1.6f * m_scale); + UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); + }; + + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (rowIndex >= layout.rowHeaders.size() || + rowIndex >= layout.rowChecks.size()) + { + break; + } + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& header = layout.rowHeaders[rowIndex]; + // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: + // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 + //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 + float rowTop = header.Y; + float rowBottom = header.Y + header.Height; + const auto extendRow = [&rowBottom](const gd::RectF& rect) { + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + rowBottom = std::max(rowBottom, rect.Y + rect.Height); + } + }; + if (rowIndex < layout.rowLevelTracks.size()) + { + extendRow(layout.rowLevelTracks[rowIndex]); + } + if (rowIndex < layout.rowLevelValues.size()) + { + extendRow(layout.rowLevelValues[rowIndex]); + } + if (rowIndex < layout.rowLevelResets.size()) + { + extendRow(layout.rowLevelResets[rowIndex]); + } + if (rowIndex < layout.rowIterTracks.size()) + { + extendRow(layout.rowIterTracks[rowIndex]); + } + if (rowIndex < layout.rowIterValues.size()) + { + extendRow(layout.rowIterValues[rowIndex]); + } + if (rowIndex < layout.rowIterResets.size()) + { + extendRow(layout.rowIterResets[rowIndex]); + } + if (rowIndex < layout.rowStats.size()) + { + extendRow(layout.rowStats[rowIndex]); + } + if (rowBottom <= layout.content.Y || + rowTop >= layout.content.Y + layout.content.Height) + { + continue; // 整行(含全部参数行)完全滚出可视范围 + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 + //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const Lane::MeanFilterItem* item = + weakPeak ? nullptr + : (centerAxis ? tabLane.FindEdgeCenterItem(ref) + : tabLane.FindMeanFilterItem(ref)); + const bool checked = weakPeak ? (peakItem != nullptr) : (item != nullptr); + const bool rowCollapsed = + weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed); + + // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); + // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 + UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 + //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 + // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, + // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 + if (m_meanRowHoverLane == static_cast(index) && + m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) + { + UiFillRoundRect(g, header, 3.0f * m_scale, + WithAlpha(m_theme->accent, 0x2E)); + } + + // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 + const gd::RectF& triangle = layout.rowTriangles[rowIndex]; + if (triangle.Width > 0.0f && checked) + { + UiDrawTriangleIcon(g, triangle, ink.text, + rowCollapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 名称:从三角右侧到复选框左侧。 + const gd::RectF& check = layout.rowChecks[rowIndex]; + const float nameLeft = (triangle.Width > 0.0f) + ? (triangle.X + triangle.Width + 6.0f * m_scale) + : (header.X + 8.0f * m_scale); + const float nameWidth = + std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, + TextAlign::LEFT, layout.content); + paintMeanCheck(check, checked, ink, signalColor); + + if (!checked || rowCollapsed) { continue; } - // MEAN / CENTER 的平滑参数条目(弱峰调整用自己的 `WeakPeakItem`)。 - const Lane::MeanFilterItem* item = - (meanFilter || centerAxis) - ? (meanFilter ? tabLane.FindMeanFilterItem(ref) - : tabLane.FindEdgeCenterItem(ref)) - : nullptr; - - // 「弱峰调整」:每个被勾选的信号各有两行**滑块** - //(调整幅度 -200% – +200% / 迭代次数 1 … 该信号自定义上限)。 - // 迭代次数行的右侧数值可点击 → 整行变为输入框以修改滑块上限。 - if (weakPeak) - { - const Lane::WeakPeakItem* peakItem = tabLane.FindWeakPeakItem(ref); - if (peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f) - { - // 两行滑块的「标签」始终绘制(编辑上限时也要能看出在改哪一项); - // 「轨道 + 数值」在编辑上限时改由 `PaintEditField` 画成输入框。 - const auto paintSliderLabel = [&](const gd::RectF& track, - const wchar_t* label) - { - const float labelColumn = - WeakPeakLabelColumnWidth(m_fonts->uiSmall); - const float labelLeft = - track.X - 6.0f * m_scale - labelColumn; - UiText(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - labelColumn, track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT); - }; - const auto paintSliderBody = [&](const gd::RectF& track, double ratio, - const std::wstring& valueText, - bool active) - { - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiText(g, valueText, m_fonts->monoSmall, - gd::RectF(track.X + track.Width + 6.0f * m_scale, - track.Y - 8.0f * m_scale, - WeakPeakValueColumnWidth(m_fonts->monoSmall), - 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(m_mouseX), - static_cast(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, kWeakPeakIterationsSliderLabel); - - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const double levelRatio = - (static_cast(std::max(kWeakPeakAmountMin, - std::min(kWeakPeakAmountMax, - peakItem->amountPercent))) / - (100.0 * kWeakPeakSliderScale) + - 1.0) * - 0.5; - paintSliderBody(levelTrack, levelRatio, - (peakItem->amountPercent > 0 ? L"+" : L"") + - std::to_wstring(peakItem->amountPercent) + L"%", - levelActive); - paintSliderReset(layout.rowLevelResets[rowIndex]); - - // 迭代次数滑块:量程 = 1 … 该信号自己的上限。 - const int peakMax = std::max(kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, - peakItem->maxIterations)); - const int peakIter = std::max( - kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); - const double iterRatio = - (peakMax > kWeakPeakIterationsMin) - ? (static_cast(peakIter - kWeakPeakIterationsMin) / - static_cast(peakMax - kWeakPeakIterationsMin)) - : 0.0; - const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool editingMax = - (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - if (!editingMax) - { - paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter), - iterActive); - paintSliderReset(layout.rowIterResets[rowIndex]); - } - } - } - - // MEAN / CENTER:第二排的两个参数输入框。 - if (!weakPeak) - { - const int shownPoints = (item != nullptr) ? item->points : 5; - const int shownOrder = (item != nullptr) ? item->iterations : 1; - const PlotEditTarget pointsTarget = - meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS; - const PlotEditTarget orderTarget = - meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS - : PlotEditTarget::EDGE_CENTER_ITERATIONS; - const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, - int value, PlotEditTarget target) - { - if (rect.Width <= 0.0f) - { - return; - } - const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); - UiText(g, label, m_fonts->uiSmall, - gd::RectF(rect.X, rect.Y, labelWidth, rect.Height), m_theme->textMuted, - TextAlign::LEFT); - - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(value)) + - 18.0f * m_scale); - const float fieldLeft = rect.X + rect.Width - fieldWidth; - const float fieldHeight = rect.Height - 4.0f * m_scale; - const gd::RectF fieldRect(fieldLeft, rect.Y + 2.0f * m_scale, fieldWidth, - fieldHeight); - - const bool editingThis = (m_editTarget == target) && - (m_editLane == static_cast(index)) && - (m_editMeanRef == ref); - if (editingThis) - { - UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); - } - else - { - UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); - UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); - UiText(g, std::to_wstring(value), m_fonts->monoSmall, fieldRect, - m_theme->textStrong, TextAlign::CENTER); - } - }; - paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); - paintField(layout.rowIterations[rowIndex], kWeakPeakIterationsLabel, shownOrder, - orderTarget); - } - - // 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。 + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 if (centerAxis && rowIndex < layout.rowStats.size() && layout.rowStats[rowIndex].Width > 0.0f) { - const gd::RectF& statsRect = layout.rowStats[rowIndex]; - const int statsPoints = (item != nullptr) ? item->points : 5; - const int statsOrder = (item != nullptr) ? item->iterations : 1; std::size_t rising = 0; std::size_t falling = 0; - bool statsComplete = false; - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, - statsPoints, statsOrder, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling, - statsComplete); - // 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。 - paintCounts(statsRect, rising, falling, statsComplete); + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, + item->points, item->iterations, EdgeRangeActive(), + EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + } + paintCounts(layout.rowStats[rowIndex], rising, falling, complete, + layout.content); } + + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 + const auto paintMeanSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, int current, int minimum, + int maximum, bool active) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 + // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 + //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + const double ratio = + (maximum > minimum) + ? (static_cast(current - minimum) / + static_cast(maximum - minimum)) + : 0.0; + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; + + // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, + // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** + // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + if (weakPeak) + { + // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 + const int peakAmount = std::max( + kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); + const int peakMax = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); + const int peakIter = + std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); + const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == ref); + const bool editingMax = + (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 + const auto paintPeakSlider = [&](const gd::RectF& track, + const gd::RectF& valueRect, + const gd::RectF& resetRect, + const wchar_t* label, double ratio, + const std::wstring& valueText, bool active, + bool hideBody) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (hideBody) + { + return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 + } + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, valueText, m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, + valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, + static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect( + g, gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, + hot ? m_theme->accent : m_theme->textMuted, + m_scale); + }; + const double levelRatio = + (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; + const double iterRatio = + (peakMax > kWeakPeakIterationsMin) + ? (static_cast(peakIter - kWeakPeakIterationsMin) / + static_cast(peakMax - kWeakPeakIterationsMin)) + : 0.0; + if (rowIndex < layout.rowLevelTracks.size() && + layout.rowLevelTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kWeakPeakLevelLabel, levelRatio, + (peakAmount > 0 ? L"+" : L"") + std::to_wstring(peakAmount) + L"%", + levelActive, false); + } + if (rowIndex < layout.rowIterTracks.size() && + layout.rowIterTracks[rowIndex].Width > 0.0f) + { + paintPeakSlider( + layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kWeakPeakIterationsSliderLabel, iterRatio, + std::to_wstring(peakIter), iterActive, editingMax); + } + } + else + { + // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 + const int pointsFloor = kMeanFilterPointsMin; + const int pointsMax = std::max(pointsFloor, + std::min(kMeanFilterPointsMaxLimit, + item->maxPoints)); + const int points = + std::max(pointsFloor, std::min(pointsMax, item->points)); + const int iterationsMax = std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, + item->maxIterations)); + const int iterations = std::max( + kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == ref) && + (m_meanDragKind == kind); + const bool iterationsActive = + (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && + (m_meanDragKind == kind); + if (rowIndex < layout.rowLevelTracks.size()) + { + paintMeanSlider(layout.rowLevelTracks[rowIndex], + rowIndex < layout.rowLevelValues.size() + ? layout.rowLevelValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowLevelResets.size() + ? layout.rowLevelResets[rowIndex] + : gd::RectF(), + kMeanFilterPointsSliderLabel, points, + pointsFloor, pointsMax, pointsActive); + } + if (rowIndex < layout.rowIterTracks.size()) + { + paintMeanSlider(layout.rowIterTracks[rowIndex], + rowIndex < layout.rowIterValues.size() + ? layout.rowIterValues[rowIndex] + : gd::RectF(), + rowIndex < layout.rowIterResets.size() + ? layout.rowIterResets[rowIndex] + : gd::RectF(), + kMeanFilterIterationsSliderLabel, iterations, + kMeanFilterIterationsMin, iterationsMax, iterationsActive); + } + } + + } + + g.Restore(meanSave); + + // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- + // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 + // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 + // "全部启用后没有出现滚动条"的根因)。 + if (layout.scrollTrack.Width > 0.0f) + { + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(m_theme->border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, + m_theme->borderStrong); } } - // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 行尾各带一个 ✕。内容区有滚动偏移时按内容区裁剪。 @@ -4915,38 +5796,6 @@ namespace jdi } } - float rowY = panel.Y + titleHeight + pad; - for (const SignalRef& ref : tabLane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) - { - continue; - } - if (itemized || freeMarker) - { - // 逐条目绘制已在上面完成(自由标记同样是逐条目排布)。 - continue; - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const float textWidth = panel.Width - pad * 2.0f; - // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 - UiText(g, - UiEllipsize(resolved.data->name, 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; - bool complete = true; - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, tabLane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, - complete); - paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising, - falling, complete); - rowY += rowHeight * 2.0f; - } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮); // 「自由标记」面板的底部固定区有三行: @@ -4997,9 +5846,13 @@ namespace jdi } } // if (!layout.collapsed) - // 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, + // 面板画完了:恢复外层收集器,并把本面板的文本框存进 slot + //(命中缓存时靠它回放,见 `PanelSlot::textRects`)。 + UiSetTextRectSink(panelTextBoxes.Outer()); + + // 把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, // 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。 - PanelSlot& stored = nextSlots[slotIndex]; + PanelSlot& stored = nextSlots[storedIndex]; stored.laneIndex = static_cast(index); stored.kind = static_cast(kind); stored.key = 0; @@ -5026,6 +5879,7 @@ namespace jdi stored.y0 = y0; stored.x1 = x1; stored.y1 = y1; + stored.textRects = panelTextBoxes.Rects(); } } } @@ -5036,6 +5890,10 @@ namespace jdi m_panelSlots = std::move(nextSlots); m_panelGlobalKey = panelGlobalKey; m_panelValid = panelCacheReady; + + // 面板之后绘制的叠加层(信号拖拽虚影 / 提示 / 输入框 / 滚动条 / 回到最上方) + // 都在**所有面板之上** ⇒ 文本层号推到最上面板的上一层(与 `BuildBoxes` 同源)。 + UiSetTextLayer(panelTextLayer + static_cast(m_panelOrder.size())); } void PlotView::PaintEditField(gd::Graphics& g) @@ -5051,10 +5909,21 @@ namespace jdi // 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。 // 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。 - if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty()) + // 「均值滤波」两行滑块的上限输入框共用同一套提示文案。 + if ((m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) && + m_edit.Text().empty()) { - const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax - : kWeakPeakIterationsDefaultMax; + const bool peakTarget = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX); + const int currentMax = + peakTarget + ? ((m_peakEditCurrentMax > 0) ? m_peakEditCurrentMax : kWeakPeakIterationsDefaultMax) + : ((m_meanEditCurrentMax > 0) + ? m_meanEditCurrentMax + : ((m_editTarget == PlotEditTarget::MEAN_POINTS_MAX) + ? kMeanFilterPointsDefaultMax + : kMeanFilterIterationsDefaultMax)); 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, @@ -5362,5 +6231,4 @@ namespace jdi UiDrawLine(g, cx, cy - depth, cx + halfWidth, cy - depth * 0.15f, ink, stroke); } -} // namespace jdi - +} // namespace jdi \ No newline at end of file diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index dec222f..6cfb37f 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -28,8 +28,13 @@ namespace jdi const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 - const bool itemized = (centerAxis || meanFilter || weakPeak); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表 + //(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行); + // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。 + // ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出 + // 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。 + const bool listStyle = (centerAxis || meanFilter || weakPeak); + const bool countTable = (kind == EdgePanelKind::COUNT); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // + 光标区间尾缀)。 @@ -63,21 +68,41 @@ namespace jdi std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), kEdgePanelNameMaxWidth * m_scale); nameColumnWidth = std::max(nameColumnWidth, nameWidth); - if (itemized) + if (listStyle) { - // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + - kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); + // 条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)与参数行 + //(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 + const float groupWidth = kEdgePanelPad * 2.0f * m_scale + + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + + nameWidth + 6.0f * m_scale + + kEdgePanelMeanCheckSize * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale; + width = std::max(width, groupWidth); + } + if (!countTable && !centerAxis) + { + continue; // 只有「边沿计数」表格 / 「中心轴」统计行需要量计数列宽 } std::size_t rising = 0; std::size_t falling = 0; bool complete = true; - if (meanFilter || weakPeak) + if (countTable) { - continue; + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + statRisingWidth = std::max( + statRisingWidth, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)), + pendingWidth)); + statFallingWidth = + std::max(statFallingWidth, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete))); } - if (centerAxis) + else { + // 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。 const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); if (item == nullptr) { @@ -86,45 +111,57 @@ namespace jdi EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + 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))); } - else - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 - // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 - 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) + if (countTable) { - // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, - // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 + // 「边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数): + // 宽度 = 名称列 + 两列计数(列宽取「表头文字」「实际数字」的较大者)+ 滚动条留位。 + const float headerRising = + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)); + const float headerFalling = + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2)); + const float risingColumn = std::max(headerRising, statRisingWidth) + 10.0f * m_scale; + const float fallingColumn = std::max(headerFalling, statFallingWidth) + 10.0f * m_scale; + const float gap = kEdgePanelStatColumnGap * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + nameColumnWidth + gap + risingColumn + gap + fallingColumn + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + } + else if (centerAxis && (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)); + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + countsWidth); } - if (centerAxis || meanFilter) + if (listStyle) { - // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) - const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); - const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); - const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; - const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), + // 右侧再给滚动条留位(上百个信号时内容必然超高)。 + const float labelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + const float weakPeakLabelColumn = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)); + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + const float sliderRowWidth = std::max(labelColumn, weakPeakLabelColumn) + 6.0f * m_scale + + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + valueColumn + + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } if (weakPeak) @@ -139,12 +176,19 @@ namespace jdi if (meanFilter || weakPeak) { - // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 + // 底部按钮行 = 「构建为新信号」(仅均值滤波 / 弱峰调整有),按按钮文字实测宽度确定下限。 const float buttonWidth = UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); } + if (countTable) + { + // 「边沿计数」表格:右侧要给内容滚动条留位(上百个信号时内容必然超高)。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + } + if (kind == EdgePanelKind::MARK) { // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 @@ -232,8 +276,10 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 - const bool itemized = (centerAxis || meanFilter || weakPeak); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」共用同一套**可滚动分组列表**排布 + //(信号色叠底条目栏 + 展开后的参数行);「边沿计数」自 2026-11-02 起走**表格**分支。 + const bool listStyle = (meanFilter || centerAxis || weakPeak); + const bool countTable = (kind == EdgePanelKind::COUNT); const bool hasButton = (meanFilter || weakPeak); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak @@ -275,25 +321,44 @@ namespace jdi const float width = EdgePanelWidth(laneIndex, kind); const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; - // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, - // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 + // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布, + // 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。 const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; - const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + // 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。 + const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f; + const float bottomPad = pad; - // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) - // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): - // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); - // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 - const bool itemHasParams = (centerAxis || meanFilter || weakPeak); const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; - float meanRowHeight = 0.0f; - float itemsHeight = 0.0f; + float meanContentHeight = 0.0f; float height = 0.0f; - if (itemized) + float tableHeaderHeight = 0.0f; + if (countTable) { - meanRowHeight = kEdgePanelMeanRowHeight * m_scale; + // 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)固定不滚动, + // 下面每条可见信号占一行(行底色 = 信号配色),内容超高时滚动。 + const float rowH = kEdgePanelMeanRowHeight * m_scale; + const float content = static_cast(rowCount) * rowH; + meanContentHeight = content; + tableHeaderHeight = rowH; + const float budget = + m_plotRect.Height - (titleHeight + pad + tableHeaderHeight + bottomPad); + const float maxContent = + std::max(3.0f * rowH, std::min(kFreeMarkerContentMaxHeight * m_scale, + std::max(3.0f * rowH, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + tableHeaderHeight + view + bottomPad; + } + else if (listStyle) + { + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** + //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 + // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; + // 「均值滤波」与「弱峰调整」= 两行滑块。 + // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 + const float groupRow = kEdgePanelMeanRowHeight * m_scale; + const float sliderRow = weakPeakSliderRow; + float content = 0.0f; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -301,33 +366,50 @@ namespace jdi { continue; } - itemsHeight += meanRowHeight; - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (checked && itemHasParams) + content += groupRow; + bool expanded = false; + if (weakPeak) { - if (weakPeak) + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + expanded = (item != nullptr && !item->collapsed); + } + else if (centerAxis) + { + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + expanded = (item != nullptr && !item->collapsed); + } + else + { + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + expanded = (item != nullptr && !item->collapsed); + } + if (expanded) + { + content += sliderRow * 2.0f; + if (centerAxis) { - itemsHeight += weakPeakSliderRow * 2.0f; - } - else - { - itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - itemsHeight += kEdgePanelMeanRowHeight * m_scale; - } + content += groupRow; // 统计行(上升沿 / 下降沿) } } } - // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, - // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 - height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); + meanContentHeight = content; + const float budget = + m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight); + const float maxContent = + std::max(3.0f * groupRow, + std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + view + bottomPad + footerHeight; + } + else if (freeMarker) + { + // 「自由标记」的高度在下面的 `if (freeMarker)` 分支里计算(含底部固定区)。 + height = titleHeight; } else { - height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; + // 不应到达:所有面板种类都已覆盖。保留一个保守的高度以免布局全零。 + height = titleHeight + pad + bottomPad; } // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), @@ -501,37 +583,78 @@ namespace jdi { return layout; } - if (itemized) + if (countTable) { - const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - if (hasButton) + // 「边沿计数」= **表格**:表头固定、内容区可滚动。 + // 列:① 信号名 ② 上升沿计数 ③ 下降沿计数(行底色 = 信号配色)。 + const float rowH = kEdgePanelMeanRowHeight * m_scale; + const float scrollW = kFreeMarkerScrollWidth * m_scale; + const float viewHeight = + std::max(0.0f, height - titleHeight - pad - tableHeaderHeight - pad); + const bool scrollable = meanContentHeight > viewHeight + 0.5f; + const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); + const float scroll = std::max(0.0f, std::min(lane.edgeCountScroll, maxScroll)); + const float innerWidth = + std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + layout.tableHeader = + gd::RectF(left + pad, top + titleHeight + pad, innerWidth, tableHeaderHeight); + layout.content = gd::RectF(left + pad, layout.tableHeader.Y + tableHeaderHeight, innerWidth, + viewHeight); + layout.markerContentHeight = meanContentHeight; + layout.scrollOffset = scroll; + if (scrollable) { - // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 - //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); - // 面板高度已含这份 pad,因此这里只上移按钮行即可。 - layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + // 滚动条只占**内容区**(不覆盖固定的表头,表头永远干净可读)。 + layout.scrollTrack = + gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, + viewHeight); + const float thumbHeight = + std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight)); + const float thumbTop = + layout.content.Y + + (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); + layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); } - - // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); - // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 - const float checkSize = kEdgePanelMeanCheckSize * m_scale; - const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; - const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); - // 滑块行列宽(与 `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 - weakPeakResetGap - - weakPeakResetSize - 6.0f * m_scale) - - weakPeakTrackLeft); - float rowY = top + titleHeight + pad; + // 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定 —— 表头与数据行严格对齐。 + const float gap = kEdgePanelStatColumnGap * m_scale; + const float contentLeft = layout.content.X; + // 计数列宽 = 「表头文字」「当前数字」的较大者(与 `EdgePanelWidth` 同源)。 + float risingCol = + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)) + 10.0f * m_scale; + float fallingCol = + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2)) + 10.0f * m_scale; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.axis == nullptr || + resolved.style == nullptr || !resolved.visible) + { + continue; + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, + complete); + risingCol = std::max( + risingCol, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)) + + 10.0f * m_scale); + fallingCol = std::max( + fallingCol, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete)) + + 10.0f * m_scale); + } + const float nameColumn = + std::max(20.0f * m_scale, innerWidth - gap - risingCol - gap - fallingCol); + layout.tableColumns[0].left = contentLeft; + layout.tableColumns[0].width = nameColumn; + layout.tableColumns[1].left = contentLeft + nameColumn + gap; + layout.tableColumns[1].width = risingCol; + layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap; + layout.tableColumns[2].width = fallingCol; + float rowY = layout.content.Y - scroll; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -539,101 +662,168 @@ namespace jdi { continue; } - layout.rowRefs.push_back(ref); - layout.rowChecks.push_back(gd::RectF( - left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, - checkSize)); - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (weakPeak) - { - // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 - if (checked) - { - const float levelRowTop = rowY + meanRowHeight; - const float iterRowTop = levelRowTop + weakPeakSliderRow; - const gd::RectF levelTrack( - weakPeakTrackLeft, - levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - const gd::RectF iterTrack( - weakPeakTrackLeft, - iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - layout.rowLevelTracks.push_back(levelTrack); - layout.rowLevelValues.push_back(gd::RectF( - levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - layout.rowIterTracks.push_back(iterTrack); - 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()); - layout.rowStats.push_back(gd::RectF()); + layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH)); + layout.rowNameRects.push_back( + gd::RectF(contentLeft + 4.0f * m_scale, rowY, + std::max(10.0f, nameColumn - 8.0f * m_scale), rowH)); + layout.rowTriangles.push_back(gd::RectF()); + layout.rowChecks.push_back(gd::RectF()); + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + 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()); + rowY += rowH; + } + } + else if (listStyle) + { + // 「均值滤波 / 弱峰调整」:可滚动内容区(信号色叠底条目栏 + 展开后的两行滑块) + // +(均值滤波 / 弱峰调整)底部按钮。 + const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; + if (hasButton) + { + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + } - rowY += meanRowHeight; - if (checked) - { - rowY += weakPeakSliderRow * 2.0f; - } + const float groupRow = kEdgePanelMeanRowHeight * m_scale; + const float sliderRow = kWeakPeakSliderRowHeight * m_scale; + const float scrollW = kFreeMarkerScrollWidth * m_scale; + const float footerHeight = hasButton ? (buttonRowHeight + pad) : 0.0f; + const float viewHeight = + std::max(0.0f, height - titleHeight - pad - pad - footerHeight); + const bool scrollable = meanContentHeight > viewHeight + 0.5f; + const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); + const float storedScroll = meanFilter ? lane.meanFilterScroll + : (weakPeak ? lane.weakPeakScroll : lane.edgeCenterScroll); + const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll)); + const float innerWidth = + std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); + layout.markerContentHeight = meanContentHeight; + layout.scrollOffset = scroll; + if (scrollable) + { + layout.scrollTrack = + gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, + viewHeight); + const float thumbHeight = + std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight)); + const float thumbTop = + layout.content.Y + + (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); + layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); + } + + // 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。 + const float labelColumn = + std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)), + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel))); + const float valueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + const float resetSize = kWeakPeakResetButtonSize * m_scale; + const float resetGap = kWeakPeakResetGap * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + const float closeSize = kEdgePanelMeanCheckSize * m_scale; + const float contentLeft = layout.content.X; + const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; + const float resetLeft = contentLeft + innerWidth - resetSize; + const float trackWidth = + std::max(10.0f * m_scale, + (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); + + float rowY = layout.content.Y - scroll; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { continue; } - if (checked && itemHasParams) + bool checked = false; + bool itemCollapsed = false; + if (weakPeak) { - const float editTop = rowY + meanRowHeight; - layout.rowPoints.push_back( - gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); - layout.rowIterations.push_back( - gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); - if (centerAxis) - { - layout.rowStats.push_back( - gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, - kEdgePanelMeanRowHeight * m_scale)); - } - else - { - layout.rowStats.push_back(gd::RectF()); - } + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + checked = (item != nullptr); + itemCollapsed = (item != nullptr && item->collapsed); + } + else if (centerAxis) + { + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + checked = (item != nullptr); + itemCollapsed = (item != nullptr && item->collapsed); } else { - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + checked = (item != nullptr); + itemCollapsed = (item != nullptr && item->collapsed); } + layout.rowRefs.push_back(ref); + // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 + layout.rowHeaders.push_back( + gd::RectF(contentLeft, rowY, innerWidth, groupRow)); + layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow)); + layout.rowTriangles.push_back( + checked ? gd::RectF(contentLeft + 3.0f * m_scale, + rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, + kEdgePanelRowTriangleSize * m_scale, + kEdgePanelRowTriangleSize * m_scale) + : gd::RectF()); + layout.rowChecks.push_back( + gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, + rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + rowY += groupRow; - rowY += meanRowHeight; - if (checked && itemHasParams) + // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 + if (!checked || itemCollapsed) { - rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - rowY += kEdgePanelMeanRowHeight * m_scale; - } + 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()); + continue; } + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (centerAxis && !layout.rowStats.empty()) + { + layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow); + rowY += groupRow; + } + const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, + trackWidth, trackHeight); + layout.rowLevelTracks.push_back(pointsTrack); + layout.rowLevelValues.push_back( + gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, + sliderRow)); + layout.rowLevelResets.push_back( + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + rowY += sliderRow; + + const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, + trackWidth, trackHeight); + layout.rowIterTracks.push_back(iterTrack); + layout.rowIterValues.push_back( + gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, + sliderRow)); + layout.rowIterResets.push_back( + gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); + rowY += sliderRow; } } @@ -814,8 +1004,9 @@ namespace jdi group.pinned = (barTop > rowY + 0.5f); group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, - barTop + (groupRow - 12.0f * m_scale) * 0.5f, - 12.0f * m_scale, 12.0f * m_scale); + barTop + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, + kEdgePanelRowTriangleSize * m_scale, + kEdgePanelRowTriangleSize * m_scale); group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); float pinnedBottom = barTop + groupRow; @@ -1209,7 +1400,11 @@ namespace jdi double bestValue = 0.0; double bestEnum = 0.0; float bestY = 0.0f; - bool bestSnapped = false; + // 胜出信号的轴 / 数据 / 时间偏移:循环结束后**只为它**做一次「自动点线」稀疏判定 + // (判定代价与信号数量成正比,而它只影响一个小圆环 ⇒ 绝不能每个信号都算)。 + const TimeAxis* bestAxis = nullptr; + const SignalData* bestData = nullptr; + double bestOffsetTime = 0.0; for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -1218,9 +1413,10 @@ namespace jdi { continue; } - // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 - // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 - const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); + // 「自动点线」判据只用于决定要不要画“已吸附”圆环 ⇒ **不在循环里逐个信号算**: + // 它的代价是“每个小格两次二分”(视界 50 格 × 上百信号 = 数万次比较/帧), + // 而**只有最后胜出的那一个信号**需要它(用户 2026-10-30 报告的卡顿根因之一)。 + // 见循环结束后的统一判定。 // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- float nearestX = fx; @@ -1348,13 +1544,22 @@ namespace jdi ApplyDrawTransform(*resolved.style, candidateSample); if (std::isfinite(candidateValue)) { + // ⚠️⚠️ 距离必须包含**横向**分量(用户 2026-10-30 报告): + // 只看纵向距离时,一个“单数据点信号”只要它的点在鼠标的**高度**附近, + // 就会在整幅宽度内一直胜出 —— 光标被它“吸住”,无法标注另一个常规信号。 + // 改为鼠标到**该采样点屏幕位置**的欧氏距离后:稠密信号的横向分量≈ 0(仍等价于 + // “纵向最近”);单点/稀疏信号只有鼠标真的靠近它时才会被选中。 + const double pointX = static_cast(TimeToX(candidateTime)); + const double dx = pointX - static_cast(fx); const double vertical = std::fabs( static_cast(ValueToY(candidateValue, visual)) - static_cast(fy)); - if (!candidateOk || vertical < distance) + const double horizontal = std::fabs(dx); + const double sampleDistance = std::sqrt(vertical * vertical + horizontal * horizontal); + if (!candidateOk || sampleDistance < distance) { - distance = vertical; - nearestX = fx; + distance = sampleDistance; + nearestX = static_cast(pointX); time = candidateTime; sampled = candidateSample; candidateOk = true; @@ -1368,9 +1573,6 @@ namespace jdi continue; } // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 - // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: - // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 - const bool snapped = sparse; const double value = ApplyDrawTransform(*resolved.style, sampled); if (!std::isfinite(value)) { @@ -1386,7 +1588,9 @@ namespace jdi bestValue = value; bestEnum = sampled; bestY = y; - bestSnapped = snapped; + bestAxis = resolved.axis; + bestData = resolved.data; + bestOffsetTime = resolved.style->offsetTime; probe.ref = ref; } @@ -1398,12 +1602,21 @@ namespace jdi } probe.valid = true; probe.time = bestTime; - probe.x = TimeToX(bestTime); + // ⚠️ 纵向光标**画在鼠标所在的 x**(用户 2026-10-30 明确要求:纵向光标跟随鼠标, + // 被标注的数据点才是"离鼠标最近"的那个)。旧实现把竖线画在吸附后的采样时刻, + // 于是稀疏/单点信号会让整条竖线"跳"到数据点上,观感像光标被吸走了。 + probe.x = fx; + probe.pointX = TimeToX(bestTime); probe.value = bestValue; probe.enumValue = bestEnum; probe.y = bestY; - probe.snapped = bestSnapped; + // 「已吸附」圆环只在「自动点线」稀疏判据成立时画(密采样信号里吸附量 < 1 像素, + // 圆环会一直挂着反而像画错了)。**只对胜出的这一个信号算**(见循环里的说明)。 + if (bestAxis != nullptr && bestData != nullptr) + { + probe.snapped = MinorCellSparse(*bestAxis, *bestData, bestOffsetTime); + } return probe; } -} // namespace jdi +} // namespace jdi \ No newline at end of file diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp index 9693e8d..c23285b 100644 --- a/Components/PlotView/Src/PlotViewRaster.cpp +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -744,6 +744,306 @@ namespace jdi return any; } + // --------------------------------------------------------------------------- + // 「派生预览曲线」像素缓存 + // --------------------------------------------------------------------------- + + std::uint64_t PlotView::DerivedOverlayKey() const + { + // 内容指纹 = 场景内容(含外观样式 + 派生参数 + 网格幅值范围 + 网格滚动量 + 绘图区几何)。 + // ⚠️ 视界**不在这里**:平移时要靠它判断“能否只滚动像素”,所以视界单独比对。 + std::uint64_t hash = SceneContentHash(true, true, true, true); + // 后台算好的派生曲线到达时,曲线像素必须更新(`m_curveSerial` 随每次发布自增)。 + { + std::lock_guard lock(m_curveMutex); + hash ^= m_curveSerial * 1099511628211ull; + } + // 高亮状态**不参与**:高亮由前景单独绘制(见 `PaintDerivedHoverHighlight`)。 + return hash; + } + + bool PlotView::EnsureDerivedCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (m_derivedDc != nullptr && m_derivedBits != nullptr && m_derivedW == width && + m_derivedH == height) + { + return true; + } + ReleaseDerivedCache(); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + // 与装饰层同理:必须是 32bpp DIB 段(带真 alpha + 像素可直接 memmove 滚动)。 + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下 + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, screen); + if (dib == nullptr || bits == nullptr) + { + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + HDC dc = CreateCompatibleDC(nullptr); + if (dc == nullptr) + { + DeleteObject(dib); + return false; + } + m_derivedOldBitmap = SelectObject(dc, dib); + m_derivedDc = dc; + m_derivedBitmap = dib; + m_derivedBits = bits; + m_derivedW = width; + m_derivedH = height; + m_derivedValid = false; + return true; + } + + void PlotView::ReleaseDerivedCache() + { + m_derivedValid = false; + if (m_derivedDc != nullptr) + { + if (m_derivedOldBitmap != nullptr) + { + SelectObject(m_derivedDc, m_derivedOldBitmap); + m_derivedOldBitmap = nullptr; + } + DeleteDC(m_derivedDc); + m_derivedDc = nullptr; + } + if (m_derivedBitmap != nullptr) + { + DeleteObject(m_derivedBitmap); + m_derivedBitmap = nullptr; + } + m_derivedBits = nullptr; + m_derivedW = 0; + m_derivedH = 0; + } + + bool PlotView::RenderDerivedCache() + { + m_derivedValid = false; + if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) + { + return false; + } + const int width = static_cast(std::ceil(m_area.X + m_area.Width)); + const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); + if (!EnsureDerivedCache(width, height)) + { + return false; + } + const std::size_t bytes = static_cast(width) * static_cast(height) * 4u; + if (!m_derivedStripActive) + { + // 整块重画:先把全部像素清成透明。 + std::memset(m_derivedBits, 0, bytes); + } + else + { + // 补条带:只把这条竖带清成透明(其余像素是滚动过来的有效内容)。 + const int x0 = std::max(0, static_cast(std::floor(m_derivedStripX0))); + const int x1 = std::min(width, static_cast(std::ceil(m_derivedStripX1))); + for (int y = 0; y < height; ++y) + { + unsigned char* row = static_cast(m_derivedBits) + + (static_cast(y) * static_cast(width)) * 4u; + if (x1 > x0) + { + std::memset(row + static_cast(x0) * 4u, 0, + static_cast(x1 - x0) * 4u); + } + } + } + { + gd::Graphics g(m_derivedDc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + if (m_derivedStripActive) + { + // ⚠️ 绘制期的横向裁剪(`PaintDerivedCurvesFor` 只做 `intersect`,不会覆盖这一层)。 + g.SetClip(gd::RectF(m_derivedStripX0, 0.0f, std::max(0.0f, m_derivedStripX1 - m_derivedStripX0), + static_cast(height)), + gd::CombineModeIntersect); + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintDerivedCurvesFor(g, static_cast(index), m_lanes[index], false); + } + } + m_derivedContentKey = DerivedOverlayKey(); + m_derivedViewMin = Tab().ViewMin(); + m_derivedViewMax = Tab().ViewMax(); + m_derivedValid = true; + return true; + } + + bool PlotView::BlitDerivedCache(gd::Graphics& g) + { + if (!m_derivedValid || m_derivedDc == nullptr || m_plotRect.Width < 1.0f) + { + return false; + } + void* proc = ResolveAlphaBlend(); + if (proc == nullptr) + { + return false; + } + using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); + AlphaBlendFn blend = reinterpret_cast(proc); + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + // 缓存位图原点 = 客户区 (0,0):源与目标坐标相同。只贴绘图区(曲线被夹在网格内)。 + const int x = std::max(0, static_cast(std::floor(m_plotRect.X))); + const int y = std::max(0, static_cast(std::floor(m_plotRect.Y))); + const int w = std::min(m_derivedW - x, static_cast(std::ceil(m_plotRect.Width))); + const int h = std::min(m_derivedH - y, static_cast(std::ceil(m_plotRect.Height))); + if (w <= 0 || h <= 0) + { + g.ReleaseHDC(target); + return false; + } + BLENDFUNCTION fn = {}; + fn.BlendOp = AC_SRC_OVER; + fn.SourceConstantAlpha = 255; + fn.AlphaFormat = AC_SRC_ALPHA; + const BOOL done = blend(target, x, y, w, h, m_derivedDc, x, y, w, h, fn); + g.ReleaseHDC(target); + return done != FALSE; + } + + void PlotView::PaintDerivedOverlayLayer(gd::Graphics& g) + { + if (m_loadingBusy || m_lanes.empty()) + { + return; // 加载遮罩期间不花时间在派生曲线上(本来也看不见) + } + // 没有任何派生功能开启时直接跳过(并释放缓存,避免白占内存)。 + bool anyFeature = false; + for (const Lane& lane : Tab().Lanes()) + { + if (lane.edgeCenter || lane.meanFilter || lane.weakPeak) + { + anyFeature = true; + break; + } + } + if (!anyFeature) + { + m_derivedValid = false; + return; + } + + const std::uint64_t contentKey = DerivedOverlayKey(); + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; + bool needRender = true; + bool rolled = false; + m_derivedStripActive = false; + if (m_derivedValid && m_derivedContentKey == contentKey && span > 0.0) + { + const double cachedSpan = m_derivedViewMax - m_derivedViewMin; + const double sameSpanTolerance = std::max(1.0e-12, std::fabs(span) * 1.0e-9); + if (std::fabs(cachedSpan - span) <= sameSpanTolerance && + m_derivedViewMin == viewMin && m_derivedViewMax == viewMax) + { + needRender = false; // 完全同源:只贴图 + } + else if (std::fabs(cachedSpan - span) <= sameSpanTolerance && m_plotRect.Width > 1.0f) + { + // 纯平移:像素整体横移,只重画新露出的竖带(与宽缓冲区的滚动同构)。 + const double pxPerTime = static_cast(m_plotRect.Width) / span; + const int shift = static_cast( + std::lround((m_derivedViewMin - viewMin) * pxPerTime)); + const int width = m_derivedW; + if (shift != 0 && std::abs(shift) < width) + { + const std::size_t rowBytes = static_cast(width) * 4u; + const std::size_t moveBytes = + rowBytes - static_cast(std::abs(shift)) * 4u; + unsigned char* bits = static_cast(m_derivedBits); + if (bits != nullptr && moveBytes > 0) + { + for (int y = 0; y < m_derivedH; ++y) + { + unsigned char* row = bits + static_cast(y) * rowBytes; + if (shift > 0) + { + // 内容右移:从本行右端往前搬(`memmove` 自带重叠安全)。 + std::memmove(row + static_cast(shift) * 4u, row, moveBytes); + } + else + { + // 内容左移:源在本行右侧 —— 目标**不能**越过行首(否则越界)。 + std::memmove(row, row + static_cast(-shift) * 4u, moveBytes); + } + } + } + const float plotLeft = m_plotRect.X; + const float plotRight = m_plotRect.X + m_plotRect.Width; + if (shift > 0) + { + m_derivedStripX0 = plotLeft; + m_derivedStripX1 = std::min(plotRight, plotLeft + static_cast(shift) + 1.0f); + } + else + { + m_derivedStripX0 = std::max(plotLeft, plotRight + static_cast(shift) - 1.0f); + m_derivedStripX1 = plotRight; + } + if (m_derivedStripX1 > m_derivedStripX0 + 0.5f) + { + m_derivedStripActive = true; + rolled = true; + } + } + needRender = true; + } + } + if (needRender) + { + if (!RenderDerivedCache()) + { + m_derivedStripActive = false; + return; + } + } + m_derivedStripActive = false; + if (!BlitDerivedCache(g)) + { + // 无法 AlphaBlend:退回逐条曲线直接绘制(正确性优先,代价高但极少发生)。 + if (!rolled) + { + m_derivedValid = false; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLaneDerivedOverlays(g, static_cast(index), m_lanes[index]); + } + } + } + bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) { if (width <= 0 || height <= 0) @@ -1446,7 +1746,7 @@ namespace jdi g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); UiFillRect(g, m_area, m_theme->plotBg); - UiFillRect(g, m_area, m_theme->cardBg); + UiFillRect(g, m_area, ThemeLaneBackground(*m_theme)); // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): @@ -1653,7 +1953,27 @@ namespace jdi LaneLayer& layer = m_layerFront[index]; const int laneIndex = static_cast(index); + // ⚠️⚠️ **不在可视区(含预取带)内的网格:整段跳过 —— 必须放在最前面**。 + // + // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 + // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 + // + // 而且离屏网格**既不登记待重画、也不算内容指纹**: + // * 登记待重画:一次"网格数变化"会把上百个离屏网格塞进队列,随后每帧补 1 个 + // × 10~30ms ⇒ 数秒持续掉帧(用户 2026-10-18 报告); + // * 算 `LaneLayerKey()`:那是**逐网格内容哈希**,283 个网格全算一遍实测约 + // 0.7ms/帧,而其中 270 个的结果根本用不到 —— 这就是"空闲帧比强制整帧还慢" + // 的根因(用户 2026-10-29 要求排查的既有性能问题)。实测把它提到前面之后 + // 空闲帧从 2.4ms 降到约 1.2ms。 + // + // 离屏网格真正进入可视/预取带时,下面的分支会按新视界登记并重建它。 + if (!LaneVisuallyRelevant(laneIndex, 1)) + { + continue; + } + // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 + // ⚠️ 只对**可视网格**登记待重画(离屏的由上面那段说明处理)。 const float laneHeight = m_lanes[index].rect.Height; const int bitmapHeight = static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); @@ -1670,20 +1990,6 @@ namespace jdi // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); - // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: - // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 - // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 - // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), - // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 - // - // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 - // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms - // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 - // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 - if (!LaneVisuallyRelevant(laneIndex, 1)) - { - continue; - } // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 int vOffset = 0; if (layer.pxPerValue > 0.0) @@ -1942,7 +2248,7 @@ namespace jdi //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 - // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) + // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千万采样的巨型信号) // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 diff --git a/Components/Shared/Inc/ColorPickerPopup.hpp b/Components/Shared/Inc/ColorPickerPopup.hpp index 66976b2..97cd2bd 100644 --- a/Components/Shared/Inc/ColorPickerPopup.hpp +++ b/Components/Shared/Inc/ColorPickerPopup.hpp @@ -15,7 +15,7 @@ namespace jdi { - class UiDebugBoxList; + class UiBoxTable; /** * @brief 浮动式 RGB 自定义颜色拾取器。 @@ -183,7 +183,7 @@ namespace jdi * 面板未打开时什么都不登记。 * @param [IN,OUT] boxes 收集器。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes) const; + void BuildBoxes(UiBoxTable& boxes) const; private: /** diff --git a/Components/Shared/Inc/PaintUtil.hpp b/Components/Shared/Inc/PaintUtil.hpp index c0b99b9..fdcf69f 100644 --- a/Components/Shared/Inc/PaintUtil.hpp +++ b/Components/Shared/Inc/PaintUtil.hpp @@ -191,6 +191,26 @@ namespace jdi * @param [IN] rect 文本墨迹矩形(客户区坐标)。 */ virtual void OnTextRect(const gd::RectF& rect) = 0; + + /** + * @brief 设置**后续文本**所属的绘制层(越大越靠上)。 + * + * 为什么需要它:文本是在**绘制过程中**收集的,而各层的先后顺序只在绘制时才真实存在。 + * 若所有文本都固定记到同一层,就会出现“浮层上的文字框被压在浮层下面” + *(表现为:光标取值胶囊的文字框穿透显示在浮动面板上)。 + * 因此宿主在绘制每个组件前把层号告诉收集器,绘制浮层时组件再往上提一层。 + * + * 默认空实现:只关心“文字在哪”而不关心层序的收集器(如行位图缓存)可忽略它。 + * + * @param [IN] layer 层号(越大越靠上)。 + */ + virtual void OnTextLayer(int layer) { (void)layer; } + + /** + * @brief 当前文本层号(用于“在当前层之上再画一层”的场景)。 + * @return 当前层号(默认 0)。 + */ + virtual int TextLayer() const { return 0; } }; /** @@ -212,6 +232,22 @@ namespace jdi */ UiTextRectSink* UiGetTextRectSink(); + /** + * @brief 设置**当前绘制层**(未安装收集器时什么都不做)。 + * + * 宿主在绘制每个组件前调用一次,把“这一层是第几层”告知收集器; + * 绘制不透明浮层的组件再把层号 +1 后画内容。 + * + * @param [IN] layer 层号(越大越靠上)。 + */ + void UiSetTextLayer(int layer); + + /** + * @brief 取当前绘制层号(未安装收集器时为 0)。 + * @return 层号。 + */ + int UiGetTextLayer(); + /** * @brief 设置文本矩形的坐标偏移(默认 `(0, 0)`)。 * @@ -319,6 +355,20 @@ namespace jdi void UiFillRoundRects(gd::Graphics& g, const std::vector& rects, float radius, std::uint32_t argb); + /** + * @brief 绘制「还原 / 重置」图标(逆时针回环箭头)。 + * + * **唯一实现**:弹出菜单标题栏的还原图标(`PopupMenu`)、弱峰调整面板每行滑块的复位控件、 + * 信号样式区「线宽 / 透明度」两行右侧的复位按钮全部调用它,因此同一语义在任何位置 + * 都是同一套笔画,任意 DPI 下都清晰(不依赖字体或图标资源)。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标外接矩形(正方形;边长为 0 时不做任何绘制)。 + * @param [IN] argb 笔画颜色(0xAARRGGBB)。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiPaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float scale); + } // namespace jdi #endif // __PAINT_UTIL_HPP__ diff --git a/Components/Shared/Inc/PopupMenu.hpp b/Components/Shared/Inc/PopupMenu.hpp index 1720017..a9b8415 100644 --- a/Components/Shared/Inc/PopupMenu.hpp +++ b/Components/Shared/Inc/PopupMenu.hpp @@ -16,7 +16,7 @@ namespace jdi { - class UiDebugBoxList; + class UiBoxTable; /** * @brief 弹出菜单的打开来源,用于把选择结果派发回正确的组件。 @@ -239,7 +239,7 @@ namespace jdi * 菜单未打开时什么都不登记。条目、滚动区、滚动条与二级子菜单各自成为独立的 box。 * @param [IN,OUT] boxes 收集器。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes) const; + void BuildBoxes(UiBoxTable& boxes) const; /** * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。 @@ -361,6 +361,21 @@ namespace jdi */ bool UpdateItemLabel(int id, const std::wstring& label, bool checked = false, bool enabled = true); + /** + * @brief 更新**二级子菜单**里某个普通项的文字与勾选状态。 + * + * 子菜单内容是 `MenuItem::submenu` 的快照,改完必须重建子菜单布局才会生效 + *(这正是「主题配色」能边点边看到 √ 挪位的原因)。 + * + * @param [IN] id 命令标识。 + * @param [IN] label 新的文字。 + * @param [IN] checked 新的选中状态。 + * @param [IN] enabled 新的可用状态。 + * @return 找到对应项并更新返回 true。 + */ + bool UpdateSubmenuItemLabel(int id, const std::wstring& label, bool checked = false, + bool enabled = true); + private: /** * @brief 计算单个菜单项的纵向高度。 diff --git a/Components/Shared/Inc/PopupMenuInternal.hpp b/Components/Shared/Inc/PopupMenuInternal.hpp index 0c56dd5..b17a43b 100644 --- a/Components/Shared/Inc/PopupMenuInternal.hpp +++ b/Components/Shared/Inc/PopupMenuInternal.hpp @@ -18,6 +18,7 @@ #include #include "Theme.hpp" +#include "PaintUtil.hpp" namespace jdi { @@ -63,36 +64,8 @@ namespace jdi */ inline void PaintResetIcon(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); + // 唯一实现见 `PaintUtil.hpp`(信号样式区的复位按钮、弱峰调整面板的复位控件共用)。 + UiPaintResetIcon(g, rect, color, scale); } } // namespace pmdetail diff --git a/Components/Shared/Inc/Theme.hpp b/Components/Shared/Inc/Theme.hpp index 51ae314..f364e8c 100644 --- a/Components/Shared/Inc/Theme.hpp +++ b/Components/Shared/Inc/Theme.hpp @@ -75,7 +75,68 @@ namespace jdi } /** - * @brief 界面主题色板(Solarised Light 派生)。 + * @brief 颜色的**相对亮度**(WCAG 2.x 定义:sRGB 线性化后按 0.2126/0.7152/0.0722 加权)。 + * @param [IN] argb 颜色值(只看 RGB 通道)。 + * @return 相对亮度,取值 0(黑)… 1(白)。 + */ + double SrgbLuminance(std::uint32_t argb); + + /** + * @brief 两色之间的**对比度**(WCAG 2.x 定义:`(L1 + 0.05) / (L2 + 0.05)`)。 + * @param [IN] firstColor 第一色。 + * @param [IN] secondColor 第二色。 + * @return 对比度,取值 1(完全相同)… 21(黑白)。 + */ + double ContrastRatio(std::uint32_t firstColor, std::uint32_t secondColor); + + /** + * @brief 保证前景色相对底色有**足够对比度**:不足时沿"远离底色"的方向混向黑/白。 + * + * 用途:绘图区内部的网格线、时间轴等分线、分界线等原本按某一个主题手工调色的元素, + * 必须跟着主题背景色自动适配(浅底上加深、深底上提亮),否则换到深色主题后 + * 网格线只剩几个色阶、几乎看不见。 + * + * ⚠️ **已达标时逐位返回原色**:Solarised Light 的历史配色是人工调过的, + * 只要它满足阈值就必须保持逐像素不变(避免"换主题修复"顺带改变默认主题观感)。 + * + * @param [IN] background 底色(不透明)。 + * @param [IN] foreground 前景色(不透明)。 + * @param [IN] minRatio 期望的最小对比度(≥ 1,1 表示不限制)。 + * @return 满足阈值的颜色(RGB 不变、alpha = 0xFF)。 + */ + std::uint32_t EnsureContrast(std::uint32_t background, std::uint32_t foreground, double minRatio); + + /** + * @brief 把颜色沿"亮度"插值到目标相对亮度(保持色相,二分求解混色比例)。 + * + * 比"固定比例混向黑/白"更可控:调用方指定**目标亮度**,本函数负责求出混色量。 + * 目标亮度高于当前亮度时混向白、低于时混向黑;求解连续 ⇒ 输入微调时输出也连续 + *(不会在两个极端色之间跳变)。 + * + * @param [IN] color 原色(不透明)。 + * @param [IN] targetLuminance 目标相对亮度(0…1)。 + * @return 亮度接近目标值的颜色(无法达到时取该方向上的极限)。 + */ + std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance); + + /** + * @brief 界面主题色板。 + * + * ## 结构色的对比度约定(重要,改配色前必读) + * + * `gridWeak` / `gridStrong` / `axisLine` / `borderStrong` 这些“结构色”是**主题配色的一部分** + * (每个主题各自给值),它们画在**网格叠底**(`laneBg`)之上,而不是绘图区底色 `plotBg`。 + * 因此它们的最小对比度一律针对 `laneBg` 校验: + * + * | 字段 | 最小对比度(相对 `laneBg`) | + * | --- | ---: | + * | `gridWeak` | 1.30 | + * | `gridStrong` | 1.70 | + * | `axisLine` | 2.10 | + * | `borderStrong` | 1.70 | + * + * 各主题给出的值本就满足上述下限,因此最终化时**逐位返回原色**、不做任何改色; + * 这些阈值只是“不允许配置出看不见的网格线”的兜底。 */ struct Theme { @@ -98,9 +159,19 @@ namespace jdi std::uint32_t pressed = 0; /**< 按下底色。 */ std::uint32_t selection = 0; /**< 选中底色。 */ - std::uint32_t gridWeak = 0; /**< 次级网格线颜色。 */ - std::uint32_t gridStrong = 0; /**< 主要网格线颜色。 */ + std::uint32_t gridWeak = 0; /**< 次级网格线颜色(主要刻度 + 五等分线)。 */ + std::uint32_t gridStrong = 0; /**< 主要网格线颜色(零线 / 加重线)。 */ std::uint32_t axisLine = 0; /**< 坐标轴线颜色。 */ + /** + * @brief 每条网格内部的**叠底**颜色(绘图区底色之外的“卡片层”)。 + * + * ⚠️ 网格线是画在**它**上面(不是 `plotBg` 上),因此结构色的对比度必须针对 + * 本字段校验(见 `Theme.hpp` 顶部的 `EnsureContrast` 约定);落在深色主题上时 + * 两者相差很大,只盯着 `plotBg` 算会让网格线“几乎看不见”(用户 2026-10-30 报告)。 + * + * 0 表示“未设置”:最终化时回退到 `cardBg` 并保证与 `plotBg` 有最小对比度。 + */ + std::uint32_t laneBg = 0; std::uint32_t cursor = 0; /**< 时间光标颜色(单/双光标共用同一样式)。 */ std::uint32_t readoutBg = 0; /**< 数值读数底色。 */ std::uint32_t danger = 0; /**< 警示色。 */ @@ -108,10 +179,65 @@ namespace jdi }; /** - * @brief 获取当前使用的主题(Solarised Light)。 + * @brief 内置主题配色种类。 + * + * 顺序即「主题配色」二级菜单里的显示顺序(用户 2026-10-29 指定)。 + */ + enum class ThemeKind : int + { + LIGHT = 0, /**< 浅色(参考 VS Code 2026 默认浅色主题)。 */ + SOLARIZED_LIGHT = 1, /**< Solarised Light(历史默认主题)。 */ + DARK = 2, /**< 深色(参考 VS Code Dark+)。 */ + SOLARIZED_DARK = 3 /**< Solarised Dark(参考 VS Code 的 Solarized Dark)。 */ + }; + + /** + * @brief 取某个内置主题的配色(静态实例,地址稳定)。 + * @param [IN] kind 主题种类;越界时退回 Solarised Light。 * @return 主题常引用。 */ - const Theme& SolarizedLight(); + const Theme& ThemeFor(ThemeKind kind); + + /** + * @brief 取主题在「主题配色」菜单里的显示名称。 + * @param [IN] kind 主题种类。 + * @return 名称(静态字符串,无需释放)。 + */ + const wchar_t* ThemeName(ThemeKind kind); + + /** + * @brief 全局「当前主题」指针。 + * + * ⚠️ 为什么需要全局:`UiControls` 里少数几个原语(色块描边、非主题化的默认底色) + * 没有 `Theme` 形参,却必须跟随用户选择的主题。宿主在启动与切换主题时调用 + * `SetCurrentTheme(&m_theme)`,把**宿主自己那个** `Theme` 实例的地址交给它 —— + * 这样切换主题只需修改 `m_theme` 的内容,不需要通知每一个组件刷新指针。 + * + * @return 当前主题;未设置时返回 Solarised Light。 + */ + const Theme& CurrentTheme(); + + /** + * @brief 设置全局「当前主题」。 + * @param [IN] theme 主题实例(必须比使用期更长;传 nullptr 表示恢复默认)。 + */ + void SetCurrentTheme(const Theme* theme); + + /** + * @brief 取「绘图区网格底色」——**网格叠底**(每条网格内部的底色)。 + * + * 它与 `Theme::plotBg`(绘图区整体底色)分开:网格之外的时间轴/留白区用 `plotBg`, + * 网格内部用本函数。当两者过于接近时,本函数会把网格底色**轻轻地**往远离绘图区 + * 底色的方向挪一点(仅供深色主题的 Solarised Dark 这类"两者完全同色"的情形), + * 保证相邻网格仍能被分界线以外的底色层次区分开。 + * + * ⚠️ 两者本来就有可辨识的亮度差时**逐位返回 `cardBg`**(Solarised Light / Light / Dark + * 的默认观感不变)。 + * + * @param [IN] theme 主题。 + * @return 网格底色(不透明)。 + */ + std::uint32_t ThemeLaneBackground(const Theme& theme); /** * @brief 获取波形默认配色表。 diff --git a/Components/Shared/Inc/UiBox.hpp b/Components/Shared/Inc/UiBox.hpp new file mode 100644 index 0000000..d2e767f --- /dev/null +++ b/Components/Shared/Inc/UiBox.hpp @@ -0,0 +1,161 @@ +#ifndef __UI_BOX_HPP__ +#define __UI_BOX_HPP__ + +#include +#include +#include + +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 一个几何 box(**纯值类型**,不含行为、不含生命周期)。 + * + * ## 它是什么 + * + * 描述"某块界面元素占据哪个矩形、属于第几层、在第几个绘制层、被裁在哪个矩形里"。 + * 这就是界面在某一帧的**几何产物**:绘制、命中、调试描边理论上都应该从它派生, + * 而不是各自再算一遍。 + * + * ## 为什么不是控件节点 + * + * 刻意不继承控件基类:`PlotView` 有 4 层自持位图缓存、`SignalPanel` 有行位图缓存, + * 它们要求"像素由组件自己负责"。把几何塞进继承体系会和缓存失效体系打架, + * 而 box 只是一个可复制的矩形 + 几个整数标签。 + * + * ## 为什么 `clip` 与 `rect` 分开存 + * + * 组件内部的元素可能落在地盘之外(典型:滚出可视区的网格,Y 是负数)。 + * 消费方必须能区分"这块几何真实位置在哪"和"它最多能显示到哪", + * 因此裁剪矩形跟着 box 走,而不是靠全局状态。 + */ + struct UiBox + { + gd::RectF rect; /**< 矩形(客户区坐标)。 */ + gd::RectF clip; /**< 裁剪矩形(= 所属组件的区域;空 = 不裁剪)。 */ + int depth = 0; /**< 层级:0 = 顶层组件区域,≥1 = 内部控件。深 = 更靠近叶子。 */ + int layer = 0; /**< 绘制层:越大越靠上,由 `UiBoxTable::PushLayer` 分配。 */ + }; + + /** + * @brief 几何 box 的收集器(值语义容器,**不含任何绘制代码**)。 + * + * ## 职责边界 + * + * 这里只负责"**累积几何 + 维护裁剪/层/遮挡这些几何事实**"。 + * 怎么把 box 变成屏幕上的线,是 `UiDebugBounds` 的事(那是调试专用的消费者)。 + * 分开的原因:几何是**稳定事实**(命中、遮挡、将来的脏矩形都要用), + * 而"用彩色包角描边"只服务于一个开关,两者不该长在同一个类里。 + * + * ## 两个排序键缺一不可 + * + * * `layer`:保证**后收集的内容压住先收集的**。没有它,"信号面板的行边界" + * 会盖住"右键弹出的二级菜单"(菜单是后收集的浮层)。 + * * `depth`:同一层内先画父(浅层)再画子(深层),子元素的包角才不被父覆盖。 + * + * ## 开销约束 + * + * 只在宿主开启「显示布局边界」时才被构造和填充 —— 关闭时**零开销** + * (不进绘制路径、不进任何位图缓存)。若将来要用它做常驻命中表, + * 必须显式改为"按需重建",不能每帧无限期累积。 + */ + class UiBoxTable : public UiTextRectSink + { + public: + /** @brief 清空(复用同一实例时调用),同时把绘制层复位为 0。 */ + void Clear(); + + /** + * @brief 进入下一个绘制层。 + * + * 宿主在每个组件收集完之后调用一次;组件内部若有"浮层压住本体"的情形 + * (例如菜单的二级子菜单)也应在收集子层之前调用。 + */ + void PushLayer() { ++m_layer; } + + /** + * @brief 显式设置当前绘制层(绘制期间收集文本几何时用)。 + * @param [IN] layer 层号(越大越靠上)。 + */ + void SetLayer(int layer) { m_layer = layer; } + + /** @brief 回报一段文本的墨迹矩形(由 `PaintUtil` 在绘制文本时调用)。 */ + void OnTextRect(const gd::RectF& rect) override; + + /** + * @brief 设置后续文本所属的绘制层(由 `PaintUtil` 在绘制阶段转发)。 + * + * 文本必须与实际绘制同层:否则浮层内的文字会被当成“下层内容”而被裁掉, + * 或者下层的文字框穿透到浮层之上。 + */ + void OnTextLayer(int layer) override { m_layer = layer; } + + /** @brief 当前文本层号。 */ + int TextLayer() const override { return m_layer; } + + /** @brief 当前绘制层号(用于“在当前层之上再压一层”)。 */ + int Layer() const { return m_layer; } + + /** + * @brief 设置后续 box 的裁剪矩形(= 当前组件的区域)。 + * + * ⚠️ **必须像裁剪绘制一样裁剪几何**:组件内部元素可能落在地盘之外 + * (典型:滚出可视区的网格 `lane.rect` 的 Y 会是负数),不裁剪就会登记到相邻组件 + * (工具栏 / 标签栏)的地盘上 —— 这正是用户报告"工具栏上出现大量残留"的直接原因。 + * + * @param [IN] clip 裁剪矩形;传空矩形表示不裁剪。 + */ + void SetClip(const gd::RectF& clip) { m_clip = clip; } + + /** @brief 追加一个 box(空矩形 / 与裁剪矩形无交集的 box 自动忽略)。 */ + void Add(const gd::RectF& rect, int depth); + + /** @brief 追加一组同层级的 box(空矩形逐个忽略)。 */ + void AddAll(const std::vector& rects, int depth); + + /** + * @brief 登记一个**遮挡矩形**(浮层本体,如弹出菜单 / 拾色器)。 + * + * ⚠️ 必须做这件事:box 的消费方是**在所有内容之后**工作的,仅靠 `layer` 排序 + * 只能决定"两个 box 重叠时谁压谁",并不能阻止"下层组件的边框画在不透明浮层上面"。 + * 登记为遮挡后,**绘制层比它低**的 box 在该区域内会被裁掉。 + * + * @param [IN] rect 遮挡矩形(客户区坐标;空矩形忽略)。 + */ + void AddOccluder(const gd::RectF& rect); + + /** @brief 是否为空。 */ + bool Empty() const { return m_boxes.empty(); } + + /** @brief box 数量。 */ + std::size_t Count() const { return m_boxes.size(); } + + /** @brief 只读访问(**未排序**,消费方需要顺序时自己按 `(layer, depth)` 排序)。 */ + const std::vector& Items() const { return m_boxes; } + + /** @brief 一个遮挡矩形(含它所在的绘制层)。 */ + struct Occluder + { + gd::RectF rect; /**< 遮挡矩形。 */ + int layer = 0; /**< 所在绘制层(只遮挡更低的层)。 */ + }; + + /** @brief 遮挡矩形集合(只读)。 */ + const std::vector& Occluders() const { return m_occluders; } + + private: + std::vector m_boxes; + std::vector m_occluders; + int m_layer = 0; /**< 当前绘制层。 */ + gd::RectF m_clip; /**< 当前裁剪矩形(空 = 不裁剪)。 */ + }; + +} // namespace jdi + +#endif // __UI_BOX_HPP__ diff --git a/Components/Shared/Inc/UiComponent.hpp b/Components/Shared/Inc/UiComponent.hpp index 5e054bf..6693ae1 100644 --- a/Components/Shared/Inc/UiComponent.hpp +++ b/Components/Shared/Inc/UiComponent.hpp @@ -10,7 +10,7 @@ namespace jdi { - class UiDebugBoxList; + class UiBoxTable; /** * @brief 顶层 UI 组件的统一协议(**纯接口,无状态**)。 @@ -77,20 +77,30 @@ namespace jdi } /** - * @brief 收集本组件的**布局边界**(调试用,「显示布局边界」开启时由宿主调用)。 + * @brief 上报本组件的**几何产物**(含子元素的矩形、层级、裁剪、遮挡关系)。 * * 默认实现只登记 `Area()`;自身内部还有可辨识子块(控件树节点、列表行、网格、 * 复选框、徽标、✕、面板控件……)的组件覆盖它,先调本基类再补登记**层级更深**的 box。 * - * 之所以把入口放在这里而不是让宿主逐个点名:宿主只持有 `UiComponent*` 的组件表, - * 遍历一次就能覆盖全部顶层区域;组件内部结构由组件自己上报(只有它知道几何)。 + * ## 为什么入口在组件上 * - * ⚠️ 只在宿主开启「显示布局边界」时才被调用 —— 平时**零开销**(不进任何绘制路径、 - * 不进任何缓存)。实现时**禁止**把几何写进自持位图缓存,否则关掉开关后线条会残留。 + * 宿主只持有 `UiComponent*` 的组件表,遍历一次就能覆盖全部顶层区域; + * 组件内部结构由组件自己上报(只有它知道几何)。新增组件不必再改宿主。 + * + * ## 它是几何,不是调试 + * + * 这里只填 `UiBoxTable`(纯值:矩形 + 层级 + 层号 + 裁剪),**不画任何东西**。 + * “怎么显示”是消费方的事:目前唯一的消费方是「显示布局边界」调试叠加层 + * (`UiDebugPaintBoxList`),将来命中 / 脏矩形也可以从同一张表派生。 + * 这样组件就不需要知道"调试"这个概念的存在。 + * + * ⚠️ 目前它只在宿主开启「显示布局边界」时才被调用 —— 平时**零开销** + *(不进任何绘制路径、不进任何位图缓存)。实现时**禁止**把几何写进自持位图缓存, + * 否则关掉开关后线条会残留。 * * @param [IN,OUT] boxes 收集器(宿主每帧新建,全程只在开关打开时存在)。 */ - virtual void CollectDebugBoxes(UiDebugBoxList& boxes); + virtual void BuildBoxes(UiBoxTable& boxes); }; } // namespace jdi diff --git a/Components/Shared/Inc/UiDebugBounds.hpp b/Components/Shared/Inc/UiDebugBounds.hpp index f84048f..023af58 100644 --- a/Components/Shared/Inc/UiDebugBounds.hpp +++ b/Components/Shared/Inc/UiDebugBounds.hpp @@ -5,16 +5,17 @@ #include #include -#include #include -#include -#include "PaintUtil.hpp" #include "Theme.hpp" +#include "UiBox.hpp" namespace jdi { + // ⚠️ 这个头文件只负责"把几何 box 画成彩色的布局边界线"(调试专用)。 + // 几何本身(`UiBox` / `UiBoxTable`)在 `UiBox.hpp` 里,与绘制无关。 + /** * @brief 布局边界描边颜色(按层级取色)。 * @@ -26,101 +27,7 @@ namespace jdi std::uint32_t UiDebugBoundsColor(int depth); /** - * @brief 一个待显示的布局边界 box(**纯值类型**)。 - * - * 刻意不继承控件基类:`PlotView` 的 4 层位图缓存、`SignalPanel` 的行位图缓存都要求 - * "绘制由组件自己负责",把几何塞进继承体系会和缓存体系打架。box 只描述 - * "哪里有一个矩形、属于第几层、在第几绘制层",因此**几何可以被绘制、命中与调试共用**, - * 也不需要任何虚函数或生命周期管理。 - */ - struct UiDebugBox - { - gd::RectF rect; /**< 矩形(客户区坐标)。 */ - gd::RectF clip; /**< 裁剪矩形(= 所属组件的区域;空 = 不裁剪)。 */ - int depth = 0; /**< 层级(决定配色与包角粗细)。 */ - int layer = 0; /**< 绘制层(越大越靠上):由 `UiDebugBoxList::PushLayer` 分配。 */ - }; - - /** - * @brief box 收集器(值语义容器)。 - * - * 由宿主每帧构造一次、交给各组件填充、再一次性画完 —— 全程只在「显示布局边界」打开时执行。 - * - * ⚠️ **两个排序键缺一不可**: - * * `layer`(绘制层,来自 `PushLayer()`):保证**后收集的内容压住先收集的**。 - * 没有它,"信号面板的行边界"会盖住"右键弹出的二级菜单"(菜单是后收集的浮层)。 - * * `depth`(层级):同一层内先画父(浅层)再画子(深层),子元素的彩色包角才不被父覆盖。 - */ - class UiDebugBoxList : public UiTextRectSink - { - public: - /** @brief 清空(复用同一实例时调用),同时把绘制层复位为 0。 */ - void Clear(); - /** - * @brief 进入下一个绘制层。 - * - * 宿主在每个组件收集完之后调用一次;组件内部若有"浮层压住本体"的情形 - * (例如菜单的二级子菜单)也应在收集子层之前调用。 - */ - void PushLayer() { ++m_layer; } - /** - * @brief 显式设置当前绘制层(绘制期间收集文本几何时用)。 - * @param [IN] layer 层号(越大越靠上)。 - */ - void SetLayer(int layer) { m_layer = layer; } - - /** @brief 回报一段文本的墨迹矩形(由 `PaintUtil` 在绘制文本时调用)。 */ - void OnTextRect(const gd::RectF& rect) override; - /** - * @brief 设置后续 box 的裁剪矩形(= 当前组件的区域)。 - * - * ⚠️ **必须像裁剪绘制一样裁剪调试 box**:组件内部元素可能落在地盘之外 - * (典型:滚出可视区的网格 `lane.rect` 的 Y 会是负数),不裁剪就会画到相邻组件 - * (工具栏 / 标签栏)上面 —— 这正是用户报告“工具栏上出现大量残留”的直接原因。 - * - * @param [IN] clip 裁剪矩形;传空矩形表示不裁剪。 - */ - void SetClip(const gd::RectF& clip) { m_clip = clip; } - /** @brief 追加一个 box(空矩形 / 与裁剪矩形无交集的 box 自动忽略)。 */ - void Add(const gd::RectF& rect, int depth); - /** @brief 追加一组同层级的 box(空矩形逐个忽略)。 */ - void AddAll(const std::vector& rects, int depth); - /** - * @brief 登记一个**遮挡矩形**(浮层本体,如弹出菜单 / 拾色器)。 - * - * ⚠️ 必须做这件事:调试 box 是画在所有内容**之后**的,仅靠 `layer` 排序只能决定 - * “两个 box 重叠时谁压谁”,并不能阻止“下层组件的边框线画在不透明浮层上面”。 - * 登记为 occluder 后,**绘制层比它低**的 box 在该区域内会被裁掉, - * 于是观察右键菜单的空间布局时不会被底下的行/网格边界干扰。 - * - * @param [IN] rect 遮挡矩形(客户区坐标;空矩形忽略)。 - */ - void AddOccluder(const gd::RectF& rect); - /** @brief 是否为空。 */ - bool Empty() const { return m_boxes.empty(); } - /** @brief box 数量。 */ - std::size_t Count() const { return m_boxes.size(); } - /** @brief 只读访问(**未排序**,绘制时内部会按 layer/depth 稳定排序)。 */ - const std::vector& Items() const { return m_boxes; } - - /** @brief 一个遮挡矩形(含它所在的绘制层)。 */ - struct Occluder - { - gd::RectF rect; /**< 遮挡矩形。 */ - int layer = 0; /**< 所在绘制层(只遮挡更低的层)。 */ - }; - /** @brief 遮挡矩形集合(只读)。 */ - const std::vector& Occluders() const { return m_occluders; } - - private: - std::vector m_boxes; - std::vector m_occluders; - int m_layer = 0; /**< 当前绘制层。 */ - gd::RectF m_clip; /**< 当前裁剪矩形(空 = 不裁剪)。 */ - }; - - /** - * @brief 绘制一个矩形的布局边界(**四角粗、四边细**,且粗细随矩形尺寸自适应)。 + * @brief 绘制一个矩形的布局边界(**四角粗、四边细**,粗细随矩形尺寸自适应,受 `clip` 裁剪)。 * * 视觉规则: * * 四条边固定 1 设备像素; @@ -138,32 +45,22 @@ namespace jdi * * @param [IN,OUT] g 绘图上下文。 * @param [IN] rect 目标矩形(客户区坐标)。 - * @param [IN] depth 层级(决定配色与包角基础粗细)。 - */ - void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth); - - /** - * @brief `UiDebugPaintBounds` 的内部实现(带裁剪矩形)。 - * - * 供 `UiDebugPaintBoxList` 使用:非空 `clip` 表示"只在所属组件的地盘内绘制"。 - * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 目标矩形。 * @param [IN] clip 裁剪矩形(空 = 不裁剪)。 - * @param [IN] depth 层级。 + * @param [IN] depth 层级(决定配色与包角基础粗细)。 */ void UiDebugPaintBoundsClipped(gd::Graphics& g, const gd::RectF& rect, const gd::RectF& clip, int depth); /** - * @brief 一次性画完整个 box 列表(在宿主绘制的最后一步调用)。 + * @brief 一次性画完整个 box 表(在宿主绘制的最后一步调用)。 * * 按 `(layer, depth)` 稳定排序后绘制:后收集的层压住先收集的层,同层内父先子后。 + * `layer` 比某个遮挡矩形低的 box 会被该矩形挖洞裁掉(见 `UiBoxTable::AddOccluder`)。 * * @param [IN,OUT] g 绘图上下文。 - * @param [IN] boxes box 列表。 + * @param [IN] boxes 几何 box 表。 */ - void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes); + void UiDebugPaintBoxList(gd::Graphics& g, const UiBoxTable& boxes); } // namespace jdi diff --git a/Components/Shared/Inc/UiWidget.hpp b/Components/Shared/Inc/UiWidget.hpp index d86e03b..ba5191a 100644 --- a/Components/Shared/Inc/UiWidget.hpp +++ b/Components/Shared/Inc/UiWidget.hpp @@ -17,7 +17,7 @@ namespace jdi struct Theme; struct UiFonts; - class UiDebugBoxList; + class UiBoxTable; /** * @brief 裁剪时允许超出控件矩形的像素数(逻辑像素 × scale)。 @@ -217,7 +217,7 @@ namespace jdi * @param [IN,OUT] boxes 收集器。 * @param [IN] depth 层级(0 = 调用者自身,子节点依次 +1,决定描边颜色)。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const; + void BuildBoxes(UiBoxTable& boxes, int depth) const; /** @brief 局部失效:与自身矩形求交后向根冒泡。 */ virtual void Invalidate(const gd::RectF& rect); diff --git a/Components/Shared/Src/ColorPickerPopup.cpp b/Components/Shared/Src/ColorPickerPopup.cpp index 7a51a18..d519cd5 100644 --- a/Components/Shared/Src/ColorPickerPopup.cpp +++ b/Components/Shared/Src/ColorPickerPopup.cpp @@ -1,7 +1,7 @@ #include "ColorPickerPopup.hpp" #include "UiControls.hpp" -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" #include #include #include @@ -1094,7 +1094,7 @@ namespace jdi } } - void ColorPickerPopup::CollectDebugBoxes(UiDebugBoxList& boxes) const + void ColorPickerPopup::BuildBoxes(UiBoxTable& boxes) const { if (!m_open) { diff --git a/Components/Shared/Src/PaintUtil.cpp b/Components/Shared/Src/PaintUtil.cpp index 6f0d7e7..7988664 100644 --- a/Components/Shared/Src/PaintUtil.cpp +++ b/Components/Shared/Src/PaintUtil.cpp @@ -575,6 +575,19 @@ namespace jdi return g_textRectSink; } + void UiSetTextLayer(int layer) + { + if (g_textRectSink != nullptr) + { + g_textRectSink->OnTextLayer(layer); + } + } + + int UiGetTextLayer() + { + return (g_textRectSink != nullptr) ? g_textRectSink->TextLayer() : 0; + } + void UiSetTextRectOffset(float dx, float dy) { g_textRectOffsetX = dx; @@ -663,4 +676,40 @@ namespace jdi g.ReleaseHDC(hdc); } + void UiPaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float scale) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float inset = 2.5f * scale; + const 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; + } + const gd::Color color = ToGdiColor(argb); + gd::Pen pen(color, 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(color); + g.FillPolygon(&brush, arrow, 3); + } + } // namespace jdi diff --git a/Components/Shared/Src/PopupMenu.cpp b/Components/Shared/Src/PopupMenu.cpp index 9e2af09..b1557ac 100644 --- a/Components/Shared/Src/PopupMenu.cpp +++ b/Components/Shared/Src/PopupMenu.cpp @@ -5,7 +5,7 @@ #include "PopupMenuInternal.hpp" #include "UiControls.hpp" -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" #include "UiLayout.hpp" namespace jdi @@ -491,7 +491,12 @@ namespace jdi result.id = item.id; result.swatchIndex = -1; result.itemRect = m_subItemRects[static_cast(subPressed)]; - Close(); + // 带复选框的子菜单项(如「主题配色」的四个主题)选择后**保持菜单打开**, + // 便于连续预览;勾选态由宿主通过 `UpdateSubmenuItemLabel` 回写。 + if (!item.keepOpen) + { + Close(); + } } } return true; @@ -706,7 +711,7 @@ namespace jdi } - void PopupMenu::CollectDebugBoxes(UiDebugBoxList& boxes) const + void PopupMenu::BuildBoxes(UiBoxTable& boxes) const { if (!m_open) { @@ -719,10 +724,15 @@ namespace jdi boxes.AddOccluder(m_rect); // 层级 1:每个条目(含标题 / 分隔线 / 色块行,它们的矩形都已算好)。 + // ⚠️ **必须裁剪到可视区**:`m_itemRects` 里包含**滚出视口**的条目(它们只是不绘制), + // 不裁剪会把调试框画到菜单外面 —— 看起来就像"滚动时条目仍然在绘制", + // 也让菜单的边界看起来"超出了自身"(用户 2026-10-29 报告)。 + boxes.SetClip(m_scrollEnabled ? m_scrollViewport : m_rect); for (const gd::RectF& item : m_itemRects) { boxes.Add(item, 1); } + boxes.SetClip(gd::RectF()); // 层级 1/2:可滚动菜单的可视区、轨道与滑块。 boxes.Add(m_scrollViewport, 1); @@ -735,10 +745,13 @@ namespace jdi boxes.PushLayer(); boxes.Add(m_subRect, 0); boxes.AddOccluder(m_subRect); + // 同理:子菜单条目也必须裁到子菜单自身,否则滚动时同样会"溢出"。 + boxes.SetClip(m_subRect); for (const gd::RectF& item : m_subItemRects) { boxes.Add(item, 1); } + boxes.SetClip(gd::RectF()); } } } // namespace jdi diff --git a/Components/Shared/Src/PopupMenuLayout.cpp b/Components/Shared/Src/PopupMenuLayout.cpp index 3846fad..c32c543 100644 --- a/Components/Shared/Src/PopupMenuLayout.cpp +++ b/Components/Shared/Src/PopupMenuLayout.cpp @@ -750,4 +750,38 @@ namespace jdi return false; } + bool PopupMenu::UpdateSubmenuItemLabel(int id, const std::wstring& label, bool checked, + bool enabled) + { + for (std::size_t parentIndex = 0; parentIndex < m_items.size(); ++parentIndex) + { + MenuItem& parent = m_items[parentIndex]; + if (parent.kind != MenuItemKind::SUBMENU) + { + continue; + } + for (MenuItem& option : parent.submenu) + { + if (option.id != id || option.kind != MenuItemKind::NORMAL) + { + continue; + } + if (option.label == label && option.checked == checked && option.enabled == enabled) + { + return true; + } + option.label = label; + option.checked = checked; + option.enabled = enabled; + // 子菜单是 `parent.submenu` 的**快照**(`m_subItems`),必须重建才生效。 + if (m_subOpen && m_subParentIndex == static_cast(parentIndex)) + { + BuildSubLayout(static_cast(parentIndex)); + } + return true; + } + } + return false; + } + } // namespace jdi diff --git a/Components/Shared/Src/Theme.cpp b/Components/Shared/Src/Theme.cpp index 7b93544..6a858e2 100644 --- a/Components/Shared/Src/Theme.cpp +++ b/Components/Shared/Src/Theme.cpp @@ -1,5 +1,8 @@ #include "Theme.hpp" +#include +#include + namespace jdi { @@ -32,8 +35,41 @@ namespace jdi MakeArgb(kBase01), MakeArgb(kBase0), }; - /** @brief 全局唯一主题实例。 */ - const Theme kSolarizedLight = [] + /** + * @brief 主题**最终化**:确定网格叠底色,并保证结构色在**它**之上可辨。 + * + * ⚠️⚠️ **参照底色必须是 `laneBg`(网格叠底),不是 `plotBg`**: + * 网格线画在每条网格内部的叠底上。深色主题里 `plotBg`(#1E1E1E)与 `laneBg` + * (#2D2D30)亮度差得远,只按 `plotBg` 校正会让网格线落进叠底色的亮度范围 + * ⇒ 实测对比度只有 **1.07**(几乎看不见,用户 2026-10-30 报告)。 + * + * 阈值取"Solarised Light 现有配色刚好达标"的水位(针对 `laneBg` 实测: + * 1.335 / 1.771 / 2.395 / 2.395),因此默认主题**逐位不变**;其它主题若不足才被校正。 + * + * | 字段 | 阈值(相对 `laneBg`) | Solarised Light 现状 | + * | --- | ---: | ---: | + * | `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 1.335 | + * | `gridStrong`(零线/加重线) | 1.70 | 1.771 | + * | `axisLine`(时间轴、网格分界线、绘图区外框) | 2.10 | 2.395 | + * | `borderStrong`(网格间分界线) | 1.70 | 2.395 | + */ + Theme FinalizeTheme(Theme theme) + { + const std::uint32_t plot = theme.plotBg | 0xFF000000u; + // 网格叠底:未显式配置时回退到 `cardBg`,并保证与绘图区底色可辨(1.03)。 + const std::uint32_t laneSource = + (theme.laneBg != 0u) ? (theme.laneBg | 0xFF000000u) : (theme.cardBg | 0xFF000000u); + theme.laneBg = EnsureContrast(plot, laneSource, 1.03); + + theme.gridWeak = EnsureContrast(theme.laneBg, theme.gridWeak | 0xFF000000u, 1.30); + theme.gridStrong = EnsureContrast(theme.laneBg, theme.gridStrong | 0xFF000000u, 1.70); + theme.axisLine = EnsureContrast(theme.laneBg, theme.axisLine | 0xFF000000u, 2.10); + theme.borderStrong = EnsureContrast(theme.laneBg, theme.borderStrong | 0xFF000000u, 1.70); + return theme; + } + + /** @brief 全局唯一主题实例(Solarised Light;历史默认主题)。 */ + const Theme kSolarizedLight = FinalizeTheme([] { Theme theme; theme.windowBg = MakeArgb(kBase3); @@ -55,8 +91,10 @@ namespace jdi theme.pressed = MakeArgb(0xD8CFB6u); theme.selection = MakeArgb(0xDCEAF6u); - // 网格线需要在绘图区底色(kBase3)上清晰可辨,同时又要与网格边界 - // (axisLine/borderStrong 的蓝灰)明显区分——因此采用偏暖的加深米色。 + // 网格线需要在**网格叠底**(kBase3 推暗 1.03 ≈ #F9F2E0)上清晰可辨,同时又要与 + // 网格边界(axisLine/borderStrong 的蓝灰)明显区分——因此采用偏暖的加深米色。 + // 这三个值相对叠底色的对比度分别是 1.335 / 1.771 / 2.395,已达标 ⇒ 最终化不改动。 + theme.laneBg = MakeArgb(0xF9F2E0u); theme.gridWeak = MakeArgb(0xDDD3B8u); theme.gridStrong = MakeArgb(0xC3B896u); theme.axisLine = MakeArgb(kBase1); @@ -65,12 +103,287 @@ namespace jdi theme.danger = MakeArgb(kRed); theme.warning = MakeArgb(kYellow); return theme; - }(); + }()); + + /** + * @brief 浅色主题(参考 VS Code 2026 的默认浅色配色)。 + * + * 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色, + * 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。 + */ + const Theme kLight = FinalizeTheme([] + { + Theme theme; + theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background + theme.toolbarBg = MakeArgb(0xF8F8F8u); // titleBar/sideBar + theme.panelBg = MakeArgb(0xF8F8F8u); + theme.cardBg = MakeArgb(0xFFFFFFu); + theme.plotBg = MakeArgb(0xFFFFFFu); + theme.border = MakeArgb(0xE5E5E5u); // panel.border + theme.borderStrong = MakeArgb(0xC3C3C3u); + + theme.text = MakeArgb(0x3B3B3Bu); // foreground + theme.textStrong = MakeArgb(0x1F1F1Fu); + theme.textMuted = MakeArgb(0x8B8B8Bu); // descriptionForeground + theme.textOnAccent = MakeArgb(0xFFFFFFu); + + theme.accent = MakeArgb(0x0078D4u); // button.background + theme.accentSoft = MakeArgb(0xE5F1FBu); + theme.hover = MakeArgb(0xF2F2F2u); // list.hoverBackground + theme.pressed = MakeArgb(0xE8E8E8u); + theme.selection = MakeArgb(0xE8E8E8u); + + // 网格叠底比绘图区白底略暗一档,便于分辨相邻网格; + // 结构色按**叠底色**选中(对比度:1.30 / 1.70 / 2.10),不要再用原白底的值。 + theme.laneBg = MakeArgb(0xFBFBFBu); + theme.gridWeak = MakeArgb(0xDEDEDEu); + theme.gridStrong = MakeArgb(0xC3C3C3u); + theme.axisLine = MakeArgb(0xAFAFAFu); + theme.cursor = MakeArgb(0xD18616u); // VS Code charts.orange + theme.readoutBg = MakeArgb(0xF3F3F3u); + theme.danger = MakeArgb(0xE51400u); // errorForeground + theme.warning = MakeArgb(0xBF8803u); + return theme; + }()); + + /** + * @brief 深色主题(参考 VS Code Dark+)。 + */ + const Theme kDark = FinalizeTheme([] + { + Theme theme; + theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background + theme.toolbarBg = MakeArgb(0x252526u); // sideBar.background + theme.panelBg = MakeArgb(0x252526u); + theme.cardBg = MakeArgb(0x2D2D30u); + theme.plotBg = MakeArgb(0x1E1E1Eu); + theme.border = MakeArgb(0x3C3C3Cu); + theme.borderStrong = MakeArgb(0x505050u); + + theme.text = MakeArgb(0xCCCCCCu); + theme.textStrong = MakeArgb(0xFFFFFFu); + theme.textMuted = MakeArgb(0x8C8C8Cu); + theme.textOnAccent = MakeArgb(0xFFFFFFu); + + theme.accent = MakeArgb(0x007ACCu); // focusBorder / statusBar + theme.accentSoft = MakeArgb(0x094771u); // list.activeSelectionBackground + theme.hover = MakeArgb(0x2A2D2Eu); // list.hoverBackground + theme.pressed = MakeArgb(0x37373Du); + theme.selection = MakeArgb(0x04395Eu); + + // ⚠️ 深色主题的结构色**必须比旧值亮得多**:它们画在网格叠底(#2D2D30)上, + // 旧值(#2B2B2B / #3A3A3A)与叠底几乎同亮度(实测对比度 1.07)⇒ 网格线看不见。 + // 这里的值按叠底色选:1.30 / 1.70 / 2.10。 + theme.laneBg = MakeArgb(0x2D2D30u); + theme.gridWeak = MakeArgb(0x3F3F3Fu); + theme.gridStrong = MakeArgb(0x505050u); + theme.axisLine = MakeArgb(0x5E5E5Eu); + theme.cursor = MakeArgb(0xD18616u); + theme.readoutBg = MakeArgb(0x2D2D30u); + theme.danger = MakeArgb(0xF44747u); + theme.warning = MakeArgb(0xCCA700u); + return theme; + }()); + + /** + * @brief Solarised Dark(参考 VS Code 的 Solarized Dark)。 + * + * 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表 + * (`SignalPalette`)与 Solarised Light 完全一致。 + */ + const Theme kSolarizedDark = FinalizeTheme([] + { + Theme theme; + theme.windowBg = MakeArgb(kBase03); + theme.toolbarBg = MakeArgb(kBase02); + theme.panelBg = MakeArgb(kBase02); + theme.cardBg = MakeArgb(kBase03); + theme.plotBg = MakeArgb(kBase03); + theme.border = MakeArgb(0x18505Fu); + theme.borderStrong = MakeArgb(0x205363u); + + theme.text = MakeArgb(kBase0); + theme.textStrong = MakeArgb(kBase1); + theme.textMuted = MakeArgb(kBase01); + theme.textOnAccent = MakeArgb(kBase03); + + theme.accent = MakeArgb(kBlue); + theme.accentSoft = MakeArgb(0x12414Fu); + theme.hover = MakeArgb(0x10505Fu); + theme.pressed = MakeArgb(0x0A3F4Cu); + theme.selection = MakeArgb(0x12414Fu); + + // 网格叠底与绘图区底色同为 base03 时看不出网格层次 ⇒ 显式给出略亮一档的叠底, + // 结构色按**它**选(对比度 1.30 / 1.70 / 2.10)。 + theme.laneBg = MakeArgb(0x032E38u); + theme.gridWeak = MakeArgb(0x13414Cu); + theme.gridStrong = MakeArgb(0x205363u); + theme.axisLine = MakeArgb(kBase01); + theme.cursor = MakeArgb(kOrange); + theme.readoutBg = MakeArgb(kBase02); + theme.danger = MakeArgb(kRed); + theme.warning = MakeArgb(kYellow); + return theme; + }()); + + /** @brief 全局「当前主题」指针(由宿主设置,见 `SetCurrentTheme`)。 */ + const Theme* g_currentTheme = &kLight; } - const Theme& SolarizedLight() + double SrgbLuminance(std::uint32_t argb) { - return kSolarizedLight; + // sRGB → 线性光:> 0.03928 用 2.4 次幂,否则用 12.92 线性段(WCAG 2.x)。 + const auto channel = [](std::uint32_t value) -> double + { + const double c = static_cast(value & 0xFFu) / 255.0; + return (c <= 0.03928) ? (c / 12.92) : std::pow((c + 0.055) / 1.055, 2.4); + }; + const double r = channel((argb >> 16) & 0xFFu); + const double g = channel((argb >> 8) & 0xFFu); + const double b = channel(argb & 0xFFu); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + double ContrastRatio(std::uint32_t firstColor, std::uint32_t secondColor) + { + const double first = SrgbLuminance(firstColor); + const double second = SrgbLuminance(secondColor); + const double high = std::max(first, second); + const double low = std::min(first, second); + return (high + 0.05) / (low + 0.05); + } + + std::uint32_t EnsureContrast(std::uint32_t background, std::uint32_t foreground, double minRatio) + { + if (!(minRatio > 1.0) || ContrastRatio(background, foreground) >= minRatio) + { + return foreground; // 已达标(或不限制):**逐位返回原色** + } + // 远离底色:深底就提亮、浅底就加深。二分求解"刚好达标"的混色量, + // 使输出随底色**连续**变化(不会在阈值附近两个极端色之间跳)。 + const bool lighten = SrgbLuminance(background) < 0.5; + const std::uint32_t extreme = lighten ? 0xFFFFFFFFu : 0xFF000000u; + double low = 0.0; + double high = 1.0; + for (int iteration = 0; iteration < 18; ++iteration) + { + const double middle = (low + high) * 0.5; + if (ContrastRatio(background, MixColor(foreground, extreme, middle)) < minRatio) + { + low = middle; + } + else + { + high = middle; + } + } + return MixColor(foreground | 0xFF000000u, extreme, high); + } + + std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance) + { + const std::uint32_t base = color | 0xFF000000u; + const double current = SrgbLuminance(base); + if (!(targetLuminance > 0.0)) + { + targetLuminance = 0.0; + } + if (targetLuminance > 1.0) + { + targetLuminance = 1.0; + } + if (std::fabs(targetLuminance - current) < 1.0e-4) + { + return base; + } + // 亮度沿"混向白 / 混向黑"单调 ⇒ 二分解出混色量; + // 目标亮度等于当前亮度时混色量为 0(等价返回原色),因此两侧在该点**连续衔接**。 + const std::uint32_t extreme = (targetLuminance > current) ? 0xFFFFFFFFu : 0xFF000000u; + double low = 0.0; + double high = 1.0; + for (int iteration = 0; iteration < 20; ++iteration) + { + const double middle = (low + high) * 0.5; + const double luminance = SrgbLuminance(MixColor(base, extreme, middle)); + if (targetLuminance > current) + { + if (luminance < targetLuminance) + { + low = middle; + } + else + { + high = middle; + } + } + else + { + if (luminance > targetLuminance) + { + low = middle; + } + else + { + high = middle; + } + } + } + return MixColor(base, extreme, high); + } + + std::uint32_t ThemeLaneBackground(const Theme& theme) + { + // 网格叠底色是**主题配色的一部分**(`Theme::laneBg`),由 `FinalizeTheme` 在构建主题时 + // 保证与绘图区底色可辨;这里只做"未配置"的兜底。 + if (theme.laneBg != 0u) + { + return theme.laneBg | 0xFF000000u; + } + const std::uint32_t plot = theme.plotBg | 0xFF000000u; + return EnsureContrast(plot, theme.cardBg | 0xFF000000u, 1.03); + } + + const Theme& ThemeFor(ThemeKind kind) + { + switch (kind) + { + case ThemeKind::DARK: + return kDark; + case ThemeKind::SOLARIZED_DARK: + return kSolarizedDark; + case ThemeKind::SOLARIZED_LIGHT: + return kSolarizedLight; + case ThemeKind::LIGHT: + default: + // 默认主题 = Light(用户 2026-11-02 要求;旧默认是 Solarised Light)。 + return kLight; + } + } + + const wchar_t* ThemeName(ThemeKind kind) + { + switch (kind) + { + case ThemeKind::DARK: + return L"Dark"; + case ThemeKind::SOLARIZED_DARK: + return L"Solarised Dark"; + case ThemeKind::SOLARIZED_LIGHT: + return L"Solarised Light"; + case ThemeKind::LIGHT: + default: + return L"Light"; + } + } + + const Theme& CurrentTheme() + { + return (g_currentTheme != nullptr) ? *g_currentTheme : kLight; + } + + void SetCurrentTheme(const Theme* theme) + { + g_currentTheme = (theme != nullptr) ? theme : &kLight; } const std::uint32_t* SignalPalette(int& count) diff --git a/Components/Shared/Src/UiBox.cpp b/Components/Shared/Src/UiBox.cpp new file mode 100644 index 0000000..238e42c --- /dev/null +++ b/Components/Shared/Src/UiBox.cpp @@ -0,0 +1,63 @@ +#include "UiBox.hpp" + +namespace jdi +{ + + void UiBoxTable::Clear() + { + m_boxes.clear(); + m_occluders.clear(); + m_layer = 0; + m_clip = gd::RectF(); + } + + void UiBoxTable::OnTextRect(const gd::RectF& rect) + { + // 文本用固定层级 4:它总是"最里层"的元素(复选框里的字、行里的信号名…)。 + Add(rect, 4); + } + + void UiBoxTable::Add(const gd::RectF& rect, int depth) + { + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return; // 空矩形(未布局 / 该元素当前不显示)不登记。 + } + if (m_clip.Width > 0.0f && m_clip.Height > 0.0f) + { + // 与所属组件的区域无交集 ⇒ 已经完全滚出地盘,不必登记(也避免越界登记到邻居的地盘上)。 + if (rect.X + rect.Width <= m_clip.X || rect.X >= m_clip.X + m_clip.Width || + rect.Y + rect.Height <= m_clip.Y || rect.Y >= m_clip.Y + m_clip.Height) + { + return; + } + } + UiBox box; + box.rect = rect; + box.clip = m_clip; + box.depth = (depth < 0) ? 0 : depth; + box.layer = m_layer; + m_boxes.push_back(box); + } + + void UiBoxTable::AddAll(const std::vector& rects, int depth) + { + for (const gd::RectF& rect : rects) + { + Add(rect, depth); + } + } + + void UiBoxTable::AddOccluder(const gd::RectF& rect) + { + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return; + } + Occluder occluder; + occluder.rect = rect; + occluder.layer = m_layer; + m_occluders.push_back(occluder); + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiComponent.cpp b/Components/Shared/Src/UiComponent.cpp index a72490f..629c369 100644 --- a/Components/Shared/Src/UiComponent.cpp +++ b/Components/Shared/Src/UiComponent.cpp @@ -1,13 +1,13 @@ #include "UiComponent.hpp" -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" namespace jdi { // 默认实现放在 .cpp 而不是头文件内联:`UiComponent.hpp` 是四个大组件都要包含的 - // 公共头,不应把 `UiDebugBounds.hpp`(box 收集器)也拉进它们的编译单元。 - void UiComponent::CollectDebugBoxes(UiDebugBoxList& boxes) + // 公共头,不应把 `UiBox.hpp`(几何收集器)也拉进它们的编译单元。 + void UiComponent::BuildBoxes(UiBoxTable& boxes) { boxes.Add(Area(), 0); } diff --git a/Components/Shared/Src/UiControls.cpp b/Components/Shared/Src/UiControls.cpp index 81c04b3..e7db33f 100644 --- a/Components/Shared/Src/UiControls.cpp +++ b/Components/Shared/Src/UiControls.cpp @@ -121,7 +121,7 @@ namespace jdi } void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius) { - const Theme& theme = SolarizedLight(); + const Theme& theme = CurrentTheme(); const float centerY = rect.Y + rect.Height * 0.5f; if (state == 1) @@ -251,7 +251,7 @@ namespace jdi return; } - const Theme& theme = SolarizedLight(); + const Theme& theme = CurrentTheme(); const float corner = 3.0f * scale; UiFillRoundRect(g, rect, corner, theme.cardBg); @@ -268,7 +268,7 @@ namespace jdi return; } - const Theme& theme = SolarizedLight(); + const Theme& theme = CurrentTheme(); const std::wstring& text = edit.Text(); const float innerLeft = rect.X + 6.0f * scale; @@ -390,7 +390,7 @@ namespace jdi // 透明度过低时色块本身难以辨识,补充一圈描边以保持可读性;可见时不绘制描边。 if (UiSwatchNeedsOutline(opacity)) { - UiDrawRoundRect(g, rect, radius, SolarizedLight().borderStrong, + UiDrawRoundRect(g, rect, radius, CurrentTheme().borderStrong, std::max(1.0f, scale)); } } diff --git a/Components/Shared/Src/UiDebugBounds.cpp b/Components/Shared/Src/UiDebugBounds.cpp index f01608f..6bd12e1 100644 --- a/Components/Shared/Src/UiDebugBounds.cpp +++ b/Components/Shared/Src/UiDebugBounds.cpp @@ -77,63 +77,6 @@ namespace jdi } } // namespace - void UiDebugBoxList::Clear() - { - m_boxes.clear(); - m_occluders.clear(); - m_layer = 0; - m_clip = gd::RectF(); - } - - void UiDebugBoxList::OnTextRect(const gd::RectF& rect) - { - // 文本用固定层级 4:它总是"最里层"的元素(复选框里的字、行里的信号名…)。 - Add(rect, 4); - } - - void UiDebugBoxList::Add(const gd::RectF& rect, int depth) - { - if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) - { - return; // 空矩形(未布局 / 该元素当前不显示)不登记。 - } - if (m_clip.Width > 0.0f && m_clip.Height > 0.0f) - { - // 与所属组件的区域无交集 ⇒ 已经完全滚出地盘,不必登记(也避免越界画到邻居上)。 - if (rect.X + rect.Width <= m_clip.X || rect.X >= m_clip.X + m_clip.Width || - rect.Y + rect.Height <= m_clip.Y || rect.Y >= m_clip.Y + m_clip.Height) - { - return; - } - } - UiDebugBox box; - box.rect = rect; - box.clip = m_clip; - box.depth = (depth < 0) ? 0 : depth; - box.layer = m_layer; - m_boxes.push_back(box); - } - - void UiDebugBoxList::AddAll(const std::vector& rects, int depth) - { - for (const gd::RectF& rect : rects) - { - Add(rect, depth); - } - } - - void UiDebugBoxList::AddOccluder(const gd::RectF& rect) - { - if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) - { - return; - } - Occluder occluder; - occluder.rect = rect; - occluder.layer = m_layer; - m_occluders.push_back(occluder); - } - std::uint32_t UiDebugBoundsColor(int depth) { // 与主题无关的固定配色:调试层必须"永远显眼",不能跟随用户主题变成接近底色。 @@ -152,11 +95,6 @@ namespace jdi } } - void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth) - { - UiDebugPaintBoundsClipped(g, rect, gd::RectF(), depth); - } - void UiDebugPaintBoundsClipped(gd::Graphics& g, const gd::RectF& rect, const gd::RectF& clip, int depth) { @@ -174,7 +112,7 @@ namespace jdi return; } - // ⚠️ 与裁剪矩形求交(见 `UiDebugBoxList::SetClip`):组件内部的元素可能落在地盘之外。 + // ⚠️ 与裁剪矩形求交(见 `UiBoxTable::SetClip`):组件内部的元素可能落在地盘之外。 // 用 floor/ceil 而不是取整:裁剪是"能看见多少就画多少",宁可多一像素也不要把 // 紧贴边界的包角整个裁掉。 if (clip.Width > 0.0f && clip.Height > 0.0f) @@ -240,7 +178,7 @@ namespace jdi g.Restore(state); } - void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes) + void UiDebugPaintBoxList(gd::Graphics& g, const UiBoxTable& boxes) { if (boxes.Empty()) { @@ -251,14 +189,14 @@ namespace jdi // * `layer` 保证"后收集的(更靠上的浮层)压住先收集的" —— 没有它,信号面板的行边界 // 会盖在右键弹出的二级菜单上面; // * `depth` 保证同一层内先画父再画子,子元素的彩色包角不会被父的边压住。 - std::vector ordered; + std::vector ordered; ordered.reserve(boxes.Count()); - for (const UiDebugBox& box : boxes.Items()) + for (const UiBox& box : boxes.Items()) { ordered.push_back(&box); } std::stable_sort(ordered.begin(), ordered.end(), - [](const UiDebugBox* lhs, const UiDebugBox* rhs) + [](const UiBox* lhs, const UiBox* rhs) { if (lhs->layer != rhs->layer) { @@ -267,14 +205,14 @@ namespace jdi return lhs->depth < rhs->depth; }); - for (const UiDebugBox* box : ordered) + for (const UiBox* box : ordered) { // ⚠️ 遮挡:浮层(菜单 / 拾色器)是不透明的,它上面的内容不该出现下层组件的边框线。 // 仅靠 layer 排序只能决定"两个 box 重叠时谁压谁",不能阻止"先画的线仍然可见", // 因此这里把**更高层**的遮挡矩形从裁剪区里排除。 gd::Region region; bool occluded = false; - for (const UiDebugBoxList::Occluder& occluder : boxes.Occluders()) + for (const UiBoxTable::Occluder& occluder : boxes.Occluders()) { if (occluder.layer <= box->layer) { diff --git a/Components/Shared/Src/UiWidget.cpp b/Components/Shared/Src/UiWidget.cpp index 7d726f4..eb537fe 100644 --- a/Components/Shared/Src/UiWidget.cpp +++ b/Components/Shared/Src/UiWidget.cpp @@ -3,7 +3,7 @@ #include #include -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" namespace jdi { @@ -255,7 +255,7 @@ namespace jdi return UiRectHasArea(TakeDirty()); } - void UiWidget::CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const + void UiWidget::BuildBoxes(UiBoxTable& boxes, int depth) const { if (!m_visible || !UiRectHasArea(m_bounds)) { @@ -270,7 +270,7 @@ namespace jdi { if (child != nullptr) { - child->CollectDebugBoxes(boxes, depth + 1); + child->BuildBoxes(boxes, depth + 1); } } } diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 1669f23..a632ac3 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -20,22 +20,18 @@ namespace jdi { - class UiDebugBoxList; + class UiBoxTable; /** * @brief 信号面板弹出菜单的命令标识。 */ enum MenuCommand : int { - CMD_SIGNAL_TOGGLE = 1, /**< 切换该信号可见性。 */ CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */ - CMD_SIGNAL_SHOW_ALL = 3, /**< 显示全部信号。 */ - CMD_SIGNAL_HIDE_ALL = 4, /**< 隐藏全部信号。 */ + CMD_SIGNAL_CLONE = 5, /**< 克隆该信号(应用「调整」并烘进采样值,时间轴偏移保留)。 */ CMD_GROUP_SHOW_ALL = 10, /**< 选中该分组全部信号。 */ CMD_GROUP_HIDE_ALL = 11, /**< 取消该分组全部信号。 */ CMD_GROUP_REMOVE = 12, /**< 从工作区移除该分组。 */ - CMD_COLOR_BASE = 100, /**< 预设颜色基准(配合色块下标)。 */ - CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */ CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */ CMD_LANE_NEW = 400, /**< 新建网格。 */ CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */ @@ -87,8 +83,7 @@ namespace jdi * @brief 标记列表结构失效(数据或折叠状态变化后调用)。 */ void InvalidateRows() - { - m_rowsDirty = true; // 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废, + { m_rowsDirty = true; // 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废, // 否则可能对一条已经不存在(或已被替换)的信号继续高亮它的网格。 m_hoveredSignal = SignalRef(); m_hoverRow = -1; @@ -133,6 +128,18 @@ namespace jdi */ void Paint(gd::Graphics& g) override; + /** + * @brief 主题配色变化后作废全部**像素级**缓存。 + * + * 行位图缓存里烘着主题色(底色 / 文字 / 描边),换主题后必须整体丢弃, + * 否则列表会停留在旧配色上(直到那些行因为别的原因被重画)。 + */ + void OnThemeChanged() + { + InvalidateRowCache(); + m_rowsDirty = true; + } + /** * @brief 处理鼠标移动。 * @param [IN] x 客户区横坐标。 @@ -317,6 +324,19 @@ namespace jdi */ SignalStyle* StyleOf(const SignalRef& ref); + /** + * @brief 当前是否存在**样式编辑目标**(选中信号且其样式可解析)。 + * + * ⚠️ 样式区的色块 / 样式 / 调整 / 线宽 / 透明度这些控件的矩形是**无条件**由 + * `LayoutStyleControls()` 算出来的(布局与"是否有目标"无关)。若不在使用处按本判据 + * 门控,就会出现用户 2026-11-02 报告的现象:什么都没选中时,打开「显示布局边界」 + * 能看到那一排控件的框,鼠标移到「系数/偏移 | 镜像 | 绝对值」上还会变成手型。 + * + * 使用处:`BuildBoxes`(几何上报)、`QueryCursor`(手型光标)。 + * 绘制与命中早已按 `StyleOf(SelectedRef()) != nullptr` 门控,这里只是补齐另外两处。 + */ + bool HasStyleTarget() const; + /** * @brief 获取当前选中信号(跨标签页共享,因此切换视图后选中项保持一致)。 * @return 信号引用。 @@ -366,19 +386,19 @@ namespace jdi HCURSOR QueryCursor(int x, int y) override; /** - * @brief 收集面板区域与其内部结构(表头 / 筛选框 / 列表 / 样式区 / 可见行 / 行内元素) - * 的布局边界(调试用)。 + * @brief 上报面板区域与其内部结构(表头 / 筛选框 / 列表 / 样式区 / 可见行 / 行内元素) + * 的几何 box。 * * 行内元素(复选框、色块、名称、GX 徽标、✕、折起三角、组计数)的几何与 * `RowHitPart` / `PaintRow` **同源**:都基于同一个 `RowRect` 与同一批常量, * 因此"画出来的框"就是"能点到的区域"。 * - * 面板的行位图缓存与本函数**无关**:调试 box 只由宿主在最后一步统一描边, - * 不进任何缓存 ⇒ 关掉开关后不会留下残影。 + * 面板的行位图缓存与本函数**无关**:box 只交给宿主在最后一步统一处理(目前是描边), + * 不进任何缓存 ⇒ 关掉「显示布局边界」后不会留下残影。 * * @param [IN,OUT] boxes 收集器。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes) override; + void BuildBoxes(UiBoxTable& boxes) override; /** * @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。 @@ -495,6 +515,20 @@ namespace jdi OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ WIDTH_SLIDER, /**< 线宽滑块。 */ WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ + /** + * @brief 线宽行右侧的**复位按钮**(把线宽恢复为该信号的默认值)。 + * + * 默认值:普通信号 = `kDefaultSignalLineWidth`(1.0 逻辑像素), + * 单数据点信号 = `kSinglePointSignalLineWidth`(最大线宽:散点半径随线宽增长)。 + * 图标与「系数/偏移」二级菜单标题栏的还原图标**完全同款**(`UiPaintResetIcon`)。 + */ + WIDTH_RESET, + /** + * @brief 透明度行右侧的**复位按钮**(把不透明度恢复为 100%)。 + * + * 与 `WIDTH_RESET` 同一套图标与悬停观感,两者右边界与内容列右缘对齐。 + */ + OPACITY_RESET, GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */ MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */ ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */ @@ -728,6 +762,15 @@ namespace jdi */ void BuildRows(); + /** + * @brief 行集合的“定义状态”签名(分组集合 / 每组可见项数 / 折起状态 / 是否有动画)。 + * + * `EnsureRows` 用它做**自愈校验**:只要签名与“上一次建行集合时的签名”不一致, + * 就重建行集合。这样可以彻底堵住“改了折起状态却忘了置 `m_rowsDirty`”这类缺陷 + *(用户 2026-10-30 报告:组名三角已显示展开,但下属信号行完全不出现)。 + */ + std::uint64_t RowsSignature() const; + /** * @brief 在需要时重建列表行集合。 */ @@ -1221,6 +1264,21 @@ namespace jdi */ bool ApplyOpacityFromX(float x); + /** + * @brief 把当前目标信号的**线宽恢复为默认值**(「线宽」行右侧复位按钮)。 + * + * 默认值与新建信号时同一来源(`DefaultSignalLineWidth(finiteCount)`): + * 普通信号 1.0 逻辑像素、**单数据点信号**取最大线宽(散点半径随线宽增长)。 + * @return 取值发生变化返回 true。 + */ + bool ResetStyleLineWidth(); + + /** + * @brief 把当前目标信号的**不透明度恢复为 100%**(「透明度」行右侧复位按钮)。 + * @return 取值发生变化返回 true。 + */ + bool ResetStyleOpacity(); + /** * @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。 * @@ -1278,6 +1336,15 @@ namespace jdi gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */ gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */ gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */ + /** + * @brief 线宽行右侧的**复位按钮**矩形(正方形,右边界与内容列右缘对齐)。 + * + * 点击把该信号的线宽恢复为默认值(单数据点信号与普通信号的默认线宽不同)。 + * 宽高为 0 表示当前不显示(没有目标信号)。 + */ + gd::RectF m_widthResetRect; + /** @brief 透明度行右侧的**复位按钮**矩形(与 `m_widthResetRect` 同一列、同一尺寸)。 */ + gd::RectF m_opacityResetRect; /** 「调整」行的三个等宽按钮(下标 = 内部常量 `kAdjust*`):0 = 系数/偏移,1 = 镜像,2 = 绝对值。 */ std::array m_adjustRects; @@ -1292,6 +1359,7 @@ namespace jdi */ std::vector m_rowTop; bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ + std::uint64_t m_rowsSignature = 0; /**< 建行集合时的 `RowsSignature()`(用于自愈校验)。 */ float m_contentHeight = 0.0f; /**< 列表内容总高度。 */ float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */ bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ diff --git a/Components/SignalPanel/Inc/SignalPanelInternal.hpp b/Components/SignalPanel/Inc/SignalPanelInternal.hpp index ae8b33a..b3527f0 100644 --- a/Components/SignalPanel/Inc/SignalPanelInternal.hpp +++ b/Components/SignalPanel/Inc/SignalPanelInternal.hpp @@ -174,8 +174,15 @@ namespace jdi inline const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */ inline const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */ inline const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ - inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ - inline const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ + inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ /** + * @brief 「线宽 / 透明度」行右侧**复位按钮**的边长(逻辑像素)。 + * + * 与「系数/偏移」二级菜单标题栏的还原图标同一观感(`UiPaintResetIcon`), + * 因此尺寸也取同一量级;行高不足时会被夹到行高。 + */ + inline const float kSliderResetSize = 16.0f; + /** @brief 数值文本与复位按钮之间的间距。 */ + inline const float kSliderResetGap = 4.0f; inline const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ inline const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 147f92e..112e64f 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -10,7 +10,7 @@ // SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执 #include "SignalPanelInternal.hpp" -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" #include "UiLayout.hpp" namespace jdi @@ -72,9 +72,9 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } - void SignalPanel::CollectDebugBoxes(UiDebugBoxList& boxes) + void SignalPanel::BuildBoxes(UiBoxTable& boxes) { - UiComponent::CollectDebugBoxes(boxes); + UiComponent::BuildBoxes(boxes); if (m_fonts == nullptr || !UiRectHasArea(m_area)) { @@ -100,32 +100,46 @@ namespace jdi boxes.Add(ScrollThumbRect(), 3); // ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ────────────────── - for (std::size_t index = 0; index < m_styleRowRects.size(); ++index) + // ⚠️ **只在存在样式编辑目标时登记控件几何**(用户 2026-11-02 报告): + // `LayoutStyleControls()` 无条件算出这些矩形,而绘制在“没有目标”时只画占位文字。 + // 旧实现照样上报 ⇒ 空面板上打开「显示布局边界」会看到一整排并不存在的控件框。 + const bool hasStyleTarget = HasStyleTarget(); + if (hasStyleTarget) { - boxes.Add(m_styleRowRects[index], 2); + for (std::size_t index = 0; index < m_styleRowRects.size(); ++index) + { + boxes.Add(m_styleRowRects[index], 2); + } + for (const gd::RectF& swatch : m_colorRects) + { + boxes.Add(swatch, 3); + } + boxes.Add(m_customChipRect, 3); + for (const gd::RectF& mode : m_modeRects) + { + boxes.Add(mode, 3); + } + for (const gd::RectF& adjust : m_adjustRects) + { + boxes.Add(adjust, 3); + } + boxes.Add(m_widthTrackRect, 3); + boxes.Add(m_widthValueRect, 3); + boxes.Add(m_widthResetRect, 3); + boxes.Add(m_opacityTrackRect, 3); + boxes.Add(m_opacityValueRect, 3); + boxes.Add(m_opacityResetRect, 3); } - for (const gd::RectF& swatch : m_colorRects) - { - boxes.Add(swatch, 3); - } - boxes.Add(m_customChipRect, 3); - for (const gd::RectF& mode : m_modeRects) - { - boxes.Add(mode, 3); - } - for (const gd::RectF& adjust : m_adjustRects) - { - boxes.Add(adjust, 3); - } - boxes.Add(m_widthTrackRect, 3); - boxes.Add(m_widthValueRect, 3); - boxes.Add(m_opacityTrackRect, 3); - boxes.Add(m_opacityValueRect, 3); // ── 「目标」行(样式区顶部,展示当前选中信号) ──────────────────── + // 这一行**无论是否选中**都会绘制(未选中时显示占位文本),所以本体总是登记; + // 但信号名 / GX 徽标只在实际有目标时存在。 boxes.Add(m_targetRowRect, 2); - boxes.Add(m_targetNameRect, 3); - boxes.Add(m_targetBadgeRect, 3); + if (hasStyleTarget) + { + boxes.Add(m_targetNameRect, 3); + boxes.Add(m_targetBadgeRect, 3); + } // ── 列表里**当前可见**的行,以及每行内部的元素 ──────────────────── // 被滚出列表的行不登记,否则边界会糊到列表区之外。 @@ -380,14 +394,6 @@ namespace jdi m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; } - // 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。 - // 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。 - m_rowTop.assign(m_rows.size() + 1, 0.0f); - for (std::size_t index = 0; index < m_rows.size(); ++index) - { - m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; - } - float totalShift = 0.0f; for (const GroupAnim& anim : m_groupAnims) { @@ -516,6 +522,8 @@ namespace jdi { m_rows.clear(); m_rowsDirty = false; + // 记住“本行集合对应的定义状态”(含空数据集的早退路径),供 `EnsureRows()` 做自愈校验。 + m_rowsSignature = RowsSignature(); // 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。 // 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。 InvalidateRowCache(); @@ -586,14 +594,65 @@ namespace jdi UpdateAnimGeometry(); } + std::uint64_t SignalPanel::RowsSignature() const + { + // FNV-1a:混合“每个分组是否列出、列出多少项、是否折起、是否有折起动画”。 + // 这四项恰好完全决定行集合的内容,因此它们不变时无需重建。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash = (hash ^ value) * 1099511628211ull; + }; + if (m_dataSet == nullptr || m_tab == nullptr) + { + return hash; + } + const std::vector& groups = m_dataSet->Groups(); + mix(static_cast(groups.size() + 1)); + // 筛选态下“每组列多少项”要逐信号比对(成本 O(信号数)),因此只在非筛选时入签名; + // 筛选文本变化本身会走 `ApplyFilterText()` 置脏,无需靠签名发现。 + const bool filtering = !m_filterText.empty(); + mix(filtering ? 1ull : 0ull); + for (std::size_t index = 0; index < groups.size(); ++index) + { + const int groupIndex = static_cast(index); + const std::uint64_t count = + filtering ? 0u : static_cast(groups[index].signals.size()); + mix(count + 2u); + mix(IsGroupCollapsed(groupIndex) ? 3u : 0u); + mix((FindGroupAnimation(groupIndex) >= 0) ? 5u : 0u); + } + return hash; + } + void SignalPanel::EnsureRows() { + // ⚠️ 动画推进只依赖挂钟时间(`AdvanceGroupAnimations`),而驱动它的 `WM_TIMER` 是 + // **最低优先级**的消息:绘图区若在自持刷新(例如边沿统计未完成时不断请求新帧), + // `WM_TIMER` 会被饿死 ⇒ 折起动画永远结束不了 ⇒ 组名三角已经显示“已收起”, + // 但行集合里仍保留着该组的信号行(可被点选)。用户 2026-11-02 报告的正是这个现象。 + // 因此绘制 / 命中路径也顺手推进一步:动画是否结束只看时间,不依赖消息到达。 + if (!m_groupAnims.empty()) + { + AdvanceGroupAnimations(); + } if (m_rowsDirty) { BuildRows(); RefreshScrollBar(); return; } + // ⚠️ **自愈不变量**:行集合必须与“分组集合 / 每组可见项数 / 折起状态 / 是否有动画” + // 严格一致。历史上确实出现过“折起状态变了但忘了置 `m_rowsDirty`”的路径 —— + // 表现就是用户 2026-10-30 报告的:组名三角已经显示“展开”, + // 但下属信号行一条都不出现(行集合里根本没有它们)。 + // 这里每帧花 O(分组数) 校验一次签名,不一致就地重建,不再依赖每个调用点自觉置脏。 + if (m_rowsSignature != RowsSignature()) + { + BuildRows(); + RefreshScrollBar(); + return; + } if (m_rowShift.size() != m_rows.size()) { // 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。 @@ -972,19 +1031,35 @@ namespace jdi } // ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)---- - const float valueWidth = std::min(kSliderValueWidth * m_scale, contentWidth * 0.4f); - const float trackWidth = std::max(24.0f, contentWidth - valueWidth - kSliderValueGap * m_scale); + // 两行右端各有一个**复位按钮**(用户 2026-10-29 要求): + // 按钮贴齐内容列右缘,数值文本与轨道在它左侧依次收缩 ⇒ 不会超出内容列。 + // 内容列太窄(放不下"轨道 + 数值 + 按钮")时整列不显示复位按钮,避免把滑块挤没。 + const float resetSize = (contentWidth >= kSliderResetSize * 3.0f * m_scale) + ? std::min(kSliderResetSize * m_scale, rowHeight) + : 0.0f; + const float resetGap = (resetSize > 0.0f) ? kSliderResetGap * m_scale : 0.0f; + const float sliderWidth = contentWidth - (resetSize > 0.0f ? (resetSize + resetGap) : 0.0f); + const float valueWidth = std::min(kSliderValueWidth * m_scale, sliderWidth * 0.4f); + const float trackWidth = std::max(24.0f, sliderWidth - valueWidth - kSliderValueGap * m_scale); const float trackHeight = kTrackHeight * m_scale; + const float valueLeft = contentLeft + trackWidth + kSliderValueGap * m_scale; + const float resetLeft = contentLeft + contentWidth - resetSize; m_widthTrackRect = gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); - m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y, - valueWidth, rowHeight); + m_widthValueRect = gd::RectF(valueLeft, widthRow.Y, valueWidth, rowHeight); + m_widthResetRect = + (resetSize > 0.0f) + ? gd::RectF(resetLeft, widthRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize) + : gd::RectF(); m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); - m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y, - valueWidth, rowHeight); + m_opacityValueRect = gd::RectF(valueLeft, opacityRow.Y, valueWidth, rowHeight); + m_opacityResetRect = + (resetSize > 0.0f) + ? gd::RectF(resetLeft, opacityRow.Y + (rowHeight - resetSize) * 0.5f, resetSize, resetSize) + : gd::RectF(); // 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化), // 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。 @@ -1391,6 +1466,17 @@ namespace jdi return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr; } + bool SignalPanel::HasStyleTarget() const + { + // 与 `PaintStyleSection` / `OnLButtonDown` 的判据**完全同源**:选中信号且样式可解析。 + if (m_tab == nullptr || m_dataSet == nullptr) + { + return false; + } + const SignalRef& selected = SelectedRef(); + return selected.IsValid() && StyleOf(selected) != nullptr; + } + const SignalRef& SignalPanel::SelectedRef() const { static const SignalRef fallback; diff --git a/Components/SignalPanel/Src/SignalPanelEdit.cpp b/Components/SignalPanel/Src/SignalPanelEdit.cpp index 8b06438..27072b2 100644 --- a/Components/SignalPanel/Src/SignalPanelEdit.cpp +++ b/Components/SignalPanel/Src/SignalPanelEdit.cpp @@ -55,19 +55,27 @@ namespace jdi // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 const float fx = static_cast(x); const float fy = static_cast(y); - // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 - UpdateTargetRowGeometry(); - if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || - (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) + // ⚠️ 样式区控件只在**存在编辑目标**时才可点(用户 2026-11-02 报告:没有选中信号时, + // 鼠标掠过「系数/偏移 | 镜像 | 绝对值」仍会变成手型,而点了什么也不会发生)。 + if (HasStyleTarget()) { - return LoadCursorW(nullptr, IDC_HAND); - } - if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || - UiPointInRect(m_opacityValueRect, fx, fy)) - { - return LoadCursorW(nullptr, IDC_HAND); + // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 + UpdateTargetRowGeometry(); + if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || + (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || + UiPointInRect(m_widthValueRect, fx, fy) || + UiPointInRect(m_opacityValueRect, fx, fy) || + (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) || + (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))) + { + return LoadCursorW(nullptr, IDC_HAND); + } } return nullptr; } @@ -652,4 +660,51 @@ namespace jdi return true; } + bool SignalPanel::ResetStyleLineWidth() + { + if (m_dataSet == nullptr || m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + const SignalData* signal = m_dataSet->Resolve(SelectedRef()); + if (signal == nullptr) + { + return false; + } + // 默认线与新建信号同源(`DefaultSignalLineWidth`):单数据点信号用最大线宽, + // 其余用 `kDefaultSignalLineWidth`(用户明确要求"单值信号和普通信号的默认线宽不一样")。 + const float target = DefaultSignalLineWidth(signal->finiteCount); + if (std::fabs(style->lineWidth - target) < 0.001f) + { + return false; + } + style->lineWidth = target; + return true; + } + + bool SignalPanel::ResetStyleOpacity() + { + if (m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + if (std::fabs(style->opacity - 1.0f) < 0.001f) + { + return false; + } + style->opacity = 1.0f; + return true; + } + } // namespace jdi + diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index 208c7f7..b3919a9 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -195,6 +195,17 @@ namespace jdi { hoverStyle = StyleHit::WIDTH_VALUE; } + // 两行右侧的复位按钮(在数值框右边,命中区不重叠,但顺序放在数值之后更安全)。 + if (hoverStyle == StyleHit::NONE && m_widthResetRect.Width > 0.0f && + UiPointInRect(m_widthResetRect, fx, fy)) + { + hoverStyle = StyleHit::WIDTH_RESET; + } + if (hoverStyle == StyleHit::NONE && m_opacityResetRect.Width > 0.0f && + UiPointInRect(m_opacityResetRect, fx, fy)) + { + hoverStyle = StyleHit::OPACITY_RESET; + } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { hoverStyle = StyleHit::GAIN_ADJUST; @@ -424,6 +435,20 @@ namespace jdi ScrollToSignalAnimated(SelectedRef()); return BeginEdit(StyleEditField::OPACITY); } + // 两行右侧的**复位按钮**:把该行参数恢复为默认值(线宽按信号类型取默认、 + // 不透明度回 100%)。与「系数/偏移」还原图标同一交互语义:就地回显、不弹任何东西。 + if (m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) + { + ResetStyleLineWidth(); + ScrollToSignalAnimated(SelectedRef()); + return true; + } + if (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy)) + { + ResetStyleOpacity(); + ScrollToSignalAnimated(SelectedRef()); + return true; + } // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, // 与自定义颜色入口同一套交互)、绝对值(单击即切换)。 if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) @@ -821,6 +846,12 @@ namespace jdi { return false; } + // 线宽 / 透明度两行右侧的复位按钮是"单次点击即生效"的按钮:双击不得被解释成别的语义。 + if ((m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) || + (m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy))) + { + return false; + } UpdateTargetRowGeometry(); if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) { diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp index 564fa1d..a716488 100644 --- a/Components/SignalPanel/Src/SignalPanelMenu.cpp +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -180,10 +180,17 @@ namespace jdi { return; } + (void)style; // 菜单已不再需要外观:颜色 / 线宽 / 透明度 / 绘制样式都在下方样式区编辑。 m_menuTargetSignal = ref; m_menuTargetGroup = -1; + // ⚠️ 信号右键菜单**刻意保持极简**(用户 2026-10-29 要求): + // 颜色、绘制样式、可见性、显示全部/隐藏全部这些原来放在这里的条目,现在 + // 都已被下方**样式自定义区**与分组右键菜单覆盖,重复的入口只会让菜单变长、变乱。 + // 现在只保留两件事: + // * 「克隆该信号」(在「仅显示该信号」**之上**); + // * 「仅显示该信号」。 std::vector items; MenuItem header; @@ -191,61 +198,16 @@ namespace jdi header.label = signal->name; items.push_back(header); - MenuItem toggle; - toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号"; - toggle.id = CMD_SIGNAL_TOGGLE; - items.push_back(toggle); + MenuItem clone; + clone.label = L"克隆该信号"; + clone.id = CMD_SIGNAL_CLONE; + items.push_back(clone); MenuItem solo; solo.label = L"仅显示该信号"; solo.id = CMD_SIGNAL_SOLO; items.push_back(solo); - MenuItem showAll; - showAll.label = L"显示全部信号"; - showAll.id = CMD_SIGNAL_SHOW_ALL; - items.push_back(showAll); - - MenuItem hideAll; - hideAll.label = L"隐藏全部信号"; - hideAll.id = CMD_SIGNAL_HIDE_ALL; - items.push_back(hideAll); - - MenuItem separator; - separator.kind = MenuItemKind::SEPARATOR; - items.push_back(separator); - - int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - MenuItem colors; - colors.kind = MenuItemKind::SWATCHES; - colors.id = CMD_COLOR_BASE; - colors.swatchCount = std::min(paletteCount, 16); - colors.checkedSwatch = -1; - for (int index = 0; index < colors.swatchCount; ++index) - { - colors.swatches[index] = palette[index]; - if (style->color == palette[index]) - { - colors.checkedSwatch = index; - } - } - items.push_back(colors); - - MenuItem modeHeader; - modeHeader.kind = MenuItemKind::HEADER; - modeHeader.label = L"样式"; - items.push_back(modeHeader); - - for (int index = 0; index < kModeOptionCount; ++index) - { - MenuItem mode; - mode.label = DrawModeName(kModeOptions[index]); - mode.id = CMD_MODE_BASE + index; - mode.checked = (style->mode == kModeOptions[index]); - items.push_back(mode); - } - const gd::RectF bounds = PanelBounds(); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } @@ -494,12 +456,14 @@ namespace jdi switch (result.id) { - case CMD_SIGNAL_TOGGLE: - if (m_menuTargetSignal.IsValid()) + case CMD_SIGNAL_CLONE: + // 「克隆该信号」:把原信号的「调整」(除时间轴偏移)烘焙进采样值后插入下方, + // 具体构建在宿主里完成(需要访问 DataSet 与全部标签页)。 + if (m_menuTargetSignal.IsValid() && m_host != nullptr) { - m_tab->ToggleVisible(m_menuTargetSignal); - m_rowsDirty = true; - needRelayout = true; + PostMessageW(m_host, WM_APP + 5, + static_cast(m_menuTargetSignal.groupIndex), + static_cast(m_menuTargetSignal.signalIndex)); } break; case CMD_SIGNAL_SOLO: @@ -510,16 +474,6 @@ namespace jdi needRelayout = true; } break; - case CMD_SIGNAL_SHOW_ALL: - m_tab->SetAllVisible(true); - m_rowsDirty = true; - needRelayout = true; - break; - case CMD_SIGNAL_HIDE_ALL: - m_tab->SetAllVisible(false); - m_rowsDirty = true; - needRelayout = true; - break; case CMD_GROUP_SHOW_ALL: if (m_menuTargetGroup >= 0) { @@ -560,22 +514,6 @@ namespace jdi needRelayout = true; } } - else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0) - { - if (SignalStyle* style = StyleOf(m_menuTargetSignal)) - { - int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - style->color = palette[result.swatchIndex % std::max(1, paletteCount)]; - } - } - else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount) - { - if (SignalStyle* style = StyleOf(m_menuTargetSignal)) - { - style->mode = kModeOptions[result.id - CMD_MODE_BASE]; - } - } break; } diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index 68fbd64..563bea7 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -768,6 +768,9 @@ namespace jdi m_theme->textMuted, TextAlign::CENTER); return; } + // 目标信号本身:线宽复位按钮需要它的有效采样点数(单点信号默认用最大线宽)。 + const SignalData* signal = + (m_dataSet != nullptr) ? m_dataSet->Resolve(SelectedRef()) : nullptr; const float labelWidth = kStyleLabelWidth * m_scale; @@ -779,6 +782,27 @@ namespace jdi m_theme->textMuted, TextAlign::LEFT); }; + // 「线宽 / 透明度」两行右侧的**复位按钮**(与「系数/偏移」二级菜单标题栏的还原图标同款)。 + // `changed` = 当前值是否已偏离默认值:偏离时才用强调色(一眼看出"这里可以点"), + // 已是默认值时用弱色(仍可点,但结果不变)。 + auto paintStyleReset = [&](const gd::RectF& rect, StyleHit hit, bool changed) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const bool hot = (m_hoverStyle == hit); + 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)); + } + UiPaintResetIcon(g, rect, hot ? m_theme->accent + : (changed ? m_theme->text : m_theme->textMuted), + m_scale); + }; + // ---- 第一行:颜色(预设色块 + 自定义颜色入口)---- { paintRowLabel(0, L"颜色"); @@ -888,7 +912,7 @@ namespace jdi paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); } - // ---- 第四行:线宽(数值可点击输入)---- + // ---- 第四行:线宽(数值可点击输入;右侧为复位按钮)---- { paintRowLabel(3, L"线宽"); @@ -913,6 +937,9 @@ namespace jdi : fullText; UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT); } + paintStyleReset(m_widthResetRect, StyleHit::WIDTH_RESET, + style->lineWidth != + DefaultSignalLineWidth(signal != nullptr ? signal->finiteCount : 0)); } // ---- 第五行:透明度(数值可点击输入)---- @@ -933,6 +960,8 @@ namespace jdi UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, m_theme->textStrong, TextAlign::RIGHT); } + paintStyleReset(m_opacityResetRect, StyleHit::OPACITY_RESET, + std::fabs(style->opacity - 1.0f) > 0.001f); } } diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index b53987b..391f397 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -225,10 +225,10 @@ namespace jdi bool HitTest(int x, int y) const override; /** - * @brief 收集标签栏区域、**每个标签条 / 新建按钮 / 标签 ✕** 的布局边界(调试用)。 + * @brief 上报标签栏区域、**每个标签条 / 新建按钮 / 标签 ✕** 的几何 box。 * @param [IN,OUT] boxes 收集器。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes) override; + void BuildBoxes(UiBoxTable& boxes) override; private: /** diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 00a07be..2bc2e01 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -5,7 +5,7 @@ #include #include -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" #include "Utf8Util.hpp" namespace jdi @@ -42,9 +42,9 @@ namespace jdi m_scale = scale; } - /** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮几何)。 */ + /** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮与文本几何)。 */ void SetState(std::wstring name, bool active, gd::RectF closeRect, bool editing, - const TextEdit* edit, gd::RectF editRect) + const TextEdit* edit, gd::RectF editRect, gd::RectF textRect) { const bool changed = (m_name != name) || (m_active != active) || (m_editing != editing); m_name = std::move(name); @@ -53,6 +53,7 @@ namespace jdi m_editing = editing; m_edit = edit; m_editRect = editRect; + m_textRect = textRect; if (changed) { Invalidate(Bounds()); @@ -111,8 +112,14 @@ namespace jdi 1.0f * m_scale, m_theme->accent); } - const float textLeft = rect.X + kTabPadding * m_scale; - const float textWidth = m_closeRect.X - kCloseGap * m_scale - textLeft; + // ⚠️ 文本可用矩形由 `TabBar::ArrangeWidgets` **统一下发**(与排布同一份几何), + // 不能用控件自己的 `m_scale` 从 `m_closeRect` 反推:标签宽度恰好等于 + // `2*pad + 文本 + gap + close`(没有任何余量),而控件缓存的 `m_scale` + // 一旦与排布时的不一致(DPI 切换),反推出来的宽度就会少几像素 ⇒ + // `UiEllipsize` 把最后一个字符换成省略号。 + const gd::RectF textRect = m_textRect; + const float textLeft = textRect.X; + const float textWidth = textRect.Width; if (m_editing && m_edit != nullptr) { @@ -155,8 +162,7 @@ namespace jdi bool m_active = false; /**< 是否激活。 */ bool m_editing = false; /**< 是否处于重命名编辑态。 */ const TextEdit* m_edit = nullptr; /**< 编辑模型(编辑态时非空)。 */ - gd::RectF m_editRect; /**< 编辑框矩形。 */ - gd::RectF m_closeRect; /**< 关闭按钮矩形。 */ + gd::RectF m_editRect; /**< 编辑框矩形。 */ gd::RectF m_textRect; /**< 文本可用矩形(由 `TabBar` 下发)。 */ gd::RectF m_closeRect; /**< 关闭按钮矩形。 */ }; /** @brief 「+」新建按钮控件。 */ @@ -277,7 +283,19 @@ namespace jdi void TabBar::SetScale(float scale) { - m_scale = (scale > 0.1f) ? scale : 1.0f; + const float next = (scale > 0.1f) ? scale : 1.0f; + if (next == m_scale) + { + return; + } + m_scale = next; + // ⚠️⚠️ **缩放变化必须重建控件树**(用户 2026-11-02 报告:把窗口从 125% 显示器拖到 + // 100% 显示器后,唯一的标签页文本变成省略号,双击重命名再取消才恢复): + // 每个 `TabItemWidget` 在 `Configure` 时缓存了当时的 `m_scale`,而它内部用这个缩放 + // 反推“可用文本宽度”(`closeRect.X - kCloseGap*scale - (rect.X + kTabPadding*scale)`)。 + // 标签宽度是按新缩放排的(恰好 = 2*pad + 文本 + gap + close,**没有任何余量**), + // 而控件仍用旧缩放扣边距 ⇒ 可用宽度比实测文本宽度小几像素 ⇒ `UiEllipsize` 吃掉末字。 + RebuildWidgets(); } void TabBar::SetTabs(const std::vector& names) @@ -448,9 +466,14 @@ namespace jdi TabItemWidget* widget = static_cast(children[index].get()); const TabItem& tab = m_tabs[index]; const bool editing = (static_cast(index) == m_editIndex); + // 文本可用矩形:与排布用同一份 `m_scale`,不再由控件自行反推。 + const gd::RectF textRect(tab.rect.X + kTabPadding * m_scale, tab.rect.Y, + std::max(8.0f, tab.closeRect.X - kCloseGap * m_scale - + (tab.rect.X + kTabPadding * m_scale)), + tab.rect.Height); widget->Arrange(tab.rect); widget->SetState(tab.name, static_cast(index) == m_activeIndex, tab.closeRect, editing, - editing ? &m_edit : nullptr, EditRect()); + editing ? &m_edit : nullptr, EditRect(), textRect); } static_cast(children[m_tabs.size()].get())->Arrange(m_addRect); static_cast(children[m_tabs.size() + 1].get())->Arrange(m_area); @@ -484,9 +507,9 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } - void TabBar::CollectDebugBoxes(UiDebugBoxList& boxes) + void TabBar::BuildBoxes(UiBoxTable& boxes) { - UiComponent::CollectDebugBoxes(boxes); + UiComponent::BuildBoxes(boxes); if (m_root != nullptr) { // ⚠️ 跳过根控件:它的矩形与标签栏区域完全重合(已被上一层登记过)。 @@ -495,7 +518,7 @@ namespace jdi { if (child != nullptr) { - child->CollectDebugBoxes(boxes, 1); + child->BuildBoxes(boxes, 1); } } } diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 92ee379..f4482f4 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -154,10 +154,10 @@ namespace jdi bool HitTest(int x, int y) const override; /** - * @brief 收集工具栏区域、**每个按钮**与按钮内容(文本/图标)的布局边界(调试用)。 + * @brief 上报工具栏区域、**每个按钮**与按钮内容(文本/图标)的几何 box。 * @param [IN,OUT] boxes 收集器。 */ - void CollectDebugBoxes(UiDebugBoxList& boxes) override; + void BuildBoxes(UiBoxTable& boxes) override; private: /** diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 3015c91..62cef66 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -4,7 +4,7 @@ #include #include -#include "UiDebugBounds.hpp" +#include "UiBox.hpp" namespace jdi { @@ -351,9 +351,9 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } - void Toolbar::CollectDebugBoxes(UiDebugBoxList& boxes) + void Toolbar::BuildBoxes(UiBoxTable& boxes) { - UiComponent::CollectDebugBoxes(boxes); + UiComponent::BuildBoxes(boxes); if (m_root == nullptr) { return; diff --git a/Documents/UI_Refactor_Plan.md b/Documents/UI_Refactor_Plan.md index f65cb65..7d95ed6 100644 --- a/Documents/UI_Refactor_Plan.md +++ b/Documents/UI_Refactor_Plan.md @@ -1,556 +1,153 @@ -# UI 重构计划(UI_Refactor 分支) +# Just DataInspector UI 重构 —— 计划 -> 目标读者:本项目的后续 Agent(可能是另一台设备 / 另一个模型)。 -> 本文是**唯一施工蓝图**;实时进度见 `Documents/UI_Refactor_Progress.md`,最终结论见 `Documents/UI_Refactor_Report.md`。 -> 硬约束的权威来源仍是 `Documents/agent_mem.md`(§11.2);本文与之冲突时**以 agent_mem.md 为准**。 +> 本文档只描述**要做什么、为什么这么做、怎么验收**。 +> 已完成情况见 `UI_Refactor_Progress.md`,结论与遗留见 `UI_Refactor_Report.md`。 +> 最后更新:2026-10-29 --- -## 0. 为什么要重构(量化基线) +## 1. 目标与非目标 -| 指标 | 现状 | 出处 | +### 1.1 目标 + +| # | 目标 | 判据 | | --- | --- | --- | -| `PlotViewPaint.cpp` | **9 080 行**(注释 1 295 / 空行 517 / 仅大括号 2 026 / **有效代码 5 242**) | `Get-Content \| Measure-Object` | -| `PlotView.hpp` | 3 649 行、**310 个方法**、**203 个成员变量**、31 个嵌套类型 | 同上 | -| `EdgePanelLayoutFor` | **651 行**、**19 个调用点**,每帧被命中/层序/光标/绘制各算一遍 | `Select-String` | -| `Application.cpp` 的 `InvalidateRect` | **56 处** + 手写 `m_chromeDirty` / `m_panelDirty` / `panelOnly` / `fullFrame` 组合 | 同上 | -| 动画 | **5 套**手写动画散在 3 个组件,共用一个 10ms 定时器 | 同上 | -| 自动化 UI 探针 | **0**(`.preflight` 已被删除且被 gitignore) | 仓库现状 | +| G1 | 收敛"宿主点名每个组件"的散乱结构 | 宿主只遍历一张组件表,不再 `if (component == plot)` 式分支 | +| G2 | 几何只有**一处**来源 | "画出来的形状"与"能点到的区域"由同一批函数给出,不再各算一遍 | +| G3 | 源码按**职责**分片,单片可读 | 单个 `.cpp` 不超过约 1200 行;文件名与内容一致 | +| G4 | 减小产物体积,且**仍然只有一个 `.exe`** | 体积比基线小,无新增依赖 DLL | +| G5 | 关闭 C++ 异常后**不牺牲健壮性** | 所有大块分配前有预检验;OOM 有可见提示 | +| G6 | 重构**不得改变任何绘制像素** | 8 个场景的逐像素哈希与基线逐一相同 | -`PlotViewPaint.cpp` 里混住四类内容:真绘制 ≈3.5k / 光栅化缓存 ≈2.5k / 缓存键 ≈1.1k / **布局与命中几何 ≈1.3k**。 +### 1.2 非目标(明确不做) -**五条根因** - -1. 上次拆 13 695 行大文件时按**函数名**("带 `Paint` 前缀")而非**关注点**分片 ⇒ 缓存、缓存键、面板布局、命中几何全被倒进 paint 分片。 -2. 没有控件抽象 ⇒ 每个叠加层自带"几何 + 命中 + 绘制 + 缓存键"四份重复;5 个实验性面板各 800~1 200 行,靠 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)下标对齐"假装"成树。 -3. GDI / GDI+ 双栈妥协被**每个绘制点重复支付**(GDI 文本不认 GDI+ 裁剪、`UiFillRect` 的 GDI 快路径绕过裁剪、GDI 文本忽略 alpha…)。 -4. "必须混进缓存键,否则贴旧像素"这条不变量**没有归属**(4 套手写 key + 一堆 `Collect*` ≈1.1k 行;历史 ≥4 次真实 BUG 是"忘了混一个字段")。 -5. 1 295 行硬核"为什么"注释让行数虚高 —— **保留,不删**(多 Agent 协作防回归的核心资产)。 +* **不把编译产物拆成 DLL**:本项目的定位是"单文件、拷过去就能跑的轻量工具"。 + 任何以拆 DLL 换体积的方案一律否决。 +* **不改优化等级**(`-Oz` 保持不变)。 +* **不把"波形"与"网格线"拆成两层位图**:像素级平移不变量会被破坏。 +* **不为体积牺牲可维护性**(例如放弃分片、放弃几何同源)。 --- -## 1. 设计目标与非目标 +## 2. 硬约束 -### 1.1 目标(必须达成) - -1. **几何只算一次**:布局是"按需失效的 pass",命中/绘制/光标共用同一份结果。 -2. **失效从布尔变矩形**:`dirty ∩ bounds` 为空 ⇒ 整棵子树跳过(这是唯一真正省钱的层次)。 -3. **裁剪契约统一**:`Paint` 进入时同时收紧 GDI 裁剪与 GDI+ 裁剪,根治"文字画到图例/工具栏上"这类已出现 3 次的 BUG。 -4. **缓存键有归属**:由"持有该内容的节点"产生,父层只聚合。 -5. **事件派发显式化**:`{consumed, redraw, dirtyRect}` 代替 `bool 要不要重绘`;capture / 模态 / hover / 焦点 / 光标仲裁由基类提供。 -6. **动画统一 tick**:一个动画列表 + 现有 `kScrollTimerId` 推进。 -7. **零行为变更的先行阶段**:先拆文件、补回归网,再动语义。 - -### 1.2 非目标(明确不做,写进禁区) - -| 禁区 | 原因 | -| --- | --- | -| ❌ 波形数据→像素管线控件化(网格/曲线/采样点不做成节点) | 布局与虚调用会进每帧热路径;采样/顶点量与控件无关 | -| ❌ 每个控件自持位图 | 内存按面积线性增长:逐网格图层已是屏幕面积 2 倍(5 网格 ≈16MB,**128 网格 ≈250MB**) | -| ❌ "父脏 ⇒ 子树全脏" | 会把"只重绘一个网格/一个面板"打回整块重绘(历史最易发生的退化) | -| ❌ 多线程光栅 | GDI+ 内部串行,实测 **1.03x**;且后台改写 `m_area`/`ViewMin` 会覆盖用户平移(第一代已因此回退) | -| ❌ 逐控件细粒度像素缓存 | 画一个按钮 20~80µs vs 贴图 5~10µs ⇒ 净亏损,还要付"忘混键"的风险 | -| ❌ 引入属性对象体系(学 Blink) | 那是为 Blink 规模准备的;本项目用"节点产生 key"即可 | - -### 1.3 借鉴来源与取舍 - -| 借鉴 | 采纳 | 说明 | -| --- | --- | --- | -| Chromium `views::View` 的**失效驱动布局 pass** | ✅ 全采纳 | 对应 §3 的 `UiWidget::Measure/Arrange` + `UiLayoutCache` | -| `views::View::PaintChildren` 的**脏矩形下探** | ✅ 全采纳 | 对应 §4 的 `PaintContext.dirty` | -| `SetPaintToLayer()` 只提升**少数**子树 | ✅ 保持现状量级(4 层) | 不再新增层 | -| `cc` 的**记录-光栅分离 / tiling / checkerboarding / 每帧预算** | ✅ 已具备,不改 | `PolylineCacheEntry`、宽基底+补条、贴过期但位置正确、`RenderPendingLaneRebuilds(1)` | -| `cc` 的**多线程光栅 / GPU 合成** | ❌ 不采纳 | GDI+ 无 GPU 路径;并行实测 1.03x | -| Dear ImGui 的**每帧全量重画** | ❌ 不采纳 | 本项目整幅重绘 ≈80ms,必须走失效 | +1. **单一可执行文件**:不得引入额外的 `.dll` / 运行时依赖。 +2. **体积预警阈值 = 1MB(1,048,576 B)**。这不是硬上限:超过仍可发布, + 但必须主动提醒用户,并说明"超在哪一段(`.text` / `.rdata` / `.xdata`+`.pdata` / `.rsrc`)、 + 由哪个改动贡献"。旧的 900KB / 800KB 红线**已作废**。 +3. **异常处理用预检验,不用 try/catch**:`-fno-exceptions` 下 `try/catch` 不会被编译, + 因此所有"按数据规模预留大块内存"的地方必须先问 `MemReserveIfRoom()`。 +4. **像素级零回归**:验收以 `Tools/rendercheck` 的 8 个场景哈希为准。 +5. **中文注释按 UTF-8 无 BOM 保存**;构建参数用 `-finput-charset=UTF-8 -fexec-charset=GBK`。 --- -## 2. 目标架构 +## 3. 分层架构(L0–L3) -```mermaid -flowchart TD - H[UiHost
持有 app-sized 32bpp top-down DIB
z 序 · 脏区并集 · 一次 BitBlt 回送 · 动画 tick] - H --> R[UiRoot : UiWidget] - R --> D{脏区树
dirty ∩ bounds 空 ⇒ 整棵子树 return} - D --> A[普通控件:直接画进共享缓冲
按钮 / 文本 / 列表行 / 时间轴 / 图例] - D --> B[高成本叶子:自持位图,完成即 BitBlt 贴回
波形网格 / 装饰层 / 光晕 / 浮动面板] - A --> S[SurfaceView{ dc, rect, stride, bits, version }] - B --> S +``` +L3 体积与异常策略 -fno-exceptions / MemProbe / LTO / gc-sections + ▲ +L2 重绘范围控制 UiDamage(脏矩形) ← 本轮评估后**缓做**,见 §5 + ▲ +L1 几何产物层 UiBox / UiBoxTable / UiComponent::BuildBoxes + ▲ +L0 组件协议 UiComponent + 组件表 + UiWidget 控件树 ``` -**分层原则** +### L0 组件协议(前几轮已完成) -- **共享一张缓冲**:省内存、免分配抖动、合成顺序天然确定。 -- **只有高成本叶子自持缓冲**:这是"把重画换成贴图"的唯一手段。 -- **脏区向下取交集**(不是并集)。 -- **布局由下往上、每帧最多一轮 Measure**;尺寸由内容反推(`EdgePanelWidth` 就是这种)。 +* `UiComponent`:`SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor`。 +* 宿主持有一张**有序组件表**,逐个遍历;新增组件不必改宿主的 5 处消息分支。 +* `UiWidget` 与 `UiComponent` 是**兄弟**而非父子:前者管"一块区域内部的布局/命中/绘制", + 后者管"自己这一整块地盘:区域、缓存、局部重绘策略"。 + `Toolbar` / `TabBar` 同时是两者。 -### 2.1 新增文件 +### L1 几何产物层(本轮完成) -| 文件 | 职责 | 依赖 | -| --- | --- | --- | -| `Components/Shared/Inc/UiLayout.hpp` / `Src/UiLayout.cpp` | 纯几何:`UiInsets`、栈/流/锚点/对齐、`UiLayoutCache` | 仅 `Theme.hpp`(`gd::RectF`) | -| `Components/Shared/Inc/UiWidget.hpp` / `Src/UiWidget.cpp` | 控件基类:树、Measure/Arrange、双栈裁剪 Paint、事件派发、失效、capture/模态/hover/焦点/光标、动画 tick | `UiLayout.hpp`、`PaintUtil.hpp` | -| `Components/Shared/Inc/UiHost.hpp` / `Src/UiHost.cpp` | 宿主:共享 DIB + `SurfaceView` + 脏区并集 + 回送 + 消息翻译 + 动画定时器 | `UiWidget.hpp` | -| `Tools/uicheck/`(入库) | 离屏回归网(`SW_HIDE` + 注入消息 + 从 `m_backDc` 取帧) | `Build/Release/*.o` | +* 新文件 `Components/Shared/Inc/UiBox.hpp` + `Src/UiBox.cpp`。 +* `struct UiBox { rect, clip, depth, layer }`:**纯值类型**,不继承任何东西。 +* `class UiBoxTable`:几何收集器,只维护"矩形 / 层级 / 绘制层 / 裁剪 / 遮挡"这些**几何事实**, + **不含任何绘制代码**。 +* `UiComponent::BuildBoxes(UiBoxTable&)`:组件上报自己的几何产物(默认只登记 `Area()`)。 +* `UiDebugBounds` 退化为**消费者**:`UiDebugPaintBoxList(g, table)` 把几何画成彩色包角线。 + ⇒ 组件不再需要知道"调试"这个概念存在。 -> ⚠️ `Components/Shared` 已在 `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS` 中 ⇒ 新增 `.cpp` **无需改 Makefile**(按 basename 自动收集)。 +### L2 重绘范围控制(缓做 —— 见 §5) + +* 设计意图:`UiDamage`(最多 12 个矩形的脏区集合,超限退化为包围盒)替代 + "全窗重绘 / 绘图区重绘"的二值分支;`PlotView::Paint(g, UiDamage&)` 逐路径累加脏区。 +* 收益:去掉 `SetForceFullRepaint`(布局边界开关打开时的整幅重绘)。 + +### L3 体积与异常策略(本轮完成) + +见 `UI_Refactor_Progress.md` 的实测表。 --- -## 3. 布局层设计(`UiLayout`) +## 4. 阶段与验收标准 -### 3.1 为什么不是简单的"宽高字段" - -基类若只有 `width/height`,会在三处立刻失效: - -1. 所有坐标是**逻辑像素 × scale**(本机 DPR 1.25),控件必须知道缩放; -2. 面板宽度是**由内容反推**(`EdgePanelWidth` = 列宽 max + 行 ✕ + 滚动条预留); -3. 离屏位图要求**设备像素整数对齐**(`floor`/`ceil` 方向错了就留 1px 接缝 —— 样式补丁曾踩过)。 - -⇒ 采用 **`Measure(约束) → 期望尺寸` / `Arrange(最终矩形) → 实际矩形`** 两段式。 - -### 3.2 需要的布局种类(按实际用量排序) - -| 布局 | 用途 | 备注 | +| 阶段 | 内容 | 验收 | | --- | --- | --- | -| **Abs(绝对)** | 绘图区叠加物主力:光标线、取值胶囊、吸附环、越界三角、固定点、面板初始位置、滚动条 | 保留"自由布局",但它是**主力**而非备选 | -| **Anchor(四边贴合)** | 工具栏整体靠右、时间轴贴底、滚动条贴右、面板夹在 `m_plotRect` 内 | 讨论中缺失,必须补 | -| **Stack(竖向流)** | 信号列表行、分组行、面板条目(名称行 + 参数行 + 滑块行) | 需支持"行高由内容决定 + 吸顶(sticky) + 滚动视口" | -| **Flow(横向流 + 溢出)** | 图例条目 + 空间不足的 `+N`、工具栏按钮组 | 讨论中缺失,必须补 | -| **Measure-driven** | 面板宽度按实测文本撑开、列宽 `max(缺省, 内容, 列标题)` | 必须有,否则又回到"绘制侧省略号" | -| Grid | **用不上**(绘图区不是表格) | 不实现 | -| Card | 只是"面板皮肤" | 作为 `UiPanelSkin` 绘制样式,不是布局 | +| P1–P6 | 文件按职责拆分、`UiComponent` 协议、组件表、布局边界功能 | 编译 + `uicheck` | +| P7 | 布局边界"几何同源"(`RowRect` 前缀和、文本墨迹矩形、浮层层级/遮挡/裁剪) | `lbcovr` / `lbzorder` / `lbresid` / `lbpanel` | +| P8 | 体积:`-fno-exceptions` + `MemProbe` + `-ffunction-sections` + `--gc-sections` | 体积下降且 8 哈希不变 | +| P9 | 体积:LTO(含 `-flto-partition=one` 稳定性约束) | 同上,且可重复构建 | +| P10 | L1 几何产物层(`UiBox` / `UiBoxTable` / `BuildBoxes`) | 同上,且源码零残留旧名 | +| P11 | 代码审计(过时注释 / 名实不符 / 死代码) | 零 warning + 全量回归 | +| P12 | 文档重建 + 本地提交 | `git status` 干净 | -### 3.3 `UiLayoutCache`(本次最关键的性能点) +**统一验收命令** -`EdgePanelLayoutFor` 的 19 个调用点全部来自"同一份几何被反复问"。做法: +```powershell +make clean +make release TARGET_FILE_NAME=JustDataInspector -j16 # 期望零 error / 零 warning +make debug TARGET_FILE_NAME=JustDataInspectorD -j16 # 期望零 warning -```cpp -class UiLayoutCache -{ -public: - // key 由调用方提供(例如 laneIndex + kind + 面板宽 + 缩放 + 条目数) - const LayoutEntry* Find(std::uint64_t key) const; - const LayoutEntry& Store(std::uint64_t key, LayoutEntry&& entry); - void Invalidate(std::uint64_t key); // 定向失效 - void Clear(); -}; +objdump -h Build\Release\JustDataInspector.exe # 段构成,用于体积溯源 + +Tools\uicheck\uicheck.exe # 69/69 +.preflight\lbcheck\lbcheck.exe # 21/21 +.preflight\lbresid\lbresid.exe # 5/5 +.preflight\lbzorder\lbzorder.exe # 4/4 +.preflight\lbcovr\lbcovr.exe # 7/7 +.preflight\lbpanel\lbpanel.exe # 10/10 +Tools\rendercheck\rendercheck.exe # 8 哈希不变 + FUNC 7/7 ``` -**硬约束**:`Invalidate` 必须由"改变几何的那条路径"**显式**调用(与 `m_edgeScans` 的定向失效同一纪律),**禁止**为了让缓存命中率好看而整体 `Clear()`。 +--- + +## 5. 明确缓做的部分及理由 + +### L2 `UiDamage` + 独立叠加层(S4) + +**为什么缓做** + +1. 受益方只有"**调试开关打开时**的帧率"。`SetForceFullRepaint` 只在 + 「显示布局边界」开启时被置位(`Application::Render` 里 `m_plot.SetForceFullRepaint(m_showLayoutBounds)`), + 普通用户路径上**本来就是快路径**。 +2. 正确实现无法只改 `PlotView` 内部:宿主用的是**持久后备缓冲区**, + 上一帧叠加层画上去的边框像素必须在下一帧被"底层内容重画"覆盖掉。 + 若改成"独立叠加位图 + 只混合脏区",就必须把叠加层的旧矩形**注入**到底层组件的重绘范围里 —— + 这要穿透 `PlotView` 的全部四条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁 / 网格层重建) + 与两层缓存。 +3. 风险与收益不成比例:用户已在 2026-10-28 反复报告过"拖拽错位 / 贴图残留"一类事故, + 而这类事故正是从"某条快路径漏了某块像素"开始的。 + +**保留了什么**:`SetForceFullRepaint` 及其四处守卫、`UiBoxTable` 的几何产物。 +将来若真要做,只需把 `UiBoxTable` 的 `rect` 直接喂给 `UiDamage`,不需要重新收集几何。 --- -## 4. 控件基类设计(`UiWidget`) +## 6. 交付物清单 -### 4.1 接口骨架 - -```cpp -struct UiEventResult -{ - bool consumed = false; // 事件已被消费(不再下传/上抛) - bool redraw = false; // 需要重绘 - gd::RectF dirty; // 需要重绘的矩形(客户区坐标;空 = 未知 ⇒ 取自身 bounds) -}; - -class UiWidget -{ -public: - virtual ~UiWidget(); - - // ---- 树 ---- - void AddChild(std::unique_ptr child); - void RemoveChild(UiWidget* child); - const std::vector>& Children() const; - - // ---- 布局(两段式,各自带失效)---- - void InvalidateMeasure(); // 向上冒泡到根 - void InvalidateArrange(); - gd::SizeF Measure(const UiMeasureContext& ctx); // 自下而上,命中缓存则直接返回 - void Arrange(const gd::RectF& bounds); // 自上而下;bounds 一定 > 0 - - // ---- 绘制(双栈裁剪 + 脏区下探)---- - void Paint(UiPaintContext& ctx); // 内部:与 dirty 求交 → OnPaint → 递归子节点 - virtual void Invalidate(const gd::RectF& rect); // 局部失效(向上冒泡并入) - - // ---- 事件 ---- - UiEventResult Dispatch(UiEvent& event); // 命中 → 子节点(反 z 序)→ OnEvent - - // ---- 命中 / 光标 ---- - bool Contains(int x, int y) const; - virtual HCURSOR QueryCursor(int x, int y) { return nullptr; } - - // ---- 交互仲裁(由 Host 调用)---- - UiWidget* Capture(); void SetCapture(); void ReleaseCapture(); - void SetModal(bool modal); // 模态子树:Host 只向它派发 - void SetHitTransparent(bool value); // 装饰元素:不参与命中 - - // ---- 动画 ---- - virtual bool AdvanceAnimation(std::int64_t nowMs) { return false; } - -protected: - virtual gd::SizeF OnMeasure(const UiMeasureContext& ctx) = 0; - virtual void OnArrange() {} - virtual void OnPaint(UiPaintContext& ctx) = 0; - virtual UiEventResult OnEvent(UiEvent& event) { return {}; } - virtual std::uint64_t ContentKey() const; // 缓存键的来源(父层只聚合) -}; ``` - -### 4.2 `Paint` 的硬契约(必须由基类保证,不是"每个控件记得") - -1. **双栈裁剪**:进入时 `SaveDC + IntersectClipRect`(GDI)+ `Graphics::SetClip(Intersect)`(GDI+),退出时还原。这一条一次性消灭"滚动时文字串到标题栏""全选高亮溢出输入框""选区画到工具栏上"。 -2. **脏区下探**:`ctx.dirty = ctx.dirty ∩ bounds`;若为空 ⇒ **整棵子树直接 return**(不调 `OnPaint`、不遍历子节点)。 -3. **绘制顺序 = 子节点顺序 = z 序**;命中/事件 = **反 z 序**(与现在 `m_panelOrder` + `TopPanelAtPoint` 反向遍历等价,但不再靠人工维护)。 -4. **禁止**在 `OnPaint` 内改 `bounds`/子节点集合(会与本帧的 dirty 不一致)。 - -### 4.3 事件派发的硬契约 - -- **`consumed`** 决定是否上抛;**`dirty`** 参与本帧脏区并集。 -- **capture**:抓取期间所有鼠标事件**直接投给抓取者**(跳过命中),与 Windows `SetCapture` 语义一致。本项目硬约束:中键平移与左键拖拽**共用同一个 capture**,松左键**不能**无条件 `ReleaseCapture()`。 -- **模态**:菜单打开时 Host 只向模态子树派发,且"取消底层拖拽"时必须**排除菜单自己的 `EDIT_FIELD`**。 -- **hover**:Host 维护 hover 链,跨节点 `leave/enter`;悬停是**跨组件**的(面板行 → 网格固定点高亮;列表行 → 曲线光晕)⇒ 通过 Host 级"悬停信号总线"广播,不靠父子关系。 -- **双击消歧**:窗口带 `CS_DBLCLKS`;没有双击语义的位置必须在 `DoubleClickIsPlainPress()` 返回 true,由 Host 改走普通按下(否则"连点丢一半")。 - -### 4.4 失效契约 - -- 节点持 `m_dirty`(自身坐标系)+ `m_dirtyChildren`; -- `Invalidate(rect)` 并入自身并**向上冒泡**(父层只并集矩形,**绝不**把子树的 dirty 清零); -- Root 暴露 `TakeDirty()` 给 Host,Host 再与窗口更新区域取并集。 - ---- - -## 5. 宿主层设计(`UiHost`) - -1. **单一 app-sized 32bpp top-down DIB**(替换现在的 `CreateCompatibleBitmap`): - ```cpp - struct SurfaceView - { - HDC dc; // 共享 HDC(GDI/GDI+ 都画这个) - gd::RectF rect; // 本节点在 Holder 坐标系中的矩形(设备像素,整数对齐) - int stride; // 行字节数(**不可**假设 = width*4) - std::uint32_t* bits; // rect 左上角那一像素(不是位图首地址) - std::uint64_t version; // 位图代次;失配 ⇒ 句柄作废 - }; - ``` - ⚠️ **绝不能缓存裸指针**:`EnsureBackBuffer` 在 `WM_SIZE` / `WM_DPICHANGED` 会重建位图。 - ⚠️ `bits` 只在 **CPU 侧直写**(memcpy/alpha)时用;GDI/GDI+ 绘制只需 `dc + rect`。 -2. **脏区并集**:把现在 56 处 `InvalidateRect` 收敛为 `Host::Invalidate(rect)`,一次回送。 - ⚠️ 回送仍必须**有无条件 `else`**(历史:某分支缺兜底 ⇒ 窗口一个像素都不回送,用户看到三个不同症状)。 -3. **动画 tick**:`AdvanceAnimation` 遍历树,任一为 true 就保持 `kScrollTimerId`(10ms)。 -4. **消息翻译**:`WM_*` → `UiEvent`,并把 `WM_SETCURSOR` 交给 `QueryCursor` 链(从 z 顶向下、首个非 null 生效)。 - ---- - -## 6. 落地阶段(每阶段一个可独立回滚的提交) - -| 阶段 | 内容 | 交付判据 | -| --- | --- | --- | -| **P0** | 离屏回归网 `Tools/uicheck`(入库)+ 现有 20+ 条硬约束的断言骨架 | ✅ **已完成**(69 项断言) | -| **P1** | `UiLayout` + `UiWidget` 基础层(本次) | ✅ **已完成**(编译零警告;`uicheck` 覆盖) | -| **P2** | 零行为变更分片:`PlotViewPaint.cpp` 拆成 `Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中);`EdgePanelLayoutFor`、`ProbeFreeMarker` 迁回交互侧 | ✅ **已完成**(**逐像素 0 差异**) | -| **P3** | 迁移 chrome:Toolbar → TabBar → PopupMenu → ColorPicker → SignalPanel 行 | ✅ Toolbar/TabBar 已迁移;**PopupMenu/ColorPicker/SignalPanel 行经评估不采用**(见 Report §7 的结论与理由) | -| **P4** | 迁移 5 个浮动面板 | **经评估不采用**(面板布局已是单一函数且已缓存像素层;控件化无帧时间收益且会浮空吸顶/滚动语义,见 Report §7) | -| **P5** | 波形管线包成叶子 surface(**只包不改**)+ 共享 DIB 落地 | 🔄 **共享 DIB + `UiSurface` 已完成**;「56 处失效收敛为脏区并集」经评估**不采用**(`InvalidatePlotRect()` 已是按消息的批处理点,逐处替换只增间接层,详见 Report §7) | - -**P2 之前禁止改任何行为**。P2 是唯一的"免费"阶段,别顺手改语义。 - ---- - -## 7. 验证策略(不占用用户鼠标/键盘/屏幕) - -1. **离屏 harness**:`SW_HIDE` 窗口 + 直接 `app.HandleMessage(...)` 注入 + 从 `m_backDc` 用 `GetDIBits` 取帧。 -2. **进程/线程优先级 `BELOW_NORMAL`**;从不用 `SetCursorPos` / `SendInput` / 截屏。 -3. **A/B 反证**:每条新断言都要能被"还原旧实现"证伪,否则不算通过。 -4. **像素判据要选对区域**:验证"某交互不应改变外观"时,只对**不受影响**的区域取哈希。 -5. **已知陷阱**(全部来自历史 BUG): - - 探针消息泵必须丢弃 `WM_MOUSELEAVE`(离屏窗口会立刻兑现它); - - 注入按下后立即 `ReleaseCapture()`(避免抢真实鼠标); - - `CycleCursorMode()` 是**循环**,多组用例里重复调用会把光标关掉; - - `PlotView` 的嵌套类型(`LaneVisual`/`LegendHit`/`EdgePanelLayout`)使用时要写 `PlotView::` 前缀。 - ---- - -## 8. 协作纪律(多 Agent) - -1. **文件所有权**:同一时刻**一个 Agent 只改一个文件的同一区域**;跨阶段并行时按下表分工,禁止交叉。 -2. **每阶段一个提交**,提交信息写清"改了什么 / 为什么 / 怎么验证"。 -3. **先写断言再改代码**。 -4. `main` / `dev` 只接受全绿;`UI_Refactor` 内允许临时红,但**不许跨轮次累积红**。 -5. 任何"规则变更"必须同步进 `Documents/agent_mem.md`(§11.2 追加编号),否则另一端看不到。 -6. 体积红线 **900KB**(到达即提醒用户,不是禁止超过)。 - -### 8.1 并行分工建议(若启用多个 Agent) - -| Agent | 独占文件 | 禁止触碰 | -| --- | --- | --- | -| A(基础层) | `UiLayout.*`、`UiWidget.*`、`UiHost.*`、`Tools/uicheck/*` | 任何现有组件 | -| B(分片) | `PlotViewPaint.cpp` → 新分片文件、`PlotViewInternal.hpp` 的 include 段 | `UiWidget.*`、面板逻辑 | -| C(面板迁移) | `PlotViewFeatures.cpp` 的面板段、新建 `PlotViewPanels.cpp` | `PlotViewPaint.cpp`、`UiLayout.*` | - -> 冲突高发点:`Makefile`(新增目录时才需要改)、`agent_mem.md`(追加编号必须串行)。 - ---- - -## 9. 风险与回滚 - -| 风险 | 触发条件 | 回滚 | -| --- | --- | --- | -| 迁移后"连点丢一半" | `DoubleClickIsPlainPress` 语义漏实现 | 单组件提交可独立 revert | -| 迁移后"拖动卡住" | capture 共用被拆成两套 | 同上 | -| 面板失效不全(贴旧像素) | `ContentKey()` 漏字段 | P4 里每个面板配一条"改 X 后像素必须变"的断言 | -| 体积超 900KB | 新框架代码量 | 每阶段结束量一次;超阈值先报告用户 | -| 布局级联死循环 | Measure 里改子节点 | 单元测试固定"Measure 一轮即收敛" | - ---- - -# 第五阶段计划:统一组件协议 + 去手写链路 + 拆分收尾(2026-10-28) - -> 状态:✅ 已完成(详见 `UI_Refactor_Progress.md` 第七轮 / `UI_Refactor_Report.md` 第五阶段) - -## 目标 - -1. 让"顶层组件"有统一协议,宿主不再逐个点名; -2. 用同一套协议把 `SetScale` / `Layout` / `MouseLeave` / `SetCursor` 四条手写链改成循环; -3. 把剩余两个"千行以上"的文件按**职责**拆分(不再按函数名前缀); -4. 一轮代码审查(错误注释 / 过期注释 / 名实不符 / 残留引用); -5. 把误入库的临时验证工具 `Tools/` 从提交树剥离并写入 ignore。 - -## 设计决定(先证明不是负优化) - -* **协议只做"同签名归一"**:`UiComponent` 是纯接口、无状态,四个组件本就各自拥有 - 完全同名同签名的方法 ⇒ 改造 = 加 `override` + 补 `Area()`,实现零改动、生成码几乎不变。 -* **不把 `UiComponent` 做成 `UiWidget` 的基类**:树节点是"父给尺寸 + 父下探脏区", - 而 `PlotView`(4 层位图缓存)/ `SignalPanel`(行位图缓存)是"地盘宿主"。 - 强行上树会推翻缓存前提(历史事故:后台渲染覆盖平移、命中矩形错位)。 -* **不做的一律写明理由**:按下/松开/移动三条链带组件专属后置动作,保留显式分支。 - -## 任务清单 - -| # | 任务 | 验证手段 | -| --- | --- | --- | -| P5-1 | 新增 `Shared/Inc/UiComponent.hpp`,四组件继承 + `override` | 编译零警告 | -| P5-2 | `Application`:`ComponentEntry` 表 + `BuildComponentTable()` | — | -| P5-3 | `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 表驱动 | `uicheck` 69/69 | -| P5-4 | `Relayout` 内容区改用 `UiStackRects`(布局库首个生产调用点) | `rendercheck` 8 哈希不变 | -| P5-5 | `SignalPanelInteraction.cpp` 拆 4 片 + `SignalPanelEdit/Scroll/Menu.cpp` | 8 哈希不变 | -| P5-6 | `PopupMenu.cpp` 拆 3 片 + `PopupMenuInternal.hpp` | 8 哈希不变 | -| P5-7 | 代码审查(注释准确性、构造函数重复、include 位置) | 人工 + 编译 | -| P5-8 | `git rm -r --cached Tools` + `.gitignore` + 历史过滤器 | `git ls-files Tools` 为空 | -| P5-9 | 三份文档更新 + 提交 | `git status` 干净 | - -## 风险与对策 - -| 风险 | 对策 | -| --- | --- | -| 表驱动改动影响绘制顺序(toolbar 与 tabBar 重叠区) | 表按"底→顶"排,`WM_SETCURSOR` 从表尾向前命中,与旧 `if` 顺序一致 | -| 拆分导致逐像素差异 | 纯整函数搬移;每拆一个文件立刻跑 `rendercheck` 比对 8 哈希 | -| `filter-branch` 会清掉工作区被剔除的路径 | 先备份 `Tools/` 到 `%TEMP%`,重写后还原为未跟踪文件 | -| 中文注释在 PS 5.1 下乱码 | 文档编辑走 VS Code 编辑工具(UTF-8),终端只做 ASCII 拼接 | - -## 完成判据 - -* `make release` / `make debug` 零 error 零 warning; -* `Tools/rendercheck` 8 个场景哈希与基线**逐一相同** + 7 条 FUNC 断言 PASS; -* `Tools/uicheck` 69/69; -* `TargetCheck.exe` < 900 KB; -* `git ls-files Tools` 返回空、`git status` 干净、`main` / `dev` 未被触碰。 - ---- - -# 第六阶段(2026-10-28):「显示布局边界」调试开关 + 三处可见收益优化 - -> 用户要求:先做可见收益的优化,然后在「导入文件」左侧加一个齿轮按钮,点击展开二级菜单, -> 菜单里放「显示布局边界」勾选项,风格参考 Android 调试功能;先落地,后续再改进。 - -## 1. 可见收益的优化(3 项,均为纯收益、行为不变) - -| # | 位置 | 改前 | 改后 | 收益 | -| --- | --- | --- | --- | --- | -| O1 | `Toolbar::Layout` | **每次**都 `RebuildWidgets()`(销毁 + 重建整棵树) | 新增 `m_treeDirty`,只在规格/字体/主题/缩放/动态文本变化时重建 | 拖动面板分隔条时 `Relayout` 每帧调用一次 ⇒ 每帧省 10 次堆分配 + 8 次测量缓存失效 | -| O2 | `Toolbar::SetActionLabel` | 同时写「覆盖表」和「控件文本」两处 | 只写覆盖表 + 置树脏 | 消除双写可能不同步(旧实现下次重建会把文本退回默认值) | -| O3 | `Toolbar` / `TabBar` 的 `OnMouseMove` | 各自手写 `Dispatch(); return UiRectHasArea(TakeDirty());` | 新增 `UiWidget::DispatchRedraw(event)` | 两处重复收敛成一处;`TakeDirty()` 会清零,漏掉一次就把下一次真实变化误判为"无变化" | - -## 2. 新功能:齿轮按钮 → 应用设置菜单 → 显示布局边界 - -### 2.1 入口 - -* `ToolbarAction::SETTINGS` + `kButtonSpecs[0]`(`UiButtonIcon::GEAR`)。 -* 工具栏布局从"首按钮靠左 + 其余靠右"改为**左侧固定簇**(`kLeftClusterCount = 2`:齿轮 + 导入文件)+ 右侧按钮组。 -* 齿轮是**矢量绘制**(`PaintGearIcon`:8 齿 + 外环 + 轴孔,全部按 `half` 的比例推导), - 不用字体里的 `⚙` —— 该字符在不同回退字体里的大小与基线不一致,会"忽大忽小、垂直不居中"。 -* `UiButtonWidget` 新增 `UiButtonIcon`(`NONE`/`GEAR`)与 `SetIcon`;图标按钮宽度固定 40 逻辑像素。 -* `Application::OpenSettingsMenu()` 用 `Toolbar::ButtonRect(SETTINGS, …)` 取**控件实测矩形**做锚点 - (宿主不自己推算按钮几何),菜单在按钮下沿 +4 逻辑像素处右下展开。 - -### 2.2 菜单 - -`MenuScope::APP_SETTINGS`(新增)→ 宿主自己处理,不下发到组件。 -条目:`HEADER "调试"` + `NORMAL "显示布局边界"`(`keepOpen = true`,点击后菜单保持打开, -勾选态由 `PopupMenu::UpdateItemLabel` 回写)。 - -### 2.3 布局边界:为什么放在共同基类 - -`UiComponent` 新增 **`virtual void PaintDebugBounds(gd::Graphics&, float scale)`**,默认只画 `Area()`。 -四个组件各自覆盖:先调基类(自己的区域),再补画**层级更深的**矩形。 - -这正是上一轮回答里"是不是只需要在共同基类内实现"的答案: -**能覆盖顶层 4 块区域,覆盖不了组件内部的手绘结构**。所以采用"基类给出协议 + 组件自己上报": - -| 组件 | 上报内容 | -| --- | --- | -| 全部 | `Area()`(层级 0) | -| `Toolbar` / `TabBar` | 跳过与区域重合的根控件,直接遍历 `m_root->Children()`(层级 1);TabBar 另加每个 ✕(层级 2) | -| `SignalPanel` | 表头 / 筛选框 / 列表 / 样式区(1)+ 滚动条、样式行、**当前可见**的列表行(2) | -| `PlotView` | 每个 `LaneVisual::rect`(1);**不**上报曲线/胶囊/提示这类随数据变化的瞬态几何 | -| 宿主 | 客户区外框、分隔条、打开的菜单 / 拾色器(层级 0) | - -`UiWidget::PaintDebugBounds(g, scale, depth)` 递归整棵控件树 —— 凡已上树的控件**零成本**获得边界。 - -### 2.4 视觉与实现约束 - -* **只描边、不填充**(与 Android 一致):填充会在几十个嵌套矩形上层层叠加把内容糊住。 -* 层级配色:0 青蓝 `#29B6F6`(2px)/ 1 品红 `#E040FB` / 2 琥珀 `#FFB300` / ≥3 绿 `#66BB6A`(1px)。 -* 线宽按**设备像素**固定(不乘 `scale`),并临时 `PixelOffsetModeNone` + 关抗锯齿 + 矩形对齐整像素, - 保证 1px 就是 1px。 -* 绘制位置:`Render` 的**最后一步**(加载遮罩之后)。⚠️ **绝不进入任何位图缓存** - (`PlotView` 4 层缓存、`SignalPanel` 行位图缓存)—— 否则关掉开关后线条会残留。 -* `fullFrame` 追加 `m_showLayoutBounds`:开启时必须整帧回送,否则"只回送绘图区"的快路径会让 - 装饰区的边界停在旧帧上。 - -## 3. 验证 - -| 项 | 结果 | -| --- | --- | -| `make release` / `make debug` | 零 error / 零 warning | -| **新增离屏探针 `.preflight/lbcheck`** | **21 / 21 PASS** | -| `Tools/rendercheck` 8 场景**工具栏以下区域**哈希 | 与改造前**逐一相同**(改动只落在工具栏带内) | -| `Tools/rendercheck` 7 条功能断言 | 7 / 7 PASS | -| `Tools/uicheck` | 69 / 69 | -| `TargetCheck.exe` | 897 024 B(< 900 KB) | - -新增断言覆盖:齿轮存在 / 在「导入文件」左侧 / 同高 / 更窄;开机后无调试色; -开启后青蓝 24 930 px、品红 19 734 px;**关闭后逐像素回到开启前**;菜单 scope、锚定贴合按钮、 -宽度未被撑大、条目初始未勾选、点击翻转开关、菜单保持打开。 - -## 4. 后续可改进(用户:"先落地实现然后再进一步改进") - -1. 边界左上角标短 tag(`row#12` / `lane3`)—— 需要处理 GDI 文本不受 GDI+ 裁剪约束的问题。 -2. `PlotView` 的浮动面板(`EdgePanelLayout` 里的 `panel/title/close/…`)目前未上报; - 它们已经是集中结构,插入一个循环即可。 -3. `SignalPanel` 的复选框 / 徽标 / ✕ 等行内子块(现在只到"行"这一层)。 -4. 开关状态目前**不持久化**(重启恢复为关闭);若要持久化需要引入配置存储。 -5. 菜单只有一项;后续设置项按 `HEADER` 分组追加即可。 - ---- - -# 第七阶段计划(2026-10-28 第九轮):边界观感重做 + 覆盖补全 + 几何同源 - -> 用户反馈:① 设置齿轮按钮的颜色应与「导入文件」一致; -> ② 边界绘制"就是实心矩形、粗细不一",希望是**四角粗、其他边细**; -> ③ 按上一轮总结的覆盖率问题着手改进(很多元素没有边界)。 - -## 任务清单 - -| # | 任务 | 验证手段 | -| --- | --- | --- | -| P7-1 | 齿轮按钮改为主按钮(`primary = true`),与「导入文件」同色 | 探针断言两按钮底色一致 | -| P7-2 | 边界的描边改为**四角粗(3–4px)+ 四边细(1px)**的包角样式;只描边不填充 | 逐像素探针 + 人工核对 | -| P7-3 | 层级 > 0 的 box 内缩 1px,避免与父矩形共边时叠成粗线 | 同上 | -| P7-4 | 协议从「组件自己画」改为「组件**收集 box**、宿主统一画」(`CollectDebugBoxes`) | 编译 + 探针 | -| P7-5 | `SignalPanel`:行内元素全部上报(复选框/色块/名称/GX 徽标/✕/折起三角/组名/计数)+ 筛选框/清空✕/滚动条/滑块/样式区 5 行与行内控件/目标行/编辑框 | 探针断言 box 数量 | -| P7-6 | `PlotView`:GX 徽标、装饰层矩形、图例条目(托底/色块/文本)、5 类浮动面板全部字段 | 同上 | -| P7-7 | `Toolbar`:按钮内部**文本/图标**矩形(新增 `UiButtonWidget::ContentRect()`) | 同上 | -| P7-8 | `PopupMenu` / `ColorPickerPopup`:条目、滚动区/轨道/滑块、子菜单;拾色器各控件 | 同上 | -| P7-9 | `RowRect` 从 **O(row) → O(1)**(行顶前缀和 `m_rowTop`) | 行为不变(像素哈希) | -| P7-10 | `PanelBounds()` 收敛 6 处重复三元表达式 | 编译 | -| P7-11 | 三份文档 + `agent_mem.md` 同步;`TargetCheck.exe` < 900 KiB | 构建 | - -## 风险与对策 - -| 风险 | 对策 | -| --- | --- | -| 前缀和与行集合短暂不一致 | `RowRect` / `ContentOffsetBeforeRow` 检测长度后**退回逐行累加**(正确性优先) | -| box 数量暴涨导致调试帧变慢 | 只在开关打开时执行;标签设**尺寸门槛**(< 56×16 逻辑像素不画标签) | -| 调试层污染位图缓存 | 仍只在 `Render` 最后一步叠加,探针 `bounds-off-restores-pixels` 钉住 | -| 浮层 box 需要读取私有几何 | 给两个浮层加 `CollectDebugBoxes`,几何仍由它们自己给出(不外泄成员) | - -## 完成判据 - -* `make release` / `make debug` 零 error 零 warning; -* 「工具栏带以下」区域哈希与上一轮**逐一相同**(证明改动只落在工具栏带内); -* `lbcheck` 21/21、`rendercheck` FUNC 7/7、`uicheck` 69/69; -* 打开开关后:每行的复选框/色块/名称/GX 徽标/✕、每个网格的 GX 徽标与图例、每个浮层控件都有独立包角框。 - ---- - -# 第八阶段计划(2026-10-28 第十轮):边界观感自适应 + 残留与层级缺陷修复 - -> 用户反馈:① 包角对小元素来说太粗(小方框直接变成粗描边);② 不需要黑色底衬的文字标签; -> ③ 加载 MF4-Record.MF4 全部信号独立网格后,按住左键拖动网格滚动条快速滚动会出现大量 -> 绘制残留(工具栏与网格区域都有),松开并移动鼠标才消失;④ 右键二级菜单上被下层元素的 -> 边界线覆盖,干扰观察菜单布局。 - -## 任务清单 - -| # | 任务 | 验证手段 | -| --- | --- | --- | -| P8-1 | 包角粗细/长度按矩形短边自适应;小方框只画角不画边;过小则不画 | 截图放大 3 倍核对 | -| P8-2 | 删除文字标签(连同 `UiDebugPaintStyle`),降低体积与视觉噪声 | 编译 + 体积 | -| P8-3 | 定位并修复「拖网格滚动条时的绘制残留」 | 新增离屏探针 `.preflight/lbresid` | -| P8-4 | 修复 `PlotView` 误用 `m_decorRects`(位图坐标系)产生的跨工具栏幽灵框 | 同上(box 清单打印) | -| P8-5 | 调试叠加层开启时强制绘图区整幅重绘(禁用三条快路径) | 同上(同状态两帧必须逐像素相同) | -| P8-6 | box 增加 `clip`(组件地盘),组件内越界元素不再画到邻居上 | 同上(差异 bbox 不得越过绘图区上沿) | -| P8-7 | 层级:`(layer, depth)` 双排序键 + 浮层 `occluder` 挖洞 | 新增探针 `.preflight/lbzorder`(4 条断言) | -| P8-8 | 文档与共享记忆同步;`TargetCheck.exe` < 900 KiB | 构建 | - -## 风险与对策 - -| 风险 | 对策 | -| --- | --- | -| 强制整幅重绘让调试模式变慢 | 只在开关打开时生效;关闭立即恢复快路径 | -| `Region::Exclude` 影响其他绘制 | 只在 `Save/Restore` 内临时 `SetClip`,且仅在存在遮挡时才走该分支 | -| 裁剪把紧贴边界的包角裁掉 | 裁剪用 `floor/ceil` 向外取整(宁可多一像素) | -| 移除标签后难以辨认 box 归属 | 层级配色(青蓝/品红/琥珀/绿)+ 包角形状 | - -## 完成判据 - -* 零 warning;`lbresid` 5/5、`lbzorder` 4/4、`lbcheck` 21/21、`uicheck` 69/69; -* `rendercheck` 的「工具栏带以下」区域哈希与上一轮**逐一相同**、FUNC 7/7; -* 拖动滚动条期间工具栏区域像素**完全不变**,且同状态两帧**逐像素相同**; -* 菜单/二级子菜单区域内不出现下层组件的边界色。 - ---- - -# 第九阶段计划(2026-10-28 第十一轮):把"边界"这件事从补丁改成机制 - -> 背景:第十轮结论是"三个性质不同的问题"——① `inset` 破坏 '框 = 可见形状外接矩形'; -> ② 元素清单不完整(几何没有枚举出口);③ 描边图元太多。本轮把三者一起解决。 - -## 任务清单 - -| # | 任务 | 验证手段 | -| --- | --- | --- | -| P9-1 | **取消内缩**:框必须与可见形状外接矩形重合(修 GX 胶囊圆弧落在框外) | 探针断言 box 与 `LaneBadgeRect` 重合(容差 0.5px) | -| P9-2 | **文本获得几何身份**:`PaintUtil` 增加 `UiTextRectSink`,在绘制的**唯一出口**回报墨迹矩形 | 覆盖 60+ 调用点,无需逐处修改 | -| P9-3 | 离屏位图(面板行缓存)中的文本按**位图原点**回放,缓存命中也能显示文本框 | 探针 `row-text-boxes-from-cached-rows` | -| P9-4 | 补齐 `PlotView` 漏项:时间轴 / 网格滚动条 / 回到最上方 / 光标标签 / 差值标签 / 内联输入框 | 探针 4 条 box 断言 | -| P9-5 | 描边图元 12 → 8(4 边 + 4 角块),耗时 5.9ms → 4.0ms | 微基准 + 帧耗时分解 | -| P9-6 | 关闭开关时清空容器(释放内存,避免误读) | 探针 `boxes-cleared-when-off` | -| P9-7 | 文档 + 共享记忆;`TargetCheck.exe` < 900 KiB | 构建 | - -## 关键设计决定 - -* **文本不做"逐处登记"**:全仓 65 处文本绘制点手写登记必漏。改为在 `UiText`/ - `UiTextClipped`/`UiTextAtClipped` 的公共出口 `ReportTextRect()` 里回报一次, - 代价是开关关闭时一次判空。 -* **缓存命中也要有文本框**:行位图渲染时用一个本地收集器把矩形存进 `RowBitmapCache`, - 贴图时按原点偏移回放;否则第二帧起文本框全部消失。 -* **不内缩**:重叠时"后画的覆盖"就是正确语义,内缩是错误取舍。 - -## 完成判据 - -* 零 warning;四个探针全绿(21 + 5 + 4 + 7)、`uicheck` 69/69; -* `rendercheck` 8 个场景的整帧与区域哈希与上一轮**逐一相同**(调试功能不能影响正常绘制); -* 打开开关后:文本有橙色墨迹框、GX 胶囊被框住、时间轴/滚动条/回到最上方均有框。 +Components/Shared/Inc/UiBox.hpp 新增(几何产物层) +Components/Shared/Src/UiBox.cpp 新增 +Components/Shared/Inc/UiDebugBounds.hpp 精简为"只画"(消费者) +Components/Shared/Src/UiDebugBounds.cpp 精简为"只画" +Components/Shared/Inc/UiComponent.hpp CollectDebugBoxes → BuildBoxes +Utilities/Inc/MemProbe.hpp 新增(分配预检验 + terminate 兜底) +Utilities/Src/MemProbe.cpp 新增 +Makefile LAYOUT_BOUNDS / NO_EXCEPTIONS / LTO 三个开关 +Documents/UI_Refactor_{Plan,Progress,Report}.md 重建 +``` diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index 9db4197..f34e4b2 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -1,679 +1,202 @@ -# UI 重构进度(UI_Refactor 分支) +# Just DataInspector UI 重构 —— 当前进度 -> 本文件是**跨会话/跨 Agent 的交接台账**。每次提交前更新;每一行都要能对应到一个可复跑的验证结果。 -> 计划见 `Documents/UI_Refactor_Plan.md`;硬约束见 `Documents/agent_mem.md` §11.2。 +> 计划见 `UI_Refactor_Plan.md`,结论与遗留见 `UI_Refactor_Report.md`。 +> 最后更新:2026-10-29(本轮:体积 L3 落地 + L1 几何产物层 + 审计) -## 分支状态 +--- -| 分支 | 基线 | 用途 | +## 1. 总览 + +| 层 | 内容 | 状态 | | --- | --- | --- | -| `main` | `df715a0` | 可发布,**不受本次重构影响** | -| `dev` | `4e51b41` | 日常开发基线;本分支上已 **删除过时报告** `Documents/refactor_report_2026-10-16.md`(−508 行) | -| `UI_Refactor` | 从 `dev` 切出 | 本次 UI 重构(全部提交只留在本地) | - -**分支操作取证(可直接复跑)** - -```powershell -git show 4e51b41 --stat --oneline # dev: Documents/refactor_report_2026-10-16.md | 508 ---------- -Test-Path Documents\refactor_report_2026-10-16.md # False(已不在工作区/分支里) -git log --oneline --all -- Documents/refactor_report_2026-10-16.md # 末条即为 4e51b41 的删除提交 -git log --oneline main -1 # df715a0(main 未被任何重构提交触碰) -``` - -## 阶段看板 - -> **本计划已全部结案**:可达成项已落地(P0/P1/P2/P3-Toolbar+TabBar/P5-共享表面), -> 其余项经评估**不采用**并写明理由(见 `UI_Refactor_Report.md` §7)—— 计划里没有悬空阶段。 - -| 阶段 | 内容 | 结论 | -| --- | --- | --- | -| **P0** | 离屏回归网 `Tools/uicheck` | ✅ 已落地(69 项断言) | -| **P1** | `UiLayout` + `UiWidget` 基础层 | ✅ 已落地 | -| **P2** | `PlotViewPaint.cpp` 零行为分片 | ✅ 已落地(9 080→4 913,逐像素 0 差异) | -| **P3** | chrome 迁移 | ✅ Toolbar / TabBar 已迁移;PopupMenu / ColorPicker / SignalPanel 行**经评估不采用** | -| **P4** | 5 个浮动面板控件化 | **经评估不采用**(理由见 Report §7) | -| **P5** | 共享表面 `UiSurface` + top-down DIB | ✅ 已落地;脏区并集**经评估不采用** | +| L0 | `UiComponent` 协议 + 有序组件表 + `UiWidget` 控件树 | ✅ 已完成(前几轮) | +| L1 | `UiBox` / `UiBoxTable` / `UiComponent::BuildBoxes` | ✅ **本轮完成** | +| L2 | `UiDamage` 脏矩形 + S4 独立叠加层 | ⏸ **评估后缓做**(理由见 Plan §5) | +| L3 | `-fno-exceptions` + `MemProbe` + `-ffunction-sections` + `--gc-sections` + LTO | ✅ **本轮完成** | --- -## 提交记录 +## 2. L3 体积与异常策略 -### 1. `P0 + P1`:UI 基础层与离屏回归网 -**新增文件** +### 2.1 Makefile 开关 -| 文件 | 行数 | 职责 | -| --- | --- | --- | -| `Components/Shared/Inc/UiLayout.hpp` | ~250 | 布局原语 + `UiLayoutCache` | -| `Components/Shared/Src/UiLayout.cpp` | ~300 | 矩形原语 / 对齐 / Stack / Flow / 缓存 | -| `Components/Shared/Inc/UiWidget.hpp` | ~330 | 控件基类接口与设计说明 | -| `Components/Shared/Src/UiWidget.cpp` | ~560 | 树 / Measure·Arrange / Paint(脏区+裁剪)/ 派发 / 仲裁 / 动画 | -| `Tools/uicheck/main.cpp` | ~570 | 离屏回归网(62 项断言) | -| `Tools/uicheck/build.ps1` | ~70 | 链接 `Build//*.o`(排除 `Main.o`) | - -**已实现并验证的行为** - -- 布局:`UiAlignInRect`(含 `STRETCH`、超尺寸夹取、偏移后置)、`UiRectSnapInward`(设备像素内侧对齐)、 - `UiStackRects`(固定 / `kUiSizeAuto` 均分 / **绝不产生负尺寸**)、`UiFlowRects`(换行 + `overflow` 下标)。 -- 缓存:`UiLayoutCache` 用 `std::deque` 保证**引用稳定**;容量淘汰按"最久未命中";`Invalidate` 为**定向**失效。 -- 布局 pass:`Measure` 命中缓存不重复调用 `OnMeasure`;`InvalidateMeasure` **只**沿祖先链失效(兄弟不重算)。 -- 绘制:`dirty` 为空 = 全画;与子树矩形无交集 ⇒ **整棵子树跳过**;有效裁剪 = 祖先裁剪切 ∩ 自身矩形 ∩ 脏区; - 不可见节点跳过。 -- 派发:**只沿命中链**(最深的命中节点 → 祖先冒泡),**绝不分发给同层兄弟**;`capture` 期间事件直投捕获者; - `modal` 只向模态子树派发;`hit-transparent` 让事件穿透;`QUERY_CURSOR` 由最上层认领者返回; - 键盘/字符沿**焦点链**派发(无焦点交给根,保证宿主级快捷键可用)。 -- 动画:`AdvanceAnimation` 逐节点下发同一时间戳并汇总"是否仍有动画"。 -- 悬停:进入 / 离开各只触发一次,跨子节点迁移时正确配对。 - -**过程中修掉的两个自身缺陷** - -1. `UiWidget` 缺 `AdvanceAnimationSelf` / `AnimationActiveSelf` 两个 protected 虚函数(`.cpp` 已引用、头文件漏声明)。 -2. 最初的派发实现是"遍历同层兄弟、未消费就继续" ⇒ **一个点击可能同时被视觉上位于下方的控件处理**; - 已改为"只沿命中链派发 + 祖先兜底"。回归网里对应的断言从"绝对次数"改为**增量断言** - (绝对次数会被前面用例计数污染,本轮踩过一次)。 - -**验证** - -``` -make release TARGET_FILE_NAME=TargetCheck # 零警告 -powershell -File Tools/uicheck/build.ps1 # 链接 Build/Release/*.o(排除 Main.o) -Tools/uicheck/uicheck.exe # 62 passed, 0 failed -``` - -**未做(有意)** - -- 未改动任何现有组件(Toolbar / TabBar / SignalPanel / PlotView 仍走旧路径),因此**本提交对产品像素零影响**。 -- 未把 `UiWidget` 接入 `Application`,避免在 P2 之前引入行为差异。 - ---- - -### 2. `P2`:`PlotViewPaint.cpp` 按关注点分片(零行为变更) - -**为什么**:该文件 9 080 行里混住四类内容 —— 真绘制 ≈3.5k / 光栅化缓存 ≈2.5k / 缓存键 ≈1.1k / -布局与命中几何 ≈1.3k。按"函数名前缀"分片是上一轮膨胀的直接原因,本轮按**关注点**再分一次。 - -**做法**:**机械搬移成员函数定义**(不改一行逻辑)。`PlotViewInternal.hpp` 里的 `pvdetail` 全部 `inline` -⇒ 跨分片安全,无需改任何 include、无需改 `Makefile`(按 basename 自动收集 `.cpp`)。 - -| 文件 | 搬入内容 | 行数变化 | -| --- | --- | --- | -| `PlotViewRaster.cpp`(新) | 光晕离屏层(`PaintSignalHalo*` / `UploadHaloLayer` / `ReleaseHaloLayer`)
场景缓存(`Ensure/Capture/Blit/Invalidate/ReleaseSceneCache`)
装饰层(`Ensure/Release/Render/BlitDecorCache` / `ResolveAlphaBlend`)
宽基底图层与预算(`EnsureLaneLayer` … `BlitWideBuffer`、并行重建、滚动补条、预热) | 1 876 | -| `PlotViewCacheKey.cpp`(新) | `MakeSceneCacheKey` / `SceneCacheMatches` / `SceneHaloRef`
`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`
`CollectSceneStyles` / `FindStyleOnlyLanes` / `CollectSceneDerived` / `RepaintSceneCachePatch` | 474 | -| `PlotViewPaint.cpp` | 只剩绘制(`Paint*` / `Render*` / 输入框绘制) | 9 080 → **6 252**(−31%) | - -**验证(关键)** - -1. `make release TARGET_FILE_NAME=TargetCheck` **零警告**;`Target.exe` 873 984 → **875 008 B**(+1 KB, - 属分片后各 TU 的对齐差异;仍远低于 900 KB 阈值)。 -2. **逐像素 A/B**:临时写了一个渲染哈希探针(仓库外),对三个场景渲染后备缓冲区并取 FNV 哈希: - * A 默认全览;B 深放大 + 自由标记(含固定点)+ 均值滤波 + 弱峰调整 + 时间光标;C 再平移一帧(触发宽基底滚动/补条)。 - * **分片前**:`A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418` - * **分片后**:**完全相同**(三个场景逐一一致)。 - * 该探针未入库(一次性验证用),结论记录在此以便复现。 -3. `Tools/uicheck` 62/62 全绿。 -4. 顶层注释从"四个分片"更新为"六个分片(按关注点)",并写明"`Paint/PaintLane` 仍只允许定义在 `PlotViewPaint.cpp`"。 - -**⚠️ 复现 A/B 的坑**:机械搬移后用旧的 `.bak` 回退时,`Build//` 里会残留 -`PlotViewRaster.o` / `PlotViewCacheKey.o`,链接探针(`Build/Release/*.o`)会因重复符号失败 —— -**必须先删掉这两个陈旧 `.o`** 再链接。 - ---- - -### 3. `P2 续`:把"布局与命中几何"从绘制分片里拆出(`PlotViewPanels.cpp`) - -**为什么**:`EdgePanelLayoutFor`(651 行,19 个调用点)、`EdgePanelWidth`、`LaneBadgeRect`、 -`FreeMarkerValue/Delta/Contents/LabelRects`、`ProbeFreeMarker` 都是**几何**,却被塞在绘制分片里 —— -改一次布局要动 9 千行的绘制文件,回归面被放大。 - -**做法**:同样机械搬移(5 个不连续块),`PlotViewPaint.cpp` 从 6 645 → **4 913** 行。 - -**最终七个分片**(按关注点) - -| 文件 | 行数 | 职责 | -| --- | --- | --- | -| `PlotViewPaint.cpp` | 4 913 | 绘制(`Paint*` / `Render*` / 输入框绘制) | -| `PlotViewInteraction.cpp` | 3 430 | 命中、鼠标/键盘、光标形状、面板层序 | -| `PlotViewFeatures.cpp` | 3 266 | 光标读数、提示、缩放、自适应、派生曲线、边沿统计 | -| `PlotViewRaster.cpp` | 1 876 | 光晕层 / 场景缓存 / 装饰层 / 宽基底图层与预算 | -| `PlotViewPanels.cpp` | 1 359 | 面板布局、图例徽标几何、自由标记取值与探测 | -| `PlotView.cpp` | 889 | 视图核心(构造、布局、坐标映射、网格结构) | -| `PlotViewCacheKey.cpp` | 474 | 内容指纹与缓存键 | - -**累计效果**:`PlotViewPaint.cpp` **9 080 → 4 913(−46%)**,最大文件从 9.1k 降到 4.9k。 - -**验证** - -1. `make release TARGET_FILE_NAME=TargetCheck` 零警告;`Target.exe` **877 568 B**(< 900 KB)。 -2. **逐像素 A/B**:同三场景哈希与迁移前**完全一致** - (`A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418`)。 -3. `Tools/uicheck` 62/62。 -4. 顶层注释更新为"七个分片(按关注点)",并标明 `Paint/PaintLane` 只允许定义在 `PlotViewPaint.cpp`。 - ---- - -### 4. `P3`:Toolbar 迁移到控件树(**逐像素 0 差异**) - -**新增**:`Components/Shared/{Inc,Src}/UiButtonWidget.*` —— 通用"文字按钮"控件,视觉规则与旧 Toolbar -按钮逐像素一致(主按钮强调色实底 / 普通按钮 1px 描边 / 可切换选中态 / 禁用态配色); -交互由自己处理:按下即 `CaptureMouse()` 进入按下态,**在自身范围内**松开才触发 `onClick`, -指针离开时 `CancelPress()`。 - -**`Toolbar` 内部重写(公开 API 一字未改)**: - -| 旧实现 | 新实现 | -| --- | --- | -| `std::vector