新增主题配色(4 套)+ 信号右键菜单精简与「克隆该信号」+ 布局边界层级根治 + 单点信号吸附修复

## 为什么

用户 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)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-29 20:38:55 +08:00
1 parent 71c6ff91cb
commit 71195c4c48
21 files changed
+907 -151

No files matched your search

+17
View File
@@ -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<gd::RectF> textRects;
};
HBITMAP m_panelBitmap = nullptr; /**< 面板图层位图(原点与客户区一致)。 */
+11
View File
@@ -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();
+7 -1
View File
@@ -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;
}
+104 -9
View File
@@ -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<gd::RectF>& Rects() const { return m_rects; }
private:
UiTextRectSink* m_outer = nullptr;
std::vector<gd::RectF> 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<int>(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<int>(index);
stored.kind = static_cast<int>(kind);
stored.key = 0;
@@ -5061,6 +5155,7 @@ namespace jdi
stored.y0 = y0;
stored.x1 = x1;
stored.y1 = y1;
stored.textRects = panelTextBoxes.Rects();
}
}
}