修复浮动面板双击穿透、网格高亮触发来源、新增样式区「目标」行与联动平滑滚动
用户 2026-10-15 第六轮报告的四个问题与要求(详见 Documents/agent_mem.md §9.34):
1. 在网格内的实验性功能浮动面板上双击任意位置会穿透到下层网格,导致网格触发自适应。
根因:PlotView::OnDoubleClick 在“差值标签 / 光标标签 / 光标线 / 图例”都不命中时
无条件调用 FitAll()。
修法:在该函数最前面(高于一切下层命中)用 TopPanelAtPoint() 判定,命中浮动面板
则直接消费(面板自身双击无额外语义)。确立“上层元素的交互不得影响下层”的规则。
2. 未开启任何工具栏功能时鼠标移到网格上会让整个网格变色高亮(旧需求已不适用)。
修法:取消“鼠标在网格里移动就高亮”的行为;改为在信号选择区域悬停某条信号时,
若该信号已被勾选绘制到某个网格,则该网格变色高亮,并且该信号曲线本身也高亮;
鼠标移开即恢复。悬停未勾选的信号不产生任何高亮。
实现:PlotView 新增 m_externalHoverSignal + SetExternalHoverSignal()(与 m_hoverSignal
分开保存,因为绘图区 OnMouseLeave 会清掉后者,而列表悬停时鼠标不在绘图区内)、
EffectiveHoverLane();SignalPanel 新增 HoveredSignal(),并在 InvalidateRows()
里一并作废;Application::Render() 每帧同步(覆盖 WM_MOUSELEAVE / 数据变化 /
切换标签页等全部路径)。GX 徽标的悬停高亮仍按鼠标位置独立判定。
3. 在信号样式自定义区域最上方(预设颜色色块之上)新增「目标」行。
内容:当前正在被编辑的目标信号名称;若它已绘制到网格,则在名称后方显示所在 GX 标签。
交互:与信号选择区域的信号行一致 —— 名称/GX 标签悬停有高亮提示,且悬停时同样触发
该信号所在网格高亮 + 曲线高亮;单击不进入编辑(只触发联动滚动),双击才进入改名
(复用 StyleEditField::SIGNAL_NAME,m_editRow < 0 表示目标是选中信号)。
实现:SignalPanel 新增 m_targetRowRect / PaintTargetRow / UpdateTargetRowGeometry
(名称宽度与“是否有 GX 标签”都可能在不重排时变化,因此按需重算并带缓存键)、
StyleHit::TARGET_NAME / TARGET_BADGE;DesiredStyleHeight() 与 LayoutStyleControls()
相应多算一行。
4. 联动机制更新:从文件导入大量信号(如 MF4 的 124 条)时,被选中的信号可能被滚动到
可见范围之外而找不到。
修法:新增 SignalPanel::ScrollToSignalAnimated()(+ AdvanceScrollAnimation),
触发联动时把信号列表平滑滚动到目标信号完整可见:
* 目标在视界上方 ⇒ 向上滚动,且顶部让出一个组行高度,避免被悬浮组名遮挡;
* 目标在视界下方 ⇒ 向下滚动到完整露出;
* 已经完整可见则不动;
* ease-out cubic(越远越快、靠近变慢),时长 = clamp(距离×0.35ms, 130, 300),
总时长有上限;用户滚轮 / 拖滚动条立即取消动画。
触发点:绘图区内把某条信号选为当前目标(点击图例、点击信号曲线、双击图例改名)
⇒ PlotView::SelectSignal() 发布请求(TakePanelScrollRequest()),宿主在鼠标消息
处理后调用 ApplyPendingPanelScroll();样式自定义区的任何交互(预设色块、自定义
颜色入口、样式按钮、线宽/透明度滑块与数值框、「系数/偏移」「镜像」二级菜单、
绝对值)以及「目标」行的单击也都会触发同一次滚动。
宿主:Application 新增 16ms 定时器(kScrollTimerId)驱动动画,并由 Render 自管开关
(与提示浮层淡出同一套做法)。
验证(全部离屏,不占用鼠标 / 键盘 / 屏幕):
- .preflight/check(SW_HIDE + 只注入合成消息 + 进程/线程 BELOW_NORMAL)**73/73 通过**,
连跑 3 次稳定:[A] 浮动面板双击不移视界,且对照组(空白处双击)必须触发自适应;
[B] 鼠标在网格里移动不高亮;[C] 列表悬停 → 网格高亮 + 移开恢复 + 未勾选信号不高亮;
[D] 目标行的布局/占位/GX 标签位置与显隐/悬停联动/单击不编辑 + 双击改名(初始文本正确);
[E] 长列表 122 行:动画分多帧、单调、时长上限 300ms、目标完整可见且不被悬浮组名遮挡、
已可见时不动、滚轮取消动画;[F] 样式区交互与绘图区联动都会滚动到目标并完整显示;
[G] 布局回归(目标行在列表下方、样式区仍完整、默认无悬停与高亮)。
- A/B 判别:回退六处实现后 **15 项如实失败**(含"面板双击仍触发自适应"、"鼠标移到网格即高亮"、
"目标行不联动"、"联动瞬移而非动画")。
- make release:全部源文件重新编译 **零警告**;链接用临时 `make release TARGET_FILE_NAME=Target_check` 通过。
同步记忆:Documents/agent_mem.md 新增 §9.34、§11.2 第 72–75 条硬约束、变更日志新行。
This commit is contained in:
1 parent
73389f424c
commit
cb9d3e4b8f
7 files changed
+848
-32
No files matched your search
@@ -254,6 +254,17 @@ namespace jdi
|
||||
*/
|
||||
bool ApplyPendingDerivedBuild();
|
||||
|
||||
/**
|
||||
* @brief 应用绘图区提交的「把信号列表滚动到目标信号」的联动请求。
|
||||
*
|
||||
* 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为
|
||||
* 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它
|
||||
*(信号很多时否则找不见选中的那一条)。
|
||||
*
|
||||
* @return 存在并应用了请求返回 true。
|
||||
*/
|
||||
bool ApplyPendingPanelScroll();
|
||||
|
||||
/**
|
||||
* @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。
|
||||
*/
|
||||
@@ -400,6 +411,8 @@ namespace jdi
|
||||
Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */
|
||||
bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */
|
||||
bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */
|
||||
/** 信号列表「滚动到目标信号」动画的定时器是否已启动(见 `SignalPanel::AdvanceScrollAnimation`)。 */
|
||||
bool m_scrollTimerActive = false;
|
||||
std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */
|
||||
int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */
|
||||
int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */
|
||||
|
||||
@@ -49,6 +49,15 @@ namespace jdi
|
||||
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
|
||||
const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
|
||||
const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
|
||||
/**
|
||||
* @brief 信号列表「滚动到目标信号」动画的定时器 ID。
|
||||
*
|
||||
* 用户 2026-10-15 要求:联动时列表要**平滑滚动**(不是立刻翻到目标位置),
|
||||
* 且"整个动画过程不要太久"。动画本身由 `SignalPanel::AdvanceScrollAnimation()`
|
||||
* 按经过时间推进,这里只负责按帧驱动它(约 60fps)。
|
||||
*/
|
||||
const UINT_PTR kScrollTimerId = 3;
|
||||
const UINT kScrollTimerPeriodMs = 16;
|
||||
|
||||
/**
|
||||
* @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。
|
||||
@@ -889,6 +898,12 @@ namespace jdi
|
||||
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
|
||||
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
|
||||
|
||||
// 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。
|
||||
// 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。
|
||||
// ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径:
|
||||
// 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。
|
||||
m_plot.SetExternalHoverSignal(m_panel.HoveredSignal());
|
||||
|
||||
if (chromeDirty)
|
||||
{
|
||||
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
|
||||
@@ -943,6 +958,21 @@ namespace jdi
|
||||
KillTimer(m_hwnd, kHintTimerId);
|
||||
m_hintTimerActive = false;
|
||||
}
|
||||
|
||||
// 信号列表的目标信号滚动动画:仅在动画进行中保持定时重绘。
|
||||
if (m_panel.ScrollAnimationActive())
|
||||
{
|
||||
if (!m_scrollTimerActive)
|
||||
{
|
||||
SetTimer(m_hwnd, kScrollTimerId, kScrollTimerPeriodMs, nullptr);
|
||||
m_scrollTimerActive = true;
|
||||
}
|
||||
}
|
||||
else if (m_scrollTimerActive)
|
||||
{
|
||||
KillTimer(m_hwnd, kScrollTimerId);
|
||||
m_scrollTimerActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
void Application::SetStatusMessage(const std::wstring& text, bool isError)
|
||||
@@ -1777,6 +1807,18 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Application::ApplyPendingPanelScroll()
|
||||
{
|
||||
SignalRef ref;
|
||||
if (!m_plot.TakePanelScrollRequest(ref) || !ref.IsValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 平滑滚动(带动画):用户要求"是滚动的而不是立刻翻到目标信号位置"。
|
||||
m_panel.ScrollToSignalAnimated(ref);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Application::ApplyPendingDerivedBuild()
|
||||
{
|
||||
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
|
||||
@@ -2640,6 +2682,8 @@ namespace jdi
|
||||
ApplyPendingDerivedBuild();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
// 联动:在绘图区选中了某条信号 ⇒ 让信号列表平滑滚动到它并完整显示。
|
||||
ApplyPendingPanelScroll();
|
||||
// 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次,
|
||||
// 使"按下但尚未拖动"时能立刻看到十字/方向箭头提示。
|
||||
UpdateDragCursor(x, y);
|
||||
@@ -2791,6 +2835,8 @@ namespace jdi
|
||||
{
|
||||
InvalidateRect(hwnd, nullptr, TRUE);
|
||||
}
|
||||
// 联动:双击图例改名也会选中该信号 ⇒ 同步把列表滚动到它。
|
||||
ApplyPendingPanelScroll();
|
||||
return 0;
|
||||
}
|
||||
case WM_RBUTTONDOWN:
|
||||
@@ -3034,6 +3080,13 @@ namespace jdi
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
}
|
||||
// 信号列表「滚动到目标信号」动画:推进一步并重绘(Render 会在结束后停表)。
|
||||
if (wParam == kScrollTimerId)
|
||||
{
|
||||
m_panel.AdvanceScrollAnimation();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WM_DROPFILES:
|
||||
|
||||
@@ -880,6 +880,39 @@ namespace jdi
|
||||
*/
|
||||
bool TakePendingRename(SignalRef& ref, std::wstring& name);
|
||||
|
||||
/**
|
||||
* @brief 设置「外部悬停信号」(信号列表 / 样式区「目标」行悬停到某条信号时调用)。
|
||||
*
|
||||
* 用户 2026-10-15 要求:
|
||||
* * 鼠标**在网格里移动不再触发**网格变色高亮(旧行为已取消);
|
||||
* * 改为**在信号列表里悬停某条信号**时,若该信号已勾选绘制到某个网格,
|
||||
* 则该网格变色高亮,并且该信号曲线本身也高亮;鼠标移开即恢复。
|
||||
*
|
||||
* ⚠️ 与 `m_hoverSignal`(鼠标在绘图区内直接命中的信号)**分开保存**:绘图区的
|
||||
* `OnMouseLeave` 会清掉后者,而列表悬停时鼠标恰恰不在绘图区内。
|
||||
*
|
||||
* @param [IN] ref 悬停的信号;无效引用表示取消外部悬停。
|
||||
*/
|
||||
void SetExternalHoverSignal(const SignalRef& ref);
|
||||
|
||||
/**
|
||||
* @brief 当前外部悬停的信号(供验证 / 宿主查询)。
|
||||
* @return 悬停的信号引用,无则无效。
|
||||
*/
|
||||
const SignalRef& ExternalHoverSignal() const { return m_externalHoverSignal; }
|
||||
|
||||
/**
|
||||
* @brief 取出并清除「请把信号列表滚动到该信号」的联动请求。
|
||||
*
|
||||
* 用户 2026-10-15 要求:在绘图区触发联动(点击图例 / 点击信号曲线等把某条信号选为
|
||||
* 当前目标)时,信号选择区域要**平滑滚动**到该信号并完整显示它 ——
|
||||
* 信号很多时(如 MF4 的 124 条)否则找不见选中的那一条。
|
||||
*
|
||||
* @param [OUT] ref 需要滚动到的信号。
|
||||
* @return 存在待处理的请求返回 true。
|
||||
*/
|
||||
bool TakePanelScrollRequest(SignalRef& ref);
|
||||
|
||||
/**
|
||||
* @brief 处理编辑态下的字符输入。
|
||||
* @param [IN] character 输入字符。
|
||||
@@ -1974,6 +2007,20 @@ namespace jdi
|
||||
gd::RectF m_rubberRect; /**< 拖拽选区矩形。 */
|
||||
|
||||
SignalRef m_hoverSignal; /**< 鼠标悬停命中的信号(曲线高亮)。 */
|
||||
/**
|
||||
* @brief 「外部悬停信号」:信号列表 / 样式区「目标」行悬停到某条信号时的联动高亮源。
|
||||
*
|
||||
* 与 `m_hoverSignal` 分开(绘图区的 `OnMouseLeave` 会清掉后者,而列表悬停时
|
||||
* 鼠标并不在绘图区内);二者的高亮效果相同:网格变色 + 曲线光晕。
|
||||
*/
|
||||
SignalRef m_externalHoverSignal;
|
||||
/**
|
||||
* @brief 待宿主处理的「把信号列表滚动到该信号」请求(见 `TakePanelScrollRequest`)。
|
||||
*
|
||||
* 绘图区自身无法访问信号面板,因此把请求记在这里,由宿主在鼠标消息处理后取走。
|
||||
*/
|
||||
SignalRef m_pendingPanelScroll;
|
||||
bool m_pendingPanelScrollFlag = false;
|
||||
SignalRef m_hoverLegend; /**< 鼠标悬停命中的图例条目(半高亮提示,不触发曲线拖拽提示)。 */
|
||||
SignalRef m_dragSignal; /**< 正在被拖拽的信号。 */
|
||||
int m_dragTargetLane = -1; /**< 拖拽信号时的目标网格下标。 */
|
||||
@@ -2035,6 +2082,14 @@ namespace jdi
|
||||
EdgePanelKind m_pendingPanelCloseKind = EdgePanelKind::COUNT; /**< 待关闭面板的功能类型。 */
|
||||
|
||||
int m_hoverLane = -1; /**< 当前悬停网格下标。 */
|
||||
/**
|
||||
* @brief 取本次绘制应该高亮的网格下标(-1 表示无)。
|
||||
*
|
||||
* 来源两处:① 鼠标悬停在实验性功能浮动面板上(该面板所属网格);
|
||||
* ② 外部悬停信号(信号列表 / 样式区)且它已绘制到某个网格时,高亮该网格。
|
||||
* ⚠️ 鼠标在网格里移动**不再**触发高亮(用户 2026-10-15 要求取消旧行为)。
|
||||
*/
|
||||
int EffectiveHoverLane() const;
|
||||
/**
|
||||
* @brief 上一帧鼠标是否悬停在某个网格的“GX”编号徽标上(是则记录网格下标,否则 -1)。
|
||||
*
|
||||
|
||||
@@ -1061,6 +1061,48 @@ namespace jdi
|
||||
{
|
||||
m_dataSetWritable->SetSelected(ref);
|
||||
}
|
||||
// 联动(用户 2026-10-15 要求):绘图区内把某条信号选为当前目标时,
|
||||
// 请求宿主把信号列表**平滑滚动**到它,保证在长列表里也能看到它。
|
||||
if (ref.IsValid())
|
||||
{
|
||||
m_pendingPanelScroll = ref;
|
||||
m_pendingPanelScrollFlag = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool PlotView::TakePanelScrollRequest(SignalRef& ref)
|
||||
{
|
||||
if (!m_pendingPanelScrollFlag)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ref = m_pendingPanelScroll;
|
||||
m_pendingPanelScrollFlag = false;
|
||||
m_pendingPanelScroll = SignalRef();
|
||||
return ref.IsValid();
|
||||
}
|
||||
|
||||
void PlotView::SetExternalHoverSignal(const SignalRef& ref)
|
||||
{
|
||||
if (m_externalHoverSignal == ref)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_externalHoverSignal = ref;
|
||||
}
|
||||
|
||||
int PlotView::EffectiveHoverLane() const
|
||||
{
|
||||
if (m_hoverLane >= 0)
|
||||
{
|
||||
return m_hoverLane;
|
||||
}
|
||||
// 外部悬停信号(信号列表 / 样式区「目标」行):只在该信号确实绘制到某个网格时生效。
|
||||
if (m_tab != nullptr && m_externalHoverSignal.IsValid())
|
||||
{
|
||||
return m_tab->LaneOfSignal(m_externalHoverSignal);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void PlotView::ClampLaneScroll()
|
||||
@@ -6080,8 +6122,11 @@ namespace jdi
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。
|
||||
const int lane = overEdgePanel ? panelLane : LaneAtY(y);
|
||||
// 悬停网格:**只有**鼠标悬停在实验性功能浮动面板上时才高亮该面板所属网格。
|
||||
// ⚠️ 用户 2026-10-15 明确要求:鼠标在网格里移动**不再触发**网格变色高亮;
|
||||
// 改为在信号列表 / 样式区「目标」行悬停到某条已绘制信号时联动高亮
|
||||
// (见 `SetExternalHoverSignal` 与 `EffectiveHoverLane`)。
|
||||
const int lane = overEdgePanel ? panelLane : -1;
|
||||
if (lane != m_hoverLane)
|
||||
{
|
||||
m_hoverLane = lane;
|
||||
@@ -6089,16 +6134,20 @@ namespace jdi
|
||||
}
|
||||
|
||||
// 「GX」编号徽标的悬停高亮:徽标本体在静态场景 / 装饰缓存里被冻结,高亮是叠加层,
|
||||
// 因此必须靠这个**单独的状态**触发重绘与取消(鼠标在同一个网格的徽标范围内移动时
|
||||
// `m_hoverLane` 不变)。
|
||||
// 因此必须靠这个**单独的状态**触发重绘与取消。徽标命中的网格直接用鼠标纵坐标算,
|
||||
// 与上面的「悬停网格高亮」解耦(后者已不再跟随鼠标位置)。
|
||||
int hoverBadge = -1;
|
||||
if (m_dragMode == PlotDragMode::NONE && !overEdgePanel && lane >= 0)
|
||||
if (m_dragMode == PlotDragMode::NONE && !overEdgePanel)
|
||||
{
|
||||
const gd::RectF badge = LaneBadgeRect(lane);
|
||||
if (badge.Width > 0.0f &&
|
||||
UiPointInRect(badge, static_cast<float>(x), static_cast<float>(y)))
|
||||
const int underMouse = LaneAtY(y);
|
||||
if (underMouse >= 0)
|
||||
{
|
||||
hoverBadge = lane;
|
||||
const gd::RectF badge = LaneBadgeRect(underMouse);
|
||||
if (badge.Width > 0.0f &&
|
||||
UiPointInRect(badge, static_cast<float>(x), static_cast<float>(y)))
|
||||
{
|
||||
hoverBadge = underMouse;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hoverBadge != m_hoverBadge)
|
||||
@@ -6826,6 +6875,19 @@ namespace jdi
|
||||
return false;
|
||||
}
|
||||
|
||||
// ⚠️ **上层元素的交互绝不能穿透到下层**:实验性功能的浮动面板浮在网格之上(且绘制在
|
||||
// 光标标签 / 图例之上),因此在它上面双击必须由它消费掉。旧实现在所有下层命中都不成
|
||||
// 立时无条件 `FitAll()`,于是"在面板上双击任意位置"会让网格莫名其妙地自适应一次
|
||||
// (用户 2026-10-15 报告)。面板自身双击没有额外语义,直接消费即可。
|
||||
{
|
||||
int panelLane = -1;
|
||||
int panelKind = 0;
|
||||
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 双光标差值标签:双击进入差值编辑(输入值的量级始终为秒)。
|
||||
// 注意:差值标签在"两光标过近"时会落到主时间轴内部,因此必须先于区域判定。
|
||||
if (IsOnCursorSpanLabel(x, y))
|
||||
@@ -7835,9 +7897,12 @@ namespace jdi
|
||||
|
||||
// 悬停网格:一层很淡的强调色叠加。为了能画在静态场景之上,
|
||||
// 这里用半透明而非不透明混色,观感与原实现基本一致。
|
||||
if (m_hoverLane >= 0 && m_hoverLane < static_cast<int>(m_lanes.size()))
|
||||
// ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停),
|
||||
// **不再**由"鼠标在网格里"触发。
|
||||
const int hoverLane = EffectiveHoverLane();
|
||||
if (hoverLane >= 0 && hoverLane < static_cast<int>(m_lanes.size()))
|
||||
{
|
||||
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(m_hoverLane)].rect;
|
||||
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(hoverLane)].rect;
|
||||
const float clipTop = std::max(laneRect.Y, m_plotRect.Y);
|
||||
const float clipBottom = std::min(laneRect.Y + laneRect.Height, m_plotRect.Y + m_plotRect.Height);
|
||||
if (clipBottom > clipTop)
|
||||
@@ -7849,16 +7914,23 @@ namespace jdi
|
||||
}
|
||||
|
||||
// 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。
|
||||
if (m_hoverSignal.IsValid())
|
||||
// 鼠标在绘图区内直接命中时用 `m_hoverSignal`;信号列表 / 样式区悬停时用
|
||||
// `m_externalHoverSignal`(二者高亮方式相同,用户要求列表悬停也要高亮曲线本身)。
|
||||
SignalRef haloRef = m_hoverSignal;
|
||||
if (!haloRef.IsValid())
|
||||
{
|
||||
const ResolvedSignal resolved = ResolveSignal(m_hoverSignal);
|
||||
haloRef = m_externalHoverSignal;
|
||||
}
|
||||
if (haloRef.IsValid())
|
||||
{
|
||||
const ResolvedSignal resolved = ResolveSignal(haloRef);
|
||||
if (resolved.data != nullptr && resolved.style != nullptr && resolved.visible)
|
||||
{
|
||||
const int laneIndex = m_tab->LaneOfSignal(m_hoverSignal);
|
||||
const int laneIndex = m_tab->LaneOfSignal(haloRef);
|
||||
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
|
||||
{
|
||||
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
|
||||
const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane);
|
||||
const PolylineCacheEntry* cached = AcquirePolyline(haloRef, resolved, lane);
|
||||
if (cached != nullptr)
|
||||
{
|
||||
PaintSignalHaloFor(g, resolved, lane, *cached);
|
||||
|
||||
@@ -83,7 +83,15 @@ namespace jdi
|
||||
/**
|
||||
* @brief 标记列表结构失效(数据或折叠状态变化后调用)。
|
||||
*/
|
||||
void InvalidateRows() { m_rowsDirty = true; }
|
||||
void InvalidateRows()
|
||||
{
|
||||
m_rowsDirty = true;
|
||||
// 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废,
|
||||
// 否则可能对一条已经不存在(或已被替换)的信号继续高亮它的网格。
|
||||
m_hoveredSignal = SignalRef();
|
||||
m_hoverRow = -1;
|
||||
m_hoverPart = RowPart::NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 设置 DPI 缩放比例。
|
||||
@@ -292,11 +300,48 @@ namespace jdi
|
||||
HCURSOR QueryCursor(int x, int y);
|
||||
|
||||
/**
|
||||
* @brief 将列表滚动到指定信号所在位置。
|
||||
* @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。
|
||||
* @param [IN] ref 目标信号引用。
|
||||
*/
|
||||
void ScrollToSignal(const SignalRef& ref);
|
||||
|
||||
/**
|
||||
* @brief 平滑滚动到指定信号(带动画)。
|
||||
*
|
||||
* 用户 2026-10-15 要求:触发联动机制(点击图例 / 信号曲线,或在样式自定义区与「目标」行
|
||||
* 做任何交互)时,要把信号列表**滚动**到目标信号完整可见:
|
||||
* * 目标在当前视界上方 ⇒ 向上滚动,直到它**完整露出且不被悬浮组名遮挡**;
|
||||
* * 目标在当前视界下方 ⇒ 向下滚动直到它完整露出;
|
||||
* * 滚动速度随距离变化(越远越快、靠近时变慢),但总时长有上限(不要拖得太久)。
|
||||
*
|
||||
* @param [IN] ref 目标信号引用。
|
||||
*/
|
||||
void ScrollToSignalAnimated(const SignalRef& ref);
|
||||
|
||||
/**
|
||||
* @brief 推进一步滚动动画(由宿主定时器驱动)。
|
||||
* @return 动画仍在进行返回 true(宿主应保持定时重绘)。
|
||||
*/
|
||||
bool AdvanceScrollAnimation();
|
||||
|
||||
/** @brief 滚动动画是否正在进行。 */
|
||||
bool ScrollAnimationActive() const { return m_scrollAnimActive; }
|
||||
|
||||
/**
|
||||
* @brief 供绘图区联动高亮的「悬停信号」。
|
||||
*
|
||||
* 来源:① 列表里鼠标悬停的信号行;② 样式区「目标」行的信号名 / GX 标签。
|
||||
* 鼠标移开即返回无效引用。
|
||||
* @return 悬停的信号引用。
|
||||
*/
|
||||
const SignalRef& HoveredSignal() const { return m_hoveredSignal; }
|
||||
|
||||
/**
|
||||
* @brief 「目标」(正在编辑样式的)信号所在的网格下标。
|
||||
* @return 网格下标;未勾选绘制到任何网格时返回 -1。
|
||||
*/
|
||||
int TargetLaneIndex() const;
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief 样式区与列表行中可进行内联文本编辑的字段。
|
||||
@@ -306,7 +351,7 @@ namespace jdi
|
||||
NONE = 0, /**< 未处于编辑状态。 */
|
||||
WIDTH = 1, /**< 线宽数值。 */
|
||||
OPACITY = 2, /**< 不透明度数值(百分比)。 */
|
||||
SIGNAL_NAME = 3, /**< 信号显示名称(列表行内)。 */
|
||||
SIGNAL_NAME = 3, /**< 信号显示名称(列表行内;`row < 0` 表示样式区「目标」行)。 */
|
||||
FILE_NAME = 4 /**< 分组显示名称(列表行内)。 */
|
||||
};
|
||||
|
||||
@@ -349,6 +394,8 @@ namespace jdi
|
||||
GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */
|
||||
MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */
|
||||
ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */
|
||||
TARGET_NAME, /**< 样式区「目标」行的信号名。 */
|
||||
TARGET_BADGE, /**< 样式区「目标」行的 GX 网格标签。 */
|
||||
TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */
|
||||
FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */
|
||||
};
|
||||
@@ -364,6 +411,35 @@ namespace jdi
|
||||
float height = 0.0f; /**< 行高(像素)。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 计算「把某信号完整滚到可见」所需的滚动偏移。
|
||||
*
|
||||
* 顶部避让:当本组组名正在列表顶部悬浮时,列表顶部一段被组名占据,
|
||||
* 上行方向必须让目标行落在组名的下方才算“完整可见且不被遮挡”。
|
||||
*
|
||||
* @param [IN] ref 目标信号。
|
||||
* @param [OUT] desired 目标滚动偏移(未经 clamp)。
|
||||
* @return 找到了该行返回 true。
|
||||
*/
|
||||
bool SignalScrollTarget(const SignalRef& ref, float& desired) const;
|
||||
|
||||
/**
|
||||
* @brief 重新计算「目标」行内信号名与 GX 标签的矩形。
|
||||
*
|
||||
* 名称宽度依赖文本实际宽度、GX 标签是否出现依赖该信号是否已绘制到网格 —— 两者都可能在
|
||||
* 不重新布局的情况下变化(切换选中、勾选/取消勾选),因此用一个小缓存键避免重算。
|
||||
*/
|
||||
void UpdateTargetRowGeometry();
|
||||
|
||||
/**
|
||||
* @brief 绘制「目标」行(样式区最上方:名称 + GX 标签,可悬停 / 双击改名)。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
*/
|
||||
void PaintTargetRow(gd::Graphics& g);
|
||||
|
||||
/** @brief 取消正在进行的滚动动画(用户主动滚动时调用)。 */
|
||||
void CancelScrollAnimation() { m_scrollAnimActive = false; }
|
||||
|
||||
/**
|
||||
* @brief 重新构建列表行集合。
|
||||
*/
|
||||
@@ -742,6 +818,32 @@ namespace jdi
|
||||
RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */
|
||||
StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */
|
||||
int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */
|
||||
/**
|
||||
* @brief 供绘图区联动高亮的悬停信号(列表信号行 / 样式区「目标」行)。
|
||||
*
|
||||
* 用户 2026-10-15 要求:鼠标悬停在信号列表里的某条信号(或样式区「目标」行的
|
||||
* 信号名 / GX 标签)上时,若该信号已勾选绘制到某个网格,则该网格变色高亮、
|
||||
* 且该信号曲线本身也高亮;鼠标移开即恢复。
|
||||
*
|
||||
* ⚠️ 不能用 `m_hoverRow` 现算:鼠标移出列表后它被清掉,而悬停可能仍在「目标」行上;
|
||||
* 且列表行会在数据变化时重建,行下标会失效。
|
||||
*/
|
||||
SignalRef m_hoveredSignal;
|
||||
/** 「目标」行(样式区最上方)的几何。 */
|
||||
gd::RectF m_targetRowRect;
|
||||
gd::RectF m_targetNameRect;
|
||||
gd::RectF m_targetBadgeRect;
|
||||
/** 「目标」行几何的缓存键(目标信号 + 所在网格 + 名称),避免每次鼠标移动都重算。 */
|
||||
SignalRef m_targetGeomRef;
|
||||
int m_targetGeomLane = -2; /**< -2 = 尚未计算过。 */
|
||||
std::wstring m_targetGeomTitle;
|
||||
bool m_targetGeomValid = false;
|
||||
/** 滚动动画状态(见 `ScrollToSignalAnimated` / `AdvanceScrollAnimation`)。 */
|
||||
bool m_scrollAnimActive = false;
|
||||
float m_scrollAnimFrom = 0.0f;
|
||||
float m_scrollAnimTo = 0.0f;
|
||||
std::uint64_t m_scrollAnimStartMs = 0;
|
||||
std::uint32_t m_scrollAnimDurationMs = 0;
|
||||
|
||||
StyleEditField m_editField = StyleEditField::NONE; /**< 当前处于输入状态的字段。 */
|
||||
int m_editRow = -1; /**< 行内输入所作用的列表行下标。 */
|
||||
|
||||
@@ -96,7 +96,23 @@ namespace jdi
|
||||
const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */
|
||||
const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */
|
||||
const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */
|
||||
const float kStyleRowCount = 5.0f; /**< 样式区固定行数。 */
|
||||
const float kStyleRowCount = 5.0f; /**< 样式区固定行数(不含顶部「目标」行)。 */
|
||||
const float kStyleTargetRowHeight = 26.0f; /**< 样式区顶部「目标」行高度(与其它行一致)。 */
|
||||
/**
|
||||
* @brief GX 网格标签与信号名之间的间距(逻辑像素)。
|
||||
*
|
||||
* 用户要求:目标信号已绘制到网格时,在信号名**后方**显示它所在的 GX 标签。
|
||||
*/
|
||||
const float kTargetBadgeGap = 8.0f;
|
||||
/**
|
||||
* @brief 平滑滚动的动画时长(毫秒)。
|
||||
*
|
||||
* 距离越远时长越长(但不会无限增长),靠近时由 ease-out 自动变慢 —— 用户要求
|
||||
* “越远时越快、靠近时变慢”且“整个动画过程不要太久”。
|
||||
*/
|
||||
const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
|
||||
const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
|
||||
const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
|
||||
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
|
||||
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
|
||||
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
|
||||
@@ -316,8 +332,10 @@ namespace jdi
|
||||
|
||||
float SignalPanel::DesiredStyleHeight() const
|
||||
{
|
||||
return kStylePadV * 2.0f + kStyleRowCount * kStyleRowHeight +
|
||||
(kStyleRowCount - 1.0f) * kStyleRowGap;
|
||||
// ⚠️ 「目标」行也要计入:它是样式区最上方的一行(用户 2026-10-15 要求),
|
||||
// 与其余五行同高、同间距,少算一行会导致列表与样式区重叠。
|
||||
return kStylePadV * 2.0f + (kStyleRowCount + 1.0f) * kStyleRowHeight +
|
||||
kStyleRowCount * kStyleRowGap;
|
||||
}
|
||||
|
||||
bool SignalPanel::TakeLayoutDirty()
|
||||
@@ -370,6 +388,13 @@ namespace jdi
|
||||
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
|
||||
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) ||
|
||||
@@ -849,10 +874,16 @@ namespace jdi
|
||||
const float groupLeft = m_styleRect.X + padH;
|
||||
const float contentLeft = groupLeft + labelWidth + labelGap;
|
||||
|
||||
// ---- 顶部「目标」行:显示当前正在被编辑的目标信号(名称 + GX 标签)----
|
||||
// 用户 2026-10-15 要求:位于样式自定义区最上方(预设颜色色块之上),
|
||||
// 显示效果与交互效果与信号选择区域的信号行相同(悬停高亮、双击改名、单键滚动联动)。
|
||||
m_targetRowRect = gd::RectF(groupLeft, firstTop, groupWidth, kStyleTargetRowHeight * m_scale);
|
||||
|
||||
for (int index = 0; index < static_cast<int>(kStyleRowCount); ++index)
|
||||
{
|
||||
m_styleRowRects[static_cast<std::size_t>(index)] =
|
||||
gd::RectF(groupLeft, firstTop + static_cast<float>(index) * rowStep, groupWidth, rowHeight);
|
||||
gd::RectF(groupLeft, firstTop + static_cast<float>(index + 1) * rowStep, groupWidth,
|
||||
rowHeight);
|
||||
}
|
||||
|
||||
const gd::RectF& colorRow = m_styleRowRects[0];
|
||||
@@ -939,6 +970,77 @@ namespace jdi
|
||||
trackWidth, trackHeight);
|
||||
m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y,
|
||||
valueWidth, rowHeight);
|
||||
|
||||
// 「目标」行的子几何依赖目标信号的名称与可见性(可能在未重排时变化),
|
||||
// 因此每次重排后让缓存失效,下一次绘制 / 命中测试时按需重算。
|
||||
m_targetGeomValid = false;
|
||||
}
|
||||
|
||||
int SignalPanel::TargetLaneIndex() const
|
||||
{
|
||||
if (m_tab == nullptr || !SelectedRef().IsValid())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
// 未勾选绘制的信号不在任何网格的信号列表里,`LaneOfSignal` 自然返回 -1。
|
||||
return m_tab->LaneOfSignal(SelectedRef());
|
||||
}
|
||||
|
||||
void SignalPanel::UpdateTargetRowGeometry()
|
||||
{
|
||||
if (m_fonts == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
m_targetNameRect = gd::RectF();
|
||||
m_targetBadgeRect = gd::RectF();
|
||||
return;
|
||||
}
|
||||
|
||||
const SignalRef& target = SelectedRef();
|
||||
const SignalData* signal = target.IsValid() ? m_dataSet->Resolve(target) : nullptr;
|
||||
const std::wstring title = (signal != nullptr) ? signal->Title() : std::wstring();
|
||||
const int lane = TargetLaneIndex();
|
||||
|
||||
// 缓存键:目标信号 + 它所在网格 + 名称(名称宽度与 GX 标签是否出现都由它们决定)。
|
||||
if (m_targetGeomValid && m_targetGeomRef == target && m_targetGeomLane == lane &&
|
||||
m_targetGeomTitle == title)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_targetGeomRef = target;
|
||||
m_targetGeomLane = lane;
|
||||
m_targetGeomTitle = title;
|
||||
m_targetGeomValid = true;
|
||||
|
||||
m_targetNameRect = gd::RectF();
|
||||
m_targetBadgeRect = gd::RectF();
|
||||
if (signal == nullptr || m_targetRowRect.Width <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||||
const float labelGap = kStyleLabelGap * m_scale;
|
||||
const float contentLeft = m_targetRowRect.X + labelWidth + labelGap;
|
||||
const float contentWidth = std::max(0.0f, m_targetRowRect.Width - labelWidth - labelGap);
|
||||
|
||||
const float badgeWidth = kBadgeWidth * m_scale;
|
||||
const float badgeHeight = kBadgeHeight * m_scale;
|
||||
const float badgeGap = kTargetBadgeGap * m_scale;
|
||||
const bool badgeShown = (lane >= 0);
|
||||
|
||||
// 名称可用宽度:有 GX 标签时把它排在名称**后面**(用户要求“名称后方”),因此要预留宽度。
|
||||
const float nameLimit = std::max(0.0f, contentWidth - (badgeShown ? badgeWidth + badgeGap : 0.0f));
|
||||
const float measured = UiMeasureTextWidth(m_fonts->ui, title);
|
||||
const float nameWidth = std::min(measured, nameLimit);
|
||||
|
||||
// 名称的可交互矩形:整行高,便于悬停(文本较短时也容易命中)。
|
||||
m_targetNameRect = gd::RectF(contentLeft, m_targetRowRect.Y, nameWidth, m_targetRowRect.Height);
|
||||
if (badgeShown)
|
||||
{
|
||||
m_targetBadgeRect = gd::RectF(contentLeft + nameWidth + badgeGap,
|
||||
m_targetRowRect.Y + (m_targetRowRect.Height - badgeHeight) * 0.5f,
|
||||
badgeWidth, badgeHeight);
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::Layout(const gd::RectF& area)
|
||||
@@ -1376,11 +1478,18 @@ namespace jdi
|
||||
|
||||
UiFillRect(g, m_styleRect, m_theme->panelBg);
|
||||
|
||||
// ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)----
|
||||
PaintTargetRow(g);
|
||||
|
||||
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef());
|
||||
if (style == nullptr || SelectedRef().IsValid() == false)
|
||||
{
|
||||
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted,
|
||||
TextAlign::CENTER);
|
||||
// 提示文本只占「目标」行以下的区域,避免与目标行重叠。
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1545,6 +1654,190 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
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(g, UiEllipsize(signal->Title(), 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);
|
||||
PaintRowText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
|
||||
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text,
|
||||
TextAlign::CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
bool SignalPanel::SignalScrollTarget(const SignalRef& ref, float& desired) const
|
||||
{
|
||||
desired = m_scrollOffset;
|
||||
if (!ref.IsValid() || m_rows.empty() || m_listRect.Height <= 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float top = 0.0f;
|
||||
float rowTop = -1.0f;
|
||||
float rowHeight = 0.0f;
|
||||
int rowIndex = -1;
|
||||
for (std::size_t index = 0; index < m_rows.size(); ++index)
|
||||
{
|
||||
const ListRow& row = m_rows[index];
|
||||
if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex &&
|
||||
row.signalIndex == ref.signalIndex)
|
||||
{
|
||||
rowTop = top;
|
||||
rowHeight = row.height;
|
||||
rowIndex = static_cast<int>(index);
|
||||
break;
|
||||
}
|
||||
top += row.height;
|
||||
}
|
||||
if (rowTop < 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ⚠️ **顶部避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
|
||||
// 上行方向必须让目标行落在悬浮组名的下方。目前悬浮的组名只可能是目标信号**自己那个**
|
||||
// 分组:分组行在其信号行之上、各分组连续排列,若目标行完整可见,则更靠下的分组行
|
||||
// 不可能同时位于列表顶部(参见 `StickyGroupRowTop` 的公式)。
|
||||
float topInset = 0.0f;
|
||||
for (int index = rowIndex; index >= 0; --index)
|
||||
{
|
||||
if (m_rows[static_cast<std::size_t>(index)].kind == RowKind::GROUP)
|
||||
{
|
||||
topInset = m_rows[static_cast<std::size_t>(index)].height;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const float visibleTop = m_scrollOffset + topInset;
|
||||
const float visibleBottom = m_scrollOffset + m_listRect.Height;
|
||||
if (rowTop < visibleTop)
|
||||
{
|
||||
desired = rowTop - topInset; // 向上滚动,直到它完整出现在组名下方
|
||||
}
|
||||
else if (rowTop + rowHeight > visibleBottom)
|
||||
{
|
||||
desired = rowTop + rowHeight - m_listRect.Height; // 向下滚动,直到完整出现
|
||||
}
|
||||
else
|
||||
{
|
||||
desired = m_scrollOffset; // 已经完整可见:不动
|
||||
}
|
||||
desired = std::max(0.0f, std::min(desired, std::max(0.0f, m_contentHeight - m_listRect.Height)));
|
||||
return true;
|
||||
}
|
||||
|
||||
void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref)
|
||||
{
|
||||
EnsureRows();
|
||||
float desired = m_scrollOffset;
|
||||
if (!SignalScrollTarget(ref, desired))
|
||||
{
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool SignalPanel::AreAllSignalsVisible() const
|
||||
{
|
||||
if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty())
|
||||
@@ -1664,7 +1957,18 @@ namespace jdi
|
||||
}
|
||||
case StyleEditField::SIGNAL_NAME:
|
||||
{
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
// `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。
|
||||
if (row < 0)
|
||||
{
|
||||
const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr;
|
||||
if (signal == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
initial = signal->Title();
|
||||
break;
|
||||
}
|
||||
if (row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1698,6 +2002,12 @@ namespace jdi
|
||||
m_editField = field;
|
||||
m_editRow = row;
|
||||
|
||||
if (field == StyleEditField::SIGNAL_NAME && row < 0)
|
||||
{
|
||||
// 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。
|
||||
UpdateTargetRowGeometry();
|
||||
}
|
||||
|
||||
// 名称类字段可输入任意字符,数值类字段限制为数字与小数点。
|
||||
const bool nameField =
|
||||
(field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::FILE_NAME);
|
||||
@@ -1768,7 +2078,23 @@ namespace jdi
|
||||
}
|
||||
case StyleEditField::SIGNAL_NAME:
|
||||
{
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()) || m_dataSetWritable == nullptr)
|
||||
if (m_dataSetWritable == nullptr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (row < 0)
|
||||
{
|
||||
// 样式区「目标」行:重命名当前选中的(目标)信号。
|
||||
const SignalRef& target = SelectedRef();
|
||||
if (target.IsValid())
|
||||
{
|
||||
m_dataSetWritable->SetSignalDisplayName(target.groupIndex, target.signalIndex, trimmed);
|
||||
}
|
||||
// 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。
|
||||
m_targetGeomValid = false;
|
||||
break;
|
||||
}
|
||||
if (row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -1822,6 +2148,15 @@ namespace jdi
|
||||
return m_opacityValueRect;
|
||||
case StyleEditField::SIGNAL_NAME:
|
||||
case StyleEditField::FILE_NAME:
|
||||
// `row < 0` = 样式区「目标」行:输入框用内容列全宽(名称本身很短时也能舒适地编辑)。
|
||||
if (m_editRow < 0)
|
||||
{
|
||||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||||
const float labelGap = kStyleLabelGap * m_scale;
|
||||
const float left = m_targetRowRect.X + labelWidth + labelGap;
|
||||
const float width = std::max(24.0f, m_targetRowRect.X + m_targetRowRect.Width - left);
|
||||
return gd::RectF(left, m_targetRowRect.Y, width, m_targetRowRect.Height);
|
||||
}
|
||||
return RowNameRect(m_editRow);
|
||||
case StyleEditField::NONE:
|
||||
default:
|
||||
@@ -2370,7 +2705,21 @@ namespace jdi
|
||||
}
|
||||
else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
|
||||
{
|
||||
for (int index = 0; index < m_colorCount; ++index)
|
||||
// 「目标」行的信号名 / GX 标签(优先于其余样式控件:它们几何不重叠,
|
||||
// 但显式放在最前更易读)。
|
||||
UpdateTargetRowGeometry();
|
||||
if (UiPointInRect(m_targetRowRect, fx, fy))
|
||||
{
|
||||
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))
|
||||
{
|
||||
hoverStyle = StyleHit::TARGET_BADGE;
|
||||
}
|
||||
else if (m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy))
|
||||
{
|
||||
hoverStyle = StyleHit::TARGET_NAME;
|
||||
}
|
||||
}
|
||||
for (int index = 0; hoverStyle == StyleHit::NONE && index < m_colorCount; ++index)
|
||||
{
|
||||
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
|
||||
{
|
||||
@@ -2437,6 +2786,25 @@ namespace jdi
|
||||
m_hoverPart = part;
|
||||
m_hoverStyle = hoverStyle;
|
||||
m_hoverStyleIndex = hoverStyleIndex;
|
||||
|
||||
// 供绘图区联动高亮的悬停信号(用户 2026-10-15 要求):
|
||||
// * 列表里悬停某条信号行 → 高亮它所在的网格 + 该信号的曲线;
|
||||
// * 样式区「目标」行的信号名 / GX 标签上悬停 → 同样联动高亮。
|
||||
// ⚠️ ✕(移除该信号)不算"悬停到该信号":它是一条即将被删除的信号,
|
||||
// 高亮它会让人误以为要保留它。
|
||||
SignalRef hovered;
|
||||
if (hoverRow >= 0 && hoverRow < static_cast<int>(m_rows.size()) &&
|
||||
m_rows[static_cast<std::size_t>(hoverRow)].kind == RowKind::SIGNAL &&
|
||||
part != RowPart::REMOVE_SIGNAL)
|
||||
{
|
||||
hovered = SignalRef{m_rows[static_cast<std::size_t>(hoverRow)].groupIndex,
|
||||
m_rows[static_cast<std::size_t>(hoverRow)].signalIndex};
|
||||
}
|
||||
else if (hoverStyle == StyleHit::TARGET_NAME || hoverStyle == StyleHit::TARGET_BADGE)
|
||||
{
|
||||
hovered = SelectedRef();
|
||||
}
|
||||
m_hoveredSignal = hovered;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2477,6 +2845,7 @@ namespace jdi
|
||||
// 滚动条拖拽优先于其它命中测试。
|
||||
if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy))
|
||||
{
|
||||
CancelScrollAnimation();
|
||||
const gd::RectF thumb = ScrollThumbRect();
|
||||
m_draggingScrollBar = true;
|
||||
if (UiPointInRect(thumb, fx, fy))
|
||||
@@ -2521,6 +2890,22 @@ namespace jdi
|
||||
// 样式编辑区。
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
// ---- 顶层「目标」行:单击**不进入编辑**(用户明确要求),只做联动滚动 ----
|
||||
// 双击改名由 `OnDoubleClick` 处理;这里单击即把列表滚动到目标信号,
|
||||
// 便于在长列表(上百条信号的 MF4)里定位正在编辑的那条信号。
|
||||
{
|
||||
UpdateTargetRowGeometry();
|
||||
const bool onName =
|
||||
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
|
||||
const bool onBadge =
|
||||
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
|
||||
if (onName || onBadge)
|
||||
{
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
int paletteCount = 0;
|
||||
const std::uint32_t* palette = SignalPalette(paletteCount);
|
||||
for (int index = 0; index < m_colorCount; ++index)
|
||||
@@ -2528,6 +2913,8 @@ namespace jdi
|
||||
if (UiPointInRect(m_colorRects[static_cast<std::size_t>(index)], fx, fy))
|
||||
{
|
||||
style->color = palette[index % paletteCount];
|
||||
// 联动:样式自定义区的任何交互都把列表滚动到正在编辑的目标信号。
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2536,6 +2923,7 @@ namespace jdi
|
||||
// 先恢复到记忆的自定义颜色(即便随后收起浮窗也会生效),再在抬起时展开浮窗。
|
||||
style->color = style->customColor;
|
||||
m_pendingColorPicker = true;
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
for (int index = 0; index < kModeOptionCount; ++index)
|
||||
@@ -2543,6 +2931,7 @@ namespace jdi
|
||||
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
|
||||
{
|
||||
style->mode = kModeOptions[index];
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2550,21 +2939,25 @@ namespace jdi
|
||||
{
|
||||
m_draggingOpacity = true;
|
||||
ApplyOpacityFromX(fx);
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
if (UiPointInRect(SliderHitRect(m_widthTrackRect, m_scale), fx, fy))
|
||||
{
|
||||
m_draggingWidth = true;
|
||||
ApplyWidthFromX(fx);
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
// 数值框的命中优先于滑块:两者几何重叠时要能进入输入状态。
|
||||
if (UiPointInRect(m_widthValueRect, fx, fy))
|
||||
{
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return BeginEdit(StyleEditField::WIDTH);
|
||||
}
|
||||
if (UiPointInRect(m_opacityValueRect, fx, fy))
|
||||
{
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return BeginEdit(StyleEditField::OPACITY);
|
||||
}
|
||||
// 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板,
|
||||
@@ -2572,16 +2965,19 @@ namespace jdi
|
||||
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
|
||||
{
|
||||
m_pendingGainMenu = true;
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
|
||||
{
|
||||
m_pendingMirrorMenu = true;
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
|
||||
{
|
||||
style->absolute = !style->absolute;
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2788,6 +3184,23 @@ namespace jdi
|
||||
return BeginEdit(StyleEditField::OPACITY);
|
||||
}
|
||||
|
||||
// 样式区顶部「目标」行:双击信号名(或它后面的 GX 标签)进入**改名**编辑态。
|
||||
// ⚠️ 单击不进入编辑(见 `OnLButtonDown`),必须双击 —— 用户明确要求。
|
||||
if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr)
|
||||
{
|
||||
UpdateTargetRowGeometry();
|
||||
const bool onName =
|
||||
m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy);
|
||||
const bool onBadge =
|
||||
m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy);
|
||||
if (onName || onBadge)
|
||||
{
|
||||
// `row < 0` 表示编辑目标是"当前选中信号"(即目标行显示的信号)。
|
||||
m_rowsDirty = true;
|
||||
return BeginEdit(StyleEditField::SIGNAL_NAME, -1);
|
||||
}
|
||||
}
|
||||
|
||||
RowPart part = RowPart::NONE;
|
||||
const int row = HitRow(x, y, part);
|
||||
if (row < 0)
|
||||
@@ -2856,6 +3269,9 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnMouseWheel(int delta)
|
||||
{
|
||||
// 用户主动滚动 ⇒ 立即取消正在进行的目标信号滚动动画(否则两者会互相打架)。
|
||||
CancelScrollAnimation();
|
||||
|
||||
if (m_dataSet == nullptr)
|
||||
{
|
||||
return false;
|
||||
@@ -2869,15 +3285,15 @@ namespace jdi
|
||||
|
||||
bool SignalPanel::OnMouseLeave()
|
||||
{
|
||||
if (m_hoverRow < 0 && m_hoverStyle == StyleHit::NONE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const bool changed =
|
||||
(m_hoverRow >= 0) || (m_hoverStyle != StyleHit::NONE) || m_hoveredSignal.IsValid();
|
||||
m_hoverRow = -1;
|
||||
m_hoverPart = RowPart::NONE;
|
||||
m_hoverStyle = StyleHit::NONE;
|
||||
m_hoverStyleIndex = -1;
|
||||
return true;
|
||||
// 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。
|
||||
m_hoveredSignal = SignalRef();
|
||||
return changed;
|
||||
}
|
||||
|
||||
void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y)
|
||||
|
||||
@@ -1863,6 +1863,85 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
|
||||
---
|
||||
|
||||
### 9.34 浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(本轮,起源设备)
|
||||
|
||||
> 本轮四项(用户 2026-10-15 第六轮)。离屏验证 `.preflight/check` **73/73 通过**(连跑 3 次稳定);
|
||||
> **A/B** 回退六处实现后 **15 项如实失败**。
|
||||
|
||||
#### 一、浮动面板上双击不再穿透到网格(用户报告)
|
||||
|
||||
- **根因**:实验性功能的浮动面板绘制在网格之上,但 `PlotView::OnDoubleClick` 在
|
||||
"差值标签 → 光标标签 → 光标线 → 图例" 都不命中时**无条件** `FitAll()` ⇒
|
||||
在面板上双击任意位置都会让网格莫名其妙地自适应一次。
|
||||
- **修法**:在 `OnDoubleClick` 的**最前面**(高于一切下层命中)用
|
||||
`TopPanelAtPoint()` 判定,命中浮动面板则直接消费(面板自身双击无额外语义)。
|
||||
这是"上层元素的交互不得影响下层"的通用做法,与 `QueryCursor` / `OnLButtonDown`
|
||||
里已有的面板守卫同源。
|
||||
|
||||
#### 二、网格变色高亮改由"信号列表悬停"触发(用户重新定义)
|
||||
|
||||
- **旧行为(已取消)**:鼠标在网格里移动 ⇒ `m_hoverLane = LaneAtY(y)` ⇒ 整个网格变色高亮。
|
||||
- **新行为**:
|
||||
* 鼠标**在网格里移动不再触发**任何网格高亮;
|
||||
* 在**信号列表**里悬停某条信号 ⇒ 若它已勾选绘制到某个网格,则该网格变色高亮,
|
||||
**并且该信号曲线本身也高亮**(复用 `PaintSignalHalo`);鼠标移开即恢复;
|
||||
* 悬停**未勾选**的信号不产生任何高亮(`LaneOfSignal` 只认可见信号);
|
||||
* 悬停信号行的 ✕(移除)**不算**"悬停到该信号"。
|
||||
- **实现要点**:
|
||||
* `PlotView` 新增 `m_externalHoverSignal` + `SetExternalHoverSignal()`:
|
||||
⚠️ **必须与 `m_hoverSignal` 分开**——绘图区的 `OnMouseLeave` 会清掉后者,
|
||||
而列表悬停时鼠标恰恰不在绘图区内;
|
||||
* 新增 `PlotView::EffectiveHoverLane()`:`m_hoverLane`(浮动面板悬停)优先,
|
||||
否则取外部悬停信号所在网格;`PaintHoverHighlight` 与光晕都改用它 /
|
||||
`m_hoverSignal ?: m_externalHoverSignal`;
|
||||
* `SignalPanel` 新增 `HoveredSignal()`(列表信号行 / 「目标」行),
|
||||
⚠️ 不能用 `m_hoverRow` 现算(鼠标移出列表即被清、行下标会因重建而失效),
|
||||
且在 `InvalidateRows()` 里一并作废(结构变化后悬停引用不再可信);
|
||||
* 同步时机:`Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE、数据变化、
|
||||
切换标签页等全部改变悬停的路径,而不只是 WM_MOUSEMOVE)。
|
||||
* GX 徽标的悬停高亮仍按鼠标纵坐标现算(它与"悬停网格高亮"已解耦)。
|
||||
|
||||
#### 三、样式区顶部「目标」行(用户要求)
|
||||
|
||||
- 位置:样式自定义区**最上方**(预设颜色色块之上),与其余五行同高同间距
|
||||
(`DesiredStyleHeight()` 因此多算一行;标签列文字「目标」)。
|
||||
- 内容:当前**正在被编辑的目标信号**(= 选中信号)的名称;
|
||||
**已绘制到网格时**在名称**后方**显示它所在的 GX 标签(`TargetLaneIndex()`)。
|
||||
- 交互(用户要求"与信号选择区域的信号相同"):
|
||||
* 名称悬停 ⇒ 一层 `hover` 背景;GX 标签悬停 ⇒ `accentSoft` 底 + `accent` 边;
|
||||
两者都用手型光标;
|
||||
* **悬停名称 / GX 标签也触发绘图区的网格高亮 + 曲线高亮**(与列表悬停同效);
|
||||
* **单击不进入编辑**,只触发"滚动到目标信号"的联动;**双击**才进入改名
|
||||
(复用 `StyleEditField::SIGNAL_NAME`,用 `m_editRow < 0` 表示"目标是选中信号");
|
||||
* 名称颜色与列表一致:已绘制 = `text`,未绘制 = `textMuted`。
|
||||
- ⚠️ 名称 / GX 标签的矩形依赖**文本实际宽度**与**是否已绘制**,
|
||||
这两者都可能在不重排的情况下变化 ⇒ 用 `UpdateTargetRowGeometry()` 在
|
||||
绘制 / 悬停 / 命中 / 取光标四处按需重算(带小缓存键)。
|
||||
|
||||
#### 四、联动:把信号列表**平滑滚动**到目标信号(用户要求)
|
||||
|
||||
- **触发**:绘图区内把某条信号选为当前目标(点击图例、点击信号曲线、双击图例改名)
|
||||
⇒ `PlotView::SelectSignal()` 记录一个待处理请求(`TakePanelScrollRequest()`),
|
||||
宿主在鼠标消息处理后取走并调用 `SignalPanel::ScrollToSignalAnimated()`;
|
||||
另外样式自定义区的**任何交互**(预设色块、自定义颜色入口、样式按钮、
|
||||
线宽 / 透明度滑块与数值框、「系数/偏移」「镜像」二级菜单、绝对值)与
|
||||
「目标」行的单击也都会触发同一次滚动。
|
||||
- **滚动目标位置**(`SignalScrollTarget()`):
|
||||
* 目标在当前视界**上方** ⇒ 向上滚动到 `rowTop - 组名高度`,
|
||||
⚠️ **顶部必须避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
|
||||
悬浮组名只可能是目标自己那个分组的组名(分组连续排列,见 `StickyGroupRowTop`),
|
||||
因此让出**一个组行高度**即可;
|
||||
* 目标在视界**下方** ⇒ 向下滚动到 `rowBottom - 列表高度`;
|
||||
* 已经完整可见 ⇒ **不动**。
|
||||
- **动画**(`AdvanceScrollAnimation()`,宿主用 16ms 定时器驱动):
|
||||
* ease-out cubic(起步快、靠近目标变慢 —— 用户要求"越远时越快,靠近时变慢");
|
||||
* 时长 = `clamp(距离 × 0.35ms, 130ms, 300ms)`(用户要求"不要太久");
|
||||
* 用户滚轮 / 拖滚动条 ⇒ **立即取消**动画(否则互相打架)。
|
||||
- ⚠️ 动画只改"面板的滚动偏移",因此宿主只需持续 `InvalidateRect`(整帧),
|
||||
由 `Render` 检测 `ScrollAnimationActive()` 自管定时器(与提示浮层淡出同一套做法)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志(本文件维护记录)
|
||||
|
||||
| 日期 | 设备/模型 | 摘要 |
|
||||
@@ -1907,6 +1986,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(详见 §9.29)**。① **新网格不继承全局 Y 轴自适应**:`RebuildLanes` 对新出现的信号返回默认 `LaneCarry()` ⇒ `fitYToSelection=false`;修法:重建前先算 `previousAllAutoFit`(非空且**每个**网格都开着 = 全局开启的正式定义),`LaneCarry` 加 `matched` 标记,新信号用它当默认;并新增 `ReapplyGlobalAutoFitIfEnabled()` 在 **`Layout()`(网格数变化)/`RefreshLanes()`(信号数变化)/`SplitLane()`** 之后立即重算 ⇒ 新勾选(按**当前视界**自适应)、取消显示(剩余信号重新收敛)、拆分(每份各自自适应)三项全覆盖。② **组名悬浮**:新增 `SignalPanel::StickyGroupRowIndex()` + `PaintStickyGroupRow()`(不透明底 + 底部边界线),`RowRect` 加 `m_rowYOverrideActive/Y` 覆盖行顶部(行内所有几何的唯一来源);判据 = 分组行已在列表上边界之上且本组最后一行仍在列表内;常规循环**跳过**被钉住的行(否则画两次)。③ **稀疏信号默认样式**:新增 `ApplySparseDefaultStyle`(1 个有效点 → 点线 + **最大线宽 10px**;2..16 → 点线 + 2px),在 `BuildSignalGroup`/`InsertDerivedSignal` 两处调用;线宽常量统一到 `DataModel.hpp`(`kSignalLineWidthMin/Max`)并被 SignalPanel 滑块引用;`ComputeStats` 输出 `firstFiniteIndex`、`SignalData` 新增 `singlePointTime`。④ **单点光标吸附**:`SnapTimeToSample` 新增半径分支(`kCursorSnapPointRadiusPixels=8` 逻辑像素)吸附 `singlePointTime`,半径外自由移动(**吸附而非卡住**)。⑤ **放大自动点线**:`SignalStyle` 新增 `autoScatterActive/autoScatterSavedMode/autoScatterUserOverride`,`AxisSparseInView`(间距 > 半个绘图区宽度,与吸附上门限同源,**单点轴不算稀疏**)+ `UpdateAutoScatterStyles()`(在 `SetTimeView` 等四处调用)⇒ 进入阈值自动切点线、退出阈值恢复原样式,**用户手动改过则不恢复**(判定必须放在恢复判断**之前**,否则用户改完紧接着缩出去会被覆盖)。验证:新增离屏 `.preflight/check` **65/65 通过**(连跑 3 次稳定,覆盖 7 组断言;含“钉住行与自然位置逐像素一致”“单点吸附半径内外”“用户手动改样式后退出不覆盖”),**A/B**:临时禁用六个机制后 **21 项如实失败**;`make release` 零警告。 |
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 |
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 |
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(详见 §9.34)**。① **浮动面板上双击穿透到网格**:`PlotView::OnDoubleClick` 在所有下层命中都不成立时无条件 `FitAll()` ⇒ 在面板上双击会让网格莫名其妙自适应一次;修法:**最前面**用 `TopPanelAtPoint()` 判定并消费。② **网格变色高亮的触发来源重新定义**(旧行为"鼠标在网格里移动就高亮"**作废**):改为**信号列表 / 样式区「目标」行悬停到某条已绘制信号** ⇒ 该信号所在网格变色高亮**且该信号曲线本身也高亮**(新增 `m_externalHoverSignal`,与 `m_hoverSignal` 分开保存,由 `Render()` 每帧同步;新增 `EffectiveHoverLane()`)。③ **样式区「目标」行**(样式自定义区最上方):显示当前编辑的目标信号名称 + (已绘制时)名称**后方**的 GX 标签,悬停高亮并联动网格/曲线高亮,**单击不编辑、双击改名**。④ **联动平滑滚动**:绘图区选中信号(点图例 / 点曲线 / 双击图例)与样式区任何交互都触发 `ScrollToSignalAnimated()`(ease-out cubic、时长 `clamp(距离×0.35ms,130,300)`、**上行让出组行高度避免被悬浮组名遮挡**、滚轮/拖滚动条即取消)。验证:离屏 `.preflight/check` **73/73 通过**(连跑 3 次稳定;含浮动面板双击不移视界 vs 空白处双击必自适应的对照组、未勾选信号悬停不高亮、目标行 GX 标签位置与单击/双击区分、长列表 122 行平滑滚动与遮挡检查);**A/B** 回退六处实现 ⇒ **15 项如实失败**;`make release` 零警告、链接通过。 |
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **「适应视图」边界规则撤回重做:单值散点让出半径 + 单点簇视图(详见 §9.33)**。① **初次自适应没有发生**(用户报告):`FitAll()` 在**没有任何可见信号**时也把 `ViewInitialized()` 置 true ⇒ 随后勾选第一个信号时 `SynchronizeView` 认为已初始化、**跳过自适应**(实测 MF4 视界停在 42.386s,数据点被推到右边缘外);修法:只有存在可见信号才置位,并让初次自适应与工具栏「适应视图」**共用 `FitAll()` 一条路径**。② **单值散点被裁掉半个**(用户报告):旧规则"左右各留一小格"在"单值 + 连续信号"下既不准也不够(`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点,正好落在边界上);按用户要求**撤回旧策略**(删除 `FitSinglePointLanes`/`TimeGridCellSeconds`/`LaneValueGridCell`/`FitSinglePointSignal`),改为:**任何一条边界被单值散点顶住就让出该散点的半径(+1px)**,只在散点**会被裁切**时才扩展(内部散点不动视界),边界不是单值散点时边界就落在信号点上。③ **特例**:全部可见数据点都是单值信号且时刻相同 ⇒ 时间轴以该时刻居中、缩放到**一大格 = 1s**(视界 10s,`NiceStep(10,10)==1.0`),每个网格的幅值区间保证其数据点都能完整画出(退化成单值时跨度 1 居中)。验证:离屏 `.preflight/check` **62/62 通过**(连跑 3 次稳定;含**真实文件** `TestData/MF4-Record.MF4`:`WIN_Motor_StallCurrentVolBrk`(1 点) + `m_MOT_Current_DCom[0]`(8475 点) 复现用户场景,散点圆心离绘图区左边界 16.5px、半径 15px ⇒ 完整可见);**A/B** 回退四处规则后 **20 项如实失败**(含"散点正好压在绘图区右边缘"、"初次视界 42.386s");`make release` 零警告、链接通过。 |
|
||||
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 |
|
||||
|
||||
@@ -2208,6 +2288,31 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
`Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,
|
||||
单值信号的数据点既不居中、还会有一半被裁在网格外(用户 2026-10-15 报告)。
|
||||
另外「适应视图」两条路径(初次自适应 / 工具栏按钮)必须**只有一条实现**(都走 `FitAll`)。
|
||||
72. ⚠️ **上层元素的交互不得穿透到下层**(用户 2026-10-15 第六轮明确要求):
|
||||
实验性功能的浮动面板浮在网格之上,`PlotView::OnDoubleClick` 必须**最先**用
|
||||
`TopPanelAtPoint()` 判定并消费 —— 旧实现在"差值标签/光标标签/光标线/图例都不命中"时
|
||||
无条件 `FitAll()`,于是在面板上双击会让网格莫名其妙地自适应一次。
|
||||
新增/修改任何"空白处点击/双击"的默认行为时,都要先过一遍所有更上层的元素
|
||||
(浮动面板、菜单、拾色器、装饰层)。
|
||||
73. ⚠️ **网格变色高亮的触发来源只有两个**(旧行为"鼠标在网格里移动就高亮"**已作废**):
|
||||
① 鼠标悬停在实验性功能浮动面板上 ⇒ 高亮该面板所属网格;
|
||||
② 信号列表 / 样式区「目标」行**悬停到某条已绘制信号** ⇒ 高亮它所在网格**并且
|
||||
该信号曲线本身也高亮**(`PaintSignalHalo`)。
|
||||
`PlotView` 的 `m_externalHoverSignal` 必须与 `m_hoverSignal` **分开保存**
|
||||
(绘图区 `OnMouseLeave` 会清掉后者,而列表悬停时鼠标不在绘图区内),
|
||||
并由 `Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE / 数据变化 / 切标签页)。
|
||||
`SignalPanel::HoveredSignal()` 不能在 `InvalidateRows()` 之后继续有效(必须一并作废)。
|
||||
74. ⚠️ **样式区「目标」行**(用户 2026-10-15 第六轮要求):位于样式自定义区**最上方**,
|
||||
显示当前正在编辑的目标信号名称,**已绘制到网格时**在名称**后方**加 GX 标签;
|
||||
悬停名称/GX 同时触发绘图区网格高亮 + 曲线高亮;**单击不进入编辑**(只做联动滚动),
|
||||
**双击才进入改名**(`StyleEditField::SIGNAL_NAME` + `m_editRow < 0` 表示目标信号)。
|
||||
它的名称宽度与"是否有 GX 标签"都可能在**未重排**时变化 ⇒ 必须用
|
||||
`UpdateTargetRowGeometry()` 按需重算(带缓存键),不能只在 `LayoutStyleControls()` 里算一次。
|
||||
75. ⚠️ **联动滚动必须是平滑动画,且目标不得被悬浮组名遮挡**(用户 2026-10-15 第六轮要求):
|
||||
`SignalPanel::ScrollToSignalAnimated()` + `AdvanceScrollAnimation()`
|
||||
(ease-out cubic;时长 = `clamp(距离×0.35ms, 130, 300)`;宿主 16ms 定时器驱动)。
|
||||
向上滚动时顶部要**让出一个组行高度**(悬浮组名只可能是目标自己那个分组的组名,
|
||||
因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。
|
||||
|
||||
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
|
||||
|
||||
|
||||
Reference in new issue
Block a user