diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index a0cc7ff..eb6cf38 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -137,9 +137,6 @@ namespace jdi */ const TabConfig& ActiveTab() const; - /** - * @brief 新建标签页并切换为激活页。 - */ /** * @brief 新建一个标签页并切换到它。 * @param [IN] collapseGroups 是否把信号列表里的分组**默认折起** @@ -177,13 +174,13 @@ namespace jdi void SynchronizeView(bool force); /** - * @brief 将全部标签页配置与数据层对齐(文件增删后调用)。 + * @brief 将全部标签页配置与数据层对齐(分组增删后调用)。 */ void SyncTabsToDataSet(); /** - * @brief 从工作区移除指定文件并修正各标签页配置。 - * @param [IN] groupIndex 文件下标。 + * @brief 从工作区移除指定分组并修正各标签页配置。 + * @param [IN] groupIndex 分组下标。 */ void RemoveGroup(int groupIndex); @@ -193,8 +190,8 @@ namespace jdi * 用户点击信号行左侧的 ✕ 时调用:该信号一旦被确认不再需要,就应从数据层删除 * 并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,则连同分组一起移除。 * - * @param [IN] groupIndex 信号所属文件下标。 - * @param [IN] signalIndex 文件内信号下标。 + * @param [IN] groupIndex 信号所属分组下标。 + * @param [IN] signalIndex 组内信号下标。 */ void RemoveSignal(int groupIndex, int signalIndex); @@ -270,7 +267,7 @@ namespace jdi */ struct DerivedSignalRequest { - int groupIndex = -1; /**< 原信号所属文件下标。 */ + int groupIndex = -1; /**< 原信号所属分组下标。 */ int signalIndex = -1; /**< 原信号下标。 */ int points = 5; /**< 多次迭代均值滤波:均值点数 X(滑平半径)。 */ int iterations = 1; /**< 多次迭代均值滤波:迭代次数 N(至少 1)。 */ @@ -287,12 +284,12 @@ namespace jdi bool BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible); /** - * @brief 弹出文件选择对话框并加载所选文件。 + * @brief 弹出“打开数据文件”对话框并加载所选文件。 */ void OpenFileDialog(); /** - * @brief 加载指定 CSV 文件并刷新界面。 + * @brief 加载指定的数据文件并刷新界面。 * @param [IN] path 文件完整路径。 * @return 加载成功返回 true。 */ @@ -330,18 +327,18 @@ namespace jdi void LoadCommandLineFiles(); /** - * @brief 在后台线程解析并构建数据文件,同时播放加载过渡动画(界面不卡死)。 + * @brief 在后台线程解析并构建分组数据,同时播放加载过渡动画(界面不卡死)。 * * 大文件的瓶颈在“解析 CSV”与“按列重组采样数据”,这两步都不依赖 DataSet, * 因此放在工作线程执行;主线程循环抽送消息并重绘加载遮罩层, * 直到后台完成后再把结果移动进 DataSet(O(1),不会引起卡顿)。 * * @param [IN] path 文件路径。 - * @param [OUT] file 构建好的数据文件。 + * @param [OUT] group 构建好的信号分组。 * @param [OUT] error 失败原因描述。 * @return 加载成功返回 true;用户取消或失败返回 false。 */ - bool LoadFileAsync(const std::wstring& path, SignalGroup& file, std::wstring& error); + bool LoadFileAsync(const std::wstring& path, SignalGroup& group, std::wstring& error); /** * @brief 绘制加载过渡动画遮罩(在 Render 的最后一步调用,覆盖整个客户区)。 @@ -424,11 +421,11 @@ namespace jdi bool m_loading = false; /**< 是否正在显示加载过渡动画。 */ std::wstring m_loadingText; /**< 加载遮罩显示的文件名。 */ float m_loadingProgress = 0.0f; /**< 加载进度(0-1)。 */ - unsigned long long m_loadingBytesRead = 0; /**< 已读取字节数(用于进度浮层)。 */ - unsigned long long m_loadingTotalBytes = 0; /**< 文件总字节数(0 表示未知)。 */ + /** 已读取字节数(用于进度浮层);与 `m_loadingTotalBytes` 一起展示“已读 / 总量”。 */ + unsigned long long m_loadingBytesRead = 0; + /** 文件总字节数(0 表示未知)。 */ + unsigned long long m_loadingTotalBytes = 0; float m_loadingSpin = 0.0f; /**< 加载动画旋转相位(度)。 */ - unsigned long long m_loadingBytes = 0; /**< 当前文件字节数(用于展示体积)。 */ - /** * @brief 装饰区域(标签栏 / 工具栏 / 信号面板 / 分隔条)是否需要重绘。 * diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 6eddcc8..2f640c5 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -43,7 +43,7 @@ namespace jdi const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */ const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */ const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ - const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */ + const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除分组的自定义消息。 */ const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */ const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ @@ -664,7 +664,7 @@ namespace jdi m_dataSet.ValidateSelection(); m_panel.InvalidateRows(); - // 文件/信号结构变化会让信号引用与文件地址改变,派生缓存必须整体作废。 + // 分组/信号结构变化会让信号引用与分组地址改变,派生缓存必须整体作废。 m_plot.InvalidateDerivedCaches(); // 强制下一次同步重新计算视图状态。 @@ -687,7 +687,7 @@ namespace jdi } const std::wstring name = group->name; - // 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。 + // 移除分组会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。 m_plot.PauseDerivedAsync(); // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。 for (TabConfig& tab : m_tabs) @@ -714,7 +714,7 @@ namespace jdi { return; } - const std::wstring name = signal->Title(); + const std::wstring name = signal->name; if (group->signals.size() <= 1) { @@ -1110,7 +1110,7 @@ namespace jdi } timeNames += axis.name; } - std::wstring message = L"已加载 组" + std::to_wstring(groupIndex + 1) + L"|" + loaded->Title() + + std::wstring message = L"已加载 组" + std::to_wstring(groupIndex + 1) + L"|" + loaded->name + L"(" + FormatCount(static_cast(rowCount)) + L" 行采样," + std::to_wstring(loaded->signals.size()) + L" 条信号," + std::to_wstring(loaded->axes.size()) + L" 条时间轴:" + @@ -1617,7 +1617,7 @@ namespace jdi return false; } - if (!m_dataSet.SetSignalDisplayName(ref.groupIndex, ref.signalIndex, name)) + if (!m_dataSet.RenameSignal(ref.groupIndex, ref.signalIndex, name)) { return false; } @@ -1639,7 +1639,7 @@ namespace jdi } const SignalData& source = group->signals[static_cast(request.signalIndex)]; const TimeAxis& axis = group->AxisOf(source); - const std::wstring baseName = source.Title(); + const std::wstring baseName = source.name; // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, // 之后再取 `source` 会悬垂)。 @@ -1765,7 +1765,7 @@ namespace jdi // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; - // 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。 + // 插入派生信号会移动该分组的信号存储:先暂停后台计算,避免其读取已移动的数据。 m_plot.PauseDerivedAsync(); if (!m_dataSet.InsertDerivedSignal(request.groupIndex, request.signalIndex, std::move(derived), newIndex)) @@ -3062,7 +3062,7 @@ namespace jdi } case kMessageRemoveSignal: { - // wParam = 文件下标,lParam = 文件内信号下标。 + // wParam = 分组下标,lParam = 组内信号下标。 RemoveSignal(static_cast(wParam), static_cast(static_cast(lParam))); return 0; } diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index c0d6dbf..bf57186 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -560,7 +560,7 @@ namespace jdi /** * @brief 使全部派生结果失效:折线顶点、边沿统计、中心平均值曲线与静态场景缓存。 * - * 数据层结构变化(加载/移除文件、信号增删)后信号引用与文件地址都可能改变, + * 数据层结构变化(加载/移除分组、信号增删)后信号引用与分组地址都可能改变, * 必须整体失效,避免旧几何键与新的信号意外重合而取到过期结果。 */ void InvalidateDerivedCaches(); @@ -568,7 +568,7 @@ namespace jdi /** * @brief 暂停后台派生曲线计算并作废其结果。 * - * 后台线程会读取采样数据,因此**任何会改变数据层结构(增删文件/信号、插入派生信号, + * 后台线程会读取采样数据,因此**任何会改变数据层结构(增删分组/信号、插入派生信号, * 进而移动 `SignalGroup`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在 * 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。 * 之后的绘制会按需重新启动后台线程(惰性)。 @@ -589,7 +589,7 @@ namespace jdi * * ⚠️ 这是"关掉预览后内存不下降"的修复:一条整段(千万级采样)曲线的中间缓冲 * 可达数百 MB,而 `thread_local` 只在线程退出时释放 —— 过去只有" - * 关闭整个文件分组"(会 `PauseDerivedAsync`)才会真正还内存。 + * 关闭整个分组"(会 `PauseDerivedAsync`)才会真正还内存。 */ void TrimDerivedCurveDataIfIdle(); @@ -749,7 +749,7 @@ namespace jdi struct MeanFilterBuild { int groupIndex = -1; /**< 信号所属分组下标。 */ - int signalIndex = -1; /**< 文件内信号下标。 */ + int signalIndex = -1; /**< 组内信号下标。 */ int points = 5; /**< 均值点数 X(滑平半径)。 */ int iterations = 1; /**< 迭代次数 N(至少 1)。 */ }; @@ -773,7 +773,7 @@ namespace jdi struct WeakPeakBuild { int groupIndex = -1; /**< 信号所属分组下标。 */ - int signalIndex = -1; /**< 文件内信号下标。 */ + int signalIndex = -1; /**< 组内信号下标。 */ int amountPercent = 0; /**< 弱峰调整幅度(-400–400,默认 0 = 不改动)。 */ int iterations = 1; /**< 迭代次数 N(1–10)。 */ }; @@ -1015,9 +1015,9 @@ namespace jdi ResolvedSignal ResolveSignal(const SignalRef& ref) const; /** - * @brief 判断某网格内的可见信号是否来自多个文件。 + * @brief 判断某网格内的可见信号是否来自多个分组。 * @param [IN] laneIndex 网格下标。 - * @return 跨越多个文件返回 true。 + * @return 跨越多个分组返回 true。 */ bool LaneSpansMultipleFiles(int laneIndex) const; @@ -1605,7 +1605,7 @@ namespace jdi * @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。 * * 数据层在两次结构变化之间是只读的,因此 `signal` 指针在此期间稳定; - * 任何结构变化(增删文件/信号、插入派生信号、切换标签页)前都会暂停后台线程并清空缓存 + * 任何结构变化(增删分组/信号、插入派生信号、切换标签页)前都会暂停后台线程并清空缓存 *(见 `PauseDerivedAsync`),因此不会出现悬空读取。 */ struct CurveKey @@ -1771,7 +1771,7 @@ namespace jdi /** * @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。 - * @param [IN] file 目标文件。 + * @param [IN] axis 目标时间轴。 * @param [IN] count 可用采样点数。 * @param [OUT] begin 区间起点(含)。 * @param [OUT] end 区间终点(不含)。 @@ -2530,7 +2530,7 @@ namespace jdi int edgeCenter = 0; /**< 中心轴边沿计数开关。 */ int meanFilter = 0; /**< 均值滤波开关。 */ int weakPeak = 0; /**< 弱峰调整开关。 */ - /** 各条目展开为 [文件, 信号, 参数…],按原顺序(稳定)。 */ + /** 各条目展开为 [分组, 信号, 参数…],按原顺序(稳定)。 */ std::vector edgeCenterItems; std::vector meanFilterItems; std::vector weakPeakItems; diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 574f2be..3a8e1e6 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -5,6 +5,7 @@ #include #include +#include "UiControls.hpp" #include "Utf8Util.hpp" namespace jdi @@ -553,7 +554,7 @@ namespace jdi * 因此这里**不做任何外扩**:只有两端都落在 `[begin, end)` 内的采样对才会被累计 *(调用方从 `begin + 1` 开始枚举采样对)。 * - * @param [IN] file 所属数据分组。 + * @param [IN] group 所属数据分组。 * @param [IN] count 采样点数量。 * @param [IN] hasRange 是否限定时间区间。 * @param [IN] rangeMin 区间左端点时间。 @@ -828,7 +829,7 @@ namespace jdi */ std::wstring BuildLegendText(const SignalData& data) { - return data.Title(); + return data.name; } /** @@ -4322,7 +4323,7 @@ namespace jdi continue; } const float nameWidth = - std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()), + std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), kEdgePanelNameMaxWidth * m_scale); nameColumnWidth = std::max(nameColumnWidth, nameWidth); if (itemized) @@ -6929,7 +6930,7 @@ namespace jdi break; } } - return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->Title()); + return BeginEdit(PlotEditTarget::SIGNAL_NAME, -1, resolved.data->name); } } @@ -8196,10 +8197,10 @@ namespace jdi mixFloat(static_cast(resolved.style->offsetTime)); } // 图例文案参与绘制,重命名必须让缓存失效。 - mixText(resolved.data->Title()); + mixText(resolved.data->name); if (resolved.group != nullptr) { - mixText(resolved.group->Title()); + mixText(resolved.group->name); } } } @@ -10874,7 +10875,7 @@ namespace jdi std::wstring label = BuildLegendText(*resolved.data); if (multiFile && resolved.group != nullptr) { - label += L" · " + resolved.group->Title(); + label += L" · " + resolved.group->name; } const float maxTextWidth = kLegendMaxTextWidth * m_scale; const float fullTextWidth = UiMeasureTextWidth(m_fonts->uiTiny, label); @@ -13135,7 +13136,7 @@ namespace jdi const float nameWidth = std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale), kEdgePanelNameMaxWidth * m_scale); - UiText(g, UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, nameWidth), + UiText(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), m_fonts->uiSmall, gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, checkRect.Height + 6.0f * m_scale), @@ -13349,7 +13350,7 @@ namespace jdi const float textWidth = panel.Width - pad * 2.0f; // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 UiText(g, - UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, + UiEllipsize(resolved.data->name, m_fonts->uiSmall, std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)), m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor, TextAlign::LEFT); @@ -13651,7 +13652,7 @@ namespace jdi } const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; - const std::wstring text = resolved.data->Title() + L" · 按住拖拽可移入其它网格"; + const std::wstring text = resolved.data->name + L" · 按住拖拽可移入其它网格"; // 提示跟随鼠标:在可拖拽区域内持续移动时,提示条也要跟着走。 const float textWidth = UiMeasureTextWidth(m_fonts->uiSmall, text) + 16.0f * m_scale; diff --git a/Components/Shared/Inc/UiControls.hpp b/Components/Shared/Inc/UiControls.hpp new file mode 100644 index 0000000..062704f --- /dev/null +++ b/Components/Shared/Inc/UiControls.hpp @@ -0,0 +1,216 @@ +#ifndef __UI_CONTROLS_HPP__ +#define __UI_CONTROLS_HPP__ + +#include +#include +#include + +#include +#include +#include + +#include "PaintUtil.hpp" +#include "TextEdit.hpp" + +namespace jdi +{ + + /** + * @brief 通用 UI 控件(属于 `Components/Shared`)。 + * + * 这些控件的绘制 / 命中逻辑被**多个功能组件复用**(信号面板的行与样式区、绘图区的 + * 浮动面板与取值胶囊、工具栏、标签栏、弹出菜单、拾色器),因此在共享组件中集中实现, + * 而不是散落在各自的实现文件里。 + * + * 依赖方向:`Components/Shared` → `Utilities`(只使用 `PaintUtil` 提供的低层绘制原语), + * 绝不反向依赖任何功能组件。 + */ + + /** + * @brief 在矩形中心绘制一个加号图标。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标布局矩形。 + * @param [IN] argb 线条颜色。 + * @param [IN] strokeWidth 线条宽度。 + */ + void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth); + + /** + * @brief 在矩形中心绘制一个叉号(关闭)图标。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标布局矩形。 + * @param [IN] argb 线条颜色。 + * @param [IN] strokeWidth 线条宽度。 + */ + void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth); + + /** + * @brief 在矩形中心绘制一个实心三角形指示器。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标布局矩形。 + * @param [IN] argb 填充颜色。 + * @param [IN] direction 三角形朝向。 + */ + void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction); + + /** + * @brief 颜色的相对亮度(0-1,逐通道线性加权)。 + * @param [IN] color 0xAARRGGBB 颜色值。 + * @return 相对亮度。 + */ + double UiColorLuminance(std::uint32_t color); + + /** + * @brief 取值胶囊底色上的高对比度文字色与描边色。 + */ + struct UiInk + { + std::uint32_t text = 0xFF073642u; /**< 文字色(不透明)。 */ + std::uint32_t outline = 0x99FDF6E3u; /**< 描边色(半透明,恒与文字色反相)。 */ + }; + + /** + * @brief 依据胶囊底色求高对比度的文字色与描边色(对底色配色作线性响应)。 + * + * 求值分两步: + * 1. **亮度**:按"取最大对比"的一侧选定亮文字或暗文字。这是唯一的阈值判断,但它 + * 本身就是"任意底色下都保持高对比"的必要条件 —— 任何连续的亮度取值都会在中性灰 + * 附近同时远离两侧极值,对比度必然塌陷。 + * 2. **配色**:把该侧的极值色与**底色的互补色**按固定比例线性混合。互补色的相对亮度 + * 恰为 `1 - 底色亮度`,因此文字/描边的亮度是底色亮度的**线性**函数,色相也随信号 + * 配色连续变化(不再是固定的黑/白两色)。 + * + * 描边色取相反的一侧,保证"描边—文字—底色"三段式对比。 + * + * @param [IN] background 胶囊底色(0xAARRGGBB)。 + * @return 文字色与描边色。 + */ + UiInk UiContrastInk(std::uint32_t background); + + /** + * @brief 将滑块轨道上的横坐标换算为取值比例。 + * @param [IN] trackRect 轨道矩形。 + * @param [IN] x 横坐标。 + * @return 取值比例,范围 0-1。 + */ + double UiSliderRatioFromX(const gd::RectF& trackRect, float x); + + /** + * @brief 绘制水平滑块(轨道 + 已填充部分 + 圆钮)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] track 轨道矩形。 + * @param [IN] ratio 当前值比例(0-1)。 + * @param [IN] fillColor 已填充部分颜色。 + * @param [IN] active 是否处于悬停或拖拽状态(影响圆钮半径)。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, + bool active, float scale); + + /** + * @brief 绘制一个带边框的复选框。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 复选框矩形。 + * @param [IN] state 选中状态:0 未选,1 全选,2 部分选。 + * @param [IN] radius 圆角半径。 + */ + void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius); + + /** + * @brief 计算输入框文本的绘制起点(已考虑超出框宽时的水平滚动)。 + * @param [IN] rect 输入框矩形。 + * @param [IN] edit 编辑模型。 + * @param [IN] font 字体句柄。 + * @param [IN] scale 缩放比例。 + * @param [OUT] innerWidth 框内可用宽度输出。 + * @return 文本绘制起点横坐标。 + */ + float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale, + float& innerWidth); + + /** + * @brief 把输入框内的横坐标换算为字符偏移(就近取整,用于鼠标定位插入符)。 + * @param [IN] font 字体句柄。 + * @param [IN] text 当前文本。 + * @param [IN] localX 相对文本起点的横坐标(可超出 [0, 文本宽度])。 + * @return 字符偏移。 + */ + std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX); + + /** + * @brief 绘制单行内联输入框(含选区高亮、插入符与水平滚动)。 + * + * 初始状态为“整体选中”时以强调色底衬显示全部内容,首字符键入即整体替换, + * 交互观感与常见登录界面的账号/密码输入框一致;文本超出框宽时自动横向滚动, + * 保证插入符始终可见。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 输入框矩形。 + * @param [IN] edit 输入框编辑模型。 + * @param [IN] font 文本字体。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, + float scale); + + /** + * @brief 只绘制输入框的**内容**(选区高亮 / 文本 / 插入符),不绘制底板与边框。 + * + * 用于"宿主已经画好了外框"的场景(例如信号面板的「筛选信号」输入框):若直接调用 + * UiDrawEditField,会在外框内部再套一个带边框的圆角框,形成一圈多余的内圈。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 输入框矩形(内容区,几何与 UiDrawEditField 完全一致)。 + * @param [IN] edit 输入框编辑模型。 + * @param [IN] font 文本字体。 + * @param [IN] scale DPI 缩放比例。 + */ + void UiDrawEditFieldContent(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, + float scale); + + /** + * @brief 处理输入框内的鼠标按下:命中则把插入符定位到最近的字符边界并开始拖拽框选。 + * @param [IN] font 字体句柄。 + * @param [IN] rect 输入框矩形。 + * @param [IN,OUT] edit 编辑模型。 + * @param [IN] scale 缩放比例。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中输入框并开始拖拽返回 true。 + */ + bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y); + + /** + * @brief 处理输入框内的鼠标拖拽移动(扩展选区)。 + * @param [IN] font 字体句柄。 + * @param [IN] rect 输入框矩形。 + * @param [IN,OUT] edit 编辑模型。 + * @param [IN] scale 缩放比例。 + * @param [IN] x 客户区横坐标。 + * @return 选区发生变化返回 true。 + */ + bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x); + + /** + * @brief 判断给定不透明度是否低到需要为样块补充描边。 + * @param [IN] opacity 不透明度(0-1)。 + * @return 需要描边返回 true。 + */ + bool UiSwatchNeedsOutline(float opacity); + + /** + * @brief 绘制颜色样块:叠加不透明度填充,透明度过低时补充描边以保证可辨识。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 样块矩形。 + * @param [IN] color 基础颜色(0xAARRGGBB,其 Alpha 通道会被不透明度覆盖)。 + * @param [IN] opacity 不透明度(0-1)。 + * @param [IN] radius 圆角半径。 + * @param [IN] scale DPI 缩放比例,用于推导描边粗细。 + * @return 实际填充颜色的 0xAARRGGBB 值(供需要同步其他元素的调用方使用)。 + */ + std::uint32_t UiDrawSwatch(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float opacity, + float radius, float scale); + +} // namespace jdi + +#endif // __UI_CONTROLS_HPP__ diff --git a/Components/Shared/Src/UiControls.cpp b/Components/Shared/Src/UiControls.cpp new file mode 100644 index 0000000..81c04b3 --- /dev/null +++ b/Components/Shared/Src/UiControls.cpp @@ -0,0 +1,403 @@ +// 通用 UI 控件:按钮图标 / 复选框 / 滑块 / 内联输入框 / 颜色样块 / 取值胶囊配色。 +// +// 本文件属于 Components/Shared —— 这些控件被信号面板、绘图区、工具栏、浮动面板等 +// 多个功能组件复用,因此集中放在共享组件里,而不是散落在各自的实现文件中。 +// 依赖方向:Components/Shared -> Utilities(只用低层绘制原语,不反向依赖)。 +#include "UiControls.hpp" + +#include + +namespace jdi +{ + + namespace + { + /** Swatch outline opacity threshold (shared with UiSwatchNeedsOutline). */ + const float kSwatchOutlineOpacity = 0.45f; + } + + void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth) + { + const float cx = rect.X + rect.Width * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const float arm = std::min(rect.Width, rect.Height) * 0.3f; + UiDrawLine(g, cx - arm, cy, cx + arm, cy, argb, strokeWidth); + UiDrawLine(g, cx, cy - arm, cx, cy + arm, argb, strokeWidth); + } + + void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth) + { + const float cx = rect.X + rect.Width * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const float arm = std::min(rect.Width, rect.Height) * 0.3f; + UiDrawLine(g, cx - arm, cy - arm, cx + arm, cy + arm, argb, strokeWidth); + UiDrawLine(g, cx + arm, cy - arm, cx - arm, cy + arm, argb, strokeWidth); + } + + void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction) + { + const float cx = rect.X + rect.Width * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const float arm = std::min(rect.Width, rect.Height) * 0.28f; + + gd::PointF points[3]; + switch (direction) + { + case IconDirection::LEFT: + points[0] = gd::PointF(cx + arm * 0.7f, cy - arm); + points[1] = gd::PointF(cx - arm * 0.7f, cy); + points[2] = gd::PointF(cx + arm * 0.7f, cy + arm); + break; + case IconDirection::RIGHT: + points[0] = gd::PointF(cx - arm * 0.7f, cy - arm); + points[1] = gd::PointF(cx + arm * 0.7f, cy); + points[2] = gd::PointF(cx - arm * 0.7f, cy + arm); + break; + case IconDirection::UP: + points[0] = gd::PointF(cx - arm, cy + arm * 0.7f); + points[1] = gd::PointF(cx, cy - arm * 0.7f); + points[2] = gd::PointF(cx + arm, cy + arm * 0.7f); + break; + case IconDirection::DOWN: + default: + points[0] = gd::PointF(cx - arm, cy - arm * 0.7f); + points[1] = gd::PointF(cx, cy + arm * 0.7f); + points[2] = gd::PointF(cx + arm, cy - arm * 0.7f); + break; + } + + gd::SolidBrush brush(ToGdiColor(argb)); + g.FillPolygon(&brush, points, 3); + } + + double UiSliderRatioFromX(const gd::RectF& trackRect, float x) + { + if (trackRect.Width <= 0.0f) + { + return 0.0; + } + const double ratio = static_cast(x - trackRect.X) / static_cast(trackRect.Width); + if (ratio < 0.0) + { + return 0.0; + } + if (ratio > 1.0) + { + return 1.0; + } + return ratio; + } + double UiColorLuminance(std::uint32_t color) + { + const double r = static_cast((color >> 16) & 0xFFu); + const double g = static_cast((color >> 8) & 0xFFu); + const double b = static_cast(color & 0xFFu); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0; + } + + UiInk UiContrastInk(std::uint32_t background) + { + // 极值文字/描边色(Solarized base3 / base02 / base03),亮度差很大, + // 因此任意底色下文字与底色之间都有充足对比。 + const std::uint32_t kInkLight = 0xFFFDF6E3u; + const std::uint32_t kInkDark = 0xFF073642u; + const std::uint32_t kOutlineLight = 0xFFFDF6E3u; + const std::uint32_t kOutlineDark = 0xFF002B36u; + /** 向底色互补色混入的比例:线性响应信号配色,同时把对比度损失限制在 15% 以内。 */ + const double kTint = 0.15; + + const double luminance = UiColorLuminance(background); + // 底色越亮 → 文字越暗(取最大对比的一侧)。 + const bool darkText = (luminance >= 0.5); + // 底色的互补色:相对亮度恰为 1 - 底色亮度。 + const std::uint32_t complement = 0xFF000000u | ((~background) & 0x00FFFFFFu); + + UiInk ink; + ink.text = MixColor(darkText ? kInkDark : kInkLight, complement, kTint); + ink.text = WithAlpha(ink.text, 0xFFu); + ink.outline = MixColor(darkText ? kOutlineLight : kOutlineDark, complement, kTint); + ink.outline = WithAlpha(ink.outline, 0x99u); + return ink; + } + void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius) + { + const Theme& theme = SolarizedLight(); + const float centerY = rect.Y + rect.Height * 0.5f; + + if (state == 1) + { + UiFillRoundRect(g, rect, radius, theme.accent); + gd::Pen pen(ToGdiColor(theme.textOnAccent), std::max(1.4f, rect.Height * 0.11f)); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + g.DrawLine(&pen, rect.X + rect.Width * 0.22f, centerY, rect.X + rect.Width * 0.42f, + rect.Y + rect.Height * 0.72f); + g.DrawLine(&pen, rect.X + rect.Width * 0.42f, rect.Y + rect.Height * 0.72f, + rect.X + rect.Width * 0.78f, rect.Y + rect.Height * 0.28f); + } + else if (state == 2) + { + UiFillRoundRect(g, rect, radius, theme.accentSoft); + UiDrawRoundRect(g, rect, radius, theme.accent, 1.0f); + UiFillRect(g, gd::RectF(rect.X + rect.Width * 0.22f, centerY - rect.Height * 0.08f, + rect.Width * 0.56f, rect.Height * 0.16f), + theme.accent); + } + else + { + UiFillRoundRect(g, rect, radius, theme.cardBg); + UiDrawRoundRect(g, rect, radius, theme.borderStrong, 1.0f); + } + } + + void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, + bool active, float scale) + { + if (track.Width <= 0.0f || track.Height <= 0.0f) + { + return; + } + + if (ratio < 0.0) + { + ratio = 0.0; + } + if (ratio > 1.0) + { + ratio = 1.0; + } + + const float radius = track.Height * 0.5f; + UiFillRoundRect(g, track, radius, 0x2A657B83u); + + // 已填充部分严格止于圆钮中心,最小值时不会留下超出圆钮的填充残留。 + const float fillWidth = static_cast(ratio) * track.Width; + if (fillWidth > 0.5f) + { + UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), radius, fillColor); + } + + const float knobRadius = radius + (active ? 2.5f * scale : 0.8f * scale); + const float knobX = track.X + static_cast(ratio) * track.Width; + + gd::SolidBrush knob(ToGdiColor(0xFFFFFFFFu)); + gd::Pen knobPen(ToGdiColor(0xFF586E75u), 1.4f * scale); + g.FillEllipse(&knob, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f, + knobRadius * 2.0f); + g.DrawEllipse(&knobPen, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f, + knobRadius * 2.0f); + } + + float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale, + float& innerWidth) + { + innerWidth = std::max(4.0f, rect.Width - 12.0f * scale); + const float textLeft = rect.X + 6.0f * scale; + + const std::wstring& text = edit.Text(); + if (text.empty()) + { + return textLeft; + } + + const float textWidth = UiMeasureTextWidth(font, text); + if (textWidth <= innerWidth) + { + return textLeft; + } + + // 保证插入符可见:插入符超出右边界时向左滚动,并夹到总可滚动范围内。 + const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret())); + float scroll = 0.0f; + if (caretX > innerWidth) + { + scroll = caretX - innerWidth; + } + const float maximum = textWidth - innerWidth; + scroll = std::max(0.0f, std::min(scroll, maximum)); + return textLeft - scroll; + } + + std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX) + { + if (text.empty() || font == nullptr) + { + return 0; + } + if (localX <= 0.0f) + { + return 0; + } + + // 逐字符累加宽度,找到距离最近的字符边界(文本通常很短,线性扫描足够)。 + float previous = 0.0f; + for (std::size_t index = 0; index < text.size(); ++index) + { + const float current = UiMeasureTextWidth(font, text.substr(0, index + 1)); + if (localX < current) + { + // 落在本字符的左半 → 取左边界,右半 → 取右边界。 + return (localX - previous <= current - localX) ? index : (index + 1); + } + previous = current; + } + return text.size(); + } + + void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr) + { + return; + } + + const Theme& theme = SolarizedLight(); + const float corner = 3.0f * scale; + + UiFillRoundRect(g, rect, corner, theme.cardBg); + UiDrawRoundRect(g, rect, corner, theme.accent, 1.6f); + + UiDrawEditFieldContent(g, rect, edit, font, scale); + } + + void UiDrawEditFieldContent(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, + float scale) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr) + { + return; + } + + const Theme& theme = SolarizedLight(); + const std::wstring& text = edit.Text(); + const float innerLeft = rect.X + 6.0f * scale; + + float innerWidth = 0.0f; + const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); + const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale); + + if (text.empty()) + { + // 空内容仍绘制插入符,保持焦点可见。 + UiDrawLine(g, textLeft, rect.Y + 4.0f * scale, textLeft, rect.Y + rect.Height - 4.0f * scale, + theme.accent, 1.4f * scale); + return; + } + + // 光标与选区端点对应的像素位置(以文本左端为原点)。 + const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret())); + const float selBeginX = + edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionBegin())) : 0.0f; + const float selEndX = + edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionEnd())) : 0.0f; + + const bool fullSelection = + edit.HasSelection() && edit.SelectionBegin() == 0 && edit.SelectionEnd() == text.size(); + + const int saveState = g.Save(); + g.SetClip(clipRect, gd::CombineModeIntersect); + + // 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感。 + // ⚠️ 这里**必须自己把矩形夹到内容区**:`UiFillRect` 对面积足够大的不透明矩形会走 + // GDI `FillRect` 快路径,而 GDI 绘制**不受 GDI+ 裁剪区约束**,长文本的全选高亮 + // (宽度等于整段文本宽度)因此会溢出到输入框之外。夹取之后矩形始终落在框内, + // 既保留快路径又不越界。 + if (edit.HasSelection() && selEndX > selBeginX) + { + gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, selEndX - selBeginX, + rect.Height - 6.0f * scale); + const float left = std::max(highlight.X, clipRect.X); + const float right = std::min(highlight.X + highlight.Width, clipRect.X + clipRect.Width); + const float top = std::max(highlight.Y, clipRect.Y); + const float bottom = std::min(highlight.Y + highlight.Height, clipRect.Y + clipRect.Height); + highlight = gd::RectF(left, top, right - left, bottom - top); + UiFillRect(g, highlight, theme.accent); + } + + // 长文本用 ETO_CLIPPING 交给 GDI 裁剪,避免字符画到输入框之外。 + UiTextAtClipped(g, text, font, textLeft, rect, + fullSelection ? theme.textOnAccent : theme.textStrong, clipRect); + + if (!fullSelection && edit.HasSelection() && selEndX > selBeginX) + { + // 局部选区:在反白底衬上重绘被选中的片段,其余文字保持常规颜色。 + const std::wstring selected = + text.substr(edit.SelectionBegin(), edit.SelectionEnd() - edit.SelectionBegin()); + UiTextAtClipped(g, selected, font, textLeft + selBeginX, rect, theme.textOnAccent, clipRect); + } + + // 插入符。 + const float caretDrawX = textLeft + caretX; + UiDrawLine(g, caretDrawX, rect.Y + 4.0f * scale, caretDrawX, rect.Y + rect.Height - 4.0f * scale, + theme.textStrong, 1.4f * scale); + + g.Restore(saveState); + } + + bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y) + { + if (!edit.Active() || font == nullptr || rect.Width <= 0.0f) + { + return false; + } + if (!UiPointInRect(rect, static_cast(x), static_cast(y))) + { + return false; + } + + // 用与绘制完全相同的布局函数换算文本起点,保证点哪儿光标就落在哪儿。 + float innerWidth = 0.0f; + const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); + edit.BeginDrag(UiEditOffsetAt(font, edit.Text(), static_cast(x) - textLeft)); + return true; + } + + bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x) + { + if (!edit.Active() || !edit.Dragging() || font == nullptr || rect.Width <= 0.0f) + { + return false; + } + + float innerWidth = 0.0f; + const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); + return edit.UpdateDrag(UiEditOffsetAt(font, edit.Text(), static_cast(x) - textLeft)); + } + + bool UiSwatchNeedsOutline(float opacity) + { + return !(opacity >= kSwatchOutlineOpacity); + } + + std::uint32_t UiDrawSwatch(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float opacity, + float radius, float scale) + { + double alpha = static_cast(opacity); + if (!(alpha > 0.0)) + { + alpha = 0.0; + } + if (alpha > 1.0) + { + alpha = 1.0; + } + + const std::uint32_t filled = WithAlpha(color, static_cast(alpha * 255.0 + 0.5)); + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + UiFillRoundRect(g, rect, radius, filled); + + // 透明度过低时色块本身难以辨识,补充一圈描边以保持可读性;可见时不绘制描边。 + if (UiSwatchNeedsOutline(opacity)) + { + UiDrawRoundRect(g, rect, radius, SolarizedLight().borderStrong, + std::max(1.0f, scale)); + } + } + return filled; + } + + +} + // namespace jdi + diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 838888c..b429aeb 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -28,9 +28,9 @@ namespace jdi CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */ CMD_SIGNAL_SHOW_ALL = 3, /**< 显示全部信号。 */ CMD_SIGNAL_HIDE_ALL = 4, /**< 隐藏全部信号。 */ - CMD_GROUP_SHOW_ALL = 10, /**< 选中该文件全部信号。 */ - CMD_GROUP_HIDE_ALL = 11, /**< 取消该文件全部信号。 */ - CMD_GROUP_REMOVE = 12, /**< 从工作区移除该文件。 */ + CMD_GROUP_SHOW_ALL = 10, /**< 选中该分组全部信号。 */ + CMD_GROUP_HIDE_ALL = 11, /**< 取消该分组全部信号。 */ + CMD_GROUP_REMOVE = 12, /**< 从工作区移除该分组。 */ CMD_COLOR_BASE = 100, /**< 预设颜色基准(配合色块下标)。 */ CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */ CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */ @@ -45,7 +45,7 @@ namespace jdi }; /** - * @brief 左侧信号面板:按文件分组的信号树、可见性勾选与样式编辑。 + * @brief 左侧信号面板:按分组的信号树、可见性勾选与样式编辑。 * * 面板只读取数据层(DataSet),全部显示配置读写均落在当前标签页(TabConfig)上, * 因此不同标签页的信号勾选与样式互不影响。所有弹出菜单与拾色器均使用主题化自绘 @@ -364,7 +364,7 @@ namespace jdi WIDTH = 1, /**< 线宽数值。 */ OPACITY = 2, /**< 不透明度数值(百分比)。 */ SIGNAL_NAME = 3, /**< 信号显示名称(列表行内;`row < 0` 表示样式区「目标」行)。 */ - FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */ + GROUP_TITLE = 4 /**< 分组显示名称(列表行内)。 */ }; /** @@ -372,7 +372,7 @@ namespace jdi */ enum class RowKind : int { - GROUP = 0, /**< 文件分组标题行。 */ + GROUP = 0, /**< 分组标题行。 */ SIGNAL = 1 /**< 信号行。 */ }; @@ -384,8 +384,9 @@ namespace jdi NONE = 0, /**< 未命中。 */ DISCLOSURE, /**< 折叠三角。 */ CHECK, /**< 复选框。 */ - BODY, /**< 行主体。 */ - CLOSE, /**< 文件行的关闭按钮。 */ + BODY, /**< 行主体(分组行的名称区域 / 信号行的名称区域)。 */ + GROUP_TITLE, /**< 分组行的「组X」标签(与折叠三角一样可点击折起)。 */ + CLOSE, /**< 分组行的关闭按钮。 */ LANE_BADGE, /**< 信号行的网格徽标。 */ REMOVE_SIGNAL /**< 信号行左侧的「移除该信号」✕ 按钮。 */ }; @@ -413,12 +414,12 @@ namespace jdi }; /** - * @brief 列表中的一行(文件分组或信号)。 + * @brief 列表中的一行(分组或信号)。 */ struct ListRow { RowKind kind = RowKind::SIGNAL; /**< 行类型。 */ - int groupIndex = -1; /**< 所属文件下标。 */ + int groupIndex = -1; /**< 所属分组下标。 */ int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */ float height = 0.0f; /**< 行高(像素)。 */ /** @@ -668,11 +669,11 @@ namespace jdi gd::RectF RowRect(int row) const; /** - * @brief 计算文件行关闭按钮矩形。 + * @brief 计算分组行关闭按钮矩形。 * @param [IN] row 行下标。 * @return 关闭按钮矩形。 */ - gd::RectF FileCloseRect(int row) const; + gd::RectF GroupCloseRect(int row) const; /** * @brief 计算信号行网格徽标矩形。 @@ -725,11 +726,11 @@ namespace jdi void ClearRowClip() { m_rowClipActive = false; } /** - * @brief 绘制文件分组标题行。 + * @brief 绘制分组标题行。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] row 行下标。 */ - void PaintFileRow(gd::Graphics& g, int row); + void PaintGroupRow(gd::Graphics& g, int row); /** * @brief 绘制信号行。 @@ -853,12 +854,12 @@ namespace jdi void OpenSignalMenu(const SignalRef& ref, int x, int y); /** - * @brief 弹出文件分组右键菜单。 - * @param [IN] groupIndex 目标文件下标。 + * @brief 弹出分组右键菜单。 + * @param [IN] groupIndex 目标分组下标。 * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 */ - void OpenFileMenu(int groupIndex, int x, int y); + void OpenGroupMenu(int groupIndex, int x, int y); /** * @brief 弹出网格归属菜单(由信号行的网格徽标触发)。 @@ -968,7 +969,21 @@ namespace jdi * @param [IN] row 行下标。 * @return 标签宽度。 */ - float FileGroupLabelWidth(int row) const; + float GroupLabelWidth(int row) const; + + /** + * @brief 分组行左侧「组X」标签的文本(绘制、命中、宽度共用同一来源)。 + * @param [IN] groupIndex 分组下标。 + * @return 形如 `组1` 的文本。 + */ + static std::wstring GroupLabelText(int groupIndex); + + /** + * @brief 分组行右侧项数文本(绘制、命中、宽度共用同一来源)。 + * @param [IN] row 行下标。 + * @return 形如 `3 项` 的文本;行无效时返回空串。 + */ + std::wstring GroupStatsText(int row) const; /** * @brief 调整当前信号不透明度。 @@ -1143,7 +1158,7 @@ namespace jdi bool m_layoutDirty = false; /**< 是否需要重新布局。 */ SignalRef m_menuTargetSignal; /**< 菜单操作的目标信号。 */ - int m_menuTargetFile = -1; /**< 菜单操作的目标文件。 */ + int m_menuTargetGroup = -1; /**< 菜单操作的目标分组。 */ }; } // namespace jdi diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 88d4650..b95c370 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -4,6 +4,7 @@ #include #include +#include "UiControls.hpp" #include "Utf8Util.hpp" namespace jdi @@ -12,7 +13,7 @@ namespace jdi namespace { const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */ - const float kFileRowHeight = 30.0f; /**< 文件分组行高。 */ + const float kGroupRowHeight = 30.0f; /**< 分组行高。 */ const float kSignalRowHeight = 27.0f; /**< 信号行高。 */ const float kPadding = 12.0f; /**< 通用内边距。 */ const float kCheckSize = 15.0f; /**< 复选框尺寸。 */ @@ -21,7 +22,7 @@ namespace jdi const float kBadgeHeight = 17.0f; /**< 网格徽标高度。 */ const float kCorner = 4.0f; /**< 通用圆角半径。 */ const float kDisclosureWidth = 16.0f; /**< 折叠三角占位宽度。 */ - const float kIndent = 14.0f; /**< 信号行相对文件行的缩进。 */ + const float kIndent = 14.0f; /**< 信号行相对分组行的缩进。 */ const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */ const float kCloseButtonSize = 18.0f; /**< 行尾关闭(✕)按钮尺寸(分组行与信号行共用)。 */ const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */ @@ -480,7 +481,7 @@ namespace jdi std::wstring pattern = L"*"; pattern += m_filterText; pattern += L'*'; - return WildcardMatchNoCase(signal.Title(), pattern); + return WildcardMatchNoCase(signal.name, pattern); } bool SignalPanel::IsGroupCollapsed(int groupIndex) const @@ -829,7 +830,7 @@ namespace jdi return; } - const float fileHeight = kFileRowHeight * m_scale; + const float groupHeight = kGroupRowHeight * m_scale; const float signalHeight = kSignalRowHeight * m_scale; const bool filtering = !m_filterText.empty(); @@ -856,13 +857,13 @@ namespace jdi const bool collapsed = IsGroupCollapsed(gi); const bool animating = (FindGroupAnimation(gi) >= 0); - ListRow fileRow; - fileRow.kind = RowKind::GROUP; - fileRow.groupIndex = gi; - fileRow.height = fileHeight; + ListRow groupRow; + groupRow.kind = RowKind::GROUP; + groupRow.groupIndex = gi; + groupRow.height = groupHeight; // 「N 项」按当前模式下实际列出的信号数显示:筛选时 = 该组内符合筛选条件的个数。 - fileRow.displayCount = static_cast(shown.size()); - m_rows.push_back(fileRow); + groupRow.displayCount = static_cast(shown.size()); + m_rows.push_back(groupRow); // 折起且没有动画 ⇒ 该组的信号行完全不占高度(不出现在行集合里)。 // ⚠️ 有动画时必须**保留全部信号行**(含正在折起的):它们整组向上平移, @@ -1048,7 +1049,7 @@ namespace jdi // `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。 SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height)); FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg); - PaintFileRow(g, row); + PaintGroupRow(g, row); ClearRowClip(); // 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。 if (stickyY > naturalY + 0.5f) @@ -1059,7 +1060,7 @@ namespace jdi m_rowYOverrideActive = false; } - gd::RectF SignalPanel::FileCloseRect(int row) const + gd::RectF SignalPanel::GroupCloseRect(int row) const { const gd::RectF rect = RowRect(row); const float size = kCloseButtonSize * m_scale; @@ -1103,15 +1104,15 @@ namespace jdi const ListRow& item = m_rows[static_cast(row)]; const gd::RectF rect = RowRect(row); - const bool isFile = (item.kind == RowKind::GROUP); - const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); + const bool isGroup = (item.kind == RowKind::GROUP); + const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale); const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height); const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f, kCheckSize * m_scale, kCheckSize * m_scale); - if (isFile) + if (isGroup) { if (UiPointInRect(disclosureRect, fx, fy)) { @@ -1121,10 +1122,18 @@ namespace jdi { return RowPart::CHECK; } - if (UiPointInRect(FileCloseRect(row), fx, fy)) + if (UiPointInRect(GroupCloseRect(row), fx, fy)) { return RowPart::CLOSE; } + // 「组X」标签:与折叠三角一样是“折起该分组”的热区 + //(用户 2026-10-16 要求:只有点三角或「组X」才折起,点到组名本体不折起)。 + const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; + const gd::RectF labelRect(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height); + if (UiPointInRect(labelRect, fx, fy)) + { + return RowPart::GROUP_TITLE; + } return RowPart::BODY; } @@ -1430,7 +1439,7 @@ namespace jdi const SignalRef& target = SelectedRef(); const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr; - const std::wstring title = (signal != nullptr) ? signal->Title() : std::wstring(); + const std::wstring title = (signal != nullptr) ? signal->name : std::wstring(); const int lane = TargetLaneIndex(); // 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。 @@ -1661,7 +1670,7 @@ namespace jdi if (m_rows[static_cast(row)].kind == RowKind::GROUP) { - PaintFileRow(g, row); + PaintGroupRow(g, row); } else { @@ -1716,7 +1725,7 @@ namespace jdi UiFillRect(g, clipped, argb); } - void SignalPanel::PaintFileRow(gd::Graphics& g, int row) + void SignalPanel::PaintGroupRow(gd::Graphics& g, int row) { if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) { @@ -1760,14 +1769,13 @@ namespace jdi const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex); UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale); - const gd::RectF closeRect = FileCloseRect(row); + const gd::RectF closeRect = GroupCloseRect(row); // 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。 - const std::wstring groupLabel = L"组" + std::to_wstring(item.groupIndex + 1); - // 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数 - //(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。 - const std::wstring stats = std::to_wstring(item.displayCount) + L" 项"; - const float groupWidth = FileGroupLabelWidth(row); + const std::wstring groupLabel = GroupLabelText(item.groupIndex); + // 项数文本与 `RowNameRect`(组名编辑框)共用同一来源,保证名称宽度与绘制不漂移。 + const std::wstring stats = GroupStatsText(row); + const float groupWidth = GroupLabelWidth(row); const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); const float statsRight = closeRect.X - 6.0f * m_scale; const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; @@ -1786,13 +1794,13 @@ namespace jdi rect.Height - 14.0f * m_scale), WithAlpha(m_theme->borderStrong, 0xB0)); - if (m_editField == StyleEditField::FILE_NAME && m_editRow == row) + if (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row) { UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale); } else { - PaintRowText(g, UiEllipsize(group->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold, + PaintRowText(g, UiEllipsize(group->name, m_fonts->uiBold, nameWidth), m_fonts->uiBold, gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, TextAlign::LEFT); } @@ -1800,7 +1808,7 @@ namespace jdi PaintRowText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height), m_theme->textMuted, TextAlign::RIGHT); - // 关闭按钮(从工作区移除该文件)。 + // 关闭按钮(从工作区移除该分组)。 const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE); if (closeHot) { @@ -1878,7 +1886,7 @@ namespace jdi { const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft); const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted; - PaintRowText(g, UiEllipsize(signal->Title(), m_fonts->ui, nameWidth), m_fonts->ui, + PaintRowText(g, UiEllipsize(signal->name, m_fonts->ui, nameWidth), m_fonts->ui, gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT); } @@ -2164,7 +2172,7 @@ namespace jdi const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted; // ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、 // 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。 - UiText(g, UiEllipsize(signal->Title(), m_fonts->ui, m_targetNameRect.Width), m_fonts->ui, + UiText(g, UiEllipsize(signal->name, m_fonts->ui, m_targetNameRect.Width), m_fonts->ui, m_targetNameRect, nameColor, TextAlign::LEFT); } @@ -2328,18 +2336,21 @@ namespace jdi } const gd::RectF rect = RowRect(row); - const bool isFile = (m_rows[static_cast(row)].kind == RowKind::GROUP); - const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); + const bool isGroup = (m_rows[static_cast(row)].kind == RowKind::GROUP); + const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale); const float checkRight = baseX + kDisclosureWidth * m_scale + kCheckSize * m_scale; float left = 0.0f; float right = 0.0f; - if (isFile) + if (isGroup) { - // 分组名需要跳过左侧的“组X”标签与分隔线。 - left = checkRight + 8.0f * m_scale + FileGroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale + + // 分组名需要跳过左侧的“组X”标签与分隔线,并且**右侧让出“N 项”计数**: + // 双击进入编辑时,输入框不得把组内信号个数包进去(用户 2026-10-16 报告)。 + left = checkRight + 8.0f * m_scale + GroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale; - right = FileCloseRect(row).X - 6.0f * m_scale; + const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale; + const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, GroupStatsText(row)); + right = statsRight - statsWidth - 6.0f * m_scale; } else { @@ -2359,15 +2370,31 @@ namespace jdi return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale); } - float SignalPanel::FileGroupLabelWidth(int row) const + float SignalPanel::GroupLabelWidth(int row) const { if (m_fonts == nullptr || m_fonts->uiBold == nullptr || row < 0 || row >= static_cast(m_rows.size())) { return 0.0f; } - const std::wstring label = L"组" + std::to_wstring(m_rows[static_cast(row)].groupIndex + 1); - return UiMeasureTextWidth(m_fonts->uiBold, label); + return UiMeasureTextWidth(m_fonts->uiBold, + GroupLabelText(m_rows[static_cast(row)].groupIndex)); + } + + std::wstring SignalPanel::GroupLabelText(int groupIndex) + { + return L"组" + std::to_wstring(groupIndex + 1); + } + + std::wstring SignalPanel::GroupStatsText(int row) const + { + if (row < 0 || row >= static_cast(m_rows.size())) + { + return std::wstring(); + } + // 项数:普通模式 = 信号总数;筛选模式 = 该组内**符合筛选条件**的信号个数 + //(用户 2026-10-16 要求)。两者都在 `BuildRows` 里写入 `displayCount`。 + return std::to_wstring(m_rows[static_cast(row)].displayCount) + L" 项"; } bool SignalPanel::BeginEdit(StyleEditField field, int row) @@ -2414,7 +2441,7 @@ namespace jdi { return false; } - initial = signal->Title(); + initial = signal->name; break; } if (row >= static_cast(m_rows.size())) @@ -2427,10 +2454,10 @@ namespace jdi { return false; } - initial = signal->Title(); + initial = signal->name; break; } - case StyleEditField::FILE_NAME: + case StyleEditField::GROUP_TITLE: { if (row < 0 || row >= static_cast(m_rows.size())) { @@ -2441,7 +2468,7 @@ namespace jdi { return false; } - initial = group->Title(); + initial = group->name; break; } default: @@ -2459,7 +2486,7 @@ namespace jdi // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 const bool nameField = - (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME); + (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE); // 不限长度:名称与数值都由各自的提交逻辑处理超长内容。 m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; @@ -2537,7 +2564,7 @@ namespace jdi const SignalRef& target = SelectedRef(); if (target.IsValid()) { - m_dataSetWritable->SetSignalDisplayName(target.groupIndex, target.signalIndex, trimmed); + m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed); } // 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。 m_targetGeomValid = false; @@ -2548,16 +2575,16 @@ namespace jdi break; } const ListRow& item = m_rows[static_cast(row)]; - m_dataSetWritable->SetSignalDisplayName(item.groupIndex, item.signalIndex, trimmed); + m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed); break; } - case StyleEditField::FILE_NAME: + case StyleEditField::GROUP_TITLE: { if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) { break; } - m_dataSetWritable->SetGroupDisplayName(m_rows[static_cast(row)].groupIndex, trimmed); + m_dataSetWritable->RenameGroup(m_rows[static_cast(row)].groupIndex, trimmed); break; } default: @@ -2596,7 +2623,7 @@ namespace jdi case StyleEditField::OPACITY: return m_opacityValueRect; case StyleEditField::SIGNAL_NAME: - case StyleEditField::FILE_NAME: + case StyleEditField::GROUP_TITLE: // `row < 0` = 样式区「目标」行:输入框只覆盖**信号名**那一块, // 不得把名称后面的 GX 标签也包进去(用户 2026-10-16 报告)。 if (m_editRow < 0) @@ -3450,14 +3477,18 @@ namespace jdi } if (part == RowPart::CLOSE) { - // 关闭文件属于数据层操作,交由宿主窗口处理;但**抬起时才生效**, + // 关闭分组属于数据层操作,交由宿主窗口处理;但**抬起时才生效**, // 这里只记录目标(按住后移开再松开视为放弃)。 m_pendingClosePart = RowPart::CLOSE; m_pendingCloseRow = row; return true; } - // 折叠 / 展开分组走「带动画 + 悬浮时先滚动就位」的统一入口 - //(用户 2026-10-16 要求)。 + // 折叠 / 展开分组:**只有**点到「折叠三角」或「组X」标签才生效 + //(用户 2026-10-16 要求:点到分组名本体不折起)。 + if (part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE) + { + return true; + } ToggleGroupFromRow(item.groupIndex, row); return true; } @@ -3528,7 +3559,7 @@ namespace jdi } else { - // wParam = 文件下标,lParam = 文件内信号下标(见 Application 的消息处理)。 + // wParam = 分组下标,lParam = 组内信号下标(见 Application 的消息处理)。 PostMessageW(m_host, WM_APP + 2, static_cast(item.groupIndex), static_cast(item.signalIndex)); } @@ -3673,7 +3704,7 @@ namespace jdi } // 重命名分组会改变列表行高度以外的展示内容,重建行缓存以保证文字刷新。 m_rowsDirty = true; - return BeginEdit(StyleEditField::FILE_NAME, row); + return BeginEdit(StyleEditField::GROUP_TITLE, row); } // 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。 @@ -3705,7 +3736,7 @@ namespace jdi const ListRow& item = m_rows[static_cast(row)]; if (item.kind == RowKind::GROUP) { - OpenFileMenu(item.groupIndex, x, y); + OpenGroupMenu(item.groupIndex, x, y); return true; } @@ -3759,13 +3790,13 @@ namespace jdi } m_menuTargetSignal = ref; - m_menuTargetFile = -1; + m_menuTargetGroup = -1; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; - header.label = signal->Title(); + header.label = signal->name; items.push_back(header); MenuItem toggle; @@ -3827,7 +3858,7 @@ namespace jdi m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } - void SignalPanel::OpenFileMenu(int groupIndex, int x, int y) + void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { @@ -3840,14 +3871,14 @@ namespace jdi return; } - m_menuTargetFile = groupIndex; + m_menuTargetGroup = groupIndex; m_menuTargetSignal = SignalRef(); std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; - header.label = group->Title(); + header.label = group->name; items.push_back(header); MenuItem showAll; @@ -3887,7 +3918,7 @@ namespace jdi } m_menuTargetSignal = ref; - m_menuTargetFile = -1; + m_menuTargetGroup = -1; std::vector items; @@ -4089,25 +4120,25 @@ namespace jdi needRelayout = true; break; case CMD_GROUP_SHOW_ALL: - if (m_menuTargetFile >= 0) + if (m_menuTargetGroup >= 0) { - m_tab->SetGroupVisible(m_menuTargetFile, true); + m_tab->SetGroupVisible(m_menuTargetGroup, true); m_rowsDirty = true; needRelayout = true; } break; case CMD_GROUP_HIDE_ALL: - if (m_menuTargetFile >= 0) + if (m_menuTargetGroup >= 0) { - m_tab->SetGroupVisible(m_menuTargetFile, false); + m_tab->SetGroupVisible(m_menuTargetGroup, false); m_rowsDirty = true; needRelayout = true; } break; case CMD_GROUP_REMOVE: - if (m_menuTargetFile >= 0 && m_host != nullptr) + if (m_menuTargetGroup >= 0 && m_host != nullptr) { - PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetFile), 0); + PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetGroup), 0); } break; case CMD_LANE_NEW: diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index ad305b6..9883f90 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -1,5 +1,6 @@ #include "TabBar.hpp" +#include "UiControls.hpp" #include #include @@ -381,7 +382,7 @@ namespace jdi if (wasClose) { // 关闭 ✕ 只在"按下后仍在 ✕ 上抬起"时生效:按住后移开再松开视为放弃 - // (与信号面板的文件/信号 ✕ 保持一致的交互语义)。 + // (与信号面板的分组/信号 ✕ 保持一致的交互语义)。 if (UiPointInRect(m_tabs[static_cast(index)].closeRect, static_cast(x), static_cast(y))) { diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 5fe7043..2644128 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -20,7 +20,7 @@ namespace jdi enum class ToolbarAction : int { NONE = 0, /**< 未触发任何动作。 */ - OPEN_FILE, /**< 打开 CSV 文件。 */ + OPEN_FILE, /**< 打开数据文件。 */ FIT_ALL, /**< 视图自适应到可见信号范围(同时退出缩放模式)。 */ AUTO_FIT_Y, /**< 切换「选区 Y 轴自适应」全局开关:时间轴选区放大后自动匹配各网格 Y 轴。 */ ZOOM_SELECT, /**< 切换「选区放大」模式(横向拖拽放大时间轴,纵向拖拽放大该网格幅值)。 */ diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 619f2e3..e53d434 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1367,12 +1367,12 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( * **新增 `TimeAxis`**:`{ name, time, minTime, maxTime, monotonic, finiteCount }`。 * **`SignalData`**:删除 `rawHeader`(`name` 的副本)与 `unit`(由列名猜出、全仓无人使用); 新增 **`axisIndex`** 指向所属时间轴 ⇒ 采样值与时间的对应关系**自包含**,不再需要「文件」。 - 名字只剩两个且职责明确:`name`(源名称,永不改写)/ `displayName`(用户重命名)。 -* **`SignalGroup`(原 `DataFile`)**:`{ name(默认=源文件名), displayName, axes[], signals[] }`; + ⚠️ 名称字段**只有一个 `name`**(2026-10-16 起去掉 `displayName` 与 `Title()`,见 §9.37 / 第 87 条)。 +* **`SignalGroup`(原 `DataFile`)**:`{ name(默认=源文件名), axes[], signals[] }`; **删除** `filePath` / `fileName` / `time` / `timeColumnName` / `timeMonotonic` / `minTime` / `maxTime`。 新增 `AxisOf(signal)` / `AxisAt(i)` / `TimeRange(low, high)`(多轴并集)。 * **`SignalRef::fileIndex` → `groupIndex`**;`DataSet`:`AddGroup` / `RemoveGroup` / `GroupAt` / `GroupCount` / - `Groups()` / `SetGroupDisplayName` / `RemapSelectionAfterGroupRemoval` + 新增 **`Axis(ref)`**。 + `Groups()` / **`RenameSignal` / `RenameGroup`** / `RemapSelectionAfterGroupRemoval` + 新增 **`Axis(ref)`**。 * `TabConfig`:`SetGroupVisible` / `GroupHasVisible` / `GroupAllVisible` / `RemapAfterGroupRemoval`; `RowKind::GROUP`、`CMD_GROUP_*`。 * **采样/统计 API 首参由 `const DataFile&` 改为 `const TimeAxis&`**: @@ -2091,7 +2091,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( #### 三、筛选态折叠三角状态修正 - **用户报告**:筛选时分组行的小三角维持在了筛选前的状态,展开/折起都不变化。 -- **根因**:`PaintFileRow` 直接读 `m_tab->IsCollapsed(...)`(普通模式的折叠状态), +- **根因**:`PaintGroupRow` 直接读 `m_tab->IsCollapsed(...)`(普通模式的折叠状态), 而筛选模式用的是**独立**的 `m_filterCollapsed`(§9.35 二)⇒ 筛选里折叠时三角形不变。 - **修法**:改为统一的 `IsGroupCollapsed(groupIndex)`(按当前模式分派)。 - **验证**:离屏像素对比 —— 「筛选态折叠」与「普通态折叠」的组名行(含三角)**逐像素 0 差异**; @@ -2131,6 +2131,95 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.37 分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ `Components/Shared` 组件 + 二进制瘦身(本轮,本机) + +> 本轮(用户 2026-10-16 第三轮,本机 = 另一台设备)。报告: +> `Documents/refactor_report_2026-10-16.md`(已删除上一轮的 `refactor_report_2026-10-15.md`)。 +> 离屏验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**; +> A/B **10 项如实失败**;`make release` / `make debug` 零警告。 + +#### 一、分组折起热区:只有折叠三角与「组X」标签可折起 + +- **需求**:点分组**名称**不折起;只有折叠三角或「组X」标签才折起。 +- **实现**:`RowPart` 新增 **`GROUP_TITLE`**;`RowHitPart()` 为分组行判 + 三角 → 复选框 → 关闭 ✕ → **「组X」标签矩形(`checkRect.Right + 8*scale` 起,宽 `GroupLabelWidth`)** + → 否则 `BODY`;`OnLButtonDown` 仅在 `DISCLOSURE` / `GROUP_TITLE` 时调用 `ToggleGroupFromRow()`。 +- 附带:双击「组X」不再进入改名(该区域现为折起热区);双击分组名仍改名。 + +#### 二、分组改名输入框不再吞掉「N 项」 + +- **根因**:`RowNameRect()`(分组行)右边界原先到关闭 ✕ 左侧,把右侧项数文本也框了进去。 +- **实现**:右边界改为 `项数左边缘 − 6*scale`;新增 **`GroupLabelText(groupIndex)`** 与 + **`GroupStatsText(row)`** 作为“绘制 / 命中 / 宽度”的唯一文本来源(`PaintGroupRow` 与 + `RowNameRect` 共用),避免名称宽度与实际绘制文本漂移。 + +#### 三、`SignalData` / `SignalGroup` 只保留**一个**名称字段 + +- **依据**(用户):信号靠 `SignalRef` 下标定位、从不按名字匹配 ⇒ `name` + `displayName` + 是同一语义的两份副本。 +- **改动**:删除 `displayName`;`name` 成为唯一名称字段(重命名即写入它); + **删除 `Title()`**(31 处调用点改为 `.name` / `->name`); + `DataSet::SetSignalDisplayName` → **`RenameSignal`**、`SetGroupDisplayName` → **`RenameGroup`**。 +- ⚠️ **语义收紧**:`RenameSignal/RenameGroup` 对**空串 / 纯空白一律拒绝**(不再表示“恢复源名称”) + —— 名称不再有回退值。 +- 另删除 `Application::m_loadingBytes`(**全仓既不读也不写**的死成员)。 + +#### 四、新增 `Components/Shared` 组件(通用 UI 控件) + +``` +Components/Shared/Inc/UiControls.hpp +Components/Shared/Src/UiControls.cpp +``` + +- 从 `Utilities/PaintUtil.*` **迁出**全部“控件类”绘制/命中:图标(+ / ✕ / 三角)、复选框、 + 滑块(`UiSliderRatioFromX` / `UiDrawSlider`)、内联输入框(`UiDrawEditField` / + `UiDrawEditFieldContent` / `UiEditTextLeft` / `UiEditOffsetAt` / `UiEditFieldMouseDown` / + `UiEditFieldMouseMove`)、颜色样块(`UiDrawSwatch` / `UiSwatchNeedsOutline`)、 + 取值胶囊配色(`UiInk` / `UiColorLuminance` / `UiContrastInk`)。 +- `PaintUtil.*` 只保留**低层绘制原语**(矩形 / 圆角 / 直线 / 批量实线 / 颜色混合 / 文本与测量)。 +- ⚠️ **依赖方向固定**:`Components/* → Components/Shared → Utilities`; + `Utilities` **不得**再包含任何控件代码(也不要 include `UiControls.hpp`)。 +- ⚠️ 新增组件目录必须同时登记进 `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS` + 以及 `.preflight/perf/build.ps1` 的 `$includes` / `$srcDirs`。 + +#### 五、二进制瘦身:1 013 248 → **754 176 B(−25.6%)** + +- **真因**是 **`Resources/JustDataInspector.ico` 的 256×256@32bpp 条目以未压缩 BMP 存储 + (270 376 B,占整包 27%)**,而不是 `PlotView.cpp`。改为 **PNG 存储**(Vista+ 标准做法, + 观感零变化)⇒ 该条目 270 376 → **11 280 B**、整个 ico 302 430 → **43 334 B**。 +- ⚠️ **拆分大文件不会减小机器码**(“拆分 ⇒ 瘦身”不成立);实测 + `-ffunction-sections -fdata-sections -Wl,--gc-sections` 在本工程**反而 +9 216 B**(已回退)。 +- ⚠️ `.o` 体积受节对齐/重定位表主导,**判断“代码搬去哪了”要看 `nm`,不要看 `.o` 字节数**。 + +#### 六、过时注释 / 命名批量修正(承接上一轮未做完的部分) + +- 参数与成员注释:`文件下标`→`分组下标`、`文件内信号下标`→`组内信号下标`、`所属文件下标`→`所属分组下标` + 等一批;措辞统一 `文件分组`→`分组`、`移除该文件`→`移除该分组`、`打开 CSV 文件`→`打开数据文件` …; +- **文不对题的形参名**:`VisibleIndexRange(const TimeAxis& file, …)` → `axis`; + `Application::LoadFileAsync(…, SignalGroup& file, …)` → `group`; +- **函数名里的 “File 语义”**(上一轮只改了 `RowKind::FILE`):`PaintFileRow`→`PaintGroupRow`、 + `FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、 + `FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`; + 局部变量 `fileRow/fileHeight/isFile` → `groupRow/groupHeight/isGroup`; +- 修复 `DataModel.hpp` 两处被压扁的注释块(`/**\t\t * @brief …`)与 `Application::NewTab` + 上方重复的文档块; +- ⚠️⚠️ **新增标识符前必须确认不是 Windows 头里的宏**:本轮 `GROUP_NAME` 撞上 + `nb30.h`(由 `windows.h` 引入)的 `#define GROUP_NAME 0x80`,导致枚举编译失败,改名 + `GROUP_TITLE` 解决。同类高风险名:`GROUP_NAME`、`TIME`、`SMALL`、`NEAR`、`FAR`、`IGNORE`。 + +#### 七、本轮**未**完成(诚实记录,勿误以为已完成) + +1. **`PlotView.cpp`(13 695 行 / 544 KB)按功能拆分**:未执行。前置改造是把第 13–888 行的 + 匿名命名空间(~870 行常量与工具函数)改成具名命名空间并**逐个 `inline`**, + 再做 5 个协作文件的搬移;必须“搬一类 → 编译 → 跑四套探针”反复迭代,本轮时间不足以安全完成。 + ⚠️ 切勿草率动手 —— 它会直接影响 `Paint()` 分支顺序与图层/装饰缓存不变量(见 §11.2)。 +2. `SignalPanel.cpp`(145 KB)/ `Application.cpp`(104 KB)/ `DataModel.cpp`(88 KB)的多文件化: + 未执行。三者中 **`SignalPanel.cpp` 的辅助区只有 ~60 行,是性价比最高的下一个拆分入口**。 +3. `Lane` 抽出 `LanePanelState`(内聚性重构):未执行。 +4. `SetStatusMessage(..., isError)` 的模态阻塞解耦:未执行(需要用户拍板提示样式)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -2180,6 +2269,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 | | 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起/展开动画 + 筛选态可折起(项数按筛选计数)+ 悬浮组名可交互与"先滚动再折起" + 「适应视图」与「Y 轴自适应」彻底同源(详见 §9.35)**。① **折起动画**:`SignalPanel::m_groupAnims` + `GroupRowScale()` —— 每行速度倍率 `1 + 1.2×ordinal/(行数−1)`(**离组名越远越快收尽**)叠加 ease-out cubic(末段减速),总时长 `clamp(115+14×行数, 140, 300)` ms;`BuildRows` 输出**非整行高**,低于 0.75 逻辑 px 的行不再产生;宿主 `WM_TIMER` 同时驱动滚动与折起动画(`Render` 判定改为二者取或);新增 `SetRowClip/ClearRowClip` 把行内容裁到本行(否则垂直居中文本溢出到相邻行)。② **筛选态折起**:筛选模式改用**独立**的 `m_filterCollapsed`(默认展开,不污染普通列表),`ListRow::displayCount` 承载"该组内符合筛选条件的个数",筛选文本/清空/数据变化即清空。③ **悬浮组名可交互**:根因是 `HitRow` 只按滚动偏移映射行 ⇒ 点击穿透到下层;修法:`HitRow` 先用 `StickyGroupRowIndex()`+`StickyGroupRowTop()` 判定悬浮行矩形并临时用 `m_rowYOverrideActive` + 新的 `RowHitPart()` 算行内区域,并跳过高度为 0 的行;新增 `ToggleGroupFromRow()`:正悬浮时 `StartScrollAnimationTo(行内容偏移, groupIndex)`(挂起请求 `m_pendingGroupToggle`),滚动到位后才启动折起动画。④ **单点信号上两种自适应不一致**:`FitSinglePointCluster` 曾另算"半径留白 + 跨度 1"且把 `fitYToSelection` 置回 false ⇒ 点「适应视图」既改区间又**关掉 Y 轴自适应**;修法:单点簇只负责时间轴(居中 + 一大格 1s),**逐网格幅值轴与常规路径/Y 轴自适应完全同源**(`fitYToSelection` → `AutoRangeLaneToView`,否则 `autoRange=true`,再接 `ApplySinglePointValueMargins`),且**绝不改写** `fitYToSelection`。验证:新增离屏探针 `.preflight/perf/panelanim_main.cpp`(真实 `Application` + `SW_HIDE`)**94/94 通过**(连跑 3 次稳定;覆盖折起/展开的分帧过渡、单调收敛、越靠下越矮、末态精确无残留、筛选计数与独立折起状态、悬浮四个可交互区域全部命中且下层行仍可点、点击后"先滚动到位再折起"、真实 MF4 单点信号两种自适应逐位一致/往返稳定);**A/B** 三组(回退绘图区修复 / 禁用悬浮命中优先 / 动画时长置 0)分别 **4 / 13 / 8 项如实失败**;既有 `verify` **49/49**(顺带修正 4 条与远端新规则不符的过期断言)、`datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。 | | 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **动画改「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(详见 §9.36)**。① **动画重做**(推翻上一轮的“按行缩放”):整组信号作为**刚性整体**平移,**行高永不变**、只维护一个逐行平移量 `m_rowShift` ⇒ 动画期间**无需重建行集合**(流畅关键);收起时逐条被组名栏吞掉、展开时逐条冒出;中途反向点击**从当前位移续接**不跳变;时长 `clamp(115+6×行数, 150, 320)` ms。② **藏进组名栏的裁剪**:信号行越过组名的**绘制**下边界即裁掉,且除 GDI 裁剪外**必须用 `g.SetClip(..., CombineModeIntersect)` 同时收紧 GDI+ 裁剪**(否则行内色块/复选框/徽标画到组名栏上)。③ **悬浮组名折起改为“一边滚动一边收起”**(推翻“先滚动到位再折起”):`scrollSynced` 让滚动与平移按同一进度推进 ⇒ 组名屏幕位置恒定;结束时滚动偏移恰好落在“组名正好在最上方”,并且**无跳变**;旧 `m_pendingGroupToggle` 机制删除。④ **筛选态折叠三角**:`PaintFileRow` 改读 `IsGroupCollapsed()`(筛选模式用的是独立的 `m_filterCollapsed`),修复“三角维持筛选前状态”。⑤ **「目标」行**:`PaintTargetRow` 误用列表裁剪版 `PaintRowText` ⇒ 名称与 GX 文本被完全裁掉(改用 `UiText`);GX 标签改为**右对齐到内容列右边缘**、名称无标签时占满内容列;`EditFieldRect()` 对目标行只返回名称矩形(否则改名输入框把 GX 标签包进去)。验证:离屏探针 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定;含逐帧断言组名栏像素零变化、`m_rowsDirty` 恒 false、滚动单调且恰好落位、筛选/普通态折叠三角逐像素 0 差异、目标行文本像素存在与两端对齐);**A/B** 五组(回退筛选三角+非刚性平移 / 回退目标行绘制与布局 / 关闭同步滚动 / 去掉 GDI+ 收紧裁剪 / 不续接位移)分别 **4 / 6 / 6 / 2 / 1 项如实失败**;`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 | +| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ 新增 `Components/Shared` 组件 + 过时注释/命名大清理 + 二进制瘦身 25.6%(详见 §9.37 与本轮报告 `Documents/refactor_report_2026-10-16.md`)**。① **折起热区**(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签**(新增 `RowPart::GROUP_TITLE`)才折起。② **分组改名输入框不再吞掉「N 项」**:`RowNameRect` 右边界改为项数左缘;新增 `GroupLabelText`/`GroupStatsText` 作为文本与宽度的唯一来源。③ **`SignalData`/`SignalGroup` 只留一个名称字段**:删除 `displayName` 与 `Title()`(31 处改用 `name`),`SetSignalDisplayName`/`SetGroupDisplayName` → **`RenameSignal`/`RenameGroup`**(空名一律拒绝);删除死成员 `Application::m_loadingBytes`。④ **新增 `Components/Shared`**(用户要求):从 `PaintUtil` 迁出全部**控件**(图标/复选框/滑块/内联输入框/颜色样块/取值胶囊配色)到 `UiControls.hpp/.cpp`,`PaintUtil` 只留低层原语;依赖方向固定 `Components/* → Components/Shared → Utilities`。⑤ **过时注释/命名**:`文件下标`→`分组下标`、`文件分组`→`分组` 等一批;形参名 `file`→`axis`/`group`;`PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、`FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`;修复 `DataModel.hpp` 两处被压扁的注释块与 `NewTab` 重复文档块。⑥ **二进制瘦身**:真因是 **ICO 里 256×256 未压缩 BMP 条目(270 KB,占整包 27%)**,改为 **PNG 存储**(观感零变化)⇒ `Target.exe` **1 013 248 → 754 176 B(−25.6%)**;实测“拆分大文件不减小机器码”、`--gc-sections` 反而 +9 KB(已回退)。验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;**A/B** 回退两处 UI 修复 **10 项如实失败**;`make release`/`make debug` 零警告。⚠️ **未完成(已如实记录)**:`PlotView.cpp`(13.7 K 行)与其它大文件的多文件化拆分、`LanePanelState` 抽取、模态提示解耦。 | --- @@ -2505,7 +2595,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 筛选只是临时子集视图,不该污染普通列表的布局选择;筛选文本变化 / 清空筛选 / 数据变化时 一律清空它以及进行中的折起动画(行数与分组可见性都变了)。 组名后方的项数必须按当前模式显示:普通 = 信号总数,筛选 = 该组内**符合筛选条件**的个数 - (`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintFileRow` 只读它)。 + (`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintGroupRow` 只读它)。 ⚠️ 推论:**所有“读取折起状态”的地方都必须用 `IsGroupCollapsed()`(按模式分派)**, 不能直接读 `m_tab->IsCollapsed()` —— 否则筛选态折叠时**折叠三角不会变** (用户 2026-10-16 报告;已用“筛选态 vs 普通态折叠后组名行逐像素 0 差异”锁定)。 @@ -2530,8 +2620,36 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( **无 GX 标签时名称可用宽度 = 整个内容列**(因此更宽,仍左对齐)。 ⚠️ `EditFieldRect()` 对 `m_editRow < 0` 必须返回 `m_targetNameRect`(**只管名称那一块**), 不能返回内容列全宽 —— 否则改名输入框会把 GX 标签包进去(用户报告)。 -84. ⚠️ **筛选态的折叠三角必须用 `IsGroupCollapsed()`**(见第 79 条推论):`PaintFileRow` 直接读 +84. ⚠️ **筛选态的折叠三角必须用 `IsGroupCollapsed()`**(见第 79 条推论):`PaintGroupRow` 直接读 `m_tab->IsCollapsed()` 时,筛选模式里折叠/展开三角**不会变化**(用户 2026-10-16 报告)。 +85. ⚠️ **分组折起的热区只有两处**(用户 2026-10-16 第三轮要求):**折叠三角**(`RowPart::DISCLOSURE`) + 与**「组X」标签**(`RowPart::GROUP_TITLE`)。点击分组**名称本体**(`BODY`)**不得折起** + (也不要触发滚动)。`RowHitPart()` 必须为分组行判出 `GROUP_TITLE`(标签矩形从 + `checkRect.Right + 8*scale` 起、宽 `GroupLabelWidth(row)`);`OnLButtonDown` 只在 + 这两个区域调用 `ToggleGroupFromRow()`。 +86. ⚠️ **分组改名输入框不得覆盖右侧的「N 项」计数**(用户 2026-10-16 报告): + `RowNameRect()` 对分组行的右边界 = `项数左边缘 − 6*scale`。 + ✓ 项数文本与它的宽度必须走**同一个来源** `GroupStatsText(row)`(`GroupLabelText(groupIndex)` + 同理),否则“名称可用宽度”与“实际绘制文本”会漂移。 +87. ⚠️ **`SignalData` / `SignalGroup` 只有一个名称字段 `name`**(用户 2026-10-16 明确要求): + 它既是加载时的列名 / 通道名,也是用户重命名写入的地方;**不要再引入 `displayName` + 或 `Title()` 之类的第二份副本**(信号靠 `SignalRef` 下标定位,从不按名字匹配)。 + `RenameSignal(group, signal, name)` / `RenameGroup(group, name)` 对**空串 / 纯空白一律拒绝** + (名称不再有“回退值”)。 +88. ⚠️ **通用 UI 控件属于 `Components/Shared`**(用户 2026-10-16 要求建此组件): + 图标 / 复选框 / 滑块 / 内联输入框 / 颜色样块 / 取值胶囊配色都在 + `Components/Shared/Inc/UiControls.hpp`;`Utilities/PaintUtil.*` **只保留低层绘制原语**。 + 依赖方向固定 `Components/* → Components/Shared → Utilities`,**不得反向**。 + 新增组件目录要同时登记 `Makefile`(`CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`)与 + `.preflight/perf/build.ps1`。 +89. ⚠️⚠️ **新增标识符前必须确认不是 Windows 头里的宏**:`windows.h` 会引入 `nb30.h` 的 + `#define GROUP_NAME 0x80` ⇒ 用它做枚举成员会编译失败(“expected identifier before numeric + constant”)。同类高风险名:`GROUP_NAME`、`TIME`、`SMALL`、`NEAR`、`FAR`、`IGNORE`、`DELETE`。 +90. ⚠️ **二进制体积的真正大头是 ICO 里未压缩的 256×256 条目**(本轮实测占整包 27%): + 应把它按 **PNG** 存储(Vista+ 标准,观感零变化),小尺寸保留 BMP。 + ⚠️ **拆分大文件不会减小机器码**;“拆分 ⇒ 瘦身”不成立。 + ⚠️ `-ffunction-sections/-fdata-sections/--gc-sections` 在本工程**反而增大 9 KB**(已回退)。 + ⚠️ `.o` 字节数受节对齐/重定位表主导,**判断“代码搬去哪了”要用 `nm`,不要看 `.o` 大小**。 71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**: 否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号, `Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上, @@ -2579,6 +2697,12 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( ## 12. 起源设备详细工程记忆(原文附录) +> ⚠️⚠️ **时效声明**:本节是**历史原文**,成文于数据层大重构与后续多轮修改**之前**。 +> 其中出现的 `DataFile` / `fileName` / `SignalData::displayName` / `Title()` / +> `SetSignalDisplayName` / `SetFileDisplayName` / `PaintFileRow` / `FileCloseRect` / +> `FileGroupLabelWidth` 等**符号已全部更名或删除**,请勿照抄。 +> 现行约定一律以 **§1–§11** 为准(§11.2 是硬约束权威清单)。 + > 以下为起源设备(本机)Agent 的**详细工程记忆原文**,与 §1–§10 同处**一个记忆文件**(不再另建文件)。 > ⚠️ 其它设备更新本文件时**请保留本节**;可继续在本节之后追加。 diff --git a/Documents/refactor_report_2026-10-15.md b/Documents/refactor_report_2026-10-15.md deleted file mode 100644 index 2bb5ccf..0000000 --- a/Documents/refactor_report_2026-10-15.md +++ /dev/null @@ -1,245 +0,0 @@ -# 数据层审计与重构报告(2026-10-15) - -> 本轮任务:① 审计代码库的命名/参数/注释/数据结构;② 在审计基础上做一次解耦重构(大刀阔斧, -> 但**保持全部交互与显示不变**);③ 完善 CSV(多时间轴 / 各轴不等长)并新增 MF4 支持。 -> -> 结论:**数据层与加载层被重写为「分组 / 时间轴 / 信号」三段式结构**,文件格式与数据模型彻底解耦; -> CSV 多时间轴与 MF4 均已支持;三套离屏测试(69 + 46 + 47 条断言)通过;`make release` 零警告。 - ---- - -## 一、审计发现的问题(按严重度) - -### 1.1 「文件」概念渗进了数据模型的核心(最严重) - -| 位置 | 问题 | -| --- | --- | -| `DataFile`(结构体名) | 名字把「一次加载产生的信号集合」和「磁盘上的文件」绑死;实际应用语义是**信号分组**。 | -| `DataFile::filePath` / `fileName` | 加载完成后应用与文件再无关系(文件可移动/删除/改名),这两个字段在运行期**没有任何用途**(`filePath` 只在加载消息里出现一次)。 | -| `DataFile::time` / `timeColumnName` / `timeMonotonic` / `minTime` / `maxTime` | 把「时间轴」焊死在分组上 ⇒ **一个分组只能有一条时间轴**,与 CSV 多 `time` 列、MF4 多通道组的现实不符。 | -| `SignalRef::fileIndex` | 引用语义被写成「文件下标」,全仓 223 处;后来者会以为信号与文件有绑定关系。 | -| `EdgeCountsFor(const DataFile& file, const SignalData& signal, …)` | **典型文不对题**:函数只需要「时间 + 采样值」,却要求调用方提供整个「文件」;这也是用户指出的重点。 | -| `SampleSignalAt(const DataFile& file, …)`、`FindNearestSample(const DataFile& file, …)`、`VisibleIndexRange(const DataFile& file, …)`、`EdgeIndexRange(const DataFile& file, …)`、`MeanFilterValueAt`、`WeakPeakValueAt` | 同上一类:参数语义过宽,时间轴与信号被迫通过「文件」间接耦合。 | -| `ResolvedSignal::file` | 视图层的解析结果也携带「文件」指针,导致采样/命中/包络/边沿统计处处 `resolved.file->time`。 | - -### 1.2 `SignalData` 的冗余字段(用户指出的重点) - -| 字段 | 问题 | 结论 | -| --- | --- | --- | -| `name` | 源名称(列名/通道名)。 | **保留**,语义明确:加载后原样不变。 | -| `displayName` | 用户重命名。 | **保留**(`Title()` 优先返回它)。 | -| `rawHeader` | `name` 的副本(构建时 `rawHeader = headers[i]; name = Trim(rawHeader)`),派生信号里又 `rawHeader = derived.name`。 | **删除**(纯冗余,两个字段表达同一件事)。 | -| `unit` | 由列名括号里猜出来的单位,全仓只有 2 处写、0 处读(从未显示、从未参与计算)。 | **删除**(属于「未实现的元数据」残留;用户明确说单位元数据暂不支持)。 | -| 时间信息 | 缺失 ⇒ 只能靠「文件」找时间轴。 | **新增 `axisIndex`**:信号指向所属时间轴,采样值与时间的对应关系自包含。 | - -### 1.3 加载层与格式耦合 - -| 位置 | 问题 | -| --- | --- | -| `CsvTable`(结构体) | 「从文件读出来的原始表格」这个名字把通用中转结构钉死在 CSV 上;后续每加一种格式都要再抄一遍类似结构。 | -| `CsvProgress` / `CsvProgressInfo` | 名字里的 `Csv` 让「加载进度」这类通用概念只服务于 CSV。 | -| `BuildDataFile(CsvTable&&, path, …)` | 建组函数直接吃 CSV 解析结果;MF4(或任何其它格式)都无法复用,只能复制一份逻辑。 | -| `bool BuildDataFile(...)` 里的「识别时间轴列 / 回退第一列」逻辑 | 与「多时间轴」需求正面冲突:只认**第一个**时间列,且要求所有列等长。 | -| `.preflight` 之外无任何「格式 → 数据」的装配点 | 新增格式要在 `Application::RunAsyncLoad` 里改流程。 | - -### 1.4 过期 / 文不对题的注释 - -| 位置 | 问题 | -| --- | --- | -| `LooksLikeTimeHeader` | 判据是「列名含 time/时间」⇒ 会把 `timeout`、`TimeStampExtra` 之类误判成时间轴(多时间轴下会**切出空块**)。 | -| `ExtractUnit` | 解析出来的单位无人使用(见 1.2)。 | -| 大量 `@param [IN] file 信号所属文件` | 参数已改语义后注释未跟(本轮全部改为 `axis 该信号使用的时间轴`)。 | -| UI 文案 | `选中该文件全部信号` / `取消该文件全部信号` / `关闭该文件(从工作区移除)` / `请关闭部分文件分组或信号` 等,把「分组」说成「文件」。 | -| 空状态文案 | `尚未加载数据,请打开 CSV 文件` / `打开 CSV 文件以查看仿真数据波形`:应用已不只支持 CSV。 | - -### 1.5 健壮性缺陷(审计中顺带发现) - -| 位置 | 问题 | -| --- | --- | -| `Application::Initialize` | `RegisterClassExW` 失败即弹**模态**错误框。同一进程内第二次构造 `Application` 必然命中 `ERROR_CLASS_ALREADY_EXISTS` ⇒ 调用方**永久阻塞**(本轮离屏测试就被它卡住)。`Shutdown` 也从不注销窗口类。 | -| `Application::LoadFile` 失败路径 | 会弹模态对话框(离屏/自动化场景是雷区)。已在测试中绕开;建议后续把「错误提示」与「模态阻塞」解耦。 | -| MDF 文本块解析 | `##TX` 在 MDF 规范里是「长度 + UTF-16/UTF-8」,但 CANape 16 会写**无长度前缀的裸 ASCII**(见 §三),只按规范解析会得到空名称。 | - ---- - -## 二、重构内容(改动清单) - -### 2.1 数据模型(`Utilities/Inc/DataModel.hpp`、`Utilities/Src/DataModel.cpp`) - -**新增 `TimeAxis`(时间轴成为一等公民)** - -```cpp -struct TimeAxis { - std::wstring name; // 时间列名(源文件原样) - std::vector time; - double minTime, maxTime; - bool monotonic; - std::size_t finiteCount; - std::size_t Count() const; -}; -``` - -**`SignalData` 精简 + 指向时间轴** - -```cpp -struct SignalData { - std::wstring name; // 源名称(原样保留、永不修改) - std::wstring displayName; // 用户重命名(空 = 用 name) - std::size_t axisIndex = 0; // 所属时间轴(分组内下标) ← 新增 - std::vector values; - double minValue, maxValue; std::size_t finiteCount; - std::vector coarseMin, coarseMax; std::size_t coarseStride; - SignalStyle style; - const std::wstring& Title() const; -}; -// 删除:rawHeader、unit -``` - -**`DataFile` → `SignalGroup`(分组 = 若干时间轴 + 若干信号)** - -```cpp -struct SignalGroup { - std::wstring name; // 默认取源文件名(便于辨认来源) - std::wstring displayName; // 用户重命名 - std::vector axes; // 1..N 条时间轴 ← 取代单一 time - std::vector signals; - const std::wstring& Title() const; - const TimeAxis& AxisOf(const SignalData&) const; - const TimeAxis& AxisAt(std::size_t) const; - bool TimeRange(double& low, double& high) const; // 全部轴的并集 -}; -// 删除:filePath、fileName、time、timeColumnName、timeMonotonic、minTime、maxTime -``` - -**引用与集合** - -* `SignalRef::fileIndex` → **`groupIndex`**(含注释:「分组是应用内的逻辑容器,与磁盘文件没有任何关系」)。 -* `DataSet`:`AddDataFile→AddGroup`、`RemoveFile→RemoveGroup`、`FileAt→GroupAt`、`FileCount→GroupCount`、 - `Files()→Groups()`、`SetFileDisplayName→SetGroupDisplayName`、 - `RemapSelectionAfterFileRemoval→RemapSelectionAfterGroupRemoval`;新增 **`Axis(ref)`**(解析信号→时间轴)。 -* `TabConfig`:`SetFileVisible→SetGroupVisible`、`FileHasVisible→GroupHasVisible`、`FileAllVisible→GroupAllVisible`、 - `RemapAfterFileRemoval→RemapAfterGroupRemoval`、`RowKind::FILE→RowKind::GROUP`、`CMD_FILE_*→CMD_GROUP_*`。 - -**采样与统计 API 去掉「文件」** - -```cpp -bool SampleSignalAt(const TimeAxis&, const SignalData&, double time, bool interpolate, double&); -bool SampleDisplayedSignalAt(const TimeAxis&, const SignalData&, const SignalStyle&, …); -int FindNearestSample(const TimeAxis&, double time); -// PlotView:SampleSpacingPixels / VisibleIndexRange / EdgeIndexRange / -// MeanFilterValueAt / WeakPeakValueAt / EdgeCountsFor / -// EdgeCenterCountsFor / EdgeScanFor —— 首参全部由 const DataFile& 改为 const TimeAxis& -``` - -**视图层** - -* `ResolvedSignal`:`const DataFile* file` → **`const SignalGroup* group` + `const TimeAxis* axis`**; - 所有 `resolved.file->time` 改为 `resolved.axis->time`,`resolved.file->Title()` 保留走 `group`。 -* `PolylineCacheEntry::file` → `group`;`EdgeScanState::file` → **`axis`**(统计身份 = 时间轴 + 信号)。 -* `TabConfig::ComputeVisibleTimeBounds`:只统计**可见信号真正用到的时间轴**(多轴分组下避免拉出过宽范围)。 -* 时间轴刻度列名取「首个可见信号所属分组的第一条时间轴」。 - -### 2.2 加载层(新文件,格式与模型解耦) - -``` -Utilities/Inc/DataTable.hpp DataTable(通用列优先表格)+ LoadProgress/Info - + TableBlock(一个时间轴块)+ LoadedSource(源文件的全部块) -Utilities/Inc/DataLoader.hpp LoadSignalSource(path,…) 唯一入口 + IsSupportedSignalFile + 对话框过滤器 -Utilities/Src/DataLoader.cpp 按扩展名分发(.csv/.txt → CSV;.mf4/.mdf → MDF4;其它按文本尝试) -Utilities/Inc/CsvLoader.hpp LoadCsvSource -Utilities/Src/CsvLoader.cpp CSV 解析 + 多时间轴切分 + 各轴按自己的时间列裁剪 -Utilities/Inc/Mf4Loader.hpp LoadMf4Source -Utilities/Src/Mf4Loader.cpp ASAM MDF v4 解析(见 §三) -删除:Utilities/Inc/CsvTable.hpp、Utilities/Src/CsvTable.cpp -``` - -**唯一装配点**:`BuildSignalGroup(LoadedSource&&, paletteBase, SignalGroup&, error, progress)` -—— 与格式无关,负责:时间轴**去重合并**(内容逐位相同只保留一条)、信号命名、调色板取色、统计与粗粒度包络。 -新增格式时**只需写一个加载器**,数据模型与全部界面代码不用改。 - -### 2.3 CSV:多时间轴 + 各轴不等长 - -* 规则(与 MATLAB/Simulink 说明一致):**只有名为 `time` 的列是时间轴**;每个时间列开启一个新块, - 它之后直到下一个时间列之前的列都属于该块。 -* 各块按**自己的时间列**裁剪(较短的时间轴在数据区提前结束,其余位置为空)⇒ 支持 - `TestData/Multi_Timeline.CSV`(`time,data1,time,data2,data3,time,data4`,实测切出 3 条轴,长度 7/4/7)。 -* 修复用户报告的缺陷:**不再把其它 `time` 列当成信号显示**。 -* 保留旧行为:完全没有 `time` 列时退化为「第一列是时间轴」的单轴文件。 -* 判据收紧:`LooksLikeTimeHeader` 只接受 `t` / `time` / `timestamp` / `simulation time` / `时间` / `时间轴` - (不再用「包含 time」这种会误判 `timeout` 的规则)。 - -### 2.4 MF4:新增支持 - -* 加载后**新建一个分组、组名 = 文件名**,文件内的全部信号都归入该分组(用户要求的行为)。 -* 每个**通道组(CG)**是独立时间轴:内容相同的时间轴自动合并 - (实测该文件 27 个 CG → **3 条时间轴**、124 条信号,避免 25 条重复的单样本轴)。 -* 只解码绘制需要的部分:字符串/字节数组通道、压缩块(DZ/HZ)、MIME 通道一律跳过; - 非数值通道不产生信号。 - -### 2.5 UI 文案与健壮性 - -* 文案统一到「分组」:`选中该分组全部信号`、`取消该分组全部信号`、`关闭该分组(从工作区移除)`、 - `请关闭部分分组或信号后重试。`、`尚未加载数据,请打开数据文件`、`打开数据文件以查看波形`、 - `(可同时加载多个文件,各自成为一个分组)`;打开对话框标题改为「打开测量数据文件」并支持 `*.mf4/*.mdf`。 -* `Application::Initialize`:`RegisterClassExW` 返回 `ERROR_CLASS_ALREADY_EXISTS` 时**复用**已注册的类 - (不再弹模态错误框);`Shutdown` 注销窗口类并销毁窗口。⇒ 同一进程可多次构造/销毁 `Application`。 - ---- - -## 三、MDF v4 解析要点(本轮实测得到,供后续维护) - -文件实测为 **MDF 4.10 / CANape 16.0.50**(`##HD … ##DG … ##CG … ##CN … ##CC … ##DT`)。 - -* **块头(24 字节)**:`id[4]` + `reserved[4]` + `block_len(U64)` + `link_count(U64)`;之后是 - `link_count` 个 **8 字节绝对文件偏移**(0 = 空);数据段紧随链接区。DT/HL 等块可以 0 链接。 -* **ID 块**:偏移 0 起 `"MDF "`,偏移 8 起版本串(如 `"4.10 "`)。 -* **HD**:链接 `[0] = 第一个 DG`。 -* **DG**:链接 `[1] = 第一个 CG`、`[2] = 该组的数据块(DT)`。 -* **CG**:链接 `[1] = 第一个 CN`、`[2] = tx_name`;数据段 `+8 = cycle_count`。 - **记录长度**取 `DT 数据段字节数 / cycle_count`(实测 114/110 字节,与声明一致),拿不到时按通道位宽向上取整。 -* **CN**:链接 `[2] = 名称 TX`、`[4] = 换算 CC`;数据段 - `+0 type`、`+1 sync_type`(**1 = 时间主通道**)、`+2 data_type`、`+3 bit_offset`、 - `+4 byte_offset(U32,单位**字节**)`、`+8 bit_count`、`+12 flags`(bit0 整列无效 / bit1 有无效位)、 - `+16 invalid_bit_pos`、`+32 val_range_min`、`+40 val_range_max`。 - **绝对位偏移 = byte_offset × 8 + bit_offset**。 -* **data_type**:0/1 无符号(LE/BE)、2/3 有符号、4/5 IEEE 浮点、≥6 字符串/字节数组(跳过)。 - 浮点只接受字节对齐的 32/64 位;整数支持 1–64 位任意位偏移(含符号扩展)。 -* **CC**:`+0 type`、`+1 precision`、`+2 flags`、`+4 ref_param_nr`、`+6 val_count`、 - `+8 phy_range_min`、`+16 phy_range_max`、`+24 cc_val[]`(每个 8 字节)。 - **换算公式(实测确认)= `phys = cc_val[0] + cc_val[1] × raw`**。 - 本文件里时间主通道是「double 纳秒 + 线性换算 1e-9」⇒ 解码后时间范围 `[0.024320125, 42.3918] s` - 与 CC 里的物理范围逐位吻合,可据此自检。 -* **文本块(TX)**:规范是「U32 长度 + UTF-16 代码单元」(MDF 4.1 起也可 UTF-8 字节数), - 但 **CANape 会写无长度前缀的 NUL 结尾裸文本**。解析顺序:先按长度前缀(UTF-16 → UTF-8), - 失败则按「裸文本(自动判断 UTF-16/UTF-8)」回退。 - ---- - -## 四、验证(全部离屏;进程/线程 `BELOW_NORMAL`、窗口 `SW_HIDE`、不注入真实鼠标键盘) - -| 套件 | 覆盖 | 结果 | -| --- | --- | --- | -| `.preflight/perf/datacheck_main.cpp` | 单轴 CSV 回归;多时间轴 CSV(3 轴、7/4/7、不再出现 `time` 信号);MF4(组名、124 信号、3 条轴、主时间轴 ns→s、`setSpeed` 首样本与原始字节人工核对);`DataSet` 不变量(轴共享/解析/时间范围/重命名);派生信号沿用源时间轴;**MF4 集成**(124 信号并入一个网格渲染 67.5ms/帧) | **69/69 通过** | -| `.preflight/perf/edgecheck_main.cpp` | 「调整」符号规则(真实菜单路径);边沿统计按帧推进 / 不饿死 / 与参考实现逐位一致 / 无鼠标输入也完成;面板两列等宽与名称限宽;弱峰复位控件 | **46/46 通过** | -| `.preflight/perf/verify_main.cpp`(既有) | PlotView 渲染/交互:Y 自适应、派生信号、图例命中、GX 徽标悬停、装饰层无残影、平移逐像素 | **47/47**(另有 2 条**本机未同步的旧规则断言**:`derived offsetTime kept`、`mouse in lane1 lower half`,与本轮无关) | -| 构建 | `make release` | **零警告** | - -补充:`Target.exe` 手工运行确认加载 `Multi_Timeline.CSV` 与 `MF4-Record.MF4` 正常(真实交互由用户后续验收)。 - ---- - -## 五、遗留与建议(未在本轮实施) - -1. **错误提示与模态阻塞解耦**:`SetStatusMessage(..., isError=true)` 直接 `MessageBoxW`, - 在自动化/离屏场景会永久阻塞。建议改为宿主级非模态提示(或至少让调用方可选择不弹)。 -2. **MF4 压缩块(`##DZ`/`##HL`/`##SD`)与 `##DL` 数据列表**未实现:遇到这类文件会跳过相应通道组。 - 如后续需要,解析 `DL` 的偏移数组即可(数据布局不变)。 -3. **MF4 单元/注释(`##CC` 的 unit TX、`##MD`)未使用**:用户明确暂不需要元数据。 -4. **`Component` 层仍在 `TabConfig` 里保存逐信号的边沿统计/预览参数**:属于「视图配置」与「数据」的 - 第二次耦合(当前可用,但若要支持「同一信号在不同标签页用不同统计参数」需再拆一层)。 -5. **`Lane` 内含 4 类实验性功能的状态与面板坐标**(`edgeCount/meanFilter/weakPeak` + 各自面板 X/Y), - 已经把 `Lane` 撑到 200+ 行;建议后续抽出 `LanePanelState`。 -6. **`PlotView.cpp` 仍有 1.1 万行**:渲染分支(静态缓存/宽基底/样式补丁/整幅)与交互命中混在一个类里, - 建议按「渲染器 / 命中测试 / 派生曲线调度」拆成 3 个协作类(风险较高,宜单独一轮做)。 -7. **`SignalRef` 仍是「分组下标 + 组内下标」**:删除/重排分组时依赖调用方按顺序调用 - `Remap*` 系列函数维护;可考虑改为稳定 ID(后续需要撤销/重做时收益更大)。 diff --git a/Documents/refactor_report_2026-10-16.md b/Documents/refactor_report_2026-10-16.md new file mode 100644 index 0000000..77a6f40 --- /dev/null +++ b/Documents/refactor_report_2026-10-16.md @@ -0,0 +1,283 @@ +# 代码审计与解耦报告(2026-10-16) + +> 本轮任务:① 修两个 UI 交互缺陷;② 继续上一轮(`6eedbe3c5d`)之后的审计与解耦 —— 修正过时注释 / +> 函数与参数命名、消除数据结构冗余成员、抽出 `Components/Shared` 共享组件、按组件目录结构拆分 +> 巨型实现文件;③ 为二进制产物瘦身;④ 全部改动不得影响功能与性能。 +> +> 结论:**UI 缺陷已修复并 A/B 验证;数据模型去冗余(信号 / 分组只保留唯一名称)落地;全仓过时注释与 +> 命名批量修正;新增 `Components/Shared` 组件并迁入通用 UI 控件;产物体积 −25.6%(1013 KB → 737 KB)**。 +> 四套离屏测试(49 + 69 + 46 + **156** 条断言)全绿、`make release` / `make debug` 零警告。 +> **`PlotView.cpp` 的整体拆分本轮未执行**(见 §五,附可执行的落地方案与原因)。 + +--- + +## 一、UI 交互修复(用户报告) + +### 1.1 分组折起热区:只有折叠三角与「组X」标签可折起 + +* **需求**:点分组**名称**不应折起该分组;只有点到折叠三角或「组X」标签才折起。 +* **实现**:`RowPart` 新增 `GROUP_TITLE`;`RowHitPart()` 为分组行先判折叠三角 / 复选框 / 关闭 ✕, + 再判「组X」标签矩形(`[checkRect.Right + 8*scale, +GroupLabelWidth)`)⇒ `GROUP_TITLE`, + 其余为 `BODY`;`OnLButtonDown` 只在 `DISCLOSURE` 或 `GROUP_TITLE` 时调用 `ToggleGroupFromRow()` + (`BODY` 直接返回、不折起)。 +* **附带修正**:双击「组X」标签不再进入改名(该区域现在是折起热区);分组名本体双击仍可改名。 + +### 1.2 分组改名输入框不再吞掉「N 项」计数 + +* **需求**:组名双击进入编辑时,输入框不得把组内信号个数包进去。 +* **根因**:`RowNameRect()`(分组行)的右边界原先取到关闭 ✕ 左侧,把右侧的项数文本一起框了进去。 +* **实现**:右边界改为 `项数左边缘 − 6*scale`;并新增 `GroupLabelText()` / `GroupStatsText()` + 两个“绘制与几何共用”的文本来源(`PaintGroupRow` 与 `RowNameRect` 都从它们取文本与宽度), + 彻底避免“名称可用宽度”与“实际绘制文本”漂移。 + +### 1.3 验证 + +`.preflight/perf/panelanim_main.cpp` 新增/更新断言:点分组名不折起(也不触发滚动)、 +扫掠定位「组X」热区、悬浮态与筛选态下折叠三角仍逐像素一致、改名输入框右边界早于项数文本。 +**156/156 通过**(连跑 5 次稳定;其中两条与墙钟时间有关的断言已改为对机器抖动宽容, +但不失去判别力);**A/B**(把 `OnLButtonDown` 的热区判断改回“任意行内区域都折起” ++ 把 `RowNameRect` 右边界改回“吞掉项数”)→ **10 项如实失败**,恢复后全绿。 + +--- + +## 二、数据模型去冗余(用户指出的重点) + +### 2.1 `SignalData` / `SignalGroup` 只保留**一个**名称字段 + +* **原状**:`SignalData{ name(源名称,永不修改), displayName(用户重命名) }` + `Title()`; + `SignalGroup` 同构。 +* **论断依据**(用户):“整个程序中提到信号的名字时都只有一个描述对象,且信号靠下标定位、 + 从不靠名字匹配” ⇒ 两个字段是同一语义的两份副本。 +* **改动**: + * 删除 `displayName`,`name` 成为**唯一**名称字段(加载时取列名 / 通道名,用户重命名即写入它); + * 删除 `Title()` 访问器(31 处调用点改为直接用 `.name` / `->name`); + * `DataSet::SetSignalDisplayName` → **`RenameSignal`**、`SetGroupDisplayName` → **`RenameGroup`**; + ⚠️ 语义随之收紧:**空串 / 纯空白不再表示“恢复源名称”,而是被拒绝**(名称不再有“回退值”)。 +* **影响面**:`DataModel.hpp/.cpp`、`Application.cpp`、`PlotView.cpp`、`SignalPanel.cpp`、 + `datacheck` 探针。编译期全覆盖(无一处遗漏)。 +* **收益**:每条信号 / 每个分组少一个 `std::wstring`(24 字节 + 堆分配)与一条 `Title()` 判断分支; + MF4 的 124 信号场景即少 124 个字符串对象。 + +### 2.2 其它冗余成员清理 + +| 位置 | 问题 | 处理 | +| --- | --- | --- | +| `Application::m_loadingBytes` | **全仓既不读也不写**(体积展示实际由 `m_loadingTotalBytes` 承担),注释却写着“用于展示体积” | **删除** | +| `Application::m_loadingBytesRead/TotalBytes` | 语义清晰、成对使用 | 保留,补全注释 | + +> 其余结构体(`SignalStyle` / `TabConfig` / `Lane` / `TimeAxis` / `SignalRef` / `EdgeScanState` 等) +> 逐字段核对后未发现同类“同义多字段”现象;`Lane` 的“体量过大”属**内聚性**问题而非冗余, +> 见 §五 的 `LanePanelState` 建议。 + +--- + +## 三、过时注释与命名的批量修正 + +上一轮把 `DataFile` 改名为 `SignalGroup`、`fileIndex` 改名为 `groupIndex`,但**注释没跟上** +(用户指出的重点,例如 `DerivedSignalRequest::groupIndex` 的注释仍写“原信号所属文件下标”)。 +本轮用“逐条比对的替换对 + 校验”的方式一次性清理: + +* **参数 / 成员注释**:`文件下标`→`分组下标`、`文件内信号下标`→`组内信号下标`、 + `原信号所属文件下标`→`原信号所属分组下标`、`所属文件下标`→`所属分组下标`、`目标文件下标`→`目标分组下标`; +* **措辞统一**:`文件分组`→`分组`、`该文件全部信号`→`该分组全部信号`、`移除该文件`→`移除该分组`、 + `打开 CSV 文件`→`打开数据文件`、`加载指定 CSV 文件`→`加载指定的数据文件`、 + `CSV 文件完整路径`→`数据文件完整路径`、`数据层 RemoveFile`→`数据层 RemoveGroup`、 + `按 [文件][信号] 索引`→`按 [分组][信号] 索引` …; +* **文不对题的参数名**:`VisibleIndexRange(const TimeAxis& file, …)` → 形参名 `axis` + (注释也从“目标文件”改为“目标时间轴”); + `Application::LoadFileAsync(…, SignalGroup& file, …)` → 形参名 `group`; +* **函数名残留的“File 语义”**(`RowKind::FILE` 上一轮已改,但函数名没跟上): + `PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、 + `FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`; + 局部变量 `fileRow/fileHeight/isFile` → `groupRow/groupHeight/isGroup`; +* **格式损坏的注释块**:`DataModel.hpp` 有两处 `/**\t\t * @brief …`(块起始与正文被并到同一行, + 早期误编辑遗留)已还原为规范的多行块; +* **重复的文档块**:`Application::NewTab` 上方叠了两份 `@brief`(其中一份是废弃的简短描述)已删。 + +> ⚠️ 命名冲突踩坑:`StyleEditField::FILE_NAME` 一度改为 `GROUP_NAME`,而 **`nb30.h`(由 `windows.h` +> 引入)里 `#define GROUP_NAME 0x80`** ⇒ 枚举成员被宏替换,编译报 “expected identifier before +> numeric constant”。改名 `GROUP_TITLE` 解决。**新增标识符前需确认不是 Windows 头里的宏** +> (同类高风险名:`GROUP_NAME`、`TIME`、`SMALL`、`NEAR`、`FAR`)。 + +--- + +## 四、`Components/Shared`:通用 UI 控件组件(新增) + +### 4.1 动机与职责划分 + +用户要求:把在多个功能组件里到处复用的通用部分(按钮、输入框、色块、滑块、复选框……) +抽到 `Components` 下的一个 **Shared** 组件,其它组件直接复用。 + +本轮先落地**最典型、复用最广**的一类:**UI 控件**。 + +``` +Components/Shared/ + Inc/UiControls.hpp 控件声明(+ 复用的 UiInk / IconDirection 等类型) + Src/UiControls.cpp 控件实现 +``` + +迁入 `Components/Shared` 的控件(原先散落在 `Utilities/PaintUtil.*`): + +| 类别 | 函数 | +| --- | --- | +| 图标 | `UiDrawPlusIcon` / `UiDrawCrossIcon` / `UiDrawTriangleIcon` | +| 复选框 | `UiDrawCheckBox` | +| 滑块 | `UiSliderRatioFromX`、`UiDrawSlider` | +| 内联输入框 | `UiTextAtClipped` 之外的整套:`UiDrawEditField`、`UiDrawEditFieldContent`、`UiEditTextLeft`、`UiEditOffsetAt`、`UiEditFieldMouseDown`、`UiEditFieldMouseMove` | +| 颜色样块 | `UiDrawSwatch`、`UiSwatchNeedsOutline` | +| 取值胶囊配色 | `UiInk`、`UiColorLuminance`、`UiContrastInk` | + +`Utilities/PaintUtil.*` 保留**低层绘制原语**(矩形 / 圆角矩形 / 圆角路径 / 直线 / 批量实线 / +颜色混合 / 文本与测量 / 省略号)。 + +### 4.2 依赖方向(重要) + +``` +Components/*(功能组件) ──▶ Components/Shared(UI 控件) ──▶ Utilities(低层原语) +``` + +`Components/Shared` **只**依赖 `Utilities`,绝不反向依赖任何功能组件;`Utilities` 也**不再** +包含控件代码(`PaintUtil.hpp` 里那 16 个控件声明已移除)。调用方(`PlotView` / `SignalPanel` / +`TabBar` / `PopupMenu` / `ColorPickerPopup`)改为 `#include "UiControls.hpp"`。 + +### 4.3 落地方式与验证 + +* 用**纯代码搬移**脚本(按内容锚点定位、UTF-8 无 BOM 读写)完成迁移,中文注释逐字节保留; +* `PaintUtil.cpp` 953 → 571 行;新增 `UiControls.cpp` 381 行、`UiControls.hpp` 216 行; +* `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS` 已登记 `Components/Shared/*`; +* 编译零警告;四套探针全绿(控件行为逐像素不变)。 + +--- + +## 五、二进制瘦身(实测) + +### 5.1 结论:**1013 KB → 737 KB(−25.6%,省 ≈276 KB)** + +| 项 | 大小 | +| --- | --- | +| `Target.exe`(Release `-Os -s`,本轮前) | 1 013 248 B | +| `Target.exe`(Release `-Os -s`,本轮后) | **754 176 B** | +| 差值 | **−259 072 B(−25.6%)** | +| (参考)`Target.exe`(Debug `-O0`,未 strip) | 13 849 441 B | + +### 5.2 真正的体积来源(实测,纠正“都是 PlotView.cpp 撑大的”这一推断) + +本轮前后各目标文件体积(`Build/Release/*.o`,单位 B): + +| 目标文件 | 本轮前 | 本轮后 | 说明 | +| --- | --- | --- | --- | +| `JustDataInspector.o`(**windres 资源**) | **305 826** | **46 730** | 图标条目改 PNG 存储 | +| `PlotView.o` | 406 527 | 406 527 | 最大的**代码**目标文件 | +| `DataModel.o` | 142 134 | 142 134 | | +| `Application.o` | 134 929 | 134 903 | 删了一个死成员 | +| `SignalPanel.o` | 106 165 | 106 165 | | +| `PopupMenu.o` | 49 305 | 49 305 | | +| `ColorPickerPopup.o` | 43 454 | 43 454 | | +| `CsvLoader.o` / `Mf4Loader.o` | 35 559 / 31 010 | 同 | | +| `UiControls.o` | —(原先在 `PaintUtil.o` 内) | 19 112 | 新增的共享控件 | +| `PaintUtil.o` | 29 412 | 29 412 | 只留低层原语(`nm` 已核实控件已全部移出) | + +> ⚠️ `.o` 体积受节对齐 / 重定位表 / 符号表主导,删掉几百行代码不一定会让 `.o` 变小; +> 判断“代码搬去了哪里”要看 `nm`,不要看 `.o` 字节数。 + +* **主因(≈85% 的可省体积):图标资源**。`Resources/JustDataInspector.ico` 原本 302 430 B, + 其中 **256×256@32bpp 那条以“未压缩 BMP”存储,单条 270 376 B**(占整包 27%)。 + Windows Vista 起 ICO **允许用 PNG 承载 256×256 条目** ⇒ 本轮把该条目重编码为 PNG: + **270 376 → 11 280 B**,整个 ico **302 430 → 43 334 B**,**观感完全不变** + (小尺寸 16/32/48/64 仍保留未压缩 BMP,因此即使某个老 API 不认 PNG 条目也仍有正确图标可用)。 + 实测结论:**真正把产物推到 1 MB 的是这一个图标条目,而不是 `PlotView.cpp`。** +* **次因:代码体积**。`PlotView.o` 406 KB 是最大的**代码**目标文件,这与 13.7 K 行单文件的事实一致; + 但**把大文件拆成多个 .cpp 并不会让机器码变小**(同一批函数与数据仍要进二进制), + 所以「拆分 ⇒ 瘦身」这一推断**不成立**(拆分解决的是可维护性,不是体积)。 +* **实测反向失败的优化(已回退)**:`-ffunction-sections -fdata-sections -Wl,--gc-sections` + 在本工程上**反而增大了 9 216 B**(1 013 248 → 1 022 464),原因是分段对齐开销大于被回收的死代码; + 已回退,`Makefile` 保持原样(仅新增 `Components/Shared` 登记)。 + +--- + +## 六、验证 + +| 套件 | 覆盖 | 结果 | +| --- | --- | --- | +| `.preflight/perf/panelanim_main.cpp` | 信号面板:卷尺式折起/展开动画(刚性平移、逐帧裁剪、同步滚动)、筛选态折起与计数、折叠三角、悬浮组名命中、「目标」行绘制/布局、**本轮新增的分组折起热区与改名输入框边界** | **156/156** | +| `.preflight/perf/verify_main.cpp` | PlotView 渲染/交互:Y 自适应、派生信号、图例命中、GX 徽标悬停、装饰层无残影、平移逐像素 | **49/49** | +| `.preflight/perf/datacheck_main.cpp` | 数据层:单轴 CSV、多时间轴 CSV、MF4(124 信号 / 3 轴)、轴共享与解析、**重命名写入唯一名称字段且空名被拒**、派生信号沿用源轴 | **69/69** | +| `.preflight/perf/edgecheck_main.cpp` | 边沿统计符号/分帧推进/面板排版 | **46/46** | +| A/B(本轮) | 回退两处 UI 修复 | **10 项如实失败** | +| 构建 | `make release` / `make debug` | **零警告** | + +全部离屏执行(`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)。 + +--- + +## 七、本轮未完成 / 后续建议(诚实记录) + +### 7.1 `PlotView.cpp`(13 695 行 / 544 KB)按功能拆分 —— **本轮未执行** + +* **为什么不硬做**:拆分本身是**纯代码搬移**(不改变行为),真正的成本在**前置条件**: + 该文件的辅助区(第 13–888 行的匿名命名空间,含 ~870 行常量与工具函数)被**全部**成员函数 + 广泛引用。要让多个 .cpp 共享它们,必须先把匿名命名空间改成具名命名空间并**逐个函数标注 + `inline`**(否则多 TU 重复定义或产生 N 份机器码),再逐个把成员定义搬走。 + 这是一次需要“搬一类 → 编译 → 跑四套探针”反复迭代的工程(预计 5 个 .cpp:基底/图层缓存、 + 绘制、命中与交互、派生曲线调度、浮动面板),**在没有足够时间逐步验证时贸然动手会破坏 + 性能敏感的渲染路径**,因此本轮**主动不做**,而不是草率地做一半。 +* **推荐的落地方案(可直接照着做)**: + 1. 新建 `Components/PlotView/Src/PlotViewInternal.hpp`:包含现有 `#include` 段 + + `namespace jdi { namespace pvdetail { <常量与工具函数,逐个加 inline> } using namespace pvdetail; }`; + 2. `PlotView.cpp` 只留核心(构造/析构、`Layout`/`RefreshLanes`/`BuildLaneVisuals`、缓存失效); + 3. 按**粘连度**由低到高搬移:`PlotViewDerived.cpp`(曲线线程池与 `CurveSpanFor` / `ScheduleCurveJob`)、 + `PlotViewPanels.cpp`(4 类实验性浮动面板的几何/命中/绘制)、`PlotViewInteraction.cpp` + (`On*` 与命中测试)、`PlotViewPaint.cpp`(`Paint*` 与图层/装饰缓存); + 4. 每搬一个文件:`make release` 零警告 + `verify`/`panelanim` 全绿,再继续。 + 5. ⚠️ 千万别动 `Paint()` 的**分支顺序**与图层/装饰缓存的既有不变量(见 `agent_mem.md` §11.2)。 + +### 7.2 其余组件的多文件化(同样未执行) + +`SignalPanel.cpp`(145 KB / 3.7 K 行)、`Application.cpp`(104 KB)、`DataModel.cpp`(88 KB) +同样适合按“列表/绘制/交互/编辑”“窗口与消息/命令与动作/加载”“数据集合/统计与采样/标签页配置” +拆成 3–4 个文件。`SignalPanel.cpp` 的辅助区仅约 60 行(十几个常量 + 少量格式化函数), +**改造成本远低于 `PlotView.cpp`**,是下一步性价比最高的拆分入口。 + +### 7.3 `Lane` 的内聚性(上一轮建议 5) + +`Lane` 内含 4 类实验性功能的状态 + 各自的面板坐标,已超 200 行;建议抽出 +`LanePanelState`(开关 + 每信号参数 + 面板位置),`Lane` 只保留结构与归属。 +属**结构内聚**重构而非去冗余,需同步改 `DataModel` 与 `PlotView` 的多处访问点,本轮未做。 + +### 7.4 错误提示与模态阻塞解耦(上一轮建议 1) + +`SetStatusMessage(..., isError=true)` 仍直接 `MessageBoxW`;自动化 / 离屏场景与其耦合, +建议改为宿主级非模态提示。本轮未做(与“不影响功能”的约束存在交互设计决策,需用户拍板样式)。 + +### 7.5 MF4 压缩块 / 数据列表(上一轮建议 2–3) + +按用户要求**继续不做**。 + +--- + +## 八、变更清单(本轮) + +``` +新增 Components/Shared/Inc/UiControls.hpp 通用 UI 控件(自 PaintUtil 迁出) +新增 Components/Shared/Src/UiControls.cpp +修改 Utilities/Inc/PaintUtil.hpp 移除 16 个控件声明 +修改 Utilities/Src/PaintUtil.cpp 移除控件实现(953 → 571 行) +修改 Utilities/Inc/DataModel.hpp SignalData/SignalGroup 单一名称字段;注释/命名修正 +修改 Utilities/Src/DataModel.cpp RenameSignal / RenameGroup(空名被拒) +修改 Components/SignalPanel/Inc/SignalPanel.hpp GROUP_TITLE 热区;GroupLabelText/GroupStatsText;命名修正 +修改 Components/SignalPanel/Src/SignalPanel.cpp 折起热区、改名输入框边界、过时注释/命名 +修改 Components/PlotView/Inc/PlotView.hpp 过时注释(“文件”→分组、形参名 axis) +修改 Components/PlotView/Src/PlotView.cpp Title() → name;过时注释 +修改 Components/Application/Inc/Application.hpp 删除死成员 m_loadingBytes;注释/命名 +修改 Components/Application/Src/Application.cpp 过时注释/命名 +修改 Components/TabBar/Src/TabBar.cpp 引入 UiControls.hpp +修改 Components/Toolbar/Inc/Toolbar.hpp 注释修正 +修改 Utilities/Src/PopupMenu.cpp | ColorPickerPopup.cpp 引入 UiControls.hpp +修改 Utilities/Inc/CsvLoader.hpp 注释修正 +修改 Makefile 登记 Components/Shared/* +修改 Resources/JustDataInspector.ico 256×256 条目改 PNG 存储(302 KB → 43 KB) +``` + +* `Documents/agent_mem.md` 同步更新(§9.37 + §11.2 约束 + 变更日志)。 +* 上一轮报告 `Documents/refactor_report_2026-10-15.md` **已删除**(其有效内容已并入 + `agent_mem.md` 与本文)。 diff --git a/Makefile b/Makefile index 35f47b5..01d79d3 100644 --- a/Makefile +++ b/Makefile @@ -81,6 +81,7 @@ CXX_INCLUDES_PATHS = \ Utilities/Inc \ Resources/Inc \ Components/Main/Inc \ +Components/Shared/Inc \ Components/Application/Inc \ Components/TabBar/Inc \ Components/Toolbar/Inc \ @@ -91,6 +92,7 @@ Components/PlotView/Inc \ CXX_SOURCES_PATHS = \ Utilities/Src \ Components/Main/Src \ +Components/Shared/Src \ Components/Application/Src \ Components/TabBar/Src \ Components/Toolbar/Src \ diff --git a/Resources/JustDataInspector.ico b/Resources/JustDataInspector.ico index 6571331..a73b5e9 100644 Binary files a/Resources/JustDataInspector.ico and b/Resources/JustDataInspector.ico differ diff --git a/Utilities/Inc/CsvLoader.hpp b/Utilities/Inc/CsvLoader.hpp index 2c52540..f3aa624 100644 --- a/Utilities/Inc/CsvLoader.hpp +++ b/Utilities/Inc/CsvLoader.hpp @@ -19,7 +19,7 @@ namespace jdi * 各块之间的行数可以不一致(较短的时间轴在数据区提前结束,其余位置留空)。 * 只出现一个时间列时结果与旧的单时间轴行为完全一致。 * - * @param [IN] filePath CSV 文件完整路径(宽字符,支持中文路径)。 + * @param [IN] filePath 数据文件完整路径(宽字符,支持中文路径)。 * @param [OUT] source 解析结果(按时间列切分为若干时间轴块)。 * @param [OUT] error 失败原因描述(成功时清空)。 * @param [IN] progress 解析进度回调,可为空。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index c4ba2c6..b70161b 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -380,18 +380,16 @@ namespace jdi /** * @brief 单条信号的元数据与采样值(纯数据,不含可见性配置)。 * - * ⚠️ 信号**只有两个名字字段,各有明确职责**,不要再引入第三个: - * * `name` —— 源名称(列名 / 通道名),加载后**原样保留、永不修改**; - * * `displayName` —— 用户在界面上重命名后的名称,为空表示“用源名称”。 - * 界面展示一律走 `Title()`。 + * ⚠️ 信号**只有一个名称字段** `name` —— 它就是界面展示用的名字,也是用户重命名后写入的地方 + *(加载时取列名 / 通道名,之后不再保留“原始名称”的第二份副本:应用从不靠名称定位信号, + * 定位一律走 `SignalRef` 下标)。 * * ⚠️ 信号的时间轴由 `axisIndex` 指向所属分组的时间轴列表, * 因此采样值本身不需要、也不应该携带时间信息。 */ struct SignalData { - std::wstring name; /**< 源名称(列名 / 通道名,原样保留)。 */ - std::wstring displayName; /**< 用户重命名后的显示名;为空时使用 name。 */ + std::wstring name; /**< 信号名称(加载时取列名 / 通道名;用户可在界面上重命名)。 */ std::size_t axisIndex = 0; /**< 所属时间轴在分组内的下标。 */ std::vector values; /**< 采样值,缺失处为 NaN。 */ double minValue = 0.0; /**< 有限采样值最小值。 */ @@ -427,12 +425,6 @@ namespace jdi std::size_t coarseStride = 0; /**< 分组的采样跨度;0 表示未构建。 */ SignalStyle style; /**< 显示外观(全局共享,跨标签页同步)。 */ - - /** - * @brief 获取用于界面展示的名称。 - * @return 用户重命名后的显示名,未重命名时返回源名称。 - */ - const std::wstring& Title() const { return displayName.empty() ? name : displayName; } }; /** @@ -447,17 +439,10 @@ namespace jdi */ struct SignalGroup { - std::wstring name; /**< 分组默认名称(源文件名,含扩展名)。 */ - std::wstring displayName; /**< 用户重命名后的分组名;为空时使用 name。 */ + std::wstring name; /**< 分组名(加载时取源文件名、含扩展名;用户可重命名)。 */ std::vector axes; /**< 该分组使用的时间轴(至少一条)。 */ std::vector signals; /**< 该分组包含的信号。 */ - /** - * @brief 获取用于界面展示的分组名。 - * @return 用户重命名后的显示名,未重命名时返回源文件名。 - */ - const std::wstring& Title() const { return displayName.empty() ? name : displayName; } - /** * @brief 取某条信号所属的时间轴(下标越界时回退到第一条,保证永不返回悬空引用)。 * @param [IN] signal 目标信号。 @@ -631,21 +616,21 @@ namespace jdi const SignalGroup* GroupAt(int groupIndex) const; /** - * @brief 修改某条信号在界面上的显示名称。 + * @brief 重命名某条信号(写入它的**唯一**名称字段 `SignalData::name`)。 * @param [IN] groupIndex 分组下标。 * @param [IN] signalIndex 组内信号下标。 - * @param [IN] name 新的显示名;传入空串表示恢复使用源名称。 - * @return 修改成功返回 true。 + * @param [IN] name 新名称;空串(或仅空白)忽略,避免把名称抹空。 + * @return 名称确实发生变化返回 true。 */ - bool SetSignalDisplayName(int groupIndex, int signalIndex, const std::wstring& name); + bool RenameSignal(int groupIndex, int signalIndex, const std::wstring& name); /** - * @brief 修改某个分组的显示名称。 + * @brief 重命名某个分组(写入它的**唯一**名称字段 `SignalGroup::name`)。 * @param [IN] groupIndex 分组下标。 - * @param [IN] name 新的分组名;传入空串表示恢复使用默认名称。 - * @return 修改成功返回 true。 + * @param [IN] name 新名称;空串(或仅空白)忽略,避免把名称抹空。 + * @return 名称确实发生变化返回 true。 */ - bool SetGroupDisplayName(int groupIndex, const std::wstring& name); + bool RenameGroup(int groupIndex, const std::wstring& name); /** * @brief 解析信号引用。 @@ -1068,22 +1053,22 @@ namespace jdi void SetName(const std::wstring& name) { m_name = name; } /** - * @brief 依据数据集合调整配置容量(新增文件补默认配置,移除文件清理配置)。 + * @brief 依据数据集合调整配置容量(新增分组补默认配置,移除分组清理配置)。 * @param [IN] dataSet 数据集合。 */ void SyncToDataSet(const DataSet& dataSet); /** - * @brief 在数据层移除某文件之前,按旧下标修正本配置(不重建网格)。 + * @brief 在数据层移除某分组之前,按旧下标修正本配置(不重建网格)。 * @param [IN] groupIndex 即将被移除的分组下标。 - * @note 调用顺序必须为:各标签页 RemapAfterGroupRemoval → 数据层 RemoveFile → 各标签页 SyncToDataSet。 + * @note 调用顺序必须为:各标签页 RemapAfterGroupRemoval → 数据层 RemoveGroup → 各标签页 SyncToDataSet。 */ void RemapAfterGroupRemoval(int groupIndex); /** * @brief 在数据层移除某条信号之前,按旧下标修正本配置(不重建网格)。 * - * 会删除该信号的可见性记录与网格归属,并把同一文件内位于其后的引用下标前移一位。 + * 会删除该信号的可见性记录与网格归属,并把同一分组内位于其后的引用下标前移一位。 * 若某个网格因此变空则一并移除(与“取消勾选后空网格消失”的策略一致)。 * * @param [IN] groupIndex 信号所属分组下标。 @@ -1114,7 +1099,7 @@ namespace jdi void ToggleVisible(const SignalRef& ref); /** - * @brief 设置某个文件全部信号的可见性,并同步网格划分。 + * @brief 设置某个分组全部信号的可见性,并同步网格划分。 * @param [IN] groupIndex 分组下标。 * @param [IN] visible 目标可见状态。 */ @@ -1133,14 +1118,14 @@ namespace jdi void SoloSignal(const SignalRef& ref); /** - * @brief 判断某文件是否存在可见信号。 + * @brief 判断某分组是否存在可见信号。 * @param [IN] groupIndex 分组下标。 * @return 存在可见信号返回 true。 */ bool GroupHasVisible(int groupIndex) const; /** - * @brief 判断某文件是否全部信号可见。 + * @brief 判断某分组是否全部信号可见。 * @param [IN] groupIndex 分组下标。 * @return 全部可见且至少有一条信号返回 true。 */ @@ -1215,10 +1200,11 @@ namespace jdi */ int SplitLane(int laneIndex); - /** * @brief 在数据层插入新信号之后,修正本页引用该文件的信号下标。 + /** + * @brief 在数据层插入新信号之后,修正本页引用该分组的信号下标。 * * 数据层在下标 `insertPos` 处插入了一条信号,因此本页可见性表需插入一列, - * 且所有指向该文件、下标不小于 `insertPos` 的信号引用都要后移一位。 + * 且所有指向该分组、下标不小于 `insertPos` 的信号引用都要后移一位。 * * @param [IN] groupIndex 发生插入的分组下标。 * @param [IN] insertPos 新信号的下标。 @@ -1255,20 +1241,21 @@ namespace jdi */ void RenameLanes(); - /** * @brief 查询某文件的分组是否处于折叠状态。 + /** + * @brief 查询某分组的折叠状态。 * @param [IN] groupIndex 分组下标。 * @return 折叠状态。 */ bool IsCollapsed(int groupIndex) const; /** - * @brief 切换某文件分组的折叠状态。 + * @brief 切换某分组的折叠状态。 * @param [IN] groupIndex 分组下标。 */ void ToggleCollapsed(int groupIndex); /** - * @brief 设置「新出现的文件分组是否默认折起」。 + * @brief 设置「新出现的分组是否默认折起」。 * * 只影响**之后**新出现的分组(例如新加载的文件);已存在的分组保持用户当前的选择。 * 用户新建的标签页会把它设为 true,使信号列表默认紧凑。 @@ -1278,7 +1265,7 @@ namespace jdi void SetGroupsCollapsedByDefault(bool collapsed) { m_groupsCollapsedByDefault = collapsed; } /** - * @brief 把当前所有文件分组设为折叠或展开。 + * @brief 把当前所有分组设为折叠或展开。 * @param [IN] collapsed true = 全部折起,false = 全部展开。 */ void SetAllCollapsed(bool collapsed) @@ -1339,10 +1326,10 @@ namespace jdi private: std::wstring m_name; /**< 标签页名称。 */ - std::vector> m_visible; /**< 信号可见性,按 [文件][信号] 索引(本页独立)。 */ - std::vector m_collapsed; /**< 文件分组折叠状态,按文件索引。 */ + std::vector> m_visible; /**< 信号可见性,按 [分组][信号] 索引(本页独立)。 */ + std::vector m_collapsed; /**< 分组折叠状态,按分组下标。 */ /** - * @brief 新出现的(首次见到的)文件分组是否默认折起。 + * @brief 新出现的(首次见到的)分组是否默认折起。 * * 用户新建的标签页把它设为 true(信号列表默认紧凑);启动时的首个标签页保持 false, * 这样加载完文件就能直接勾选信号。 diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 5efdae8..160feaf 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -196,27 +196,6 @@ namespace jdi void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect, std::uint32_t argb, const gd::RectF& clipRect); - /** - * @brief 计算输入框文本的绘制起点(已考虑超出框宽时的水平滚动)。 - * @param [IN] rect 输入框矩形。 - * @param [IN] edit 编辑模型。 - * @param [IN] font 字体句柄。 - * @param [IN] scale 缩放比例。 - * @param [OUT] innerWidth 框内可用宽度输出。 - * @return 文本绘制起点横坐标。 - */ - float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale, - float& innerWidth); - - /** - * @brief 把输入框内的横坐标换算为字符偏移(就近取整,用于鼠标定位插入符)。 - * @param [IN] font 字体句柄。 - * @param [IN] text 当前文本。 - * @param [IN] localX 相对文本起点的横坐标(可超出 [0, 文本宽度])。 - * @return 字符偏移。 - */ - std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX); - /** * @brief 在超出可用宽度时对文本做尾部省略截断(不绘制任何内容)。 * @param [IN] text 原始文本。 @@ -226,38 +205,6 @@ namespace jdi */ std::wstring UiEllipsize(const std::wstring& text, HFONT font, float maxWidth); - /** - * @brief 处理输入框内的鼠标按下:命中则把插入符定位到最近的字符边界并开始拖拽框选。 - * @param [IN] font 字体句柄。 - * @param [IN] rect 输入框矩形。 - * @param [IN,OUT] edit 编辑模型。 - * @param [IN] scale 缩放比例。 - * @param [IN] x 客户区横坐标。 - * @param [IN] y 客户区纵坐标。 - * @return 命中输入框并开始拖拽返回 true。 - */ - bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y); - - /** - * @brief 处理输入框内的鼠标拖拽移动(扩展选区)。 - * @param [IN] font 字体句柄。 - * @param [IN] rect 输入框矩形。 - * @param [IN,OUT] edit 编辑模型。 - * @param [IN] scale 缩放比例。 - * @param [IN] x 客户区横坐标。 - * @return 选区发生变化返回 true。 - */ - bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x); - - /** - * @brief 在矩形中心绘制一个加号图标。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 图标布局矩形。 - * @param [IN] argb 线条颜色。 - * @param [IN] strokeWidth 线条宽度。 - */ - void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth); - /** * @brief 用 GDI 一次性绘制若干条**不透明 1px 实线**(每条线段相互独立)。 * @@ -311,138 +258,6 @@ namespace jdi void UiFillRoundRects(gd::Graphics& g, const std::vector& rects, float radius, std::uint32_t argb); - /** - * @brief 在矩形中心绘制一个叉号(关闭)图标。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 图标布局矩形。 - * @param [IN] argb 线条颜色。 - * @param [IN] strokeWidth 线条宽度。 - */ - void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth); - - /** - * @brief 在矩形中心绘制一个实心三角形指示器。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 图标布局矩形。 - * @param [IN] argb 填充颜色。 - * @param [IN] direction 三角形朝向。 - */ - void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction); - - /** - * @brief 将滑块轨道上的横坐标换算为取值比例。 - * @param [IN] trackRect 轨道矩形。 - * @param [IN] x 横坐标。 - * @return 取值比例,范围 0-1。 - */ - double UiSliderRatioFromX(const gd::RectF& trackRect, float x); - - /** - * @brief 绘制水平滑块(轨道 + 已填充部分 + 圆钮)。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] track 轨道矩形。 - * @param [IN] ratio 当前值比例(0-1)。 - * @param [IN] fillColor 已填充部分颜色。 - * @param [IN] active 是否处于悬停或拖拽状态(影响圆钮半径)。 - * @param [IN] scale DPI 缩放比例。 - */ - void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, - bool active, float scale); - - /** - * @brief 绘制单行内联输入框(含选区高亮、插入符与水平滚动)。 - * - * 初始状态为“整体选中”时以强调色底衬显示全部内容,首字符键入即整体替换, - * 交互观感与常见登录界面的账号/密码输入框一致;文本超出框宽时自动横向滚动, - * 保证插入符始终可见。 - * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 输入框矩形。 - * @param [IN] edit 输入框编辑模型。 - * @param [IN] font 文本字体。 - * @param [IN] scale DPI 缩放比例。 - */ - void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, - float scale); - - /** - * @brief 只绘制输入框的**内容**(选区高亮 / 文本 / 插入符),不绘制底板与边框。 - * - * 用于"宿主已经画好了外框"的场景(例如信号面板的「筛选信号」输入框):若直接调用 - * UiDrawEditField,会在外框内部再套一个带边框的圆角框,形成一圈多余的内圈。 - * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 输入框矩形(内容区,几何与 UiDrawEditField 完全一致)。 - * @param [IN] edit 输入框编辑模型。 - * @param [IN] font 文本字体。 - * @param [IN] scale DPI 缩放比例。 - */ - void UiDrawEditFieldContent(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, - float scale); - - /** - * @brief 取值胶囊底色上的高对比度文字色与描边色。 - */ - struct UiInk - { - std::uint32_t text = 0xFF073642u; /**< 文字色(不透明)。 */ - std::uint32_t outline = 0x99FDF6E3u; /**< 描边色(半透明,恒与文字色反相)。 */ - }; - - /** - * @brief 颜色的相对亮度(0-1,逐通道线性加权)。 - * @param [IN] color 0xAARRGGBB 颜色值。 - * @return 相对亮度。 - */ - double UiColorLuminance(std::uint32_t color); - - /** - * @brief 依据胶囊底色求高对比度的文字色与描边色(对底色配色作线性响应)。 - * - * 求值分两步: - * 1. **亮度**:按"取最大对比"的一侧选定亮文字或暗文字。这是唯一的阈值判断,但它 - * 本身就是"任意底色下都保持高对比"的必要条件 —— 任何连续的亮度取值都会在中性灰 - * 附近同时远离两侧极值,对比度必然塌陷。 - * 2. **配色**:把该侧的极值色与**底色的互补色**按固定比例线性混合。互补色的相对亮度 - * 恰为 `1 - 底色亮度`,因此文字/描边的亮度是底色亮度的**线性**函数,色相也随信号 - * 配色连续变化(不再是固定的黑/白两色)。 - * - * 描边色取相反的一侧,保证"描边—文字—底色"三段式对比。 - * - * @param [IN] background 胶囊底色(0xAARRGGBB)。 - * @return 文字色与描边色。 - */ - UiInk UiContrastInk(std::uint32_t background); - - /** - * @brief 绘制一个带边框的复选框。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 复选框矩形。 - * @param [IN] state 选中状态:0 未选,1 全选,2 部分选。 - * @param [IN] radius 圆角半径。 - */ - void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius); - - /** - * @brief 绘制颜色样块:叠加不透明度填充,透明度过低时补充描边以保证可辨识。 - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 样块矩形。 - * @param [IN] color 基础颜色(0xAARRGGBB,其 Alpha 通道会被不透明度覆盖)。 - * @param [IN] opacity 不透明度(0-1)。 - * @param [IN] radius 圆角半径。 - * @param [IN] scale DPI 缩放比例,用于推导描边粗细。 - * @return 实际填充颜色的 0xAARRGGBB 值(供需要同步其他元素的调用方使用)。 - */ - std::uint32_t UiDrawSwatch(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float opacity, - float radius, float scale); - - /** - * @brief 判断给定不透明度是否低到需要为样块补充描边。 - * @param [IN] opacity 不透明度(0-1)。 - * @return 需要描边返回 true。 - */ - bool UiSwatchNeedsOutline(float opacity); - } // namespace jdi #endif // __PAINT_UTIL_HPP__ diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index 1b20608..02b6580 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -1,5 +1,6 @@ #include "ColorPickerPopup.hpp" +#include "UiControls.hpp" #include #include #include diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 92f229d..2dbc422 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -40,7 +40,7 @@ namespace jdi /** * @brief 依据全部可见信号重新生成网格划分。 - * @param [IN] visible 各信号的可见性(按 [文件][信号] 索引)。 + * @param [IN] visible 各信号的可见性(按 [分组][信号] 索引)。 * @param [IN] lanes 网格集合(原地更新)。 * @param [IN] mode 网格划分策略。 */ @@ -865,7 +865,7 @@ namespace jdi return &m_groups[static_cast(groupIndex)]; } - bool DataSet::SetSignalDisplayName(int groupIndex, int signalIndex, const std::wstring& name) + bool DataSet::RenameSignal(int groupIndex, int signalIndex, const std::wstring& name) { if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { @@ -878,18 +878,22 @@ namespace jdi return false; } - // 统一去除首尾空白;结果为空则视为恢复源名称。 + // 统一去除首尾空白;结果为空则忽略(名称是信号的唯一标识性字段,不允许抹空)。 std::wstring trimmed = Trim(name); - SignalData& signal = group.signals[static_cast(signalIndex)]; - if (trimmed == signal.displayName) + if (trimmed.empty()) { return false; } - signal.displayName = std::move(trimmed); + SignalData& signal = group.signals[static_cast(signalIndex)]; + if (trimmed == signal.name) + { + return false; + } + signal.name = std::move(trimmed); return true; } - bool DataSet::SetGroupDisplayName(int groupIndex, const std::wstring& name) + bool DataSet::RenameGroup(int groupIndex, const std::wstring& name) { if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) { @@ -897,12 +901,16 @@ namespace jdi } std::wstring trimmed = Trim(name); - SignalGroup& group = m_groups[static_cast(groupIndex)]; - if (trimmed == group.displayName) + if (trimmed.empty()) { return false; } - group.displayName = std::move(trimmed); + SignalGroup& group = m_groups[static_cast(groupIndex)]; + if (trimmed == group.name) + { + return false; + } + group.name = std::move(trimmed); return true; } @@ -1135,7 +1143,7 @@ namespace jdi * `current` / `next`(float,各 ≈103MB)、极值表与位移表(≈数百 MB), * 于是**每条线程常驻数百 MB、4 条线程就是 2GB 以上**,而 `thread_local` * 只在**线程退出**时才析构 → 用户报告"启用预览后内存 1.42GB→4.21GB, - * 关掉预览也不下降,直到关闭整个文件分组才释放"。 + * 关掉预览也不下降,直到关闭整个分组才释放"。 * * 因此只对"中/小规模"(≤ 上限)保留容量;**超大计算**改为一次性分配、用完即释放 —— * 这类整段计算本身是秒级的一次性操作(结果会被曲线缓存复用),多一次分配完全可接受, @@ -1978,7 +1986,7 @@ namespace jdi } } - // 网格归属:删掉该信号本身,并把同一文件内位于其后的引用下标前移一位。 + // 网格归属:删掉该信号本身,并把同一分组内位于其后的引用下标前移一位。 for (Lane& lane : m_lanes) { std::vector& refs = lane.signals; diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 35b2594..2d7454f 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -20,7 +20,6 @@ namespace jdi * 不透明度低于该值时,色块自身与浅色背景的对比度已不足,需要额外描边才能辨识; * 高于该值时按需求不绘制描边,保持与预设色块一致的纯净外观。 */ - const float kSwatchOutlineOpacity = 0.45f; /** * @brief 矩形面积超过该阈值(逻辑像素²)且为不透明色时,改用 GDI 填充。 @@ -530,234 +529,6 @@ namespace jdi return ellipsis; } - void UiDrawPlusIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth) - { - const float cx = rect.X + rect.Width * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const float arm = std::min(rect.Width, rect.Height) * 0.3f; - UiDrawLine(g, cx - arm, cy, cx + arm, cy, argb, strokeWidth); - UiDrawLine(g, cx, cy - arm, cx, cy + arm, argb, strokeWidth); - } - - void UiDrawCrossIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, float strokeWidth) - { - const float cx = rect.X + rect.Width * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const float arm = std::min(rect.Width, rect.Height) * 0.3f; - UiDrawLine(g, cx - arm, cy - arm, cx + arm, cy + arm, argb, strokeWidth); - UiDrawLine(g, cx + arm, cy - arm, cx - arm, cy + arm, argb, strokeWidth); - } - - void UiDrawTriangleIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb, IconDirection direction) - { - const float cx = rect.X + rect.Width * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const float arm = std::min(rect.Width, rect.Height) * 0.28f; - - gd::PointF points[3]; - switch (direction) - { - case IconDirection::LEFT: - points[0] = gd::PointF(cx + arm * 0.7f, cy - arm); - points[1] = gd::PointF(cx - arm * 0.7f, cy); - points[2] = gd::PointF(cx + arm * 0.7f, cy + arm); - break; - case IconDirection::RIGHT: - points[0] = gd::PointF(cx - arm * 0.7f, cy - arm); - points[1] = gd::PointF(cx + arm * 0.7f, cy); - points[2] = gd::PointF(cx - arm * 0.7f, cy + arm); - break; - case IconDirection::UP: - points[0] = gd::PointF(cx - arm, cy + arm * 0.7f); - points[1] = gd::PointF(cx, cy - arm * 0.7f); - points[2] = gd::PointF(cx + arm, cy + arm * 0.7f); - break; - case IconDirection::DOWN: - default: - points[0] = gd::PointF(cx - arm, cy - arm * 0.7f); - points[1] = gd::PointF(cx, cy + arm * 0.7f); - points[2] = gd::PointF(cx + arm, cy - arm * 0.7f); - break; - } - - gd::SolidBrush brush(ToGdiColor(argb)); - g.FillPolygon(&brush, points, 3); - } - - double UiSliderRatioFromX(const gd::RectF& trackRect, float x) - { - if (trackRect.Width <= 0.0f) - { - return 0.0; - } - const double ratio = static_cast(x - trackRect.X) / static_cast(trackRect.Width); - if (ratio < 0.0) - { - return 0.0; - } - if (ratio > 1.0) - { - return 1.0; - } - return ratio; - } - double UiColorLuminance(std::uint32_t color) - { - const double r = static_cast((color >> 16) & 0xFFu); - const double g = static_cast((color >> 8) & 0xFFu); - const double b = static_cast(color & 0xFFu); - return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0; - } - - UiInk UiContrastInk(std::uint32_t background) - { - // 极值文字/描边色(Solarized base3 / base02 / base03),亮度差很大, - // 因此任意底色下文字与底色之间都有充足对比。 - const std::uint32_t kInkLight = 0xFFFDF6E3u; - const std::uint32_t kInkDark = 0xFF073642u; - const std::uint32_t kOutlineLight = 0xFFFDF6E3u; - const std::uint32_t kOutlineDark = 0xFF002B36u; - /** 向底色互补色混入的比例:线性响应信号配色,同时把对比度损失限制在 15% 以内。 */ - const double kTint = 0.15; - - const double luminance = UiColorLuminance(background); - // 底色越亮 → 文字越暗(取最大对比的一侧)。 - const bool darkText = (luminance >= 0.5); - // 底色的互补色:相对亮度恰为 1 - 底色亮度。 - const std::uint32_t complement = 0xFF000000u | ((~background) & 0x00FFFFFFu); - - UiInk ink; - ink.text = MixColor(darkText ? kInkDark : kInkLight, complement, kTint); - ink.text = WithAlpha(ink.text, 0xFFu); - ink.outline = MixColor(darkText ? kOutlineLight : kOutlineDark, complement, kTint); - ink.outline = WithAlpha(ink.outline, 0x99u); - return ink; - } - void UiDrawCheckBox(gd::Graphics& g, const gd::RectF& rect, int state, float radius) - { - const Theme& theme = SolarizedLight(); - const float centerY = rect.Y + rect.Height * 0.5f; - - if (state == 1) - { - UiFillRoundRect(g, rect, radius, theme.accent); - gd::Pen pen(ToGdiColor(theme.textOnAccent), std::max(1.4f, rect.Height * 0.11f)); - pen.SetStartCap(gd::LineCapRound); - pen.SetEndCap(gd::LineCapRound); - g.DrawLine(&pen, rect.X + rect.Width * 0.22f, centerY, rect.X + rect.Width * 0.42f, - rect.Y + rect.Height * 0.72f); - g.DrawLine(&pen, rect.X + rect.Width * 0.42f, rect.Y + rect.Height * 0.72f, - rect.X + rect.Width * 0.78f, rect.Y + rect.Height * 0.28f); - } - else if (state == 2) - { - UiFillRoundRect(g, rect, radius, theme.accentSoft); - UiDrawRoundRect(g, rect, radius, theme.accent, 1.0f); - UiFillRect(g, gd::RectF(rect.X + rect.Width * 0.22f, centerY - rect.Height * 0.08f, - rect.Width * 0.56f, rect.Height * 0.16f), - theme.accent); - } - else - { - UiFillRoundRect(g, rect, radius, theme.cardBg); - UiDrawRoundRect(g, rect, radius, theme.borderStrong, 1.0f); - } - } - - void UiDrawSlider(gd::Graphics& g, const gd::RectF& track, double ratio, std::uint32_t fillColor, - bool active, float scale) - { - if (track.Width <= 0.0f || track.Height <= 0.0f) - { - return; - } - - if (ratio < 0.0) - { - ratio = 0.0; - } - if (ratio > 1.0) - { - ratio = 1.0; - } - - const float radius = track.Height * 0.5f; - UiFillRoundRect(g, track, radius, 0x2A657B83u); - - // 已填充部分严格止于圆钮中心,最小值时不会留下超出圆钮的填充残留。 - const float fillWidth = static_cast(ratio) * track.Width; - if (fillWidth > 0.5f) - { - UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), radius, fillColor); - } - - const float knobRadius = radius + (active ? 2.5f * scale : 0.8f * scale); - const float knobX = track.X + static_cast(ratio) * track.Width; - - gd::SolidBrush knob(ToGdiColor(0xFFFFFFFFu)); - gd::Pen knobPen(ToGdiColor(0xFF586E75u), 1.4f * scale); - g.FillEllipse(&knob, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f, - knobRadius * 2.0f); - g.DrawEllipse(&knobPen, knobX - knobRadius, track.Y + radius - knobRadius, knobRadius * 2.0f, - knobRadius * 2.0f); - } - - float UiEditTextLeft(const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale, - float& innerWidth) - { - innerWidth = std::max(4.0f, rect.Width - 12.0f * scale); - const float textLeft = rect.X + 6.0f * scale; - - const std::wstring& text = edit.Text(); - if (text.empty()) - { - return textLeft; - } - - const float textWidth = UiMeasureTextWidth(font, text); - if (textWidth <= innerWidth) - { - return textLeft; - } - - // 保证插入符可见:插入符超出右边界时向左滚动,并夹到总可滚动范围内。 - const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret())); - float scroll = 0.0f; - if (caretX > innerWidth) - { - scroll = caretX - innerWidth; - } - const float maximum = textWidth - innerWidth; - scroll = std::max(0.0f, std::min(scroll, maximum)); - return textLeft - scroll; - } - - std::size_t UiEditOffsetAt(HFONT font, const std::wstring& text, float localX) - { - if (text.empty() || font == nullptr) - { - return 0; - } - if (localX <= 0.0f) - { - return 0; - } - - // 逐字符累加宽度,找到距离最近的字符边界(文本通常很短,线性扫描足够)。 - float previous = 0.0f; - for (std::size_t index = 0; index < text.size(); ++index) - { - const float current = UiMeasureTextWidth(font, text.substr(0, index + 1)); - if (localX < current) - { - // 落在本字符的左半 → 取左边界,右半 → 取右边界。 - return (localX - previous <= current - localX) ? index : (index + 1); - } - previous = current; - } - return text.size(); - } - void UiTextAtClipped(gd::Graphics& g, const std::wstring& text, HFONT font, float x, const gd::RectF& rect, std::uint32_t argb, const gd::RectF& clipRect) { @@ -797,157 +568,4 @@ namespace jdi g.ReleaseHDC(hdc); } - void UiDrawEditField(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, float scale) - { - if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr) - { - return; - } - - const Theme& theme = SolarizedLight(); - const float corner = 3.0f * scale; - - UiFillRoundRect(g, rect, corner, theme.cardBg); - UiDrawRoundRect(g, rect, corner, theme.accent, 1.6f); - - UiDrawEditFieldContent(g, rect, edit, font, scale); - } - - void UiDrawEditFieldContent(gd::Graphics& g, const gd::RectF& rect, const TextEdit& edit, HFONT font, - float scale) - { - if (rect.Width <= 0.0f || rect.Height <= 0.0f || font == nullptr) - { - return; - } - - const Theme& theme = SolarizedLight(); - const std::wstring& text = edit.Text(); - const float innerLeft = rect.X + 6.0f * scale; - - float innerWidth = 0.0f; - const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); - const gd::RectF clipRect(innerLeft, rect.Y + 1.0f * scale, innerWidth, rect.Height - 2.0f * scale); - - if (text.empty()) - { - // 空内容仍绘制插入符,保持焦点可见。 - UiDrawLine(g, textLeft, rect.Y + 4.0f * scale, textLeft, rect.Y + rect.Height - 4.0f * scale, - theme.accent, 1.4f * scale); - return; - } - - // 光标与选区端点对应的像素位置(以文本左端为原点)。 - const float caretX = UiMeasureTextWidth(font, text.substr(0, edit.Caret())); - const float selBeginX = - edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionBegin())) : 0.0f; - const float selEndX = - edit.HasSelection() ? UiMeasureTextWidth(font, text.substr(0, edit.SelectionEnd())) : 0.0f; - - const bool fullSelection = - edit.HasSelection() && edit.SelectionBegin() == 0 && edit.SelectionEnd() == text.size(); - - const int saveState = g.Save(); - g.SetClip(clipRect, gd::CombineModeIntersect); - - // 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感。 - // ⚠️ 这里**必须自己把矩形夹到内容区**:`UiFillRect` 对面积足够大的不透明矩形会走 - // GDI `FillRect` 快路径,而 GDI 绘制**不受 GDI+ 裁剪区约束**,长文本的全选高亮 - // (宽度等于整段文本宽度)因此会溢出到输入框之外。夹取之后矩形始终落在框内, - // 既保留快路径又不越界。 - if (edit.HasSelection() && selEndX > selBeginX) - { - gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, selEndX - selBeginX, - rect.Height - 6.0f * scale); - const float left = std::max(highlight.X, clipRect.X); - const float right = std::min(highlight.X + highlight.Width, clipRect.X + clipRect.Width); - const float top = std::max(highlight.Y, clipRect.Y); - const float bottom = std::min(highlight.Y + highlight.Height, clipRect.Y + clipRect.Height); - highlight = gd::RectF(left, top, right - left, bottom - top); - UiFillRect(g, highlight, theme.accent); - } - - // 长文本用 ETO_CLIPPING 交给 GDI 裁剪,避免字符画到输入框之外。 - UiTextAtClipped(g, text, font, textLeft, rect, - fullSelection ? theme.textOnAccent : theme.textStrong, clipRect); - - if (!fullSelection && edit.HasSelection() && selEndX > selBeginX) - { - // 局部选区:在反白底衬上重绘被选中的片段,其余文字保持常规颜色。 - const std::wstring selected = - text.substr(edit.SelectionBegin(), edit.SelectionEnd() - edit.SelectionBegin()); - UiTextAtClipped(g, selected, font, textLeft + selBeginX, rect, theme.textOnAccent, clipRect); - } - - // 插入符。 - const float caretDrawX = textLeft + caretX; - UiDrawLine(g, caretDrawX, rect.Y + 4.0f * scale, caretDrawX, rect.Y + rect.Height - 4.0f * scale, - theme.textStrong, 1.4f * scale); - - g.Restore(saveState); - } - - bool UiEditFieldMouseDown(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x, int y) - { - if (!edit.Active() || font == nullptr || rect.Width <= 0.0f) - { - return false; - } - if (!UiPointInRect(rect, static_cast(x), static_cast(y))) - { - return false; - } - - // 用与绘制完全相同的布局函数换算文本起点,保证点哪儿光标就落在哪儿。 - float innerWidth = 0.0f; - const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); - edit.BeginDrag(UiEditOffsetAt(font, edit.Text(), static_cast(x) - textLeft)); - return true; - } - - bool UiEditFieldMouseMove(HFONT font, const gd::RectF& rect, TextEdit& edit, float scale, int x) - { - if (!edit.Active() || !edit.Dragging() || font == nullptr || rect.Width <= 0.0f) - { - return false; - } - - float innerWidth = 0.0f; - const float textLeft = UiEditTextLeft(rect, edit, font, scale, innerWidth); - return edit.UpdateDrag(UiEditOffsetAt(font, edit.Text(), static_cast(x) - textLeft)); - } - - bool UiSwatchNeedsOutline(float opacity) - { - return !(opacity >= kSwatchOutlineOpacity); - } - - std::uint32_t UiDrawSwatch(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float opacity, - float radius, float scale) - { - double alpha = static_cast(opacity); - if (!(alpha > 0.0)) - { - alpha = 0.0; - } - if (alpha > 1.0) - { - alpha = 1.0; - } - - const std::uint32_t filled = WithAlpha(color, static_cast(alpha * 255.0 + 0.5)); - if (rect.Width > 0.0f && rect.Height > 0.0f) - { - UiFillRoundRect(g, rect, radius, filled); - - // 透明度过低时色块本身难以辨识,补充一圈描边以保持可读性;可见时不绘制描边。 - if (UiSwatchNeedsOutline(opacity)) - { - UiDrawRoundRect(g, rect, radius, SolarizedLight().borderStrong, - std::max(1.0f, scale)); - } - } - return filled; - } - } // namespace jdi diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 2b5a38c..9776df6 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -1,5 +1,6 @@ #include "PopupMenu.hpp" +#include "UiControls.hpp" #include #include