#include "SignalPanel.hpp" #include #include #include #include "Utf8Util.hpp" namespace jdi { namespace { const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */ const float kFileRowHeight = 30.0f; /**< 文件分组行高。 */ const float kSignalRowHeight = 27.0f; /**< 信号行高。 */ const float kPadding = 12.0f; /**< 通用内边距。 */ const float kCheckSize = 15.0f; /**< 复选框尺寸。 */ const float kSwatchSize = 12.0f; /**< 信号行颜色小方块尺寸。 */ const float kBadgeWidth = 30.0f; /**< 网格徽标宽度。 */ const float kBadgeHeight = 17.0f; /**< 网格徽标高度。 */ const float kCorner = 4.0f; /**< 通用圆角半径。 */ const float kDisclosureWidth = 16.0f; /**< 折叠三角占位宽度。 */ const float kIndent = 14.0f; /**< 信号行相对文件行的缩进。 */ const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */ const float kCloseButtonSize = 18.0f; /**< 行尾关闭(✕)按钮尺寸(分组行与信号行共用)。 */ const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */ const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */ const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */ const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */ const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */ const float kRowTailGap = 6.0f; /**< 网格徽标与行尾 ✕ 之间的间距。 */ /** * @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。 * @param [IN] text 待匹配文本。 * @param [IN] pattern 模式(可含多个 `*`)。 * @return 匹配返回 true。 */ bool WildcardMatchNoCase(const std::wstring& text, const std::wstring& pattern) { const auto fold = [](wchar_t character) -> wchar_t { // 仅对基本拉丁字母做大小写折叠(中文等其它字符原样比较)。 if (character >= L'A' && character <= L'Z') { return static_cast(character - L'A' + L'a'); } return character; }; // 经典双指针回溯:`star` 记录最近一个 `*` 的位置,`resume` 记录当时尝试的文本位置。 std::size_t t = 0; std::size_t p = 0; std::size_t star = std::wstring::npos; std::size_t resume = 0; while (t < text.size()) { if (p < pattern.size() && pattern[p] == L'*') { star = p; resume = t; ++p; } else if (p < pattern.size() && fold(pattern[p]) == fold(text[t])) { ++p; ++t; } else if (star != std::wstring::npos) { p = star + 1; ++resume; t = resume; } else { return false; } } // 文本已耗尽:模式剩余部分必须全是 `*`。 while (p < pattern.size() && pattern[p] == L'*') { ++p; } 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 kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */ const float kStyleRowCount = 5.0f; /**< 样式区固定行数。 */ const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */ const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */ const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */ const float kSwatchGap = 4.0f; /**< 颜色色块最小间距。 */ const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */ const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */ const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */ const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */ const int kAdjustMirror = 1; /**< 镜像(点击展开镜像二级面板)。 */ const int kAdjustAbsolute = 2; /**< 绝对值。 */ const int kAdjustCount = 3; /**< 按钮数量。 */ const float kWidthMin = 0.5f; /**< 允许的最小线宽(px)。 */ const float kWidthMax = 10.0f; /**< 允许的最大线宽(px)。 */ /** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */ const double kGainLimit = 10000.0; /** @brief 系数为默认值 1(不做任何缩放)时按钮显示为未启用样式。 */ bool GainIsDefault(float gain) { return std::fabs(gain - 1.0f) < 1.0e-6f; } /** @brief 可选绘制样式顺序(不含阶梯方式)。 */ const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER}; const int kModeOptionCount = static_cast(sizeof(kModeOptions) / sizeof(kModeOptions[0])); /** * @brief 计算滑块的便捷命中矩形(比轨道略大,便于拖拽)。 * @param [IN] track 轨道矩形。 * @param [IN] scale 缩放比例。 * @return 命中矩形。 */ gd::RectF SliderHitRect(const gd::RectF& track, float scale) { return gd::RectF(track.X - 6.0f * scale, track.Y - 9.0f * scale, track.Width + 12.0f * scale, track.Height + 18.0f * scale); } /** * @brief 判断颜色是否偏暗(用于在色块之上选择可读的图标颜色)。 * @param [IN] color 0xAARRGGBB 颜色值。 * @return 相对亮度低于阈值返回 true。 */ bool IsDarkColor(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) < 140.0; } /** * @brief 将数值限制到合法区间。 * @param [IN] value 输入值。 * @param [IN] low 下限。 * @param [IN] high 上限。 * @return 限制后的数值。 */ float ClampFloat(float value, float low, float high) { return (value < low) ? low : ((value > high) ? high : value); } /** * @brief 数值文本:**至少两位小数**,精度更高时按实际精度显示。 * * 例:`1` → `1.00`、`2.5` → `2.50`、`0.125` → `0.125`、`1.2345678` → `1.2345678`。 * 实现:从 2 位小数开始递增位数,直到文本能精确表示该数值 *(容差 1e-7 相对量,与 float 精度匹配)。 * * @param [IN] value 数值。 * @return 显示文本。 */ std::wstring FormatNumberText(double value) { const double tolerance = std::max(1.0e-12, std::fabs(value) * 1.0e-7); for (int decimals = 2; decimals <= 9; ++decimals) { const std::wstring text = jdi::FormatNumber(value, decimals); const double parsed = std::wcstod(text.c_str(), nullptr); if (std::fabs(parsed - value) <= tolerance) { return text; } } return jdi::FormatNumber(value, 9); } /** * @brief 带正负符号的数值文本(非编辑态显示偏移量时使用)。 * * 规则(用户明确要求):**正数带 `+`、负数带 `-`、0 不带任何符号**。 * (旧实现 0 也会显示 `+0.00`,用户报告“偏移调整的 Y 轴输入框默认值为 0 时也显示了正号,这不正确”。) * @param [IN] value 数值。 * @return 形如 `+1.50` / `-0.25` / `0.00` 的文本。 */ std::wstring FormatSignedNumber(double value) { const std::wstring text = FormatNumberText(std::fabs(value)); if (value > 0.0) { return L"+" + text; } if (value < 0.0) { return L"-" + text; } return text; // 0:不带符号 } /** * @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则 + 显式正号)。 * * 规则(用户明确要求):“系数调整”输入框**非编辑态**显示时,非 0 正值前面加 `+`, * 负值按原样显示负号,0 不带符号。编辑态仍用不带符号的纯数值(见 `FormatNumberText`)。 * @param [IN] gain 系数。 * @return 显示文本。 */ std::wstring FormatGainText(float gain) { const double value = static_cast(gain); if (value > 0.0) { return L"+" + FormatNumberText(value); } return FormatNumberText(value); } /** * @brief 「时间轴」偏移的非编辑态显示文本(`Δt:` + 带正负号的复合单位间隔)。 * * 规则(用户明确要求):在 `Δt:` 冒号后的时间显示值**前面加上正负号**, * 只有值为 0 时不显示符号。示例:`Δt:+1h 1min 2s` / `Δt:-5s 60ms` / `Δt:0s`。 *(`FormatDurationCompound` 内部取绝对值,因此符号由这里补上。) * @param [IN] seconds 时间偏移(秒,可为负)。 * @return 显示文本。 */ std::wstring FormatOffsetTimeText(double seconds) { if (seconds > 0.0) { return L"Δt:+" + FormatDurationCompound(seconds); } if (seconds < 0.0) { return L"Δt:-" + FormatDurationCompound(seconds); } return L"Δt:" + FormatDurationCompound(0.0); } } SignalPanel::SignalPanel() { m_colorRects.fill(gd::RectF()); m_modeRects.fill(gd::RectF()); m_adjustRects.fill(gd::RectF()); } void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu, ColorPickerPopup* colorPicker) { m_host = host; m_fonts = fonts; m_theme = theme; m_menu = menu; m_colorPicker = colorPicker; } void SignalPanel::SetContext(DataSet* dataSet, TabConfig* tab) { m_dataSet = dataSet; m_dataSetWritable = dataSet; m_tab = tab; m_rowsDirty = true; m_scrollOffset = 0.0f; m_draggingOpacity = false; m_draggingWidth = false; m_edit.End(); m_editRow = -1; m_editField = StyleEditField::NONE; m_pendingLaneMenu = false; m_pendingColorPicker = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; } void SignalPanel::SetScale(float scale) { m_scale = (scale > 0.1f) ? scale : 1.0f; } bool SignalPanel::HitTest(int x, int y) const { return UiPointInRect(m_area, static_cast(x), static_cast(y)); } int SignalPanel::TotalSignalCount() const { if (m_dataSet == nullptr) { return 0; } return static_cast(m_dataSet->SignalCount()); } float SignalPanel::DesiredStyleHeight() const { return kStylePadV * 2.0f + kStyleRowCount * kStyleRowHeight + (kStyleRowCount - 1.0f) * kStyleRowGap; } bool SignalPanel::TakeLayoutDirty() { const bool dirty = m_layoutDirty; m_layoutDirty = false; return dirty; } bool SignalPanel::IsOnFilterBox(int x, int y) const { return UiPointInRect(m_filterRect, static_cast(x), static_cast(y)); } void SignalPanel::ClearFilter() { if (m_filterText.empty() && !m_filterEditing) { return; } m_filterText.clear(); if (m_filterEditing) { // 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。 m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u); } // 清空后回到完整列表:重建行集合、回到顶部。 m_rowsDirty = true; m_scrollOffset = 0.0f; m_layoutDirty = true; } HCURSOR SignalPanel::QueryCursor(int x, int y) { // 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。 const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_HAND); } // 信号行左侧的「移除该信号」✕ 同理。 RowPart part = RowPart::NONE; if (HitRow(x, y, part) >= 0 && part == RowPart::REMOVE_SIGNAL) { return LoadCursorW(nullptr, IDC_HAND); } // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy)) { return LoadCursorW(nullptr, IDC_HAND); } return nullptr; } void SignalPanel::BeginFilterEdit() { if (m_filterEditing) { return; } m_filterEditing = true; // 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。 m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u); } bool SignalPanel::EndFilterEdit() { if (!m_filterEditing) { return false; } // **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。 const std::wstring finalText = Trim(m_filterEdit.Text()); m_filterEditing = false; m_filterEdit.End(); // 文本在编辑过程中已实时生效,这里只需把最终值再同步一次并收起插入符。 m_filterText = finalText; return true; } void SignalPanel::ApplyFilterText(const std::wstring& text) { if (m_filterText == text) { return; } m_filterText = text; // 筛选结果变化 → 重建行集合、回到列表顶部并重新计算滚动条。 m_rowsDirty = true; m_scrollOffset = 0.0f; m_layoutDirty = true; } bool SignalPanel::FilterMatchesSignal(const SignalData& signal) const { if (m_filterText.empty()) { return true; } // 以 `*` 为通配符、忽略大小写,匹配"信号名中是否出现过符合该模式的片段"。 // 两侧补 `*` 使模式等价于子串匹配,因此下面这些写法都能按直觉工作: // `编码器` → 名称包含"编码器" // `编码器*相` → 名称中出现以"编码器"开头、"相"结尾的片段 // `*x10` / `ABS*` → 尾部/首部片段匹配(无需用户自己补另一侧的 `*`) std::wstring pattern = L"*"; pattern += m_filterText; pattern += L'*'; return WildcardMatchNoCase(signal.Title(), pattern); } void SignalPanel::BuildRows() { m_rows.clear(); m_rowsDirty = false; if (m_dataSet == nullptr || m_tab == nullptr) { m_contentHeight = 0.0f; return; } const float fileHeight = kFileRowHeight * m_scale; const float signalHeight = kSignalRowHeight * m_scale; const bool filtering = !m_filterText.empty(); const std::vector& files = m_dataSet->Files(); for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex) { // 筛选时先挑出本组内匹配的信号;整组都没有匹配则**不显示该组**。 std::vector matched; if (filtering) { const std::size_t signalCount = files[fileIndex].signals.size(); for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) { if (FilterMatchesSignal(files[fileIndex].signals[signalIndex])) { matched.push_back(static_cast(signalIndex)); } } if (matched.empty()) { continue; } } ListRow fileRow; fileRow.kind = RowKind::FILE; fileRow.fileIndex = static_cast(fileIndex); fileRow.height = fileHeight; m_rows.push_back(fileRow); // 筛选结果**默认展开**(忽略折叠状态),便于直接看到匹配项。 if (!filtering && m_tab->IsCollapsed(static_cast(fileIndex))) { continue; } if (filtering) { for (const int signalIndex : matched) { ListRow signalRow; signalRow.kind = RowKind::SIGNAL; signalRow.fileIndex = static_cast(fileIndex); signalRow.signalIndex = signalIndex; signalRow.height = signalHeight; m_rows.push_back(signalRow); } continue; } const std::size_t signalCount = files[fileIndex].signals.size(); for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) { ListRow signalRow; signalRow.kind = RowKind::SIGNAL; signalRow.fileIndex = static_cast(fileIndex); signalRow.signalIndex = static_cast(signalIndex); signalRow.height = signalHeight; m_rows.push_back(signalRow); } } m_contentHeight = 0.0f; for (const ListRow& row : m_rows) { m_contentHeight += row.height; } } void SignalPanel::EnsureRows() { if (m_rowsDirty) { BuildRows(); RefreshScrollBar(); } } void SignalPanel::RefreshScrollBar() { if (m_listFullWidth <= 0.0f) { m_scrollBarVisible = false; return; } m_scrollBarVisible = (m_contentHeight > m_listRect.Height + 0.5f); if (m_scrollBarVisible) { const float thickness = kScrollBarThickness * m_scale; m_listRect.Width = std::max(0.0f, m_listFullWidth - thickness - 2.0f * m_scale); m_scrollBarRect = gd::RectF(m_area.X + m_area.Width - thickness - 3.0f * m_scale, m_listRect.Y, thickness, m_listRect.Height); } else { m_listRect.Width = m_listFullWidth; m_scrollBarRect = gd::RectF(); } ClampScroll(); } void SignalPanel::ClampScroll() { const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height); m_scrollOffset = std::max(0.0f, std::min(m_scrollOffset, maxOffset)); } gd::RectF SignalPanel::RowRect(int row) const { if (row < 0 || row >= static_cast(m_rows.size())) { return gd::RectF(); } float top = m_listRect.Y - m_scrollOffset; for (int index = 0; index < row; ++index) { top += m_rows[static_cast(index)].height; } return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(row)].height); } gd::RectF SignalPanel::FileCloseRect(int row) const { const gd::RectF rect = RowRect(row); const float size = kCloseButtonSize * m_scale; return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size, rect.Y + (rect.Height - size) * 0.5f, size, size); } gd::RectF SignalPanel::SignalBadgeRect(int row) const { const gd::RectF rect = RowRect(row); const float width = kBadgeWidth * m_scale; const float height = kBadgeHeight * m_scale; // 行尾现在被「移除该信号」✕ 占用(与分组 ✕ 同尺寸、同列),网格徽标因此整体前移; // 两者都恒在列表最右侧,且始终保留固定的间隔,不会互相挤压。 const float right = SignalRemoveRect(row).X - kRowTailGap * m_scale; return gd::RectF(right - width, rect.Y + (rect.Height - height) * 0.5f, width, height); } gd::RectF SignalPanel::SignalRemoveRect(int row) const { if (row < 0 || row >= static_cast(m_rows.size()) || m_rows[static_cast(row)].kind != RowKind::SIGNAL) { return gd::RectF(); } // 与分组行的关闭 ✕ **完全一致**(同尺寸、同右边距、同行内垂直居中), // 只是作用对象是本行信号:位于信号条目的最右端。 const gd::RectF rect = RowRect(row); const float size = kCloseButtonSize * m_scale; return gd::RectF(rect.X + rect.Width - kPadding * m_scale - size, rect.Y + (rect.Height - size) * 0.5f, size, size); } int SignalPanel::HitRow(int x, int y, RowPart& part) { part = RowPart::NONE; EnsureRows(); if (m_listRect.Height <= 0.0f) { return -1; } const float fx = static_cast(x); const float fy = static_cast(y); if (fy < m_listRect.Y || fy > m_listRect.Y + m_listRect.Height) { return -1; } if (fx < m_listRect.X || fx > m_listRect.X + m_listRect.Width) { return -1; } const float local = fy - m_listRect.Y + m_scrollOffset; if (local < 0.0f) { return -1; } float accumulated = 0.0f; for (std::size_t index = 0; index < m_rows.size(); ++index) { const ListRow& row = m_rows[index]; if (local >= accumulated && local < accumulated + row.height) { const int rowIndex = static_cast(index); const gd::RectF rect = RowRect(rowIndex); const bool isFile = (row.kind == RowKind::FILE); const float baseX = rect.X + kPadding * m_scale + (isFile ? 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 (UiPointInRect(disclosureRect, fx, fy)) { part = RowPart::DISCLOSURE; } else if (UiPointInRect(checkRect, fx, fy)) { part = RowPart::CHECK; } else if (UiPointInRect(FileCloseRect(rowIndex), fx, fy)) { part = RowPart::CLOSE; } else { part = RowPart::BODY; } } else { const SignalRef ref{row.fileIndex, row.signalIndex}; const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref); // ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。 if (UiPointInRect(SignalRemoveRect(rowIndex), fx, fy)) { part = RowPart::REMOVE_SIGNAL; } else if (UiPointInRect(checkRect, fx, fy)) { part = RowPart::CHECK; } else if (badgeVisible && UiPointInRect(SignalBadgeRect(rowIndex), fx, fy)) { part = RowPart::LANE_BADGE; } else { part = RowPart::BODY; } } return rowIndex; } accumulated += row.height; } return -1; } void SignalPanel::ScrollToSignal(const SignalRef& ref) { EnsureRows(); if (!ref.IsValid()) { return; } float top = 0.0f; for (std::size_t index = 0; index < m_rows.size(); ++index) { const ListRow& row = m_rows[index]; if (row.kind == RowKind::SIGNAL && row.fileIndex == ref.fileIndex && row.signalIndex == ref.signalIndex) { if (top < m_scrollOffset) { m_scrollOffset = top; } else if (top + row.height > m_scrollOffset + m_listRect.Height) { m_scrollOffset = top + row.height - m_listRect.Height; } ClampScroll(); return; } top += row.height; } } void SignalPanel::LayoutStyleControls() { const float padH = kStylePadH * m_scale; const float labelWidth = kStyleLabelWidth * m_scale; const float labelGap = kStyleLabelGap * m_scale; const float rowHeight = kStyleRowHeight * m_scale; const float rowStep = (kStyleRowHeight + kStyleRowGap) * m_scale; const float firstTop = m_styleRect.Y + kStylePadV * m_scale; // 内容列宽度带上下限:面板变窄时收缩,面板变宽时不再延伸,五行始终共用同一宽度。 const float available = std::max(40.0f, m_styleRect.Width - padH * 2.0f - labelWidth - labelGap); const float contentWidth = std::min(available, kStyleContentMax * m_scale); // 标签列 + 内容列整体靠左对齐(始终从左侧内边距开始),不随面板变宽而居中偏移。 const float groupWidth = labelWidth + labelGap + contentWidth; const float groupLeft = m_styleRect.X + padH; const float contentLeft = groupLeft + labelWidth + labelGap; for (int index = 0; index < static_cast(kStyleRowCount); ++index) { m_styleRowRects[static_cast(index)] = gd::RectF(groupLeft, firstTop + static_cast(index) * rowStep, groupWidth, rowHeight); } const gd::RectF& colorRow = m_styleRowRects[0]; const gd::RectF& modeRow = m_styleRowRects[1]; const gd::RectF& adjustRow = m_styleRowRects[2]; const gd::RectF& widthRow = m_styleRowRects[3]; const gd::RectF& opacityRow = m_styleRowRects[4]; // ---- 颜色行:预设色块 + 同尺寸自定义颜色入口(合计恰好铺满内容列宽度)---- int paletteCount = 0; SignalPalette(paletteCount); const float swatchGap = kSwatchGap * m_scale; const float swatchMaxGap = kSwatchMaxGap * m_scale; const float swatchMin = kSwatchMin * m_scale; int count = std::min(paletteCount, static_cast(m_colorRects.size())); float swatchSize = kSwatchEdge * m_scale; float gap = swatchGap; while (count >= 1) { const float slots = static_cast(count + 1); const float fit = (contentWidth - swatchGap * (slots - 1.0f)) / slots; if (count == 1 || fit >= swatchMin) { swatchSize = std::max(swatchMin, std::min(kSwatchEdge * m_scale, fit)); // 用实际色块尺寸反算间距,让颜色行与透明度、线宽、样式各行同宽。 gap = (contentWidth - swatchSize * slots) / static_cast(count); gap = std::max(swatchGap, std::min(gap, swatchMaxGap)); break; } --count; } m_colorCount = std::max(1, count); // 颜色行与其余各行共用同一左边界(不再居中),保证五行纵向对齐。 const float colorRowLeft = contentLeft; const float swatchTop = colorRow.Y + (rowHeight - swatchSize) * 0.5f; for (int index = 0; index < m_colorCount; ++index) { m_colorRects[static_cast(index)] = gd::RectF(colorRowLeft + static_cast(index) * (swatchSize + gap), swatchTop, swatchSize, swatchSize); } m_customChipRect = gd::RectF(colorRowLeft + static_cast(m_colorCount) * (swatchSize + gap), swatchTop, swatchSize, swatchSize); // ---- 样式行:三个等宽按钮铺满内容列 ---- const float modeGap = 6.0f * m_scale; const float modeWidth = std::max(20.0f, (contentWidth - modeGap * static_cast(kModeOptionCount - 1)) / static_cast(kModeOptionCount)); for (int index = 0; index < kModeOptionCount; ++index) { m_modeRects[static_cast(index)] = gd::RectF(contentLeft + static_cast(index) * (modeWidth + modeGap), modeRow.Y, modeWidth, rowHeight); } // ---- 「调整」行:系数调整 / 取绝对值 / 中心反转(三个铺满内容列的等宽按钮)---- const float adjustGap = kAdjustGap * m_scale; const float adjustWidth = std::max(20.0f, (contentWidth - adjustGap * static_cast(kAdjustCount - 1)) / static_cast(kAdjustCount)); for (int index = 0; index < kAdjustCount; ++index) { m_adjustRects[static_cast(index)] = gd::RectF(contentLeft + static_cast(index) * (adjustWidth + adjustGap), adjustRow.Y, adjustWidth, 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); } void SignalPanel::Layout(const gd::RectF& area) { m_area = area; m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale); // 标题下方的「筛选信号」输入框(列表内容过滤)。 const float filterHeight = kFilterHeight * m_scale; m_filterRect = gd::RectF(area.X + kPadding * m_scale, m_headerRect.Y + m_headerRect.Height + kFilterPadV * m_scale, std::max(20.0f, area.Width - kPadding * 2.0f * m_scale), filterHeight); const float styleHeight = DesiredStyleHeight() * m_scale; m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight); const float listTop = m_filterRect.Y + m_filterRect.Height + kFilterPadV * m_scale; m_listRect = gd::RectF(area.X, listTop, area.Width, std::max(0.0f, m_styleRect.Y - listTop)); // 标题区右侧的“全部显示/隐藏”按钮。 const float toggleWidth = 68.0f * m_scale; const float toggleHeight = 22.0f * m_scale; m_toggleAllRect = gd::RectF(area.X + area.Width - kPadding * m_scale - toggleWidth, m_headerRect.Y + (m_headerRect.Height - toggleHeight) * 0.5f, toggleWidth, toggleHeight); // 依据内容决定是否显示滚动条,并据此收缩列表可用宽度。 m_listFullWidth = m_listRect.Width; EnsureRows(); RefreshScrollBar(); LayoutStyleControls(); } void SignalPanel::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) { return; } EnsureRows(); UiFillRect(g, m_area, m_theme->panelBg); // 标题区。 { std::wstring title = L"信号"; if (m_dataSet != nullptr && m_tab != nullptr) { title += L" · " + std::to_wstring(m_tab->VisibleCount()) + L"/" + std::to_wstring(TotalSignalCount()) + L" 已选"; } gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y, std::max(20.0f, m_headerRect.Width - kPadding * 2.0f * m_scale - m_toggleAllRect.Width - 8.0f * m_scale), m_headerRect.Height); UiText(g, UiEllipsize(title, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect, m_theme->textStrong, TextAlign::LEFT); const bool allVisible = AreAllSignalsVisible(); const std::wstring toggleLabel = allVisible ? L"全部隐藏" : L"全部显示"; const bool toggleHover = (m_hoverStyle == StyleHit::TOGGLE_ALL); if (toggleHover) { UiFillRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->hover); } UiDrawRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->border, 1.0f); const bool enabled = (m_dataSet != nullptr && !m_dataSet->Empty()); UiText(g, toggleLabel, m_fonts->uiSmall, m_toggleAllRect, enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER); } // 「筛选信号」输入框:未输入内容时显示占位提示,单击进入编辑,输入实时过滤下方列表。 { UiFillRoundRect(g, m_filterRect, kCorner * m_scale, m_theme->cardBg); UiDrawRoundRect(g, m_filterRect, kCorner * m_scale, m_filterEditing ? m_theme->accent : m_theme->border, m_filterEditing ? 1.6f : 1.0f); const float pad = kFilterTextPad * m_scale; const gd::RectF clearRect = FilterClearRect(); // 有输入内容时在右侧留出 ✕ 的位置,文本不会压到按钮上。 const float textRight = (clearRect.Width > 0.0f) ? clearRect.X : (m_filterRect.X + m_filterRect.Width); const float textWidth = std::max(8.0f, textRight - pad - (m_filterRect.X + pad)); const gd::RectF textRect(m_filterRect.X + pad, m_filterRect.Y, textWidth, m_filterRect.Height); if (m_filterEditing) { // 外框(m_filterRect)已经画好了底板与强调色边框,这里只叠加文本内容。 // 若改用 UiDrawEditField,会在外框内部再套一个带边框的圆角框, // 空内容时只剩一圈内框十分突兀(用户明确要求去掉这层"内圈")。 gd::RectF fieldRect = FilterFieldRect(); fieldRect.Width = std::max(8.0f, textWidth + pad); UiDrawEditFieldContent(g, fieldRect, m_filterEdit, m_fonts->uiSmall, m_scale); } else if (m_filterText.empty()) { UiText(g, L"筛选信号", m_fonts->uiSmall, textRect, m_theme->textMuted, TextAlign::LEFT); } else { UiText(g, UiEllipsize(m_filterText, m_fonts->uiSmall, textWidth), m_fonts->uiSmall, textRect, m_theme->text, TextAlign::LEFT); } // 清空按钮 ✕:仅在筛选框有内容时出现,悬停高亮(鼠标光标保持箭头)。 if (clearRect.Width > 0.0f) { const bool clearHot = (m_hoverStyle == StyleHit::FILTER_CLEAR); if (clearHot) { UiFillRoundRect(g, clearRect, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33)); } const std::uint32_t crossColor = clearHot ? m_theme->danger : m_theme->textMuted; const float crossPad = clearRect.Width * 0.28f; UiDrawLine(g, clearRect.X + crossPad, clearRect.Y + crossPad, clearRect.X + clearRect.Width - crossPad, clearRect.Y + clearRect.Height - crossPad, crossColor, 1.5f * m_scale); UiDrawLine(g, clearRect.X + clearRect.Width - crossPad, clearRect.Y + crossPad, clearRect.X + crossPad, clearRect.Y + clearRect.Height - crossPad, crossColor, 1.5f * m_scale); } } // 列表区。 { const int saveState = g.Save(); g.SetClip(gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, m_listRect.Height)); if (m_dataSet == nullptr || m_dataSet->Empty()) { UiText(g, L"尚未加载数据,请打开 CSV 文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted, TextAlign::CENTER); } else if (m_rows.empty() && !m_filterText.empty()) { // 筛选结果为空:明确提示"无匹配",而不是让人误以为数据丢失。 UiText(g, L"无匹配的筛选信号", m_fonts->uiSmall, m_listRect, m_theme->textMuted, TextAlign::CENTER); } else { const int rowCount = static_cast(m_rows.size()); for (int row = 0; row < rowCount; ++row) { const gd::RectF rect = RowRect(row); if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height) { continue; } if (m_rows[static_cast(row)].kind == RowKind::FILE) { PaintFileRow(g, row); } else { PaintSignalRow(g, row); } } } g.Restore(saveState); } PaintScrollBar(g); PaintStyleSection(g); // 分隔线。 UiDrawLine(g, m_area.X + m_area.Width - 1.0f, m_area.Y, m_area.X + m_area.Width - 1.0f, m_area.Y + m_area.Height, m_theme->border, 1.0f); UiDrawLine(g, m_headerRect.X, m_headerRect.Y + m_headerRect.Height - 1.0f, m_headerRect.X + m_headerRect.Width, m_headerRect.Y + m_headerRect.Height - 1.0f, m_theme->border, 1.0f); if (m_styleRect.Height > 0.0f) { UiDrawLine(g, m_styleRect.X, m_styleRect.Y, m_styleRect.X + m_styleRect.Width, m_styleRect.Y, m_theme->border, 1.0f); } } void SignalPanel::PaintFileRow(gd::Graphics& g, int row) { if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) { return; } const ListRow& item = m_rows[static_cast(row)]; const DataFile* file = m_dataSet->FileAt(item.fileIndex); if (file == nullptr) { return; } const gd::RectF rect = RowRect(row); const bool hovered = (m_hoverRow == row); if (hovered) { UiFillRect(g, rect, m_theme->hover); } UiFillRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), WithAlpha(m_theme->borderStrong, 0x90)); const float baseX = rect.X + kPadding * m_scale; const float centerY = rect.Y + rect.Height * 0.5f; const bool collapsed = m_tab->IsCollapsed(item.fileIndex); // 折叠三角。 { const float cx = baseX + kDisclosureWidth * m_scale * 0.5f; const std::uint32_t color = (hovered && m_hoverPart == RowPart::DISCLOSURE) ? m_theme->accent : m_theme->text; UiDrawTriangleIcon(g, gd::RectF(cx - 6.0f * m_scale, centerY - 6.0f * m_scale, 12.0f * m_scale, 12.0f * m_scale), color, collapsed ? IconDirection::RIGHT : IconDirection::DOWN); } // 分组复选框(三态:全选 / 部分 / 未选)。 const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f, kCheckSize * m_scale, kCheckSize * m_scale); const bool allVisible = m_tab->FileAllVisible(item.fileIndex); const bool anyVisible = m_tab->FileHasVisible(item.fileIndex); UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale); const gd::RectF closeRect = FileCloseRect(row); // 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。 const std::wstring groupLabel = L"组" + std::to_wstring(item.fileIndex + 1); const std::wstring stats = std::to_wstring(file->signals.size()) + L" 项"; const float groupWidth = FileGroupLabelWidth(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; const float nameAreaLeft = nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale; const float nameWidth = std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft); // 序号以强调色突出,便于在多组之间快速定位。 UiText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height), m_theme->accent, TextAlign::LEFT); // 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。 UiFillRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale, rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale, rect.Height - 14.0f * m_scale), WithAlpha(m_theme->borderStrong, 0xB0)); if (m_editField == StyleEditField::FILE_NAME && m_editRow == row) { UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale); } else { UiText(g, UiEllipsize(file->Title(), m_fonts->uiBold, nameWidth), m_fonts->uiBold, gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, TextAlign::LEFT); } UiText(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) { UiFillRoundRect(g, closeRect, 3.0f * m_scale, m_theme->pressed); } UiDrawCrossIcon(g, closeRect, closeHot ? m_theme->danger : m_theme->textMuted, 1.5f * m_scale); } void SignalPanel::PaintSignalRow(gd::Graphics& g, int row) { if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) { return; } const ListRow& item = m_rows[static_cast(row)]; const SignalRef ref{item.fileIndex, item.signalIndex}; const SignalData* signal = m_dataSet->Resolve(ref); const SignalStyle* style = StyleOf(ref); if (signal == nullptr || style == nullptr) { return; } const gd::RectF rect = RowRect(row); const bool selected = (SelectedRef() == ref); const bool hovered = (m_hoverRow == row); if (selected) { UiFillRect(g, rect, m_theme->selection); UiFillRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent); } else if (hovered) { UiFillRect(g, rect, m_theme->hover); } const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale; const float centerY = rect.Y + rect.Height * 0.5f; // 复选框。 const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f, kCheckSize * m_scale, kCheckSize * m_scale); // 可见性属于当前标签页,与全局共享的显示外观分开读取。 const bool visible = m_tab->IsVisible(ref); UiDrawCheckBox(g, checkRect, visible ? 1 : 0, 3.0f * m_scale); // 颜色块:叠加不透明度,透明度过低时自动补描边(与样式区色块规则一致)。 const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale, centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale, kSwatchSize * m_scale); UiDrawSwatch(g, swatchRect, style->color, visible ? style->opacity : 0.3f, 2.5f * m_scale, m_scale); // 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。 const int laneIndex = m_tab->LaneOfSignal(ref); const bool badgeVisible = (laneIndex >= 0) && visible; const gd::RectF badgeRect = SignalBadgeRect(row); const gd::RectF removeRect = SignalRemoveRect(row); float nameRightLimit = removeRect.X - 6.0f * m_scale; if (badgeVisible) { nameRightLimit = badgeRect.X - 6.0f * m_scale; } // 名称:完全保留文件内的列名(含 "[n]" 序号与括号描述),双击可重命名。 const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale; if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row) { UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->ui, m_scale); } else { const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft); const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted; UiText(g, UiEllipsize(signal->Title(), m_fonts->ui, nameWidth), m_fonts->ui, gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT); } if (badgeVisible) { const bool badgeHot = hovered && (m_hoverPart == RowPart::LANE_BADGE); UiFillRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accent : m_theme->border, 1.0f); UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } // 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。 // 最后绘制,确保它永远压在最上层(与分组行 ✕ 的绘制顺序保持一致)。 { const bool removeHot = hovered && (m_hoverPart == RowPart::REMOVE_SIGNAL); if (removeHot) { UiFillRoundRect(g, removeRect, 3.0f * m_scale, m_theme->pressed); } UiDrawCrossIcon(g, removeRect, removeHot ? m_theme->danger : m_theme->textMuted, 1.5f * m_scale); } } gd::RectF SignalPanel::ScrollThumbRect() const { if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height || m_listRect.Height <= 0.0f) { return gd::RectF(); } const float ratio = std::min(1.0f, m_listRect.Height / m_contentHeight); const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); const float maxScroll = m_contentHeight - m_listRect.Height; const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f; const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); } void SignalPanel::PaintScrollBar(gd::Graphics& g) { if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height) { return; } UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak); UiFillRoundRect(g, ScrollThumbRect(), kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); } void SignalPanel::PaintStyleSection(gd::Graphics& g) { if (m_styleRect.Height <= 0.0f) { return; } UiFillRect(g, m_styleRect, m_theme->panelBg); const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef()); if (style == nullptr || SelectedRef().IsValid() == false) { UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, TextAlign::CENTER); return; } const float labelWidth = kStyleLabelWidth * m_scale; // 行标签统一以整行为基准垂直居中,确保与同行控件纵向对齐。 auto paintRowLabel = [&](int rowIndex, const wchar_t* text) { const gd::RectF& row = m_styleRowRects[static_cast(rowIndex)]; UiText(g, text, m_fonts->uiSmall, gd::RectF(row.X, row.Y, labelWidth, row.Height), m_theme->textMuted, TextAlign::LEFT); }; // ---- 第一行:颜色(预设色块 + 自定义颜色入口)---- { paintRowLabel(0, L"颜色"); int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); // 判定“当前是否在用自定义色”:必须同时满足 // 1) 显示颜色等于记忆的自定义色; // 2) 该颜色不是任何一个可见的预设色。 // 否则一旦自定义色与某个预设色相同时,点击那个预设色块会把高亮错误地 // 挪到自定义色块上(用户报告的“点预设色直接跳到自定义色块”)。 bool presetMatches = false; for (int index = 0; index < m_colorCount; ++index) { if (style->color == palette[index % paletteCount]) { presetMatches = true; break; } } const bool customActive = (style->color == style->customColor) && !presetMatches; for (int index = 0; index < m_colorCount; ++index) { const gd::RectF& rect = m_colorRects[static_cast(index)]; const std::uint32_t color = palette[index % paletteCount]; const bool hovered = (m_hoverStyle == StyleHit::COLOR && m_hoverStyleIndex == index); const bool active = !customActive && (style->color == color); // 预设色块同样叠加不透明度;透明度过低时自动补描边以保证可辨识(与自定义色块一致)。 UiDrawSwatch(g, rect, color, style->opacity, kCorner * m_scale, m_scale); if (active || hovered) { const gd::RectF ring(rect.X - 2.0f * m_scale, rect.Y - 2.0f * m_scale, rect.Width + 4.0f * m_scale, rect.Height + 4.0f * m_scale); UiDrawRoundRect(g, ring, 5.0f * m_scale, active ? m_theme->textStrong : m_theme->borderStrong, active ? 1.6f : 1.0f); } } // 自定义颜色入口:与预设色块完全同尺寸、同外框规则的小方格,仅多一个加号图标。 // 方格始终显示记忆的自定义颜色,因此从任意预设色再次点击它都能立即回到上次的自定义色。 const bool chipHot = (m_hoverStyle == StyleHit::CUSTOM_CHIP); const std::uint32_t chipColor = UiDrawSwatch(g, m_customChipRect, style->customColor, style->opacity, kCorner * m_scale, m_scale); const std::uint32_t iconColor = IsDarkColor(chipColor) ? 0xFFFDF6E3u : 0xFF073642u; UiDrawPlusIcon(g, m_customChipRect, iconColor, 1.6f * m_scale); // 当前正在使用自定义颜色、或鼠标悬停时,显示与预设色块一致的外框。 if (customActive || chipHot) { const gd::RectF& chip = m_customChipRect; const gd::RectF ring(chip.X - 2.0f * m_scale, chip.Y - 2.0f * m_scale, chip.Width + 4.0f * m_scale, chip.Height + 4.0f * m_scale); UiDrawRoundRect(g, ring, 5.0f * m_scale, customActive ? m_theme->textStrong : m_theme->borderStrong, customActive ? 1.6f : 1.0f); } } // ---- 第二行:样式(线段 / 散点 / 点线)---- { paintRowLabel(1, L"样式"); for (int index = 0; index < kModeOptionCount; ++index) { const gd::RectF& rect = m_modeRects[static_cast(index)]; const bool active = (style->mode == kModeOptions[index]); const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index); UiFillRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg)); UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f); UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect, active ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } } // ---- 第三行:调整(系数 / 镜像 / 绝对值,三个切换类按钮)---- // 结算顺序固定为「系数 → 绝对值 → 镜像」;「系数」「镜像」点击后展开二级面板。 { paintRowLabel(2, L"调整"); const auto paintToggle = [&](int index, const wchar_t* label, bool checked, StyleHit target) { const gd::RectF& rect = m_adjustRects[static_cast(index)]; 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); }; // 系数/偏移:系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。 paintToggle(kAdjustGain, L"系数/偏移", !GainIsDefault(style->gain) || style->Offset(), StyleHit::GAIN_ADJUST); // 镜像:水平或垂直任一被勾选即显示为激活态。 paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST); paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); } // ---- 第四行:线宽(数值可点击输入)---- { paintRowLabel(3, L"线宽"); const double ratio = (static_cast(style->lineWidth) - kWidthMin) / (kWidthMax - kWidthMin); const bool active = m_draggingWidth || (m_hoverStyle == StyleHit::WIDTH_SLIDER); UiDrawSlider(g, m_widthTrackRect, ratio, m_theme->accent, active, m_scale); if (m_editField == StyleEditField::WIDTH) { UiDrawEditField(g, m_widthValueRect, m_edit, m_fonts->monoSmall, m_scale); } else { const std::wstring numberText = FormatNumber(style->lineWidth, 1); const std::wstring fullText = numberText + L" px"; // 空间不足时省略单位,保证数值本身完整可读。 const std::wstring shown = (UiMeasureTextWidth(m_fonts->monoSmall, fullText) > m_widthValueRect.Width - 6.0f * m_scale) ? numberText : fullText; UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT); } } // ---- 第五行:透明度(数值可点击输入)---- { paintRowLabel(4, 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); } } } bool SignalPanel::AreAllSignalsVisible() const { if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty()) { return false; } return m_tab->VisibleCount() == TotalSignalCount(); } bool SignalPanel::ApplyWidthFromX(float x) { if (m_tab == nullptr) { return false; } SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } const double ratio = UiSliderRatioFromX(m_widthTrackRect, x); float value = static_cast(kWidthMin + ratio * (kWidthMax - kWidthMin)); value = std::round(value * 10.0f) / 10.0f; value = ClampFloat(value, kWidthMin, kWidthMax); if (std::fabs(value - style->lineWidth) < 0.001f) { return false; } style->lineWidth = value; return true; } gd::RectF SignalPanel::RowNameRect(int row) const { if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) { return gd::RectF(); } const gd::RectF rect = RowRect(row); const bool isFile = (m_rows[static_cast(row)].kind == RowKind::FILE); const float baseX = rect.X + kPadding * m_scale + (isFile ? 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) { // 分组名需要跳过左侧的“组X”标签与分隔线。 left = checkRight + 8.0f * m_scale + FileGroupLabelWidth(row) + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale; right = FileCloseRect(row).X - 6.0f * m_scale; } else { left = checkRight + 6.0f * m_scale + kSwatchSize * m_scale + 6.0f * m_scale; // 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。 const SignalRef ref{m_rows[static_cast(row)].fileIndex, m_rows[static_cast(row)].signalIndex}; const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref) && (m_tab->LaneOfSignal(ref) >= 0); right = (badgeVisible ? SignalBadgeRect(row).X : SignalRemoveRect(row).X) - 6.0f * m_scale; } if (right <= left) { return gd::RectF(); } return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale); } float SignalPanel::FileGroupLabelWidth(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)].fileIndex + 1); return UiMeasureTextWidth(m_fonts->uiBold, label); } bool SignalPanel::BeginEdit(StyleEditField field, int row) { if (m_tab == nullptr || field == StyleEditField::NONE) { return false; } if (m_editField == field && m_editRow == row) { return false; } std::wstring initial; switch (field) { case StyleEditField::WIDTH: { const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } initial = FormatNumber(style->lineWidth, 1); break; } case StyleEditField::OPACITY: { const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); break; } case StyleEditField::SIGNAL_NAME: { if (row < 0 || row >= static_cast(m_rows.size())) { return false; } const ListRow& item = m_rows[static_cast(row)]; const SignalData* signal = m_dataSet->Resolve(SignalRef{item.fileIndex, item.signalIndex}); if (signal == nullptr) { return false; } initial = signal->Title(); break; } case StyleEditField::FILE_NAME: { if (row < 0 || row >= static_cast(m_rows.size())) { return false; } const DataFile* file = m_dataSet->FileAt(m_rows[static_cast(row)].fileIndex); if (file == nullptr) { return false; } initial = file->Title(); break; } default: return false; } m_editField = field; m_editRow = row; // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 const bool nameField = (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME); // 不限长度:名称与数值都由各自的提交逻辑处理超长内容。 m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); return true; } bool SignalPanel::EndEdit(bool commit) { if (m_editField == StyleEditField::NONE) { return false; } const StyleEditField field = m_editField; const int row = m_editRow; const std::wstring text = m_edit.Text(); m_editField = StyleEditField::NONE; m_editRow = -1; m_edit.End(); if (!commit || m_tab == nullptr || m_dataSet == nullptr) { return true; } const std::wstring trimmed = Trim(text); switch (field) { case StyleEditField::WIDTH: { if (trimmed.empty()) { break; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || parsed != parsed) { break; } if (SignalStyle* style = StyleOf(SelectedRef())) { const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); style->lineWidth = std::round(value * 10.0f) / 10.0f; } break; } case StyleEditField::OPACITY: { if (trimmed.empty()) { break; } wchar_t* stop = nullptr; const double parsed = std::wcstod(trimmed.c_str(), &stop); if (stop == trimmed.c_str() || parsed != parsed) { break; } if (SignalStyle* style = StyleOf(SelectedRef())) { style->opacity = ClampFloat(static_cast(parsed) / 100.0f, 0.0f, 1.0f); } break; } case StyleEditField::SIGNAL_NAME: { if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) { break; } const ListRow& item = m_rows[static_cast(row)]; m_dataSetWritable->SetSignalDisplayName(item.fileIndex, item.signalIndex, trimmed); break; } case StyleEditField::FILE_NAME: { if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) { break; } m_dataSetWritable->SetFileDisplayName(m_rows[static_cast(row)].fileIndex, trimmed); break; } default: break; } return true; } gd::RectF SignalPanel::FilterFieldRect() const { // 输入框内容区:左右与上下使用**同一内缩**,避免编辑态出现"左右空隙明显更大"的观感; // 占位提示与显示文本也用同一内缩,因此进入/退出编辑时文字不会跳动。 const float inset = kFilterTextPad * m_scale; return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + inset, std::max(8.0f, m_filterRect.Width - inset * 2.0f), std::max(8.0f, m_filterRect.Height - inset * 2.0f)); } gd::RectF SignalPanel::FilterClearRect() const { if (m_filterText.empty()) { return gd::RectF(); } const float size = std::max(12.0f, m_filterRect.Height - kFilterTextPad * 2.0f * m_scale); const float right = m_filterRect.X + m_filterRect.Width - kFilterTextPad * m_scale; return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size); } gd::RectF SignalPanel::EditFieldRect() const { switch (m_editField) { case StyleEditField::WIDTH: return m_widthValueRect; case StyleEditField::OPACITY: return m_opacityValueRect; case StyleEditField::SIGNAL_NAME: case StyleEditField::FILE_NAME: return RowNameRect(m_editRow); case StyleEditField::NONE: default: return gd::RectF(); } } bool SignalPanel::OnEditChar(wchar_t character) { // 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。 if (m_filterEditing) { switch (character) { case 0x03: // Ctrl+C m_filterEdit.CopyToClipboard(m_host); return false; case 0x18: // Ctrl+X m_filterEdit.CutToClipboard(m_host); ApplyFilterText(m_filterEdit.Text()); return true; case 0x16: // Ctrl+V m_filterEdit.PasteFromClipboard(m_host); ApplyFilterText(m_filterEdit.Text()); return true; case 0x01: // Ctrl+A m_filterEdit.SelectAll(); return true; default: break; } if (character == L'\r' || character == L'\n') { return EndFilterEdit(); } // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,运里忽略控制字符避免重复删除。 if (character < 0x20 || character == 0x7F) { return false; } if (!m_filterEdit.Insert(character)) { return false; } ApplyFilterText(m_filterEdit.Text()); return true; } if (m_editField == StyleEditField::NONE) { return false; } // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 switch (character) { case 0x03: // Ctrl+C m_edit.CopyToClipboard(m_host); return false; case 0x18: // Ctrl+X return m_edit.CutToClipboard(m_host); case 0x16: // Ctrl+V return m_edit.PasteFromClipboard(m_host); case 0x01: // Ctrl+A m_edit.SelectAll(); return true; default: break; } if (character == L'\r' || character == L'\n') { return EndEdit(true); } // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符, // 否则同一次按键会被处理两次(一次删除两个字符)。 if (character < 0x20 || character == 0x7F) { return false; } // 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。 if (m_editField == StyleEditField::OPACITY && character == L'%') { return false; } return m_edit.Insert(character); } bool SignalPanel::OnEditKey(WPARAM key) { // 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。 if (m_filterEditing) { const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; switch (key) { case VK_RETURN: return EndFilterEdit(); case VK_ESCAPE: { const bool changed = m_filterEdit.Text() != m_filterText; EndFilterEdit(); return changed; } case VK_BACK: { if (!m_filterEdit.Backspace()) { return false; } ApplyFilterText(m_filterEdit.Text()); return true; } case VK_DELETE: { if (!m_filterEdit.DeleteForward()) { return false; } ApplyFilterText(m_filterEdit.Text()); return true; } case VK_LEFT: return m_filterEdit.MoveCaret(-1, shift); case VK_RIGHT: return m_filterEdit.MoveCaret(1, shift); case VK_HOME: return m_filterEdit.MoveHome(shift); case VK_END: return m_filterEdit.MoveEnd(shift); case 'A': if (control) { m_filterEdit.SelectAll(); return true; } break; default: break; } return true; } if (m_editField == StyleEditField::NONE) { return false; } const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; switch (key) { case VK_RETURN: return EndEdit(true); case VK_ESCAPE: return EndEdit(false); case VK_BACK: return m_edit.Backspace(); case VK_DELETE: return m_edit.DeleteForward(); case VK_LEFT: return m_edit.MoveCaret(-1, shift); case VK_RIGHT: return m_edit.MoveCaret(1, shift); case VK_HOME: return m_edit.MoveHome(shift); case VK_END: return m_edit.MoveEnd(shift); case 'A': if (control) { m_edit.SelectAll(); return true; } break; default: break; } return true; } bool SignalPanel::CollapseEditSelectionToEnd() { if (m_filterEditing) { return m_filterEdit.DeselectToEnd(); } if (m_editField == StyleEditField::NONE) { return false; } return m_edit.DeselectToEnd(); } bool SignalPanel::OnEditMouseDown(int x, int y) { if (m_filterEditing) { // 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理, // 否则点击会被当成"把光标移到此处"而清空不了内容。 // 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。 const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, static_cast(x), static_cast(y))) { m_pendingFilterClear = true; return true; } return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y); } if (m_editField == StyleEditField::NONE) { return false; } return UiEditFieldMouseDown(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x, y); } bool SignalPanel::OnEditMouseMove(int x) { if (m_filterEditing) { return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x); } if (m_editField == StyleEditField::NONE) { return false; } return UiEditFieldMouseMove(m_fonts->ui, EditFieldRect(), m_edit, m_scale, x); } bool SignalPanel::OnEditMouseUp(int x, int y) { if (m_filterEditing) { // 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位), // 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算: // 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。 if (m_pendingFilterClear) { m_pendingFilterClear = false; const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, static_cast(x), static_cast(y))) { ClearFilter(); return true; } } if (!m_filterEdit.Dragging()) { return false; } m_filterEdit.EndDrag(); return true; } if (!m_edit.Dragging()) { return false; } m_edit.EndDrag(); return true; } const SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) const { return (m_dataSet != nullptr) ? m_dataSet->Style(ref) : nullptr; } SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) { return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr; } const SignalRef& SignalPanel::SelectedRef() const { static const SignalRef fallback; return (m_dataSet != nullptr) ? m_dataSet->Selected() : fallback; } void SignalPanel::SelectSignal(const SignalRef& ref) { if (m_dataSetWritable != nullptr) { m_dataSetWritable->SetSelected(ref); } } bool SignalPanel::ApplyColor(std::uint32_t color) { if (m_tab == nullptr) { return false; } SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } // 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。 const bool changed = (style->color != color) || (style->customColor != color); style->color = color; style->customColor = color; return changed; } bool SignalPanel::ApplyOpacityFromX(float x) { if (m_tab == nullptr) { return false; } SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } const float value = static_cast(UiSliderRatioFromX(m_opacityTrackRect, x)); if (std::fabs(value - style->opacity) < 0.005f) { return false; } style->opacity = value; return true; } bool SignalPanel::OpenColorPickerPopup(const SignalStyle& style) { if (m_colorPicker == nullptr) { return false; } const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; // 以记忆的自定义颜色作为初始取值,实现“再次打开即恢复上次取色”。 m_colorPicker->Open(bounds, m_customChipRect, style.customColor); return true; } bool SignalPanel::OpenGainMenu() { if (m_menu == nullptr || m_tab == nullptr) { return false; } const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } // 二级面板:两张“标题(带还原图标)+ 输入框”的组合: // 1. 「系数调整」:一整行系数输入框; // 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。 //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。) std::vector items; MenuItem gainHeader; gainHeader.kind = MenuItemKind::HEADER_ACTION; gainHeader.label = L"系数调整"; gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 items.push_back(gainHeader); // 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。 const std::wstring gainText = FormatGainText(style->gain); const std::wstring gainEditText = FormatNumberText(static_cast(style->gain)); MenuItem gainEdit; gainEdit.kind = MenuItemKind::EDIT; gainEdit.label.clear(); // 不显示说明文字:输入框独占整行 gainEdit.id = CMD_GAIN_EDIT; gainEdit.value = gainText; gainEdit.editValue = gainEditText; gainEdit.filter = EditFilter::NUMBER; items.push_back(gainEdit); MenuItem offsetHeader; offsetHeader.kind = MenuItemKind::HEADER_ACTION; offsetHeader.label = L"偏移调整"; offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值 items.push_back(offsetHeader); // 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。 MenuItem offsetY; offsetY.kind = MenuItemKind::EDIT; offsetY.label = L"Y轴"; offsetY.id = CMD_OFFSET_Y; offsetY.value = FormatSignedNumber(static_cast(style->offsetY)); offsetY.editValue = FormatNumberText(static_cast(style->offsetY)); offsetY.filter = EditFilter::NUMBER; items.push_back(offsetY); // 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。 // 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求); // 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。 MenuItem offsetTime; offsetTime.kind = MenuItemKind::EDIT; offsetTime.label = L"时间轴"; offsetTime.id = CMD_OFFSET_TIME; offsetTime.value = FormatOffsetTimeText(style->offsetTime); offsetTime.editValue = FormatNumberText(style->offsetTime); offsetTime.editSuffix = L"s"; offsetTime.filter = EditFilter::NUMBER; items.push_back(offsetTime); // 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角, // 并再往上让出 `kAdjustMenuGap`(用户明确要求)。 //(旧实现在按钮下沿向右下展开,面板会盖住下方内容。) const gd::RectF& button = m_adjustRects[kAdjustGain]; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; const int anchorY = static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, true); // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 ClearStyleHover(); return true; } bool SignalPanel::OpenMirrorMenu() { if (m_menu == nullptr || m_tab == nullptr) { return false; } const SignalStyle* style = StyleOf(SelectedRef()); if (style == nullptr) { return false; } // 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。 std::vector items; MenuItem horizontal; horizontal.kind = MenuItemKind::NORMAL; horizontal.label = L"水平镜像"; horizontal.id = CMD_MIRROR_HORIZONTAL; horizontal.checked = style->mirrorHorizontal; horizontal.keepOpen = true; items.push_back(horizontal); MenuItem vertical; vertical.kind = MenuItemKind::NORMAL; vertical.label = L"垂直镜像"; vertical.id = CMD_MIRROR_VERTICAL; vertical.checked = style->mirrorVertical; vertical.keepOpen = true; items.push_back(vertical); const gd::RectF& button = m_adjustRects[kAdjustMirror]; const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 const int anchorY = static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, true); ClearStyleHover(); return true; } void SignalPanel::ClearStyleHover() { if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0) { return; } m_hoverStyle = StyleHit::NONE; m_hoverStyleIndex = -1; } bool SignalPanel::CloseTransientUi() { bool changed = false; if (m_editField != StyleEditField::NONE) { changed = EndEdit(true) || changed; } // 点击面板之外也要结束「筛选信号」的编辑状态。 if (m_filterEditing) { changed = EndFilterEdit() || changed; } if (m_colorPicker != nullptr && m_colorPicker->IsOpen()) { m_colorPicker->Close(); changed = true; } // 面板外点击:取消尚未抬起确认的关闭操作。 m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; return changed; } bool SignalPanel::OnMouseMove(int x, int y) { // 拖拽优先级最高。 if (m_draggingOpacity) { return ApplyOpacityFromX(static_cast(x)); } if (m_draggingWidth) { return ApplyWidthFromX(static_cast(x)); } if (m_draggingScrollBar) { const gd::RectF thumb = ScrollThumbRect(); const float travel = static_cast(y - m_scrollGrabOffset) - m_scrollBarRect.Y; const float usable = m_scrollBarRect.Height - thumb.Height; if (usable > 0.0f) { const float progress = std::max(0.0f, std::min(1.0f, travel / usable)); m_scrollOffset = progress * (m_contentHeight - m_listRect.Height); ClampScroll(); } return true; } RowPart part = RowPart::NONE; const int hoverRow = HitRow(x, y, part); StyleHit hoverStyle = StyleHit::NONE; int hoverStyleIndex = -1; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_toggleAllRect, fx, fy)) { hoverStyle = StyleHit::TOGGLE_ALL; } else if (UiPointInRect(FilterClearRect(), fx, fy)) { // 筛选框右侧的清空 ✕(仅在筛选框有内容时存在)。 hoverStyle = StyleHit::FILTER_CLEAR; } else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) { for (int index = 0; index < m_colorCount; ++index) { if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) { hoverStyle = StyleHit::COLOR; hoverStyleIndex = index; break; } } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_customChipRect, fx, fy)) { hoverStyle = StyleHit::CUSTOM_CHIP; } if (hoverStyle == StyleHit::NONE) { for (int index = 0; index < kModeOptionCount; ++index) { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { hoverStyle = StyleHit::MODE; hoverStyleIndex = index; break; } } } if (hoverStyle == StyleHit::NONE && UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy)) { hoverStyle = StyleHit::OPACITY_SLIDER; } if (hoverStyle == StyleHit::NONE && UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy)) { hoverStyle = StyleHit::WIDTH_SLIDER; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_opacityValueRect, fx, fy)) { hoverStyle = StyleHit::OPACITY_VALUE; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_widthValueRect, fx, fy)) { hoverStyle = StyleHit::WIDTH_VALUE; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { hoverStyle = StyleHit::GAIN_ADJUST; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) { hoverStyle = StyleHit::MIRROR_ADJUST; } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) { hoverStyle = StyleHit::ABSOLUTE_TOGGLE; } } if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex && part == m_hoverPart) { return false; } m_hoverRow = hoverRow; m_hoverPart = part; m_hoverStyle = hoverStyle; m_hoverStyleIndex = hoverStyleIndex; return true; } bool SignalPanel::OnLButtonDown(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); // 新的按下开始:上一轮留下的"待确认关闭"一律作废。 m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; // 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。 if (IsOnFilterBox(x, y)) { // 右侧 ✕:**抬起**时才清空(不退出编辑态,便于继续输入); // 按下后移开再松开视为放弃。 if (UiPointInRect(FilterClearRect(), fx, fy)) { m_pendingFilterClear = true; return true; } BeginFilterEdit(); return true; } if (m_filterEditing) { // 文本已实时生效,这里只需收起编辑态,随后继续处理本次点击。 EndFilterEdit(); } // 滚动条拖拽优先于其它命中测试。 if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) { const gd::RectF thumb = ScrollThumbRect(); m_draggingScrollBar = true; if (UiPointInRect(thumb, fx, fy)) { m_scrollGrabOffset = y - static_cast(thumb.Y); } else { m_scrollGrabOffset = static_cast(thumb.Height * 0.5f); const float travel = fy - m_scrollBarRect.Y - static_cast(m_scrollGrabOffset); const float usable = m_scrollBarRect.Height - thumb.Height; if (usable > 0.0f) { const float progress = std::max(0.0f, std::min(1.0f, travel / usable)); m_scrollOffset = progress * (m_contentHeight - m_listRect.Height); ClampScroll(); } } return true; } m_pendingLaneMenu = false; m_pendingColorPicker = false; m_pendingGainMenu = false; m_pendingMirrorMenu = false; // 点击当前输入框以外的位置时提交输入。 if (m_editField != StyleEditField::NONE && !UiPointInRect(EditFieldRect(), fx, fy)) { EndEdit(true); } // 全部显示/隐藏。 if (UiPointInRect(m_toggleAllRect, fx, fy)) { m_tab->SetAllVisible(!AreAllSignalsVisible()); m_rowsDirty = true; return true; } // 样式编辑区。 if (SignalStyle* style = StyleOf(SelectedRef())) { int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); for (int index = 0; index < m_colorCount; ++index) { if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) { style->color = palette[index % paletteCount]; return true; } } if (UiPointInRect(m_customChipRect, fx, fy)) { // 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。 style->color = style->customColor; m_pendingColorPicker = true; return true; } for (int index = 0; index < kModeOptionCount; ++index) { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { style->mode = kModeOptions[index]; return true; } } if (UiPointInRect(SliderHitRect(m_opacityTrackRect, m_scale), fx, fy)) { m_draggingOpacity = true; ApplyOpacityFromX(fx); return true; } if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy)) { m_draggingWidth = true; ApplyWidthFromX(fx); return true; } // 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { return BeginEdit(StyleEditField::WIDTH); } if (UiPointInRect(m_opacityValueRect, fx, fy)) { return BeginEdit(StyleEditField::OPACITY); } // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, // 与自定义颜色入口同一套交互)、绝对值(单击即切换)。 if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { m_pendingGainMenu = true; return true; } if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) { m_pendingMirrorMenu = true; return true; } if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) { style->absolute = !style->absolute; return true; } } // 列表行。 RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { return false; } const ListRow& item = m_rows[static_cast(row)]; if (item.kind == RowKind::FILE) { if (part == RowPart::CHECK) { const bool allVisible = m_tab->FileAllVisible(item.fileIndex); m_tab->SetFileVisible(item.fileIndex, !allVisible); m_rowsDirty = true; return true; } if (part == RowPart::CLOSE) { // 关闭文件属于数据层操作,交由宿主窗口处理;但**抬起时才生效**, // 这里只记录目标(按住后移开再松开视为放弃)。 m_pendingClosePart = RowPart::CLOSE; m_pendingCloseRow = row; return true; } m_tab->ToggleCollapsed(item.fileIndex); m_rowsDirty = true; return true; } const SignalRef ref{item.fileIndex, item.signalIndex}; // ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。 // 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号; // 且与其它 ✕ 一致,**抬起时才真正删除**(按住后移开再松开视为放弃)。 if (part == RowPart::REMOVE_SIGNAL) { m_pendingClosePart = RowPart::REMOVE_SIGNAL; m_pendingCloseRow = row; return true; } SelectSignal(ref); if (part == RowPart::CHECK) { // 勾选/取消勾选会立即触发网格重新划分(取消后空网格随之消失)。 m_tab->ToggleVisible(ref); m_rowsDirty = true; } else if (part == RowPart::LANE_BADGE) { // 在左键抬起时弹出,保证菜单不会随着按键松开而消失。 m_pendingLaneMenu = true; m_pendingLaneRef = ref; } return true; } bool SignalPanel::OnLButtonUp(int x, int y) { bool changed = false; // ---- 关闭类按钮(✕):按下时记录目标,抬起时若指针仍在同一个 ✕ 上才执行 ---- if (m_pendingFilterClear) { m_pendingFilterClear = false; const float fx = static_cast(x); const float fy = static_cast(y); const gd::RectF clearRect = FilterClearRect(); if (clearRect.Width > 0.0f && UiPointInRect(clearRect, fx, fy)) { ClearFilter(); changed = true; } } if (m_pendingClosePart != RowPart::NONE) { const RowPart pendingPart = m_pendingClosePart; const int pendingRow = m_pendingCloseRow; m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row == pendingRow && part == pendingPart && row >= 0) { const ListRow& item = m_rows[static_cast(row)]; if (m_host != nullptr) { if (pendingPart == RowPart::CLOSE) { PostMessageW(m_host, WM_APP + 1, static_cast(item.fileIndex), 0); } else { // wParam = 文件下标,lParam = 文件内信号下标(见 Application 的消息处理)。 PostMessageW(m_host, WM_APP + 2, static_cast(item.fileIndex), static_cast(item.signalIndex)); } } changed = true; } } if (m_draggingOpacity) { m_draggingOpacity = false; changed = true; } if (m_draggingWidth) { m_draggingWidth = false; changed = true; } if (m_draggingScrollBar) { m_draggingScrollBar = false; changed = true; } if (m_pendingColorPicker) { m_pendingColorPicker = false; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_customChipRect, fx, fy) && m_tab != nullptr) { if (const SignalStyle* style = StyleOf(SelectedRef())) { changed = OpenColorPickerPopup(*style) || changed; } } } // 「系数」/「镜像」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板 //(与自定义颜色入口一致:按住后移开再松开视为放弃)。 if (m_pendingGainMenu) { m_pendingGainMenu = false; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) && m_tab != nullptr) { changed = OpenGainMenu() || changed; } } if (m_pendingMirrorMenu) { m_pendingMirrorMenu = false; const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) && m_tab != nullptr) { changed = OpenMirrorMenu() || changed; } } if (m_pendingLaneMenu) { m_pendingLaneMenu = false; RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row >= 0 && part == RowPart::LANE_BADGE) { const ListRow& item = m_rows[static_cast(row)]; const SignalRef ref{item.fileIndex, item.signalIndex}; if (ref == m_pendingLaneRef) { const gd::RectF badge = SignalBadgeRect(row); OpenLaneMenu(ref, static_cast(badge.X), static_cast(badge.Y + badge.Height)); changed = true; } } } return changed; } bool SignalPanel::OnDoubleClick(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } const float fx = static_cast(x); const float fy = static_cast(y); // 样式区数值框:双击进入(或保持)输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { return BeginEdit(StyleEditField::WIDTH); } if (UiPointInRect(m_opacityValueRect, fx, fy)) { return BeginEdit(StyleEditField::OPACITY); } RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { return false; } const ListRow& item = m_rows[static_cast(row)]; // 分组行:双击折叠三角用于折叠,双击名称区域进入重命名。 if (item.kind == RowKind::FILE) { if (part == RowPart::DISCLOSURE) { m_tab->ToggleCollapsed(item.fileIndex); m_rowsDirty = true; return true; } if (part != RowPart::BODY) { return false; } // 重命名分组会改变列表行高度以外的展示内容,重建行缓存以保证文字刷新。 m_rowsDirty = true; return BeginEdit(StyleEditField::FILE_NAME, row); } // 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。 if (part == RowPart::CHECK || part == RowPart::LANE_BADGE || part == RowPart::REMOVE_SIGNAL) { return false; } const SignalRef ref{item.fileIndex, item.signalIndex}; SelectSignal(ref); m_rowsDirty = true; return BeginEdit(StyleEditField::SIGNAL_NAME, row); } bool SignalPanel::OnRButtonDown(int x, int y) { if (m_dataSet == nullptr || m_tab == nullptr) { return false; } RowPart part = RowPart::NONE; const int row = HitRow(x, y, part); if (row < 0) { return false; } const ListRow& item = m_rows[static_cast(row)]; if (item.kind == RowKind::FILE) { OpenFileMenu(item.fileIndex, x, y); return true; } const SignalRef ref{item.fileIndex, item.signalIndex}; SelectSignal(ref); OpenSignalMenu(ref, x, y); return true; } bool SignalPanel::OnMouseWheel(int delta) { if (m_dataSet == nullptr) { return false; } const float previous = m_scrollOffset; m_scrollOffset -= static_cast(delta) / 120.0f * 54.0f * m_scale; ClampScroll(); return std::fabs(previous - m_scrollOffset) > 0.01f; } bool SignalPanel::OnMouseLeave() { if (m_hoverRow < 0 && m_hoverStyle == StyleHit::NONE) { return false; } m_hoverRow = -1; m_hoverPart = RowPart::NONE; m_hoverStyle = StyleHit::NONE; m_hoverStyleIndex = -1; return true; } void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } const SignalStyle* style = StyleOf(ref); const SignalData* signal = m_dataSet->Resolve(ref); if (style == nullptr || signal == nullptr) { return; } m_menuTargetSignal = ref; m_menuTargetFile = -1; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = signal->Title(); items.push_back(header); MenuItem toggle; toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号"; toggle.id = CMD_SIGNAL_TOGGLE; items.push_back(toggle); MenuItem solo; solo.label = L"仅显示该信号"; solo.id = CMD_SIGNAL_SOLO; items.push_back(solo); MenuItem showAll; showAll.label = L"显示全部信号"; showAll.id = CMD_SIGNAL_SHOW_ALL; items.push_back(showAll); MenuItem hideAll; hideAll.label = L"隐藏全部信号"; hideAll.id = CMD_SIGNAL_HIDE_ALL; items.push_back(hideAll); MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); MenuItem colors; colors.kind = MenuItemKind::SWATCHES; colors.id = CMD_COLOR_BASE; colors.swatchCount = std::min(paletteCount, 16); colors.checkedSwatch = -1; for (int index = 0; index < colors.swatchCount; ++index) { colors.swatches[index] = palette[index]; if (style->color == palette[index]) { colors.checkedSwatch = index; } } items.push_back(colors); MenuItem modeHeader; modeHeader.kind = MenuItemKind::HEADER; modeHeader.label = L"样式"; items.push_back(modeHeader); for (int index = 0; index < kModeOptionCount; ++index) { MenuItem mode; mode.label = DrawModeName(kModeOptions[index]); mode.id = CMD_MODE_BASE + index; mode.checked = (style->mode == kModeOptions[index]); items.push_back(mode); } const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } void SignalPanel::OpenFileMenu(int fileIndex, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } const DataFile* file = m_dataSet->FileAt(fileIndex); if (file == nullptr) { return; } m_menuTargetFile = fileIndex; m_menuTargetSignal = SignalRef(); std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = file->Title(); items.push_back(header); MenuItem showAll; showAll.label = L"选中该文件全部信号"; showAll.id = CMD_FILE_SHOW_ALL; items.push_back(showAll); MenuItem hideAll; hideAll.label = L"取消该文件全部信号"; hideAll.id = CMD_FILE_HIDE_ALL; items.push_back(hideAll); MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); MenuItem remove; remove.label = L"关闭该文件(从工作区移除)"; remove.id = CMD_FILE_REMOVE; items.push_back(remove); const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y) { if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) { return; } const SignalData* signal = m_dataSet->Resolve(ref); if (signal == nullptr) { return; } m_menuTargetSignal = ref; m_menuTargetFile = -1; std::vector items; MenuItem header; header.kind = MenuItemKind::HEADER; header.label = L"所属网格"; items.push_back(header); const std::vector& lanes = m_tab->Lanes(); const int currentLane = m_tab->LaneOfSignal(ref); for (std::size_t index = 0; index < lanes.size(); ++index) { MenuItem lane; lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" + std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; lane.id = CMD_LANE_BASE + static_cast(index); lane.checked = (currentLane == static_cast(index)); items.push_back(lane); } MenuItem separator; separator.kind = MenuItemKind::SEPARATOR; items.push_back(separator); MenuItem create; create.label = L"新建网格"; create.id = CMD_LANE_NEW; items.push_back(create); const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); } bool SignalPanel::OnMenuCommand(const MenuResult& result) { if (m_tab == nullptr || m_dataSet == nullptr || !result.valid) { return false; } // 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开 //(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。 if (result.editCommit) { SignalStyle* style = StyleOf(SelectedRef()); if (style != nullptr) { const std::wstring trimmed = Trim(result.editText); wchar_t* stop = nullptr; const double parsed = trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop); // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed); switch (result.id) { case CMD_GAIN_EDIT: { if (valid) { style->gain = static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); } if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatNumberText(static_cast(style->gain))); } return false; } case CMD_OFFSET_Y: { if (valid) { style->offsetY = static_cast( std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); } if (m_menu != nullptr) { m_menu->UpdateItemValue( CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), FormatNumberText(static_cast(style->offsetY))); } return false; } case CMD_OFFSET_TIME: { if (valid) { style->offsetTime = std::max(-kGainLimit, std::min(parsed, kGainLimit)); } if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(style->offsetTime), FormatNumberText(style->offsetTime)); } return false; } default: break; } } else { return false; } } // 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。 if (result.id == CMD_GAIN_RESET) { if (SignalStyle* style = StyleOf(SelectedRef())) { style->gain = 1.0f; if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatNumberText(static_cast(style->gain))); } } return false; } // 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。 if (result.id == CMD_OFFSET_RESET) { if (SignalStyle* style = StyleOf(SelectedRef())) { style->offsetY = 0.0f; style->offsetTime = 0.0; if (m_menu != nullptr) { m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), FormatNumberText(0.0)); m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0), FormatNumberText(0.0)); } } return false; } // 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。 if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL) { if (SignalStyle* style = StyleOf(SelectedRef())) { const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL); bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical; flag = !flag; if (!horizontal) { // ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量, // 使绘制等价于「先加上偏移再镜像」(用户明确要求)。 style->offsetY = -style->offsetY; if (m_menu != nullptr) { m_menu->UpdateItemValue( CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), FormatNumberText(static_cast(style->offsetY))); } } if (m_menu != nullptr) { m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true); } } return false; } bool needRelayout = false; switch (result.id) { case CMD_SIGNAL_TOGGLE: if (m_menuTargetSignal.IsValid()) { m_tab->ToggleVisible(m_menuTargetSignal); m_rowsDirty = true; needRelayout = true; } break; case CMD_SIGNAL_SOLO: if (m_menuTargetSignal.IsValid()) { m_tab->SoloSignal(m_menuTargetSignal); m_rowsDirty = true; needRelayout = true; } break; case CMD_SIGNAL_SHOW_ALL: m_tab->SetAllVisible(true); m_rowsDirty = true; needRelayout = true; break; case CMD_SIGNAL_HIDE_ALL: m_tab->SetAllVisible(false); m_rowsDirty = true; needRelayout = true; break; case CMD_FILE_SHOW_ALL: if (m_menuTargetFile >= 0) { m_tab->SetFileVisible(m_menuTargetFile, true); m_rowsDirty = true; needRelayout = true; } break; case CMD_FILE_HIDE_ALL: if (m_menuTargetFile >= 0) { m_tab->SetFileVisible(m_menuTargetFile, false); m_rowsDirty = true; needRelayout = true; } break; case CMD_FILE_REMOVE: if (m_menuTargetFile >= 0 && m_host != nullptr) { PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetFile), 0); } break; case CMD_LANE_NEW: if (m_menuTargetSignal.IsValid()) { m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1); m_rowsDirty = true; needRelayout = true; } break; default: if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW) { if (m_menuTargetSignal.IsValid()) { m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE); m_rowsDirty = true; needRelayout = true; } } else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0) { if (SignalStyle* style = StyleOf(m_menuTargetSignal)) { int paletteCount = 0; const std::uint32_t* palette = SignalPalette(paletteCount); style->color = palette[result.swatchIndex % std::max(1, paletteCount)]; } } else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount) { if (SignalStyle* style = StyleOf(m_menuTargetSignal)) { style->mode = kModeOptions[result.id - CMD_MODE_BASE]; } } break; } return needRelayout; } } // namespace jdi