修复五项交互缺陷:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区
1) 分组折起后再展开,信号列表的滚动条消失(筛选态与常规态均可复现)
- 根因:AdvanceGroupAnimations 只在“折起完成”时重建行集合;而“展开完成”那一帧
m_contentHeight 从收起态跳回展开态,却没有任何路径重算滚动条可见性 ⇒ 滚动条停在隐藏。
- 修法:任一方向动画结束都 m_rowsDirty = true + BuildRows(),并每帧 RefreshScrollBar(false)。
⚠️ 动画期间不夹取滚动偏移,避免打断与滚动同步的折起动画(scrollSynced)。
2) 单点信号(finiteCount == 1)默认绘制方式由「点线」改为「散点」
- ApplySinglePointDefaultStyle → DrawMode::SCATTER(仍取最大线宽)。
- 新增 SignalPanel::ModeOptionEnabled:单点信号的「线段」「点线」置灰、不可点、
且不参与悬停高亮(避免“看着能点其实点不动”);样式区的绘制与命中都用它。
- PlotView::UpdateAutoScatterStyles 跳过单点信号,否则“放大自动点线”会把它又改回「点线」。
3) 拖动线宽滑块完全不生效(拖动时线宽不变、松手瞬间变了、再移动鼠标又变回去)
- 根因一:AcquirePolyline 的折线缓存键没有 lineWidth,而 BuildPolyline 把线宽烘进
包络带几何(每个像素列的上下包络各向外扩 max(1,线宽)*scale*0.5,「梳状」判定也依赖它)
⇒ 稠密信号(采样数 > 绘图区宽度)永远复用旧线宽的顶点与包络带。
- 根因二:样式补丁只更新“静态场景缓存 + 后备缓冲区”,而每网格图层(宽基底)仍是旧样式像素,
下一帧命中“宽基底快路径”时把新样式覆盖回旧像素。
- 修法:PolylineCacheEntry 增加 lineWidth 并进比较;
样式补丁之后 QueueLaneRebuild(受影响网格) + RenderPendingLaneRebuilds(1)(沿用每帧至多 1 个的预算)。
4) 「目标」行的 GX 标签复用「所属网格」菜单,并先给 PopupMenu 增加「限高 + 条目区滚动 + 尾部固定项」
- MenuLayoutConstraint::maxHeight(OpenLaneMenu 传客户区高度的 62%);
- 固定区 = 最后一个分隔线及其之后的条目(即「新建网格」)始终钉在菜单底部,其余条目可滚动;
- 滚轮滚动条目区(Application 在菜单内滚动时不再关闭菜单,其余情形保持既有行为);
- 键盘移动悬停项自动滚入可视区(EnsureItemVisible);ItemAtPoint 要求条目同时命中滚动视口;
- 滚动区文本用 UiTextClipped(GDI 文本不遵循 GDI+ SetClip),并跳过完全不可见的条目(性能);
- BuildLayout 不再无条件清零 m_scrollOffset(否则 RebuildLayout 会把滚轮位置吃掉);
- 「目标」行 GX 标签点击**不参与**联动滚动(悬停联动保留)。
5) 点选信号名 ⇒ 绘图区自动把目标网格滚动到可视区内(上百个独立网格时旧有高亮无法定位)
- SignalPanel::RequestPlotLaneScroll / TakePlotLaneScrollRequest;
Application::ApplyPendingPlotLaneScroll(WM_LBUTTONDOWN 的面板分支);
PlotView::ScrollLaneIntoView(在可视区上方就上滚、在下方就下滚、已完整可见则不动)。
- 只有“点击信号名”触发;悬停绝不改变视图;勾选框 / GX 徽标 / 移除 ✕ 也不触发。
验证(全部离屏执行,窗口 SW_HIDE + 只注入合成消息 + 进程与线程 BELOW_NORMAL,
不移动真实光标、不抢焦点、不截屏):
- 新增 .preflight/check(不入库)68/68 通过;
- A/B:五处修复全部回退 ⇒ 17 项如实失败(B 4 / C 4 / D 7 / E 2);“折起后滚动条”需要
逐帧重绘驱动动画才能复现,单独回退时 ⇒ 5 项如实失败;恢复后 68/68;
- make release / make debug 零警告。
其它:
- 按要求删除工作区 Tools/(structcheck 脚本)并移除 Makefile 的 structcheck 目标;
- Documents/agent_mem.md 同步:新增 §9.38、§11.2 第 97–103 条与变更日志一行。
This commit is contained in:
1 parent
9cf6b2fedc
commit
2904699eae
19 files changed
+733
-340
No files matched your search
@@ -278,6 +278,17 @@ namespace jdi
|
||||
*/
|
||||
bool ApplyPendingPanelScroll();
|
||||
|
||||
/**
|
||||
* @brief 应用信号面板提交的「把绘图区滚动到目标网格」的联动请求。
|
||||
*
|
||||
* 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时,
|
||||
* 若该信号所在的网格不在绘图区可视区内,就自动调节绘图区的滚动条让该网格完整出现
|
||||
*(上百个独立网格时,仅靠网格高亮无法定位到画面之外的网格)。悬停不触发。
|
||||
*
|
||||
* @return 存在请求并已应用返回 true。
|
||||
*/
|
||||
bool ApplyPendingPlotLaneScroll();
|
||||
|
||||
/**
|
||||
* @brief 派生信号构建请求(多次迭代均值滤波 / 弱峰调整共用同一套插入流程)。
|
||||
*/
|
||||
|
||||
@@ -603,9 +603,27 @@ namespace jdi
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSEWHEEL:
|
||||
{
|
||||
// 可滚动菜单(条目过多、按最大高度截断的菜单,如「所属网格」):
|
||||
// 鼠标在菜单内时滚轮用来翻动条目区,**不关闭菜单**;其余情形仍是
|
||||
// “滚轮关闭菜单”(保持既有行为)。
|
||||
POINT wheelPoint = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
|
||||
ScreenToClient(hwnd, &wheelPoint);
|
||||
if (m_menu.IsScrollable() && m_menu.ContainsPoint(wheelPoint.x, wheelPoint.y))
|
||||
{
|
||||
if (m_menu.OnMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)))
|
||||
{
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
m_menu.Close();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
}
|
||||
case WM_RBUTTONDOWN:
|
||||
case WM_MBUTTONDOWN:
|
||||
case WM_MOUSEWHEEL:
|
||||
m_menu.Close();
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
return 0;
|
||||
@@ -966,6 +984,8 @@ namespace jdi
|
||||
m_activeTarget = Target::PANEL;
|
||||
if (m_panel.OnLButtonDown(x, y))
|
||||
{
|
||||
// 点击信号名 ⇒ 让绘图区把目标网格滚动到可视区内(用户 2026-10-16 要求)。
|
||||
ApplyPendingPlotLaneScroll();
|
||||
InvalidateRect(hwnd, nullptr, TRUE);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -794,6 +794,17 @@ namespace jdi
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Application::ApplyPendingPlotLaneScroll()
|
||||
{
|
||||
int laneIndex = -1;
|
||||
if (!m_panel.TakePlotLaneScrollRequest(laneIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 目标网格不在绘图区可视区内时才真正滚动(已完整可见则不动视图)。
|
||||
return m_plot.ScrollLaneIntoView(laneIndex);
|
||||
}
|
||||
|
||||
bool Application::ApplyPendingDerivedBuild()
|
||||
{
|
||||
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
|
||||
|
||||
@@ -923,6 +923,23 @@ namespace jdi
|
||||
*/
|
||||
bool TakePanelScrollRequest(SignalRef& ref);
|
||||
|
||||
/**
|
||||
* @brief 把指定网格纵向滚动到**完整可见**(网格不在可视区时自动调节滚动条)。
|
||||
*
|
||||
* 用户 2026-10-16 要求:信号很多时(例如把 MF4 的上百条信号各自放进独立网格)
|
||||
* 每个网格只占绘图区的一小段,绝大多数网格都在可视区之外 —— 在信号列表里点选一条
|
||||
* 信号后,用户无法从画面上的高亮看出它究竟在哪个网格。因此点选信号时:
|
||||
* * 目标网格在可视区**上方** ⇒ 向上滚动直到它的上边界进入绘图区上沿;
|
||||
* * 目标网格在可视区**下方** ⇒ 向下滚动直到它的下边界进入绘图区下沿;
|
||||
* * 已经完整可见 ⇒ 不动(避免无意义地改变视图)。
|
||||
*
|
||||
* ⚠️ 只在**点击**信号时调用(悬停只做高亮联动,绝不改变视图)。
|
||||
*
|
||||
* @param [IN] laneIndex 网格下标。
|
||||
* @return 视图(网格滚动量)发生变化返回 true。
|
||||
*/
|
||||
bool ScrollLaneIntoView(int laneIndex);
|
||||
|
||||
/**
|
||||
* @brief 处理编辑态下的字符输入。
|
||||
* @param [IN] character 输入字符。
|
||||
@@ -2345,6 +2362,16 @@ namespace jdi
|
||||
bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */
|
||||
float offsetY = 0.0f; /**< Y 轴偏移。 */
|
||||
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
|
||||
/**
|
||||
* @brief 线宽(逻辑像素)。
|
||||
*
|
||||
* ⚠️ **必须进缓存键**:`BuildPolyline` 会把线宽**烘进包络带几何**
|
||||
* (`strokeHalf = max(1, 线宽) * scale * 0.5`,逐列上下包络各向外扩这么多),
|
||||
* 且「梳状」判定也依赖扩展后的列跨度。旧实现漏了这一项 ⇒ 改变线宽时
|
||||
* 稠密信号会命中旧条目、一直按**旧线宽**绘制(用户 2026-10-16 报告:
|
||||
* 拖动线宽滑块时信号线宽不变,松开后再移动鼠标又变回去)。
|
||||
*/
|
||||
float lineWidth = 1.0f;
|
||||
std::vector<gd::PointF> points; /**< 折线顶点。 */
|
||||
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
|
||||
|
||||
|
||||
@@ -180,6 +180,48 @@ namespace jdi
|
||||
Tab().LaneScroll() = static_cast<float>(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset));
|
||||
}
|
||||
|
||||
bool PlotView::ScrollLaneIntoView(int laneIndex)
|
||||
{
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()) || m_plotRect.Height <= 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ⚠️ 不能直接用 `laneIndex * 网格高`:网格之间虽然目前没有间距,但内容高度会随
|
||||
// “最小网格高”夹取而大于绘图区高,公式里已经含有 `- LaneScroll()`。
|
||||
// 从当前绘制位置反推**内容偏移**最稳妥:rect.Y = plotRect.Y + contentTop - LaneScroll()。
|
||||
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
|
||||
const float contentTop = rect.Y - m_plotRect.Y + Tab().LaneScroll();
|
||||
const float laneHeight = rect.Height;
|
||||
|
||||
const float previous = Tab().LaneScroll();
|
||||
float scroll = previous;
|
||||
if (contentTop - scroll < 0.0f)
|
||||
{
|
||||
// 目标网格在可视区上方:把它的上边界对齐到绘图区上沿。
|
||||
scroll = contentTop;
|
||||
}
|
||||
else if (contentTop + laneHeight - scroll > m_plotRect.Height)
|
||||
{
|
||||
// 目标网格在可视区下方:把它的下边界对齐到绘图区下沿。
|
||||
scroll = contentTop + laneHeight - m_plotRect.Height;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false; // 已经完整可见
|
||||
}
|
||||
|
||||
Tab().LaneScroll() = scroll;
|
||||
ClampLaneScroll();
|
||||
if (std::fabs(Tab().LaneScroll() - previous) <= 0.01f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 网格滚动量参与场景/图层指纹,因此重建可视几何(各网格 rect 与 yMin/yMax 依赖滚动量)。
|
||||
BuildLaneVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlotView::LaneSpansMultipleGroups(int laneIndex) const
|
||||
{
|
||||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||||
|
||||
@@ -2916,6 +2916,17 @@ namespace jdi
|
||||
continue;
|
||||
}
|
||||
|
||||
// ⚠️ **只有一个有效采样点**的信号**不参与**自动切换:
|
||||
// 它们的初始样式固定为「散点」(见 `ApplySinglePointDefaultStyle`),而信号面板的
|
||||
// 「线段」/「点线」按钮已对其置灰(用户 2026-10-16 要求)——
|
||||
// 若这里再自动写成「点线」,用户会看到一个“已置灰却仍处于点线态”的矛盾状态。
|
||||
if (data->finiteCount == 1)
|
||||
{
|
||||
style->autoScatterActive = false;
|
||||
style->autoScatterUserOverride = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」,
|
||||
// 所以自动启用期间 mode 不等于「点线」只可能是用户改的。
|
||||
// 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去**
|
||||
|
||||
@@ -175,6 +175,9 @@ namespace jdi
|
||||
const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal;
|
||||
const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f;
|
||||
const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0;
|
||||
// ⚠️ 线宽也必须进键:`BuildPolyline` 把它烘进包络带几何(逐列外扩半个线宽)
|
||||
//(详见 `PolylineCacheEntry::lineWidth` 的说明;漏掉它会让线宽改动在**稠密信号**上不生效)。
|
||||
const float lineWidth = (resolved.style != nullptr) ? resolved.style->lineWidth : 1.0f;
|
||||
|
||||
for (const PolylineCacheEntry& entry : m_polylineCache)
|
||||
{
|
||||
@@ -184,7 +187,8 @@ namespace jdi
|
||||
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
|
||||
entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute &&
|
||||
entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal &&
|
||||
entry.offsetY == offsetY && entry.offsetTime == offsetTime)
|
||||
entry.offsetY == offsetY && entry.offsetTime == offsetTime &&
|
||||
entry.lineWidth == lineWidth)
|
||||
{
|
||||
return &entry;
|
||||
}
|
||||
@@ -215,6 +219,7 @@ namespace jdi
|
||||
entry.mirrorHorizontal = mirrorHorizontal;
|
||||
entry.offsetY = offsetY;
|
||||
entry.offsetTime = offsetTime;
|
||||
entry.lineWidth = lineWidth;
|
||||
|
||||
BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks,
|
||||
entry.bandMode, entry.bandSpiky);
|
||||
@@ -924,6 +929,17 @@ namespace jdi
|
||||
m_fastFrame = true;
|
||||
RepaintSceneCachePatch(g, patchLanes);
|
||||
m_sceneDegraded = true;
|
||||
// ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”,
|
||||
// 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中
|
||||
// “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** ——
|
||||
// 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新,
|
||||
// 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。
|
||||
// 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。
|
||||
for (const int laneIndex : patchLanes)
|
||||
{
|
||||
QueueLaneRebuild(laneIndex);
|
||||
}
|
||||
RenderPendingLaneRebuilds(1);
|
||||
paintOverlays();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,6 +199,28 @@ namespace jdi
|
||||
*/
|
||||
bool EndFilterEdit();
|
||||
|
||||
/**
|
||||
* @brief 取出并清除「请把绘图区滚动到该网格」的联动请求。
|
||||
*
|
||||
* 用户 2026-10-16 要求:点击信号选择区域的**信号名**(或样式区「目标」行的信号名)时,
|
||||
* 若该信号所在网格不在绘图区可视范围内,则自动调节绘图区的滚动条让该网格完整出现。
|
||||
* 悬停时**不做**这个滚动(只保留既有的网格高亮联动)。
|
||||
*
|
||||
* @param [OUT] laneIndex 需要滚动到可视区内的网格下标。
|
||||
* @return 存在待处理的请求返回 true。
|
||||
*/
|
||||
bool TakePlotLaneScrollRequest(int& laneIndex)
|
||||
{
|
||||
if (!m_plotLaneRequestFlag)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_plotLaneRequestFlag = false;
|
||||
laneIndex = m_pendingPlotLane;
|
||||
m_pendingPlotLane = -1;
|
||||
return laneIndex >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 向当前输入框送入一个字符。
|
||||
* @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。
|
||||
@@ -644,8 +666,11 @@ namespace jdi
|
||||
|
||||
/**
|
||||
* @brief 依据内容高度重算滚动条可见性与列表可用宽度。
|
||||
* @param [IN] clampScroll 是否顺带把滚动偏移夹到合法范围。
|
||||
* ⚠️ 分组折起/展开动画期间必须传 `false`:与滚动同步的折起动画正按自己的进度
|
||||
* 推进 `m_scrollOffset`,此时夹取会打断它(折起后内容比列表还短时尤其明显)。
|
||||
*/
|
||||
void RefreshScrollBar();
|
||||
void RefreshScrollBar(bool clampScroll = true);
|
||||
|
||||
/**
|
||||
* @brief 限制滚动偏移量到合法范围。
|
||||
@@ -992,6 +1017,23 @@ namespace jdi
|
||||
*/
|
||||
bool ApplyOpacityFromX(float x);
|
||||
|
||||
/**
|
||||
* @brief 断言某「样式」选项对当前目标信号是否可用(不可用时置灰且不可点)。
|
||||
*
|
||||
* ⚠️ **只有一个有效采样点**的信号固定使用「散点」:单个数据点在「线段」下根本画不出来,
|
||||
* 而「点线」与「放大自动点线」机制语义重叠,因此这两个选项对单点信号置灰
|
||||
*(用户 2026-10-16 要求)。
|
||||
* @param [IN] optionIndex 选项下标(与 `kModeOptions` 同序)。
|
||||
* @return 可用返回 true。
|
||||
*/
|
||||
bool ModeOptionEnabled(int optionIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 登记「点击信号 ⇒ 请宿主把绘图区滚动到该网格」的联动请求。
|
||||
* @param [IN] ref 被点击的信号。
|
||||
*/
|
||||
void RequestPlotLaneScroll(const SignalRef& ref);
|
||||
|
||||
/**
|
||||
* @brief 依据线宽滑块横坐标调整当前信号线宽。
|
||||
* @param [IN] x 客户区横坐标。
|
||||
@@ -1144,6 +1186,11 @@ namespace jdi
|
||||
bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */
|
||||
bool m_pendingGainMenu = false; /**< 左键抬起时是否弹出「系数」的二级面板。 */
|
||||
bool m_pendingMirrorMenu = false; /**< 左键抬起时是否弹出「镜像」的二级面板。 */
|
||||
bool m_pendingTargetLaneMenu = false; /**< 左键抬起时是否弹出「目标」行 GX 标签的网格菜单。 */
|
||||
|
||||
/** 待宿主处理的「把绘图区滚动到该网格」请求(见 `TakePlotLaneScrollRequest`)。 */
|
||||
bool m_plotLaneRequestFlag = false;
|
||||
int m_pendingPlotLane = -1;
|
||||
|
||||
/**
|
||||
* @brief 待确认的「关闭类」按钮(✕)。
|
||||
|
||||
@@ -48,6 +48,9 @@ namespace jdi
|
||||
m_groupAnims.clear();
|
||||
m_rowShift.clear();
|
||||
m_scrollAnimActive = false;
|
||||
m_pendingTargetLaneMenu = false;
|
||||
m_plotLaneRequestFlag = false;
|
||||
m_pendingPlotLane = -1;
|
||||
}
|
||||
|
||||
void SignalPanel::SetScale(float scale)
|
||||
@@ -453,7 +456,7 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::RefreshScrollBar()
|
||||
void SignalPanel::RefreshScrollBar(bool clampScroll)
|
||||
{
|
||||
if (m_listFullWidth <= 0.0f)
|
||||
{
|
||||
@@ -475,8 +478,11 @@ namespace jdi
|
||||
m_scrollBarRect = gd::RectF();
|
||||
}
|
||||
|
||||
if (clampScroll)
|
||||
{
|
||||
ClampScroll();
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::ClampScroll()
|
||||
{
|
||||
@@ -812,6 +818,41 @@ namespace jdi
|
||||
return m_tab->LaneOfSignal(SelectedRef());
|
||||
}
|
||||
|
||||
bool SignalPanel::ModeOptionEnabled(int optionIndex) const
|
||||
{
|
||||
if (optionIndex < 0 || optionIndex >= kModeOptionCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 单点信号固定用「散点」(见函数说明):其余两种绘制方式置灰且不可点。
|
||||
if (m_dataSet == nullptr || !SelectedRef().IsValid())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const SignalData* signal = m_dataSet->Resolve(SelectedRef());
|
||||
if (signal == nullptr || signal->finiteCount != 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return kModeOptions[optionIndex] == DrawMode::SCATTER;
|
||||
}
|
||||
|
||||
void SignalPanel::RequestPlotLaneScroll(const SignalRef& ref)
|
||||
{
|
||||
if (m_tab == nullptr || !ref.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 未勾选绘制到网格时 `LaneOfSignal` 返回 -1 ⇒ 不产生请求(没什么好滚的)。
|
||||
const int lane = m_tab->LaneOfSignal(ref);
|
||||
if (lane < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_pendingPlotLane = lane;
|
||||
m_plotLaneRequestFlag = true;
|
||||
}
|
||||
|
||||
void SignalPanel::UpdateTargetRowGeometry()
|
||||
{
|
||||
if (m_fonts == nullptr || m_dataSet == nullptr)
|
||||
|
||||
@@ -105,7 +105,7 @@ namespace jdi
|
||||
}
|
||||
|
||||
const std::uint64_t now = GetTickCount64();
|
||||
bool rowsBecameStale = false;
|
||||
bool animationFinished = false;
|
||||
for (std::size_t index = 0; index < m_groupAnims.size();)
|
||||
{
|
||||
const GroupAnim& anim = m_groupAnims[index];
|
||||
@@ -117,12 +117,7 @@ namespace jdi
|
||||
{
|
||||
m_scrollOffset = anim.scrollTo;
|
||||
}
|
||||
// 折起完成后该组的信号行要**真正移除** ⇒ 行集合需重建
|
||||
//(展开完成后行集合本来就是展开态,无需重建;平移量归零即可)。
|
||||
if (anim.collapsing)
|
||||
{
|
||||
rowsBecameStale = true;
|
||||
}
|
||||
animationFinished = true;
|
||||
m_groupAnims.erase(m_groupAnims.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
}
|
||||
else
|
||||
@@ -131,13 +126,26 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ **不设置 `m_rowsDirty`**:行高不随动画变化,只需重算逐行位移与内容高度 ——
|
||||
// ⚠️ **动画结束(折起与展开都要)后必须重建行集合**:
|
||||
// * 折起:该组的信号行要真正从行集合里移除;
|
||||
// * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”,
|
||||
// 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会
|
||||
// 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。
|
||||
// ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 ——
|
||||
// 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。
|
||||
UpdateAnimGeometry();
|
||||
if (rowsBecameStale)
|
||||
if (animationFinished)
|
||||
{
|
||||
m_rowsDirty = true;
|
||||
BuildRows(); // 内部会重算内容高度与逐行位移
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateAnimGeometry();
|
||||
}
|
||||
// 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间
|
||||
// 列表宽度/滚动条与内容不匹配)。⚠️ 不夹取滚动偏移:与滚动同步的折起动画正按自己的进度
|
||||
// 推进 `m_scrollOffset`,此时夹取会打断它。
|
||||
RefreshScrollBar(false);
|
||||
return !m_groupAnims.empty();
|
||||
}
|
||||
|
||||
@@ -1038,6 +1046,7 @@ namespace jdi
|
||||
m_pendingFilterClear = false;
|
||||
m_pendingGainMenu = false;
|
||||
m_pendingMirrorMenu = false;
|
||||
m_pendingTargetLaneMenu = false;
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -1120,7 +1129,9 @@ namespace jdi
|
||||
{
|
||||
for (int index = 0; index < kModeOptionCount; ++index)
|
||||
{
|
||||
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
|
||||
// 置灰的选项(单点信号的「线段」/「点线」)不参与悬停高亮 ——
|
||||
// 它们点不动,高亮会让用户以为可以点击。
|
||||
if (ModeOptionEnabled(index) && UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
|
||||
{
|
||||
hoverStyle = StyleHit::MODE;
|
||||
hoverStyleIndex = index;
|
||||
@@ -1255,6 +1266,7 @@ namespace jdi
|
||||
m_pendingColorPicker = false;
|
||||
m_pendingGainMenu = false;
|
||||
m_pendingMirrorMenu = false;
|
||||
m_pendingTargetLaneMenu = false;
|
||||
|
||||
// 点击当前输入框以外的位置时提交输入。
|
||||
if (m_editField != StyleEditField::NONE &&
|
||||
@@ -1274,18 +1286,27 @@ namespace jdi
|
||||
// 样式编辑区。
|
||||
if (SignalStyle* style = StyleOf(SelectedRef()))
|
||||
{
|
||||
// ---- 顶层「目标」行:单击**不进入编辑**(用户明确要求),只做联动滚动 ----
|
||||
// 双击改名由 `OnDoubleClick` 处理;这里单击即把列表滚动到目标信号,
|
||||
// 便于在长列表(上百条信号的 MF4)里定位正在编辑的那条信号。
|
||||
// ---- 顶层「目标」行 ----
|
||||
// 信号名:单击**不进入编辑**(用户明确要求),只做联动(列表滚动 + 绘图区滚动到该网格);
|
||||
// GX 标签:单击展开「所属网格」二级菜单(与信号列表行结尾的 GX 徽标**同一份菜单**)。
|
||||
// ⚠️ GX 标签**不做**联动滚动(用户 2026-10-16 要求):点击即开菜单、保持原地,
|
||||
// 但悬停仍然联动高亮(见 `OnMouseMove`)。
|
||||
{
|
||||
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)
|
||||
if (onBadge)
|
||||
{
|
||||
// 与网格徽标一致:按下只登记,**左键抬起**且指针仍在同一个徽标上时才展开菜单。
|
||||
m_pendingTargetLaneMenu = true;
|
||||
return true;
|
||||
}
|
||||
if (onName)
|
||||
{
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
RequestPlotLaneScroll(SelectedRef());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1314,6 +1335,11 @@ namespace jdi
|
||||
{
|
||||
if (UiPointInRect(m_modeRects[static_cast<std::size_t>(index)], fx, fy))
|
||||
{
|
||||
// 单点信号的「线段」/「点线」两项置灰 ⇒ 点了不生效(也不做联动滚动)。
|
||||
if (!ModeOptionEnabled(index))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
style->mode = kModeOptions[index];
|
||||
ScrollToSignalAnimated(SelectedRef());
|
||||
return true;
|
||||
@@ -1429,6 +1455,12 @@ namespace jdi
|
||||
m_pendingLaneMenu = true;
|
||||
m_pendingLaneRef = ref;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内
|
||||
//(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。
|
||||
RequestPlotLaneScroll(ref);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1550,6 +1582,22 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
// 「目标」行的 GX 标签:与信号行的网格徽标同一套交互(抬起且仍在同一徽标上才展开)。
|
||||
if (m_pendingTargetLaneMenu)
|
||||
{
|
||||
m_pendingTargetLaneMenu = false;
|
||||
const float fx = static_cast<float>(x);
|
||||
const float fy = static_cast<float>(y);
|
||||
UpdateTargetRowGeometry();
|
||||
if (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy) &&
|
||||
m_tab != nullptr && SelectedRef().IsValid())
|
||||
{
|
||||
OpenLaneMenu(SelectedRef(), static_cast<int>(m_targetBadgeRect.X),
|
||||
static_cast<int>(m_targetBadgeRect.Y + m_targetBadgeRect.Height));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -1859,7 +1907,13 @@ namespace jdi
|
||||
items.push_back(create);
|
||||
|
||||
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items);
|
||||
// ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格):
|
||||
// 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。
|
||||
// 这里限制高度(最多约占客户区高度的 62%):超出的部分可滚动翻页,
|
||||
// 而最后一个分隔线与它下面的「新建网格」**始终钉在菜单底部**(用户 2026-10-16 要求)。
|
||||
MenuLayoutConstraint constraint;
|
||||
constraint.maxHeight = std::max(180.0f, bounds.Height * 0.62f);
|
||||
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint);
|
||||
}
|
||||
|
||||
bool SignalPanel::OnMenuCommand(const MenuResult& result)
|
||||
|
||||
@@ -539,20 +539,26 @@ namespace jdi
|
||||
}
|
||||
|
||||
// ---- 第二行:样式(线段 / 散点 / 点线)----
|
||||
// ⚠️ 单点信号固定使用「散点」:「线段」在单个数据点上完全画不出来、「点线」与
|
||||
// 「放大自动点线」语义重叠,因此它们对单点信号**置灰且不可点**(用户 2026-10-16 要求)。
|
||||
{
|
||||
paintRowLabel(1, L"样式");
|
||||
|
||||
for (int index = 0; index < kModeOptionCount; ++index)
|
||||
{
|
||||
const gd::RectF& rect = m_modeRects[static_cast<std::size_t>(index)];
|
||||
const bool enabled = ModeOptionEnabled(index);
|
||||
const bool active = (style->mode == kModeOptions[index]);
|
||||
const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index);
|
||||
const bool hovered =
|
||||
enabled && (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index);
|
||||
|
||||
UiFillRoundRect(g, rect, kCorner * m_scale,
|
||||
active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg));
|
||||
UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f);
|
||||
UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect,
|
||||
active ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||||
!enabled ? m_theme->textMuted
|
||||
: (active ? m_theme->textStrong : m_theme->text),
|
||||
TextAlign::CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2288,6 +2288,108 @@ Components/Shared/Src/UiControls.cpp
|
||||
|
||||
---
|
||||
|
||||
### 9.38 五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(本轮,起源设备)
|
||||
|
||||
> 用户 2026-10-16 第四轮(起源设备)。
|
||||
> 离屏验证:**`.preflight/check`(新建)68/68 通过**(`SW_HIDE` + 只注入合成消息 + 进程/线程 `BELOW_NORMAL`);
|
||||
> **A/B**:五处修复全部回退 ⇒ **17 项如实失败**(B 4 / C 4 / D 7 / E 2),
|
||||
> 其中「折起后滚动条」需要**逐帧重绘驱动动画**才能复现 ⇒ 单独回退时**5 项如实失败**;
|
||||
> 恢复后 **68/68**;`make release` / `make debug` **零警告**。
|
||||
> 另按要求**删除 `Tools/`(`structcheck` 脚本)并移除 `make structcheck` 目标**(见本节七)。
|
||||
|
||||
#### 一、分组折起后再展开,信号列表的滚动条消失(用户报告)
|
||||
|
||||
- **现象**:加载 MF4 → 折起分组 → 展开分组 ⇒ 列表右侧滚动条**消失且不再回来**(筛选态、普通态均可复现)。
|
||||
- **根因**(两处叠加):
|
||||
* `AdvanceGroupAnimations` 只在**折起**完成时 `m_rowsDirty = true`(认为“展开后行集合本来就是展开态”);
|
||||
* `RefreshScrollBar()` 只在 `EnsureRows()`(行集脏)/`Layout()` 里被调用 ——
|
||||
展开结束那一帧 `m_contentHeight` 从“收起态”跳回“展开态”,但**没有任何路径去重算滚动条可见性**。
|
||||
* 于是 `m_scrollBarVisible` 停在动画开始时的 `false`、`m_listRect.Width` 停在“无滚动条”的整宽,
|
||||
直到用户做别的动作(筛选 / 缩放窗口 / 勾选信号)才恢复。
|
||||
- **修法**:`AdvanceGroupAnimations` 里 **任一方向的动画结束都 `m_rowsDirty = true` 并 `BuildRows()`**
|
||||
(内部重算内容高度与逐行位移),随后**每帧都调 `RefreshScrollBar(false)`** ——
|
||||
让滚动条可见性/列表可用宽度跟随动画中的内容高度实时更新。
|
||||
- ⚠️ `RefreshScrollBar` 新增 `clampScroll` 形参(默认 true);**动画期间必须传 false**:
|
||||
与滚动同步的折起动画(`scrollSynced`)正按自己的进度推进 `m_scrollOffset`,夹取会打断它。
|
||||
- ⚠️⚠️ **探针教训**:必须**逐帧重绘**(每帧 `Render()`)来驱动这个动画,
|
||||
否则 `EnsureRows()` 会在动画结束后顺手修好“完成帧的不一致”,把缺陷**掩盖**掉
|
||||
(第一版探针就是这样假绿的)。
|
||||
|
||||
#### 二、单点信号:默认绘制方式改「散点」+ 「线段」/「点线」置灰(用户要求)
|
||||
|
||||
- `ApplySinglePointDefaultStyle`:`DrawMode::LINE_SCATTER` → **`DrawMode::SCATTER`**(仍取最大线宽)。
|
||||
- 新增 `SignalPanel::ModeOptionEnabled(optionIndex)`:**只有一个有效采样点**的信号只允许「散点」,
|
||||
「线段」「点线」**置灰**(弱色文字)**且不可点**、**不参与悬停高亮**(避免“看着能点其实点不动”)。
|
||||
- `PlotView::UpdateAutoScatterStyles` **跳过单点信号**(`finiteCount == 1`):
|
||||
否则“放大自动点线”会把它又改回「点线」,与置灰状态自相矛盾。
|
||||
|
||||
#### 三、拖动线宽滑块完全不生效(用户报告)—— **根因是折线缓存键漏了线宽**
|
||||
|
||||
- **现象**:拖动线宽滑块时信号线宽**纹丝不动**;松开左键的瞬间变了;再移动鼠标又**变回初始线宽**。
|
||||
- **根因(两个,都要修)**:
|
||||
1. ⚠️⚠️ **`PlotView::AcquirePolyline` 的缓存键没有 `lineWidth`**,而 `BuildPolyline` 会把线宽
|
||||
**烘进包络带几何**(`strokeHalf = max(1, 线宽) * scale * 0.5`,逐列上下包络各向外扩这么多),
|
||||
「梳状」判定也依赖扩展后的列跨度 ⇒ **稠密信号(采样数 > 绘图区宽度)改变线宽时命中旧条目**、
|
||||
永远按旧线宽绘制(顶点、包络带全部复用)。实测常量信号:`0.5px → 5.3px → 10px` 时
|
||||
绘图区列厚度始终是 **1px**。
|
||||
2. **样式补丁没有同步宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”,
|
||||
而每网格图层仍是旧样式像素;下一帧命中“宽基底快路径”时会把新样式**覆盖回旧像素**
|
||||
⇒ 这正是用户看到的“松手后变了、移动鼠标又变回去”。
|
||||
- **修法**:
|
||||
* `PolylineCacheEntry` 新增 `lineWidth` 并进比较(凡是**被烘进几何**的样式都必须进键);
|
||||
* 样式补丁分支在 `RepaintSceneCachePatch` 之后 **`QueueLaneRebuild(受影响网格) + RenderPendingLaneRebuilds(1)`**
|
||||
—— 按既有预算机制(每帧至多 1 个网格)把宽基底同步到新样式,不在交互帧里做整批重建。
|
||||
- **实测**(`.preflight/check`,常量信号 + 逐帧测量信号色像素):
|
||||
`0.5px` → 列厚 **1px**;`5.3px` → **7px**;`10px` → **12px**;松手后 **12px 保持**;再移动鼠标 **仍 12px**。
|
||||
|
||||
#### 四、「目标」行 GX 标签复用「所属网格」菜单 + 限高滚动 + 底部固定项(用户要求)
|
||||
|
||||
- **入口**:「目标」行(样式区)的 GX 标签单击 ⇒ 展开与信号列表行末尾 GX 徽标**完全同一份**菜单
|
||||
(`SignalPanel::OpenLaneMenu`;按下只登记 `m_pendingTargetLaneMenu`,**抬起仍在同一徽标上**才展开)。
|
||||
- ⚠️ GX 标签点击**不做**联动滚动(只有信号名点击才滚动);**悬停联动仍然保留**(`StyleHit::TARGET_BADGE`)。
|
||||
- **`PopupMenu` 新增「限高 + 条目区滚动 + 尾部固定」能力**(`MenuLayoutConstraint::maxHeight`):
|
||||
* 菜单最高 ≈ 客户区高度的 62%(`OpenLaneMenu` 传入);
|
||||
* **固定区 = 最后一个分隔线及其之后的条目**(即「新建网格」+ 它上面的分隔线),
|
||||
始终钉在菜单底部(`m_pinnedIndex` / `m_pinnedTargetTop`);其余条目在滚动区内;
|
||||
* 滚轮滚动(`PopupMenu::OnMouseWheel`;`Application` 在菜单内滚动时**不关闭菜单**),
|
||||
键盘方向键移动悬停项时自动把它滚入可视区(`EnsureItemVisible`);
|
||||
* ⚠️ 滚动区文本必须用 **`UiTextClipped`**(GDI 文本不遵循 GDI+ 的 `SetClip`),
|
||||
并**跳过完全不可见的条目**(否则上百条逐条绘制照样卡顿);
|
||||
* ⚠️ `ItemAtPoint` 要求滚动区条目**同时命中滚动视口**(夹在边界上的部分可见条目只有可视部分可点);
|
||||
* ⚠️⚠️ **`BuildLayout` 不得无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发)
|
||||
会重走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”,实测踩到)。
|
||||
|
||||
#### 五、点选信号 ⇒ 绘图区自动把目标网格滚到可视区内(用户要求)
|
||||
|
||||
- **背景**:把 MF4 的上百条信号各自放进独立网格时,绝大多数网格在可视区之外,
|
||||
仅靠网格高亮无法定位到画面之外的网格。
|
||||
- **链路**:`SignalPanel::RequestPlotLaneScroll(ref)`(→ `m_pendingPlotLane`)
|
||||
→ `Application::ApplyPendingPlotLaneScroll()`(`WM_LBUTTONDOWN` 的面板分支里调用)
|
||||
→ `PlotView::ScrollLaneIntoView(laneIndex)`。
|
||||
- **触发条件**:**点击信号名**(列表行主体 / 样式区「目标」行的信号名)。
|
||||
⚠️ **悬停不滚动**(只保留既有网格高亮联动);勾选框 / GX 徽标 / 移除 ✕ 也不滚动。
|
||||
- **规则**:目标网格在可视区上方 ⇒ 向上滚到它的上边界贴绘图区上沿;在下方 ⇒ 向下滚到它的下边界贴下沿;
|
||||
已完整可见 ⇒ **不动视图**(避免无意义改动)。
|
||||
|
||||
#### 六、验证(全部离屏,不占用鼠标 / 键盘 / 屏幕)
|
||||
|
||||
- 新增 `.preflight/check/{main.cpp,build.ps1}`(不入库):真实 `Application`(`SW_HIDE`)+ 合成消息注入,
|
||||
六个断言组:**[A]** 折起/展开与滚动条、**[B]** 单点信号样式与置灰(含“悬停置灰按钮像素零变化”)、
|
||||
**[C]** 线宽拖动逐帧像素度量、**[D]** 「目标」行 GX 菜单(限高 / 滚动 / 固定项 / 命中 / 复用换网格)、
|
||||
**[E]** 点选信号滚动绘图区(含“悬停不滚动”与“已可见不动”)、**[F]** 回归(列表行徽标菜单、向上滚动、筛选后滚动条)。
|
||||
- 结果:**68/68 通过**;`make release` / `make debug` **零警告**。
|
||||
- **A/B 反证**:五处修复全部回退 ⇒ **17 项如实失败**(B 4 / C 4 / D 7 / E 2);
|
||||
A 组(滚动条)因探针需要**逐帧重绘**才能复现,单独回退时 ⇒ **5 项如实失败**。
|
||||
|
||||
#### 七、按要求移除远端协助设备的测试工具(用户明确要求)
|
||||
|
||||
- 删除工作区 `Tools/`(`structcheck.ps1` / `structcheck_phrases.txt`)并移除 `Makefile` 的 `structcheck` 目标
|
||||
(该工具只是另一台设备上次为“多文件拆分”验证一次性上传的)。
|
||||
- ⚠️ §9.37 / §11.2 第 96 条里凡是“`make structcheck` 可复跑”的描述**自此失效**,
|
||||
仅作为当时那一轮拆分的**历史证据**保留;后续若要再加结构检查,需要重新上传脚本。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志(本文件维护记录)
|
||||
|
||||
| 日期 | 设备/模型 | 摘要 |
|
||||
@@ -2339,6 +2441,7 @@ Components/Shared/Src/UiControls.cpp
|
||||
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **动画改「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(详见 §9.36)**。① **动画重做**(推翻上一轮的“按行缩放”):整组信号作为**刚性整体**平移,**行高永不变**、只维护一个逐行平移量 `m_rowShift` ⇒ 动画期间**无需重建行集合**(流畅关键);收起时逐条被组名栏吞掉、展开时逐条冒出;中途反向点击**从当前位移续接**不跳变;时长 `clamp(115+6×行数, 150, 320)` ms。② **藏进组名栏的裁剪**:信号行越过组名的**绘制**下边界即裁掉,且除 GDI 裁剪外**必须用 `g.SetClip(..., CombineModeIntersect)` 同时收紧 GDI+ 裁剪**(否则行内色块/复选框/徽标画到组名栏上)。③ **悬浮组名折起改为“一边滚动一边收起”**(推翻“先滚动到位再折起”):`scrollSynced` 让滚动与平移按同一进度推进 ⇒ 组名屏幕位置恒定;结束时滚动偏移恰好落在“组名正好在最上方”,并且**无跳变**;旧 `m_pendingGroupToggle` 机制删除。④ **筛选态折叠三角**:`PaintFileRow` 改读 `IsGroupCollapsed()`(筛选模式用的是独立的 `m_filterCollapsed`),修复“三角维持筛选前状态”。⑤ **「目标」行**:`PaintTargetRow` 误用列表裁剪版 `PaintRowText` ⇒ 名称与 GX 文本被完全裁掉(改用 `UiText`);GX 标签改为**右对齐到内容列右边缘**、名称无标签时占满内容列;`EditFieldRect()` 对目标行只返回名称矩形(否则改名输入框把 GX 标签包进去)。验证:离屏探针 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定;含逐帧断言组名栏像素零变化、`m_rowsDirty` 恒 false、滚动单调且恰好落位、筛选/普通态折叠三角逐像素 0 差异、目标行文本像素存在与两端对齐);**A/B** 五组(回退筛选三角+非刚性平移 / 回退目标行绘制与布局 / 关闭同步滚动 / 去掉 GDI+ 收紧裁剪 / 不续接位移)分别 **4 / 6 / 6 / 2 / 1 项如实失败**;`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 |
|
||||
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ 新增 `Components/Shared` 组件 + 过时注释/命名大清理 + 二进制瘦身 24.6% 并完成多文件拆分(详见 §9.37 与本轮报告 `Documents/refactor_report_2026-10-16.md`)**。① **折起热区**(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签**(新增 `RowPart::GROUP_TITLE`)才折起。② **分组改名输入框不再吞掉「N 项」**:`RowNameRect` 右边界改为项数左缘;新增 `GroupLabelText`/`GroupStatsText` 作为文本与宽度的唯一来源。③ **`SignalData`/`SignalGroup` 只留一个名称字段**:删除 `displayName` 与 `Title()`(31 处改用 `name`),`SetSignalDisplayName`/`SetGroupDisplayName` → **`RenameSignal`/`RenameGroup`**(空名一律拒绝);删除死成员 `Application::m_loadingBytes`。④ **新增 `Components/Shared`**(用户要求):从 `PaintUtil` 迁出全部**控件**(图标/复选框/滑块/内联输入框/颜色样块/取值胶囊配色)到 `UiControls.hpp/.cpp`,`PaintUtil` 只留低层原语;依赖方向固定 `Components/* → Components/Shared → Utilities`。⑤ **过时注释/命名**:`文件下标`→`分组下标`、`文件分组`→`分组` 等一批;形参名 `file`→`axis`/`group`;`PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、`FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`;修复 `DataModel.hpp` 两处被压扁的注释块与 `NewTab` 重复文档块。⑥ **多文件拆分(本轮完成)**:`PlotView.cpp`(13 695 行)+ `SignalPanel.cpp` + `Application.cpp` + `DataModel.cpp` 四个大文件全部按功能拆成多个协作文件(私有内部头 + 具名命名空间 + 全 `inline` + 按名字分桶),最大单文件 544 KB → 276 KB。⑦ **二进制瘦身**:真因是 **ICO 里 256×256 未压缩 BMP 条目(270 KB,占整包 27%)**,改为 **PNG 存储**(观感零变化)⇒ `Target.exe` **1 013 248 → 764 416 B(−24.6%)**;实测“拆分不会减小机器码,反而 +1.4%”、`--gc-sections` 反而 +9 KB(已回退)。验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;**A/B** 回退两处 UI 修复 **10 项如实失败**;`make release`/`make debug` 零警告。⚠️ 该提交当时的待办(`PlotView.cpp` 等大文件多文件化拆分、`LanePanelState` 抽取、模态提示解耦)**已全部在紧随其后的两个提交里完成**:拆分 → 提交 **`e6b7bf6`**(详见 §9.37 第七节),`LanePanelState` + 模态解耦 → 提交 **`d2e6fdd`**(详见 §9.37 第八节)。**本行不再表示任何未完成项。** |
|
||||
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **收尾:`LanePanelState` 抽取 + 错误提示非模态化(把上一行的「未完成」清零,详见 §9.37 第八节与报告 `Documents/refactor_report_2026-10-16.md` §九)**。① **`Lane` 的 12 个平铺面板字段**(`edgePanelOpen/X/Y` × 4 类实验性功能)→ **4 个 `LanePanelState`**(`struct LanePanelState { bool open=true; double x=-1; double y=-1; }`),访问点全部改为 `lane.edgePanel.open` 形式;`RebuildLanes` 的 `LaneCarry` 同步收敛为 4 个 `LanePanelState`(逐字段赋值 → 整体赋值)。⚠️ 教训:**批量改名脚本不能对「声明」与「使用」跑同一组替换对**(会生成 `bool edgePanel.open = true;` 非法声明),声明处须单独重写。② **错误提示与模态阻塞解耦**:新增 `Application::SetModalErrorsEnabled(bool)`/`ModalErrorsEnabled()`(成员默认 `true`),`Initialize` **最先**执行 `SetModalErrorsEnabled(showCommand != SW_HIDE)`(隐藏窗口弹模态框 = 进程被钉死);`SetStatusMessage(err,true)` 允许模态时原样 `MessageBoxW`,否则走 `m_plot.ShowPersistentHint(text)`(绘图区底部非模态浮层);`PlotView` 新增公开 `ShowPersistentHint`。普通提示/空文本仍被忽略 ⇒ **交互行为不变**。③ **顺带修掉真实缺陷**:`PlotView::Paint` 在 `m_lanes.empty()` 时提前 `return`,提示浮层在其后绘制 ⇒ **未勾选任何信号时错误永远不可见**(正是"加载失败"场景),该分支补一次 `PaintHint(g)`。④ **新增探针 `.preflight/perf/errcheck` 19/19**:默认值/隐藏降级/宿主开关、**工作线程 + 5s 超时**判"不阻塞"(旧行为会永久阻塞 ⇒ 判失败并 `ExitProcess`,探针不挂)、`EnumWindows` 数本进程 `#32770` 顶层窗口数不变、提示文本/粘滞/需重绘/起始时刻、**像素级**"有/无提示"差异 3781 且全帧差异同为 3781。验证:`make release`/`make debug` 零警告,`Target.exe` **764 416 B**;`verify` 49/49、`datacheck` 69/69、`edgecheck` 46/46、`panelanim` 156/156。⑤ **新增结构检查** `Tools/structcheck.ps1` + `make structcheck`(**93/93**):[1] 四个巨型文件已按职责拆分(`PlotView.cpp` 13 695 行 → 705 行,`nm`:绘制符号只在 `PlotViewPaint.o`);[2] `Components/Shared` 16 个控件全部由 `UiControls.o` 定义、69 处调用跨 ≥3 个组件 + 分层方向;[3] 本轮三项用户要求的源码级断言(折起热区、改名框不吞项数、`SignalData` 单一 `name` 字段 + 注释核验)。**判别力 A/B**:回退两处 UI 修复 ⇒ `panelanim` 147/156(9 项如实失败,文案点名“group rename box stops before the item counter”“clicking the group name does not collapse the group”)、`structcheck` 90/93;恢复后 156/156 与 93/93。 |
|
||||
| 2026-10-16 | DeepSeek-V4.1-Flash(**起源设备**) | **五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(详见 §9.38)**。① **分组折起后再展开滚动条消失**:`AdvanceGroupAnimations` 只在折起结束时重建行集合,而展开结束那一帧 `m_contentHeight` 跳回展开态却无人重算滚动条可见性 ⇒ 滚动条停在“隐藏”。修法:任一方向结束都 `m_rowsDirty = true` + `BuildRows()`,并**每帧** `RefreshScrollBar(false)`(动画期间不夹取滚动偏移,避免打断 `scrollSynced`)。② **单点信号默认绘制方式改「散点」**(原「点线」),且信号面板「样式」行的「线段」「点线」对它**置灰且不可点**(新增 `ModeOptionEnabled`,也不参与悬停高亮);`UpdateAutoScatterStyles` 跳过单点信号,避免“自动点线”与置灰状态自相矛盾。③ **拖动线宽滑块完全不生效**(用户报告:拖动时不变、松手瞬间变了、再移动鼠标又变回去)——根因两个:**`AcquirePolyline` 的缓存键漏了 `lineWidth`**(而 `BuildPolyline` 把线宽烘进包络带几何:逐列上下包络各外扩半个线宽,「梳状」判定也依赖它),以及**样式补丁没有同步宽基底图层**(下一帧宽基底快路径把新样式覆盖回旧像素)。修法:`PolylineCacheEntry` 新增 `lineWidth` 并进比较;样式补丁后 `QueueLaneRebuild(patchLanes) + RenderPendingLaneRebuilds(1)`。实测常量信号列厚 `0.5px→1px / 5.3px→7px / 10px→12px` 且松手与移动鼠标后**保持 12px**。④ **「目标」行的 GX 标签复用「所属网格」菜单**(与列表行徽标同一份),并先给 `PopupMenu` 加**限高 + 条目区滚动 + 尾部固定项**(`MenuLayoutConstraint::maxHeight`;固定区 = 最后一个分隔线及其后的「新建网格」;滚轮滚动、键盘移动自动滚入可视区;滚动区文本用 `UiTextClipped` 并跳过不可见条目;`ItemAtPoint` 增加滚动视口判定)——顺手解决“上百个网格时菜单超长且交互卡顿”;GX 标签点击**不参与**联动滚动,悬停联动保留。⑤ **点选信号名 ⇒ 绘图区自动把目标网格滚到可视区内**(`RequestPlotLaneScroll` / `ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):在上方就上滚、在下方就下滚、已可见则不动;**悬停不滚动**。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**68/68**;**A/B** 五处修复全回退 ⇒ **17 项如实失败**(A 组需逐帧重绘驱动,单独回退 ⇒ 5 项);`make release` / `make debug` 零警告。⑥ 按要求**删除 `Tools/`(`structcheck`)并移除 `make structcheck` 目标**(§9.37/§11.2 第 96 条的“可复跑”描述自此失效,仅作历史证据)。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -2808,6 +2911,37 @@ Components/Shared/Src/UiControls.cpp
|
||||
因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。
|
||||
⚠️ 宿主定时器同时驱动「折起/展开动画」(见第 77 条):`Render` 的判定必须是
|
||||
`ScrollAnimationActive() || GroupAnimationActive()`。
|
||||
97. ⚠️ **分组折起/展开**动画结束的那一帧**必须重建行集合并按新内容高度刷新滚动条**:
|
||||
`AdvanceGroupAnimations` 里任一方向结束都会 `m_rowsDirty = true` + `BuildRows()`(重算
|
||||
`m_contentHeight` 与逐行位移),随后**每帧** `RefreshScrollBar(false)`。
|
||||
旧实现只在折起结束时重建 ⇒ **展开后滚动条消失且不再回来**(用户 2026-10-16 报告)。
|
||||
⚠️ `RefreshScrollBar(clampScroll = false)`:动画期间不能夹取滚动偏移(会打断 `scrollSynced`)。
|
||||
⚠️ 探针必须以**逐帧重绘**驱动动画,否则 `EnsureRows()` 会把缺陷掩盖掉。
|
||||
98. ⚠️ **单点信号(`finiteCount == 1`)固定使用「散点」**:`ApplySinglePointDefaultStyle` 写入
|
||||
`DrawMode::SCATTER` + 最大线宽;信号面板「样式」行的「线段」「点线」对它**置灰且不可点**
|
||||
(`SignalPanel::ModeOptionEnabled`,同时不参与悬停高亮);
|
||||
`PlotView::UpdateAutoScatterStyles` **必须跳过单点信号**(否则会又把它改回「点线」)。
|
||||
99. ⚠️⚠️ **折线顶点缓存(`PolylineCacheEntry`)的键必须包含所有“被烘进几何”的样式** ——
|
||||
目前是 **`lineWidth`**(`BuildPolyline` 把每个像素列的上下包络向外扩 `max(1, 线宽)*scale*0.5`,
|
||||
「梳状」判定也依赖它)。漏掉线宽会让**稠密信号**(采样数 > 绘图区宽)的线宽改动完全不生效
|
||||
(用户 2026-10-16 报告:拖动线宽滑块无变化、松手后变了、再移动鼠标又变回去)。
|
||||
新增任何影响顶点/包络几何的样式时,必须同步加入缓存键。
|
||||
100. ⚠️ **“样式补丁”(`RepaintSceneCachePatch`)之后必须同步重建受影响的宽基底图层**
|
||||
(`QueueLaneRebuild(patchLanes)` + `RenderPendingLaneRebuilds(1)`):
|
||||
补丁只更新“场景缓存 + 后备缓冲区”,而每网格图层(宽基底)里仍是旧样式像素,
|
||||
下一帧命中“宽基底快路径”时会把新样式**覆盖回旧像素**(同上一条的第二个根因)。
|
||||
101. ⚠️ **`PopupMenu` 的“限高 + 可滚动 + 尾部固定项”**(`MenuLayoutConstraint::maxHeight`):
|
||||
固定区 = **最后一个分隔线及其之后的条目**(如「新建网格」)钉在菜单底部,其余条目可滚动;
|
||||
⚠️ 滚动区文本必须用 `UiTextClipped`(GDI 不遵循 `SetClip`)、**跳过完全不可见的条目**(性能);
|
||||
`ItemAtPoint` 要求滚动区条目同时命中**滚动视口**;
|
||||
⚠️⚠️ `BuildLayout` **不得无条件清零 `m_scrollOffset`**(`RebuildLayout` 会重走它 ⇒ 滚轮“滚不动”)。
|
||||
`Application` 在菜单内滚动滚轮时**不关闭菜单**(其余情形仍是“滚轮关菜单”)。
|
||||
102. ⚠️ **“点选信号 ⇒ 绘图区滚到目标网格”只在点击时生效**(`RequestPlotLaneScroll` /
|
||||
`ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):**悬停绝不改变视图**;
|
||||
目标网格已完整可见时**不动**。“目标”行 GX 标签点击 = 打开网格菜单(不滚动),悬停仍联动高亮。
|
||||
103. ⚠️ **交互类缺陷的离屏探针必须“每帧重绘”驱动**(动画 / 分帧推进 / 自持帧):
|
||||
面板 `Paint` → `EnsureRows()`、绘图区 `Paint` → 预算推进,都会在重绘时**顺手修复**一部分
|
||||
中间态;只在动作结束后才重绘会把“完成帧”的缺陷掩盖掉(本轮滚动条缺陷就因此假绿过一次)。
|
||||
|
||||
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
|
||||
|
||||
|
||||
@@ -188,11 +188,6 @@ debug: $(BUILD_DIR)/$(SUB_DIR_DEBUG)/$(TARGET_FILE_NAME).exe
|
||||
clean: $(BUILD_DIR)
|
||||
powershell rm -r $(BUILD_DIR)
|
||||
|
||||
# 结构检查任务:校验「巨型文件按职责拆分」与「Components/Shared 共享控件」两项结构约束
|
||||
# (需要先完成一次 release 构建,脚本会读取 Build/Release 下的目标文件)
|
||||
structcheck:
|
||||
powershell -ExecutionPolicy Bypass -File Tools/structcheck.ps1
|
||||
|
||||
# 构建目录生成
|
||||
$(BUILD_DIR):
|
||||
powershell mkdir $@
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# Structural verification (NOT part of the shipped app; under .preflight/, gitignored).
|
||||
#
|
||||
# Proves on disk -- not by prose -- that the two structural requirements are satisfied:
|
||||
# [1] The giant implementation files were split by responsibility into sibling files
|
||||
# under each component's own Src/ (and each new translation unit is compiled).
|
||||
# [2] The common UI controls live in a real `Components/Shared` component, are compiled
|
||||
# into the binary, and are consumed by the other components.
|
||||
#
|
||||
# Run: powershell -File .preflight\perf\structcheck.ps1
|
||||
# ASCII only (PowerShell 5.1 reads .ps1 as ANSI).
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
$pass = 0
|
||||
$fail = 0
|
||||
|
||||
function Check([bool]$ok, [string]$what) {
|
||||
if ($ok) { $script:pass++ ; Write-Host (" OK " + $what) }
|
||||
else { $script:fail++ ; Write-Host (" FAIL " + $what) }
|
||||
}
|
||||
|
||||
function Lines([string]$path) {
|
||||
if (-not (Test-Path $path)) { return -1 }
|
||||
return (Get-Content $path | Measure-Object -Line).Lines
|
||||
}
|
||||
|
||||
Write-Host "== [1] split of the giant implementation files =="
|
||||
|
||||
# 1a. every expected file of the split exists and is a sibling under the component's Src/
|
||||
$split = @(
|
||||
"Components/PlotView/Src/PlotView.cpp",
|
||||
"Components/PlotView/Src/PlotViewPaint.cpp",
|
||||
"Components/PlotView/Src/PlotViewInteraction.cpp",
|
||||
"Components/PlotView/Src/PlotViewFeatures.cpp",
|
||||
"Components/PlotView/Src/PlotViewInternal.hpp",
|
||||
"Components/SignalPanel/Src/SignalPanel.cpp",
|
||||
"Components/SignalPanel/Src/SignalPanelPaint.cpp",
|
||||
"Components/SignalPanel/Src/SignalPanelInteraction.cpp",
|
||||
"Components/SignalPanel/Src/SignalPanelInternal.hpp",
|
||||
"Components/Application/Src/Application.cpp",
|
||||
"Components/Application/Src/ApplicationCommands.cpp",
|
||||
"Components/Application/Src/ApplicationLoading.cpp",
|
||||
"Components/Application/Src/ApplicationInternal.hpp",
|
||||
"Utilities/Src/DataModel.cpp",
|
||||
"Utilities/Src/DataModelTab.cpp",
|
||||
"Utilities/Src/DataModelInternal.hpp"
|
||||
)
|
||||
foreach ($f in $split) {
|
||||
$n = Lines $f
|
||||
Check ($n -gt 0) ("exists + non-empty: " + $f + " (" + $n + " lines)")
|
||||
}
|
||||
|
||||
# 1b. the core file of each split is a small fraction of the original monster file
|
||||
Check ((Lines "Components/PlotView/Src/PlotView.cpp") -lt 900) "PlotView.cpp shrunk to < 900 lines (was 13 695)"
|
||||
Check ((Lines "Components/PlotView/Src/PlotViewPaint.cpp") -gt 4000) "PlotViewPaint.cpp carries the rendering (> 4000 lines)"
|
||||
Check ((Lines "Components/SignalPanel/Src/SignalPanel.cpp") -lt 1200) "SignalPanel.cpp shrunk to < 1200 lines (was 4 184)"
|
||||
Check ((Lines "Components/Application/Src/Application.cpp") -lt 1500) "Application.cpp shrunk to < 1500 lines (was 3 135)"
|
||||
Check ((Lines "Utilities/Src/DataModel.cpp") -lt 1600) "DataModel.cpp shrunk to < 1600 lines (was 2 615)"
|
||||
|
||||
# 1c. responsibility really moved: each concern is defined in exactly one file
|
||||
$concerns = @(
|
||||
@{ symbol = "PlotView::Paint"; file = "PlotViewPaint.cpp" },
|
||||
@{ symbol = "PlotView::PaintLane"; file = "PlotViewPaint.cpp" },
|
||||
@{ symbol = "PlotView::PaintHint"; file = "PlotViewPaint.cpp" },
|
||||
@{ symbol = "PlotView::LaneAtY"; file = "PlotViewInteraction.cpp" },
|
||||
@{ symbol = "PlotView::CancelDrag"; file = "PlotViewInteraction.cpp" },
|
||||
@{ symbol = "PlotView::FitAll"; file = "PlotViewFeatures.cpp" },
|
||||
@{ symbol = "PlotView::SetHint"; file = "PlotViewFeatures.cpp" },
|
||||
@{ symbol = "PlotView::Layout"; file = "PlotView.cpp" },
|
||||
@{ symbol = "PlotView::BuildLaneVisuals"; file = "PlotView.cpp" },
|
||||
@{ symbol = "SignalPanel::Paint"; file = "SignalPanelPaint.cpp" },
|
||||
@{ symbol = "SignalPanel::OnLButtonDown"; file = "SignalPanelInteraction.cpp" },
|
||||
@{ symbol = "SignalPanel::EnsureRows"; file = "SignalPanel.cpp" }
|
||||
)
|
||||
foreach ($c in $concerns) {
|
||||
$hits = @(Select-String -Path "Components/*/Src/*.cpp", "Utilities/Src/*.cpp" -Pattern ("^\s*\w[\w:<>, ]* " + [regex]::Escape($c.symbol) + "\(") |
|
||||
ForEach-Object { Split-Path $_.Path -Leaf })
|
||||
$ok = ($hits.Count -eq 1) -and ($hits[0] -eq $c.file)
|
||||
Check $ok ($c.symbol + " defined only in " + $c.file + " (found: " + ($hits -join ",") + ")")
|
||||
}
|
||||
|
||||
# 1d. the constant/tool block of each big file became a named internal header + using-declaration
|
||||
$internal = @(
|
||||
@{ file = "Components/PlotView/Src/PlotViewInternal.hpp"; ns = "pvdetail" },
|
||||
@{ file = "Components/SignalPanel/Src/SignalPanelInternal.hpp"; ns = "spdetail" },
|
||||
@{ file = "Components/Application/Src/ApplicationInternal.hpp"; ns = "appdetail" },
|
||||
@{ file = "Utilities/Src/DataModelInternal.hpp"; ns = "dmdetail" }
|
||||
)
|
||||
foreach ($i in $internal) {
|
||||
$text = Get-Content $i.file -Raw
|
||||
$hasNs = $text -match ("namespace\s+" + $i.ns)
|
||||
$hasUsing = $text -match ("using namespace\s+" + $i.ns + "\s*;")
|
||||
$inlineCount = ([regex]::Matches($text, "(?m)^\s*(?:\[\[[^\]]*\]\]\s*)?inline\s")).Count
|
||||
Check $hasNs ($i.file + " declares namespace " + $i.ns)
|
||||
Check $hasUsing ($i.file + " injects it via the using-declaration for " + $i.ns)
|
||||
Check ($inlineCount -ge 4) ($i.file + " marks the shared helpers inline (" + $inlineCount + " definitions)")
|
||||
}
|
||||
|
||||
# 1e. the split files are compiled, and their objects hold the moved code
|
||||
$objs = @("PlotView.o","PlotViewPaint.o","PlotViewInteraction.o","PlotViewFeatures.o",
|
||||
"SignalPanel.o","SignalPanelPaint.o","SignalPanelInteraction.o",
|
||||
"Application.o","ApplicationCommands.o","ApplicationLoading.o",
|
||||
"DataModel.o","DataModelTab.o")
|
||||
$missing = @()
|
||||
foreach ($o in $objs) { if (-not (Test-Path ("Build/Release/" + $o))) { $missing += $o } }
|
||||
Check ($missing.Count -eq 0) ("all split translation units were compiled (missing: " + ($missing -join ",") + ")")
|
||||
|
||||
$nm = Get-Command nm -ErrorAction SilentlyContinue
|
||||
if ($nm -ne $null) {
|
||||
$paintInPaint = @(nm --defined-only -C "Build/Release/PlotViewPaint.o" 2>$null | Select-String "jdi::PlotView::Paint")
|
||||
$paintInCore = @(nm --defined-only -C "Build/Release/PlotView.o" 2>$null | Select-String "jdi::PlotView::Paint")
|
||||
Check ($paintInPaint.Count -gt 0) "nm: paint symbols live in PlotViewPaint.o"
|
||||
Check ($paintInCore.Count -eq 0) "nm: no paint symbol left in PlotView.o"
|
||||
} else {
|
||||
Check $false "nm not available (cannot verify symbol placement)"
|
||||
}
|
||||
|
||||
# 1f. every implementation file of every component is registered in the Makefile
|
||||
$srcFiles = @(Get-ChildItem "Components/*/Src/*.cpp", "Utilities/Src/*.cpp" | ForEach-Object {
|
||||
$_.FullName.Substring($root.Length + 1).Replace("\", "/")
|
||||
})
|
||||
$makefile = Get-Content "Makefile" -Raw
|
||||
$unregistered = @()
|
||||
foreach ($s in $srcFiles) {
|
||||
$dir = $s.Substring(0, $s.LastIndexOf("/"))
|
||||
if ($makefile -notmatch [regex]::Escape($dir)) { $unregistered += $s }
|
||||
}
|
||||
Check ($unregistered.Count -eq 0) ("every implementation directory is registered in the Makefile (missing: " + ($unregistered -join ",") + ")")
|
||||
Check ($srcFiles.Count -ge 24) ("implementation files in the tree: " + $srcFiles.Count)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "== [2] Components/Shared =="
|
||||
|
||||
Check (Test-Path "Components/Shared/Inc/UiControls.hpp") "Components/Shared/Inc/UiControls.hpp exists"
|
||||
Check (Test-Path "Components/Shared/Src/UiControls.cpp") "Components/Shared/Src/UiControls.cpp exists"
|
||||
Check ((Lines "Components/Shared/Inc/UiControls.hpp") -gt 100) ("UiControls.hpp declares the control API (" + (Lines "Components/Shared/Inc/UiControls.hpp") + " lines)")
|
||||
|
||||
# 2a. the shared header really owns the enumerated control families
|
||||
$families = @("UiDrawPlusIcon","UiDrawCrossIcon","UiDrawTriangleIcon","UiDrawCheckBox",
|
||||
"UiDrawSlider","UiDrawEditField","UiEditFieldMouseDown","UiDrawSwatch",
|
||||
"UiSwatchNeedsOutline","UiContrastInk","UiSliderRatioFromX")
|
||||
$header = Get-Content "Components/Shared/Inc/UiControls.hpp" -Raw
|
||||
$missingFamilies = @()
|
||||
foreach ($f in $families) { if ($header -notmatch ("\b" + $f + "\b")) { $missingFamilies += $f } }
|
||||
Check ($missingFamilies.Count -eq 0) ("shared header exposes all control families (missing: " + ($missingFamilies -join ",") + ")")
|
||||
|
||||
# 2b. symbol-level proof: every declared control is DEFINED by UiControls.o and by no other object.
|
||||
# (Text matching cannot tell a definition from a call, so ask the linker's view instead.)
|
||||
$declared = @([regex]::Matches($header, "(?m)^\s*[\w:<>, ]+\s+(Ui\w+)\s*\(") | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||
Check ($declared.Count -ge 15) ("UiControls.hpp declares " + $declared.Count + " control functions")
|
||||
|
||||
$definitionOwner = @{}
|
||||
$objectFiles = @(Get-ChildItem "Build/Release/*.o" -ErrorAction SilentlyContinue)
|
||||
if ($nm -eq $null) {
|
||||
Check $false "nm not available (cannot verify control definitions)"
|
||||
} else {
|
||||
foreach ($o in $objectFiles) {
|
||||
$syms = nm --defined-only -C $o.FullName 2>$null
|
||||
foreach ($name in $declared) {
|
||||
if ($syms | Select-String (" T jdi::" + [regex]::Escape($name) + "\(")) {
|
||||
$definitionOwner[$name] = $o.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
$wrongOwner = @($declared | Where-Object { -not $definitionOwner.ContainsKey($_) -or $definitionOwner[$_] -ne "UiControls.o" })
|
||||
Check ($wrongOwner.Count -eq 0) ("nm: all " + $declared.Count + " declared controls are defined in UiControls.o (wrong/undefined: " + ($wrongOwner -join ",") + ")")
|
||||
$uiControlsSymbols = @(nm --defined-only -C "Build/Release/UiControls.o" 2>$null | Select-String " T jdi::Ui").Count
|
||||
Check ($uiControlsSymbols -ge 15) ("nm: UiControls.o defines " + $uiControlsSymbols + " Ui* symbols")
|
||||
}
|
||||
|
||||
# 2c. other components actually consume them (>= 3 components, >= 30 call sites)
|
||||
$consumers = @(Select-String -Path "Components/*/Src/*.cpp", "Components/*/Inc/*.hpp" -Pattern "UiDrawPlusIcon|UiDrawCrossIcon|UiDrawTriangleIcon|UiDrawCheckBox|UiDrawSlider|UiDrawEditField|UiEditFieldMouseDown|UiEditFieldMouseMove|UiDrawSwatch|UiSwatchNeedsOutline|UiContrastInk|UiSliderRatioFromX|UiEditTextLeft|UiEditOffsetAt" |
|
||||
Where-Object { (Split-Path $_.Path -Leaf) -ne "UiControls.hpp" } |
|
||||
Group-Object { ($_.Path.Substring($root.Length + 1) -split "[\\/]")[1] })
|
||||
$componentNames = @($consumers | ForEach-Object { $_.Name })
|
||||
$totalCalls = 0
|
||||
foreach ($g in $consumers) { $totalCalls += $g.Count }
|
||||
Check ($componentNames.Count -ge 3) ("shared controls consumed by >= 3 components (" + ($componentNames -join ",") + ")")
|
||||
Check ($totalCalls -ge 30) ("shared control call sites across components: " + $totalCalls)
|
||||
|
||||
# 2d. layering, stated exactly as the code obeys it:
|
||||
# Components/Shared -> Utilities only (it must not include any component header)
|
||||
# Utilities/PaintUtil = base layer (must not include UiControls.hpp)
|
||||
# Utilities must not include feature components (PlotView / SignalPanel / Toolbar / TabBar)
|
||||
# NOTE: Utilities/Src/PopupMenu.cpp and Utilities/Src/ColorPickerPopup.cpp are hand-drawn UI
|
||||
# widgets that live in the Utilities folder historically; they consume the shared controls
|
||||
# (same direction as the components do) and are deliberately excluded from the ban below.
|
||||
$sharedIncludes = @(Select-String -Path "Components/Shared/Inc/*.hpp", "Components/Shared/Src/*.cpp" -Pattern '^#include "' |
|
||||
Where-Object { $_.Line -match '"(Application|PlotView|SignalPanel|Toolbar|TabBar|PopupMenu|ColorPickerPopup)\.hpp"' })
|
||||
Check ($sharedIncludes.Count -eq 0) "Components/Shared depends on Utilities only (no component header)"
|
||||
$paintUtilIncludes = @(Select-String -Path "Utilities/Inc/PaintUtil.hpp", "Utilities/Src/PaintUtil.cpp" -Pattern '#include "UiControls.hpp"' -ErrorAction SilentlyContinue)
|
||||
Check ($paintUtilIncludes.Count -eq 0) "Utilities/PaintUtil (base layer) does not depend on Components/Shared"
|
||||
$badDir = @(Select-String -Path "Utilities/Src/*.cpp", "Utilities/Inc/*.hpp" -Pattern '#include "(PlotView|SignalPanel|Toolbar|TabBar)' -ErrorAction SilentlyContinue)
|
||||
Check ($badDir.Count -eq 0) "Utilities never includes a feature component"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "== [3] user-requested behaviour invariants (source level) =="
|
||||
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
$panelFile = "Components/SignalPanel/Src/SignalPanel.cpp"
|
||||
$panelIntFile = "Components/SignalPanel/Src/SignalPanelInteraction.cpp"
|
||||
$modelFile = "Utilities/Inc/DataModel.hpp"
|
||||
|
||||
$panelText = [System.IO.File]::ReadAllText($panelFile, $utf8)
|
||||
$panelIntText = [System.IO.File]::ReadAllText($panelIntFile, $utf8)
|
||||
$modelText = [System.IO.File]::ReadAllText($modelFile, $utf8)
|
||||
|
||||
# 3a. collapsing a group needs the disclosure triangle OR the "Group N" label -- clicking the name
|
||||
# itself must not collapse it. (User 2026-10-16.)
|
||||
Check ($panelText -match "return RowPart::GROUP_TITLE;") "hit test yields a dedicated GROUP_TITLE part"
|
||||
Check ($panelText -match "const gd::RectF labelRect\(labelLeft, rect\.Y, GroupLabelWidth\(row\), rect\.Height\);") "GROUP_TITLE hot zone is exactly the Group-N label rect"
|
||||
$gate = @(Select-String -Path $panelIntFile -Pattern "^\s*if \(part != RowPart::DISCLOSURE && part != RowPart::GROUP_TITLE\)")
|
||||
Check ($gate.Count -eq 1) ("single-click toggle gate accepts only DISCLOSURE / GROUP_TITLE (found " + $gate.Count + ")")
|
||||
$toggles = @(Select-String -Path $panelIntFile -Pattern "^\s*ToggleGroupFromRow\(item\.groupIndex, row\);")
|
||||
Check ($toggles.Count -eq 2) ("ToggleGroupFromRow has exactly 2 entry points, single click + double click (found " + $toggles.Count + ")")
|
||||
Check ($panelIntText -match "(?s)if \(part == RowPart::DISCLOSURE\)\s*\{\s*//[^\n]*\n(\s*//[^\n]*\n)*\s*ToggleGroupFromRow") "double click on the triangle still collapses"
|
||||
Check ($panelIntText -match "(?s)if \(part != RowPart::BODY\)\s*\{\s*return false;\s*\}") "double click outside the name body never starts a rename"
|
||||
|
||||
# 3b. the group rename box must stop before the "N items" counter. (User 2026-10-16.)
|
||||
Check ($panelText -match "right = statsRight - statsWidth - 6\.0f \* m_scale;") "rename box right edge excludes the item counter"
|
||||
Check ($panelText -notmatch "right = statsRight;") "rename box never extends over the counter"
|
||||
Check ($panelText -match "const float statsWidth = UiMeasureTextWidth\(m_fonts->uiSmall, GroupStatsText\(row\)\);") "counter width comes from GroupStatsText"
|
||||
|
||||
# 3c. SignalData / SignalGroup keep exactly ONE name field. (User 2026-10-16: drop displayName.)
|
||||
$data = [regex]::Match($modelText, "(?s)struct SignalData\s*\{(.*?)\n\t\};")
|
||||
Check ($data.Success) "SignalData struct found in the data model"
|
||||
if ($data.Success) {
|
||||
$body = $data.Groups[1].Value
|
||||
$fields = @([regex]::Matches($body, "std::wstring\s+\w+;"))
|
||||
Check ($fields.Count -eq 1) ("SignalData declares exactly one string field (found " + $fields.Count + ")")
|
||||
Check ($body -match "std::wstring name;") "that single field is name"
|
||||
}
|
||||
$group = [regex]::Match($modelText, "(?s)struct SignalGroup\s*\{(.*?)\n\t\};")
|
||||
Check ($group.Success) "SignalGroup struct found in the data model"
|
||||
if ($group.Success) {
|
||||
$gbody = $group.Groups[1].Value
|
||||
$gfields = @([regex]::Matches($gbody, "std::wstring\s+\w+;"))
|
||||
Check ($gfields.Count -eq 1) ("SignalGroup declares exactly one string field (found " + $gfields.Count + ")")
|
||||
}
|
||||
Check ($modelText -match "bool RenameSignal\(") "DataSet::RenameSignal replaces the old SetSignalDisplayName"
|
||||
Check ($modelText -match "bool RenameGroup\(") "DataSet::RenameGroup replaces the old SetGroupDisplayName"
|
||||
$staleNames = @(Select-String -Path "Components/*/Src/*.cpp","Components/*/Inc/*.hpp","Components/*/Src/*.hpp",
|
||||
"Utilities/Src/*.cpp","Utilities/Inc/*.hpp" -Pattern "displayName|SetSignalDisplayName|SetGroupDisplayName|\.Title\(\)|->Title\(\)")
|
||||
Check ($staleNames.Count -eq 0) ("no displayName / Title() left in any source (found " + $staleNames.Count + ")")
|
||||
|
||||
# 3d. comment hygiene, driven by a UTF-8 data file (the .ps1 itself must stay ASCII for PowerShell 5.1)
|
||||
$phraseFile = "Tools/structcheck_phrases.txt"
|
||||
Check (Test-Path $phraseFile) "comment-hygiene phrase list exists"
|
||||
if (Test-Path $phraseFile) {
|
||||
$scanFiles = @(Get-ChildItem "Components/*/Src/*.cpp","Components/*/Src/*.hpp","Components/*/Inc/*.hpp",
|
||||
"Utilities/Src/*.cpp","Utilities/Inc/*.hpp" | ForEach-Object { $_.FullName })
|
||||
$scanText = @{}
|
||||
foreach ($f in $scanFiles) { $scanText[$f] = [System.IO.File]::ReadAllText($f, $utf8) }
|
||||
foreach ($line in [System.IO.File]::ReadAllLines($phraseFile, $utf8)) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
$parts = $line -split "`t"
|
||||
$mode = $parts[0]
|
||||
if ($mode -eq "forbid") {
|
||||
$phrase = $parts[1]
|
||||
$offenders = @($scanFiles | Where-Object { $scanText[$_].Contains($phrase) })
|
||||
Check ($offenders.Count -eq 0) ("stale wording absent everywhere: " + $phrase + " (found in " + $offenders.Count + " files)")
|
||||
} elseif ($mode -eq "require") {
|
||||
$target = $parts[1]
|
||||
$phrase = $parts[2]
|
||||
Check ((Test-Path $target) -and ([System.IO.File]::ReadAllText($target, $utf8).Contains($phrase))) ("corrected comment present in " + $target + ": " + $phrase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("structcheck: " + $pass + " passed, " + $fail + " failed")
|
||||
if ($fail -ne 0) { exit 1 }
|
||||
exit 0
|
||||
@@ -1,13 +0,0 @@
|
||||
forbid 文件下标
|
||||
forbid 所属文件下标
|
||||
forbid 原信号所属文件下标
|
||||
forbid 文件分组
|
||||
forbid 原文件下标
|
||||
forbid 文件序号
|
||||
forbid displayName
|
||||
forbid SetSignalDisplayName
|
||||
forbid SetGroupDisplayName
|
||||
require Components/Application/Inc/Application.hpp 原信号所属分组下标
|
||||
require Utilities/Inc/DataModel.hpp 信号名称(加载时取列名 / 通道名;用户可在界面上重命名)
|
||||
require Utilities/Inc/DataModel.hpp 分组名(加载时取源文件名、含扩展名;用户可重命名)
|
||||
require Utilities/Inc/DataModel.hpp 与磁盘上的文件**没有任何关系**
|
||||
@@ -38,7 +38,9 @@ namespace jdi
|
||||
/**
|
||||
* @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。
|
||||
*
|
||||
* 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。
|
||||
* 单个数据点的初始绘制方式是「散点」,半径随线宽线性增长;取最大值才能看清那个点。
|
||||
* ⚠️ 用户 2026-10-16 要求:初始绘制方式由「点线」改为「**散点**」,
|
||||
* 同时信号面板「样式」行的「线段」/「点线」两个按钮对该信号置灰。
|
||||
* ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式,
|
||||
* 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制
|
||||
* 已经覆盖了那个场景,无需再改默认样式。
|
||||
|
||||
@@ -79,6 +79,24 @@ namespace jdi
|
||||
bool keepOpen = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 打开菜单时的附加布局约束。
|
||||
*/
|
||||
struct MenuLayoutConstraint
|
||||
{
|
||||
/**
|
||||
* @brief 菜单允许的最大高度(**客户区像素**,0 = 不限制)。
|
||||
*
|
||||
* 超过该高度时菜单不再变高:**最后一个分隔线及其之后的条目固定在菜单底部**
|
||||
* (典型用法:把「新建网格」钉在底部),其上方的内容成为可滚动的条目区
|
||||
*(鼠标滚轮 / 键盘方向键滚动)。
|
||||
*
|
||||
* 用于「条目数不定」的菜单:例如加载含上百个信号的 MF4 并把每个信号都放进独立网格时,
|
||||
* 「所属网格」菜单会有上百个条目(既超出屏幕、绘制开销也很大)。
|
||||
*/
|
||||
float maxHeight = 0.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 菜单选择结果。
|
||||
*/
|
||||
@@ -127,9 +145,11 @@ namespace jdi
|
||||
* @param [IN] expandUp 为 true 时菜单**向上展开**,且 `(x, y)` 是菜单的**左下角**
|
||||
* (用于“二级面板贴在按钮左上方”这类锚定);为 false(默认)时向右下展开,
|
||||
* `(x, y)` 是菜单的左上角。
|
||||
* @param [IN] constraint 附加布局约束(最大高度 / 超长时可滚动),默认不限制。
|
||||
*/
|
||||
void Open(MenuScope scope, const gd::RectF& bounds, int x, int y,
|
||||
const std::vector<MenuItem>& items, bool expandUp = false);
|
||||
const std::vector<MenuItem>& items, bool expandUp = false,
|
||||
const MenuLayoutConstraint& constraint = MenuLayoutConstraint());
|
||||
|
||||
/**
|
||||
* @brief 关闭菜单。
|
||||
@@ -196,6 +216,19 @@ namespace jdi
|
||||
*/
|
||||
bool ContainsPoint(int x, int y) const;
|
||||
|
||||
/**
|
||||
* @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、尾部分隔线之后固定到底部)。
|
||||
* @return 启用滚动返回 true。
|
||||
*/
|
||||
bool IsScrollable() const { return m_open && m_scrollEnabled; }
|
||||
|
||||
/**
|
||||
* @brief 处理鼠标滚轮:滚动可滚动菜单的条目区。
|
||||
* @param [IN] delta 滚轮增量(`WM_MOUSEWHEEL` 的 `GET_WHEEL_DELTA_WPARAM`)。
|
||||
* @return 滚动位置发生变化返回 true。
|
||||
*/
|
||||
bool OnMouseWheel(int delta);
|
||||
|
||||
/**
|
||||
* @brief 设置剪贴板所有者窗口句柄(供内联输入框复制/粘贴)。
|
||||
* @param [IN] host 宿主窗口句柄,允许为空。
|
||||
@@ -392,6 +425,13 @@ namespace jdi
|
||||
*/
|
||||
void PaintSubmenu(gd::Graphics& g);
|
||||
|
||||
/**
|
||||
* @brief 键盘移动悬停项后,把该项滚动到可滚动区的可视范围内。
|
||||
* @param [IN] index 目标条目下标。
|
||||
* @return 滚动偏移发生变化返回 true。
|
||||
*/
|
||||
bool EnsureItemVisible(int index);
|
||||
|
||||
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
|
||||
const Theme* m_theme = nullptr; /**< 主题资源。 */
|
||||
float m_scale = 1.0f; /**< DPI 缩放比例。 */
|
||||
@@ -427,6 +467,20 @@ namespace jdi
|
||||
int m_buildY = 0; /**< 最近一次排布使用的锚点纵坐标。 */
|
||||
bool m_buildExpandUp = false; /**< 最近一次排布是否为“向上展开(锚点为左下角)”。 */
|
||||
|
||||
/** 最近一次使用的布局约束(`RebuildLayout` 复用)。 */
|
||||
MenuLayoutConstraint m_constraint;
|
||||
/**
|
||||
* @brief 尾部固定区(钉在菜单底部)的**首个条目下标**;-1 表示没有固定项。
|
||||
*
|
||||
* 由 `BuildLayout` 在启用滚动时设为最后一个分隔线的下标:该分隔线及其之后的条目
|
||||
* (典型:「新建网格」)始终画在菜单底部,其上方的内容在滚动区内。
|
||||
*/
|
||||
int m_pinnedIndex = -1;
|
||||
bool m_scrollEnabled = false; /**< 条目区是否可滚动(内容超过 `maxHeight` 时启用)。 */
|
||||
float m_scrollOffset = 0.0f; /**< 滚动区当前的滚动偏移(像素,0 = 顶部)。 */
|
||||
float m_scrollRegionHeight = 0.0f; /**< 滚动区的内容总高(像素)。 */
|
||||
gd::RectF m_scrollViewport; /**< 滚动区的可视矩形(也是滚动区条目的裁剪矩形)。 */
|
||||
|
||||
bool m_subOpen = false; /**< 二级子菜单是否展开。 */
|
||||
int m_subParentIndex = -1; /**< 展开子菜单的父项下标。 */
|
||||
gd::RectF m_subRect; /**< 子菜单整体矩形。 */
|
||||
|
||||
@@ -48,8 +48,12 @@ namespace jdi
|
||||
/**
|
||||
* @brief 为**只有一个有效采样点**的信号设置初始绘制样式。
|
||||
*
|
||||
* 单个数据点在「线段」模式下完全画不出来,因此初始就用「点线」并取**最大线宽**
|
||||
* (点线模式下数据点是一个圆点,半径与线宽成正比)。
|
||||
* 单个数据点在「线段」模式下完全画不出来,因此初始就用「散点」并取**最大线宽**
|
||||
* (散点半径随线宽线性增长,取最大值才能看清那个点)。
|
||||
*
|
||||
* ⚠️ 用户 2026-10-16 要求:初始绘制方式由「点线」改为「**散点**」,并且信号面板
|
||||
* 「样式」行里的「线段」/「点线」两个按钮对该信号**置灰**
|
||||
* (见 `SignalPanel::ModeOptionEnabled`)。
|
||||
*
|
||||
* ⚠️ 曾经还对「有效采样点 2…16」的信号也改用「点线 + 2px」—— 该需求已**作废**
|
||||
*(用户 2026-10-15 明确取消:放大后自动切点线的机制已覆盖那个场景)。
|
||||
@@ -63,7 +67,7 @@ namespace jdi
|
||||
{
|
||||
if (finiteCount == 1)
|
||||
{
|
||||
style.mode = DrawMode::LINE_SCATTER;
|
||||
style.mode = DrawMode::SCATTER;
|
||||
style.lineWidth = kSinglePointSignalLineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
+226
-20
@@ -125,10 +125,12 @@ namespace jdi
|
||||
float contentWidth = kMinWidth * m_scale;
|
||||
float totalHeight = kPadV * 2.0f * m_scale;
|
||||
|
||||
std::vector<float> heights(m_items.size(), 0.0f);
|
||||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_items[index];
|
||||
totalHeight += ItemHeight(item);
|
||||
heights[index] = ItemHeight(item);
|
||||
totalHeight += heights[index];
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
@@ -168,7 +170,76 @@ namespace jdi
|
||||
|
||||
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
|
||||
const float width = contentWidth;
|
||||
const float height = totalHeight;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 超长菜单:限制高度 + 条目区可滚动 + 尾部固定项(见 `MenuLayoutConstraint`)。
|
||||
// 固定区 = **最后一个分隔线及其之后的条目**(典型:「新建网格」),
|
||||
// 它始终钉在菜单底部,因此滚动不会把“新建网格”飘走。
|
||||
// ------------------------------------------------------------------
|
||||
int pinnedFrom = -1;
|
||||
if (m_constraint.maxHeight > 0.0f)
|
||||
{
|
||||
for (int index = static_cast<int>(m_items.size()) - 1; index > 0; --index)
|
||||
{
|
||||
if (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SEPARATOR)
|
||||
{
|
||||
pinnedFrom = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float scrollRegionHeight = 0.0f;
|
||||
float tailHeight = 0.0f;
|
||||
if (pinnedFrom > 0)
|
||||
{
|
||||
for (int index = 0; index < pinnedFrom; ++index)
|
||||
{
|
||||
scrollRegionHeight += heights[static_cast<std::size_t>(index)];
|
||||
}
|
||||
for (int index = pinnedFrom; index < static_cast<int>(m_items.size()); ++index)
|
||||
{
|
||||
tailHeight += heights[static_cast<std::size_t>(index)];
|
||||
}
|
||||
}
|
||||
|
||||
float height = totalHeight;
|
||||
m_scrollEnabled = false;
|
||||
m_scrollRegionHeight = 0.0f;
|
||||
m_pinnedIndex = -1;
|
||||
m_scrollViewport = gd::RectF();
|
||||
// ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发)
|
||||
// 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要**
|
||||
// 滚动时才复位(`Open` 里已经为每次打开重置过)。
|
||||
if (m_constraint.maxHeight > 0.0f)
|
||||
{
|
||||
// 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”,
|
||||
// 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。
|
||||
float limit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale);
|
||||
if (limit < 40.0f * m_scale)
|
||||
{
|
||||
limit = 0.0f;
|
||||
}
|
||||
const float viewportHeight = (limit > 0.0f) ? (limit - kPadV * 2.0f * m_scale - tailHeight) : 0.0f;
|
||||
if (pinnedFrom > 0 && limit > 0.0f && totalHeight > limit &&
|
||||
viewportHeight >= kRowHeight * m_scale)
|
||||
{
|
||||
height = limit;
|
||||
m_scrollEnabled = true;
|
||||
m_pinnedIndex = pinnedFrom;
|
||||
m_scrollRegionHeight = scrollRegionHeight;
|
||||
m_scrollOffset = std::max(
|
||||
0.0f, std::min(m_scrollOffset, std::max(0.0f, scrollRegionHeight - viewportHeight)));
|
||||
}
|
||||
else
|
||||
{
|
||||
m_scrollOffset = 0.0f;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_scrollOffset = 0.0f;
|
||||
}
|
||||
|
||||
// 带说明文字的 EDIT 项共用**同一个输入框宽度**:取其中最大的自然宽度,
|
||||
// 再按“给最宽的说明文字留位 / 不超过内容区 3/4”夹取。
|
||||
@@ -215,12 +286,38 @@ namespace jdi
|
||||
|
||||
m_rect = gd::RectF(left, top, width, height);
|
||||
|
||||
float cursorY = top + kPadV * m_scale;
|
||||
// 条目排布:滚动区的条目整体上移 `m_scrollOffset`;固定区从菜单底部往上对齐。
|
||||
const float bodyTop = top + kPadV * m_scale;
|
||||
const float pinnedTop = top + height - kPadV * m_scale - tailHeight;
|
||||
m_scrollViewport =
|
||||
m_scrollEnabled
|
||||
? gd::RectF(left, bodyTop, width,
|
||||
std::max(0.0f, height - kPadV * 2.0f * m_scale - tailHeight))
|
||||
: gd::RectF();
|
||||
|
||||
float cursorY = bodyTop;
|
||||
float scrollCursorY = bodyTop - m_scrollOffset;
|
||||
float pinnedCursorY = (pinnedFrom > 0) ? pinnedTop : bodyTop;
|
||||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_items[index];
|
||||
const float itemHeight = ItemHeight(item);
|
||||
m_itemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
|
||||
const float itemHeight = heights[index];
|
||||
|
||||
float rectY = cursorY;
|
||||
if (m_scrollEnabled)
|
||||
{
|
||||
if (static_cast<int>(index) < m_pinnedIndex)
|
||||
{
|
||||
rectY = scrollCursorY;
|
||||
scrollCursorY += itemHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
rectY = pinnedCursorY;
|
||||
pinnedCursorY += itemHeight;
|
||||
}
|
||||
}
|
||||
m_itemRects[index] = gd::RectF(left + kPadH * m_scale, rectY,
|
||||
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
@@ -228,7 +325,7 @@ namespace jdi
|
||||
const int count = std::max(0, std::min(item.swatchCount, 16));
|
||||
std::vector<gd::RectF>& rects = m_swatchRects[index];
|
||||
rects.reserve(static_cast<std::size_t>(count));
|
||||
const float swatchY = cursorY + (itemHeight - kSwatchSize * m_scale) * 0.5f;
|
||||
const float swatchY = rectY + (itemHeight - kSwatchSize * m_scale) * 0.5f;
|
||||
const float startX = left + (kPadH + kItemPad) * m_scale;
|
||||
for (int swatch = 0; swatch < count; ++swatch)
|
||||
{
|
||||
@@ -242,6 +339,47 @@ namespace jdi
|
||||
}
|
||||
}
|
||||
|
||||
bool PopupMenu::EnsureItemVisible(int index)
|
||||
{
|
||||
if (!m_scrollEnabled || index < 0 || index >= m_pinnedIndex || m_scrollViewport.Height <= 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
|
||||
const float relative = rect.Y - m_scrollViewport.Y;
|
||||
float delta = 0.0f;
|
||||
if (relative < 0.0f)
|
||||
{
|
||||
delta = relative;
|
||||
}
|
||||
else if (relative + rect.Height > m_scrollViewport.Height)
|
||||
{
|
||||
delta = relative + rect.Height - m_scrollViewport.Height;
|
||||
}
|
||||
if (std::fabs(delta) < 0.5f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_scrollOffset += delta;
|
||||
// 重新排布(内部会重新夹取 `m_scrollOffset`)。
|
||||
RebuildLayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PopupMenu::OnMouseWheel(int delta)
|
||||
{
|
||||
if (!m_open || !m_scrollEnabled || delta == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const float previous = m_scrollOffset;
|
||||
// 向上滚(`delta > 0`)= 看更靠前的条目 ⇒ 偏移减小。
|
||||
m_scrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
|
||||
// 重新排布:条目矩形依赖滚动偏移,同时会夹取到合法范围。
|
||||
RebuildLayout();
|
||||
return std::fabs(m_scrollOffset - previous) > 0.01f;
|
||||
}
|
||||
|
||||
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
|
||||
{
|
||||
const std::wstring& widest =
|
||||
@@ -633,7 +771,8 @@ namespace jdi
|
||||
}
|
||||
|
||||
void PopupMenu::Open(MenuScope scope, const gd::RectF& bounds, int x, int y,
|
||||
const std::vector<MenuItem>& items, bool expandUp)
|
||||
const std::vector<MenuItem>& items, bool expandUp,
|
||||
const MenuLayoutConstraint& constraint)
|
||||
{
|
||||
m_items = items;
|
||||
if (m_items.empty())
|
||||
@@ -653,6 +792,9 @@ namespace jdi
|
||||
m_buildX = x;
|
||||
m_buildY = y;
|
||||
m_buildExpandUp = expandUp;
|
||||
// 每次打开都从顶部开始(滚动位置不跨次沿用)。
|
||||
m_constraint = constraint;
|
||||
m_scrollOffset = 0.0f;
|
||||
|
||||
CloseSubmenu();
|
||||
CloseEditState();
|
||||
@@ -671,6 +813,12 @@ namespace jdi
|
||||
m_hoverSwatch = -1;
|
||||
m_pressedIndex = -1;
|
||||
m_pressedSwatch = -1;
|
||||
m_constraint = MenuLayoutConstraint();
|
||||
m_pinnedIndex = -1;
|
||||
m_scrollEnabled = false;
|
||||
m_scrollOffset = 0.0f;
|
||||
m_scrollRegionHeight = 0.0f;
|
||||
m_scrollViewport = gd::RectF();
|
||||
CloseSubmenu();
|
||||
CloseEditState();
|
||||
}
|
||||
@@ -720,7 +868,15 @@ namespace jdi
|
||||
}
|
||||
if (!UiPointInRect(m_itemRects[index], fx, fy))
|
||||
{
|
||||
continue; }
|
||||
continue;
|
||||
}
|
||||
// 可滚动菜单:滚动区内的条目还必须落在**滚动视口**内 ——
|
||||
// 夹在视口边界上、只有一部分可见的条目,只有落在视口内的那一部分可以点击。
|
||||
if (m_scrollEnabled && static_cast<int>(index) < m_pinnedIndex &&
|
||||
!UiPointInRect(m_scrollViewport, fx, fy))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind == MenuItemKind::SWATCHES)
|
||||
{
|
||||
@@ -772,6 +928,8 @@ namespace jdi
|
||||
{
|
||||
m_hoverIndex = index;
|
||||
m_hoverSwatch = -1;
|
||||
// 键盘移动时把目标条目滚入可视区(长菜单里按方向键不能“跳到看不见的条目”)。
|
||||
EnsureItemVisible(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1204,10 +1362,51 @@ namespace jdi
|
||||
UiFillRoundRect(g, m_rect, kCorner * m_scale, m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_rect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
|
||||
|
||||
// 条目文字统一走本 lambda:可滚动区里的条目必须夹到**滚动视口** ——
|
||||
// `UiText` 走 GDI `DrawTextW`,**不遵循 GDI+ 的 `SetClip`**,不显式裁剪就会画到固定区上。
|
||||
//(GDI+ 图元 —— 行底色 / 圆角 / 分隔线 / 对勾 / 子菜单三角 —— 受 `SetClip` 约束,无需额外处理。)
|
||||
const auto itemText = [this, &g](const std::wstring& text, HFONT font, const gd::RectF& rect,
|
||||
std::uint32_t argb, TextAlign align, bool clipped)
|
||||
{
|
||||
if (clipped)
|
||||
{
|
||||
UiTextClipped(g, text, font, rect, argb, align, m_scrollViewport);
|
||||
}
|
||||
else
|
||||
{
|
||||
UiText(g, text, font, rect, argb, align);
|
||||
}
|
||||
};
|
||||
|
||||
// 可滚动菜单:条目 [0, pinnedFrom) 在滚动区(裁剪到滚动视口),
|
||||
// 其余(最后一个分隔线及其之后的条目)钉在菜单底部、不参与滚动。
|
||||
const int pinnedFrom = (m_scrollEnabled && m_pinnedIndex >= 0)
|
||||
? m_pinnedIndex
|
||||
: static_cast<int>(m_items.size());
|
||||
const int clipState = m_scrollEnabled ? g.Save() : 0;
|
||||
if (m_scrollEnabled)
|
||||
{
|
||||
g.SetClip(m_scrollViewport, gd::CombineModeIntersect);
|
||||
}
|
||||
|
||||
for (std::size_t index = 0; index < m_items.size(); ++index)
|
||||
{
|
||||
const MenuItem& item = m_items[index];
|
||||
const gd::RectF& rect = m_itemRects[index];
|
||||
// 进入固定区:撤掉滚动区的裁剪(固定区永远完整可见)。
|
||||
if (m_scrollEnabled && static_cast<int>(index) == pinnedFrom)
|
||||
{
|
||||
g.Restore(clipState);
|
||||
}
|
||||
const bool inScrollRegion = static_cast<int>(index) < pinnedFrom;
|
||||
// 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。
|
||||
if (m_scrollEnabled && inScrollRegion &&
|
||||
(rect.Y + rect.Height <= m_scrollViewport.Y ||
|
||||
rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const bool clipped = m_scrollEnabled && inScrollRegion;
|
||||
|
||||
if (item.kind == MenuItemKind::SEPARATOR)
|
||||
{
|
||||
@@ -1220,9 +1419,9 @@ namespace jdi
|
||||
if (item.kind == MenuItemKind::HEADER)
|
||||
{
|
||||
// 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。
|
||||
UiText(g, item.label, m_fonts->uiSmallBold,
|
||||
itemText(item.label, m_fonts->uiSmallBold,
|
||||
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
m_theme->textMuted, TextAlign::LEFT, clipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1232,9 +1431,9 @@ namespace jdi
|
||||
const gd::RectF icon = HeaderIconRect(static_cast<int>(index));
|
||||
const float labelWidth =
|
||||
std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale);
|
||||
UiText(g, UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold,
|
||||
itemText(UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold,
|
||||
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
m_theme->textMuted, TextAlign::LEFT, clipped);
|
||||
if (icon.Width > 0.0f)
|
||||
{
|
||||
const bool iconHovered = (static_cast<int>(index) == m_hoverIconIndex);
|
||||
@@ -1266,8 +1465,9 @@ namespace jdi
|
||||
const float labelLeft = rect.X + kItemPad * m_scale;
|
||||
const gd::RectF fieldRect = EditFieldRect(static_cast<int>(index));
|
||||
const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale);
|
||||
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
|
||||
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT);
|
||||
itemText(UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
|
||||
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT,
|
||||
clipped);
|
||||
|
||||
const float suffix = EditSuffixWidth(item);
|
||||
if (static_cast<int>(index) == m_editIndex)
|
||||
@@ -1287,12 +1487,12 @@ namespace jdi
|
||||
if (suffix > 0.0f)
|
||||
{
|
||||
const float suffixLeft = textRect.X + textRect.Width;
|
||||
UiText(g, item.editSuffix, m_fonts->uiSmall,
|
||||
itemText(item.editSuffix, m_fonts->uiSmall,
|
||||
gd::RectF(suffixLeft, fieldRect.Y,
|
||||
std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft -
|
||||
4.0f * m_scale),
|
||||
fieldRect.Height),
|
||||
m_theme->textMuted, TextAlign::RIGHT);
|
||||
m_theme->textMuted, TextAlign::RIGHT, clipped);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -1301,10 +1501,10 @@ namespace jdi
|
||||
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f);
|
||||
const std::wstring text =
|
||||
UiEllipsize(item.value, m_fonts->uiSmall, fieldRect.Width - 12.0f * m_scale);
|
||||
UiText(g, text, m_fonts->uiSmall,
|
||||
itemText(text, m_fonts->uiSmall,
|
||||
gd::RectF(fieldRect.X + 6.0f * m_scale, fieldRect.Y,
|
||||
std::max(8.0f, fieldRect.Width - 12.0f * m_scale), fieldRect.Height),
|
||||
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT);
|
||||
item.enabled ? m_theme->textStrong : m_theme->textMuted, TextAlign::LEFT, clipped);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1384,9 +1584,15 @@ namespace jdi
|
||||
const float textLeft = rect.X + kItemPad * m_scale;
|
||||
const float textWidth =
|
||||
rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale;
|
||||
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
|
||||
itemText(UiEllipsize(item.label, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
|
||||
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
|
||||
TextAlign::LEFT);
|
||||
TextAlign::LEFT, clipped);
|
||||
}
|
||||
|
||||
// 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。
|
||||
if (m_scrollEnabled)
|
||||
{
|
||||
g.Restore(clipState);
|
||||
}
|
||||
|
||||
// 二级子菜单最后绘制,保证覆盖在主菜单之上。
|
||||
|
||||
Reference in new issue
Block a user