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

No files matched your search

@@ -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<int>(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; /**< 与主题一致的浮动拾色器。 */
@@ -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 从应用资源中按指定尺寸加载图标。
+26 -11
View File
@@ -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<float>(width), static_cast<float>(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<int>(wParam), static_cast<int>(static_cast<LONG_PTR>(lParam)));
return 0;
}
case kMessageCloneSignal:
{
// wParam = 分组下标,lParam = 组内信号下标。
SignalRef ref;
ref.groupIndex = static_cast<int>(wParam);
ref.signalIndex = static_cast<int>(static_cast<LONG_PTR>(lParam));
CloneSignal(ref);
return 0;
}
case WM_TIMER:
{
// 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。
@@ -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. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。
// 4. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。
SyncTabsToDataSet();
return true;
}
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<int>(group->signals.size()))
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(sourceRef.signalIndex)];
// 源信号的「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。
SignalStyle transform;
bool hasStyle = false;
if (const SignalStyle* style = m_dataSet.Style(sourceRef))
{
SignalRef derivedRef;
derivedRef.groupIndex = request.groupIndex;
derivedRef.signalIndex = newIndex;
ActiveTab().SetVisible(derivedRef, true);
transform = *style;
hasStyle = true;
}
SignalRef sourceRef;
sourceRef.groupIndex = request.groupIndex;
sourceRef.signalIndex = request.signalIndex;
ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef);
// 克隆 = 把「调整」烘进采样值(**时间轴偏移除外**,它留在样式上)。
// 与「构建为新信号」完全同一套烘值规则,因此克隆出来的曲线与原信号**逐像素重合**,
// 但它的「调整」面板回到默认值,可以继续在此基础上继续叠加新调整。
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<std::size_t>(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<int>(ThemeKind::SOLARIZED_DARK); ++index)
{
const ThemeKind kind = static_cast<ThemeKind>(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<float>(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<int>(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<float>(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<int>(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<int>(ThemeKind::SOLARIZED_DARK))
{
const ThemeKind kind = static_cast<ThemeKind>(result.id - kAppCmdThemeBase);
ApplyTheme(kind);
// 回写勾选态:四项里只有当前主题打勾(子菜单项要用专用接口才会重建布局)。
for (int index = 0; index <= static_cast<int>(ThemeKind::SOLARIZED_DARK); ++index)
{
const ThemeKind option = static_cast<ThemeKind>(index);
m_menu.UpdateSubmenuItemLabel(kAppCmdThemeBase + index, ThemeName(option),
m_themeKind == option);
}
}
InvalidateRect(m_hwnd, nullptr, TRUE);
break;
}
+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();
}
}
}
+36
View File
@@ -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)`)。
*
+15
View File
@@ -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 计算单个菜单项的纵向高度。
+41 -2
View File
@@ -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 获取波形默认配色表。
+14
View File
@@ -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 的裁剪矩形(= 当前组件的区域)。
*
+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;
+5
View File
@@ -491,9 +491,14 @@ namespace jdi
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = m_subItemRects[static_cast<std::size_t>(subPressed)];
// 带复选框的子菜单项(如「主题配色」的四个主题)选择后**保持菜单打开**,
// 便于连续预览;勾选态由宿主通过 `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
+153 -1
View File
@@ -66,12 +66,164 @@ 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)
{
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));
}
}
+14 -7
View File
@@ -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 客户区横坐标。
+18 -80
View File
@@ -180,10 +180,17 @@ namespace jdi
{
return;
}
(void)style; // 菜单已不再需要外观:颜色 / 线宽 / 透明度 / 绘制样式都在下方样式区编辑。
m_menuTargetSignal = ref;
m_menuTargetGroup = -1;
// ⚠️ 信号右键菜单**刻意保持极简**(用户 2026-10-29 要求):
// 颜色、绘制样式、可见性、显示全部/隐藏全部这些原来放在这里的条目,现在
// 都已被下方**样式自定义区**与分组右键菜单覆盖,重复的入口只会让菜单变长、变乱。
// 现在只保留两件事:
// * 「克隆该信号」(在「仅显示该信号」**之上**);
// * 「仅显示该信号」。
std::vector<MenuItem> 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<WPARAM>(m_menuTargetSignal.groupIndex),
static_cast<LPARAM>(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;
}
+111
View File
@@ -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` 取层。
+5 -7
View File
@@ -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