修复浮动面板双击穿透、网格高亮触发来源、新增样式区「目标」行与联动平滑滚动
用户 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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user