Files
Just-DataInspector/Components/SignalPanel/Src/SignalPanelEdit.cpp
T
DeepSeek V4.1 Flash 0ec8055e95 重构(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 同步变更日志与「第七轮」硬约束。
2026-10-28 14:35:32 +08:00

656 lines
18 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 的成员函数定义(从 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