diff --git a/.gitignore b/.gitignore index 7e8c10e..806a709 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ Build/ # 测试数据(仅本地保留,不纳入版本控制) TestData/ + +# 临时验证工具(仅本地保留,不入版本控制) +Tools/ diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 9ab3ea0..0f554a7 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -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 m_components; + + /** @brief 填充组件表(`Initialize` 里 `Setup` 之后调用一次)。 */ + void BuildComponentTable(); + gd::RectF m_tabBarRect; /**< 标签栏区域。 */ gd::RectF m_toolbarRect; /**< 工具栏区域。 */ gd::RectF m_panelRect; /**< 信号面板区域。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index c66e03e..fc7fcd5 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -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(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 contentSizes{ panelWidth, splitterWidth, kUiSizeAuto }; + std::vector 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(point.x), static_cast(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) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 705dd60..d956cd9 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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(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 取出并清除待应用的信号重命名请求。 diff --git a/Components/Shared/Inc/PopupMenuInternal.hpp b/Components/Shared/Inc/PopupMenuInternal.hpp new file mode 100644 index 0000000..0c56dd5 --- /dev/null +++ b/Components/Shared/Inc/PopupMenuInternal.hpp @@ -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 +#include +#include + +#include +#include +#include + +#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__ diff --git a/Components/Shared/Inc/UiComponent.hpp b/Components/Shared/Inc/UiComponent.hpp new file mode 100644 index 0000000..b17f57f --- /dev/null +++ b/Components/Shared/Inc/UiComponent.hpp @@ -0,0 +1,80 @@ +#ifndef __UI_COMPONENT_HPP__ +#define __UI_COMPONENT_HPP__ + +#include +#include +#include + +#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__ diff --git a/Components/Shared/Src/PopupMenu.cpp b/Components/Shared/Src/PopupMenu.cpp index 5ef27df..e0b5884 100644 --- a/Components/Shared/Src/PopupMenu.cpp +++ b/Components/Shared/Src/PopupMenu.cpp @@ -1,825 +1,15 @@ #include "PopupMenu.hpp" -#include "UiControls.hpp" #include #include +#include "PopupMenuInternal.hpp" +#include "UiControls.hpp" + namespace jdi { - - namespace - { - const float kRowHeight = 27.0f; /**< 普通项高度。 */ - const float kHeaderHeight = 22.0f; /**< 分组标题高度。 */ - const float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */ - const float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */ - const float kPadV = 5.0f; /**< 菜单上下内边距。 */ - const float kPadH = 5.0f; /**< 菜单左右内边距。 */ - const float kItemPad = 8.0f; /**< 条目内文本的左右内缩(所有条目统一)。 */ - const float kMarkerColumn = 18.0f; /**< 条目**右侧**的选中标记列宽度(对勾画在这里)。 */ - const float kMarkerGap = 4.0f; /**< 文本与右侧标记列之间的间隔。 */ - const float kMinWidth = 148.0f; /**< 菜单最小宽度。 */ - const float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */ - const float kCorner = 6.0f; /**< 菜单圆角半径。 */ - const float kSwatchSize = 22.0f; /**< 色块尺寸。 */ - const float kSwatchGap = 5.0f; /**< 色块间距。 */ - const float kEditFieldWidth = 88.0f; /**< EDIT 项输入框宽度。 */ - const float kEditFieldGap = 6.0f; /**< 说明文字与输入框之间的最小间隔。 */ - const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */ - const float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */ - const float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */ - const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */ - const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */ - const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */ - const float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */ - const float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */ - const float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */ - - /** - * @brief 绘制「还原/重置」图标(逆时针回环箭头)。 - * - * 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。 - * 不依赖任何字体或资源,因此在任何 DPI 下都清晰。 - * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 图标外接矩形(正方形)。 - * @param [IN] color 笔画颜色。 - * @param [IN] scale DPI 缩放比例。 - */ - 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); - } - } - PopupMenu::PopupMenu() = default; - 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()); - - if (m_fonts == nullptr) - { - m_rect = gd::RectF(); - return; - } - - float contentWidth = kMinWidth * m_scale; - float totalHeight = kPadV * 2.0f * m_scale; - - std::vector 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(count) * kSwatchSize * m_scale + - static_cast(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(m_items.size()) - 1; index > 0; --index) - { - if (m_items[static_cast(index)].kind == MenuItemKind::SEPARATOR) - { - pinnedFrom = index; - break; - } - } - while (scrollFirst < pinnedFrom && - (m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER || - m_items[static_cast(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(m_items.size()); ++index) - { - if (index >= pinnedFrom) - { - tailHeight += heights[static_cast(index)]; - } - else if (index < scrollFirst) - { - headHeight += heights[static_cast(index)]; - } - else - { - scrollRegionHeight += heights[static_cast(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(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(x); - // `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。 - float top = expandUp ? (static_cast(y) - height) : static_cast(y); - - // 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。 - if (left + width > bounds.X + bounds.Width) - { - left = static_cast(x) - width; - } - if (!expandUp && top + height > bounds.Y + bounds.Height) - { - top = static_cast(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(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& rects = m_swatchRects[index]; - rects.reserve(static_cast(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(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(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(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(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(m_items.size())) - { - return; - } - const MenuItem& parent = m_items[static_cast(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(count) * kSwatchSize * m_scale + - static_cast(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(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(x); - const float fy = static_cast(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(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(m_items.size())) - { - return gd::RectF(); - } - const MenuItem& item = m_items[static_cast(index)]; - if (item.kind != MenuItemKind::EDIT) - { - return gd::RectF(); - } - - // 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度, - // 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。 - // ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限, - // 让输入框独占整行。 - // ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`: - // 同一面板里所有这类输入框必须一样宽、右边界对齐。 - const gd::RectF& rect = m_itemRects[static_cast(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(m_items.size())) - { - return gd::RectF(); - } - const MenuItem& item = m_items[static_cast(index)]; - if (item.kind != MenuItemKind::HEADER_ACTION) - { - return gd::RectF(); - } - const gd::RectF& rect = m_itemRects[static_cast(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(x), static_cast(y)); - } - - bool PopupMenu::BeginItemEdit(int index) - { - if (!m_open || index < 0 || index >= static_cast(m_items.size())) - { - return false; - } - const MenuItem& item = m_items[static_cast(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(m_items.size())) - { - return true; - } - - MenuItem& item = m_items[static_cast(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; - } bool PopupMenu::OnEditMouseDown(int x, int y) { @@ -1513,389 +703,5 @@ namespace jdi return true; } - 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(m_items.size()); - const int scrollLast = scrollable ? m_pinnedIndex : static_cast(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(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(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(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(index) == m_hoverIndex) && - (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0); - - if (item.kind == MenuItemKind::EDIT) - { - // 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式, - // 菜单始终不关闭,数值就地修改)。 - if (hovered && item.enabled && static_cast(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(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(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& 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(index) == m_hoverIndex) && - (static_cast(swatch) == m_hoverSwatch); - if (static_cast(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(swatch) == item.checkedSwatch) ? m_theme->textStrong - : m_theme->borderStrong, - (static_cast(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(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(index) == m_subHoverIndex) && item.enabled; - - if (item.kind == MenuItemKind::EDIT) - { - // 子菜单中的编辑项:与主菜单一样就地编辑。 - if (hovered && static_cast(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(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(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 diff --git a/Components/Shared/Src/PopupMenuLayout.cpp b/Components/Shared/Src/PopupMenuLayout.cpp new file mode 100644 index 0000000..3846fad --- /dev/null +++ b/Components/Shared/Src/PopupMenuLayout.cpp @@ -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()); + + if (m_fonts == nullptr) + { + m_rect = gd::RectF(); + return; + } + + float contentWidth = kMinWidth * m_scale; + float totalHeight = kPadV * 2.0f * m_scale; + + std::vector 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(count) * kSwatchSize * m_scale + + static_cast(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(m_items.size()) - 1; index > 0; --index) + { + if (m_items[static_cast(index)].kind == MenuItemKind::SEPARATOR) + { + pinnedFrom = index; + break; + } + } + while (scrollFirst < pinnedFrom && + (m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER || + m_items[static_cast(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(m_items.size()); ++index) + { + if (index >= pinnedFrom) + { + tailHeight += heights[static_cast(index)]; + } + else if (index < scrollFirst) + { + headHeight += heights[static_cast(index)]; + } + else + { + scrollRegionHeight += heights[static_cast(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(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(x); + // `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。 + float top = expandUp ? (static_cast(y) - height) : static_cast(y); + + // 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。 + if (left + width > bounds.X + bounds.Width) + { + left = static_cast(x) - width; + } + if (!expandUp && top + height > bounds.Y + bounds.Height) + { + top = static_cast(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(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& rects = m_swatchRects[index]; + rects.reserve(static_cast(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(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(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(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(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(m_items.size())) + { + return; + } + const MenuItem& parent = m_items[static_cast(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(count) * kSwatchSize * m_scale + + static_cast(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(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(x); + const float fy = static_cast(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(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(m_items.size())) + { + return gd::RectF(); + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::EDIT) + { + return gd::RectF(); + } + + // 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度, + // 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。 + // ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限, + // 让输入框独占整行。 + // ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`: + // 同一面板里所有这类输入框必须一样宽、右边界对齐。 + const gd::RectF& rect = m_itemRects[static_cast(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(m_items.size())) + { + return gd::RectF(); + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::HEADER_ACTION) + { + return gd::RectF(); + } + const gd::RectF& rect = m_itemRects[static_cast(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(x), static_cast(y)); + } + + bool PopupMenu::BeginItemEdit(int index) + { + if (!m_open || index < 0 || index >= static_cast(m_items.size())) + { + return false; + } + const MenuItem& item = m_items[static_cast(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(m_items.size())) + { + return true; + } + + MenuItem& item = m_items[static_cast(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 diff --git a/Components/Shared/Src/PopupMenuPaint.cpp b/Components/Shared/Src/PopupMenuPaint.cpp new file mode 100644 index 0000000..7e4e0c4 --- /dev/null +++ b/Components/Shared/Src/PopupMenuPaint.cpp @@ -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(m_items.size()); + const int scrollLast = scrollable ? m_pinnedIndex : static_cast(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(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(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(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(index) == m_hoverIndex) && + (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0); + + if (item.kind == MenuItemKind::EDIT) + { + // 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式, + // 菜单始终不关闭,数值就地修改)。 + if (hovered && item.enabled && static_cast(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(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(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& 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(index) == m_hoverIndex) && + (static_cast(swatch) == m_hoverSwatch); + if (static_cast(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(swatch) == item.checkedSwatch) ? m_theme->textStrong + : m_theme->borderStrong, + (static_cast(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(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(index) == m_subHoverIndex) && item.enabled; + + if (item.kind == MenuItemKind::EDIT) + { + // 子菜单中的编辑项:与主菜单一样就地编辑。 + if (hovered && static_cast(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(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(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 diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index d43b9e3..f897b07 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -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 将列表滚动到指定信号所在位置(无动画,立即到位)。 diff --git a/Components/SignalPanel/Src/SignalPanelEdit.cpp b/Components/SignalPanel/Src/SignalPanelEdit.cpp new file mode 100644 index 0000000..8b06438 --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanelEdit.cpp @@ -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(x), static_cast(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(x), static_cast(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(x); + const float fy = static_cast(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(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(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(m_rows.size())) + { + return false; + } + const ListRow& item = m_rows[static_cast(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(m_rows.size())) + { + return false; + } + const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast(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(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(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(m_rows.size())) + { + break; + } + const ListRow& item = m_rows[static_cast(row)]; + m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed); + break; + } + case StyleEditField::GROUP_TITLE: + { + if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) + { + break; + } + m_dataSetWritable->RenameGroup(m_rows[static_cast(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(x), static_cast(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(x), static_cast(y)) && + !UiPointInRect(field, static_cast(x), static_cast(y))) + { + // 把落点夹回输入框,保证插入符换算与绘制使用同一套几何 + //(`UiEditFieldMouseDown` 自己会再算一次文本起点)。 + const int clampedX = static_cast(std::lround( + std::max(field.X + 1.0f, std::min(static_cast(x), field.X + field.Width - 2.0f)))); + const int clampedY = static_cast(std::lround( + std::max(field.Y + 1.0f, std::min(static_cast(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(x), static_cast(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(UiSliderRatioFromX(m_opacityTrackRect, x)); + if (std::fabs(value - style->opacity) < 0.005f) + { + return false; + } + style->opacity = value; + return true; + } + +} // namespace jdi diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index a966ab2..208c7f7 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -1,199 +1,19 @@ -// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// SignalPanel 实现分片 —— 交互(命中、鼠标/键盘派发、光标形状)。 // -// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 -// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 +// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 +// 六个分片按**职责**划分(不再按函数名前缀): +// SignalPanel.cpp 核心:状态、行集合构建、布局与几何、分组/信号可见性 +// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条 +// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状(**本文件**) +// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度 +// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号" +// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执 #include "SignalPanelInternal.hpp" namespace jdi { - bool SignalPanel::IsOnFilterBox(int x, int y) const - { - return UiPointInRect(m_filterRect, static_cast(x), static_cast(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(x), static_cast(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(x); - const float fy = static_cast(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::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(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(m_rows.size()) && - m_rows[static_cast(row)].kind == RowKind::GROUP && - m_rows[static_cast(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); - } int SignalPanel::HitRow(int x, int y, RowPart& part) { @@ -264,835 +84,9 @@ namespace jdi return -1; } - 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(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(elapsed) / static_cast(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; - } - - 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(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(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(m_rows.size())) - { - return false; - } - const ListRow& item = m_rows[static_cast(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(m_rows.size())) - { - return false; - } - const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast(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(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(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(m_rows.size())) - { - break; - } - const ListRow& item = m_rows[static_cast(row)]; - m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed); - break; - } - case StyleEditField::GROUP_TITLE: - { - if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) - { - break; - } - m_dataSetWritable->RenameGroup(m_rows[static_cast(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(x), static_cast(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(x), static_cast(y)) && - !UiPointInRect(field, static_cast(x), static_cast(y))) - { - // 把落点夹回输入框,保证插入符换算与绘制使用同一套几何 - //(`UiEditFieldMouseDown` 自己会再算一次文本起点)。 - const int clampedX = static_cast(std::lround( - std::max(field.X + 1.0f, std::min(static_cast(x), field.X + field.Width - 2.0f)))); - const int clampedY = static_cast(std::lround( - std::max(field.Y + 1.0f, std::min(static_cast(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(x), static_cast(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(UiSliderRatioFromX(m_opacityTrackRect, x)); - if (std::fabs(value - style->opacity) < 0.005f) - { - return false; - } - style->opacity = value; - return true; - } - - 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 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(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(style->offsetY)); - offsetY.editValue = FormatNumberText(static_cast(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(std::lround(button.Y - kAdjustMenuGap * m_scale)); - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(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 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(std::lround(button.Y - kAdjustMenuGap * m_scale)); - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(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; - } bool SignalPanel::OnMouseMove(int x, int y) { @@ -1910,420 +904,6 @@ namespace jdi 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 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 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 items; - - MenuItem header; - header.kind = MenuItemKind::HEADER; - header.label = L"所属网格"; - items.push_back(header); - - const std::vector& 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(index); - lane.checked = (currentLane == static_cast(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(std::max(-kGainLimit, std::min(parsed, kGainLimit))); - } - if (m_menu != nullptr) - { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), - FormatNumberText(static_cast(style->gain))); - } - return false; - } - case CMD_OFFSET_Y: - { - if (valid) - { - style->offsetY = static_cast( - std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); - } - if (m_menu != nullptr) - { - m_menu->UpdateItemValue( - CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), - FormatNumberText(static_cast(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(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(style->offsetY)), - FormatNumberText(static_cast(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(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 diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp new file mode 100644 index 0000000..ec60c65 --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -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 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(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(style->offsetY)); + offsetY.editValue = FormatNumberText(static_cast(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(std::lround(button.Y - kAdjustMenuGap * m_scale)); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(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 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(std::lround(button.Y - kAdjustMenuGap * m_scale)); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(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 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 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 items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = L"所属网格"; + items.push_back(header); + + const std::vector& 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(index); + lane.checked = (currentLane == static_cast(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(std::max(-kGainLimit, std::min(parsed, kGainLimit))); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), + FormatNumberText(static_cast(style->gain))); + } + return false; + } + case CMD_OFFSET_Y: + { + if (valid) + { + style->offsetY = static_cast( + std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue( + CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), + FormatNumberText(static_cast(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(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(style->offsetY)), + FormatNumberText(static_cast(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(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 diff --git a/Components/SignalPanel/Src/SignalPanelScroll.cpp b/Components/SignalPanel/Src/SignalPanelScroll.cpp new file mode 100644 index 0000000..4404f27 --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanelScroll.cpp @@ -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(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(m_rows.size()) && + m_rows[static_cast(row)].kind == RowKind::GROUP && + m_rows[static_cast(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(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(elapsed) / static_cast(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 diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index 8c7f4df..24a4dbf 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -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: /** diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 649330c..191313f 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -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: /** diff --git a/Documents/UI_Refactor_Plan.md b/Documents/UI_Refactor_Plan.md index 0750926..8030c55 100644 --- a/Documents/UI_Refactor_Plan.md +++ b/Documents/UI_Refactor_Plan.md @@ -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` 未被触碰。 diff --git a/Documents/UI_Refactor_Progress.md b/Documents/UI_Refactor_Progress.md index de5a5ba..2e3807d 100644 --- a/Documents/UI_Refactor_Progress.md +++ b/Documents/UI_Refactor_Progress.md @@ -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) +``` diff --git a/Documents/UI_Refactor_Report.md b/Documents/UI_Refactor_Report.md index c73c25f..24b568c 100644 --- a/Documents/UI_Refactor_Report.md +++ b/Documents/UI_Refactor_Report.md @@ -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` 的例外规则)。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index c2b4ee7..69b4eae 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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//*.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 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` +(历史提交里也不含它 —— 见台账的「分支操作取证」)。本地文件保留,仍可随时复跑。