修复输入框全选高亮溢出、标签页重命名无法失焦、新增信号行删除按钮

1. 内联输入框全选高亮越界(组名/标签页名/信号名编辑)
   UiFillRect 在不透明且面积 >= 20000 时走 GDI FillRect 快路径,而 GDI 填充不受
   GDI+ SetClip 约束;长文本全选时高亮宽度可达上千像素,于是画到输入框之外。
   UiDrawEditField 现在先把高亮矩形与裁剪区求交再填充。

2. 标签页重命名处于编辑态时,点击输入框之外的任何位置都不退出编辑
   Application 只在点击落在标签栏内时才把点击交给 TabBar。现在 WM_LBUTTONDOWN
   会先检查 m_tabBar.IsEditing(),是则提交(OnEditKey + OnTabBarResult)并退出编辑态,
   覆盖绘图区、信号面板、工具栏、分隔条等位置,且不会丢失新名字。

3. 信号选择区域每个信号新增 ✕ 删除按钮
   - 位置与所在组复选框同一列(垂直对齐),悬停显示手型光标并高亮。
   - 点击后面板向宿主发送 WM_APP+2;Application::RemoveSignal 会重映射所有标签页的
     可见性与网格引用、修正当前选中项,再真正从 DataSet 中删除该信号并释放采样内存;
     删除组内最后一个信号时退化为移除整个文件分组。
   - DataSet::RemoveSignal 采用移动填充 + shrink_to_fit,保证缓冲区内即刻析构。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-10 10:54:50 +08:00
1 parent 18b8f3e38e
commit a1e30985b8
7 files changed
+270 -11

No files matched your search

@@ -182,6 +182,17 @@ namespace jdi
*/ */
void RemoveFile(int fileIndex); void RemoveFile(int fileIndex);
/**
* @brief 从工作区移除某分组中的一条信号,并立即释放其采样内存。
*
* 用户点击信号行左侧的 ✕ 时调用:该信号一旦被确认不再需要,就应从数据层删除
* 并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,则连同分组一起移除。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 文件内信号下标。
*/
void RemoveSignal(int fileIndex, int signalIndex);
/** /**
* @brief 处理标签栏交互结果。 * @brief 处理标签栏交互结果。
* @param [IN] result 交互结果。 * @param [IN] result 交互结果。
@@ -34,6 +34,7 @@ namespace jdi
const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */ const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */ const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
@@ -583,6 +584,39 @@ namespace jdi
UpdateWindowTitle(); UpdateWindowTitle();
} }
void Application::RemoveSignal(int fileIndex, int signalIndex)
{
SignalRef target;
target.fileIndex = fileIndex;
target.signalIndex = signalIndex;
const SignalData* signal = m_dataSet.Resolve(target);
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (signal == nullptr || file == nullptr)
{
return;
}
const std::wstring name = signal->Title();
if (file->signals.size() <= 1)
{
// 组内只剩这一条信号:直接移除整个分组,避免在列表里留下一个空分组。
RemoveFile(fileIndex);
return;
}
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalRemoval(fileIndex, signalIndex);
}
m_dataSet.RemapSelectionAfterSignalRemoval(fileIndex, signalIndex);
m_dataSet.RemoveSignal(fileIndex, signalIndex);
SyncTabsToDataSet();
SetStatusMessage(L"已从工作区移除信号:" + name, false);
}
void Application::Relayout() void Application::Relayout()
{ {
RECT client = {}; RECT client = {};
@@ -2169,6 +2203,17 @@ namespace jdi
return 0; return 0;
} }
// 标签栏重命名处于编辑态时,点击输入框之外的**任何**位置都提交并退出编辑态。
// (面板与绘图区的输入框会在各自的 OnLButtonDown 内部提交,但点击它们之外
// 的区域时宿主根本不会把消息转给标签栏,重命名输入框会一直停留在编辑态。)
if (m_tabBar.IsEditing())
{
TabBarResult renameResult;
m_tabBar.OnEditKey(VK_RETURN, renameResult);
OnTabBarResult(renameResult);
InvalidateRect(hwnd, nullptr, FALSE);
}
// 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。 // 内联数值输入处于活动态时,点击绘图区之外视为提交(与线宽输入一致)。
if (m_plot.IsEditing() && !m_plot.HitTest(x, y)) if (m_plot.IsEditing() && !m_plot.HitTest(x, y))
{ {
@@ -2590,6 +2635,12 @@ namespace jdi
RemoveFile(static_cast<int>(wParam)); RemoveFile(static_cast<int>(wParam));
return 0; return 0;
} }
case kMessageRemoveSignal:
{
// wParam = 文件下标,lParam = 文件内信号下标。
RemoveSignal(static_cast<int>(wParam), static_cast<int>(static_cast<LONG_PTR>(lParam)));
return 0;
}
case WM_TIMER: case WM_TIMER:
{ {
// 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。 // 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。
+16 -3
View File
@@ -276,12 +276,13 @@ namespace jdi
bool HitTest(int x, int y) const; bool HitTest(int x, int y) const;
/** /**
* @brief 查询该位置应使用的鼠标光标(当前仅「筛选信号」清空按钮 ✕ 使用手型)。 * @brief 查询该位置应使用的鼠标光标(✕ 类按钮使用手型)。
* @param [IN] x 客户区横坐标。 * @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。 * @param [IN] y 客户区纵坐标。
* @return 需要特殊光标时返回光标句柄,否则返回 nullptr。 * @return 需要特殊光标时返回光标句柄,否则返回 nullptr。
* @note 需要按需重建列表行缓存(查 ✕ 的命中),因此不是 const。
*/ */
HCURSOR QueryCursor(int x, int y) const; HCURSOR QueryCursor(int x, int y);
/** /**
* @brief 将列表滚动到指定信号所在位置。 * @brief 将列表滚动到指定信号所在位置。
@@ -321,7 +322,8 @@ namespace jdi
CHECK, /**< 复选框。 */ CHECK, /**< 复选框。 */
BODY, /**< 行主体。 */ BODY, /**< 行主体。 */
CLOSE, /**< 文件行的关闭按钮。 */ CLOSE, /**< 文件行的关闭按钮。 */
LANE_BADGE /**< 信号行的网格徽标。 */ LANE_BADGE, /**< 信号行的网格徽标。 */
REMOVE_SIGNAL /**< 信号行左侧的「移除该信号」✕ 按钮。 */
}; };
/** /**
@@ -402,6 +404,17 @@ namespace jdi
*/ */
gd::RectF SignalBadgeRect(int row) const; gd::RectF SignalBadgeRect(int row) const;
/**
* @brief 计算信号行左侧「移除该信号」✕ 按钮的矩形。
*
* 位置就信号行空着的折叠三角列,并与所属分组行的复选框**同一列**(左边界对齐),
* 因此始终位于复选框前方且与其垂直对齐。
*
* @param [IN] row 行下标。
* @return 按钮矩形;非信号行时返回空矩形。
*/
gd::RectF SignalRemoveRect(int row) const;
/** /**
* @brief 绘制文件分组标题行。 * @brief 绘制文件分组标题行。
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
+60 -4
View File
@@ -29,6 +29,7 @@ namespace jdi
const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */ const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */
const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */ const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */
const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */ const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */
const float kRemoveButtonSize = 14.0f; /**< 信号行「移除该信号」✕ 按钮尺寸。 */
/** /**
* @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。 * @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。
@@ -237,7 +238,7 @@ namespace jdi
m_layoutDirty = true; m_layoutDirty = true;
} }
HCURSOR SignalPanel::QueryCursor(int x, int y) const HCURSOR SignalPanel::QueryCursor(int x, int y)
{ {
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。 // 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
const gd::RectF clearRect = FilterClearRect(); const gd::RectF clearRect = FilterClearRect();
@@ -246,6 +247,13 @@ namespace jdi
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
} }
// 信号行左侧的「移除该信号」✕ 同理。
RowPart part = RowPart::NONE;
if (HitRow(x, y, part) >= 0 && part == RowPart::REMOVE_SIGNAL)
{
return LoadCursorW(nullptr, IDC_HAND);
}
return nullptr; return nullptr;
} }
@@ -457,6 +465,23 @@ namespace jdi
rect.Y + (rect.Height - height) * 0.5f, width, height); rect.Y + (rect.Height - height) * 0.5f, width, height);
} }
gd::RectF SignalPanel::SignalRemoveRect(int row) const
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::SIGNAL)
{
return gd::RectF();
}
// 分组行的复选框矩形为 [rect.X + kPadding + kDisclosureWidth, +kCheckSize],
// 信号行的折叠三角列是空着的,正好用来放 ✕:左边界与分组复选框对齐
// (垂直方向同列),右边界仍留在三角列内,不会压到信号自己的复选框。
const gd::RectF rect = RowRect(row);
const float left = rect.X + (kPadding + kDisclosureWidth) * m_scale;
const float size = kRemoveButtonSize * m_scale;
return gd::RectF(left, rect.Y + (rect.Height - size) * 0.5f, size, size);
}
int SignalPanel::HitRow(int x, int y, RowPart& part) int SignalPanel::HitRow(int x, int y, RowPart& part)
{ {
part = RowPart::NONE; part = RowPart::NONE;
@@ -524,7 +549,12 @@ namespace jdi
const SignalRef ref{row.fileIndex, row.signalIndex}; const SignalRef ref{row.fileIndex, row.signalIndex};
const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref); const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref);
if (UiPointInRect(checkRect, fx, fy)) // ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。
if (UiPointInRect(SignalRemoveRect(rowIndex), fx, fy))
{
part = RowPart::REMOVE_SIGNAL;
}
else if (UiPointInRect(checkRect, fx, fy))
{ {
part = RowPart::CHECK; part = RowPart::CHECK;
} }
@@ -980,6 +1010,18 @@ namespace jdi
const bool visible = m_tab->IsVisible(ref); const bool visible = m_tab->IsVisible(ref);
UiDrawCheckBox(g, checkRect, visible ? 1 : 0, 3.0f * m_scale); UiDrawCheckBox(g, checkRect, visible ? 1 : 0, 3.0f * m_scale);
// 「移除该信号」✕:位于复选框前方(分组复选框所在列),悬停时高亮为危险色。
{
const gd::RectF removeRect = SignalRemoveRect(row);
const bool removeHot = hovered && (m_hoverPart == RowPart::REMOVE_SIGNAL);
if (removeHot)
{
UiFillRoundRect(g, removeRect, 3.0f * m_scale, m_theme->pressed);
}
UiDrawCrossIcon(g, removeRect, removeHot ? m_theme->danger : m_theme->textMuted,
1.4f * m_scale);
}
// 颜色块:叠加不透明度,透明度过低时自动补描边(与样式区色块规则一致)。 // 颜色块:叠加不透明度,透明度过低时自动补描边(与样式区色块规则一致)。
const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale, const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale,
centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale, centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale,
@@ -2082,6 +2124,20 @@ namespace jdi
} }
const SignalRef ref{item.fileIndex, item.signalIndex}; const SignalRef ref{item.fileIndex, item.signalIndex};
// ✕:用户明确表示不再需要该信号 → 交由宿主从数据层移除并释放其采样内存。
// 必须在 SelectSignal 之前处理,避免选中一条即将被删除的信号。
if (part == RowPart::REMOVE_SIGNAL)
{
if (m_host != nullptr)
{
// wParam = 文件下标,lParam = 文件内信号下标(见 Application 的消息处理)。
PostMessageW(m_host, WM_APP + 2, static_cast<WPARAM>(item.fileIndex),
static_cast<LPARAM>(item.signalIndex));
}
return true;
}
SelectSignal(ref); SelectSignal(ref);
if (part == RowPart::CHECK) if (part == RowPart::CHECK)
@@ -2202,8 +2258,8 @@ namespace jdi
return BeginEdit(StyleEditField::FILE_NAME, row); return BeginEdit(StyleEditField::FILE_NAME, row);
} }
// 复选框与网格徽标各自由单击处理,双击不再切换可见性。 // 复选框、网格徽标与「移除信号」✕ 各自由单击处理,双击不再切换可见性/进入重命名。
if (part == RowPart::CHECK || part == RowPart::LANE_BADGE) if (part == RowPart::CHECK || part == RowPart::LANE_BADGE || part == RowPart::REMOVE_SIGNAL)
{ {
return false; return false;
} }
+33
View File
@@ -220,6 +220,26 @@ namespace jdi
*/ */
bool RemoveFile(int fileIndex); bool RemoveFile(int fileIndex);
/**
* @brief 从某个分组中移除一条信号,并立即释放它的采样内存。
*
* 该信号之后的所有信号下标整体前移一位,因此调用方必须紧接着对每个标签页调用
* `TabConfig::RemapAfterSignalRemoval()`,并用 `RemapSelectionAfterSignalRemoval()`
* 修正共享选中项。使用者一旦确认不再需要该信号即可调用,本调用后其采样缓冲立即析构。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 文件内信号下标。
* @return 移除成功返回 true。
*/
bool RemoveSignal(int fileIndex, int signalIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正选中引用。
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 即将被移除的信号下标。
*/
void RemapSelectionAfterSignalRemoval(int fileIndex, int signalIndex);
/** /**
* @brief 判断数据集合是否为空。 * @brief 判断数据集合是否为空。
* @return 未加载任何文件返回 true。 * @return 未加载任何文件返回 true。
@@ -486,6 +506,19 @@ namespace jdi
*/ */
void RemapAfterFileRemoval(int fileIndex); void RemapAfterFileRemoval(int fileIndex);
/**
* @brief 在数据层移除某条信号之前,按旧下标修正本配置(不重建网格)。
*
* 会删除该信号的可见性记录与网格归属,并把同一文件内位于其后的引用下标前移一位。
* 若某个网格因此变空则一并移除(与“取消勾选后空网格消失”的策略一致)。
*
* @param [IN] fileIndex 信号所属文件下标。
* @param [IN] signalIndex 即将被移除的信号下标。
* @note 调用顺序必须为:各标签页 RemapAfterSignalRemoval → 数据层修正选中 →
* 数据层 RemoveSignal → 各标签页 SyncToDataSet。
*/
void RemapAfterSignalRemoval(int fileIndex, int signalIndex);
/** /**
* @brief 判断指定信号是否可见。 * @brief 判断指定信号是否可见。
* @param [IN] ref 信号引用。 * @param [IN] ref 信号引用。
+87
View File
@@ -690,6 +690,50 @@ namespace jdi
return true; return true;
} }
bool DataSet::RemoveSignal(int fileIndex, int signalIndex)
{
if (fileIndex < 0 || fileIndex >= static_cast<int>(m_files.size()))
{
return false;
}
DataFile& file = m_files[static_cast<std::size_t>(fileIndex)];
if (signalIndex < 0 || signalIndex >= static_cast<int>(file.signals.size()))
{
return false;
}
// 与 RemoveFile 同一思路:先把待移除项移到末位(move 赋值时其原有缓冲当场析构),
// 再 pop_back + shrink_to_fit,使该信号的全部采样内存在本调用内立即归还给系统,
// 而不是留在容量里等后续插入覆盖。
std::vector<SignalData>& signals = file.signals;
const std::size_t position = static_cast<std::size_t>(signalIndex);
for (std::size_t index = position; index + 1 < signals.size(); ++index)
{
signals[index] = std::move(signals[index + 1]);
}
signals.pop_back();
signals.shrink_to_fit();
return true;
}
void DataSet::RemapSelectionAfterSignalRemoval(int fileIndex, int signalIndex)
{
if (fileIndex < 0 || signalIndex < 0 || m_selected.fileIndex != fileIndex)
{
return;
}
if (m_selected.signalIndex == signalIndex)
{
// 选中的信号本身被移除:取消选中。
m_selected = SignalRef();
}
else if (m_selected.signalIndex > signalIndex)
{
// 其后信号整体前移一位。
--m_selected.signalIndex;
}
}
std::size_t DataSet::SignalCount() const std::size_t DataSet::SignalCount() const
{ {
std::size_t count = 0; std::size_t count = 0;
@@ -1112,6 +1156,49 @@ namespace jdi
m_lanes.end()); m_lanes.end());
} }
void TabConfig::RemapAfterSignalRemoval(int fileIndex, int signalIndex)
{
if (fileIndex < 0 || signalIndex < 0)
{
return;
}
// 可见性表:删掉该信号对应的那一列。
const std::size_t slot = static_cast<std::size_t>(fileIndex);
if (slot < m_visible.size())
{
std::vector<bool>& fileVisible = m_visible[slot];
if (static_cast<std::size_t>(signalIndex) < fileVisible.size())
{
fileVisible.erase(fileVisible.begin() + signalIndex);
}
}
// 网格归属:删掉该信号本身,并把同一文件内位于其后的引用下标前移一位。
for (Lane& lane : m_lanes)
{
std::vector<SignalRef>& refs = lane.signals;
refs.erase(std::remove_if(refs.begin(), refs.end(),
[fileIndex, signalIndex](const SignalRef& ref)
{
return ref.fileIndex == fileIndex &&
ref.signalIndex == signalIndex;
}),
refs.end());
for (SignalRef& ref : refs)
{
if (ref.fileIndex == fileIndex && ref.signalIndex > signalIndex)
{
--ref.signalIndex;
}
}
}
// 被移除信号若独占了某个网格,该网格随之消失(与取消勾选的行为一致)。
m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(),
[](const Lane& lane) { return lane.signals.empty(); }),
m_lanes.end());
}
bool TabConfig::IsVisible(const SignalRef& ref) const bool TabConfig::IsVisible(const SignalRef& ref) const
{ {
if (ref.fileIndex < 0 || static_cast<std::size_t>(ref.fileIndex) >= m_visible.size()) if (ref.fileIndex < 0 || static_cast<std::size_t>(ref.fileIndex) >= m_visible.size())
+12 -4
View File
@@ -578,12 +578,20 @@ namespace jdi
const int saveState = g.Save(); const int saveState = g.Save();
g.SetClip(clipRect, gd::CombineModeIntersect); g.SetClip(clipRect, gd::CombineModeIntersect);
// 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感; // 选区高亮绘制在文本之下,整段选中时呈现“已全选”观感。
// 叠加上 GDI+ 裁剪,高亮同样不会越过输入框边界。 // ⚠️ 这里**必须自己把矩形夹到内容区**:`UiFillRect` 对面积足够大的不透明矩形会走
// GDI `FillRect` 快路径,而 GDI 绘制**不受 GDI+ 裁剪区约束**,长文本的全选高亮
// (宽度等于整段文本宽度)因此会溢出到输入框之外。夹取之后矩形始终落在框内,
// 既保留快路径又不越界。
if (edit.HasSelection() && selEndX > selBeginX) if (edit.HasSelection() && selEndX > selBeginX)
{ {
const gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, selEndX - selBeginX, gd::RectF highlight(textLeft + selBeginX, rect.Y + 3.0f * scale, selEndX - selBeginX,
rect.Height - 6.0f * scale); rect.Height - 6.0f * scale);
const float left = std::max(highlight.X, clipRect.X);
const float right = std::min(highlight.X + highlight.Width, clipRect.X + clipRect.Width);
const float top = std::max(highlight.Y, clipRect.Y);
const float bottom = std::min(highlight.Y + highlight.Height, clipRect.Y + clipRect.Height);
highlight = gd::RectF(left, top, right - left, bottom - top);
UiFillRect(g, highlight, theme.accent); UiFillRect(g, highlight, theme.accent);
} }