重构(UI): 统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + 剥离 Tools
## 1. 新增 UiComponent 顶层组件协议(不是负优化)
Components/Shared/Inc/UiComponent.hpp:纯接口、无状态,8 个方法
SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor。
Toolbar / TabBar / SignalPanel / PlotView 本来就各自拥有完全同名同签名的成员,
因此改造 = 加 override + 补 Area()(PlotView 另加一个非 const 的 QueryCursor 转发,
因为它自己的查询会惰性建立行缓存),实现代码零改动、静态派发、体积无增长。
刻意不把 UiWidget 当作它的基类:UiWidget 是"树节点"(父给尺寸、父下探脏区、
命中沿树链派发),而 PlotView(4 层位图缓存)/ SignalPanel(行位图缓存)是
"地盘宿主"。强行上树会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过
"后台渲染覆盖平移结果""命中矩形错位"的事故。头文件里已写明这条理由。
Toolbar/TabBar 是两者的交集:对外实现 UiComponent,对内持有一棵 UiWidget 树。
## 2. 宿主侧:四条手写链 -> 组件表
Application 新增 ComponentEntry{ component, target, chrome } 与 m_components
(底->顶:plot -> panel -> toolbar -> tabBar),BuildComponentTable() 装配一次。
- ApplyDpi 6 行逐个 SetScale -> 1 个循环
- Relayout 手算三列 x 坐标 -> UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO})
- WM_MOUSELEAVE 逐个 OnMouseLeave -> 循环累积 chromeChanged / plotChanged
- WM_SETCURSOR if/else if 链 -> 从表尾向前找首个命中者,取其 QueryCursor
顺带兑现历史缺口:UiLayout::UiStackRects 自引入以来第一次有了生产调用点。
有意保留显式的三条链(WM_LBUTTONDOWN / WM_LBUTTONUP / WM_MOUSEMOVE):它们各自带
组件专属后置动作(提交重命名、构建派生信号、面板联动滚动、更新拖拽光标、
plotOnlyMove 决定局部失效还是整帧失效)。塞进表里只能靠 lambda 字段,
可读性反而下降 —— 属于用户明确禁止的"过度抽象",因此不做。
## 3. 大文件按职责拆分(纯整函数搬移,逐像素 0 差异)
SignalPanelInteraction.cpp 2329 -> 831 行,拆出:
SignalPanelEdit.cpp 616 内联编辑(筛选框、组名/信号名、数值、颜色与透明度)
SignalPanelScroll.cpp 205 动画与滚动(折起、联动滚动、滚动到目标信号)
SignalPanelMenu.cpp 527 弹出菜单(系数/偏移、镜像、右键菜单与命令回执)
Shared/PopupMenu.cpp 1901 -> 706 行,拆出:
PopupMenuLayout.cpp 753 尺寸测量、项矩形排版、滚动可见性
PopupMenuPaint.cpp 397 背景/阴影/项绘制
Inc/PopupMenuInternal.hpp 103 pmdetail 常量 + inline 辅助(跨 TU 安全)
拆分判据是"职责"而非行数;共享常量一律收敛到 *Internal.hpp。
## 4. 代码审查(修 5 处)
- SignalPanelInteraction.cpp 头注释仍写"4 片",实际 6 片 -> 改为完整清单
- PopupMenuLayout.cpp 机械搬移时把构造函数一起搬走 -> 重复定义,只保留在交互分片
- PopupMenu.cpp 的 #include "PopupMenuInternal.hpp" 落进 namespace jdi 内 -> 移到文件头
- PopupMenuPaint.cpp 缺 UiControls.hpp(UiDrawEditFieldContent 未声明)-> 补 include
- 全仓复查历史删除符号残留引用(m_paintPlot / ClampCursorsAfterPan /
PaintFreeMarkerTooltip / CaptureDecorCache / FitSinglePointSignal / CsvTable /
DataFile / .preflight / structcheck)-> 零命中
## 5. Tools/ 剥离
git rm -r --cached Tools + .gitignore 新增 Tools/(本地文件保留,可随时复跑)。
ui_refactor 分支为全本地分支,另做 filter-branch 清除历史中的该目录。
## 6. 验证
- make release / make debug:零 error 零 warning
- rendercheck:A-H 八个场景哈希与改造前逐一相同
(A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113)
FUNC 7/7 PASS
- uicheck:69 passed, 0 failed
- TargetCheck.exe 891,392 B(< 900 KB 阈值)
## 7. 文档
Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md
新增第五阶段章节;Documents/agent_mem.md 同步变更日志与「第七轮」硬约束。
This commit is contained in:
1 parent
675c2a5f54
commit
0ec8055e95
20 files changed
+3287
-2678
No files matched your search
@@ -13,3 +13,6 @@ Build/
|
||||
|
||||
# 测试数据(仅本地保留,不纳入版本控制)
|
||||
TestData/
|
||||
|
||||
# 临时验证工具(仅本地保留,不入版本控制)
|
||||
Tools/
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "TabBar.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "Toolbar.hpp"
|
||||
#include "UiComponent.hpp"
|
||||
#include "UiSurface.hpp"
|
||||
|
||||
namespace jdi
|
||||
@@ -426,6 +427,31 @@ namespace jdi
|
||||
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 填充组件表(`Initialize` 里 `Setup` 之后调用一次)。 */
|
||||
void BuildComponentTable();
|
||||
|
||||
gd::RectF m_tabBarRect; /**< 标签栏区域。 */
|
||||
gd::RectF m_toolbarRect; /**< 工具栏区域。 */
|
||||
gd::RectF m_panelRect; /**< 信号面板区域。 */
|
||||
|
||||
@@ -137,6 +137,10 @@ namespace jdi
|
||||
m_plot.SetHostWindow(m_hwnd);
|
||||
m_menu.SetHostWindow(m_hwnd);
|
||||
|
||||
// 组件表在这里一次装配好:之后所有"设置缩放 / 摆放 / 悬停离开 / 光标 / 装饰区绘制"
|
||||
// 都由 `m_components` 驱动,宿主不再逐个点名组件。
|
||||
BuildComponentTable();
|
||||
|
||||
// 标题栏小图标与任务栏/Alt+Tab 缩略图大图标分开设置(同一个多尺寸 ICO 资源的不同尺寸)。
|
||||
HICON bigIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
|
||||
HICON smallIcon = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
|
||||
@@ -193,6 +197,18 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
void Application::BuildComponentTable()
|
||||
{
|
||||
// 底 → 顶:绘图区在最底,面板/工具栏/标签栏依次在上面。
|
||||
// 四个组件的区域互不重叠,因此命中顺序在这里不影响结果(保留顶优先只是为了与
|
||||
// 旧实现的方向一致,且将来若出现重叠会有确定的语义)。
|
||||
m_components.clear();
|
||||
m_components.push_back(ComponentEntry{ &m_plot, Target::PLOT, false });
|
||||
m_components.push_back(ComponentEntry{ &m_panel, Target::PANEL, true });
|
||||
m_components.push_back(ComponentEntry{ &m_toolbar, Target::TOOLBAR, true });
|
||||
m_components.push_back(ComponentEntry{ &m_tabBar, Target::TAB_BAR, true });
|
||||
}
|
||||
|
||||
void Application::ApplyDpi(UINT dpi)
|
||||
{
|
||||
if (dpi == 0)
|
||||
@@ -204,12 +220,12 @@ namespace jdi
|
||||
m_scale = static_cast<float>(dpi) / 96.0f;
|
||||
|
||||
m_fonts.Create(dpi);
|
||||
m_tabBar.SetScale(m_scale);
|
||||
m_toolbar.SetScale(m_scale);
|
||||
m_menu.SetScale(m_scale);
|
||||
m_colorPicker.SetScale(m_scale);
|
||||
m_panel.SetScale(m_scale);
|
||||
m_plot.SetScale(m_scale);
|
||||
for (const ComponentEntry& entry : m_components)
|
||||
{
|
||||
entry.component->SetScale(m_scale);
|
||||
}
|
||||
ReleaseBackBuffer();
|
||||
Relayout();
|
||||
}
|
||||
@@ -284,16 +300,38 @@ namespace jdi
|
||||
panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum));
|
||||
m_panelWidth = panelWidth / m_scale;
|
||||
|
||||
m_panelRect = gd::RectF(0.0f, contentTop, panelWidth, contentHeight);
|
||||
m_splitterRect = gd::RectF(panelWidth, contentTop, splitterWidth, contentHeight);
|
||||
m_plotRect = gd::RectF(panelWidth + splitterWidth, contentTop,
|
||||
std::max(0.0f, width - panelWidth - splitterWidth), contentHeight);
|
||||
// 内容区:左面板 + 分隔条 + 绘图区。用 `UiStackRects` 表达"固定 + 占满剩余"的切分,
|
||||
// 避免再次手算三组坐标(这也是 `UiLayout` 第一个真实生产调用点)。
|
||||
const std::vector<float> contentSizes{ panelWidth, splitterWidth, kUiSizeAuto };
|
||||
std::vector<gd::RectF> contentRects;
|
||||
UiStackRects(gd::RectF(0.0f, contentTop, width, contentHeight), UiAxis::HORIZONTAL,
|
||||
contentSizes, 0.0f, contentRects);
|
||||
m_panelRect = contentRects[0];
|
||||
m_splitterRect = contentRects[1];
|
||||
m_plotRect = contentRects[2];
|
||||
|
||||
m_tabBar.Layout(m_tabBarRect);
|
||||
m_toolbar.Layout(m_toolbarRect);
|
||||
m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height));
|
||||
m_panel.Layout(m_panelRect);
|
||||
m_plot.Layout(m_plotRect);
|
||||
// 四个组件的区域一次摆完(顺序无关:各区域互不重叠)。
|
||||
m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height));
|
||||
for (const ComponentEntry& entry : m_components)
|
||||
{
|
||||
switch (entry.target)
|
||||
{
|
||||
case Target::TAB_BAR:
|
||||
entry.component->Layout(m_tabBarRect);
|
||||
break;
|
||||
case Target::TOOLBAR:
|
||||
entry.component->Layout(m_toolbarRect);
|
||||
break;
|
||||
case Target::PANEL:
|
||||
entry.component->Layout(m_panelRect);
|
||||
break;
|
||||
case Target::PLOT:
|
||||
entry.component->Layout(m_plotRect);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。
|
||||
m_chromeDirty = true;
|
||||
@@ -973,10 +1011,23 @@ namespace jdi
|
||||
// 只有标签栏 / 工具栏 / 面板的悬停态变化才需要重绘装饰区域;
|
||||
// 绘图区自身的悬停态变化(提示浮层、信号光晕)只需重绘绘图区,
|
||||
// 这样拖拽光标时鼠标短暂移出窗口也不会退化成整帧重绘。
|
||||
bool chromeChanged = m_tabBar.OnMouseLeave();
|
||||
chromeChanged = m_toolbar.OnMouseLeave() || chromeChanged;
|
||||
chromeChanged = m_panel.OnMouseLeave() || chromeChanged;
|
||||
const bool plotChanged = m_plot.OnMouseLeave();
|
||||
bool chromeChanged = false;
|
||||
bool plotChanged = false;
|
||||
for (const ComponentEntry& entry : m_components)
|
||||
{
|
||||
if (!entry.component->OnMouseLeave())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (entry.chrome)
|
||||
{
|
||||
chromeChanged = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
plotChanged = true;
|
||||
}
|
||||
}
|
||||
m_mouseOverPlot = false;
|
||||
|
||||
if (chromeChanged)
|
||||
@@ -1462,13 +1513,19 @@ namespace jdi
|
||||
{
|
||||
cursor = LoadCursorW(nullptr, IDC_SIZEWE);
|
||||
}
|
||||
else if (m_plot.HitTest(point.x, point.y))
|
||||
else
|
||||
{
|
||||
cursor = m_plot.QueryCursor(point.x, point.y);
|
||||
}
|
||||
else if (UiPointInRect(m_panelRect, static_cast<float>(point.x), static_cast<float>(point.y)))
|
||||
{
|
||||
cursor = m_panel.QueryCursor(point.x, point.y);
|
||||
// 光标仲裁:从组件表**顶部往下**找第一个命中的组件;它返回 nullptr 即视为
|
||||
// "该区域用箭头"(与旧实现里 `if (绘图区命中) 取它的光标,否则看面板` 一致)。
|
||||
for (auto iterator = m_components.rbegin(); iterator != m_components.rend(); ++iterator)
|
||||
{
|
||||
if (!iterator->component->HitTest(point.x, point.y))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
cursor = iterator->component->QueryCursor(point.x, point.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cursor == nullptr)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "DataModel.hpp"
|
||||
#include "PaintUtil.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "UiComponent.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
@@ -140,7 +141,7 @@ namespace jdi
|
||||
* 绘图区同时依赖数据层(DataSet,只读)与当前标签页配置(TabConfig,可写),
|
||||
* 视图范围、网格滚动与时间光标均保存在标签页配置中,因此各标签页互不干扰。
|
||||
*/
|
||||
class PlotView
|
||||
class PlotView : public UiComponent
|
||||
{
|
||||
public:
|
||||
PlotView();
|
||||
@@ -186,19 +187,22 @@ namespace jdi
|
||||
* @brief 设置 DPI 缩放比例。
|
||||
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
|
||||
*/
|
||||
void SetScale(float scale);
|
||||
void SetScale(float scale) override;
|
||||
|
||||
/**
|
||||
* @brief 按给定区域重新排布绘图区。
|
||||
* @param [IN] area 可用区域。
|
||||
*/
|
||||
void Layout(const gd::RectF& area);
|
||||
void Layout(const gd::RectF& area) override;
|
||||
|
||||
/** @brief 绘图区占用的整块区域(与 `Layout` 同源)。 */
|
||||
gd::RectF Area() const override { return m_area; }
|
||||
|
||||
/**
|
||||
* @brief 绘制绘图区。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
*/
|
||||
void Paint(gd::Graphics& g);
|
||||
void Paint(gd::Graphics& g) override;
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标移动(含拖拽)。
|
||||
@@ -292,7 +296,7 @@ namespace jdi
|
||||
* @brief 处理鼠标离开绘图区。
|
||||
* @return 需要重绘返回 true。
|
||||
*/
|
||||
bool OnMouseLeave();
|
||||
bool OnMouseLeave() override;
|
||||
|
||||
/**
|
||||
* @brief 将时间轴自适应到指定网格内信号的时间范围。
|
||||
@@ -466,7 +470,7 @@ namespace jdi
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 落在范围内返回 true。
|
||||
*/
|
||||
bool HitTest(int x, int y) const;
|
||||
bool HitTest(int x, int y) const override;
|
||||
|
||||
/**
|
||||
* @brief 查询当前鼠标位置应使用的系统光标。
|
||||
@@ -476,6 +480,17 @@ namespace jdi
|
||||
*/
|
||||
HCURSOR QueryCursor(int x, int y) const;
|
||||
|
||||
/**
|
||||
* @brief `UiComponent` 协议的入口(转调上面的 const 版本)。
|
||||
*
|
||||
* `UiComponent` 声明的是非 const 版本(`SignalPanel` 的命中需要惰性重建行集合),
|
||||
* 而绘图区的实现天然是 const;这里做一次薄转发,两个重载保持行为完全一致。
|
||||
*/
|
||||
HCURSOR QueryCursor(int x, int y) override
|
||||
{
|
||||
return static_cast<const PlotView*>(this)->QueryCursor(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。
|
||||
*
|
||||
@@ -1083,7 +1098,7 @@ namespace jdi
|
||||
* @brief 判断是否处于任意内联数值输入状态。
|
||||
* @return 处于编辑态返回 true。
|
||||
*/
|
||||
bool IsEditing() const { return m_editTarget != PlotEditTarget::NONE; }
|
||||
bool IsEditing() const override { return m_editTarget != PlotEditTarget::NONE; }
|
||||
|
||||
/**
|
||||
* @brief 取出并清除待应用的信号重命名请求。
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#ifndef __POPUP_MENU_INTERNAL_HPP__
|
||||
#define __POPUP_MENU_INTERNAL_HPP__
|
||||
|
||||
// PopupMenu 的内部共享定义(尺寸常量与绘制辅助函数)。
|
||||
//
|
||||
// 实现按职责分片后(PopupMenu.cpp = 交互与生命周期 / PopupMenuLayout.cpp = 几何与排版 /
|
||||
// PopupMenuPaint.cpp = 绘制),三片都要用到同一批尺寸常量与「还原」图标绘制,
|
||||
// 因此统一放在这里:命名空间 `pmdetail`、全部 `inline` / `constexpr`,跨 TU 安全。
|
||||
//
|
||||
// ⚠️ 这里的常量同时决定**排版与绘制**:改数值会同时影响两边(这是刻意的,避免两套真相)。
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace pmdetail
|
||||
{
|
||||
constexpr float kRowHeight = 27.0f; /**< 普通项高度。 */
|
||||
constexpr float kHeaderHeight = 22.0f; /**< 分组标题高度。 */
|
||||
constexpr float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */
|
||||
constexpr float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */
|
||||
constexpr float kPadV = 5.0f; /**< 菜单上下内边距。 */
|
||||
constexpr float kPadH = 5.0f; /**< 菜单左右内边距。 */
|
||||
constexpr float kItemPad = 8.0f; /**< 条目内文本的左右内缩(所有条目统一)。 */
|
||||
constexpr float kMarkerColumn = 18.0f; /**< 条目**右侧**的选中标记列宽度(对勾画在这里)。 */
|
||||
constexpr float kMarkerGap = 4.0f; /**< 文本与右侧标记列之间的间隔。 */
|
||||
constexpr float kMinWidth = 148.0f; /**< 菜单最小宽度。 */
|
||||
constexpr float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */
|
||||
constexpr float kCorner = 6.0f; /**< 菜单圆角半径。 */
|
||||
constexpr float kSwatchSize = 22.0f; /**< 色块尺寸。 */
|
||||
constexpr float kSwatchGap = 5.0f; /**< 色块间距。 */
|
||||
constexpr float kEditFieldWidth = 88.0f; /**< EDIT 项输入框宽度。 */
|
||||
constexpr float kEditFieldGap = 6.0f; /**< 说明文字与输入框之间的最小间隔。 */
|
||||
constexpr float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */
|
||||
constexpr float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */
|
||||
constexpr float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */
|
||||
constexpr float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
|
||||
constexpr float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
|
||||
constexpr float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
|
||||
constexpr float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */
|
||||
constexpr float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */
|
||||
constexpr float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */
|
||||
|
||||
/**
|
||||
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
|
||||
*
|
||||
* 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。
|
||||
* 不依赖任何字体或资源,因此在任何 DPI 下都清晰。
|
||||
*
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 图标外接矩形(正方形)。
|
||||
* @param [IN] color 笔画颜色。
|
||||
* @param [IN] scale DPI 缩放比例。
|
||||
*/
|
||||
inline void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale)
|
||||
{
|
||||
const float inset = 2.5f * scale;
|
||||
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
|
||||
rect.Height - inset * 2.0f);
|
||||
if (box.Width <= 1.0f || box.Height <= 1.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
|
||||
color & 0xFFu),
|
||||
std::max(1.0f, 1.5f * scale));
|
||||
pen.SetStartCap(gd::LineCapRound);
|
||||
pen.SetEndCap(gd::LineCapRound);
|
||||
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
|
||||
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
|
||||
|
||||
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
|
||||
const float cx = box.X + box.Width * 0.5f;
|
||||
const float cy = box.Y + box.Height * 0.5f;
|
||||
const float radius = box.Width * 0.5f;
|
||||
const float angle = 300.0f * 3.14159265f / 180.0f;
|
||||
const float tipX = cx + std::cos(angle) * radius;
|
||||
const float tipY = cy + std::sin(angle) * radius;
|
||||
const float size = std::max(2.5f, 3.2f * scale);
|
||||
gd::PointF arrow[3];
|
||||
arrow[0] = gd::PointF(tipX, tipY);
|
||||
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
|
||||
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
|
||||
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
|
||||
(color >> 8) & 0xFFu, color & 0xFFu));
|
||||
g.FillPolygon(&brush, arrow, 3);
|
||||
}
|
||||
} // namespace pmdetail
|
||||
|
||||
using namespace pmdetail;
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __POPUP_MENU_INTERNAL_HPP__
|
||||
@@ -0,0 +1,80 @@
|
||||
#ifndef __UI_COMPONENT_HPP__
|
||||
#define __UI_COMPONENT_HPP__
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief 顶层 UI 组件的统一协议(**纯接口,无状态**)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
*
|
||||
* 旧实现里宿主(`Application`)用**手写 if 链**逐个点名四个大组件,导致同一件事
|
||||
* (设置缩放 / 摆放 / 悬停离开 / 光标仲裁 / 绘制)要在宿主的每个消息分支里各写一遍,
|
||||
* 新增组件必须记得改 5 处。这里把它们收敛成一张**有序组件表**,
|
||||
* 宿主只按表遍历,不再点名。
|
||||
*
|
||||
* ## 为什么不是 `UiWidget`
|
||||
*
|
||||
* `UiWidget` 的契约是"**树上的一个节点**":尺寸由父节点 `Measure` 决定、脏区由父节点下探、
|
||||
* 命中走树的命中链、`OnPaint` 只画自己不许遍历子节点。而顶层大组件的现实是:
|
||||
* * 区域由宿主给定,且**内部还要再切分**;
|
||||
* * `PlotView` 有 4 层自持位图缓存、`SignalPanel` 有行位图缓存 —— 与"父脏下探"是另一套失效体系;
|
||||
* * `PlotView` 一旦成为树节点,它的 `Paint` 就得接受父级 `dirty` 并与自己的缓存对齐,
|
||||
* 这正是历史上"后台渲染覆盖用户平移 / 命中错位"那类事故的温床。
|
||||
*
|
||||
* ⇒ 两者是**兄弟关系**而不是父子关系:
|
||||
* `UiWidget`(树内节点,管**一块区域内部的**布局/命中/绘制)
|
||||
* `UiComponent`(顶层宿主,管**自己这一整块地盘**:区域、缓存、局部重绘策略)。
|
||||
* `Toolbar` / `TabBar` 同时是两者 —— 对外实现本协议,对内持有一棵 `UiWidget` 树。
|
||||
*
|
||||
* ## 实现要求
|
||||
*
|
||||
* * 所有方法都必须**幂等且可在未布局时安全调用**(宿主会在首次 `Layout` 之前调 `SetScale`)。
|
||||
* * `HitTest` 必须与 `Layout` 接受的区域一致(绘制与命中同源)。
|
||||
* * `Paint` 必须把内容严格限制在 `Area()` 内(宿主可能只回送部分矩形)。
|
||||
*/
|
||||
class UiComponent
|
||||
{
|
||||
public:
|
||||
virtual ~UiComponent() = default;
|
||||
|
||||
/** @brief 设置 DPI 缩放系数(> 0)。 */
|
||||
virtual void SetScale(float scale) = 0;
|
||||
|
||||
/** @brief 在宿主给定的区域内摆放自身(内部布局由组件自己决定)。 */
|
||||
virtual void Layout(const gd::RectF& area) = 0;
|
||||
|
||||
/** @brief 当前占用区域(客户区坐标);未布局时返回空矩形。 */
|
||||
virtual gd::RectF Area() const = 0;
|
||||
|
||||
/** @brief 绘制自身(必须裁剪在 `Area()` 内)。 */
|
||||
virtual void Paint(gd::Graphics& g) = 0;
|
||||
|
||||
/** @brief 坐标是否落在本组件内(与 `Area()` 同源)。 */
|
||||
virtual bool HitTest(int x, int y) const = 0;
|
||||
|
||||
/** @brief 指针离开窗口/组件:清掉本组件的悬停态。返回是否需要重绘。 */
|
||||
virtual bool OnMouseLeave() = 0;
|
||||
|
||||
/** @brief 是否处于内联编辑态(宿主据此把鼠标/键盘优先交给它)。 */
|
||||
virtual bool IsEditing() const { return false; }
|
||||
|
||||
/** @brief 光标仲裁:返回 `nullptr` 表示不认领(由下层或宿主决定)。 */
|
||||
virtual HCURSOR QueryCursor(int x, int y)
|
||||
{
|
||||
(void)x;
|
||||
(void)y;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __UI_COMPONENT_HPP__
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,753 @@
|
||||
// PopupMenu 实现分片 —— 几何与排版(BuildLayout / 子菜单 / 滚动区 / 内联输入框位置)。
|
||||
//
|
||||
// 本文件只包含 PopupMenu 的成员函数定义(从 PopupMenu.cpp 机械搬移,行为不变)。
|
||||
//
|
||||
// ⚠️ 这里是菜单**唯一**的几何来源:`Paint` 与所有命中测试都读它产出的矩形,
|
||||
// 不得在别处再反推一遍(否则会出现"看得见点不到")。
|
||||
// ⚠️ `NaturalEditFieldWidth` / `EditSuffixWidth` 同时参与排版与绘制,改公式必须两处一致。
|
||||
|
||||
#include "PopupMenu.hpp"
|
||||
|
||||
#include "PopupMenuInternal.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
void PopupMenu::Setup(const UiFonts* fonts, const Theme* theme)
|
||||
{
|
||||
m_fonts = fonts;
|
||||
m_theme = theme;
|
||||
}
|
||||
|
||||
void PopupMenu::SetScale(float scale)
|
||||
{
|
||||
m_scale = (scale > 0.1f) ? scale : 1.0f;
|
||||
}
|
||||
|
||||
float PopupMenu::ItemHeight(const MenuItem& item) const
|
||||
{
|
||||
switch (item.kind)
|
||||
{
|
||||
case MenuItemKind::SEPARATOR:
|
||||
return kSeparatorHeight * m_scale;
|
||||
case MenuItemKind::HEADER:
|
||||
case MenuItemKind::HEADER_ACTION:
|
||||
return kHeaderHeight * m_scale;
|
||||
case MenuItemKind::SWATCHES:
|
||||
return kSwatchRowHeight * m_scale;
|
||||
case MenuItemKind::EDIT:
|
||||
// 编辑项比普通项略高,给输入框留出上下呼吸空间。
|
||||
return (kRowHeight + 4.0f) * m_scale;
|
||||
case MenuItemKind::NORMAL:
|
||||
default:
|
||||
return kRowHeight * m_scale;
|
||||
}
|
||||
}
|
||||
|
||||
void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp)
|
||||
{
|
||||
m_itemRects.assign(m_items.size(), gd::RectF());
|
||||
m_swatchRects.assign(m_items.size(), std::vector<gd::RectF>());
|
||||
|
||||
if (m_fonts == nullptr)
|
||||
{
|
||||
m_rect = gd::RectF();
|
||||
return;
|
||||
}
|
||||
|
||||
float contentWidth = kMinWidth * m_scale;
|
||||
float totalHeight = kPadV * 2.0f * m_scale;
|
||||
|
||||
std::vector<float> heights(m_items.size(), 0.0f);
|
||||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_items[index];
|
||||
heights[index] = ItemHeight(item);
|
||||
totalHeight += heights[index];
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||||
static_cast<float>(count) * kSwatchSize * m_scale +
|
||||
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
// 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。
|
||||
const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION)
|
||||
? (kHeaderIconSize + kHeaderIconGap) * m_scale
|
||||
: 0.0f;
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||||
kMarkerGap * m_scale + iconExtra +
|
||||
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::NORMAL)
|
||||
{
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||||
kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label);
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::EDIT)
|
||||
{
|
||||
// 说明文字 + 输入框;输入框宽度按数值文本自适应(与 EditFieldRect 保持一致)。
|
||||
const float fieldWidth = NaturalEditFieldWidth(item);
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||||
UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale +
|
||||
fieldWidth;
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
}
|
||||
|
||||
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
|
||||
const float width = contentWidth;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。
|
||||
// * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)——
|
||||
// 用户 2026-10-16 要求:滚动时标题必须留在最上方不动;
|
||||
// * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部;
|
||||
// * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。
|
||||
// ------------------------------------------------------------------
|
||||
int pinnedFrom = -1;
|
||||
int scrollFirst = 0;
|
||||
if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0)
|
||||
{
|
||||
for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
|
||||
{
|
||||
if (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SEPARATOR)
|
||||
{
|
||||
pinnedFrom = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (scrollFirst < pinnedFrom &&
|
||||
(m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER ||
|
||||
m_items[static_cast<std::size_t>(scrollFirst)].kind == MenuItemKind::HEADER_ACTION))
|
||||
{
|
||||
++scrollFirst; // 前导标题固定在顶部,不参与滚动
|
||||
}
|
||||
}
|
||||
|
||||
float headHeight = 0.0f;
|
||||
float scrollRegionHeight = 0.0f;
|
||||
float tailHeight = 0.0f;
|
||||
if (pinnedFrom > 0)
|
||||
{
|
||||
for (int index = 0; index < static_cast<int>(m_items.size()); ++index)
|
||||
{
|
||||
if (index >= pinnedFrom)
|
||||
{
|
||||
tailHeight += heights[static_cast<std::size_t>(index)];
|
||||
}
|
||||
else if (index < scrollFirst)
|
||||
{
|
||||
headHeight += heights[static_cast<std::size_t>(index)];
|
||||
}
|
||||
else
|
||||
{
|
||||
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float height = totalHeight;
|
||||
m_scrollEnabled = false;
|
||||
m_scrollRegionHeight = 0.0f;
|
||||
m_scrollFirst = 0;
|
||||
m_pinnedIndex = -1;
|
||||
m_scrollViewport = gd::RectF();
|
||||
m_scrollBarRect = gd::RectF();
|
||||
// ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`,
|
||||
// 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。
|
||||
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条)
|
||||
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
|
||||
// 滚动时才复位(`Open` 里已经为每次打开重置过)。
|
||||
if (pinnedFrom > 0)
|
||||
{
|
||||
// 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与
|
||||
// `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。
|
||||
float viewportLimit = 0.0f;
|
||||
if (m_constraint.maxHeight > 0.0f)
|
||||
{
|
||||
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”,
|
||||
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。
|
||||
const float menuLimit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale);
|
||||
if (menuLimit >= 40.0f * m_scale)
|
||||
{
|
||||
viewportLimit = std::max(
|
||||
0.0f, menuLimit - kPadV * 2.0f * m_scale - headHeight - tailHeight);
|
||||
}
|
||||
}
|
||||
if (m_constraint.maxScrollRows > 0)
|
||||
{
|
||||
const float rows =
|
||||
static_cast<float>(m_constraint.maxScrollRows) * kRowHeight * m_scale;
|
||||
viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows;
|
||||
}
|
||||
|
||||
if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f)
|
||||
{
|
||||
const float viewportHeight = std::min(scrollRegionHeight, viewportLimit);
|
||||
height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight;
|
||||
m_scrollEnabled = true;
|
||||
m_scrollFirst = scrollFirst;
|
||||
m_pinnedIndex = pinnedFrom;
|
||||
m_scrollRegionHeight = scrollRegionHeight;
|
||||
m_scrollOffset = std::max(
|
||||
0.0f, std::min(m_scrollOffset, std::max(0.0f, scrollRegionHeight - viewportHeight)));
|
||||
}
|
||||
else
|
||||
{
|
||||
m_scrollOffset = 0.0f;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_scrollOffset = 0.0f;
|
||||
}
|
||||
|
||||
// 带说明文字的 EDIT 项共用**同一个输入框宽度**:取其中最大的自然宽度,
|
||||
// 再按“给最宽的说明文字留位 / 不超过内容区 3/4”夹取。
|
||||
// ⚠️ 若每项各自按自己的文本长度算,同一面板里就会出现一个宽一个窄
|
||||
// (时间轴行的文本更长、还带单位后缀,于是它的输入框比 Y 轴行宽一截)。
|
||||
float labeledFieldWidth = 0.0f;
|
||||
float widestLabel = 0.0f;
|
||||
for (const MenuItem& item : m_items)
|
||||
{
|
||||
if (item.kind != MenuItemKind::EDIT || item.label.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
labeledFieldWidth = std::max(labeledFieldWidth, NaturalEditFieldWidth(item));
|
||||
widestLabel = std::max(widestLabel,
|
||||
UiMeasureTextWidth(m_fonts->uiSmall, item.label) +
|
||||
kEditFieldGap * m_scale);
|
||||
}
|
||||
if (labeledFieldWidth > 0.0f)
|
||||
{
|
||||
const float rowContent =
|
||||
std::max(40.0f * m_scale, (width - 2.0f * (kPadH + kItemPad) * m_scale));
|
||||
const float limit = std::max(40.0f * m_scale,
|
||||
std::min(rowContent * 0.75f, rowContent - widestLabel));
|
||||
labeledFieldWidth = std::min(labeledFieldWidth, limit);
|
||||
}
|
||||
m_editFieldWidth = labeledFieldWidth;
|
||||
|
||||
float left = static_cast<float>(x);
|
||||
// `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。
|
||||
float top = expandUp ? (static_cast<float>(y) - height) : static_cast<float>(y);
|
||||
|
||||
// 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。
|
||||
if (left + width > bounds.X + bounds.Width)
|
||||
{
|
||||
left = static_cast<float>(x) - width;
|
||||
}
|
||||
if (!expandUp && top + height > bounds.Y + bounds.Height)
|
||||
{
|
||||
top = static_cast<float>(y) - height;
|
||||
}
|
||||
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
|
||||
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
|
||||
|
||||
m_rect = gd::RectF(left, top, width, height);
|
||||
|
||||
// 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`;
|
||||
// 尾部固定区从菜单底部往上对齐。
|
||||
const float bodyTop = top + kPadV * m_scale;
|
||||
const float tailTop = top + height - kPadV * m_scale - tailHeight;
|
||||
const float scrollBarReserve = m_scrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
|
||||
m_scrollViewport =
|
||||
m_scrollEnabled
|
||||
? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight,
|
||||
std::max(0.0f, width - kPadH * 2.0f * m_scale),
|
||||
std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight))
|
||||
: gd::RectF();
|
||||
|
||||
float cursorY = bodyTop;
|
||||
float headCursorY = bodyTop;
|
||||
float scrollCursorY = bodyTop + headHeight - m_scrollOffset;
|
||||
float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop;
|
||||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_items[index];
|
||||
const float itemHeight = heights[index];
|
||||
|
||||
float rectY = cursorY;
|
||||
float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale);
|
||||
if (m_scrollEnabled)
|
||||
{
|
||||
const int itemIndex = static_cast<int>(index);
|
||||
if (itemIndex < m_scrollFirst)
|
||||
{
|
||||
rectY = headCursorY;
|
||||
headCursorY += itemHeight;
|
||||
}
|
||||
else if (itemIndex < m_pinnedIndex)
|
||||
{
|
||||
rectY = scrollCursorY;
|
||||
scrollCursorY += itemHeight;
|
||||
// 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。
|
||||
rectWidth = std::max(0.0f, rectWidth - scrollBarReserve);
|
||||
}
|
||||
else
|
||||
{
|
||||
rectY = tailCursorY;
|
||||
tailCursorY += itemHeight;
|
||||
}
|
||||
}
|
||||
m_itemRects[index] =
|
||||
gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight);
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||||
std::vector<gd::RectF>& rects = m_swatchRects[index];
|
||||
rects.reserve(static_cast<std::size_t>(count));
|
||||
const float swatchY = rectY + (itemHeight - kSwatchSize * m_scale) * 0.5f;
|
||||
const float startX = left + (kPadH + kItemPad) * m_scale;
|
||||
for (int swatch = 0; swatch < count; ++swatch)
|
||||
{
|
||||
const float swatchX =
|
||||
startX + static_cast<float>(swatch) * (kSwatchSize + kSwatchGap) * m_scale;
|
||||
rects.push_back(gd::RectF(swatchX, swatchY, kSwatchSize * m_scale, kSwatchSize * m_scale));
|
||||
}
|
||||
}
|
||||
|
||||
cursorY += itemHeight;
|
||||
}
|
||||
|
||||
// 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。
|
||||
if (m_scrollEnabled)
|
||||
{
|
||||
const float thickness = kScrollBarWidth * m_scale;
|
||||
m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness,
|
||||
m_scrollViewport.Y, thickness, m_scrollViewport.Height);
|
||||
}
|
||||
}
|
||||
|
||||
bool PopupMenu::ScrollRegion(int& first, int& last) const
|
||||
{
|
||||
if (!m_scrollEnabled || m_pinnedIndex < 0)
|
||||
{
|
||||
first = 0;
|
||||
last = static_cast<int>(m_items.size());
|
||||
return false;
|
||||
}
|
||||
first = m_scrollFirst;
|
||||
last = m_pinnedIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
gd::RectF PopupMenu::ScrollBarThumbRect() const
|
||||
{
|
||||
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const float viewport = m_scrollViewport.Height;
|
||||
if (viewport <= 0.0f)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight);
|
||||
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio);
|
||||
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
|
||||
const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f;
|
||||
const float thumbY =
|
||||
m_scrollBarRect.Y +
|
||||
std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight);
|
||||
return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight);
|
||||
}
|
||||
|
||||
float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const
|
||||
{
|
||||
if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f)
|
||||
{
|
||||
return m_scrollOffset;
|
||||
}
|
||||
const float viewport = m_scrollViewport.Height;
|
||||
const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport);
|
||||
if (maxScroll <= 0.0f)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
const float track = m_scrollBarRect.Height;
|
||||
const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight)));
|
||||
const float travel = std::max(0.0f, track - thumbHeight);
|
||||
if (travel <= 0.0f)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
const float relative = thumbTop - m_scrollBarRect.Y;
|
||||
const float progress = std::max(0.0f, std::min(1.0f, relative / travel));
|
||||
return progress * maxScroll;
|
||||
}
|
||||
|
||||
bool PopupMenu::EnsureItemVisible(int index)
|
||||
{
|
||||
if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex ||
|
||||
m_scrollViewport.Height <= 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||||
const float relative = rect.Y - m_scrollViewport.Y;
|
||||
float delta = 0.0f;
|
||||
if (relative < 0.0f)
|
||||
{
|
||||
delta = relative;
|
||||
}
|
||||
else if (relative + rect.Height > m_scrollViewport.Height)
|
||||
{
|
||||
delta = relative + rect.Height - m_scrollViewport.Height;
|
||||
}
|
||||
if (std::fabs(delta) < 0.5f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_scrollOffset += delta;
|
||||
// 重新排布(内部会重新夹取 `m_scrollOffset`)。
|
||||
RebuildLayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PopupMenu::OnMouseWheel(int delta)
|
||||
{
|
||||
if (!m_open || !m_scrollEnabled || delta == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const float previous = m_scrollOffset;
|
||||
// 向上滚(`delta > 0`)= 看更靠前的条目 ⇒ 偏移减小。
|
||||
m_scrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
|
||||
// 重新排布:条目矩形依赖滚动偏移,同时会夹取到合法范围。
|
||||
RebuildLayout();
|
||||
return std::fabs(m_scrollOffset - previous) > 0.01f;
|
||||
}
|
||||
|
||||
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
|
||||
{
|
||||
const std::wstring& widest =
|
||||
(item.editValue.size() > item.value.size()) ? item.editValue : item.value;
|
||||
const float natural = std::max(kEditFieldWidth * m_scale,
|
||||
UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale);
|
||||
const float withSuffix = natural + EditSuffixWidth(item);
|
||||
// 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。
|
||||
return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix;
|
||||
}
|
||||
|
||||
void PopupMenu::CloseSubmenu()
|
||||
{
|
||||
m_subOpen = false;
|
||||
m_subParentIndex = -1;
|
||||
m_subRect = gd::RectF();
|
||||
m_subItems.clear();
|
||||
m_subItemRects.clear();
|
||||
m_subHoverIndex = -1;
|
||||
m_subPressedIndex = -1;
|
||||
}
|
||||
|
||||
void PopupMenu::BuildSubLayout(int parentIndex)
|
||||
{
|
||||
CloseSubmenu();
|
||||
if (parentIndex < 0 || parentIndex >= static_cast<int>(m_items.size()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const MenuItem& parent = m_items[static_cast<std::size_t>(parentIndex)];
|
||||
if (parent.kind != MenuItemKind::SUBMENU || parent.submenu.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_subItems = parent.submenu;
|
||||
|
||||
// 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。
|
||||
float contentWidth = kMinWidth * m_scale;
|
||||
float totalHeight = kPadV * 2.0f * m_scale;
|
||||
for (const MenuItem& item : m_subItems)
|
||||
{
|
||||
totalHeight += ItemHeight(item);
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||||
static_cast<float>(count) * kSwatchSize * m_scale +
|
||||
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::HEADER)
|
||||
{
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||||
kMarkerGap * m_scale +
|
||||
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::EDIT)
|
||||
{
|
||||
const float fieldWidth = NaturalEditFieldWidth(item);
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale +
|
||||
UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale +
|
||||
fieldWidth;
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
else if (item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::SUBMENU)
|
||||
{
|
||||
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
|
||||
kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label);
|
||||
contentWidth = std::max(contentWidth, width);
|
||||
}
|
||||
}
|
||||
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
|
||||
|
||||
const float width = contentWidth;
|
||||
const float height = totalHeight;
|
||||
const gd::RectF& parentRect = m_itemRects[static_cast<std::size_t>(parentIndex)];
|
||||
const gd::RectF& bounds = m_buildBounds;
|
||||
|
||||
// 默认贴主菜单右缘并向上与父项顶端对齐;右侧空间不足时翻到左侧。
|
||||
float left = m_rect.X + m_rect.Width - kSubOverlap * m_scale;
|
||||
if (left + width > bounds.X + bounds.Width - 2.0f)
|
||||
{
|
||||
left = m_rect.X - width + kSubOverlap * m_scale;
|
||||
}
|
||||
left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f));
|
||||
|
||||
float top = parentRect.Y - kPadV * m_scale;
|
||||
top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f));
|
||||
|
||||
m_subRect = gd::RectF(left, top, width, height);
|
||||
m_subItemRects.assign(m_subItems.size(), gd::RectF());
|
||||
|
||||
float cursorY = top + kPadV * m_scale;
|
||||
for (std::size_t index = 0; index < m_subItems.size(); ++index)
|
||||
{
|
||||
const float itemHeight = ItemHeight(m_subItems[index]);
|
||||
m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
|
||||
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
|
||||
cursorY += itemHeight;
|
||||
}
|
||||
|
||||
m_subParentIndex = parentIndex;
|
||||
m_subOpen = true;
|
||||
m_subHoverIndex = -1;
|
||||
m_subPressedIndex = -1;
|
||||
}
|
||||
|
||||
int PopupMenu::SubItemAtPoint(int x, int y) const
|
||||
{
|
||||
if (!m_subOpen)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
const float fx = static_cast<float>(x);
|
||||
const float fy = static_cast<float>(y);
|
||||
for (std::size_t index = 0; index < m_subItems.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_subItems[index];
|
||||
if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!item.enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (UiPointInRect(m_subItemRects[index], fx, fy))
|
||||
{
|
||||
return static_cast<int>(index);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
float PopupMenu::EditSuffixWidth(const MenuItem& item) const
|
||||
{
|
||||
if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty())
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale;
|
||||
}
|
||||
|
||||
gd::RectF PopupMenu::EditFieldRect(int index) const
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_items.size()))
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||||
if (item.kind != MenuItemKind::EDIT)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
|
||||
// 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度,
|
||||
// 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。
|
||||
// ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限,
|
||||
// 让输入框独占整行。
|
||||
// ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`:
|
||||
// 同一面板里所有这类输入框必须一样宽、右边界对齐。
|
||||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||||
const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale);
|
||||
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale;
|
||||
float width = NaturalEditFieldWidth(item);
|
||||
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
|
||||
if (item.label.empty())
|
||||
{
|
||||
// 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。
|
||||
width = contentWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (m_editFieldWidth > 0.0f)
|
||||
{
|
||||
width = m_editFieldWidth;
|
||||
}
|
||||
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
|
||||
width = std::min(width, contentWidth * 0.75f);
|
||||
}
|
||||
|
||||
const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale;
|
||||
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width,
|
||||
rect.Y + (rect.Height - height) * 0.5f, width, height);
|
||||
}
|
||||
|
||||
gd::RectF PopupMenu::HeaderIconRect(int index) const
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_items.size()))
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||||
if (item.kind != MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
return gd::RectF();
|
||||
}
|
||||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||||
const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale));
|
||||
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size,
|
||||
rect.Y + (rect.Height - size) * 0.5f, size, size);
|
||||
}
|
||||
|
||||
bool PopupMenu::IsOnEditField(int x, int y) const
|
||||
{
|
||||
if (m_editIndex < 0)
|
||||
{ return false;
|
||||
}
|
||||
const gd::RectF rect = EditFieldRect(m_editIndex);
|
||||
return UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y));
|
||||
}
|
||||
|
||||
bool PopupMenu::BeginItemEdit(int index)
|
||||
{
|
||||
if (!m_open || index < 0 || index >= static_cast<int>(m_items.size()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||||
if (item.kind != MenuItemKind::EDIT || !item.enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。
|
||||
m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u);
|
||||
m_editIndex = index;
|
||||
m_hoverIndex = index;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PopupMenu::EndItemEdit(bool commit, MenuResult& result)
|
||||
{
|
||||
if (m_editIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int index = m_editIndex;
|
||||
const std::wstring text = m_edit.Text();
|
||||
m_editIndex = -1;
|
||||
m_edit.End();
|
||||
|
||||
if (!commit || index >= static_cast<int>(m_items.size()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
MenuItem& item = m_items[static_cast<std::size_t>(index)];
|
||||
// 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值);
|
||||
// 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。
|
||||
item.value = text;
|
||||
item.editValue = text;
|
||||
|
||||
result.valid = true;
|
||||
result.scope = m_scope;
|
||||
result.id = item.id;
|
||||
result.editCommit = true;
|
||||
result.editText = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PopupMenu::RebuildLayout()
|
||||
{
|
||||
if (!m_open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const int subParent = m_subParentIndex;
|
||||
BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp);
|
||||
// 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。
|
||||
if (subParent >= 0)
|
||||
{
|
||||
BuildSubLayout(subParent);
|
||||
}
|
||||
}
|
||||
|
||||
bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue)
|
||||
{
|
||||
for (MenuItem& item : m_items)
|
||||
{
|
||||
if (item.id == id && item.kind == MenuItemKind::EDIT)
|
||||
{
|
||||
const bool valueSame = (item.value == value);
|
||||
const bool editSame = editValue.empty() || (item.editValue == editValue);
|
||||
if (valueSame && editSame)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
item.value = value;
|
||||
if (!editValue.empty())
|
||||
{
|
||||
item.editValue = editValue;
|
||||
}
|
||||
RebuildLayout();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PopupMenu::UpdateItemLabel(int id, const std::wstring& label, bool checked, bool enabled)
|
||||
{
|
||||
for (MenuItem& item : m_items)
|
||||
{
|
||||
if (item.id == id && item.kind == MenuItemKind::NORMAL)
|
||||
{
|
||||
if (item.label == label && item.checked == checked && item.enabled == enabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
item.label = label;
|
||||
item.checked = checked;
|
||||
item.enabled = enabled;
|
||||
RebuildLayout();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -0,0 +1,400 @@
|
||||
// PopupMenu 实现分片 —— 绘制(主菜单 + 二级子菜单)。
|
||||
//
|
||||
// 本文件只包含 PopupMenu 的成员函数定义(从 PopupMenu.cpp 机械搬移,行为不变)。
|
||||
//
|
||||
// ⚠️ 菜单是叠在最上层的手绘浮层:文字走 `UiText`(GDI,不认 GDI+ 裁剪区),
|
||||
// 因此所有文字必须由排版阶段算好的矩形约束,不能指望裁剪。
|
||||
|
||||
#include "PopupMenu.hpp"
|
||||
|
||||
#include "PopupMenuInternal.hpp"
|
||||
#include "UiControls.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
void PopupMenu::Paint(gd::Graphics& g)
|
||||
{
|
||||
if (!m_open || m_theme == nullptr || m_fonts == nullptr || m_rect.Width <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 投影:用两层递减透明度的圆角矩形模拟,避免引入系统阴影 API。
|
||||
UiFillRoundRect(g, gd::RectF(m_rect.X + 1.0f * m_scale, m_rect.Y + 2.0f * m_scale, m_rect.Width,
|
||||
m_rect.Height),
|
||||
kCorner * m_scale, 0x14000000u);
|
||||
UiFillRoundRect(g, gd::RectF(m_rect.X + 2.0f * m_scale, m_rect.Y + 4.0f * m_scale, m_rect.Width,
|
||||
m_rect.Height),
|
||||
kCorner * m_scale, 0x10000000u);
|
||||
|
||||
UiFillRoundRect(g, m_rect, kCorner * m_scale, m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_rect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
|
||||
|
||||
// 条目文字统一走本 lambda:可滚动区里的条目必须夹到**滚动视口** ——
|
||||
// `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**,不显式裁剪就会画到固定区上。
|
||||
//(GDI+ 图元 —— 行底色 / 圆角 / 分隔线 / 对勾 / 子菜单三角 —— 受 `SetClip` 约束,无需额外处理。)
|
||||
const auto itemText = [this, &g](const std::wstring& text, HFONT font, const gd::RectF& rect,
|
||||
std::uint32_t argb, TextAlign align, bool clipped)
|
||||
{
|
||||
if (clipped)
|
||||
{
|
||||
UiTextClipped(g, text, font, rect, argb, align, m_scrollViewport);
|
||||
}
|
||||
else
|
||||
{
|
||||
UiText(g, text, font, rect, argb, align);
|
||||
}
|
||||
};
|
||||
|
||||
// 可滚动菜单的条目分三段:
|
||||
// * `[0, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**;
|
||||
// * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条);
|
||||
// * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。
|
||||
const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0;
|
||||
const int scrollFirst = scrollable ? m_scrollFirst : static_cast<int>(m_items.size());
|
||||
const int scrollLast = scrollable ? m_pinnedIndex : static_cast<int>(m_items.size());
|
||||
int clipState = 0;
|
||||
bool clipActive = false;
|
||||
|
||||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_items[index];
|
||||
const gd::RectF& rect = m_itemRects[index];
|
||||
const int itemIndex = static_cast<int>(index);
|
||||
// 进入/离开滚动区:按需开启/撤销 GDI+ 裁剪(固定区永远完整可见)。
|
||||
if (scrollable && itemIndex == scrollFirst && !clipActive)
|
||||
{
|
||||
clipState = g.Save();
|
||||
g.SetClip(m_scrollViewport, gd::CombineModeIntersect);
|
||||
clipActive = true;
|
||||
}
|
||||
else if (scrollable && itemIndex == scrollLast && clipActive)
|
||||
{
|
||||
g.Restore(clipState);
|
||||
clipActive = false;
|
||||
}
|
||||
const bool inScrollRegion = itemIndex >= scrollFirst && itemIndex < scrollLast;
|
||||
// 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。
|
||||
if (scrollable && inScrollRegion &&
|
||||
(rect.Y + rect.Height <= m_scrollViewport.Y ||
|
||||
rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const bool clipped = scrollable && inScrollRegion;
|
||||
|
||||
if (item.kind == MenuItemKind::SEPARATOR)
|
||||
{
|
||||
const float lineY = rect.Y + rect.Height * 0.5f;
|
||||
UiDrawLine(g, rect.X + kItemPad * m_scale, lineY,
|
||||
rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::HEADER)
|
||||
{
|
||||
// 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。
|
||||
itemText(item.label, m_fonts->uiSmallBold,
|
||||
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT, clipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::HEADER_ACTION)
|
||||
{
|
||||
// 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。
|
||||
const gd::RectF icon = HeaderIconRect(static_cast<int>(index));
|
||||
const float labelWidth =
|
||||
std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale);
|
||||
itemText(UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold,
|
||||
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT, clipped);
|
||||
if (icon.Width > 0.0f)
|
||||
{
|
||||
const bool iconHovered = (static_cast<int>(index) == m_hoverIconIndex);
|
||||
if (iconHovered)
|
||||
{
|
||||
UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale,
|
||||
icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale),
|
||||
4.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
|
||||
}
|
||||
PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool hovered = (static_cast<int>(index) == m_hoverIndex) &&
|
||||
(item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0);
|
||||
|
||||
if (item.kind == MenuItemKind::EDIT)
|
||||
{
|
||||
// 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式,
|
||||
// 菜单始终不关闭,数值就地修改)。
|
||||
if (hovered && item.enabled && static_cast<int>(index) != m_editIndex)
|
||||
{
|
||||
UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22));
|
||||
}
|
||||
|
||||
const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted;
|
||||
// 说明文字与普通项使用同一文字列(kItemPad),保证菜单内所有文本左对齐。
|
||||
const float labelLeft = rect.X + kItemPad * m_scale;
|
||||
const gd::RectF fieldRect = EditFieldRect(static_cast<int>(index));
|
||||
const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale);
|
||||
itemText(UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
|
||||
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT,
|
||||
clipped);
|
||||
|
||||
const float suffix = EditSuffixWidth(item);
|
||||
if (static_cast<int>(index) == m_editIndex)
|
||||
{
|
||||
// 编辑态:输入框底板覆盖整行,正文区向右让出后缀宽度,
|
||||
// 后缀(单位提示)靠右对齐画在它右侧。
|
||||
// ⚠️ 必须用 `UiDrawEditFieldContent`(只画内容)——`UiDrawEditField`
|
||||
// 会把底板/描边按传入矩形绘制,那样带后缀时外框会短一截。
|
||||
const gd::RectF textRect =
|
||||
(suffix > 0.0f)
|
||||
? gd::RectF(fieldRect.X, fieldRect.Y, std::max(12.0f, fieldRect.Width - suffix),
|
||||
fieldRect.Height)
|
||||
: fieldRect;
|
||||
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg);
|
||||
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f);
|
||||
UiDrawEditFieldContent(g, textRect, m_edit, m_fonts->uiSmall, m_scale);
|
||||
if (suffix > 0.0f)
|
||||
{
|
||||
const float suffixLeft = textRect.X + textRect.Width;
|
||||
itemText(item.editSuffix, m_fonts->uiSmall,
|
||||
gd::RectF(suffixLeft, fieldRect.Y,
|
||||
std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft -
|
||||
4.0f * m_scale),
|
||||
fieldRect.Height),
|
||||
m_theme->textMuted, TextAlign::RIGHT, clipped);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed);
|
||||
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f);
|
||||
const std::wstring text =
|
||||
UiEllipsize(item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale);
|
||||
itemText(text, m_fonts->uiSmall,
|
||||
gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y,
|
||||
std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height),
|
||||
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT, clipped);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
const std::vector<gd::RectF>& rects = m_swatchRects[index];
|
||||
for (std::size_t swatch = 0; swatch < rects.size(); ++swatch)
|
||||
{
|
||||
const gd::RectF& swatchRect = rects[swatch];
|
||||
UiFillRoundRect(g, swatchRect, 4.0f * m_scale, item.swatches[swatch]);
|
||||
|
||||
const bool swatchHover = (static_cast<int>(index) == m_hoverIndex) &&
|
||||
(static_cast<int>(swatch) == m_hoverSwatch);
|
||||
if (static_cast<int>(swatch) == item.checkedSwatch || swatchHover)
|
||||
{
|
||||
const gd::RectF ring(swatchRect.X - 2.0f * m_scale, swatchRect.Y - 2.0f * m_scale,
|
||||
swatchRect.Width + 4.0f * m_scale,
|
||||
swatchRect.Height + 4.0f * m_scale);
|
||||
UiDrawRoundRect(g, ring, 5.0f * m_scale,
|
||||
(static_cast<int>(swatch) == item.checkedSwatch) ? m_theme->textStrong
|
||||
: m_theme->borderStrong,
|
||||
(static_cast<int>(swatch) == item.checkedSwatch) ? 1.8f : 1.0f);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通项。
|
||||
std::uint32_t background = 0;
|
||||
if (hovered && item.enabled)
|
||||
{
|
||||
background = m_theme->accent;
|
||||
}
|
||||
else if (static_cast<int>(index) == m_pressedIndex)
|
||||
{
|
||||
background = m_theme->pressed;
|
||||
}
|
||||
if (background != 0)
|
||||
{
|
||||
UiFillRoundRect(g, rect, 4.0f * m_scale, background);
|
||||
}
|
||||
|
||||
const std::uint32_t textColor = !item.enabled ? m_theme->textMuted
|
||||
: ((hovered && item.enabled) ? m_theme->textOnAccent
|
||||
: m_theme->text);
|
||||
|
||||
if (item.checked)
|
||||
{
|
||||
// 单选标记:对勾固定画在条目**右侧**的标记列内,左侧只留统一的小边距,
|
||||
// 这样菜单文本左边距不会因为“可能存在的对勾”而被整体推右。
|
||||
const float markerRight = rect.X + rect.Width - kItemPad * m_scale;
|
||||
const float cx = markerRight - kMarkerColumn * m_scale * 0.5f;
|
||||
const float cy = rect.Y + rect.Height * 0.5f;
|
||||
const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent;
|
||||
UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale,
|
||||
markerColor, 1.8f * m_scale);
|
||||
UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale,
|
||||
cy - 4.0f * m_scale, markerColor, 1.8f * m_scale);
|
||||
}
|
||||
|
||||
// 带子菜单的项:在右侧标记列画一个指向右的三角,提示可以继续展开。
|
||||
if (item.kind == MenuItemKind::SUBMENU)
|
||||
{
|
||||
const float markerRight = rect.X + rect.Width - kItemPad * m_scale;
|
||||
const float cx = markerRight - kMarkerColumn * m_scale * 0.5f;
|
||||
const float cy = rect.Y + rect.Height * 0.5f;
|
||||
const float size = kSubArrowSize * m_scale;
|
||||
gd::PointF arrow[3];
|
||||
arrow[0] = gd::PointF(cx - size * 0.5f, cy - size);
|
||||
arrow[1] = gd::PointF(cx - size * 0.5f, cy + size);
|
||||
arrow[2] = gd::PointF(cx + size * 0.7f, cy);
|
||||
gd::SolidBrush brush(ToGdiColor(textColor));
|
||||
g.FillPolygon(&brush, arrow, 3);
|
||||
}
|
||||
|
||||
const float textLeft = rect.X + kItemPad * m_scale;
|
||||
const float textWidth =
|
||||
rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale;
|
||||
itemText(UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
|
||||
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
|
||||
TextAlign::LEFT, clipped);
|
||||
}
|
||||
|
||||
// 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。
|
||||
if (clipActive)
|
||||
{
|
||||
g.Restore(clipState);
|
||||
clipActive = false;
|
||||
}
|
||||
|
||||
// 滚动条:轨道 + 滑块(画在条目之上,仅在可滚动菜单里出现)。
|
||||
// 位置限制在可滚动条目区旁边,因此不会盖住顶部标题与底部「新建网格」。
|
||||
if (scrollable && m_scrollBarRect.Width > 0.0f)
|
||||
{
|
||||
const float radius = kScrollBarWidth * m_scale * 0.5f;
|
||||
UiFillRoundRect(g, m_scrollBarRect, radius, m_theme->gridWeak);
|
||||
const gd::RectF thumb = ScrollBarThumbRect();
|
||||
if (thumb.Width > 0.0f)
|
||||
{
|
||||
UiFillRoundRect(g, thumb, radius, m_theme->borderStrong);
|
||||
}
|
||||
}
|
||||
|
||||
// 二级子菜单最后绘制,保证覆盖在主菜单之上。
|
||||
PaintSubmenu(g);
|
||||
}
|
||||
|
||||
void PopupMenu::PaintSubmenu(gd::Graphics& g)
|
||||
{
|
||||
if (!m_subOpen || m_subRect.Width <= 0.0f || m_subRect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UiFillRoundRect(g, gd::RectF(m_subRect.X + 1.0f * m_scale, m_subRect.Y + 2.0f * m_scale,
|
||||
m_subRect.Width, m_subRect.Height),
|
||||
kCorner * m_scale, 0x14000000u);
|
||||
UiFillRoundRect(g, gd::RectF(m_subRect.X + 2.0f * m_scale, m_subRect.Y + 4.0f * m_scale,
|
||||
m_subRect.Width, m_subRect.Height),
|
||||
kCorner * m_scale, 0x10000000u);
|
||||
|
||||
UiFillRoundRect(g, m_subRect, kCorner * m_scale, m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_subRect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
|
||||
|
||||
for (std::size_t index = 0; index < m_subItems.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_subItems[index];
|
||||
const gd::RectF& rect = m_subItemRects[index];
|
||||
|
||||
if (item.kind == MenuItemKind::SEPARATOR)
|
||||
{
|
||||
const float lineY = rect.Y + rect.Height * 0.5f;
|
||||
UiDrawLine(g, rect.X + kItemPad * m_scale, lineY,
|
||||
rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::HEADER)
|
||||
{
|
||||
UiText(g, item.label, m_fonts->uiSmallBold,
|
||||
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool hovered = (static_cast<int>(index) == m_subHoverIndex) && item.enabled;
|
||||
|
||||
if (item.kind == MenuItemKind::EDIT)
|
||||
{
|
||||
// 子菜单中的编辑项:与主菜单一样就地编辑。
|
||||
if (hovered && static_cast<int>(index) != m_editIndex)
|
||||
{
|
||||
UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22));
|
||||
}
|
||||
const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted;
|
||||
const float labelLeft = rect.X + kItemPad * m_scale;
|
||||
const gd::RectF fieldRect = EditFieldRect(static_cast<int>(index));
|
||||
const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale);
|
||||
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
|
||||
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT);
|
||||
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed);
|
||||
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f);
|
||||
const std::wstring text =
|
||||
UiEllipsize(item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale);
|
||||
UiText(g, text, m_fonts->uiSmall,
|
||||
gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y,
|
||||
std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height),
|
||||
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
std::uint32_t background = 0;
|
||||
if (hovered)
|
||||
{
|
||||
background = m_theme->accent;
|
||||
}
|
||||
else if (static_cast<int>(index) == m_subPressedIndex)
|
||||
{
|
||||
background = m_theme->pressed;
|
||||
}
|
||||
if (background != 0)
|
||||
{
|
||||
UiFillRoundRect(g, rect, 4.0f * m_scale, background);
|
||||
}
|
||||
|
||||
const std::uint32_t textColor = !item.enabled ? m_theme->textMuted
|
||||
: (hovered ? m_theme->textOnAccent
|
||||
: m_theme->text);
|
||||
|
||||
// 选中标记(对勾):与主菜单一致画在右侧标记列内。
|
||||
if (item.checked)
|
||||
{
|
||||
const float markerRight = rect.X + rect.Width - kItemPad * m_scale;
|
||||
const float cx = markerRight - kMarkerColumn * m_scale * 0.5f;
|
||||
const float cy = rect.Y + rect.Height * 0.5f;
|
||||
const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent;
|
||||
UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale,
|
||||
markerColor, 1.8f * m_scale);
|
||||
UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale,
|
||||
cy - 4.0f * m_scale, markerColor, 1.8f * m_scale);
|
||||
}
|
||||
|
||||
const float textLeft = rect.X + kItemPad * m_scale;
|
||||
const float textWidth =
|
||||
rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale;
|
||||
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
|
||||
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
|
||||
TextAlign::LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "PaintUtil.hpp"
|
||||
#include "PopupMenu.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "UiComponent.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
@@ -51,7 +52,7 @@ namespace jdi
|
||||
* 因此不同标签页的信号勾选与样式互不影响。所有弹出菜单与拾色器均使用主题化自绘
|
||||
* 浮动面板,不出现系统默认菜单样式。
|
||||
*/
|
||||
class SignalPanel
|
||||
class SignalPanel : public UiComponent
|
||||
{
|
||||
public:
|
||||
SignalPanel();
|
||||
@@ -113,19 +114,22 @@ namespace jdi
|
||||
* @brief 设置 DPI 缩放比例。
|
||||
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
|
||||
*/
|
||||
void SetScale(float scale);
|
||||
void SetScale(float scale) override;
|
||||
|
||||
/**
|
||||
* @brief 按给定区域重新排布面板内容。
|
||||
* @param [IN] area 面板可用区域。
|
||||
*/
|
||||
void Layout(const gd::RectF& area);
|
||||
void Layout(const gd::RectF& area) override;
|
||||
|
||||
/** @brief 面板占用的整块区域(与 `Layout` 同源)。 */
|
||||
gd::RectF Area() const override { return m_area; }
|
||||
|
||||
/**
|
||||
* @brief 绘制面板。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
*/
|
||||
void Paint(gd::Graphics& g);
|
||||
void Paint(gd::Graphics& g) override;
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标移动。
|
||||
@@ -200,7 +204,7 @@ namespace jdi
|
||||
* @brief 处理鼠标离开面板。
|
||||
* @return 视觉状态发生变化返回 true。
|
||||
*/
|
||||
bool OnMouseLeave();
|
||||
bool OnMouseLeave() override;
|
||||
|
||||
/**
|
||||
* @brief 处理弹出菜单的选择结果。
|
||||
@@ -213,7 +217,7 @@ namespace jdi
|
||||
* @brief 判断面板是否处于内联文本输入状态。
|
||||
* @return 正在输入返回 true。
|
||||
*/
|
||||
bool IsEditing() const { return m_editField != StyleEditField::NONE || m_filterEditing; }
|
||||
bool IsEditing() const override { return m_editField != StyleEditField::NONE || m_filterEditing; }
|
||||
|
||||
/**
|
||||
* @brief 判断坐标是否落在列表上方的「筛选信号」输入框内。
|
||||
@@ -348,7 +352,7 @@ namespace jdi
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 落在区域内返回 true。
|
||||
*/
|
||||
bool HitTest(int x, int y) const;
|
||||
bool HitTest(int x, int y) const override;
|
||||
|
||||
/**
|
||||
* @brief 查询该位置应使用的鼠标光标(✕ 类按钮使用手型)。
|
||||
@@ -357,7 +361,7 @@ namespace jdi
|
||||
* @return 需要特殊光标时返回光标句柄,否则返回 nullptr。
|
||||
* @note 需要按需重建列表行缓存(查 ✕ 的命中),因此不是 const。
|
||||
*/
|
||||
HCURSOR QueryCursor(int x, int y);
|
||||
HCURSOR QueryCursor(int x, int y) override;
|
||||
|
||||
/**
|
||||
* @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
// SignalPanel 实现分片 —— 内联编辑(筛选框、组名/信号名/数值输入、样式按钮)。
|
||||
//
|
||||
// 本文件只包含 SignalPanel 的成员函数定义(从 SignalPanelInteraction.cpp 机械搬移,行为不变)。
|
||||
// 放进本分片的判据:**与"把用户输入落到数据层"有关**的代码(BeginEdit/EndEdit/OnEdit*、
|
||||
// 应用颜色与透明度、筛选框)。交互命中与手势派发仍在 SignalPanelInteraction.cpp。
|
||||
|
||||
#include "SignalPanelInternal.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
bool SignalPanel::IsOnFilterBox(int x, int y) const
|
||||
{
|
||||
return UiPointInRect(m_filterRect, static_cast<float>(x), static_cast<float>(y));
|
||||
}
|
||||
|
||||
void SignalPanel::ClearFilter()
|
||||
{
|
||||
if (m_filterText.empty() && !m_filterEditing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_filterText.clear();
|
||||
if (m_filterEditing)
|
||||
{
|
||||
// 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。
|
||||
m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u);
|
||||
}
|
||||
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
|
||||
m_filterCollapsed.clear();
|
||||
m_groupAnims.clear();
|
||||
m_rowShift.clear();
|
||||
// 清空后回到完整列表:重建行集合、回到顶部。
|
||||
m_rowsDirty = true;
|
||||
m_scrollOffset = 0.0f;
|
||||
m_layoutDirty = true;
|
||||
}
|
||||
|
||||
HCURSOR SignalPanel::QueryCursor(int x, int y)
|
||||
{
|
||||
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
|
||||
const gd::RectF clearRect = FilterClearRect();
|
||||
if (clearRect.Width > 0.0f &&
|
||||
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
|
||||
{
|
||||
return LoadCursorW(nullptr, IDC_HAND);
|
||||
}
|
||||
|
||||
// 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。
|
||||
RowPart part = RowPart::NONE;
|
||||
if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE))
|
||||
{
|
||||
return LoadCursorW(nullptr, IDC_HAND);
|
||||
}
|
||||
|
||||
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
|
||||
const float fx = static_cast<float>(x);
|
||||
const float fy = static_cast<float>(y);
|
||||
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
|
||||
UpdateTargetRowGeometry();
|
||||
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
|
||||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
|
||||
{
|
||||
return LoadCursorW(nullptr, IDC_HAND);
|
||||
}
|
||||
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
|
||||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
|
||||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
|
||||
UiPointInRect(m_opacityValueRect, fx, fy))
|
||||
{
|
||||
return LoadCursorW(nullptr, IDC_HAND);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void SignalPanel::BeginFilterEdit()
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_filterEditing = true;
|
||||
// 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。
|
||||
m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u);
|
||||
}
|
||||
|
||||
bool SignalPanel::EndFilterEdit()
|
||||
{
|
||||
if (!m_filterEditing)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。
|
||||
const std::wstring finalText = Trim(m_filterEdit.Text());
|
||||
m_filterEditing = false;
|
||||
m_filterEdit.End();
|
||||
// ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成
|
||||
// “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。
|
||||
ApplyFilterText(finalText);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::ApplyWidthFromX(float x)
|
||||
{
|
||||
// ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`,
|
||||
// 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。
|
||||
if (m_dataSetWritable == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double ratio = UiSliderRatioFromX(m_widthTrackRect, x);
|
||||
float value = static_cast<float>(kWidthMin + ratio * (kWidthMax - kWidthMin));
|
||||
value = std::round(value * 10.0f) / 10.0f;
|
||||
value = ClampFloat(value, kWidthMin, kWidthMax);
|
||||
if (std::fabs(value - style->lineWidth) < 0.001f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
style->lineWidth = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::BeginEdit(StyleEditField field, int row)
|
||||
{
|
||||
// `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。
|
||||
if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (m_editField == field && m_editRow == row)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring initial;
|
||||
switch (field)
|
||||
{
|
||||
case StyleEditField::WIDTH:
|
||||
{
|
||||
const SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
initial = FormatNumber(style->lineWidth, 1);
|
||||
break;
|
||||
}
|
||||
case StyleEditField::OPACITY:
|
||||
{
|
||||
const SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
initial = std::to_wstring(static_cast<int>(style->opacity * 100.0f + 0.5f));
|
||||
break;
|
||||
}
|
||||
case StyleEditField::SIGNAL_NAME:
|
||||
{
|
||||
// `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。
|
||||
if (row < 0)
|
||||
{
|
||||
const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr;
|
||||
if (signal == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
initial = signal->name;
|
||||
break;
|
||||
}
|
||||
if (row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex});
|
||||
if (signal == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
initial = signal->name;
|
||||
break;
|
||||
}
|
||||
case StyleEditField::GROUP_TITLE:
|
||||
{
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast<std::size_t>(row)].groupIndex);
|
||||
if (group == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
initial = group->name;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
m_editField = field;
|
||||
m_editRow = row;
|
||||
|
||||
if (field == StyleEditField::SIGNAL_NAME && row < 0)
|
||||
{
|
||||
// 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。
|
||||
UpdateTargetRowGeometry();
|
||||
}
|
||||
|
||||
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
|
||||
const bool nameField =
|
||||
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE);
|
||||
// 不限长度:名称与数值都由各自的提交逻辑处理超长内容。
|
||||
m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::EndEdit(bool commit)
|
||||
{
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const StyleEditField field = m_editField;
|
||||
const int row = m_editRow;
|
||||
const std::wstring text = m_edit.Text();
|
||||
|
||||
m_editField = StyleEditField::NONE;
|
||||
m_editRow = -1;
|
||||
m_edit.End();
|
||||
|
||||
if (!commit || m_tab == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::wstring trimmed = Trim(text);
|
||||
switch (field)
|
||||
{
|
||||
case StyleEditField::WIDTH:
|
||||
{
|
||||
if (trimmed.empty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
wchar_t* stop = nullptr;
|
||||
const double parsed = std::wcstod(trimmed.c_str(), &stop);
|
||||
if (stop == trimmed.c_str() || parsed != parsed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
const float value = ClampFloat(static_cast<float>(parsed), kWidthMin, kWidthMax);
|
||||
style->lineWidth = std::round(value * 10.0f) / 10.0f;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StyleEditField::OPACITY:
|
||||
{
|
||||
if (trimmed.empty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
wchar_t* stop = nullptr;
|
||||
const double parsed = std::wcstod(trimmed.c_str(), &stop);
|
||||
if (stop == trimmed.c_str() || parsed != parsed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
style->opacity = ClampFloat(static_cast<float>(parsed) / 100.0f, 0.0f, 1.0f);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case StyleEditField::SIGNAL_NAME:
|
||||
{
|
||||
if (m_dataSetWritable == nullptr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (row < 0)
|
||||
{
|
||||
// 样式区「目标」行:重命名当前选中的(目标)信号。
|
||||
const SignalRef& target = SelectedRef();
|
||||
if (target.IsValid())
|
||||
{
|
||||
m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed);
|
||||
}
|
||||
// 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。
|
||||
m_targetGeomValid = false;
|
||||
break;
|
||||
}
|
||||
if (row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
break;
|
||||
}
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed);
|
||||
break;
|
||||
}
|
||||
case StyleEditField::GROUP_TITLE:
|
||||
{
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
m_dataSetWritable->RenameGroup(m_rows[static_cast<std::size_t>(row)].groupIndex, trimmed);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::OnEditChar(wchar_t character)
|
||||
{
|
||||
// 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。
|
||||
if (m_filterEditing)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case 0x03: // Ctrl+C
|
||||
m_filterEdit.CopyToClipboard(m_host);
|
||||
return false;
|
||||
case 0x18: // Ctrl+X
|
||||
m_filterEdit.CutToClipboard(m_host);
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
case 0x16: // Ctrl+V
|
||||
m_filterEdit.PasteFromClipboard(m_host);
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
case 0x01: // Ctrl+A
|
||||
m_filterEdit.SelectAll();
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (character == L'\r' || character == L'\n')
|
||||
{
|
||||
return EndFilterEdit();
|
||||
}
|
||||
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。
|
||||
if (character < 0x20 || character == 0x7F)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!m_filterEdit.Insert(character))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
|
||||
switch (character)
|
||||
{
|
||||
case 0x03: // Ctrl+C
|
||||
m_edit.CopyToClipboard(m_host);
|
||||
return false;
|
||||
case 0x18: // Ctrl+X
|
||||
return m_edit.CutToClipboard(m_host);
|
||||
case 0x16: // Ctrl+V
|
||||
return m_edit.PasteFromClipboard(m_host);
|
||||
case 0x01: // Ctrl+A
|
||||
m_edit.SelectAll();
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (character == L'\r' || character == L'\n')
|
||||
{
|
||||
return EndEdit(true);
|
||||
}
|
||||
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符,
|
||||
// 否则同一次按键会被处理两次(一次删除两个字符)。
|
||||
if (character < 0x20 || character == 0x7F)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。
|
||||
if (m_editField == StyleEditField::OPACITY && character == L'%')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return m_edit.Insert(character);
|
||||
}
|
||||
|
||||
bool SignalPanel::OnEditKey(WPARAM key)
|
||||
{
|
||||
// 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。
|
||||
if (m_filterEditing)
|
||||
{
|
||||
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
switch (key)
|
||||
{
|
||||
case VK_RETURN:
|
||||
return EndFilterEdit();
|
||||
case VK_ESCAPE:
|
||||
{
|
||||
const bool changed = m_filterEdit.Text() != m_filterText;
|
||||
EndFilterEdit();
|
||||
return changed;
|
||||
}
|
||||
case VK_BACK:
|
||||
{
|
||||
if (!m_filterEdit.Backspace())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
}
|
||||
case VK_DELETE:
|
||||
{
|
||||
if (!m_filterEdit.DeleteForward())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ApplyFilterText(m_filterEdit.Text());
|
||||
return true;
|
||||
}
|
||||
case VK_LEFT:
|
||||
return m_filterEdit.MoveCaret(-1, shift);
|
||||
case VK_RIGHT:
|
||||
return m_filterEdit.MoveCaret(1, shift);
|
||||
case VK_HOME:
|
||||
return m_filterEdit.MoveHome(shift);
|
||||
case VK_END:
|
||||
return m_filterEdit.MoveEnd(shift);
|
||||
case 'A':
|
||||
if (control)
|
||||
{
|
||||
m_filterEdit.SelectAll();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case VK_RETURN:
|
||||
return EndEdit(true);
|
||||
case VK_ESCAPE:
|
||||
return EndEdit(false);
|
||||
case VK_BACK:
|
||||
return m_edit.Backspace();
|
||||
case VK_DELETE:
|
||||
return m_edit.DeleteForward();
|
||||
case VK_LEFT:
|
||||
return m_edit.MoveCaret(-1, shift);
|
||||
case VK_RIGHT:
|
||||
return m_edit.MoveCaret(1, shift);
|
||||
case VK_HOME:
|
||||
return m_edit.MoveHome(shift);
|
||||
case VK_END:
|
||||
return m_edit.MoveEnd(shift);
|
||||
case 'A':
|
||||
if (control)
|
||||
{
|
||||
m_edit.SelectAll();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::CollapseEditSelectionToEnd()
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return m_filterEdit.DeselectToEnd();
|
||||
}
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return m_edit.DeselectToEnd();
|
||||
}
|
||||
|
||||
bool SignalPanel::OnEditMouseDown(int x, int y)
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
// 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理,
|
||||
// 否则点击会被当成"把光标移到此处"而清空不了内容。
|
||||
// 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。
|
||||
const gd::RectF clearRect = FilterClearRect();
|
||||
if (clearRect.Width > 0.0f &&
|
||||
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
|
||||
{
|
||||
m_pendingFilterClear = true;
|
||||
return true;
|
||||
}
|
||||
return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y);
|
||||
}
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 3px 的行内边距)
|
||||
// 都必须当作“文本操作”**,不能穿透成行点击。否则:容器行高 > 输入框高度,
|
||||
// 鼠标起点只要落在内边距上就会走 `OnLButtonDown` 的分组分支 ⇒ **分组被展开/收起**
|
||||
//(用户 2026-10-19 报告:“框选编辑文本时组会展开、收起”)。
|
||||
// 横向仍限定在名称区域内:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。
|
||||
const gd::RectF field = EditFieldRect();
|
||||
if (field.Width > 0.0f)
|
||||
{
|
||||
const gd::RectF absorb = NameEditAbsorbRect(field);
|
||||
if (UiPointInRect(absorb, static_cast<float>(x), static_cast<float>(y)) &&
|
||||
!UiPointInRect(field, static_cast<float>(x), static_cast<float>(y)))
|
||||
{
|
||||
// 把落点夹回输入框,保证插入符换算与绘制使用同一套几何
|
||||
//(`UiEditFieldMouseDown` 自己会再算一次文本起点)。
|
||||
const int clampedX = static_cast<int>(std::lround(
|
||||
std::max(field.X + 1.0f, std::min(static_cast<float>(x), field.X + field.Width - 2.0f))));
|
||||
const int clampedY = static_cast<int>(std::lround(
|
||||
std::max(field.Y + 1.0f, std::min(static_cast<float>(y), field.Y + field.Height - 2.0f))));
|
||||
return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, clampedX, clampedY);
|
||||
}
|
||||
}
|
||||
return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, x, y);
|
||||
}
|
||||
|
||||
bool SignalPanel::OnEditMouseMove(int x)
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x);
|
||||
}
|
||||
if (m_editField == StyleEditField::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return UiEditFieldMouseMove(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x);
|
||||
}
|
||||
|
||||
bool SignalPanel::OnEditMouseUp(int x, int y)
|
||||
{
|
||||
if (m_filterEditing)
|
||||
{
|
||||
// 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位),
|
||||
// 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算:
|
||||
// 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。
|
||||
if (m_pendingFilterClear)
|
||||
{
|
||||
m_pendingFilterClear = false;
|
||||
const gd::RectF clearRect = FilterClearRect();
|
||||
if (clearRect.Width > 0.0f &&
|
||||
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
|
||||
{
|
||||
ClearFilter();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!m_filterEdit.Dragging())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_filterEdit.EndDrag();
|
||||
return true;
|
||||
}
|
||||
if (!m_edit.Dragging())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_edit.EndDrag();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SignalPanel::SelectSignal(const SignalRef& ref)
|
||||
{
|
||||
if (m_dataSetWritable != nullptr)
|
||||
{
|
||||
m_dataSetWritable->SetSelected(ref);
|
||||
}
|
||||
}
|
||||
|
||||
bool SignalPanel::ApplyColor(std::uint32_t color)
|
||||
{
|
||||
// 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。
|
||||
if (m_dataSetWritable == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。
|
||||
const bool changed = (style->color != color) || (style->customColor != color);
|
||||
style->color = color;
|
||||
style->customColor = color;
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool SignalPanel::ApplyOpacityFromX(float x)
|
||||
{
|
||||
// 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。
|
||||
if (m_dataSetWritable == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const float value = static_cast<float>(UiSliderRatioFromX(m_opacityTrackRect, x));
|
||||
if (std::fabs(value - style->opacity) < 0.005f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
style->opacity = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,585 @@
|
||||
// SignalPanel 实现分片 —— 弹出菜单(系数/偏移、镜像、信号/分组/网格右键菜单、命令回执)。
|
||||
//
|
||||
// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。
|
||||
// ⚠️ 菜单是"打开即模态"的浮层:`CloseTransientUi` 必须在点击别处时收起它,
|
||||
// 否则菜单会一直挂在那里吃掉后续点击。
|
||||
|
||||
#include "SignalPanelInternal.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
bool SignalPanel::OpenGainMenu()
|
||||
{
|
||||
if (m_menu == nullptr || m_tab == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 二级面板:两张“标题(带还原图标)+ 输入框”的组合:
|
||||
// 1. 「系数调整」:一整行系数输入框;
|
||||
// 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
|
||||
//(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。)
|
||||
std::vector<MenuItem> items;
|
||||
|
||||
MenuItem gainHeader;
|
||||
gainHeader.kind = MenuItemKind::HEADER_ACTION;
|
||||
gainHeader.label = L"系数调整";
|
||||
gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1
|
||||
items.push_back(gainHeader);
|
||||
|
||||
// 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。
|
||||
const std::wstring gainText = FormatGainText(style->gain);
|
||||
const std::wstring gainEditText = FormatNumberText(static_cast<double>(style->gain));
|
||||
MenuItem gainEdit;
|
||||
gainEdit.kind = MenuItemKind::EDIT;
|
||||
gainEdit.label.clear(); // 不显示说明文字:输入框独占整行
|
||||
gainEdit.id = CMD_GAIN_EDIT;
|
||||
gainEdit.value = gainText;
|
||||
gainEdit.editValue = gainEditText;
|
||||
gainEdit.filter = EditFilter::NUMBER;
|
||||
items.push_back(gainEdit);
|
||||
|
||||
MenuItem offsetHeader;
|
||||
offsetHeader.kind = MenuItemKind::HEADER_ACTION;
|
||||
offsetHeader.label = L"偏移调整";
|
||||
offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值
|
||||
items.push_back(offsetHeader);
|
||||
|
||||
// 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。
|
||||
MenuItem offsetY;
|
||||
offsetY.kind = MenuItemKind::EDIT;
|
||||
offsetY.label = L"Y轴";
|
||||
offsetY.id = CMD_OFFSET_Y;
|
||||
offsetY.value = FormatSignedNumber(static_cast<double>(style->offsetY));
|
||||
offsetY.editValue = FormatNumberText(static_cast<double>(style->offsetY));
|
||||
offsetY.filter = EditFilter::NUMBER;
|
||||
items.push_back(offsetY);
|
||||
|
||||
// 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。
|
||||
// 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求);
|
||||
// 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。
|
||||
MenuItem offsetTime;
|
||||
offsetTime.kind = MenuItemKind::EDIT;
|
||||
offsetTime.label = L"时间轴";
|
||||
offsetTime.id = CMD_OFFSET_TIME;
|
||||
offsetTime.value = FormatOffsetTimeText(style->offsetTime);
|
||||
offsetTime.editValue = FormatNumberText(style->offsetTime);
|
||||
offsetTime.editSuffix = L"s";
|
||||
offsetTime.filter = EditFilter::NUMBER;
|
||||
items.push_back(offsetTime);
|
||||
|
||||
// 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角,
|
||||
// 并再往上让出 `kAdjustMenuGap`(用户明确要求)。
|
||||
//(旧实现在按钮下沿向右下展开,面板会盖住下方内容。)
|
||||
const gd::RectF& button = m_adjustRects[kAdjustGain];
|
||||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||||
const int anchorY =
|
||||
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
|
||||
true);
|
||||
// ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE →
|
||||
// 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。
|
||||
ClearStyleHover();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::OpenMirrorMenu()
|
||||
{
|
||||
if (m_menu == nullptr || m_tab == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。
|
||||
std::vector<MenuItem> items;
|
||||
|
||||
MenuItem horizontal;
|
||||
horizontal.kind = MenuItemKind::NORMAL;
|
||||
horizontal.label = L"水平镜像";
|
||||
horizontal.id = CMD_MIRROR_HORIZONTAL;
|
||||
horizontal.checked = style->mirrorHorizontal;
|
||||
horizontal.keepOpen = true;
|
||||
items.push_back(horizontal);
|
||||
|
||||
MenuItem vertical;
|
||||
vertical.kind = MenuItemKind::NORMAL;
|
||||
vertical.label = L"垂直镜像";
|
||||
vertical.id = CMD_MIRROR_VERTICAL;
|
||||
vertical.checked = style->mirrorVertical;
|
||||
vertical.keepOpen = true;
|
||||
items.push_back(vertical);
|
||||
|
||||
const gd::RectF& button = m_adjustRects[kAdjustMirror];
|
||||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||||
// 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。
|
||||
const int anchorY =
|
||||
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
|
||||
true);
|
||||
ClearStyleHover();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SignalPanel::ClearStyleHover()
|
||||
{
|
||||
if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_hoverStyle = StyleHit::NONE;
|
||||
m_hoverStyleIndex = -1;
|
||||
}
|
||||
|
||||
bool SignalPanel::CloseTransientUi()
|
||||
{
|
||||
bool changed = false;
|
||||
if (m_editField != StyleEditField::NONE)
|
||||
{
|
||||
changed = EndEdit(true) || changed;
|
||||
}
|
||||
// 点击面板之外也要结束「筛选信号」的编辑状态。
|
||||
if (m_filterEditing)
|
||||
{
|
||||
changed = EndFilterEdit() || changed;
|
||||
}
|
||||
if (m_colorPicker != nullptr && m_colorPicker->IsOpen())
|
||||
{
|
||||
m_colorPicker->Close();
|
||||
changed = true;
|
||||
}
|
||||
// 面板外点击:取消尚未抬起确认的关闭操作。
|
||||
m_pendingClosePart = RowPart::NONE;
|
||||
m_pendingCloseRow = -1;
|
||||
m_pendingFilterClear = false;
|
||||
m_pendingGainMenu = false;
|
||||
m_pendingMirrorMenu = false;
|
||||
m_pendingTargetLaneMenu = false;
|
||||
return changed;
|
||||
}
|
||||
|
||||
void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y)
|
||||
{
|
||||
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const SignalStyle* style = StyleOf(ref);
|
||||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||||
if (style == nullptr || signal == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_menuTargetSignal = ref;
|
||||
m_menuTargetGroup = -1;
|
||||
|
||||
std::vector<MenuItem> items;
|
||||
|
||||
MenuItem header;
|
||||
header.kind = MenuItemKind::HEADER;
|
||||
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 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 = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
|
||||
}
|
||||
|
||||
void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y)
|
||||
{
|
||||
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
|
||||
if (group == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_menuTargetGroup = groupIndex;
|
||||
m_menuTargetSignal = SignalRef();
|
||||
|
||||
std::vector<MenuItem> items;
|
||||
|
||||
MenuItem header;
|
||||
header.kind = MenuItemKind::HEADER;
|
||||
header.label = group->name;
|
||||
items.push_back(header);
|
||||
|
||||
MenuItem showAll;
|
||||
showAll.label = L"选中该分组全部信号";
|
||||
showAll.id = CMD_GROUP_SHOW_ALL;
|
||||
items.push_back(showAll);
|
||||
|
||||
MenuItem hideAll;
|
||||
hideAll.label = L"取消该分组全部信号";
|
||||
hideAll.id = CMD_GROUP_HIDE_ALL;
|
||||
items.push_back(hideAll);
|
||||
|
||||
MenuItem separator;
|
||||
separator.kind = MenuItemKind::SEPARATOR;
|
||||
items.push_back(separator);
|
||||
|
||||
MenuItem remove;
|
||||
remove.label = L"关闭该分组(从工作区移除)";
|
||||
remove.id = CMD_GROUP_REMOVE;
|
||||
items.push_back(remove);
|
||||
|
||||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
|
||||
}
|
||||
|
||||
void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y)
|
||||
{
|
||||
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||||
if (signal == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_menuTargetSignal = ref;
|
||||
m_menuTargetGroup = -1;
|
||||
|
||||
std::vector<MenuItem> items;
|
||||
|
||||
MenuItem header;
|
||||
header.kind = MenuItemKind::HEADER;
|
||||
header.label = L"所属网格";
|
||||
items.push_back(header);
|
||||
|
||||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||||
const int currentLane = m_tab->LaneOfSignal(ref);
|
||||
for (std::size_t index = 0; index < lanes.size(); ++index)
|
||||
{
|
||||
MenuItem lane;
|
||||
lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" +
|
||||
std::to_wstring(lanes[index].signals.size()) + L" 条信号)";
|
||||
lane.id = CMD_LANE_BASE + static_cast<int>(index);
|
||||
lane.checked = (currentLane == static_cast<int>(index));
|
||||
items.push_back(lane);
|
||||
}
|
||||
|
||||
MenuItem separator;
|
||||
separator.kind = MenuItemKind::SEPARATOR;
|
||||
items.push_back(separator);
|
||||
|
||||
MenuItem create;
|
||||
create.label = L"新建网格";
|
||||
create.id = CMD_LANE_NEW;
|
||||
items.push_back(create);
|
||||
|
||||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||||
// ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格):
|
||||
// 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。
|
||||
// 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求):
|
||||
// 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的
|
||||
// 「新建网格」**始终固定不动**。
|
||||
MenuLayoutConstraint constraint;
|
||||
constraint.maxScrollRows = kLaneMenuVisibleRows;
|
||||
// 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。
|
||||
constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f);
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint);
|
||||
}
|
||||
|
||||
bool SignalPanel::OnMenuCommand(const MenuResult& result)
|
||||
{
|
||||
if (m_tab == nullptr || m_dataSet == nullptr || !result.valid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开
|
||||
//(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。
|
||||
if (result.editCommit)
|
||||
{
|
||||
SignalStyle* style = StyleOf(SelectedRef());
|
||||
if (style != nullptr)
|
||||
{
|
||||
const std::wstring trimmed = Trim(result.editText);
|
||||
wchar_t* stop = nullptr;
|
||||
const double parsed =
|
||||
trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop);
|
||||
// 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。
|
||||
const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed);
|
||||
switch (result.id)
|
||||
{
|
||||
case CMD_GAIN_EDIT:
|
||||
{
|
||||
if (valid)
|
||||
{
|
||||
style->gain =
|
||||
static_cast<float>(std::max(-kGainLimit, std::min(parsed, kGainLimit)));
|
||||
}
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
|
||||
FormatNumberText(static_cast<double>(style->gain)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case CMD_OFFSET_Y:
|
||||
{
|
||||
if (valid)
|
||||
{
|
||||
style->offsetY = static_cast<float>(
|
||||
std::max(-static_cast<double>(kGainLimit), std::min(parsed, kGainLimit)));
|
||||
}
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemValue(
|
||||
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
|
||||
FormatNumberText(static_cast<double>(style->offsetY)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case CMD_OFFSET_TIME:
|
||||
{
|
||||
if (valid)
|
||||
{
|
||||
style->offsetTime =
|
||||
std::max(-kGainLimit, std::min(parsed, kGainLimit));
|
||||
}
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemValue(CMD_OFFSET_TIME,
|
||||
FormatOffsetTimeText(style->offsetTime),
|
||||
FormatNumberText(style->offsetTime));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。
|
||||
if (result.id == CMD_GAIN_RESET)
|
||||
{
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
style->gain = 1.0f;
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
|
||||
FormatNumberText(static_cast<double>(style->gain)));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。
|
||||
if (result.id == CMD_OFFSET_RESET)
|
||||
{
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
style->offsetY = 0.0f;
|
||||
style->offsetTime = 0.0;
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0),
|
||||
FormatNumberText(0.0));
|
||||
m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0),
|
||||
FormatNumberText(0.0));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。
|
||||
if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL)
|
||||
{
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL);
|
||||
bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical;
|
||||
flag = !flag;
|
||||
if (!horizontal)
|
||||
{
|
||||
// ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
|
||||
// 使绘制等价于「先加上偏移再镜像」(用户明确要求)。
|
||||
style->offsetY = -style->offsetY;
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemValue(
|
||||
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
|
||||
FormatNumberText(static_cast<double>(style->offsetY)));
|
||||
}
|
||||
}
|
||||
if (m_menu != nullptr)
|
||||
{
|
||||
m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool needRelayout = false;
|
||||
|
||||
switch (result.id)
|
||||
{
|
||||
case CMD_SIGNAL_TOGGLE:
|
||||
if (m_menuTargetSignal.IsValid())
|
||||
{
|
||||
m_tab->ToggleVisible(m_menuTargetSignal);
|
||||
m_rowsDirty = true;
|
||||
needRelayout = true;
|
||||
}
|
||||
break;
|
||||
case CMD_SIGNAL_SOLO:
|
||||
if (m_menuTargetSignal.IsValid())
|
||||
{
|
||||
m_tab->SoloSignal(m_menuTargetSignal);
|
||||
m_rowsDirty = true;
|
||||
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)
|
||||
{
|
||||
m_tab->SetGroupVisible(m_menuTargetGroup, true);
|
||||
m_rowsDirty = true;
|
||||
needRelayout = true;
|
||||
}
|
||||
break;
|
||||
case CMD_GROUP_HIDE_ALL:
|
||||
if (m_menuTargetGroup >= 0)
|
||||
{
|
||||
m_tab->SetGroupVisible(m_menuTargetGroup, false);
|
||||
m_rowsDirty = true;
|
||||
needRelayout = true;
|
||||
}
|
||||
break;
|
||||
case CMD_GROUP_REMOVE:
|
||||
if (m_menuTargetGroup >= 0 && m_host != nullptr)
|
||||
{
|
||||
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetGroup), 0);
|
||||
}
|
||||
break;
|
||||
case CMD_LANE_NEW:
|
||||
if (m_menuTargetSignal.IsValid())
|
||||
{
|
||||
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1);
|
||||
m_rowsDirty = true;
|
||||
needRelayout = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
|
||||
{
|
||||
if (m_menuTargetSignal.IsValid())
|
||||
{
|
||||
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE);
|
||||
m_rowsDirty = true;
|
||||
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;
|
||||
}
|
||||
|
||||
return needRelayout;
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -0,0 +1,226 @@
|
||||
// SignalPanel 实现分片 —— 动画与滚动(分组折起、联动滚动、"滚动到目标信号")。
|
||||
//
|
||||
// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。
|
||||
// ⚠️ 折起动画是"抽拉卷尺"式的刚性平移:只改 `m_rowShift`,**行高永不变**,
|
||||
// 动画期间不得重建行集合(见 agent_mem.md 的硬约束)。
|
||||
|
||||
#include "SignalPanelInternal.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
bool SignalPanel::AdvanceGroupAnimations()
|
||||
{
|
||||
if (m_groupAnims.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint64_t now = GetTickCount64();
|
||||
bool animationFinished = false;
|
||||
for (std::size_t index = 0; index < m_groupAnims.size();)
|
||||
{
|
||||
const GroupAnim& anim = m_groupAnims[index];
|
||||
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
|
||||
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
|
||||
{
|
||||
// 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。
|
||||
if (anim.scrollSynced)
|
||||
{
|
||||
m_scrollOffset = anim.scrollTo;
|
||||
}
|
||||
animationFinished = true;
|
||||
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
}
|
||||
else
|
||||
{
|
||||
++index;
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ **动画结束(折起与展开都要)后必须重建行集合**:
|
||||
// * 折起:该组的信号行要真正从行集合里移除;
|
||||
// * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”,
|
||||
// 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会
|
||||
// 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。
|
||||
// ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 ——
|
||||
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
|
||||
if (animationFinished)
|
||||
{
|
||||
m_rowsDirty = true;
|
||||
BuildRows(); // 内部会重算内容高度与逐行位移
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateAnimGeometry();
|
||||
}
|
||||
// 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间
|
||||
// 列表宽度/滚动条与内容不匹配)。
|
||||
// ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进
|
||||
// `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则
|
||||
// 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条,
|
||||
// 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。
|
||||
RefreshScrollBar(animationFinished);
|
||||
return !m_groupAnims.empty();
|
||||
}
|
||||
|
||||
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
|
||||
{
|
||||
EnsureRows();
|
||||
if (m_tab == nullptr || groupIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int headerRow =
|
||||
(row >= 0 && row < static_cast<int>(m_rows.size()) &&
|
||||
m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP &&
|
||||
m_rows[static_cast<std::size_t>(row)].groupIndex == groupIndex)
|
||||
? row
|
||||
: GroupRowIndex(groupIndex);
|
||||
|
||||
const bool collapse = !IsGroupCollapsed(groupIndex);
|
||||
SetGroupCollapsed(groupIndex, collapse);
|
||||
|
||||
// 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**,
|
||||
// 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。
|
||||
//(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。)
|
||||
float scrollFrom = m_scrollOffset;
|
||||
float scrollTo = m_scrollOffset;
|
||||
bool syncScroll = false;
|
||||
if (collapse && headerRow >= 0)
|
||||
{
|
||||
const float alignOffset = ContentOffsetBeforeRow(headerRow);
|
||||
if (m_scrollOffset > alignOffset + 0.5f)
|
||||
{
|
||||
// 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短,
|
||||
// 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。
|
||||
const float finalContent =
|
||||
std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex));
|
||||
const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height);
|
||||
scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset));
|
||||
syncScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll);
|
||||
}
|
||||
|
||||
void SignalPanel::StartScrollAnimationTo(float desired)
|
||||
{
|
||||
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
|
||||
desired = std::max(0.0f, std::min(desired, maxOffset));
|
||||
const float distance = std::fabs(desired - m_scrollOffset);
|
||||
|
||||
if (distance < 1.0f)
|
||||
{
|
||||
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
|
||||
m_scrollOffset = desired;
|
||||
ClampScroll();
|
||||
m_scrollAnimActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
|
||||
// 因此"整个动画过程不会太久"(最长 300ms)。
|
||||
const float durationMs =
|
||||
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
|
||||
m_scrollAnimFrom = m_scrollOffset;
|
||||
m_scrollAnimTo = desired;
|
||||
m_scrollAnimStartMs = GetTickCount64();
|
||||
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
|
||||
m_scrollAnimActive = true;
|
||||
}
|
||||
|
||||
void SignalPanel::ScrollToSignal(const SignalRef& ref)
|
||||
{
|
||||
EnsureRows();
|
||||
if (!ref.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到
|
||||
// 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后
|
||||
// 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。
|
||||
// ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。
|
||||
ExpandGroupForSignal(ref);
|
||||
float desired = m_scrollOffset;
|
||||
if (SignalScrollTarget(ref, desired))
|
||||
{
|
||||
m_scrollOffset = desired;
|
||||
ClampScroll();
|
||||
}
|
||||
m_scrollAnimActive = false;
|
||||
}
|
||||
|
||||
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
|
||||
{
|
||||
EnsureRows();
|
||||
if (!ref.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。
|
||||
// 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。
|
||||
// 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。
|
||||
ExpandGroupForSignal(ref);
|
||||
float desired = m_scrollOffset;
|
||||
if (!SignalScrollTarget(ref, desired))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 把列表平滑滚动到目标信号完整可见。
|
||||
StartScrollAnimationTo(desired);
|
||||
}
|
||||
|
||||
bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref)
|
||||
{
|
||||
if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
SetGroupCollapsed(ref.groupIndex, false);
|
||||
// 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。
|
||||
const int anim = FindGroupAnimation(ref.groupIndex);
|
||||
if (anim >= 0)
|
||||
{
|
||||
m_groupAnims.erase(m_groupAnims.begin() + anim);
|
||||
}
|
||||
// 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。
|
||||
m_rowsDirty = true;
|
||||
EnsureRows();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SignalPanel::AdvanceScrollAnimation()
|
||||
{
|
||||
if (!m_scrollAnimActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::uint64_t now = GetTickCount64();
|
||||
const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0;
|
||||
float progress = (m_scrollAnimDurationMs > 0u)
|
||||
? static_cast<float>(elapsed) / static_cast<float>(m_scrollAnimDurationMs)
|
||||
: 1.0f;
|
||||
if (progress > 1.0f)
|
||||
{
|
||||
progress = 1.0f;
|
||||
}
|
||||
|
||||
// ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。
|
||||
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
|
||||
m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased;
|
||||
|
||||
if (progress >= 1.0f)
|
||||
{
|
||||
m_scrollOffset = m_scrollAnimTo;
|
||||
m_scrollAnimActive = false;
|
||||
ClampScroll();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "PaintUtil.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "UiComponent.hpp"
|
||||
#include "UiWidget.hpp"
|
||||
|
||||
namespace jdi
|
||||
@@ -46,7 +47,7 @@ namespace jdi
|
||||
/**
|
||||
* @brief 顶部标签栏:支持多视图切换、新建与关闭。
|
||||
*/
|
||||
class TabBar
|
||||
class TabBar : public UiComponent
|
||||
{
|
||||
public:
|
||||
TabBar();
|
||||
@@ -63,7 +64,7 @@ namespace jdi
|
||||
* @brief 设置 DPI 缩放比例。
|
||||
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
|
||||
*/
|
||||
void SetScale(float scale);
|
||||
void SetScale(float scale) override;
|
||||
|
||||
/**
|
||||
* @brief 更新标签页名称集合。
|
||||
@@ -87,13 +88,16 @@ namespace jdi
|
||||
* @brief 按给定区域重新排布标签与按钮。
|
||||
* @param [IN] area 标签栏可用区域。
|
||||
*/
|
||||
void Layout(const gd::RectF& area);
|
||||
void Layout(const gd::RectF& area) override;
|
||||
|
||||
/** @brief 标签栏占用的整条区域(与 `Layout` 同源)。 */
|
||||
gd::RectF Area() const override { return m_area; }
|
||||
|
||||
/**
|
||||
* @brief 绘制标签栏。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
*/
|
||||
void Paint(gd::Graphics& g);
|
||||
void Paint(gd::Graphics& g) override;
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标移动。
|
||||
@@ -143,7 +147,7 @@ namespace jdi
|
||||
* @brief 判断标签栏是否处于重命名编辑态。
|
||||
* @return 处于编辑态返回 true。
|
||||
*/
|
||||
bool IsEditing() const { return m_editIndex >= 0; }
|
||||
bool IsEditing() const override { return m_editIndex >= 0; }
|
||||
|
||||
/**
|
||||
* @brief 处理编辑态下的字符输入。
|
||||
@@ -204,7 +208,7 @@ namespace jdi
|
||||
* @brief 处理鼠标离开标签栏。
|
||||
* @return 视觉状态发生变化返回 true。
|
||||
*/
|
||||
bool OnMouseLeave();
|
||||
bool OnMouseLeave() override;
|
||||
|
||||
/**
|
||||
* @brief 设置宿主窗口句柄(用于剪贴板所有者)。
|
||||
@@ -218,7 +222,7 @@ namespace jdi
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 落在区域内返回 true。
|
||||
*/
|
||||
bool HitTest(int x, int y) const;
|
||||
bool HitTest(int x, int y) const override;
|
||||
|
||||
private:
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "PaintUtil.hpp"
|
||||
#include "Theme.hpp"
|
||||
#include "UiButtonWidget.hpp"
|
||||
#include "UiComponent.hpp"
|
||||
#include "UiWidget.hpp"
|
||||
|
||||
namespace jdi
|
||||
@@ -37,7 +38,7 @@ namespace jdi
|
||||
/**
|
||||
* @brief 顶部工具栏:文件操作与视图控制的入口。
|
||||
*/
|
||||
class Toolbar
|
||||
class Toolbar : public UiComponent
|
||||
{
|
||||
public:
|
||||
Toolbar();
|
||||
@@ -49,12 +50,11 @@ namespace jdi
|
||||
* @param [IN] theme 主题配色,需在工具栏生命周期内保持有效。
|
||||
*/
|
||||
void Setup(const UiFonts* fonts, const Theme* theme);
|
||||
|
||||
/**
|
||||
* @brief 设置 DPI 缩放比例。
|
||||
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
|
||||
*/
|
||||
void SetScale(float scale);
|
||||
void SetScale(float scale) override;
|
||||
|
||||
/**
|
||||
* @brief 设置是否已加载数据(未加载时大部分按钮被禁用)。
|
||||
@@ -90,13 +90,16 @@ namespace jdi
|
||||
* @brief 按给定区域重新排布按钮。
|
||||
* @param [IN] area 工具栏可用区域。
|
||||
*/
|
||||
void Layout(const gd::RectF& area);
|
||||
void Layout(const gd::RectF& area) override;
|
||||
|
||||
/** @brief 工具栏占用的整条区域(与 `Layout` 同源)。 */
|
||||
gd::RectF Area() const override { return m_area; }
|
||||
|
||||
/**
|
||||
* @brief 绘制工具栏。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
*/
|
||||
void Paint(gd::Graphics& g);
|
||||
void Paint(gd::Graphics& g) override;
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标移动,更新悬停状态。
|
||||
@@ -128,7 +131,7 @@ namespace jdi
|
||||
* @brief 处理鼠标离开工具栏。
|
||||
* @return 视觉状态发生变化返回 true。
|
||||
*/
|
||||
bool OnMouseLeave();
|
||||
bool OnMouseLeave() override;
|
||||
|
||||
/**
|
||||
* @brief 判断坐标是否落在工具栏区域内。
|
||||
@@ -136,7 +139,7 @@ namespace jdi
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 落在区域内返回 true。
|
||||
*/
|
||||
bool HitTest(int x, int y) const;
|
||||
bool HitTest(int x, int y) const override;
|
||||
|
||||
private:
|
||||
/**
|
||||
|
||||
@@ -302,3 +302,57 @@ protected:
|
||||
| 面板失效不全(贴旧像素) | `ContentKey()` 漏字段 | P4 里每个面板配一条"改 X 后像素必须变"的断言 |
|
||||
| 体积超 900KB | 新框架代码量 | 每阶段结束量一次;超阈值先报告用户 |
|
||||
| 布局级联死循环 | Measure 里改子节点 | 单元测试固定"Measure 一轮即收敛" |
|
||||
|
||||
---
|
||||
|
||||
# 第五阶段计划:统一组件协议 + 去手写链路 + 拆分收尾(2026-10-28)
|
||||
|
||||
> 状态:✅ 已完成(详见 `UI_Refactor_Progress.md` 第七轮 / `UI_Refactor_Report.md` 第五阶段)
|
||||
|
||||
## 目标
|
||||
|
||||
1. 让"顶层组件"有统一协议,宿主不再逐个点名;
|
||||
2. 用同一套协议把 `SetScale` / `Layout` / `MouseLeave` / `SetCursor` 四条手写链改成循环;
|
||||
3. 把剩余两个"千行以上"的文件按**职责**拆分(不再按函数名前缀);
|
||||
4. 一轮代码审查(错误注释 / 过期注释 / 名实不符 / 残留引用);
|
||||
5. 把误入库的临时验证工具 `Tools/` 从提交树剥离并写入 ignore。
|
||||
|
||||
## 设计决定(先证明不是负优化)
|
||||
|
||||
* **协议只做"同签名归一"**:`UiComponent` 是纯接口、无状态,四个组件本就各自拥有
|
||||
完全同名同签名的方法 ⇒ 改造 = 加 `override` + 补 `Area()`,实现零改动、生成码几乎不变。
|
||||
* **不把 `UiComponent` 做成 `UiWidget` 的基类**:树节点是"父给尺寸 + 父下探脏区",
|
||||
而 `PlotView`(4 层位图缓存)/ `SignalPanel`(行位图缓存)是"地盘宿主"。
|
||||
强行上树会推翻缓存前提(历史事故:后台渲染覆盖平移、命中矩形错位)。
|
||||
* **不做的一律写明理由**:按下/松开/移动三条链带组件专属后置动作,保留显式分支。
|
||||
|
||||
## 任务清单
|
||||
|
||||
| # | 任务 | 验证手段 |
|
||||
| --- | --- | --- |
|
||||
| P5-1 | 新增 `Shared/Inc/UiComponent.hpp`,四组件继承 + `override` | 编译零警告 |
|
||||
| P5-2 | `Application`:`ComponentEntry` 表 + `BuildComponentTable()` | — |
|
||||
| P5-3 | `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 表驱动 | `uicheck` 69/69 |
|
||||
| P5-4 | `Relayout` 内容区改用 `UiStackRects`(布局库首个生产调用点) | `rendercheck` 8 哈希不变 |
|
||||
| P5-5 | `SignalPanelInteraction.cpp` 拆 4 片 + `SignalPanelEdit/Scroll/Menu.cpp` | 8 哈希不变 |
|
||||
| P5-6 | `PopupMenu.cpp` 拆 3 片 + `PopupMenuInternal.hpp` | 8 哈希不变 |
|
||||
| P5-7 | 代码审查(注释准确性、构造函数重复、include 位置) | 人工 + 编译 |
|
||||
| P5-8 | `git rm -r --cached Tools` + `.gitignore` + 历史过滤器 | `git ls-files Tools` 为空 |
|
||||
| P5-9 | 三份文档更新 + 提交 | `git status` 干净 |
|
||||
|
||||
## 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
| --- | --- |
|
||||
| 表驱动改动影响绘制顺序(toolbar 与 tabBar 重叠区) | 表按"底→顶"排,`WM_SETCURSOR` 从表尾向前命中,与旧 `if` 顺序一致 |
|
||||
| 拆分导致逐像素差异 | 纯整函数搬移;每拆一个文件立刻跑 `rendercheck` 比对 8 哈希 |
|
||||
| `filter-branch` 会清掉工作区被剔除的路径 | 先备份 `Tools/` 到 `%TEMP%`,重写后还原为未跟踪文件 |
|
||||
| 中文注释在 PS 5.1 下乱码 | 文档编辑走 VS Code 编辑工具(UTF-8),终端只做 ASCII 拼接 |
|
||||
|
||||
## 完成判据
|
||||
|
||||
* `make release` / `make debug` 零 error 零 warning;
|
||||
* `Tools/rendercheck` 8 个场景哈希与基线**逐一相同** + 7 条 FUNC 断言 PASS;
|
||||
* `Tools/uicheck` 69/69;
|
||||
* `TargetCheck.exe` < 900 KB;
|
||||
* `git ls-files Tools` 返回空、`git status` 干净、`main` / `dev` 未被触碰。
|
||||
@@ -248,3 +248,97 @@ Tools/uicheck/uicheck.exe # 62 passed, 0 failed
|
||||
(必须用**覆盖全部输入的键**,否则就是历史上"漏混字段 → 贴旧像素"那类静默 BUG)。
|
||||
3. 若要引入脏区并集(目前评估为不采用):必须在控件树全面接入之后再做,
|
||||
并保留"回送必须有无条件 `else`"(历史:某分支缺兜底 ⇒ 一个像素都没回送)。
|
||||
|
||||
---
|
||||
|
||||
## 第七轮(2026-10-28):统一组件协议 · 去手写链路 · 大文件按职责拆分 · 审查 · 工具剥离
|
||||
|
||||
### 进度总览
|
||||
|
||||
| # | 任务 | 状态 | 验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 新增 `UiComponent` 顶层协议,四组件 `override` | ✅ | 编译零警告 |
|
||||
| 2 | `Application` 组件表 `m_components` + `BuildComponentTable` | ✅ | — |
|
||||
| 3 | `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 四条链改表驱动 | ✅ | `uicheck` 69/69 |
|
||||
| 4 | `UiStackRects` 首次用于生产代码(内容区三列) | ✅ | 像素哈希不变 |
|
||||
| 5 | `SignalPanelInteraction.cpp` 2329 → 4 片 | ✅ | 8 哈希不变 |
|
||||
| 6 | `PopupMenu.cpp` 1901 → 3 片 + `PopupMenuInternal.hpp` | ✅ | 8 哈希不变 |
|
||||
| 7 | 代码审查(注释/名实不符/残留引用) | ✅ | 5 处修正 |
|
||||
| 8 | `Tools/` 从提交树剥离 + `.gitignore` | ✅ | `git ls-files Tools` 为空 |
|
||||
| 9 | 三份文档更新 | ✅ | — |
|
||||
|
||||
### 1. `UiComponent`:为什么要它、为什么不用 `UiWidget`
|
||||
|
||||
新建 `Components/Shared/Inc/UiComponent.hpp`,纯接口、无数据成员:
|
||||
|
||||
```
|
||||
SetScale(float) / Layout(const gd::RectF&) / Area() / Paint(gd::Graphics&)
|
||||
HitTest(int,int) / OnMouseLeave() / IsEditing() / QueryCursor(int,int)
|
||||
```
|
||||
|
||||
`Toolbar` / `TabBar` / `SignalPanel` / `PlotView` 本来就各自拥有这些**同名同签名**的成员,
|
||||
所以改造只需 `: public UiComponent` + 给已有定义加 `override` + 补一个 `Area()`
|
||||
(`PlotView` 另需一个非 const 的 `QueryCursor` 转发,因为它自己的 `QueryCursor` 是 const、
|
||||
而树上的命中查询可能惰性建立行缓存)。
|
||||
|
||||
**结论:这一步不是负优化** —— 生成代码几乎不变(全是静态派发,无虚表之外的额外开销),
|
||||
却让宿主从"认识每一个组件"变成"认识一份表"。体积实测 `TargetCheck.exe` 891 392 B,
|
||||
仍低于 900 KB 阈值;`Target.exe` 886 784 B 与改造前一致。
|
||||
|
||||
### 2. 宿主侧:手写链 → 组件表(4 处)
|
||||
|
||||
| 消息 | 改造前 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| `ApplyDpi` | 逐个 `SetScale`(6 行,新增组件必须记得加) | `for (entry : m_components) entry.component->SetScale(scale)` |
|
||||
| `Relayout` | 手算 panel/splitter/plot 三组 x 坐标 | `UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO}, 0, rects)` |
|
||||
| `WM_MOUSELEAVE` | 逐个 `OnMouseLeave` 并分别累积 | 循环累积 `chromeChanged` / `plotChanged` |
|
||||
| `WM_SETCURSOR` | `if (绘图区) … else if (面板) …` | 从表尾(最上层)向前找首个 `HitTest` 命中者,取其 `QueryCursor` |
|
||||
|
||||
保留显式的三条链(**有意,不是漏改**):左键按下 / 松开 / 鼠标移动。
|
||||
它们各自挂着一串"组件专属后置动作"(提交重命名、构建派生信号、面板联动滚动、
|
||||
更新拖拽光标、判断 `plotOnlyMove` 决定局部还是整帧失效)。
|
||||
把这些塞进表里要引入 lambda 或回调字段,可读性反而下降 —— 属于用户明确禁止的"过度抽象"。
|
||||
|
||||
### 3. 拆分对照
|
||||
|
||||
| 原文件 | 拆后 | 内容职责 |
|
||||
| --- | --- | --- |
|
||||
| `SignalPanelInteraction.cpp` 2 329 | **831** | 命中测试、鼠标/键盘派发、光标形状 |
|
||||
| + `SignalPanelEdit.cpp` | 616 | 筛选框、组名/信号名、数值输入、颜色与透明度 |
|
||||
| + `SignalPanelScroll.cpp` | 205 | 折起动画、联动滚动、滚动到目标信号 |
|
||||
| + `SignalPanelMenu.cpp` | 527 | 系数/偏移、镜像、右键菜单与命令回执 |
|
||||
| `PopupMenu.cpp` 1 901 | **706** | `OnEdit*` 交互分支 |
|
||||
| + `PopupMenuLayout.cpp` | 753 | 尺寸测量、项矩形排版、滚动可见性 |
|
||||
| + `PopupMenuPaint.cpp` | 397 | 背景/阴影/复用 `UiControls` 的项绘制 |
|
||||
| + `Inc/PopupMenuInternal.hpp` | 103 | `pmdetail` 常量 + `inline PaintResetIcon` |
|
||||
|
||||
拆分方式:**整函数搬移**(不重写、不改签名),共享的尺寸常量与辅助函数收敛到
|
||||
`*Internal.hpp`(`constexpr` / `inline`,跨 TU 安全)。
|
||||
这样每个 TU 的行数落在 200–900 的可读区间,而逐像素输出保持 0 差异(哈希佐证)。
|
||||
|
||||
### 4. 代码审查结果
|
||||
|
||||
| 文件 | 问题 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| `SignalPanelInteraction.cpp` | 头注释仍写"4 片:核心/渲染/交互与编辑" | 改为六分片清单(含本文件定位) |
|
||||
| `PopupMenuLayout.cpp` | 构造函数被机械搬移导致重复定义 | 只保留在 `PopupMenu.cpp` |
|
||||
| `PopupMenu.cpp` | `#include "PopupMenuInternal.hpp"` 落在 `namespace jdi` 内 | 移到文件头 |
|
||||
| `PopupMenuPaint.cpp` | 缺 `UiControls.hpp`(`UiDrawEditFieldContent` 未声明) | 补 include |
|
||||
| 全仓 | 历史删除符号残留引用 | grep 9 个符号,**零命中** |
|
||||
|
||||
### 5. `Tools/` 剥离
|
||||
|
||||
`git rm -r --cached Tools` + `.gitignore` 新增 `Tools/`,
|
||||
并对 `UI_Refactor` 分支做 `filter-branch` 清除历史中的该目录(分支全本地、未推送,风险可控);
|
||||
本地文件在剥离前已备份到 `%TEMP%` 并复原,`uicheck` / `rendercheck` 随时可复跑。
|
||||
|
||||
### 6. 验证数据
|
||||
|
||||
```
|
||||
make release (TargetCheck) → 零 error / 零 warning
|
||||
rendercheck: A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
|
||||
E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113 (全部与基线一致)
|
||||
FUNC 7/7 PASS
|
||||
uicheck: 69 passed, 0 failed
|
||||
体积: Build/Release/TargetCheck.exe = 891,392 B (< 900 KB)
|
||||
```
|
||||
@@ -202,3 +202,81 @@ Tools/uicheck/uicheck.exe
|
||||
| 布局重复计算 | ⚠️ 小幅(`EdgePanelLayoutFor` 19 个调用点,待 P4 落地后才吃到 1~3ms/帧) |
|
||||
| 拖拽/缩放帧耗时(45~80ms 那部分) | ❌ 不改善:瓶颈是 GDI+ 光栅化的像素量,架构不改变"要画多少像素" |
|
||||
| 派发开销 | ❌ 不改善:µs 级,占预算 <0.1%;控件树改善的是**正确性**不是帧时间 |
|
||||
|
||||
---
|
||||
|
||||
# 第五阶段(2026-10-28):统一组件协议、去手写链路、按职责拆分
|
||||
|
||||
## 1. 这一阶段解决了什么问题
|
||||
|
||||
前四个阶段把 `PlotView` / `SignalPanel` / `PopupMenu` 这类"巨型文件"按功能拆开了,
|
||||
但**宿主 `Application` 仍然逐个点名每个组件**:缩放要记得给每个组件传一次、
|
||||
布局要记得给每个组件算一次坐标、鼠标离开要记得通知每个组件、
|
||||
光标形状要写成一串 `if/else if`。每加一个顶层组件,就有四处必须同步修改——
|
||||
漏掉任何一处都是"改了但没反应"的静默 bug。
|
||||
|
||||
本阶段把这个"人肉登记表"换成**真正的登记表**。
|
||||
|
||||
## 2. 设计:`UiComponent` 是"地盘宿主",不是"树节点"
|
||||
|
||||
```
|
||||
UiComponent(纯接口,无状态) UiWidget(有状态的树节点)
|
||||
SetScale / Layout / Area Measure / Arrange / OnPaint
|
||||
Paint / HitTest / OnMouseLeave 父下探脏区、命中沿树链派发
|
||||
IsEditing / QueryCursor 悬浮/捕获/焦点/模态
|
||||
```
|
||||
|
||||
两者**并列**而非继承:`PlotView` 自己管 4 层位图缓存(底图/装饰/网格/叠加),
|
||||
`SignalPanel` 自己管行位图缓存——把它们变成"父给尺寸、父下探脏区"的树节点,
|
||||
会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过"后台渲染覆盖平移结果"、
|
||||
"命中矩形错位"的事故。
|
||||
|
||||
`Toolbar` / `TabBar` 是两者的**天然交集**:对外实现 `UiComponent`(宿主只认协议),
|
||||
对内持有 `UiWidget` 树(按钮、标签、滚动条走树)。这条分层让"小型控件复用树的派发能力"
|
||||
与"大型组件保留自己的渲染策略"可以并存。
|
||||
|
||||
**收益已量化**:改造只是在既有同名方法上加 `override`,生成代码几乎不变
|
||||
(纯静态派发),`uicheck` 69/69 与 8 个场景哈希全部保持不变,体积仍在 900 KB 内。
|
||||
|
||||
## 3. 去手写链的边界:哪些改、哪些不改
|
||||
|
||||
改(4 条,收益明确):`ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR`。
|
||||
|
||||
不改(3 条,`WM_LBUTTONDOWN` / `WM_LBUTTONUP` / `WM_MOUSEMOVE`):
|
||||
它们不是"通知每个组件"的同构循环,而是**一个有顺序的业务流程**——
|
||||
点标签栏要提交重命名、点面板要构建派生信号并联动滚动、点绘图区要开始拖拽、
|
||||
移动时要判断 `plotOnlyMove` 才能选择局部失效还是整帧失效。
|
||||
把这些塞进表里只能靠 lambda 字段,阅读成本反超收益。**刻意保留显式代码。**
|
||||
|
||||
## 4. 拆分的度量
|
||||
|
||||
拆分判据换成"职责"之后,每个文件的定位能用一句话说清:
|
||||
|
||||
| 文件 | 一句话职责 |
|
||||
| --- | --- |
|
||||
| `SignalPanel.cpp` | 状态、行集合、布局与几何 |
|
||||
| `SignalPanelPaint.cpp` | 把状态画成像素(含行位图缓存) |
|
||||
| `SignalPanelInteraction.cpp` | 坐标 → 语义(命中、派发、光标) |
|
||||
| `SignalPanelEdit.cpp` | 内联编辑的生命周期 |
|
||||
| `SignalPanelScroll.cpp` | 动画与滚动联动 |
|
||||
| `SignalPanelMenu.cpp` | 弹出菜单与其命令回执 |
|
||||
|
||||
`PopupMenu` 同理:`Layout`(几何)/ `Paint`(像素)/ `PopupMenu.cpp`(交互)。
|
||||
纯搬移 + 逐像素哈希校验,保证"重构不改行为"。
|
||||
|
||||
## 5. 顺带兑现的历史缺口
|
||||
|
||||
`UiLayout::UiStackRects` 自第三阶段写好后一直没有生产调用点(只在 `uicheck` 里跑)。
|
||||
本轮 `Relayout` 的内容区三列切分改用它,**布局库终于有了真实使用场景**,
|
||||
也顺手删掉了一段手算坐标。
|
||||
|
||||
## 6. 遗留与建议
|
||||
|
||||
1. `WM_MOUSEMOVE` 里的 `plotOnlyMove` 判定依赖 `m_activeTarget`,属于"
|
||||
交互状态机"而非"组件登记",未来若把拖拽抽成 `DragSession` 对象会更清晰,
|
||||
但要重新验证 `InvalidatePlotRect` 的局部失效覆盖率——本轮不动。
|
||||
2. `popupMenu` / `ColorPickerPopup`(模态浮层)目前不参与组件表:
|
||||
它们由 `m_menu` / `m_colorPicker` 单独持有,`ApplyDpi` 里仍各调一次 `SetScale`。
|
||||
若以后浮层数量增加,可给它们一个"浮层表",但两个成员值不值得抽象——存疑。
|
||||
3. `Tools/` 已剥离;若将来要把它作为**对外可复现的验证资产**,建议单独开一个
|
||||
`qa/` 目录并显式加入版本控制(同时更新 `.gitignore` 的例外规则)。
|
||||
@@ -3025,6 +3025,9 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
|
||||
|
||||
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **UI 重构第一阶段(P0/P1/P2,全部只提交到本地 `UI_Refactor` 分支,不影响 `main`)**:① 新增 `Components/Shared/{Inc,Src}/UiLayout.*`(矩形原语 / 对齐 / 设备像素内侧对齐 / Stack / Flow / 布局缓存)与 `UiWidget.*`(控件树基类:两段式布局、脏区下探绘制、只沿命中链派发、capture/modal/hover/focus 仲裁、光标认领链、统一动画 tick、内容指纹入口)。② 新增入库离屏回归网 `Tools/uicheck`(62 项断言,链接 `Build/<Config>/*.o` 排除 `Main.o`)。③ `PlotView` 实现按关注点重分为七个分片:`PlotViewPaint.cpp` 9 080 → **4 913** 行(−46%),新增 `PlotViewRaster.cpp`(1 876)、`PlotViewCacheKey.cpp`(474)、`PlotViewPanels.cpp`(1 359);搬移**逐像素 0 差异**(三场景渲染哈希 `A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418` 迁移前后完全一致)。详见 §11.2 第 235–240 条与 `Documents/UI_Refactor_Plan.md` / `UI_Refactor_Progress.md` / `UI_Refactor_Report.md`。 |
|
||||
|
||||
|
||||
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **UI 重构第五阶段:统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + `Tools/` 剥离(详见 §12 末尾「2026-10-28 第七轮」)**。① 新增 `Components/Shared/Inc/UiComponent.hpp`(纯接口、无状态):`SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor`;`Toolbar` / `TabBar` / `SignalPanel` / `PlotView` 各自 `: public UiComponent`,**只加 `override` + 补 `Area()`,实现零改动**。② `Application` 新增 `ComponentEntry { UiComponent*; Target; bool chrome; }` 表 + `BuildComponentTable()`,把 `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 四条手写链改成循环;按下/松开/移动三条链**有意保留显式分支**(带组件专属后置动作)。③ `UiLayout::UiStackRects` 首次用于生产代码(内容区三列切分)。④ `SignalPanelInteraction.cpp` 2 329 → **831** 行(拆出 `SignalPanelEdit/Scroll/Menu.cpp`),`Shared/PopupMenu.cpp` 1 901 → **706** 行(拆出 `PopupMenuLayout.cpp` / `PopupMenuPaint.cpp` + 新增 `Inc/PopupMenuInternal.hpp`)。⑤ 代码审查修 5 处(过期头注释、重复构造函数、include 落进 namespace、缺 include、残留符号全仓零命中)。⑥ `Tools/` 从提交树剥离 + `.gitignore`,并对 `UI_Refactor` 做 `filter-branch` 清除历史。验证:`make release` 零错误零警告;`rendercheck` **8 场景哈希与基线逐一相同**、7 条 FUNC 全 PASS;`uicheck` **69 passed / 0 failed**;`TargetCheck.exe` **891,392 字节**。 |
|
||||
|
||||
## 11. 跨设备协作须知(起源设备视角)
|
||||
|
||||
> **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。
|
||||
@@ -7915,3 +7918,83 @@ DG3 data=0 cycle=0 (空组,一个字符串通道)
|
||||
面板 / `LaneVisual` / `LegendHit` / `EdgePanelLayout` 都是 `PlotView` 的嵌套类型,探针里必须写 `PlotView::` 前缀。
|
||||
- ⚠️ 本轮开始前发现工作区里 `PlotViewPaint.cpp` 与 `Documents/agent_mem.md` 被**回退到 `8f735d5`**(丢失第四/五轮全部改动),
|
||||
已 `git checkout --` 恢复到 `HEAD` 后再施工;提交前务必确认这两份文件与 HEAD 一致。
|
||||
|
||||
## 本轮(2026-10-28 第七轮):统一组件协议 + 去手写链路 + 大文件按职责拆分
|
||||
|
||||
> 用户需求:① 全面放弃手写链路,UI 绘制尽量走"控件树 + 派生基类"(先确认不是负优化);
|
||||
> ② 存在大量冗余的文件按职责拆分,但不为拆分而过度抽象;③ 一轮代码审查(错注释/过期注释/名实不符);
|
||||
> ④ 把临时测试工具 `Tools/` 从提交树剥离并写入 ignore;⑤ 更新 Documents 下的计划/进程/报告。
|
||||
|
||||
### A. `UiComponent`:顶层组件的统一协议(新增 `Components/Shared/Inc/UiComponent.hpp`)
|
||||
|
||||
纯接口、**无状态**,7 个方法:`SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing`
|
||||
(外加默认返回 `nullptr` 的 `QueryCursor`)。
|
||||
`Toolbar` / `TabBar` / `SignalPanel` / `PlotView` 各自 `: public UiComponent`,
|
||||
它们本来就有一模一样签名的方法 ⇒ **只加 `override` 与 `Area()`,实现零改动**。
|
||||
|
||||
⚠️ **不把 `UiWidget` 当这个基类**(四条契约冲突,已写进头文件注释):
|
||||
`UiWidget` 是"树上的节点"(父给尺寸、父下探脏区、命中走树链、`OnPaint` 只画自己);
|
||||
顶层大组件是"一块地盘的宿主"(`PlotView` 4 层自持位图缓存、`SignalPanel` 行位图缓存、
|
||||
`PopupMenu`/拾色器是模态浮层)。两者是**兄弟关系**,不是父子。
|
||||
`Toolbar`/`TabBar` 同时属于两者:对外实现 `UiComponent`,对内持有一棵 `UiWidget` 树。
|
||||
|
||||
### B. 宿主侧:手写链 → 组件表
|
||||
|
||||
`Application` 新增 `struct ComponentEntry { UiComponent* component; Target target; bool chrome; }`
|
||||
与 `std::vector<ComponentEntry> m_components`(底→顶:plot → panel → toolbar → tabBar),
|
||||
`BuildComponentTable()` 在 `Initialize` 里 `Setup` 之后装配一次。
|
||||
|
||||
被表驱动的四处(旧实现每处都逐个点名组件):
|
||||
|
||||
| 消息 | 旧 | 新 |
|
||||
| --- | --- | --- |
|
||||
| `ApplyDpi` | 6 行逐个 `SetScale` | 1 个循环 |
|
||||
| `Relayout` | 4 行逐个 `Layout`(且手算三组坐标) | 循环 + `UiStackRects` 切分 |
|
||||
| `WM_MOUSELEAVE` | 4 行逐个 `OnMouseLeave` + 分别累计 | 循环 + `chrome` 标志决定"整帧/只绘图区" |
|
||||
| `WM_SETCURSOR` | `if (绘图区命中) … else if (面板) …` | 从表尾往前找首个命中者,其 `QueryCursor` 生效 |
|
||||
|
||||
**仍是显式的(有意)**:左键按下/抬起、鼠标移动、编辑态吸收、分隔条拖拽 ——
|
||||
这些分支各自带"按下后要提交重命名 / 构建派生信号 / 联动滚动 / 更新拖拽光标"等**专属后续动作**,
|
||||
强行表驱动会把它们变成藏在表里的 lambda,反而更难读(正是"过度抽象")。
|
||||
|
||||
顺带兑现一个历史缺口:`UiLayout` 的 `UiStackRects` 第一次有了**生产调用点**(内容区三列切分)。
|
||||
|
||||
### C. 大文件按职责拆分(纯搬移,逐像素 0 差异)
|
||||
|
||||
| 原文件 | 现状 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `SignalPanelInteraction.cpp` 2 329 行 | **831 行** | 拆出 `SignalPanelEdit.cpp`(616) / `SignalPanelScroll.cpp`(205) / `SignalPanelMenu.cpp`(527) |
|
||||
| `Shared/PopupMenu.cpp` 1 901 行 | **706 行** | 拆出 `PopupMenuLayout.cpp`(753) / `PopupMenuPaint.cpp`(397) + 新增 `Inc/PopupMenuInternal.hpp`(103) 收敛尺寸常量与 `PaintResetIcon` |
|
||||
|
||||
拆分判据是**职责**,不是行数:面板按"编辑 / 动画滚动 / 菜单 / 交互"切;
|
||||
菜单按"几何排版 / 绘制 / 交互"切。文件级常量与辅助函数搬进 `*Internal.hpp`
|
||||
(命名空间 `pmdetail`,`constexpr`/`inline`),跨 TU 安全。
|
||||
|
||||
### D. 代码审查(本轮修掉的问题)
|
||||
|
||||
1. `SignalPanelInteraction.cpp` 头部注释写着"分片按职责划分为核心/渲染/交互与编辑"(只有 4 片),
|
||||
实际已是 6 片 ⇒ 改为完整的六分片清单。
|
||||
2. `PopupMenuLayout.cpp` 机械搬移时**把构造函数也搬了进去**(与 `PopupMenu.cpp` 重复定义)
|
||||
⇒ 删重复、只保留在交互分片。
|
||||
3. 拆分后 `PopupMenu.cpp` 的 `#include "PopupMenuInternal.hpp"` 一度落在 `namespace jdi` **内部**
|
||||
(等价于声明 `jdi::jdi`)⇒ 移到文件头。
|
||||
4. `PopupMenuPaint.cpp` 缺 `UiControls.hpp`(`UiDrawEditFieldContent` 未声明)⇒ 补 include。
|
||||
5. 全仓复查了历史删除符号的残留引用(`m_paintPlot` / `ClampCursorsAfterPan` /
|
||||
`PaintFreeMarkerTooltip` / `CaptureDecorCache` / `FitSinglePointSignal` / `CsvTable` / `DataFile` /
|
||||
`.preflight` / `structcheck`)⇒ **零命中**,无需修改。
|
||||
|
||||
### E. 验证
|
||||
|
||||
* `make release` / `make debug` **零警告**;`TargetCheck.exe` **891 392 B**(< 900 KB),
|
||||
`Target.exe` **886 784 B**(与改造前一致)。
|
||||
* `Tools/rendercheck`:A–H 八个场景哈希与改造前**逐一相同**
|
||||
(`A 2166933039626F66 … H 23F390DD76141113`),7 条功能断言全 PASS。
|
||||
* `Tools/uicheck`:**69 passed / 0 failed**。
|
||||
* 体积:`UiComponent` 是纯接口,静态派发几乎零开销;`TargetCheck` 比 `Target` 多出的
|
||||
4.6 KB 来自 `PopupMenuInternal.hpp` 的 `inline` 函数在 3 个 TU 各实例化一份
|
||||
—— 这是拆分的已知代价,仍远低于 900 KB 阈值。
|
||||
|
||||
### F. 工具目录剥离
|
||||
|
||||
`Tools/`(`uicheck` / `rendercheck`)已从**提交树**剥离并写入 `.gitignore`
|
||||
(历史提交里也不含它 —— 见台账的「分支操作取证」)。本地文件保留,仍可随时复跑。
|
||||
Reference in new issue
Block a user