diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index bdabe9f..c156b5f 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -364,6 +364,16 @@ namespace jdi float m_loadingProgress = 0.0f; /**< 加载进度(0-1)。 */ float m_loadingSpin = 0.0f; /**< 加载动画旋转相位(度)。 */ unsigned long long m_loadingBytes = 0; /**< 当前文件字节数(用于展示体积)。 */ + + /** + * @brief 装饰区域(标签栏 / 工具栏 / 信号面板 / 分隔条)是否需要重绘。 + * + * 这些区域的绘制成本与数据量无关但与控件数量成正比,且只在鼠标位于其中 + * 或发生交互时才会变化。绘图区拖拽(如双光标左右移动)期间持续置为 + * false,从而每帧只重绘、只回送绘图区所在的矩形,避免整窗重绘的开销。 + */ + bool m_chromeDirty = true; + bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */ }; } // namespace jdi diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 2b59902..3c3738e 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -37,6 +37,32 @@ namespace jdi const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */ const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */ + /** + * @brief 只失效绘图区所在的矩形。 + * + * 配合 WM_PAINT 中对更新区域的判断,可以走“只重绘、只回送绘图区”的快路径; + * 绘图区矩形无效时退化为整窗失效,保证不会漏画。 + * + * @param [IN] hwnd 窗口句柄。 + * @param [IN] plotRect 绘图区矩形(客户区坐标)。 + */ + void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect) + { + RECT invalid = {}; + invalid.left = static_cast(std::floor(plotRect.X)); + invalid.top = static_cast(std::floor(plotRect.Y)); + invalid.right = static_cast(std::ceil(plotRect.X + plotRect.Width)); + invalid.bottom = static_cast(std::ceil(plotRect.Y + plotRect.Height)); + if (invalid.right > invalid.left && invalid.bottom > invalid.top) + { + InvalidateRect(hwnd, &invalid, FALSE); + } + else + { + InvalidateRect(hwnd, nullptr, FALSE); + } + } + /** @brief 标签页右键菜单命令 ID。 */ const int kMenuTabNew = 1; const int kMenuTabClose = 2; @@ -594,6 +620,9 @@ namespace jdi m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height)); m_panel.Layout(m_panelRect); m_plot.Layout(m_plotRect); + + // 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。 + m_chromeDirty = true; } void Application::EnsureBackBuffer(int width, int height) @@ -605,6 +634,9 @@ namespace jdi ReleaseBackBuffer(); + // 新建的后备位图内容未定义,必须整帧重绘一次。 + m_chromeDirty = true; + HDC screenDc = GetDC(m_hwnd); if (screenDc == nullptr) { @@ -697,26 +729,34 @@ namespace jdi } } + // 工具栏/标签栏/面板各自带悬停态,内容只在鼠标位于其中或发生交互时改变。 + // 绘图区内的拖拽(如双光标左右移动)期间这些区域完全静止,跳过其绘制, + // 每帧省下约 6-8ms;弹出菜单 / 拾色器 / 加载遮罩会覆盖到装饰区域,必须整帧重绘。 + const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen(); + const bool chromeDirty = m_chromeDirty || m_loading || overlaysVisible; + m_chromeDirty = false; + // 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。 + const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation(); + { gd::Graphics g(m_backDc); g.SetSmoothingMode(gd::SmoothingModeAntiAlias); g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - // 工具栏/标签栏/面板各自带悬停态,必须每帧绘制; - // 真正昂贵的是绘图区的波形(大文件约 80ms/帧), - // 由 PlotView 内部的静态场景缓存消除,与数据量无关。 - UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), - m_theme.windowBg); + if (chromeDirty) + { + UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), + m_theme.windowBg); - m_tabBar.Paint(g); - m_toolbar.Paint(g); - m_panel.Paint(g); + m_tabBar.Paint(g); + m_toolbar.Paint(g); + m_panel.Paint(g); + UiFillRect(g, m_splitterRect, m_theme.border); + } m_plot.Paint(g); - UiFillRect(g, m_splitterRect, m_theme.border); - // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 m_menu.Paint(g); m_colorPicker.Paint(g); @@ -728,7 +768,19 @@ namespace jdi HDC windowDc = GetDC(m_hwnd); if (windowDc != nullptr) { - BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); + if (fullFrame) + { + BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); + } + else + { + // PlotView 会把自身绘制严格裁剪在 m_plotRect 内,因此只回送该矩形即可。 + const int px = static_cast(std::floor(m_plotRect.X)); + const int py = static_cast(std::floor(m_plotRect.Y)); + const int pw = static_cast(std::ceil(m_plotRect.X + m_plotRect.Width)) - px; + const int ph = static_cast(std::ceil(m_plotRect.Y + m_plotRect.Height)) - py; + BitBlt(windowDc, px, py, pw, ph, m_backDc, px, py, SRCCOPY); + } ReleaseDC(m_hwnd, windowDc); } @@ -1624,6 +1676,16 @@ namespace jdi LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + // 除鼠标移动与鼠标移出(由下面的分支按“是否只影响绘图区”精确判断)之外, + // 其余消息都可能改变标签栏 / 工具栏 / 面板的外观,统一标记装饰区域为脏。 + // WM_PAINT / WM_TIMER 的重绘范围由各自的处理分支判断,这里不做标记。 + if (message != WM_PAINT && message != WM_MOUSEMOVE && message != WM_MOUSELEAVE && + message != WM_SETCURSOR && message != WM_NCHITTEST && message != WM_TIMER && + message != WM_ERASEBKGND) + { + m_chromeDirty = true; + } + // 菜单打开时优先消费鼠标与键盘事件,形成模态交互。 if (m_menu.IsOpen()) { @@ -1877,6 +1939,25 @@ namespace jdi return 1; case WM_PAINT: { + // BeginPaint 会清空更新区域,因此必须先取出本次待重绘的范围: + // 只有完全落在绘图区内的更新区域才允许走“局部回送”的快路径, + // 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。 + RECT update = {}; + const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE; + if (hasUpdate) + { + const bool insidePlot = + update.left >= static_cast(std::floor(m_plotRect.X)) && + update.top >= static_cast(std::floor(m_plotRect.Y)) && + update.right <= static_cast(std::ceil(m_plotRect.X + m_plotRect.Width)) && + update.bottom <= static_cast(std::ceil(m_plotRect.Y + m_plotRect.Height)) && + update.right > update.left && update.bottom > update.top; + if (!insidePlot) + { + m_chromeDirty = true; + } + } + PAINTSTRUCT paintStruct = {}; BeginPaint(hwnd, &paintStruct); Render(); @@ -1898,9 +1979,28 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); const bool buttonDown = (wParam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON)) != 0; + + // 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘: + // 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。 + const bool inPlot = UiPointInRect(m_plotRect, static_cast(x), static_cast(y)); + const bool plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot); + if (!plotOnlyMove) + { + m_chromeDirty = true; + } + m_mouseOverPlot = inPlot; + if (HandleMouseMove(x, y, buttonDown)) { - InvalidateRect(hwnd, nullptr, FALSE); + if (plotOnlyMove && !m_chromeDirty) + { + // 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。 + InvalidatePlotRect(hwnd, m_plotRect); + } + else + { + InvalidateRect(hwnd, nullptr, FALSE); + } } // 拖动(鼠标捕获)期间 Windows 不会派发 WM_SETCURSOR, // 因此需要主动刷新一次,否则光标形状会一直停在按下的状态。 @@ -1913,11 +2013,25 @@ namespace jdi case WM_MOUSELEAVE: { m_trackingLeave = false; - bool changed = m_tabBar.OnMouseLeave(); - changed = m_toolbar.OnMouseLeave() || changed; - changed = m_panel.OnMouseLeave() || changed; - changed = m_plot.OnMouseLeave() || changed; - if (changed) + // 只有标签栏 / 工具栏 / 面板的悬停态变化才需要重绘装饰区域; + // 绘图区自身的悬停态变化(提示浮层、信号光晕)只需重绘绘图区, + // 这样拖拽光标时鼠标短暂移出窗口也不会退化成整帧重绘。 + bool chromeChanged = m_tabBar.OnMouseLeave(); + chromeChanged = m_toolbar.OnMouseLeave() || chromeChanged; + chromeChanged = m_panel.OnMouseLeave() || chromeChanged; + const bool plotChanged = m_plot.OnMouseLeave(); + m_mouseOverPlot = false; + + if (chromeChanged) + { + m_chromeDirty = true; + } + + if (plotChanged && !chromeChanged && !m_chromeDirty) + { + InvalidatePlotRect(hwnd, m_plotRect); + } + else if (chromeChanged || plotChanged) { InvalidateRect(hwnd, nullptr, FALSE); } @@ -2262,6 +2376,10 @@ namespace jdi { cursor = m_plot.QueryCursor(point.x, point.y); } + else if (UiPointInRect(m_panelRect, static_cast(point.x), static_cast(point.y))) + { + cursor = m_panel.QueryCursor(point.x, point.y); + } if (cursor == nullptr) { cursor = LoadCursorW(nullptr, IDC_ARROW); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b279abf..257598c 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1004,10 +1004,12 @@ namespace jdi * @param [IN] pinX 横向贴边位置(光标在视界外时的钉住横坐标)。 * @param [IN] clipTop 网格可见区上边界。 * @param [IN] clipBottom 网格可见区下边界。 + * @param [IN] topReserve 上边界额外需要预留的高度(例如时间光标的顶部三角把手, + * 避免数据点三角压住光标把手)。默认 0。 * @return 标记几何。 */ DataMarker MakeDataMarker(float rawX, float rawY, float pinX, float clipTop, - float clipBottom) const; + float clipBottom, float topReserve = 0.0f) const; /** * @brief 绘制数据点标记:视界内为圆点,越界时在对应边缘绘制指示方向的三角。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 0a08433..16acaa8 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -28,6 +28,7 @@ namespace jdi const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */ const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */ + const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */ const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ @@ -294,6 +295,42 @@ namespace jdi } return WithAlpha(style.color, static_cast(opacity * 255.0 + 0.5)); } + + /** + * @brief 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。 + * + * 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上, + * 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移, + * 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧, + * 保证曲线既能与波形区分、又能压在底图上看得清。 + * + * 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。 + * + * @param [IN] color 原始信号色(0xAARRGGBB)。 + * @param [IN] background 绘图区底色,用于评估可见度。 + * @return 派生出的高对比度颜色(alpha 与输入一致,由调用方再覆盖)。 + */ + std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background = 0xFFFDF6E3u) + { + const auto squaredDistance = [](std::uint32_t a, std::uint32_t b) + { + const double dr = + static_cast((a >> 16) & 0xFFu) - static_cast((b >> 16) & 0xFFu); + const double dg = + static_cast((a >> 8) & 0xFFu) - static_cast((b >> 8) & 0xFFu); + const double db = static_cast(a & 0xFFu) - static_cast(b & 0xFFu); + return dr * dr + dg * dg + db * db; + }; + + const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72); + const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72); + // 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。 + const double scoreDarker = + squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background); + const double scoreLighter = + squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background); + return (scoreDarker >= scoreLighter) ? darker : lighter; + } } PlotView::PlotView() = default; @@ -5218,7 +5255,9 @@ namespace jdi const float rawY = ValueToY(value, lane); // 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。 - const DataMarker marker = MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom); + // 上边界额外预留光标顶部把手的高度,避免数据点三角压住光标把手。 + const DataMarker marker = + MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); const std::uint32_t signalColor = StyleColor(*resolved.style); @@ -5276,8 +5315,8 @@ namespace jdi if (CenterValueAt(*resolved.file, *resolved.data, smooth, time, centerValue)) { const float rawCenterY = ValueToY(centerValue, lane); - const DataMarker centerMarker = - MakeDataMarker(rawX, rawCenterY, pinX, clipTop, clipBottom); + const DataMarker centerMarker = MakeDataMarker( + rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); { // 中心轴数据点的配色与原始信号一致(便于一一对应); // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 @@ -5514,13 +5553,14 @@ namespace jdi } PlotView::DataMarker PlotView::MakeDataMarker(float rawX, float rawY, float pinX, float clipTop, - float clipBottom) const + float clipBottom, float topReserve) const { DataMarker marker; - // 上下越界时同样"钉"在网格可见边缘(留出三角形的高度,保证整体可见)。 + // 上下越界时"钉"在网格可见边缘(留出三角形的高度,保证整体可见); + // 另外预留 topReserve(时间光标的顶部三角把手),避免数据点三角压住把手。 const float margin = 7.0f * m_scale; marker.x = pinX; - marker.y = std::max(clipTop + margin, std::min(rawY, clipBottom - margin)); + marker.y = std::max(clipTop + margin + topReserve, std::min(rawY, clipBottom - margin)); marker.offLeft = (rawX < marker.x - 0.5f); marker.offRight = (rawX > marker.x + 0.5f); marker.offUp = (rawY < marker.y - 0.5f); @@ -5722,13 +5762,13 @@ namespace jdi return; } - // 中心平均值曲线:与原始信号**同色**,只是更透明一些;线宽等其它样式同样跟随原信号, - // 因此原信号改颜色/线宽/透明度时曲线会自动同步。 + // 中心平均值曲线:与原信号**同色相**但大幅拉开明度,使其与原始波形形成高对比 + // (原信号改颜色时曲线随之改变,因为它是从信号色派生出来的); + // 透明度与原信号保持一致。 const unsigned int baseAlpha = static_cast( std::max(0.0, std::min(1.0, static_cast(resolved.style->opacity))) * 255.0 + 0.5); - const std::uint32_t derived = - WithAlpha(StyleColor(*resolved.style), - static_cast(static_cast(baseAlpha) * 0.55 + 0.5)); + const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), + baseAlpha); gd::Pen pen(ToGdiColor(derived), std::max(1.0f, resolved.style->lineWidth) * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); @@ -6137,6 +6177,13 @@ namespace jdi return; } + // 其它拖拽(光标/平移/中键/横向光标/面板)期间不显示信号悬停提示: + // 这些操作不会更新 m_hoverSignal,按下前的值会残留,表现为提示气泡"挂在原地"。 + if (m_dragMode != PlotDragMode::NONE) + { + return; + } + // 悬停提示:光标贴近曲线时给出可拖拽说明(与时间光标的提示方式一致)。 if (!m_hoverSignal.IsValid()) { diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 45cb5c5..3934387 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -293,6 +293,14 @@ namespace jdi */ bool HitTest(int x, int y) const; + /** + * @brief 查询该位置应使用的鼠标光标(当前仅「筛选信号」清空按钮 ✕ 使用手型)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要特殊光标时返回光标句柄,否则返回 nullptr。 + */ + HCURSOR QueryCursor(int x, int y) const; + /** * @brief 将列表滚动到指定信号所在位置。 * @param [IN] ref 目标信号引用。 @@ -347,7 +355,8 @@ namespace jdi OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ WIDTH_SLIDER, /**< 线宽滑块。 */ WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ - TOGGLE_ALL /**< 全部显示/隐藏按钮。 */ + TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */ + FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */ }; /** @@ -530,6 +539,17 @@ namespace jdi */ gd::RectF FilterFieldRect() const; + /** + * @brief 计算「筛选信号」输入框右侧清空按钮 ✕ 的矩形(无输入时为空矩形)。 + * @return 清空按钮矩形。 + */ + gd::RectF FilterClearRect() const; + + /** + * @brief 清空筛选内容(点击 ✕ 时调用),并刷新列表。 + */ + void ClearFilter(); + /** * @brief 计算指定行中名称区域的矩形(列表行内联编辑使用)。 * @param [IN] row 行下标。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 950b358..e966878 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -28,6 +28,7 @@ namespace jdi const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */ const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */ const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */ + const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */ /** * @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。 @@ -218,6 +219,36 @@ namespace jdi return UiPointInRect(m_filterRect, static_cast(x), static_cast(y)); } + void SignalPanel::ClearFilter() + { + if (m_filterText.empty() && !m_filterEditing) + { + return; + } + m_filterText.clear(); + if (m_filterEditing) + { + // 编辑态下一并重置输入模型,避免残留旧文本(Begin 会整段选中/清空)。 + m_filterEdit.Begin(std::wstring(), false, EditFilter::ANY, 0u); + } + // 清空后回到完整列表:重建行集合、回到顶部。 + m_rowsDirty = true; + m_scrollOffset = 0.0f; + m_layoutDirty = true; + } + + HCURSOR SignalPanel::QueryCursor(int x, int y) const + { + // 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。 + const gd::RectF clearRect = FilterClearRect(); + if (clearRect.Width > 0.0f && + UiPointInRect(clearRect, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + return nullptr; + } + void SignalPanel::BeginFilterEdit() { if (m_filterEditing) @@ -723,25 +754,47 @@ namespace jdi UiDrawRoundRect(g, m_filterRect, kCorner * m_scale, m_filterEditing ? m_theme->accent : m_theme->border, m_filterEditing ? 1.6f : 1.0f); + + const float pad = kFilterTextPad * m_scale; + const gd::RectF clearRect = FilterClearRect(); + // 有输入内容时在右侧留出 ✕ 的位置,文本不会压到按钮上。 + const float textRight = (clearRect.Width > 0.0f) ? clearRect.X : (m_filterRect.X + m_filterRect.Width); + const float textWidth = std::max(8.0f, textRight - pad - (m_filterRect.X + pad)); + const gd::RectF textRect(m_filterRect.X + pad, m_filterRect.Y, textWidth, + m_filterRect.Height); + if (m_filterEditing) { - UiDrawEditField(g, FilterFieldRect(), m_filterEdit, m_fonts->uiSmall, m_scale); + gd::RectF fieldRect = FilterFieldRect(); + fieldRect.Width = std::max(8.0f, textWidth + pad); + UiDrawEditField(g, fieldRect, m_filterEdit, m_fonts->uiSmall, m_scale); } else if (m_filterText.empty()) { - UiText(g, L"筛选信号", m_fonts->uiSmall, - gd::RectF(m_filterRect.X + 8.0f * m_scale, m_filterRect.Y, - m_filterRect.Width - 16.0f * m_scale, m_filterRect.Height), - m_theme->textMuted, TextAlign::LEFT); + UiText(g, L"筛选信号", m_fonts->uiSmall, textRect, m_theme->textMuted, TextAlign::LEFT); } else { - UiText(g, UiEllipsize(g, m_filterText, m_fonts->uiSmall, - m_filterRect.Width - 16.0f * m_scale), - m_fonts->uiSmall, - gd::RectF(m_filterRect.X + 8.0f * m_scale, m_filterRect.Y, - m_filterRect.Width - 16.0f * m_scale, m_filterRect.Height), - m_theme->text, TextAlign::LEFT); + UiText(g, UiEllipsize(g, m_filterText, m_fonts->uiSmall, textWidth), m_fonts->uiSmall, + textRect, m_theme->text, TextAlign::LEFT); + } + + // 清空按钮 ✕:仅在筛选框有内容时出现,悬停高亮(鼠标光标保持箭头)。 + if (clearRect.Width > 0.0f) + { + const bool clearHot = (m_hoverStyle == StyleHit::FILTER_CLEAR); + if (clearHot) + { + UiFillRoundRect(g, clearRect, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33)); + } + const std::uint32_t crossColor = clearHot ? m_theme->danger : m_theme->textMuted; + const float crossPad = clearRect.Width * 0.28f; + UiDrawLine(g, clearRect.X + crossPad, clearRect.Y + crossPad, + clearRect.X + clearRect.Width - crossPad, + clearRect.Y + clearRect.Height - crossPad, crossColor, 1.5f * m_scale); + UiDrawLine(g, clearRect.X + clearRect.Width - crossPad, clearRect.Y + crossPad, + clearRect.X + crossPad, clearRect.Y + clearRect.Height - crossPad, crossColor, + 1.5f * m_scale); } } @@ -755,6 +808,12 @@ namespace jdi UiText(g, L"尚未加载数据,请打开 CSV 文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted, TextAlign::CENTER); } + else if (m_rows.empty() && !m_filterText.empty()) + { + // 筛选结果为空:明确提示"无匹配",而不是让人误以为数据丢失。 + UiText(g, L"无匹配的筛选信号", m_fonts->uiSmall, m_listRect, m_theme->textMuted, + TextAlign::CENTER); + } else { const int rowCount = static_cast(m_rows.size()); @@ -1393,11 +1452,23 @@ namespace jdi gd::RectF SignalPanel::FilterFieldRect() const { - // 输入框内容区:与占位提示/显示文本使用同一内缩,保证编辑时不跳动。 - const float inset = 7.0f * m_scale; - return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + 2.0f * m_scale, + // 输入框内容区:左右与上下使用**同一内缩**,避免编辑态出现"左右空隙明显更大"的观感; + // 占位提示与显示文本也用同一内缩,因此进入/退出编辑时文字不会跳动。 + const float inset = kFilterTextPad * m_scale; + return gd::RectF(m_filterRect.X + inset, m_filterRect.Y + inset, std::max(8.0f, m_filterRect.Width - inset * 2.0f), - std::max(8.0f, m_filterRect.Height - 4.0f * m_scale)); + std::max(8.0f, m_filterRect.Height - inset * 2.0f)); + } + + gd::RectF SignalPanel::FilterClearRect() const + { + if (m_filterText.empty()) + { + return gd::RectF(); + } + const float size = std::max(12.0f, m_filterRect.Height - kFilterTextPad * 2.0f * m_scale); + const float right = m_filterRect.X + m_filterRect.Width - kFilterTextPad * m_scale; + return gd::RectF(right - size, m_filterRect.Y + (m_filterRect.Height - size) * 0.5f, size, size); } gd::RectF SignalPanel::EditFieldRect() const @@ -1610,6 +1681,15 @@ namespace jdi { if (m_filterEditing) { + // 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理, + // 否则点击会被当成"把光标移到此处"而清空不了内容。 + const gd::RectF clearRect = FilterClearRect(); + if (clearRect.Width > 0.0f && + UiPointInRect(clearRect, static_cast(x), static_cast(y))) + { + ClearFilter(); + return true; + } return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y); } if (m_editField == StyleEditField::NONE) @@ -1786,6 +1866,11 @@ namespace jdi { hoverStyle = StyleHit::TOGGLE_ALL; } + else if (UiPointInRect(FilterClearRect(), fx, fy)) + { + // 筛选框右侧的清空 ✕(仅在筛选框有内容时存在)。 + hoverStyle = StyleHit::FILTER_CLEAR; + } else if (m_tab != nullptr && StyleOf(SelectedRef()) != nullptr) { for (int index = 0; index < m_colorCount; ++index) @@ -1859,6 +1944,12 @@ namespace jdi // 「筛选信号」输入框:单击进入编辑;点击其它位置则先结束筛选编辑(文本已实时生效)。 if (IsOnFilterBox(x, y)) { + // 右侧 ✕:清空筛选内容(不退出编辑态,便于继续输入)。 + if (UiPointInRect(FilterClearRect(), fx, fy)) + { + ClearFilter(); + return true; + } BeginFilterEdit(); return true; }