diff --git a/.gitignore b/.gitignore index 7e8c10e..806a709 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ Build/ # 测试数据(仅本地保留,不纳入版本控制) TestData/ + +# 临时验证工具(仅本地保留,不入版本控制) +Tools/ diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 9d63374..730ee8a 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -17,6 +17,9 @@ #include "TabBar.hpp" #include "Theme.hpp" #include "Toolbar.hpp" +#include "UiComponent.hpp" +#include "UiDebugBounds.hpp" +#include "UiSurface.hpp" namespace jdi { @@ -130,12 +133,21 @@ namespace jdi * @param [IN] height 客户区高度。 */ void EnsureBackBuffer(int width, int height); - /** * @brief 释放后备缓冲区。 */ void ReleaseBackBuffer(); + /** + * @brief 取"整个应用共享的那张后台位图"作为一段可绘制表面。 + * + * 这是 P5 的基础设施:后备缓冲是 **top-down 32bpp DIB 段**,因此除了 `dc` 之外 + * 还能给出 `bits`(像素直写)与 `version`(位图代次;`WM_SIZE` / `WM_DPICHANGED` + * 重建后旧句柄必须作废)。 + * ⚠️ 绝大多数绘制只需要 `dc + rect`;`bits` 仅用于 CPU 侧合成,**不要**用它画控件。 + */ + UiSurface BackBufferSurface() const; + /** * @brief 完整重绘客户区并呈现到窗口。 */ @@ -376,6 +388,29 @@ namespace jdi */ void PaintLoadingOverlay(gd::Graphics& g, int width, int height); + /** + * @brief 展开「应用设置」菜单(工具栏齿轮按钮)。 + * + * 锚点取齿轮按钮的**实际矩形**(由控件实测),因此菜单总能贴在按钮下方, + * 不会因按钮文本宽度变化而错位。 + */ + void OpenSettingsMenu(); + + /** + * @brief 绘制全部 UI 控件的布局边界(调试开关开启时在 Render 的最后一步调用)。 + * + * 内容分两路: + * * **组件结构**:遍历组件表(底 → 顶)让每个组件上报内部结构,再补上不属于任何 + * 组件的区域(客户区外框、面板分隔条、菜单 / 拾色器); + * * **文本**:绘制期间由 `UiSetTextRectSink(&m_debugBoxes)` 自动收集(见 `PaintUtil`), + * 因为文本是本项目唯一"没有几何身份"的元素,它在 60+ 个调用点现算矩形、画完即弃。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] width 客户区宽度。 + * @param [IN] height 客户区高度。 + */ + void PaintLayoutBounds(gd::Graphics& g, int width, int height); + /** * @brief 处理客户区鼠标移动。 * @param [IN] x 客户区横坐标。 @@ -416,6 +451,48 @@ namespace jdi SignalPanel m_panel; /**< 左侧信号面板。 */ PlotView m_plot; /**< 波形绘图区。 */ + /** + * @brief 顶层组件表的一项。 + * + * 宿主对“设置缩放 / 摆放 / 悬停离开 / 光标仲裁 / 绘制装饰区”都**只按这张表遍历**, + * 不再在每条消息里逐个点名组件(旧实现新增一个组件要改 5 处)。 + */ + struct ComponentEntry + { + UiComponent* component = nullptr; /**< 组件实例。 */ + Target target = Target::NONE; /**< 按下时写入 `m_activeTarget` 的值。 */ + bool chrome = false; /**< 是否属于“装饰区”(标签栏/工具栏/面板): + 决定悬停离开后是全帧重绘还是只重绘绘图区。 */ + }; + + /** + * @brief 组件表(**底 → 顶**顺序)。 + * + * 命中与光标仲裁从**后往前**遍历(末尾优先),与旧实现“菜单 > 拾色器 > …… > 绘图区”的 + * 优先级方向一致;由于四个组件的区域互不重叠,实际结果与旧的手写链完全等价。 + */ + std::vector m_components; + + /** + * @brief 是否显示**布局边界**(设置菜单里的调试开关)。 + * + * 开启时 `Render` 在最后一步遍历组件表让每个组件上报边界(见 `PaintLayoutBounds`), + * 并强制整帧回送(局部回送只会更新绘图区,装饰区的边界会留在旧帧上)。 + * 关闭时**完全不执行**,对正常绘制零影响。 + */ + bool m_showLayoutBounds = false; + + /** + * @brief 调试 box 容器(只在 `m_showLayoutBounds` 为真时使用)。 + * + * 作为成员而不是局部变量:避免开关打开时每帧重新分配, + * 也让它能直接充当 `PaintUtil` 的文本收集器(`UiTextRectSink`)。 + */ + UiDebugBoxList m_debugBoxes; + + /** @brief 填充组件表(`Initialize` 里 `Setup` 之后调用一次)。 */ + void BuildComponentTable(); + gd::RectF m_tabBarRect; /**< 标签栏区域。 */ gd::RectF m_toolbarRect; /**< 工具栏区域。 */ gd::RectF m_panelRect; /**< 信号面板区域。 */ @@ -428,10 +505,13 @@ namespace jdi int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */ HDC m_backDc = nullptr; /**< 后备缓冲设备上下文。 */ - HBITMAP m_backBitmap = nullptr; /**< 后备缓冲位图。 */ + HBITMAP m_backBitmap = nullptr; /**< 后备缓冲位图(top-down 32bpp DIB 段)。 */ HGDIOBJ m_backOldBitmap = nullptr; /**< 后备缓冲原始位图。 */ - int m_backWidth = 0; /**< 后备缓冲宽度。 */ - int m_backHeight = 0; /**< 后备缓冲高度。 */ + int m_backWidth = 0; /**< 后备缓冲宽度(设备像素)。 */ + int m_backHeight = 0; /**< 后备缓冲高度(设备像素)。 */ + std::uint32_t* m_backBits = nullptr; /**< 后备缓冲像素首地址(仅 CPU 侧合成时使用)。 */ + int m_backStride = 0; /**< 后备缓冲行字节数(**不可**假设 = 宽 × 4)。 */ + std::uint64_t m_backVersion = 0; /**< 后备缓冲代次(重建位图时递增,用于作废旧句柄)。 */ Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ diff --git a/Components/Application/Inc/ApplicationInternal.hpp b/Components/Application/Inc/ApplicationInternal.hpp index c013472..172696d 100644 --- a/Components/Application/Inc/ApplicationInternal.hpp +++ b/Components/Application/Inc/ApplicationInternal.hpp @@ -176,6 +176,9 @@ namespace jdi inline const int kMenuTabClose = 2; inline const int kMenuTabCloseOthers = 3; + /** @brief 应用设置菜单(工具栏齿轮)命令 ID。 */ + inline const int kAppCmdShowLayoutBounds = 1; + /** * @brief 从应用资源中按指定尺寸加载图标。 * @param [IN] cx 期望宽度(像素)。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index f4de91c..019cad9 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -137,6 +137,10 @@ namespace jdi m_plot.SetHostWindow(m_hwnd); m_menu.SetHostWindow(m_hwnd); + // 组件表在这里一次装配好:之后所有"设置缩放 / 摆放 / 悬停离开 / 光标 / 装饰区绘制" + // 都由 `m_components` 驱动,宿主不再逐个点名组件。 + BuildComponentTable(); + // 标题栏小图标与任务栏/Alt+Tab 缩略图大图标分开设置(同一个多尺寸 ICO 资源的不同尺寸)。 HICON bigIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON)); HICON smallIcon = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON)); @@ -173,13 +177,11 @@ namespace jdi // 而 `Application` 是进程级单例 ⇒ 成员要等 `WinMain` 返回后才析构,那时 GDI+ 已关闭。 m_plot.ReleaseGraphicsResources(); - if (m_gdiplusReady) - { - gd::GdiplusShutdown(m_gdiplusToken); - m_gdiplusReady = false; - } - - // 注销窗口类:否则同一进程里再次创建 `Application` 时会命中 + // ⚠️ **窗口必须在 GdiplusShutdown 之前销毁**: + // 前一帧的 `InvalidateRect` 可能还留下未处理的更新区域,`DestroyWindow` 期间会派发 + // `WM_PAINT` ⇒ `Render()` ⇒ 在**已关闭**的 GDI+ 上构造 `Graphics` ⇒ 退出时访问违例 + //(离屏探针实测退出码 0xC0000005,正是这个顺序造成的)。 + // 注销窗口类放在销毁之后:否则同一进程里再次创建 `Application` 会命中 // `ERROR_CLASS_ALREADY_EXISTS`(离屏测试需要多次构造/销毁)。 if (m_hwnd != nullptr) { @@ -187,6 +189,24 @@ namespace jdi m_hwnd = nullptr; } UnregisterClassW(kWindowClassName, m_instance); + + if (m_gdiplusReady) + { + gd::GdiplusShutdown(m_gdiplusToken); + m_gdiplusReady = false; + } + } + + void Application::BuildComponentTable() + { + // 底 → 顶:绘图区在最底,面板/工具栏/标签栏依次在上面。 + // 四个组件的区域互不重叠,因此命中顺序在这里不影响结果(保留顶优先只是为了与 + // 旧实现的方向一致,且将来若出现重叠会有确定的语义)。 + m_components.clear(); + m_components.push_back(ComponentEntry{ &m_plot, Target::PLOT, false }); + m_components.push_back(ComponentEntry{ &m_panel, Target::PANEL, true }); + m_components.push_back(ComponentEntry{ &m_toolbar, Target::TOOLBAR, true }); + m_components.push_back(ComponentEntry{ &m_tabBar, Target::TAB_BAR, true }); } void Application::ApplyDpi(UINT dpi) @@ -200,12 +220,12 @@ namespace jdi m_scale = static_cast(dpi) / 96.0f; m_fonts.Create(dpi); - m_tabBar.SetScale(m_scale); - m_toolbar.SetScale(m_scale); m_menu.SetScale(m_scale); m_colorPicker.SetScale(m_scale); - m_panel.SetScale(m_scale); - m_plot.SetScale(m_scale); + for (const ComponentEntry& entry : m_components) + { + entry.component->SetScale(m_scale); + } ReleaseBackBuffer(); Relayout(); } @@ -280,16 +300,38 @@ namespace jdi panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum)); m_panelWidth = panelWidth / m_scale; - m_panelRect = gd::RectF(0.0f, contentTop, panelWidth, contentHeight); - m_splitterRect = gd::RectF(panelWidth, contentTop, splitterWidth, contentHeight); - m_plotRect = gd::RectF(panelWidth + splitterWidth, contentTop, - std::max(0.0f, width - panelWidth - splitterWidth), contentHeight); +// 内容区:左面板 + 分隔条 + 绘图区。用 `UiStackRects` 表达"固定 + 占满剩余"的切分, + // 避免再次手算三组坐标(这也是 `UiLayout` 第一个真实生产调用点)。 + const std::vector contentSizes{ panelWidth, splitterWidth, kUiSizeAuto }; + std::vector contentRects; + UiStackRects(gd::RectF(0.0f, contentTop, width, contentHeight), UiAxis::HORIZONTAL, + contentSizes, 0.0f, contentRects); + m_panelRect = contentRects[0]; + m_splitterRect = contentRects[1]; + m_plotRect = contentRects[2]; - m_tabBar.Layout(m_tabBarRect); - m_toolbar.Layout(m_toolbarRect); - m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height)); - m_panel.Layout(m_panelRect); - m_plot.Layout(m_plotRect); + // 四个组件的区域一次摆完(顺序无关:各区域互不重叠)。 + m_panel.SetClientBounds(gd::RectF(0.0f, 0.0f, width, height)); + for (const ComponentEntry& entry : m_components) + { + switch (entry.target) + { + case Target::TAB_BAR: + entry.component->Layout(m_tabBarRect); + break; + case Target::TOOLBAR: + entry.component->Layout(m_toolbarRect); + break; + case Target::PANEL: + entry.component->Layout(m_panelRect); + break; + case Target::PLOT: + entry.component->Layout(m_plotRect); + break; + default: + break; + } + } // 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。 m_chromeDirty = true; @@ -315,12 +357,28 @@ namespace jdi m_backDc = CreateCompatibleDC(screenDc); if (m_backDc != nullptr) { - m_backBitmap = CreateCompatibleBitmap(screenDc, width, height); + // ⚠️ 用 **top-down 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): + // * top-down(`biHeight` 取负)才让 `bits` 从第一行开始、y 向下递增; + // * 32bpp 才能稳定给出像素指针供 CPU 侧合成使用(`CreateCompatibleBitmap` + // 返回设备格式位图,没有 `bits`,也无从保证 32 位)。 + // 绘制结果与旧的 compatible bitmap 逐像素一致(`BitsPerPixel` 同为 32)。 + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + m_backBitmap = CreateDIBSection(screenDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); if (m_backBitmap != nullptr) { m_backOldBitmap = SelectObject(m_backDc, m_backBitmap); m_backWidth = width; m_backHeight = height; + m_backBits = static_cast(bits); + m_backStride = width * 4; + ++m_backVersion; } else { @@ -350,10 +408,35 @@ namespace jdi } m_backWidth = 0; m_backHeight = 0; + m_backBits = nullptr; + m_backStride = 0; + } + + UiSurface Application::BackBufferSurface() const + { + UiSurface surface; + if (m_backDc == nullptr || m_backWidth <= 0 || m_backHeight <= 0) + { + return surface; + } + surface.dc = m_backDc; + surface.rect = gd::RectF(0.0f, 0.0f, static_cast(m_backWidth), + static_cast(m_backHeight)); + surface.stride = m_backStride; + surface.bits = m_backBits; + surface.version = m_backVersion; + surface.scale = m_scale; + return surface; } void Application::Render() { + // 关闭流程中(GDI+ 已 shutdown)或窗口已销毁时**绝不能再进入绘制**: + // `DestroyWindow` 期间仍可能派发 WM_PAINT,在已关闭的 GDI+ 上构造 Graphics 会直接崩溃。 + if (!m_gdiplusReady || m_hwnd == nullptr) + { + return; + } RECT client = {}; GetClientRect(m_hwnd, &client); const int width = client.right - client.left; @@ -416,14 +499,23 @@ namespace jdi // * `fullChrome` —— 标签栏 / 工具栏 / 整窗底色 / 面板都要重绘(有真内容变化); // * `panelOnly` —— **只有面板像素变了**(面板滚动动画),标签栏/工具栏/整窗底色 // 与上一帧逐像素相同,没必要重绘(实测 129 网格时约 3~4ms/帧)。 - const bool fullChrome = m_chromeDirty || m_loading || overlaysVisible; + const bool fullChrome = m_chromeDirty || m_loading || overlaysVisible || m_showLayoutBounds; const bool panelOnly = !fullChrome && (panelAnimating || m_panelDirty); m_chromeDirty = false; m_panelDirty = false; // 仍需要“重绘装饰区域”这个判定(决定回送整窗还是只回送绘图区)。 const bool chromeDirty = fullChrome || panelOnly; // 提示浮层淡出由定时器驱动,其绘制范围局限在绘图区,但为稳妥起见整帧回送。 - const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation(); + // ⚠️ 开启「显示布局边界」后同样必须**整帧回送**:边界跨所有装饰区且比组件矩形外扩 + // 1px,走"只回送绘图区"的快路径会让装饰区的边界停留在旧帧上(看起来像没开)。 + const bool fullFrame = chromeDirty || m_plot.HintNeedsAnimation() || m_showLayoutBounds; + + // ⚠️⚠️ **调试叠加层必须让绘图区走整幅重绘**:`PlotView` 的三条快路径(静态场景贴图 / + // 宽基底贴图 / 样式补丁)只重画**变化的部分**,而调试边框是画在整个窗口上的叠加内容 —— + // 底层不逐像素重画时,上一帧的边框会与新内容叠在一起(用户 2026-10-28 报告:拖网格 + // 滚动条时"工具栏和网格区域上出现大量绘制残留,松开并移动鼠标才消失")。 + // 关掉开关后立即恢复快路径,因此只有调试时损失帧率。 + m_plot.SetForceFullRepaint(m_showLayoutBounds); { gd::Graphics g(m_backDc); @@ -431,6 +523,20 @@ namespace jdi g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + // ⚠️ 调试:文本是本项目唯一"没有几何身份"的绘制元素(60+ 个调用点现算矩形、 + // 画完即弃),因此在这里安装收集器,让 `PaintUtil` 在绘制时把**实际墨迹矩形** + // 回报给 `m_debugBoxes`。开关关闭时完全不安装(空指针,一次判空即返回)。 + if (m_showLayoutBounds) + { + m_debugBoxes.Clear(); + m_debugBoxes.SetLayer(2); // 基础内容层(工具栏/面板/绘图区) + UiSetTextRectSink(&m_debugBoxes); + } + else if (!m_debugBoxes.Empty()) + { + // 关闭后释放容器(否则上一次的几何会一直占着内存,也容易被误读成"仍在收集")。 + m_debugBoxes.Clear(); + } // 信号列表 / 样式区「目标」行的悬停 → 绘图区联动高亮(网格变色 + 该信号曲线高亮)。 // 用户 2026-10-15 要求;鼠标在网格里移动**不再**触发网格变色高亮。 // ⚠️ 每帧同步(而不是只在 WM_MOUSEMOVE 里同步)才能覆盖全部改变悬停的路径: @@ -462,11 +568,29 @@ namespace jdi m_plot.Paint(g); // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 + // ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层, + // 否则会被自己登记到遮挡矩形里的"挖洞"逻辑裁掉。 + if (m_showLayoutBounds) + { + m_debugBoxes.SetLayer(5); + } m_menu.Paint(g); + if (m_showLayoutBounds) + { + m_debugBoxes.SetLayer(6); + } m_colorPicker.Paint(g); // 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。 PaintLoadingOverlay(g, width, height); + + // 布局边界是**纯调试叠加层**:必须在所有内容(含遮罩)之上、且绝不进入任何 + // 位图缓存 —— 否则关掉开关后线条会残留在缓存里。 + if (m_showLayoutBounds) + { + UiSetTextRectSink(nullptr); + PaintLayoutBounds(g, width, height); + } } HDC windowDc = GetDC(m_hwnd); @@ -928,10 +1052,23 @@ namespace jdi // 只有标签栏 / 工具栏 / 面板的悬停态变化才需要重绘装饰区域; // 绘图区自身的悬停态变化(提示浮层、信号光晕)只需重绘绘图区, // 这样拖拽光标时鼠标短暂移出窗口也不会退化成整帧重绘。 - bool chromeChanged = m_tabBar.OnMouseLeave(); - chromeChanged = m_toolbar.OnMouseLeave() || chromeChanged; - chromeChanged = m_panel.OnMouseLeave() || chromeChanged; - const bool plotChanged = m_plot.OnMouseLeave(); + bool chromeChanged = false; + bool plotChanged = false; + for (const ComponentEntry& entry : m_components) + { + if (!entry.component->OnMouseLeave()) + { + continue; + } + if (entry.chrome) + { + chromeChanged = true; + } + else + { + plotChanged = true; + } + } m_mouseOverPlot = false; if (chromeChanged) @@ -1417,13 +1554,19 @@ namespace jdi { cursor = LoadCursorW(nullptr, IDC_SIZEWE); } - else if (m_plot.HitTest(point.x, point.y)) + else { - 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); + // 光标仲裁:从组件表**顶部往下**找第一个命中的组件;它返回 nullptr 即视为 + // "该区域用箭头"(与旧实现里 `if (绘图区命中) 取它的光标,否则看面板` 一致)。 + for (auto iterator = m_components.rbegin(); iterator != m_components.rend(); ++iterator) + { + if (!iterator->component->HitTest(point.x, point.y)) + { + continue; + } + cursor = iterator->component->QueryCursor(point.x, point.y); + break; + } } if (cursor == nullptr) { diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index 85787c3..8965cb2 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -1,9 +1,11 @@ -// Application 实现分片 —— 本文件只包含 Application 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// Application 实现分片 —— 本文件包含:标签页管理、工具栏动作、设置菜单、键盘、菜单结果派发。 // -// 共用的常量与自由函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 -// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 +// 共用的常量与工具函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。 +// 分片按"职责"划分:核心(状态/布局)、渲染、命令与菜单、加载。 #include "ApplicationInternal.hpp" +#include "UiDebugBounds.hpp" + namespace jdi { @@ -305,6 +307,15 @@ namespace jdi { switch (action) { + case ToolbarAction::SETTINGS: + // 齿轮按钮只负责**展开菜单**:不改变任何业务状态,因此不必走后面的"整帧失效", + // 只需要把浮层画出来(菜单打开后 `Render` 会因 `overlaysVisible` 自动整帧重绘)。 + OpenSettingsMenu(); + if (m_hwnd != nullptr) + { + InvalidateRect(m_hwnd, nullptr, TRUE); + } + return; case ToolbarAction::OPEN_FILE: OpenFileDialog(); break; @@ -1106,6 +1117,83 @@ namespace jdi m_plot.AllLanesAutoFitToSelection(), true); } + void Application::OpenSettingsMenu() + { + // 锚点取齿轮按钮的**实际矩形**:按钮宽度由控件实测,宿主不应自己推算几何。 + gd::RectF anchor; + if (!m_toolbar.ButtonRect(ToolbarAction::SETTINGS, anchor)) + { + // 尚未布局(极端情形):退化为从菜单快捷键位置展开,而不是什么都不发生。 + anchor = m_toolbarRect; + } + + std::vector items; + + // ── 调试 ────────────────────────────────────────────── + MenuItem debugHeader; + debugHeader.kind = MenuItemKind::HEADER; + debugHeader.label = L"调试"; + items.push_back(debugHeader); + + MenuItem boundsItem; + boundsItem.label = L"显示布局边界"; + boundsItem.id = kAppCmdShowLayoutBounds; + boundsItem.checked = m_showLayoutBounds; + // 切换项:点击后菜单保持打开,勾选态由宿主回写(与既有「镜像」等切换项一致)。 + boundsItem.keepOpen = true; + items.push_back(boundsItem); + + RECT client = {}; + GetClientRect(m_hwnd, &client); + const gd::RectF bounds(0.0f, 0.0f, static_cast(client.right - client.left), + static_cast(client.bottom - client.top)); + + // 右下展开,锚在按钮**下沿**(留 4 逻辑像素,视觉上不贴着按钮边框)。 + const int x = static_cast(anchor.X); + const int y = static_cast(anchor.Y + anchor.Height + 4.0f * m_scale); + m_menu.Open(MenuScope::APP_SETTINGS, bounds, x, y, items); + } + + void Application::PaintLayoutBounds(gd::Graphics& g, int width, int height) + { + // ⚠️ 复用成员容器(不是局部变量):文本几何已经在**绘制期间**由 `PaintUtil` + // 通过 `UiSetTextRectSink(&m_debugBoxes)` 收进来了,这里只负责补组件结构。 + m_debugBoxes.SetLayer(0); + + // 客户区外框:便于确认"窗口边缘 == 最外层边界"。 + m_debugBoxes.Add(gd::RectF(0.0f, 0.0f, static_cast(width), + static_cast(height)), 0); + + // 组件区域(底 → 顶;组件自己负责补齐内部结构)。 + // ⚠️ 两点都很重要: + // 1. `SetClip(组件区域)`:组件内部的元素可能落在自己地盘之外(典型:滚出可视区的 + // 网格 `lane.rect` 的 Y 为负数),不裁剪会把边框画到工具栏/标签栏上。 + // 2. 每收集完一个组件就 `PushLayer()`:浮层压在本体之上(右键二级菜单压在行上面), + // 只有按收集顺序分层,后收集的内容才画在上面。 + for (const ComponentEntry& entry : m_components) + { + if (entry.component != nullptr) + { + m_debugBoxes.SetClip(entry.component->Area()); + entry.component->CollectDebugBoxes(m_debugBoxes); + } + m_debugBoxes.PushLayer(); + } + + // 不属于任何组件的两块:面板分隔条(由宿主直接填充色块)、菜单 / 拾色器(浮层)。 + // ⚠️ 浮层**不设裁剪矩形**:菜单的二级子菜单会展开到菜单本体之外,设裁剪会把它裁掉; + // 它们自己会把本体登记为"遮挡矩形"(见 `PopupMenu::CollectDebugBoxes`), + // 由绘制阶段把更低层的 box 从浮层区域里挖掉。 + m_debugBoxes.SetClip(gd::RectF()); + m_debugBoxes.Add(m_splitterRect, 0); + m_debugBoxes.PushLayer(); + m_menu.CollectDebugBoxes(m_debugBoxes); + m_debugBoxes.PushLayer(); + m_colorPicker.CollectDebugBoxes(m_debugBoxes); + + UiDebugPaintBoxList(g, m_debugBoxes); + } + void Application::DispatchMenuResult(const MenuResult& result) { if (!result.valid) @@ -1115,6 +1203,20 @@ namespace jdi switch (result.scope) { + case MenuScope::APP_SETTINGS: + { + // 应用设置菜单:全部条目都是**带勾选态的切换项**,菜单保持打开, + // 勾选变化通过 `UpdateItemLabel` 立即回写(连续切换多项时不用反复打开菜单)。 + if (result.id == kAppCmdShowLayoutBounds) + { + m_showLayoutBounds = !m_showLayoutBounds; + m_menu.UpdateItemLabel(kAppCmdShowLayoutBounds, L"显示布局边界", m_showLayoutBounds); + // 边界比组件矩形外扩 1px 且跨越所有装饰区 ⇒ 必须整帧重绘。 + m_chromeDirty = true; + } + InvalidateRect(m_hwnd, nullptr, TRUE); + break; + } case MenuScope::SIGNAL_PANEL: { const bool needRelayout = m_panel.OnMenuCommand(result); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 705dd60..9a525e3 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -20,10 +20,13 @@ #include "DataModel.hpp" #include "PaintUtil.hpp" #include "Theme.hpp" +#include "UiComponent.hpp" namespace jdi { + class UiDebugBoxList; + /** * @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。 * @@ -140,7 +143,7 @@ namespace jdi * 绘图区同时依赖数据层(DataSet,只读)与当前标签页配置(TabConfig,可写), * 视图范围、网格滚动与时间光标均保存在标签页配置中,因此各标签页互不干扰。 */ - class PlotView + class PlotView : public UiComponent { public: PlotView(); @@ -186,19 +189,46 @@ namespace jdi * @brief 设置 DPI 缩放比例。 * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 */ - void SetScale(float scale); + void SetScale(float scale) override; /** * @brief 按给定区域重新排布绘图区。 * @param [IN] area 可用区域。 */ - void Layout(const gd::RectF& area); + void Layout(const gd::RectF& area) override; + + /** @brief 绘图区占用的整块区域(与 `Layout` 同源)。 */ + gd::RectF Area() const override { return m_area; } /** * @brief 绘制绘图区。 * @param [IN,OUT] g 绘图上下文。 */ - void Paint(gd::Graphics& g); + void Paint(gd::Graphics& g) override; + + /** + * @brief 收集绘图区区域、**每个网格**、GX 徽标、图例条目与浮动面板的布局边界(调试用)。 + * + * 只上报“布局结构”,**不上报**曲线折线、取值胶囊、悬停提示这类随数据/鼠标逐帧变化的 + * 瞬态几何 —— 它们每帧都不同,叠上去只是噪声。 + * + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) override; + + /** + * @brief 强制走「整幅重绘」路径(用于调试叠加层)。 + * + * 平时 `Paint` 会用三条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁)只重画变化部分, + * 这在拖动网格滚动条时会**跳过一部分像素**。而调试叠加层(「显示布局边界」)是画在 + * 整个窗口上的叠加内容 —— 一旦底层没有逐像素重画,上一帧的调试边框就会与新内容叠加, + * 表现为「工具栏与网格区域上大量绘制残留」(用户 2026-10-28 报告)。 + * + * 因此调试叠加层开启时必须打开本开关:牺牲帧率,换取"每帧都是完整一帧"。 + * + * @param [IN] force 为 true 时禁用全部快路径且不作画质降级。 + */ + void SetForceFullRepaint(bool force) { m_forceFullRepaint = force; } /** * @brief 处理鼠标移动(含拖拽)。 @@ -292,7 +322,7 @@ namespace jdi * @brief 处理鼠标离开绘图区。 * @return 需要重绘返回 true。 */ - bool OnMouseLeave(); + bool OnMouseLeave() override; /** * @brief 将时间轴自适应到指定网格内信号的时间范围。 @@ -466,7 +496,7 @@ namespace jdi * @param [IN] y 客户区纵坐标。 * @return 落在范围内返回 true。 */ - bool HitTest(int x, int y) const; + bool HitTest(int x, int y) const override; /** * @brief 查询当前鼠标位置应使用的系统光标。 @@ -476,6 +506,17 @@ namespace jdi */ HCURSOR QueryCursor(int x, int y) const; + /** + * @brief `UiComponent` 协议的入口(转调上面的 const 版本)。 + * + * `UiComponent` 声明的是非 const 版本(`SignalPanel` 的命中需要惰性重建行集合), + * 而绘图区的实现天然是 const;这里做一次薄转发,两个重载保持行为完全一致。 + */ + HCURSOR QueryCursor(int x, int y) override + { + return static_cast(this)->QueryCursor(x, y); + } + /** * @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。 * @@ -1083,7 +1124,7 @@ namespace jdi * @brief 判断是否处于任意内联数值输入状态。 * @return 处于编辑态返回 true。 */ - bool IsEditing() const { return m_editTarget != PlotEditTarget::NONE; } + bool IsEditing() const override { return m_editTarget != PlotEditTarget::NONE; } /** * @brief 取出并清除待应用的信号重命名请求。 @@ -1244,6 +1285,9 @@ namespace jdi /** @brief 前台是否正在播放 UI 动画(见 `SetAnimationBusy`)。 */ bool m_animationBusy = false; + /** @brief 是否禁用全部快速绘制路径(见 `SetForceFullRepaint`)。 */ + bool m_forceFullRepaint = false; + public: /** * @brief 单个网格的绘制信息。 diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp new file mode 100644 index 0000000..9effdbc --- /dev/null +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -0,0 +1,504 @@ +// PlotView 实现分片 —— 内容指纹与缓存键("为什么这一帧可以复用上一帧的像素")。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// 职责: +// * 场景缓存键 `SceneCacheKey` 的构造与比较; +// * 场景内容指纹(`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`); +// * 逐信号样式快照与"只改了外观"的网格定位(`CollectSceneStyles` / `FindStyleOnlyLanes`); +// * 派生曲线参数快照(`CollectSceneDerived`); +// * 样式补丁帧(贴图 → 只重画受影响网格 → 写回缓存)。 +// +// ⚠️ 硬约束:任何"会影响像素"的状态都必须进指纹;漏一个字段的症状是"贴旧像素" +// (本项目历史上至少 4 次真实 BUG 都是这个原因)。 +// ⚠️ 本文件**不做**像素分配与贴图(那些在 `PlotViewRaster.cpp`),也不做绘制(`PlotViewPaint.cpp`)。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const + { + SceneCacheKey key; + key.viewMin = Tab().ViewMin(); + key.viewMax = Tab().ViewMax(); + key.laneScroll = Tab().LaneScroll(); + key.laneCount = static_cast(m_lanes.size()); + key.areaX = m_area.X; + key.areaY = m_area.Y; + key.areaW = m_area.Width; + key.areaH = m_area.Height; + key.scale = m_scale; + + const SignalRef halo = SceneHaloRef(); + key.haloGroup = halo.groupIndex; + key.haloSignal = halo.signalIndex; + key.contentHash = SceneContentHash(); + return key; + } + + bool PlotView::SceneCacheMatches() const + { + if (!m_sceneValid) + { + return false; + } + // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: + // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 + if (m_sceneDegraded) + { + return false; + } + const SceneCacheKey& cached = m_sceneKey; + const SceneCacheKey key = MakeSceneCacheKey(); + return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && + (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && + (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && + (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && + (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && + (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); + } + + SignalRef PlotView::SceneHaloRef() const + { + // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; + // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 + if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) + { + return m_dragSignal; + } + return SignalRef(); + } + + std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, + bool includeLaneRange, bool includeLaneScroll) const + { + // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 + // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, + // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 + // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), + // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixText = [&mix](const std::wstring& text) + { + mix(text.size()); + for (const wchar_t character : text) + { + mix(static_cast(character)); + } + }; + + mixFloat(m_plotRect.X); + mixFloat(m_plotRect.Y); + mixFloat(m_plotRect.Width); + mixFloat(m_plotRect.Height); + mixFloat(m_laneContentHeight); + mix(m_scrollBarVisible ? 1ull : 0ull); + // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 + // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, + // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 + // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 + //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` + // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 + // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 + if (includeLaneScroll) + { + mixFloat(Tab().LaneScroll()); + } + + // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), + // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 + { + const SignalRef selected = SelectedRef(); + mix(selected.IsValid() ? 1ull : 0ull); + mix(static_cast(selected.groupIndex)); + mix(static_cast(selected.signalIndex)); + } + + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const LaneVisual& visual = m_lanes[laneIndex]; + // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 + if (includeLaneScroll) + { + mixFloat(visual.rect.Y); + } + mixFloat(visual.rect.Height); + // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, + // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 + if (includeLaneRange) + { + mixFloat(static_cast(visual.yMin)); + mixFloat(static_cast(visual.yMax)); + } + + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 + // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), + // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, + // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 + // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 + // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 + if (includeStyles && includeDerived) + { + mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); + for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); + for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); + for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) + { + // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.amountPercent)); + mix(static_cast(item.iterations)); + } + } + mix(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.groupIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 + // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, + // 于是可以只重画颜色变了的网格,而不必整幅重绘。) + if (includeStyles) + { + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + // 偏移(Y 平移 + 时间平移)同样会改变波形。 + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); + } + // 图例文案参与绘制,重命名必须让缓存失效。 + mixText(resolved.data->name); + if (resolved.group != nullptr) + { + mixText(resolved.group->name); + } + } + } + return hash; + } + + std::uint64_t PlotView::SceneGeometryHash() const + { + // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, + // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); + // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, + // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), + // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 + // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 + return SceneContentHash(false, true, false, false); + } + + std::uint64_t PlotView::SceneDecorHash() const + { + // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** + //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 + return SceneContentHash(true, false); + } + + void PlotView::CollectSceneStyles(std::vector>& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const std::vector& signals = Tab().Lanes()[laneIndex].signals; + std::vector& lane = out[laneIndex]; + lane.reserve(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + SceneStyleEntry entry; + if (resolved.style != nullptr) + { + entry.color = resolved.style->color; + entry.opacity = resolved.style->opacity; + entry.lineWidth = resolved.style->lineWidth; + entry.mode = static_cast(resolved.style->mode); + entry.gain = resolved.style->gain; + entry.absolute = resolved.style->absolute; + entry.mirrorVertical = resolved.style->mirrorVertical; + entry.mirrorHorizontal = resolved.style->mirrorHorizontal; + entry.offsetY = resolved.style->offsetY; + entry.offsetTime = resolved.style->offsetTime; + } + lane.push_back(entry); + } + } + } + + bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const + { + lanes.clear(); + if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) + { + // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 + return false; + } + + // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, + // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, + // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 + // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 + { + const SceneCacheKey key = MakeSceneCacheKey(); + if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || + key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || + key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || + key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || + key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || + key.haloSignal != m_sceneKey.haloSignal) + { + return false; + } + } + + std::vector> current; + CollectSceneStyles(current); + if (current.size() != m_sceneStyles.size()) + { + return false; + } + + // 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。 + std::vector currentDerived; + CollectSceneDerived(currentDerived); + const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); + + for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) + { + const std::vector& now = current[laneIndex]; + const std::vector& before = m_sceneStyles[laneIndex]; + if (now.size() != before.size()) + { + return false; + } + bool differs = false; + for (std::size_t index = 0; index < now.size() && !differs; ++index) + { + // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: + // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** + // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 + // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` + // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, + // 一直残留到某次无关的失效。 + differs = !(now[index] == before[index]); + } + if (!differs && derivedComparable) + { + const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; + const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; + differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || + (nowDerived.meanFilter != beforeDerived.meanFilter) || + (nowDerived.weakPeak != beforeDerived.weakPeak) || + (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || + (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || + (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); + } + if (differs) + { + lanes.push_back(static_cast(laneIndex)); + } + } + return !lanes.empty(); + } + + void PlotView::CollectSceneDerived(std::vector& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const Lane& lane = Tab().Lanes()[laneIndex]; + SceneDerivedEntry entry; + entry.edgeCenter = lane.edgeCenter ? 1 : 0; + entry.meanFilter = lane.meanFilter ? 1 : 0; + entry.weakPeak = lane.weakPeak ? 1 : 0; + const auto flatten = [](const auto& items, std::vector& target, auto valueOf) + { + target.clear(); + target.reserve(items.size() * 4); + for (const auto& item : items) + { + target.push_back(item.ref.groupIndex); + target.push_back(item.ref.signalIndex); + // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 + target.push_back(static_cast(valueOf(item))); + target.push_back(item.iterations); + } + }; + flatten(lane.edgeCenterItems, entry.edgeCenterItems, + [](const Lane::EdgeCenterItem& item) { return item.points; }); + flatten(lane.meanFilterItems, entry.meanFilterItems, + [](const Lane::MeanFilterItem& item) { return item.points; }); + flatten(lane.weakPeakItems, entry.weakPeakItems, + [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); + out[laneIndex] = std::move(entry); + } + } + + void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) + { + if (lanes.empty() || m_sceneDc == nullptr) + { + return; + } + + // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 + float top = 1.0e9f; + float bottom = -1.0e9f; + for (const int laneIndex : lanes) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + top = std::min(top, rect.Y); + bottom = std::max(bottom, rect.Y + rect.Height); + } + if (bottom <= top) + { + return; + } + top = std::max(top, m_plotRect.Y); + bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + return; + } + + // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): + // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, + // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), + // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 + // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 + const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); + const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); + const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), + static_cast(std::floor(m_area.X + m_area.Width))); + const int y1 = std::min(static_cast(std::floor(bottom)), + static_cast(std::floor(m_area.Y + m_area.Height))); + if (x1 <= x0 || y1 <= y0) + { + return; + } + + const gd::RectF patch(static_cast(x0), static_cast(y0), + static_cast(x1 - x0), static_cast(y1 - y0)); + + // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 + // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 + std::vector repaint; + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const gd::RectF& rect = m_lanes[laneIndex].rect; + if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) + { + repaint.push_back(static_cast(laneIndex)); + } + } + if (repaint.empty()) + { + return; + } + + // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 + { + const int saveState = g.Save(); + g.SetClip(patch, gd::CombineModeIntersect); + // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: + // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 + // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 + UiFillRect(g, patch, m_theme->plotBg); + const float cardTop = std::max(patch.Y, m_plotRect.Y); + const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); + if (cardBottom > cardTop) + { + UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), + m_theme->cardBg); + } + + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; + const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); + std::vector timeTicks; + BuildTicks(viewMin, viewMax, timeStep, timeTicks); + + // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 + // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, + // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 + const bool savedUseDecorCache = m_useDecorCache; + m_useDecorCache = false; + for (const int laneIndex : repaint) + { + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + } + } + m_useDecorCache = savedUseDecorCache; + g.Restore(saveState); + } + + // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 + { + HDC target = g.GetHDC(); + if (target != nullptr) + { + BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); + g.ReleaseHDC(target); + } + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = m_sceneDegraded || m_fastFrame; + } + +} // namespace jdi diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 1235313..8dd2c42 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1,195 +1,29 @@ -// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// PlotView 实现分片 —— 绘制(paint):只负责"把内容画出来"。 // // 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。 -// 四个分片的职责划分(按功能): -// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、 -// 选区/网格划分、拖动取消 -// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、 -// 宽基底与滚动补条、并行图层重建 -// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中 -// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、 -// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数 +// 七个分片的职责划分(按**关注点**,不再按函数名前缀): +// PlotView.cpp 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、 +// 网格结构与归属、选区/网格划分、拖动取消 +// PlotViewPaint.cpp 绘制:全部 Paint*/Render* 与内联输入框绘制(**本文件**) +// PlotViewRaster.cpp 光栅化像素缓存与每帧预算:光晕层、场景缓存、装饰层、每网格宽基底 +// 图层、滚动补条、预热("怎么画才不卡") +// PlotViewCacheKey.cpp 内容指纹与缓存键:SceneCacheKey / *Hash / 样式与派生快照 / 样式补丁帧 +// PlotViewPanels.cpp 几何:浮动面板布局(EdgePanelWidth/EdgePanelLayoutFor)、图例徽标、 +// 「自由标记」的取值/胶囊矩形/探测(绘制与命中同源的唯一来源) +// PlotViewInteraction.cpp 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、 +// 浮动面板层序与命中 +// PlotViewFeatures.cpp 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、 +// 光标吸附、单点信号、派生曲线线程池、边沿统计、实验性功能参数 +// +// ⚠️ 新增函数按**职责**放入对应分片;`PlotView::Paint/PaintLane` 仍只允许定义在本文件。 #include "PlotViewInternal.hpp" +#include "UiDebugBounds.hpp" +#include "UiLayout.hpp" + namespace jdi { - float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const - { - if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size())) - { - return kEdgePanelWidth * m_scale; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool meanFilter = (kind == EdgePanelKind::MEAN); - const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 - const bool itemized = (centerAxis || meanFilter || weakPeak); - - // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” - // + 光标区间尾缀)。 - const std::wstring title = - meanFilter ? std::wstring(L"均值滤波") - : (weakPeak ? std::wstring(kWeakPeakTitle) - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); - float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + - 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + - kEdgePanelTitleHeight * m_scale; - - // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 - // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, - // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 - // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 - float nameColumnWidth = 0.0f; - float statRisingWidth = 0.0f; - float statFallingWidth = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 - // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 - // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - const float nameWidth = - std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), - kEdgePanelNameMaxWidth * m_scale); - nameColumnWidth = std::max(nameColumnWidth, nameWidth); - if (itemized) - { - // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + - kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (meanFilter || weakPeak) - { - continue; - } - if (centerAxis) - { - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (item == nullptr) - { - continue; - } - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling, complete); - } - else - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 - // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), - pendingWidth)); - statFallingWidth = - std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); - } - if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) - { - // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, - // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 - const float maxColumn = std::max(statRisingWidth, statFallingWidth); - const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + - std::max(nameColumnWidth, countsWidth)); - } - - if (centerAxis || meanFilter) - { - // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) - const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); - const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); - const float fieldWidth = - std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, - UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); - const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; - const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (weakPeak) - { - // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 - const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + - kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + - WeakPeakValueColumnWidth(m_fonts->monoSmall) + - kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - - if (meanFilter || weakPeak) - { - // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 - const float buttonWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); - } - - if (kind == EdgePanelKind::MARK) - { - // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 - // 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。 - // ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。 - // 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder` - // 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实 - // 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。 - const bool hasEnum = [this, &lane]() { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible && - !resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref)) - { - return true; - } - } - return false; - }(); - float columnsWidth = 0.0f; - for (int column = 0; column < 6; ++column) - { - const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; - if (omitted) - { - continue; - } - // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + - 2.0f * m_scale; - columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(column)]), - labelWidth); - columnsWidth += kFreeMarkerColumnGap * m_scale; - } - // 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。 - const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale; - const float markerWidth = kEdgePanelPad * 2.0f * m_scale + - (kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth + - rowCloseWidth; - width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth)); - } - - // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 - return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); - } const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved, @@ -729,6 +563,103 @@ namespace jdi UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); } + void PlotView::CollectDebugBoxes(UiDebugBoxList& boxes) + { + UiComponent::CollectDebugBoxes(boxes); + + // 层级 1:每个网格的波形区;层级 2:该网格的 GX 编号徽标。 + // ⚠️ **绝不能读 `m_decorRects`**:那里存的是"装饰层位图缓存"的登记矩形, + // 坐标处在**位图/图层坐标系**(会带负 Y、或超出绘图区),拿它当屏幕矩形会出现 + // 横跨工具栏与绘图区的巨型幽灵框(用户报告的"绘制残留"的直接原因)。 + // 需要装饰元素的屏幕几何时,请用 `m_legendHits`(屏幕坐标)或 `LaneBadgeRect`。 + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const LaneVisual& lane = m_lanes[index]; + boxes.Add(lane.rect, 1); + + // GX 编号徽标:矩形由 `LaneBadgeRect` 统一给出(绘制与命中都用它)。 + boxes.Add(LaneBadgeRect(static_cast(index)), 2); + } + + // 层级 2/3:图例条目(托底 + 色块 + 文本),绘制时已登记在 `m_legendHits`。 + for (const LegendHit& hit : m_legendHits) + { + boxes.Add(hit.rect, 2); + boxes.Add(hit.swatch, 3); + boxes.Add(hit.text, 3); + } + + // 层级 1/2:绘图区里**不属于任何网格**的固定构件。 + // 这些曾经全部漏掉(调试时"看得见却没框")—— 它们都已经有成员变量,直接读即可。 + boxes.Add(m_timeAxisRect, 1); + boxes.Add(m_scrollBarRect, 2); + boxes.Add(BackToTopRect(), 2); + + // 层级 2/3:时间光标读数标签(避让后的实际矩形,绘制时已登记)。 + boxes.Add(m_cursorSpanRect, 2); + boxes.AddAll(m_cursorLabelRects, 3); + + // 层级 3:内联输入框(正在编辑时才有意义)。 + boxes.Add(EditFieldRect(), 3); + + // 层级 2/3/4:5 类实验性功能浮动面板的内部控件。 + // 这些矩形由 `EdgePanelLayoutFor` 统一算出(绘制与命中共用),一个循环即可全部上报。 + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const int laneIndex = static_cast(index); + for (int kindValue = static_cast(EdgePanelKind::COUNT); + kindValue <= static_cast(EdgePanelKind::MARK); ++kindValue) + { + const EdgePanelLayout layout = + EdgePanelLayoutFor(laneIndex, static_cast(kindValue)); + if (!UiRectHasArea(layout.panel)) + { + continue; + } + boxes.Add(layout.panel, 2); + boxes.Add(layout.title, 3); + boxes.Add(layout.close, 3); + boxes.Add(layout.disclosure, 3); + boxes.Add(layout.badge, 3); + boxes.Add(layout.edit, 3); + boxes.Add(layout.button, 3); + boxes.Add(layout.content, 3); + boxes.Add(layout.scrollTrack, 3); + boxes.Add(layout.scrollThumb, 4); + boxes.Add(layout.opacityTrack, 3); + boxes.Add(layout.opacityValue, 3); + boxes.Add(layout.showDelta, 3); + boxes.Add(layout.showEnum, 3); + + boxes.AddAll(layout.rowChecks, 4); + boxes.AddAll(layout.rowPoints, 4); + boxes.AddAll(layout.rowIterations, 4); + boxes.AddAll(layout.rowStats, 4); + boxes.AddAll(layout.rowLevelTracks, 4); + boxes.AddAll(layout.rowLevelValues, 4); + boxes.AddAll(layout.rowIterTracks, 4); + boxes.AddAll(layout.rowIterValues, 4); + boxes.AddAll(layout.rowLevelResets, 4); + boxes.AddAll(layout.rowIterResets, 4); + boxes.AddAll(layout.rowNameRects, 4); + boxes.AddAll(layout.rowNameCloses, 4); + + for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) + { + boxes.Add(group.header, 3); + boxes.Add(group.tableHeader, 3); + boxes.Add(group.triangle, 4); + boxes.Add(group.close, 4); + } + for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) + { + boxes.Add(row.rect, 4); + boxes.Add(row.close, 4); + } + } + } + } + void PlotView::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) @@ -845,7 +776,9 @@ namespace jdi // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 // 这里逐网格把缓存中的横向条带平移贴回(几十微秒), // 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。 - if (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid) + // ⚠️ `m_forceFullRepaint`(调试叠加层)下必须跳过:局部贴图会留下上一帧的边界。 + if (!m_forceFullRepaint && m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && + m_sceneValid) { PaintLaneReorder(g); paintOverlays(); @@ -857,7 +790,7 @@ namespace jdi // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 // "只改一小块叠加层"的交互因此不再重绘波形。 // 随鼠标变化的内容(悬停高亮、读数框、选区、光标、拖拽虚影、输入框)一律画在贴图之后。 - if (SceneCacheMatches() && BlitSceneCache(g)) + if (!m_forceFullRepaint && SceneCacheMatches() && BlitSceneCache(g)) { // ⚠️ **顺手做一次"空闲预热"**:图层不存在(刚加载 / 网格数量变化)或仍是旧跨度时, // 在这里用既有预算机制**每帧至多补 1 个网格**。若不预热,用户第一次平移 / 选区放大 @@ -881,7 +814,8 @@ namespace jdi // ⚠️ **不要求"正在拖拽"**:非拖拽的视界变化(键盘方向键 / 工具栏)同样走此快路径, // 避免该放大区间"整幅重绘约 124ms"的卡顿。 bool wideUsable = false; - if (HasDrawableContent() && m_sceneValid && m_sceneGeometryHash == SceneGeometryHash()) + if (!m_forceFullRepaint && HasDrawableContent() && m_sceneValid && + m_sceneGeometryHash == SceneGeometryHash()) { std::vector> currentStyles; CollectSceneStyles(currentStyles); @@ -1024,7 +958,7 @@ namespace jdi // 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。) { std::vector patchLanes; - if (FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) + if (!m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) { // ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。 // 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带 @@ -1099,382 +1033,6 @@ namespace jdi paintOverlays(); } - void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const PolylineCacheEntry& cached) - { - if (resolved.style == nullptr) - { - return; - } - // ------------------------------------------------------------------ - // 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。 - // - // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 - // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, - // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。 - // ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是 - // `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。 - // 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于 - // 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化, - // 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色" - //(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。 - // ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。 - // 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 - // ------------------------------------------------------------------ - if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) - { - // 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。 - const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f; - const int savedSmoothing = g.GetSmoothingMode(); - g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 - gd::GraphicsPath bandPath; - bandPath.SetFillMode(gd::FillModeWinding); - gd::PointF quad[4]; - for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k) - { - const std::size_t start = cached.bandBreaks[k]; - const std::size_t stop = - (k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size(); - const std::size_t total = stop - start; - if (total < 4 || (total % 2) != 0) - { - continue; - } - // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此 - // 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。 - // - // ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的 - // 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处 - // 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到 - // "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。 - // ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带, - // 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath` - // 内,重叠区域只混合一次(不会叠出更深的竖条)。 - const std::size_t half = total / 2; - for (std::size_t column = 0; column + 1 < half; ++column) - { - const gd::PointF& topA = cached.band[start + column]; - const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)]; - const gd::PointF& topB = cached.band[start + column + 1]; - const gd::PointF& bottomB = - cached.band[start + half + (half - 1 - (column + 1))]; - const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y)); - const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y)); - quad[0] = gd::PointF(topA.X - grow, highY - grow); - quad[1] = gd::PointF(topB.X + grow, highY - grow); - quad[2] = gd::PointF(topB.X + grow, lowY + grow); - quad[3] = gd::PointF(topA.X - grow, lowY + grow); - bandPath.AddPolygon(quad, 4); - } - } - if (bandPath.GetPointCount() > 0) - { - // alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。 - gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); - g.FillPath(&brush, &bandPath); - } - g.SetSmoothingMode(static_cast(savedSmoothing)); - return; - } - // 其余情形一律用折线描边(与"之前"的画法一致)。 - PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks); - } - - void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, const PolylineCacheEntry& cached) - { - if (resolved.style == nullptr) - { - return; - } - - // 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。 - const float pad = (resolved.style->lineWidth + 8.0f) * m_scale; - const float left = std::max(lane.rect.X, m_plotRect.X) - pad; - const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad; - const float right = - std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad; - const float bottom = - std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad; - if (!(right > left + 1.0f) || !(bottom > top + 1.0f)) - { - return; - } - const int targetX = static_cast(std::floor(left)); - const int targetY = static_cast(std::floor(top)); - const int width = static_cast(std::ceil(right)) - targetX; - const int height = static_cast(std::ceil(bottom)) - targetY; - if (width <= 0 || height <= 0) - { - return; - } - - HaloLayerCache& cache = m_haloLayer; - // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 - // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 - const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && - static_cast(cache.bitmap->GetHeight()) == height; - const bool sameKey = - cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group && - cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin && - cache.viewMax == cached.viewMax && cache.plotX == cached.plotX && - cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight && - cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax && - cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode && - cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute && - cache.mirrorVertical == resolved.style->mirrorVertical && - cache.mirrorHorizontal == resolved.style->mirrorHorizontal && - cache.offsetY == resolved.style->offsetY && - cache.offsetTime == resolved.style->offsetTime; - - if (!sameKey) - { - if (!sameSize) - { - cache.bitmap = std::make_unique(width, height, PixelFormat32bppARGB); - } - { - gd::Graphics layerGraphics(cache.bitmap.get()); - layerGraphics.Clear(gd::Color(0, 0, 0, 0)); - layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); - layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - // 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。 - layerGraphics.TranslateTransform(static_cast(-targetX), - static_cast(-targetY)); - if (cached.points.size() > kHaloNoAaMinPoints) - { - // 顶点很多的区间(例如深放大后的方波:每段都跨满网格高): - // 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍), - // 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。 - layerGraphics.SetSmoothingMode(gd::SmoothingModeNone); - } - PaintSignalHaloFor(layerGraphics, resolved, lane, cached); - } - cache.ref = cached.ref; - cache.group = cached.group; - cache.sampleCount = cached.sampleCount; - cache.viewMin = cached.viewMin; - cache.viewMax = cached.viewMax; - cache.plotX = cached.plotX; - cache.plotWidth = cached.plotWidth; - cache.laneHeight = cached.laneHeight; - cache.laneYMin = cached.laneYMin; - cache.laneYMax = cached.laneYMax; - cache.lineWidth = cached.lineWidth; - cache.mode = resolved.style->mode; - cache.gain = resolved.style->gain; - cache.absolute = resolved.style->absolute; - cache.mirrorVertical = resolved.style->mirrorVertical; - cache.mirrorHorizontal = resolved.style->mirrorHorizontal; - cache.offsetY = resolved.style->offsetY; - cache.offsetTime = resolved.style->offsetTime; - cache.originX = targetX; - cache.originY = targetY; - cache.valid = true; - if (!UploadHaloLayer(width, height)) - { - cache.valid = false; - return; - } - } - - // 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。 - if (cache.dc == nullptr) - { - return; - } - const HDC targetDc = g.GetHDC(); - if (targetDc != nullptr) - { - BLENDFUNCTION blend = {}; - blend.BlendOp = AC_SRC_OVER; - blend.SourceConstantAlpha = 0xFF; - blend.AlphaFormat = AC_SRC_ALPHA; - ::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height, - blend); - g.ReleaseHDC(targetDc); - } - } - - bool PlotView::UploadHaloLayer(int width, int height) - { - HaloLayerCache& cache = m_haloLayer; - if (!cache.bitmap) - { - return false; - } - if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height) - { - ReleaseHaloLayer(); - BITMAPINFO info = {}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = width; - info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致) - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - HDC memoryDc = CreateCompatibleDC(nullptr); - if (memoryDc == nullptr) - { - return false; - } - void* bits = nullptr; - HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); - if (dib == nullptr || bits == nullptr) - { - DeleteDC(memoryDc); - if (dib != nullptr) - { - DeleteObject(dib); - } - return false; - } - SelectObject(memoryDc, dib); - cache.dc = memoryDc; - cache.dib = dib; - cache.bits = bits; - cache.dibWidth = width; - cache.dibHeight = height; - } - - // 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。 - // ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法), - // **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积), - // 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。 - gd::Rect rect(0, 0, width, height); - gd::BitmapData data = {}; - if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) != - gd::Ok) - { - return false; - } - unsigned char* destination = static_cast(cache.bits); - for (int y = 0; y < height; ++y) - { - const unsigned char* source = - static_cast(data.Scan0) + static_cast(y) * data.Stride; - unsigned char* row = destination + static_cast(y) * width * 4u; - for (int x = 0; x < width; ++x) - { - const unsigned alpha = source[x * 4 + 3]; - if (alpha == 0) - { - row[x * 4 + 0] = 0; - row[x * 4 + 1] = 0; - row[x * 4 + 2] = 0; - row[x * 4 + 3] = 0; - continue; - } - if (alpha == 0xFF) - { - row[x * 4 + 0] = source[x * 4 + 0]; - row[x * 4 + 1] = source[x * 4 + 1]; - row[x * 4 + 2] = source[x * 4 + 2]; - row[x * 4 + 3] = 0xFF; - continue; - } - row[x * 4 + 0] = static_cast( - (static_cast(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 1] = static_cast( - (static_cast(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 2] = static_cast( - (static_cast(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16); - row[x * 4 + 3] = static_cast(alpha); - } - } - cache.bitmap->UnlockBits(&data); - return true; - } - - void PlotView::ReleaseHaloLayer() - { - HaloLayerCache& cache = m_haloLayer; - if (cache.dc != nullptr) - { - DeleteDC(cache.dc); - cache.dc = nullptr; - } - if (cache.dib != nullptr) - { - DeleteObject(cache.dib); - cache.dib = nullptr; - } - cache.bits = nullptr; - cache.dibWidth = 0; - cache.dibHeight = 0; - cache.valid = false; - } - - void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& points, - const std::vector& breaks) - { - // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): - // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 - // 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。 - if (resolved.data != nullptr && resolved.data->finiteCount == 1) - { - gd::PointF dot; - if (SinglePointScreenPos(resolved, lane, dot)) - { - const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale; - gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale); - g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); - } - return; - } - - const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); - if (!drawLine) - { - // ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求): - // 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。 - // 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比, - // 正好是“去掉数据点之间连线的部分”(用户原话)。 - // ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`), - // 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。 - if (points.empty() || resolved.style == nullptr) - { - return; - } - const float radius = static_cast( - ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, - static_cast(kMinScatterRadius), static_cast(kMaxScatterRadius)) * - m_scale); - const float ringRadius = radius + 2.5f * m_scale; - const std::size_t scatterStride = - std::max(1, points.size() / kMaxScatterPoints); - // 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。 - gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale); - for (std::size_t index = 0; index < points.size(); index += scatterStride) - { - const gd::PointF& point = points[index]; - g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f, - ringRadius * 2.0f); - } - return; - } - if (breaks.empty()) - { - return; - } - - gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), - (resolved.style->lineWidth + 4.0f) * m_scale); - halo.SetLineJoin(gd::LineJoinRound); - halo.SetStartCap(gd::LineCapRound); - halo.SetEndCap(gd::LineCapRound); - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - if (stop <= start + 1) - { - continue; - } - g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); - } - } void PlotView::PaintHoverHighlight(gd::Graphics& g) { @@ -1628,514 +1186,7 @@ namespace jdi g.Restore(saveState); } - PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const - { - SceneCacheKey key; - key.viewMin = Tab().ViewMin(); - key.viewMax = Tab().ViewMax(); - key.laneScroll = Tab().LaneScroll(); - key.laneCount = static_cast(m_lanes.size()); - key.areaX = m_area.X; - key.areaY = m_area.Y; - key.areaW = m_area.Width; - key.areaH = m_area.Height; - key.scale = m_scale; - const SignalRef halo = SceneHaloRef(); - key.haloGroup = halo.groupIndex; - key.haloSignal = halo.signalIndex; - key.contentHash = SceneContentHash(); - return key; - } - - bool PlotView::SceneCacheMatches() const - { - if (!m_sceneValid) - { - return false; - } - // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: - // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 - if (m_sceneDegraded) - { - return false; - } - const SceneCacheKey& cached = m_sceneKey; - const SceneCacheKey key = MakeSceneCacheKey(); - return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && - (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && - (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && - (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && - (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && - (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); - } - - SignalRef PlotView::SceneHaloRef() const - { - // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; - // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 - if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) - { - return m_dragSignal; - } - return SignalRef(); - } - - std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, - bool includeLaneRange, bool includeLaneScroll) const - { - // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 - // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, - // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 - // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), - // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 - std::uint64_t hash = 1469598103934665603ull; - const auto mix = [&hash](std::uint64_t value) - { - hash ^= value; - hash *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) - { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - const auto mixText = [&mix](const std::wstring& text) - { - mix(text.size()); - for (const wchar_t character : text) - { - mix(static_cast(character)); - } - }; - - mixFloat(m_plotRect.X); - mixFloat(m_plotRect.Y); - mixFloat(m_plotRect.Width); - mixFloat(m_plotRect.Height); - mixFloat(m_laneContentHeight); - mix(m_scrollBarVisible ? 1ull : 0ull); - // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 - // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, - // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 - // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 - //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` - // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 - // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 - if (includeLaneScroll) - { - mixFloat(Tab().LaneScroll()); - } - - // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), - // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 - { - const SignalRef selected = SelectedRef(); - mix(selected.IsValid() ? 1ull : 0ull); - mix(static_cast(selected.groupIndex)); - mix(static_cast(selected.signalIndex)); - } - - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const LaneVisual& visual = m_lanes[laneIndex]; - // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 - if (includeLaneScroll) - { - mixFloat(visual.rect.Y); - } - mixFloat(visual.rect.Height); - // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, - // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 - if (includeLaneRange) - { - mixFloat(static_cast(visual.yMin)); - mixFloat(static_cast(visual.yMax)); - } - - const std::vector& signals = Tab().Lanes()[laneIndex].signals; - // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 - // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), - // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, - // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 - // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 - // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 - if (includeStyles && includeDerived) - { - mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); - for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) - { - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); - for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) - { - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); - for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) - { - // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 - mix(static_cast(item.ref.groupIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.amountPercent)); - mix(static_cast(item.iterations)); - } - } - mix(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.groupIndex)); - mix(static_cast(ref.signalIndex)); - mix(resolved.visible ? 1ull : 0ull); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 - // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, - // 于是可以只重画颜色变了的网格,而不必整幅重绘。) - if (includeStyles) - { - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); - // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 - mixFloat(resolved.style->gain); - mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->mirrorVertical ? 1ull : 0ull); - mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); - // 偏移(Y 平移 + 时间平移)同样会改变波形。 - mixFloat(resolved.style->offsetY); - mixFloat(static_cast(resolved.style->offsetTime)); - } - // 图例文案参与绘制,重命名必须让缓存失效。 - mixText(resolved.data->name); - if (resolved.group != nullptr) - { - mixText(resolved.group->name); - } - } - } - return hash; - } - - std::uint64_t PlotView::SceneGeometryHash() const - { - // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, - // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); - // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, - // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), - // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 - // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 - return SceneContentHash(false, true, false, false); - } - - std::uint64_t PlotView::SceneDecorHash() const - { - // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** - //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 - return SceneContentHash(true, false); - } - - void PlotView::CollectSceneStyles(std::vector>& out) const - { - out.clear(); - out.resize(m_lanes.size()); - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const std::vector& signals = Tab().Lanes()[laneIndex].signals; - std::vector& lane = out[laneIndex]; - lane.reserve(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - SceneStyleEntry entry; - if (resolved.style != nullptr) - { - entry.color = resolved.style->color; - entry.opacity = resolved.style->opacity; - entry.lineWidth = resolved.style->lineWidth; - entry.mode = static_cast(resolved.style->mode); - entry.gain = resolved.style->gain; - entry.absolute = resolved.style->absolute; - entry.mirrorVertical = resolved.style->mirrorVertical; - entry.mirrorHorizontal = resolved.style->mirrorHorizontal; - entry.offsetY = resolved.style->offsetY; - entry.offsetTime = resolved.style->offsetTime; - } - lane.push_back(entry); - } - } - } - - bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const - { - lanes.clear(); - if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) - { - // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 - return false; - } - - // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, - // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, - // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 - // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 - { - const SceneCacheKey key = MakeSceneCacheKey(); - if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || - key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || - key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || - key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || - key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || - key.haloSignal != m_sceneKey.haloSignal) - { - return false; - } - } - - std::vector> current; - CollectSceneStyles(current); - if (current.size() != m_sceneStyles.size()) - { - return false; - } - - // 叠加预览曲线(均值滤波 / 中心轴 / 弱峰调整)的参数变化同样只需局部重画。 - std::vector currentDerived; - CollectSceneDerived(currentDerived); - const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); - - for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) - { - const std::vector& now = current[laneIndex]; - const std::vector& before = m_sceneStyles[laneIndex]; - if (now.size() != before.size()) - { - return false; - } - bool differs = false; - for (std::size_t index = 0; index < now.size() && !differs; ++index) - { - // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: - // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** - // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 - // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` - // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, - // 一直残留到某次无关的失效。 - differs = !(now[index] == before[index]); - } - if (!differs && derivedComparable) - { - const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; - const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; - differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || - (nowDerived.meanFilter != beforeDerived.meanFilter) || - (nowDerived.weakPeak != beforeDerived.weakPeak) || - (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || - (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || - (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); - } - if (differs) - { - lanes.push_back(static_cast(laneIndex)); - } - } - return !lanes.empty(); - } - - void PlotView::CollectSceneDerived(std::vector& out) const - { - out.clear(); - out.resize(m_lanes.size()); - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const Lane& lane = Tab().Lanes()[laneIndex]; - SceneDerivedEntry entry; - entry.edgeCenter = lane.edgeCenter ? 1 : 0; - entry.meanFilter = lane.meanFilter ? 1 : 0; - entry.weakPeak = lane.weakPeak ? 1 : 0; - const auto flatten = [](const auto& items, std::vector& target, auto valueOf) - { - target.clear(); - target.reserve(items.size() * 4); - for (const auto& item : items) - { - target.push_back(item.ref.groupIndex); - target.push_back(item.ref.signalIndex); - // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 - target.push_back(static_cast(valueOf(item))); - target.push_back(item.iterations); - } - }; - flatten(lane.edgeCenterItems, entry.edgeCenterItems, - [](const Lane::EdgeCenterItem& item) { return item.points; }); - flatten(lane.meanFilterItems, entry.meanFilterItems, - [](const Lane::MeanFilterItem& item) { return item.points; }); - flatten(lane.weakPeakItems, entry.weakPeakItems, - [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); - out[laneIndex] = std::move(entry); - } - } - - void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) - { - if (lanes.empty() || m_sceneDc == nullptr) - { - return; - } - - // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 - float top = 1.0e9f; - float bottom = -1.0e9f; - for (const int laneIndex : lanes) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - continue; - } - const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - top = std::min(top, rect.Y); - bottom = std::max(bottom, rect.Y + rect.Height); - } - if (bottom <= top) - { - return; - } - top = std::max(top, m_plotRect.Y); - bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); - if (bottom <= top) - { - return; - } - - // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): - // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, - // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), - // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 - // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 - const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); - const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); - const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), - static_cast(std::floor(m_area.X + m_area.Width))); - const int y1 = std::min(static_cast(std::floor(bottom)), - static_cast(std::floor(m_area.Y + m_area.Height))); - if (x1 <= x0 || y1 <= y0) - { - return; - } - - const gd::RectF patch(static_cast(x0), static_cast(y0), - static_cast(x1 - x0), static_cast(y1 - y0)); - - // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 - // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 - std::vector repaint; - for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) - { - const gd::RectF& rect = m_lanes[laneIndex].rect; - if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) - { - repaint.push_back(static_cast(laneIndex)); - } - } - if (repaint.empty()) - { - return; - } - - // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 - { - const int saveState = g.Save(); - g.SetClip(patch, gd::CombineModeIntersect); - // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: - // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 - // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 - UiFillRect(g, patch, m_theme->plotBg); - const float cardTop = std::max(patch.Y, m_plotRect.Y); - const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); - if (cardBottom > cardTop) - { - UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), - m_theme->cardBg); - } - - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); - const double span = viewMax - viewMin; - const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); - std::vector timeTicks; - BuildTicks(viewMin, viewMax, timeStep, timeTicks); - - // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 - // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, - // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 - const bool savedUseDecorCache = m_useDecorCache; - m_useDecorCache = false; - for (const int laneIndex : repaint) - { - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) - { - PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); - } - } - m_useDecorCache = savedUseDecorCache; - g.Restore(saveState); - } - - // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 - { - HDC target = g.GetHDC(); - if (target != nullptr) - { - BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); - g.ReleaseHDC(target); - } - } - - m_sceneKey = MakeSceneCacheKey(); - m_sceneGeometryHash = SceneGeometryHash(); - CollectSceneStyles(m_sceneStyles); - CollectSceneDerived(m_sceneDerived); - m_sceneDegraded = m_sceneDegraded || m_fastFrame; - } - - gd::RectF PlotView::LaneBadgeRect(int laneIndex) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return gd::RectF(); - } - const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; - const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, - lane.rect.Width, kLegendHeight * m_scale); - // 网格被滚动出可视区时没有徽标可点。 - const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); - const float visibleBottom = - std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); - if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) - { - return gd::RectF(); - } - - const float height = kLegendHeight * m_scale; - const float badgeHeight = kLaneBadgeHeight * m_scale; - return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, - kLaneBadgeWidth * m_scale, badgeHeight); - } void PlotView::PaintLaneReorder(gd::Graphics& g) { @@ -2250,1586 +1301,6 @@ namespace jdi } } - bool PlotView::EnsureSceneCache(int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, - // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 - if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) - { - return true; - } - - ReleaseSceneCache(); - // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 - m_panelValid = false; - - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - m_sceneDc = CreateCompatibleDC(screen); - m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); - ReleaseDC(nullptr, screen); - - if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) - { - ReleaseSceneCache(); - return false; - } - m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); - m_sceneWidth = width; - m_sceneHeight = height; - return true; - } - - bool PlotView::CaptureSceneCache(gd::Graphics& g) - { - // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, - // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 - const int x = static_cast(std::floor(m_area.X)); - const int y = static_cast(std::floor(m_area.Y)); - const int right = static_cast(std::ceil(m_area.X + m_area.Width)); - const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); - const int width = right - x; - const int height = bottom - y; - if (width <= 0 || height <= 0) - { - m_sceneValid = false; - return false; - } - - m_sceneX = x; - m_sceneY = y; - - if (!EnsureSceneCache(width, height)) - { - m_sceneValid = false; - return false; - } - - HDC source = g.GetHDC(); - if (source == nullptr) - { - m_sceneValid = false; - return false; - } - const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); - g.ReleaseHDC(source); - - if (ok == FALSE) - { - m_sceneValid = false; - return false; - } - - m_sceneKey = MakeSceneCacheKey(); - m_sceneGeometryHash = SceneGeometryHash(); - CollectSceneStyles(m_sceneStyles); - CollectSceneDerived(m_sceneDerived); - m_sceneDegraded = false; - m_sceneValid = true; - return true; - } - - bool PlotView::BlitSceneCache(gd::Graphics& g) - { - if (!m_sceneValid || m_sceneDc == nullptr) - { - return false; - } - - HDC target = g.GetHDC(); - if (target == nullptr) - { - return false; - } - const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, - SRCCOPY); - g.ReleaseHDC(target); - return ok != FALSE; - } - - void PlotView::InvalidateSceneCache() - { - m_sceneValid = false; - m_sceneStyles.clear(); - m_sceneDerived.clear(); - m_sceneDegraded = false; - m_sceneGeometryHash = 0; - // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): - // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 - m_panelValid = false; - } - - void PlotView::ReleaseSceneCache() - { - m_sceneValid = false; - if (m_sceneDc != nullptr) - { - if (m_sceneOldBitmap != nullptr) - { - SelectObject(m_sceneDc, m_sceneOldBitmap); - m_sceneOldBitmap = nullptr; - } - DeleteDC(m_sceneDc); - m_sceneDc = nullptr; - } - if (m_sceneBitmap != nullptr) - { - DeleteObject(m_sceneBitmap); - m_sceneBitmap = nullptr; - } - m_sceneWidth = 0; - m_sceneHeight = 0; - } - - bool PlotView::EnsureDecorCache(int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height) - { - return true; - } - ReleaseDecorCache(); - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): - // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上; - // 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。 - BITMAPINFO info = {}; - info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); - info.bmiHeader.biWidth = width; - info.bmiHeader.biHeight = -height; // 自上而下 - info.bmiHeader.biPlanes = 1; - info.bmiHeader.biBitCount = 32; - info.bmiHeader.biCompression = BI_RGB; - void* bits = nullptr; - HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); - ReleaseDC(nullptr, screen); - if (dib == nullptr || bits == nullptr) - { - if (dib != nullptr) - { - DeleteObject(dib); - } - return false; - } - HDC dc = CreateCompatibleDC(nullptr); - if (dc == nullptr) - { - DeleteObject(dib); - return false; - } - m_decorOldBitmap = SelectObject(dc, dib); - m_decorDc = dc; - m_decorBitmap = dib; - m_decorBits = bits; - m_decorW = width; - m_decorH = height; - return true; - } - - void PlotView::ReleaseDecorCache() - { - m_decorValid = false; - m_decorCapturedRects.clear(); - if (m_decorDc != nullptr) - { - if (m_decorOldBitmap != nullptr) - { - SelectObject(m_decorDc, m_decorOldBitmap); - m_decorOldBitmap = nullptr; - } - DeleteDC(m_decorDc); - m_decorDc = nullptr; - } - if (m_decorBitmap != nullptr) - { - DeleteObject(m_decorBitmap); - m_decorBitmap = nullptr; - } - m_decorBits = nullptr; - m_decorW = 0; - m_decorH = 0; - } - - void* PlotView::ResolveAlphaBlend() - { - // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现; - // 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。 - static void* cached = nullptr; - static bool resolved = false; - if (resolved) - { - return cached; - } - resolved = true; - HMODULE gdi = GetModuleHandleW(L"gdi32.dll"); - if (gdi != nullptr) - { - cached = reinterpret_cast(GetProcAddress(gdi, "GdiAlphaBlend")); - } - if (cached == nullptr) - { - HMODULE msimg = LoadLibraryW(L"msimg32.dll"); - if (msimg != nullptr) - { - cached = reinterpret_cast(GetProcAddress(msimg, "AlphaBlend")); - } - } - return cached; - } - - bool PlotView::RenderDecorCache() - { - m_decorValid = false; - if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) - { - return false; - } - // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, - // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 - const int width = static_cast(std::ceil(m_area.X + m_area.Width)); - const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); - if (!EnsureDecorCache(width, height)) - { - return false; - } - // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。 - std::memset(m_decorBits, 0, static_cast(width) * static_cast(height) * 4u); - - m_decorRects.clear(); - { - gd::Graphics g(m_decorDc); - g.SetSmoothingMode(gd::SmoothingModeAntiAlias); - g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - - const bool savedUse = m_useDecorCache; - m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支 - m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形) - const std::vector noTicks; - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - PaintLane(g, static_cast(index), m_lanes[index], noTicks); - } - m_renderDecorOnly = false; - m_useDecorCache = savedUse; - } - - // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0 - // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。 - // 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身 - // 是画在不透明底板上的,这些像素本来就该不透明。 - for (const gd::RectF& rect : m_decorRects) - { - const int x0 = std::max(0, static_cast(std::floor(rect.X))); - const int y0 = std::max(0, static_cast(std::floor(rect.Y))); - const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); - const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); - for (int y = y0; y < y1; ++y) - { - unsigned char* row = static_cast(m_decorBits) + - (static_cast(y) * static_cast(width)) * 4u; - for (int x = x0; x < x1; ++x) - { - unsigned char* p = row + static_cast(x) * 4u; - if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0) - { - p[3] = 255; - } - } - } - } - - m_decorCapturedRects = m_decorRects; - m_decorValid = true; - return true; - } - - bool PlotView::BlitDecorCache(gd::Graphics& g) - { - if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) - { - return false; - } - void* proc = ResolveAlphaBlend(); - if (proc == nullptr) - { - return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃) - } - using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); - AlphaBlendFn blend = reinterpret_cast(proc); - HDC target = g.GetHDC(); - if (target == nullptr) - { - return false; - } - // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。 - BLENDFUNCTION fn = {}; - fn.BlendOp = AC_SRC_OVER; - fn.SourceConstantAlpha = 255; - fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘) - bool any = false; - for (const gd::RectF& rect : m_decorCapturedRects) - { - const int x = static_cast(std::floor(rect.X)); - const int y = static_cast(std::floor(rect.Y)); - const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; - const int h = static_cast(std::ceil(rect.Y + rect.Height)) - y; - if (w <= 0 || h <= 0) - { - continue; - } - if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE) - { - any = true; - } - } - g.ReleaseHDC(target); - return any; - } - - bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) - { - if (width <= 0 || height <= 0) - { - return false; - } - if (layer.dc != nullptr && layer.width == width && layer.height == height) - { - return true; - } - ReleaseLaneLayer(layer); - HDC screen = GetDC(nullptr); - if (screen == nullptr) - { - return false; - } - layer.dc = CreateCompatibleDC(screen); - layer.bitmap = CreateCompatibleBitmap(screen, width, height); - ReleaseDC(nullptr, screen); - if (layer.dc == nullptr || layer.bitmap == nullptr) - { - ReleaseLaneLayer(layer); - return false; - } - layer.oldBitmap = SelectObject(layer.dc, layer.bitmap); - layer.width = width; - layer.height = height; - layer.valid = false; - return true; - } - - void PlotView::ReleaseLaneLayer(LaneLayer& layer) - { - layer.valid = false; - if (layer.dc != nullptr) - { - if (layer.oldBitmap != nullptr) - { - SelectObject(layer.dc, layer.oldBitmap); - layer.oldBitmap = nullptr; - } - DeleteDC(layer.dc); - layer.dc = nullptr; - } - if (layer.bitmap != nullptr) - { - DeleteObject(layer.bitmap); - layer.bitmap = nullptr; - } - layer.width = 0; - layer.height = 0; - } - - void PlotView::ReleaseWideBuffer() - { - std::lock_guard lock(m_layerMutex); - for (LaneLayer& layer : m_layerFront) - { - ReleaseLaneLayer(layer); - } - m_layerFront.clear(); - m_layerRebuildPending.clear(); - } - - void PlotView::InvalidateWideBuffer() - { - std::lock_guard lock(m_layerMutex); - for (LaneLayer& layer : m_layerFront) - { - layer.valid = false; - } - m_layerRebuildPending.clear(); - } - - std::uint64_t PlotView::LaneLayerKey(int laneIndex) const - { - // 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容** - // ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。 - const double span = Tab().ViewMax() - Tab().ViewMin(); - std::uint64_t key = LaneLayerHash(laneIndex); - key ^= static_cast(laneIndex + 1) * 1315423911ull; - key *= 1099511628211ull; - key ^= static_cast(std::llround(span * 1.0e6)); - key *= 1099511628211ull; - key ^= static_cast(std::llround(m_plotRect.Width)); - return key; - } - - std::uint64_t PlotView::LaneLayerHash(int laneIndex) const - { - // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 - // 可见性与外观。 - // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** - // 本指纹里;改信号名不需要重建图层。 - // - // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` - // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), - // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 - std::uint64_t hash = 1469598103934665603ull; - const auto mix = [&hash](std::uint64_t value) - { - hash ^= value; - hash *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) - { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return hash; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - mixFloat(visual.rect.Height); - mixFloat(static_cast(visual.yMax - visual.yMin)); - const std::vector& signals = Tab().Lanes()[static_cast(laneIndex)].signals; - mix(signals.size()); - for (const SignalRef& ref : signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - mix(static_cast(ref.groupIndex)); - mix(static_cast(ref.signalIndex)); - mix(resolved.visible ? 1ull : 0ull); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->lineWidth); - mix(static_cast(resolved.style->mode)); - // 「调整」变换会改变图层里的波形,也必须让图层失效 - //(否则贴回的旧像素会与当前样式不一致)。 - mixFloat(resolved.style->gain); - mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->mirrorVertical ? 1ull : 0ull); - mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); - mixFloat(resolved.style->offsetY); - mixFloat(static_cast(resolved.style->offsetTime)); - } - return hash; - } - - int PlotView::LayerMarginX() const - { - const int width = static_cast(std::lround(m_plotRect.Width)); - return std::max(1, width / 2); // 左右各半屏 - } - - int PlotView::LayerMarginY(float laneHeight) const - { - // 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围, - // 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。 - const int half = static_cast(std::lround(static_cast(laneHeight) * 0.5)); - return std::max(1, half); - } - - double PlotView::LayerDefaultWideMin() const - { - // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 - const double span = Tab().ViewMax() - Tab().ViewMin(); - const int width = static_cast(std::lround(m_plotRect.Width)); - if (!(span > 0.0) || width <= 0) - { - return Tab().ViewMin(); - } - const double scale = static_cast(width) / span; - return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; - } - - bool PlotView::LayersStructurallyReady() const - { - std::lock_guard lock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) - { - return false; - } - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int bitmapWidth = width + 2 * LayerMarginX(); - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - const LaneLayer& layer = m_layerFront[index]; - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) - { - return false; - } - // 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。 - if (std::fabs(span - layer.span) > 1.0e-12) - { - return false; - } - // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 - const float laneHeight = m_lanes[index].rect.Height; - const int bitmapHeight = - static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (layer.height != bitmapHeight) - { - return false; - } - } - return true; - } - - bool PlotView::LayersAllocated() const - { - std::lock_guard lock(m_layerMutex); - return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); - } - - bool PlotView::LaneLayerReady(int laneIndex) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - std::lock_guard lock(m_layerMutex); - if (laneIndex >= static_cast(m_layerFront.size())) - { - return false; - } - const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int bitmapWidth = width + 2 * LayerMarginX(); - const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; - const int bitmapHeight = static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || - layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) - { - return false; - } - return layer.key == LaneLayerKey(laneIndex); - } - - bool PlotView::LanesAllReadyForView() const - { - if (m_lanes.empty()) - { - return false; - } - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - if (!LaneLayerReady(static_cast(index))) - { - return false; - } - } - return true; - } - - bool PlotView::LaneVisuallyRelevant(int laneIndex) const - { - return LaneVisuallyRelevant(laneIndex, 0); - } - - bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - if (!(m_plotRect.Height > 0.0f)) - { - return false; - } - const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; - // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); - // 预取带在其上再按屏高放大(见头文件说明)。 - const float margin = std::max(rect.Height, 0.0f) + - static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; - return (rect.Y + rect.Height + margin > m_plotRect.Y) && - (rect.Y - margin < m_plotRect.Y + m_plotRect.Height); - } - - bool PlotView::LanesVisiblyReadyForView() const - { - if (m_lanes.empty()) - { - return false; - } - bool anyRelevant = false; - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - if (!LaneVisuallyRelevant(static_cast(index))) - { - continue; - } - anyRelevant = true; - if (!LaneLayerReady(static_cast(index))) - { - return false; - } - } - return anyRelevant; - } - - bool PlotView::AnyVisibleLanePending() const - { - // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, - // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 - // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 - for (const int laneIndex : m_layerRebuildPending) - { - if (LaneVisuallyRelevant(laneIndex, 1)) - { - return true; - } - } - return false; - } - - void PlotView::QueueLaneRebuild(int laneIndex) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return; - } - for (const int existing : m_layerRebuildPending) - { - if (existing == laneIndex) - { - return; // 已经在队列里 - } - } - m_layerRebuildPending.push_back(laneIndex); - } - - bool PlotView::RenderPendingLaneRebuilds(int budget) - { - if (m_layerRebuildPending.empty() || budget <= 0) - { - return !m_layerRebuildPending.empty(); - } - // ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个 - // 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态 - //(用户报告:缩放 / 折起后“需要等一会才正确加载”)。 - // 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。 - std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), - [this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); }); - const double defaultWideMin = LayerDefaultWideMin(); - // 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。 - // ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见, - // 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer` - // 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后 - // 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。 - m_layerRebuildPending.erase( - std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), - [this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }), - m_layerRebuildPending.end()); - if (m_layerRebuildPending.empty()) - { - return false; - } - const int available = static_cast(m_layerRebuildPending.size()); - const int take = std::min(budget, available); - std::vector batch; - batch.reserve(static_cast(take)); - for (int index = 0; index < take; ++index) - { - const int laneIndex = m_layerRebuildPending[static_cast(index)]; - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) - { - batch.push_back(laneIndex); - } - } - m_layerRebuildPending.erase(m_layerRebuildPending.begin(), - m_layerRebuildPending.begin() + take); - - if (batch.size() > 1) - { - // **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。 - RebuildLaneLayersParallel(batch, defaultWideMin); - } - else - { - for (const int laneIndex : batch) - { - // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - break; - } - LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 - // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), - // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, - // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 - // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 - if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, - m_lanes[static_cast(laneIndex)].yMax)) - { - continue; - } - } - } - return !m_layerRebuildPending.empty(); - } - - void PlotView::EnsureLayerWorkers() - { - unsigned int hardware = std::thread::hardware_concurrency(); - const int want = std::max(1, std::min(static_cast(hardware == 0 ? 2u : hardware), 4)); - if (m_layerWorkerCount == want && static_cast(m_layerWorkers.size()) == want) - { - // 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。 - for (int index = 0; index < want; ++index) - { - m_layerWorkers[static_cast(index)]->Setup(m_fonts, m_theme); - m_layerWorkers[static_cast(index)]->SetScale(m_scale); - m_layerWorkers[static_cast(index)]->m_dataSet = m_dataSet; - m_layerWorkers[static_cast(index)]->m_dataSetWritable = m_dataSetWritable; - } - return; - } - ReleaseLayerWorkers(); - m_layerWorkers.reserve(static_cast(want)); - for (int index = 0; index < want; ++index) - { - auto worker = std::make_unique(); - worker->Setup(m_fonts, m_theme); - worker->SetScale(m_scale); - // ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本, - // 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。 - worker->m_dataSet = m_dataSet; - worker->m_dataSetWritable = m_dataSetWritable; - // ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`); - // 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。 - worker->m_tab = &worker->m_workerTab; - worker->RefreshLanes(); - m_layerWorkers.push_back(std::move(worker)); - } - m_layerWorkerCount = want; - } - - void PlotView::ReleaseLayerWorkers() - { - m_layerWorkers.clear(); - m_layerWorkerCount = 0; - } - - void PlotView::RebuildLaneLayersParallel(const std::vector& laneIndices, double wideMin) - { - if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr) - { - return; - } - // 单网格 / 单核:直接顺序走既有路径(避免线程开销)。 - unsigned int hardware = std::thread::hardware_concurrency(); - if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1) - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - for (const int laneIndex : laneIndices) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - continue; - } - RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast(laneIndex)], 0, 0, 0, - 0, wideMin, m_lanes[static_cast(laneIndex)].yMax); - } - return; - } - - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - } - EnsureLayerWorkers(); - if (m_layerWorkerCount <= 1) - { - return; // 保守回退:让调用方走顺序路径 - } - - // 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。 - const TabConfig tabSnapshot = *m_tab; - const gd::RectF plotRectSnapshot = m_plotRect; - const gd::RectF areaSnapshot = m_area; - const float scaleSnapshot = m_scale; - const std::vector laneSnapshot = m_lanes; - // 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。 - std::vector* const front = &m_layerFront; - - const int workerCount = m_layerWorkerCount; - std::atomic next{0}; - std::vector threads; - threads.reserve(static_cast(workerCount)); - for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex) - { - threads.emplace_back([&, workerIndex]() - { - PlotView* worker = m_layerWorkers[static_cast(workerIndex)].get(); - for (;;) - { - const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed); - if (slot >= laneIndices.size()) - { - break; - } - const int laneIndex = laneIndices[slot]; - if (laneIndex < 0 || laneIndex >= static_cast(laneSnapshot.size()) || - laneIndex >= static_cast(tabSnapshot.Lanes().size())) - { - continue; - } - // 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。 - worker->m_workerTab = tabSnapshot; - worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast(laneIndex)]}; - worker->m_tab = &worker->m_workerTab; - worker->m_fonts = m_fonts; - worker->m_theme = m_theme; - worker->m_scale = scaleSnapshot; - worker->m_dataSet = m_dataSet; - worker->m_dataSetWritable = m_dataSetWritable; - worker->m_area = areaSnapshot; - worker->m_plotRect = plotRectSnapshot; - worker->m_lanes = {laneSnapshot[static_cast(laneIndex)]}; - // ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染, - // 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除 - // "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。 - worker->RenderLaneLayerRegion(0, (*front)[static_cast(laneIndex)], 0, 0, 0, 0, - wideMin, - laneSnapshot[static_cast(laneIndex)].yMax); - } - }); - } - for (std::thread& thread : threads) - { - if (thread.joinable()) - { - thread.join(); - } - } - - // 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。 - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return; - } - std::uint64_t rendered = 0; - for (const int laneIndex : laneIndices) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_layerFront.size())) - { - continue; - } - LaneLayer& target = m_layerFront[static_cast(laneIndex)]; - if (!target.valid) - { - continue; - } - target.key = LaneLayerKey(laneIndex); - ++rendered; - } - m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed); - } - - bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, - int stripY, int stripH, double wideMin, double anchorYMax) - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - std::lock_guard renderLock(m_renderMutex); - - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - layer.valid = false; - return false; - } - - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 - const int bitmapWidth = width + 2 * margin; - const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; - const int lanePixelHeight = static_cast(std::ceil(laneHeight)); - const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高 - const int bitmapHeight = lanePixelHeight + 2 * marginY; - if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight)) - { - return false; - } - - const double scale = static_cast(width) / span; // 像素 / 时间 - const double laneSpan = m_lanes[static_cast(laneIndex)].yMax - - m_lanes[static_cast(laneIndex)].yMin; - const double pxPerValue = (laneSpan > 0.0) ? static_cast(laneHeight) / laneSpan : 0.0; - - if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth) - { - stripX = 0; - stripW = bitmapWidth; - } - // 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。 - if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight) - { - stripY = 0; - stripH = bitmapHeight; - } - - // 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。 - const gd::RectF savedArea = m_area; - const gd::RectF savedPlot = m_plotRect; - const double savedViewMin = Tab().ViewMin(); - const double savedViewMax = Tab().ViewMax(); - const std::vector savedLanes = m_lanes; - const float savedLaneContentHeight = m_laneContentHeight; - const bool savedFastFrame = m_fastFrame; - const bool savedRenderWide = m_renderWide; - const float savedScroll = Tab().LaneScroll(); - const double savedWideTickSpan = m_wideTickSpan; - const float savedWideTickHeight = m_wideTickHeight; - const double savedRenderTimeStep = m_renderTimeStep; - - m_plotRect = gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), - static_cast(bitmapHeight)); - // 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面; - // x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。 - m_area = gd::RectF(static_cast(stripX), static_cast(stripY), - static_cast(stripW), static_cast(stripH)); - // 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。 - // 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。 - const bool stripIsHorizontalBand = (static_cast(stripW) < static_cast(bitmapWidth) - 0.5f); - const bool savedStripActive = m_stripRenderActive; - const float savedStripX0 = m_stripX0; - const float savedStripX1 = m_stripX1; - m_stripRenderActive = stripIsHorizontalBand; - m_stripX0 = static_cast(stripX); - m_stripX1 = static_cast(stripX + stripW); - Tab().ViewMin() = wideMin; - Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; - - m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 - // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 - m_fastFrame = false; - // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): - // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 - m_wideTickSpan = laneSpan; - m_wideTickHeight = laneHeight; - // ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算 - // `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。 - // 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals - // = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。 - // 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。 - // ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"): - // - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致; - // - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax` - // 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。 - // 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。 - m_lanes[static_cast(laneIndex)].yMax = anchorYMax; - m_lanes[static_cast(laneIndex)].yMin = anchorYMax - laneSpan; - // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 - // 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算** - //(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`), - // 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射 - // 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差, - // 让拖拽期间的像素残差明显变大)。 - if (pxPerValue > 0.0) - { - const double topExtend = static_cast(marginY) / pxPerValue; - const double bottomRows = - static_cast(bitmapHeight - marginY) - static_cast(laneHeight); - const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue; - m_lanes[static_cast(laneIndex)].yMin -= bottomExtend; - m_lanes[static_cast(laneIndex)].yMax += topExtend; - } - m_lanes[static_cast(laneIndex)].rect = - gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), static_cast(bitmapHeight)); - { - gd::Graphics g(layer.dc); - g.SetSmoothingMode(gd::SmoothingModeAntiAlias); - g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); - g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); - - UiFillRect(g, m_area, m_theme->plotBg); - UiFillRect(g, m_area, m_theme->cardBg); - - // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 - // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): - // 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。 - const double fgSpan = savedViewMax - savedViewMin; - const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); - m_renderTimeStep = timeStep; - std::vector timeTicks; - BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); - PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); - m_renderTimeStep = 0.0; - } - m_fastFrame = savedFastFrame; - m_renderWide = savedRenderWide; - m_wideTickSpan = savedWideTickSpan; - m_wideTickHeight = savedWideTickHeight; - m_renderTimeStep = savedRenderTimeStep; - m_stripRenderActive = savedStripActive; - m_stripX0 = savedStripX0; - m_stripX1 = savedStripX1; - - // 恢复布局 / 视图 / 网格。 - // ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange` - // 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝, - // 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。 - m_area = savedArea; - m_plotRect = savedPlot; - Tab().ViewMin() = savedViewMin; - Tab().ViewMax() = savedViewMax; - m_lanes = savedLanes; - m_laneContentHeight = savedLaneContentHeight; - Tab().LaneScroll() = savedScroll; - - layer.wideMin = wideMin; - layer.span = span; - layer.marginY = marginY; - // 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定; - // 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。 - layer.anchorYMax = anchorYMax; - layer.pxPerValue = pxPerValue; - layer.key = LaneLayerKey(laneIndex); - layer.valid = true; - m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); - return true; - } - - bool PlotView::RebuildWideBuffer() - { - const float plotWidth = m_plotRect.Width; - const float areaHeight = m_area.Height; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty()) - { - InvalidateWideBuffer(); - return false; - } - - // 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`; - // 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。 - // ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。 - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - m_layerFront.resize(m_lanes.size()); - m_layerRebuildPending.clear(); - } - - // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 - const double wideMin = LayerDefaultWideMin(); - std::vector allLanes; - allLanes.reserve(m_lanes.size()); - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - allLanes.push_back(static_cast(index)); - } - // **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。 - RebuildLaneLayersParallel(allLanes, wideMin); - std::lock_guard layerLock(m_layerMutex); - bool ok = true; - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - if (!m_layerFront[index].valid) - { - ok = false; - } - } - return ok; - } - - bool PlotView::PrefetchStepAllowed() - { - const std::uint64_t now = GetTickCount64(); - if (now >= m_lastPrefetchRequestMs && - (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) - { - return false; - } - m_lastPrefetchRequestMs = now; - return true; - } - - void PlotView::EnsureLaneLayersPrefetched() - { - if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) - { - return; - } - if (!LayersAllocated()) - { - // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 - RebuildWideBuffer(); - return; - } - // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), - // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 - RollWideBuffer(); - } - - void PlotView::WarmLaneLayersStep() - { - if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) - { - return; - } - // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 - // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 - if (m_animationBusy) - { - return; - } - if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) - { - return; - } - { - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() > m_lanes.size()) - { - // 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。 - for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index) - { - ReleaseLaneLayer(m_layerFront[index]); - } - } - if (m_layerFront.size() != m_lanes.size()) - { - m_layerFront.resize(m_lanes.size()); - } - } - // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, - // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 - RollWideBuffer(); - RenderPendingLaneRebuilds(1); - // ⚠️ 是否继续请求下一帧: - // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); - // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, - // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 - const bool morePending = - AnyVisibleLanePending() || - (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); - if (morePending) - { - const HWND host = m_host; - if (host != nullptr) - { - PostMessageW(host, kMessagePlotDerivedReady, 0, 0); - } - } - } - - void PlotView::RollWideBuffer() - { - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return; - } - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); - const int bitmapWidth = width + 2 * margin; - const double scale = static_cast(width) / span; - - std::lock_guard renderLock(m_renderMutex); - std::lock_guard layerLock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) - { - return; - } - // 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。 - int rollBudget = kLayerRollsPerFrame; - - // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), - // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, - // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, - // 表现为严重的绘制错误与波形抖动。 - // - // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 - //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 - // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - LaneLayer& layer = m_layerFront[index]; - const int laneIndex = static_cast(index); - - // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 - const float laneHeight = m_lanes[index].rect.Height; - const int bitmapHeight = - static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || - layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) - { - QueueLaneRebuild(laneIndex); - continue; - } - - // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 - const double desired = (Tab().ViewMin() - layer.wideMin) * scale; - const int shift = static_cast(std::llround(desired - static_cast(margin))); - // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 - const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); - - // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: - // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 - // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 - // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), - // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 - // - // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 - // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms - // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 - // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 - if (!LaneVisuallyRelevant(laneIndex, 1)) - { - continue; - } - // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 - int vOffset = 0; - if (layer.pxPerValue > 0.0) - { - vOffset = static_cast( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - - // ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告: - // 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。 - // 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移, - // 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。 - // 因此这里分三种情形: - // * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费); - // * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开), - // 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**; - // * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画 - // (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。 - const int driftLimit = std::max(8, margin - 24); - const int vOffsetAbs = std::abs(vOffset); - if (vOffsetAbs >= layer.height) - { - // 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。 - layer.valid = false; - QueueLaneRebuild(laneIndex); - continue; - } - if (contentStale) - { - QueueLaneRebuild(laneIndex); - } - - // ------------------------------------------------------------------ - // 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行, - // 再只补画新暴露的那一条(整宽 × 行数)。 - // - // ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染, - // 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上, - // 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。 - // ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高 - // 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分 - // 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。 - // - // ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**, - // 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**, - // 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算 - // (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。 - // 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。 - // 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。 - // ------------------------------------------------------------------ - if (std::abs(vOffset) >= kLayerRollQuantum) - { - const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动) - int stripY = 0; - int stripH = 0; - if (roll > 0) - { - // 内容上移:new[y] = old[y + roll],底部暴露 roll 行。 - BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY); - stripY = layer.height - roll; - stripH = roll; - } - else - { - const int amount = -roll; - // 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。 - BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0, - SRCCOPY); - stripY = 0; - stripH = amount; - } - // 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。 - layer.anchorYMax -= static_cast(roll) / layer.pxPerValue; - // 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。 - RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin, - layer.anchorYMax); - } - - // ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明): - // `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移 - // **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要 - // 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。 - if (std::abs(shift) <= driftLimit) - { - continue; - } - // ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限 - // (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担, - // 因此可以安全地分帧完成。 - if (rollBudget <= 0) - { - continue; - } - --rollBudget; - const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2); - const int roll = std::max(-maxStep, std::min(maxStep, shift)); - - // 整数像素自贴图平移:内容整体移动 `roll` 像素。 - if (roll > 0) - { - // 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。 - BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY); - } - else if (roll < 0) - { - const int amount = -roll; - BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY); - } - else - { - continue; - } - layer.wideMin += static_cast(roll) / scale; - - // 只补画新暴露的细条(每帧代价恒为 O(细条))。 - // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" - // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 - // ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条 - // 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素 - // 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素 - // 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。 - const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1); - const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0; - RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin, - layer.anchorYMax); - } - } - - bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) - { - const float plotWidth = m_plotRect.Width; - const double span = Tab().ViewMax() - Tab().ViewMin(); - if (!(plotWidth > 8.0f) || !(span > 0.0)) - { - return false; - } - const int width = static_cast(std::lround(plotWidth)); - const int margin = LayerMarginX(); - const int bitmapWidth = width + 2 * margin; - const double scale = static_cast(width) / span; - - /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */ - struct LayerBlit - { - HDC dc = nullptr; - int srcX = 0; - int srcY = 0; - int destX = 0; - int destY = 0; - int width = 0; - int height = 0; - }; - - std::vector blits; - std::vector backing; // 本帧拿不到(完整)图层像素的网格 - blits.reserve(m_lanes.size()); - backing.reserve(m_lanes.size()); - { - std::lock_guard lock(m_layerMutex); - if (m_layerFront.size() != m_lanes.size()) - { - return false; - } - for (std::size_t index = 0; index < m_layerFront.size(); ++index) - { - const LaneLayer& layer = m_layerFront[index]; - const gd::RectF& lane = m_lanes[index].rect; - const float top = std::max(lane.Y, m_plotRect.Y); - const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); - if (bottom <= top) - { - continue; // 网格整体移出可视区 - } - const int bandHeight = static_cast(std::lround(bottom - top)); - // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", - // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 - const int expectBitmapHeight = - static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); - bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth && - layer.height == expectBitmapHeight && layer.span > 0.0 && - std::fabs(layer.span - span) <= 1.0e-12); - - LayerBlit blit; - if (usable) - { - // ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的 - // 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 —— - // 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。 - int vOffset = 0; - if (layer.pxPerValue > 0.0) - { - vOffset = static_cast( - std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); - } - int srcY = static_cast(std::lround(top - lane.Y)) + layer.marginY + vOffset; - int height = bandHeight; - int destTopOffset = 0; - // 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。 - if (srcY < 0) - { - destTopOffset = -srcY; - height -= destTopOffset; - srcY = 0; - } - if (srcY + height > layer.height) - { - height = layer.height - srcY; - } - // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 - // 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。 - const double subX = (Tab().ViewMin() - layer.wideMin) * scale; - int srcX = static_cast(std::lround(subX)); - int destOffset = 0; // 从视界左端起偏移多少像素开始贴 - int blitWidth = width; - if (srcX < 0) - { - destOffset = -srcX; - blitWidth -= destOffset; - srcX = 0; - } - if (srcX + blitWidth > bitmapWidth) - { - blitWidth = bitmapWidth - srcX; - } - if (height > 0 && blitWidth > 0) - { - blit.dc = layer.dc; - blit.srcX = srcX; - blit.srcY = srcY; - blit.destX = static_cast(std::lround(m_plotRect.X)) + destOffset; - blit.destY = static_cast(std::lround(top)) + destTopOffset; - blit.width = blitWidth; - blit.height = height; - blits.push_back(blit); - // 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。 - if (destOffset > 0 || blitWidth < width || height < bandHeight) - { - backing.push_back(static_cast(index)); - } - continue; - } - } - - // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` - //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 - // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 - // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): - // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 - backing.push_back(static_cast(index)); - } - } - - if (blits.empty()) - { - // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** - //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 - // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 - // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 - // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) - // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 - // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 - //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 - if (backing.empty()) - { - // 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。 - return false; - } - } - - // ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** —— - // `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。 - for (const int laneIndex : backing) - { - PaintLaneGridBacking(g, laneIndex, timeTicks); - } - - HDC target = g.GetHDC(); - if (target == nullptr) - { - return true; - } - for (const LayerBlit& blit : blits) - { - BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, - SRCCOPY); - } - g.ReleaseHDC(target); - return true; - } /** * @brief 为单个网格绘制网格底衬(底色 + 横向/竖向网格线 + 网格分界线)。 @@ -5471,656 +2942,6 @@ namespace jdi } } - PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const - { - EdgePanelLayout layout; - if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size()) || - laneIndex >= static_cast(m_lanes.size())) - { - return layout; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool meanFilter = (kind == EdgePanelKind::MEAN); - const bool weakPeak = (kind == EdgePanelKind::PEAK); - const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 - const bool itemized = (centerAxis || meanFilter || weakPeak); - const bool hasButton = (meanFilter || weakPeak); - const bool enabled = meanFilter ? lane.meanFilter - : (weakPeak ? lane.weakPeak - : (freeMarker ? lane.freeMarker - : (centerAxis ? lane.edgeCenter - : lane.edgeCount))); - const bool open = meanFilter ? lane.meanFilterPanel.open - : (weakPeak ? lane.weakPeakPanel.open - : (freeMarker ? lane.freeMarkerPanel.open - : (centerAxis ? lane.edgeCenterPanel.open - : lane.edgePanel.open))); - if (!enabled || !open) - { - return layout; - } - // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 - const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed - : (weakPeak ? lane.weakPeakPanel.collapsed - : (freeMarker ? lane.freeMarkerPanel.collapsed - : (centerAxis - ? lane.edgeCenterPanel.collapsed - : lane.edgePanel.collapsed))); - layout.collapsed = collapsed; - - int rowCount = 0; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible) - { - ++rowCount; - } - } - if (rowCount <= 0) - { - return layout; - } - layout.rows = rowCount; - - const float width = EdgePanelWidth(laneIndex, kind); - const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float rowHeight = kEdgePanelRowHeight * m_scale; - const float pad = kEdgePanelPad * m_scale; - // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, - // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 - const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; - const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; - - // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) - // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): - // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); - // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 - const bool itemHasParams = (centerAxis || meanFilter || weakPeak); - const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; - float meanRowHeight = 0.0f; - float itemsHeight = 0.0f; - float height = 0.0f; - if (itemized) - { - meanRowHeight = kEdgePanelMeanRowHeight * m_scale; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - itemsHeight += meanRowHeight; - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (checked && itemHasParams) - { - if (weakPeak) - { - itemsHeight += weakPeakSliderRow * 2.0f; - } - else - { - itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - itemsHeight += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, - // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 - height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); - } - else - { - height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; - } - - // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), - // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 - // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 - bool markerAny = false; - for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) - { - if (item.ref.IsValid()) - { - markerAny = true; - break; - } - } - if (freeMarker) - { - const float groupRow = kFreeMarkerNameRowHeight * m_scale; - const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; - const float itemRow = kFreeMarkerItemRowHeight * m_scale; - const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; - // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于「自由标记点选取」按钮)。 - const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; - // 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方 - //(用户 2026-10-23 要求)。 - const float switchRow = kEdgePanelButtonHeight * m_scale; - const float buttonGap = kFreeMarkerButtonGap * m_scale; - const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow; - float content = 0.0f; - std::vector order; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的分组栏**不出现**在面板上。 - SortedFreeMarkerIndices(lane, ref, order); - if (order.empty()) - { - continue; - } - content += groupRow; - if (!lane.FreeMarkerGroupCollapsed(ref)) - { - // 展开的分组:分组栏 + **表头行** + 条目。 - content += headerRow + static_cast(order.size()) * itemRow; - } - } - if (!markerAny) - { - // 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。 - content += groupRow; - } - // 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。 - const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + footer); - const float maxContent = - std::max(2.0f * itemRow, - std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(2.0f * itemRow, budget))); - const float view = std::min(content, maxContent); - height = titleHeight + pad + view + pad + footer + pad; - } - - // 折起的面板:只剩标题栏(内容区完全不占高度)。 - if (collapsed) - { - height = titleHeight; - } - - // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** - // 依次错开,避免完全重叠。 - // ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大, - // `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角** - //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 - // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 - // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 - const double storedX = meanFilter ? lane.meanFilterPanel.x - : (weakPeak ? lane.weakPeakPanel.x - : (freeMarker ? lane.freeMarkerPanel.x - : (centerAxis ? lane.edgeCenterPanel.x - : lane.edgePanel.x))); - const double storedY = meanFilter ? lane.meanFilterPanel.y - : (weakPeak ? lane.weakPeakPanel.y - : (freeMarker ? lane.freeMarkerPanel.y - : (centerAxis ? lane.edgeCenterPanel.y - : lane.edgePanel.y))); - int openPanelOrdinal = 0; - { - // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: - // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); - // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; - // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 - // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); - // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 - // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 - const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool - { - switch (panelKind) - { - case EdgePanelKind::COUNT: - return lane.edgeCount && lane.edgePanel.open; - case EdgePanelKind::CENTER: - return lane.edgeCenter && lane.edgeCenterPanel.open; - case EdgePanelKind::MEAN: - return lane.meanFilter && lane.meanFilterPanel.open; - case EdgePanelKind::PEAK: - return lane.weakPeak && lane.weakPeakPanel.open; - case EdgePanelKind::MARK: - return lane.freeMarker && lane.freeMarkerPanel.open; - } - return false; - }; - static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, - EdgePanelKind::MEAN, EdgePanelKind::PEAK, - EdgePanelKind::MARK}; - int openSeen = 0; - bool found = false; - for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) - { - const Lane& tabLane = Tab().Lanes()[lane]; - for (const EdgePanelKind panelKind : kOrder) - { - if (!shown(tabLane, panelKind)) - { - continue; - } - if (lane == static_cast(laneIndex) && panelKind == kind) - { - found = true; - break; - } - ++openSeen; - } - } - openPanelOrdinal = found ? openSeen : 0; - } - // 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。 - const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale); - const float stagger = - std::min(static_cast(openPanelOrdinal) * 28.0f * m_scale, maxStagger); - float left = (storedX >= 0.0) - ? static_cast(storedX) - : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); - float top = (storedY >= 0.0) ? static_cast(storedY) - : (m_plotRect.Y + 16.0f * m_scale + stagger); - - // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 - left = std::max(m_plotRect.X + 2.0f * m_scale, - std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); - top = std::max(m_plotRect.Y + 2.0f * m_scale, - std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); - - layout.panel = gd::RectF(left, top, width, height); - // 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。 - layout.title = gd::RectF(left, top, width, titleHeight); - const float closeSize = titleHeight; - layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); - // 标题栏左侧的「展开 / 折起」三角指示器(位于 GX 徽标左边)。 - // 折起后面板只剩标题栏,因此这个指示器是唯一的“重新展开”入口(双击标题栏亦可)。 - layout.disclosure = gd::RectF(left + pad, top + (titleHeight - kEdgePanelDisclosureWidth * m_scale) * 0.5f, - kEdgePanelDisclosureWidth * m_scale, - kEdgePanelDisclosureWidth * m_scale); - // 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格” - // 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。 - layout.badge = - gd::RectF(left + pad + kEdgePanelDisclosureWidth * m_scale, - top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale, - kLaneBadgeHeight * m_scale); - // 折起状态:到此为止(内容区、按钮、滚动条全部为空)。 - if (collapsed) - { - return layout; - } - if (itemized) - { - const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; - if (hasButton) - { - // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 - //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); - // 面板高度已含这份 pad,因此这里只上移按钮行即可。 - layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, - std::max(0.0f, width - pad * 2.0f), buttonRowHeight); - } - - // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); - // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 - const float checkSize = kEdgePanelMeanCheckSize * m_scale; - const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; - const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); - // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 - const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); - const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); - const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; - const float weakPeakResetGap = kWeakPeakResetGap * m_scale; - const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; - const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; - const float weakPeakTrackWidth = - std::max(10.0f * m_scale, - (left + width - pad - weakPeakValueWidth - weakPeakResetGap - - weakPeakResetSize - 6.0f * m_scale) - - weakPeakTrackLeft); - float rowY = top + titleHeight + pad; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - - layout.rowRefs.push_back(ref); - layout.rowChecks.push_back(gd::RectF( - left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, - checkSize)); - const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) - : (weakPeak ? lane.WeakPeakEnabled(ref) - : lane.EdgeCenterEnabled(ref)); - if (weakPeak) - { - // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 - if (checked) - { - const float levelRowTop = rowY + meanRowHeight; - const float iterRowTop = levelRowTop + weakPeakSliderRow; - const gd::RectF levelTrack( - weakPeakTrackLeft, - levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - const gd::RectF iterTrack( - weakPeakTrackLeft, - iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, - weakPeakTrackWidth, weakPeakTrackHeight); - layout.rowLevelTracks.push_back(levelTrack); - layout.rowLevelValues.push_back(gd::RectF( - levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - layout.rowIterTracks.push_back(iterTrack); - layout.rowIterValues.push_back(gd::RectF( - iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, - weakPeakValueWidth, weakPeakSliderRow)); - // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 - const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + - weakPeakValueWidth + weakPeakResetGap; - layout.rowLevelResets.push_back( - gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - layout.rowIterResets.push_back( - gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, - weakPeakResetSize, weakPeakResetSize)); - } - else - { - layout.rowLevelTracks.push_back(gd::RectF()); - layout.rowLevelValues.push_back(gd::RectF()); - layout.rowLevelResets.push_back(gd::RectF()); - layout.rowIterTracks.push_back(gd::RectF()); - layout.rowIterValues.push_back(gd::RectF()); - layout.rowIterResets.push_back(gd::RectF()); - } - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - - rowY += meanRowHeight; - if (checked) - { - rowY += weakPeakSliderRow * 2.0f; - } - continue; - } - if (checked && itemHasParams) - { - const float editTop = rowY + meanRowHeight; - layout.rowPoints.push_back( - gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); - layout.rowIterations.push_back( - gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); - if (centerAxis) - { - layout.rowStats.push_back( - gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, - kEdgePanelMeanRowHeight * m_scale)); - } - else - { - layout.rowStats.push_back(gd::RectF()); - } - } - else - { - layout.rowPoints.push_back(gd::RectF()); - layout.rowIterations.push_back(gd::RectF()); - layout.rowStats.push_back(gd::RectF()); - } - - rowY += meanRowHeight; - if (checked && itemHasParams) - { - rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; - if (centerAxis) - { - rowY += kEdgePanelMeanRowHeight * m_scale; - } - } - } - } - - if (freeMarker) - { - // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** - //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), - // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 - // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 - const float groupRow = kFreeMarkerNameRowHeight * m_scale; - const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; - const float itemRow = kFreeMarkerItemRowHeight * m_scale; - const float closeSize = kFreeMarkerCloseSize * m_scale; - const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; - // 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。 - const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; - const float scrollW = kFreeMarkerScrollWidth * m_scale; - // 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。 - const float switchRow = kEdgePanelButtonHeight * m_scale; - const float buttonGap = kFreeMarkerButtonGap * m_scale; - - // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 - { - const float switchTop = top + height - pad - switchRow; - const float halfWidth = - std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f); - layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow); - layout.showEnum = - gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow); - // 「自由标记点选取」切换按钮(整行宽度)。 - layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight, - std::max(0.0f, width - pad * 2.0f), markerButtonHeight); - } - { - // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 - //(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。 - // ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。 - const float rowTop = layout.button.Y - sliderRow; - const float valueWidth = - std::max(36.0f * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); - const float labelWidth = - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; - const float trackLeft = left + pad + labelWidth; - const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; - const float trackHeight = kWeakPeakTrackHeight * m_scale; - layout.opacityTrack = - gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, - std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); - layout.opacityValue = - gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); - } - - // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 - const float viewHeight = std::max( - 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow - - buttonGap - switchRow); - float totalContent = 0.0f; - std::vector order; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - SortedFreeMarkerIndices(lane, ref, order); - if (order.empty()) - { - continue; - } - totalContent += groupRow; - if (!lane.FreeMarkerGroupCollapsed(ref)) - { - totalContent += headerRow + static_cast(order.size()) * itemRow; - } - } - if (!markerAny) - { - totalContent += groupRow; - } - const bool scrollable = totalContent > viewHeight + 0.5f; - const float maxScroll = std::max(0.0f, totalContent - viewHeight); - const float scroll = std::max(0.0f, std::min(lane.freeMarkerScroll, maxScroll)); - const float innerWidth = - std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); - layout.content = - gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); - layout.markerContentHeight = totalContent; - if (scrollable) - { - layout.scrollTrack = - gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, - viewHeight); - const float thumbHeight = - std::max(18.0f * m_scale, viewHeight * (viewHeight / totalContent)); - const float thumbTop = - layout.content.Y + - (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); - layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); - } - - // 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔) - // 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。 - // ⚠️ 整个面板里没有任何枚举文本时,③ 枚举列**整列缺省**(列宽记 0,后面的列左移)。 - // 这里只查"该信号是否带枚举表"(O(1)),**不**逐条采样(布局函数被高频调用)。 - bool hasEnum = false; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data != nullptr && resolved.visible && !resolved.data->enumText.Empty() && - lane.HasFreeMarkers(ref)) - { - hasEnum = true; - break; - } - } - layout.markerHasEnumColumn = hasEnum; - float columnX = left + pad; - for (int column = 0; column < 6; ++column) - { - const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; - // ⚠️ 列宽取「缺省列宽」「实测内容宽度」与**表头列标题宽度**三者的大者: - // 面板里不再出现省略号(“时间间隔”四个字比缺省列宽 78 还宽)。 - const float columnWidth = - omitted ? 0.0f - : std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, - lane.freeMarkerColumnWidths[static_cast(column)]), - UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + - 2.0f * m_scale); - layout.markerColumns[column].left = columnX; - layout.markerColumns[column].width = columnWidth; - columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale); - } - - const float contentBottom = layout.content.Y + layout.content.Height; - const float contentTop = layout.content.Y; - float rowY = contentTop - scroll; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - // 该信号没有任何固定点 ⇒ 分组栏与数据行都**不出现**(用户明确要求)。 - SortedFreeMarkerIndices(lane, ref, order); - if (order.empty()) - { - continue; - } - EdgePanelLayout::MarkerGroup group; - group.ref = ref; - group.collapsed = lane.FreeMarkerGroupCollapsed(ref); - const float itemsHeight = - group.collapsed ? 0.0f : (headerRow + static_cast(order.size()) * itemRow); - const float groupHeight = groupRow + itemsHeight; - - // ---- 吸顶(sticky):分组栏与表头**各自**钉住,先后移出 ---- - // 规则(用户 2026-10-22): - // * 该分组还有条目没显示完全 ⇒ 分组栏钉在内容区顶部、表头钉在分组栏下方; - // * 到分组末尾时顺序是:**表头**先向上滚(被分组栏盖住)→ 最后一个条目滚到 - // 分组栏下 → **分组栏**最后移出内容区。 - // 实现(每个元素各自的“吸顶 + 推挤”夹取): - // y = max(自然位置, min(吸顶位置, 本组下边界 - 自身高度)) - // 夹取上限保证元素不会被推离自己的分组(这正是“推挤”效果)。 - float barTop = rowY; - float headerTop = rowY + groupRow; - if (!group.collapsed) - { - const float maxHeaderTop = rowY + groupHeight - headerRow; - headerTop = std::max(rowY + groupRow, std::min(contentTop + groupRow, maxHeaderTop)); - } - { - const float maxBarTop = rowY + groupHeight - groupRow; - barTop = std::max(rowY, std::min(contentTop, maxBarTop)); - } - group.pinned = (barTop > rowY + 0.5f); - group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); - group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, - barTop + (groupRow - 12.0f * m_scale) * 0.5f, - 12.0f * m_scale, 12.0f * m_scale); - group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, - barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); - float pinnedBottom = barTop + groupRow; - if (!group.collapsed) - { - group.tableHeader = gd::RectF(layout.content.X, headerTop, innerWidth, headerRow); - pinnedBottom = std::max(pinnedBottom, headerTop + headerRow); - } - // 被(吸顶的)分组栏 / 表头盖住的那一片区域:其下的条目既不可绘制也不可命中。 - const float pinnedTop = std::min(barTop, group.tableHeader.Width > 0.0f ? headerTop : barTop); - const auto coveredByPinned = [pinnedTop, pinnedBottom](const gd::RectF& rect) - { - return rect.Y < pinnedBottom - 0.01f && rect.Y + rect.Height > pinnedTop + 0.01f; - }; - if (group.header.Y + group.header.Height > contentTop && group.header.Y < contentBottom) - { - // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 - // 底部按钮 / 滑块的点击)。 - layout.markerGroups.push_back(group); - layout.rowRefs.push_back(ref); - layout.rowNameRects.push_back(group.header); - layout.rowNameCloses.push_back(group.close); - } - rowY += groupRow; - if (group.collapsed) - { - continue; - } - rowY += headerRow; - for (std::size_t orderPosition = 0; orderPosition < order.size(); ++orderPosition) - { - const std::size_t itemIndex = order[orderPosition]; - EdgePanelLayout::MarkerRow markerRow; - markerRow.itemIndex = itemIndex; - // 「序号」列:组内按时间升序从 1 开始(每组 = 一个信号)。 - markerRow.serial = orderPosition + 1; - markerRow.rect = gd::RectF(layout.content.X, rowY, innerWidth, itemRow); - markerRow.close = gd::RectF(layout.content.X + innerWidth - closeSize, - rowY + (itemRow - closeSize) * 0.5f, closeSize, - closeSize); - // 被吸顶的分组栏 / 表头完全盖住的条目既不绘制也不可命中。 - if (markerRow.rect.Y + markerRow.rect.Height > contentTop && - markerRow.rect.Y < contentBottom && !coveredByPinned(markerRow.rect)) - { - layout.markerRows.push_back(markerRow); - } - rowY += itemRow; - } - } - } - return layout; - } void PlotView::PaintDataMarker(gd::Graphics& g, const DataMarker& marker, float radius, std::uint32_t fillColor, std::uint32_t ringColor) const @@ -6787,256 +3608,6 @@ namespace jdi return hash; } - bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value, - double& enumValue) const - { - if (m_dataSet == nullptr) - { - return false; - } - const ResolvedSignal resolved = ResolveSignal(item.ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - return false; - } - // 固定点存的是**显示时刻**,因此要用 `SampleDisplayedSignalAt`(内部处理时间偏移与 - // 水平镜像)。散点模式下不插值,取最近采样点,与光标读数的规则一致。 - const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); - double sampled = 0.0; - if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, item.time, - interpolate, sampled) || - !std::isfinite(sampled)) - { - return false; - } - (void)lane; - enumValue = sampled; // 枚举按**原值**查 - value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致 - return std::isfinite(value); - } - - bool PlotView::FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev, - double& delta, double& timeDelta) const - { - hasPrev = false; - delta = 0.0; - timeDelta = 0.0; - if (itemIndex >= lane.freeMarkerItems.size()) - { - return false; - } - const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; - std::vector order; - SortedFreeMarkerIndices(lane, item.ref, order); - // 找到自己在**排序后序列**里的位置;前一个即「左边最近的那个固定点」。 - std::size_t position = order.size(); - for (std::size_t index = 0; index < order.size(); ++index) - { - if (order[index] == itemIndex) - { - position = index; - break; - } - } - if (position == order.size() || position == 0) - { - return true; // 最左边(或找不到):没有增量 - } - const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]]; - double previousValue = 0.0; - double previousEnum = 0.0; - if (!FreeMarkerValue(lane, previous, previousValue, previousEnum)) - { - return true; // 前一个点没有有效取值:不显示增量(避免编造一个数) - } - hasPrev = true; - delta = value - previousValue; - timeDelta = item.time - previous.time; - return true; - } - - const std::vector& PlotView::FreeMarkerContents(int laneIndex) const - { - static const std::vector kNoEntries; - if (m_tab == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size())) - { - return kNoEntries; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (static_cast(laneIndex) >= m_freeMarkerContent.size()) - { - m_freeMarkerContent.resize(static_cast(laneIndex) + 1); - } - FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast(laneIndex)]; - - // 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。 - // ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、 - // 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。 - std::uint64_t key = 1469598103934665603ull; - const auto mix = [&key](std::uint64_t value) { - key ^= value; - key *= 1099511628211ull; - }; - const auto mixFloat = [&mix](float value) { - std::uint32_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(static_cast(bits)); - }; - const auto mixDouble = [&mix](double value) { - std::uint64_t bits = 0; - std::memcpy(&bits, &value, sizeof(bits)); - mix(bits); - }; - mix(static_cast(lane.freeMarkerItems.size())); - // ⚠️ 两个显示开关只影响胶囊文本,但同样属于“与视界无关的输入”,必须进键。 - mix(lane.freeMarkerShowDelta ? 1ull : 0ull); - mix(lane.freeMarkerShowEnum ? 1ull : 0ull); - for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) - { - mix(static_cast(static_cast(item.ref.groupIndex))); - mix(static_cast(static_cast(item.ref.signalIndex))); - mixDouble(item.time); - const ResolvedSignal resolved = ResolveSignal(item.ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - mix(0ull); - continue; - } - mix(reinterpret_cast(resolved.data)); - mix(static_cast(resolved.data->values.size())); - mix(resolved.visible ? 1ull : 2ull); - mix(static_cast(resolved.style->color)); - mixFloat(resolved.style->opacity); - mixFloat(resolved.style->gain); - mixFloat(resolved.style->offsetY); - mixDouble(resolved.style->offsetTime); - mix(static_cast(static_cast(resolved.style->mode))); - mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) | - (resolved.style->mirrorHorizontal ? 4ull : 0ull)); - // ⚠️ 枚举表的内容决定「枚举文本」列与胶囊里的 `= <文本>`(以及缺省时的 `N/A`): - // 旧实现只混了信号指针与采样数量 ⇒ 重新加载/替换枚举表后仍贴回旧文本 - //(离屏探针实测:加上枚举表后条目里仍是 `N/A`)。这里把表的规模与文本数组 - // 的地址一并入键 —— 只比较 `size()` 不足以区分“换成另一张同样长度的表”。 - mix(static_cast(resolved.data->enumText.values.size())); - mix(static_cast(resolved.data->enumText.texts.size())); - mix(static_cast(resolved.data->enumText.rangeMin.size())); - mix(static_cast( - reinterpret_cast(resolved.data->enumText.texts.data()))); - } - - if (cache.key != key) - { - cache.key = key; - cache.entries.clear(); - cache.entries.reserve(lane.freeMarkerItems.size()); - for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) - { - const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; - FreeMarkerContentEntry entry; - entry.itemIndex = itemIndex; - double value = 0.0; - double enumValue = 0.0; - const bool valueOk = FreeMarkerValue(lane, item, value, enumValue); - bool hasPrev = false; - double delta = 0.0; - double timeDelta = 0.0; - if (valueOk) - { - FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta); - } - const ResolvedSignal resolved = ResolveSignal(item.ref); - const std::wstring* enumPtr = - (resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr; - entry.valid = valueOk; - entry.value = value; - entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u; - entry.capsuleText = FreeMarkerValueText(value, lane.freeMarkerShowDelta, hasPrev, delta, - lane.freeMarkerShowEnum, enumPtr); - entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable); - // ⚠️ 枚举列一旦存在(面板里有任一固定点带枚举表),**没有**枚举文本的点必须显示 - // `N/A` 而不是空着(用户 2026-10-23 要求);数值对不上任何枚举项也落在这一分支。 - entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring(kFreeMarkerNotAvailable); - entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta) - : std::wstring(kFreeMarkerNotAvailable); - entry.timeText = FreeMarkerTimeText(item.time); - entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta); - cache.entries.push_back(std::move(entry)); - } - } - return cache.entries; - } - - bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label, - gd::RectF& close, std::wstring& text) const - { - label = gd::RectF(); - close = gd::RectF(); - text.clear(); - if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || - laneIndex >= static_cast(m_tab->Lanes().size()) || - laneIndex >= static_cast(m_lanes.size())) - { - return false; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (itemIndex >= lane.freeMarkerItems.size()) - { - return false; - } - const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; - // 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。 - const std::vector& contents = FreeMarkerContents(laneIndex); - if (itemIndex >= contents.size() || !contents[itemIndex].valid) - { - return false; - } - const FreeMarkerContentEntry& entry = contents[itemIndex]; - const double value = entry.value; - - // 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。 - const float rawX = TimeToX(item.time); - if (rawX < m_plotRect.X - 0.5f || rawX > m_plotRect.X + m_plotRect.Width + 0.5f) - { - return false; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - const float clipTop = std::max(visual.rect.Y, m_plotRect.Y); - const float clipBottom = - std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); - const float rawY = ValueToY(value, visual); - if (!std::isfinite(rawY) || rawY < clipTop - 0.5f || rawY > clipBottom + 0.5f) - { - return false; // 纵向越界同样直接不显示 - } - - // 枚举文本按**采样原值**查找(与光标读数完全一致)。 - text = entry.capsuleText; - - // ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。 - // ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形 - // 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。 - const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale; - const float textHeight = 17.0f * m_scale; - const float closeSize = textHeight; - const float gap = kFreeMarkerCloseGap * m_scale; - const float totalWidth = textWidth + gap + closeSize; - - float left = rawX + 6.0f * m_scale; - if (left + totalWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) - { - left = rawX - 6.0f * m_scale - totalWidth; - } - left = std::max(m_plotRect.X + 2.0f * m_scale, - std::min(left, m_plotRect.X + m_plotRect.Width - totalWidth - 2.0f * m_scale)); - float top = rawY - textHeight * 0.5f; - top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); - - label = gd::RectF(left, top, textWidth, textHeight); - close = gd::RectF(left + textWidth + gap, top, closeSize, closeSize); - return true; - } /** * @brief 用 **GDI+** 绘制一段可带 alpha 的文字(仅用于「标记透明度」低于 100% 的胶囊)。 @@ -7401,291 +3972,6 @@ namespace jdi } - PlotView::FreeMarkerProbe PlotView::ProbeFreeMarker(int mouseX, int mouseY) const - { - FreeMarkerProbe probe; - if (m_tab == nullptr || m_dataSet == nullptr || - !UiPointInRect(m_plotRect, static_cast(mouseX), static_cast(mouseY))) - { - return probe; - } - const int laneIndex = LaneAtY(mouseY); - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || - laneIndex >= static_cast(m_tab->Lanes().size())) - { - return probe; - } - const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - if (!lane.freeMarker) - { - return probe; - } - // ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 明确要求): - // 不预览数据点、不画纵向光标、按下/拖动完全走常规语义(平移 / 框选 / 拖信号), - // 也不会固定任何数据点。所有自由标记的探测都从这一句的“早退”开始。 - if (!lane.freeMarkerCursor) - { - return probe; - } - const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - - // 「自由标记点选取」已开启:网格内始终跟随鼠标画纵向光标(见 `PaintFreeMarker`)。 - const bool cursorMode = true; - probe.cursorMode = cursorMode; - probe.laneIndex = laneIndex; - probe.time = XToTime(static_cast(mouseX)); - probe.x = static_cast(mouseX); - - // 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`), - // 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。 - // ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值 - // 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。 - const float fx = static_cast(mouseX); - const float fy = static_cast(mouseY); - const float tolerance = kSignalHitTolerance * m_scale; - - // 把“显示时刻”吸附到最近的**采样时刻**(返回显示时刻空间的值)。 - // 与 `SnapTimeToSample` 的区别:这里**不做稀疏判定**,任何信号都可以吸附, - // 因为固定点本来就应当落在真实数据点上(阶跃端点必须能选中)。 - const auto snapToNearestSample = [](const TimeAxis& axis, const SignalData& data, - const SignalStyle& style, double displayTime, - double& out) -> bool - { - const std::size_t count = std::min(axis.time.size(), data.values.size()); - if (count == 0) - { - return false; - } - const double target = displayTime - style.offsetTime; - if (axis.monotonic) - { - const auto lower = - std::lower_bound(axis.time.begin(), - axis.time.begin() + static_cast(count), target); - std::size_t index = static_cast(lower - axis.time.begin()); - if (index >= count) - { - index = count - 1; - } - if (index > 0 && - std::fabs(axis.time[index - 1] - target) <= std::fabs(axis.time[index] - target)) - { - --index; - } - out = axis.time[index] + style.offsetTime; - return true; - } - const int nearest = FindNearestSample(axis, target); - if (nearest < 0 || static_cast(nearest) >= count) - { - return false; - } - out = axis.time[static_cast(nearest)] + style.offsetTime; - return true; - }; - - // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 - double bestDistance = std::numeric_limits::infinity(); - double bestTime = probe.time; - double bestValue = 0.0; - double bestEnum = 0.0; - float bestY = 0.0f; - bool bestSnapped = false; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 - // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 - const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); - - // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- - float nearestX = fx; - double distance = std::numeric_limits::infinity(); - const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual); - if (cached != nullptr && cached->points.size() >= 2) - { - const std::vector& points = cached->points; - const std::vector& breaks = cached->breaks; - // 横向先行裁剪(点到线段的距离必然 ≥ 横向距离):时间单调时二分定位。 - std::size_t windowFirst = 0; - std::size_t windowLast = points.size(); - if (resolved.axis->monotonic) - { - const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; - windowFirst = static_cast( - std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - - points.begin()); - windowLast = static_cast( - std::upper_bound(points.begin(), points.end(), fx + tolerance, - [](float limit, const gd::PointF& point) - { return limit < point.X; }) - - points.begin()); - } - if (resolved.style->mode == DrawMode::SCATTER) - { - // 散点:直接比较采样点本身(不把连线当成可命中目标)。 - const std::size_t begin = (windowFirst > 0) ? (windowFirst - 1) : 0; - const std::size_t end = std::min(points.size(), windowLast + 1); - for (std::size_t index = begin; index < end; ++index) - { - const float dx = points[index].X - fx; - const float dy = points[index].Y - fy; - const double probeDistance = std::sqrt(static_cast(dx * dx + dy * dy)); - if (probeDistance < distance) - { - distance = probeDistance; - nearestX = points[index].X; - } - } - } - else - { - for (std::size_t k = 0; k < breaks.size(); ++k) - { - const std::size_t start = breaks[k]; - const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); - const std::size_t scanBegin = std::max(start + 1, windowFirst); - // `windowLast + 1`:包含“一端在窗口内、另一端在窗口外”的跨窗口线段 —— - // 阶跃的竖直段就是这种线段(上端与下端相隔整个跳变幅度)。 - const std::size_t scanEnd = std::min(stop, windowLast + 1); - for (std::size_t index = scanBegin; index < scanEnd; ++index) - { - const gd::PointF& a = points[index - 1]; - const gd::PointF& b = points[index]; - const float vx = b.X - a.X; - const float vy = b.Y - a.Y; - const float wx = fx - a.X; - const float wy = fy - a.Y; - const float lengthSquared = vx * vx + vy * vy; - float t = 0.0f; - if (lengthSquared > 1.0e-6f) - { - t = (wx * vx + wy * vy) / lengthSquared; - t = std::max(0.0f, std::min(1.0f, t)); - } - const float dx = wx - t * vx; - const float dy = wy - t * vy; - const double segmentDistance = - std::sqrt(static_cast(dx * dx + dy * dy)); - if (segmentDistance < distance) - { - distance = segmentDistance; - nearestX = a.X + t * vx; - } - } - } - } - } - - // ---- ② 决定标注时刻 ---- - // ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点 - //(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A), - // **不要求鼠标贴近曲线**。 - // 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线 - // 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。 - // - // 因此这里准备**两个候选**,取更近的那一个: - // * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中); - // * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。 - // 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。 - const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); - double time = 0.0; - double sampled = 0.0; - bool candidateOk = false; - // (a) 曲线候选 - { - double candidateTime = XToTime(nearestX); - if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, - candidateTime)) - { - double candidateSample = 0.0; - if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, - candidateTime, interpolate, candidateSample) && - std::isfinite(candidateSample)) - { - time = candidateTime; - sampled = candidateSample; - candidateOk = true; - } - } - } - // (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据) - { - double candidateTime = probe.time; - if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, - candidateTime)) - { - double candidateSample = 0.0; - if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, - candidateTime, interpolate, candidateSample) && - std::isfinite(candidateSample)) - { - const double candidateValue = - ApplyDrawTransform(*resolved.style, candidateSample); - if (std::isfinite(candidateValue)) - { - const double vertical = std::fabs( - static_cast(ValueToY(candidateValue, visual)) - - static_cast(fy)); - if (!candidateOk || vertical < distance) - { - distance = vertical; - nearestX = fx; - time = candidateTime; - sampled = candidateSample; - candidateOk = true; - } - } - } - } - } - if (!candidateOk) - { - continue; - } - // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 - // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: - // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 - const bool snapped = sparse; - const double value = ApplyDrawTransform(*resolved.style, sampled); - if (!std::isfinite(value)) - { - continue; - } - const float y = ValueToY(value, visual); - if (distance >= bestDistance) - { - continue; - } - bestDistance = distance; - bestTime = time; - bestValue = value; - bestEnum = sampled; - bestY = y; - bestSnapped = snapped; - probe.ref = ref; - } - - if (!probe.ref.IsValid()) - { - // 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。 - probe.valid = true; - return probe; - } - probe.valid = true; - probe.time = bestTime; - probe.x = TimeToX(bestTime); - probe.value = bestValue; - probe.enumValue = bestEnum; - probe.y = bestY; - probe.snapped = bestSnapped; - return probe; - } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp new file mode 100644 index 0000000..dec222f --- /dev/null +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -0,0 +1,1409 @@ +// PlotView 实现分片 —— 几何:浮动面板布局、图例徽标几何、"自由标记"的取值/标签/探测。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// +// 为什么要独立出来: +// * 这些函数**被高频询问**(`TopPanelAtPoint` / `SyncPanelOrder` / `QueryCursor` / 绘制各算一遍, +// `EdgePanelLayoutFor` 一度有 19 个调用点),属于"几何"而不是"绘制"; +// * 与绘制混在一个文件里时,改动布局会连带触碰 9 千行的绘制文件,回归面被放大。 +// +// ⚠️ 硬约束: +// 1. `EdgePanelLayoutFor` / `EdgePanelWidth` 会被鼠标移动的高频路径调用 ⇒ **不得**逐条查询采样值, +// 枚举列缺省只允许查 `SignalEnumText::Empty()`(O(1))。 +// 2. 布局结果必须"绘制与命中同源":绘制侧不得自行反推列边界。 +// 3. 面板几何键(`PanelSlotKey` 等)留在 `PlotViewPaint.cpp`(属于缓存键,不属于几何)。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const + { + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return kEdgePanelWidth * m_scale; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool meanFilter = (kind == EdgePanelKind::MEAN); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 + const bool itemized = (centerAxis || meanFilter || weakPeak); + + // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” + // + 光标区间尾缀)。 + const std::wstring title = + meanFilter ? std::wstring(L"均值滤波") + : (weakPeak ? std::wstring(kWeakPeakTitle) + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); + float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + + kEdgePanelTitleHeight * m_scale; + + // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 + // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, + // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 + // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 + float nameColumnWidth = 0.0f; + float statRisingWidth = 0.0f; + float statFallingWidth = 0.0f; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 + // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 + // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + const float nameWidth = + std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), + kEdgePanelNameMaxWidth * m_scale); + nameColumnWidth = std::max(nameColumnWidth, nameWidth); + if (itemized) + { + // 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + + kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (meanFilter || weakPeak) + { + continue; + } + if (centerAxis) + { + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr) + { + continue; + } + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), + rising, falling, complete); + } + else + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + } + // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 + // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + statRisingWidth = std::max( + statRisingWidth, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), + pendingWidth)); + statFallingWidth = + std::max(statFallingWidth, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); + } + if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) + { + // 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定, + // 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。 + const float maxColumn = std::max(statRisingWidth, statFallingWidth); + const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + std::max(nameColumnWidth, countsWidth)); + } + + if (centerAxis || meanFilter) + { + // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 + // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」。标签与数值框各占半行。) + const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); + const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); + const float fieldWidth = + std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, + UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); + const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; + const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + } + + if (weakPeak) + { + // 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。 + const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale + + kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + + WeakPeakValueColumnWidth(m_fonts->monoSmall) + + kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); + } + + if (meanFilter || weakPeak) + { + // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 + const float buttonWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); + } + + if (kind == EdgePanelKind::MARK) + { + // 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者 + // 的大者)+ 行 ✕ + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。 + // ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。 + // 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder` + // 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实 + // 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。 + const bool hasEnum = [this, &lane]() { + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible && + !resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref)) + { + return true; + } + } + return false; + }(); + float columnsWidth = 0.0f; + for (int column = 0; column < 6; ++column) + { + const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; + if (omitted) + { + continue; + } + // 列宽还要容得下**表头的列标题**(如「时间间隔」),否则表头会被省略。 + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * m_scale; + columnsWidth += std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(column)]), + labelWidth); + columnsWidth += kFreeMarkerColumnGap * m_scale; + } + // 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。 + const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale; + const float markerWidth = kEdgePanelPad * 2.0f * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth + + rowCloseWidth; + width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth)); + } + + // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 + return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); + } + + gd::RectF PlotView::LaneBadgeRect(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return gd::RectF(); + } + const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; + const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, + lane.rect.Width, kLegendHeight * m_scale); + // 网格被滚动出可视区时没有徽标可点。 + const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); + const float visibleBottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); + if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) + { + return gd::RectF(); + } + + const float height = kLegendHeight * m_scale; + const float badgeHeight = kLaneBadgeHeight * m_scale; + return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, + kLaneBadgeWidth * m_scale, badgeHeight); + } + + PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const + { + EdgePanelLayout layout; + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size()) || + laneIndex >= static_cast(m_lanes.size())) + { + return layout; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool meanFilter = (kind == EdgePanelKind::MEAN); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + const bool freeMarker = (kind == EdgePanelKind::MARK); + // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 + const bool itemized = (centerAxis || meanFilter || weakPeak); + const bool hasButton = (meanFilter || weakPeak); + const bool enabled = meanFilter ? lane.meanFilter + : (weakPeak ? lane.weakPeak + : (freeMarker ? lane.freeMarker + : (centerAxis ? lane.edgeCenter + : lane.edgeCount))); + const bool open = meanFilter ? lane.meanFilterPanel.open + : (weakPeak ? lane.weakPeakPanel.open + : (freeMarker ? lane.freeMarkerPanel.open + : (centerAxis ? lane.edgeCenterPanel.open + : lane.edgePanel.open))); + if (!enabled || !open) + { + return layout; + } + // 面板的**折起**状态(只显示标题栏)。折起时仍然算宽度与位置,但内容区全部省略。 + const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed + : (weakPeak ? lane.weakPeakPanel.collapsed + : (freeMarker ? lane.freeMarkerPanel.collapsed + : (centerAxis + ? lane.edgeCenterPanel.collapsed + : lane.edgePanel.collapsed))); + layout.collapsed = collapsed; + + int rowCount = 0; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + ++rowCount; + } + } + if (rowCount <= 0) + { + return layout; + } + layout.rows = rowCount; + + const float width = EdgePanelWidth(laneIndex, kind); + const float titleHeight = kEdgePanelTitleHeight * m_scale; + const float rowHeight = kEdgePanelRowHeight * m_scale; + const float pad = kEdgePanelPad * m_scale; + // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布, + // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 + const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; + const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + + // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) + // + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化): + // * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果); + // * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。 + const bool itemHasParams = (centerAxis || meanFilter || weakPeak); + const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; + float meanRowHeight = 0.0f; + float itemsHeight = 0.0f; + float height = 0.0f; + if (itemized) + { + meanRowHeight = kEdgePanelMeanRowHeight * m_scale; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + itemsHeight += meanRowHeight; + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (weakPeak ? lane.WeakPeakEnabled(ref) + : lane.EdgeCenterEnabled(ref)); + if (checked && itemHasParams) + { + if (weakPeak) + { + itemsHeight += weakPeakSliderRow * 2.0f; + } + else + { + itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + itemsHeight += kEdgePanelMeanRowHeight * m_scale; + } + } + } + } + // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, + // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 + height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); + } + else + { + height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad; + } + + // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), + // 只列出确实有固定点的信号;没有任何固定点时给一行灰色提示。 + // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 + bool markerAny = false; + for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) + { + if (item.ref.IsValid()) + { + markerAny = true; + break; + } + } + if (freeMarker) + { + const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; + const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + // 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于「自由标记点选取」按钮)。 + const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; + // 「显示增量 / 显示枚举文本」两个开关按钮独占一行,位于「自由标记点选取」下方 + //(用户 2026-10-23 要求)。 + const float switchRow = kEdgePanelButtonHeight * m_scale; + const float buttonGap = kFreeMarkerButtonGap * m_scale; + const float footer = sliderRow + markerButtonHeight + buttonGap + switchRow; + float content = 0.0f; + std::vector order; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + // ⚠️ 用户明确要求:某个信号没有任何固定点时,它的分组栏**不出现**在面板上。 + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + content += groupRow; + if (!lane.FreeMarkerGroupCollapsed(ref)) + { + // 展开的分组:分组栏 + **表头行** + 条目。 + content += headerRow + static_cast(order.size()) * itemRow; + } + } + if (!markerAny) + { + // 还没有任何固定点:留一行提示文案(绘制时画「在网格内单击以固定数据点」)。 + content += groupRow; + } + // 内容区高度上限:太长的列表不能把面板顶出绘图区(超出部分靠滚动条翻页)。 + const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + footer); + const float maxContent = + std::max(2.0f * itemRow, + std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(2.0f * itemRow, budget))); + const float view = std::min(content, maxContent); + height = titleHeight + pad + view + pad + footer + pad; + } + + // 折起的面板:只剩标题栏(内容区完全不占高度)。 + if (collapsed) + { + height = titleHeight; + } + + // 首次使用(位置为负)时自动放到绘图区**右上角**,并按网格在**当前可见网格中的序数** + // 依次错开,避免完全重叠。 + // ⚠️ 旧实现用 `laneIndex` 错开 —— 上百个网格时 `laneIndex` 很大, + // `16 + laneIndex*28` 早就超过绘图区高度、被下面的夹取推到**右下角** + //(用户 2026-10-17 报告:“在当前范围内的网格开启实验性功能,面板首次出现在右下角”)。 + // 改用**可见序数**:它的量级只有一屏的网格数(约 20),再夹到“面板上沿不超过绘图区 + // 中间偏下”以内,于是面板始终停**右上区域**,同时保留“依次错开”的观感。 + const double storedX = meanFilter ? lane.meanFilterPanel.x + : (weakPeak ? lane.weakPeakPanel.x + : (freeMarker ? lane.freeMarkerPanel.x + : (centerAxis ? lane.edgeCenterPanel.x + : lane.edgePanel.x))); + const double storedY = meanFilter ? lane.meanFilterPanel.y + : (weakPeak ? lane.weakPeakPanel.y + : (freeMarker ? lane.freeMarkerPanel.y + : (centerAxis ? lane.edgeCenterPanel.y + : lane.edgePanel.y))); + int openPanelOrdinal = 0; + { + // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: + // * 第一个打开的面板 ⇒ 0 ⇒ 正好贴在右上角(用户 2026-10-17 要求“应该在右上角”); + // * 后续面板依次下移 28 逻辑像素,避免与已打开的面板完全重叠; + // * 遍历顺序固定为(网格下标 → 面板种类)⇒ 每个面板的槽位稳定、与帧无关。 + // ⚠️ 不能用 `laneIndex`(上百个网格时会把面板推到右下角); + // ⚠️ 也不能只看 `*Panel.open`:该标志**默认就是 true**,必须同时看功能是否启用 + // (与 `EdgePanelLayoutFor` 的 `enabled && open` 判定一致)。 + const auto shown = [](const Lane& lane, EdgePanelKind panelKind) -> bool + { + switch (panelKind) + { + case EdgePanelKind::COUNT: + return lane.edgeCount && lane.edgePanel.open; + case EdgePanelKind::CENTER: + return lane.edgeCenter && lane.edgeCenterPanel.open; + case EdgePanelKind::MEAN: + return lane.meanFilter && lane.meanFilterPanel.open; + case EdgePanelKind::PEAK: + return lane.weakPeak && lane.weakPeakPanel.open; + case EdgePanelKind::MARK: + return lane.freeMarker && lane.freeMarkerPanel.open; + } + return false; + }; + static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK}; + int openSeen = 0; + bool found = false; + for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) + { + const Lane& tabLane = Tab().Lanes()[lane]; + for (const EdgePanelKind panelKind : kOrder) + { + if (!shown(tabLane, panelKind)) + { + continue; + } + if (lane == static_cast(laneIndex) && panelKind == kind) + { + found = true; + break; + } + ++openSeen; + } + } + openPanelOrdinal = found ? openSeen : 0; + } + // 错开量夹到“面板下沿不超过绘图区 45% 高度”以内 ⇒ 首现位置永远在右上区域。 + const float maxStagger = std::max(0.0f, m_plotRect.Height * 0.45f - height - 16.0f * m_scale); + const float stagger = + std::min(static_cast(openPanelOrdinal) * 28.0f * m_scale, maxStagger); + float left = (storedX >= 0.0) + ? static_cast(storedX) + : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); + float top = (storedY >= 0.0) ? static_cast(storedY) + : (m_plotRect.Y + 16.0f * m_scale + stagger); + + // 面板始终限制在绘图区内(用户明确要求"可以在右侧的整个绘制区域拖动")。 + left = std::max(m_plotRect.X + 2.0f * m_scale, + std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale)); + top = std::max(m_plotRect.Y + 2.0f * m_scale, + std::min(top, m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale)); + + layout.panel = gd::RectF(left, top, width, height); + // 只有标题栏可以拖拽;✕ 位于标题栏右端,命中时优先按“关闭按钮”处理。 + layout.title = gd::RectF(left, top, width, titleHeight); + const float closeSize = titleHeight; + layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); + // 标题栏左侧的「展开 / 折起」三角指示器(位于 GX 徽标左边)。 + // 折起后面板只剩标题栏,因此这个指示器是唯一的“重新展开”入口(双击标题栏亦可)。 + layout.disclosure = gd::RectF(left + pad, top + (titleHeight - kEdgePanelDisclosureWidth * m_scale) * 0.5f, + kEdgePanelDisclosureWidth * m_scale, + kEdgePanelDisclosureWidth * m_scale); + // 标题栏左侧的「GX」编号徽标:绘制、悬停提示、手型光标与“点击滚动到该网格” + // 全部以这个矩形为准(唯一几何来源,避免“画的”和“点的”漂移)。 + layout.badge = + gd::RectF(left + pad + kEdgePanelDisclosureWidth * m_scale, + top + (titleHeight - kLaneBadgeHeight * m_scale) * 0.5f, kLaneBadgeWidth * m_scale, + kLaneBadgeHeight * m_scale); + // 折起状态:到此为止(内容区、按钮、滚动条全部为空)。 + if (collapsed) + { + return layout; + } + if (itemized) + { + const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; + if (hasButton) + { + // 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距 + //(原先按钮行直接贴着下边框,视觉上与其它元素不协调); + // 面板高度已含这份 pad,因此这里只上移按钮行即可。 + layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + } + + // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); + // 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果); + // PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。 + const float checkSize = kEdgePanelMeanCheckSize * m_scale; + const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; + const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); + // 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。 + const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall); + const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall); + const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale; + const float weakPeakResetGap = kWeakPeakResetGap * m_scale; + const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale; + const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale; + const float weakPeakTrackWidth = + std::max(10.0f * m_scale, + (left + width - pad - weakPeakValueWidth - weakPeakResetGap - + weakPeakResetSize - 6.0f * m_scale) - + weakPeakTrackLeft); + float rowY = top + titleHeight + pad; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + + layout.rowRefs.push_back(ref); + layout.rowChecks.push_back(gd::RectF( + left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, + checkSize)); + const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) + : (weakPeak ? lane.WeakPeakEnabled(ref) + : lane.EdgeCenterEnabled(ref)); + if (weakPeak) + { + // 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。 + if (checked) + { + const float levelRowTop = rowY + meanRowHeight; + const float iterRowTop = levelRowTop + weakPeakSliderRow; + const gd::RectF levelTrack( + weakPeakTrackLeft, + levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, + weakPeakTrackWidth, weakPeakTrackHeight); + const gd::RectF iterTrack( + weakPeakTrackLeft, + iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f, + weakPeakTrackWidth, weakPeakTrackHeight); + layout.rowLevelTracks.push_back(levelTrack); + layout.rowLevelValues.push_back(gd::RectF( + levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop, + weakPeakValueWidth, weakPeakSliderRow)); + layout.rowIterTracks.push_back(iterTrack); + layout.rowIterValues.push_back(gd::RectF( + iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop, + weakPeakValueWidth, weakPeakSliderRow)); + // 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。 + const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale + + weakPeakValueWidth + weakPeakResetGap; + layout.rowLevelResets.push_back( + gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, + weakPeakResetSize, weakPeakResetSize)); + layout.rowIterResets.push_back( + gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f, + weakPeakResetSize, weakPeakResetSize)); + } + else + { + layout.rowLevelTracks.push_back(gd::RectF()); + layout.rowLevelValues.push_back(gd::RectF()); + layout.rowLevelResets.push_back(gd::RectF()); + layout.rowIterTracks.push_back(gd::RectF()); + layout.rowIterValues.push_back(gd::RectF()); + layout.rowIterResets.push_back(gd::RectF()); + } + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + + rowY += meanRowHeight; + if (checked) + { + rowY += weakPeakSliderRow * 2.0f; + } + continue; + } + if (checked && itemHasParams) + { + const float editTop = rowY + meanRowHeight; + layout.rowPoints.push_back( + gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); + layout.rowIterations.push_back( + gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); + if (centerAxis) + { + layout.rowStats.push_back( + gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f, + kEdgePanelMeanRowHeight * m_scale)); + } + else + { + layout.rowStats.push_back(gd::RectF()); + } + } + else + { + layout.rowPoints.push_back(gd::RectF()); + layout.rowIterations.push_back(gd::RectF()); + layout.rowStats.push_back(gd::RectF()); + } + + rowY += meanRowHeight; + if (checked && itemHasParams) + { + rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + if (centerAxis) + { + rowY += kEdgePanelMeanRowHeight * m_scale; + } + } + } + } + + if (freeMarker) + { + // 「自由标记」面板:**只列出确实有固定点的信号**,每个信号一个**分组栏** + //(信号色托底 + 左侧展开/收起三角 + 信号名 + 右侧「N 标记点」与 ✕), + // 下面逐行列出它的固定点(组内按时间升序 == 从上到下),行尾再带一个 ✕。 + // 内容区超高时右侧出现滚动条;底部按钮与「标记透明度」滑块**固定**不滚动。 + const float groupRow = kFreeMarkerNameRowHeight * m_scale; + const float headerRow = kFreeMarkerHeaderRowHeight * m_scale; + const float itemRow = kFreeMarkerItemRowHeight * m_scale; + const float closeSize = kFreeMarkerCloseSize * m_scale; + const float markerButtonHeight = kEdgePanelButtonHeight * m_scale; + // 「标记透明度」独立于「自由标记点选取」:**总是**显示在按钮上方。 + const float sliderRow = kFreeMarkerSliderRowHeight * m_scale; + const float scrollW = kFreeMarkerScrollWidth * m_scale; + // 「显示增量 / 显示枚举文本」两个开关按钮:独占底部最后一行(左右各半个宽度)。 + const float switchRow = kEdgePanelButtonHeight * m_scale; + const float buttonGap = kFreeMarkerButtonGap * m_scale; + + // 底部固定区(自上而下):标记透明度滑块 → 「自由标记点选取」→ 两个显示开关。 + { + const float switchTop = top + height - pad - switchRow; + const float halfWidth = + std::max(0.0f, (width - pad * 2.0f - buttonGap) * 0.5f); + layout.showDelta = gd::RectF(left + pad, switchTop, halfWidth, switchRow); + layout.showEnum = + gd::RectF(left + pad + halfWidth + buttonGap, switchTop, halfWidth, switchRow); + // 「自由标记点选取」切换按钮(整行宽度)。 + layout.button = gd::RectF(left + pad, switchTop - buttonGap - markerButtonHeight, + std::max(0.0f, width - pad * 2.0f), markerButtonHeight); + } + { + // 「标记透明度」行:与信号样式区的「透明度」行同一套排版 + //(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。 + // ⚠️ 无论是否启用「自由标记点选取」都显示(用户 2026-10-21 要求)。 + const float rowTop = layout.button.Y - sliderRow; + const float valueWidth = + std::max(36.0f * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, L"100%") + 6.0f * m_scale); + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerOpacityLabel) + 6.0f * m_scale; + const float trackLeft = left + pad + labelWidth; + const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; + const float trackHeight = kWeakPeakTrackHeight * m_scale; + layout.opacityTrack = + gd::RectF(trackLeft, rowTop + (sliderRow - trackHeight) * 0.5f, + std::max(20.0f * m_scale, trackRight - trackLeft), trackHeight); + layout.opacityValue = + gd::RectF(trackRight + 6.0f * m_scale, rowTop, valueWidth, sliderRow); + } + + // 内容区高度(面板高度算式里的 `view` 部分)与"内容总高"决定是否需要滚动条。 + const float viewHeight = std::max( + 0.0f, height - titleHeight - pad * 3.0f - markerButtonHeight - sliderRow - + buttonGap - switchRow); + float totalContent = 0.0f; + std::vector order; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + totalContent += groupRow; + if (!lane.FreeMarkerGroupCollapsed(ref)) + { + totalContent += headerRow + static_cast(order.size()) * itemRow; + } + } + if (!markerAny) + { + totalContent += groupRow; + } + const bool scrollable = totalContent > viewHeight + 0.5f; + const float maxScroll = std::max(0.0f, totalContent - viewHeight); + const float scroll = std::max(0.0f, std::min(lane.freeMarkerScroll, maxScroll)); + const float innerWidth = + std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); + layout.content = + gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight); + layout.markerContentHeight = totalContent; + if (scrollable) + { + layout.scrollTrack = + gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW, + viewHeight); + const float thumbHeight = + std::max(18.0f * m_scale, viewHeight * (viewHeight / totalContent)); + const float thumbTop = + layout.content.Y + + (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); + layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); + } + + // 六列(① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔) + // 从左往右排布:每一行的同类信息都从同一横坐标开始 ⇒ 纵向上严格对齐(用户要求)。 + // ⚠️ 整个面板里没有任何枚举文本时,③ 枚举列**整列缺省**(列宽记 0,后面的列左移)。 + // 这里只查"该信号是否带枚举表"(O(1)),**不**逐条采样(布局函数被高频调用)。 + bool hasEnum = false; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible && !resolved.data->enumText.Empty() && + lane.HasFreeMarkers(ref)) + { + hasEnum = true; + break; + } + } + layout.markerHasEnumColumn = hasEnum; + float columnX = left + pad; + for (int column = 0; column < 6; ++column) + { + const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum; + // ⚠️ 列宽取「缺省列宽」「实测内容宽度」与**表头列标题宽度**三者的大者: + // 面板里不再出现省略号(“时间间隔”四个字比缺省列宽 78 还宽)。 + const float columnWidth = + omitted ? 0.0f + : std::max(std::max(kFreeMarkerColumnWidths[column] * m_scale, + lane.freeMarkerColumnWidths[static_cast(column)]), + UiMeasureTextWidth(m_fonts->uiSmall, kFreeMarkerColumnLabels[column]) + + 2.0f * m_scale); + layout.markerColumns[column].left = columnX; + layout.markerColumns[column].width = columnWidth; + columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale); + } + + const float contentBottom = layout.content.Y + layout.content.Height; + const float contentTop = layout.content.Y; + float rowY = contentTop - scroll; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + // 该信号没有任何固定点 ⇒ 分组栏与数据行都**不出现**(用户明确要求)。 + SortedFreeMarkerIndices(lane, ref, order); + if (order.empty()) + { + continue; + } + EdgePanelLayout::MarkerGroup group; + group.ref = ref; + group.collapsed = lane.FreeMarkerGroupCollapsed(ref); + const float itemsHeight = + group.collapsed ? 0.0f : (headerRow + static_cast(order.size()) * itemRow); + const float groupHeight = groupRow + itemsHeight; + + // ---- 吸顶(sticky):分组栏与表头**各自**钉住,先后移出 ---- + // 规则(用户 2026-10-22): + // * 该分组还有条目没显示完全 ⇒ 分组栏钉在内容区顶部、表头钉在分组栏下方; + // * 到分组末尾时顺序是:**表头**先向上滚(被分组栏盖住)→ 最后一个条目滚到 + // 分组栏下 → **分组栏**最后移出内容区。 + // 实现(每个元素各自的“吸顶 + 推挤”夹取): + // y = max(自然位置, min(吸顶位置, 本组下边界 - 自身高度)) + // 夹取上限保证元素不会被推离自己的分组(这正是“推挤”效果)。 + float barTop = rowY; + float headerTop = rowY + groupRow; + if (!group.collapsed) + { + const float maxHeaderTop = rowY + groupHeight - headerRow; + headerTop = std::max(rowY + groupRow, std::min(contentTop + groupRow, maxHeaderTop)); + } + { + const float maxBarTop = rowY + groupHeight - groupRow; + barTop = std::max(rowY, std::min(contentTop, maxBarTop)); + } + group.pinned = (barTop > rowY + 0.5f); + group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); + group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, + barTop + (groupRow - 12.0f * m_scale) * 0.5f, + 12.0f * m_scale, 12.0f * m_scale); + group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, + barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); + float pinnedBottom = barTop + groupRow; + if (!group.collapsed) + { + group.tableHeader = gd::RectF(layout.content.X, headerTop, innerWidth, headerRow); + pinnedBottom = std::max(pinnedBottom, headerTop + headerRow); + } + // 被(吸顶的)分组栏 / 表头盖住的那一片区域:其下的条目既不可绘制也不可命中。 + const float pinnedTop = std::min(barTop, group.tableHeader.Width > 0.0f ? headerTop : barTop); + const auto coveredByPinned = [pinnedTop, pinnedBottom](const gd::RectF& rect) + { + return rect.Y < pinnedBottom - 0.01f && rect.Y + rect.Height > pinnedTop + 0.01f; + }; + if (group.header.Y + group.header.Height > contentTop && group.header.Y < contentBottom) + { + // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 + // 底部按钮 / 滑块的点击)。 + layout.markerGroups.push_back(group); + layout.rowRefs.push_back(ref); + layout.rowNameRects.push_back(group.header); + layout.rowNameCloses.push_back(group.close); + } + rowY += groupRow; + if (group.collapsed) + { + continue; + } + rowY += headerRow; + for (std::size_t orderPosition = 0; orderPosition < order.size(); ++orderPosition) + { + const std::size_t itemIndex = order[orderPosition]; + EdgePanelLayout::MarkerRow markerRow; + markerRow.itemIndex = itemIndex; + // 「序号」列:组内按时间升序从 1 开始(每组 = 一个信号)。 + markerRow.serial = orderPosition + 1; + markerRow.rect = gd::RectF(layout.content.X, rowY, innerWidth, itemRow); + markerRow.close = gd::RectF(layout.content.X + innerWidth - closeSize, + rowY + (itemRow - closeSize) * 0.5f, closeSize, + closeSize); + // 被吸顶的分组栏 / 表头完全盖住的条目既不绘制也不可命中。 + if (markerRow.rect.Y + markerRow.rect.Height > contentTop && + markerRow.rect.Y < contentBottom && !coveredByPinned(markerRow.rect)) + { + layout.markerRows.push_back(markerRow); + } + rowY += itemRow; + } + } + } + return layout; + } + + bool PlotView::FreeMarkerValue(const Lane& lane, const Lane::FreeMarkerItem& item, double& value, + double& enumValue) const + { + if (m_dataSet == nullptr) + { + return false; + } + const ResolvedSignal resolved = ResolveSignal(item.ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + return false; + } + // 固定点存的是**显示时刻**,因此要用 `SampleDisplayedSignalAt`(内部处理时间偏移与 + // 水平镜像)。散点模式下不插值,取最近采样点,与光标读数的规则一致。 + const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); + double sampled = 0.0; + if (!SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, item.time, + interpolate, sampled) || + !std::isfinite(sampled)) + { + return false; + } + (void)lane; + enumValue = sampled; // 枚举按**原值**查 + value = ApplyDrawTransform(*resolved.style, sampled); // 读数与画出来的波形一致 + return std::isfinite(value); + } + + bool PlotView::FreeMarkerDelta(const Lane& lane, std::size_t itemIndex, double value, bool& hasPrev, + double& delta, double& timeDelta) const + { + hasPrev = false; + delta = 0.0; + timeDelta = 0.0; + if (itemIndex >= lane.freeMarkerItems.size()) + { + return false; + } + const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; + std::vector order; + SortedFreeMarkerIndices(lane, item.ref, order); + // 找到自己在**排序后序列**里的位置;前一个即「左边最近的那个固定点」。 + std::size_t position = order.size(); + for (std::size_t index = 0; index < order.size(); ++index) + { + if (order[index] == itemIndex) + { + position = index; + break; + } + } + if (position == order.size() || position == 0) + { + return true; // 最左边(或找不到):没有增量 + } + const Lane::FreeMarkerItem& previous = lane.freeMarkerItems[order[position - 1]]; + double previousValue = 0.0; + double previousEnum = 0.0; + if (!FreeMarkerValue(lane, previous, previousValue, previousEnum)) + { + return true; // 前一个点没有有效取值:不显示增量(避免编造一个数) + } + hasPrev = true; + delta = value - previousValue; + timeDelta = item.time - previous.time; + return true; + } + + const std::vector& PlotView::FreeMarkerContents(int laneIndex) const + { + static const std::vector kNoEntries; + if (m_tab == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return kNoEntries; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (static_cast(laneIndex) >= m_freeMarkerContent.size()) + { + m_freeMarkerContent.resize(static_cast(laneIndex) + 1); + } + FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast(laneIndex)]; + + // 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。 + // ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、 + // 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。 + std::uint64_t key = 1469598103934665603ull; + const auto mix = [&key](std::uint64_t value) { + key ^= value; + key *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(static_cast(bits)); + }; + const auto mixDouble = [&mix](double value) { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + mix(static_cast(lane.freeMarkerItems.size())); + // ⚠️ 两个显示开关只影响胶囊文本,但同样属于“与视界无关的输入”,必须进键。 + mix(lane.freeMarkerShowDelta ? 1ull : 0ull); + mix(lane.freeMarkerShowEnum ? 1ull : 0ull); + for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems) + { + mix(static_cast(static_cast(item.ref.groupIndex))); + mix(static_cast(static_cast(item.ref.signalIndex))); + mixDouble(item.time); + const ResolvedSignal resolved = ResolveSignal(item.ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + mix(0ull); + continue; + } + mix(reinterpret_cast(resolved.data)); + mix(static_cast(resolved.data->values.size())); + mix(resolved.visible ? 1ull : 2ull); + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->gain); + mixFloat(resolved.style->offsetY); + mixDouble(resolved.style->offsetTime); + mix(static_cast(static_cast(resolved.style->mode))); + mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) | + (resolved.style->mirrorHorizontal ? 4ull : 0ull)); + // ⚠️ 枚举表的内容决定「枚举文本」列与胶囊里的 `= <文本>`(以及缺省时的 `N/A`): + // 旧实现只混了信号指针与采样数量 ⇒ 重新加载/替换枚举表后仍贴回旧文本 + //(离屏探针实测:加上枚举表后条目里仍是 `N/A`)。这里把表的规模与文本数组 + // 的地址一并入键 —— 只比较 `size()` 不足以区分“换成另一张同样长度的表”。 + mix(static_cast(resolved.data->enumText.values.size())); + mix(static_cast(resolved.data->enumText.texts.size())); + mix(static_cast(resolved.data->enumText.rangeMin.size())); + mix(static_cast( + reinterpret_cast(resolved.data->enumText.texts.data()))); + } + + if (cache.key != key) + { + cache.key = key; + cache.entries.clear(); + cache.entries.reserve(lane.freeMarkerItems.size()); + for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex) + { + const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; + FreeMarkerContentEntry entry; + entry.itemIndex = itemIndex; + double value = 0.0; + double enumValue = 0.0; + const bool valueOk = FreeMarkerValue(lane, item, value, enumValue); + bool hasPrev = false; + double delta = 0.0; + double timeDelta = 0.0; + if (valueOk) + { + FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta); + } + const ResolvedSignal resolved = ResolveSignal(item.ref); + const std::wstring* enumPtr = + (resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr; + entry.valid = valueOk; + entry.value = value; + entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u; + entry.capsuleText = FreeMarkerValueText(value, lane.freeMarkerShowDelta, hasPrev, delta, + lane.freeMarkerShowEnum, enumPtr); + entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable); + // ⚠️ 枚举列一旦存在(面板里有任一固定点带枚举表),**没有**枚举文本的点必须显示 + // `N/A` 而不是空着(用户 2026-10-23 要求);数值对不上任何枚举项也落在这一分支。 + entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring(kFreeMarkerNotAvailable); + entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta) + : std::wstring(kFreeMarkerNotAvailable); + entry.timeText = FreeMarkerTimeText(item.time); + entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta); + cache.entries.push_back(std::move(entry)); + } + } + return cache.entries; + } + + bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label, + gd::RectF& close, std::wstring& text) const + { + label = gd::RectF(); + close = gd::RectF(); + text.clear(); + if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || + laneIndex >= static_cast(m_tab->Lanes().size()) || + laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (itemIndex >= lane.freeMarkerItems.size()) + { + return false; + } + const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex]; + // 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。 + const std::vector& contents = FreeMarkerContents(laneIndex); + if (itemIndex >= contents.size() || !contents[itemIndex].valid) + { + return false; + } + const FreeMarkerContentEntry& entry = contents[itemIndex]; + const double value = entry.value; + + // 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。 + const float rawX = TimeToX(item.time); + if (rawX < m_plotRect.X - 0.5f || rawX > m_plotRect.X + m_plotRect.Width + 0.5f) + { + return false; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + const float clipTop = std::max(visual.rect.Y, m_plotRect.Y); + const float clipBottom = + std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height); + const float rawY = ValueToY(value, visual); + if (!std::isfinite(rawY) || rawY < clipTop - 0.5f || rawY > clipBottom + 0.5f) + { + return false; // 纵向越界同样直接不显示 + } + + // 枚举文本按**采样原值**查找(与光标读数完全一致)。 + text = entry.capsuleText; + + // ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。 + // ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形 + // 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。 + const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale; + const float textHeight = 17.0f * m_scale; + const float closeSize = textHeight; + const float gap = kFreeMarkerCloseGap * m_scale; + const float totalWidth = textWidth + gap + closeSize; + + float left = rawX + 6.0f * m_scale; + if (left + totalWidth > m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) + { + left = rawX - 6.0f * m_scale - totalWidth; + } + left = std::max(m_plotRect.X + 2.0f * m_scale, + std::min(left, m_plotRect.X + m_plotRect.Width - totalWidth - 2.0f * m_scale)); + float top = rawY - textHeight * 0.5f; + top = std::max(clipTop + 1.0f * m_scale, std::min(top, clipBottom - textHeight - 1.0f * m_scale)); + + label = gd::RectF(left, top, textWidth, textHeight); + close = gd::RectF(left + textWidth + gap, top, closeSize, closeSize); + return true; + } + + PlotView::FreeMarkerProbe PlotView::ProbeFreeMarker(int mouseX, int mouseY) const + { + FreeMarkerProbe probe; + if (m_tab == nullptr || m_dataSet == nullptr || + !UiPointInRect(m_plotRect, static_cast(mouseX), static_cast(mouseY))) + { + return probe; + } + const int laneIndex = LaneAtY(mouseY); + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size()) || + laneIndex >= static_cast(m_tab->Lanes().size())) + { + return probe; + } + const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (!lane.freeMarker) + { + return probe; + } + // ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 明确要求): + // 不预览数据点、不画纵向光标、按下/拖动完全走常规语义(平移 / 框选 / 拖信号), + // 也不会固定任何数据点。所有自由标记的探测都从这一句的“早退”开始。 + if (!lane.freeMarkerCursor) + { + return probe; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + + // 「自由标记点选取」已开启:网格内始终跟随鼠标画纵向光标(见 `PaintFreeMarker`)。 + const bool cursorMode = true; + probe.cursorMode = cursorMode; + probe.laneIndex = laneIndex; + probe.time = XToTime(static_cast(mouseX)); + probe.x = static_cast(mouseX); + + // 命中容差 = **与"常规状态下触发信号曲线高亮"完全一致**(`kSignalHitTolerance`), + // 且判定的是「鼠标到**画出来的折线**的垂直距离」,而不是"同一横坐标处的采样值之差"。 + // ⚠️ 旧实现只看纵向差值 ⇒ 阶跃的**竖直跳变段**永远判不中:那一列上下两个采样值 + // 都不等于鼠标所在的高度(用户 2026-10-22 报告“在跳变纵线上完全点不中、没有吸附提示”)。 + const float fx = static_cast(mouseX); + const float fy = static_cast(mouseY); + const float tolerance = kSignalHitTolerance * m_scale; + + // 把“显示时刻”吸附到最近的**采样时刻**(返回显示时刻空间的值)。 + // 与 `SnapTimeToSample` 的区别:这里**不做稀疏判定**,任何信号都可以吸附, + // 因为固定点本来就应当落在真实数据点上(阶跃端点必须能选中)。 + const auto snapToNearestSample = [](const TimeAxis& axis, const SignalData& data, + const SignalStyle& style, double displayTime, + double& out) -> bool + { + const std::size_t count = std::min(axis.time.size(), data.values.size()); + if (count == 0) + { + return false; + } + const double target = displayTime - style.offsetTime; + if (axis.monotonic) + { + const auto lower = + std::lower_bound(axis.time.begin(), + axis.time.begin() + static_cast(count), target); + std::size_t index = static_cast(lower - axis.time.begin()); + if (index >= count) + { + index = count - 1; + } + if (index > 0 && + std::fabs(axis.time[index - 1] - target) <= std::fabs(axis.time[index] - target)) + { + --index; + } + out = axis.time[index] + style.offsetTime; + return true; + } + const int nearest = FindNearestSample(axis, target); + if (nearest < 0 || static_cast(nearest) >= count) + { + return false; + } + out = axis.time[static_cast(nearest)] + style.offsetTime; + return true; + }; + + // 在网格内所有可见信号中找**离鼠标最近**的那个数据点。 + double bestDistance = std::numeric_limits::infinity(); + double bestTime = probe.time; + double bestValue = 0.0; + double bestEnum = 0.0; + float bestY = 0.0f; + bool bestSnapped = false; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + // 「自动点线」判据成立时吸附到最近采样点(与光标吸附同一规则)。 + // ⚠️ 用户明确要求:只要判据成立就吸附,**不**管当前是散点还是用户自己改过的样式。 + const bool sparse = MinorCellSparse(*resolved.axis, *resolved.data, resolved.style->offsetTime); + + // ---- ① 取“鼠标到这条曲线”的距离(复用绘制阶段的折线顶点)---- + float nearestX = fx; + double distance = std::numeric_limits::infinity(); + const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, visual); + if (cached != nullptr && cached->points.size() >= 2) + { + const std::vector& points = cached->points; + const std::vector& breaks = cached->breaks; + // 横向先行裁剪(点到线段的距离必然 ≥ 横向距离):时间单调时二分定位。 + std::size_t windowFirst = 0; + std::size_t windowLast = points.size(); + if (resolved.axis->monotonic) + { + const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; }; + windowFirst = static_cast( + std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - + points.begin()); + windowLast = static_cast( + std::upper_bound(points.begin(), points.end(), fx + tolerance, + [](float limit, const gd::PointF& point) + { return limit < point.X; }) - + points.begin()); + } + if (resolved.style->mode == DrawMode::SCATTER) + { + // 散点:直接比较采样点本身(不把连线当成可命中目标)。 + const std::size_t begin = (windowFirst > 0) ? (windowFirst - 1) : 0; + const std::size_t end = std::min(points.size(), windowLast + 1); + for (std::size_t index = begin; index < end; ++index) + { + const float dx = points[index].X - fx; + const float dy = points[index].Y - fy; + const double probeDistance = std::sqrt(static_cast(dx * dx + dy * dy)); + if (probeDistance < distance) + { + distance = probeDistance; + nearestX = points[index].X; + } + } + } + else + { + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + const std::size_t scanBegin = std::max(start + 1, windowFirst); + // `windowLast + 1`:包含“一端在窗口内、另一端在窗口外”的跨窗口线段 —— + // 阶跃的竖直段就是这种线段(上端与下端相隔整个跳变幅度)。 + const std::size_t scanEnd = std::min(stop, windowLast + 1); + for (std::size_t index = scanBegin; index < scanEnd; ++index) + { + const gd::PointF& a = points[index - 1]; + const gd::PointF& b = points[index]; + const float vx = b.X - a.X; + const float vy = b.Y - a.Y; + const float wx = fx - a.X; + const float wy = fy - a.Y; + const float lengthSquared = vx * vx + vy * vy; + float t = 0.0f; + if (lengthSquared > 1.0e-6f) + { + t = (wx * vx + wy * vy) / lengthSquared; + t = std::max(0.0f, std::min(1.0f, t)); + } + const float dx = wx - t * vx; + const float dy = wy - t * vy; + const double segmentDistance = + std::sqrt(static_cast(dx * dx + dy * dy)); + if (segmentDistance < distance) + { + distance = segmentDistance; + nearestX = a.X + t * vx; + } + } + } + } + } + + // ---- ② 决定标注时刻 ---- + // ⚠️⚠️ 用户 2026-10-27 明确要求:**总是**预览「网格内离鼠标较近的那个信号」的数据点 + //(同一时刻下 A 信号比 B 信号离鼠标更近 ⇒ 预览 A、单击固定的也是 A), + // **不要求鼠标贴近曲线**。 + // 旧实现在两种距离都超出容差时直接 `continue`,于是「网格里只有一条信号、鼠标离曲线 + // 较远」时既没有预览点,也无法用左键固定(用户报告的核心问题)。 + // + // 因此这里准备**两个候选**,取更近的那一个: + // * 曲线候选 = 鼠标到**画出来的折线**的垂直距离(阶跃的竖直跳变段只有它能命中); + // * 采样候选 = 同一时刻(鼠标时刻吸附到最近采样)该采样点在屏幕上的**纵向距离**。 + // 两者都不再设“必须命中”的门槛;门槛(`tolerance`)只用来决定是否画「已吸附」圆环。 + const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); + double time = 0.0; + double sampled = 0.0; + bool candidateOk = false; + // (a) 曲线候选 + { + double candidateTime = XToTime(nearestX); + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, + candidateTime)) + { + double candidateSample = 0.0; + if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, + candidateTime, interpolate, candidateSample) && + std::isfinite(candidateSample)) + { + time = candidateTime; + sampled = candidateSample; + candidateOk = true; + } + } + } + // (b) 采样候选:同一时刻的纵向距离(“哪个信号离鼠标更近”的主判据) + { + double candidateTime = probe.time; + if (snapToNearestSample(*resolved.axis, *resolved.data, *resolved.style, candidateTime, + candidateTime)) + { + double candidateSample = 0.0; + if (SampleDisplayedSignalAt(*resolved.axis, *resolved.data, *resolved.style, + candidateTime, interpolate, candidateSample) && + std::isfinite(candidateSample)) + { + const double candidateValue = + ApplyDrawTransform(*resolved.style, candidateSample); + if (std::isfinite(candidateValue)) + { + const double vertical = std::fabs( + static_cast(ValueToY(candidateValue, visual)) - + static_cast(fy)); + if (!candidateOk || vertical < distance) + { + distance = vertical; + nearestX = fx; + time = candidateTime; + sampled = candidateSample; + candidateOk = true; + } + } + } + } + } + if (!candidateOk) + { + continue; + } + // 固定点必须落在真实采样时刻上(两个候选都已吸附到采样点)。 + // 表示“已吸附”的圆环仍只在「自动点线」判据成立(`sparse`)时画: + // 密采样信号里吸附量远小于 1 像素,圆环会一直挂着,反而像是画错了。 + const bool snapped = sparse; + const double value = ApplyDrawTransform(*resolved.style, sampled); + if (!std::isfinite(value)) + { + continue; + } + const float y = ValueToY(value, visual); + if (distance >= bestDistance) + { + continue; + } + bestDistance = distance; + bestTime = time; + bestValue = value; + bestEnum = sampled; + bestY = y; + bestSnapped = snapped; + probe.ref = ref; + } + + if (!probe.ref.IsValid()) + { + // 网格内没有可标注的数据点:「自由标记点选取」模式下仍要画纵向线(`valid` 为真)。 + probe.valid = true; + return probe; + } + probe.valid = true; + probe.time = bestTime; + probe.x = TimeToX(bestTime); + probe.value = bestValue; + probe.enumValue = bestEnum; + probe.y = bestY; + probe.snapped = bestSnapped; + return probe; + } + +} // namespace jdi diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp new file mode 100644 index 0000000..9693e8d --- /dev/null +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -0,0 +1,1977 @@ +// PlotView 实现分片 —— 光栅化像素缓存与每帧预算("怎么画才不卡")。 +// +// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 +// 职责: +// * 信号光晕的离屏位图层(整条曲线一次性渲染 + 每帧贴图); +// * 静态场景缓存(底色 + 网格 + 时间轴)的建立 / 抓取 / 贴回 / 作废; +// * 装饰层(带真 alpha 的独立图层,AlphaBlend 贴回); +// * 每网格"宽基底"图层:分配 / 内容指纹 / 就绪判定 / 并行重建 / 区域重画 / 滚动补条 / 贴图 / 预热预算。 +// +// ⚠️ 与绘制(`PlotViewPaint.cpp`)、指纹(`PlotViewCacheKey.cpp`)的分工: +// * 本文件**只管像素与预算**,不做业务判据; +// * 内容指纹由 `PlotViewCacheKey.cpp` 提供(`LaneLayerKey/LaneLayerHash` 里用到的哈希函数在那里); +// * 共用的常量与工具函数在 `PlotViewInternal.hpp`(命名空间 `pvdetail`,全部 `inline`),因此跨分片安全。 + +#include "PlotViewInternal.hpp" + +namespace jdi +{ + void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const PolylineCacheEntry& cached) + { + if (resolved.style == nullptr) + { + return; + } + // ------------------------------------------------------------------ + // 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。 + // + // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 + // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, + // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。 + // ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是 + // `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。 + // 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于 + // 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化, + // 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色" + //(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。 + // ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。 + // 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 + // ------------------------------------------------------------------ + if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty()) + { + // 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。 + const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f; + const int savedSmoothing = g.GetSmoothingMode(); + g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益 + gd::GraphicsPath bandPath; + bandPath.SetFillMode(gd::FillModeWinding); + gd::PointF quad[4]; + for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k) + { + const std::size_t start = cached.bandBreaks[k]; + const std::size_t stop = + (k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size(); + const std::size_t total = stop - start; + if (total < 4 || (total % 2) != 0) + { + continue; + } + // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此 + // 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。 + // + // ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的 + // 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处 + // 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到 + // "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。 + // ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带, + // 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath` + // 内,重叠区域只混合一次(不会叠出更深的竖条)。 + const std::size_t half = total / 2; + for (std::size_t column = 0; column + 1 < half; ++column) + { + const gd::PointF& topA = cached.band[start + column]; + const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)]; + const gd::PointF& topB = cached.band[start + column + 1]; + const gd::PointF& bottomB = + cached.band[start + half + (half - 1 - (column + 1))]; + const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y)); + const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y)); + quad[0] = gd::PointF(topA.X - grow, highY - grow); + quad[1] = gd::PointF(topB.X + grow, highY - grow); + quad[2] = gd::PointF(topB.X + grow, lowY + grow); + quad[3] = gd::PointF(topA.X - grow, lowY + grow); + bandPath.AddPolygon(quad, 4); + } + } + if (bandPath.GetPointCount() > 0) + { + // alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。 + gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); + g.FillPath(&brush, &bandPath); + } + g.SetSmoothingMode(static_cast(savedSmoothing)); + return; + } + // 其余情形一律用折线描边(与"之前"的画法一致)。 + PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks); + } + + void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, const PolylineCacheEntry& cached) + { + if (resolved.style == nullptr) + { + return; + } + + // 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。 + const float pad = (resolved.style->lineWidth + 8.0f) * m_scale; + const float left = std::max(lane.rect.X, m_plotRect.X) - pad; + const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad; + const float right = + std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad; + const float bottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad; + if (!(right > left + 1.0f) || !(bottom > top + 1.0f)) + { + return; + } + const int targetX = static_cast(std::floor(left)); + const int targetY = static_cast(std::floor(top)); + const int width = static_cast(std::ceil(right)) - targetX; + const int height = static_cast(std::ceil(bottom)) - targetY; + if (width <= 0 || height <= 0) + { + return; + } + + HaloLayerCache& cache = m_haloLayer; + // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 + // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 + const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && + static_cast(cache.bitmap->GetHeight()) == height; + const bool sameKey = + cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group && + cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin && + cache.viewMax == cached.viewMax && cache.plotX == cached.plotX && + cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight && + cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax && + cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode && + cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute && + cache.mirrorVertical == resolved.style->mirrorVertical && + cache.mirrorHorizontal == resolved.style->mirrorHorizontal && + cache.offsetY == resolved.style->offsetY && + cache.offsetTime == resolved.style->offsetTime; + + if (!sameKey) + { + if (!sameSize) + { + cache.bitmap = std::make_unique(width, height, PixelFormat32bppARGB); + } + { + gd::Graphics layerGraphics(cache.bitmap.get()); + layerGraphics.Clear(gd::Color(0, 0, 0, 0)); + layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); + layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + // 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。 + layerGraphics.TranslateTransform(static_cast(-targetX), + static_cast(-targetY)); + if (cached.points.size() > kHaloNoAaMinPoints) + { + // 顶点很多的区间(例如深放大后的方波:每段都跨满网格高): + // 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍), + // 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。 + layerGraphics.SetSmoothingMode(gd::SmoothingModeNone); + } + PaintSignalHaloFor(layerGraphics, resolved, lane, cached); + } + cache.ref = cached.ref; + cache.group = cached.group; + cache.sampleCount = cached.sampleCount; + cache.viewMin = cached.viewMin; + cache.viewMax = cached.viewMax; + cache.plotX = cached.plotX; + cache.plotWidth = cached.plotWidth; + cache.laneHeight = cached.laneHeight; + cache.laneYMin = cached.laneYMin; + cache.laneYMax = cached.laneYMax; + cache.lineWidth = cached.lineWidth; + cache.mode = resolved.style->mode; + cache.gain = resolved.style->gain; + cache.absolute = resolved.style->absolute; + cache.mirrorVertical = resolved.style->mirrorVertical; + cache.mirrorHorizontal = resolved.style->mirrorHorizontal; + cache.offsetY = resolved.style->offsetY; + cache.offsetTime = resolved.style->offsetTime; + cache.originX = targetX; + cache.originY = targetY; + cache.valid = true; + if (!UploadHaloLayer(width, height)) + { + cache.valid = false; + return; + } + } + + // 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。 + if (cache.dc == nullptr) + { + return; + } + const HDC targetDc = g.GetHDC(); + if (targetDc != nullptr) + { + BLENDFUNCTION blend = {}; + blend.BlendOp = AC_SRC_OVER; + blend.SourceConstantAlpha = 0xFF; + blend.AlphaFormat = AC_SRC_ALPHA; + ::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height, + blend); + g.ReleaseHDC(targetDc); + } + } + + bool PlotView::UploadHaloLayer(int width, int height) + { + HaloLayerCache& cache = m_haloLayer; + if (!cache.bitmap) + { + return false; + } + if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height) + { + ReleaseHaloLayer(); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致) + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + HDC memoryDc = CreateCompatibleDC(nullptr); + if (memoryDc == nullptr) + { + return false; + } + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + if (dib == nullptr || bits == nullptr) + { + DeleteDC(memoryDc); + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + SelectObject(memoryDc, dib); + cache.dc = memoryDc; + cache.dib = dib; + cache.bits = bits; + cache.dibWidth = width; + cache.dibHeight = height; + } + + // 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。 + // ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法), + // **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积), + // 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。 + gd::Rect rect(0, 0, width, height); + gd::BitmapData data = {}; + if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) != + gd::Ok) + { + return false; + } + unsigned char* destination = static_cast(cache.bits); + for (int y = 0; y < height; ++y) + { + const unsigned char* source = + static_cast(data.Scan0) + static_cast(y) * data.Stride; + unsigned char* row = destination + static_cast(y) * width * 4u; + for (int x = 0; x < width; ++x) + { + const unsigned alpha = source[x * 4 + 3]; + if (alpha == 0) + { + row[x * 4 + 0] = 0; + row[x * 4 + 1] = 0; + row[x * 4 + 2] = 0; + row[x * 4 + 3] = 0; + continue; + } + if (alpha == 0xFF) + { + row[x * 4 + 0] = source[x * 4 + 0]; + row[x * 4 + 1] = source[x * 4 + 1]; + row[x * 4 + 2] = source[x * 4 + 2]; + row[x * 4 + 3] = 0xFF; + continue; + } + row[x * 4 + 0] = static_cast( + (static_cast(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 1] = static_cast( + (static_cast(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 2] = static_cast( + (static_cast(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16); + row[x * 4 + 3] = static_cast(alpha); + } + } + cache.bitmap->UnlockBits(&data); + return true; + } + + void PlotView::ReleaseHaloLayer() + { + HaloLayerCache& cache = m_haloLayer; + if (cache.dc != nullptr) + { + DeleteDC(cache.dc); + cache.dc = nullptr; + } + if (cache.dib != nullptr) + { + DeleteObject(cache.dib); + cache.dib = nullptr; + } + cache.bits = nullptr; + cache.dibWidth = 0; + cache.dibHeight = 0; + cache.valid = false; + } + + void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const std::vector& points, + const std::vector& breaks) + { + // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): + // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 + // 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。 + if (resolved.data != nullptr && resolved.data->finiteCount == 1) + { + gd::PointF dot; + if (SinglePointScreenPos(resolved, lane, dot)) + { + const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale; + gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale); + g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f); + } + return; + } + + const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); + if (!drawLine) + { + // ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求): + // 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。 + // 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比, + // 正好是“去掉数据点之间连线的部分”(用户原话)。 + // ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`), + // 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。 + if (points.empty() || resolved.style == nullptr) + { + return; + } + const float radius = static_cast( + ClampDouble(static_cast(resolved.style->lineWidth) * kScatterRadiusScale, + static_cast(kMinScatterRadius), static_cast(kMaxScatterRadius)) * + m_scale); + const float ringRadius = radius + 2.5f * m_scale; + const std::size_t scatterStride = + std::max(1, points.size() / kMaxScatterPoints); + // 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。 + gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale); + for (std::size_t index = 0; index < points.size(); index += scatterStride) + { + const gd::PointF& point = points[index]; + g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f, + ringRadius * 2.0f); + } + return; + } + if (breaks.empty()) + { + return; + } + + gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)), + (resolved.style->lineWidth + 4.0f) * m_scale); + halo.SetLineJoin(gd::LineJoinRound); + halo.SetStartCap(gd::LineCapRound); + halo.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); + } + } + + bool PlotView::EnsureSceneCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + // 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝, + // 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。 + if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height) + { + return true; + } + + ReleaseSceneCache(); + // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 + m_panelValid = false; + + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + m_sceneDc = CreateCompatibleDC(screen); + m_sceneBitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + + if (m_sceneDc == nullptr || m_sceneBitmap == nullptr) + { + ReleaseSceneCache(); + return false; + } + m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap); + m_sceneWidth = width; + m_sceneHeight = height; + return true; + } + + bool PlotView::CaptureSceneCache(gd::Graphics& g) + { + // 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制, + // 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。 + const int x = static_cast(std::floor(m_area.X)); + const int y = static_cast(std::floor(m_area.Y)); + const int right = static_cast(std::ceil(m_area.X + m_area.Width)); + const int bottom = static_cast(std::ceil(m_area.Y + m_area.Height)); + const int width = right - x; + const int height = bottom - y; + if (width <= 0 || height <= 0) + { + m_sceneValid = false; + return false; + } + + m_sceneX = x; + m_sceneY = y; + + if (!EnsureSceneCache(width, height)) + { + m_sceneValid = false; + return false; + } + + HDC source = g.GetHDC(); + if (source == nullptr) + { + m_sceneValid = false; + return false; + } + const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY); + g.ReleaseHDC(source); + + if (ok == FALSE) + { + m_sceneValid = false; + return false; + } + + m_sceneKey = MakeSceneCacheKey(); + m_sceneGeometryHash = SceneGeometryHash(); + CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = false; + m_sceneValid = true; + return true; + } + + bool PlotView::BlitSceneCache(gd::Graphics& g) + { + if (!m_sceneValid || m_sceneDc == nullptr) + { + return false; + } + + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0, + SRCCOPY); + g.ReleaseHDC(target); + return ok != FALSE; + } + + void PlotView::InvalidateSceneCache() + { + m_sceneValid = false; + m_sceneStyles.clear(); + m_sceneDerived.clear(); + m_sceneDegraded = false; + m_sceneGeometryHash = 0; + // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): + // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 + m_panelValid = false; + } + + void PlotView::ReleaseSceneCache() + { + m_sceneValid = false; + if (m_sceneDc != nullptr) + { + if (m_sceneOldBitmap != nullptr) + { + SelectObject(m_sceneDc, m_sceneOldBitmap); + m_sceneOldBitmap = nullptr; + } + DeleteDC(m_sceneDc); + m_sceneDc = nullptr; + } + if (m_sceneBitmap != nullptr) + { + DeleteObject(m_sceneBitmap); + m_sceneBitmap = nullptr; + } + m_sceneWidth = 0; + m_sceneHeight = 0; + } + + bool PlotView::EnsureDecorCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height) + { + return true; + } + ReleaseDecorCache(); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`): + // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上; + // 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。 + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; // 自上而下 + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, screen); + if (dib == nullptr || bits == nullptr) + { + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + HDC dc = CreateCompatibleDC(nullptr); + if (dc == nullptr) + { + DeleteObject(dib); + return false; + } + m_decorOldBitmap = SelectObject(dc, dib); + m_decorDc = dc; + m_decorBitmap = dib; + m_decorBits = bits; + m_decorW = width; + m_decorH = height; + return true; + } + + void PlotView::ReleaseDecorCache() + { + m_decorValid = false; + m_decorCapturedRects.clear(); + if (m_decorDc != nullptr) + { + if (m_decorOldBitmap != nullptr) + { + SelectObject(m_decorDc, m_decorOldBitmap); + m_decorOldBitmap = nullptr; + } + DeleteDC(m_decorDc); + m_decorDc = nullptr; + } + if (m_decorBitmap != nullptr) + { + DeleteObject(m_decorBitmap); + m_decorBitmap = nullptr; + } + m_decorBits = nullptr; + m_decorW = 0; + m_decorH = 0; + } + + void* PlotView::ResolveAlphaBlend() + { + // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现; + // 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。 + static void* cached = nullptr; + static bool resolved = false; + if (resolved) + { + return cached; + } + resolved = true; + HMODULE gdi = GetModuleHandleW(L"gdi32.dll"); + if (gdi != nullptr) + { + cached = reinterpret_cast(GetProcAddress(gdi, "GdiAlphaBlend")); + } + if (cached == nullptr) + { + HMODULE msimg = LoadLibraryW(L"msimg32.dll"); + if (msimg != nullptr) + { + cached = reinterpret_cast(GetProcAddress(msimg, "AlphaBlend")); + } + } + return cached; + } + + bool PlotView::RenderDecorCache() + { + m_decorValid = false; + if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) + { + return false; + } + // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, + // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 + const int width = static_cast(std::ceil(m_area.X + m_area.Width)); + const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); + if (!EnsureDecorCache(width, height)) + { + return false; + } + // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。 + std::memset(m_decorBits, 0, static_cast(width) * static_cast(height) * 4u); + + m_decorRects.clear(); + { + gd::Graphics g(m_decorDc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + const bool savedUse = m_useDecorCache; + m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支 + m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形) + const std::vector noTicks; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLane(g, static_cast(index), m_lanes[index], noTicks); + } + m_renderDecorOnly = false; + m_useDecorCache = savedUse; + } + + // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0 + // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。 + // 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身 + // 是画在不透明底板上的,这些像素本来就该不透明。 + for (const gd::RectF& rect : m_decorRects) + { + const int x0 = std::max(0, static_cast(std::floor(rect.X))); + const int y0 = std::max(0, static_cast(std::floor(rect.Y))); + const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); + const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); + for (int y = y0; y < y1; ++y) + { + unsigned char* row = static_cast(m_decorBits) + + (static_cast(y) * static_cast(width)) * 4u; + for (int x = x0; x < x1; ++x) + { + unsigned char* p = row + static_cast(x) * 4u; + if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0) + { + p[3] = 255; + } + } + } + } + + m_decorCapturedRects = m_decorRects; + m_decorValid = true; + return true; + } + + bool PlotView::BlitDecorCache(gd::Graphics& g) + { + if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) + { + return false; + } + void* proc = ResolveAlphaBlend(); + if (proc == nullptr) + { + return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃) + } + using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION); + AlphaBlendFn blend = reinterpret_cast(proc); + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。 + BLENDFUNCTION fn = {}; + fn.BlendOp = AC_SRC_OVER; + fn.SourceConstantAlpha = 255; + fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘) + bool any = false; + for (const gd::RectF& rect : m_decorCapturedRects) + { + const int x = static_cast(std::floor(rect.X)); + const int y = static_cast(std::floor(rect.Y)); + const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; + const int h = static_cast(std::ceil(rect.Y + rect.Height)) - y; + if (w <= 0 || h <= 0) + { + continue; + } + if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE) + { + any = true; + } + } + g.ReleaseHDC(target); + return any; + } + + bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (layer.dc != nullptr && layer.width == width && layer.height == height) + { + return true; + } + ReleaseLaneLayer(layer); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + layer.dc = CreateCompatibleDC(screen); + layer.bitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + if (layer.dc == nullptr || layer.bitmap == nullptr) + { + ReleaseLaneLayer(layer); + return false; + } + layer.oldBitmap = SelectObject(layer.dc, layer.bitmap); + layer.width = width; + layer.height = height; + layer.valid = false; + return true; + } + + void PlotView::ReleaseLaneLayer(LaneLayer& layer) + { + layer.valid = false; + if (layer.dc != nullptr) + { + if (layer.oldBitmap != nullptr) + { + SelectObject(layer.dc, layer.oldBitmap); + layer.oldBitmap = nullptr; + } + DeleteDC(layer.dc); + layer.dc = nullptr; + } + if (layer.bitmap != nullptr) + { + DeleteObject(layer.bitmap); + layer.bitmap = nullptr; + } + layer.width = 0; + layer.height = 0; + } + + void PlotView::ReleaseWideBuffer() + { + std::lock_guard lock(m_layerMutex); + for (LaneLayer& layer : m_layerFront) + { + ReleaseLaneLayer(layer); + } + m_layerFront.clear(); + m_layerRebuildPending.clear(); + } + + void PlotView::InvalidateWideBuffer() + { + std::lock_guard lock(m_layerMutex); + for (LaneLayer& layer : m_layerFront) + { + layer.valid = false; + } + m_layerRebuildPending.clear(); + } + + std::uint64_t PlotView::LaneLayerKey(int laneIndex) const + { + // 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容** + // ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。 + const double span = Tab().ViewMax() - Tab().ViewMin(); + std::uint64_t key = LaneLayerHash(laneIndex); + key ^= static_cast(laneIndex + 1) * 1315423911ull; + key *= 1099511628211ull; + key ^= static_cast(std::llround(span * 1.0e6)); + key *= 1099511628211ull; + key ^= static_cast(std::llround(m_plotRect.Width)); + return key; + } + + std::uint64_t PlotView::LaneLayerHash(int laneIndex) const + { + // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 + // 可见性与外观。 + // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** + // 本指纹里;改信号名不需要重建图层。 + // + // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` + // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), + // 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return hash; + } + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + mixFloat(visual.rect.Height); + mixFloat(static_cast(visual.yMax - visual.yMin)); + const std::vector& signals = Tab().Lanes()[static_cast(laneIndex)].signals; + mix(signals.size()); + for (const SignalRef& ref : signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + mix(static_cast(ref.groupIndex)); + mix(static_cast(ref.signalIndex)); + mix(resolved.visible ? 1ull : 0ull); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + mix(static_cast(resolved.style->color)); + mixFloat(resolved.style->opacity); + mixFloat(resolved.style->lineWidth); + mix(static_cast(resolved.style->mode)); + // 「调整」变换会改变图层里的波形,也必须让图层失效 + //(否则贴回的旧像素会与当前样式不一致)。 + mixFloat(resolved.style->gain); + mix(resolved.style->absolute ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); + } + return hash; + } + + int PlotView::LayerMarginX() const + { + const int width = static_cast(std::lround(m_plotRect.Width)); + return std::max(1, width / 2); // 左右各半屏 + } + + int PlotView::LayerMarginY(float laneHeight) const + { + // 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围, + // 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。 + const int half = static_cast(std::lround(static_cast(laneHeight) * 0.5)); + return std::max(1, half); + } + + double PlotView::LayerDefaultWideMin() const + { + // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 + const double span = Tab().ViewMax() - Tab().ViewMin(); + const int width = static_cast(std::lround(m_plotRect.Width)); + if (!(span > 0.0) || width <= 0) + { + return Tab().ViewMin(); + } + const double scale = static_cast(width) / span; + return Tab().ViewMin() - static_cast(LayerMarginX()) / scale; + } + + bool PlotView::LayersStructurallyReady() const + { + std::lock_guard lock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) + { + return false; + } + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int bitmapWidth = width + 2 * LayerMarginX(); + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + const LaneLayer& layer = m_layerFront[index]; + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) + { + return false; + } + // 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。 + if (std::fabs(span - layer.span) > 1.0e-12) + { + return false; + } + // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。 + const float laneHeight = m_lanes[index].rect.Height; + const int bitmapHeight = + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (layer.height != bitmapHeight) + { + return false; + } + } + return true; + } + + bool PlotView::LayersAllocated() const + { + std::lock_guard lock(m_layerMutex); + return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); + } + + bool PlotView::LaneLayerReady(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard lock(m_layerMutex); + if (laneIndex >= static_cast(m_layerFront.size())) + { + return false; + } + const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int bitmapWidth = width + 2 * LayerMarginX(); + const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; + const int bitmapHeight = static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || + layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) + { + return false; + } + return layer.key == LaneLayerKey(laneIndex); + } + + bool PlotView::LanesAllReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return true; + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex) const + { + return LaneVisuallyRelevant(laneIndex, 0); + } + + bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + if (!(m_plotRect.Height > 0.0f)) + { + return false; + } + const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; + // 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关); + // 预取带在其上再按屏高放大(见头文件说明)。 + const float margin = std::max(rect.Height, 0.0f) + + static_cast(std::max(0, prefetchScreens)) * m_plotRect.Height; + return (rect.Y + rect.Height + margin > m_plotRect.Y) && + (rect.Y - margin < m_plotRect.Y + m_plotRect.Height); + } + + bool PlotView::LanesVisiblyReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + bool anyRelevant = false; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneVisuallyRelevant(static_cast(index))) + { + continue; + } + anyRelevant = true; + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return anyRelevant; + } + + bool PlotView::AnyVisibleLanePending() const + { + // ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高, + // 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格 + // (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。 + for (const int laneIndex : m_layerRebuildPending) + { + if (LaneVisuallyRelevant(laneIndex, 1)) + { + return true; + } + } + return false; + } + + void PlotView::QueueLaneRebuild(int laneIndex) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return; + } + for (const int existing : m_layerRebuildPending) + { + if (existing == laneIndex) + { + return; // 已经在队列里 + } + } + m_layerRebuildPending.push_back(laneIndex); + } + + bool PlotView::RenderPendingLaneRebuilds(int budget) + { + if (m_layerRebuildPending.empty() || budget <= 0) + { + return !m_layerRebuildPending.empty(); + } + // ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个 + // 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态 + //(用户报告:缩放 / 折起后“需要等一会才正确加载”)。 + // 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。 + std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), + [this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); }); + const double defaultWideMin = LayerDefaultWideMin(); + // 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。 + // ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见, + // 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer` + // 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后 + // 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。 + m_layerRebuildPending.erase( + std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(), + [this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }), + m_layerRebuildPending.end()); + if (m_layerRebuildPending.empty()) + { + return false; + } + const int available = static_cast(m_layerRebuildPending.size()); + const int take = std::min(budget, available); + std::vector batch; + batch.reserve(static_cast(take)); + for (int index = 0; index < take; ++index) + { + const int laneIndex = m_layerRebuildPending[static_cast(index)]; + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) + { + batch.push_back(laneIndex); + } + } + m_layerRebuildPending.erase(m_layerRebuildPending.begin(), + m_layerRebuildPending.begin() + take); + + if (batch.size() > 1) + { + // **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。 + RebuildLaneLayersParallel(batch, defaultWideMin); + } + else + { + for (const int laneIndex : batch) + { + // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + break; + } + LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 + // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), + // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, + // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 + // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 + if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, + m_lanes[static_cast(laneIndex)].yMax)) + { + continue; + } + } + } + return !m_layerRebuildPending.empty(); + } + + void PlotView::EnsureLayerWorkers() + { + unsigned int hardware = std::thread::hardware_concurrency(); + const int want = std::max(1, std::min(static_cast(hardware == 0 ? 2u : hardware), 4)); + if (m_layerWorkerCount == want && static_cast(m_layerWorkers.size()) == want) + { + // 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。 + for (int index = 0; index < want; ++index) + { + m_layerWorkers[static_cast(index)]->Setup(m_fonts, m_theme); + m_layerWorkers[static_cast(index)]->SetScale(m_scale); + m_layerWorkers[static_cast(index)]->m_dataSet = m_dataSet; + m_layerWorkers[static_cast(index)]->m_dataSetWritable = m_dataSetWritable; + } + return; + } + ReleaseLayerWorkers(); + m_layerWorkers.reserve(static_cast(want)); + for (int index = 0; index < want; ++index) + { + auto worker = std::make_unique(); + worker->Setup(m_fonts, m_theme); + worker->SetScale(m_scale); + // ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本, + // 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。 + worker->m_dataSet = m_dataSet; + worker->m_dataSetWritable = m_dataSetWritable; + // ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`); + // 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。 + worker->m_tab = &worker->m_workerTab; + worker->RefreshLanes(); + m_layerWorkers.push_back(std::move(worker)); + } + m_layerWorkerCount = want; + } + + void PlotView::ReleaseLayerWorkers() + { + m_layerWorkers.clear(); + m_layerWorkerCount = 0; + } + + void PlotView::RebuildLaneLayersParallel(const std::vector& laneIndices, double wideMin) + { + if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + // 单网格 / 单核:直接顺序走既有路径(避免线程开销)。 + unsigned int hardware = std::thread::hardware_concurrency(); + if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1) + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + for (const int laneIndex : laneIndices) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast(laneIndex)], 0, 0, 0, + 0, wideMin, m_lanes[static_cast(laneIndex)].yMax); + } + return; + } + + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + } + EnsureLayerWorkers(); + if (m_layerWorkerCount <= 1) + { + return; // 保守回退:让调用方走顺序路径 + } + + // 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。 + const TabConfig tabSnapshot = *m_tab; + const gd::RectF plotRectSnapshot = m_plotRect; + const gd::RectF areaSnapshot = m_area; + const float scaleSnapshot = m_scale; + const std::vector laneSnapshot = m_lanes; + // 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。 + std::vector* const front = &m_layerFront; + + const int workerCount = m_layerWorkerCount; + std::atomic next{0}; + std::vector threads; + threads.reserve(static_cast(workerCount)); + for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex) + { + threads.emplace_back([&, workerIndex]() + { + PlotView* worker = m_layerWorkers[static_cast(workerIndex)].get(); + for (;;) + { + const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed); + if (slot >= laneIndices.size()) + { + break; + } + const int laneIndex = laneIndices[slot]; + if (laneIndex < 0 || laneIndex >= static_cast(laneSnapshot.size()) || + laneIndex >= static_cast(tabSnapshot.Lanes().size())) + { + continue; + } + // 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。 + worker->m_workerTab = tabSnapshot; + worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast(laneIndex)]}; + worker->m_tab = &worker->m_workerTab; + worker->m_fonts = m_fonts; + worker->m_theme = m_theme; + worker->m_scale = scaleSnapshot; + worker->m_dataSet = m_dataSet; + worker->m_dataSetWritable = m_dataSetWritable; + worker->m_area = areaSnapshot; + worker->m_plotRect = plotRectSnapshot; + worker->m_lanes = {laneSnapshot[static_cast(laneIndex)]}; + // ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染, + // 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除 + // "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。 + worker->RenderLaneLayerRegion(0, (*front)[static_cast(laneIndex)], 0, 0, 0, 0, + wideMin, + laneSnapshot[static_cast(laneIndex)].yMax); + } + }); + } + for (std::thread& thread : threads) + { + if (thread.joinable()) + { + thread.join(); + } + } + + // 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。 + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return; + } + std::uint64_t rendered = 0; + for (const int laneIndex : laneIndices) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_layerFront.size())) + { + continue; + } + LaneLayer& target = m_layerFront[static_cast(laneIndex)]; + if (!target.valid) + { + continue; + } + target.key = LaneLayerKey(laneIndex); + ++rendered; + } + m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed); + } + + bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, + int stripY, int stripH, double wideMin, double anchorYMax) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard renderLock(m_renderMutex); + + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + layer.valid = false; + return false; + } + + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏 + const int bitmapWidth = width + 2 * margin; + const float laneHeight = m_lanes[static_cast(laneIndex)].rect.Height; + const int lanePixelHeight = static_cast(std::ceil(laneHeight)); + const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高 + const int bitmapHeight = lanePixelHeight + 2 * marginY; + if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight)) + { + return false; + } + + const double scale = static_cast(width) / span; // 像素 / 时间 + const double laneSpan = m_lanes[static_cast(laneIndex)].yMax - + m_lanes[static_cast(laneIndex)].yMin; + const double pxPerValue = (laneSpan > 0.0) ? static_cast(laneHeight) / laneSpan : 0.0; + + if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth) + { + stripX = 0; + stripW = bitmapWidth; + } + // 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。 + if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight) + { + stripY = 0; + stripH = bitmapHeight; + } + + // 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。 + const gd::RectF savedArea = m_area; + const gd::RectF savedPlot = m_plotRect; + const double savedViewMin = Tab().ViewMin(); + const double savedViewMax = Tab().ViewMax(); + const std::vector savedLanes = m_lanes; + const float savedLaneContentHeight = m_laneContentHeight; + const bool savedFastFrame = m_fastFrame; + const bool savedRenderWide = m_renderWide; + const float savedScroll = Tab().LaneScroll(); + const double savedWideTickSpan = m_wideTickSpan; + const float savedWideTickHeight = m_wideTickHeight; + const double savedRenderTimeStep = m_renderTimeStep; + + m_plotRect = gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), + static_cast(bitmapHeight)); + // 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面; + // x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。 + m_area = gd::RectF(static_cast(stripX), static_cast(stripY), + static_cast(stripW), static_cast(stripH)); + // 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。 + // 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。 + const bool stripIsHorizontalBand = (static_cast(stripW) < static_cast(bitmapWidth) - 0.5f); + const bool savedStripActive = m_stripRenderActive; + const float savedStripX0 = m_stripX0; + const float savedStripX1 = m_stripX1; + m_stripRenderActive = stripIsHorizontalBand; + m_stripX0 = static_cast(stripX); + m_stripX1 = static_cast(stripX + stripW); + Tab().ViewMin() = wideMin; + Tab().ViewMax() = wideMin + static_cast(bitmapWidth) / scale; + + m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 + // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 + m_fastFrame = false; + // 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值): + // 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。 + m_wideTickSpan = laneSpan; + m_wideTickHeight = laneHeight; + // ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算 + // `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。 + // 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals + // = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。 + // 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。 + // ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"): + // - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致; + // - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax` + // 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。 + // 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。 + m_lanes[static_cast(laneIndex)].yMax = anchorYMax; + m_lanes[static_cast(laneIndex)].yMin = anchorYMax - laneSpan; + // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。 + // 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算** + //(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`), + // 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射 + // 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差, + // 让拖拽期间的像素残差明显变大)。 + if (pxPerValue > 0.0) + { + const double topExtend = static_cast(marginY) / pxPerValue; + const double bottomRows = + static_cast(bitmapHeight - marginY) - static_cast(laneHeight); + const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue; + m_lanes[static_cast(laneIndex)].yMin -= bottomExtend; + m_lanes[static_cast(laneIndex)].yMax += topExtend; + } + m_lanes[static_cast(laneIndex)].rect = + gd::RectF(0.0f, 0.0f, static_cast(bitmapWidth), static_cast(bitmapHeight)); + { + gd::Graphics g(layer.dc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + UiFillRect(g, m_area, m_theme->plotBg); + UiFillRect(g, m_area, m_theme->cardBg); + + // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 + // ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`): + // 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。 + const double fgSpan = savedViewMax - savedViewMin; + const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); + m_renderTimeStep = timeStep; + std::vector timeTicks; + BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); + PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); + m_renderTimeStep = 0.0; + } + m_fastFrame = savedFastFrame; + m_renderWide = savedRenderWide; + m_wideTickSpan = savedWideTickSpan; + m_wideTickHeight = savedWideTickHeight; + m_renderTimeStep = savedRenderTimeStep; + m_stripRenderActive = savedStripActive; + m_stripX0 = savedStripX0; + m_stripX1 = savedStripX1; + + // 恢复布局 / 视图 / 网格。 + // ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange` + // 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝, + // 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。 + m_area = savedArea; + m_plotRect = savedPlot; + Tab().ViewMin() = savedViewMin; + Tab().ViewMax() = savedViewMax; + m_lanes = savedLanes; + m_laneContentHeight = savedLaneContentHeight; + Tab().LaneScroll() = savedScroll; + + layer.wideMin = wideMin; + layer.span = span; + layer.marginY = marginY; + // 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定; + // 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。 + layer.anchorYMax = anchorYMax; + layer.pxPerValue = pxPerValue; + layer.key = LaneLayerKey(laneIndex); + layer.valid = true; + m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); + return true; + } + + bool PlotView::RebuildWideBuffer() + { + const float plotWidth = m_plotRect.Width; + const float areaHeight = m_area.Height; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty()) + { + InvalidateWideBuffer(); + return false; + } + + // 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`; + // 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。 + // ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。 + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + m_layerFront.resize(m_lanes.size()); + m_layerRebuildPending.clear(); + } + + // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 + const double wideMin = LayerDefaultWideMin(); + std::vector allLanes; + allLanes.reserve(m_lanes.size()); + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + allLanes.push_back(static_cast(index)); + } + // **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。 + RebuildLaneLayersParallel(allLanes, wideMin); + std::lock_guard layerLock(m_layerMutex); + bool ok = true; + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + if (!m_layerFront[index].valid) + { + ok = false; + } + } + return ok; + } + + bool PlotView::PrefetchStepAllowed() + { + const std::uint64_t now = GetTickCount64(); + if (now >= m_lastPrefetchRequestMs && + (now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs) + { + return false; + } + m_lastPrefetchRequestMs = now; + return true; + } + + void PlotView::EnsureLaneLayersPrefetched() + { + if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + if (!LayersAllocated()) + { + // 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。 + RebuildWideBuffer(); + return; + } + // ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画), + // 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。 + RollWideBuffer(); + } + + void PlotView::WarmLaneLayersStep() + { + if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr) + { + return; + } + // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 + // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 + if (m_animationBusy) + { + return; + } + if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) + { + return; + } + { + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() > m_lanes.size()) + { + // 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。 + for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index) + { + ReleaseLaneLayer(m_layerFront[index]); + } + } + if (m_layerFront.size() != m_lanes.size()) + { + m_layerFront.resize(m_lanes.size()); + } + } + // 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动, + // 也不会走"位移超出画布 → 立刻整块重画"那条分支。 + RollWideBuffer(); + RenderPendingLaneRebuilds(1); + // ⚠️ 是否继续请求下一帧: + // * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`); + // * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热, + // 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。 + const bool morePending = + AnyVisibleLanePending() || + (!m_layerRebuildPending.empty() && PrefetchStepAllowed()); + if (morePending) + { + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); + } + } + } + + void PlotView::RollWideBuffer() + { + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); + const int bitmapWidth = width + 2 * margin; + const double scale = static_cast(width) / span; + + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) + { + return; + } + // 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。 + int rollBudget = kLayerRollsPerFrame; + + // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), + // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, + // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, + // 表现为严重的绘制错误与波形抖动。 + // + // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 + //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 + // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + LaneLayer& layer = m_layerFront[index]; + const int laneIndex = static_cast(index); + + // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 + const float laneHeight = m_lanes[index].rect.Height; + const int bitmapHeight = + static_cast(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight); + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || + layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) + { + QueueLaneRebuild(laneIndex); + continue; + } + + // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 + const double desired = (Tab().ViewMin() - layer.wideMin) * scale; + const int shift = static_cast(std::llround(desired - static_cast(margin))); + // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。 + const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); + + // ⚠️⚠️ **不在可视区(含预取带)内的网格一律不做像素级滚动**: + // 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑 + // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 + // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), + // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 + // + // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 + // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms + // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 + // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 + if (!LaneVisuallyRelevant(laneIndex, 1)) + { + continue; + } + // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) + { + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + + // ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告: + // 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。 + // 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移, + // 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。 + // 因此这里分三种情形: + // * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费); + // * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开), + // 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**; + // * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画 + // (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。 + const int driftLimit = std::max(8, margin - 24); + const int vOffsetAbs = std::abs(vOffset); + if (vOffsetAbs >= layer.height) + { + // 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。 + layer.valid = false; + QueueLaneRebuild(laneIndex); + continue; + } + if (contentStale) + { + QueueLaneRebuild(laneIndex); + } + + // ------------------------------------------------------------------ + // 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行, + // 再只补画新暴露的那一条(整宽 × 行数)。 + // + // ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染, + // 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上, + // 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。 + // ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高 + // 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分 + // 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。 + // + // ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**, + // 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**, + // 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算 + // (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。 + // 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。 + // 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。 + // ------------------------------------------------------------------ + if (std::abs(vOffset) >= kLayerRollQuantum) + { + const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动) + int stripY = 0; + int stripH = 0; + if (roll > 0) + { + // 内容上移:new[y] = old[y + roll],底部暴露 roll 行。 + BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY); + stripY = layer.height - roll; + stripH = roll; + } + else + { + const int amount = -roll; + // 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。 + BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0, + SRCCOPY); + stripY = 0; + stripH = amount; + } + // 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。 + layer.anchorYMax -= static_cast(roll) / layer.pxPerValue; + // 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。 + RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin, + layer.anchorYMax); + } + + // ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明): + // `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移 + // **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要 + // 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。 + if (std::abs(shift) <= driftLimit) + { + continue; + } + // ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限 + // (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担, + // 因此可以安全地分帧完成。 + if (rollBudget <= 0) + { + continue; + } + --rollBudget; + const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2); + const int roll = std::max(-maxStep, std::min(maxStep, shift)); + + // 整数像素自贴图平移:内容整体移动 `roll` 像素。 + if (roll > 0) + { + // 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。 + BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY); + } + else if (roll < 0) + { + const int amount = -roll; + BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY); + } + else + { + continue; + } + layer.wideMin += static_cast(roll) / scale; + + // 只补画新暴露的细条(每帧代价恒为 O(细条))。 + // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" + // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 + // ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条 + // 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素 + // 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素 + // 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。 + const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1); + const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0; + RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin, + layer.anchorYMax); + } + } + + bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector& timeTicks) + { + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = LayerMarginX(); + const int bitmapWidth = width + 2 * margin; + const double scale = static_cast(width) / span; + + /** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */ + struct LayerBlit + { + HDC dc = nullptr; + int srcX = 0; + int srcY = 0; + int destX = 0; + int destY = 0; + int width = 0; + int height = 0; + }; + + std::vector blits; + std::vector backing; // 本帧拿不到(完整)图层像素的网格 + blits.reserve(m_lanes.size()); + backing.reserve(m_lanes.size()); + { + std::lock_guard lock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + return false; + } + for (std::size_t index = 0; index < m_layerFront.size(); ++index) + { + const LaneLayer& layer = m_layerFront[index]; + const gd::RectF& lane = m_lanes[index].rect; + const float top = std::max(lane.Y, m_plotRect.Y); + const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); + if (bottom <= top) + { + continue; // 网格整体移出可视区 + } + const int bandHeight = static_cast(std::lround(bottom - top)); + // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量", + // 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。 + const int expectBitmapHeight = + static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); + bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth && + layer.height == expectBitmapHeight && layer.span > 0.0 && + std::fabs(layer.span - span) <= 1.0e-12); + + LayerBlit blit; + if (usable) + { + // ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的 + // 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 —— + // 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。 + int vOffset = 0; + if (layer.pxPerValue > 0.0) + { + vOffset = static_cast( + std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue)); + } + int srcY = static_cast(std::lround(top - lane.Y)) + layer.marginY + vOffset; + int height = bandHeight; + int destTopOffset = 0; + // 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。 + if (srcY < 0) + { + destTopOffset = -srcY; + height -= destTopOffset; + srcY = 0; + } + if (srcY + height > layer.height) + { + height = layer.height - srcY; + } + // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 + // 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。 + const double subX = (Tab().ViewMin() - layer.wideMin) * scale; + int srcX = static_cast(std::lround(subX)); + int destOffset = 0; // 从视界左端起偏移多少像素开始贴 + int blitWidth = width; + if (srcX < 0) + { + destOffset = -srcX; + blitWidth -= destOffset; + srcX = 0; + } + if (srcX + blitWidth > bitmapWidth) + { + blitWidth = bitmapWidth - srcX; + } + if (height > 0 && blitWidth > 0) + { + blit.dc = layer.dc; + blit.srcX = srcX; + blit.srcY = srcY; + blit.destX = static_cast(std::lround(m_plotRect.X)) + destOffset; + blit.destY = static_cast(std::lround(top)) + destTopOffset; + blit.width = blitWidth; + blit.height = height; + blits.push_back(blit); + // 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。 + if (destOffset > 0 || blitWidth < width || height < bandHeight) + { + backing.push_back(static_cast(index)); + } + continue; + } + } + + // ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue` + //(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大 + // 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是 + // 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线): + // 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。 + backing.push_back(static_cast(index)); + } + } + + if (blits.empty()) + { + // ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘** + //(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。 + // 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数 + // 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的 + // **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千�万采样的巨型信号) + // 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。 + // 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补 + //(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。 + if (backing.empty()) + { + // 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。 + return false; + } + } + + // ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** —— + // `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。 + for (const int laneIndex : backing) + { + PaintLaneGridBacking(g, laneIndex, timeTicks); + } + + HDC target = g.GetHDC(); + if (target == nullptr) + { + return true; + } + for (const LayerBlit& blit : blits) + { + BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY, + SRCCOPY); + } + g.ReleaseHDC(target); + return true; + } + +} // namespace jdi diff --git a/Components/Shared/Inc/ColorPickerPopup.hpp b/Components/Shared/Inc/ColorPickerPopup.hpp index d3e7017..66976b2 100644 --- a/Components/Shared/Inc/ColorPickerPopup.hpp +++ b/Components/Shared/Inc/ColorPickerPopup.hpp @@ -15,6 +15,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 浮动式 RGB 自定义颜色拾取器。 * @@ -168,6 +170,21 @@ namespace jdi */ HCURSOR QueryCursor(int x, int y) const; + /** + * @brief 拾色器面板整体矩形(客户区坐标;未打开时为空矩形)。 + * + * 只读用途:宿主的「显示布局边界」需要把浮层区域也上报出来。 + */ + const gd::RectF& Rect() const { return m_rect; } + + /** + * @brief 收集拾色器与其内部控件的布局边界(调试用)。 + * + * 面板未打开时什么都不登记。 + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) const; + private: /** * @brief 面板内可进行内联文本输入的字段。 diff --git a/Components/Shared/Inc/PaintUtil.hpp b/Components/Shared/Inc/PaintUtil.hpp index 160feaf..c0b99b9 100644 --- a/Components/Shared/Inc/PaintUtil.hpp +++ b/Components/Shared/Inc/PaintUtil.hpp @@ -171,6 +171,59 @@ namespace jdi void UiTextClipped(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb, TextAlign align, const gd::RectF& clipRect); + /** + * @brief 文本几何收集器(调试用「显示布局边界」)。 + * + * 文本是本项目里**唯一**没有几何身份的绘制元素:`UiText` / `UiTextClipped` / + * `UiTextAtClipped` 每次都在调用点现算一个矩形、画完即弃,全仓有 60+ 处这样的调用点。 + * 与其在 60 处逐个补登记(必漏),不如在**绘制唯一出口**里回报一次。 + * + * ⚠️ 只在调试开关打开时才被安装(见 `UiSetTextRectSink`),平时为空指针、 + * 一次判空即返回,对正常绘制零影响。 + */ + class UiTextRectSink + { + public: + virtual ~UiTextRectSink() = default; + + /** + * @brief 回报一段文本**实际占用**的矩形(已按对齐方式与实测宽度收窄)。 + * @param [IN] rect 文本墨迹矩形(客户区坐标)。 + */ + virtual void OnTextRect(const gd::RectF& rect) = 0; + }; + + /** + * @brief 安装 / 卸载文本几何收集器(nullptr = 卸载)。 + * + * 单线程 UI 绘制 ⇒ 用文件级静态指针即可,不需要加锁。 + * + * @param [IN] sink 收集器(允许为空)。 + */ + void UiSetTextRectSink(UiTextRectSink* sink); + + /** + * @brief 取当前安装的文本几何收集器(`nullptr` = 未安装)。 + * + * 供"先画进离屏位图再贴回"的组件使用:它需要暂存自己的文本框, + * 并在贴图时按原点偏移回放给外层收集器。 + * + * @return 当前收集器(可能为空)。 + */ + UiTextRectSink* UiGetTextRectSink(); + + /** + * @brief 设置文本矩形的坐标偏移(默认 `(0, 0)`)。 + * + * 有些组件把内容先画进**离屏位图**再贴回(`SignalPanel` 的行位图缓存), + * 此时回报的矩形处在位图坐标系 —— 传一位图原点偏移即可换算回屏幕坐标, + * 调试层才能把框画在对的位置。 + * + * @param [IN] dx 横坐标偏移。 + * @param [IN] dy 纵坐标偏移。 + */ + void UiSetTextRectOffset(float dx, float dy); + /** * @brief 测量单行文本的像素宽度(无需 GDI+ 上下文,可在布局阶段调用)。 * @param [IN] font 字体句柄。 @@ -179,6 +232,14 @@ namespace jdi */ float UiMeasureTextWidth(HFONT font, const std::wstring& text); + /** + * @brief 测量单行文本的字符单元高度(垂直居中口径与 `UiText` 一致)。 + * @param [IN] font 字体句柄。 + * @param [IN] text 文本内容。 + * @return 字符单元高度。 + */ + float UiMeasureTextHeight(HFONT font, const std::wstring& text); + /** * @brief 在指定裁剪矩形内以显式起点绘制一段文本(使用 GDI ExtTextOut 裁剪)。 * diff --git a/Components/Shared/Inc/PopupMenu.hpp b/Components/Shared/Inc/PopupMenu.hpp index 16a7581..1720017 100644 --- a/Components/Shared/Inc/PopupMenu.hpp +++ b/Components/Shared/Inc/PopupMenu.hpp @@ -16,6 +16,8 @@ namespace jdi { + class UiDebugBoxList; + /** * @brief 弹出菜单的打开来源,用于把选择结果派发回正确的组件。 */ @@ -24,7 +26,8 @@ namespace jdi NONE = 0, /**< 无来源。 */ SIGNAL_PANEL = 1, /**< 信号面板。 */ TAB_BAR = 2, /**< 标签栏。 */ - PLOT_VIEW = 3 /**< 波形绘图区。 */ + PLOT_VIEW = 3, /**< 波形绘图区。 */ + APP_SETTINGS = 4 /**< 应用设置菜单(工具栏齿轮按钮):由宿主自己处理,不下发到组件。 */ }; /** @@ -223,6 +226,21 @@ namespace jdi */ bool ContainsPoint(int x, int y) const; + /** + * @brief 菜单整体矩形(客户区坐标;未打开时为空矩形)。 + * + * 只读用途:宿主的「显示布局边界」需要把浮层区域也上报出来。 + */ + const gd::RectF& Rect() const { return m_rect; } + + /** + * @brief 收集菜单与其内部结构的布局边界(调试用)。 + * + * 菜单未打开时什么都不登记。条目、滚动区、滚动条与二级子菜单各自成为独立的 box。 + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) const; + /** * @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。 * @return 启用滚动返回 true。 diff --git a/Components/Shared/Inc/PopupMenuInternal.hpp b/Components/Shared/Inc/PopupMenuInternal.hpp new file mode 100644 index 0000000..0c56dd5 --- /dev/null +++ b/Components/Shared/Inc/PopupMenuInternal.hpp @@ -0,0 +1,103 @@ +#ifndef __POPUP_MENU_INTERNAL_HPP__ +#define __POPUP_MENU_INTERNAL_HPP__ + +// PopupMenu 的内部共享定义(尺寸常量与绘制辅助函数)。 +// +// 实现按职责分片后(PopupMenu.cpp = 交互与生命周期 / PopupMenuLayout.cpp = 几何与排版 / +// PopupMenuPaint.cpp = 绘制),三片都要用到同一批尺寸常量与「还原」图标绘制, +// 因此统一放在这里:命名空间 `pmdetail`、全部 `inline` / `constexpr`,跨 TU 安全。 +// +// ⚠️ 这里的常量同时决定**排版与绘制**:改数值会同时影响两边(这是刻意的,避免两套真相)。 + +#include +#include +#include + +#include +#include +#include + +#include "Theme.hpp" + +namespace jdi +{ + + namespace pmdetail + { + constexpr float kRowHeight = 27.0f; /**< 普通项高度。 */ + constexpr float kHeaderHeight = 22.0f; /**< 分组标题高度。 */ + constexpr float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */ + constexpr float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */ + constexpr float kPadV = 5.0f; /**< 菜单上下内边距。 */ + constexpr float kPadH = 5.0f; /**< 菜单左右内边距。 */ + constexpr float kItemPad = 8.0f; /**< 条目内文本的左右内缩(所有条目统一)。 */ + constexpr float kMarkerColumn = 18.0f; /**< 条目**右侧**的选中标记列宽度(对勾画在这里)。 */ + constexpr float kMarkerGap = 4.0f; /**< 文本与右侧标记列之间的间隔。 */ + constexpr float kMinWidth = 148.0f; /**< 菜单最小宽度。 */ + constexpr float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */ + constexpr float kCorner = 6.0f; /**< 菜单圆角半径。 */ + constexpr float kSwatchSize = 22.0f; /**< 色块尺寸。 */ + constexpr float kSwatchGap = 5.0f; /**< 色块间距。 */ + constexpr float kEditFieldWidth = 88.0f; /**< EDIT 项输入框宽度。 */ + constexpr float kEditFieldGap = 6.0f; /**< 说明文字与输入框之间的最小间隔。 */ + constexpr float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */ + constexpr float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */ + constexpr float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */ + constexpr float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */ + constexpr float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */ + constexpr float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */ + constexpr float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */ + constexpr float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */ + constexpr float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */ + + /** + * @brief 绘制「还原/重置」图标(逆时针回环箭头)。 + * + * 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。 + * 不依赖任何字体或资源,因此在任何 DPI 下都清晰。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标外接矩形(正方形)。 + * @param [IN] color 笔画颜色。 + * @param [IN] scale DPI 缩放比例。 + */ + inline void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale) + { + const float inset = 2.5f * scale; + gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f, + rect.Height - inset * 2.0f); + if (box.Width <= 1.0f || box.Height <= 1.0f) + { + return; + } + gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu, + color & 0xFFu), + std::max(1.0f, 1.5f * scale)); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + // 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。 + g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f); + + // 箭头:位于缺口末端(约 300° 方向),指向前进方向。 + const float cx = box.X + box.Width * 0.5f; + const float cy = box.Y + box.Height * 0.5f; + const float radius = box.Width * 0.5f; + const float angle = 300.0f * 3.14159265f / 180.0f; + const float tipX = cx + std::cos(angle) * radius; + const float tipY = cy + std::sin(angle) * radius; + const float size = std::max(2.5f, 3.2f * scale); + gd::PointF arrow[3]; + arrow[0] = gd::PointF(tipX, tipY); + arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f); + arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size); + gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, + (color >> 8) & 0xFFu, color & 0xFFu)); + g.FillPolygon(&brush, arrow, 3); + } + } // namespace pmdetail + + using namespace pmdetail; + +} // namespace jdi + +#endif // __POPUP_MENU_INTERNAL_HPP__ diff --git a/Components/Shared/Inc/UiButtonWidget.hpp b/Components/Shared/Inc/UiButtonWidget.hpp new file mode 100644 index 0000000..4a18f86 --- /dev/null +++ b/Components/Shared/Inc/UiButtonWidget.hpp @@ -0,0 +1,112 @@ +#ifndef __UI_BUTTON_WIDGET_HPP__ +#define __UI_BUTTON_WIDGET_HPP__ + +#include +#include + +#include "UiWidget.hpp" + +namespace jdi +{ + + /** + * @brief 按钮图标(非 `NONE` 时按钮**只画图标**,忽略文本)。 + * + * 图标用矢量绘制(不用字体里的符号):`⚙` 这类字符在中文/西文回退字体里 + * 宽高与基线各不相同,会出现"图标忽大忽小、垂直不居中"的问题。 + */ + enum class UiButtonIcon : int + { + NONE = 0, /**< 无图标(只画文本)。 */ + GEAR = 1 /**< 齿轮:设置菜单入口。 */ + }; + + /** + * @brief 一个"文字按钮"控件(工具栏按钮的原语)。 + * + * 视觉规则与旧 `Toolbar` 的按钮**逐像素一致**: + * * 主按钮(`primary`)= 强调色实底 + `textOnAccent` 文字,按下 / 悬停时整体提亮或压暗; + * * 普通按钮 = 透明底 + 1px 描边,可切换按钮选中时用 `accentSoft` 底 + `accent` 描边; + * * 禁用时底色回退为工具栏底色、文字用 `textMuted`、描边与底色混 50%。 + * + * 交互:按下即 `CaptureMouse()` 并进入按下态;**在自身范围内**松开才触发 `onClick` + *(与"按住移开再松开 = 放弃"的既有语义一致)。 + */ + class UiButtonWidget : public UiWidget + { + public: + UiButtonWidget() = default; + + /** @brief 一次性注入绘制资源与外观参数(尺寸由 `OnMeasure` 按文本实测得出)。 */ + void Configure(const UiFonts* fonts, const Theme* theme, float scale, std::wstring label, + bool primary, bool toggle, float textPadding, float height); + + /** @brief 设置图标(`NONE` 表示回到纯文本按钮)。 */ + void SetIcon(UiButtonIcon icon); + /** @brief 当前图标。 */ + UiButtonIcon Icon() const { return m_icon; } + + /** @brief 修改文本(宽度会在下一次 `InvalidateMeasure` 后重新实测)。 */ + void SetLabel(std::wstring label); + /** @brief 文本(只读)。 */ + const std::wstring& Label() const { return m_label; } + + /** @brief 设置可切换按钮的选中态。 */ + void SetChecked(bool checked); + /** @brief 选中态。 */ + bool Checked() const { return m_checked; } + /** @brief 是否可切换按钮。 */ + bool IsToggle() const { return m_toggle; } + + /** @brief 设置可用性(禁用时不可点、不可命中)。 */ + void SetEnabled(bool enabled); + /** @brief 是否可用。 */ + bool Enabled() const { return m_enabled; } + /** @brief 是否处于按下态(按下未松开)。 */ + bool Pressed() const { return m_pressed; } + + /** @brief 放弃当前按下(指针离开窗口等不会再收到抬起的情形)。 */ + void CancelPress(); + + /** + * @brief 按钮**内容**(文本或图标)实际占用的矩形(客户区坐标)。 + * + * 内容按居中绘制,因此它的矩形与按钮矩形不同:调试层用它显示"文字/图标本身的边界"。 + * 未摆放或文本为空时返回空矩形。 + * + * @return 内容矩形(客户区坐标)。 + */ + gd::RectF ContentRect() const; + + /** @brief 点击回调(在自身范围内松开时触发)。 */ + std::function onClick; + + /** @brief 工具栏底色(普通按钮的底色与禁用配色都以它为基准)。 */ + void SetCanvasColor(std::uint32_t color) { m_canvasColor = color; } + + protected: + gd::SizeF OnMeasure(const UiMeasureContext& ctx) override; + void OnPaint(UiPaintContext& ctx) override; + UiEventResult OnEvent(UiEvent& event) override; + HCURSOR OnQueryCursor(int x, int y) override; + std::uint64_t ContentKey() const override; + + private: + const UiFonts* m_fonts = nullptr; /**< 字体。 */ + const Theme* m_theme = nullptr; /**< 主题。 */ + float m_scale = 1.0f; /**< 缩放系数。 */ + std::wstring m_label; /**< 按钮文本。 */ + UiButtonIcon m_icon = UiButtonIcon::NONE; /**< 按钮图标(非 `NONE` 时忽略文本)。 */ + bool m_primary = false; /**< 主按钮(强调色实底)。 */ + bool m_toggle = false; /**< 是否可切换。 */ + bool m_checked = false; /**< 可切换按钮的选中态。 */ + bool m_enabled = true; /**< 是否可用。 */ + bool m_pressed = false; /**< 按下态。 */ + float m_textPadding = 14.0f; /**< 左右内边距(逻辑像素)。 */ + float m_height = 30.0f; /**< 按钮高度(逻辑像素)。 */ + std::uint32_t m_canvasColor = 0; /**< 工具栏底色(配色基准)。 */ + }; + +} // namespace jdi + +#endif // __UI_BUTTON_WIDGET_HPP__ diff --git a/Components/Shared/Inc/UiComponent.hpp b/Components/Shared/Inc/UiComponent.hpp new file mode 100644 index 0000000..5e054bf --- /dev/null +++ b/Components/Shared/Inc/UiComponent.hpp @@ -0,0 +1,98 @@ +#ifndef __UI_COMPONENT_HPP__ +#define __UI_COMPONENT_HPP__ + +#include +#include +#include + +#include "Theme.hpp" + +namespace jdi +{ + + class UiDebugBoxList; + + /** + * @brief 顶层 UI 组件的统一协议(**纯接口,无状态**)。 + * + * ## 为什么需要它 + * + * 旧实现里宿主(`Application`)用**手写 if 链**逐个点名四个大组件,导致同一件事 + * (设置缩放 / 摆放 / 悬停离开 / 光标仲裁 / 绘制)要在宿主的每个消息分支里各写一遍, + * 新增组件必须记得改 5 处。这里把它们收敛成一张**有序组件表**, + * 宿主只按表遍历,不再点名。 + * + * ## 为什么不是 `UiWidget` + * + * `UiWidget` 的契约是"**树上的一个节点**":尺寸由父节点 `Measure` 决定、脏区由父节点下探、 + * 命中走树的命中链、`OnPaint` 只画自己不许遍历子节点。而顶层大组件的现实是: + * * 区域由宿主给定,且**内部还要再切分**; + * * `PlotView` 有 4 层自持位图缓存、`SignalPanel` 有行位图缓存 —— 与"父脏下探"是另一套失效体系; + * * `PlotView` 一旦成为树节点,它的 `Paint` 就得接受父级 `dirty` 并与自己的缓存对齐, + * 这正是历史上"后台渲染覆盖用户平移 / 命中错位"那类事故的温床。 + * + * ⇒ 两者是**兄弟关系**而不是父子关系: + * `UiWidget`(树内节点,管**一块区域内部的**布局/命中/绘制) + * `UiComponent`(顶层宿主,管**自己这一整块地盘**:区域、缓存、局部重绘策略)。 + * `Toolbar` / `TabBar` 同时是两者 —— 对外实现本协议,对内持有一棵 `UiWidget` 树。 + * + * ## 实现要求 + * + * * 所有方法都必须**幂等且可在未布局时安全调用**(宿主会在首次 `Layout` 之前调 `SetScale`)。 + * * `HitTest` 必须与 `Layout` 接受的区域一致(绘制与命中同源)。 + * * `Paint` 必须把内容严格限制在 `Area()` 内(宿主可能只回送部分矩形)。 + */ + class UiComponent + { + public: + virtual ~UiComponent() = default; + + /** @brief 设置 DPI 缩放系数(> 0)。 */ + virtual void SetScale(float scale) = 0; + + /** @brief 在宿主给定的区域内摆放自身(内部布局由组件自己决定)。 */ + virtual void Layout(const gd::RectF& area) = 0; + + /** @brief 当前占用区域(客户区坐标);未布局时返回空矩形。 */ + virtual gd::RectF Area() const = 0; + + /** @brief 绘制自身(必须裁剪在 `Area()` 内)。 */ + virtual void Paint(gd::Graphics& g) = 0; + + /** @brief 坐标是否落在本组件内(与 `Area()` 同源)。 */ + virtual bool HitTest(int x, int y) const = 0; + + /** @brief 指针离开窗口/组件:清掉本组件的悬停态。返回是否需要重绘。 */ + virtual bool OnMouseLeave() = 0; + + /** @brief 是否处于内联编辑态(宿主据此把鼠标/键盘优先交给它)。 */ + virtual bool IsEditing() const { return false; } + + /** @brief 光标仲裁:返回 `nullptr` 表示不认领(由下层或宿主决定)。 */ + virtual HCURSOR QueryCursor(int x, int y) + { + (void)x; + (void)y; + return nullptr; + } + + /** + * @brief 收集本组件的**布局边界**(调试用,「显示布局边界」开启时由宿主调用)。 + * + * 默认实现只登记 `Area()`;自身内部还有可辨识子块(控件树节点、列表行、网格、 + * 复选框、徽标、✕、面板控件……)的组件覆盖它,先调本基类再补登记**层级更深**的 box。 + * + * 之所以把入口放在这里而不是让宿主逐个点名:宿主只持有 `UiComponent*` 的组件表, + * 遍历一次就能覆盖全部顶层区域;组件内部结构由组件自己上报(只有它知道几何)。 + * + * ⚠️ 只在宿主开启「显示布局边界」时才被调用 —— 平时**零开销**(不进任何绘制路径、 + * 不进任何缓存)。实现时**禁止**把几何写进自持位图缓存,否则关掉开关后线条会残留。 + * + * @param [IN,OUT] boxes 收集器(宿主每帧新建,全程只在开关打开时存在)。 + */ + virtual void CollectDebugBoxes(UiDebugBoxList& boxes); + }; + +} // namespace jdi + +#endif // __UI_COMPONENT_HPP__ diff --git a/Components/Shared/Inc/UiDebugBounds.hpp b/Components/Shared/Inc/UiDebugBounds.hpp new file mode 100644 index 0000000..f84048f --- /dev/null +++ b/Components/Shared/Inc/UiDebugBounds.hpp @@ -0,0 +1,170 @@ +#ifndef __UI_DEBUG_BOUNDS_HPP__ +#define __UI_DEBUG_BOUNDS_HPP__ + +#include +#include +#include + +#include +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 布局边界描边颜色(按层级取色)。 + * + * 层级只用于**区分归属**,不代表重要性:层级越深说明越靠近叶子控件。 + * + * @param [IN] depth 层级(0 = 顶层组件区域,≥1 = 内部控件)。 + * @return ARGB 颜色(不透明)。 + */ + std::uint32_t UiDebugBoundsColor(int depth); + + /** + * @brief 一个待显示的布局边界 box(**纯值类型**)。 + * + * 刻意不继承控件基类:`PlotView` 的 4 层位图缓存、`SignalPanel` 的行位图缓存都要求 + * "绘制由组件自己负责",把几何塞进继承体系会和缓存体系打架。box 只描述 + * "哪里有一个矩形、属于第几层、在第几绘制层",因此**几何可以被绘制、命中与调试共用**, + * 也不需要任何虚函数或生命周期管理。 + */ + struct UiDebugBox + { + gd::RectF rect; /**< 矩形(客户区坐标)。 */ + gd::RectF clip; /**< 裁剪矩形(= 所属组件的区域;空 = 不裁剪)。 */ + int depth = 0; /**< 层级(决定配色与包角粗细)。 */ + int layer = 0; /**< 绘制层(越大越靠上):由 `UiDebugBoxList::PushLayer` 分配。 */ + }; + + /** + * @brief box 收集器(值语义容器)。 + * + * 由宿主每帧构造一次、交给各组件填充、再一次性画完 —— 全程只在「显示布局边界」打开时执行。 + * + * ⚠️ **两个排序键缺一不可**: + * * `layer`(绘制层,来自 `PushLayer()`):保证**后收集的内容压住先收集的**。 + * 没有它,"信号面板的行边界"会盖住"右键弹出的二级菜单"(菜单是后收集的浮层)。 + * * `depth`(层级):同一层内先画父(浅层)再画子(深层),子元素的彩色包角才不被父覆盖。 + */ + class UiDebugBoxList : public UiTextRectSink + { + public: + /** @brief 清空(复用同一实例时调用),同时把绘制层复位为 0。 */ + void Clear(); + /** + * @brief 进入下一个绘制层。 + * + * 宿主在每个组件收集完之后调用一次;组件内部若有"浮层压住本体"的情形 + * (例如菜单的二级子菜单)也应在收集子层之前调用。 + */ + void PushLayer() { ++m_layer; } + /** + * @brief 显式设置当前绘制层(绘制期间收集文本几何时用)。 + * @param [IN] layer 层号(越大越靠上)。 + */ + void SetLayer(int layer) { m_layer = layer; } + + /** @brief 回报一段文本的墨迹矩形(由 `PaintUtil` 在绘制文本时调用)。 */ + void OnTextRect(const gd::RectF& rect) override; + /** + * @brief 设置后续 box 的裁剪矩形(= 当前组件的区域)。 + * + * ⚠️ **必须像裁剪绘制一样裁剪调试 box**:组件内部元素可能落在地盘之外 + * (典型:滚出可视区的网格 `lane.rect` 的 Y 会是负数),不裁剪就会画到相邻组件 + * (工具栏 / 标签栏)上面 —— 这正是用户报告“工具栏上出现大量残留”的直接原因。 + * + * @param [IN] clip 裁剪矩形;传空矩形表示不裁剪。 + */ + void SetClip(const gd::RectF& clip) { m_clip = clip; } + /** @brief 追加一个 box(空矩形 / 与裁剪矩形无交集的 box 自动忽略)。 */ + void Add(const gd::RectF& rect, int depth); + /** @brief 追加一组同层级的 box(空矩形逐个忽略)。 */ + void AddAll(const std::vector& rects, int depth); + /** + * @brief 登记一个**遮挡矩形**(浮层本体,如弹出菜单 / 拾色器)。 + * + * ⚠️ 必须做这件事:调试 box 是画在所有内容**之后**的,仅靠 `layer` 排序只能决定 + * “两个 box 重叠时谁压谁”,并不能阻止“下层组件的边框线画在不透明浮层上面”。 + * 登记为 occluder 后,**绘制层比它低**的 box 在该区域内会被裁掉, + * 于是观察右键菜单的空间布局时不会被底下的行/网格边界干扰。 + * + * @param [IN] rect 遮挡矩形(客户区坐标;空矩形忽略)。 + */ + void AddOccluder(const gd::RectF& rect); + /** @brief 是否为空。 */ + bool Empty() const { return m_boxes.empty(); } + /** @brief box 数量。 */ + std::size_t Count() const { return m_boxes.size(); } + /** @brief 只读访问(**未排序**,绘制时内部会按 layer/depth 稳定排序)。 */ + const std::vector& Items() const { return m_boxes; } + + /** @brief 一个遮挡矩形(含它所在的绘制层)。 */ + struct Occluder + { + gd::RectF rect; /**< 遮挡矩形。 */ + int layer = 0; /**< 所在绘制层(只遮挡更低的层)。 */ + }; + /** @brief 遮挡矩形集合(只读)。 */ + const std::vector& Occluders() const { return m_occluders; } + + private: + std::vector m_boxes; + std::vector m_occluders; + int m_layer = 0; /**< 当前绘制层。 */ + gd::RectF m_clip; /**< 当前裁剪矩形(空 = 不裁剪)。 */ + }; + + /** + * @brief 绘制一个矩形的布局边界(**四角粗、四边细**,且粗细随矩形尺寸自适应)。 + * + * 视觉规则: + * * 四条边固定 1 设备像素; + * * 四个角额外加粗成 `⌜ ⌝ ⌞ ⌟` —— 这是"一眼定位元素边界"的关键, + * 即使多个矩形共边也能靠角的形状分辨层次; + * * **包角粗细与长度都随矩形短边自适应**:元素越小越细(小方框不会被描成"粗边框"), + * 短边 ≤ 12px 时只画四个角块、不画边,短边 ≤ 5px 时完全不画(否则就盖住元素本身了); + * * **不做任何内缩**:框必须与"可见形状的外接矩形"完全重合。 + * 曾经为"父子共边不叠粗"内缩 1px,结果把胶囊/圆角元素(GX 徽标、色块、✕) + * 的圆弧留在框外 —— 那是错误的取舍。重叠时让"后画的那个"覆盖即可; + * * **只描边、不填充、不写字** —— 填充会糊住内容。 + * + * ⚠️ 线宽按**设备像素**固定(不乘 `scale`)并临时 `PixelOffsetModeNone` + 关抗锯齿, + * 否则 1px 线会被抗锯齿摊成 2px 灰边,无法判断真实边界。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 目标矩形(客户区坐标)。 + * @param [IN] depth 层级(决定配色与包角基础粗细)。 + */ + void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth); + + /** + * @brief `UiDebugPaintBounds` 的内部实现(带裁剪矩形)。 + * + * 供 `UiDebugPaintBoxList` 使用:非空 `clip` 表示"只在所属组件的地盘内绘制"。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 目标矩形。 + * @param [IN] clip 裁剪矩形(空 = 不裁剪)。 + * @param [IN] depth 层级。 + */ + void UiDebugPaintBoundsClipped(gd::Graphics& g, const gd::RectF& rect, const gd::RectF& clip, + int depth); + + /** + * @brief 一次性画完整个 box 列表(在宿主绘制的最后一步调用)。 + * + * 按 `(layer, depth)` 稳定排序后绘制:后收集的层压住先收集的层,同层内父先子后。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] boxes box 列表。 + */ + void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes); + +} // namespace jdi + +#endif // __UI_DEBUG_BOUNDS_HPP__ diff --git a/Components/Shared/Inc/UiLayout.hpp b/Components/Shared/Inc/UiLayout.hpp new file mode 100644 index 0000000..a7b9ccd --- /dev/null +++ b/Components/Shared/Inc/UiLayout.hpp @@ -0,0 +1,223 @@ +#ifndef __UI_LAYOUT_HPP__ +#define __UI_LAYOUT_HPP__ + +#include +#include +#include + +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 布局方向。 + */ + enum class UiAxis : int + { + HORIZONTAL = 0, /**< 主轴为水平(从左到右排列)。 */ + VERTICAL = 1 /**< 主轴为垂直(从上到下排列)。 */ + }; + + /** + * @brief 在容器中的对齐方式(单一方向)。 + * + * `STRETCH` = 占满该方向可用空间(尺寸由容器决定,忽略自身期望尺寸)。 + */ + enum class UiAlign : int + { + BEGIN = 0, /**< 起始边(左 / 上)。 */ + CENTER = 1, /**< 居中。 */ + END = 2, /**< 结束边(右 / 下)。 */ + STRETCH = 3 /**< 拉伸占满。 */ + }; + + /** + * @brief 四边内边距(逻辑像素,使用时由调用方乘缩放系数)。 + */ + struct UiInsets + { + float left = 0.0f; /**< 左内边距。 */ + float top = 0.0f; /**< 上内边距。 */ + float right = 0.0f; /**< 右内边距。 */ + float bottom = 0.0f; /**< 下内边距。 */ + + /** @brief 四边同值。 */ + static UiInsets All(float value); + /** @brief 左右 / 上下分别指定。 */ + static UiInsets Symmetric(float horizontal, float vertical); + /** @brief 按缩放系数放大(DPR 1.25 等)。 */ + UiInsets Scaled(float scale) const; + /** @brief 水平方向占用量。 */ + float Horizontal() const { return left + right; } + /** @brief 垂直方向占用量。 */ + float Vertical() const { return top + bottom; } + }; + + // ------------------------------------------------------------------ + // 矩形原语 + // ------------------------------------------------------------------ + + /** @brief 矩形是否有面积(宽度与高度都 > 0)。 */ + bool UiRectHasArea(const gd::RectF& rect); + + /** @brief 两个矩形的交集;无交集时输出空矩形并返回 false。 */ + bool UiRectIntersect(const gd::RectF& a, const gd::RectF& b, gd::RectF& out); + + /** @brief 两个矩形的并集(任一为空时直接返回另一个)。 */ + gd::RectF UiRectUnion(const gd::RectF& a, const gd::RectF& b); + + /** @brief 内缩(结果可能为空矩形)。 */ + gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets); + + /** @brief 四边外扩(用于“描边允许超出自身矩形”的裁剪补偿)。 */ + gd::RectF UiRectInflate(const gd::RectF& rect, float amount); + + /** + * @brief 把矩形对齐到**设备像素整数边界**(上界 `floor`、下界 `ceil`)。 + * + * ⚠️ 离屏位图(图层 / 装饰层 / 面板层)必须用这个结果做贴图源矩形: + * 直接对浮点矩形取整会让补丁多算一行,从而盖到相邻网格的分界线上(历史 1px 接缝 BUG)。 + */ + gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale); + + /** @brief 把一个 `size` 的盒子按对齐方式放进 `outer`(`STRETCH` 时返回 `outer` 本身)。 */ + gd::RectF UiAlignInRect(const gd::RectF& outer, float width, float height, UiAlign horizontal, + UiAlign vertical, float offsetX = 0.0f, float offsetY = 0.0f); + + // ------------------------------------------------------------------ + // 布局:Stack(一维流)/ Flow(换行流)/ Abs(绝对) + // ------------------------------------------------------------------ + + /** @brief 尺寸未知(由剩余空间均分)。 */ + constexpr float kUiSizeAuto = -1.0f; + + /** + * @brief 一维栈布局的**期望尺寸**。 + * @param [IN] sizes 各项主尺寸(`kUiSizeAuto` 表示由剩余空间均分,不参与求和)。 + * @param [IN] gap 项间距。 + * @return 主尺寸合计(含间距)。 + */ + float UiStackExtent(const std::vector& sizes, float gap); + + /** + * @brief 一维栈布局:把 `area` 沿主轴依次切分给各项。 + * + * * 固定尺寸项按给定值取用; + * * `kUiSizeAuto` 项均分**剩余**空间(剩余为负时按 0 处理,避免负尺寸); + * * 交叉轴一律占满 `area`(需要单独对齐时调用方再 `UiAlignInRect`)。 + * + * @param [IN] area 可用区域。 + * @param [IN] axis 主轴方向。 + * @param [IN] sizes 各项主尺寸(可含 `kUiSizeAuto`)。 + * @param [IN] gap 项间距。 + * @param [OUT] out 逐项矩形(数量与 `sizes` 一致)。 + * @param [OUT] usedExtent 实际占用主尺寸(含间距),可为空。 + */ + void UiStackRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, float* usedExtent = nullptr); + + /** + * @brief 换行流布局(图例条目、工具栏按钮组)。 + * + * 沿主轴依次摆放,放不下时**换到下一行/列**;放不下的项(含之后的所有项) + * 不再输出,其下标通过 `overflow` 返回 —— 调用方据此绘制 `+N` 之类的省略提示。 + * + * @param [IN] area 可用区域。 + * @param [IN] axis 主轴方向(通常是水平)。 + * @param [IN] sizes 各项 (宽, 高)。 + * @param [IN] gap 项间距(主轴与交叉轴相同)。 + * @param [OUT] out 已放下的项矩形。 + * @param [OUT] overflow 首个未放下的项下标;全部放下时为 `sizes.size()`。 + * @return 实际占用的交叉轴尺寸(行数 × 行高)。 + */ + float UiFlowRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, std::size_t& overflow); + + /** @brief 绝对布局:`outer` 左上角偏移 `(dx, dy)` 后取 `width × height`。 */ + gd::RectF UiAbsRect(const gd::RectF& outer, float dx, float dy, float width, float height); + + // ------------------------------------------------------------------ + // 布局缓存 + // ------------------------------------------------------------------ + + /** + * @brief 一份被缓存下来的布局结果。 + * + * `rects` / `values` 的含义由调用方约定(例如浮动面板的"标题栏 / ✕ / 徽标 / 逐行矩形")。 + */ + struct UiLayoutResult + { + std::uint64_t key = 0; /**< 布局键(由调用方构造,必须覆盖**全部**影响几何的输入)。 */ + gd::RectF bounds; /**< 整体矩形。 */ + std::vector rects; /**< 逐项矩形。 */ + std::vector values; /**< 附带数值(列宽 / 行高 / 内容总高等)。 */ + std::uint64_t stamp = 0; /**< 最近一次命中序号(用于淘汰最久未用项)。 */ + }; + + /** + * @brief 布局结果缓存(把"每帧被反复询问的几何"降到只算一次)。 + * + * 设计要点: + * * 用 `std::deque` 存条目 ⇒ `Store` 之后**已有元素引用依然有效**(`vector` 扩容会失效); + * * 条目数量很小(面板种类 × 网格数),线性查找比哈希更快,也避免哈希碰撞带来的诡异行为; + * * **定向失效**:几何改变的那条路径必须显式 `Invalidate(key)`, + * **禁止**为了让缓存命中率好看而整体 `Clear()`(会让所有面板在同一帧全部重算)。 + */ + class UiLayoutCache + { + public: + explicit UiLayoutCache(std::size_t capacity = 32); + + /** + * @brief 查找命中项。 + * @param [IN] key 布局键。 + * @return 命中返回条目指针(**内容不可修改**),未命中返回 nullptr。 + * @note 命中会刷新该条目的使用序号(因此本函数不是 `const`)。 + */ + const UiLayoutResult* Find(std::uint64_t key); + + /** + * @brief 存入(同键覆盖)并返回稳定引用。 + * @param [IN] result 待存条目(`key` 必须已填)。 + * @return 缓存内条目的引用(有效期到下一次 `Store` 引发淘汰为止)。 + */ + const UiLayoutResult& Store(UiLayoutResult result); + + /** @brief 定向失效。 */ + void Invalidate(std::uint64_t key); + + /** @brief 全部清空(只在"数据层结构变化"这类场景使用)。 */ + void Clear(); + + /** @brief 当前条目数(探针用)。 */ + std::size_t Size() const { return m_entries.size(); } + + private: + std::size_t m_capacity; /**< 条目上限。 */ + std::uint64_t m_stamp = 0; /**< 递增使用序号。 */ + std::deque m_entries; /**< 条目(deque 保证引用稳定)。 */ + + /** @brief 淘汰最久未使用的条目,直到不超过上限。 */ + void Trim(); + }; + + /** + * @brief 组合布局键(FNV-1a 风格的增量混合)。 + * + * ⚠️ 键必须覆盖**全部**影响几何的输入;漏一个字段的症状是"布局不更新" + * (与历史上"缓存键漏混字段 ⇒ 贴旧像素"是同一类 BUG)。 + */ + std::uint64_t UiMixKey(std::uint64_t seed, std::uint64_t value); + + /** @brief 浮点参与成键(按 1/1024 量化,避免亚像素抖动导致键频繁变化)。 */ + std::uint64_t UiMixKeyFloat(std::uint64_t seed, float value); + +} // namespace jdi + +#endif // __UI_LAYOUT_HPP__ diff --git a/Components/Shared/Inc/UiSurface.hpp b/Components/Shared/Inc/UiSurface.hpp new file mode 100644 index 0000000..26f354c --- /dev/null +++ b/Components/Shared/Inc/UiSurface.hpp @@ -0,0 +1,55 @@ +#ifndef __UI_SURFACE_HPP__ +#define __UI_SURFACE_HPP__ + +#include +#include +#include + +#include + +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 一块可绘制表面(DC + 矩形 + 可选像素直写视图)。 + * + * 这是"整个应用共享一张后台位图"这一设想的落地载体:宿主创建一次, + * 之后把"我负责哪一块"以 `UiSurface::SubView()` 的形式发给子元素。 + * + * ## 为什么需要 `version` + * + * 位图会在 `WM_SIZE` / `WM_DPICHANGED` 时被销毁重建。任何缓存下来的 `bits` + * **立刻变成野指针**,因此像素指针必须与"位图代次"绑定:版本不一致就作废重取。 + * ⚠️ **绝不缓存裸 `bits` 跨帧使用**(本项目历史上因缓存裸指针踩过悬垂访问)。 + * + * ## 什么时候才需要 `bits` + * + * `dc + rect` 已经足够做 GDI/GDI+ 绘制(往子矩形里画本来就是 O(1))。 + * `bits` 只在**CPU 侧直接改像素**(memcpy / alpha 混合 / 重采样)时才有意义; + * 用它来"画控件"是净亏损(会破坏 ClearType、引入 stride/翻转/写序错误)。 + */ + struct UiSurface + { + HDC dc = nullptr; /**< 目标 DC(GDI / GDI+ 都画这个)。 */ + gd::RectF rect; /**< 该表面在宿主坐标系中的矩形(设备像素)。 */ + int stride = 0; /**< 行字节数(**不可**假设等于 width × 4)。 */ + std::uint32_t* bits = nullptr; /**< `rect` 左上角那一像素(不是位图首地址)。 */ + std::uint64_t version = 0; /**< 位图代次;失配 ⇒ 该表面作废。 */ + float scale = 1.0f; /**< DPI 缩放系数(逻辑像素 → 设备像素)。 */ + + /** @brief 表面是否可用。 */ + bool Valid() const { return dc != nullptr && rect.Width > 0.0f && rect.Height > 0.0f; } + + /** + * @brief 派生子视图(相对本表面左上角偏移)。 + * + * `bits` 会按 `stride` 正确换算到子矩形左上角;子矩形会被夹在本表面之内。 + */ + UiSurface SubView(const gd::RectF& localRect) const; + }; + +} // namespace jdi + +#endif // __UI_SURFACE_HPP__ diff --git a/Components/Shared/Inc/UiWidget.hpp b/Components/Shared/Inc/UiWidget.hpp new file mode 100644 index 0000000..d86e03b --- /dev/null +++ b/Components/Shared/Inc/UiWidget.hpp @@ -0,0 +1,371 @@ +#ifndef __UI_WIDGET_HPP__ +#define __UI_WIDGET_HPP__ + +#include +#include +#include + +#include +#include +#include + +#include "Theme.hpp" +#include "UiLayout.hpp" + +namespace jdi +{ + + struct Theme; + struct UiFonts; + class UiDebugBoxList; + + /** + * @brief 裁剪时允许超出控件矩形的像素数(逻辑像素 × scale)。 + * + * 边框/描边以矩形边界为中心绘制(1px 笔宽有一半在矩形外),若严格按矩形裁剪 + * 会把外半侧切掉(控件看起来变细)。因此裁剪区固定外扩这么多。 + * ⚠️ 它只用于**限制控件画到哪**,不参与脏区判定(脏区始终是精确矩形)。 + */ + constexpr float kUiClipBleed = 1.0f; + + /** + * @brief 布局测量上下文(由根节点统一下发,子节点只读)。 + */ + struct UiMeasureContext + { + const Theme* theme = nullptr; /**< 主题(子节点可能需要配色相关的尺寸)。 */ + const UiFonts* fonts = nullptr; /**< 字体(文本尺寸测量必需)。 */ + float scale = 1.0f; /**< 缩放系数(逻辑像素 → 设备像素)。 */ + float availableWidth = -1.0f; /**< 父给出的宽度上限(<0 表示不限)。 */ + float availableHeight = -1.0f; /**< 父给出的高度上限(<0 表示不限)。 */ + }; + + /** + * @brief 绘制上下文。 + * + * ⚠️ **`dirty` 的语义(重要)**:空矩形表示"脏区未知 ⇒ 全画";非空表示"只需保证这一块被更新"。 + * 基类在进入子节点前会把 `dirty` 与子节点矩形求交,为空即**整棵子树跳过** —— + * 这是本框架唯一真正省时间的机制,禁止任何派生类把它退化成"父脏 ⇒ 子树全脏"。 + */ + struct UiPaintContext + { + gd::Graphics* g = nullptr; /**< 绘图上下文(共享的后备缓冲/离屏位图)。 */ + gd::RectF dirty; /**< 需要重绘的区域(客户区坐标)。 */ + gd::RectF clip; /**< 当前有效裁剪矩形(= 祖先与本节点矩形的交集)。 */ + float scale = 1.0f; /**< 缩放系数。 */ + bool fastFrame = false; /**< 交互帧:允许降级(**几何不得改变**,只允许关抗锯齿等)。 */ + + /** @brief 取与给定矩形求交后的绘制上下文(子节点用)。 */ + UiPaintContext WithClip(const gd::RectF& rect) const; + }; + + /** + * @brief 事件类型。 + */ + enum class UiEventKind : int + { + NONE = 0, + MOUSE_MOVE, /**< 鼠标移动(可能带按键)。 */ + MOUSE_DOWN, /**< 鼠标按下。 */ + MOUSE_UP, /**< 鼠标抬起。 */ + MOUSE_DOUBLE_CLICK, /**< 双击(**只有确实有双击语义的控件才应处理它**)。 */ + MOUSE_WHEEL, /**< 滚轮。 */ + MOUSE_LEAVE, /**< 指针离开该控件。 */ + KEY_DOWN, /**< 键盘按下。 */ + CHAR, /**< 字符输入。 */ + QUERY_CURSOR /**< 询问光标形态(结果写回 `cursor`)。 */ + }; + + /** @brief 鼠标按键(与 `UiEventKind` 组合使用)。 */ + enum class UiMouseButton : int + { + NONE = 0, + LEFT = 1, + MIDDLE = 2, + RIGHT = 3 + }; + + /** + * @brief 一个 UI 事件(客户区坐标)。 + */ + struct UiEvent + { + UiEventKind kind = UiEventKind::NONE; /**< 事件类型。 */ + int x = 0; /**< 鼠标横坐标。 */ + int y = 0; /**< 鼠标纵坐标。 */ + UiMouseButton button = UiMouseButton::NONE; /**< 涉及的按键。 */ + int wheelDelta = 0; /**< 滚轮增量(1 格 = 120)。 */ + bool shift = false; /**< Shift 是否按下。 */ + bool control = false; /**< Ctrl 是否按下。 */ + WPARAM key = 0; /**< 虚拟键码(`KEY_DOWN`)。 */ + wchar_t character = 0; /**< 字符(`CHAR`)。 */ + HCURSOR cursor = nullptr; /**< `QUERY_CURSOR` 的输出(nullptr = 未认领)。 */ + }; + + /** + * @brief 事件处理结果。 + * + * 与旧架构(返回 `bool 要不要重绘`)的关键差别:**"消费"与"重绘"是两件事**。 + * 消费决定是否继续上抛;`dirty` 参与本帧脏区并集(为空表示"未知 ⇒ 取自身矩形")。 + */ + struct UiEventResult + { + bool consumed = false; /**< 事件已被消费。 */ + bool redraw = false; /**< 需要重绘。 */ + gd::RectF dirty; /**< 需要重绘的矩形(客户区坐标;空 + `redraw` ⇒ 取自身矩形)。 */ + + /** @brief 构造"已消费且需要重绘自身"的结果。 */ + static UiEventResult Handled(); + /** @brief 构造"已消费但不需重绘"的结果(例如纯状态查询)。 */ + static UiEventResult Swallowed(); + }; + + /** + * @brief UI 控件基类:树结构 + 两段式布局 + 脏区下探绘制 + 冒泡派发 + 交互仲裁。 + * + * ## 为什么要这一层 + * + * 旧架构是"并列大组件 + 手写优先级链",每个叠加层都要把"几何 / 命中 / 绘制 / 缓存键" + * 各写一遍(`PlotView` 已有 310 个方法 / 203 个成员)。这里把这些重复收敛成基类契约: + * + * * **布局只算一次**:`Measure` 自下而上、`Arrange` 自上而下;命中/绘制/光标读同一份 `m_bounds`。 + * * **裁剪契约统一**:`Paint` 进入时收紧 GDI+ 裁剪(`ctx.clip`),派生类必须把它传给 + * `UiTextClipped` 等**GDI 绘制辅助函数**(GDI 文本不认 GDI+ 裁剪区,这是本项目反复踩到的坑)。 + * * **脏区下探**:`dirty ∩ bounds` 为空 ⇒ 整棵子树跳过。 + * * **交互仲裁**:capture / 模态 / 悬停链 / 光标链由基类提供,派生类只写自己的手势。 + * + * ## 三条硬约束(改代码前先读) + * + * 1. **禁止"父脏 ⇒ 子树全脏"**:会把"只重绘一个网格/一个面板"打回整块重绘。 + * 2. **禁止把数据→像素管线(网格/曲线/采样点)做成控件节点**:布局与虚调用会进每帧热路径。 + * 3. **禁止每个控件自持位图**:内存按面积线性增长(128 网格已是 ≈250MB 量级)。 + */ + class UiWidget + { + public: + UiWidget(); + virtual ~UiWidget(); + + UiWidget(const UiWidget&) = delete; + UiWidget& operator=(const UiWidget&) = delete; + + // ------------------------------------------------------------------ + // 树结构 + // ------------------------------------------------------------------ + + /** @brief 追加子节点(**追加到末尾 = 最上层**,绘制与命中都按此顺序)。 */ + UiWidget& AddChild(std::unique_ptr child); + /** @brief 移除子节点(不存在时无操作)。 */ + void RemoveChild(UiWidget* child); + /** @brief 子节点集合(顺序 = z 序,末尾最上层)。 */ + const std::vector>& Children() const { return m_children; } + /** @brief 父节点(根节点为 nullptr)。 */ + UiWidget* Parent() const { return m_parent; } + /** @brief 根节点。 */ + UiWidget* Root(); + /** @brief 根节点(const)。 */ + const UiWidget* Root() const; + + // ------------------------------------------------------------------ + // 布局 + // ------------------------------------------------------------------ + + /** @brief 标记"期望尺寸"失效(会向根冒泡。只失效自己与**祖先**,不影响兄弟)。 */ + void InvalidateMeasure(); + + /** + * @brief 自下而上求期望尺寸(带缓存)。 + * @param [IN] ctx 测量上下文。 + * @return 期望尺寸(**含**自身内边距;不含外边距 —— 本项目没有外边距概念)。 + */ + gd::SizeF Measure(const UiMeasureContext& ctx); + + /** @brief 定位:写入自身矩形并回调 `OnArrange()`(派生容器在其中摆放子节点)。 */ + void Arrange(const gd::RectF& bounds); + + /** @brief 当前矩形(客户区坐标,整像素对齐由调用方保证)。 */ + const gd::RectF& Bounds() const { return m_bounds; } + /** @brief 上一帧测量得到的期望尺寸。 */ + gd::SizeF DesiredSize() const { return m_desiredSize; } + + // ------------------------------------------------------------------ + // 绘制 + // ------------------------------------------------------------------ + + /** @brief 绘制自身与全部子节点(内部处理裁剪下探与脏区跳过)。 */ + void Paint(UiPaintContext& ctx); + + /** + * @brief 派发一个事件,并回答"是否需要重绘"。 + * + * 等价于 `Dispatch(event); return UiRectHasArea(TakeDirty());` —— 这是 + * `Toolbar` / `TabBar` 等容器**唯一**需要的"事件后是否需要重绘"判断。 + * 收敛成一个函数是为了避免"忘记取脏区"(取脏区同时会清零,漏掉一次就会 + * 把下一次真实变化误判成"无变化")。 + * + * @param [IN,OUT] event 事件(`QUERY_CURSOR` 的结果写回 `event.cursor`)。 + * @return 事件产生了需要重绘的脏区返回 true。 + */ + bool DispatchRedraw(UiEvent& event); + + /** + * @brief 递归收集本节点与全部子节点的布局边界(调试用)。 + * + * 只读 `m_bounds`,不参与脏区、不进缓存 ⇒ 关闭开关时完全不执行。 + * 悬停/按下/选中态一律不改变边界(那是外观而非布局),因此不需要额外状态。 + * + * @param [IN,OUT] boxes 收集器。 + * @param [IN] depth 层级(0 = 调用者自身,子节点依次 +1,决定描边颜色)。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const; + + /** @brief 局部失效:与自身矩形求交后向根冒泡。 */ + virtual void Invalidate(const gd::RectF& rect); + /** @brief 整棵子树失效。 */ + void InvalidateAll(); + + /** @brief 取走累计的脏区(根节点用)。空矩形表示"无失效"。 */ + gd::RectF TakeDirty(); + + // ------------------------------------------------------------------ + // 事件 + // ------------------------------------------------------------------ + + /** @brief 派发事件:捕获/模态路由 → 命中链(自最深向上冒泡)→ 自身 `OnEvent`。 */ + UiEventResult Dispatch(UiEvent& event); + + /** @brief 点是否落在自身矩形内(不含可见性/透明判断)。 */ + bool Contains(int x, int y) const; + + // ------------------------------------------------------------------ + // 交互仲裁 + // ------------------------------------------------------------------ + + /** @brief 请求独占鼠标(根节点记录;等价于 `SetCapture`)。 */ + void CaptureMouse(); + /** @brief 释放独占(**只有当前的捕获者**才会成功,避免错误释放别人的捕获)。 */ + void ReleaseMouse(); + /** @brief 当前持有捕获的控件(无则 nullptr)。 */ + UiWidget* MouseCaptureWidget() const; + + /** @brief 设为模态子树:根节点只向它派发事件(菜单/拾色器等浮层用)。 */ + void SetModal(bool modal); + /** @brief 当前模态子树(无则 nullptr)。 */ + UiWidget* ModalWidget() const; + /** @brief 是否处于模态(向上查找到根)。 */ + bool IsModalActive() const { return ModalWidget() != nullptr; } + + /** @brief 设为"命中透明"(纯装饰元素:不参与命中与光标链)。 */ + void SetHitTransparent(bool value) { m_hitTransparent = value; } + /** @brief 是否命中透明。 */ + bool HitTransparent() const { return m_hitTransparent; } + + /** @brief 设置可见性(不可见 = 不参与绘制与命中,**但不影响布局**)。 */ + void SetVisible(bool visible) { m_visible = visible; } + /** @brief 是否可见。 */ + bool Visible() const { return m_visible; } + + /** @brief 当前悬停的控件(根节点维护)。 */ + UiWidget* HoverWidget() const; + /** @brief 本控件当前是否被悬停(由根节点的悬停链维护)。 */ + bool Hovered() const { return m_hovered; } + /** + * @brief 本控件最近一次收到的悬停点(客户区坐标)。 + * + * 派生控件据此自行判断"悬停在自身内部的哪个热区"(例如标签的关闭 ✕); + * 未悬停时返回 false,出参不变。 + */ + bool HoverPoint(int& x, int& y) const; + + /** @brief 请求键盘焦点(根节点记录;键盘/字符事件沿焦点链派发)。 */ + void RequestFocus(); + /** @brief 释放焦点(**只有当前的焦点持有者**才会成功)。 */ + void ReleaseFocus(); + /** @brief 当前持有焦点的控件(无则 nullptr)。 */ + UiWidget* FocusWidget() const; + + // ------------------------------------------------------------------ + // 动画 + // ------------------------------------------------------------------ + + /** + * @brief 推进动画。 + * @param [IN] nowMs 当前时间(`GetTickCount64()`)。 + * @return 仍有动画进行中返回 true(宿主据此保持定时器)。 + */ + bool AdvanceAnimation(std::int64_t nowMs); + /** @brief 是否仍有动画(不推进)。 */ + bool AnimationActive() const; + + protected: + /** @brief 推进**自身**动画(派生类覆写;返回"我这一层是否仍需继续推进")。 */ + virtual bool AdvanceAnimationSelf(std::int64_t nowMs) { (void)nowMs; return false; } + /** @brief **自身**是否仍有动画(派生类覆写)。 */ + virtual bool AnimationActiveSelf() const { return false; } + + /** @brief 计算期望尺寸(派生类实现;**不得**修改子节点集合或自身矩形)。 */ + virtual gd::SizeF OnMeasure(const UiMeasureContext& ctx) = 0; + /** @brief 自身矩形已更新:派生容器在这里摆放子节点。 */ + virtual void OnArrange() {} + /** @brief 绘制自身(**不要**在这里遍历子节点;基类会负责)。 */ + virtual void OnPaint(UiPaintContext& ctx) = 0; + /** @brief 自身处理事件(子节点都未消费时才会被调用)。 */ + virtual UiEventResult OnEvent(UiEvent& event) { (void)event; return UiEventResult(); } + /** @brief 该位置的光标形态(nullptr = 不认领,交给下层/宿主)。 */ + virtual HCURSOR OnQueryCursor(int x, int y) { (void)x; (void)y; return nullptr; } + + /** + * @brief 内容指纹(缓存键的**唯一来源**)。 + * + * 派生类必须把"会改变像素的**全部**状态"混进来;父层只负责聚合子节点的键。 + * 漏字段的症状是"贴旧像素"(本项目历史上至少 4 次真实 BUG 都是这个原因)。 + */ + virtual std::uint64_t ContentKey() const { return 0; } + /** @brief 基类能提供的那部分键(矩形/可见性)。 */ + std::uint64_t BaseContentKey() const; + + /** @brief 把子节点 `Measure` 一遍(派生容器在 `OnMeasure` 里调用)。 */ + void MeasureChildren(const UiMeasureContext& ctx); + /** @brief 子节点的期望尺寸(需先 `MeasureChildren`)。 */ + gd::SizeF ChildDesiredSize(std::size_t index) const; + + /** @brief 命中测试:返回最上层(z 序末尾)且包含该点的子孙控件。 */ + UiWidget* HitTestDeep(int x, int y); + /** @brief 悬停链变化通知(默认无操作;需要"指针进入/离开"语义的控件可覆写)。 */ + virtual void OnHoverChanged(bool entered) { (void)entered; } + + private: + /** @brief 沿命中链派发(位置事件);非位置事件沿子树顺序派发。 */ + UiEventResult DispatchAlongHitPath(UiEvent& event); + /** + * @brief 命中链(自**最深**的命中节点开始,逐个回溯到本节点)。 + * + * 只把事件交给"鼠标真正压住的那一条路径",**绝不**分发给同层兄弟 —— + * 否则一个点击可能同时被视觉上位于下方的控件处理(两层都改状态)。 + */ + void CollectHitChain(int x, int y, std::vector& chain); + /** @brief 更新悬停链(只在根节点调用)。 */ + void UpdateHover(int x, int y); + /** @brief 是否需要重绘(脏区与自身矩形求交)。 */ + bool DirtyIntersects(const gd::RectF& dirty) const; + + UiWidget* m_parent = nullptr; /**< 父节点。 */ + std::vector> m_children; /**< 子节点(顺序 = z 序)。 */ + gd::RectF m_bounds; /**< 当前矩形(客户区坐标)。 */ + gd::SizeF m_desiredSize; /**< 期望尺寸。 */ + gd::RectF m_dirty; /**< 累计脏区(自身坐标系)。 */ + bool m_visible = true; /**< 是否可见。 */ + bool m_hitTransparent = false; /**< 是否命中透明。 */ + bool m_measureValid = false; /**< 期望尺寸是否有效。 */ + bool m_hovered = false; /**< 是否被悬停(悬停链写入)。 */ + int m_hoverX = 0; /**< 最近一次悬停点横坐标(悬停链写入)。 */ + int m_hoverY = 0; /**< 最近一次悬停点纵坐标(悬停链写入)。 */ + + // 根节点专用状态。 + UiWidget* m_capture = nullptr; /**< 鼠标捕获者。 */ + UiWidget* m_modal = nullptr; /**< 模态子树。 */ + UiWidget* m_hover = nullptr; /**< 当前悬停控件。 */ UiWidget* m_focus = nullptr; /**< 键盘焦点控件。 */ }; + +} // namespace jdi + +#endif // __UI_WIDGET_HPP__ diff --git a/Components/Shared/Src/ColorPickerPopup.cpp b/Components/Shared/Src/ColorPickerPopup.cpp index 02b6580..7a51a18 100644 --- a/Components/Shared/Src/ColorPickerPopup.cpp +++ b/Components/Shared/Src/ColorPickerPopup.cpp @@ -1,6 +1,7 @@ #include "ColorPickerPopup.hpp" #include "UiControls.hpp" +#include "UiDebugBounds.hpp" #include #include #include @@ -1093,4 +1094,35 @@ namespace jdi } } + void ColorPickerPopup::CollectDebugBoxes(UiDebugBoxList& boxes) const + { + if (!m_open) + { + return; + } + + // 层级 0:面板浮层整体。 + boxes.Add(m_rect, 0); + // 浮层不透明 ⇒ 登记为遮挡矩形:下层组件的边框线不再画到它上面。 + boxes.AddOccluder(m_rect); + + // 层级 1:面板内部的固定控件。 + boxes.Add(m_titleRect, 1); + boxes.Add(m_previewRect, 1); + boxes.Add(m_hexRect, 1); + boxes.Add(m_gamutRect, 1); + boxes.Add(m_hueRect, 1); + + // 层级 2:三条分量滑块的轨道与数值。 + for (const gd::RectF& track : m_tracks) + { + boxes.Add(track, 2); + } + for (const gd::RectF& value : m_values) + { + boxes.Add(value, 2); + } + + boxes.Add(EditFieldRect(), 2); + } } // namespace jdi diff --git a/Components/Shared/Src/PaintUtil.cpp b/Components/Shared/Src/PaintUtil.cpp index c0910c1..6f0d7e7 100644 --- a/Components/Shared/Src/PaintUtil.cpp +++ b/Components/Shared/Src/PaintUtil.cpp @@ -405,6 +405,50 @@ namespace jdi namespace { + /** @brief 当前安装的文本几何收集器(`nullptr` = 不收集,调试开关关闭)。 */ + UiTextRectSink* g_textRectSink = nullptr; + /** @brief 当前文本矩形的坐标偏移(离屏位图渲染时非零)。 */ + float g_textRectOffsetX = 0.0f; + float g_textRectOffsetY = 0.0f; + + /** + * @brief 回报一段文本的墨迹矩形(仅在收集器已安装时执行)。 + * + * 入参 `rect` 是**布局矩形**(可能整行宽);这里按对齐方式与实测宽度/高度把它 + * 收窄成文字真正占据的那一块 —— 否则每个文本 box 都会跟整行一样宽,边界失去意义。 + * + * @param [IN] text 文本内容。 + * @param [IN] font 字体句柄。 + * @param [IN] rect 布局矩形。 + * @param [IN] align 水平对齐方式。 + */ + void ReportTextRect(const std::wstring& text, HFONT font, const gd::RectF& rect, TextAlign align) + { + if (g_textRectSink == nullptr || text.empty() || font == nullptr) + { + return; + } + const TextExtent extent = MeasureText(font, text); + if (extent.width <= 0.0f) + { + return; + } + const float width = std::min(extent.width, rect.Width); + const float height = std::min(std::max(extent.height, 1.0f), rect.Height); + float x = rect.X; + if (align == TextAlign::CENTER) + { + x = rect.X + (rect.Width - width) * 0.5f; + } + else if (align == TextAlign::RIGHT) + { + x = rect.X + rect.Width - width; + } + const float y = rect.Y + (rect.Height - height) * 0.5f; + g_textRectSink->OnTextRect( + gd::RectF(x + g_textRectOffsetX, y + g_textRectOffsetY, width, height)); + } + /** * @brief `UiText` 与 `UiTextClipped` 的公共实现。 * @@ -483,6 +527,9 @@ namespace jdi DrawTextW(hdc, text.c_str(), static_cast(text.size()), &rc, flags); + // 调试:回报文字真正占据的矩形(在恢复 DC 状态之前算,此时字体仍被选中)。 + ReportTextRect(text, font, rect, align); + SetTextColor(hdc, oldColor); SetBkMode(hdc, oldMode); SelectObject(hdc, oldFont); @@ -513,6 +560,27 @@ namespace jdi return MeasureText(font, text).width; } + float UiMeasureTextHeight(HFONT font, const std::wstring& text) + { + return MeasureText(font, text).height; + } + + void UiSetTextRectSink(UiTextRectSink* sink) + { + g_textRectSink = sink; + } + + UiTextRectSink* UiGetTextRectSink() + { + return g_textRectSink; + } + + void UiSetTextRectOffset(float dx, float dy) + { + g_textRectOffsetX = dx; + g_textRectOffsetY = dy; + } + std::wstring UiEllipsize(const std::wstring& text, HFONT font, float maxWidth) { if (text.empty() || font == nullptr || maxWidth <= 0.0f) @@ -576,6 +644,19 @@ namespace jdi ExtTextOutW(hdc, static_cast(std::floor(x)), baselineY, ETO_CLIPPED, &clip, text.c_str(), static_cast(text.size()), nullptr); + // 调试:回报文字真正占据的矩形(起点由调用方给出,宽度取实测值)。 + if (g_textRectSink != nullptr) + { + const float width = UiMeasureTextWidth(font, text); + if (width > 0.0f) + { + const float height = std::max(1.0f, static_cast(metrics.tmHeight)); + g_textRectSink->OnTextRect( + gd::RectF(std::floor(x) + g_textRectOffsetX, + rect.Y + (rect.Height - height) * 0.5f + g_textRectOffsetY, width, height)); + } + } + SetTextColor(hdc, oldColor); SetBkMode(hdc, oldMode); SelectObject(hdc, oldFont); diff --git a/Components/Shared/Src/PopupMenu.cpp b/Components/Shared/Src/PopupMenu.cpp index 5ef27df..9e2af09 100644 --- a/Components/Shared/Src/PopupMenu.cpp +++ b/Components/Shared/Src/PopupMenu.cpp @@ -1,825 +1,17 @@ #include "PopupMenu.hpp" -#include "UiControls.hpp" #include #include +#include "PopupMenuInternal.hpp" +#include "UiControls.hpp" +#include "UiDebugBounds.hpp" +#include "UiLayout.hpp" + namespace jdi { - - namespace - { - const float kRowHeight = 27.0f; /**< 普通项高度。 */ - const float kHeaderHeight = 22.0f; /**< 分组标题高度。 */ - const float kSeparatorHeight = 9.0f; /**< 分隔线高度。 */ - const float kSwatchRowHeight = 32.0f; /**< 色块行高度。 */ - const float kPadV = 5.0f; /**< 菜单上下内边距。 */ - const float kPadH = 5.0f; /**< 菜单左右内边距。 */ - const float kItemPad = 8.0f; /**< 条目内文本的左右内缩(所有条目统一)。 */ - const float kMarkerColumn = 18.0f; /**< 条目**右侧**的选中标记列宽度(对勾画在这里)。 */ - const float kMarkerGap = 4.0f; /**< 文本与右侧标记列之间的间隔。 */ - const float kMinWidth = 148.0f; /**< 菜单最小宽度。 */ - const float kMaxWidth = 340.0f; /**< 菜单最大宽度。 */ - const float kCorner = 6.0f; /**< 菜单圆角半径。 */ - const float kSwatchSize = 22.0f; /**< 色块尺寸。 */ - const float kSwatchGap = 5.0f; /**< 色块间距。 */ - const float kEditFieldWidth = 88.0f; /**< EDIT 项输入框宽度。 */ - const float kEditFieldGap = 6.0f; /**< 说明文字与输入框之间的最小间隔。 */ - const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */ - const float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */ - const float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */ - const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */ - const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */ - const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */ - const float kScrollBarWidth = 6.0f; /**< 可滚动菜单的滚动条宽度。 */ - const float kScrollBarGap = 4.0f; /**< 滚动条与条目内容之间的间距。 */ - const float kScrollBarMinThumb = 22.0f; /**< 滚动条滑块的最小高度。 */ - - /** - * @brief 绘制「还原/重置」图标(逆时针回环箭头)。 - * - * 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。 - * 不依赖任何字体或资源,因此在任何 DPI 下都清晰。 - * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] rect 图标外接矩形(正方形)。 - * @param [IN] color 笔画颜色。 - * @param [IN] scale DPI 缩放比例。 - */ - void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale) - { - const float inset = 2.5f * scale; - gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f, - rect.Height - inset * 2.0f); - if (box.Width <= 1.0f || box.Height <= 1.0f) - { - return; - } - gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu, - color & 0xFFu), - std::max(1.0f, 1.5f * scale)); - pen.SetStartCap(gd::LineCapRound); - pen.SetEndCap(gd::LineCapRound); - // 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。 - g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f); - - // 箭头:位于缺口末端(约 300° 方向),指向前进方向。 - const float cx = box.X + box.Width * 0.5f; - const float cy = box.Y + box.Height * 0.5f; - const float radius = box.Width * 0.5f; - const float angle = 300.0f * 3.14159265f / 180.0f; - const float tipX = cx + std::cos(angle) * radius; - const float tipY = cy + std::sin(angle) * radius; - const float size = std::max(2.5f, 3.2f * scale); - gd::PointF arrow[3]; - arrow[0] = gd::PointF(tipX, tipY); - arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f); - arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size); - gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, - (color >> 8) & 0xFFu, color & 0xFFu)); - g.FillPolygon(&brush, arrow, 3); - } - } - PopupMenu::PopupMenu() = default; - void PopupMenu::Setup(const UiFonts* fonts, const Theme* theme) - { - m_fonts = fonts; - m_theme = theme; - } - - void PopupMenu::SetScale(float scale) - { - m_scale = (scale > 0.1f) ? scale : 1.0f; - } - - float PopupMenu::ItemHeight(const MenuItem& item) const - { - switch (item.kind) - { - case MenuItemKind::SEPARATOR: - return kSeparatorHeight * m_scale; - case MenuItemKind::HEADER: - case MenuItemKind::HEADER_ACTION: - return kHeaderHeight * m_scale; - case MenuItemKind::SWATCHES: - return kSwatchRowHeight * m_scale; - case MenuItemKind::EDIT: - // 编辑项比普通项略高,给输入框留出上下呼吸空间。 - return (kRowHeight + 4.0f) * m_scale; - case MenuItemKind::NORMAL: - default: - return kRowHeight * m_scale; - } - } - - void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp) - { - m_itemRects.assign(m_items.size(), gd::RectF()); - m_swatchRects.assign(m_items.size(), std::vector()); - - if (m_fonts == nullptr) - { - m_rect = gd::RectF(); - return; - } - - float contentWidth = kMinWidth * m_scale; - float totalHeight = kPadV * 2.0f * m_scale; - - std::vector heights(m_items.size(), 0.0f); - for (std::size_t index = 0; index < m_items.size(); ++index) - { - const MenuItem& item = m_items[index]; - heights[index] = ItemHeight(item); - totalHeight += heights[index]; - - if (item.kind == MenuItemKind::SWATCHES) - { - const int count = std::max(0, std::min(item.swatchCount, 16)); - const float width = (kPadH + kItemPad) * 2.0f * m_scale + - static_cast(count) * kSwatchSize * m_scale + - static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; - contentWidth = std::max(contentWidth, width); - } - else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION) - { - // 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。 - const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION) - ? (kHeaderIconSize + kHeaderIconGap) * m_scale - : 0.0f; - const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + - kMarkerGap * m_scale + iconExtra + - UiMeasureTextWidth(m_fonts->uiSmallBold, item.label); - contentWidth = std::max(contentWidth, width); - } - else if (item.kind == MenuItemKind::NORMAL) - { - const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + - kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label); - contentWidth = std::max(contentWidth, width); - } - else if (item.kind == MenuItemKind::EDIT) - { - // 说明文字 + 输入框;输入框宽度按数值文本自适应(与 EditFieldRect 保持一致)。 - const float fieldWidth = NaturalEditFieldWidth(item); - const float width = (kPadH + kItemPad) * 2.0f * m_scale + - UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale + - fieldWidth; - contentWidth = std::max(contentWidth, width); - } - } - - contentWidth = std::min(contentWidth, kMaxWidth * m_scale); - const float width = contentWidth; - - // ------------------------------------------------------------------ - // 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。 - // * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)—— - // 用户 2026-10-16 要求:滚动时标题必须留在最上方不动; - // * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部; - // * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。 - // ------------------------------------------------------------------ - int pinnedFrom = -1; - int scrollFirst = 0; - if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0) - { - for (int index = static_cast(m_items.size()) - 1; index > 0; --index) - { - if (m_items[static_cast(index)].kind == MenuItemKind::SEPARATOR) - { - pinnedFrom = index; - break; - } - } - while (scrollFirst < pinnedFrom && - (m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER || - m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER_ACTION)) - { - ++scrollFirst; // 前导标题固定在顶部,不参与滚动 - } - } - - float headHeight = 0.0f; - float scrollRegionHeight = 0.0f; - float tailHeight = 0.0f; - if (pinnedFrom > 0) - { - for (int index = 0; index < static_cast(m_items.size()); ++index) - { - if (index >= pinnedFrom) - { - tailHeight += heights[static_cast(index)]; - } - else if (index < scrollFirst) - { - headHeight += heights[static_cast(index)]; - } - else - { - scrollRegionHeight += heights[static_cast(index)]; - } - } - } - - float height = totalHeight; - m_scrollEnabled = false; - m_scrollRegionHeight = 0.0f; - m_scrollFirst = 0; - m_pinnedIndex = -1; - m_scrollViewport = gd::RectF(); - m_scrollBarRect = gd::RectF(); - // ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`, - // 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。 - // ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条) - // 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要** - // 滚动时才复位(`Open` 里已经为每次打开重置过)。 - if (pinnedFrom > 0) - { - // 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与 - // `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。 - float viewportLimit = 0.0f; - if (m_constraint.maxHeight > 0.0f) - { - // 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”, - // 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。 - const float menuLimit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale); - if (menuLimit >= 40.0f * m_scale) - { - viewportLimit = std::max( - 0.0f, menuLimit - kPadV * 2.0f * m_scale - headHeight - tailHeight); - } - } - if (m_constraint.maxScrollRows > 0) - { - const float rows = - static_cast(m_constraint.maxScrollRows) * kRowHeight * m_scale; - viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows; - } - - if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f) - { - const float viewportHeight = std::min(scrollRegionHeight, viewportLimit); - height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight; - m_scrollEnabled = true; - m_scrollFirst = scrollFirst; - 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”夹取。 - // ⚠️ 若每项各自按自己的文本长度算,同一面板里就会出现一个宽一个窄 - // (时间轴行的文本更长、还带单位后缀,于是它的输入框比 Y 轴行宽一截)。 - float labeledFieldWidth = 0.0f; - float widestLabel = 0.0f; - for (const MenuItem& item : m_items) - { - if (item.kind != MenuItemKind::EDIT || item.label.empty()) - { - continue; - } - labeledFieldWidth = std::max(labeledFieldWidth, NaturalEditFieldWidth(item)); - widestLabel = std::max(widestLabel, - UiMeasureTextWidth(m_fonts->uiSmall, item.label) + - kEditFieldGap * m_scale); - } - if (labeledFieldWidth > 0.0f) - { - const float rowContent = - std::max(40.0f * m_scale, (width - 2.0f * (kPadH + kItemPad) * m_scale)); - const float limit = std::max(40.0f * m_scale, - std::min(rowContent * 0.75f, rowContent - widestLabel)); - labeledFieldWidth = std::min(labeledFieldWidth, limit); - } - m_editFieldWidth = labeledFieldWidth; - - float left = static_cast(x); - // `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。 - float top = expandUp ? (static_cast(y) - height) : static_cast(y); - - // 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。 - if (left + width > bounds.X + bounds.Width) - { - left = static_cast(x) - width; - } - if (!expandUp && top + height > bounds.Y + bounds.Height) - { - top = static_cast(y) - height; - } - left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f)); - top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f)); - - m_rect = gd::RectF(left, top, width, height); - - // 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`; - // 尾部固定区从菜单底部往上对齐。 - const float bodyTop = top + kPadV * m_scale; - const float tailTop = top + height - kPadV * m_scale - tailHeight; - const float scrollBarReserve = m_scrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f; - m_scrollViewport = - m_scrollEnabled - ? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight, - std::max(0.0f, width - kPadH * 2.0f * m_scale), - std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight)) - : gd::RectF(); - - float cursorY = bodyTop; - float headCursorY = bodyTop; - float scrollCursorY = bodyTop + headHeight - m_scrollOffset; - float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop; - for (std::size_t index = 0; index < m_items.size(); ++index) - { - const MenuItem& item = m_items[index]; - const float itemHeight = heights[index]; - - float rectY = cursorY; - float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale); - if (m_scrollEnabled) - { - const int itemIndex = static_cast(index); - if (itemIndex < m_scrollFirst) - { - rectY = headCursorY; - headCursorY += itemHeight; - } - else if (itemIndex < m_pinnedIndex) - { - rectY = scrollCursorY; - scrollCursorY += itemHeight; - // 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。 - rectWidth = std::max(0.0f, rectWidth - scrollBarReserve); - } - else - { - rectY = tailCursorY; - tailCursorY += itemHeight; - } - } - m_itemRects[index] = - gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight); - - if (item.kind == MenuItemKind::SWATCHES) - { - const int count = std::max(0, std::min(item.swatchCount, 16)); - std::vector& rects = m_swatchRects[index]; - rects.reserve(static_cast(count)); - 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) - { - const float swatchX = - startX + static_cast(swatch) * (kSwatchSize + kSwatchGap) * m_scale; - rects.push_back(gd::RectF(swatchX, swatchY, kSwatchSize * m_scale, kSwatchSize * m_scale)); - } - } - - cursorY += itemHeight; - } - - // 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。 - if (m_scrollEnabled) - { - const float thickness = kScrollBarWidth * m_scale; - m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness, - m_scrollViewport.Y, thickness, m_scrollViewport.Height); - } - } - - bool PopupMenu::ScrollRegion(int& first, int& last) const - { - if (!m_scrollEnabled || m_pinnedIndex < 0) - { - first = 0; - last = static_cast(m_items.size()); - return false; - } - first = m_scrollFirst; - last = m_pinnedIndex; - return true; - } - - gd::RectF PopupMenu::ScrollBarThumbRect() const - { - if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f) - { - return gd::RectF(); - } - const float viewport = m_scrollViewport.Height; - if (viewport <= 0.0f) - { - return gd::RectF(); - } - const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight); - const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio); - const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport); - const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f; - const float thumbY = - m_scrollBarRect.Y + - std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight); - return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); - } - - float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const - { - if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f) - { - return m_scrollOffset; - } - const float viewport = m_scrollViewport.Height; - const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport); - if (maxScroll <= 0.0f) - { - return 0.0f; - } - const float track = m_scrollBarRect.Height; - const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight))); - const float travel = std::max(0.0f, track - thumbHeight); - if (travel <= 0.0f) - { - return 0.0f; - } - const float relative = thumbTop - m_scrollBarRect.Y; - const float progress = std::max(0.0f, std::min(1.0f, relative / travel)); - return progress * maxScroll; - } - - bool PopupMenu::EnsureItemVisible(int index) - { - if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex || - m_scrollViewport.Height <= 0.0f) - { - return false; - } - const gd::RectF& rect = m_itemRects[static_cast(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(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 = - (item.editValue.size() > item.value.size()) ? item.editValue : item.value; - const float natural = std::max(kEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale); - const float withSuffix = natural + EditSuffixWidth(item); - // 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。 - return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix; - } - - void PopupMenu::CloseSubmenu() - { - m_subOpen = false; - m_subParentIndex = -1; - m_subRect = gd::RectF(); - m_subItems.clear(); - m_subItemRects.clear(); - m_subHoverIndex = -1; - m_subPressedIndex = -1; - } - - void PopupMenu::BuildSubLayout(int parentIndex) - { - CloseSubmenu(); - if (parentIndex < 0 || parentIndex >= static_cast(m_items.size())) - { - return; - } - const MenuItem& parent = m_items[static_cast(parentIndex)]; - if (parent.kind != MenuItemKind::SUBMENU || parent.submenu.empty()) - { - return; - } - - m_subItems = parent.submenu; - - // 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。 - float contentWidth = kMinWidth * m_scale; - float totalHeight = kPadV * 2.0f * m_scale; - for (const MenuItem& item : m_subItems) - { - totalHeight += ItemHeight(item); - if (item.kind == MenuItemKind::SWATCHES) - { - const int count = std::max(0, std::min(item.swatchCount, 16)); - const float width = (kPadH + kItemPad) * 2.0f * m_scale + - static_cast(count) * kSwatchSize * m_scale + - static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; - contentWidth = std::max(contentWidth, width); - } - else if (item.kind == MenuItemKind::HEADER) - { - const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + - kMarkerGap * m_scale + - UiMeasureTextWidth(m_fonts->uiSmallBold, item.label); - contentWidth = std::max(contentWidth, width); - } - else if (item.kind == MenuItemKind::EDIT) - { - const float fieldWidth = NaturalEditFieldWidth(item); - const float width = (kPadH + kItemPad) * 2.0f * m_scale + - UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale + - fieldWidth; - contentWidth = std::max(contentWidth, width); - } - else if (item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::SUBMENU) - { - const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + - kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label); - contentWidth = std::max(contentWidth, width); - } - } - contentWidth = std::min(contentWidth, kMaxWidth * m_scale); - - const float width = contentWidth; - const float height = totalHeight; - const gd::RectF& parentRect = m_itemRects[static_cast(parentIndex)]; - const gd::RectF& bounds = m_buildBounds; - - // 默认贴主菜单右缘并向上与父项顶端对齐;右侧空间不足时翻到左侧。 - float left = m_rect.X + m_rect.Width - kSubOverlap * m_scale; - if (left + width > bounds.X + bounds.Width - 2.0f) - { - left = m_rect.X - width + kSubOverlap * m_scale; - } - left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f)); - - float top = parentRect.Y - kPadV * m_scale; - top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f)); - - m_subRect = gd::RectF(left, top, width, height); - m_subItemRects.assign(m_subItems.size(), gd::RectF()); - - float cursorY = top + kPadV * m_scale; - for (std::size_t index = 0; index < m_subItems.size(); ++index) - { - const float itemHeight = ItemHeight(m_subItems[index]); - m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY, - std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight); - cursorY += itemHeight; - } - - m_subParentIndex = parentIndex; - m_subOpen = true; - m_subHoverIndex = -1; - m_subPressedIndex = -1; - } - - int PopupMenu::SubItemAtPoint(int x, int y) const - { - if (!m_subOpen) - { - return -1; - } - const float fx = static_cast(x); - const float fy = static_cast(y); - for (std::size_t index = 0; index < m_subItems.size(); ++index) - { - const MenuItem& item = m_subItems[index]; - if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER) - { - continue; - } - if (!item.enabled) - { - continue; - } - if (UiPointInRect(m_subItemRects[index], fx, fy)) - { - return static_cast(index); - } - } - return -1; - } - - float PopupMenu::EditSuffixWidth(const MenuItem& item) const - { - if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty()) - { - return 0.0f; - } - return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale; - } - - gd::RectF PopupMenu::EditFieldRect(int index) const - { - if (index < 0 || index >= static_cast(m_items.size())) - { - return gd::RectF(); - } - const MenuItem& item = m_items[static_cast(index)]; - if (item.kind != MenuItemKind::EDIT) - { - return gd::RectF(); - } - - // 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度, - // 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。 - // ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限, - // 让输入框独占整行。 - // ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`: - // 同一面板里所有这类输入框必须一样宽、右边界对齐。 - const gd::RectF& rect = m_itemRects[static_cast(index)]; - const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale); - const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale; - float width = NaturalEditFieldWidth(item); - width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth)); - if (item.label.empty()) - { - // 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。 - width = contentWidth; - } - else - { - if (m_editFieldWidth > 0.0f) - { - width = m_editFieldWidth; - } - width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth)); - width = std::min(width, contentWidth * 0.75f); - } - - const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale; - return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width, - rect.Y + (rect.Height - height) * 0.5f, width, height); - } - - gd::RectF PopupMenu::HeaderIconRect(int index) const - { - if (index < 0 || index >= static_cast(m_items.size())) - { - return gd::RectF(); - } - const MenuItem& item = m_items[static_cast(index)]; - if (item.kind != MenuItemKind::HEADER_ACTION) - { - return gd::RectF(); - } - const gd::RectF& rect = m_itemRects[static_cast(index)]; - const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale)); - return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size, - rect.Y + (rect.Height - size) * 0.5f, size, size); - } - - bool PopupMenu::IsOnEditField(int x, int y) const - { - if (m_editIndex < 0) - { return false; - } - const gd::RectF rect = EditFieldRect(m_editIndex); - return UiPointInRect(rect, static_cast(x), static_cast(y)); - } - - bool PopupMenu::BeginItemEdit(int index) - { - if (!m_open || index < 0 || index >= static_cast(m_items.size())) - { - return false; - } - const MenuItem& item = m_items[static_cast(index)]; - if (item.kind != MenuItemKind::EDIT || !item.enabled) - { - return false; - } - - // 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。 - m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u); - m_editIndex = index; - m_hoverIndex = index; - return true; - } - - bool PopupMenu::EndItemEdit(bool commit, MenuResult& result) - { - if (m_editIndex < 0) - { - return false; - } - - const int index = m_editIndex; - const std::wstring text = m_edit.Text(); - m_editIndex = -1; - m_edit.End(); - - if (!commit || index >= static_cast(m_items.size())) - { - return true; - } - - MenuItem& item = m_items[static_cast(index)]; - // 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值); - // 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。 - item.value = text; - item.editValue = text; - - result.valid = true; - result.scope = m_scope; - result.id = item.id; - result.editCommit = true; - result.editText = text; - return true; - } - - void PopupMenu::RebuildLayout() - { - if (!m_open) - { - return; - } - const int subParent = m_subParentIndex; - BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp); - // 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。 - if (subParent >= 0) - { - BuildSubLayout(subParent); - } - } - - bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue) - { - for (MenuItem& item : m_items) - { - if (item.id == id && item.kind == MenuItemKind::EDIT) - { - const bool valueSame = (item.value == value); - const bool editSame = editValue.empty() || (item.editValue == editValue); - if (valueSame && editSame) - { - return true; - } - item.value = value; - if (!editValue.empty()) - { - item.editValue = editValue; - } - RebuildLayout(); - return true; - } - } - return false; - } - - bool PopupMenu::UpdateItemLabel(int id, const std::wstring& label, bool checked, bool enabled) - { - for (MenuItem& item : m_items) - { - if (item.id == id && item.kind == MenuItemKind::NORMAL) - { - if (item.label == label && item.checked == checked && item.enabled == enabled) - { - return true; - } - item.label = label; - item.checked = checked; - item.enabled = enabled; - RebuildLayout(); - return true; - } - } - return false; - } bool PopupMenu::OnEditMouseDown(int x, int y) { @@ -1513,389 +705,40 @@ namespace jdi return true; } - void PopupMenu::Paint(gd::Graphics& g) + + void PopupMenu::CollectDebugBoxes(UiDebugBoxList& boxes) const { - if (!m_open || m_theme == nullptr || m_fonts == nullptr || m_rect.Width <= 0.0f) + if (!m_open) { return; } - // 投影:用两层递减透明度的圆角矩形模拟,避免引入系统阴影 API。 - UiFillRoundRect(g, gd::RectF(m_rect.X + 1.0f * m_scale, m_rect.Y + 2.0f * m_scale, m_rect.Width, - m_rect.Height), - kCorner * m_scale, 0x14000000u); - UiFillRoundRect(g, gd::RectF(m_rect.X + 2.0f * m_scale, m_rect.Y + 4.0f * m_scale, m_rect.Width, - m_rect.Height), - kCorner * m_scale, 0x10000000u); + // 层级 0:菜单浮层整体。 + boxes.Add(m_rect, 0); + // 菜单是不透明浮层 ⇒ 登记为遮挡矩形:下层组件(面板行 / 网格)的边框线不再画到它上面。 + boxes.AddOccluder(m_rect); - 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) + // 层级 1:每个条目(含标题 / 分隔线 / 色块行,它们的矩形都已算好)。 + for (const gd::RectF& item : m_itemRects) { - if (clipped) - { - UiTextClipped(g, text, font, rect, argb, align, m_scrollViewport); - } - else - { - UiText(g, text, font, rect, argb, align); - } - }; - - // 可滚动菜单的条目分三段: - // * `[0, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**; - // * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条); - // * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。 - const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0; - const int scrollFirst = scrollable ? m_scrollFirst : static_cast(m_items.size()); - const int scrollLast = scrollable ? m_pinnedIndex : static_cast(m_items.size()); - int clipState = 0; - bool clipActive = false; - - for (std::size_t index = 0; index < m_items.size(); ++index) - { - const MenuItem& item = m_items[index]; - const gd::RectF& rect = m_itemRects[index]; - const int itemIndex = static_cast(index); - // 进入/离开滚动区:按需开启/撤销 GDI+ 裁剪(固定区永远完整可见)。 - if (scrollable && itemIndex == scrollFirst && !clipActive) - { - clipState = g.Save(); - g.SetClip(m_scrollViewport, gd::CombineModeIntersect); - clipActive = true; - } - else if (scrollable && itemIndex == scrollLast && clipActive) - { - g.Restore(clipState); - clipActive = false; - } - const bool inScrollRegion = itemIndex >= scrollFirst && itemIndex < scrollLast; - // 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。 - if (scrollable && inScrollRegion && - (rect.Y + rect.Height <= m_scrollViewport.Y || - rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height)) - { - continue; - } - const bool clipped = scrollable && inScrollRegion; - - if (item.kind == MenuItemKind::SEPARATOR) - { - const float lineY = rect.Y + rect.Height * 0.5f; - UiDrawLine(g, rect.X + kItemPad * m_scale, lineY, - rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f); - continue; - } - - if (item.kind == MenuItemKind::HEADER) - { - // 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。 - itemText(item.label, m_fonts->uiSmallBold, - gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), - m_theme->textMuted, TextAlign::LEFT, clipped); - continue; - } - - if (item.kind == MenuItemKind::HEADER_ACTION) - { - // 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。 - const gd::RectF icon = HeaderIconRect(static_cast(index)); - const float labelWidth = - std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale); - 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, clipped); - if (icon.Width > 0.0f) - { - const bool iconHovered = (static_cast(index) == m_hoverIconIndex); - if (iconHovered) - { - UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale, - icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale), - 4.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale); - } - continue; - } - - const bool hovered = (static_cast(index) == m_hoverIndex) && - (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0); - - if (item.kind == MenuItemKind::EDIT) - { - // 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式, - // 菜单始终不关闭,数值就地修改)。 - if (hovered && item.enabled && static_cast(index) != m_editIndex) - { - UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22)); - } - - const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted; - // 说明文字与普通项使用同一文字列(kItemPad),保证菜单内所有文本左对齐。 - const float labelLeft = rect.X + kItemPad * m_scale; - const gd::RectF fieldRect = EditFieldRect(static_cast(index)); - const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale); - 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(index) == m_editIndex) - { - // 编辑态:输入框底板覆盖整行,正文区向右让出后缀宽度, - // 后缀(单位提示)靠右对齐画在它右侧。 - // ⚠️ 必须用 `UiDrawEditFieldContent`(只画内容)——`UiDrawEditField` - // 会把底板/描边按传入矩形绘制,那样带后缀时外框会短一截。 - const gd::RectF textRect = - (suffix > 0.0f) - ? gd::RectF(fieldRect.X, fieldRect.Y, std::max(12.0f, fieldRect.Width - suffix), - fieldRect.Height) - : fieldRect; - UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f); - UiDrawEditFieldContent(g, textRect, m_edit, m_fonts->uiSmall, m_scale); - if (suffix > 0.0f) - { - const float suffixLeft = textRect.X + textRect.Width; - 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, clipped); - } - } - else - { - UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); - 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); - 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, clipped); - } - continue; - } - - if (item.kind == MenuItemKind::SWATCHES) - { - const std::vector& rects = m_swatchRects[index]; - for (std::size_t swatch = 0; swatch < rects.size(); ++swatch) - { - const gd::RectF& swatchRect = rects[swatch]; - UiFillRoundRect(g, swatchRect, 4.0f * m_scale, item.swatches[swatch]); - - const bool swatchHover = (static_cast(index) == m_hoverIndex) && - (static_cast(swatch) == m_hoverSwatch); - if (static_cast(swatch) == item.checkedSwatch || swatchHover) - { - const gd::RectF ring(swatchRect.X - 2.0f * m_scale, swatchRect.Y - 2.0f * m_scale, - swatchRect.Width + 4.0f * m_scale, - swatchRect.Height + 4.0f * m_scale); - UiDrawRoundRect(g, ring, 5.0f * m_scale, - (static_cast(swatch) == item.checkedSwatch) ? m_theme->textStrong - : m_theme->borderStrong, - (static_cast(swatch) == item.checkedSwatch) ? 1.8f : 1.0f); - } - } - continue; - } - - // 普通项。 - std::uint32_t background = 0; - if (hovered && item.enabled) - { - background = m_theme->accent; - } - else if (static_cast(index) == m_pressedIndex) - { - background = m_theme->pressed; - } - if (background != 0) - { - UiFillRoundRect(g, rect, 4.0f * m_scale, background); - } - - const std::uint32_t textColor = !item.enabled ? m_theme->textMuted - : ((hovered && item.enabled) ? m_theme->textOnAccent - : m_theme->text); - - if (item.checked) - { - // 单选标记:对勾固定画在条目**右侧**的标记列内,左侧只留统一的小边距, - // 这样菜单文本左边距不会因为“可能存在的对勾”而被整体推右。 - const float markerRight = rect.X + rect.Width - kItemPad * m_scale; - const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent; - UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale, - markerColor, 1.8f * m_scale); - UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale, - cy - 4.0f * m_scale, markerColor, 1.8f * m_scale); - } - - // 带子菜单的项:在右侧标记列画一个指向右的三角,提示可以继续展开。 - if (item.kind == MenuItemKind::SUBMENU) - { - const float markerRight = rect.X + rect.Width - kItemPad * m_scale; - const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const float size = kSubArrowSize * m_scale; - gd::PointF arrow[3]; - arrow[0] = gd::PointF(cx - size * 0.5f, cy - size); - arrow[1] = gd::PointF(cx - size * 0.5f, cy + size); - arrow[2] = gd::PointF(cx + size * 0.7f, cy); - gd::SolidBrush brush(ToGdiColor(textColor)); - g.FillPolygon(&brush, arrow, 3); - } - - const float textLeft = rect.X + kItemPad * m_scale; - const float textWidth = - rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale; - 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, clipped); + boxes.Add(item, 1); } - // 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。 - if (clipActive) + // 层级 1/2:可滚动菜单的可视区、轨道与滑块。 + boxes.Add(m_scrollViewport, 1); + boxes.Add(m_scrollBarRect, 2); + boxes.Add(ScrollBarThumbRect(), 2); + + // 层级 0/1:二级子菜单(**另起一个绘制层**:它压在菜单项之上)。 + if (UiRectHasArea(m_subRect)) { - g.Restore(clipState); - clipActive = false; - } - - // 滚动条:轨道 + 滑块(画在条目之上,仅在可滚动菜单里出现)。 - // 位置限制在可滚动条目区旁边,因此不会盖住顶部标题与底部「新建网格」。 - if (scrollable && m_scrollBarRect.Width > 0.0f) - { - const float radius = kScrollBarWidth * m_scale * 0.5f; - UiFillRoundRect(g, m_scrollBarRect, radius, m_theme->gridWeak); - const gd::RectF thumb = ScrollBarThumbRect(); - if (thumb.Width > 0.0f) + boxes.PushLayer(); + boxes.Add(m_subRect, 0); + boxes.AddOccluder(m_subRect); + for (const gd::RectF& item : m_subItemRects) { - UiFillRoundRect(g, thumb, radius, m_theme->borderStrong); + boxes.Add(item, 1); } } - - // 二级子菜单最后绘制,保证覆盖在主菜单之上。 - PaintSubmenu(g); - } - - void PopupMenu::PaintSubmenu(gd::Graphics& g) - { - if (!m_subOpen || m_subRect.Width <= 0.0f || m_subRect.Height <= 0.0f) - { - return; - } - - UiFillRoundRect(g, gd::RectF(m_subRect.X + 1.0f * m_scale, m_subRect.Y + 2.0f * m_scale, - m_subRect.Width, m_subRect.Height), - kCorner * m_scale, 0x14000000u); - UiFillRoundRect(g, gd::RectF(m_subRect.X + 2.0f * m_scale, m_subRect.Y + 4.0f * m_scale, - m_subRect.Width, m_subRect.Height), - kCorner * m_scale, 0x10000000u); - - UiFillRoundRect(g, m_subRect, kCorner * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, m_subRect, kCorner * m_scale, m_theme->borderStrong, 1.0f); - - for (std::size_t index = 0; index < m_subItems.size(); ++index) - { - const MenuItem& item = m_subItems[index]; - const gd::RectF& rect = m_subItemRects[index]; - - if (item.kind == MenuItemKind::SEPARATOR) - { - const float lineY = rect.Y + rect.Height * 0.5f; - UiDrawLine(g, rect.X + kItemPad * m_scale, lineY, - rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f); - continue; - } - - if (item.kind == MenuItemKind::HEADER) - { - UiText(g, item.label, m_fonts->uiSmallBold, - gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), - m_theme->textMuted, TextAlign::LEFT); - continue; - } - - const bool hovered = (static_cast(index) == m_subHoverIndex) && item.enabled; - - if (item.kind == MenuItemKind::EDIT) - { - // 子菜单中的编辑项:与主菜单一样就地编辑。 - if (hovered && static_cast(index) != m_editIndex) - { - UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22)); - } - const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted; - const float labelLeft = rect.X + kItemPad * m_scale; - const gd::RectF fieldRect = EditFieldRect(static_cast(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); - UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); - 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, - 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); - continue; - } - - if (item.kind == MenuItemKind::SWATCHES) - { - continue; - } - - std::uint32_t background = 0; - if (hovered) - { - background = m_theme->accent; - } - else if (static_cast(index) == m_subPressedIndex) - { - background = m_theme->pressed; - } - if (background != 0) - { - UiFillRoundRect(g, rect, 4.0f * m_scale, background); - } - - const std::uint32_t textColor = !item.enabled ? m_theme->textMuted - : (hovered ? m_theme->textOnAccent - : m_theme->text); - - // 选中标记(对勾):与主菜单一致画在右侧标记列内。 - if (item.checked) - { - const float markerRight = rect.X + rect.Width - kItemPad * m_scale; - const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; - const float cy = rect.Y + rect.Height * 0.5f; - const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent; - UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale, - markerColor, 1.8f * m_scale); - UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale, - cy - 4.0f * m_scale, markerColor, 1.8f * m_scale); - } - - 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, - gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor, - TextAlign::LEFT); - } } - } // namespace jdi diff --git a/Components/Shared/Src/PopupMenuLayout.cpp b/Components/Shared/Src/PopupMenuLayout.cpp new file mode 100644 index 0000000..3846fad --- /dev/null +++ b/Components/Shared/Src/PopupMenuLayout.cpp @@ -0,0 +1,753 @@ +// PopupMenu 实现分片 —— 几何与排版(BuildLayout / 子菜单 / 滚动区 / 内联输入框位置)。 +// +// 本文件只包含 PopupMenu 的成员函数定义(从 PopupMenu.cpp 机械搬移,行为不变)。 +// +// ⚠️ 这里是菜单**唯一**的几何来源:`Paint` 与所有命中测试都读它产出的矩形, +// 不得在别处再反推一遍(否则会出现"看得见点不到")。 +// ⚠️ `NaturalEditFieldWidth` / `EditSuffixWidth` 同时参与排版与绘制,改公式必须两处一致。 + +#include "PopupMenu.hpp" + +#include "PopupMenuInternal.hpp" + +namespace jdi +{ + void PopupMenu::Setup(const UiFonts* fonts, const Theme* theme) + { + m_fonts = fonts; + m_theme = theme; + } + + void PopupMenu::SetScale(float scale) + { + m_scale = (scale > 0.1f) ? scale : 1.0f; + } + + float PopupMenu::ItemHeight(const MenuItem& item) const + { + switch (item.kind) + { + case MenuItemKind::SEPARATOR: + return kSeparatorHeight * m_scale; + case MenuItemKind::HEADER: + case MenuItemKind::HEADER_ACTION: + return kHeaderHeight * m_scale; + case MenuItemKind::SWATCHES: + return kSwatchRowHeight * m_scale; + case MenuItemKind::EDIT: + // 编辑项比普通项略高,给输入框留出上下呼吸空间。 + return (kRowHeight + 4.0f) * m_scale; + case MenuItemKind::NORMAL: + default: + return kRowHeight * m_scale; + } + } + + void PopupMenu::BuildLayout(const gd::RectF& bounds, int x, int y, bool expandUp) + { + m_itemRects.assign(m_items.size(), gd::RectF()); + m_swatchRects.assign(m_items.size(), std::vector()); + + if (m_fonts == nullptr) + { + m_rect = gd::RectF(); + return; + } + + float contentWidth = kMinWidth * m_scale; + float totalHeight = kPadV * 2.0f * m_scale; + + std::vector heights(m_items.size(), 0.0f); + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + heights[index] = ItemHeight(item); + totalHeight += heights[index]; + + if (item.kind == MenuItemKind::SWATCHES) + { + const int count = std::max(0, std::min(item.swatchCount, 16)); + const float width = (kPadH + kItemPad) * 2.0f * m_scale + + static_cast(count) * kSwatchSize * m_scale + + static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION) + { + // 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。 + const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION) + ? (kHeaderIconSize + kHeaderIconGap) * m_scale + : 0.0f; + const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + + kMarkerGap * m_scale + iconExtra + + UiMeasureTextWidth(m_fonts->uiSmallBold, item.label); + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::NORMAL) + { + const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + + kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label); + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::EDIT) + { + // 说明文字 + 输入框;输入框宽度按数值文本自适应(与 EditFieldRect 保持一致)。 + const float fieldWidth = NaturalEditFieldWidth(item); + const float width = (kPadH + kItemPad) * 2.0f * m_scale + + UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale + + fieldWidth; + contentWidth = std::max(contentWidth, width); + } + } + + contentWidth = std::min(contentWidth, kMaxWidth * m_scale); + const float width = contentWidth; + + // ------------------------------------------------------------------ + // 超长菜单:限制高度 + 条目区可滚动 + 顶部/尾部固定项(见 `MenuLayoutConstraint`)。 + // * 顶部固定 = **前导的 `HEADER` / `HEADER_ACTION`**(典型:「所属网格」标题)—— + // 用户 2026-10-16 要求:滚动时标题必须留在最上方不动; + // * 尾部固定 = **最后一个分隔线及其之后的条目**(典型:「新建网格」)钉在底部; + // * 中间是滚动区(鼠标滚轮 / 键盘 / 滚动条),右侧画一根滚动条。 + // ------------------------------------------------------------------ + int pinnedFrom = -1; + int scrollFirst = 0; + if (m_constraint.maxHeight > 0.0f || m_constraint.maxScrollRows > 0) + { + for (int index = static_cast(m_items.size()) - 1; index > 0; --index) + { + if (m_items[static_cast(index)].kind == MenuItemKind::SEPARATOR) + { + pinnedFrom = index; + break; + } + } + while (scrollFirst < pinnedFrom && + (m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER || + m_items[static_cast(scrollFirst)].kind == MenuItemKind::HEADER_ACTION)) + { + ++scrollFirst; // 前导标题固定在顶部,不参与滚动 + } + } + + float headHeight = 0.0f; + float scrollRegionHeight = 0.0f; + float tailHeight = 0.0f; + if (pinnedFrom > 0) + { + for (int index = 0; index < static_cast(m_items.size()); ++index) + { + if (index >= pinnedFrom) + { + tailHeight += heights[static_cast(index)]; + } + else if (index < scrollFirst) + { + headHeight += heights[static_cast(index)]; + } + else + { + scrollRegionHeight += heights[static_cast(index)]; + } + } + } + + float height = totalHeight; + m_scrollEnabled = false; + m_scrollRegionHeight = 0.0f; + m_scrollFirst = 0; + m_pinnedIndex = -1; + m_scrollViewport = gd::RectF(); + m_scrollBarRect = gd::RectF(); + // ⚠️ **不要在这里复位 `m_scrollBarDragging`**:拖拽滚动条时每一帧都会 `RebuildLayout()`, + // 在这里清零会把拖拽态当场吃掉(拖一下断一下)。该状态只在 `Open` / `Close` 里复位。 + // ⚠️ **不要在这里无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写 / 拖滚动条) + // 会重新走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”)。只有在菜单**不再需要** + // 滚动时才复位(`Open` 里已经为每次打开重置过)。 + if (pinnedFrom > 0) + { + // 滚动视口高度上限:`maxHeight`(客户区像素 → 换算成视口可用高)与 + // `maxScrollRows`(标准行数)**取更严格的那个**;0 = 该约束不生效。 + float viewportLimit = 0.0f; + if (m_constraint.maxHeight > 0.0f) + { + // 夹到宿主矩形以内(不盖住窗口边框);小到放不下时的上限直接视作“不限制”, + // 交给下方的边界收敛处理,避免算出一个高度为 0 的菜单。 + const float menuLimit = std::min(m_constraint.maxHeight, bounds.Height - 4.0f * m_scale); + if (menuLimit >= 40.0f * m_scale) + { + viewportLimit = std::max( + 0.0f, menuLimit - kPadV * 2.0f * m_scale - headHeight - tailHeight); + } + } + if (m_constraint.maxScrollRows > 0) + { + const float rows = + static_cast(m_constraint.maxScrollRows) * kRowHeight * m_scale; + viewportLimit = (viewportLimit > 0.0f) ? std::min(viewportLimit, rows) : rows; + } + + if (viewportLimit >= kRowHeight * m_scale && scrollRegionHeight > viewportLimit + 0.5f) + { + const float viewportHeight = std::min(scrollRegionHeight, viewportLimit); + height = kPadV * 2.0f * m_scale + headHeight + viewportHeight + tailHeight; + m_scrollEnabled = true; + m_scrollFirst = scrollFirst; + 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”夹取。 + // ⚠️ 若每项各自按自己的文本长度算,同一面板里就会出现一个宽一个窄 + // (时间轴行的文本更长、还带单位后缀,于是它的输入框比 Y 轴行宽一截)。 + float labeledFieldWidth = 0.0f; + float widestLabel = 0.0f; + for (const MenuItem& item : m_items) + { + if (item.kind != MenuItemKind::EDIT || item.label.empty()) + { + continue; + } + labeledFieldWidth = std::max(labeledFieldWidth, NaturalEditFieldWidth(item)); + widestLabel = std::max(widestLabel, + UiMeasureTextWidth(m_fonts->uiSmall, item.label) + + kEditFieldGap * m_scale); + } + if (labeledFieldWidth > 0.0f) + { + const float rowContent = + std::max(40.0f * m_scale, (width - 2.0f * (kPadH + kItemPad) * m_scale)); + const float limit = std::max(40.0f * m_scale, + std::min(rowContent * 0.75f, rowContent - widestLabel)); + labeledFieldWidth = std::min(labeledFieldWidth, limit); + } + m_editFieldWidth = labeledFieldWidth; + + float left = static_cast(x); + // `expandUp`:锚点是菜单的**左下角**(向上展开);否则锚点是左上角(向右下展开)。 + float top = expandUp ? (static_cast(y) - height) : static_cast(y); + + // 优先向右展开(向左下/右下);越界时向左或向上翻转,最后做边界收敛。 + if (left + width > bounds.X + bounds.Width) + { + left = static_cast(x) - width; + } + if (!expandUp && top + height > bounds.Y + bounds.Height) + { + top = static_cast(y) - height; + } + left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f)); + top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f)); + + m_rect = gd::RectF(left, top, width, height); + + // 条目排布:头部固定区从菜单顶往下排;滚动区条目整体上移 `m_scrollOffset`; + // 尾部固定区从菜单底部往上对齐。 + const float bodyTop = top + kPadV * m_scale; + const float tailTop = top + height - kPadV * m_scale - tailHeight; + const float scrollBarReserve = m_scrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f; + m_scrollViewport = + m_scrollEnabled + ? gd::RectF(left + kPadH * m_scale, bodyTop + headHeight, + std::max(0.0f, width - kPadH * 2.0f * m_scale), + std::max(0.0f, height - kPadV * 2.0f * m_scale - headHeight - tailHeight)) + : gd::RectF(); + + float cursorY = bodyTop; + float headCursorY = bodyTop; + float scrollCursorY = bodyTop + headHeight - m_scrollOffset; + float tailCursorY = (m_scrollEnabled) ? tailTop : bodyTop; + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + const float itemHeight = heights[index]; + + float rectY = cursorY; + float rectWidth = std::max(0.0f, width - kPadH * 2.0f * m_scale); + if (m_scrollEnabled) + { + const int itemIndex = static_cast(index); + if (itemIndex < m_scrollFirst) + { + rectY = headCursorY; + headCursorY += itemHeight; + } + else if (itemIndex < m_pinnedIndex) + { + rectY = scrollCursorY; + scrollCursorY += itemHeight; + // 滚动区条目让出右侧滚动条的位置(对勾/文本不会压在滑块上)。 + rectWidth = std::max(0.0f, rectWidth - scrollBarReserve); + } + else + { + rectY = tailCursorY; + tailCursorY += itemHeight; + } + } + m_itemRects[index] = + gd::RectF(left + kPadH * m_scale, rectY, rectWidth, itemHeight); + + if (item.kind == MenuItemKind::SWATCHES) + { + const int count = std::max(0, std::min(item.swatchCount, 16)); + std::vector& rects = m_swatchRects[index]; + rects.reserve(static_cast(count)); + 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) + { + const float swatchX = + startX + static_cast(swatch) * (kSwatchSize + kSwatchGap) * m_scale; + rects.push_back(gd::RectF(swatchX, swatchY, kSwatchSize * m_scale, kSwatchSize * m_scale)); + } + } + + cursorY += itemHeight; + } + + // 滚动条轨道:贴在可滚动条目区的右内侧(只在条目区旁边,不会盖住固定区)。 + if (m_scrollEnabled) + { + const float thickness = kScrollBarWidth * m_scale; + m_scrollBarRect = gd::RectF(left + width - kPadH * m_scale - thickness, + m_scrollViewport.Y, thickness, m_scrollViewport.Height); + } + } + + bool PopupMenu::ScrollRegion(int& first, int& last) const + { + if (!m_scrollEnabled || m_pinnedIndex < 0) + { + first = 0; + last = static_cast(m_items.size()); + return false; + } + first = m_scrollFirst; + last = m_pinnedIndex; + return true; + } + + gd::RectF PopupMenu::ScrollBarThumbRect() const + { + if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f || m_scrollRegionHeight <= 0.0f) + { + return gd::RectF(); + } + const float viewport = m_scrollViewport.Height; + if (viewport <= 0.0f) + { + return gd::RectF(); + } + const float ratio = std::min(1.0f, viewport / m_scrollRegionHeight); + const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, m_scrollBarRect.Height * ratio); + const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport); + const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f; + const float thumbY = + m_scrollBarRect.Y + + std::max(0.0f, std::min(1.0f, progress)) * std::max(0.0f, m_scrollBarRect.Height - thumbHeight); + return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); + } + + float PopupMenu::ScrollOffsetForThumbTop(float thumbTop) const + { + if (!m_scrollEnabled || m_scrollBarRect.Height <= 0.0f) + { + return m_scrollOffset; + } + const float viewport = m_scrollViewport.Height; + const float maxScroll = std::max(0.0f, m_scrollRegionHeight - viewport); + if (maxScroll <= 0.0f) + { + return 0.0f; + } + const float track = m_scrollBarRect.Height; + const float thumbHeight = std::max(kScrollBarMinThumb * m_scale, track * std::min(1.0f, viewport / std::max(1.0f, m_scrollRegionHeight))); + const float travel = std::max(0.0f, track - thumbHeight); + if (travel <= 0.0f) + { + return 0.0f; + } + const float relative = thumbTop - m_scrollBarRect.Y; + const float progress = std::max(0.0f, std::min(1.0f, relative / travel)); + return progress * maxScroll; + } + + bool PopupMenu::EnsureItemVisible(int index) + { + if (!m_scrollEnabled || index < m_scrollFirst || index >= m_pinnedIndex || + m_scrollViewport.Height <= 0.0f) + { + return false; + } + const gd::RectF& rect = m_itemRects[static_cast(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(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 = + (item.editValue.size() > item.value.size()) ? item.editValue : item.value; + const float natural = std::max(kEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale); + const float withSuffix = natural + EditSuffixWidth(item); + // 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。 + return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix; + } + + void PopupMenu::CloseSubmenu() + { + m_subOpen = false; + m_subParentIndex = -1; + m_subRect = gd::RectF(); + m_subItems.clear(); + m_subItemRects.clear(); + m_subHoverIndex = -1; + m_subPressedIndex = -1; + } + + void PopupMenu::BuildSubLayout(int parentIndex) + { + CloseSubmenu(); + if (parentIndex < 0 || parentIndex >= static_cast(m_items.size())) + { + return; + } + const MenuItem& parent = m_items[static_cast(parentIndex)]; + if (parent.kind != MenuItemKind::SUBMENU || parent.submenu.empty()) + { + return; + } + + m_subItems = parent.submenu; + + // 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。 + float contentWidth = kMinWidth * m_scale; + float totalHeight = kPadV * 2.0f * m_scale; + for (const MenuItem& item : m_subItems) + { + totalHeight += ItemHeight(item); + if (item.kind == MenuItemKind::SWATCHES) + { + const int count = std::max(0, std::min(item.swatchCount, 16)); + const float width = (kPadH + kItemPad) * 2.0f * m_scale + + static_cast(count) * kSwatchSize * m_scale + + static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::HEADER) + { + const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + + kMarkerGap * m_scale + + UiMeasureTextWidth(m_fonts->uiSmallBold, item.label); + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::EDIT) + { + const float fieldWidth = NaturalEditFieldWidth(item); + const float width = (kPadH + kItemPad) * 2.0f * m_scale + + UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale + + fieldWidth; + contentWidth = std::max(contentWidth, width); + } + else if (item.kind == MenuItemKind::NORMAL || item.kind == MenuItemKind::SUBMENU) + { + const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + + kMarkerGap * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, item.label); + contentWidth = std::max(contentWidth, width); + } + } + contentWidth = std::min(contentWidth, kMaxWidth * m_scale); + + const float width = contentWidth; + const float height = totalHeight; + const gd::RectF& parentRect = m_itemRects[static_cast(parentIndex)]; + const gd::RectF& bounds = m_buildBounds; + + // 默认贴主菜单右缘并向上与父项顶端对齐;右侧空间不足时翻到左侧。 + float left = m_rect.X + m_rect.Width - kSubOverlap * m_scale; + if (left + width > bounds.X + bounds.Width - 2.0f) + { + left = m_rect.X - width + kSubOverlap * m_scale; + } + left = std::max(bounds.X + 2.0f, std::min(left, bounds.X + bounds.Width - width - 2.0f)); + + float top = parentRect.Y - kPadV * m_scale; + top = std::max(bounds.Y + 2.0f, std::min(top, bounds.Y + bounds.Height - height - 2.0f)); + + m_subRect = gd::RectF(left, top, width, height); + m_subItemRects.assign(m_subItems.size(), gd::RectF()); + + float cursorY = top + kPadV * m_scale; + for (std::size_t index = 0; index < m_subItems.size(); ++index) + { + const float itemHeight = ItemHeight(m_subItems[index]); + m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY, + std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight); + cursorY += itemHeight; + } + + m_subParentIndex = parentIndex; + m_subOpen = true; + m_subHoverIndex = -1; + m_subPressedIndex = -1; + } + + int PopupMenu::SubItemAtPoint(int x, int y) const + { + if (!m_subOpen) + { + return -1; + } + const float fx = static_cast(x); + const float fy = static_cast(y); + for (std::size_t index = 0; index < m_subItems.size(); ++index) + { + const MenuItem& item = m_subItems[index]; + if (item.kind == MenuItemKind::SEPARATOR || item.kind == MenuItemKind::HEADER) + { + continue; + } + if (!item.enabled) + { + continue; + } + if (UiPointInRect(m_subItemRects[index], fx, fy)) + { + return static_cast(index); + } + } + return -1; + } + + float PopupMenu::EditSuffixWidth(const MenuItem& item) const + { + if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty()) + { + return 0.0f; + } + return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale; + } + + gd::RectF PopupMenu::EditFieldRect(int index) const + { + if (index < 0 || index >= static_cast(m_items.size())) + { + return gd::RectF(); + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::EDIT) + { + return gd::RectF(); + } + + // 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度, + // 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。 + // ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限, + // 让输入框独占整行。 + // ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`: + // 同一面板里所有这类输入框必须一样宽、右边界对齐。 + const gd::RectF& rect = m_itemRects[static_cast(index)]; + const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale); + const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale; + float width = NaturalEditFieldWidth(item); + width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth)); + if (item.label.empty()) + { + // 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。 + width = contentWidth; + } + else + { + if (m_editFieldWidth > 0.0f) + { + width = m_editFieldWidth; + } + width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth)); + width = std::min(width, contentWidth * 0.75f); + } + + const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale; + return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width, + rect.Y + (rect.Height - height) * 0.5f, width, height); + } + + gd::RectF PopupMenu::HeaderIconRect(int index) const + { + if (index < 0 || index >= static_cast(m_items.size())) + { + return gd::RectF(); + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::HEADER_ACTION) + { + return gd::RectF(); + } + const gd::RectF& rect = m_itemRects[static_cast(index)]; + const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale)); + return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size, + rect.Y + (rect.Height - size) * 0.5f, size, size); + } + + bool PopupMenu::IsOnEditField(int x, int y) const + { + if (m_editIndex < 0) + { return false; + } + const gd::RectF rect = EditFieldRect(m_editIndex); + return UiPointInRect(rect, static_cast(x), static_cast(y)); + } + + bool PopupMenu::BeginItemEdit(int index) + { + if (!m_open || index < 0 || index >= static_cast(m_items.size())) + { + return false; + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::EDIT || !item.enabled) + { + return false; + } + + // 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。 + m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u); + m_editIndex = index; + m_hoverIndex = index; + return true; + } + + bool PopupMenu::EndItemEdit(bool commit, MenuResult& result) + { + if (m_editIndex < 0) + { + return false; + } + + const int index = m_editIndex; + const std::wstring text = m_edit.Text(); + m_editIndex = -1; + m_edit.End(); + + if (!commit || index >= static_cast(m_items.size())) + { + return true; + } + + MenuItem& item = m_items[static_cast(index)]; + // 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值); + // 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。 + item.value = text; + item.editValue = text; + + result.valid = true; + result.scope = m_scope; + result.id = item.id; + result.editCommit = true; + result.editText = text; + return true; + } + + void PopupMenu::RebuildLayout() + { + if (!m_open) + { + return; + } + const int subParent = m_subParentIndex; + BuildLayout(m_buildBounds, m_buildX, m_buildY, m_buildExpandUp); + // 主菜单重新排布后子菜单位置会失效:若之前是展开的则按新几何重建。 + if (subParent >= 0) + { + BuildSubLayout(subParent); + } + } + + bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue) + { + for (MenuItem& item : m_items) + { + if (item.id == id && item.kind == MenuItemKind::EDIT) + { + const bool valueSame = (item.value == value); + const bool editSame = editValue.empty() || (item.editValue == editValue); + if (valueSame && editSame) + { + return true; + } + item.value = value; + if (!editValue.empty()) + { + item.editValue = editValue; + } + RebuildLayout(); + return true; + } + } + return false; + } + + bool PopupMenu::UpdateItemLabel(int id, const std::wstring& label, bool checked, bool enabled) + { + for (MenuItem& item : m_items) + { + if (item.id == id && item.kind == MenuItemKind::NORMAL) + { + if (item.label == label && item.checked == checked && item.enabled == enabled) + { + return true; + } + item.label = label; + item.checked = checked; + item.enabled = enabled; + RebuildLayout(); + return true; + } + } + return false; + } + +} // namespace jdi diff --git a/Components/Shared/Src/PopupMenuPaint.cpp b/Components/Shared/Src/PopupMenuPaint.cpp new file mode 100644 index 0000000..7e4e0c4 --- /dev/null +++ b/Components/Shared/Src/PopupMenuPaint.cpp @@ -0,0 +1,400 @@ +// PopupMenu 实现分片 —— 绘制(主菜单 + 二级子菜单)。 +// +// 本文件只包含 PopupMenu 的成员函数定义(从 PopupMenu.cpp 机械搬移,行为不变)。 +// +// ⚠️ 菜单是叠在最上层的手绘浮层:文字走 `UiText`(GDI,不认 GDI+ 裁剪区), +// 因此所有文字必须由排版阶段算好的矩形约束,不能指望裁剪。 + +#include "PopupMenu.hpp" + +#include "PopupMenuInternal.hpp" +#include "UiControls.hpp" + +namespace jdi +{ + void PopupMenu::Paint(gd::Graphics& g) + { + if (!m_open || m_theme == nullptr || m_fonts == nullptr || m_rect.Width <= 0.0f) + { + return; + } + + // 投影:用两层递减透明度的圆角矩形模拟,避免引入系统阴影 API。 + UiFillRoundRect(g, gd::RectF(m_rect.X + 1.0f * m_scale, m_rect.Y + 2.0f * m_scale, m_rect.Width, + m_rect.Height), + kCorner * m_scale, 0x14000000u); + UiFillRoundRect(g, gd::RectF(m_rect.X + 2.0f * m_scale, m_rect.Y + 4.0f * m_scale, m_rect.Width, + m_rect.Height), + kCorner * m_scale, 0x10000000u); + + 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, m_scrollFirst)`:**前导标题**(「所属网格」这类)—— 钉在菜单顶部,**不参与滚动**; + // * `[m_scrollFirst, m_pinnedIndex)`:滚动区(裁剪到滚动视口,右侧有滚动条); + // * `[m_pinnedIndex, end)`:末尾固定项(最后一个分隔线 + 「新建网格」)钉在菜单底部。 + const bool scrollable = m_scrollEnabled && m_pinnedIndex > 0; + const int scrollFirst = scrollable ? m_scrollFirst : static_cast(m_items.size()); + const int scrollLast = scrollable ? m_pinnedIndex : static_cast(m_items.size()); + int clipState = 0; + bool clipActive = false; + + for (std::size_t index = 0; index < m_items.size(); ++index) + { + const MenuItem& item = m_items[index]; + const gd::RectF& rect = m_itemRects[index]; + const int itemIndex = static_cast(index); + // 进入/离开滚动区:按需开启/撤销 GDI+ 裁剪(固定区永远完整可见)。 + if (scrollable && itemIndex == scrollFirst && !clipActive) + { + clipState = g.Save(); + g.SetClip(m_scrollViewport, gd::CombineModeIntersect); + clipActive = true; + } + else if (scrollable && itemIndex == scrollLast && clipActive) + { + g.Restore(clipState); + clipActive = false; + } + const bool inScrollRegion = itemIndex >= scrollFirst && itemIndex < scrollLast; + // 滚动区里完全不可见的条目直接跳过:长菜单(上百条)逐条绘制会明显卡顿。 + if (scrollable && inScrollRegion && + (rect.Y + rect.Height <= m_scrollViewport.Y || + rect.Y >= m_scrollViewport.Y + m_scrollViewport.Height)) + { + continue; + } + const bool clipped = scrollable && inScrollRegion; + + if (item.kind == MenuItemKind::SEPARATOR) + { + const float lineY = rect.Y + rect.Height * 0.5f; + UiDrawLine(g, rect.X + kItemPad * m_scale, lineY, + rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f); + continue; + } + + if (item.kind == MenuItemKind::HEADER) + { + // 标题与普通项共用同一文字列,保证菜单内所有文本严格左对齐。 + itemText(item.label, m_fonts->uiSmallBold, + gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), + m_theme->textMuted, TextAlign::LEFT, clipped); + continue; + } + + if (item.kind == MenuItemKind::HEADER_ACTION) + { + // 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。 + const gd::RectF icon = HeaderIconRect(static_cast(index)); + const float labelWidth = + std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale); + 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, clipped); + if (icon.Width > 0.0f) + { + const bool iconHovered = (static_cast(index) == m_hoverIconIndex); + if (iconHovered) + { + UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale, + icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale), + 4.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale); + } + continue; + } + + const bool hovered = (static_cast(index) == m_hoverIndex) && + (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0); + + if (item.kind == MenuItemKind::EDIT) + { + // 编辑项:左侧说明文字 + 右侧内联输入框(与拾色器的 RGB 编辑同一种处理方式, + // 菜单始终不关闭,数值就地修改)。 + if (hovered && item.enabled && static_cast(index) != m_editIndex) + { + UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22)); + } + + const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted; + // 说明文字与普通项使用同一文字列(kItemPad),保证菜单内所有文本左对齐。 + const float labelLeft = rect.X + kItemPad * m_scale; + const gd::RectF fieldRect = EditFieldRect(static_cast(index)); + const float labelWidth = std::max(8.0f, fieldRect.X - labelLeft - kEditFieldGap * m_scale); + 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(index) == m_editIndex) + { + // 编辑态:输入框底板覆盖整行,正文区向右让出后缀宽度, + // 后缀(单位提示)靠右对齐画在它右侧。 + // ⚠️ 必须用 `UiDrawEditFieldContent`(只画内容)——`UiDrawEditField` + // 会把底板/描边按传入矩形绘制,那样带后缀时外框会短一截。 + const gd::RectF textRect = + (suffix > 0.0f) + ? gd::RectF(fieldRect.X, fieldRect.Y, std::max(12.0f, fieldRect.Width - suffix), + fieldRect.Height) + : fieldRect; + UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f); + UiDrawEditFieldContent(g, textRect, m_edit, m_fonts->uiSmall, m_scale); + if (suffix > 0.0f) + { + const float suffixLeft = textRect.X + textRect.Width; + 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, clipped); + } + } + else + { + UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); + 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); + 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, clipped); + } + continue; + } + + if (item.kind == MenuItemKind::SWATCHES) + { + const std::vector& rects = m_swatchRects[index]; + for (std::size_t swatch = 0; swatch < rects.size(); ++swatch) + { + const gd::RectF& swatchRect = rects[swatch]; + UiFillRoundRect(g, swatchRect, 4.0f * m_scale, item.swatches[swatch]); + + const bool swatchHover = (static_cast(index) == m_hoverIndex) && + (static_cast(swatch) == m_hoverSwatch); + if (static_cast(swatch) == item.checkedSwatch || swatchHover) + { + const gd::RectF ring(swatchRect.X - 2.0f * m_scale, swatchRect.Y - 2.0f * m_scale, + swatchRect.Width + 4.0f * m_scale, + swatchRect.Height + 4.0f * m_scale); + UiDrawRoundRect(g, ring, 5.0f * m_scale, + (static_cast(swatch) == item.checkedSwatch) ? m_theme->textStrong + : m_theme->borderStrong, + (static_cast(swatch) == item.checkedSwatch) ? 1.8f : 1.0f); + } + } + continue; + } + + // 普通项。 + std::uint32_t background = 0; + if (hovered && item.enabled) + { + background = m_theme->accent; + } + else if (static_cast(index) == m_pressedIndex) + { + background = m_theme->pressed; + } + if (background != 0) + { + UiFillRoundRect(g, rect, 4.0f * m_scale, background); + } + + const std::uint32_t textColor = !item.enabled ? m_theme->textMuted + : ((hovered && item.enabled) ? m_theme->textOnAccent + : m_theme->text); + + if (item.checked) + { + // 单选标记:对勾固定画在条目**右侧**的标记列内,左侧只留统一的小边距, + // 这样菜单文本左边距不会因为“可能存在的对勾”而被整体推右。 + const float markerRight = rect.X + rect.Width - kItemPad * m_scale; + const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent; + UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale, + markerColor, 1.8f * m_scale); + UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale, + cy - 4.0f * m_scale, markerColor, 1.8f * m_scale); + } + + // 带子菜单的项:在右侧标记列画一个指向右的三角,提示可以继续展开。 + if (item.kind == MenuItemKind::SUBMENU) + { + const float markerRight = rect.X + rect.Width - kItemPad * m_scale; + const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const float size = kSubArrowSize * m_scale; + gd::PointF arrow[3]; + arrow[0] = gd::PointF(cx - size * 0.5f, cy - size); + arrow[1] = gd::PointF(cx - size * 0.5f, cy + size); + arrow[2] = gd::PointF(cx + size * 0.7f, cy); + gd::SolidBrush brush(ToGdiColor(textColor)); + g.FillPolygon(&brush, arrow, 3); + } + + const float textLeft = rect.X + kItemPad * m_scale; + const float textWidth = + rect.Width - 2.0f * kItemPad * m_scale - kMarkerColumn * m_scale - kMarkerGap * m_scale; + 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, clipped); + } + + // 恢复滚动区的裁剪(若已因固定区而恢复过则 `Restore` 会成对生效)。 + if (clipActive) + { + g.Restore(clipState); + clipActive = false; + } + + // 滚动条:轨道 + 滑块(画在条目之上,仅在可滚动菜单里出现)。 + // 位置限制在可滚动条目区旁边,因此不会盖住顶部标题与底部「新建网格」。 + if (scrollable && m_scrollBarRect.Width > 0.0f) + { + const float radius = kScrollBarWidth * m_scale * 0.5f; + UiFillRoundRect(g, m_scrollBarRect, radius, m_theme->gridWeak); + const gd::RectF thumb = ScrollBarThumbRect(); + if (thumb.Width > 0.0f) + { + UiFillRoundRect(g, thumb, radius, m_theme->borderStrong); + } + } + + // 二级子菜单最后绘制,保证覆盖在主菜单之上。 + PaintSubmenu(g); + } + + void PopupMenu::PaintSubmenu(gd::Graphics& g) + { + if (!m_subOpen || m_subRect.Width <= 0.0f || m_subRect.Height <= 0.0f) + { + return; + } + + UiFillRoundRect(g, gd::RectF(m_subRect.X + 1.0f * m_scale, m_subRect.Y + 2.0f * m_scale, + m_subRect.Width, m_subRect.Height), + kCorner * m_scale, 0x14000000u); + UiFillRoundRect(g, gd::RectF(m_subRect.X + 2.0f * m_scale, m_subRect.Y + 4.0f * m_scale, + m_subRect.Width, m_subRect.Height), + kCorner * m_scale, 0x10000000u); + + UiFillRoundRect(g, m_subRect, kCorner * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, m_subRect, kCorner * m_scale, m_theme->borderStrong, 1.0f); + + for (std::size_t index = 0; index < m_subItems.size(); ++index) + { + const MenuItem& item = m_subItems[index]; + const gd::RectF& rect = m_subItemRects[index]; + + if (item.kind == MenuItemKind::SEPARATOR) + { + const float lineY = rect.Y + rect.Height * 0.5f; + UiDrawLine(g, rect.X + kItemPad * m_scale, lineY, + rect.X + rect.Width - kItemPad * m_scale, lineY, m_theme->border, 1.0f); + continue; + } + + if (item.kind == MenuItemKind::HEADER) + { + UiText(g, item.label, m_fonts->uiSmallBold, + gd::RectF(rect.X + kItemPad * m_scale, rect.Y, rect.Width, rect.Height), + m_theme->textMuted, TextAlign::LEFT); + continue; + } + + const bool hovered = (static_cast(index) == m_subHoverIndex) && item.enabled; + + if (item.kind == MenuItemKind::EDIT) + { + // 子菜单中的编辑项:与主菜单一样就地编辑。 + if (hovered && static_cast(index) != m_editIndex) + { + UiFillRoundRect(g, rect, 4.0f * m_scale, WithAlpha(m_theme->accent, 0x22)); + } + const std::uint32_t labelColor = item.enabled ? m_theme->text : m_theme->textMuted; + const float labelLeft = rect.X + kItemPad * m_scale; + const gd::RectF fieldRect = EditFieldRect(static_cast(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); + UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); + 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, + 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); + continue; + } + + if (item.kind == MenuItemKind::SWATCHES) + { + continue; + } + + std::uint32_t background = 0; + if (hovered) + { + background = m_theme->accent; + } + else if (static_cast(index) == m_subPressedIndex) + { + background = m_theme->pressed; + } + if (background != 0) + { + UiFillRoundRect(g, rect, 4.0f * m_scale, background); + } + + const std::uint32_t textColor = !item.enabled ? m_theme->textMuted + : (hovered ? m_theme->textOnAccent + : m_theme->text); + + // 选中标记(对勾):与主菜单一致画在右侧标记列内。 + if (item.checked) + { + const float markerRight = rect.X + rect.Width - kItemPad * m_scale; + const float cx = markerRight - kMarkerColumn * m_scale * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const std::uint32_t markerColor = hovered ? m_theme->textOnAccent : m_theme->accent; + UiDrawLine(g, cx - 4.0f * m_scale, cy, cx - 1.0f * m_scale, cy + 3.0f * m_scale, + markerColor, 1.8f * m_scale); + UiDrawLine(g, cx - 1.0f * m_scale, cy + 3.0f * m_scale, cx + 4.5f * m_scale, + cy - 4.0f * m_scale, markerColor, 1.8f * m_scale); + } + + 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, + gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor, + TextAlign::LEFT); + } + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiButtonWidget.cpp b/Components/Shared/Src/UiButtonWidget.cpp new file mode 100644 index 0000000..edb3673 --- /dev/null +++ b/Components/Shared/Src/UiButtonWidget.cpp @@ -0,0 +1,340 @@ +#include "UiButtonWidget.hpp" + +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + namespace + { + const float kButtonRadius = 5.0f; /**< 按钮圆角半径(逻辑像素)。 */ + /** @brief 图标按钮的宽度(逻辑像素):与其他按钮同高、略窄。 */ + const float kIconButtonWidth = 40.0f; + /** @brief 图标自身占用的边长(逻辑像素)。 */ + const float kIconSize = 20.0f; + + /** @brief 按比例把一个颜色向目标色混合(与旧 Toolbar 的写法保持一致)。 */ + std::uint32_t BlendToward(std::uint32_t base, std::uint32_t target, double ratio) + { + return MixColor(base, target, ratio); + } + + /** + * @brief 画一个齿轮图标(矢量:8 个齿 + 外环 + 轴孔)。 + * + * 走 GDI+ 矢量而不是字体符号 `⚙`:该字符在不同回退字体里的大小与基线不一致, + * 会出现"图标忽大忽小、垂直不居中"。抗锯齿保持开启(圆环/斜齿需要它才不难看)。 + * + * 几何全部按「半径占 `half` 的比例」推导,因此任意 DPI 下形状一致: + * * 齿占**最外一圈**(0.66R → 1.0R),宽 0.30R —— 必须与外环外缘明显错开, + * 否则齿会被环的描边吃掉,看起来只剩一个"甜甜圈"; + * * 外环中心线 0.54R、笔宽 0.24R(覆盖 0.42R~0.66R),与齿根恰好相接; + * * 轴孔中心线 0.17R、笔宽 0.20R。 + */ + void PaintGearIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb) + { + const float cx = rect.X + rect.Width * 0.5f; + const float cy = rect.Y + rect.Height * 0.5f; + const float half = std::min(rect.Width, rect.Height) * 0.5f; + if (half < 5.0f) + { + return; + } + + const float toothInner = half * 0.66f; // 齿根半径(= 外环外缘) + const float toothWidth = std::max(2.0f, half * 0.30f); + const float ringRadius = half * 0.54f; // 外环中心线半径 + const float ringWidth = std::max(1.6f, half * 0.24f); + const float hubRadius = half * 0.17f; + const float hubWidth = std::max(1.4f, half * 0.20f); + const gd::Color color(argb); + + const gd::GraphicsState state = g.Save(); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.TranslateTransform(cx, cy); + + // 齿:从外环外缘一直伸到最外圈。 + gd::SolidBrush brush(color); + const gd::RectF tooth(-toothWidth * 0.5f, -half, toothWidth, half - toothInner); + for (int index = 0; index < 8; ++index) + { + g.FillRectangle(&brush, tooth); + g.RotateTransform(45.0f); + } + g.ResetTransform(); + + gd::Pen ringPen(color, ringWidth); + g.DrawEllipse(&ringPen, cx - ringRadius, cy - ringRadius, ringRadius * 2.0f, + ringRadius * 2.0f); + + gd::Pen hubPen(color, hubWidth); + g.DrawEllipse(&hubPen, cx - hubRadius, cy - hubRadius, hubRadius * 2.0f, + hubRadius * 2.0f); + + g.Restore(state); + } + } + + void UiButtonWidget::Configure(const UiFonts* fonts, const Theme* theme, float scale, + std::wstring label, bool primary, bool toggle, float textPadding, + float height) + { + m_fonts = fonts; + m_theme = theme; + m_scale = (scale > 0.1f) ? scale : 1.0f; + m_label = std::move(label); + m_primary = primary; + m_toggle = toggle; + m_textPadding = textPadding; + m_height = height; + InvalidateMeasure(); + Invalidate(Bounds()); + } + + void UiButtonWidget::SetLabel(std::wstring label) + { + if (m_label == label) + { + return; + } + m_label = std::move(label); + InvalidateMeasure(); + Invalidate(Bounds()); + } + + void UiButtonWidget::SetIcon(UiButtonIcon icon) + { + if (m_icon == icon) + { + return; + } + m_icon = icon; + // 图标按钮与文本按钮的期望宽度不同 ⇒ 必须让测量失效(宿主随后会重新 `Layout`)。 + InvalidateMeasure(); + Invalidate(Bounds()); + } + + void UiButtonWidget::SetChecked(bool checked) + { + if (m_checked == checked) + { + return; + } + m_checked = checked; + Invalidate(Bounds()); + } + + void UiButtonWidget::SetEnabled(bool enabled) + { + if (m_enabled == enabled) + { + return; + } + m_enabled = enabled; + Invalidate(Bounds()); + } + + void UiButtonWidget::CancelPress() + { + if (!m_pressed) + { + return; + } + m_pressed = false; + ReleaseMouse(); + Invalidate(Bounds()); + } + + gd::RectF UiButtonWidget::ContentRect() const + { + const gd::RectF& rect = Bounds(); + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return gd::RectF(); + } + + if (m_icon != UiButtonIcon::NONE) + { + const float side = kIconSize * m_scale; + return gd::RectF(rect.X + (rect.Width - side) * 0.5f, + rect.Y + (rect.Height - side) * 0.5f, side, side); + } + + if (m_label.empty() || m_fonts == nullptr || m_fonts->ui == nullptr) + { + return gd::RectF(); + } + const float textWidth = UiMeasureTextWidth(m_fonts->ui, m_label); + return gd::RectF(rect.X + (rect.Width - textWidth) * 0.5f, rect.Y, textWidth, + rect.Height); + } + + gd::SizeF UiButtonWidget::OnMeasure(const UiMeasureContext& ctx) + { + const float scale = (ctx.scale > 0.1f) ? ctx.scale : m_scale; + if (m_icon != UiButtonIcon::NONE) + { + // 图标按钮宽度固定:不依赖字体测量,避免图标被文本内边距拉宽。 + return gd::SizeF(kIconButtonWidth * scale, m_height * scale); + } + const float width = (m_fonts != nullptr && m_fonts->ui != nullptr) + ? (m_textPadding * 2.0f * scale + UiMeasureTextWidth(m_fonts->ui, m_label)) + : 0.0f; + return gd::SizeF(width, m_height * scale); + } + + void UiButtonWidget::OnPaint(UiPaintContext& ctx) + { + if (m_theme == nullptr || m_fonts == nullptr || ctx.g == nullptr) + { + return; + } + const gd::RectF& rect = Bounds(); + const bool hovered = Hovered(); + const bool enabled = m_enabled; + std::uint32_t background = 0; + std::uint32_t border = 0; + std::uint32_t textColor = 0; + + if (m_primary) + { + background = enabled ? m_theme->accent + : BlendToward(m_theme->accent, m_canvasColor, 0.6); + if (m_pressed) + { + background = BlendToward(m_theme->accent, 0xFF000000u, 0.12); + } + else if (enabled && hovered) + { + background = BlendToward(m_theme->accent, 0xFFFFFFFFu, 0.12); + } + border = background; + textColor = m_theme->textOnAccent; + } + else + { + background = (m_toggle && m_checked) ? m_theme->accentSoft : m_canvasColor; + if (m_pressed) + { + background = m_theme->pressed; + } + else if (enabled && hovered) + { + background = (m_toggle && m_checked) + ? BlendToward(m_theme->accentSoft, m_theme->accent, 0.16) + : m_theme->hover; + } + if (!enabled) + { + background = m_canvasColor; + } + + if (m_toggle && m_checked && enabled) + { + border = m_theme->accent; + } + else + { + border = BlendToward(m_theme->border, m_canvasColor, enabled ? 0.0 : 0.5); + } + textColor = enabled ? ((m_toggle && m_checked) ? m_theme->textStrong : m_theme->text) + : m_theme->textMuted; + } + + if (background != m_canvasColor) + { + UiFillRoundRect(*ctx.g, rect, kButtonRadius * m_scale, background); + } + if (!m_primary) + { + UiDrawRoundRect(*ctx.g, rect, kButtonRadius * m_scale, border, 1.0f); + } + + // ⚠️ 与旧实现一致:文字走 `UiText`(GDI,不受 GDI+ 裁剪约束)。 + // 按钮矩形本身就在裁剪区内(外扩 1px 只影响描边),因此不需要 `UiTextClipped`。 + if (m_icon != UiButtonIcon::NONE) + { + const float side = kIconSize * m_scale; + const gd::RectF iconRect(rect.X + (rect.Width - side) * 0.5f, + rect.Y + (rect.Height - side) * 0.5f, side, side); + PaintGearIcon(*ctx.g, iconRect, textColor); + return; + } + UiText(*ctx.g, m_label, m_fonts->ui, rect, textColor, TextAlign::CENTER); + } + + UiEventResult UiButtonWidget::OnEvent(UiEvent& event) + { + switch (event.kind) + { + case UiEventKind::MOUSE_DOWN: + if (event.button != UiMouseButton::LEFT || !m_enabled) + { + return UiEventResult(); + } + m_pressed = true; + CaptureMouse(); + { + UiEventResult result = UiEventResult::Handled(); + result.dirty = Bounds(); + return result; + } + case UiEventKind::MOUSE_UP: + if (event.button != UiMouseButton::LEFT || !m_pressed) + { + return UiEventResult(); + } + // 只有"在自身范围内松开"才算点击(按住移开再松开 = 放弃)。 + m_pressed = false; + ReleaseMouse(); + { + UiEventResult result = UiEventResult::Handled(); + result.dirty = Bounds(); + if (Contains(event.x, event.y) && m_enabled && onClick) + { + onClick(); + } + return result; + } + case UiEventKind::MOUSE_LEAVE: + if (m_pressed) + { + m_pressed = false; + ReleaseMouse(); + UiEventResult result = UiEventResult::Handled(); + result.dirty = Bounds(); + return result; + } + return UiEventResult(); + default: + return UiEventResult(); + } + } + + HCURSOR UiButtonWidget::OnQueryCursor(int x, int y) + { + (void)x; + (void)y; + // 与旧实现一致:工具栏按钮不改变光标形态(箭头)。 + return nullptr; + } + + std::uint64_t UiButtonWidget::ContentKey() const + { + std::uint64_t key = BaseContentKey(); + key = UiMixKey(key, static_cast(m_label.size())); + key = UiMixKey(key, static_cast(static_cast(m_icon))); + key = UiMixKey(key, m_primary ? 1u : 0u); + key = UiMixKey(key, m_toggle ? 1u : 0u); + key = UiMixKey(key, m_checked ? 1u : 0u); + key = UiMixKey(key, m_enabled ? 1u : 0u); + key = UiMixKey(key, m_pressed ? 1u : 0u); + key = UiMixKey(key, Hovered() ? 1u : 0u); + return key; + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiComponent.cpp b/Components/Shared/Src/UiComponent.cpp new file mode 100644 index 0000000..a72490f --- /dev/null +++ b/Components/Shared/Src/UiComponent.cpp @@ -0,0 +1,15 @@ +#include "UiComponent.hpp" + +#include "UiDebugBounds.hpp" + +namespace jdi +{ + + // 默认实现放在 .cpp 而不是头文件内联:`UiComponent.hpp` 是四个大组件都要包含的 + // 公共头,不应把 `UiDebugBounds.hpp`(box 收集器)也拉进它们的编译单元。 + void UiComponent::CollectDebugBoxes(UiDebugBoxList& boxes) + { + boxes.Add(Area(), 0); + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiDebugBounds.cpp b/Components/Shared/Src/UiDebugBounds.cpp new file mode 100644 index 0000000..f01608f --- /dev/null +++ b/Components/Shared/Src/UiDebugBounds.cpp @@ -0,0 +1,308 @@ +#include "UiDebugBounds.hpp" + +#include +#include + +namespace jdi +{ + + namespace + { + /** @brief 四条边的描边宽度(设备像素)。 */ + constexpr int kEdgeStroke = 1; + /** @brief 层级 0 的包角基础粗细(设备像素)。 */ + constexpr int kAreaCornerThick = 4; + /** @brief 层级 ≥1 的包角基础粗细(设备像素)。 */ + constexpr int kNodeCornerThick = 3; + /** @brief 包角沿边延伸的长度上限(设备像素)。 */ + constexpr int kCornerLenMax = 14; + /** @brief 短边不超过该值时**只画四个角块、不画边**(小方框画满一圈会像粗边框)。 */ + constexpr int kEdgelessThreshold = 12; + /** @brief 短边不超过该值时完全不画(再画就盖住元素本身了)。 */ + constexpr int kSkipThreshold = 5; + + /** @brief 一个已对齐到整像素、可直接填充的矩形。 */ + struct PixelRect + { + int left = 0; + int top = 0; + int right = -1; + int bottom = -1; + int Width() const { return right - left + 1; } + int Height() const { return bottom - top + 1; } + bool Valid() const { return right >= left && bottom >= top; } + }; + + /** + * @brief 用整数像素矩形填充(要求调用方已把像素偏移模式设为 `None`)。 + * + * 走 GDI+ 而不是 `UiFillRect`:这里需要**精确的 1px 覆盖**,而 `UiFillRect` 的快 + * 路径按 `floor/ceil` 取整并可能转交 GDI,混进调试层会带来额外状态切换开销。 + * + * ⚠️ **每次图元调用约 1µs**(实测 474 个 box × 12 段 = 5.9ms/帧),因此这里刻意 + * 把图元数量从 12 压到 8;并且**不要**改写成 `GraphicsPath` —— `FillPath` 单次约 + * 20µs,比 12 段 `FillRectangle` 还慢(本机实测 22.8ms vs 13.0ms)。 + */ + void FillPixels(gd::Graphics& g, int x, int y, int width, int height, std::uint32_t argb) + { + if (width <= 0 || height <= 0) + { + return; + } + gd::SolidBrush brush{gd::Color(argb)}; + g.FillRectangle(&brush, x, y, width, height); + } + + /** @brief 把逻辑矩形对齐到整像素(**不内缩**,见头文件说明)。 */ + PixelRect AlignToPixels(const gd::RectF& rect) + { + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return PixelRect(); + } + PixelRect out; + out.left = static_cast(std::lround(rect.X)); + out.top = static_cast(std::lround(rect.Y)); + out.right = static_cast(std::lround(rect.X + rect.Width)) - 1; + out.bottom = static_cast(std::lround(rect.Y + rect.Height)) - 1; + if (out.right < out.left) + { + out.right = out.left; + } + if (out.bottom < out.top) + { + out.bottom = out.top; + } + return out; + } + } // namespace + + void UiDebugBoxList::Clear() + { + m_boxes.clear(); + m_occluders.clear(); + m_layer = 0; + m_clip = gd::RectF(); + } + + void UiDebugBoxList::OnTextRect(const gd::RectF& rect) + { + // 文本用固定层级 4:它总是"最里层"的元素(复选框里的字、行里的信号名…)。 + Add(rect, 4); + } + + void UiDebugBoxList::Add(const gd::RectF& rect, int depth) + { + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return; // 空矩形(未布局 / 该元素当前不显示)不登记。 + } + if (m_clip.Width > 0.0f && m_clip.Height > 0.0f) + { + // 与所属组件的区域无交集 ⇒ 已经完全滚出地盘,不必登记(也避免越界画到邻居上)。 + if (rect.X + rect.Width <= m_clip.X || rect.X >= m_clip.X + m_clip.Width || + rect.Y + rect.Height <= m_clip.Y || rect.Y >= m_clip.Y + m_clip.Height) + { + return; + } + } + UiDebugBox box; + box.rect = rect; + box.clip = m_clip; + box.depth = (depth < 0) ? 0 : depth; + box.layer = m_layer; + m_boxes.push_back(box); + } + + void UiDebugBoxList::AddAll(const std::vector& rects, int depth) + { + for (const gd::RectF& rect : rects) + { + Add(rect, depth); + } + } + + void UiDebugBoxList::AddOccluder(const gd::RectF& rect) + { + if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f)) + { + return; + } + Occluder occluder; + occluder.rect = rect; + occluder.layer = m_layer; + m_occluders.push_back(occluder); + } + + std::uint32_t UiDebugBoundsColor(int depth) + { + // 与主题无关的固定配色:调试层必须"永远显眼",不能跟随用户主题变成接近底色。 + switch (depth) + { + case 0: + return 0xFF29B6F6u; // 青蓝:顶层组件区域 + case 1: + return 0xFFE040FBu; // 品红:组件内部的功能分区 + case 2: + return 0xFFFFB300u; // 琥珀:具体控件 / 行 + case 3: + return 0xFF66BB6Au; // 绿:更深的子块(复选框、色块、✕ …) + default: + return 0xFFFF7043u; // 橙:文本墨迹矩形 + } + } + + void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth) + { + UiDebugPaintBoundsClipped(g, rect, gd::RectF(), depth); + } + + void UiDebugPaintBoundsClipped(gd::Graphics& g, const gd::RectF& rect, const gd::RectF& clip, + int depth) + { + const int level = (depth < 0) ? 0 : depth; + + // 先判断"值不值得画":元素太小时画上去就是糊住它本身。 + if (std::min(rect.Width, rect.Height) <= static_cast(kSkipThreshold)) + { + return; + } + + PixelRect pixels = AlignToPixels(rect); + if (!pixels.Valid()) + { + return; + } + + // ⚠️ 与裁剪矩形求交(见 `UiDebugBoxList::SetClip`):组件内部的元素可能落在地盘之外。 + // 用 floor/ceil 而不是取整:裁剪是"能看见多少就画多少",宁可多一像素也不要把 + // 紧贴边界的包角整个裁掉。 + if (clip.Width > 0.0f && clip.Height > 0.0f) + { + PixelRect clipped; + clipped.left = std::max(pixels.left, static_cast(std::floor(clip.X))); + clipped.top = std::max(pixels.top, static_cast(std::floor(clip.Y))); + clipped.right = + std::min(pixels.right, static_cast(std::ceil(clip.X + clip.Width)) - 1); + clipped.bottom = + std::min(pixels.bottom, static_cast(std::ceil(clip.Y + clip.Height)) - 1); + if (!clipped.Valid()) + { + return; + } + pixels = clipped; + } + + const int width = pixels.Width(); + const int height = pixels.Height(); + const int shortSide = std::min(width, height); + const std::uint32_t color = UiDebugBoundsColor(level); + + // 包角粗细随尺寸自适应("小元素变成粗描边"的修复点): + // 基准 4/3px 只在足够大的矩形上使用;短边越小越细,且不超过短边的 1/4。 + const int baseThick = (level == 0) ? kAreaCornerThick : kNodeCornerThick; + int cornerThick = std::max(1, std::min(baseThick, shortSide / 10)); + cornerThick = std::max(1, std::min(cornerThick, std::max(1, shortSide / 4))); + + // 包角长度随短边变化:短边的 1/3,不超过上限,也不小于角本身。 + int cornerLen = std::min(kCornerLenMax, std::max(1, shortSide / 3)); + cornerLen = std::max(cornerLen, cornerThick); + + const gd::GraphicsState state = g.Save(); + // 描边必须锐利:关抗锯齿 + 关半像素偏移,矩形已对齐整像素 ⇒ 1px 就是 1px。 + g.SetSmoothingMode(gd::SmoothingModeNone); + g.SetPixelOffsetMode(gd::PixelOffsetModeNone); + + // ⚠️ 图元数量直接决定耗时(每个约 1µs),因此这里固定为**最多 8 次**: + // 4 条 1px 边 + 4 个加粗角块。 + // 旧实现把每条边拆成"中段 1px + 两端各一段加粗"共 12 次,观感几乎相同 + // (角本来就是"两条粗边的交点"),却多花 50% 时间。 + // 短边很小的矩形只画 4 个角块(画满一圈会让复选框/色块看起来"被框住")。 + const bool drawEdges = (shortSide > kEdgelessThreshold); + if (drawEdges) + { + FillPixels(g, pixels.left, pixels.top, width, kEdgeStroke, color); // 上 + FillPixels(g, pixels.left, pixels.bottom, width, kEdgeStroke, color); // 下 + FillPixels(g, pixels.left, pixels.top, kEdgeStroke, height, color); // 左 + FillPixels(g, pixels.right, pixels.top, kEdgeStroke, height, color); // 右 + } + + // 四个加粗角块(粗细随尺寸自适应,见上)。 + // * 大矩形:角块是 `cornerThick` 见方的方块,与四条 1px 边接成 `⌜ ⌝ ⌞ ⌟`; + // * 小方框(不画边):角块沿水平方向拉长到 `cornerLen`,让"四个角"更明显。 + const int blockW = std::min(drawEdges ? cornerThick : cornerLen, width); + const int blockH = std::min(cornerThick, height); + FillPixels(g, pixels.left, pixels.top, blockW, blockH, color); + FillPixels(g, pixels.right - blockW + 1, pixels.top, blockW, blockH, color); + FillPixels(g, pixels.left, pixels.bottom - blockH + 1, blockW, blockH, color); + FillPixels(g, pixels.right - blockW + 1, pixels.bottom - blockH + 1, blockW, blockH, color); + + g.Restore(state); + } + + void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes) + { + if (boxes.Empty()) + { + return; + } + + // ⚠️ 排序键是 `(layer, depth)`: + // * `layer` 保证"后收集的(更靠上的浮层)压住先收集的" —— 没有它,信号面板的行边界 + // 会盖在右键弹出的二级菜单上面; + // * `depth` 保证同一层内先画父再画子,子元素的彩色包角不会被父的边压住。 + std::vector ordered; + ordered.reserve(boxes.Count()); + for (const UiDebugBox& box : boxes.Items()) + { + ordered.push_back(&box); + } + std::stable_sort(ordered.begin(), ordered.end(), + [](const UiDebugBox* lhs, const UiDebugBox* rhs) + { + if (lhs->layer != rhs->layer) + { + return lhs->layer < rhs->layer; + } + return lhs->depth < rhs->depth; + }); + + for (const UiDebugBox* box : ordered) + { + // ⚠️ 遮挡:浮层(菜单 / 拾色器)是不透明的,它上面的内容不该出现下层组件的边框线。 + // 仅靠 layer 排序只能决定"两个 box 重叠时谁压谁",不能阻止"先画的线仍然可见", + // 因此这里把**更高层**的遮挡矩形从裁剪区里排除。 + gd::Region region; + bool occluded = false; + for (const UiDebugBoxList::Occluder& occluder : boxes.Occluders()) + { + if (occluder.layer <= box->layer) + { + continue; + } + if (!occluded) + { + region.MakeInfinite(); + occluded = true; + } + region.Exclude(occluder.rect); + } + if (!occluded) + { + UiDebugPaintBoundsClipped(g, box->rect, box->clip, box->depth); + continue; + } + + // 已有裁剪矩形时先与它求交,再设进 Graphics(region 天然支持这种"挖洞"裁剪)。 + if (box->clip.Width > 0.0f && box->clip.Height > 0.0f) + { + region.Intersect(box->clip); + } + const gd::GraphicsState state = g.Save(); + g.SetClip(®ion, gd::CombineModeIntersect); + UiDebugPaintBoundsClipped(g, box->rect, gd::RectF(), box->depth); + g.Restore(state); + } + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiLayout.cpp b/Components/Shared/Src/UiLayout.cpp new file mode 100644 index 0000000..081fed4 --- /dev/null +++ b/Components/Shared/Src/UiLayout.cpp @@ -0,0 +1,367 @@ +#include "UiLayout.hpp" + +#include +#include + +namespace jdi +{ + + UiInsets UiInsets::All(float value) + { + UiInsets insets; + insets.left = value; + insets.top = value; + insets.right = value; + insets.bottom = value; + return insets; + } + + UiInsets UiInsets::Symmetric(float horizontal, float vertical) + { + UiInsets insets; + insets.left = horizontal; + insets.right = horizontal; + insets.top = vertical; + insets.bottom = vertical; + return insets; + } + + UiInsets UiInsets::Scaled(float scale) const + { + UiInsets insets; + insets.left = left * scale; + insets.top = top * scale; + insets.right = right * scale; + insets.bottom = bottom * scale; + return insets; + } + + bool UiRectHasArea(const gd::RectF& rect) + { + return rect.Width > 0.0f && rect.Height > 0.0f; + } + + bool UiRectIntersect(const gd::RectF& a, const gd::RectF& b, gd::RectF& out) + { + if (!UiRectHasArea(a) || !UiRectHasArea(b)) + { + out = gd::RectF(); + return false; + } + const float left = std::max(a.X, b.X); + const float top = std::max(a.Y, b.Y); + const float right = std::min(a.X + a.Width, b.X + b.Width); + const float bottom = std::min(a.Y + a.Height, b.Y + b.Height); + if (right <= left || bottom <= top) + { + out = gd::RectF(); + return false; + } + out = gd::RectF(left, top, right - left, bottom - top); + return true; + } + + gd::RectF UiRectUnion(const gd::RectF& a, const gd::RectF& b) + { + if (!UiRectHasArea(a)) + { + return b; + } + if (!UiRectHasArea(b)) + { + return a; + } + const float left = std::min(a.X, b.X); + const float top = std::min(a.Y, b.Y); + const float right = std::max(a.X + a.Width, b.X + b.Width); + const float bottom = std::max(a.Y + a.Height, b.Y + b.Height); + return gd::RectF(left, top, right - left, bottom - top); + } + + gd::RectF UiRectDeflate(const gd::RectF& rect, const UiInsets& insets) + { + const float width = rect.Width - insets.Horizontal(); + const float height = rect.Height - insets.Vertical(); + if (width <= 0.0f || height <= 0.0f) + { + return gd::RectF(); + } + return gd::RectF(rect.X + insets.left, rect.Y + insets.top, width, height); + } + + gd::RectF UiRectInflate(const gd::RectF& rect, float amount) + { + if (!UiRectHasArea(rect) || amount <= 0.0f) + { + return rect; + } + return gd::RectF(rect.X - amount, rect.Y - amount, rect.Width + amount * 2.0f, + rect.Height + amount * 2.0f); + } + + gd::RectF UiRectSnapInward(const gd::RectF& rect, float scale) + { + const float safeScale = (scale > 0.0f) ? scale : 1.0f; + const float x0 = std::ceil(rect.X * safeScale - 0.001f) / safeScale; + const float y0 = std::ceil(rect.Y * safeScale - 0.001f) / safeScale; + const float x1 = std::floor((rect.X + rect.Width) * safeScale + 0.001f) / safeScale; + const float y1 = std::floor((rect.Y + rect.Height) * safeScale + 0.001f) / safeScale; + if (x1 <= x0 || y1 <= y0) + { + return gd::RectF(); + } + return gd::RectF(x0, y0, x1 - x0, y1 - y0); + } + + gd::RectF UiAlignInRect(const gd::RectF& outer, float width, float height, UiAlign horizontal, + UiAlign vertical, float offsetX, float offsetY) + { + float boxWidth = width; + float boxHeight = height; + if (horizontal == UiAlign::STRETCH) + { + boxWidth = outer.Width; + } + if (vertical == UiAlign::STRETCH) + { + boxHeight = outer.Height; + } + boxWidth = std::min(boxWidth, outer.Width); + boxHeight = std::min(boxHeight, outer.Height); + + float x = outer.X; + switch (horizontal) + { + case UiAlign::CENTER: + x = outer.X + (outer.Width - boxWidth) * 0.5f; + break; + case UiAlign::END: + x = outer.X + outer.Width - boxWidth; + break; + default: + break; + } + float y = outer.Y; + switch (vertical) + { + case UiAlign::CENTER: + y = outer.Y + (outer.Height - boxHeight) * 0.5f; + break; + case UiAlign::END: + y = outer.Y + outer.Height - boxHeight; + break; + default: + break; + } + return gd::RectF(x + offsetX, y + offsetY, boxWidth, boxHeight); + } + + float UiStackExtent(const std::vector& sizes, float gap) + { + float total = 0.0f; + std::size_t fixedCount = 0; + for (float size : sizes) + { + if (size >= 0.0f) + { + total += size; + ++fixedCount; + } + } + if (fixedCount > 0 && sizes.size() > 1) + { + total += gap * static_cast(sizes.size() - 1); + } + return total; + } + + void UiStackRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, float* usedExtent) + { + out.clear(); + if (sizes.empty()) + { + if (usedExtent != nullptr) + { + *usedExtent = 0.0f; + } + return; + } + const bool horizontal = (axis == UiAxis::HORIZONTAL); + const float totalExtent = horizontal ? area.Width : area.Height; + const float totalGap = (sizes.size() > 1) ? gap * static_cast(sizes.size() - 1) : 0.0f; + const float fixedExtent = UiStackExtent(sizes, gap) - totalGap; + + std::size_t autoCount = 0; + for (float size : sizes) + { + if (size < 0.0f) + { + ++autoCount; + } + } + // 剩余空间不足时按 0 处理(**不允许**出现负尺寸:负宽高的矩形会画出反向的线)。 + const float remaining = std::max(0.0f, totalExtent - fixedExtent - totalGap); + const float autoExtent = (autoCount > 0) ? (remaining / static_cast(autoCount)) : 0.0f; + + float cursor = horizontal ? area.X : area.Y; + out.reserve(sizes.size()); + for (std::size_t index = 0; index < sizes.size(); ++index) + { + const float extent = (sizes[index] < 0.0f) ? autoExtent : std::max(0.0f, sizes[index]); + if (horizontal) + { + out.push_back(gd::RectF(cursor, area.Y, extent, area.Height)); + } + else + { + out.push_back(gd::RectF(area.X, cursor, area.Width, extent)); + } + cursor += extent; + if (index + 1 < sizes.size()) + { + cursor += gap; + } + } + if (usedExtent != nullptr) + { + *usedExtent = cursor - (horizontal ? area.X : area.Y); + } + } + + float UiFlowRects(const gd::RectF& area, UiAxis axis, const std::vector& sizes, float gap, + std::vector& out, std::size_t& overflow) + { + out.clear(); + overflow = sizes.size(); + if (sizes.empty()) + { + return 0.0f; + } + const bool horizontal = (axis == UiAxis::HORIZONTAL); + const float mainExtent = horizontal ? area.Width : area.Height; + const float crossExtent = horizontal ? area.Height : area.Width; + + float main = 0.0f; // 当前行内已用主轴长度 + float cross = 0.0f; // 当前行起点(交叉轴) + float lineCross = 0.0f; // 当前行最大交叉尺寸 + out.reserve(sizes.size()); + + for (std::size_t index = 0; index < sizes.size(); ++index) + { + const float itemMain = horizontal ? sizes[index].Width : sizes[index].Height; + const float itemCross = horizontal ? sizes[index].Height : sizes[index].Width; + const float needed = (main > 0.0f) ? (main + gap + itemMain) : itemMain; + if (needed > mainExtent && main > 0.0f) + { + // 换行 + cross += lineCross + gap; + main = 0.0f; + lineCross = 0.0f; + } + if (itemMain > mainExtent || cross + itemCross > crossExtent) + { + overflow = index; + break; + } + const float offset = (main > 0.0f) ? (main + gap) : 0.0f; + const float x = horizontal ? (area.X + offset) : (area.X + cross); + const float y = horizontal ? (area.Y + cross) : (area.Y + offset); + out.push_back(gd::RectF(x, y, horizontal ? itemMain : itemCross, + horizontal ? itemCross : itemMain)); + main += offset + itemMain; + lineCross = std::max(lineCross, itemCross); + } + return cross + lineCross; + } + + gd::RectF UiAbsRect(const gd::RectF& outer, float dx, float dy, float width, float height) + { + return gd::RectF(outer.X + dx, outer.Y + dy, std::max(0.0f, width), std::max(0.0f, height)); + } + + std::uint64_t UiMixKey(std::uint64_t seed, std::uint64_t value) + { + // FNV-1a 64 位的一份变体:与项目里其它指纹(SceneContentHash 等)同一风格。 + seed ^= value + 0x9E3779B97F4A7C15ull + (seed << 6) + (seed >> 2); + return seed; + } + + std::uint64_t UiMixKeyFloat(std::uint64_t seed, float value) + { + // 量化到 1/1024 逻辑像素:亚像素抖动不应该让布局缓存反复失效。 + const long long quantized = static_cast(std::llround(value * 1024.0f)); + return UiMixKey(seed, static_cast(quantized)); + } + + UiLayoutCache::UiLayoutCache(std::size_t capacity) + : m_capacity(std::max(1, capacity)) + { + } + + const UiLayoutResult* UiLayoutCache::Find(std::uint64_t key) + { + for (UiLayoutResult& entry : m_entries) + { + if (entry.key == key) + { + entry.stamp = ++m_stamp; + return &entry; + } + } + return nullptr; + } + + const UiLayoutResult& UiLayoutCache::Store(UiLayoutResult result) + { + for (UiLayoutResult& entry : m_entries) + { + if (entry.key == result.key) + { + entry = std::move(result); + entry.stamp = ++m_stamp; + return entry; + } + } + result.stamp = ++m_stamp; + m_entries.push_back(std::move(result)); + Trim(); + // 淘汰只会从队首进行,而新条目在队尾 ⇒ 刚存入的条目一定仍然有效。 + return m_entries.back(); + } + + void UiLayoutCache::Invalidate(std::uint64_t key) + { + for (auto iterator = m_entries.begin(); iterator != m_entries.end(); ++iterator) + { + if (iterator->key == key) + { + m_entries.erase(iterator); + return; + } + } + } + + void UiLayoutCache::Clear() + { + m_entries.clear(); + } + + void UiLayoutCache::Trim() + { + while (m_entries.size() > m_capacity) + { + // 淘汰"最久未被命中"的条目(stamp 最小)。 + auto oldest = m_entries.begin(); + for (auto iterator = m_entries.begin(); iterator != m_entries.end(); ++iterator) + { + if (iterator->stamp < oldest->stamp) + { + oldest = iterator; + } + } + m_entries.erase(oldest); + } + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiSurface.cpp b/Components/Shared/Src/UiSurface.cpp new file mode 100644 index 0000000..78025b8 --- /dev/null +++ b/Components/Shared/Src/UiSurface.cpp @@ -0,0 +1,41 @@ +#include "UiSurface.hpp" + +#include + +#include "UiLayout.hpp" + +namespace jdi +{ + + UiSurface UiSurface::SubView(const gd::RectF& localRect) const + { + UiSurface view; + if (!Valid()) + { + return view; + } + // 子矩形先换算到宿主坐标,再夹在本表面之内:超出本表面的部分没有属于它的像素, + // 若照原样返回 `bits` 偏移会写到相邻区域(典型的"子控件涂到别人身上")。 + const gd::RectF requested(rect.X + localRect.X, rect.Y + localRect.Y, localRect.Width, + localRect.Height); + gd::RectF clamped; + if (!UiRectIntersect(rect, requested, clamped)) + { + return UiSurface(); + } + + view.dc = dc; + view.rect = clamped; + view.stride = stride; + view.version = version; + view.scale = scale; + if (bits != nullptr && stride > 0) + { + const int offsetX = static_cast(std::lround(clamped.X - rect.X)); + const int offsetY = static_cast(std::lround(clamped.Y - rect.Y)); + view.bits = bits + static_cast(offsetY) * (stride / 4) + offsetX; + } + return view; + } + +} // namespace jdi diff --git a/Components/Shared/Src/UiWidget.cpp b/Components/Shared/Src/UiWidget.cpp new file mode 100644 index 0000000..7d726f4 --- /dev/null +++ b/Components/Shared/Src/UiWidget.cpp @@ -0,0 +1,666 @@ +#include "UiWidget.hpp" + +#include +#include + +#include "UiDebugBounds.hpp" + +namespace jdi +{ + + UiEventResult UiEventResult::Handled() + { + UiEventResult result; + result.consumed = true; + result.redraw = true; + return result; + } + + UiEventResult UiEventResult::Swallowed() + { + UiEventResult result; + result.consumed = true; + result.redraw = false; + return result; + } + + UiPaintContext UiPaintContext::WithClip(const gd::RectF& rect) const + { + UiPaintContext child = *this; + gd::RectF intersection; + if (UiRectIntersect(clip, rect, intersection)) + { + child.clip = intersection; + } + else + { + child.clip = gd::RectF(); + } + return child; + } + + UiWidget::UiWidget() = default; + + UiWidget::~UiWidget() + { + // 子节点析构前先把"指向自己的根级状态"清掉:否则根的 m_capture / m_modal / m_hover + // 会留下悬空指针(子节点被移除后又被派发事件 = 访问违例)。 + UiWidget* root = Root(); + if (root != nullptr && root != this) + { + if (root->m_capture == this) + { + root->m_capture = nullptr; + } + if (root->m_modal == this) + { + root->m_modal = nullptr; + } + if (root->m_hover == this) + { + root->m_hover = nullptr; + } + } + } + + // ------------------------------------------------------------------ + // 树结构 + // ------------------------------------------------------------------ + + UiWidget& UiWidget::AddChild(std::unique_ptr child) + { + if (child == nullptr) + { + return *this; + } + UiWidget* raw = child.get(); + raw->m_parent = this; + m_children.push_back(std::move(child)); + // 子节点的期望尺寸会影响自己 ⇒ 向上失效。 + InvalidateMeasure(); + return *this; + } + + void UiWidget::RemoveChild(UiWidget* child) + { + if (child == nullptr) + { + return; + } + for (auto iterator = m_children.begin(); iterator != m_children.end(); ++iterator) + { + if (iterator->get() == child) + { + (*iterator)->m_parent = nullptr; + m_children.erase(iterator); + InvalidateMeasure(); + return; + } + } + } + + UiWidget* UiWidget::Root() + { + UiWidget* node = this; + while (node->m_parent != nullptr) + { + node = node->m_parent; + } + return node; + } + + const UiWidget* UiWidget::Root() const + { + const UiWidget* node = this; + while (node->m_parent != nullptr) + { + node = node->m_parent; + } + return node; + } + + // ------------------------------------------------------------------ + // 布局 + // ------------------------------------------------------------------ + + void UiWidget::InvalidateMeasure() + { + if (!m_measureValid) + { + // 已经失效 ⇒ 不必继续上溯(祖先此前也一定被失效过)。 + return; + } + m_measureValid = false; + if (m_parent != nullptr) + { + m_parent->InvalidateMeasure(); + } + } + + gd::SizeF UiWidget::Measure(const UiMeasureContext& ctx) + { + const gd::SizeF size = m_measureValid ? m_desiredSize : OnMeasure(ctx); + m_desiredSize = size; + m_measureValid = true; + return m_desiredSize; + } + + void UiWidget::Arrange(const gd::RectF& bounds) + { + m_bounds = bounds; + OnArrange(); + } + + void UiWidget::MeasureChildren(const UiMeasureContext& ctx) + { + for (const std::unique_ptr& child : m_children) + { + if (child == nullptr || !child->m_visible) + { + continue; + } + child->Measure(ctx); + } + } + + gd::SizeF UiWidget::ChildDesiredSize(std::size_t index) const + { + if (index >= m_children.size() || m_children[index] == nullptr) + { + return gd::SizeF(0.0f, 0.0f); + } + return m_children[index]->m_desiredSize; + } + + // ------------------------------------------------------------------ + // 绘制 + // ------------------------------------------------------------------ + + bool UiWidget::DirtyIntersects(const gd::RectF& dirty) const + { + gd::RectF intersection; + return UiRectIntersect(dirty, m_bounds, intersection); + } + + void UiWidget::Paint(UiPaintContext& ctx) + { + if (!m_visible || ctx.g == nullptr || !UiRectHasArea(m_bounds)) + { + return; + } + + // (1) 与脏区求交:**脏区为空表示"未知 ⇒ 全画"**。 + gd::RectF effectiveDirty = ctx.dirty; + if (UiRectHasArea(effectiveDirty) && !DirtyIntersects(effectiveDirty)) + { + // 本节点及其整棵子树都不在脏区内 ⇒ 直接跳过(这是本框架唯一真正省时间的机制)。 + return; + } + + // (2) 裁剪 = 祖先裁剪切 ∩ (自身矩形外扩 1px);脏区非空时再与脏区求交。 + // ⚠️ 外扩 1px 是刻意设计的:边框/描边是**以矩形边界为中心**画的(`UiDrawRoundRect` + // 的 1px 笔宽有一半在矩形外),严格按矩形裁剪会把外半侧切掉 ⇒ 控件看起来变细。 + // 这是与旧绘制代码保持“逐像素一致”的必要保证。 + UiPaintContext local = ctx; + gd::RectF clip; + if (!UiRectIntersect(ctx.clip, UiRectInflate(m_bounds, kUiClipBleed), clip)) + { + return; // 完全被祖先裁掉 + } + if (UiRectHasArea(effectiveDirty)) + { + gd::RectF clipWithDirty; + if (!UiRectIntersect(clip, effectiveDirty, clipWithDirty)) + { + return; + } + clip = clipWithDirty; + } + local.clip = clip; + + // (3) 收紧 GDI+ 裁剪。⚠️ 这里**只**约束 GDI+ 矢量绘制;GDI 绘制(`UiText` / + // `UiFillRect` 的快路径 / `UiDrawSolidLines`)**不受**该裁剪约束 ⇒ 派生类必须把 + // `local.clip` 传给 `UiTextClipped` 一类带 `ETO_CLIPPED` 的辅助函数, + // 否则文字会画到本节点之外(本项目"滚动时文字串到标题栏/工具栏"就是这类 BUG)。 + const int saveState = ctx.g->Save(); + ctx.g->SetClip(local.clip, gd::CombineModeIntersect); + + local.dirty = UiRectHasArea(effectiveDirty) ? clip : gd::RectF(); + OnPaint(local); + + // (4) 子节点:顺序 = z 序(末尾最上层),脏区向下取交集。 + for (const std::unique_ptr& child : m_children) + { + if (child == nullptr) + { + continue; + } + UiPaintContext childContext = local; + gd::RectF childClip; + if (!UiRectIntersect(local.clip, UiRectInflate(child->m_bounds, kUiClipBleed), childClip)) + { + continue; + } + childContext.clip = childClip; + childContext.dirty = local.dirty; + child->Paint(childContext); + } + + ctx.g->Restore(saveState); + } + + bool UiWidget::DispatchRedraw(UiEvent& event) + { + Dispatch(event); + return UiRectHasArea(TakeDirty()); + } + + void UiWidget::CollectDebugBoxes(UiDebugBoxList& boxes, int depth) const + { + if (!m_visible || !UiRectHasArea(m_bounds)) + { + // 不可见 / 未摆放的节点没有边界可画,其子树也不会被 `Paint` 画出来 ⇒ 一并跳过。 + return; + } + + boxes.Add(m_bounds, depth); + + // 子节点按 z 序递归(末尾最上层)—— 与 `Paint` 的顺序一致,重叠时上层压住下层。 + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr) + { + child->CollectDebugBoxes(boxes, depth + 1); + } + } + } + + void UiWidget::Invalidate(const gd::RectF& rect) + { + if (!UiRectHasArea(rect)) + { + return; + } + m_dirty = UiRectUnion(m_dirty, rect); + if (m_parent != nullptr) + { + // 向上冒泡的是"客户区坐标"的同一块矩形;父层只并集,**不清空**子节点的脏区。 + m_parent->Invalidate(rect); + } + } + + void UiWidget::InvalidateAll() + { + m_dirty = m_bounds; + if (m_parent != nullptr) + { + m_parent->Invalidate(m_bounds); + } + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr) + { + child->InvalidateAll(); + } + } + } + + gd::RectF UiWidget::TakeDirty() + { + const gd::RectF dirty = m_dirty; + m_dirty = gd::RectF(); + return dirty; + } + + // ------------------------------------------------------------------ + // 事件 + // ------------------------------------------------------------------ + + bool UiWidget::Contains(int x, int y) const + { + if (!m_visible || m_hitTransparent || !UiRectHasArea(m_bounds)) + { + return false; + } + const float fx = static_cast(x); + const float fy = static_cast(y); + return fx >= m_bounds.X && fx < m_bounds.X + m_bounds.Width && fy >= m_bounds.Y && + fy < m_bounds.Y + m_bounds.Height; + } + + UiWidget* UiWidget::HitTestDeep(int x, int y) + { + if (!Contains(x, y)) + { + return nullptr; + } + // 反 z 序:末尾(最上层)优先。 + for (auto iterator = m_children.rbegin(); iterator != m_children.rend(); ++iterator) + { + if (*iterator == nullptr) + { + continue; + } + if (UiWidget* hit = (*iterator)->HitTestDeep(x, y)) + { + return hit; + } + } + return this; + } + + UiEventResult UiWidget::Dispatch(UiEvent& event) + { + UiWidget* root = Root(); + if (root == nullptr) + { + return UiEventResult(); + } + + const bool mouseEvent = (event.kind == UiEventKind::MOUSE_MOVE || + event.kind == UiEventKind::MOUSE_DOWN || + event.kind == UiEventKind::MOUSE_UP || + event.kind == UiEventKind::MOUSE_DOUBLE_CLICK || + event.kind == UiEventKind::MOUSE_WHEEL || + event.kind == UiEventKind::QUERY_CURSOR); + + // (1) 捕获优先:拖拽期间所有鼠标事件直接投给捕获者(与 Windows `SetCapture` 同义)。 + if (mouseEvent && root->m_capture != nullptr) + { + return root->m_capture->OnEvent(event); + } + + // (2) 模态:只向模态子树派发(菜单/拾色器等浮层)。 + if (root->m_modal != nullptr) + { + return root->m_modal->DispatchAlongHitPath(event); + } + + // (3) 悬停链(只在根节点维护:鼠标移动会改变悬停者)。 + if (event.kind == UiEventKind::MOUSE_MOVE || event.kind == UiEventKind::MOUSE_LEAVE) + { + root->UpdateHover(event.x, event.y); + } + + // (4) 键盘/字符:沿焦点链派发(无焦点时交给根,保证宿主级快捷键仍可用)。 + if (event.kind == UiEventKind::KEY_DOWN || event.kind == UiEventKind::CHAR) + { + UiWidget* node = (root->m_focus != nullptr) ? root->m_focus : root; + for (; node != nullptr; node = node->m_parent) + { + UiEventResult result = node->OnEvent(event); + if (result.consumed) + { + return result; + } + } + return UiEventResult(); + } + + return DispatchAlongHitPath(event); + } + + void UiWidget::CollectHitChain(int x, int y, std::vector& chain) + { + if (!Contains(x, y)) + { + return; + } + chain.push_back(this); + // 反 z 序:末尾(最上层)优先;只进入**真正命中**的那一个子节点。 + for (auto iterator = m_children.rbegin(); iterator != m_children.rend(); ++iterator) + { + if (*iterator == nullptr) + { + continue; + } + if ((*iterator)->Contains(x, y)) + { + (*iterator)->CollectHitChain(x, y, chain); + return; + } + } + } + + UiEventResult UiWidget::DispatchAlongHitPath(UiEvent& event) + { + if (!m_visible) + { + return UiEventResult(); + } + + const bool positional = (event.kind == UiEventKind::MOUSE_MOVE || + event.kind == UiEventKind::MOUSE_DOWN || + event.kind == UiEventKind::MOUSE_UP || + event.kind == UiEventKind::MOUSE_DOUBLE_CLICK || + event.kind == UiEventKind::MOUSE_WHEEL || + event.kind == UiEventKind::QUERY_CURSOR); + + if (!positional) + { + // 非位置事件(理论上已被 `Dispatch` 分流):自身优先,再逐个子节点。 + UiEventResult result = OnEvent(event); + if (result.consumed) + { + return result; + } + for (const std::unique_ptr& child : m_children) + { + if (child == nullptr || !child->m_visible) + { + continue; + } + result = child->DispatchAlongHitPath(event); + if (result.consumed) + { + return result; + } + } + return UiEventResult(); + } + + std::vector chain; + CollectHitChain(event.x, event.y, chain); + if (chain.empty()) + { + return UiEventResult(); + } + + // 自最深的命中节点向上冒泡:命中者优先,未消费才交给祖先(合法的"兜底"语义)。 + for (UiWidget* node : chain) + { + if (event.kind == UiEventKind::QUERY_CURSOR) + { + if (HCURSOR cursor = node->OnQueryCursor(event.x, event.y)) + { + event.cursor = cursor; + return UiEventResult::Swallowed(); + } + continue; + } + UiEventResult result = node->OnEvent(event); + if (result.consumed) + { + return result; + } + } + if (event.kind == UiEventKind::QUERY_CURSOR && event.cursor != nullptr) + { + return UiEventResult::Swallowed(); + } + return UiEventResult(); + } + + void UiWidget::UpdateHover(int x, int y) + { + UiWidget* target = (m_visible) ? HitTestDeep(x, y) : nullptr; + if (target == m_hover) + { + return; + } + UiWidget* previous = m_hover; + m_hover = target; + if (previous != nullptr) + { + previous->m_hovered = false; + previous->OnHoverChanged(false); + previous->Invalidate(previous->m_bounds); + } + if (target != nullptr) + { + target->m_hovered = true; + target->m_hoverX = x; + target->m_hoverY = y; + target->OnHoverChanged(true); + target->Invalidate(target->m_bounds); + } + } + + // ------------------------------------------------------------------ + // 交互仲裁 + // ------------------------------------------------------------------ + + void UiWidget::CaptureMouse() + { + UiWidget* root = Root(); + if (root != nullptr) + { + root->m_capture = this; + } + } + + void UiWidget::ReleaseMouse() + { + UiWidget* root = Root(); + if (root != nullptr && root->m_capture == this) + { + root->m_capture = nullptr; + } + } + + UiWidget* UiWidget::MouseCaptureWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_capture : nullptr; + } + + void UiWidget::SetModal(bool modal) + { + UiWidget* root = Root(); + if (root == nullptr) + { + return; + } + if (modal) + { + root->m_modal = this; + } + else if (root->m_modal == this) + { + root->m_modal = nullptr; + } + } + + UiWidget* UiWidget::ModalWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_modal : nullptr; + } + + UiWidget* UiWidget::HoverWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_hover : nullptr; + } + + bool UiWidget::HoverPoint(int& x, int& y) const + { + if (!m_hovered) + { + return false; + } + x = m_hoverX; + y = m_hoverY; + return true; + } + + void UiWidget::RequestFocus() + { + UiWidget* root = Root(); + if (root == nullptr || root->m_focus == this) + { + return; + } + if (root->m_focus != nullptr) + { + root->m_focus->Invalidate(root->m_focus->m_bounds); + } + root->m_focus = this; + Invalidate(m_bounds); + } + + void UiWidget::ReleaseFocus() + { + UiWidget* root = Root(); + if (root != nullptr && root->m_focus == this) + { + root->m_focus = nullptr; + Invalidate(m_bounds); + } + } + + UiWidget* UiWidget::FocusWidget() const + { + const UiWidget* root = Root(); + return (root != nullptr) ? root->m_focus : nullptr; + } + + // ------------------------------------------------------------------ + // 动画 + // ------------------------------------------------------------------ + + bool UiWidget::AdvanceAnimation(std::int64_t nowMs) + { + bool active = AdvanceAnimationSelf(nowMs); + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr && child->AdvanceAnimation(nowMs)) + { + active = true; + } + } + return active; + } + + bool UiWidget::AnimationActive() const + { + if (AnimationActiveSelf()) + { + return true; + } + for (const std::unique_ptr& child : m_children) + { + if (child != nullptr && child->AnimationActive()) + { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------ + // 缓存键 + // ------------------------------------------------------------------ + + std::uint64_t UiWidget::BaseContentKey() const + { + std::uint64_t key = 0xCBF29CE484222325ull; + key = UiMixKeyFloat(key, m_bounds.X); + key = UiMixKeyFloat(key, m_bounds.Y); + key = UiMixKeyFloat(key, m_bounds.Width); + key = UiMixKeyFloat(key, m_bounds.Height); + key = UiMixKey(key, m_visible ? 1u : 0u); + return key; + } + +} // namespace jdi diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index d43b9e3..1669f23 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -15,10 +15,13 @@ #include "PaintUtil.hpp" #include "PopupMenu.hpp" #include "Theme.hpp" +#include "UiComponent.hpp" namespace jdi { + class UiDebugBoxList; + /** * @brief 信号面板弹出菜单的命令标识。 */ @@ -51,7 +54,7 @@ namespace jdi * 因此不同标签页的信号勾选与样式互不影响。所有弹出菜单与拾色器均使用主题化自绘 * 浮动面板,不出现系统默认菜单样式。 */ - class SignalPanel + class SignalPanel : public UiComponent { public: SignalPanel(); @@ -113,19 +116,22 @@ namespace jdi * @brief 设置 DPI 缩放比例。 * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 */ - void SetScale(float scale); + void SetScale(float scale) override; /** * @brief 按给定区域重新排布面板内容。 * @param [IN] area 面板可用区域。 */ - void Layout(const gd::RectF& area); + void Layout(const gd::RectF& area) override; + + /** @brief 面板占用的整块区域(与 `Layout` 同源)。 */ + gd::RectF Area() const override { return m_area; } /** * @brief 绘制面板。 * @param [IN,OUT] g 绘图上下文。 */ - void Paint(gd::Graphics& g); + void Paint(gd::Graphics& g) override; /** * @brief 处理鼠标移动。 @@ -200,7 +206,7 @@ namespace jdi * @brief 处理鼠标离开面板。 * @return 视觉状态发生变化返回 true。 */ - bool OnMouseLeave(); + bool OnMouseLeave() override; /** * @brief 处理弹出菜单的选择结果。 @@ -213,7 +219,7 @@ namespace jdi * @brief 判断面板是否处于内联文本输入状态。 * @return 正在输入返回 true。 */ - bool IsEditing() const { return m_editField != StyleEditField::NONE || m_filterEditing; } + bool IsEditing() const override { return m_editField != StyleEditField::NONE || m_filterEditing; } /** * @brief 判断坐标是否落在列表上方的「筛选信号」输入框内。 @@ -348,7 +354,7 @@ namespace jdi * @param [IN] y 客户区纵坐标。 * @return 落在区域内返回 true。 */ - bool HitTest(int x, int y) const; + bool HitTest(int x, int y) const override; /** * @brief 查询该位置应使用的鼠标光标(✕ 类按钮使用手型)。 @@ -357,7 +363,22 @@ namespace jdi * @return 需要特殊光标时返回光标句柄,否则返回 nullptr。 * @note 需要按需重建列表行缓存(查 ✕ 的命中),因此不是 const。 */ - HCURSOR QueryCursor(int x, int y); + HCURSOR QueryCursor(int x, int y) override; + + /** + * @brief 收集面板区域与其内部结构(表头 / 筛选框 / 列表 / 样式区 / 可见行 / 行内元素) + * 的布局边界(调试用)。 + * + * 行内元素(复选框、色块、名称、GX 徽标、✕、折起三角、组计数)的几何与 + * `RowHitPart` / `PaintRow` **同源**:都基于同一个 `RowRect` 与同一批常量, + * 因此"画出来的框"就是"能点到的区域"。 + * + * 面板的行位图缓存与本函数**无关**:调试 box 只由宿主在最后一步统一描边, + * 不进任何缓存 ⇒ 关掉开关后不会留下残影。 + * + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) override; /** * @brief 将列表滚动到指定信号所在位置(无动画,立即到位)。 @@ -876,6 +897,13 @@ namespace jdi std::uint64_t key = 0; /**< 内容键(不一致即重建)。 */ int width = 0; /**< 位图宽度(= 列表宽度)。 */ int height = 0; /**< 位图高度(= 行高)。 */ + /** + * @brief 本行文本的**墨迹矩形**(位图坐标;调试用)。 + * + * ⚠️ 必须缓存:命中缓存的帧根本不会调用 `UiText`,绘制期的文本收集器自然收不到 + * 任何东西 —— 不缓存就会出现"第一帧有文本框、之后全部消失"。 + */ + std::vector textRects; }; std::vector m_rowBitmapCache; /**< 与 `m_rows` 同序的行位图缓存。 */ @@ -1004,6 +1032,19 @@ namespace jdi */ gd::RectF ScrollThumbRect() const; + /** + * @brief 面板的**客户区矩形**(宿主已告知客户区时用它,否则退化为自身区域)。 + * + * 弹出菜单 / 拾色器需要"限制在窗口内"的边界;此前这段三元表达式在 6 处重复, + * 一旦判据变化就要全改,故收敛成一个函数。 + * + * @return 客户区矩形(客户区坐标)。 + */ + gd::RectF PanelBounds() const + { + return (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + } + /** * @brief 布置样式编辑区内各控件的矩形。 */ @@ -1241,6 +1282,15 @@ namespace jdi std::array m_adjustRects; std::vector m_rows; /**< 列表行集合(惰性缓存)。 */ + /** + * @brief 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行的高度之和(长度 = `m_rows.size() + 1`)。 + * + * ⚠️ **性能硬约束**:`RowRect(row)` 原来每次调用都从头累加行高(O(row)), + * 而画一行要调它 5 次(✕ / 徽标 / 名称 / 组计数 / 折起三角各一次)。 + * 200 行列表只看 30 行时每帧就是上万次加法,命中测试(每次鼠标移动)同样走这条路。 + * 改成前缀和之后一次查表即可(O(1)),统一由 `UpdateAnimGeometry()` 重算。 + */ + std::vector m_rowTop; bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ float m_contentHeight = 0.0f; /**< 列表内容总高度。 */ float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 0ccbc4d..147f92e 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -1,9 +1,18 @@ -// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// SignalPanel 实现分片 —— 核心:状态、行集合构建、布局与几何、分组/信号可见性。 // -// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 -// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 +// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 +// 六个分片按**职责**划分: +// SignalPanel.cpp 核心:状态、行集合、布局与几何、可见性(**本文件**) +// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条 +// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状 +// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度 +// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号" +// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执 #include "SignalPanelInternal.hpp" +#include "UiDebugBounds.hpp" +#include "UiLayout.hpp" + namespace jdi { @@ -63,6 +72,123 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } + void SignalPanel::CollectDebugBoxes(UiDebugBoxList& boxes) + { + UiComponent::CollectDebugBoxes(boxes); + + if (m_fonts == nullptr || !UiRectHasArea(m_area)) + { + return; // 尚未完成布局:内部矩形全是空的,登记出来只会误导。 + } + + // 行几何依赖行集合 ⇒ 先确保它是最新的(与绘制走同一个入口)。 + EnsureRows(); + + // ── 层级 1:面板内部的四个功能分区 ──────────────────────────────── + boxes.Add(m_headerRect, 1); + boxes.Add(m_filterRect, 1); + boxes.Add(m_listRect, 1); + boxes.Add(m_styleRect, 1); + + // ── 层级 2:分区内部的固定控件 ──────────────────────────────────── + boxes.Add(m_filterRect, 2); // 输入框外框(与分区边框区分) + boxes.Add(FilterFieldRect(), 3); + boxes.Add(FilterClearRect(), 3); + boxes.Add(m_toggleAllRect, 2); + + boxes.Add(m_scrollBarRect, 2); + boxes.Add(ScrollThumbRect(), 3); + + // ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ────────────────── + for (std::size_t index = 0; index < m_styleRowRects.size(); ++index) + { + boxes.Add(m_styleRowRects[index], 2); + } + for (const gd::RectF& swatch : m_colorRects) + { + boxes.Add(swatch, 3); + } + boxes.Add(m_customChipRect, 3); + for (const gd::RectF& mode : m_modeRects) + { + boxes.Add(mode, 3); + } + for (const gd::RectF& adjust : m_adjustRects) + { + boxes.Add(adjust, 3); + } + boxes.Add(m_widthTrackRect, 3); + boxes.Add(m_widthValueRect, 3); + boxes.Add(m_opacityTrackRect, 3); + boxes.Add(m_opacityValueRect, 3); + + // ── 「目标」行(样式区顶部,展示当前选中信号) ──────────────────── + boxes.Add(m_targetRowRect, 2); + boxes.Add(m_targetNameRect, 3); + boxes.Add(m_targetBadgeRect, 3); + + // ── 列表里**当前可见**的行,以及每行内部的元素 ──────────────────── + // 被滚出列表的行不登记,否则边界会糊到列表区之外。 + const float listTop = m_listRect.Y; + const float listBottom = m_listRect.Y + m_listRect.Height; + const int rowCount = static_cast(m_rows.size()); + for (int row = 0; row < rowCount; ++row) + { + const gd::RectF rect = RowRect(row); + if (!UiRectHasArea(rect)) + { + continue; + } + if (rect.Y + rect.Height <= listTop || rect.Y >= listBottom) + { + continue; + } + + boxes.Add(rect, 2); + + const ListRow& item = m_rows[static_cast(row)]; + const bool isGroup = (item.kind == RowKind::GROUP); + // ⚠️ 行内子块的几何必须与 `RowHitPart` / `PaintRow` **完全同源**: + // 这里复用同一批常量与同一个 `RowRect`,不另写一套推算。 + const float baseX = rect.X + kPadding * m_scale + (isGroup ? 0.0f : kIndent * m_scale); + const float centerY = rect.Y + rect.Height * 0.5f; + + const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height); + const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, + centerY - kCheckSize * m_scale * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + boxes.Add(disclosureRect, 3); + boxes.Add(checkRect, 3); + + if (isGroup) + { + const float labelLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; + boxes.Add(gd::RectF(labelLeft, rect.Y, GroupLabelWidth(row), rect.Height), 3); + boxes.Add(GroupStatsRect(row), 3); + boxes.Add(GroupCloseRect(row), 3); + continue; + } + + const SignalRef ref{item.groupIndex, item.signalIndex}; + const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale, + centerY - kSwatchSize * m_scale * 0.5f, + kSwatchSize * m_scale, kSwatchSize * m_scale); + boxes.Add(swatchRect, 3); + boxes.Add(RowNameRect(row), 3); + if (SignalBadgeVisible(ref)) + { + boxes.Add(SignalBadgeRect(row), 3); + } + boxes.Add(SignalRemoveRect(row), 3); + } + + // ── 内联编辑框(正在重命名时才有意义) ─────────────────────────── + if (m_editField != StyleEditField::NONE) + { + boxes.Add(EditFieldRect(), 4); + } + } + int SignalPanel::TotalSignalCount() const { if (m_dataSet == nullptr) @@ -246,6 +372,22 @@ namespace jdi { m_rowShift.assign(m_rows.size(), 0.0f); + // 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。 + // 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。 + m_rowTop.assign(m_rows.size() + 1, 0.0f); + for (std::size_t index = 0; index < m_rows.size(); ++index) + { + m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; + } + + // 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。 + // 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。 + m_rowTop.assign(m_rows.size() + 1, 0.0f); + for (std::size_t index = 0; index < m_rows.size(); ++index) + { + m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; + } + float totalShift = 0.0f; for (const GroupAnim& anim : m_groupAnims) { @@ -515,17 +657,28 @@ namespace jdi } float top = m_listRect.Y - m_scrollOffset; - for (int index = 0; index < row; ++index) + // O(1) 查表:前缀和由 `UpdateAnimGeometry()` 维护。 + // ⚠️ 前缀和与行集合长度不一致只可能出现在"行集合刚变、几何还没更新"的极短窗口, + // 此时退回逐行累加以保证结果仍然正确(不追求速度)。 + const std::size_t rowIndex = static_cast(row); + if (m_rowTop.size() == m_rows.size() + 1) { - top += m_rows[static_cast(index)].height; + top += m_rowTop[rowIndex]; + } + else + { + for (std::size_t index = 0; index < rowIndex; ++index) + { + top += m_rows[index].height; + } } // 折起/展开动画的刚性平移:组名行之后的行整组上移。 - if (row < static_cast(m_rowShift.size())) + if (rowIndex < m_rowShift.size()) { - top -= m_rowShift[static_cast(row)]; + top -= m_rowShift[rowIndex]; } return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width, - m_rows[static_cast(row)].height); + m_rows[rowIndex].height); } int SignalPanel::GroupLastRowIndex(int row, int rowCount) const @@ -700,8 +853,17 @@ namespace jdi float SignalPanel::ContentOffsetBeforeRow(int row) const { - float top = 0.0f; + // 与 `RowRect` 共用同一份前缀和(O(1)),不再逐行累加。 const int count = std::min(row, static_cast(m_rows.size())); + if (count <= 0) + { + return 0.0f; + } + if (m_rowTop.size() == m_rows.size() + 1) + { + return m_rowTop[static_cast(count)]; + } + float top = 0.0f; for (int index = 0; index < count; ++index) { top += m_rows[static_cast(index)].height; @@ -1242,7 +1404,7 @@ namespace jdi return false; } - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + const gd::RectF bounds = PanelBounds(); // 以记忆的自定义颜色作为初始取值,实现“再次打开即恢复上次取色”。 m_colorPicker->Open(bounds, m_customChipRect, style.customColor); return true; diff --git a/Components/SignalPanel/Src/SignalPanelEdit.cpp b/Components/SignalPanel/Src/SignalPanelEdit.cpp new file mode 100644 index 0000000..8b06438 --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanelEdit.cpp @@ -0,0 +1,655 @@ +// SignalPanel 实现分片 —— 内联编辑(筛选框、组名/信号名/数值输入、样式按钮)。 +// +// 本文件只包含 SignalPanel 的成员函数定义(从 SignalPanelInteraction.cpp 机械搬移,行为不变)。 +// 放进本分片的判据:**与"把用户输入落到数据层"有关**的代码(BeginEdit/EndEdit/OnEdit*、 +// 应用颜色与透明度、筛选框)。交互命中与手势派发仍在 SignalPanelInteraction.cpp。 + +#include "SignalPanelInternal.hpp" + +namespace jdi +{ + bool SignalPanel::IsOnFilterBox(int x, int y) const + { + 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_filterCollapsed.clear(); + m_groupAnims.clear(); + m_rowShift.clear(); + // 清空后回到完整列表:重建行集合、回到顶部。 + m_rowsDirty = true; + m_scrollOffset = 0.0f; + m_layoutDirty = true; + } + + HCURSOR SignalPanel::QueryCursor(int x, int y) + { + // 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。 + const gd::RectF clearRect = FilterClearRect(); + if (clearRect.Width > 0.0f && + UiPointInRect(clearRect, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + + // 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。 + RowPart part = RowPart::NONE; + if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE)) + { + return LoadCursorW(nullptr, IDC_HAND); + } + + // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 + const float fx = static_cast(x); + const float fy = static_cast(y); + // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 + UpdateTargetRowGeometry(); + if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || + (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || + UiPointInRect(m_opacityValueRect, fx, fy)) + { + return LoadCursorW(nullptr, IDC_HAND); + } + return nullptr; + } + + void SignalPanel::BeginFilterEdit() + { + if (m_filterEditing) + { + return; + } + m_filterEditing = true; + // 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。 + m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u); + } + + bool SignalPanel::EndFilterEdit() + { + if (!m_filterEditing) + { + return false; + } + // **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。 + const std::wstring finalText = Trim(m_filterEdit.Text()); + m_filterEditing = false; + m_filterEdit.End(); + // ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成 + // “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。 + ApplyFilterText(finalText); + return true; + } + + bool SignalPanel::ApplyWidthFromX(float x) + { + // ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`, + // 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。 + if (m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + + const double ratio = UiSliderRatioFromX(m_widthTrackRect, x); + float value = static_cast(kWidthMin + ratio * (kWidthMax - kWidthMin)); + value = std::round(value * 10.0f) / 10.0f; + value = ClampFloat(value, kWidthMin, kWidthMax); + if (std::fabs(value - style->lineWidth) < 0.001f) + { + return false; + } + style->lineWidth = value; + return true; + } + + bool SignalPanel::BeginEdit(StyleEditField field, int row) + { + // `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。 + if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE) + { + return false; + } + if (m_editField == field && m_editRow == row) + { + return false; + } + + std::wstring initial; + switch (field) + { + case StyleEditField::WIDTH: + { + const SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + initial = FormatNumber(style->lineWidth, 1); + break; + } + case StyleEditField::OPACITY: + { + const SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); + break; + } + case StyleEditField::SIGNAL_NAME: + { + // `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。 + if (row < 0) + { + const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr; + if (signal == nullptr) + { + return false; + } + initial = signal->name; + break; + } + if (row >= static_cast(m_rows.size())) + { + return false; + } + const ListRow& item = m_rows[static_cast(row)]; + const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex}); + if (signal == nullptr) + { + return false; + } + initial = signal->name; + break; + } + case StyleEditField::GROUP_TITLE: + { + if (row < 0 || row >= static_cast(m_rows.size())) + { + return false; + } + const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast(row)].groupIndex); + if (group == nullptr) + { + return false; + } + initial = group->name; + break; + } + default: + return false; + } + + m_editField = field; + m_editRow = row; + + if (field == StyleEditField::SIGNAL_NAME && row < 0) + { + // 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。 + UpdateTargetRowGeometry(); + } + + // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 + const bool nameField = + (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE); + // 不限长度:名称与数值都由各自的提交逻辑处理超长内容。 + m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); + return true; + } + + bool SignalPanel::EndEdit(bool commit) + { + if (m_editField == StyleEditField::NONE) + { + return false; + } + + const StyleEditField field = m_editField; + const int row = m_editRow; + const std::wstring text = m_edit.Text(); + + m_editField = StyleEditField::NONE; + m_editRow = -1; + m_edit.End(); + + if (!commit || m_tab == nullptr || m_dataSet == nullptr) + { + return true; + } + + const std::wstring trimmed = Trim(text); + switch (field) + { + case StyleEditField::WIDTH: + { + if (trimmed.empty()) + { + break; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || parsed != parsed) + { + break; + } + if (SignalStyle* style = StyleOf(SelectedRef())) + { + const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); + style->lineWidth = std::round(value * 10.0f) / 10.0f; + } + break; + } + case StyleEditField::OPACITY: + { + if (trimmed.empty()) + { + break; + } + wchar_t* stop = nullptr; + const double parsed = std::wcstod(trimmed.c_str(), &stop); + if (stop == trimmed.c_str() || parsed != parsed) + { + break; + } + if (SignalStyle* style = StyleOf(SelectedRef())) + { + style->opacity = ClampFloat(static_cast(parsed) / 100.0f, 0.0f, 1.0f); + } + break; + } + case StyleEditField::SIGNAL_NAME: + { + if (m_dataSetWritable == nullptr) + { + break; + } + if (row < 0) + { + // 样式区「目标」行:重命名当前选中的(目标)信号。 + const SignalRef& target = SelectedRef(); + if (target.IsValid()) + { + m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed); + } + // 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。 + m_targetGeomValid = false; + break; + } + if (row >= static_cast(m_rows.size())) + { + break; + } + const ListRow& item = m_rows[static_cast(row)]; + m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed); + break; + } + case StyleEditField::GROUP_TITLE: + { + if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) + { + break; + } + m_dataSetWritable->RenameGroup(m_rows[static_cast(row)].groupIndex, trimmed); + break; + } + default: + break; + } + return true; + } + + bool SignalPanel::OnEditChar(wchar_t character) + { + // 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。 + if (m_filterEditing) + { + switch (character) + { + case 0x03: // Ctrl+C + m_filterEdit.CopyToClipboard(m_host); + return false; + case 0x18: // Ctrl+X + m_filterEdit.CutToClipboard(m_host); + ApplyFilterText(m_filterEdit.Text()); + return true; + case 0x16: // Ctrl+V + m_filterEdit.PasteFromClipboard(m_host); + ApplyFilterText(m_filterEdit.Text()); + return true; + case 0x01: // Ctrl+A + m_filterEdit.SelectAll(); + return true; + default: + break; + } + if (character == L'\r' || character == L'\n') + { + return EndFilterEdit(); + } + // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。 + if (character < 0x20 || character == 0x7F) + { + return false; + } + if (!m_filterEdit.Insert(character)) + { + return false; + } + ApplyFilterText(m_filterEdit.Text()); + return true; + } + + if (m_editField == StyleEditField::NONE) + { + return false; + } + + // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 + switch (character) + { + case 0x03: // Ctrl+C + m_edit.CopyToClipboard(m_host); + return false; + case 0x18: // Ctrl+X + return m_edit.CutToClipboard(m_host); + case 0x16: // Ctrl+V + return m_edit.PasteFromClipboard(m_host); + case 0x01: // Ctrl+A + m_edit.SelectAll(); + return true; + default: + break; + } + + if (character == L'\r' || character == L'\n') + { + return EndEdit(true); + } + // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符, + // 否则同一次按键会被处理两次(一次删除两个字符)。 + if (character < 0x20 || character == 0x7F) + { + return false; + } + // 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。 + if (m_editField == StyleEditField::OPACITY && character == L'%') + { + return false; + } + return m_edit.Insert(character); + } + + bool SignalPanel::OnEditKey(WPARAM key) + { + // 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。 + if (m_filterEditing) + { + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + switch (key) + { + case VK_RETURN: + return EndFilterEdit(); + case VK_ESCAPE: + { + const bool changed = m_filterEdit.Text() != m_filterText; + EndFilterEdit(); + return changed; + } + case VK_BACK: + { + if (!m_filterEdit.Backspace()) + { + return false; + } + ApplyFilterText(m_filterEdit.Text()); + return true; + } + case VK_DELETE: + { + if (!m_filterEdit.DeleteForward()) + { + return false; + } + ApplyFilterText(m_filterEdit.Text()); + return true; + } + case VK_LEFT: + return m_filterEdit.MoveCaret(-1, shift); + case VK_RIGHT: + return m_filterEdit.MoveCaret(1, shift); + case VK_HOME: + return m_filterEdit.MoveHome(shift); + case VK_END: + return m_filterEdit.MoveEnd(shift); + case 'A': + if (control) + { + m_filterEdit.SelectAll(); + return true; + } + break; + default: + break; + } + return true; + } + + if (m_editField == StyleEditField::NONE) + { + return false; + } + + const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + + switch (key) + { + case VK_RETURN: + return EndEdit(true); + case VK_ESCAPE: + return EndEdit(false); + case VK_BACK: + return m_edit.Backspace(); + case VK_DELETE: + return m_edit.DeleteForward(); + case VK_LEFT: + return m_edit.MoveCaret(-1, shift); + case VK_RIGHT: + return m_edit.MoveCaret(1, shift); + case VK_HOME: + return m_edit.MoveHome(shift); + case VK_END: + return m_edit.MoveEnd(shift); + case 'A': + if (control) + { + m_edit.SelectAll(); + return true; + } + break; + default: + break; + } + return true; + } + + bool SignalPanel::CollapseEditSelectionToEnd() + { + if (m_filterEditing) + { + return m_filterEdit.DeselectToEnd(); + } + if (m_editField == StyleEditField::NONE) + { + return false; + } + return m_edit.DeselectToEnd(); + } + + bool SignalPanel::OnEditMouseDown(int x, int y) + { + if (m_filterEditing) + { + // 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理, + // 否则点击会被当成"把光标移到此处"而清空不了内容。 + // 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。 + const gd::RectF clearRect = FilterClearRect(); + if (clearRect.Width > 0.0f && + UiPointInRect(clearRect, static_cast(x), static_cast(y))) + { + m_pendingFilterClear = true; + return true; + } + return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y); + } + if (m_editField == StyleEditField::NONE) + { + return false; + } + + // ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 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) + { + if (m_filterEditing) + { + return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x); + } + if (m_editField == StyleEditField::NONE) + { + return false; + } + return UiEditFieldMouseMove(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x); + } + + bool SignalPanel::OnEditMouseUp(int x, int y) + { + if (m_filterEditing) + { + // 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位), + // 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算: + // 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。 + if (m_pendingFilterClear) + { + m_pendingFilterClear = false; + const gd::RectF clearRect = FilterClearRect(); + if (clearRect.Width > 0.0f && + UiPointInRect(clearRect, static_cast(x), static_cast(y))) + { + ClearFilter(); + return true; + } + } + if (!m_filterEdit.Dragging()) + { + return false; + } + m_filterEdit.EndDrag(); + return true; + } + if (!m_edit.Dragging()) + { + return false; + } + m_edit.EndDrag(); + return true; + } + + void SignalPanel::SelectSignal(const SignalRef& ref) + { + if (m_dataSetWritable != nullptr) + { + m_dataSetWritable->SetSelected(ref); + } + } + + bool SignalPanel::ApplyColor(std::uint32_t color) + { + // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 + if (m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + + // 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。 + const bool changed = (style->color != color) || (style->customColor != color); + style->color = color; + style->customColor = color; + return changed; + } + + bool SignalPanel::ApplyOpacityFromX(float x) + { + // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 + if (m_dataSetWritable == nullptr) + { + return false; + } + SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + + const float value = static_cast(UiSliderRatioFromX(m_opacityTrackRect, x)); + if (std::fabs(value - style->opacity) < 0.005f) + { + return false; + } + style->opacity = value; + return true; + } + +} // namespace jdi diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index a966ab2..208c7f7 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -1,199 +1,19 @@ -// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// SignalPanel 实现分片 —— 交互(命中、鼠标/键盘派发、光标形状)。 // -// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 -// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 +// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 +// 六个分片按**职责**划分(不再按函数名前缀): +// SignalPanel.cpp 核心:状态、行集合构建、布局与几何、分组/信号可见性 +// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条 +// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状(**本文件**) +// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度 +// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号" +// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执 #include "SignalPanelInternal.hpp" namespace jdi { - bool SignalPanel::IsOnFilterBox(int x, int y) const - { - 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_filterCollapsed.clear(); - m_groupAnims.clear(); - m_rowShift.clear(); - // 清空后回到完整列表:重建行集合、回到顶部。 - m_rowsDirty = true; - m_scrollOffset = 0.0f; - m_layoutDirty = true; - } - - HCURSOR SignalPanel::QueryCursor(int x, int y) - { - // 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。 - const gd::RectF clearRect = FilterClearRect(); - if (clearRect.Width > 0.0f && - UiPointInRect(clearRect, static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_HAND); - } - - // 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。 - RowPart part = RowPart::NONE; - if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE)) - { - return LoadCursorW(nullptr, IDC_HAND); - } - - // 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。 - const float fx = static_cast(x); - const float fy = static_cast(y); - // 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。 - UpdateTargetRowGeometry(); - if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) || - (m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy))) - { - return LoadCursorW(nullptr, IDC_HAND); - } - if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || - UiPointInRect(m_opacityValueRect, fx, fy)) - { - return LoadCursorW(nullptr, IDC_HAND); - } - return nullptr; - } - - void SignalPanel::BeginFilterEdit() - { - if (m_filterEditing) - { - return; - } - m_filterEditing = true; - // 单击进入编辑:光标落在末尾,不做全选(用户可以继续追加筛选词)。 - m_filterEdit.Begin(m_filterText, false, EditFilter::ANY, 0u); - } - - bool SignalPanel::EndFilterEdit() - { - if (!m_filterEditing) - { - return false; - } - // **必须**在 `End()` 之前取出文本:`TextEdit::End()` 会清空内部缓冲区。 - const std::wstring finalText = Trim(m_filterEdit.Text()); - m_filterEditing = false; - m_filterEdit.End(); - // ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成 - // “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。 - ApplyFilterText(finalText); - return true; - } - - bool SignalPanel::AdvanceGroupAnimations() - { - if (m_groupAnims.empty()) - { - return false; - } - - const std::uint64_t now = GetTickCount64(); - bool animationFinished = false; - for (std::size_t index = 0; index < m_groupAnims.size();) - { - const GroupAnim& anim = m_groupAnims[index]; - const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0; - if (anim.durationMs == 0u || elapsed >= anim.durationMs) - { - // 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。 - if (anim.scrollSynced) - { - m_scrollOffset = anim.scrollTo; - } - animationFinished = true; - m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); - } - else - { - ++index; - } - } - - // ⚠️ **动画结束(折起与展开都要)后必须重建行集合**: - // * 折起:该组的信号行要真正从行集合里移除; - // * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”, - // 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会 - // 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。 - // ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 —— - // 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。 - if (animationFinished) - { - m_rowsDirty = true; - BuildRows(); // 内部会重算内容高度与逐行位移 - } - else - { - UpdateAnimGeometry(); - } - // 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间 - // 列表宽度/滚动条与内容不匹配)。 - // ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进 - // `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则 - // 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条, - // 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。 - RefreshScrollBar(animationFinished); - return !m_groupAnims.empty(); - } - - void SignalPanel::ToggleGroupFromRow(int groupIndex, int row) - { - EnsureRows(); - if (m_tab == nullptr || groupIndex < 0) - { - return; - } - - const int headerRow = - (row >= 0 && row < static_cast(m_rows.size()) && - m_rows[static_cast(row)].kind == RowKind::GROUP && - m_rows[static_cast(row)].groupIndex == groupIndex) - ? row - : GroupRowIndex(groupIndex); - - const bool collapse = !IsGroupCollapsed(groupIndex); - SetGroupCollapsed(groupIndex, collapse); - - // 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**, - // 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。 - //(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。) - float scrollFrom = m_scrollOffset; - float scrollTo = m_scrollOffset; - bool syncScroll = false; - if (collapse && headerRow >= 0) - { - const float alignOffset = ContentOffsetBeforeRow(headerRow); - if (m_scrollOffset > alignOffset + 0.5f) - { - // 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短, - // 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。 - const float finalContent = - std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex)); - const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height); - scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset)); - syncScroll = true; - } - } - - StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll); - } int SignalPanel::HitRow(int x, int y, RowPart& part) { @@ -264,835 +84,9 @@ namespace jdi return -1; } - void SignalPanel::StartScrollAnimationTo(float desired) - { - const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height); - desired = std::max(0.0f, std::min(desired, maxOffset)); - const float distance = std::fabs(desired - m_scrollOffset); - if (distance < 1.0f) - { - // 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。 - m_scrollOffset = desired; - ClampScroll(); - m_scrollAnimActive = false; - return; - } - // 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长, - // 因此"整个动画过程不会太久"(最长 300ms)。 - const float durationMs = - std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel)); - m_scrollAnimFrom = m_scrollOffset; - m_scrollAnimTo = desired; - m_scrollAnimStartMs = GetTickCount64(); - m_scrollAnimDurationMs = static_cast(durationMs + 0.5f); - m_scrollAnimActive = true; - } - void SignalPanel::ScrollToSignal(const SignalRef& ref) - { - EnsureRows(); - if (!ref.IsValid()) - { - return; - } - - // ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到 - // 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后 - // 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。 - // ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。 - ExpandGroupForSignal(ref); - float desired = m_scrollOffset; - if (SignalScrollTarget(ref, desired)) - { - m_scrollOffset = desired; - ClampScroll(); - } - m_scrollAnimActive = false; - } - - void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref) - { - EnsureRows(); - if (!ref.IsValid()) - { - return; - } - // ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。 - // 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。 - // 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。 - ExpandGroupForSignal(ref); - float desired = m_scrollOffset; - if (!SignalScrollTarget(ref, desired)) - { - return; - } - // 把列表平滑滚动到目标信号完整可见。 - StartScrollAnimationTo(desired); - } - - bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref) - { - if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex)) - { - return false; - } - SetGroupCollapsed(ref.groupIndex, false); - // 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。 - const int anim = FindGroupAnimation(ref.groupIndex); - if (anim >= 0) - { - m_groupAnims.erase(m_groupAnims.begin() + anim); - } - // 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。 - m_rowsDirty = true; - EnsureRows(); - return true; - } - - bool SignalPanel::AdvanceScrollAnimation() - { - if (!m_scrollAnimActive) - { - return false; - } - - const std::uint64_t now = GetTickCount64(); - const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0; - float progress = (m_scrollAnimDurationMs > 0u) - ? static_cast(elapsed) / static_cast(m_scrollAnimDurationMs) - : 1.0f; - if (progress > 1.0f) - { - progress = 1.0f; - } - - // ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。 - const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); - m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased; - - if (progress >= 1.0f) - { - m_scrollOffset = m_scrollAnimTo; - m_scrollAnimActive = false; - ClampScroll(); - return false; - } - return true; - } - - bool SignalPanel::ApplyWidthFromX(float x) - { - // ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`, - // 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。 - if (m_dataSetWritable == nullptr) - { - return false; - } - SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - - const double ratio = UiSliderRatioFromX(m_widthTrackRect, x); - float value = static_cast(kWidthMin + ratio * (kWidthMax - kWidthMin)); - value = std::round(value * 10.0f) / 10.0f; - value = ClampFloat(value, kWidthMin, kWidthMax); - if (std::fabs(value - style->lineWidth) < 0.001f) - { - return false; - } - style->lineWidth = value; - return true; - } - - bool SignalPanel::BeginEdit(StyleEditField field, int row) - { - // `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。 - if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE) - { - return false; - } - if (m_editField == field && m_editRow == row) - { - return false; - } - - std::wstring initial; - switch (field) - { - case StyleEditField::WIDTH: - { - const SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - initial = FormatNumber(style->lineWidth, 1); - break; - } - case StyleEditField::OPACITY: - { - const SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - initial = std::to_wstring(static_cast(style->opacity * 100.0f + 0.5f)); - break; - } - case StyleEditField::SIGNAL_NAME: - { - // `row < 0`:样式区顶部「目标」行 —— 编辑对象就是当前选中的(目标)信号。 - if (row < 0) - { - const SignalData* signal = SelectedRef().IsValid() ? m_dataSet->Resolve(SelectedRef()) : nullptr; - if (signal == nullptr) - { - return false; - } - initial = signal->name; - break; - } - if (row >= static_cast(m_rows.size())) - { - return false; - } - const ListRow& item = m_rows[static_cast(row)]; - const SignalData* signal = m_dataSet->Resolve(SignalRef{item.groupIndex, item.signalIndex}); - if (signal == nullptr) - { - return false; - } - initial = signal->name; - break; - } - case StyleEditField::GROUP_TITLE: - { - if (row < 0 || row >= static_cast(m_rows.size())) - { - return false; - } - const SignalGroup* group = m_dataSet->GroupAt(m_rows[static_cast(row)].groupIndex); - if (group == nullptr) - { - return false; - } - initial = group->name; - break; - } - default: - return false; - } - - m_editField = field; - m_editRow = row; - - if (field == StyleEditField::SIGNAL_NAME && row < 0) - { - // 「目标」行的输入框与行内名称矩形同源,必须先按当前目标算好几何。 - UpdateTargetRowGeometry(); - } - - // 名称类字段可输入任意字符,数值类字段限制为数字与小数点。 - const bool nameField = - (field == StyleEditField::SIGNAL_NAME) || (field == StyleEditField::GROUP_TITLE); - // 不限长度:名称与数值都由各自的提交逻辑处理超长内容。 - m_edit.Begin(std::move(initial), true, nameField ? EditFilter::ANY : EditFilter::NUMBER, 0u); - return true; - } - - bool SignalPanel::EndEdit(bool commit) - { - if (m_editField == StyleEditField::NONE) - { - return false; - } - - const StyleEditField field = m_editField; - const int row = m_editRow; - const std::wstring text = m_edit.Text(); - - m_editField = StyleEditField::NONE; - m_editRow = -1; - m_edit.End(); - - if (!commit || m_tab == nullptr || m_dataSet == nullptr) - { - return true; - } - - const std::wstring trimmed = Trim(text); - switch (field) - { - case StyleEditField::WIDTH: - { - if (trimmed.empty()) - { - break; - } - wchar_t* stop = nullptr; - const double parsed = std::wcstod(trimmed.c_str(), &stop); - if (stop == trimmed.c_str() || parsed != parsed) - { - break; - } - if (SignalStyle* style = StyleOf(SelectedRef())) - { - const float value = ClampFloat(static_cast(parsed), kWidthMin, kWidthMax); - style->lineWidth = std::round(value * 10.0f) / 10.0f; - } - break; - } - case StyleEditField::OPACITY: - { - if (trimmed.empty()) - { - break; - } - wchar_t* stop = nullptr; - const double parsed = std::wcstod(trimmed.c_str(), &stop); - if (stop == trimmed.c_str() || parsed != parsed) - { - break; - } - if (SignalStyle* style = StyleOf(SelectedRef())) - { - style->opacity = ClampFloat(static_cast(parsed) / 100.0f, 0.0f, 1.0f); - } - break; - } - case StyleEditField::SIGNAL_NAME: - { - if (m_dataSetWritable == nullptr) - { - break; - } - if (row < 0) - { - // 样式区「目标」行:重命名当前选中的(目标)信号。 - const SignalRef& target = SelectedRef(); - if (target.IsValid()) - { - m_dataSetWritable->RenameSignal(target.groupIndex, target.signalIndex, trimmed); - } - // 名称变化会改变「目标」行的文本宽度 ⇒ 几何缓存作废。 - m_targetGeomValid = false; - break; - } - if (row >= static_cast(m_rows.size())) - { - break; - } - const ListRow& item = m_rows[static_cast(row)]; - m_dataSetWritable->RenameSignal(item.groupIndex, item.signalIndex, trimmed); - break; - } - case StyleEditField::GROUP_TITLE: - { - if (row < 0 || row >= static_cast(m_rows.size()) || m_dataSetWritable == nullptr) - { - break; - } - m_dataSetWritable->RenameGroup(m_rows[static_cast(row)].groupIndex, trimmed); - break; - } - default: - break; - } - return true; - } - - bool SignalPanel::OnEditChar(wchar_t character) - { - // 「筛选信号」输入框:字符实时进入筛选(无需回车即可看到结果变化)。 - if (m_filterEditing) - { - switch (character) - { - case 0x03: // Ctrl+C - m_filterEdit.CopyToClipboard(m_host); - return false; - case 0x18: // Ctrl+X - m_filterEdit.CutToClipboard(m_host); - ApplyFilterText(m_filterEdit.Text()); - return true; - case 0x16: // Ctrl+V - m_filterEdit.PasteFromClipboard(m_host); - ApplyFilterText(m_filterEdit.Text()); - return true; - case 0x01: // Ctrl+A - m_filterEdit.SelectAll(); - return true; - default: - break; - } - if (character == L'\r' || character == L'\n') - { - return EndFilterEdit(); - } - // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。 - if (character < 0x20 || character == 0x7F) - { - return false; - } - if (!m_filterEdit.Insert(character)) - { - return false; - } - ApplyFilterText(m_filterEdit.Text()); - return true; - } - - if (m_editField == StyleEditField::NONE) - { - return false; - } - - // 剪贴板与全选快捷键经 WM_CHAR 送达(Ctrl+C/X/V/A 分别对应 3/24/22/1)。 - switch (character) - { - case 0x03: // Ctrl+C - m_edit.CopyToClipboard(m_host); - return false; - case 0x18: // Ctrl+X - return m_edit.CutToClipboard(m_host); - case 0x16: // Ctrl+V - return m_edit.PasteFromClipboard(m_host); - case 0x01: // Ctrl+A - m_edit.SelectAll(); - return true; - default: - break; - } - - if (character == L'\r' || character == L'\n') - { - return EndEdit(true); - } - // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理:这里必须忽略控制字符, - // 否则同一次按键会被处理两次(一次删除两个字符)。 - if (character < 0x20 || character == 0x7F) - { - return false; - } - // 百分比字段允许直接输入 % 但会被忽略,其余控制字符一律丢弃。 - if (m_editField == StyleEditField::OPACITY && character == L'%') - { - return false; - } - return m_edit.Insert(character); - } - - bool SignalPanel::OnEditKey(WPARAM key) - { - // 「筛选信号」输入框的键盘处理(回车结束编辑,Esc 清空并结束)。 - if (m_filterEditing) - { - const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; - const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; - switch (key) - { - case VK_RETURN: - return EndFilterEdit(); - case VK_ESCAPE: - { - const bool changed = m_filterEdit.Text() != m_filterText; - EndFilterEdit(); - return changed; - } - case VK_BACK: - { - if (!m_filterEdit.Backspace()) - { - return false; - } - ApplyFilterText(m_filterEdit.Text()); - return true; - } - case VK_DELETE: - { - if (!m_filterEdit.DeleteForward()) - { - return false; - } - ApplyFilterText(m_filterEdit.Text()); - return true; - } - case VK_LEFT: - return m_filterEdit.MoveCaret(-1, shift); - case VK_RIGHT: - return m_filterEdit.MoveCaret(1, shift); - case VK_HOME: - return m_filterEdit.MoveHome(shift); - case VK_END: - return m_filterEdit.MoveEnd(shift); - case 'A': - if (control) - { - m_filterEdit.SelectAll(); - return true; - } - break; - default: - break; - } - return true; - } - - if (m_editField == StyleEditField::NONE) - { - return false; - } - - const bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0; - const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; - - switch (key) - { - case VK_RETURN: - return EndEdit(true); - case VK_ESCAPE: - return EndEdit(false); - case VK_BACK: - return m_edit.Backspace(); - case VK_DELETE: - return m_edit.DeleteForward(); - case VK_LEFT: - return m_edit.MoveCaret(-1, shift); - case VK_RIGHT: - return m_edit.MoveCaret(1, shift); - case VK_HOME: - return m_edit.MoveHome(shift); - case VK_END: - return m_edit.MoveEnd(shift); - case 'A': - if (control) - { - m_edit.SelectAll(); - return true; - } - break; - default: - break; - } - return true; - } - - bool SignalPanel::CollapseEditSelectionToEnd() - { - if (m_filterEditing) - { - return m_filterEdit.DeselectToEnd(); - } - if (m_editField == StyleEditField::NONE) - { - return false; - } - return m_edit.DeselectToEnd(); - } - - bool SignalPanel::OnEditMouseDown(int x, int y) - { - if (m_filterEditing) - { - // 编辑态下 ✕ 位于输入框内容区之内,必须优先于插入符定位处理, - // 否则点击会被当成"把光标移到此处"而清空不了内容。 - // 与其它 ✕ 一致:按下只记录目标,抬起时仍在 ✕ 上才真正清空。 - const gd::RectF clearRect = FilterClearRect(); - if (clearRect.Width > 0.0f && - UiPointInRect(clearRect, static_cast(x), static_cast(y))) - { - m_pendingFilterClear = true; - return true; - } - return UiEditFieldMouseDown(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x, y); - } - if (m_editField == StyleEditField::NONE) - { - return false; - } - - // ⚠️⚠️ **编辑态下,按在被编辑的那一行名称区域内(含输入框上下各 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) - { - if (m_filterEditing) - { - return UiEditFieldMouseMove(m_fonts->uiSmall, FilterFieldRect(), m_filterEdit, m_scale, x); - } - if (m_editField == StyleEditField::NONE) - { - return false; - } - return UiEditFieldMouseMove(EditFieldFont(), EditFieldRect(), m_edit, m_scale, x); - } - - bool SignalPanel::OnEditMouseUp(int x, int y) - { - if (m_filterEditing) - { - // 筛选框右端的清空 ✕ 在编辑态下的**按下**走 OnEditMouseDown(优先于插入符定位), - // 抬起同样由宿主送进 EDIT_FIELD 分支,因此确认逻辑要在这里结算: - // 只有抬起时指针仍在 ✕ 上才真正清空,移开后松开视为放弃。 - if (m_pendingFilterClear) - { - m_pendingFilterClear = false; - const gd::RectF clearRect = FilterClearRect(); - if (clearRect.Width > 0.0f && - UiPointInRect(clearRect, static_cast(x), static_cast(y))) - { - ClearFilter(); - return true; - } - } - if (!m_filterEdit.Dragging()) - { - return false; - } - m_filterEdit.EndDrag(); - return true; - } - if (!m_edit.Dragging()) - { - return false; - } - m_edit.EndDrag(); - return true; - } - - void SignalPanel::SelectSignal(const SignalRef& ref) - { - if (m_dataSetWritable != nullptr) - { - m_dataSetWritable->SetSelected(ref); - } - } - - bool SignalPanel::ApplyColor(std::uint32_t color) - { - // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 - if (m_dataSetWritable == nullptr) - { - return false; - } - SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - - // 同时记住最后一次自定义取值,便于切换到预设色后一键回到该颜色。 - const bool changed = (style->color != color) || (style->customColor != color); - style->color = color; - style->customColor = color; - return changed; - } - - bool SignalPanel::ApplyOpacityFromX(float x) - { - // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 - if (m_dataSetWritable == nullptr) - { - return false; - } - SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - - const float value = static_cast(UiSliderRatioFromX(m_opacityTrackRect, x)); - if (std::fabs(value - style->opacity) < 0.005f) - { - return false; - } - style->opacity = value; - return true; - } - - bool SignalPanel::OpenGainMenu() - { - if (m_menu == nullptr || m_tab == nullptr) - { - return false; - } - const SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - - // 二级面板:两张“标题(带还原图标)+ 输入框”的组合: - // 1. 「系数调整」:一整行系数输入框; - // 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。 - //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。) - std::vector items; - - MenuItem gainHeader; - gainHeader.kind = MenuItemKind::HEADER_ACTION; - gainHeader.label = L"系数调整"; - gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 - items.push_back(gainHeader); - - // 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。 - const std::wstring gainText = FormatGainText(style->gain); - const std::wstring gainEditText = FormatNumberText(static_cast(style->gain)); - MenuItem gainEdit; - gainEdit.kind = MenuItemKind::EDIT; - gainEdit.label.clear(); // 不显示说明文字:输入框独占整行 - gainEdit.id = CMD_GAIN_EDIT; - gainEdit.value = gainText; - gainEdit.editValue = gainEditText; - gainEdit.filter = EditFilter::NUMBER; - items.push_back(gainEdit); - - MenuItem offsetHeader; - offsetHeader.kind = MenuItemKind::HEADER_ACTION; - offsetHeader.label = L"偏移调整"; - offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值 - items.push_back(offsetHeader); - - // 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。 - MenuItem offsetY; - offsetY.kind = MenuItemKind::EDIT; - offsetY.label = L"Y轴"; - offsetY.id = CMD_OFFSET_Y; - offsetY.value = FormatSignedNumber(static_cast(style->offsetY)); - offsetY.editValue = FormatNumberText(static_cast(style->offsetY)); - offsetY.filter = EditFilter::NUMBER; - items.push_back(offsetY); - - // 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。 - // 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求); - // 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。 - MenuItem offsetTime; - offsetTime.kind = MenuItemKind::EDIT; - offsetTime.label = L"时间轴"; - offsetTime.id = CMD_OFFSET_TIME; - offsetTime.value = FormatOffsetTimeText(style->offsetTime); - offsetTime.editValue = FormatNumberText(style->offsetTime); - offsetTime.editSuffix = L"s"; - offsetTime.filter = EditFilter::NUMBER; - items.push_back(offsetTime); - - // 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角, - // 并再往上让出 `kAdjustMenuGap`(用户明确要求)。 - //(旧实现在按钮下沿向右下展开,面板会盖住下方内容。) - const gd::RectF& button = m_adjustRects[kAdjustGain]; - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - const int anchorY = - static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, - true); - // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → - // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 - ClearStyleHover(); - return true; - } - - bool SignalPanel::OpenMirrorMenu() - { - if (m_menu == nullptr || m_tab == nullptr) - { - return false; - } - const SignalStyle* style = StyleOf(SelectedRef()); - if (style == nullptr) - { - return false; - } - - // 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。 - std::vector items; - - MenuItem horizontal; - horizontal.kind = MenuItemKind::NORMAL; - horizontal.label = L"水平镜像"; - horizontal.id = CMD_MIRROR_HORIZONTAL; - horizontal.checked = style->mirrorHorizontal; - horizontal.keepOpen = true; - items.push_back(horizontal); - - MenuItem vertical; - vertical.kind = MenuItemKind::NORMAL; - vertical.label = L"垂直镜像"; - vertical.id = CMD_MIRROR_VERTICAL; - vertical.checked = style->mirrorVertical; - vertical.keepOpen = true; - items.push_back(vertical); - - const gd::RectF& button = m_adjustRects[kAdjustMirror]; - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 - const int anchorY = - static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, - true); - ClearStyleHover(); - return true; - } - - void SignalPanel::ClearStyleHover() - { - if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0) - { - return; - } - m_hoverStyle = StyleHit::NONE; - m_hoverStyleIndex = -1; - } - - bool SignalPanel::CloseTransientUi() - { - bool changed = false; - if (m_editField != StyleEditField::NONE) - { - changed = EndEdit(true) || changed; - } - // 点击面板之外也要结束「筛选信号」的编辑状态。 - if (m_filterEditing) - { - changed = EndFilterEdit() || changed; - } - if (m_colorPicker != nullptr && m_colorPicker->IsOpen()) - { - m_colorPicker->Close(); - changed = true; - } - // 面板外点击:取消尚未抬起确认的关闭操作。 - m_pendingClosePart = RowPart::NONE; - m_pendingCloseRow = -1; - m_pendingFilterClear = false; - m_pendingGainMenu = false; - m_pendingMirrorMenu = false; - m_pendingTargetLaneMenu = false; - return changed; - } bool SignalPanel::OnMouseMove(int x, int y) { @@ -1910,420 +904,6 @@ namespace jdi return changed; } - void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) - { - if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) - { - return; - } - - const SignalStyle* style = StyleOf(ref); - const SignalData* signal = m_dataSet->Resolve(ref); - if (style == nullptr || signal == nullptr) - { - return; - } - - m_menuTargetSignal = ref; - m_menuTargetGroup = -1; - - std::vector items; - - MenuItem header; - header.kind = MenuItemKind::HEADER; - header.label = signal->name; - items.push_back(header); - - MenuItem toggle; - toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号"; - toggle.id = CMD_SIGNAL_TOGGLE; - items.push_back(toggle); - - MenuItem solo; - solo.label = L"仅显示该信号"; - solo.id = CMD_SIGNAL_SOLO; - items.push_back(solo); - - MenuItem showAll; - showAll.label = L"显示全部信号"; - showAll.id = CMD_SIGNAL_SHOW_ALL; - items.push_back(showAll); - - MenuItem hideAll; - hideAll.label = L"隐藏全部信号"; - hideAll.id = CMD_SIGNAL_HIDE_ALL; - items.push_back(hideAll); - - MenuItem separator; - separator.kind = MenuItemKind::SEPARATOR; - items.push_back(separator); - - int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - MenuItem colors; - colors.kind = MenuItemKind::SWATCHES; - colors.id = CMD_COLOR_BASE; - colors.swatchCount = std::min(paletteCount, 16); - colors.checkedSwatch = -1; - for (int index = 0; index < colors.swatchCount; ++index) - { - colors.swatches[index] = palette[index]; - if (style->color == palette[index]) - { - colors.checkedSwatch = index; - } - } - items.push_back(colors); - - MenuItem modeHeader; - modeHeader.kind = MenuItemKind::HEADER; - modeHeader.label = L"样式"; - items.push_back(modeHeader); - - for (int index = 0; index < kModeOptionCount; ++index) - { - MenuItem mode; - mode.label = DrawModeName(kModeOptions[index]); - mode.id = CMD_MODE_BASE + index; - mode.checked = (style->mode == kModeOptions[index]); - items.push_back(mode); - } - - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); - } - - void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y) - { - if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) - { - return; - } - - const SignalGroup* group = m_dataSet->GroupAt(groupIndex); - if (group == nullptr) - { - return; - } - - m_menuTargetGroup = groupIndex; - m_menuTargetSignal = SignalRef(); - - std::vector items; - - MenuItem header; - header.kind = MenuItemKind::HEADER; - header.label = group->name; - items.push_back(header); - - MenuItem showAll; - showAll.label = L"选中该分组全部信号"; - showAll.id = CMD_GROUP_SHOW_ALL; - items.push_back(showAll); - - MenuItem hideAll; - hideAll.label = L"取消该分组全部信号"; - hideAll.id = CMD_GROUP_HIDE_ALL; - items.push_back(hideAll); - - MenuItem separator; - separator.kind = MenuItemKind::SEPARATOR; - items.push_back(separator); - - MenuItem remove; - remove.label = L"关闭该分组(从工作区移除)"; - remove.id = CMD_GROUP_REMOVE; - items.push_back(remove); - - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); - } - - void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y) - { - if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) - { - return; - } - - const SignalData* signal = m_dataSet->Resolve(ref); - if (signal == nullptr) - { - return; - } - - m_menuTargetSignal = ref; - m_menuTargetGroup = -1; - - std::vector items; - - MenuItem header; - header.kind = MenuItemKind::HEADER; - header.label = L"所属网格"; - items.push_back(header); - - const std::vector& lanes = m_tab->Lanes(); - const int currentLane = m_tab->LaneOfSignal(ref); - for (std::size_t index = 0; index < lanes.size(); ++index) - { - MenuItem lane; - lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" + - std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; - lane.id = CMD_LANE_BASE + static_cast(index); - lane.checked = (currentLane == static_cast(index)); - items.push_back(lane); - } - - MenuItem separator; - separator.kind = MenuItemKind::SEPARATOR; - items.push_back(separator); - - MenuItem create; - create.label = L"新建网格"; - create.id = CMD_LANE_NEW; - items.push_back(create); - - const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; - // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): - // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 - // 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求): - // 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的 - // 「新建网格」**始终固定不动**。 - MenuLayoutConstraint constraint; - constraint.maxScrollRows = kLaneMenuVisibleRows; - // 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。 - constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f); - m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint); - } - - bool SignalPanel::OnMenuCommand(const MenuResult& result) - { - if (m_tab == nullptr || m_dataSet == nullptr || !result.valid) - { - return false; - } - - // 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开 - //(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。 - if (result.editCommit) - { - SignalStyle* style = StyleOf(SelectedRef()); - if (style != nullptr) - { - const std::wstring trimmed = Trim(result.editText); - wchar_t* stop = nullptr; - const double parsed = - trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop); - // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 - const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed); - switch (result.id) - { - case CMD_GAIN_EDIT: - { - if (valid) - { - style->gain = - static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); - } - if (m_menu != nullptr) - { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), - FormatNumberText(static_cast(style->gain))); - } - return false; - } - case CMD_OFFSET_Y: - { - if (valid) - { - style->offsetY = static_cast( - std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); - } - if (m_menu != nullptr) - { - m_menu->UpdateItemValue( - CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), - FormatNumberText(static_cast(style->offsetY))); - } - return false; - } - case CMD_OFFSET_TIME: - { - if (valid) - { - style->offsetTime = - std::max(-kGainLimit, std::min(parsed, kGainLimit)); - } - if (m_menu != nullptr) - { - m_menu->UpdateItemValue(CMD_OFFSET_TIME, - FormatOffsetTimeText(style->offsetTime), - FormatNumberText(style->offsetTime)); - } - return false; - } - default: - break; - } - } - else - { - return false; - } - } - - // 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。 - if (result.id == CMD_GAIN_RESET) - { - if (SignalStyle* style = StyleOf(SelectedRef())) - { - style->gain = 1.0f; - if (m_menu != nullptr) - { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), - FormatNumberText(static_cast(style->gain))); - } - } - return false; - } - - // 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。 - if (result.id == CMD_OFFSET_RESET) - { - if (SignalStyle* style = StyleOf(SelectedRef())) - { - style->offsetY = 0.0f; - style->offsetTime = 0.0; - if (m_menu != nullptr) - { - m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), - FormatNumberText(0.0)); - m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0), - FormatNumberText(0.0)); - } - } - return false; - } - - // 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。 - if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL) - { - if (SignalStyle* style = StyleOf(SelectedRef())) - { - const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL); - bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical; - flag = !flag; - if (!horizontal) - { - // ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量, - // 使绘制等价于「先加上偏移再镜像」(用户明确要求)。 - style->offsetY = -style->offsetY; - if (m_menu != nullptr) - { - m_menu->UpdateItemValue( - CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), - FormatNumberText(static_cast(style->offsetY))); - } - } - if (m_menu != nullptr) - { - m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true); - } - } - return false; - } - - bool needRelayout = false; - - switch (result.id) - { - case CMD_SIGNAL_TOGGLE: - if (m_menuTargetSignal.IsValid()) - { - m_tab->ToggleVisible(m_menuTargetSignal); - m_rowsDirty = true; - needRelayout = true; - } - break; - case CMD_SIGNAL_SOLO: - if (m_menuTargetSignal.IsValid()) - { - m_tab->SoloSignal(m_menuTargetSignal); - m_rowsDirty = true; - needRelayout = true; - } - break; - case CMD_SIGNAL_SHOW_ALL: - m_tab->SetAllVisible(true); - m_rowsDirty = true; - needRelayout = true; - break; - case CMD_SIGNAL_HIDE_ALL: - m_tab->SetAllVisible(false); - m_rowsDirty = true; - needRelayout = true; - break; - case CMD_GROUP_SHOW_ALL: - if (m_menuTargetGroup >= 0) - { - m_tab->SetGroupVisible(m_menuTargetGroup, true); - m_rowsDirty = true; - needRelayout = true; - } - break; - case CMD_GROUP_HIDE_ALL: - if (m_menuTargetGroup >= 0) - { - m_tab->SetGroupVisible(m_menuTargetGroup, false); - m_rowsDirty = true; - needRelayout = true; - } - break; - case CMD_GROUP_REMOVE: - if (m_menuTargetGroup >= 0 && m_host != nullptr) - { - PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetGroup), 0); - } - break; - case CMD_LANE_NEW: - if (m_menuTargetSignal.IsValid()) - { - m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1); - m_rowsDirty = true; - needRelayout = true; - } - break; - default: - if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW) - { - if (m_menuTargetSignal.IsValid()) - { - m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE); - m_rowsDirty = true; - needRelayout = true; - } - } - else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0) - { - if (SignalStyle* style = StyleOf(m_menuTargetSignal)) - { - int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - style->color = palette[result.swatchIndex % std::max(1, paletteCount)]; - } - } - else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount) - { - if (SignalStyle* style = StyleOf(m_menuTargetSignal)) - { - style->mode = kModeOptions[result.id - CMD_MODE_BASE]; - } - } - break; - } - - return needRelayout; - } } // namespace jdi diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp new file mode 100644 index 0000000..564fa1d --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -0,0 +1,585 @@ +// SignalPanel 实现分片 —— 弹出菜单(系数/偏移、镜像、信号/分组/网格右键菜单、命令回执)。 +// +// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。 +// ⚠️ 菜单是"打开即模态"的浮层:`CloseTransientUi` 必须在点击别处时收起它, +// 否则菜单会一直挂在那里吃掉后续点击。 + +#include "SignalPanelInternal.hpp" + +namespace jdi +{ + bool SignalPanel::OpenGainMenu() + { + if (m_menu == nullptr || m_tab == nullptr) + { + return false; + } + const SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + + // 二级面板:两张“标题(带还原图标)+ 输入框”的组合: + // 1. 「系数调整」:一整行系数输入框; + // 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。 + //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。) + std::vector items; + + MenuItem gainHeader; + gainHeader.kind = MenuItemKind::HEADER_ACTION; + gainHeader.label = L"系数调整"; + gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 + items.push_back(gainHeader); + + // 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。 + const std::wstring gainText = FormatGainText(style->gain); + const std::wstring gainEditText = FormatNumberText(static_cast(style->gain)); + MenuItem gainEdit; + gainEdit.kind = MenuItemKind::EDIT; + gainEdit.label.clear(); // 不显示说明文字:输入框独占整行 + gainEdit.id = CMD_GAIN_EDIT; + gainEdit.value = gainText; + gainEdit.editValue = gainEditText; + gainEdit.filter = EditFilter::NUMBER; + items.push_back(gainEdit); + + MenuItem offsetHeader; + offsetHeader.kind = MenuItemKind::HEADER_ACTION; + offsetHeader.label = L"偏移调整"; + offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值 + items.push_back(offsetHeader); + + // 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。 + MenuItem offsetY; + offsetY.kind = MenuItemKind::EDIT; + offsetY.label = L"Y轴"; + offsetY.id = CMD_OFFSET_Y; + offsetY.value = FormatSignedNumber(static_cast(style->offsetY)); + offsetY.editValue = FormatNumberText(static_cast(style->offsetY)); + offsetY.filter = EditFilter::NUMBER; + items.push_back(offsetY); + + // 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。 + // 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求); + // 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。 + MenuItem offsetTime; + offsetTime.kind = MenuItemKind::EDIT; + offsetTime.label = L"时间轴"; + offsetTime.id = CMD_OFFSET_TIME; + offsetTime.value = FormatOffsetTimeText(style->offsetTime); + offsetTime.editValue = FormatNumberText(style->offsetTime); + offsetTime.editSuffix = L"s"; + offsetTime.filter = EditFilter::NUMBER; + items.push_back(offsetTime); + + // 锚定在按钮**左上角的上方一点**:二级面板**向上展开**,其左下角对准按钮左上角, + // 并再往上让出 `kAdjustMenuGap`(用户明确要求)。 + //(旧实现在按钮下沿向右下展开,面板会盖住下方内容。) + const gd::RectF& button = m_adjustRects[kAdjustGain]; + const gd::RectF bounds = PanelBounds(); + const int anchorY = + static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, + true); + // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → + // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 + ClearStyleHover(); + return true; + } + + bool SignalPanel::OpenMirrorMenu() + { + if (m_menu == nullptr || m_tab == nullptr) + { + return false; + } + const SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + + // 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。 + std::vector items; + + MenuItem horizontal; + horizontal.kind = MenuItemKind::NORMAL; + horizontal.label = L"水平镜像"; + horizontal.id = CMD_MIRROR_HORIZONTAL; + horizontal.checked = style->mirrorHorizontal; + horizontal.keepOpen = true; + items.push_back(horizontal); + + MenuItem vertical; + vertical.kind = MenuItemKind::NORMAL; + vertical.label = L"垂直镜像"; + vertical.id = CMD_MIRROR_VERTICAL; + vertical.checked = style->mirrorVertical; + vertical.keepOpen = true; + items.push_back(vertical); + + const gd::RectF& button = m_adjustRects[kAdjustMirror]; + const gd::RectF bounds = PanelBounds(); + // 与「系数/偏移」面板同一套锛定:向上展开,左下角 = 按钮左上角再上移一点。 + const int anchorY = + static_cast(std::lround(button.Y - kAdjustMenuGap * m_scale)); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), anchorY, items, + true); + ClearStyleHover(); + return true; + } + + void SignalPanel::ClearStyleHover() + { + if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0) + { + return; + } + m_hoverStyle = StyleHit::NONE; + m_hoverStyleIndex = -1; + } + + bool SignalPanel::CloseTransientUi() + { + bool changed = false; + if (m_editField != StyleEditField::NONE) + { + changed = EndEdit(true) || changed; + } + // 点击面板之外也要结束「筛选信号」的编辑状态。 + if (m_filterEditing) + { + changed = EndFilterEdit() || changed; + } + if (m_colorPicker != nullptr && m_colorPicker->IsOpen()) + { + m_colorPicker->Close(); + changed = true; + } + // 面板外点击:取消尚未抬起确认的关闭操作。 + m_pendingClosePart = RowPart::NONE; + m_pendingCloseRow = -1; + m_pendingFilterClear = false; + m_pendingGainMenu = false; + m_pendingMirrorMenu = false; + m_pendingTargetLaneMenu = false; + return changed; + } + + void SignalPanel::OpenSignalMenu(const SignalRef& ref, int x, int y) + { + if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + + const SignalStyle* style = StyleOf(ref); + const SignalData* signal = m_dataSet->Resolve(ref); + if (style == nullptr || signal == nullptr) + { + return; + } + + m_menuTargetSignal = ref; + m_menuTargetGroup = -1; + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = signal->name; + items.push_back(header); + + MenuItem toggle; + toggle.label = m_tab->IsVisible(ref) ? L"隐藏该信号" : L"显示该信号"; + toggle.id = CMD_SIGNAL_TOGGLE; + items.push_back(toggle); + + MenuItem solo; + solo.label = L"仅显示该信号"; + solo.id = CMD_SIGNAL_SOLO; + items.push_back(solo); + + MenuItem showAll; + showAll.label = L"显示全部信号"; + showAll.id = CMD_SIGNAL_SHOW_ALL; + items.push_back(showAll); + + MenuItem hideAll; + hideAll.label = L"隐藏全部信号"; + hideAll.id = CMD_SIGNAL_HIDE_ALL; + items.push_back(hideAll); + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + MenuItem colors; + colors.kind = MenuItemKind::SWATCHES; + colors.id = CMD_COLOR_BASE; + colors.swatchCount = std::min(paletteCount, 16); + colors.checkedSwatch = -1; + for (int index = 0; index < colors.swatchCount; ++index) + { + colors.swatches[index] = palette[index]; + if (style->color == palette[index]) + { + colors.checkedSwatch = index; + } + } + items.push_back(colors); + + MenuItem modeHeader; + modeHeader.kind = MenuItemKind::HEADER; + modeHeader.label = L"样式"; + items.push_back(modeHeader); + + for (int index = 0; index < kModeOptionCount; ++index) + { + MenuItem mode; + mode.label = DrawModeName(kModeOptions[index]); + mode.id = CMD_MODE_BASE + index; + mode.checked = (style->mode == kModeOptions[index]); + items.push_back(mode); + } + + const gd::RectF bounds = PanelBounds(); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); + } + + void SignalPanel::OpenGroupMenu(int groupIndex, int x, int y) + { + if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + + const SignalGroup* group = m_dataSet->GroupAt(groupIndex); + if (group == nullptr) + { + return; + } + + m_menuTargetGroup = groupIndex; + m_menuTargetSignal = SignalRef(); + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = group->name; + items.push_back(header); + + MenuItem showAll; + showAll.label = L"选中该分组全部信号"; + showAll.id = CMD_GROUP_SHOW_ALL; + items.push_back(showAll); + + MenuItem hideAll; + hideAll.label = L"取消该分组全部信号"; + hideAll.id = CMD_GROUP_HIDE_ALL; + items.push_back(hideAll); + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + MenuItem remove; + remove.label = L"关闭该分组(从工作区移除)"; + remove.id = CMD_GROUP_REMOVE; + items.push_back(remove); + + const gd::RectF bounds = PanelBounds(); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items); + } + + void SignalPanel::OpenLaneMenu(const SignalRef& ref, int x, int y) + { + if (m_menu == nullptr || m_tab == nullptr || m_dataSet == nullptr) + { + return; + } + + const SignalData* signal = m_dataSet->Resolve(ref); + if (signal == nullptr) + { + return; + } + + m_menuTargetSignal = ref; + m_menuTargetGroup = -1; + + std::vector items; + + MenuItem header; + header.kind = MenuItemKind::HEADER; + header.label = L"所属网格"; + items.push_back(header); + + const std::vector& lanes = m_tab->Lanes(); + const int currentLane = m_tab->LaneOfSignal(ref); + for (std::size_t index = 0; index < lanes.size(); ++index) + { + MenuItem lane; + lane.label = L"网格 " + std::to_wstring(index + 1) + L"(" + + std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; + lane.id = CMD_LANE_BASE + static_cast(index); + lane.checked = (currentLane == static_cast(index)); + items.push_back(lane); + } + + MenuItem separator; + separator.kind = MenuItemKind::SEPARATOR; + items.push_back(separator); + + MenuItem create; + create.label = L"新建网格"; + create.id = CMD_LANE_NEW; + items.push_back(create); + + const gd::RectF bounds = PanelBounds(); + // ⚠️ 网格可能是**上百个**(例如把 MF4 的全部信号各自放进独立网格): + // 不限制高度时菜单会长到屏幕之外、且逐条绘制上百行会明显卡顿。 + // 这里限制可滚动条目区**最多显示 10 行**(用户 2026-10-16 要求): + // 超出部分用滚轮 / 右侧滚动条翻动,而顶部「所属网格」标题与底部的 + // 「新建网格」**始终固定不动**。 + MenuLayoutConstraint constraint; + constraint.maxScrollRows = kLaneMenuVisibleRows; + // 额外加一道硬上限:窗口很矮时也不得超出客户区(与行数上限取更严格者)。 + constraint.maxHeight = std::max(180.0f, bounds.Height * 0.8f); + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, x, y, items, false, constraint); + } + + bool SignalPanel::OnMenuCommand(const MenuResult& result) + { + if (m_tab == nullptr || m_dataSet == nullptr || !result.valid) + { + return false; + } + + // 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开 + //(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。 + if (result.editCommit) + { + SignalStyle* style = StyleOf(SelectedRef()); + if (style != nullptr) + { + const std::wstring trimmed = Trim(result.editText); + wchar_t* stop = nullptr; + const double parsed = + trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop); + // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 + const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed); + switch (result.id) + { + case CMD_GAIN_EDIT: + { + if (valid) + { + style->gain = + static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), + FormatNumberText(static_cast(style->gain))); + } + return false; + } + case CMD_OFFSET_Y: + { + if (valid) + { + style->offsetY = static_cast( + std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue( + CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), + FormatNumberText(static_cast(style->offsetY))); + } + return false; + } + case CMD_OFFSET_TIME: + { + if (valid) + { + style->offsetTime = + std::max(-kGainLimit, std::min(parsed, kGainLimit)); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_OFFSET_TIME, + FormatOffsetTimeText(style->offsetTime), + FormatNumberText(style->offsetTime)); + } + return false; + } + default: + break; + } + } + else + { + return false; + } + } + + // 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。 + if (result.id == CMD_GAIN_RESET) + { + if (SignalStyle* style = StyleOf(SelectedRef())) + { + style->gain = 1.0f; + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), + FormatNumberText(static_cast(style->gain))); + } + } + return false; + } + + // 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。 + if (result.id == CMD_OFFSET_RESET) + { + if (SignalStyle* style = StyleOf(SelectedRef())) + { + style->offsetY = 0.0f; + style->offsetTime = 0.0; + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), + FormatNumberText(0.0)); + m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0), + FormatNumberText(0.0)); + } + } + return false; + } + + // 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。 + if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL) + { + if (SignalStyle* style = StyleOf(SelectedRef())) + { + const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL); + bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical; + flag = !flag; + if (!horizontal) + { + // ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量, + // 使绘制等价于「先加上偏移再镜像」(用户明确要求)。 + style->offsetY = -style->offsetY; + if (m_menu != nullptr) + { + m_menu->UpdateItemValue( + CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), + FormatNumberText(static_cast(style->offsetY))); + } + } + if (m_menu != nullptr) + { + m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true); + } + } + return false; + } + + bool needRelayout = false; + + switch (result.id) + { + case CMD_SIGNAL_TOGGLE: + if (m_menuTargetSignal.IsValid()) + { + m_tab->ToggleVisible(m_menuTargetSignal); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_SIGNAL_SOLO: + if (m_menuTargetSignal.IsValid()) + { + m_tab->SoloSignal(m_menuTargetSignal); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_SIGNAL_SHOW_ALL: + m_tab->SetAllVisible(true); + m_rowsDirty = true; + needRelayout = true; + break; + case CMD_SIGNAL_HIDE_ALL: + m_tab->SetAllVisible(false); + m_rowsDirty = true; + needRelayout = true; + break; + case CMD_GROUP_SHOW_ALL: + if (m_menuTargetGroup >= 0) + { + m_tab->SetGroupVisible(m_menuTargetGroup, true); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_GROUP_HIDE_ALL: + if (m_menuTargetGroup >= 0) + { + m_tab->SetGroupVisible(m_menuTargetGroup, false); + m_rowsDirty = true; + needRelayout = true; + } + break; + case CMD_GROUP_REMOVE: + if (m_menuTargetGroup >= 0 && m_host != nullptr) + { + PostMessageW(m_host, WM_APP + 1, static_cast(m_menuTargetGroup), 0); + } + break; + case CMD_LANE_NEW: + if (m_menuTargetSignal.IsValid()) + { + m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, -1); + m_rowsDirty = true; + needRelayout = true; + } + break; + default: + if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW) + { + if (m_menuTargetSignal.IsValid()) + { + m_tab->MoveSignalToLane(*m_dataSet, m_menuTargetSignal, result.id - CMD_LANE_BASE); + m_rowsDirty = true; + needRelayout = true; + } + } + else if (result.id == CMD_COLOR_BASE && result.swatchIndex >= 0) + { + if (SignalStyle* style = StyleOf(m_menuTargetSignal)) + { + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + style->color = palette[result.swatchIndex % std::max(1, paletteCount)]; + } + } + else if (result.id >= CMD_MODE_BASE && result.id < CMD_MODE_BASE + kModeOptionCount) + { + if (SignalStyle* style = StyleOf(m_menuTargetSignal)) + { + style->mode = kModeOptions[result.id - CMD_MODE_BASE]; + } + } + break; + } + + return needRelayout; + } + +} // namespace jdi diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index 0dc3447..68fbd64 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -1,7 +1,13 @@ -// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。 +// SignalPanel 实现分片 —— 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条。 // -// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 -// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 +// 共用的常量与工具函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。 +// 六个分片按**职责**划分: +// SignalPanel.cpp 核心:状态、行集合、布局与几何、可见性 +// SignalPanelPaint.cpp 绘制:列表行(含行位图缓存)、样式区、筛选框、滚动条(**本文件**) +// SignalPanelInteraction.cpp 交互:命中测试、鼠标/键盘派发、光标形状 +// SignalPanelEdit.cpp 内联编辑:筛选框、组名/信号名、数值输入、颜色与透明度 +// SignalPanelScroll.cpp 动画与滚动:分组折起、联动滚动、"滚动到目标信号" +// SignalPanelMenu.cpp 弹出菜单:系数/偏移、镜像、右键菜单与命令回执 #include "SignalPanelInternal.hpp" #include @@ -9,6 +15,23 @@ namespace jdi { + namespace + { + /** + * @brief 行位图渲染时使用的**临时**文本几何收集器(调试用)。 + * + * 行的内容先画进位图再贴回屏幕,因此这里收到的是**位图坐标**; + * `PaintRowCached` 会把它们连同位图一起缓存,并在贴图时按原点偏移回放给外层收集器。 + * ⚠️ 不缓存的话,"命中缓存的帧根本不调用 `UiText`",文本框会在第二帧全部消失。 + */ + class RowTextCollector : public UiTextRectSink + { + public: + void OnTextRect(const gd::RectF& rect) override { rects.push_back(rect); } + std::vector rects; /**< 位图坐标下的文本墨迹矩形。 */ + }; + } // namespace + void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row) { if (row < 0 || row >= static_cast(m_rows.size()) || @@ -459,6 +482,15 @@ namespace jdi m_rowRenderActive = true; m_rowRenderOriginX = static_cast(originX); m_rowRenderOriginY = static_cast(originY); + // 调试:把文本收集器临时指向一个**位图坐标**的本地容器(偏移 0), + // 渲染完成后连同矩形一起存进缓存,命中缓存时再按原点偏移回放。 + // ⚠️ **无条件收集**(不管外层是否有收集器):行位图可能在任何一帧被渲染 + //(例如用户在关闭调试时滚动过列表),若只在"外层有收集器"时才收集, + // 之后打开调试会命中缓存而永远拿不到文本框。收集成本只发生在**缓存未命中**的行上。 + UiTextRectSink* const outerSink = UiGetTextRectSink(); + RowTextCollector rowTextBoxes; + UiSetTextRectSink(&rowTextBoxes); + UiSetTextRectOffset(0.0f, 0.0f); m_rowClipRender = true; if (m_rows[static_cast(row)].kind == RowKind::GROUP) { @@ -470,9 +502,22 @@ namespace jdi } m_rowClipRender = false; m_rowRenderActive = false; + cache.textRects = rowTextBoxes.rects; + UiSetTextRectOffset(0.0f, 0.0f); + UiSetTextRectSink(outerSink); } cache.key = key; } + // 调试:把本行的文本框回放给外层收集器(缓存命中 / 刚渲染两条路共用这一段)。 + if (UiTextRectSink* const outerSink = UiGetTextRectSink()) + { + for (const gd::RectF& textRect : cache.textRects) + { + outerSink->OnTextRect(gd::RectF(textRect.X + static_cast(originX), + textRect.Y + static_cast(originY), + textRect.Width, textRect.Height)); + } + } // ⚠️ 整块 1:1 搬运(不经过 GDI+ 采样/混合),字的亚像素边缘才能原样保留。 // ⚠️ **必须自己给 DC 设裁剪**:`Graphics::GetHDC()` 交给我们的 DC 上,(a) GDI 绘制 // 不受 GDI+ `SetClip` 的约束(这是本文件多处注释过的坑),(b) 折起/展开动画时行会 diff --git a/Components/SignalPanel/Src/SignalPanelScroll.cpp b/Components/SignalPanel/Src/SignalPanelScroll.cpp new file mode 100644 index 0000000..4404f27 --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanelScroll.cpp @@ -0,0 +1,226 @@ +// SignalPanel 实现分片 —— 动画与滚动(分组折起、联动滚动、"滚动到目标信号")。 +// +// 本文件只包含 SignalPanel 的成员函数定义(机械搬移,行为不变)。 +// ⚠️ 折起动画是"抽拉卷尺"式的刚性平移:只改 `m_rowShift`,**行高永不变**, +// 动画期间不得重建行集合(见 agent_mem.md 的硬约束)。 + +#include "SignalPanelInternal.hpp" + +namespace jdi +{ + bool SignalPanel::AdvanceGroupAnimations() + { + if (m_groupAnims.empty()) + { + return false; + } + + const std::uint64_t now = GetTickCount64(); + bool animationFinished = false; + for (std::size_t index = 0; index < m_groupAnims.size();) + { + const GroupAnim& anim = m_groupAnims[index]; + const std::uint64_t elapsed = (now >= anim.startMs) ? (now - anim.startMs) : 0; + if (anim.durationMs == 0u || elapsed >= anim.durationMs) + { + // 完成:把同步滚动精确落到终点(最后一帧的 eased 进度未必正好是 1)。 + if (anim.scrollSynced) + { + m_scrollOffset = anim.scrollTo; + } + animationFinished = true; + m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); + } + else + { + ++index; + } + } + + // ⚠️ **动画结束(折起与展开都要)后必须重建行集合**: + // * 折起:该组的信号行要真正从行集合里移除; + // * 展开:行集合不变,但 `m_contentHeight` 会在这一帧从“收起态”跳回“展开态”, + // 而滚动条可见性只在 `EnsureRows` / `Layout` 里刷新 ⇒ 不重建的话滚动条会 + // 一直停在“隐藏”状态(用户报告:先折起再展开后,信号列表的滚动条消失)。 + // ⚠️ **不设置 `m_rowsDirty`**(动画进行中):行高不随动画变化,只需重算逐行位移与内容高度 —— + // 这正是本动画方案比“逐行缩放”流畅得多的原因(无需每帧重建行集合)。 + if (animationFinished) + { + m_rowsDirty = true; + BuildRows(); // 内部会重算内容高度与逐行位移 + } + else + { + UpdateAnimGeometry(); + } + // 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间 + // 列表宽度/滚动条与内容不匹配)。 + // ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进 + // `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则 + // 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条, + // 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。 + RefreshScrollBar(animationFinished); + return !m_groupAnims.empty(); + } + + void SignalPanel::ToggleGroupFromRow(int groupIndex, int row) + { + EnsureRows(); + if (m_tab == nullptr || groupIndex < 0) + { + return; + } + + const int headerRow = + (row >= 0 && row < static_cast(m_rows.size()) && + m_rows[static_cast(row)].kind == RowKind::GROUP && + m_rows[static_cast(row)].groupIndex == groupIndex) + ? row + : GroupRowIndex(groupIndex); + + const bool collapse = !IsGroupCollapsed(groupIndex); + SetGroupCollapsed(groupIndex, collapse); + + // 悬浮(组名的自然位置已在列表上边界之上)且要折起时:**一边向上滚动一边收起**, + // 保证悬浮组名始终留在列表最上方,动画结束时滚动偏移恰好落在“组名正好在最上方”。 + //(用户 2026-10-16 第二轮明确要求:不再“先滚动到位、再播放收起动画”。) + float scrollFrom = m_scrollOffset; + float scrollTo = m_scrollOffset; + bool syncScroll = false; + if (collapse && headerRow >= 0) + { + const float alignOffset = ContentOffsetBeforeRow(headerRow); + if (m_scrollOffset > alignOffset + 0.5f) + { + // 目标滚动量受“折起后的最终内容高度”限制:若内容比列表还短, + // 就无法把组名顶到最上方(此时取可达的最大滚动量,避免结束时被强行夹取)。 + const float finalContent = + std::max(0.0f, NaturalContentHeight() - ShownBlockHeight(groupIndex)); + const float maxOffset = std::max(0.0f, finalContent - m_listRect.Height); + scrollTo = std::max(0.0f, std::min(alignOffset, maxOffset)); + syncScroll = true; + } + } + + StartGroupToggle(groupIndex, collapse, scrollFrom, scrollTo, syncScroll); + } + + void SignalPanel::StartScrollAnimationTo(float desired) + { + const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height); + desired = std::max(0.0f, std::min(desired, maxOffset)); + const float distance = std::fabs(desired - m_scrollOffset); + + if (distance < 1.0f) + { + // 已经到位:不需要动画(但仍然要把残余的亚像素差抹平)。 + m_scrollOffset = desired; + ClampScroll(); + m_scrollAnimActive = false; + return; + } + + // 时长随距离增长(越远越快 = 同样时间内走更多距离),到上限后不再增长, + // 因此"整个动画过程不会太久"(最长 300ms)。 + const float durationMs = + std::max(kScrollAnimMinMs, std::min(kScrollAnimMaxMs, distance * kScrollAnimMsPerPixel)); + m_scrollAnimFrom = m_scrollOffset; + m_scrollAnimTo = desired; + m_scrollAnimStartMs = GetTickCount64(); + m_scrollAnimDurationMs = static_cast(durationMs + 0.5f); + m_scrollAnimActive = true; + } + + void SignalPanel::ScrollToSignal(const SignalRef& ref) + { + EnsureRows(); + if (!ref.IsValid()) + { + return; + } + + // ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到 + // 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后 + // 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。 + // ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。 + ExpandGroupForSignal(ref); + float desired = m_scrollOffset; + if (SignalScrollTarget(ref, desired)) + { + m_scrollOffset = desired; + ClampScroll(); + } + m_scrollAnimActive = false; + } + + void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref) + { + EnsureRows(); + if (!ref.IsValid()) + { + return; + } + // ⚠️ **目标信号所在分组若处于折起状态,行集合里根本没有它** —— 无论怎么滚都看不到它。 + // 用户 2026-10-16 报告:点击网格内的图例触发联动时,被折起的组不会自动展开 ⇒ 联动失效。 + // 因此先自动展开该分组(立刻生效,不做展开动画:滚动目标需要稳定的行位置)。 + ExpandGroupForSignal(ref); + float desired = m_scrollOffset; + if (!SignalScrollTarget(ref, desired)) + { + return; + } + // 把列表平滑滚动到目标信号完整可见。 + StartScrollAnimationTo(desired); + } + + bool SignalPanel::ExpandGroupForSignal(const SignalRef& ref) + { + if (ref.groupIndex < 0 || !IsGroupCollapsed(ref.groupIndex)) + { + return false; + } + SetGroupCollapsed(ref.groupIndex, false); + // 该分组若正在播放折起动画,一并作废(目标行的位置会跳回展开态,动画没有意义了)。 + const int anim = FindGroupAnimation(ref.groupIndex); + if (anim >= 0) + { + m_groupAnims.erase(m_groupAnims.begin() + anim); + } + // 行集合必须重建(展开后信号行才会出现)——滚动目标依赖它。 + m_rowsDirty = true; + EnsureRows(); + return true; + } + + bool SignalPanel::AdvanceScrollAnimation() + { + if (!m_scrollAnimActive) + { + return false; + } + + const std::uint64_t now = GetTickCount64(); + const std::uint64_t elapsed = (now >= m_scrollAnimStartMs) ? (now - m_scrollAnimStartMs) : 0; + float progress = (m_scrollAnimDurationMs > 0u) + ? static_cast(elapsed) / static_cast(m_scrollAnimDurationMs) + : 1.0f; + if (progress > 1.0f) + { + progress = 1.0f; + } + + // ease-out cubic:起步快、靠近目标时变慢(用户要求"越远时越快,靠近时变慢")。 + const float eased = 1.0f - std::pow(1.0f - progress, 3.0f); + m_scrollOffset = m_scrollAnimFrom + (m_scrollAnimTo - m_scrollAnimFrom) * eased; + + if (progress >= 1.0f) + { + m_scrollOffset = m_scrollAnimTo; + m_scrollAnimActive = false; + ClampScroll(); + return false; + } + return true; + } + +} // namespace jdi diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp index 74ab1f5..b53987b 100644 --- a/Components/TabBar/Inc/TabBar.hpp +++ b/Components/TabBar/Inc/TabBar.hpp @@ -5,11 +5,14 @@ #include #include +#include #include #include #include "PaintUtil.hpp" #include "Theme.hpp" +#include "UiComponent.hpp" +#include "UiWidget.hpp" namespace jdi { @@ -44,10 +47,11 @@ namespace jdi /** * @brief 顶部标签栏:支持多视图切换、新建与关闭。 */ - class TabBar + class TabBar : public UiComponent { public: TabBar(); + ~TabBar(); /** * @brief 绑定字体与主题资源。 @@ -60,7 +64,7 @@ namespace jdi * @brief 设置 DPI 缩放比例。 * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 */ - void SetScale(float scale); + void SetScale(float scale) override; /** * @brief 更新标签页名称集合。 @@ -84,13 +88,16 @@ namespace jdi * @brief 按给定区域重新排布标签与按钮。 * @param [IN] area 标签栏可用区域。 */ - void Layout(const gd::RectF& area); + void Layout(const gd::RectF& area) override; + + /** @brief 标签栏占用的整条区域(与 `Layout` 同源)。 */ + gd::RectF Area() const override { return m_area; } /** * @brief 绘制标签栏。 * @param [IN,OUT] g 绘图上下文。 */ - void Paint(gd::Graphics& g); + void Paint(gd::Graphics& g) override; /** * @brief 处理鼠标移动。 @@ -140,7 +147,7 @@ namespace jdi * @brief 判断标签栏是否处于重命名编辑态。 * @return 处于编辑态返回 true。 */ - bool IsEditing() const { return m_editIndex >= 0; } + bool IsEditing() const override { return m_editIndex >= 0; } /** * @brief 处理编辑态下的字符输入。 @@ -201,7 +208,7 @@ namespace jdi * @brief 处理鼠标离开标签栏。 * @return 视觉状态发生变化返回 true。 */ - bool OnMouseLeave(); + bool OnMouseLeave() override; /** * @brief 设置宿主窗口句柄(用于剪贴板所有者)。 @@ -215,7 +222,13 @@ namespace jdi * @param [IN] y 客户区纵坐标。 * @return 落在区域内返回 true。 */ - bool HitTest(int x, int y) const; + bool HitTest(int x, int y) const override; + + /** + * @brief 收集标签栏区域、**每个标签条 / 新建按钮 / 标签 ✕** 的布局边界(调试用)。 + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) override; private: /** @@ -240,14 +253,11 @@ namespace jdi const Theme* m_theme = nullptr; /**< 主题资源。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ - std::vector m_tabs; /**< 标签页布局集合。 */ + std::vector m_tabs; /**< 标签页数据(名称 + 几何;几何由控件回写)。 */ gd::RectF m_addRect; /**< 新建按钮矩形。 */ gd::RectF m_area; /**< 标签栏整体区域。 */ int m_activeIndex = -1; /**< 当前激活标签页下标。 */ - int m_hoverIndex = -1; /**< 当前悬停标签页下标。 */ - bool m_hoverClose = false; /**< 是否悬停在关闭按钮上。 */ - bool m_hoverAdd = false; /**< 是否悬停在新建按钮上。 */ int m_pressedIndex = -1; /**< 当前按下的标签页下标。 */ bool m_pressedClose = false; /**< 是否按在关闭按钮上。 */ bool m_pressedAdd = false; /**< 是否按在新建按钮上。 */ @@ -263,6 +273,13 @@ namespace jdi int m_lastClickIndex = -1; /**< 上一次单击的标签页下标(用于自行判定双击)。 */ DWORD m_lastClickTick = 0; /**< 上一次单击的时刻。 */ + + /** 控件树根:负责底色与底部分隔线,子节点是各标签与「+」按钮。 */ + std::unique_ptr m_root; + /** 重建控件树(标签数量变化时调用)。 */ + void RebuildWidgets(); + /** 把标签数据/激活态/编辑态同步给控件并摆放。 */ + void ArrangeWidgets(); }; } // namespace jdi diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp index 9883f90..00a07be 100644 --- a/Components/TabBar/Src/TabBar.cpp +++ b/Components/TabBar/Src/TabBar.cpp @@ -3,7 +3,9 @@ #include "UiControls.hpp" #include #include +#include +#include "UiDebugBounds.hpp" #include "Utf8Util.hpp" namespace jdi @@ -23,10 +25,250 @@ namespace jdi const float kMaxTabWidth = 200.0f; /**< 单个标签最大宽度(逻辑像素)。 */ const float kEditingTabWidth = 320.0f; /**< 重命名输入时标签的最大宽度(便于看清长名称)。 */ const float kMinTabWidth = 70.0f; /**< 单个标签最小宽度(逻辑像素)。 */ - } + + /** + * @brief 单个标签页控件:只负责"把这一格画出来"。 + * + * 手势语义(自行判双击 / 拖拽排序 / 关闭 ✕ 的"移开取消")仍留在 `TabBar` 里 —— + * 那些是多年补丁叠出的隐式约定,迁移控件时**不能顺手重写**。 + */ + class TabItemWidget : public UiWidget + { + public: + void Configure(const UiFonts* fonts, const Theme* theme, float scale) + { + m_fonts = fonts; + m_theme = theme; + m_scale = scale; + } + + /** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮几何)。 */ + void SetState(std::wstring name, bool active, gd::RectF closeRect, bool editing, + const TextEdit* edit, gd::RectF editRect) + { + const bool changed = (m_name != name) || (m_active != active) || (m_editing != editing); + m_name = std::move(name); + m_active = active; + m_closeRect = closeRect; + m_editing = editing; + m_edit = edit; + m_editRect = editRect; + if (changed) + { + Invalidate(Bounds()); + } + else + { + // 几何/编辑框位置变化也要重画(重命名时标签会随输入变宽)。 + Invalidate(Bounds()); + } + } + + /** @brief 关闭按钮矩形(`TabBar` 用于手势命中;绘制与命中同源)。 */ + const gd::RectF& CloseRect() const { return m_closeRect; } + + gd::SizeF OnMeasure(const UiMeasureContext& ctx) override + { + (void)ctx; + return gd::SizeF(Bounds().Width, Bounds().Height); + } + + void OnPaint(UiPaintContext& ctx) override + { + if (m_theme == nullptr || m_fonts == nullptr || ctx.g == nullptr) + { + return; + } + const gd::RectF& rect = Bounds(); + int hoverX = 0; + int hoverY = 0; + const bool hovered = HoverPoint(hoverX, hoverY); + const bool closeHot = + hovered && UiPointInRect(m_closeRect, static_cast(hoverX), + static_cast(hoverY)); + + std::uint32_t background = m_theme->panelBg; + if (m_active) + { + background = m_theme->cardBg; + } + else if (hovered) + { + background = m_theme->hover; + } + UiFillRoundRect(*ctx.g, rect, kCorner * m_scale, background); + + if (!m_active) + { + UiDrawRoundRect(*ctx.g, rect, kCorner * m_scale, m_theme->border, 1.0f); + } + else + { + // 激活页使用顶部强调条指示。 + UiFillRoundRect(*ctx.g, + gd::RectF(rect.X + kCorner * m_scale, rect.Y, + rect.Width - kCorner * 2.0f * m_scale, 2.5f * m_scale), + 1.0f * m_scale, m_theme->accent); + } + + const float textLeft = rect.X + kTabPadding * m_scale; + const float textWidth = m_closeRect.X - kCloseGap * m_scale - textLeft; + + if (m_editing && m_edit != nullptr) + { + // 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。 + UiDrawEditField(*ctx.g, m_editRect, *m_edit, m_fonts->ui, m_scale); + } + else + { + const std::wstring label = + UiEllipsize(m_name, m_fonts->ui, std::max(8.0f, textWidth)); + UiText(*ctx.g, label, m_active ? m_fonts->uiBold : m_fonts->ui, + gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), + m_active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); + } + + // 关闭按钮:悬停时显示底色,图标始终可见。 + const float closeIconSize = 8.0f * m_scale; + if (closeHot) + { + UiFillRoundRect(*ctx.g, m_closeRect, 3.0f * m_scale, m_theme->pressed); + } + const float cx = m_closeRect.X + m_closeRect.Width * 0.5f; + const float cy = m_closeRect.Y + m_closeRect.Height * 0.5f; + const std::uint32_t iconColor = closeHot + ? m_theme->danger + : (m_active ? m_theme->text : m_theme->textMuted); + UiDrawLine(*ctx.g, cx - closeIconSize * 0.5f, cy - closeIconSize * 0.5f, + cx + closeIconSize * 0.5f, cy + closeIconSize * 0.5f, iconColor, + 1.4f * m_scale); + UiDrawLine(*ctx.g, cx + closeIconSize * 0.5f, cy - closeIconSize * 0.5f, + cx - closeIconSize * 0.5f, cy + closeIconSize * 0.5f, iconColor, + 1.4f * m_scale); + } + + private: + const UiFonts* m_fonts = nullptr; /**< 字体。 */ + const Theme* m_theme = nullptr; /**< 主题。 */ + float m_scale = 1.0f; /**< 缩放系数。 */ + std::wstring m_name; /**< 标签名称。 */ + bool m_active = false; /**< 是否激活。 */ + bool m_editing = false; /**< 是否处于重命名编辑态。 */ + const TextEdit* m_edit = nullptr; /**< 编辑模型(编辑态时非空)。 */ + gd::RectF m_editRect; /**< 编辑框矩形。 */ + gd::RectF m_closeRect; /**< 关闭按钮矩形。 */ + }; + + /** @brief 「+」新建按钮控件。 */ + class TabAddWidget : public UiWidget + { + public: + void Configure(const UiFonts* fonts, const Theme* theme, float scale) + { + m_fonts = fonts; + m_theme = theme; + m_scale = scale; + } + + gd::SizeF OnMeasure(const UiMeasureContext& ctx) override + { + (void)ctx; + return gd::SizeF(Bounds().Width, Bounds().Height); + } + + void OnPaint(UiPaintContext& ctx) override + { + if (m_theme == nullptr || ctx.g == nullptr) + { + return; + } + const gd::RectF& rect = Bounds(); + const bool hovered = Hovered(); + const std::uint32_t addBg = hovered ? m_theme->hover : m_theme->panelBg; + UiFillRoundRect(*ctx.g, rect, kCorner * m_scale, addBg); + UiDrawRoundRect(*ctx.g, rect, kCorner * m_scale, m_theme->border, 1.0f); + + const float ax = rect.X + rect.Width * 0.5f; + const float ay = rect.Y + rect.Height * 0.5f; + const float armLength = 4.5f * m_scale; + const std::uint32_t addColor = hovered ? m_theme->accent : m_theme->text; + UiDrawLine(*ctx.g, ax - armLength, ay, ax + armLength, ay, addColor, 1.6f * m_scale); + UiDrawLine(*ctx.g, ax, ay - armLength, ax, ay + armLength, addColor, 1.6f * m_scale); + } + + private: + const UiFonts* m_fonts = nullptr; /**< 字体(当前未使用,保留给将来的图标)。 */ + const Theme* m_theme = nullptr; /**< 主题。 */ + float m_scale = 1.0f; /**< 缩放系数。 */ + }; + + /** + * @brief 覆盖层控件:画"拖拽插入指示条 + 底部分隔线"。 + * + * 它必须**最后绘制**(在标签与「+」之后),因此作为根节点的最后一个子节点, + * 且**命中透明**(纯装饰,不参与命中与光标链)。 + */ + class TabOverlayWidget : public UiWidget + { + public: + gd::SizeF OnMeasure(const UiMeasureContext& ctx) override + { + (void)ctx; + return gd::SizeF(Bounds().Width, Bounds().Height); + } + + void OnPaint(UiPaintContext& ctx) override + { + if (ctx.g == nullptr) + { + return; + } + if (paintOverlay) + { + paintOverlay(*ctx.g, Bounds()); + } + } + + /** @brief 由 `TabBar` 注入的覆盖层绘制回调(拖拽指示条 + 分隔线)。 */ + std::function paintOverlay; + }; + + /** @brief 标签栏根:只负责底色(其余交给子节点按 z 序绘制)。 */ + class TabRootWidget : public UiWidget + { + public: + void Configure(const Theme* theme) + { + m_theme = theme; + } + + gd::SizeF OnMeasure(const UiMeasureContext& ctx) override + { + (void)ctx; + return gd::SizeF(Bounds().Width, Bounds().Height); + } + + void OnPaint(UiPaintContext& ctx) override + { + if (m_theme == nullptr || ctx.g == nullptr || !UiRectHasArea(Bounds())) + { + return; + } + UiFillRect(*ctx.g, Bounds(), m_theme->panelBg); + } + + private: + const Theme* m_theme = nullptr; /**< 主题。 */ + }; + + } // namespace TabBar::TabBar() = default; + // 控件树用 `std::unique_ptr` 持有,而具体根类型只在本 TU 可见 + // ⇒ 析构函数必须在这里显式定义。 + TabBar::~TabBar() = default; + void TabBar::Setup(const UiFonts* fonts, const Theme* theme) { m_fonts = fonts; @@ -50,13 +292,18 @@ namespace jdi { m_activeIndex = m_tabs.empty() ? -1 : static_cast(m_tabs.size()) - 1; } - m_hoverIndex = -1; - m_hoverClose = false; + // 标签数量变化 ⇒ 重建控件树(旧控件里缓存的几何与名称全部作废)。 + RebuildWidgets(); } void TabBar::SetActiveIndex(int index) { + if (m_activeIndex == index) + { + return; + } m_activeIndex = index; + ArrangeWidgets(); } float TabBar::DesiredHeight() const @@ -64,6 +311,52 @@ namespace jdi return (kTabHeight + kTopMargin) * m_scale; } + void TabBar::RebuildWidgets() + { + m_root = std::make_unique(); + static_cast(m_root.get())->Configure(m_theme); + + for (std::size_t index = 0; index < m_tabs.size(); ++index) + { + auto widget = std::make_unique(); + static_cast(widget.get())->Configure(m_fonts, m_theme, m_scale); + m_root->AddChild(std::move(widget)); + } + + auto add = std::make_unique(); + static_cast(add.get())->Configure(m_fonts, m_theme, m_scale); + m_root->AddChild(std::move(add)); + + auto overlay = std::make_unique(); + TabOverlayWidget* overlayRaw = static_cast(overlay.get()); + overlayRaw->SetHitTransparent(true); + overlayRaw->paintOverlay = [this](gd::Graphics& g, const gd::RectF& area) + { + // 拖拽排序时在插入位置绘制强调色指示条。 + if (m_dragIndex >= 0 && m_dragMoved && !m_tabs.empty() && m_theme != nullptr) + { + const int drop = DropIndexAt(m_dragMouseX); + float markerX = m_addRect.X; + if (drop < static_cast(m_tabs.size())) + { + markerX = m_tabs[static_cast(drop)].rect.X - kAddGap * m_scale * 0.5f; + } + const float top = area.Y + kTopMargin * m_scale; + const float bottom = top + kTabHeight * m_scale; + UiFillRoundRect(g, + gd::RectF(markerX - 1.5f * m_scale, top, 3.0f * m_scale, bottom - top), + 1.5f * m_scale, m_theme->accent); + } + // 底部分隔线。 + if (m_theme != nullptr) + { + UiDrawLine(g, area.X, area.Y + area.Height - 1.0f, area.X + area.Width, + area.Y + area.Height - 1.0f, m_theme->border, 1.0f); + } + }; + m_root->AddChild(std::move(overlay)); + } + void TabBar::Layout(const gd::RectF& area) { m_area = area; @@ -71,6 +364,10 @@ namespace jdi { return; } + if (m_root == nullptr) + { + RebuildWidgets(); + } const float tabHeight = kTabHeight * m_scale; const float top = area.Y + kTopMargin * m_scale; @@ -129,6 +426,34 @@ namespace jdi m_addRect = gd::RectF(std::min(cursorX, area.X + area.Width - addSize - 2.0f * m_scale), top + (tabHeight - addSize) * 0.5f, addSize, addSize); + + ArrangeWidgets(); + } + + void TabBar::ArrangeWidgets() + { + if (m_root == nullptr || m_root->Children().size() != m_tabs.size() + 2) + { + RebuildWidgets(); + } + if (m_root == nullptr) + { + return; + } + + m_root->Arrange(m_area); + const std::vector>& children = m_root->Children(); + for (std::size_t index = 0; index < m_tabs.size(); ++index) + { + TabItemWidget* widget = static_cast(children[index].get()); + const TabItem& tab = m_tabs[index]; + const bool editing = (static_cast(index) == m_editIndex); + widget->Arrange(tab.rect); + widget->SetState(tab.name, static_cast(index) == m_activeIndex, tab.closeRect, editing, + editing ? &m_edit : nullptr, EditRect()); + } + static_cast(children[m_tabs.size()].get())->Arrange(m_addRect); + static_cast(children[m_tabs.size() + 1].get())->Arrange(m_area); } void TabBar::Paint(gd::Graphics& g) @@ -137,103 +462,21 @@ namespace jdi { return; } - - UiFillRect(g, m_area, m_theme->panelBg); - - const float closeIconSize = 8.0f * m_scale; - - for (std::size_t index = 0; index < m_tabs.size(); ++index) + if (m_root == nullptr) { - const TabItem& tab = m_tabs[index]; - const bool active = (static_cast(index) == m_activeIndex); - const bool hovered = (static_cast(index) == m_hoverIndex); - - std::uint32_t background = m_theme->panelBg; - if (active) - { - background = m_theme->cardBg; - } - else if (hovered) - { - background = m_theme->hover; - } - UiFillRoundRect(g, tab.rect, kCorner * m_scale, background); - - if (!active) - { - UiDrawRoundRect(g, tab.rect, kCorner * m_scale, m_theme->border, 1.0f); - } - else - { - // 激活页使用顶部强调条指示。 - UiFillRoundRect(g, gd::RectF(tab.rect.X + kCorner * m_scale, tab.rect.Y, - tab.rect.Width - kCorner * 2.0f * m_scale, 2.5f * m_scale), - 1.0f * m_scale, m_theme->accent); - } - - const float textLeft = tab.rect.X + kTabPadding * m_scale; - const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft; - - // 重命名编辑态:在标签本体之上叠加输入框(标签底色与边框仍然保留)。 - if (static_cast(index) == m_editIndex) - { - UiDrawEditField(g, EditRect(), m_edit, m_fonts->ui, m_scale); - } - else - { - const std::wstring label = UiEllipsize(tab.name, m_fonts->ui, std::max(8.0f, textWidth)); - UiText(g, label, active ? m_fonts->uiBold : m_fonts->ui, - gd::RectF(textLeft, tab.rect.Y, std::max(8.0f, textWidth), tab.rect.Height), - active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); - } - - // 关闭按钮:悬停时显示底色,图标始终可见。 - const bool closeHot = hovered && m_hoverClose; - if (closeHot) - { - UiFillRoundRect(g, tab.closeRect, 3.0f * m_scale, m_theme->pressed); - } - const float cx = tab.closeRect.X + tab.closeRect.Width * 0.5f; - const float cy = tab.closeRect.Y + tab.closeRect.Height * 0.5f; - const std::uint32_t iconColor = - closeHot ? m_theme->danger : (active ? m_theme->text : m_theme->textMuted); - UiDrawLine(g, cx - closeIconSize * 0.5f, cy - closeIconSize * 0.5f, cx + closeIconSize * 0.5f, - cy + closeIconSize * 0.5f, iconColor, 1.4f * m_scale); - UiDrawLine(g, cx + closeIconSize * 0.5f, cy - closeIconSize * 0.5f, cx - closeIconSize * 0.5f, - cy + closeIconSize * 0.5f, iconColor, 1.4f * m_scale); + RebuildWidgets(); + ArrangeWidgets(); + } + if (m_root == nullptr) + { + return; } - // 拖拽排序时在插入位置绘制强调色指示条。 - if (m_dragIndex >= 0 && m_dragMoved && !m_tabs.empty()) - { - const int drop = DropIndexAt(m_dragMouseX); - float markerX = m_addRect.X; - if (drop < static_cast(m_tabs.size())) - { - markerX = m_tabs[static_cast(drop)].rect.X - kAddGap * m_scale * 0.5f; - } - - const float top = m_area.Y + kTopMargin * m_scale; - const float bottom = top + kTabHeight * m_scale; - UiFillRoundRect(g, gd::RectF(markerX - 1.5f * m_scale, top, 3.0f * m_scale, bottom - top), - 1.5f * m_scale, m_theme->accent); - } - - // 新建按钮。 - const std::uint32_t addBg = m_hoverAdd ? m_theme->hover : m_theme->panelBg; - UiFillRoundRect(g, m_addRect, kCorner * m_scale, addBg); - UiDrawRoundRect(g, m_addRect, kCorner * m_scale, m_theme->border, 1.0f); - - const float ax = m_addRect.X + m_addRect.Width * 0.5f; - const float ay = m_addRect.Y + m_addRect.Height * 0.5f; - const float armLength = 4.5f * m_scale; - const std::uint32_t addColor = m_hoverAdd ? m_theme->accent : m_theme->text; - UiDrawLine(g, ax - armLength, ay, ax + armLength, ay, addColor, 1.6f * m_scale); - UiDrawLine(g, ax, ay - armLength, ax, ay + armLength, addColor, 1.6f * m_scale); - - // 底部分隔线。 - UiDrawLine(g, m_area.X, m_area.Y + m_area.Height - 1.0f, m_area.X + m_area.Width, - m_area.Y + m_area.Height - 1.0f, m_theme->border, 1.0f); + UiPaintContext ctx; + ctx.g = &g; + ctx.clip = m_area; + ctx.scale = m_scale; + m_root->Paint(ctx); } bool TabBar::HitTest(int x, int y) const @@ -241,6 +484,28 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } + void TabBar::CollectDebugBoxes(UiDebugBoxList& boxes) + { + UiComponent::CollectDebugBoxes(boxes); + if (m_root != nullptr) + { + // ⚠️ 跳过根控件:它的矩形与标签栏区域完全重合(已被上一层登记过)。 + // 从控件树的**子节点**(各标签条 / 新建按钮)开始,层级 1。 + for (const std::unique_ptr& child : m_root->Children()) + { + if (child != nullptr) + { + child->CollectDebugBoxes(boxes, 1); + } + } + } + // 关闭按钮是「标签条内部的子块」,控件树里没有对应节点(由标签条控件自绘)⇒ 单独登记。 + for (const TabItem& tab : m_tabs) + { + boxes.Add(tab.closeRect, 2); + } + } + int TabBar::TabAtPoint(int x, int y) const { const float fx = static_cast(x); @@ -257,7 +522,7 @@ namespace jdi bool TabBar::OnMouseMove(int x, int y) { - // 拖动排序中:只跟踪鼠标位置,由界面层绘制插入指示。 + // 拖动排序中:只跟踪鼠标位置,由覆盖层绘制插入指示。 if (m_dragIndex >= 0) { if (x != m_dragMouseX) @@ -269,20 +534,17 @@ namespace jdi return false; } - const int index = TabAtPoint(x, y); - const bool overClose = - (index >= 0 && UiPointInRect(m_tabs[static_cast(index)].closeRect, - static_cast(x), static_cast(y))); - const bool overAdd = UiPointInRect(m_addRect, static_cast(x), static_cast(y)); - - if (index == m_hoverIndex && overClose == m_hoverClose && overAdd == m_hoverAdd) + if (m_root == nullptr) { return false; } - m_hoverIndex = index; - m_hoverClose = overClose; - m_hoverAdd = overAdd; - return true; + UiEvent event; + event.kind = UiEventKind::MOUSE_MOVE; + event.x = x; + event.y = y; + // 悬停链切换会通过 `Invalidate` 冒泡到根 ⇒ 用脏区判断是否需要重绘 + //(与旧实现"悬停下标/关闭/新建变化才重绘"等价,但不再手工维护三个下标)。 + return m_root->DispatchRedraw(event); } bool TabBar::OnLButtonDown(int x, int y) @@ -535,16 +797,16 @@ namespace jdi gd::RectF TabBar::EditRect() const { - if (m_editIndex < 0 || m_editIndex >= static_cast(m_tabs.size()) || m_fonts == nullptr) + if (m_editIndex < 0 || m_editIndex >= static_cast(m_tabs.size())) { return gd::RectF(); } - const TabItem& tab = m_tabs[static_cast(m_editIndex)]; + const float pad = 4.0f * m_scale; const float textLeft = tab.rect.X + kTabPadding * m_scale; - const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft; - return gd::RectF(textLeft - 3.0f * m_scale, tab.rect.Y + 3.0f * m_scale, - std::max(40.0f, textWidth + 6.0f * m_scale), tab.rect.Height - 6.0f * m_scale); + const float textRight = tab.closeRect.X - kCloseGap * m_scale; + return gd::RectF(textLeft, tab.rect.Y + pad, std::max(8.0f, textRight - textLeft), + tab.rect.Height - pad * 2.0f); } bool TabBar::OnEditMouseDown(int x, int y) @@ -553,7 +815,12 @@ namespace jdi { return false; } - return UiEditFieldMouseDown(m_fonts->ui, EditRect(), m_edit, m_scale, x, y); + if (!UiPointInRect(EditRect(), static_cast(x), static_cast(y))) + { + return false; + } + UiEditFieldMouseDown(m_fonts->ui, EditRect(), m_edit, m_scale, x, y); + return true; } bool TabBar::OnEditMouseMove(int x) @@ -562,12 +829,13 @@ namespace jdi { return false; } - return UiEditFieldMouseMove(m_fonts->ui, EditRect(), m_edit, m_scale, x); + UiEditFieldMouseMove(m_fonts->ui, EditRect(), m_edit, m_scale, x); + return true; } bool TabBar::OnEditMouseUp() { - if (!m_edit.Dragging()) + if (m_editIndex < 0) { return false; } @@ -594,14 +862,16 @@ namespace jdi bool TabBar::OnMouseLeave() { - if (m_hoverIndex < 0 && !m_hoverClose && !m_hoverAdd) + if (m_root == nullptr) { return false; } - m_hoverIndex = -1; - m_hoverClose = false; - m_hoverAdd = false; - return true; + UiEvent event; + event.kind = UiEventKind::MOUSE_LEAVE; + event.x = 0; + event.y = 0; + m_root->Dispatch(event); + return UiRectHasArea(m_root->TakeDirty()); } } // namespace jdi diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 855d07c..92ee379 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -5,11 +5,16 @@ #include #include +#include #include +#include #include #include "PaintUtil.hpp" #include "Theme.hpp" +#include "UiButtonWidget.hpp" +#include "UiComponent.hpp" +#include "UiWidget.hpp" namespace jdi { @@ -20,6 +25,7 @@ namespace jdi enum class ToolbarAction : int { NONE = 0, /**< 未触发任何动作。 */ + SETTINGS, /**< **展开应用设置菜单**(齿轮图标,位于「导入文件」左侧)。 */ OPEN_FILE, /**< 打开数据文件。 */ FIT_ALL, /**< 视图自适应到可见信号范围(**不**退出选区放大模式)。 */ AUTO_FIT_Y, /**< 切换「选区 Y 轴自适应」全局开关:时间轴选区放大后自动匹配各网格 Y 轴。 */ @@ -33,10 +39,11 @@ namespace jdi /** * @brief 顶部工具栏:文件操作与视图控制的入口。 */ - class Toolbar + class Toolbar : public UiComponent { public: Toolbar(); + ~Toolbar(); /** * @brief 绑定字体与主题资源。 @@ -44,12 +51,11 @@ namespace jdi * @param [IN] theme 主题配色,需在工具栏生命周期内保持有效。 */ void Setup(const UiFonts* fonts, const Theme* theme); - /** * @brief 设置 DPI 缩放比例。 * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 */ - void SetScale(float scale); + void SetScale(float scale) override; /** * @brief 设置是否已加载数据(未加载时大部分按钮被禁用)。 @@ -81,17 +87,31 @@ namespace jdi */ float DesiredHeight() const; + /** + * @brief 查询某个按钮当前的屏幕矩形(用于把弹出菜单锚定到按钮下方)。 + * + * 几何的唯一权威来源是控件本身(按钮宽度由文本实测得出),因此这里读控件而不是重算。 + * + * @param [IN] action 目标按钮动作。 + * @param [OUT] rect 按钮矩形(客户区坐标)。 + * @return 找到且已摆放返回 true。 + */ + bool ButtonRect(ToolbarAction action, gd::RectF& rect) const; + /** * @brief 按给定区域重新排布按钮。 * @param [IN] area 工具栏可用区域。 */ - void Layout(const gd::RectF& area); + void Layout(const gd::RectF& area) override; + + /** @brief 工具栏占用的整条区域(与 `Layout` 同源)。 */ + gd::RectF Area() const override { return m_area; } /** * @brief 绘制工具栏。 * @param [IN,OUT] g 绘图上下文。 */ - void Paint(gd::Graphics& g); + void Paint(gd::Graphics& g) override; /** * @brief 处理鼠标移动,更新悬停状态。 @@ -123,7 +143,7 @@ namespace jdi * @brief 处理鼠标离开工具栏。 * @return 视觉状态发生变化返回 true。 */ - bool OnMouseLeave(); + bool OnMouseLeave() override; /** * @brief 判断坐标是否落在工具栏区域内。 @@ -131,23 +151,28 @@ namespace jdi * @param [IN] y 客户区纵坐标。 * @return 落在区域内返回 true。 */ - bool HitTest(int x, int y) const; + bool HitTest(int x, int y) const override; + + /** + * @brief 收集工具栏区域、**每个按钮**与按钮内容(文本/图标)的布局边界(调试用)。 + * @param [IN,OUT] boxes 收集器。 + */ + void CollectDebugBoxes(UiDebugBoxList& boxes) override; private: /** - * @brief 单个工具栏按钮的运行时状态。 + * @brief 单个工具栏按钮的**静态配置**(矩形与外观状态由控件自己维护)。 */ struct Button { ToolbarAction action = ToolbarAction::NONE; /**< 按钮动作。 */ - std::wstring label; /**< 按钮文本。 */ - gd::RectF rect; /**< 按钮矩形。 */ - float width = 0.0f; /**< 按钮宽度。 */ + UiButtonWidget* widget = nullptr; /**< 对应控件(由 `m_root` 拥有)。 */ + UiButtonIcon icon = UiButtonIcon::NONE; /**< 图标(非 `NONE` 时按钮只画图标)。 */ bool primary = false; /**< 是否为主按钮(强调色填充)。 */ - bool needsDocument = true; /**< 是否依赖已加载数据。 */ bool toggle = false; /**< 是否为可切换按钮。 */ + bool needsDocument = true; /**< 是否依赖已加载数据。 */ + bool toggle = false; /**< 是否为可切换按钮。 */ bool checked = false; /**< 可切换按钮的选中状态。 */ - bool hover = false; /**< 悬停状态(当前由 `Toolbar::m_hoverIndex` 派生,字段保留供未来使用)。 */ - bool pressed = false; /**< 按下状态。 */ + float width = 0.0f; /**< 按钮宽度(由控件实测得出)。 */ }; /** @@ -157,15 +182,28 @@ namespace jdi */ bool IsEnabled(const Button& button) const; + /** + * @brief 按固定规格表重建控件树。 + * + * ⚠️ 只在 `m_treeDirty` 为真时由 `Layout` 调用:规格表、字体、主题、缩放、动态文本 + * 任何一项变化都会置脏,而**纯粹的尺寸变化不会**(按钮几何由 `Arrange` 决定)。 + * 旧实现每次 `Layout` 都无条件重建(拖动面板分隔条时每帧一次)⇒ 每帧 9 次堆分配 + * 加 8 次测量缓存失效。 + */ + void RebuildWidgets(); + /** @brief 把可用性同步到控件(未加载数据时禁用依赖数据的按钮)。 */ + void SyncEnabledState(); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ bool m_documentLoaded = false; /**< 是否已加载数据。 */ - std::vector