From 71195c4c482c6cb61c2d8654f6a107c9a94d0743 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Thu, 29 Oct 2026 20:38:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=BB=E9=A2=98=E9=85=8D?= =?UTF-8?q?=E8=89=B2=EF=BC=884=20=E5=A5=97=EF=BC=89+=20=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E7=B2=BE=E7=AE=80=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E5=85=8B=E9=9A=86=E8=AF=A5=E4=BF=A1=E5=8F=B7=E3=80=8D?= =?UTF-8?q?+=20=E5=B8=83=E5=B1=80=E8=BE=B9=E7=95=8C=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=20+=20=E5=8D=95=E7=82=B9=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=E5=90=B8=E9=99=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么 用户 2026-10-29 报告/要求五件事: 1. 派生信号的「时间轴偏移保留在样式上、其它调整烘值」维持现状(确认无需改动); 2. 信号名右键菜单里很多功能已被下方样式自定义区覆盖 ⇒ 只保留「仅显示该信号」, 并在它**上方**新增「克隆该信号」; 3. 设置菜单新增「主题配色」二级菜单(带 √ 状态),顺序 Light / Solarised Light / Dark / Solarised Dark,默认主题维持现状; 4. 布局边界:中键平移视界时浮动面板上的边界**闪烁**;光标取值胶囊的**文字**框会**穿透** 到浮动面板上;并请核对网格内固定构件(图例 / Y 轴标尺 / GX 标签 / 回到最上方 / 浮动面板)的层级关系; 5. 单数据点信号在拖动光标时的**吸附**功能消失。 ## 根因 ### 布局边界层级(两个都很隐蔽) - **文本层号写死**:文本是在绘制过程中由 `UiTextRectSink` 收集的,而宿主把所有文本都记在 固定层 2;组件几何层却是"组件表下标"(`PlotView` = 0)。面板层 = PlotView 层 + 1 = 1, **低于**文本层 2 ⇒ 面板的遮挡矩形挖不到文本 ⇒ 胶囊文字画在面板表面。 - **面板像素缓存**:面板"先画进图层、命中缓存就贴图",贴图那一帧不画字 ⇒ 文本框凭空消失 ⇒ 面板上的边界闪烁。 - 顺带发现 `PlotView::BuildBoxes` 用 `PushLayer()` 却**从不复位**,会把宿主后续所有组件的 层号整体推高。 ### 单点信号吸附 `MinorCellSparse` 在"把判定范围收窄到数据实际时间跨度"的分支里有 `if (!(dataMax > dataMin)) return false;`。单个有效采样点时 `dataMax == dataMin`, 于是被判定成"不稀疏" ⇒ 不吸附。 ## 改了什么 ### 1. 布局边界层级 - `UiTextRectSink` 新增 `OnTextLayer(int)` / `TextLayer()`;新增 `UiSetTextLayer` / `UiGetTextLayer`;`UiBoxTable` 实现为改 `m_layer`,并暴露 `Layer()`。 - `Application::Render` 在绘制每个组件前下发 `UiSetTextLayer(ComponentLayer(&component))`, 浮层用 `OverlayLayer(1/2)`;新增 `ComponentLayer()` / `OverlayLayer()` / `kLayersPerComponent = 3`,与 `PaintLayoutBounds` 的层号**完全同源**。 - `PaintLayoutBounds` 改为**显式** `SetLayer(下标 × 3)`(不再依赖 `PushLayer` 累加)。 - `PlotView`:`BuildBoxes` 用 `baseLayer + 1` 放浮动面板(并登记遮挡)、 `baseLayer + 2` 放绘制在面板之上的滚动条 / 回到最上方 / 输入框,结束**复位**层号; `paintOverlays` 同步分两次抬高文本层。 - 面板像素图层新增 `PanelSlot::textRects` + 转发型收集器 `PanelTextCollector`: 渲染时存文本框,命中缓存贴图时回放 ⇒ 几何与缓存命中无关(消除闪烁)。 ### 2. 信号右键菜单 + 克隆 - 菜单只剩:标题、**克隆该信号**、**仅显示该信号**;删除显示/隐藏、显示全部/隐藏全部、 预设色块行、样式三项,以及对应的 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、 `CMD_COLOR_BASE`、`CMD_MODE_BASE` 及其分支(死代码)。 - 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`)→ `Application::CloneSignal`: 把「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘进采样值、**时间轴偏移保留在样式上**, 名称前置 `*`,插入到源信号**正下方**,并**保留原外观**(颜色/透明度/线宽/绘制样式)。 - 抽出 `Application::InsertSignalBelowSource(...)` 供派生与克隆共用。 ### 3. 主题配色 - `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}` + `ThemeFor()` / `ThemeName()`; Light 取 VS Code 2026 默认浅色、Dark 取 Dark+、Solarized Dark 取 VS Code solarized dark。 **默认仍是 Solarised Light**。 - 新增 `CurrentTheme()` / `SetCurrentTheme()`:宿主交出自己的 `m_theme` 地址, 切换主题只改内容;`UiControls` 里没有 `Theme` 形参的原语改用它(原来硬编码 `SolarizedLight()`,换主题不生效)。 - `Application::ApplyTheme()`:改配色 → `PlotView::OnThemeChanged()`(作废场景/宽基底/ 装饰/光晕/面板图层)+ `SignalPanel::OnThemeChanged()`(作废行位图)→ 整窗重绘。 - 菜单用 `SUBMENU` + 4 个 `keepOpen` 子项;新增 `PopupMenu::UpdateSubmenuItemLabel()` 回写勾选,并让子菜单分支尊重 `keepOpen`(原为无条件关闭)。 ### 4. 单点信号吸附 `MinorCellSparse` 改为只在 `dataMax < dataMin` 时放弃;相等时按"那一个小格"判定 (`need = 5 > finiteCount = 1` ⇒ 稀疏 ⇒ 吸附恢复)。 ### 5. 审计 `Makefile` 的 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用** (`LAYOUT_BOUNDS=0` 产物字节数完全相同),属名实不符的死开关 ⇒ 删除并注明原因。 ## 怎么验证 新增两支离屏探针(不占鼠标 / 键盘 / 屏幕): ``` .preflight/round15check 28 passed, 0 failed 单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧与中键平移稳定 2 条、 克隆 14 条(烘值逐点比对 / 名称前缀 / 偏移与外观保留 / 调整复位) .preflight/themecheck 17 passed, 0 failed 4 套主题可读性与可区分、默认 = Solarised Light、逐主题换色且无旧底色残留、 切回默认逐像素还原(diff = 0)、子菜单 4 项 / 顺序 / 唯一勾选 / 命令切换 / 菜单保持打开 ``` 回归全绿: ``` uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4 lbcovr 7/7 lbpanel 10/10 derivecheck 16/16 rendercheck: 8 个场景哈希与上一轮逐一相同 + FUNC 7/7, exit=0 A FAED59DF284153DB B E6443BFE4E3955F8 C 96A0AA8C402F1729 D E3D5DBD998E43720 E DA0583CB13C26A6F F FFB822159F604D1D G 19F8DE8FE3A8DF18 H DBBDDE4CB2C97C92 ``` `make release` / `make debug` 零 error / 零 warning;产物 **791,552 B** (单一 exe,未拆 DLL,未改 `-Oz`;离 1MB 预警线约 250 KB)。 --- Components/Application/Inc/Application.hpp | 83 +++++++ .../Application/Inc/ApplicationInternal.hpp | 8 + Components/Application/Src/Application.cpp | 37 ++- .../Application/Src/ApplicationCommands.cpp | 213 +++++++++++++++--- Components/PlotView/Inc/PlotView.hpp | 17 ++ Components/PlotView/Src/PlotView.cpp | 11 + Components/PlotView/Src/PlotViewFeatures.cpp | 8 +- Components/PlotView/Src/PlotViewPaint.cpp | 113 +++++++++- Components/Shared/Inc/PaintUtil.hpp | 36 +++ Components/Shared/Inc/PopupMenu.hpp | 15 ++ Components/Shared/Inc/Theme.hpp | 43 +++- Components/Shared/Inc/UiBox.hpp | 14 ++ Components/Shared/Src/PaintUtil.cpp | 13 ++ Components/Shared/Src/PopupMenu.cpp | 7 +- Components/Shared/Src/PopupMenuLayout.cpp | 34 +++ Components/Shared/Src/Theme.cpp | 156 ++++++++++++- Components/Shared/Src/UiControls.cpp | 8 +- Components/SignalPanel/Inc/SignalPanel.hpp | 21 +- .../SignalPanel/Src/SignalPanelMenu.cpp | 98 ++------ Documents/agent_mem.md | 111 +++++++++ Makefile | 12 +- 21 files changed, 907 insertions(+), 151 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 3092b09..cf8760f 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -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::SOLARIZED_LIGHT; UiFonts m_fonts; /**< 界面字体。 */ PopupMenu m_menu; /**< 与主题一致的自绘弹出菜单。 */ ColorPickerPopup m_colorPicker; /**< 与主题一致的浮动拾色器。 */ diff --git a/Components/Application/Inc/ApplicationInternal.hpp b/Components/Application/Inc/ApplicationInternal.hpp index cbc0307..f80b951 100644 --- a/Components/Application/Inc/ApplicationInternal.hpp +++ b/Components/Application/Inc/ApplicationInternal.hpp @@ -63,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。 */ @@ -179,6 +180,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 46612d5..4c36cb8 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -74,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; @@ -535,7 +539,6 @@ namespace jdi if (m_showLayoutBounds) { m_debugBoxes.Clear(); - m_debugBoxes.SetLayer(2); // 基础内容层(工具栏/面板/绘图区) UiSetTextRectSink(&m_debugBoxes); } else if (!m_debugBoxes.Empty()) @@ -559,34 +562,37 @@ 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); } + UiSetTextLayer(ComponentLayer(&m_plot)); m_plot.Paint(g); // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 // ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层, // 否则会被自己登记到遮挡矩形里的"挖洞"逻辑裁掉。 - 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); @@ -1694,6 +1700,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: { // 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。 diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 725666c..9454b7a 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -740,17 +740,11 @@ namespace jdi // 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 的说明)。 // 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线 @@ -763,22 +757,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; } @@ -1080,6 +1162,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() { // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 @@ -1106,6 +1209,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), @@ -1117,10 +1239,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); // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 @@ -1128,19 +1270,22 @@ 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->BuildBoxes(m_debugBoxes); } - m_debugBoxes.PushLayer(); } // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。 @@ -1148,10 +1293,11 @@ namespace jdi // 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::BuildBoxes`), // 由绘制阶段把更低层的 box 从浮层区域里挖掉。 m_debugBoxes.SetClip(gd::RectF()); + m_debugBoxes.SetLayer(OverlayLayer(0)); m_debugBoxes.Add(m_splitterRect, 0); - m_debugBoxes.PushLayer(); + m_debugBoxes.SetLayer(OverlayLayer(1)); m_menu.BuildBoxes(m_debugBoxes); - m_debugBoxes.PushLayer(); + m_debugBoxes.SetLayer(OverlayLayer(2)); m_colorPicker.BuildBoxes(m_debugBoxes); UiDebugPaintBoxList(g, m_debugBoxes); @@ -1177,6 +1323,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/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 0029a5e..aa6d32c 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -668,6 +668,14 @@ namespace jdi */ void InvalidateDerivedCaches(); + /** + * @brief 主题配色变化后作废全部**像素级**缓存。 + * + * 主题色被烘进了场景缓存位图、每网格宽基底图层、装饰层、光晕层与浮动面板层 —— + * 换主题后必须整体丢弃,否则这些区域会停留在旧配色上(直到因为别的原因重画)。 + */ + void OnThemeChanged(); + /** * @brief 释放本组件持有的全部绘制资源(位图 / 后台线程)。 * @@ -4011,6 +4019,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/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 62b9192..2f21942 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(); diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index ad80c44..ca95e4e 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -836,7 +836,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; } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index e9ffa2c..df158ec 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -24,6 +24,57 @@ 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, @@ -567,6 +618,10 @@ namespace jdi { UiComponent::BuildBoxes(boxes); + // 本组件的基础绘制层(= 宿主组件表里的下标)。**必须显式记录并复位**: + // 下面为浮动面板临时提了一层,若不复位,宿主后续组件的层号会被整体推高。 + const int baseLayer = boxes.Layer(); + // 层级 1:每个网格的波形区;层级 2:该网格的 GX 编号徽标。 // ⚠️ **绝不能读 `m_decorRects`**:那里存的是"装饰层位图缓存"的登记矩形, // 坐标处在**位图/图层坐标系**(会带负 Y、或超出绘图区),拿它当屏幕矩形会出现 @@ -591,16 +646,22 @@ 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` 里绘制在**浮动面板之后**(压在面板之上): + // 滚动条、右下角「回到最上方」悬浮按钮、内联输入框。 + // ⇒ 必须比面板层(`baseLayer + 1`)再高一层,否则与实际像素的层序相反。 + boxes.SetLayer(baseLayer + 2); + boxes.Add(m_scrollBarRect, 5); + boxes.Add(BackToTopRect(), 5); + boxes.Add(EditFieldRect(), 5); + boxes.SetLayer(baseLayer); // ⚠️⚠️ **浮动面板必须另起一个绘制层,并把面板矩形登记为遮挡矩形**: // 面板是不透明浮层、且常常**跨多个网格上下排列**,与网格区域重叠。 @@ -642,7 +703,7 @@ namespace jdi } if (!panelLayerPushed) { - boxes.PushLayer(); + boxes.SetLayer(baseLayer + 1); panelLayerPushed = true; } boxes.AddOccluder(layout.panel); @@ -691,8 +752,9 @@ namespace jdi } } } - // 收尾:解除面板裁剪(后续若还有调用者依赖"不裁剪"的默认行为)。 + // 收尾:解除面板裁剪并**复位层号**(宿主后续组件依赖它)。 boxes.SetClip(gd::RectF()); + boxes.SetLayer(baseLayer); } void PlotView::Paint(gd::Graphics& g) @@ -771,6 +833,13 @@ namespace jdi // 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。 const auto paintOverlays = [this, &g]() { + // 文本几何的层号必须跟着**真实绘制顺序**走: + // 这一段前半部分的文字(光标取值胶囊、读数标签…)在浮动面板**之下**(同组件层), + // 而 `PaintEdgeCursor` 之后的一切(面板、输入框、滚动条、回到最上方)在面板之上。 + // 少了这一步,胶囊里的文字框会记在比面板更低的层,然后被面板的遮挡矩形挖掉 —— + // 或者反过来(旧实现在所有组件之上统一按固定层收集)穿透到面板表面 + //(用户 2026-10-29 报告的两处现象)。 + const int textLayer = UiGetTextLayer(); PaintHoverHighlight(g); PaintLegendHighlight(g); PaintRubberBand(g); @@ -778,7 +847,11 @@ namespace jdi // 「自由标记」的跟随光标与已固定数据点(在统计面板**之下**绘制, // 这样浮动面板仍压在最上层,与其它实验性功能的层序一致)。 PaintFreeMarker(g, m_lanes); + // 从这里开始都在面板之上:文本层同步 +1(与 `BuildBoxes` 的 `baseLayer + 1` 一致)。 + UiSetTextLayer(textLayer + 1); PaintEdgeCursor(g, m_lanes); + // 之后这几样绘制在浮动面板**之上**:与 `BuildBoxes` 的 `baseLayer + 2` 对应。 + UiSetTextLayer(textLayer + 2); PaintSignalInteraction(g); PaintHint(g); // 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层 @@ -806,6 +879,8 @@ namespace jdi m_plotRect.Height + 4.0f), border, 4, m_theme->axisLine | 0xFF000000u, 2); } + // 复原文本层(宿主按组件下发,回收成进入时的值最保险)。 + UiSetTextLayer(textLayer); }; // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 @@ -4261,7 +4336,6 @@ namespace jdi } std::uint64_t slotKey = 0; - const std::size_t slotIndex = nextSlots.size(); if (panelCacheReady) { slotKey = PanelSlotKey(static_cast(index), kind, layout); @@ -4286,18 +4360,34 @@ namespace jdi m_panelDc, hit->x0, hit->y0, SRCCOPY); 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); + // 面板主体 + 标题栏。 // 投影响用两张大面积的半透明圆角矩形模拟,代价不低;交互期间(快速帧)省略它, // 让拖动参数滑块时面板重绘不再被投影拖累(松手后的全质量帧会补回投影)。 @@ -5032,9 +5122,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; @@ -5061,6 +5155,7 @@ namespace jdi stored.y0 = y0; stored.x1 = x1; stored.y1 = y1; + stored.textRects = panelTextBoxes.Rects(); } } } diff --git a/Components/Shared/Inc/PaintUtil.hpp b/Components/Shared/Inc/PaintUtil.hpp index c0b99b9..0863ad2 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)`)。 * diff --git a/Components/Shared/Inc/PopupMenu.hpp b/Components/Shared/Inc/PopupMenu.hpp index b242045..a9b8415 100644 --- a/Components/Shared/Inc/PopupMenu.hpp +++ b/Components/Shared/Inc/PopupMenu.hpp @@ -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/Theme.hpp b/Components/Shared/Inc/Theme.hpp index 51ae314..24f67e6 100644 --- a/Components/Shared/Inc/Theme.hpp +++ b/Components/Shared/Inc/Theme.hpp @@ -108,10 +108,49 @@ 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 获取波形默认配色表。 diff --git a/Components/Shared/Inc/UiBox.hpp b/Components/Shared/Inc/UiBox.hpp index e0b46df..d2e767f 100644 --- a/Components/Shared/Inc/UiBox.hpp +++ b/Components/Shared/Inc/UiBox.hpp @@ -88,6 +88,20 @@ namespace jdi /** @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 的裁剪矩形(= 当前组件的区域)。 * diff --git a/Components/Shared/Src/PaintUtil.cpp b/Components/Shared/Src/PaintUtil.cpp index 6f0d7e7..ee1aaac 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; diff --git a/Components/Shared/Src/PopupMenu.cpp b/Components/Shared/Src/PopupMenu.cpp index ba36203..b1557ac 100644 --- a/Components/Shared/Src/PopupMenu.cpp +++ b/Components/Shared/Src/PopupMenu.cpp @@ -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; 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..4ab0162 100644 --- a/Components/Shared/Src/Theme.cpp +++ b/Components/Shared/Src/Theme.cpp @@ -66,11 +66,163 @@ namespace jdi theme.warning = MakeArgb(kYellow); return theme; }(); + + /** + * @brief 浅色主题(参考 VS Code 2026 的默认浅色配色)。 + * + * 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色, + * 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。 + */ + const Theme kLight = [] + { + 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(0xCECECEu); + + 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); + + // 白底上网格线必须够淡(否则抢波形),但主刻线要能与次级线区分。 + theme.gridWeak = MakeArgb(0xEDEDEDu); + theme.gridStrong = MakeArgb(0xD6D6D6u); + theme.axisLine = MakeArgb(0xCECECEu); + 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 = [] + { + 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(0x4A4A4Au); + + 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); + + theme.gridWeak = MakeArgb(0x2B2B2Bu); + theme.gridStrong = MakeArgb(0x3A3A3Au); + theme.axisLine = MakeArgb(0x4A4A4Au); + 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 = [] + { + 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(kBase01); + + 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); + + theme.gridWeak = MakeArgb(0x0B3A46u); + theme.gridStrong = MakeArgb(0x14495Au); + 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 = &kSolarizedLight; } - const Theme& SolarizedLight() + const Theme& ThemeFor(ThemeKind kind) { - return kSolarizedLight; + switch (kind) + { + case ThemeKind::LIGHT: + return kLight; + case ThemeKind::DARK: + return kDark; + case ThemeKind::SOLARIZED_DARK: + return kSolarizedDark; + case ThemeKind::SOLARIZED_LIGHT: + default: + return kSolarizedLight; + } + } + + const wchar_t* ThemeName(ThemeKind kind) + { + switch (kind) + { + case ThemeKind::LIGHT: + return L"Light"; + case ThemeKind::DARK: + return L"Dark"; + case ThemeKind::SOLARIZED_DARK: + return L"Solarised Dark"; + case ThemeKind::SOLARIZED_LIGHT: + default: + return L"Solarised Light"; + } + } + + const Theme& CurrentTheme() + { + return (g_currentTheme != nullptr) ? *g_currentTheme : kSolarizedLight; + } + + void SetCurrentTheme(const Theme* theme) + { + g_currentTheme = (theme != nullptr) ? theme : &kSolarizedLight; } const std::uint32_t* SignalPalette(int& count) 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/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index aa4352c..d96ed2d 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -27,15 +27,11 @@ namespace jdi */ 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 客户区横坐标。 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/Documents/agent_mem.md b/Documents/agent_mem.md index b51007b..c381826 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -8500,3 +8500,114 @@ rendercheck: A FAED59DF284153DB / B E6443BFE4E3955F8 / C 96A0AA8C402F1729 / D E3 派生信号如果源自一个"带时间轴偏移"的信号,它的「调整」面板里「时间轴」会显示该偏移值 (不再像以前那样强行"默认值")。这是**刻意**的:时间偏移无法无损地写进采样值, 把偏移诚实显示出来比"面板是默认值但数据被裁掉/变空"要好。 + +## 2026-10-29 第十五轮(本机):主题配色 + 信号右键菜单精简/克隆 + 布局边界层级根治 + 单点吸附 + +> 用户本轮五件事:① 派生信号"时间轴偏移保留、其它调整烘值"维持现状(已确认无需改动); +> ② 信号名右键菜单精简为「克隆该信号 / 仅显示该信号」;③ 设置菜单新增「主题配色」二级菜单(4 套); +> ④ 修复布局边界在浮动面板上的**闪烁**与**取值胶囊文字穿透**,并审计网格内各元素的层级; +> ⑤ 修复**单数据点信号**的光标吸附。 + +### 1. ⚠️ 布局边界层级:根因是"文本层号写死" + +**症状**:中键平移视界时浮动面板上的边界闪烁;光标取值胶囊的**文字**框穿透到浮动面板之上。 + +**根因(两条,都很隐蔽)** +1. 文本是在**绘制过程中**由 `UiTextRectSink` 收集的,而宿主把所有文本都记在**固定层 2**; + 组件几何层却是"组件表下标"(PlotView = 0)。于是 + * 面板层(= PlotView 层 + 1 = 1)**低于**文本层 2 ⇒ 遮挡矩形挖不到文本 ⇒ 文字画在面板表面; + * 面板自己的文字也被算在层 2,与面板几何层不一致。 +2. 浮动面板"先画进像素图层、命中缓存就贴图":贴图那一帧**不画字** ⇒ 文本框凭空消失 + ⇒ 观感就是"面板上的边界闪烁"。 + +**修法(三条)** +1. `UiTextRectSink` 增加 `OnTextLayer(int)` / `TextLayer()`,并新增自由函数 + `UiSetTextLayer(int)` / `UiGetTextLayer()`;`UiBoxTable` 实现为改 `m_layer`。 + 宿主 `Render` 在绘制每个组件前 `UiSetTextLayer(ComponentLayer(&component))`, + 浮层用 `OverlayLayer(1/2)` —— **与 `PaintLayoutBounds` 分配几何层号完全同源**。 +2. `PlotView::BuildBoxes` / `paintOverlays` 里把层号继续细分(见下"层号约定")。 + 顺手修掉一个真缺陷:`BuildBoxes` 原来用 `PushLayer()` 却**从不复位**, + 会把宿主后面所有组件的层号整体推高。 +3. 面板像素图层新增 `PanelSlot::textRects`:渲染面板时用**转发型收集器** + (`PanelTextCollector`,一边转发给外层、一边存一份)把文本框存进 slot, + 命中缓存贴图时**回放**给外层收集器 ⇒ 几何与是否命中缓存无关。 + +**层号约定(现行,务必按此)** +``` +layer = 组件表下标 × 3 + { 0 = 基层, 1 = 组件内部不透明浮层, 2 = 浮层之上的叠加层 } +PlotView:0 = 网格/图例/Y轴标尺/GX/时间轴/光标读数与取值胶囊 + 1 = 实验性功能浮动面板(同时登记为 occluder) + 2 = 滚动条 / 「回到最上方」/ 内联输入框(绘制在面板之上) +浮层:OverlayLayer(0)=分隔条、1=菜单、2=拾色器 +``` + +### 2. 单数据点信号吸附失效(回归) + +`MinorCellSparse` 在原"把判定范围收窄到数据实际时间跨度"的分支里有 +`if (!(dataMax > dataMin)) return false;` —— **只有一个有效采样点时 `dataMax == dataMin`**, +于是被判定成"不稀疏"⇒ 不吸附。 +改为只在 `dataMax < dataMin` 时放弃;相等时继续走"那一个小格"的路径: +`firstCell == lastCell`、`need = 5`,而 `finiteCount == 1 < 5` ⇒ 判为稀疏 ⇒ 吸附恢复 +(并且 `LocalSampleSpacing` 对 `finiteCount <= 1` 返回 0 = 不额外收窄半径,行为自洽)。 + +### 3. 信号右键菜单精简 + 「克隆该信号」 + +* 菜单只剩:标题(信号名)、**克隆该信号**、**仅显示该信号**(用户 2026-10-29 要求)。 + 删掉的条目:显示/隐藏该信号、显示全部、隐藏全部、预设色块行、样式(线段/散点/点线) + —— 它们都已被下方**样式自定义区**与**分组右键菜单**覆盖。 + 同时删除 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、`CMD_COLOR_BASE`、`CMD_MODE_BASE` + 及它们的分支(死代码)。 +* 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`,注意 `WM_APP+3` 已被 + `kMessagePlotDerivedReady`、`+4` 已被 `kMessagePlotLowMemory` 占用)→ + `Application::CloneSignal(ref)`:把「调整」(系数/绝对值/镜像/Y 偏移)烘进采样值、 + **时间轴偏移保留在样式上**,名称前置 `*`,插入到源信号**正下方**。 + 克隆**保留原外观**(颜色/透明度/线宽/绘制样式),与"构建为新信号"(外观走调色板)不同。 +* 抽出 `Application::InsertSignalBelowSource(...)`:插入 → 各标签页下标重映射 → + 勾选 + 把新网格挪到源网格下方(**不做** `SyncTabsToDataSet`,让调用方先改外观)。 + +### 4. 主题配色(设置菜单 → 主题配色 → 二级菜单,带 √) + +* `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}`(顺序即菜单顺序); + `ThemeFor(kind)` / `ThemeName(kind)`。**默认仍是 Solarised Light**(用户要求默认不变)。 +* 新增 `CurrentTheme()` / `SetCurrentTheme(const Theme*)`:宿主把自己的 `m_theme` 实例地址 + 交给全局指针 ⇒ 切换主题只改 `m_theme` 内容,所有持有 `const Theme*` 的组件自动跟随; + `UiControls` 里那些**没有 Theme 形参**的原语(`UiDrawCheckBox` / 色块描边等)改用 + `CurrentTheme()`(原来硬编码 `SolarizedLight()`,换主题不生效)。 +* `Application::ApplyTheme(kind)`:改 `m_theme` → `m_plot.OnThemeChanged()` + + `m_panel.OnThemeChanged()`(作废场景/宽基底/装饰/光晕/面板图层与行位图缓存)→ 整窗重绘。 + ⚠️ 只作废场景缓存是不够的:**宽基底图层、装饰层、面板层、信号列表行位图**里都烘着主题色。 +* 菜单:`MenuItemKind::SUBMENU` + 4 个 `NORMAL` 子项(`keepOpen = true`); + 选中后宿主用**新接口 `PopupMenu::UpdateSubmenuItemLabel()`** 回写勾选(子菜单是 + `parent.submenu` 的快照,只改 `m_items` 不重建布局不会生效)。 + 同时让 `PopupMenu::OnLButtonUp` 的子菜单分支尊重 `item.keepOpen` + (原来无条件 `Close()`)⇒ 可以连续预览几种配色,勾选会实时挪动。 + +### 5. 审计:删掉名实不符的 `LAYOUT_BOUNDS` 开关 + +`Makefile` 里 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用**, +是个只声明不生效的死开关(`LAYOUT_BOUNDS=0` 产物字节数完全相同)。 +已删除并在 Makefile 注明原因:「显示布局边界」现在是**正式功能**, +真要可剔除需要给每个组件的 `BuildBoxes` 重载都加 `#if`,代价远大于十几 KB。 + +### 6. 验证 + +新增两支离屏探针(不占鼠标/键盘/屏幕): + +``` +.preflight/round15check 28/28 + 单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧/平移稳定 2 条、克隆 14 条 +.preflight/themecheck 17/17 + 4 套主题可读性/可区分、默认= Solarised Light、逐主题换色且无旧底色残留、 + 切回默认逐像素还原(diff=0)、子菜单 4 项/顺序/唯一勾选/命令切换/菜单保持打开 +``` + +回归全绿:`uicheck` 69、`lbcheck` 21、`lbresid` 5、`lbzorder` 4、`lbcovr` 7、`lbpanel` 10、 +`derivecheck` 16;`rendercheck` 8 场景哈希**与上一轮逐一相同** + FUNC 7/7;release/debug 零 warning; +产物 **791,552 B**(单一 exe,未拆 DLL,未改 `-Oz`,离 1MB 预警线约 250 KB)。 + +### 7. 探针坑(新增) + +* 验证"取值胶囊穿透"必须挑一个**该时刻真的有采样**的网格(单数据点信号的网格上没有胶囊); + 并把面板位置改到胶囊上方(`lane.freeMarkerPanel.x/y`)。 +* `lbpanel` 的 "layer 1 只能是面板内容" 断言会随层号约定变化而失效:现在滚动条/回到最上方 + 在 `base + 2`,因此各组件必须按 `kLayersPerComponent = 3` 取层。 diff --git a/Makefile b/Makefile index f53341f..f325ccf 100644 --- a/Makefile +++ b/Makefile @@ -18,9 +18,11 @@ SUB_DIR_DEBUG = Debug RELEASE_OPT = -Oz # ---- 体积相关编译开关 ------------------------------------------------------ -# LAYOUT_BOUNDS:1 = 编入"显示布局边界"调试叠加层(约 13KB);0 = 剔除。 -# 剔除后用 `make release LAYOUT_BOUNDS=0` 可得到最小体积,但发布版就没有该菜单项了。 -LAYOUT_BOUNDS = 1 +# ⚠️ 曾经有一个 `LAYOUT_BOUNDS` 开关(声称能剔除"显示布局边界"调试叠加层), +# 但它从来没有被任何 `#if` 引用过 —— 是个**名实不符**的死开关,已删除(2026-10-29 审计)。 +# 该叠加层现在是**正式功能**(设置菜单里的开关),随产物一起编译; +# 要真正把它变成可剔除项,需要给每个组件的 `BuildBoxes` 重载都加 `#if`, +# 代价远大于它占的十几 KB(当前产物 791,552 B,离 1MB 预警线还有 250+ KB)。 # NO_EXCEPTIONS:1 = 用 `-fno-exceptions` 构建(省 `.xdata`+`.pdata` 约 28KB)。 # ⚠️ 代价:所有"按数据规模预留大块内存"的地方必须用 `MemProbe.hpp` 的 @@ -186,10 +188,6 @@ endif # 说明:这些是 `=` / `+=` 的递归变量,make 在展开 recipe 时才求值, # 因此放在 LDFLAGS 定义之后仍然能正确作用到前面的 CFLAGS / LDFLAGS。 -ifeq ($(strip $(LAYOUT_BOUNDS)), 1) -CXX_DEFS += JDI_LAYOUT_BOUNDS -endif - ifeq ($(strip $(NO_EXCEPTIONS)), 1) CXXFLAGS += -fno-exceptions endif