diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 1878df7..7d7ba15 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1525,6 +1525,14 @@ namespace jdi gd::RectF panel; /**< 面板整体矩形。 */ gd::RectF title; /**< 标题栏(**唯一可拖拽区域**)。 */ gd::RectF close; /**< ✕ 关闭按钮。 */ + /** + * @brief 标题栏左侧的「GX」编号徽标。 + * + * 它是**绘制与命中的唯一几何来源**:悬停提示、手型光标、以及 + * “点击把该网格滚动到视界内”都依赖它(用户 2026-10-19 要求)。 + * 面板未展开时为空矩形。 + */ + gd::RectF badge; gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ @@ -2060,6 +2068,15 @@ namespace jdi */ void PaintSignalInteraction(gd::Graphics& g); + /** + * @brief 绘制浮动面板「GX」徽标的悬停提示气泡(用户 2026-10-19 要求)。 + * + * 气泡锚定在徽标下方,内容为“G 编号”与当前可执行的操 + *(目标网格已在视界内 / 点击滚动到该网格)。 + * ⚠️ 必须在**面板像素图层之后**绘制(见 `PaintEdgeCursor` 末尾),否则会被缓存进面板位图。 + */ + void PaintPanelBadgeTooltip(gd::Graphics& g); + /** * @brief 在绘图区底部绘制临时提示条(如“已放弃当前选区”)。 * @param [IN,OUT] g 绘图上下文。 @@ -2134,7 +2151,8 @@ namespace jdi float m_labelBandWidth = 66.0f; /**< 网格内侧纵轴标尺带宽度(逻辑像素)。 */ float m_timeAxisHeight = 32.0f; /**< 时间轴高度(逻辑像素)。 */ - float m_minLaneHeight = 54.0f; /**< 网格最小高度(逻辑像素)。 */ + /** @brief 网格最小高度(**逻辑像素**,使用处乘 `m_scale`)。用户 2026-10-19 要求为 81(原 54 的 1.5 倍)。 */ + float m_minLaneHeight = 81.0f; float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */ bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ @@ -2260,6 +2278,14 @@ namespace jdi * 因此必须单独记住这个状态才能触发重绘与取消高亮。 */ int m_hoverBadge = -1; + /** + * @brief 上一帧鼠标是否悬停在某个浮动面板标题栏的「GX」徽标上(是则记录网格下标 / 面板种类)。 + * + * 徽标绘制在面板**像素图层**里(缓存键只在鼠标位于面板内时才混入坐标), + * 而提示气泡是贴图之后的叠加层(见 `PaintPanelBadgeTooltip`),因此需要单独记录状态。 + */ + int m_hoverPanelBadgeLane = -1; + int m_hoverPanelBadgeKind = -1; /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ bool m_peakResetHover = false; int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 878860d..622eaef 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -109,7 +109,11 @@ namespace jdi m_labelBandWidth = 66.0f * m_scale; m_timeAxisHeight = 34.0f * m_scale; - m_minLaneHeight = 54.0f * m_scale; + // ⚠️ `m_minLaneHeight` 存的是**逻辑像素**(见头文件),使用处才乘 `m_scale`。 + // 旧实现这里写成 `54.0f * m_scale`,而 `BuildLaneVisuals` 又乘了一次 `m_scale`, + // 在 125% 缩放下实际最小值被放大成 54×1.5625(而非 54×1.25),且与 + // 「是否需要滚动条」的判定(只乘一次)不一致 ⇒ 会出现“网格溢出了却没有滚动条”。 + m_minLaneHeight = 81.0f; // 用户 2026-10-19:最小网格高度提升到原来的 1.5 倍(54 → 81) // 先为时间轴保留固定高度,剩余空间才交给网格,保证时间轴不会被挤压。 const float topPadding = 6.0f * m_scale; @@ -119,7 +123,7 @@ namespace jdi std::size_t laneCount = (m_tab != nullptr) ? m_tab->Lanes().size() : 0; if (laneCount > 0) { - const float needed = static_cast(laneCount) * m_minLaneHeight; + const float needed = static_cast(laneCount) * m_minLaneHeight * m_scale; m_scrollBarVisible = (needed > availHeight); } else diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 3096119..fe03271 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -878,6 +878,13 @@ namespace jdi // ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。 return LoadCursorW(nullptr, IDC_HAND); } + // 「GX」编号徽标:悬停给出提示 + 手型光标;点击把该网格滚动到视界内。 + // ⚠️ 必须排在标题栏之前(徽标位于标题栏矩形内部,否则会被判成“可拖拽”)。 + if (layout.badge.Width > 0.0f && + UiPointInRect(layout.badge, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) { return LoadCursorW(nullptr, IDC_SIZEALL); @@ -1383,6 +1390,30 @@ namespace jdi changed = true; } + // 浮动面板标题栏左侧的「GX」编号徽标:悬停高亮 + 提示气泡(用户 2026-10-19 要求)。 + // 徽标在面板像素图层里,因此悬停态必须由这里单独记录并触发重绘 + //(面板缓存键在鼠标位于面板内时会混入坐标,所以重绘即可看到高亮)。 + int hoverPanelBadgeLane = -1; + int hoverPanelBadgeKind = -1; + if (overEdgePanel && panelLane >= 0) + { + const EdgePanelLayout badgeLayout = + EdgePanelLayoutFor(panelLane, static_cast(hoverPanelKind)); + if (badgeLayout.badge.Width > 0.0f && + UiPointInRect(badgeLayout.badge, fx, static_cast(y))) + { + hoverPanelBadgeLane = panelLane; + hoverPanelBadgeKind = hoverPanelKind; + } + } + if (hoverPanelBadgeLane != m_hoverPanelBadgeLane || + hoverPanelBadgeKind != m_hoverPanelBadgeKind) + { + m_hoverPanelBadgeLane = hoverPanelBadgeLane; + m_hoverPanelBadgeKind = hoverPanelBadgeKind; + changed = true; + } + // 悬停网格:**只有**鼠标悬停在实验性功能浮动面板上时才高亮该面板所属网格。 // ⚠️ 用户 2026-10-15 明确要求:鼠标在网格里移动**不再触发**网格变色高亮; // 改为在信号列表 / 样式区「目标」行悬停到某条已绘制信号时联动高亮 @@ -1663,6 +1694,19 @@ namespace jdi return true; } + // 「GX」编号徽标:点击把该网格滚动到视界内(用户 2026-10-19 要求)。 + // ⚠️ 必须排在标题栏之前:徽标位于标题栏矩形内部,否则会变成“拖动面板”。 + // `ScrollLaneIntoView` 自带“已经完整可见 ⇒ 什么都不做”,且是平滑动画。 + if (layout.badge.Width > 0.0f && UiPointInRect(layout.badge, fx, fy)) + { + m_dragMode = PlotDragMode::NONE; + if (ScrollLaneIntoView(index)) + { + SetHint(L"正在滚动到 G" + std::to_wstring(index + 1) + L" 网格…"); + } + return true; + } + // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目 // (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || @@ -2297,6 +2341,13 @@ namespace jdi m_hoverBadge = -1; changed = true; } + if (m_hoverPanelBadgeLane != -1 || m_hoverPanelBadgeKind != -1) + { + // 浮动面板的「GX」徽标悬停提示必须随鼠标离开清除(否则提示气泡残留)。 + m_hoverPanelBadgeLane = -1; + m_hoverPanelBadgeKind = -1; + changed = true; + } if (m_hoverSignal.IsValid()) { m_hoverSignal = SignalRef(); diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 3a443ab..5575987 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -5408,6 +5408,10 @@ namespace jdi layout.title = gd::RectF(left, top, width, titleHeight); const float closeSize = titleHeight; layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); + // 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格” + // 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。 + layout.badge = gd::RectF(left + pad, top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, + kLaneBadgeWidth * m_scale, kLaneBadgeHeight * m_scale); if (itemized) { const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; @@ -6005,6 +6009,7 @@ namespace jdi mixRect(layout.panel); mixRect(layout.title); mixRect(layout.close); + mixRect(layout.badge); mixRect(layout.edit); mixRect(layout.button); mix(static_cast(layout.rows)); @@ -6376,14 +6381,18 @@ namespace jdi WithAlpha(m_theme->accent, 0x2A)); // 标题:与左侧信号面板一致的 “GX” 徽标。 - const float badgeWidth = kLaneBadgeWidth * m_scale; - const float badgeHeight = kLaneBadgeHeight * m_scale; - const gd::RectF badgeRect(panel.X + pad, panel.Y + (titleHeight - badgeHeight) * 0.5f, - badgeWidth, badgeHeight); - UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg); - UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); + // ⚠️ 几何必须取 `layout.badge`(与悬停提示 / 手型光标 / 点击联动同源); + // 悬停时给一圈强调色描边,作为“可点击”的视觉提示(用户 2026-10-19 要求)。 + const gd::RectF& badgeRect = layout.badge; + const bool badgeHot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && badgeRect.Width > 0.0f && + UiPointInRect(badgeRect, static_cast(m_mouseX), static_cast(m_mouseY)); + UiFillRoundRect(g, badgeRect, badgeRect.Height * 0.5f, + badgeHot ? m_theme->accentSoft : m_theme->cardBg); + UiDrawRoundRect(g, badgeRect, badgeRect.Height * 0.5f, + badgeHot ? m_theme->accent : m_theme->border, badgeHot ? 1.4f : 1.0f); UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect, - m_theme->text, TextAlign::CENTER); + badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakPeak ? std::wstring(kWeakPeakTitle) @@ -6728,6 +6737,58 @@ namespace jdi m_panelSlots = std::move(nextSlots); m_panelGlobalKey = panelGlobalKey; m_panelValid = panelCacheReady; + + // 「GX」徽标的悬停提示气泡:必须在**全部面板像素都存进图层之后**绘制, + // 否则气泡会被烘进面板缓存(下一帧即使鼠标移开也会重现)。 + PaintPanelBadgeTooltip(g); + } + + void PlotView::PaintPanelBadgeTooltip(gd::Graphics& g) + { + if (m_hoverPanelBadgeLane < 0 || m_fonts == nullptr || m_fonts->uiSmall == nullptr || + m_tab == nullptr || m_hoverPanelBadgeKind < 0) + { + return; + } + const EdgePanelKind kind = static_cast(m_hoverPanelBadgeKind); + const EdgePanelLayout layout = EdgePanelLayoutFor(m_hoverPanelBadgeLane, kind); + if (layout.badge.Width <= 0.0f) + { + return; + } + + // 目标网格是否已完整可见:决定提示文案(不可见时提示“点击会滚动过去”)。 + bool laneVisible = false; + const std::size_t laneIndex = static_cast(m_hoverPanelBadgeLane); + if (laneIndex < m_lanes.size()) + { + const gd::RectF& rect = m_lanes[laneIndex].rect; + laneVisible = (rect.Y >= m_plotRect.Y - 0.5f) && + (rect.Y + rect.Height <= m_plotRect.Y + m_plotRect.Height + 0.5f); + } + const std::wstring label = L"G" + std::to_wstring(m_hoverPanelBadgeLane + 1); + const std::wstring text = + label + (laneVisible ? L" · 已在视界内" : L" · 点击滚动到该网格"); + + const float padX = 8.0f * m_scale; + const float height = 22.0f * m_scale; + const float width = std::min(UiMeasureTextWidth(m_fonts->uiSmall, text) + padX * 2.0f, + std::max(10.0f, m_plotRect.Width - 4.0f * m_scale)); + float left = layout.badge.X - 2.0f * m_scale; + left = std::max(m_plotRect.X + 2.0f * m_scale, + std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); + // 默认贴在徽标下方;下方放不下就翻到上方(与弹出菜单的翻转规则一致)。 + float top = layout.badge.Y + layout.badge.Height + 4.0f * m_scale; + if (top + height > m_plotRect.Y + m_plotRect.Height - 2.0f * m_scale) + { + top = layout.badge.Y - height - 4.0f * m_scale; + } + top = std::max(m_plotRect.Y + 2.0f * m_scale, top); + + const gd::RectF bubble(left, top, width, height); + UiFillRoundRect(g, bubble, 4.0f * m_scale, m_theme->readoutBg); + UiDrawRoundRect(g, bubble, 4.0f * m_scale, m_theme->borderStrong, 1.0f); + UiText(g, text, m_fonts->uiSmall, bubble, m_theme->textStrong, TextAlign::CENTER); } void PlotView::PaintEditField(gd::Graphics& g) diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index ea2f8ce..3c96844 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -1085,6 +1085,16 @@ namespace jdi */ HFONT EditFieldFont() const; + /** + * @brief 内联名称编辑时的「吸收区」:输入框横向范围 × 整行高度。 + * @param [IN] field 输入框矩形。 + * @return 吸收区矩形(未在编辑行内时返回 `field`)。 + * @note ⚠️ 行高大于输入框高度(上下各留 3 逻辑像素)。若不吸收这块区域, + * 鼠标落在内边距上会穿透成行点击 ⇒ 分组被展开/收起(用户 2026-10-19 报告)。 + * 横向仍限定在名称区域内,同一行的复选框 / 「N 项」/ 徽标 / ✕ 继续可点。 + */ + gd::RectF NameEditAbsorbRect(const gd::RectF& field) const; + /** * @brief 计算「筛选信号」输入框的内容矩形(编辑与显示共用,保证不跳动)。 * @return 内容矩形。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 66ced31..4b2c6da 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -1197,6 +1197,21 @@ namespace jdi return (m_editField == StyleEditField::GROUP_TITLE) ? m_fonts->uiSmall : m_fonts->ui; } + gd::RectF SignalPanel::NameEditAbsorbRect(const gd::RectF& field) const + { + if (field.Width <= 0.0f || m_editRow < 0 || m_editRow >= static_cast(m_rows.size())) + { + return field; + } + // 与被编辑行同高:输入框上下各留 3 逻辑像素的行内边距,那一段点下去也必须算文本操作。 + const gd::RectF row = RowRect(m_editRow); + if (row.Height <= 0.0f) + { + return field; + } + return gd::RectF(field.X, row.Y, field.Width, row.Height); + } + const SignalStyle* SignalPanel::StyleOf(const SignalRef& ref) const { return (m_dataSet != nullptr) ? m_dataSet->Style(ref) : nullptr; diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index 6ba42e5..9776960 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -821,7 +821,29 @@ namespace jdi { return false; } - return UiEditFieldMouseDown(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x, y); + + // ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 3px 的行内边距) + // 都必须当作“文本操作”**,不能穿透成行点击。否则:容器行高 > 输入框高度, + // 鼠标起点只要落在内边距上就会走 `OnLButtonDown` 的分组分支 ⇒ **分组被展开/收起** + //(用户 2026-10-19 报告:“框选编辑文本时组会展开、收起”)。 + // 横向仍限定在名称区域内:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。 + const gd::RectF field = EditFieldRect(); + if (field.Width > 0.0f) + { + const gd::RectF absorb = NameEditAbsorbRect(field); + if (UiPointInRect(absorb, static_cast(x), static_cast(y)) && + !UiPointInRect(field, static_cast(x), static_cast(y))) + { + // 把落点夹回输入框,保证插入符换算与绘制使用同一套几何 + //(`UiEditFieldMouseDown` 自己会再算一次文本起点)。 + const int clampedX = static_cast(std::lround( + std::max(field.X + 1.0f, std::min(static_cast(x), field.X + field.Width - 2.0f)))); + const int clampedY = static_cast(std::lround( + std::max(field.Y + 1.0f, std::min(static_cast(y), field.Y + field.Height - 2.0f)))); + return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, clampedX, clampedY); + } + } + return UiEditFieldMouseDown(EditFieldFont(), field, m_edit, m_scale, x, y); } bool SignalPanel::OnEditMouseMove(int x) @@ -1245,6 +1267,22 @@ namespace jdi m_pendingCloseRow = -1; m_pendingFilterClear = false; + // ⚠️⚠️ **内联改名期间,名称区域整行都属于“文本操作区”**: + // 行高大于输入框高度(上下各留 3 逻辑像素),鼠标起点落在内边距上时旧实现会继续往下走 + // 到分组行分支 ⇒ 分组被展开/收起(用户 2026-10-19 报告)。 + // 这里直接按“文本按下”处理(定位插入符 / 开始拖拽选区),绝不触发行点击。 + if (m_editField != StyleEditField::NONE && m_editRow >= 0 && + m_editRow < static_cast(m_rows.size())) + { + const gd::RectF field = EditFieldRect(); + if (field.Width > 0.0f && + UiPointInRect(NameEditAbsorbRect(field), fx, fy)) + { + OnEditMouseDown(x, y); + return true; + } + } + // 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。 if (IsOnFilterBox(x, y)) { @@ -1647,6 +1685,23 @@ namespace jdi const float fx = static_cast(x); const float fy = static_cast(y); + // ⚠️⚠️ **编辑态下名称区域的双击必须被吃掉**: + // 窗口带 `CS_DBLCLKS`,编辑时快速再点一下会被合成为 `WM_LBUTTONDBLCLK`, + // 而旧实现直接进入行双击逻辑 ⇒ 分组被展开/收起(或再次进入改名)。 + // 输入框内的双击本该是“选词”,本组件只提供整段选择,因此这里保持现状不变、 + // 仅保证**不把双击当成行双击**(用户 2026-10-19 报告)。 + if (m_editField != StyleEditField::NONE) + { + const gd::RectF field = EditFieldRect(); + if (field.Width > 0.0f && + (UiPointInRect(field, fx, fy) || + (m_editRow >= 0 && m_editRow < static_cast(m_rows.size()) && + UiPointInRect(NameEditAbsorbRect(field), fx, fy)))) + { + return false; + } + } + // 样式区数值框:双击进入(或保持)输入状态。 if (UiPointInRect(m_widthValueRect, fx, fy)) { diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 7e45daa..e42b307 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -2997,6 +2997,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-19 | DeepSeek-V4.1-Flash(**本机**) | **修复列表内联编辑(组名/信号名)看不到插入符与选区 + `Utilities` 的 UI 基础层迁入 `Components/Shared`(详见 §12 末尾「2026-10-19 第二轮」)**。① **根因**:信号列表整行走**行位图缓存**(`SignalPanel::PaintRowCached`),而 `RowCacheKey` 只混合“是否编辑 + 编辑文本”,**没有插入符与选区** ⇒ 左右方向键、鼠标框选虽然改变了 `TextEdit` 状态,键却不变 ⇒ 贴回旧位图 ⇒ 用户看到“无法部分选择、光标不动”(绘图区光标时间输入没有行缓存,所以一直正常)。修法:编辑态把 `Caret/SelectionBegin/SelectionEnd` 混进键(分组行与信号行两处),并新增 `SignalPanel::EditFieldFont()`(分组名用 `uiSmall`、其余 `ui`)统一**绘制 / 命中 / 插入符换算**三处字体。② **目录归类**:`Theme.*` / `PaintUtil.*` / `TextEdit.*` / `PopupMenu.*` / `ColorPickerPopup.*` 由 `Utilities/{Inc,Src}` **`git mv`** 到 `Components/Shared/{Inc,Src}`(`Utilities` 只剩纯数据/逻辑;依赖方向仍 `Components → Shared → Utilities`);Makefile 无需改动,但要清掉 `Build/*` 里对应的旧 `.o/.d`。验证:新增离屏 `.preflight/editcheck`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**20/20**(A/B:修复前 6 项如实失败:键不变、屏幕不重绘);`make release`/`make debug` 零警告。 | +| 2026-10-19 | DeepSeek-V4.1-Flash(**本机**) | **组名/信号名编辑态点击穿透 + 浮动面板 GX 徽标悬停/点击联动 + 最小网格高度 1.5 倍(详见 §12 末尾「2026-10-19 第三轮」与 §11.2 第 151–153 条)**。① **点击穿透根因**:改名时行高大于输入框高度(上下各 3 逻辑像素),起点落在内边距上会继续走 `OnLButtonDown` 的行分支 ⇒ 分组展开/收起;且第二次快速按下被合成为 `WM_LBUTTONDBLCLK`,旧 `OnDoubleClick` 无编辑态保护 ⇒ 同样折叠/再次改名。修法:`NameEditAbsorbRect`(输入框横向范围 × 整行高度)在 `OnLButtonDown`/`OnEditMouseDown` 一律吸收为文本操作,`OnDoubleClick` 编辑态命中即返回。② **GX 徽标**:`EdgePanelLayout::badge` 成为唯一几何来源;悬停高亮 + 提示气泡(在面板缓存之后绘制,避免被烘进位图)+ 手型光标;点击 `ScrollLaneIntoView` 平滑滚动到该网格(已可见则不动)。③ **最小网格高度 54 → 81 逻辑像素**,并修掉 `m_minLaneHeight` 被二次乘 `m_scale` 的隐患(滚动条判定与实际高度改用同一换算)。验证:新增离屏 `.preflight/round3check` **31/31**(真实 MF4:128 网格、`height=101.25=81×1.25`);**A/B 禁用双击保护 ⇒ 1 项如实失败**;回归 `.preflight/editcheck` 20/20、`.preflight/check` 137/137;`make release`/`make debug` 零警告。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3719,6 +3721,24 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } (本轮 11ms → 26ms,普通整帧 8ms → 21ms)⇒ 断言必须用**同一时段测得的相对基准** (普通整帧耗时 / 手动拖滚动条耗时)。⚠️ 另外 **printf 的参数必须与格式串数量一致**, 否则读到栈上垃圾值(本轮把 `130.49` 当成真实耗时,误导排查)。 +151. ⚠️⚠️ **内联改名期间,「名称区域整行」都必须吸收鼠标**(用户 2026-10-19 报告): + 行高大于输入框高度(上下各留 3 逻辑像素),起点落在内边距上时旧实现会继续走行点击分支 + ⇒ **分组被展开/收起**。✓ `SignalPanel::NameEditAbsorbRect(field)` = 输入框横向范围 × 整行高度; + `OnLButtonDown` / `OnEditMouseDown` 命中该区域一律按“文本按下”处理(把落点夹回输入框再算插入符), + `OnDoubleClick` 在编辑态命中该区域**直接返回 false**(不折叠、不再次改名)。 + ⚠️ 横向仍限定在名称区域:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。 + ⚠️ 推论:任何“行内可编辑控件”的命中区都必须**覆盖到它所在行的高度**,否则会被行级点击抢走。 +152. ⚠️ **浮动面板标题栏的「GX」徽标是独立热区**(用户 2026-10-19 要求): + `EdgePanelLayout::badge` 是**绘制 / 悬停提示 / 手型光标 / 点击联动**的唯一几何来源; + 命中判定必须排在 `layout.title` **之前**(徽标位于标题栏矩形内部,否则会被判成“拖动面板”)。 + 点击调用 `ScrollLaneIntoView(laneIndex)`(自带“已完整可见 ⇒ 不动作” + 平滑动画)。 + ⚠️ 悬停提示气泡必须在**面板像素图层全部存进缓存之后**绘制(见 `PaintEdgeCursor` 末尾), + 否则气泡会被烘进面板位图、鼠标移开后仍残留。 +153. ⚠️⚠️ **`m_minLaneHeight` 只存逻辑像素,使用处才乘 `m_scale`**:旧实现 `Layout()` + 写成 `54.0f * m_scale` 而 `BuildLaneVisuals()` 又乘一次 ⇒ 125% 缩放下实际最小高度是 + `54 × 1.5625`,且与「是否需要滚动条」的判定(只乘一次)不一致 ⇒ 可能出现“网格溢出却没有滚动条”。 + 当前值 = **81 逻辑像素**(用户 2026-10-19 要求:原 54 的 1.5 倍,保证最小网格下也看得清跳变)。 + 新增任何“按最小网格高度推导”的逻辑(滚动条判定、`FitLaneHeight`、内容高度)都必须用同一个换算。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) @@ -6415,3 +6435,57 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } `CXX_INCLUDES_PATHS/CXX_SOURCES_PATHS`)。⚠️ 必须删掉 `Build/Release|Debug` 里对应的 旧 `*.o` / `*.d`(`.d` 里记着旧路径,会让 make 误判/漏依赖)。 - 文档里 §11.2 第 88 条已按本表更新(历史章节里出现的 `Utilities/PaintUtil.*` 等仅作历史记录)。 + +--- + +## 本轮(2026-10-19 第三轮,本机):组名编辑穿透 + 浮动面板 GX 徽标 + 最小网格高度 + +> 用户三项需求:① 组名处于编辑态时,在编辑框内双击 / 快速单击 / 框选会让**分组展开或收起**; +> ② 所有实验性功能浮动面板左上角的 `GX` 标签**没有悬停提示**,且点击它应把目标网格滚到视界内; +> ③ 加载 MF4 全部信号后网格太窄,最小高度提到现在的 **1.5 倍**。 +> 验证:新增离屏 `.preflight/round3check` **31/31**(真实 `TestData/MF4-Record.MF4`:128 网格); +> A/B 禁用双击保护 ⇒ 1 项如实失败;回归 `.preflight/editcheck` 20/20、`.preflight/check` 137/137; +> `make release` / `make debug` 零警告。 + +### A. ① 的根因是**两处**“行点击抢走了文本操作” + +1. **行内边距**:行高 > 输入框高度(`RowNameRect` 上下各留 3 逻辑像素)。鼠标起点落在这条内边距上时 + `OnEditMouseDown` 判“不在输入框内”而返回 false ⇒ 宿主把这次按下交给 `SignalPanel::OnLButtonDown` + ⇒ 命中分组行的 `BODY` ⇒ **展开/收起**(框选时之所以会折叠,就是起点偏出输入框 1~3px)。 +2. **快速单击 / 双击**:窗口带 `CS_DBLCLKS`,编辑态下快速再点一次会被合成为 `WM_LBUTTONDBLCLK`。 + `DoubleClickIsPlainPress` 对输入框返回 false(本意是保留“选词”语义),于是走到 + `SignalPanel::OnDoubleClick` —— 而它**没有编辑态保护**,直接进入分组行双击逻辑 + ⇒ 快速双击进改名 / 慢双击**再切一次折叠**。 + +**修法**(不是给这两条路径各打一个补丁,而是给“名称编辑”划出一个明确的吸收区): +- 新增 `SignalPanel::NameEditAbsorbRect(field)` = **输入框横向范围 × 整行高度**(与 `RowRect` 同源)。 +- `OnLButtonDown`:编辑态且命中吸收区 ⇒ 直接 `OnEditMouseDown(x, y)` 并 `return true`(**绝不触发行点击**)。 +- `OnEditMouseDown`:命中吸收区但不在输入框内时,把落点**夹回输入框**再算插入符/开始拖拽 + (保证与绘制使用同一套几何,插入符落点不偏移)。 +- `OnDoubleClick`:编辑态且命中吸收区(输入框或整行)⇒ **直接返回 false**(不折叠、不再次改名)。 +- ⚠️ 横向仍严格限定在名称区域:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ **继续可点**(不然编辑时无法关分组)。 + +### B. ② 浮动面板的「GX」徽标 + +- `EdgePanelLayout` 新增 **`badge`** 矩形,由 `EdgePanelLayoutFor` 统一算出 ⇒ + **绘制 / 悬停提示 / 手型光标 / 点击联动**共用同一个几何(此前徽标矩形只在 `Paint` 里就地算,无法命中)。 +- 悬停:`PlotView::m_hoverPanelBadgeLane/Kind` 单独记录(面板像素在图层里,必须靠状态触发重绘); + 徽标本身画强调色描边 + `accentSoft` 填底;`QueryCursor` 返回 `IDC_HAND`。 +- 提示气泡:`PaintPanelBadgeTooltip`,锚定徽标下方(放不下则翻到上方),文案 + `G12 · 点击滚动到该网格` / `G12 · 已在视界内`。 + ⚠️ **必须在全部面板像素存进缓存之后绘制**(`PaintEdgeCursor` 末尾),否则气泡会被烘进面板位图。 +- 点击:调用 `PlotView::ScrollLaneIntoView(laneIndex)` —— 已完整可见时它自身返回 false(不动), + 否则启动与“点选信号联动滚动”同源的 ease-out 平滑动画;并给一句 `正在滚动到 G12 网格…` 提示。 +- ⚠️ 命中顺序:`close` → **`badge`** → `title`。徽标在标题栏矩形内部,放在 `title` 之后就会被当成“拖面板”。 +- `PanelSlotKey` 也 mix 了 `badge`(几何参与像素内容)。 + +### C. ③ 最小网格高度 54 → 81 逻辑像素,并修掉二次缩放 + +- `m_minLaneHeight` 的语义是**逻辑像素**,使用处才乘 `m_scale`。旧实现 `Layout()` 里写成 + `54.0f * m_scale`,而 `BuildLaneVisuals()` / `FitLaneHeight()` 又各乘一次 ⇒ 125% 缩放下 + 实际最小值是 `54 × 1.5625 ≈ 84.4px`(而非 54×1.25),且与“是否需要滚动条”的判定 + (只乘一次)不一致 ⇒ **可能出现“网格已经溢出、却没有滚动条”**。 +- 现为 `m_minLaneHeight = 81.0f`(用户要求 1.5 倍),`Layout()` 不再预先乘缩放, + 滚动条判定改 `laneCount * m_minLaneHeight * m_scale`;其余两处保持 `* m_scale`。 +- 实测(本机 125%、窗 1400×900 逻辑、MF4 全部 128 信号各自一格): + `lane height = 101.25px = 81 × 1.25`,`content = 12960px`,滚动条出现且可滚动。