// SignalPanel 实现分片 —— 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条。 // // 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 // 六个分片按**职责**划分: // SignalPanel.cpp 核心:状态、行集合、布局与几何、可见性 // SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条(**本文件**) // SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状 // SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度 // SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号" // SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执 #include "SignalPanelInternal.hpp" #include namespace jdi { namespace { /** * @brief 行位图渲染时使用的**临时**文本几何收集器(调试用)。 * * 行的内容先画进位图再贴回屏幕,因此这里收到的是**位图坐标**; * `PaintRowCached` 会把它们连同位图一起缓存,并在贴图时按原点偏移回放给外层收集器。 * ⚠️ 不缓存的话,"命中缓存的帧根本不调用 `UiText`",文本框会在第二帧全部消失。 */ class RowTextCollector : public UiTextRectSink { public: void OnTextRect(const gd::RectF& rect) override { rects.push_back(rect); } std::vector rects; /**< 位图坐标下的文本墨迹矩形。 */ }; } // namespace void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row) { if (row < 0 || row >= static_cast(m_rows.size()) || m_rows[static_cast(row)].kind != RowKind::GROUP) { return; } const int rowCount = static_cast(m_rows.size()); const float height = m_rows[static_cast(row)].height; const float naturalY = RowRect(row).Y; const float stickyY = StickyGroupRowTop(row, rowCount); if (stickyY + height <= m_listRect.Y + 0.5f) { return; // 已经整块滑出列表上边界 } m_rowYOverrideActive = true; m_rowYOverrideY = stickyY; // 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清; // `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。 SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height)); FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg); PaintGroupRow(g, row); ClearRowClip(); // 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。 if (stickyY > naturalY + 0.5f) { UiDrawLine(g, m_listRect.X, stickyY + height - 0.5f, m_listRect.X + m_listRect.Width, stickyY + height - 0.5f, m_theme->border, 1.0f); } m_rowYOverrideActive = false; } 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"尚未加载数据,请打开数据文件", 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()); // 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制, // 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。 const int stickyRow = StickyGroupRowIndex(rowCount); for (int row = 0; row < rowCount; ++row) { if (row == stickyRow) { continue; } const gd::RectF rect = RowRect(row); if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height) { continue; } // 折起/展开动画:信号行整组向上平移,越过组名下边界的部分要“消失(藏进组名栏)”。 // 已经完全越过的行整行跳过(省一次绘制),部分越过的行把裁剪上边界抬到组名下边界; // ⚠️ 此时还必须把 **GDI+ 裁剪**也收紧到同一条线 —— 行内的色块 / 复选框 / 网格徽标 // 走的是 GDI+ 绘制(只受 `SetClip` 约束),不收紧就会画到组名栏上面去。 const int rowSave = g.Save(); SetRowClip(rect); const float hideTop = AnimationHideTopForRow(row, stickyRow); if (hideTop > rect.Y + 0.5f) { if (rect.Y + rect.Height <= hideTop + 0.5f) { g.Restore(rowSave); ClearRowClip(); continue; } ClampRowClipTop(hideTop); g.SetClip(m_rowClipRect, gd::CombineModeIntersect); } // ⚠️ 整行走**位图缓存**:分组折起/展开动画只是在平移行, // 行的像素一个也没变;逐行重画十来个 GDI+ 原语要 0.35ms/行 //(一屏 35~40 行 ⇒ 每帧 12~15ms),这是"只有文字的列表却滚动卡顿"的真因。 PaintRowCached(g, row); g.Restore(rowSave); ClearRowClip(); } PaintStickyGroupRow(g, stickyRow); } 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::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb, TextAlign align) { // 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`, // 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。 // 另外,分组折起动画是**刚性平移**(行高不变,信号行整体上移并被组名栏裁掉)—— // 此时同样必须裁到**本行矩形**内,否则垂直居中的文字会溢出到相邻行上。 // // ⚠️ **行位图缓存渲染时反而不能裁剪**(`m_rowClipRender`):位图是整行内容, // 裁剪留到贴图时由 GDI+ 裁剪区完成(否则部分滚出上边界的行会永久丢掉顶部像素)。 if (m_rowClipRender) { UiText(g, text, font, rect, argb, align); return; } UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect); } void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb) { // 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效 //(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。 gd::RectF clipped = rect; if (!m_rowClipRender) { clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect); if (clipped.Width <= 0.0f || clipped.Height <= 0.0f) { return; } } UiFillRect(g, clipped, argb); } void SignalPanel::InvalidateRowCache() { for (RowBitmapCache& cache : m_rowBitmapCache) { if (cache.dc != nullptr) { DeleteDC(cache.dc); cache.dc = nullptr; } if (cache.dib != nullptr) { DeleteObject(cache.dib); cache.dib = nullptr; } } m_rowBitmapCache.clear(); m_rowBitmapLiveCount = 0; } std::uint64_t SignalPanel::RowCacheKey(int row) const { std::uint64_t hash = 1469598103934665603ull; const auto mix = [&hash](std::uint64_t value) { hash ^= value; hash *= 1099511628211ull; }; const auto mixFloat = [&mix](float value) { std::uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); mix(bits); }; const auto mixText = [&mix](const std::wstring& text) { mix(text.size()); for (const wchar_t character : text) { mix(static_cast(character)); } }; if (row < 0 || row >= static_cast(m_rows.size())) { return hash; } const ListRow& item = m_rows[static_cast(row)]; mix(static_cast(item.kind)); mix(static_cast(item.groupIndex + 1)); mix(static_cast(item.signalIndex + 1)); mixFloat(item.height); mixFloat(m_scale); mix(static_cast(reinterpret_cast(m_theme))); mix(static_cast(reinterpret_cast(m_fonts))); mix(static_cast((m_hoverRow == row) ? (static_cast(m_hoverPart) + 1) : 0)); if (item.kind == RowKind::GROUP) { const SignalGroup* group = (m_dataSet != nullptr) ? m_dataSet->GroupAt(item.groupIndex) : nullptr; mixText((group != nullptr) ? group->name : std::wstring()); mixText(GroupStatsText(row)); mix(IsGroupCollapsed(item.groupIndex) ? 1ull : 0ull); mix(static_cast(item.displayCount + 1)); if (m_tab != nullptr) { mix(m_tab->GroupAllVisible(item.groupIndex) ? 1ull : 0ull); mix(m_tab->GroupHasVisible(item.groupIndex) ? 2ull : 0ull); } const bool editing = (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row); mix(editing ? 1ull : 0ull); if (editing) { mixText(m_edit.Text()); // ⚠️ 插入符与选区必须进键:行位图缓存只按“行的可见内容”生效, // 而编辑态下插入符位置 / 选区范围也是可见内容。漏掉它们时, // 方向键与鼠标框选虽然改变了 `TextEdit` 状态,但键不变 ⇒ 贴回的仍是旧位图, // 屏幕上看就是“无法部分选择、光标不动”(用户 2026-10-19 报告)。 mix(static_cast(m_edit.Caret()) + 1ull); mix(static_cast(m_edit.SelectionBegin()) + 1ull); mix(static_cast(m_edit.SelectionEnd()) + 1ull); } return hash; } const SignalRef ref{item.groupIndex, item.signalIndex}; const SignalData* signal = (m_dataSet != nullptr) ? m_dataSet->Resolve(ref) : nullptr; const SignalStyle* style = StyleOf(ref); mixText((signal != nullptr) ? signal->name : std::wstring()); mix((SelectedRef() == ref) ? 1ull : 0ull); if (style != nullptr) { mix(static_cast(style->color)); mixFloat(style->opacity); } if (m_tab != nullptr) { mix(m_tab->IsVisible(ref) ? 1ull : 0ull); mix(static_cast(m_tab->LaneOfSignal(ref) + 1)); } const bool editing = (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row); mix(editing ? 1ull : 0ull); if (editing) { mixText(m_edit.Text()); // 同分组行:插入符 / 选区也是行的可见内容(见 `GROUP_TITLE` 分支的注释)。 mix(static_cast(m_edit.Caret()) + 1ull); mix(static_cast(m_edit.SelectionBegin()) + 1ull); mix(static_cast(m_edit.SelectionEnd()) + 1ull); } return hash; } void SignalPanel::PaintRowCached(gd::Graphics& g, int row) { if (m_dataSet == nullptr || m_tab == nullptr || m_theme == nullptr || row < 0 || row >= static_cast(m_rows.size())) { return; } const gd::RectF rect = RowRect(row); if (rect.Height <= 0.5f) { return; } const int originX = static_cast(std::floor(m_listRect.X)); const int originY = static_cast(std::floor(rect.Y)); const int width = static_cast(std::ceil(m_listRect.Width)); const int height = static_cast(std::ceil(rect.Height)); if (width <= 0 || height <= 0) { return; } // ⚠️ 上限检查必须在**取引用之前**:`InvalidateRowCache()` 会清空容器, // 清理后原有引用(`RowBitmapCache&`)就悬空了。 if (m_rowBitmapLiveCount >= kRowBitmapCacheLimit) { InvalidateRowCache(); } if (m_rowBitmapCache.size() < m_rows.size()) { m_rowBitmapCache.resize(m_rows.size()); } RowBitmapCache& cache = m_rowBitmapCache[static_cast(row)]; const std::uint64_t key = RowCacheKey(row); const bool sameSize = (cache.dc != nullptr) && cache.width == width && cache.height == height; if (!sameSize || cache.key != key) { if (!sameSize) { if (cache.dc != nullptr) { DeleteDC(cache.dc); cache.dc = nullptr; if (m_rowBitmapLiveCount > 0) { --m_rowBitmapLiveCount; } } if (cache.dib != nullptr) { DeleteObject(cache.dib); cache.dib = nullptr; } // ⚠️ DIB + 内存 DC(而不是 `gd::Bitmap`):GDI 在 GDI+ 位图的临时 HDC 上 // 会关闭 ClearType,导致列表文字丢失亚像素渲染、与正文观感不一致。 BITMAPINFO info = {}; info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); info.bmiHeader.biWidth = width; info.bmiHeader.biHeight = -height; // 自上而下 info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; info.bmiHeader.biCompression = BI_RGB; void* bits = nullptr; HDC memoryDc = CreateCompatibleDC(nullptr); HBITMAP dib = (memoryDc != nullptr) ? CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0) : nullptr; if (memoryDc == nullptr || dib == nullptr) { if (dib != nullptr) { DeleteObject(dib); } if (memoryDc != nullptr) { DeleteDC(memoryDc); } return; } SelectObject(memoryDc, dib); cache.dc = memoryDc; cache.dib = dib; cache.width = width; cache.height = height; ++m_rowBitmapLiveCount; } { gd::Graphics rowGraphics(cache.dc); rowGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); rowGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); rowGraphics.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); rowGraphics.Clear(ToGdiColor(m_theme->panelBg)); // ⚠️ 用**几何平移**而不是 `TranslateTransform`(见 `m_rowRenderActive`)。 m_rowRenderActive = true; m_rowRenderOriginX = static_cast(originX); m_rowRenderOriginY = static_cast(originY); // 调试:把文本收集器临时指向一个**位图坐标**的本地容器(偏移 0), // 渲染完成后连同矩形一起存进缓存,命中缓存时再按原点偏移回放。 // ⚠️ **无条件收集**(不管外层是否有收集器):行位图可能在任何一帧被渲染 //(例如用户在关闭调试时滚动过列表),若只在"外层有收集器"时才收集, // 之后打开调试会命中缓存而永远拿不到文本框。收集成本只发生在**缓存未命中**的行上。 UiTextRectSink* const outerSink = UiGetTextRectSink(); RowTextCollector rowTextBoxes; UiSetTextRectSink(&rowTextBoxes); UiSetTextRectOffset(0.0f, 0.0f); m_rowClipRender = true; if (m_rows[static_cast(row)].kind == RowKind::GROUP) { PaintGroupRow(rowGraphics, row); } else { PaintSignalRow(rowGraphics, row); } m_rowClipRender = false; m_rowRenderActive = false; cache.textRects = rowTextBoxes.rects; UiSetTextRectOffset(0.0f, 0.0f); UiSetTextRectSink(outerSink); } cache.key = key; } // 调试:把本行的文本框回放给外层收集器(缓存命中 / 刚渲染两条路共用这一段)。 if (UiTextRectSink* const outerSink = UiGetTextRectSink()) { for (const gd::RectF& textRect : cache.textRects) { outerSink->OnTextRect(gd::RectF(textRect.X + static_cast(originX), textRect.Y + static_cast(originY), textRect.Width, textRect.Height)); } } // ⚠️ 整块 1:1 搬运(不经过 GDI+ 采样/混合),字的亚像素边缘才能原样保留。 // ⚠️ **必须自己给 DC 设裁剪**:`Graphics::GetHDC()` 交给我们的 DC 上,(a) GDI 绘制 // 不受 GDI+ `SetClip` 的约束(这是本文件多处注释过的坑),(b) 折起/展开动画时行会 // 滑进组名栏,必须裁到 `m_rowClipRect`。因此这里显式 `IntersectClipRect` 后再 `BitBlt`。 const HDC targetDc = g.GetHDC(); if (targetDc != nullptr) { const int saved = SaveDC(targetDc); if (saved != 0) { const gd::RectF clip = m_rowClipActive ? m_rowClipRect : m_listRect; IntersectClipRect(targetDc, static_cast(std::floor(clip.X)), static_cast(std::floor(clip.Y)), static_cast(std::ceil(clip.X + clip.Width)), static_cast(std::ceil(clip.Y + clip.Height))); } BitBlt(targetDc, originX, originY, width, height, cache.dc, 0, 0, SRCCOPY); if (saved != 0) { RestoreDC(targetDc, saved); } g.ReleaseHDC(targetDc); } } void SignalPanel::PaintGroupRow(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 SignalGroup* group = m_dataSet->GroupAt(item.groupIndex); if (group == nullptr) { return; } const gd::RectF rect = RowRect(row); const bool hovered = (m_hoverRow == row); if (hovered) { FillRowRect(g, rect, m_theme->hover); } FillRowRect(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 = IsGroupCollapsed(item.groupIndex); // 折叠三角。 { 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->GroupAllVisible(item.groupIndex); const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex); UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale); const gd::RectF closeRect = GroupCloseRect(row); // 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。 const std::wstring groupLabel = GroupLabelText(item.groupIndex); // 项数文本与 `RowNameRect`(组名编辑框)、`GroupStatsRect`(命中区)共用同一来源, // 保证“画的”与“点的”不漂移。 const std::wstring stats = GroupStatsText(row); const float groupWidth = GroupLabelWidth(row); const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); const float statsRight = GroupCloseRect(row).X - 6.0f * m_scale; const gd::RectF statsTextRect(statsRight - statsWidth, rect.Y, statsWidth, rect.Height); 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); // 序号以强调色突出,便于在多组之间快速定位。 PaintRowText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height), m_theme->accent, TextAlign::LEFT); // 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。 FillRowRect(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::GROUP_TITLE && m_editRow == row) { UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale); } else { PaintRowText(g, UiEllipsize(group->name, m_fonts->uiBold, nameWidth), m_fonts->uiBold, gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, TextAlign::LEFT); } PaintRowText(g, stats, m_fonts->uiSmall, statsTextRect, 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.groupIndex, 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) { FillRowRect(g, rect, m_theme->selection); FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent); } else if (hovered) { FillRowRect(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); // 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。 // ⚠️ 可见性判据必须与命中测试共用 `SignalBadgeVisible`(否则“可见但未分配网格”的信号 // 会在未绘制的空白处仍可点出「所属网格」菜单)。 const bool badgeVisible = SignalBadgeVisible(ref); 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; PaintRowText(g, UiEllipsize(signal->name, 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); PaintRowText(g, L"G" + std::to_wstring(m_tab->LaneOfSignal(ref) + 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); } } 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); // ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)---- PaintTargetRow(g); const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef()); if (style == nullptr || SelectedRef().IsValid() == false) { // 提示文本只占「目标」行以下的区域,避免与目标行重叠。 const float top = m_targetRowRect.Y + m_targetRowRect.Height; UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, gd::RectF(m_styleRect.X, top, m_styleRect.Width, std::max(0.0f, m_styleRect.Y + m_styleRect.Height - top)), m_theme->textMuted, TextAlign::CENTER); return; } // 目标信号本身:线宽复位按钮需要它的有效采样点数(单点信号默认用最大线宽)。 const SignalData* signal = (m_dataSet != nullptr) ? m_dataSet->Resolve(SelectedRef()) : nullptr; 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); }; // 「线宽 / 透明度」两行右侧的**复位按钮**(与「系数/偏移」二级菜单标题栏的还原图标同款)。 // `changed` = 当前值是否已偏离默认值:偏离时才用强调色(一眼看出"这里可以点"), // 已是默认值时用弱色(仍可点,但结果不变)。 auto paintStyleReset = [&](const gd::RectF& rect, StyleHit hit, bool changed) { if (rect.Width <= 0.0f || rect.Height <= 0.0f) { return; } const bool hot = (m_hoverStyle == hit); if (hot) { UiFillRoundRect(g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale, rect.Width + 2.0f * m_scale, rect.Height + 2.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); } UiPaintResetIcon(g, rect, hot ? m_theme->accent : (changed ? m_theme->text : m_theme->textMuted), m_scale); }; // ---- 第一行:颜色(预设色块 + 自定义颜色入口)---- { 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); } } // ---- 第二行:样式(线段 / 散点 / 点线)---- // ⚠️ 单点信号固定使用「散点」:「线段」在单个数据点上完全画不出来、「点线」与 // 「放大自动点线」语义重叠,因此它们对单点信号**置灰且不可点**(用户 2026-10-16 要求)。 { paintRowLabel(1, L"样式"); for (int index = 0; index < kModeOptionCount; ++index) { const gd::RectF& rect = m_modeRects[static_cast(index)]; const bool enabled = ModeOptionEnabled(index); const bool active = (style->mode == kModeOptions[index]); const bool hovered = enabled && (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, !enabled ? m_theme->textMuted : (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); } paintStyleReset(m_widthResetRect, StyleHit::WIDTH_RESET, style->lineWidth != DefaultSignalLineWidth(signal != nullptr ? signal->finiteCount : 0)); } // ---- 第五行:透明度(数值可点击输入)---- { 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); } paintStyleReset(m_opacityResetRect, StyleHit::OPACITY_RESET, std::fabs(style->opacity - 1.0f) > 0.001f); } } void SignalPanel::PaintTargetRow(gd::Graphics& g) { if (m_fonts == nullptr || m_theme == nullptr || m_targetRowRect.Width <= 0.0f) { return; } UpdateTargetRowGeometry(); const float labelWidth = kStyleLabelWidth * m_scale; // 行标签与前五行一致(同一标签列宽度、同一纵向居中基准)。 UiText(g, L"目标", m_fonts->uiSmall, gd::RectF(m_targetRowRect.X, m_targetRowRect.Y, labelWidth, m_targetRowRect.Height), m_theme->textMuted, TextAlign::LEFT); const SignalRef& target = SelectedRef(); const SignalData* signal = (m_dataSet != nullptr && target.IsValid()) ? m_dataSet->Resolve(target) : nullptr; // 没有可编辑的目标信号:只显示占位提示(与样式区其余行的"请选择信号"提示一致)。 if (signal == nullptr) { const float contentLeft = m_targetRowRect.X + labelWidth + kStyleLabelGap * m_scale; UiText(g, L"未选择信号", m_fonts->uiSmall, gd::RectF(contentLeft, m_targetRowRect.Y, std::max(0.0f, m_targetRowRect.X + m_targetRowRect.Width - contentLeft), m_targetRowRect.Height), m_theme->textMuted, TextAlign::LEFT); return; } // 名称:悬停时给一层与信号列表行一致的背景高亮(用户要求"显示与交互效果相同")。 if (m_hoverStyle == StyleHit::TARGET_NAME && m_targetNameRect.Width > 0.0f) { UiFillRoundRect(g, m_targetNameRect, kCorner * m_scale, m_theme->hover); } if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow < 0) { UiDrawEditField(g, EditFieldRect(), m_edit, m_fonts->ui, m_scale); } else { // 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。 const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted; // ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、 // 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。 UiText(g, UiEllipsize(signal->name, m_fonts->ui, m_targetNameRect.Width), m_fonts->ui, m_targetNameRect, nameColor, TextAlign::LEFT); } // GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求), // 并与其它控件一样**右对齐到内容列右边缘**。 if (m_targetBadgeRect.Width > 0.0f) { const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE); UiFillRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accent : m_theme->border, 1.0f); UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall, m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } } } // namespace jdi