Files
Just-DataInspector/Components/Shared/Inc/UiBox.hpp
T
DeepSeek V4.1 Flash 71195c4c48 新增主题配色(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)。
2026-10-29 20:38:55 +08:00

162 lines
6.3 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef __UI_BOX_HPP__
#define __UI_BOX_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <cstddef>
#include <vector>
#include "PaintUtil.hpp"
#include "Theme.hpp"
namespace jdi
{
/**
* @brief 一个几何 box(**纯值类型**,不含行为、不含生命周期)。
*
* ## 它是什么
*
* 描述"某块界面元素占据哪个矩形、属于第几层、在第几个绘制层、被裁在哪个矩形里"。
* 这就是界面在某一帧的**几何产物**:绘制、命中、调试描边理论上都应该从它派生,
* 而不是各自再算一遍。
*
* ## 为什么不是控件节点
*
* 刻意不继承控件基类:`PlotView` 有 4 层自持位图缓存、`SignalPanel` 有行位图缓存,
* 它们要求"像素由组件自己负责"。把几何塞进继承体系会和缓存失效体系打架,
* 而 box 只是一个可复制的矩形 + 几个整数标签。
*
* ## 为什么 `clip` 与 `rect` 分开存
*
* 组件内部的元素可能落在地盘之外(典型:滚出可视区的网格,Y 是负数)。
* 消费方必须能区分"这块几何真实位置在哪"和"它最多能显示到哪",
* 因此裁剪矩形跟着 box 走,而不是靠全局状态。
*/
struct UiBox
{
gd::RectF rect; /**< 矩形(客户区坐标)。 */
gd::RectF clip; /**< 裁剪矩形(= 所属组件的区域;空 = 不裁剪)。 */
int depth = 0; /**< 层级:0 = 顶层组件区域,≥1 = 内部控件。深 = 更靠近叶子。 */
int layer = 0; /**< 绘制层:越大越靠上,由 `UiBoxTable::PushLayer` 分配。 */
};
/**
* @brief 几何 box 的收集器(值语义容器,**不含任何绘制代码**)。
*
* ## 职责边界
*
* 这里只负责"**累积几何 + 维护裁剪/层/遮挡这些几何事实**"。
* 怎么把 box 变成屏幕上的线,是 `UiDebugBounds` 的事(那是调试专用的消费者)。
* 分开的原因:几何是**稳定事实**(命中、遮挡、将来的脏矩形都要用),
* 而"用彩色包角描边"只服务于一个开关,两者不该长在同一个类里。
*
* ## 两个排序键缺一不可
*
* * `layer`:保证**后收集的内容压住先收集的**。没有它,"信号面板的行边界"
* 会盖住"右键弹出的二级菜单"(菜单是后收集的浮层)。
* * `depth`:同一层内先画父(浅层)再画子(深层),子元素的包角才不被父覆盖。
*
* ## 开销约束
*
* 只在宿主开启「显示布局边界」时才被构造和填充 —— 关闭时**零开销**
* (不进绘制路径、不进任何位图缓存)。若将来要用它做常驻命中表,
* 必须显式改为"按需重建",不能每帧无限期累积。
*/
class UiBoxTable : public UiTextRectSink
{
public:
/** @brief 清空(复用同一实例时调用),同时把绘制层复位为 0。 */
void Clear();
/**
* @brief 进入下一个绘制层。
*
* 宿主在每个组件收集完之后调用一次;组件内部若有"浮层压住本体"的情形
* (例如菜单的二级子菜单)也应在收集子层之前调用。
*/
void PushLayer() { ++m_layer; }
/**
* @brief 显式设置当前绘制层(绘制期间收集文本几何时用)。
* @param [IN] layer 层号(越大越靠上)。
*/
void SetLayer(int layer) { m_layer = layer; }
/** @brief 回报一段文本的墨迹矩形(由 `PaintUtil` 在绘制文本时调用)。 */
void OnTextRect(const gd::RectF& rect) override;
/**
* @brief 设置后续文本所属的绘制层(由 `PaintUtil` 在绘制阶段转发)。
*
* 文本必须与实际绘制同层:否则浮层内的文字会被当成“下层内容”而被裁掉,
* 或者下层的文字框穿透到浮层之上。
*/
void OnTextLayer(int layer) override { m_layer = layer; }
/** @brief 当前文本层号。 */
int TextLayer() const override { return m_layer; }
/** @brief 当前绘制层号(用于“在当前层之上再压一层”)。 */
int Layer() const { return m_layer; }
/**
* @brief 设置后续 box 的裁剪矩形(= 当前组件的区域)。
*
* ⚠️ **必须像裁剪绘制一样裁剪几何**:组件内部元素可能落在地盘之外
* (典型:滚出可视区的网格 `lane.rect` 的 Y 会是负数),不裁剪就会登记到相邻组件
* (工具栏 / 标签栏)的地盘上 —— 这正是用户报告"工具栏上出现大量残留"的直接原因。
*
* @param [IN] clip 裁剪矩形;传空矩形表示不裁剪。
*/
void SetClip(const gd::RectF& clip) { m_clip = clip; }
/** @brief 追加一个 box(空矩形 / 与裁剪矩形无交集的 box 自动忽略)。 */
void Add(const gd::RectF& rect, int depth);
/** @brief 追加一组同层级的 box(空矩形逐个忽略)。 */
void AddAll(const std::vector<gd::RectF>& rects, int depth);
/**
* @brief 登记一个**遮挡矩形**(浮层本体,如弹出菜单 / 拾色器)。
*
* ⚠️ 必须做这件事:box 的消费方是**在所有内容之后**工作的,仅靠 `layer` 排序
* 只能决定"两个 box 重叠时谁压谁",并不能阻止"下层组件的边框画在不透明浮层上面"。
* 登记为遮挡后,**绘制层比它低**的 box 在该区域内会被裁掉。
*
* @param [IN] rect 遮挡矩形(客户区坐标;空矩形忽略)。
*/
void AddOccluder(const gd::RectF& rect);
/** @brief 是否为空。 */
bool Empty() const { return m_boxes.empty(); }
/** @brief box 数量。 */
std::size_t Count() const { return m_boxes.size(); }
/** @brief 只读访问(**未排序**,消费方需要顺序时自己按 `(layer, depth)` 排序)。 */
const std::vector<UiBox>& Items() const { return m_boxes; }
/** @brief 一个遮挡矩形(含它所在的绘制层)。 */
struct Occluder
{
gd::RectF rect; /**< 遮挡矩形。 */
int layer = 0; /**< 所在绘制层(只遮挡更低的层)。 */
};
/** @brief 遮挡矩形集合(只读)。 */
const std::vector<Occluder>& Occluders() const { return m_occluders; }
private:
std::vector<UiBox> m_boxes;
std::vector<Occluder> m_occluders;
int m_layer = 0; /**< 当前绘制层。 */
gd::RectF m_clip; /**< 当前裁剪矩形(空 = 不裁剪)。 */
};
} // namespace jdi
#endif // __UI_BOX_HPP__