Files
Just-DataInspector/Components/Shared/Src/UiDebugBounds.cpp
T
DeepSeek V4.1 Flash 57e12cfb27 fix(UI): 布局边界按尺寸自适应 + 修复拖滚动条的绘制残留与浮层层级
## 1. 边界描边按元素尺寸自适应(修复"小元素变成粗描边")

旧规则四角固定 3-4px:对 15px 的复选框来说包角占短边 20%,看起来就是一整圈粗边框。
新规则全部以**短边**为基准:
- 四边固定 1 设备像素;
- 包角粗细 = min(基础 4/3, 短边/10),再夹到 [1, 短边/4];
- 包角长度 = min(14, 短边/3),且不小于包角粗细;
- 短边 <= 12 只画四个 L 形角标记、**不画边**(一圈连续描边会让小方框显得"被框住");
- 短边 <= 5 完全不画(再画就盖住元素本身);
- 内缩只用于「层级 > 0 且短边 >= 10」,否则小元素会被缩没。

同时**删除文字标签**(含 UiDebugPaintStyle):黑底白字在小元素上必糊成一片,体积也省约 1.5KB。

## 2. 拖网格滚动条时的"大量绘制残留":三个独立成因

R1. PlotView::CollectDebugBoxes 读了 m_decorRects —— 那是**装饰层位图缓存**的登记矩形,
    坐标处在位图/图层坐标系(带负 Y、超出绘图区),于是出现 (472,-27 1213x101) 这类
    **横跨工具栏的幽灵巨框**。改为只用 LaneBadgeRect 与 m_legendHits(屏幕坐标)。

R2. Paint 的三条快路径(静态场景贴图 / 宽基底贴图 / 样式补丁)只重画**变化的部分**,
    而调试边框是画在整个窗口上的叠加内容 ⇒ 上一帧的边框残留在未被重画的像素上,
    直到松开鼠标走整幅重绘才消失。
    新增 PlotView::SetForceFullRepaint(bool):开启调试叠加层时禁用全部快路径。

R3. 组件内元素可能落在自己地盘之外(滚出可视区的 lane.rect 的 Y 为负),不裁剪就画到
    工具栏/标签栏上。box 增加 clip:宿主按组件 Area() 设置,Add() 跳过无交集、
    绘制时与 clip 求交。

另外把 Render 的 fullChrome 并入 m_showLayoutBounds,装饰区也保证完整一帧。

## 3. 层级:排序 + 挖洞(修复二级菜单被下层边界覆盖)

- UiDebugBoxList::PushLayer() + 按 (layer, depth) 稳定排序:后收集的内容压住先收集的。
- 但仅靠排序不够:调试线画在所有内容之后,下层组件的线仍会显示在不透明菜单上面。
  新增 AddOccluder():菜单/拾色器把本体登记为遮挡矩形,绘制**更低层**的 box 时用
  Region::Exclude 挖洞。实测菜单区与子菜单区的琥珀/绿色像素均为 0。
- 浮层**不设** clip = 本体矩形:二级子菜单会展开到本体之外,设裁剪会把它整个裁掉。

## 4. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 906,752 B(885.5 KiB < 900 KiB)。
- 新增 .preflight/lbresid(MF4-Record.MF4 / 283 网格 / 拖滚动条)**5/5**:
  toolbar-unchanged-while-scrolling、repaint-same-state-is-identical、
  toolbar-restored-by-full-repaint、residual-never-above-plot-area、later-layer-wins-over-earlier。
- 新增 .preflight/lbzorder **4/4**:菜单与二级子菜单区内下层颜色像素 = 0。
- 回归:.preflight/lbcheck 21/21、Tools/uicheck 69/69、
  Tools/rendercheck「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7。

注:判断"是否有绘制残留"不能用"拖拽帧 vs 松开后一帧"——两者图层预热进度不同会产生
几十万像素的**合法**差异;正确判据是**同一状态连续两帧必须逐像素相同**。

## 5. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第八阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第十轮」硬约束与踩坑。
2026-10-28 17:01:26 +08:00

322 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "UiDebugBounds.hpp"
#include <algorithm>
#include <cmath>
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 短边小于该值时不做内缩(内缩会把小元素缩成一条线)。 */
constexpr int kInsetThreshold = 10;
/** @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,混进调试层会带来额外状态切换开销。
*/
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 把逻辑矩形对齐到整像素(`inset` 先内缩,用于避免父子共边)。 */
PixelRect AlignToPixels(const gd::RectF& rect, int inset)
{
const float shrink = static_cast<float>(inset);
const float x = rect.X + shrink;
const float y = rect.Y + shrink;
const float width = rect.Width - shrink * 2.0f;
const float height = rect.Height - shrink * 2.0f;
if (!(width > 0.0f) || !(height > 0.0f))
{
return PixelRect();
}
PixelRect out;
out.left = static_cast<int>(std::lround(x));
out.top = static_cast<int>(std::lround(y));
out.right = static_cast<int>(std::lround(x + width)) - 1;
out.bottom = static_cast<int>(std::lround(y + 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::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);
}
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<gd::RectF>& rects, int depth)
{
for (const gd::RectF& rect : rects)
{
Add(rect, depth);
}
}
std::uint32_t UiDebugBoundsColor(int depth)
{
// 与主题无关的固定配色:调试层必须"永远显眼",不能跟随用户主题变成接近底色。
switch (depth)
{
case 0:
return 0xFF29B6F6u; // 青蓝:顶层组件区域
case 1:
return 0xFFE040FBu; // 品红:组件内部的功能分区
case 2:
return 0xFFFFB300u; // 琥珀:具体控件 / 行
default:
return 0xFF66BB6Au; // 绿:更深的子块(复选框、文本、✕ …)
}
}
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;
// 先用未内缩的尺寸判断"值不值得画"和"要不要内缩":
// 内缩是给"父子共边"用的,元素本身很小时内缩只会把它缩没。
const float rawShortSide = std::min(rect.Width, rect.Height);
if (rawShortSide <= static_cast<float>(kSkipThreshold))
{
return; // 太小:画上去就是糊住元素本身。
}
const int inset = (level > 0 && rawShortSide >= static_cast<float>(kInsetThreshold)) ? 1 : 0;
PixelRect pixels = AlignToPixels(rect, inset);
if (!pixels.Valid())
{
return;
}
// ⚠️ 与裁剪矩形求交(见 `UiDebugBoxList::SetClip`):组件内部的元素可能落在地盘之外。
// 用 floor/ceil 而不是取整:裁剪是“能看见多少就画多少”,宁可多一像素也不要把
// 紧贴边界的包角整个裁掉。
if (clip.Width > 0.0f && clip.Height > 0.0f)
{
const int clipLeft = static_cast<int>(std::floor(clip.X));
const int clipTop = static_cast<int>(std::floor(clip.Y));
const int clipRight = static_cast<int>(std::ceil(clip.X + clip.Width)) - 1;
const int clipBottom = static_cast<int>(std::ceil(clip.Y + clip.Height)) - 1;
PixelRect clipped;
clipped.left = std::max(pixels.left, clipLeft);
clipped.top = std::max(pixels.top, clipTop);
clipped.right = std::min(pixels.right, clipRight);
clipped.bottom = std::min(pixels.bottom, clipBottom);
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 只在足够大的矩形上使用;短边越小越细,最终退化为 1px。
// 同时不超过短边的 1/4,避免包角在元素自身上"占比过大"。
const int baseThick = (level == 0) ? kAreaCornerThick : kNodeCornerThick;
int cornerThick = 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);
// 小方框只画角、不画边:一圈连续描边会让复选框/色块/✕ 看起来"被粗框包住",
// 而四个 L 形小标记既不糊内容,又能立刻定位边界的四角。
const bool drawEdges = (shortSide > kEdgelessThreshold);
const gd::GraphicsState state = g.Save();
// 描边必须锐利:关抗锯齿 + 关半像素偏移,矩形已对齐整像素 ⇒ 1px 就是 1px。
g.SetSmoothingMode(gd::SmoothingModeNone);
g.SetPixelOffsetMode(gd::PixelOffsetModeNone);
if (drawEdges)
{
// 四边:统一 1px,用实心矩形而不是 `DrawRectangle`
//(后者会因笔宽奇偶而偏移半像素,且抗锯齿会糊成 2px)。
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); // 右
}
// 四个角:加粗成 ⌜ ⌝ ⌞ ⌟ 包角(粗细见上)。
// 左上
FillPixels(g, pixels.left, pixels.top, cornerLen, cornerThick, color);
FillPixels(g, pixels.left, pixels.top, cornerThick, cornerLen, color);
// 右上
FillPixels(g, pixels.right - cornerLen + 1, pixels.top, cornerLen, cornerThick, color);
FillPixels(g, pixels.right - cornerThick + 1, pixels.top, cornerThick, cornerLen, color);
// 左下
FillPixels(g, pixels.left, pixels.bottom - cornerThick + 1, cornerLen, cornerThick, color);
FillPixels(g, pixels.left, pixels.bottom - cornerLen + 1, cornerThick, cornerLen, color);
// 右下
FillPixels(g, pixels.right - cornerLen + 1, pixels.bottom - cornerThick + 1, cornerLen,
cornerThick, color);
FillPixels(g, pixels.right - cornerThick + 1, pixels.bottom - cornerLen + 1, cornerThick,
cornerLen, color);
g.Restore(state);
}
void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes)
{
if (boxes.Empty())
{
return;
}
// ⚠️ 排序键是 `(layer, depth)`:
// * `layer` 保证"后收集的(更靠上的浮层)压住先收集的" —— 没有它,信号面板的行边界
// 会盖在右键弹出的二级菜单上面;
// * `depth` 保证同一层内先画父再画子,子元素的彩色包角不会被父的边压住。
std::vector<const UiDebugBox*> 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 重叠时谁压谁”,不能阻止“先画的线仍然可见”,
// 因此这里把**更高层**的遮挡矩形从裁剪区里排除。
bool occluded = false;
gd::Region region;
bool regionReady = false;
for (const UiDebugBoxList::Occluder& occluder : boxes.Occluders())
{
if (occluder.layer <= box->layer)
{
continue;
}
if (!regionReady)
{
// 以“整个客户区”为起点(足够大的矩形,超出窗口的部分自然不会被画到)。
const gd::RectF everything(-100000.0f, -100000.0f, 200000.0f, 200000.0f);
region.MakeInfinite();
region.Intersect(everything);
regionReady = true;
}
region.Exclude(occluder.rect);
occluded = true;
}
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(&region, gd::CombineModeIntersect);
UiDebugPaintBoundsClipped(g, box->rect, gd::RectF(), box->depth);
g.Restore(state);
}
}
} // namespace jdi