Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelPaint.cpp
T
DeepSeek-V4.1-Flash 6b15575829 滚动动画根治(巨型信号不再影响滚动)+「N 项」计数点击语义 + 浮动面板首现右上角 + 开关/关闭面板卡顿
一、滚动过渡动画在巨型信号处卡顿 —— 真因是「退化成整幅重绘」
滚动到**从未构建过图层的区域**时,可见网格一个都贴不出图层像素 ⇒ BlitWideBuffer() 返回 false
⇒ 落到 Paint() 末尾的整幅重绘 ⇒ 把本帧全部可见网格的波形(含千万级采样的巨型信号)每帧重画一遍。
实测(MF4 124 网格 + VERY_BIG_DATA 5 网格,点选最后一个信号):单帧 20~35ms,且「可见的巨型信号
网格数」越多越慢(0 个 ≈20ms、5 个 ≈34ms);对照手动拖滚动条只要 ~6ms。
修法:BlitWideBuffer 只要还能画网格底衬(backing 非空)就返回 true,
只有 blits 与 backing 都为空时才交给整幅重绘 —— 这正是「没来得及画信号时就按只有网格叠底的状态继续滚动」。
实测:动画均值 23.0→11.2ms、最坏 35.6→13.1ms;含 5 个巨型信号网格的帧 33→10.7~12.3ms。

二、面板动画帧只重绘面板(额外 −2.7ms/帧)
新增 Application::m_panelDirty:滚动定时器在只有面板动画在跑时置位,Render 走 else if (panelOnly)
只重绘面板 + 分隔条(SignalPanel::Paint 会铺满自己的区域)。m_chromeDirty(真内容变化)仍走完整装饰重绘。
⚠️ 回送仍是 if (fullFrame) {整窗} else {绘图区} 的无条件分支(遵守 §9.41)。
实测帧耗时拆解:plot 4.8ms / plot+panel 11.4ms / plot+全装饰 13.9ms。

三、分组行「N 项」计数:只折叠展开,绝不进入改名
新增 RowPart::GROUP_COUNT 与 SignalPanel::GroupStatsRect(row)(绘制与命中的唯一几何来源,命中矩形在
文本左右各外扩 3 逻辑像素)。RowHitPart 在名称区域之前判定计数区:
- OnLButtonDown:折叠/展开;OnDoubleClick:只折叠/展开,任何点击频率都不进入改名;
- DoubleClickIsPlainPress 返回 true ⇒ 快速连点每一击都生效。
⚠️「快速双击进改名」的计时只对 RowPart::BODY 记录(否则“点计数、紧接着点名称”会被误判成双击)。

四、浮动面板首次出现的位置:右上角
旧默认位置 top = plotTop + 16 + laneIndex*28:上百个网格时 laneIndex 很大 ⇒ 被夹到右下角
(实测 plotH=1081 时 topMargin=964)。改为按「当前真正显示出来的面板中排在本面板之前的个数」错开
(固定遍历顺序:网格下标 → 面板种类),并把错开量夹到“面板下沿不超过绘图区 45% 高度”以内:
第一个面板错开 0 ⇒ 正好贴在上右角(实测 topMargin=24、rightMargin=24),6 个面板时最坏 topMargin=234。
⚠️ 判定「面板是否显示」不能只看 *Panel.open —— LanePanelState::open 默认就是 true,必须同时看功能启用。

五、开关实验性功能 / 关闭面板卡顿 —— RebuildWideBuffer() 整批重建
四个 SetLane* 末尾都调 RebuildWideBuffer()(注释说是“预建宽缓冲基底”),而它同步重渲染全部网格的图层:
129 个网格(含 5 个千万级采样的巨型信号)整批重建要几百毫秒 ⇒ 点一下功能开关 / 关闭浮动面板就会卡住。
修法:新增 PlotView::EnsureLaneLayersPrefetched() —— 只有「图层数组与网格数量不符」(首次绘制 / 增删网格)
才同步整批建立;其余只 RollWideBuffer() 登记缺失/过期网格,由既有预算(每帧最多一个)逐帧补齐。
实测:几百毫秒 → ≤22ms;巨型信号网格上开关均值滤波同样 15ms 量级。

验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/perf/lanescroll_main.cpp(lanescroll.exe)23/23:逐帧耗时 + 「可见巨型信号网格数」关联、
  「N 项」计数热区与双击不进改名、面板首现位置与多面板错开、开着巨型信号的开关/关闭面板耗时。
- 既有回归:verify 49/49、panelanim 158/158、datacheck 69/69、edgecheck 46/46、errcheck 19/19、
  gridanim 93/93。
- make release / make debug 零错误零警告(用 TARGET_FILE_NAME=TargetCheck/TargetCheckD 避免与运行中实例冲突)。

文档:Documents/agent_mem.md 新增 §9.44、§11.2 第 134–140 条与变更日志一行。
2026-10-17 09:56:45 +08:00

706 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
//
// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
#include "SignalPanelInternal.hpp"
namespace jdi
{
void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row)
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
{
return;
}
const int rowCount = static_cast<int>(m_rows.size());
const float height = m_rows[static_cast<std::size_t>(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<int>(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);
}
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintGroupRow(g, row);
}
else
{
PaintSignalRow(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`,
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
// 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内,
// 否则垂直居中的文字会溢出到相邻行上。
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;
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
{
return;
}
UiFillRect(g, clipped, argb);
}
void SignalPanel::PaintGroupRow(gd::Graphics& g, int row)
{
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
{
return;
}
const ListRow& item = m_rows[static_cast<std::size_t>(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<int>(m_rows.size()))
{
return;
}
const ListRow& item = m_rows[static_cast<std::size_t>(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);
// 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。
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;
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(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);
}
}
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 float labelWidth = kStyleLabelWidth * m_scale;
// 行标签统一以整行为基准垂直居中,确保与同行控件纵向对齐。
auto paintRowLabel = [&](int rowIndex, const wchar_t* text)
{
const gd::RectF& row = m_styleRowRects[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<double>(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<double>(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<int>(style->opacity * 100.0f + 0.5f);
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect,
m_theme->textStrong, TextAlign::RIGHT);
}
}
}
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