diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index ecafa64..5244a7c 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -395,6 +395,7 @@ namespace jdi Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */ + std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */ int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */ int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 459194b..1d121f5 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -27,7 +28,16 @@ namespace jdi namespace { const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */ - const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题(仅显示应用名称)。 */ + const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题中的应用名称部分。 */ + /** + * @brief 应用名称与内存占用之间的分隔竖线。 + * + * 与信号分组标题里「组X | 组名」的分隔方式一致:竖线两侧各留出空隙 + *(`kGroupSeparatorGap` 是那个分隔线的左右间距,这里用两个空格达到同等观感)。 + */ + const wchar_t* kTitleSeparator = L" | "; + /** @brief 内存占用的显示前缀(与具体数值之间保留一个空格)。 */ + const wchar_t* kRamTitlePrefix = L"RAM : "; const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */ const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */ const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */ @@ -37,6 +47,29 @@ namespace jdi const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */ const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ + const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */ + const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */ + + /** + * @brief 查询本进程当前占用的物理内存(工作集)。 + * + * 与任务管理器里显示的“内存”一致:进程已驻留在物理内存中的字节数。 + * 使用 `K32GetProcessMemoryInfo`(kernel32 导出)以免为 `psapi` 增加一条链接依赖。 + * + * @return 工作集字节数;查询失败返回 0。 + */ + unsigned long long ProcessWorkingSetBytes() + { + PROCESS_MEMORY_COUNTERS_EX counters = {}; + counters.cb = sizeof(counters); + if (K32GetProcessMemoryInfo(GetCurrentProcess(), + reinterpret_cast(&counters), + sizeof(counters)) == FALSE) + { + return 0ull; + } + return static_cast(counters.WorkingSetSize); + } /** * @brief 只失效绘图区所在的矩形。 @@ -345,6 +378,8 @@ namespace jdi NewTab(); Relayout(); + // 标题栏内存占用按固定间隔刷新(不使用每帧刷新,避免频繁改动窗口标题)。 + SetTimer(m_hwnd, kRamTimerId, kRamTimerPeriodMs, nullptr); ShowWindow(m_hwnd, showCommand); UpdateWindow(m_hwnd); LoadCommandLineFiles(); @@ -862,8 +897,19 @@ namespace jdi void Application::UpdateWindowTitle() { - // 标题栏只显示应用名称,不附带标签页、文件数量等无关信息。 - SetWindowTextW(m_hwnd, kWindowTitle); + // 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。 + // 除内存占用外不再附带标签页、文件数量等无关信息。 + std::wstring title = kWindowTitle; + title += kTitleSeparator; + title += kRamTitlePrefix; + title += FormatByteSize(ProcessWorkingSetBytes()); + // 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。 + if (title == m_windowTitleText) + { + return; + } + m_windowTitleText = title; + SetWindowTextW(m_hwnd, m_windowTitleText.c_str()); } void Application::OpenFileDialog() @@ -2748,6 +2794,12 @@ namespace jdi } case WM_TIMER: { + // 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。 + if (wParam == kRamTimerId) + { + UpdateWindowTitle(); + return 0; + } // 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。 if (wParam == kHintTimerId) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 9d97ad5..815a303 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1838,6 +1838,9 @@ namespace jdi float laneHeight = 0.0f; /**< 网格高度。 */ double laneYMin = 0.0; /**< 网格幅值下界。 */ double laneYMax = 0.0; /**< 网格幅值上界。 */ + float gain = 1.0f; /**< 绘制前的系数增益。 */ + bool absolute = false; /**< 绘制前是否取绝对值。 */ + bool inverted = false; /**< 绘制前是否中心反转。 */ std::vector points; /**< 折线顶点。 */ std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ @@ -2001,12 +2004,17 @@ namespace jdi float opacity = 1.0f; /**< 不透明度。 */ float lineWidth = 1.0f; /**< 线宽。 */ int mode = 0; /**< 绘制模式。 */ + float gain = 1.0f; /**< 系数增益。 */ + bool absolute = false; /**< 是否取绝对值。 */ + bool inverted = false; /**< 是否中心反转。 */ /** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */ bool operator==(const SceneStyleEntry& other) const { return color == other.color && opacity == other.opacity && - lineWidth == other.lineWidth && mode == other.mode; + lineWidth == other.lineWidth && mode == other.mode && + gain == other.gain && absolute == other.absolute && + inverted == other.inverted; } }; diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 8e37312..4cf1789 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -4654,22 +4654,17 @@ namespace jdi } } - // 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。 - { - int cursorWhich = 0; - if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy)) - { - m_dragMode = PlotDragMode::CURSOR_MOVE; - m_hoverCursor = cursorWhich; - // 点中的光标成为“活动光标”:它触发的内容将画在上层。 - m_activeCursor = cursorWhich; - m_cursorHover = true; - return true; - } - } + // 图例与 GX 编号徽标叠在波形之上、且直接位于网格顶部——用户明确要求它们**优先响应**: + // 必须先于「时间光标线 / 光标读数 / 边沿计数横向光标 / 选区放大」判定,否则: + // * 光标线恰好经过徽标/图例时,按下会变成拖光标; + // * 选区放大模式下会变成“开始框选”,用户看到的就是“GX 拖不动、图例点不中”。 + // 层序上仍然让位于浮动面板(面板绘制在徽标与图例之上),因此先挡一次面板命中。 + int guardLane = -1; + int guardKind = 0; + const bool guardPanel = TopPanelAtPoint(fx, fy, guardLane, guardKind); // 网格次序拖拽:按在图例的 GX 编号徽标上即可整块拎起该网格上下移动。 - // 位置在浮动面板之后(面板压在波形之上,应优先命中),但在信号拖拽之前。 + if (!guardPanel) { const int badgeLane = LaneBadgeAtPoint(x, y); if (badgeLane >= 0) @@ -4686,6 +4681,36 @@ namespace jdi } } + // 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。 + // **必须在选区放大判定之前**:选区放大模式只改变拖拽语义, + // 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。 + if (!guardPanel) + { + const SignalRef legendHit = LegendAtPoint(x, y); + if (legendHit.IsValid()) + { + SelectSignal(legendHit); + m_dragMode = PlotDragMode::NONE; + return true; + } + } + + // 靠近时间光标线时进入拖动。 + // (放在徽标/图例之后:两者重叠时徽标与图例优先,与用户要求一致。) + if (!guardPanel) + { + int cursorWhich = 0; + if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy)) + { + m_dragMode = PlotDragMode::CURSOR_MOVE; + m_hoverCursor = cursorWhich; + // 点中的光标成为“活动光标”:它触发的内容将画在上层。 + m_activeCursor = cursorWhich; + m_cursorHover = true; + return true; + } + } + // 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定): // * ✕ → 关闭该网格对应的功能; // * 逐条目参数输入框 → 进入内联编辑; @@ -4976,19 +5001,6 @@ namespace jdi return false; } - // 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。 - // **必须在选区放大判定之前**:选区放大模式只改变拖拽语义, - // 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。 - { - const SignalRef legendHit = LegendAtPoint(x, y); - if (legendHit.IsValid()) - { - SelectSignal(legendHit); - m_dragMode = PlotDragMode::NONE; - return true; - } - } - const PlotZoomMode mode = Tab().ZoomMode(); if (mode == PlotZoomMode::SELECT) { @@ -5330,6 +5342,10 @@ namespace jdi const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size()); const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); + // 绘制前的数值变换(增益/绝对值/中心反转)会改变顶点,必须进缓存键。 + const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f; + const bool absolute = (resolved.style != nullptr) && resolved.style->absolute; + const bool inverted = (resolved.style != nullptr) && resolved.style->inverted; for (const PolylineCacheEntry& entry : m_polylineCache) { @@ -5337,7 +5353,8 @@ namespace jdi entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X && entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && - entry.laneYMax == lane.yMax) + entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute && + entry.inverted == inverted) { return &entry; } @@ -5362,6 +5379,9 @@ namespace jdi entry.laneHeight = lane.rect.Height; entry.laneYMin = lane.yMin; entry.laneYMax = lane.yMax; + entry.gain = gain; + entry.absolute = absolute; + entry.inverted = inverted; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, entry.bandMode, entry.bandSpiky); @@ -5511,8 +5531,13 @@ namespace jdi const std::vector& coarseMin = resolved.data->coarseMin; const std::vector& coarseMax = resolved.data->coarseMax; const std::size_t coarseStride = resolved.data->coarseStride; + // ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 中心反转会把两者互换, + // 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。 const bool hasCoarse = - (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()); + (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) && + DrawTransformOrderPreserving(*resolved.style); + // 保序变换(正增益)下,分组极值直接乘增益即为变换后的极值(gain==1 时逐位不变)。 + const double coarseScale = static_cast(resolved.style->gain); bool inSegment = false; @@ -5566,8 +5591,8 @@ namespace jdi std::min((i1 - 1) / coarseStride, coarseMin.size() - 1); for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket) { - const double bucketLow = coarseMin[bucket]; - const double bucketHigh = coarseMax[bucket]; + const double bucketLow = coarseMin[bucket] * coarseScale; + const double bucketHigh = coarseMax[bucket] * coarseScale; if (!std::isfinite(bucketLow)) { // 该分组全部是无效值:包络到此断开,后续另起一段。 @@ -5590,7 +5615,7 @@ namespace jdi { for (std::size_t index = i0; index < i1; ++index) { - const double value = values[index]; + const double value = ApplyDrawTransform(*resolved.style, values[index]); if (!std::isfinite(value)) { // 该列含无效值:包络绘制到本列结束,后续另起一段。 @@ -5624,6 +5649,7 @@ namespace jdi double boundary = 0.0; if (PolylineValueAt(time, values, count, i0, t0, boundary)) { + boundary = ApplyDrawTransform(*resolved.style, boundary); if (boundary < low) { low = boundary; @@ -5637,6 +5663,7 @@ namespace jdi } if (PolylineValueAt(time, values, count, i1, t1, boundary)) { + boundary = ApplyDrawTransform(*resolved.style, boundary); if (boundary < low) { low = boundary; @@ -5722,7 +5749,7 @@ namespace jdi for (std::size_t index = begin; index < end; index += stride) { const double t = time[index]; - const double value = values[index]; + const double value = ApplyDrawTransform(*resolved.style, values[index]); if (!std::isfinite(t) || !std::isfinite(value)) { inSegment = false; @@ -6386,6 +6413,10 @@ namespace jdi mixFloat(resolved.style->opacity); mixFloat(resolved.style->lineWidth); mix(static_cast(resolved.style->mode)); + // 绘制前的数值变换(增益 / 取绝对值 / 中心反转)会改变波形形状。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->inverted ? 1ull : 0ull); } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->Title()); @@ -6431,6 +6462,9 @@ namespace jdi entry.opacity = resolved.style->opacity; entry.lineWidth = resolved.style->lineWidth; entry.mode = static_cast(resolved.style->mode); + entry.gain = resolved.style->gain; + entry.absolute = resolved.style->absolute; + entry.inverted = resolved.style->inverted; } lane.push_back(entry); } @@ -8851,7 +8885,7 @@ namespace jdi int visibleCount = 0; int drawnCount = 0; - // 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。 + // 先测量图例总宽度:只用来确定装饰缓存矩形(不再绘制任何托底/底板)。 float legendWidth = 0.0f; for (const SignalRef& ref : laneSignals) { @@ -8877,23 +8911,24 @@ namespace jdi legendWidth += itemWidth; } { - // 底板从编号徽标开始一直盖到最后一个图例条目(编号徽标一并被掠开底下的网格线)。 + // 底板范围(编号徽标 + 图例条目):这里**只记录装饰矩形**用于缓存贴回。 const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f); - // 记录图例装饰矩形(含编号徽标):从徽标左缘到图例右缘。 { const float left = std::min(badgeRect.X, legendBand.X - 4.0f * m_scale) - 2.0f * m_scale; const float right = std::max(badgeRect.X + badgeRect.Width, plateRight) + 2.0f * m_scale; m_decorRects.push_back(gd::RectF(left, top - 4.0f * m_scale, right - left, height + 8.0f * m_scale)); } - // 命中装饰缓存:整块图例(底板 + 徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。 + // ⚠️ **不再绘制不透明托底**(用户要求去掉):原来的底板会把徽标与图例下方的 + // 网格线整块擦掉,使这一条看起来像一个"可点击的控件",而它右侧的空白区域 + // 实际并不响应点击(选区放大模式下会变成开始框选)。现在图例与徽标直接叠在 + // 网格线之上,命中范围与看到的控件一一对应。 + // + // 命中装饰缓存:整块图例(徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。 if (m_useDecorCache) { return; } - UiFillRoundRect(g, gd::RectF(legendBand.X - 4.0f * m_scale, top - 2.0f * m_scale, - plateRight - legendBand.X + 4.0f * m_scale, height + 4.0f * m_scale), - 3.0f * m_scale, m_theme->cardBg | 0xFF000000u); } // 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。 @@ -9366,6 +9401,8 @@ namespace jdi { continue; } + // 取值读数必须与画出来的波形一致:同样套用增益/取绝对值/中心反转。 + value = ApplyDrawTransform(*resolved.style, value); const float rawY = ValueToY(value, lane); // 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。 @@ -9388,6 +9425,7 @@ namespace jdi double otherValue = 0.0; if (SampleSignalAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue)) { + otherValue = ApplyDrawTransform(*resolved.style, otherValue); const double delta = value - otherValue; // 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。 text += L" ("; @@ -10318,7 +10356,7 @@ namespace jdi inSegment = false; continue; } - float y = ValueToY(static_cast(sampled), lane); + float y = ValueToY(ApplyDrawTransform(*resolved.style, static_cast(sampled)), lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); const float x = TimeToX(time[index]); if (!inSegment) diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 867a65d..06ed407 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -300,7 +300,8 @@ namespace jdi WIDTH = 1, /**< 线宽数值。 */ OPACITY = 2, /**< 不透明度数值(百分比)。 */ SIGNAL_NAME = 3, /**< 信号显示名称(列表行内)。 */ - FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */ + FILE_NAME = 4, /**< 分组显示名称(列表行内)。 */ + GAIN = 5 /**< 系数增益(绘制前把采样值乘以该系数)。 */ }; /** @@ -339,6 +340,9 @@ namespace jdi OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ WIDTH_SLIDER, /**< 线宽滑块。 */ WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ + GAIN_FIELD, /**< 系数增益输入框。 */ + ABSOLUTE_TOGGLE, /**< 「取绝对值」切换按钮。 */ + INVERT_TOGGLE, /**< 「中心反转」切换按钮。 */ TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */ FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */ }; @@ -592,13 +596,17 @@ namespace jdi std::array m_modeRects; /**< 绘制样式按钮矩形。 */ - // 样式区固定四行:颜色 / 样式 / 透明度 / 线宽。各行控件共用同一内容列宽度与左边界。 - std::array m_styleRowRects; /**< 四行整体矩形(含标签列)。 */ + // 样式区固定六行:颜色 / 样式 / 线宽 / 透明度 / 系数增益 / 取绝对值与中心反转。 + // 各行控件共用同一内容列宽度与左边界。 + std::array m_styleRowRects; /**< 六行整体矩形(含标签列)。 */ gd::RectF m_opacityTrackRect; /**< 不透明度滑块轨道矩形。 */ gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */ gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */ gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */ + gd::RectF m_gainFieldRect; /**< 系数增益输入框矩形。 */ + gd::RectF m_absoluteRect; /**< 「取绝对值」切换按钮矩形。 */ + gd::RectF m_invertedRect; /**< 「中心反转」切换按钮矩形。 */ std::vector m_rows; /**< 列表行集合(惰性缓存)。 */ bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index a461bb9..4f1696b 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -88,15 +88,15 @@ namespace jdi return p == pattern.size(); } - // 样式编辑区:固定四行网格(颜色 / 样式 / 透明度 / 线宽),行高、间距与内容列宽度统一, - // 保证面板宽度变化时四行控件始终对齐,且不会被无限制拉伸。 + // 样式编辑区:固定六行网格(颜色 / 样式 / 线宽 / 透明度 / 系数增益 / 取绝对值与中心反转), + // 行高、间距与内容列宽度统一,保证面板宽度变化时各行控件始终对齐,且不会被无限制拉伸。 const float kStylePadH = 12.0f; /**< 样式区左右内边距。 */ const float kStylePadV = 12.0f; /**< 样式区上下内边距。 */ const float kStyleRowHeight = 26.0f; /**< 样式区单行高度。 */ const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */ - const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */ + const float kStyleLabelWidth = 56.0f; /**< 样式行标签列宽度(需容纳「系数增益」四个字)。 */ const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */ - const float kStyleRowCount = 4.0f; /**< 样式区固定行数。 */ + const float kStyleRowCount = 6.0f; /**< 样式区固定行数。 */ const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ @@ -105,10 +105,16 @@ namespace jdi const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */ const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ + const float kGainFieldWidth = 110.0f; /**< 系数增益输入框宽度(逻辑像素)。 */ + const float kGainFieldPadH = 6.0f; /**< 系数增益输入框内文字左右内缩。 */ + const float kStyleFlagGap = 6.0f; /**< 「取绝对值」与「中心反转」两个按钮之间的间距。 */ const float kWidthMin = 0.5f; /**< 允许的最小线宽(px)。 */ const float kWidthMax = 10.0f; /**< 允许的最大线宽(px)。 */ + /** @brief 系数增益允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */ + const double kGainLimit = 10000.0; + /** @brief 可选绘制样式顺序(不含阶梯方式)。 */ const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER}; const int kModeOptionCount = static_cast(sizeof(kModeOptions) / sizeof(kModeOptions[0])); @@ -149,6 +155,33 @@ namespace jdi { return (value < low) ? low : ((value > high) ? high : value); } + + /** + * @brief 把系数增益格式化为界面文本(最多 4 位小数,并去掉无意义的尾随 0 与小数点)。 + * + * 默认值 1 因此显示为 `1`(而不是 `1.0000`),输入 2.5 显示为 `2.5`。 + * + * @param [IN] gain 增益系数。 + * @return 显示文本。 + */ + std::wstring FormatGainText(float gain) + { + std::wstring text = jdi::FormatNumber(static_cast(gain), 4); + if (text.find(L'.') != std::wstring::npos) + { + std::size_t end = text.size(); + while (end > 0 && text[end - 1] == L'0') + { + --end; + } + if (end > 0 && text[end - 1] == L'.') + { + --end; + } + text.erase(end); + } + return text; + } } SignalPanel::SignalPanel() @@ -257,6 +290,16 @@ namespace jdi { return LoadCursorW(nullptr, IDC_HAND); } + + // 样式区的可点击控件(系数增益输入框、两个切换按钮、数值框)也用手型光标。 + const float fx = static_cast(x); + const float fy = static_cast(y); + if (UiPointInRect(m_gainFieldRect, fx, fy) || UiPointInRect(m_absoluteRect, fx, fy) || + UiPointInRect(m_invertedRect, fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || + UiPointInRect(m_opacityValueRect, fx, fy)) + { + return LoadCursorW(nullptr, IDC_HAND); + } return nullptr; } @@ -634,8 +677,10 @@ namespace jdi const gd::RectF& colorRow = m_styleRowRects[0]; const gd::RectF& modeRow = m_styleRowRects[1]; - const gd::RectF& opacityRow = m_styleRowRects[2]; - const gd::RectF& widthRow = m_styleRowRects[3]; + const gd::RectF& widthRow = m_styleRowRects[2]; + const gd::RectF& opacityRow = m_styleRowRects[3]; + const gd::RectF& gainRow = m_styleRowRects[4]; + const gd::RectF& flagRow = m_styleRowRects[5]; // ---- 颜色行:预设色块 + 同尺寸自定义颜色入口(合计恰好铺满内容列宽度)---- int paletteCount = 0; @@ -689,20 +734,30 @@ namespace jdi modeWidth, rowHeight); } - // ---- 透明度行与线宽行:几何完全一致,保证纵向宽度对齐 ---- + // ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)---- const float valueWidth = std::min(kSliderValueWidth * m_scale, contentWidth * 0.4f); const float trackWidth = std::max(24.0f, contentWidth - valueWidth - kSliderValueGap * m_scale); const float trackHeight = kTrackHeight * m_scale; + m_widthTrackRect = + gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); + m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y, + valueWidth, rowHeight); + m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y, valueWidth, rowHeight); - m_widthTrackRect = - gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight); - m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y, - valueWidth, rowHeight); + // ---- 系数增益行:靠左的数值输入框 ---- + const float gainWidth = std::min(kGainFieldWidth * m_scale, contentWidth); + m_gainFieldRect = gd::RectF(contentLeft, gainRow.Y, gainWidth, rowHeight); + + // ---- 「取绝对值」与「中心反转」:同一行的两个等宽切换按钮 ---- + const float flagGap = kStyleFlagGap * m_scale; + const float flagWidth = std::max(20.0f, (contentWidth - flagGap) * 0.5f); + m_absoluteRect = gd::RectF(contentLeft, flagRow.Y, flagWidth, rowHeight); + m_invertedRect = gd::RectF(contentLeft + flagWidth + flagGap, flagRow.Y, flagWidth, rowHeight); } void SignalPanel::Layout(const gd::RectF& area) @@ -1207,29 +1262,9 @@ namespace jdi } } - // ---- 第三行:透明度(数值可点击输入)---- + // ---- 第三行:线宽(数值可点击输入)---- { - paintRowLabel(2, L"透明度"); - - const bool active = m_draggingOpacity || (m_hoverStyle == StyleHit::OPACITY_SLIDER); - UiDrawSlider(g, m_opacityTrackRect, static_cast(style->opacity), m_theme->accent, active, - m_scale); - - if (m_editField == StyleEditField::OPACITY) - { - UiDrawEditField(g, m_opacityValueRect, m_edit, m_fonts->monoSmall, m_scale); - } - else - { - const int percent = static_cast(style->opacity * 100.0f + 0.5f); - UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, - m_theme->textStrong, TextAlign::RIGHT); - } - } - - // ---- 第四行:线宽(与透明度行几何一致,数值可点击输入)---- - { - paintRowLabel(3, L"线宽"); + paintRowLabel(2, L"线宽"); const double ratio = (static_cast(style->lineWidth) - kWidthMin) / (kWidthMax - kWidthMin); @@ -1253,6 +1288,65 @@ namespace jdi UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT); } } + + // ---- 第四行:透明度(数值可点击输入)---- + { + paintRowLabel(3, L"透明度"); + + const bool active = m_draggingOpacity || (m_hoverStyle == StyleHit::OPACITY_SLIDER); + UiDrawSlider(g, m_opacityTrackRect, static_cast(style->opacity), m_theme->accent, active, + m_scale); + + if (m_editField == StyleEditField::OPACITY) + { + UiDrawEditField(g, m_opacityValueRect, m_edit, m_fonts->monoSmall, m_scale); + } + else + { + const int percent = static_cast(style->opacity * 100.0f + 0.5f); + UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect, + m_theme->textStrong, TextAlign::RIGHT); + } + } + + // ---- 第五行:系数增益(绘制前把采样值乘以该系数,可点击输入)---- + { + paintRowLabel(4, L"系数增益"); + + if (m_editField == StyleEditField::GAIN) + { + UiDrawEditField(g, m_gainFieldRect, m_edit, m_fonts->monoSmall, m_scale); + } + else + { + const bool hovered = (m_hoverStyle == StyleHit::GAIN_FIELD); + UiFillRoundRect(g, m_gainFieldRect, kCorner * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, m_gainFieldRect, kCorner * m_scale, + hovered ? m_theme->accent : m_theme->border, hovered ? 1.6f : 1.0f); + const gd::RectF textRect(m_gainFieldRect.X + kGainFieldPadH * m_scale, m_gainFieldRect.Y, + std::max(8.0f, m_gainFieldRect.Width - kGainFieldPadH * 2.0f * m_scale), + m_gainFieldRect.Height); + UiText(g, FormatGainText(style->gain), m_fonts->monoSmall, textRect, m_theme->textStrong, + TextAlign::LEFT); + } + } + + // ---- 第六行:取绝对值 / 中心反转(两个切换按钮,作用顺序为「先绝对值、后中心反转」)---- + { + const auto paintFlag = [&](const gd::RectF& rect, const wchar_t* label, bool checked, + StyleHit target) + { + const bool hot = (m_hoverStyle == target); + UiFillRoundRect(g, rect, kCorner * m_scale, + checked ? m_theme->accentSoft : (hot ? m_theme->hover : m_theme->cardBg)); + UiDrawRoundRect(g, rect, kCorner * m_scale, + checked ? m_theme->accent : m_theme->border, 1.0f); + UiText(g, label, m_fonts->uiSmall, rect, + checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); + }; + paintFlag(m_absoluteRect, L"取绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); + paintFlag(m_invertedRect, L"中心反转", style->inverted, StyleHit::INVERT_TOGGLE); + } } bool SignalPanel::AreAllSignalsVisible() const @@ -1372,6 +1466,16 @@ namespace jdi initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); break; } + case StyleEditField::GAIN: + { + const SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + initial = FormatGainText(style->gain); + break; + } case StyleEditField::SIGNAL_NAME: { if (row < 0 || row >= static_cast(m_rows.size())) @@ -1476,6 +1580,25 @@ namespace jdi } break; } + case StyleEditField::GAIN: + { + if (trimmed.empty()) + { + break; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || !std::isfinite(parsed)) + { + break; // 非法输入保持原值 + } + if (SignalStyle* style = StyleOf(SelectedRef())) + { + style->gain = + static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); + } + break; + } case StyleEditField::SIGNAL_NAME: { if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) @@ -1530,6 +1653,8 @@ namespace jdi return m_widthValueRect; case StyleEditField::OPACITY: return m_opacityValueRect; + case StyleEditField::GAIN: + return m_gainFieldRect; case StyleEditField::SIGNAL_NAME: case StyleEditField::FILE_NAME: return RowNameRect(m_editRow); @@ -1986,6 +2111,18 @@ namespace jdi { hoverStyle = StyleHit::WIDTH_VALUE; } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_gainFieldRect, fx, fy)) + { + hoverStyle = StyleHit::GAIN_FIELD; + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_absoluteRect, fx, fy)) + { + hoverStyle = StyleHit::ABSOLUTE_TOGGLE; + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_invertedRect, fx, fy)) + { + hoverStyle = StyleHit::INVERT_TOGGLE; + } } if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex && @@ -2126,6 +2263,22 @@ namespace jdi { return BeginEdit(StyleEditField::OPACITY); } + // 系数增益:点击输入框即进入输入状态。 + if (UiPointInRect(m_gainFieldRect, fx, fy)) + { + return BeginEdit(StyleEditField::GAIN); + } + // 「取绝对值」/「中心反转」:单击即切换(作用顺序为 增益 → 绝对值 → 中心反转)。 + if (UiPointInRect(m_absoluteRect, fx, fy)) + { + style->absolute = !style->absolute; + return true; + } + if (UiPointInRect(m_invertedRect, fx, fy)) + { + style->inverted = !style->inverted; + return true; + } } // 列表行。 @@ -2306,6 +2459,10 @@ namespace jdi { return BeginEdit(StyleEditField::OPACITY); } + if (UiPointInRect(m_gainFieldRect, fx, fy)) + { + return BeginEdit(StyleEditField::GAIN); + } RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 5af7cad..899683e 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -90,7 +90,7 @@ namespace jdi }; /** - * @brief 单个信号的显示外观(颜色/透明度/线宽/绘制方式)。 + * @brief 单个信号的显示外观(颜色/透明度/线宽/绘制方式/数值变换)。 * * 外观属于信号自身而非某个标签页——同一信号在所有标签页中必须显示效果一致, * 因此在任一视图里修改颜色/线宽/透明度/样式,其它视图会自动同步。 @@ -103,8 +103,60 @@ namespace jdi float opacity = 1.0f; /**< 不透明度,取值 0.0-1.0,默认 1.0。 */ float lineWidth = 1.0f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */ DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */ + + /** + * 绘制前的数值变换(三者**依次**作用,见 `ApplyDrawTransform`): + * 1. `gain` 系数增益:采样值乘以该系数(默认 1,即不改变); + * 2. `absolute` 取绝对值:|value|; + * 3. `inverted` 中心反转:按 Y = 0 上下反向(value → −value)。 + */ + float gain = 1.0f; /**< 系数增益,默认 1.0。 */ + bool absolute = false; /**< 是否对绘制值取绝对值,默认关闭。 */ + bool inverted = false; /**< 是否按 Y=0 中心反转绘制值,默认关闭。 */ }; + /** + * @brief 把原始采样值变换为**绘制用值**。 + * + * 顺序固定为「系数增益 → 取绝对值 → 中心反转」(用户明确要求中心反转最后结算), + * 例如增益 2、原始值 1.5 → 2×1.5 = 3;再取绝对值仍为 3;再中心反转为 −3。 + * NaN 经乘/取绝对值/取反后仍为 NaN(保持断线语义)。 + * + * @param [IN] style 信号外观(提供增益与两个开关)。 + * @param [IN] value 原始采样值。 + * @return 绘制用值。 + */ + inline double ApplyDrawTransform(const SignalStyle& style, double value) + { + if (style.gain != 1.0f) + { + value *= static_cast(style.gain); + } + if (style.absolute) + { + value = (value < 0.0) ? -value : value; + } + if (style.inverted) + { + value = -value; + } + return value; + } + + /** + * @brief 判断数值变换是否**保持大小顺序**(正增益、且不取绝对值、不反转)。 + * + * 只有保序时,粗粒度包络层(每组的 min/max)才能直接按增益缩放后继续使用; + * 取绝对值 / 反转会把最小值与最大值互换,此时必须回退到逐采样点变换。 + * + * @param [IN] style 信号外观。 + * @return 变换单调递增返回 true。 + */ + inline bool DrawTransformOrderPreserving(const SignalStyle& style) + { + return (style.gain > 0.0f) && !style.absolute && !style.inverted; + } + /** * @brief 单条信号的元数据与采样值(纯数据,不含可见性配置)。 */ diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index f8c4eba..8b480b5 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -18,8 +18,7 @@ namespace jdi const float kTitleGap = 8.0f; /**< 标题与内容区间距。 */ const float kRowHeight = 26.0f; /**< 单个滑块行高度。 */ const float kRowGap = 2.0f; /**< 滑块行间距。 */ - const float kPreviewGap = 10.0f; /**< 滑块区与预览区间距。 */ - const float kPreviewHeight = 24.0f; /**< 预览行高度。 */ + const float kPreviewHeight = 24.0f; /**< 预览行(预览色块 + 色号)高度。 */ const float kLabelWidth = 16.0f; /**< 通道标签宽度。 */ const float kLabelGap = 6.0f; /**< 标签与滑块间距。 */ const float kValueWidth = 40.0f; /**< 数值文本宽度。 */ @@ -220,13 +219,14 @@ namespace jdi void ColorPickerPopup::BuildLayout(const gd::RectF& bounds, const gd::RectF& anchor) { - // 布局:左侧一列放"色域方块 + 色相条",右侧一列放 R/G/B 三行 - //(每行 = 通道标签 + 滑块 + 数值),两列下方再放"预览色块 + 色号"一行。 + // 布局:左侧一列放"色域方块 + 色相条",右侧一列**上方**放"预览色块 + 色号"、 + // **下方**放 R/G/B 三行(每行 = 通道标签 + 滑块 + 数值)。 + // 右列总高 = 预览行 + 间隙 + 三行滑块,与左侧"色域方块"高度**严格相等**, + // 因此两列上下边对齐、整体版面协调(不再有单起一行的底部预览条)。 // 所有控件都从面板内边距起算,因此不会出现越界。 const float width = kPanelWidth * m_scale; - const float height = (kPadV * 2.0f + kTitleHeight + kTitleGap + kGamutSize + kPreviewGap + - kPreviewHeight) * - m_scale; + const float height = + (kPadV * 2.0f + kTitleHeight + kTitleGap + kGamutSize) * m_scale; const float gap = 4.0f * m_scale; @@ -260,22 +260,30 @@ namespace jdi m_hueRect = gd::RectF(m_gamutRect.X + m_gamutRect.Width + kGamutGap * m_scale, contentTop, kHueWidth * m_scale, kGamutSize * m_scale); - // 右列:R/G/B 三行。整列在方块高度内纵向居中,与左侧方块视觉平衡。 - const float leftColumnWidth = - (kGamutSize + kGamutGap + kHueWidth) * m_scale; + // 右列:预览行在滑块之上,整列高度与左侧方块一致。 + const float leftColumnWidth = (kGamutSize + kGamutGap + kHueWidth) * m_scale; const float columnLeft = m_gamutRect.X + leftColumnWidth + kColumnGap * m_scale; const float columnWidth = std::max(60.0f * m_scale, contentRight - columnLeft); - const float rowsHeight = (kRowHeight * 3.0f + kRowGap * 2.0f) * m_scale; - const float firstRowTop = - contentTop + std::max(0.0f, (kGamutSize * m_scale - rowsHeight) * 0.5f); + // 预览行与滑块之间的间隙:正好补齐"方块高度 − 预览行高 − 三行滑块高", + // 使右列总高与左侧色域方块严格相等(常量变化时自动适配)。 + const float previewRowGap = + std::max(0.0f, kGamutSize - kPreviewHeight - (kRowHeight * 3.0f + kRowGap * 2.0f)) * m_scale; + + m_previewRect = gd::RectF(columnLeft, contentTop, kSwatchWidth * m_scale, kPreviewHeight * m_scale); + m_hexRect = gd::RectF(m_previewRect.X + m_previewRect.Width + kSwatchHexGap * m_scale, contentTop, + std::max(40.0f * m_scale, + columnWidth - (kSwatchWidth + kSwatchHexGap) * m_scale), + kPreviewHeight * m_scale); + + const float sliderTop = m_previewRect.Y + m_previewRect.Height + previewRowGap; const float trackLeft = columnLeft + (kLabelWidth + kLabelGap) * m_scale; const float trackWidth = std::max(24.0f, columnWidth - (kLabelWidth + kLabelGap + kValueWidth + 6.0f) * m_scale); for (int index = 0; index < 3; ++index) { - const float rowTop = firstRowTop + static_cast(index) * (kRowHeight + kRowGap) * m_scale; + const float rowTop = sliderTop + static_cast(index) * (kRowHeight + kRowGap) * m_scale; m_tracks[static_cast(index)] = gd::RectF(trackLeft, rowTop + (kRowHeight * m_scale - kTrackHeight * m_scale) * 0.5f, trackWidth, kTrackHeight * m_scale); @@ -283,15 +291,6 @@ namespace jdi gd::RectF(trackLeft + trackWidth + 6.0f * m_scale, rowTop, kValueWidth * m_scale, kRowHeight * m_scale); } - - // 下行:预览色块 + 色号,与上方的"方块 + 色相条"同宽。 - const float previewTop = contentTop + (kGamutSize + kPreviewGap) * m_scale; - m_previewRect = - gd::RectF(contentLeft, previewTop, kSwatchWidth * m_scale, kPreviewHeight * m_scale); - m_hexRect = gd::RectF(m_previewRect.X + m_previewRect.Width + kSwatchHexGap * m_scale, - previewTop, - std::max(40.0f * m_scale, leftColumnWidth - (kSwatchWidth + kSwatchHexGap) * m_scale), - kPreviewHeight * m_scale); } void ColorPickerPopup::Open(const gd::RectF& bounds, const gd::RectF& anchor, std::uint32_t color)