## 为什么
用户 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)。
162 lines
6.3 KiB
C++
162 lines
6.3 KiB
C++
#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__
|