按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查, 四个只读子审查交叉核对后逐条回到源码确认再改。 一、数据层真 BUG - MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个 64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree 的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存, 否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。 - Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后 block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数", 下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。 - DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位 再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。 - cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。 - CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。 - MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。 - Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。 二、界面 BUG - 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。 - 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉 => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。 - GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在 WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。 - WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。 - WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。 - TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。 - SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。 - EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。 - 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。 - 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。 - ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。 - RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。 - 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。 - SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。 三、PlotView - FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段, 另一个网格同时改"调整"时带着旧像素被当成最新)。 - 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。 - PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。 - 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。 - PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。 - 内联输入框命中字体与绘制统一为 monoSmall。 - 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。 - EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。 四、清理 - 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、 Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、 PaintUtil 里孤立的注释块。 - 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、 均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、 RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的 "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。 五、二进制体积评估(按要求只评估,未做压缩修改) - release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、 .rsrc 20KB、.idata 11.5KB。 - 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益 (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。 - 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。 - 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能), 需要用户决策,本轮未动。 验证 - editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。 - check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有 不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。 - 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、 带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。 - make release / make debug 零警告。 文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束 与文末本轮小节(含体积评估数据)。
961 lines
38 KiB
C++
961 lines
38 KiB
C++
// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||
//
|
||
// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
|
||
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
|
||
#include "SignalPanelInternal.hpp"
|
||
|
||
#include <cstring>
|
||
|
||
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);
|
||
}
|
||
|
||
// ⚠️ 整行走**位图缓存**:分组折起/展开动画只是在平移行,
|
||
// 行的像素一个也没变;逐行重画十来个 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<std::uint64_t>(character));
|
||
}
|
||
};
|
||
|
||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||
{
|
||
return hash;
|
||
}
|
||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||
mix(static_cast<std::uint64_t>(item.kind));
|
||
mix(static_cast<std::uint64_t>(item.groupIndex + 1));
|
||
mix(static_cast<std::uint64_t>(item.signalIndex + 1));
|
||
mixFloat(item.height);
|
||
mixFloat(m_scale);
|
||
mix(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(m_theme)));
|
||
mix(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(m_fonts)));
|
||
mix(static_cast<std::uint64_t>((m_hoverRow == row) ? (static_cast<int>(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<std::uint64_t>(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<std::uint64_t>(m_edit.Caret()) + 1ull);
|
||
mix(static_cast<std::uint64_t>(m_edit.SelectionBegin()) + 1ull);
|
||
mix(static_cast<std::uint64_t>(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<std::uint64_t>(style->color));
|
||
mixFloat(style->opacity);
|
||
}
|
||
if (m_tab != nullptr)
|
||
{
|
||
mix(m_tab->IsVisible(ref) ? 1ull : 0ull);
|
||
mix(static_cast<std::uint64_t>(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<std::uint64_t>(m_edit.Caret()) + 1ull);
|
||
mix(static_cast<std::uint64_t>(m_edit.SelectionBegin()) + 1ull);
|
||
mix(static_cast<std::uint64_t>(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<int>(m_rows.size()))
|
||
{
|
||
return;
|
||
}
|
||
const gd::RectF rect = RowRect(row);
|
||
if (rect.Height <= 0.5f)
|
||
{
|
||
return;
|
||
}
|
||
const int originX = static_cast<int>(std::floor(m_listRect.X));
|
||
const int originY = static_cast<int>(std::floor(rect.Y));
|
||
const int width = static_cast<int>(std::ceil(m_listRect.Width));
|
||
const int height = static_cast<int>(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<std::size_t>(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<float>(originX);
|
||
m_rowRenderOriginY = static_cast<float>(originY);
|
||
m_rowClipRender = true;
|
||
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
|
||
{
|
||
PaintGroupRow(rowGraphics, row);
|
||
}
|
||
else
|
||
{
|
||
PaintSignalRow(rowGraphics, row);
|
||
}
|
||
m_rowClipRender = false;
|
||
m_rowRenderActive = false;
|
||
}
|
||
cache.key = key;
|
||
}
|
||
// ⚠️ 整块 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<int>(std::floor(clip.X)),
|
||
static_cast<int>(std::floor(clip.Y)),
|
||
static_cast<int>(std::ceil(clip.X + clip.Width)),
|
||
static_cast<int>(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<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);
|
||
|
||
// 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。
|
||
// ⚠️ 可见性判据必须与命中测试共用 `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 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
|
||
|