新增主题配色(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

+13
View File
@@ -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;
+6 -1
View File
@@ -491,7 +491,12 @@ namespace jdi
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_subItemRects[static_cast<std::size_t>(subPressed)];
Close();
// 带复选框的子菜单项(如「主题配色」的四个主题)选择后**保持菜单打开**,
// 便于连续预览;勾选态由宿主通过 `UpdateSubmenuItemLabel` 回写。
if (!item.keepOpen)
{
Close();
}
}
}
return true;
+34
View File
@@ -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<int>(parentIndex))
{
BuildSubLayout(static_cast<int>(parentIndex));
}
return true;
}
}
return false;
}
} // namespace jdi
+154 -2
View File
@@ -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)
+4 -4
View File
@@ -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));
}
}