重构(UI): 统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + 剥离 Tools

## 1. 新增 UiComponent 顶层组件协议(不是负优化)

Components/Shared/Inc/UiComponent.hpp:纯接口、无状态,8 个方法
SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor。

Toolbar / TabBar / SignalPanel / PlotView 本来就各自拥有完全同名同签名的成员,
因此改造 = 加 override + 补 Area()(PlotView 另加一个非 const 的 QueryCursor 转发,
因为它自己的查询会惰性建立行缓存),实现代码零改动、静态派发、体积无增长。

刻意不把 UiWidget 当作它的基类:UiWidget 是"树节点"(父给尺寸、父下探脏区、
命中沿树链派发),而 PlotView(4 层位图缓存)/ SignalPanel(行位图缓存)是
"地盘宿主"。强行上树会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过
"后台渲染覆盖平移结果""命中矩形错位"的事故。头文件里已写明这条理由。
Toolbar/TabBar 是两者的交集:对外实现 UiComponent,对内持有一棵 UiWidget 树。

## 2. 宿主侧:四条手写链 -> 组件表

Application 新增 ComponentEntry{ component, target, chrome } 与 m_components
(底->顶:plot -> panel -> toolbar -> tabBar),BuildComponentTable() 装配一次。

- ApplyDpi        6 行逐个 SetScale  ->  1 个循环
- Relayout        手算三列 x 坐标    ->  UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO})
- WM_MOUSELEAVE   逐个 OnMouseLeave   ->  循环累积 chromeChanged / plotChanged
- WM_SETCURSOR    if/else if 链      ->  从表尾向前找首个命中者,取其 QueryCursor

顺带兑现历史缺口:UiLayout::UiStackRects 自引入以来第一次有了生产调用点。

有意保留显式的三条链(WM_LBUTTONDOWN / WM_LBUTTONUP / WM_MOUSEMOVE):它们各自带
组件专属后置动作(提交重命名、构建派生信号、面板联动滚动、更新拖拽光标、
plotOnlyMove 决定局部失效还是整帧失效)。塞进表里只能靠 lambda 字段,
可读性反而下降 —— 属于用户明确禁止的"过度抽象",因此不做。

## 3. 大文件按职责拆分(纯整函数搬移,逐像素 0 差异)

SignalPanelInteraction.cpp 2329 -> 831 行,拆出:
  SignalPanelEdit.cpp   616  内联编辑(筛选框、组名/信号名、数值、颜色与透明度)
  SignalPanelScroll.cpp 205  动画与滚动(折起、联动滚动、滚动到目标信号)
  SignalPanelMenu.cpp   527  弹出菜单(系数/偏移、镜像、右键菜单与命令回执)

Shared/PopupMenu.cpp 1901 -> 706 行,拆出:
  PopupMenuLayout.cpp 753  尺寸测量、项矩形排版、滚动可见性
  PopupMenuPaint.cpp  397  背景/阴影/项绘制
  Inc/PopupMenuInternal.hpp 103   pmdetail 常量 + inline 辅助(跨 TU 安全)

拆分判据是"职责"而非行数;共享常量一律收敛到 *Internal.hpp。

## 4. 代码审查(修 5 处)

- SignalPanelInteraction.cpp 头注释仍写"4 片",实际 6 片 -> 改为完整清单
- PopupMenuLayout.cpp 机械搬移时把构造函数一起搬走 -> 重复定义,只保留在交互分片
- PopupMenu.cpp 的 #include "PopupMenuInternal.hpp" 落进 namespace jdi 内 -> 移到文件头
- PopupMenuPaint.cpp 缺 UiControls.hpp(UiDrawEditFieldContent 未声明)-> 补 include
- 全仓复查历史删除符号残留引用(m_paintPlot / ClampCursorsAfterPan /
  PaintFreeMarkerTooltip / CaptureDecorCache / FitSinglePointSignal / CsvTable /
  DataFile / .preflight / structcheck)-> 零命中

## 5. Tools/ 剥离

git rm -r --cached Tools + .gitignore 新增 Tools/(本地文件保留,可随时复跑)。
ui_refactor 分支为全本地分支,另做 filter-branch 清除历史中的该目录。

## 6. 验证

- make release / make debug:零 error 零 warning
- rendercheck:A-H 八个场景哈希与改造前逐一相同
  (A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
   E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113)
  FUNC 7/7 PASS
- uicheck:69 passed, 0 failed
- TargetCheck.exe 891,392 B(< 900 KB 阈值)

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md
新增第五阶段章节;Documents/agent_mem.md 同步变更日志与「第七轮」硬约束。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-28 14:35:32 +08:00
1 parent 675c2a5f54
commit 0ec8055e95
20 files changed
+3287 -2678

No files matched your search

+12 -8
View File
@@ -15,6 +15,7 @@
#include "PaintUtil.hpp"
#include "PopupMenu.hpp"
#include "Theme.hpp"
#include "UiComponent.hpp"
namespace jdi
{
@@ -51,7 +52,7 @@ namespace jdi
* 因此不同标签页的信号勾选与样式互不影响。所有弹出菜单与拾色器均使用主题化自绘
* 浮动面板,不出现系统默认菜单样式。
*/
class SignalPanel
class SignalPanel : public UiComponent
{
public:
SignalPanel();
@@ -113,19 +114,22 @@ namespace jdi
* @brief 设置 DPI 缩放比例。
* @param [IN] scale 缩放比例(1.0 对应 96 DPI)。
*/
void SetScale(float scale);
void SetScale(float scale) override;
/**
* @brief 按给定区域重新排布面板内容。
* @param [IN] area 面板可用区域。
*/
void Layout(const gd::RectF& area);
void Layout(const gd::RectF& area) override;
/** @brief 面板占用的整块区域(与 `Layout` 同源)。 */
gd::RectF Area() const override { return m_area; }
/**
* @brief 绘制面板。
* @param [IN,OUT] g 绘图上下文。
*/
void Paint(gd::Graphics& g);
void Paint(gd::Graphics& g) override;
/**
* @brief 处理鼠标移动。
@@ -200,7 +204,7 @@ namespace jdi
* @brief 处理鼠标离开面板。
* @return 视觉状态发生变化返回 true。
*/
bool OnMouseLeave();
bool OnMouseLeave() override;
/**
* @brief 处理弹出菜单的选择结果。
@@ -213,7 +217,7 @@ namespace jdi
* @brief 判断面板是否处于内联文本输入状态。
* @return 正在输入返回 true。
*/
bool IsEditing() const { return m_editField != StyleEditField::NONE || m_filterEditing; }
bool IsEditing() const override { return m_editField != StyleEditField::NONE || m_filterEditing; }
/**
* @brief 判断坐标是否落在列表上方的「筛选信号」输入框内。
@@ -348,7 +352,7 @@ namespace jdi
* @param [IN] y 客户区纵坐标。
* @return 落在区域内返回 true。
*/
bool HitTest(int x, int y) const;
bool HitTest(int x, int y) const override;
/**
* @brief 查询该位置应使用的鼠标光标(✕ 类按钮使用手型)。
@@ -357,7 +361,7 @@ namespace jdi
* @return 需要特殊光标时返回光标句柄,否则返回 nullptr。
* @note 需要按需重建列表行缓存(查 ✕ 的命中),因此不是 const。
*/
HCURSOR QueryCursor(int x, int y);
HCURSOR QueryCursor(int x, int y) override;
/**
* @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。
@@ -0,0 +1,655 @@
// SignalPanel 实现分片 —— 内联编辑(筛选框、组名/信号名/数值输入、样式按钮)。
//
// 本文件只包含 SignalPanel 的成员函数定义(从 SignalPanelInteraction.cpp 机械搬移,行为不变)。
// 放进本分片的判据:**与"把用户输入落到数据层"有关**的代码(BeginEdit/EndEdit/OnEdit*、
// 应用颜色与透明度、筛选框)。交互命中与手势派发仍在 SignalPanelInteraction.cpp。
#include "SignalPanelInternal.hpp"
namespace jdi
{
bool SignalPanel::IsOnFilterBox(int x, int y) const
{
return UiPointInRect(m_filterRect, static_cast<float>(x), static_cast<float>(y));
}
void SignalPanel::ClearFilter()
{
if (m_filterText.empty() && !m_filterEditing)
{
return;
}
m_filterText.clear();
if (m_filterEditing)
{
// 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。
m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u);
}
// 退出筛选:筛选模式下临时折起的状态与动画一并作废(行集合会整体重建)。
m_filterCollapsed.clear();
m_groupAnims.clear();
m_rowShift.clear();
// 清空后回到完整列表:重建行集合、回到顶部。
m_rowsDirty = true;
m_scrollOffset = 0.0f;
m_layoutDirty = true;
}
HCURSOR SignalPanel::QueryCursor(int x, int y)
{
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。
RowPart part = RowPart::NONE;
if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE))
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
UpdateTargetRowGeometry();
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
UiPointInRect(m_opacityValueRect, fx, fy))
{
return LoadCursorW(nullptr, IDC_HAND);
}
return nullptr;
}
void SignalPanel::BeginFilterEdit()
{
if (m_filterEditing)
{
return;
}
m_filterEditing = true;
// 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。
m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u);
}
bool SignalPanel::EndFilterEdit()
{
if (!m_filterEditing)
{
return false;
}
// **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。
const std::wstring finalText = Trim(m_filterEdit.Text());
m_filterEditing = false;
m_filterEdit.End();
// ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成
// “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。
ApplyFilterText(finalText);
return true;
}
bool SignalPanel::ApplyWidthFromX(float x)
{
// ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`,
// 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。
if (m_dataSetWritable == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
const double ratio = UiSliderRatioFromX(m_widthTrackRect, x);
float value = static_cast<float>(kWidthMin + ratio * (kWidthMax - kWidthMin));
value = std::round(value * 10.0f) / 10.0f;
value = ClampFloat(value, kWidthMin, kWidthMax);
if (std::fabs(value - style->lineWidth) < 0.001f)
{
return false;
}
style->lineWidth = value;
return true;
}
bool SignalPanel::BeginEdit(StyleEditField field, int row)
{
// `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。
if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE)
{
return false;
}
if (m_editField == field && m_editRow == row)
{
return false;
}
std::wstring initial;
switch (field)
{
case StyleEditField::WIDTH:
{
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
initial = FormatNumber(style->lineWidth, 1);
break;
}
case StyleEditField::OPACITY:
{
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
initial = std::to_wstring(static_cast<int>(style->opacity * 100.0f + 0.5f));
break;
}
case StyleEditField::SIGNAL_NAME:
{
// `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。
if (row < 0)
{
const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr;
if (signal == nullptr)
{
return false;
}
initial = signal->name;
break;
}
if (row >= static_cast<int>(m_rows.size()))
{
return false;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex});
if (signal == nullptr)
{
return false;
}
initial = signal->name;
break;
}
case StyleEditField::GROUP_TITLE:
{
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return false;
}
const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast<std::size_t>(row)].groupIndex);
if (group == nullptr)
{
return false;
}
initial = group->name;
break;
}
default:
return false;
}
m_editField = field;
m_editRow = row;
if (field == StyleEditField::SIGNAL_NAME && row < 0)
{
// 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。
UpdateTargetRowGeometry();
}
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
const bool nameField =
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE);
// 不限长度:名称与数值都由各自的提交逻辑处理超长内容。
m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u);
return true;
}
bool SignalPanel::EndEdit(bool commit)
{
if (m_editField == StyleEditField::NONE)
{
return false;
}
const StyleEditField field = m_editField;
const int row = m_editRow;
const std::wstring text = m_edit.Text();
m_editField = StyleEditField::NONE;
m_editRow = -1;
m_edit.End();
if (!commit || m_tab == nullptr || m_dataSet == nullptr)
{
return true;
}
const std::wstring trimmed = Trim(text);
switch (field)
{
case StyleEditField::WIDTH:
{
if (trimmed.empty())
{
break;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || parsed != parsed)
{
break;
}
if (SignalStyle* style = StyleOf(SelectedRef()))
{
const float value = ClampFloat(static_cast<float>(parsed), kWidthMin, kWidthMax);
style->lineWidth = std::round(value * 10.0f) / 10.0f;
}
break;
}
case StyleEditField::OPACITY:
{
if (trimmed.empty())
{
break;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || parsed != parsed)
{
break;
}
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->opacity = ClampFloat(static_cast<float>(parsed) / 100.0f, 0.0f, 1.0f);
}
break;
}
case StyleEditField::SIGNAL_NAME:
{
if (m_dataSetWritable == nullptr)
{
break;
}
if (row < 0)
{
// 样式区「目标」行:重命名当前选中的(目标)信号。
const SignalRef& target = SelectedRef();
if (target.IsValid())
{
m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed);
}
// 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。
m_targetGeomValid = false;
break;
}
if (row >= static_cast<int>(m_rows.size()))
{
break;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed);
break;
}
case StyleEditField::GROUP_TITLE:
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr)
{
break;
}
m_dataSetWritable->RenameGroup(m_rows[static_cast<std::size_t>(row)].groupIndex, trimmed);
break;
}
default:
break;
}
return true;
}
bool SignalPanel::OnEditChar(wchar_t character)
{
// 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。
if (m_filterEditing)
{
switch (character)
{
case 0x03: // Ctrl+C
m_filterEdit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
m_filterEdit.CutToClipboard(m_host);
ApplyFilterText(m_filterEdit.Text());
return true;
case 0x16: // Ctrl+V
m_filterEdit.PasteFromClipboard(m_host);
ApplyFilterText(m_filterEdit.Text());
return true;
case 0x01: // Ctrl+A
m_filterEdit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndFilterEdit();
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。
if (character < 0x20 || character == 0x7F)
{
return false;
}
if (!m_filterEdit.Insert(character))
{
return false;
}
ApplyFilterText(m_filterEdit.Text());
return true;
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
// 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。
switch (character)
{
case 0x03: // Ctrl+C
m_edit.CopyToClipboard(m_host);
return false;
case 0x18: // Ctrl+X
return m_edit.CutToClipboard(m_host);
case 0x16: // Ctrl+V
return m_edit.PasteFromClipboard(m_host);
case 0x01: // Ctrl+A
m_edit.SelectAll();
return true;
default:
break;
}
if (character == L'\r' || character == L'\n')
{
return EndEdit(true);
}
// 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符,
// 否则同一次按键会被处理两次(一次删除两个字符)。
if (character < 0x20 || character == 0x7F)
{
return false;
}
// 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。
if (m_editField == StyleEditField::OPACITY && character == L'%')
{
return false;
}
return m_edit.Insert(character);
}
bool SignalPanel::OnEditKey(WPARAM key)
{
// 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。
if (m_filterEditing)
{
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
return EndFilterEdit();
case VK_ESCAPE:
{
const bool changed = m_filterEdit.Text() != m_filterText;
EndFilterEdit();
return changed;
}
case VK_BACK:
{
if (!m_filterEdit.Backspace())
{
return false;
}
ApplyFilterText(m_filterEdit.Text());
return true;
}
case VK_DELETE:
{
if (!m_filterEdit.DeleteForward())
{
return false;
}
ApplyFilterText(m_filterEdit.Text());
return true;
}
case VK_LEFT:
return m_filterEdit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_filterEdit.MoveCaret(1, shift);
case VK_HOME:
return m_filterEdit.MoveHome(shift);
case VK_END:
return m_filterEdit.MoveEnd(shift);
case 'A':
if (control)
{
m_filterEdit.SelectAll();
return true;
}
break;
default:
break;
}
return true;
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
switch (key)
{
case VK_RETURN:
return EndEdit(true);
case VK_ESCAPE:
return EndEdit(false);
case VK_BACK:
return m_edit.Backspace();
case VK_DELETE:
return m_edit.DeleteForward();
case VK_LEFT:
return m_edit.MoveCaret(-1, shift);
case VK_RIGHT:
return m_edit.MoveCaret(1, shift);
case VK_HOME:
return m_edit.MoveHome(shift);
case VK_END:
return m_edit.MoveEnd(shift);
case 'A':
if (control)
{
m_edit.SelectAll();
return true;
}
break;
default:
break;
}
return true;
}
bool SignalPanel::CollapseEditSelectionToEnd()
{
if (m_filterEditing)
{
return m_filterEdit.DeselectToEnd();
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
return m_edit.DeselectToEnd();
}
bool SignalPanel::OnEditMouseDown(int x, int y)
{
if (m_filterEditing)
{
// 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理,
// 否则点击会被当成"把光标移到此处"而清空不了内容。
// 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
{
m_pendingFilterClear = true;
return true;
}
return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y);
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
// ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 3px 的行内边距)
// 都必须当作“文本操作”**,不能穿透成行点击。否则:容器行高 > 输入框高度,
// 鼠标起点只要落在内边距上就会走 `OnLButtonDown` 的分组分支 ⇒ **分组被展开/收起**
//(用户 2026-10-19 报告:“框选编辑文本时组会展开、收起”)。
// 横向仍限定在名称区域内:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。
const gd::RectF field = EditFieldRect();
if (field.Width > 0.0f)
{
const gd::RectF absorb = NameEditAbsorbRect(field);
if (UiPointInRect(absorb, static_cast<float>(x), static_cast<float>(y)) &&
!UiPointInRect(field, static_cast<float>(x), static_cast<float>(y)))
{
// 把落点夹回输入框,保证插入符换算与绘制使用同一套几何
//(`UiEditFieldMouseDown` 自己会再算一次文本起点)。
const int clampedX = static_cast<int>(std::lround(
std::max(field.X + 1.0f, std::min(static_cast<float>(x), field.X + field.Width - 2.0f))));
const int clampedY = static_cast<int>(std::lround(
std::max(field.Y + 1.0f, std::min(static_cast<float>(y), field.Y + field.Height - 2.0f))));
return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, clampedX, clampedY);
}
}
return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, x, y);
}
bool SignalPanel::OnEditMouseMove(int x)
{
if (m_filterEditing)
{
return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x);
}
if (m_editField == StyleEditField::NONE)
{
return false;
}
return UiEditFieldMouseMove(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x);
}
bool SignalPanel::OnEditMouseUp(int x, int y)
{
if (m_filterEditing)
{
// 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位),
// 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算:
// 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。
if (m_pendingFilterClear)
{
m_pendingFilterClear = false;
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
UiPointInRect(clearRect, static_cast<float>(x), static_cast<float>(y)))
{
ClearFilter();
return true;
}
}
if (!m_filterEdit.Dragging())
{
return false;
}
m_filterEdit.EndDrag();
return true;
}
if (!m_edit.Dragging())
{
return false;
}
m_edit.EndDrag();
return true;
}
void SignalPanel::SelectSignal(const SignalRef& ref)
{
if (m_dataSetWritable != nullptr)
{
m_dataSetWritable->SetSelected(ref);
}
}
bool SignalPanel::ApplyColor(std::uint32_t color)
{
// 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。
if (m_dataSetWritable == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。
const bool changed = (style->color != color) || (style->customColor != color);
style->color = color;
style->customColor = color;
return changed;
}
bool SignalPanel::ApplyOpacityFromX(float x)
{
// 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。
if (m_dataSetWritable == nullptr)
{
return false;
}
SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
const float value = static_cast<float>(UiSliderRatioFromX(m_opacityTrackRect, x));
if (std::fabs(value - style->opacity) < 0.005f)
{
return false;
}
style->opacity = value;
return true;
}
} // namespace jdi
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,585 @@
// SignalPanel 实现分片 —— 弹出菜单(系数/偏移、镜像、信号/分组/网格右键菜单、命令回执)。
//
// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。
// ⚠️ 菜单是"打开即模态"的浮层:`CloseTransientUi` 必须在点击别处时收起它,
// 否则菜单会一直挂在那里吃掉后续点击。
#include "SignalPanelInternal.hpp"
namespace jdi
{
bool SignalPanel::OpenGainMenu()
{
if (m_menu == nullptr || m_tab == nullptr)
{
return false;
}
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 二级面板:两张“标题(带还原图标)+ 输入框”的组合:
// 1. 「系数调整」:一整行系数输入框;
// 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
//(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。)
std::vector<MenuItem> items;
MenuItem gainHeader;
gainHeader.kind = MenuItemKind::HEADER_ACTION;
gainHeader.label = L"系数调整";
gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1
items.push_back(gainHeader);
// 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。
const std::wstring gainText = FormatGainText(style->gain);
const std::wstring gainEditText = FormatNumberText(static_cast<double>(style->gain));
MenuItem gainEdit;
gainEdit.kind = MenuItemKind::EDIT;
gainEdit.label.clear(); // 不显示说明文字:输入框独占整行
gainEdit.id = CMD_GAIN_EDIT;
gainEdit.value = gainText;
gainEdit.editValue = gainEditText;
gainEdit.filter = EditFilter::NUMBER;
items.push_back(gainEdit);
MenuItem offsetHeader;
offsetHeader.kind = MenuItemKind::HEADER_ACTION;
offsetHeader.label = L"偏移调整";
offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值
items.push_back(offsetHeader);
// 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。
MenuItem offsetY;
offsetY.kind = MenuItemKind::EDIT;
offsetY.label = L"Y轴";
offsetY.id = CMD_OFFSET_Y;
offsetY.value = FormatSignedNumber(static_cast<double>(style->offsetY));
offsetY.editValue = FormatNumberText(static_cast<double>(style->offsetY));
offsetY.filter = EditFilter::NUMBER;
items.push_back(offsetY);
// 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。
// 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求);
// 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。
MenuItem offsetTime;
offsetTime.kind = MenuItemKind::EDIT;
offsetTime.label = L"时间轴";
offsetTime.id = CMD_OFFSET_TIME;
offsetTime.value = FormatOffsetTimeText(style->offsetTime);
offsetTime.editValue = FormatNumberText(style->offsetTime);
offsetTime.editSuffix = L"s";
offsetTime.filter = EditFilter::NUMBER;
items.push_back(offsetTime);
// 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角,
// 并再往上让出 `kAdjustMenuGap`(用户明确要求)。
//(旧实现在按钮下沿向右下展开,面板会盖住下方内容。)
const gd::RectF& button = m_adjustRects[kAdjustGain];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
const int anchorY =
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
true);
// ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE →
// 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。
ClearStyleHover();
return true;
}
bool SignalPanel::OpenMirrorMenu()
{
if (m_menu == nullptr || m_tab == nullptr)
{
return false;
}
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。
std::vector<MenuItem> items;
MenuItem horizontal;
horizontal.kind = MenuItemKind::NORMAL;
horizontal.label = L"水平镜像";
horizontal.id = CMD_MIRROR_HORIZONTAL;
horizontal.checked = style->mirrorHorizontal;
horizontal.keepOpen = true;
items.push_back(horizontal);
MenuItem vertical;
vertical.kind = MenuItemKind::NORMAL;
vertical.label = L"垂直镜像";
vertical.id = CMD_MIRROR_VERTICAL;
vertical.checked = style->mirrorVertical;
vertical.keepOpen = true;
items.push_back(vertical);
const gd::RectF& button = m_adjustRects[kAdjustMirror];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
// 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。
const int anchorY =
static_cast<int>(std::lround(button.Y - kAdjustMenuGap * m_scale));
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X), anchorY, items,
true);
ClearStyleHover();
return true;
}
void SignalPanel::ClearStyleHover()
{
if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0)
{
return;
}
m_hoverStyle = StyleHit::NONE;
m_hoverStyleIndex = -1;
}
bool SignalPanel::CloseTransientUi()
{
bool changed = false;
if (m_editField != StyleEditField::NONE)
{
changed = EndEdit(true) || changed;
}
// 点击面板之外也要结束「筛选信号」的编辑状态。
if (m_filterEditing)
{
changed = EndFilterEdit() || changed;
}
if (m_colorPicker != nullptr && m_colorPicker->IsOpen())
{
m_colorPicker->Close();
changed = true;
}
// 面板外点击:取消尚未抬起确认的关闭操作。
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingTargetLaneMenu = false;
return changed;
}
void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const SignalStyle* style = StyleOf(ref);
const SignalData* signal = m_dataSet->Resolve(ref);
if (style == nullptr || signal == nullptr)
{
return;
}
m_menuTargetSignal = ref;
m_menuTargetGroup = -1;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = signal->name;
items.push_back(header);
MenuItem toggle;
toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号";
toggle.id = CMD_SIGNAL_TOGGLE;
items.push_back(toggle);
MenuItem solo;
solo.label = L"仅显示该信号";
solo.id = CMD_SIGNAL_SOLO;
items.push_back(solo);
MenuItem showAll;
showAll.label = L"显示全部信号";
showAll.id = CMD_SIGNAL_SHOW_ALL;
items.push_back(showAll);
MenuItem hideAll;
hideAll.label = L"隐藏全部信号";
hideAll.id = CMD_SIGNAL_HIDE_ALL;
items.push_back(hideAll);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
MenuItem colors;
colors.kind = MenuItemKind::SWATCHES;
colors.id = CMD_COLOR_BASE;
colors.swatchCount = std::min(paletteCount, 16);
colors.checkedSwatch = -1;
for (int index = 0; index < colors.swatchCount; ++index)
{
colors.swatches[index] = palette[index];
if (style->color == palette[index])
{
colors.checkedSwatch = index;
}
}
items.push_back(colors);
MenuItem modeHeader;
modeHeader.kind = MenuItemKind::HEADER;
modeHeader.label = L"样式";
items.push_back(modeHeader);
for (int index = 0; index < kModeOptionCount; ++index)
{
MenuItem mode;
mode.label = DrawModeName(kModeOptions[index]);
mode.id = CMD_MODE_BASE + index;
mode.checked = (style->mode == kModeOptions[index]);
items.push_back(mode);
}
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
}
void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return;
}
m_menuTargetGroup = groupIndex;
m_menuTargetSignal = SignalRef();
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = group->name;
items.push_back(header);
MenuItem showAll;
showAll.label = L"选中该分组全部信号";
showAll.id = CMD_GROUP_SHOW_ALL;
items.push_back(showAll);
MenuItem hideAll;
hideAll.label = L"取消该分组全部信号";
hideAll.id = CMD_GROUP_HIDE_ALL;
items.push_back(hideAll);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
MenuItem remove;
remove.label = L"关闭该分组(从工作区移除)";
remove.id = CMD_GROUP_REMOVE;
items.push_back(remove);
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
}
void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y)
{
if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
const SignalData* signal = m_dataSet->Resolve(ref);
if (signal == nullptr)
{
return;
}
m_menuTargetSignal = ref;
m_menuTargetGroup = -1;
std::vector<MenuItem> items;
MenuItem header;
header.kind = MenuItemKind::HEADER;
header.label = L"所属网格";
items.push_back(header);
const std::vector<Lane>& lanes = m_tab->Lanes();
const int currentLane = m_tab->LaneOfSignal(ref);
for (std::size_t index = 0; index < lanes.size(); ++index)
{
MenuItem lane;
lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" +
std::to_wstring(lanes[index].signals.size()) + L" 条信号)";
lane.id = CMD_LANE_BASE + static_cast<int>(index);
lane.checked = (currentLane == static_cast<int>(index));
items.push_back(lane);
}
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
MenuItem create;
create.label = L"新建网格";
create.id = CMD_LANE_NEW;
items.push_back(create);
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
// ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格):
// 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。
// 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求):
// 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的
// 「新建网格」**始终固定不动**。
MenuLayoutConstraint constraint;
constraint.maxScrollRows = kLaneMenuVisibleRows;
// 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。
constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f);
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint);
}
bool SignalPanel::OnMenuCommand(const MenuResult& result)
{
if (m_tab == nullptr || m_dataSet == nullptr || !result.valid)
{
return false;
}
// 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开
//(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。
if (result.editCommit)
{
SignalStyle* style = StyleOf(SelectedRef());
if (style != nullptr)
{
const std::wstring trimmed = Trim(result.editText);
wchar_t* stop = nullptr;
const double parsed =
trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop);
// 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。
const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed);
switch (result.id)
{
case CMD_GAIN_EDIT:
{
if (valid)
{
style->gain =
static_cast<float>(std::max(-kGainLimit, std::min(parsed, kGainLimit)));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
FormatNumberText(static_cast<double>(style->gain)));
}
return false;
}
case CMD_OFFSET_Y:
{
if (valid)
{
style->offsetY = static_cast<float>(
std::max(-static_cast<double>(kGainLimit), std::min(parsed, kGainLimit)));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
FormatNumberText(static_cast<double>(style->offsetY)));
}
return false;
}
case CMD_OFFSET_TIME:
{
if (valid)
{
style->offsetTime =
std::max(-kGainLimit, std::min(parsed, kGainLimit));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_TIME,
FormatOffsetTimeText(style->offsetTime),
FormatNumberText(style->offsetTime));
}
return false;
}
default:
break;
}
}
else
{
return false;
}
}
// 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。
if (result.id == CMD_GAIN_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->gain = 1.0f;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
FormatNumberText(static_cast<double>(style->gain)));
}
}
return false;
}
// 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。
if (result.id == CMD_OFFSET_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->offsetY = 0.0f;
style->offsetTime = 0.0;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0),
FormatNumberText(0.0));
m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0),
FormatNumberText(0.0));
}
}
return false;
}
// 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。
if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL);
bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical;
flag = !flag;
if (!horizontal)
{
// ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
// 使绘制等价于「先加上偏移再镜像」(用户明确要求)。
style->offsetY = -style->offsetY;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
FormatNumberText(static_cast<double>(style->offsetY)));
}
}
if (m_menu != nullptr)
{
m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true);
}
}
return false;
}
bool needRelayout = false;
switch (result.id)
{
case CMD_SIGNAL_TOGGLE:
if (m_menuTargetSignal.IsValid())
{
m_tab->ToggleVisible(m_menuTargetSignal);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_SIGNAL_SOLO:
if (m_menuTargetSignal.IsValid())
{
m_tab->SoloSignal(m_menuTargetSignal);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_SIGNAL_SHOW_ALL:
m_tab->SetAllVisible(true);
m_rowsDirty = true;
needRelayout = true;
break;
case CMD_SIGNAL_HIDE_ALL:
m_tab->SetAllVisible(false);
m_rowsDirty = true;
needRelayout = true;
break;
case CMD_GROUP_SHOW_ALL:
if (m_menuTargetGroup >= 0)
{
m_tab->SetGroupVisible(m_menuTargetGroup, true);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_GROUP_HIDE_ALL:
if (m_menuTargetGroup >= 0)
{
m_tab->SetGroupVisible(m_menuTargetGroup, false);
m_rowsDirty = true;
needRelayout = true;
}
break;
case CMD_GROUP_REMOVE:
if (m_menuTargetGroup >= 0 && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 1, static_cast<WPARAM>(m_menuTargetGroup), 0);
}
break;
case CMD_LANE_NEW:
if (m_menuTargetSignal.IsValid())
{
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1);
m_rowsDirty = true;
needRelayout = true;
}
break;
default:
if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
{
if (m_menuTargetSignal.IsValid())
{
m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE);
m_rowsDirty = true;
needRelayout = true;
}
}
else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0)
{
if (SignalStyle* style = StyleOf(m_menuTargetSignal))
{
int paletteCount = 0;
const std::uint32_t* palette = SignalPalette(paletteCount);
style->color = palette[result.swatchIndex % std::max(1, paletteCount)];
}
}
else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount)
{
if (SignalStyle* style = StyleOf(m_menuTargetSignal))
{
style->mode = kModeOptions[result.id - CMD_MODE_BASE];
}
}
break;
}
return needRelayout;
}
} // namespace jdi
@@ -0,0 +1,226 @@
// SignalPanel 实现分片 —— 动画与滚动(分组折起、联动滚动、"滚动到目标信号")。
//
// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。
// ⚠️ 折起动画是"抽拉卷尺"式的刚性平移:只改 `m_rowShift`,**行高永不变**,
// 动画期间不得重建行集合(见 agent_mem.md 的硬约束)。
#include "SignalPanelInternal.hpp"
namespace jdi
{
bool SignalPanel::AdvanceGroupAnimations()
{
if (m_groupAnims.empty())
{
return false;
}
const std::uint64_t now = GetTickCount64();
bool animationFinished = false;
for (std::size_t index = 0; index < m_groupAnims.size();)
{
const GroupAnim& anim = m_groupAnims[index];
const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0;
if (anim.durationMs == 0u || elapsed >= anim.durationMs)
{
// 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。
if (anim.scrollSynced)
{
m_scrollOffset = anim.scrollTo;
}
animationFinished = true;
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
// ⚠️ **动画结束(折起与展开都要)后必须重建行集合**:
// * 折起:该组的信号行要真正从行集合里移除;
// * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”,
// 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会
// 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。
// ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 ——
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
if (animationFinished)
{
m_rowsDirty = true;
BuildRows(); // 内部会重算内容高度与逐行位移
}
else
{
UpdateAnimGeometry();
}
// 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间
// 列表宽度/滚动条与内容不匹配)。
// ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进
// `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则
// 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条,
// 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。
RefreshScrollBar(animationFinished);
return !m_groupAnims.empty();
}
void SignalPanel::ToggleGroupFromRow(int groupIndex, int row)
{
EnsureRows();
if (m_tab == nullptr || groupIndex < 0)
{
return;
}
const int headerRow =
(row >= 0 && row < static_cast<int>(m_rows.size()) &&
m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP &&
m_rows[static_cast<std::size_t>(row)].groupIndex == groupIndex)
? row
: GroupRowIndex(groupIndex);
const bool collapse = !IsGroupCollapsed(groupIndex);
SetGroupCollapsed(groupIndex, collapse);
// 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**,
// 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。
//(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。)
float scrollFrom = m_scrollOffset;
float scrollTo = m_scrollOffset;
bool syncScroll = false;
if (collapse && headerRow >= 0)
{
const float alignOffset = ContentOffsetBeforeRow(headerRow);
if (m_scrollOffset > alignOffset + 0.5f)
{
// 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短,
// 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。
const float finalContent =
std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex));
const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height);
scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset));
syncScroll = true;
}
}
StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll);
}
void SignalPanel::StartScrollAnimationTo(float desired)
{
const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height);
desired = std::max(0.0f, std::min(desired, maxOffset));
const float distance = std::fabs(desired - m_scrollOffset);
if (distance < 1.0f)
{
// 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。
m_scrollOffset = desired;
ClampScroll();
m_scrollAnimActive = false;
return;
}
// 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长,
// 因此"整个动画过程不会太久"(最长 300ms)。
const float durationMs =
std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel));
m_scrollAnimFrom = m_scrollOffset;
m_scrollAnimTo = desired;
m_scrollAnimStartMs = GetTickCount64();
m_scrollAnimDurationMs = static_cast<std::uint32_t>(durationMs + 0.5f);
m_scrollAnimActive = true;
}
void SignalPanel::ScrollToSignal(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到
// 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后
// 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。
// ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (SignalScrollTarget(ref, desired))
{
m_scrollOffset = desired;
ClampScroll();
}
m_scrollAnimActive = false;
}
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
{
EnsureRows();
if (!ref.IsValid())
{
return;
}
// ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。
// 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。
// 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。
ExpandGroupForSignal(ref);
float desired = m_scrollOffset;
if (!SignalScrollTarget(ref, desired))
{
return;
}
// 把列表平滑滚动到目标信号完整可见。
StartScrollAnimationTo(desired);
}
bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref)
{
if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex))
{
return false;
}
SetGroupCollapsed(ref.groupIndex, false);
// 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。
const int anim = FindGroupAnimation(ref.groupIndex);
if (anim >= 0)
{
m_groupAnims.erase(m_groupAnims.begin() + anim);
}
// 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。
m_rowsDirty = true;
EnsureRows();
return true;
}
bool SignalPanel::AdvanceScrollAnimation()
{
if (!m_scrollAnimActive)
{
return false;
}
const std::uint64_t now = GetTickCount64();
const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0;
float progress = (m_scrollAnimDurationMs > 0u)
? static_cast<float>(elapsed) / static_cast<float>(m_scrollAnimDurationMs)
: 1.0f;
if (progress > 1.0f)
{
progress = 1.0f;
}
// ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。
const float eased = 1.0f - std::pow(1.0f - progress, 3.0f);
m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased;
if (progress >= 1.0f)
{
m_scrollOffset = m_scrollAnimTo;
m_scrollAnimActive = false;
ClampScroll();
return false;
}
return true;
}
} // namespace jdi