Files
Just-DataInspector/Components/Application/Inc/Application.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

654 lines
25 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 __APPLICATION_HPP__
#define __APPLICATION_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <string>
#include <vector>
#include "ColorPickerPopup.hpp"
#include "DataModel.hpp"
#include "PaintUtil.hpp"
#include "PlotView.hpp"
#include "PopupMenu.hpp"
#include "SignalPanel.hpp"
#include "TabBar.hpp"
#include "Theme.hpp"
#include "Toolbar.hpp"
#include "UiComponent.hpp"
#include "UiBox.hpp"
#include "UiSurface.hpp"
namespace jdi
{
/**
* @brief 应用主框架:负责窗口、布局、消息分发、标签页管理与绘制合成。
*
* 数据层(DataSet)全局共享,标签页仅保存各自的视图配置(TabConfig),
* 因此新标签页可以独立勾选信号、独立配色与分组,互不影响。
*/
class Application
{
public:
/**
* @brief 获取全局唯一应用实例。
* @return 应用实例引用。
*/
static Application& Instance();
/**
* @brief 初始化环境、创建窗口并进入消息循环。
* @param [IN] instance 模块实例句柄。
* @param [IN] showCommand 窗口显示方式。
* @return 进程退出码。
*/
int Run(HINSTANCE instance, int showCommand);
/**
* @brief 窗口过程中转处理函数。
* @param [IN] hwnd 窗口句柄。
* @param [IN] message 消息标识。
* @param [IN] wParam 消息参数。
* @param [IN] lParam 消息参数。
* @return 消息处理结果。
*/
LRESULT HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
/**
* @brief 设置错误反馈是否使用模态对话框。
*
* 模态错误框会抢断用户当前操作;宿主在不需要打断(或无法交互)的场景下
* 应关闭它,此时错误信息改为在绘图区浮层中非模态展示。
*
* @param [IN] enabled true 表示允许弹出模态错误框。
*/
void SetModalErrorsEnabled(bool enabled) { m_modalErrorsEnabled = enabled; }
/**
* @brief 查询错误反馈是否使用模态对话框。
* @return 允许弹出模态错误框返回 true。
*/
bool ModalErrorsEnabled() const { return m_modalErrorsEnabled; }
private:
/**
* @brief 鼠标交互的作用目标。
*/
enum class Target : int
{
NONE = 0, /**< 无目标。 */
TAB_BAR, /**< 标签栏。 */
TOOLBAR, /**< 工具栏。 */
PANEL, /**< 信号面板。 */
PLOT, /**< 波形绘图区。 */
SPLITTER, /**< 面板分隔条。 */
EDIT_FIELD /**< 内联输入框(拖拽框选选区)。 */
};
Application() = default;
~Application() = default;
Application(const Application&) = delete;
Application& operator=(const Application&) = delete;
/**
* @brief 窗口过程静态回调。
* @param [IN] hwnd 窗口句柄。
* @param [IN] message 消息标识。
* @param [IN] wParam 消息参数。
* @param [IN] lParam 消息参数。
* @return 消息处理结果。
*/
static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
/**
* @brief 创建主窗口与相关资源。
* @param [IN] instance 模块实例句柄。
* @param [IN] showCommand 窗口显示方式。
* @return 创建成功返回 true。
*/
bool Initialize(HINSTANCE instance, int showCommand);
/**
* @brief 释放窗口与绘图资源。
*/
void Shutdown();
/**
* @brief 依据 DPI 重建字体与布局。
* @param [IN] dpi 目标 DPI。
*/
void ApplyDpi(UINT dpi);
/**
* @brief 重新计算各区域的布局。
*/
void Relayout();
/**
* @brief 创建或调整后备缓冲区尺寸。
* @param [IN] width 客户区宽度。
* @param [IN] height 客户区高度。
*/
void EnsureBackBuffer(int width, int height);
/**
* @brief 释放后备缓冲区。
*/
void ReleaseBackBuffer();
/**
* @brief 取"整个应用共享的那张后台位图"作为一段可绘制表面。
*
* 这是 P5 的基础设施:后备缓冲是 **top-down 32bpp DIB 段**,因此除了 `dc` 之外
* 还能给出 `bits`(像素直写)与 `version`(位图代次;`WM_SIZE` / `WM_DPICHANGED`
* 重建后旧句柄必须作废)。
* ⚠️ 绝大多数绘制只需要 `dc + rect`;`bits` 仅用于 CPU 侧合成,**不要**用它画控件。
*/
UiSurface BackBufferSurface() const;
/**
* @brief 完整重绘客户区并呈现到窗口。
*/
void Render();
/**
* @brief 获取当前激活标签页配置(可写)。
* @return 激活标签页配置引用。
*/
TabConfig& ActiveTab();
/**
* @brief 获取当前激活标签页配置(只读)。
* @return 激活标签页配置引用。
*/
const TabConfig& ActiveTab() const;
/**
* @brief 新建一个标签页并切换到它。
* @param [IN] collapseGroups 是否把信号列表里的分组**默认折起**
*(用户新建的标签页传 true;启动时的首个标签页传 false,便于直接勾选信号)。
*/
void NewTab(bool collapseGroups);
/**
* @brief 生成当前未被占用的最小标签页名称(关闭标签页后编号可被复用)。
* @return 形如「视图 N」的名称。
*/
std::wstring NextTabName() const;
/**
* @brief 关闭指定标签页。
* @param [IN] index 标签页下标。
*/
void CloseTab(int index);
/**
* @brief 切换激活标签页并重新绑定各组件上下文。
* @param [IN] index 标签页下标。
*/
void SetActiveTab(int index);
/**
* @brief 将标签栏内容与当前标签页集合同步。
*/
void RefreshTabBar();
/**
* @brief 检测激活标签页的可见性/网格变化,并同步绘图区与布局。
* @param [IN] force 是否强制同步(忽略缓存比对)。
*/
void SynchronizeView(bool force);
/**
* @brief 将全部标签页配置与数据层对齐(分组增删后调用)。
*/
void SyncTabsToDataSet();
/**
* @brief 从工作区移除指定分组并修正各标签页配置。
* @param [IN] groupIndex 分组下标。
*/
void RemoveGroup(int groupIndex);
/**
* @brief 从工作区移除某分组中的一条信号,并立即释放其采样内存。
*
* 用户点击信号行**行尾**的 ✕ 时调用(与分组行的关闭 ✕ 同列):该信号一旦被确认不再需要,
* 就应从数据层删除并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,
* 则连同分组一起移除。
*
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 组内信号下标。
*/
void RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 处理标签栏交互结果。
* @param [IN] result 交互结果。
*/
void OnTabBarResult(const TabBarResult& result);
/**
* @brief 弹出标签页右键菜单(主题化自绘菜单)。
* @param [IN] result 标签栏交互结果。
*/
void ShowTabContextMenu(const TabBarResult& result);
/**
* @brief 弹出绘图区网格右键菜单(设置 Y 轴上下限 / 恢复自适应)。
* @param [IN] laneIndex 目标网格下标。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void ShowPlotContextMenu(int laneIndex, int x, int y);
/**
* @brief 把网格菜单上的 Y 轴上下限显示值刷新为数据层的真实值(菜单保持打开)。
* @param [IN] laneIndex 网格下标。
*/
void RefreshPlotMenuValues(int laneIndex);
/**
* @brief 派发弹出菜单的选择结果。
* @param [IN] result 菜单选择结果。
*/
void DispatchMenuResult(const MenuResult& result);
/**
* @brief 调整标签页顺序(拖拽重排)。
* @param [IN] from 被移动的标签页下标。
* @param [IN] target 目标插入位置(插入到该下标之前)。
*/
void MoveTab(int from, int target);
/**
* @brief 应用绘图区提交的信号重命名请求。
* @return 应用了重命名返回 true。
*/
bool ApplyPendingRename();
/**
* @brief 应用绘图区提交的「实验性功能 → 构建为新信号」请求。
*
* 为指定网格内的每条被勾选信号各构建一条派生信号(多次迭代均值滤波或弱峰调整),
* 命名为 `<原信号名称> (N点均值…)` / `<原信号名称> (弱峰调整 M%)`,
* 插入到原信号所在组的原信号位置**下方**,并把新信号所在的网格移动到原信号网格的下方。
*
* @return 实际构建了新信号返回 true。
*/
bool ApplyPendingDerivedBuild();
/**
* @brief 应用绘图区提交的「把信号列表滚动到目标信号」的联动请求。
*
* 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为
* 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它
*(信号很多时否则找不见选中的那一条)。
*
* @return 存在并应用了请求返回 true。
*/
bool ApplyPendingPanelScroll();
/**
* @brief 应用信号面板提交的「把绘图区滚动到目标网格」的联动请求。
*
* 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时,
* 若该信号所在的网格不在绘图区可视区内,就自动调节绘图区的滚动条让该网格完整出现
*(上百个独立网格时,仅靠网格高亮无法定位到画面之外的网格)。悬停不触发。
*
* @return 存在请求并已应用返回 true。
*/
bool ApplyPendingPlotLaneScroll();
/**
* @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。
*/
struct DerivedSignalRequest
{
int groupIndex = -1; /**< 原信号所属分组下标。 */
int signalIndex = -1; /**< 原信号下标。 */
int points = 5; /**< 多次迭代均值滤波:均值点数 X(滑平半径)。 */
int iterations = 1; /**< 多次迭代均值滤波:迭代次数 N(至少 1)。 */
int amountPercent = 0; /**< 弱峰调整:调整幅度(-400–400,默认 0)。 */
bool weakPeak = false; /**< true = 弱峰调整,false = 多次迭代均值滤波。 */
};
/**
* @brief 把一条派生信号(多次迭代均值滤波 / 弱峰调整)插入数据层并同步各视图。
* @param [IN] request 构建请求。
* @param [IN] makeVisible 是否在当前标签页中勾选新信号。
* @return 构建并插入成功返回 true。
*/
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 弹出“打开数据文件”对话框并加载所选文件。
*/
void OpenFileDialog();
/**
* @brief 加载指定的数据文件并刷新界面。
* @param [IN] path 文件完整路径。
* @return 加载成功返回 true。
*/
bool LoadFile(const std::wstring& path);
/**
* @brief 执行工具栏动作。
* @param [IN] action 动作标识。
*/
void ExecuteToolbarAction(ToolbarAction action);
/**
* @brief 处理键盘按键。
* @param [IN] key 虚拟键码。
* @return 已处理返回 true。
*/
bool HandleKeyDown(WPARAM key);
/**
* @brief 报告操作结果信息。
* @param [IN] text 提示文本。
* @param [IN] isError 是否为错误提示。
* @note 界面不再保留常驻状态栏:错误以对话框提示,普通提示被忽略,避免无用信息干扰。
*/
void SetStatusMessage(const std::wstring& text, bool isError);
/**
* @brief 刷新窗口标题。
*/
void UpdateWindowTitle();
/**
* @brief 解析命令行中的文件参数并加载。
*/
void LoadCommandLineFiles();
/**
* @brief 在后台线程解析并构建分组数据,同时播放加载过渡动画(界面不卡死)。
*
* 大文件的瓶颈在“解析 CSV”与“按列重组采样数据”,这两步都不依赖 DataSet,
* 因此放在工作线程执行;主线程循环抽送消息并重绘加载遮罩层,
* 直到后台完成后再把结果移动进 DataSet(O(1),不会引起卡顿)。
*
* @param [IN] path 文件路径。
* @param [OUT] groups 构建好的信号分组(多数文件只有 1 个;`.mat` 的嵌套数据集会有多个)。
* @param [OUT] error 失败原因描述。
* @return 加载成功返回 true;用户取消或失败返回 false。
*/
bool LoadFileAsync(const std::wstring& path, std::vector<SignalGroup>& groups, std::wstring& error);
/**
* @brief 绘制加载过渡动画遮罩(在 Render 的最后一步调用,覆盖整个客户区)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] width 客户区宽度。
* @param [IN] height 客户区高度。
*/
void PaintLoadingOverlay(gd::Graphics& g, int width, int height);
/**
* @brief 展开「应用设置」菜单(工具栏齿轮按钮)。
*
* 锚点取齿轮按钮的**实际矩形**(由控件实测),因此菜单总能贴在按钮下方,
* 不会因按钮文本宽度变化而错位。
*/
void OpenSettingsMenu();
/**
* @brief 切换主题配色,并作废全部**像素级**缓存后整窗重绘。
*
* 主题色被烘进了多处位图缓存(场景缓存、每网格宽基底图层、装饰层、浮动面板层、
* 信号列表行位图),换主题后必须整体作废,否则界面会停留在旧配色上。
*
* @param [IN] kind 目标主题。
*/
void ApplyTheme(ThemeKind kind);
/**
* @brief 绘制全部 UI 控件的布局边界(调试开关开启时在 Render 的最后一步调用)。
*
* 内容分两路:
* * **组件结构**:遍历组件表(底 → 顶)让每个组件上报内部结构,再补上不属于任何
* 组件的区域(客户区外框、面板分隔条、菜单 / 拾色器);
* * **文本**:绘制期间由 `UiSetTextRectSink(&m_debugBoxes)` 自动收集(见 `PaintUtil`),
* 因为文本是本项目唯一"没有几何身份"的元素,它在 60+ 个调用点现算矩形、画完即弃。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] width 客户区宽度。
* @param [IN] height 客户区高度。
*/
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 客户区横坐标。
* @param [IN] y 客户区纵坐标。
* @param [IN] buttonDown 是否有按键处于按下状态。
* @return 需要重绘返回 true。
*/
bool HandleMouseMove(int x, int y, bool buttonDown);
/**
* @brief 拖动(鼠标捕获)期间主动刷新光标形状。
*
* Windows 在鼠标被捕获时不再派发 `WM_SETCURSOR`,若不主动设置,
* 光标会一直停留在按下瞬间的形状(例如选区放大时看不到十字/方向箭头)。
*
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。
*/
void UpdateDragCursor(int x, int y);
HINSTANCE m_instance = nullptr; /**< 模块实例句柄。 */
HWND m_hwnd = nullptr; /**< 主窗口句柄。 */
UINT m_dpi = 96; /**< 当前 DPI。 */
float m_scale = 1.0f; /**< DPI 缩放比例。 */
ULONG_PTR m_gdiplusToken = 0; /**< GDI+ 运行时令牌。 */
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; /**< 与主题一致的浮动拾色器。 */
DataSet m_dataSet; /**< 全局共享的数据集合。 */
std::vector<TabConfig> m_tabs; /**< 标签页配置集合。 */
int m_activeTab = -1; /**< 当前激活标签页下标。 */
TabBar m_tabBar; /**< 顶部标签栏。 */
Toolbar m_toolbar; /**< 顶部工具栏。 */
SignalPanel m_panel; /**< 左侧信号面板。 */
PlotView m_plot; /**< 波形绘图区。 */
/**
* @brief 顶层组件表的一项。
*
* 宿主对“设置缩放 / 摆放 / 悬停离开 / 光标仲裁 / 绘制装饰区”都**只按这张表遍历**,
* 不再在每条消息里逐个点名组件(旧实现新增一个组件要改 5 处)。
*/
struct ComponentEntry
{
UiComponent* component = nullptr; /**< 组件实例。 */
Target target = Target::NONE; /**< 按下时写入 `m_activeTarget` 的值。 */
bool chrome = false; /**< 是否属于“装饰区”(标签栏/工具栏/面板):
决定悬停离开后是全帧重绘还是只重绘绘图区。 */
};
/**
* @brief 组件表(**底 → 顶**顺序)。
*
* 命中与光标仲裁从**后往前**遍历(末尾优先),与旧实现“菜单 > 拾色器 > …… > 绘图区”的
* 优先级方向一致;由于四个组件的区域互不重叠,实际结果与旧的手写链完全等价。
*/
std::vector<ComponentEntry> m_components;
/**
* @brief 是否显示**布局边界**(设置菜单里的调试开关)。
*
* 开启时 `Render` 在最后一步遍历组件表让每个组件上报边界(见 `PaintLayoutBounds`),
* 并强制整帧回送(局部回送只会更新绘图区,装饰区的边界会留在旧帧上)。
* 关闭时**完全不执行**,对正常绘制零影响。
*/
bool m_showLayoutBounds = false;
/**
* @brief 调试 box 容器(只在 `m_showLayoutBounds` 为真时使用)。
*
* 作为成员而不是局部变量:避免开关打开时每帧重新分配,
* 也让它能直接充当 `PaintUtil` 的文本收集器(`UiTextRectSink`)。
*/
UiBoxTable m_debugBoxes;
/** @brief 填充组件表(`Initialize` 里 `Setup` 之后调用一次)。 */
void BuildComponentTable();
gd::RectF m_tabBarRect; /**< 标签栏区域。 */
gd::RectF m_toolbarRect; /**< 工具栏区域。 */
gd::RectF m_panelRect; /**< 信号面板区域。 */
gd::RectF m_splitterRect; /**< 面板分隔条区域。 */
gd::RectF m_plotRect; /**< 绘图区区域。 */
float m_panelWidth = 366.0f; /**< 面板宽度(逻辑像素)。 */
bool m_draggingSplitter = false; /**< 是否正在拖动分隔条。 */
bool m_middlePanning = false; /**< 是否正在用鼠标中键自由平移绘图区。 */
int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */
HDC m_backDc = nullptr; /**< 后备缓冲设备上下文。 */
HBITMAP m_backBitmap = nullptr; /**< 后备缓冲位图(top-down 32bpp DIB 段)。 */
HGDIOBJ m_backOldBitmap = nullptr; /**< 后备缓冲原始位图。 */
int m_backWidth = 0; /**< 后备缓冲宽度(设备像素)。 */
int m_backHeight = 0; /**< 后备缓冲高度(设备像素)。 */
std::uint32_t* m_backBits = nullptr; /**< 后备缓冲像素首地址(仅 CPU 侧合成时使用)。 */
int m_backStride = 0; /**< 后备缓冲行字节数(**不可**假设 = 宽 × 4)。 */
std::uint64_t m_backVersion = 0; /**< 后备缓冲代次(重建位图时递增,用于作废旧句柄)。 */
Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */
bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */
bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */
/** 信号列表「滚动到目标信号」动画的定时器是否已启动(见 `SignalPanel::AdvanceScrollAnimation`)。 */
bool m_scrollTimerActive = false;
/**
* 错误反馈是否使用模态对话框(默认开启;`Initialize` 遇到隐藏窗口时自动关闭)。
*
* 隐藏窗口(无人可点确定)下弹模态框等于把进程卡死,因此按启动方式自动降级为
* 绘图区非模态浮层提示;调用方也可用 `SetModalErrorsEnabled` 显式控制。
*/
bool m_modalErrorsEnabled = true;
std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */
int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */
int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */
int m_cachedVisibleCount = -1; /**< 上次同步时的可见信号数量。 */
std::size_t m_cachedLaneCount = 0; /**< 上次同步时的网格数量。 */
bool m_cachedAnyVisible = false; /**< 上次同步时是否存在可见信号。 */
bool m_loading = false; /**< 是否正在显示加载过渡动画。 */
std::wstring m_loadingText; /**< 加载遮罩显示的文件名。 */
float m_loadingProgress = 0.0f; /**< 加载进度(0-1)。 */
/** 已读取字节数(用于进度浮层);与 `m_loadingTotalBytes` 一起展示“已读 / 总量”。 */
unsigned long long m_loadingBytesRead = 0;
/** 文件总字节数(0 表示未知)。 */
unsigned long long m_loadingTotalBytes = 0;
float m_loadingSpin = 0.0f; /**< 加载动画旋转相位(度)。 */
/**
* @brief 装饰区域(标签栏 / 工具栏 / 信号面板 / 分隔条)是否需要重绘。
*
* 这些区域的绘制成本与数据量无关但与控件数量成正比,且只在鼠标位于其中
* 或发生交互时才会变化。绘图区拖拽(如双光标左右移动)期间持续置为
* false,从而每帧只重绘、只回送绘图区所在的矩形,避免整窗重绘的开销。
*/
bool m_chromeDirty = true;
/**
* @brief 「只有信号面板(与分隔条)需要重绘」。
*
* 面板滚动动画(「滚动到目标信号」)每帧只改变面板像素,而 `m_chromeDirty` 会
* 连**标签栏 / 工具栏 / 整窗底色**一起重绘 —— 实测 129 个网格时这一份装饰重绘
* 约 **3~4ms/帧**,会把动画挤出 15.6ms 的预算(用户 2026-10-17 报告:滚动过渡动画卡顿)。
*
* ✓ 由滚动定时器在**只有面板动画在跑**时置位:`Render` 只重绘面板 + 分隔条
*(标签栏/工具栏/整窗底色与上一帧逐像素相同,后备缓冲区里的旧像素本来就是对的)。
* ⚠️ 回送仍是 `if (fullFrame) {整窗} else {绘图区}` 的**无条件分支**,
* 与 §9.41 的教训一致(**不**按更新区域决定回送范围)。
*/
bool m_panelDirty = false;
bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */
};
} // namespace jdi
#endif // __APPLICATION_HPP__