From 9155a86a8a769624c5eb2ae2ce9bc55b036d99d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 17 May 2026 00:26:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=89=8D=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E9=87=8D=E7=BB=98=E4=B8=8E=E5=B8=83=E5=B1=80=E5=B0=81=E7=89=88?= =?UTF-8?q?=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收口 Dialog/overlay 后鼠标状态同步、Tooltip 临时 coverage 与持久 coverage 拆分、跨 root 脏区补提交、TextBox/Button 绘制副作用修复,并补充 KEY6 回归用例和 BUG/Fix/Feature 开发记录。 --- Button.cpp | 21 +- Button.h | 6 +- Canvas.cpp | 77 +++- Canvas.h | 1 + Control.cpp | 7 + Control.h | 3 + Dialog.h | 2 + StellarX.h | 14 +- TabControl.cpp | 74 ++- TabControl.h | 1 + Table.cpp | 34 ++ Table.h | 4 +- TextBox.cpp | 132 +++++- TextBox.h | 4 +- Window.cpp | 156 ++++++- Window.h | 15 +- z-testDome.cpp | 426 ++++++++++++++---- ...511-0009-TabControl-indexOf未命中返回值错误.md | 51 +++ ...部提交内部coverage扩张未反馈导致Dialog漏补.md | 52 +++ ...1-跨root回调改脏未同轮提交导致Label延迟刷新.md | 51 +++ ...-0012-Tooltip临时浮层污染或擦除兄弟控件快照.md | 54 +++ ...511-0009-TabControl-indexOf未命中返回值修复.md | 55 +++ ...11-0010-局部提交overlay补画coverage兜底修复.md | 57 +++ ...ix-BUG-20260511-0011-事件尾脏root补收集修复.md | 60 +++ ...2-Tooltip临时coverage与持久coverage拆分修复.md | 68 +++ ...ture-20260511-0012-发布前托管重绘与KEY6封版收口.md | 105 +++++ 26 files changed, 1355 insertions(+), 175 deletions(-) create mode 100644 开发记录/BUG/BUG-20260511-0009-TabControl-indexOf未命中返回值错误.md create mode 100644 开发记录/BUG/BUG-20260511-0010-局部提交内部coverage扩张未反馈导致Dialog漏补.md create mode 100644 开发记录/BUG/BUG-20260511-0011-跨root回调改脏未同轮提交导致Label延迟刷新.md create mode 100644 开发记录/BUG/BUG-20260511-0012-Tooltip临时浮层污染或擦除兄弟控件快照.md create mode 100644 开发记录/Fix/Fix-BUG-20260511-0009-TabControl-indexOf未命中返回值修复.md create mode 100644 开发记录/Fix/Fix-BUG-20260511-0010-局部提交overlay补画coverage兜底修复.md create mode 100644 开发记录/Fix/Fix-BUG-20260511-0011-事件尾脏root补收集修复.md create mode 100644 开发记录/Fix/Fix-BUG-20260511-0012-Tooltip临时coverage与持久coverage拆分修复.md create mode 100644 开发记录/功能变更/Feature-20260511-0012-发布前托管重绘与KEY6封版收口.md diff --git a/Button.cpp b/Button.cpp index 4e6a50e..296ab6b 100644 --- a/Button.cpp +++ b/Button.cpp @@ -173,11 +173,13 @@ void Button::draw() //保存当前样式和颜色 saveStyle(); + StellarX::ControlText drawTextStyle = textStyle; if (StellarX::ButtonMode::DISABLED == mode) //设置禁用按钮色 { setfillcolor(DISABLEDCOLOUR); - textStyle.bStrikeOut = true; + // 禁用态删除线只属于本次绘制效果,不回写用户公开 textStyle。 + drawTextStyle.bStrikeOut = true; } else { @@ -192,11 +194,11 @@ void Button::draw() setlinecolor(buttonBorderColor); //设置字体颜色 - settextcolor(textStyle.color); + settextcolor(drawTextStyle.color); //设置字体样式 - settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace, - textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight, - textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut); + settextstyle(drawTextStyle.nHeight, drawTextStyle.nWidth, drawTextStyle.lpszFace, + drawTextStyle.nEscapement, drawTextStyle.nOrientation, drawTextStyle.nWeight, + drawTextStyle.bItalic, drawTextStyle.bUnderline, drawTextStyle.bStrikeOut); if (needCutText) cutButtonText(); @@ -526,6 +528,13 @@ RECT Button::getManagedRepaintCoverageRect() const return coverage; } +RECT Button::getManagedRepaintPersistentCoverageRect() const +{ + // Tooltip 属于临时浮层,只参与完整 coverage 和上层 overlay 判断; + // 兄弟控件是否需要作废背景快照,只应由按钮本体这类持久绘制范围决定。 + return getBoundsRect(); +} + void Button::setOnClickListener(std::function callback) { this->onClickCallback = std::move(callback); @@ -542,8 +551,6 @@ void Button::setOnToggleOffListener(std::function callback) void Button::setbuttonMode(StellarX::ButtonMode mode) { - if (this->mode == StellarX::ButtonMode::DISABLED && mode != StellarX::ButtonMode::DISABLED) - textStyle.bStrikeOut = false; //取值范围参考 buttMode的枚举注释 this->mode = mode; dirty = true; // 标记需要重绘 diff --git a/Button.h b/Button.h index f220ef4..a398a1c 100644 --- a/Button.h +++ b/Button.h @@ -105,6 +105,8 @@ public: bool clearTransientMouseState() override; // Tooltip 可见时,按钮实际写像素范围不再等于按钮本体,需要把 Tooltip 矩形并入 coverage。 RECT getManagedRepaintCoverageRect() const override; + // Button 的持久绘制范围只包含本体;Tooltip 是临时浮层,不能污染兄弟控件背景快照。 + RECT getManagedRepaintPersistentCoverageRect() const override; // 设置 NORMAL 模式下的点击回调 void setOnClickListener(std::function callback); @@ -134,7 +136,7 @@ public: void setButtonText(std::string text); // 设置按钮几何形状 void setButtonShape(StellarX::ControlShape shape); - // 直接设置按钮点击状态;TOGGLE 模式下会按状态变化触发相应回调 + // 直接设置按钮点击状态;NORMAL 设置为 true 时触发 onClick,TOGGLE 仅在状态变化时触发 on/off 回调 void setButtonClick(BOOL click); // 查询按钮当前是否处于点击/选中状态 @@ -168,7 +170,7 @@ public: void setTooltipDelay(int ms) { tipDelayMs = (ms < 0 ? 0 : ms); } // 设置 Tooltip 是否跟随鼠标 void setTooltipFollowCursor(bool on) { tipFollowCursor = on; } - // 设置 Tooltip 相对鼠标/按钮的偏移量 + // 设置 Tooltip 偏移量;当前仅在 setTooltipFollowCursor(true) 时参与定位 void setTooltipOffset(int dx, int dy) { tipOffsetX = dx; tipOffsetY = dy; } // 设置 Tooltip 的文字、背景和透明样式 void setTooltipStyle(COLORREF text, COLORREF bk, bool transparent); diff --git a/Canvas.cpp b/Canvas.cpp index 6485be1..0492d7f 100644 --- a/Canvas.cpp +++ b/Canvas.cpp @@ -22,6 +22,12 @@ static const char* SxCanvasMsgName(UINT m) namespace { + enum class SxCanvasOverlayRedrawMode + { + None, + RefreshSnapshot + }; + bool SxCanvasRectValid(const RECT& rc) { return rc.right > rc.left && rc.bottom > rc.top; @@ -209,9 +215,18 @@ bool Canvas::handleEvent(const ExMessage& msg) consumed = true; } } - else if (c->clearTransientMouseState()) + else { - anyVisualChanged = true; + // 后续兄弟只走临时状态清理,不会再进入自己的 handleEvent()。 + // Tooltip 隐藏会先回贴旧快照,再改变 coverage;因此必须先保存旧覆盖范围, + // 避免登记重绘时丢失旧 Tooltip 区域,导致上层 overlay 补画判断不完整。 + const RECT previousCoverage = c->getManagedRepaintCoverageRect(); + if (c->clearTransientMouseState()) + { + if (Window* host = getHostWindow()) + host->requestManagedRepaint(c, previousCoverage); + anyVisualChanged = true; + } } } } @@ -403,9 +418,11 @@ void Canvas::requestRepaint(Control* parent) SX_LOG_TRACE("Dirty") << SX_T("Canvas 请求局部重绘:id=", "Canvas::requestRepaint(partial): id=") << id; - RECT coverage{}; - bool hasCoverage = false; - auto commitManagedChild = [&](Control* child, bool forceOverlayRedraw) + RECT paintCoverage{}; + bool hasPaintCoverage = false; + RECT persistentCoverage{}; + bool hasPersistentCoverage = false; + auto commitManagedChild = [&](Control* child, SxCanvasOverlayRedrawMode overlayMode) { if (!child || !child->IsVisible()) return; @@ -413,10 +430,10 @@ void Canvas::requestRepaint(Control* parent) const bool directDirty = child->isDirty(); const bool subtreeDirty = child->hasManagedDirtySubtree(); - if (forceOverlayRedraw) + if (overlayMode == SxCanvasOverlayRedrawMode::RefreshSnapshot) { // overlay 补画必须先作废旧快照: - // 下层兄弟刚刚已经写过像素,若继续沿用旧快照,会把旧背景再贴回来。 + // 下层兄弟的持久内容刚刚已经写过像素,若继续沿用旧快照,会把旧背景再贴回来。 child->invalidateBackgroundSnapshot(); child->setDirty(true); child->draw(); @@ -437,15 +454,26 @@ void Canvas::requestRepaint(Control* parent) return; } - const RECT childRect = child->getManagedRepaintCoverageRect(); - if (!hasCoverage) + const RECT childPaintRect = child->getManagedRepaintCoverageRect(); + if (!hasPaintCoverage) { - coverage = childRect; - hasCoverage = true; + paintCoverage = childPaintRect; + hasPaintCoverage = true; } else { - coverage = SxCanvasUnionRect(coverage, childRect); + paintCoverage = SxCanvasUnionRect(paintCoverage, childPaintRect); + } + + const RECT childPersistentRect = child->getManagedRepaintPersistentCoverageRect(); + if (!hasPersistentCoverage) + { + persistentCoverage = childPersistentRect; + hasPersistentCoverage = true; + } + else + { + persistentCoverage = SxCanvasUnionRect(persistentCoverage, childPersistentRect); } }; @@ -457,12 +485,17 @@ void Canvas::requestRepaint(Control* parent) if (child->hasManagedDirtySubtree()) { - commitManagedChild(child, false); + commitManagedChild(child, SxCanvasOverlayRedrawMode::None); } - else if (hasCoverage && SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), coverage)) + else if (hasPaintCoverage && SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), paintCoverage)) { // 位于本次累计 coverage 上方、且发生相交的兄弟控件,需要补画回最上层。 - commitManagedChild(child, true); + // 但只有下层“持久内容”影响到它时,才允许作废并重新抓背景快照; + // 如果只是被 Tooltip 等临时浮层覆盖,则跳过兄弟补画,避免透明控件回贴旧快照擦掉 Tooltip。 + const bool persistentHit = hasPersistentCoverage && + SxCanvasRectsIntersect(child->getManagedRepaintPersistentCoverageRect(), persistentCoverage); + if (persistentHit) + commitManagedChild(child, SxCanvasOverlayRedrawMode::RefreshSnapshot); } } @@ -502,6 +535,20 @@ RECT Canvas::getManagedRepaintCoverageRect() const return coverage; } +RECT Canvas::getManagedRepaintPersistentCoverageRect() const +{ + // 持久 coverage 只描述会进入背景快照语义的范围。 + // 子控件 Tooltip 等临时浮层不会被并入,避免兄弟控件补画时抓到临时像素。 + RECT coverage = getBoundsRect(); + for (const auto& child : controls) + { + if (!child->IsVisible()) + continue; + coverage = SxCanvasUnionRect(coverage, child->getManagedRepaintPersistentCoverageRect()); + } + return coverage; +} + bool Canvas::canCommitManagedPartialRepaint() const { // Canvas 只有在“自己本体不脏 + 仍持有有效背景快照”时, diff --git a/Canvas.h b/Canvas.h index 2623284..d995427 100644 --- a/Canvas.h +++ b/Canvas.h @@ -79,6 +79,7 @@ public: void requestRepaint(Control* parent)override; bool hasManagedDirtySubtree() const override; RECT getManagedRepaintCoverageRect() const override; + RECT getManagedRepaintPersistentCoverageRect() const override; // 判断当前 Canvas 是否可安全做局部提交 bool canCommitManagedPartialRepaint() const override; // 托管收口阶段执行 Canvas 的真正重绘 diff --git a/Control.cpp b/Control.cpp index 2f0090d..3eb69d8 100644 --- a/Control.cpp +++ b/Control.cpp @@ -579,6 +579,13 @@ RECT Control::getManagedRepaintCoverageRect() const return getBoundsRect(); } +RECT Control::getManagedRepaintPersistentCoverageRect() const +{ + // 基类默认认为持久绘制范围等于控件本体。 + // 只有 Tooltip 这类临时浮层需要从持久范围中剔除。 + return getBoundsRect(); +} + bool Control::canCommitManagedPartialRepaint() const { // 基类默认不承诺自己能安全做局部提交; diff --git a/Control.h b/Control.h index f8f7687..2640117 100644 --- a/Control.h +++ b/Control.h @@ -154,6 +154,9 @@ public: // 默认等于控件本体矩形;像 Button 这类会额外绘制 Tooltip 的控件,可 override 后扩大范围。 // 托管重绘 coverage、overlay 相交判断统一走这个接口,而不再默认使用控件本体 bounds。 virtual RECT getManagedRepaintCoverageRect() const; + // 获取会影响后续控件背景快照的“持久绘制范围”。 + // Tooltip 等临时浮层不应进入该范围,避免上层兄弟补画时把临时浮层抓进背景快照。 + virtual RECT getManagedRepaintPersistentCoverageRect() const; Control* getManagedRepaintRoot(); // 找到本控件对应的托管重绘 root Control* getManagedRepaintDirectBranch(Control* root); // 找到“root 下面承接本次脏变化的直接子分支” bool hasValidBackgroundSnapshot() const { return hasSnap && saveBkImage != nullptr; } // 当前是否持有可用于局部恢复的快照 diff --git a/Dialog.h b/Dialog.h index 98a4832..baab61e 100644 --- a/Dialog.h +++ b/Dialog.h @@ -33,6 +33,8 @@ #define BorderWidth 3 //边框宽度 class Dialog : public Canvas { + friend class Window; + Window& hWnd; //窗口引用 int textWidth = 0; //文本宽度 diff --git a/StellarX.h b/StellarX.h index bf593cc..993aeab 100644 --- a/StellarX.h +++ b/StellarX.h @@ -1,13 +1,13 @@ /******************************************************************************* * @文件: StellarX.h * @摘要: 星垣(StellarX) GUI框架 - 主包含头文件 - * @版本: v3.0.2 + * @版本: v3.1.0 * @描述: * 一个为Windows平台打造的轻量级、模块化C++ GUI框架。 * 基于EasyX图形库,提供简洁易用的API和丰富的控件。 * * 通过包含此单一头文件,即可使用框架的所有功能。 - * 内部包含顺序经过精心设计,确保所有依赖关系正确解析。 + * 内部头文件包含顺序由框架维护,用户代码不应依赖该顺序。 * * @作者: 我在人间做废物 * @邮箱: [3150131407@qq.com] | [ysm3150131407@gmail.com] @@ -21,12 +21,10 @@ * @使用说明: * 只需包含此文件即可使用框架所有功能。 * 示例: #include "StellarX.h" - * @包含顺序: - * 1. CoreTypes.h - 基础类型定义 - * 2. Control.h - 控件基类 - * 3. ...其他具体控件头文件 - * 4. Dialog:继承自 Canvas(Dialog 为可包含子控件的对话框容器) - * 5. MessageBox:对话框工厂,提供便捷的模态/非模态调用方式 + * @包含模块: + * CoreTypes.h / SxLog.h / Control.h / Canvas.h / Window.h + * Button.h / Label.h / TextBox.h / Table.h + * Dialog.h / MessageBox.h / TabControl.h ******************************************************************************/ #pragma once diff --git a/TabControl.cpp b/TabControl.cpp index 3eba7de..3486fa0 100644 --- a/TabControl.cpp +++ b/TabControl.cpp @@ -4,6 +4,12 @@ namespace { + enum class SxTabOverlayRedrawMode + { + None, + RefreshSnapshot + }; + bool SxTabRectsIntersect(const RECT& a, const RECT& b) { return a.left < b.right && a.right > b.left && @@ -473,7 +479,7 @@ int TabControl::indexOf(const std::string& tabText) const return idx; } - return idx; + return -1; } void TabControl::setDirty(bool dirty) @@ -498,9 +504,11 @@ void TabControl::requestRepaint(Control* parent) if (this == parent) { - RECT coverage{}; - bool hasCoverage = false; - auto commitTabUnit = [&](Control* unit, bool forceOverlayRedraw) + RECT paintCoverage{}; + bool hasPaintCoverage = false; + RECT persistentCoverage{}; + bool hasPersistentCoverage = false; + auto commitTabUnit = [&](Control* unit, SxTabOverlayRedrawMode overlayMode) { if (!unit || !unit->IsVisible()) return; @@ -508,9 +516,9 @@ void TabControl::requestRepaint(Control* parent) const bool directDirty = unit->isDirty(); const bool subtreeDirty = unit->hasManagedDirtySubtree(); - if (forceOverlayRedraw) + if (overlayMode == SxTabOverlayRedrawMode::RefreshSnapshot) { - // 下层单元已经写过像素,上层页签/页面作为 overlay 补画时, + // 下层单元的持久内容已经写过像素,上层页签/页面作为 overlay 补画时, // 必须先丢掉旧快照,重新抓取当前背景后再画,否则会把旧背景再贴回来。 unit->invalidateBackgroundSnapshot(); unit->setDirty(true); @@ -529,15 +537,26 @@ void TabControl::requestRepaint(Control* parent) return; } - const RECT rc = unit->getManagedRepaintCoverageRect(); - if (!hasCoverage) + const RECT paintRect = unit->getManagedRepaintCoverageRect(); + if (!hasPaintCoverage) { - coverage = rc; - hasCoverage = true; + paintCoverage = paintRect; + hasPaintCoverage = true; } else { - coverage = SxTabUnionRect(coverage, rc); + paintCoverage = SxTabUnionRect(paintCoverage, paintRect); + } + + const RECT persistentRect = unit->getManagedRepaintPersistentCoverageRect(); + if (!hasPersistentCoverage) + { + persistentCoverage = persistentRect; + hasPersistentCoverage = true; + } + else + { + persistentCoverage = SxTabUnionRect(persistentCoverage, persistentRect); } }; @@ -553,11 +572,14 @@ void TabControl::requestRepaint(Control* parent) if (page->hasManagedDirtySubtree()) { - commitTabUnit(page, false); + commitTabUnit(page, SxTabOverlayRedrawMode::None); } - else if (hasCoverage && SxTabRectsIntersect(page->getManagedRepaintCoverageRect(), coverage)) + else if (hasPaintCoverage && SxTabRectsIntersect(page->getManagedRepaintCoverageRect(), paintCoverage)) { - commitTabUnit(page, true); + const bool persistentHit = hasPersistentCoverage && + SxTabRectsIntersect(page->getManagedRepaintPersistentCoverageRect(), persistentCoverage); + if (persistentHit) + commitTabUnit(page, SxTabOverlayRedrawMode::RefreshSnapshot); } } @@ -569,11 +591,14 @@ void TabControl::requestRepaint(Control* parent) if (button->hasManagedDirtySubtree()) { - commitTabUnit(button, false); + commitTabUnit(button, SxTabOverlayRedrawMode::None); } - else if (hasCoverage && SxTabRectsIntersect(button->getManagedRepaintCoverageRect(), coverage)) + else if (hasPaintCoverage && SxTabRectsIntersect(button->getManagedRepaintCoverageRect(), paintCoverage)) { - commitTabUnit(button, true); + const bool persistentHit = hasPersistentCoverage && + SxTabRectsIntersect(button->getManagedRepaintPersistentCoverageRect(), persistentCoverage); + if (persistentHit) + commitTabUnit(button, SxTabOverlayRedrawMode::RefreshSnapshot); } } return; @@ -612,6 +637,21 @@ RECT TabControl::getManagedRepaintCoverageRect() const return coverage; } +RECT TabControl::getManagedRepaintPersistentCoverageRect() const +{ + // 持久 coverage 排除页签按钮 Tooltip 等临时浮层, + // 用于判断上层页签/页面补画时是否允许刷新背景快照。 + RECT coverage = getBoundsRect(); + for (const auto& control : controls) + { + if (control.first->IsVisible()) + coverage = SxTabUnionRect(coverage, control.first->getManagedRepaintPersistentCoverageRect()); + if (control.second->IsVisible()) + coverage = SxTabUnionRect(coverage, control.second->getManagedRepaintPersistentCoverageRect()); + } + return coverage; +} + bool TabControl::canCommitManagedPartialRepaint() const { // TabControl 只有在自己本体不脏且背景快照有效时,才允许只更新脏页签/脏页面。 diff --git a/TabControl.h b/TabControl.h index 0887f51..4eda6f5 100644 --- a/TabControl.h +++ b/TabControl.h @@ -84,6 +84,7 @@ public: void requestRepaint(Control* parent)override; // 托管模式下登记为 root;非托管模式下局部更新脏按钮/脏页面 bool hasManagedDirtySubtree() const override; RECT getManagedRepaintCoverageRect() const override; + RECT getManagedRepaintPersistentCoverageRect() const override; bool canCommitManagedPartialRepaint() const override; // 判断当前 TabControl 是否可安全做局部提交 void commitManagedRepaint() override; // 托管收口阶段执行 TabControl 的真正重绘 protected: diff --git a/Table.cpp b/Table.cpp index 4173595..90106e3 100644 --- a/Table.cpp +++ b/Table.cpp @@ -614,6 +614,40 @@ RECT Table::getManagedRepaintCoverageRect() const return coverage; } +RECT Table::getManagedRepaintPersistentCoverageRect() const +{ + // Table 当前仍按整表绘制保证正确性;分页按钮 Tooltip 属于临时浮层, + // 不能进入持久 coverage,否则可能污染外层兄弟控件快照。 + RECT coverage = getBoundsRect(); + + if (pageNum && pageNum->IsVisible()) + { + const RECT pageRect = pageNum->getManagedRepaintPersistentCoverageRect(); + coverage.left = (std::min)(coverage.left, pageRect.left); + coverage.top = (std::min)(coverage.top, pageRect.top); + coverage.right = (std::max)(coverage.right, pageRect.right); + coverage.bottom = (std::max)(coverage.bottom, pageRect.bottom); + } + if (prevButton && prevButton->IsVisible()) + { + const RECT prevRect = prevButton->getManagedRepaintPersistentCoverageRect(); + coverage.left = (std::min)(coverage.left, prevRect.left); + coverage.top = (std::min)(coverage.top, prevRect.top); + coverage.right = (std::max)(coverage.right, prevRect.right); + coverage.bottom = (std::max)(coverage.bottom, prevRect.bottom); + } + if (nextButton && nextButton->IsVisible()) + { + const RECT nextRect = nextButton->getManagedRepaintPersistentCoverageRect(); + coverage.left = (std::min)(coverage.left, nextRect.left); + coverage.top = (std::min)(coverage.top, nextRect.top); + coverage.right = (std::max)(coverage.right, nextRect.right); + coverage.bottom = (std::max)(coverage.bottom, nextRect.bottom); + } + + return coverage; +} + void Table::setHeaders(std::initializer_list headers) { this->headers.clear(); diff --git a/Table.h b/Table.h index 423cb50..5b75e48 100644 --- a/Table.h +++ b/Table.h @@ -122,12 +122,14 @@ public: bool clearTransientMouseState() override; // Table 重绘时会一并绘制页码 Label 和分页按钮,coverage 需要把这些内部绘制单元并入。 RECT getManagedRepaintCoverageRect() const override; + // 持久 coverage 排除分页按钮 Tooltip 等临时浮层。 + RECT getManagedRepaintPersistentCoverageRect() const override; // 设置表头 void setHeaders(std::initializer_list headers); // 设置表格数据(单行追加) void setData(std::vector data); - // 设置表格数据(多行覆盖) + // 设置表格数据(多行追加);如需覆盖请先 clearData() 或 resetTable() void setData(std::initializer_list> data); //设置每页显示的行数 void setRowsPerPage(int rows); diff --git a/TextBox.cpp b/TextBox.cpp index ef974c1..3abb49f 100644 --- a/TextBox.cpp +++ b/TextBox.cpp @@ -2,6 +2,105 @@ #include "TextBox.h" #include "SxLog.h" +namespace +{ + int SxTextBoxMbcCharLen(const std::string& s, size_t i) + { + unsigned char b = static_cast(s[i]); + if (b <= 0x7F) + return 1; + + if (b >= 0x81 && b <= 0xFE && i + 1 < s.size()) + { + unsigned char b2 = static_cast(s[i + 1]); + if (b2 >= 0x40 && b2 <= 0xFE && b2 != 0x7F) + return 2; + } + + // 非法或不完整字节按单字节容错,避免死循环。 + return 1; + } + + void SxTextBoxTrimTrailingSpaces(std::string& s) + { + while (!s.empty() && s.back() == ' ') + s.pop_back(); + + while (s.size() >= 2) + { + unsigned char a = static_cast(s[s.size() - 2]); + unsigned char b = static_cast(s[s.size() - 1]); + if (a == 0xA1 && b == 0xA1) + s.resize(s.size() - 2); + else + break; + } + } + + std::string SxTextBoxTruncateByMbcBoundary(const std::string& text, size_t maxBytes) + { + size_t i = 0; + size_t lastSafe = 0; + while (i < text.size()) + { + const int charLen = SxTextBoxMbcCharLen(text, i); + const size_t next = i + static_cast(charLen); + if (next > maxBytes || next > text.size()) + break; + lastSafe = next; + i = next; + } + return text.substr(0, lastSafe); + } + + std::string SxTextBoxEllipsizeMbc(const std::string& text, int maxWidth) + { + if (maxWidth <= 0) + return ""; + if (textwidth(LPCTSTR(text.c_str())) <= maxWidth) + return text; + + const std::string ellipsis = "..."; + const int ellipsisWidth = textwidth(LPCTSTR(ellipsis.c_str())); + if (ellipsisWidth > maxWidth) + { + std::string clippedEllipsis = ellipsis; + while (!clippedEllipsis.empty() && textwidth(LPCTSTR(clippedEllipsis.c_str())) > maxWidth) + clippedEllipsis.pop_back(); + return clippedEllipsis; + } + + const int contentLimit = maxWidth - ellipsisWidth; + size_t i = 0; + size_t lastFit = 0; + while (i < text.size()) + { + const int charLen = SxTextBoxMbcCharLen(text, i); + const size_t next = i + static_cast(charLen); + if (next > text.size()) + break; + + const std::string candidate = text.substr(0, next); + if (textwidth(LPCTSTR(candidate.c_str())) <= contentLimit) + { + lastFit = next; + i = next; + } + else + { + break; + } + } + + if (lastFit == 0) + return ellipsis; + + std::string head = text.substr(0, lastFit); + SxTextBoxTrimTrailingSpaces(head); + return head + ellipsis; + } +} + TextBox::TextBox(int x, int y, int width, int height, std::string text, StellarX::TextBoxmode mode, StellarX::ControlShape shape) :Control(x, y, width, height), text(text), mode(mode), shape(shape) { @@ -20,22 +119,22 @@ void TextBox::draw() saveStyle(); setfillcolor(textBoxBkClor); setlinecolor(textBoxBorderClor); - if (textStyle.nHeight > height) - textStyle.nHeight = height; - if (textStyle.nWidth > width) - textStyle.nWidth = width; - settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace, - textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight, - textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut); + StellarX::ControlText drawStyle = textStyle; + if (drawStyle.nHeight > height) + drawStyle.nHeight = height; + if (drawStyle.nWidth > width) + drawStyle.nWidth = width; + settextstyle(drawStyle.nHeight, drawStyle.nWidth, drawStyle.lpszFace, + drawStyle.nEscapement, drawStyle.nOrientation, drawStyle.nWeight, + drawStyle.bItalic, drawStyle.bUnderline, drawStyle.bStrikeOut); - settextcolor(textStyle.color); + settextcolor(drawStyle.color); setbkmode(TRANSPARENT); int text_width = 0; int text_height = 0; std::string pwdText; std::string displayText; // 用于显示的文本(可能被截断) - bool isTextTruncated = false; // 标记文本是否被截断 if (StellarX::TextBoxmode::PASSWORD_MODE == mode) { @@ -55,17 +154,8 @@ void TextBox::draw() int currentWidth = textwidth(LPCTSTR(displayText.c_str())); if (currentWidth > availableWidth && availableWidth > 0) { - // 需要截断文本,预留空间放置省略号 - int ellipsisWidth = textwidth("..."); - int truncatedWidth = availableWidth - ellipsisWidth; - - std::string truncatedText = displayText; - while (truncatedText.size() > 0 && textwidth(LPCTSTR(truncatedText.c_str())) > truncatedWidth) - { - truncatedText.pop_back(); - } - displayText = truncatedText + "..."; - isTextTruncated = true; + // 按 GBK/MBCS 字符边界裁切,避免中文文本被截断到半个字节。 + displayText = SxTextBoxEllipsizeMbc(displayText, availableWidth); currentWidth = textwidth(LPCTSTR(displayText.c_str())); } @@ -231,7 +321,7 @@ void TextBox::setText(std::string text) if(text == this->text) return; // 文本未改变,无需更新和重绘 if (text.size() > maxCharLen) - text = text.substr(0, maxCharLen); + text = SxTextBoxTruncateByMbcBoundary(text, maxCharLen); this->text = text; this->dirty = true; // 标记需要重绘,不论是否窗口图形上下文是否已初始化,等第一次绘制时由窗口真正调用 draw() 来重绘显示文本 diff --git a/TextBox.h b/TextBox.h index 940cc95..79fe748 100644 --- a/TextBox.h +++ b/TextBox.h @@ -39,9 +39,9 @@ public: bool handleEvent(const ExMessage& msg) override; // 设置文本框模式 void setMode(StellarX::TextBoxmode mode); - // 设置可输入最大字符长度 + // 设置可输入最大长度;当前按 std::string 字节长度限制,不等同于 Unicode 字符数 void setMaxCharLen(size_t len); - // 设置文本框形状 + // 设置文本框形状;仅支持矩形/圆角矩形,圆形和椭圆会回退为 RECTANGLE void setTextBoxshape(StellarX::ControlShape shape); // 设置边框颜色 void setTextBoxBorder(COLORREF color); diff --git a/Window.cpp b/Window.cpp index c9db31e..ad6714d 100644 --- a/Window.cpp +++ b/Window.cpp @@ -33,6 +33,16 @@ static bool SxRectsIntersect(const RECT& a, const RECT& b) a.top < b.bottom && a.bottom > b.top; } +static RECT SxUnionRect(const RECT& a, const RECT& b) +{ + RECT out{}; + out.left = (std::min)(a.left, b.left); + out.top = (std::min)(a.top, b.top); + out.right = (std::max)(a.right, b.right); + out.bottom = (std::max)(a.bottom, b.bottom); + return out; +} + static void collectManagedControlOverlays(const std::vector>& controls, Control* repaintRoot, const RECT& coverage, std::vector& overlays); @@ -50,6 +60,13 @@ bool Window::isManagedDispatchActive() const * - coverage 记录这次变化影响的范围,用于判断哪些上层 Dialog 需要补画。 */ void Window::requestManagedRepaint(Control* source) +{ + if (!source) + return; + requestManagedRepaint(source, source->getManagedRepaintCoverageRect()); +} + +void Window::requestManagedRepaint(Control* source, const RECT& previousCoverage) { if (!source) return; @@ -59,29 +76,27 @@ void Window::requestManagedRepaint(Control* source) if (!root) return; - RECT coverage = root->getManagedRepaintCoverageRect(); + RECT coverage = previousCoverage; if (root->canCommitManagedPartialRepaint()) { // 对支持局部提交的 root,coverage 不能再盯着最深处的 source; // 否则像“三层 Canvas 里的按钮变色”这种情况,只会登记成一个很小的叶子矩形, // 顶层 root 提交时既容易漏掉那条直接脏分支,也会低估后续 overlay 补画范围。 Control* branch = source->getManagedRepaintDirectBranch(root); - coverage = branch ? branch->getManagedRepaintCoverageRect() : source->getManagedRepaintCoverageRect(); + coverage = SxUnionRect(coverage, branch ? branch->getManagedRepaintCoverageRect() : source->getManagedRepaintCoverageRect()); const RECT sourceCoverage = source->getManagedRepaintCoverageRect(); - coverage.left = (std::min)(coverage.left, sourceCoverage.left); - coverage.top = (std::min)(coverage.top, sourceCoverage.top); - coverage.right = (std::max)(coverage.right, sourceCoverage.right); - coverage.bottom = (std::max)(coverage.bottom, sourceCoverage.bottom); + coverage = SxUnionRect(coverage, sourceCoverage); + } + else + { + coverage = SxUnionRect(coverage, root->getManagedRepaintCoverageRect()); } for (auto& item : managedRepaintItems) { if (item.root == root) { - item.coverage.left = (std::min)(item.coverage.left, coverage.left); - item.coverage.top = (std::min)(item.coverage.top, coverage.top); - item.coverage.right = (std::max)(item.coverage.right, coverage.right); - item.coverage.bottom = (std::max)(item.coverage.bottom, coverage.bottom); + item.coverage = SxUnionRect(item.coverage, coverage); return; } } @@ -92,6 +107,43 @@ void Window::requestManagedRepaint(Control* source) managedRepaintItems.push_back(item); } +void Window::collectDirtyRootsForManagedRepaint() +{ + auto isAlreadyTracked = [this](Control* root) + { + for (const auto& item : managedRepaintItems) + { + if (item.root == root) + return true; + } + return false; + }; + + auto collectRoot = [this, &isAlreadyTracked](Control* root) + { + if (!root || !root->IsVisible()) + return; + if (!root->hasManagedDirtySubtree()) + return; + if (isAlreadyTracked(root)) + return; + + // 事件回调可以修改另一个顶层 root 的控件,例如右侧页签按钮更新左侧状态 Label。 + // 这种跨 root 改脏不会经过当前事件分发链的 requestManagedRepaint(), + // 因此在 flush 前补登记为 root 级重绘,保证本轮事件尾统一提交。 + ManagedRepaintItem item; + item.root = root; + item.coverage = root->getManagedRepaintCoverageRect(); + managedRepaintItems.push_back(item); + managedSceneDirty = true; + }; + + for (auto& control : controls) + collectRoot(control.get()); + for (auto& dialog : dialogs) + collectRoot(dialog.get()); +} + // 清空本轮托管重绘状态;通常在 flush/全场景重绘/resize 收口后调用 void Window::clearManagedRepaintState() { @@ -178,6 +230,16 @@ void Window::flushManagedRepaint() root->commitManagedRepaint(); RECT workingCoverage = initialCoverage; + if (root->canCommitManagedPartialRepaint()) + { + // 当前 Canvas / TabControl 的局部提交会在内部按兄弟覆盖关系继续扩张实际写屏区域, + // 但 commitManagedRepaint() 还没有返回“本轮实际 coverage”的接口。 + // 为避免 Window 仍按初始叶子区域判断,漏补上层普通控件或 Dialog, + // 这里对可局部提交的 root 使用 root 当前覆盖范围做保守兜底。 + // 这只扩大“上层是否需要补画”的判断,不改变 root 本身的局部提交策略。 + unionCoverage(workingCoverage, root->getManagedRepaintCoverageRect()); + } + size_t controlStartIdx = controls.size(); for (size_t i = 0; i < controls.size(); ++i) { @@ -271,6 +333,54 @@ void Window::dispatchSyntheticMouseMoveToControls(short x, short y) (*it)->handleEvent(mm); } +void Window::syncMouseStateAfterOverlayChanged(OverlayMouseSyncMode mode) +{ + short syncX = -32768; + short syncY = -32768; + + if (mode == OverlayMouseSyncMode::RestoreAtCursor) + { + POINT pt{}; + if (!GetCursorPos(&pt)) + return; + if (!ScreenToClient(this->hWnd, &pt)) + return; + syncX = static_cast(pt.x); + syncY = static_cast(pt.y); + } + + // synthetic move 仍走既有事件路径,但必须临时进入托管分发模式: + // 控件只更新 hover / tooltip / click 等瞬时状态并登记重绘,真正绘制留到事件尾统一收口。 + const bool oldManagedDispatchActive = managedDispatchActive; + managedDispatchActive = true; + dispatchSyntheticMouseMoveToControls(syncX, syncY); + managedDispatchActive = oldManagedDispatchActive; +} + +void Window::sweepClosedDialogs() +{ + for (auto it = dialogs.begin(); it != dialogs.end();) + { + Dialog* dialog = dynamic_cast(it->get()); + if (!dialog || dialog->IsVisible()) + { + ++it; + continue; + } + + // Dialog::Close() 只负责关闭语义和结果回调; + // 真正的快照回贴、子控件清理和对象移除必须放到事件安全点完成, + // 避免在 dialogs 正被倒序分发或绘制遍历时 erase 导致迭代器失效。 + if (dialog->pendingCleanup && !dialog->isCleaning) + dialog->performDelayedCleanup(); + + if (!dialog->pendingCleanup && !dialog->isCleaning) + it = dialogs.erase(it); + else + ++it; + } +} + /** * collectManagedDialogOverlays(repaintRoot, coverage, overlays) * 作用:找出在本轮提交后需要重新盖到最上层的非模态 Dialog。 @@ -746,7 +856,7 @@ int Window::runEventLoop() // 会残留 hover。这里补一条落在窗口外的合成移动,只用于清理底层 hover, // 不会让底层控件重新命中。 if (msg.message == WM_MOUSEMOVE) - dispatchSyntheticMouseMoveToControls(-32768, -32768); + syncMouseStateAfterOverlayChanged(OverlayMouseSyncMode::ClearBehindOverlay); break; } } @@ -769,7 +879,12 @@ int Window::runEventLoop() } else { - current->clearTransientMouseState(); + // 后续兄弟只清理 hover / press / tooltip 等临时鼠标状态; + // 这条路径不会再走控件自己的 handleEvent(),所以必须在顶层补登记重绘。 + // 先记录旧 coverage,避免 Tooltip 隐藏后丢失原悬浮层范围。 + const RECT previousCoverage = current->getManagedRepaintCoverageRect(); + if (current->clearTransientMouseState()) + requestManagedRepaint(current, previousCoverage); } } } @@ -812,17 +927,9 @@ int Window::runEventLoop() { // 对话框关闭后,需要手动合成一个鼠标移动消息并分发给所有普通控件, // 以便它们能及时更新悬停状态(hover),否则悬停状态可能保持错误状态。 - // 先把当前鼠标位置转换为客户区坐标,并合成一次 WM_MOUSEMOVE,先分发给控件更新 hover 状态 + // 先按当前鼠标位置刷新底层控件状态,真正绘制仍由后续全场景重绘兜底。 SX_LOGD("Event") << SX_T("对话框关闭,合成WM_MOUSEMOVE已下发", "Dialog closed; synthetic WM_MOUSEMOVE dispatched"); - POINT pt; - if (GetCursorPos(&pt)) - { - ScreenToClient(this->hWnd, &pt); - // 只分发给 window 层控件(因为 dialog 已经关闭或即将关闭) - managedDispatchActive = true; - dispatchSyntheticMouseMoveToControls((short)pt.x, (short)pt.y); - managedDispatchActive = false; - } + syncMouseStateAfterOverlayChanged(OverlayMouseSyncMode::RestoreAtCursor); dialogClose = false; // 重置标志 } @@ -945,7 +1052,12 @@ int Window::runEventLoop() // 普通输入事件收口:只在没有 resize / 对话框开关这种全局变化时,才提交本轮托管重绘。 if (!needResizeDirty && !dialogOpen && !dialogClose) + { + collectDirtyRootsForManagedRepaint(); flushManagedRepaint(); + } + + sweepClosedDialogs(); // 轻微睡眠,削峰填谷(不阻塞拖拽体验) Sleep(10); diff --git a/Window.h b/Window.h index 157d648..a37ae2b 100644 --- a/Window.h +++ b/Window.h @@ -114,7 +114,9 @@ public: // 当前是否处于“事件只改状态,不立即画”的阶段 bool isManagedDispatchActive() const; // 记录一笔由 source 发起的托管重绘请求 - void requestManagedRepaint(Control* source); + void requestManagedRepaint(Control* source); + // 记录一笔托管重绘请求,并显式并入状态清理前的旧覆盖范围 + void requestManagedRepaint(Control* source, const RECT& previousCoverage); // 在事件收口阶段提交本轮登记的 root 重绘 void flushManagedRepaint(); private: @@ -124,6 +126,17 @@ private: void drawWindowBackground(); // 合成 WM_MOUSEMOVE,用于同步底层 hover 状态 void dispatchSyntheticMouseMoveToControls(short x, short y); + enum class OverlayMouseSyncMode + { + ClearBehindOverlay, // overlay 吞掉鼠标移动:只清理底层 hover / tooltip,不重新命中底层控件 + RestoreAtCursor // overlay 关闭:按当前鼠标位置刷新底层 hover 状态 + }; + // overlay 层级变化后,同步底层鼠标瞬时状态;内部仍复用既有 synthetic WM_MOUSEMOVE 路径 + void syncMouseStateAfterOverlayChanged(OverlayMouseSyncMode mode); + // 在事件安全点清理已关闭的非模态 Dialog,避免 dialogs 容器长期积累无效对象 + void sweepClosedDialogs(); + // 事件回调可能改脏其他 root;flush 前补收集这些跨 root 脏子树 + void collectDirtyRootsForManagedRepaint(); // 清空本轮托管重绘登记 void clearManagedRepaintState(); // 找出需要补画到最上层的对话框 diff --git a/z-testDome.cpp b/z-testDome.cpp index 8c6fa5a..0e5792a 100644 --- a/z-testDome.cpp +++ b/z-testDome.cpp @@ -39,12 +39,318 @@ // * TabControl 页签按钮与页面层级正确;页签 tooltip 不会再被页面盖掉。 // * Table 分页按钮、页码标签、与上层浮层相交时的重绘链保持正确。 // +// 6. KEY6:已实现但主回归未覆盖的枚举/分支补充 +// - 目标:补充 TabPlacement::Right、Button 圆/椭圆/禁用态、TextBox 圆角形状、 +// 以及 YesNo / RetryCancel / AbortRetryIgnore 消息框类型。 +// - 预期: +// * 右侧页签布局、切页、页内按钮和输入框正常。 +// * 圆形/椭圆按钮命中区域符合形状,禁用按钮不触发点击回调。 +// * 剩余 MessageBox 类型按钮结果链正常,非模态关闭不残留。 +// // 当前阶段建议主回归集:KEY1 + KEY2 + KEY5 // Dialog / MessageBox 补充专项:KEY4 +// 实现分支补充专项:KEY6 #include"StellarX.h" #include #ifndef KEY -#define KEY 5 +#define KEY 6 +#endif + +// 测试用例统一使用最新布局 API,避免继续依赖旧 setAnchor() 兼容层。 +// 这些 helper 只服务 z-testDome:命名带 SxTest 前缀,避免被误认为框架公开接口。 +static void SxTestSetDesignX(Control* control) +{ + if (!control) return; + control->setHorizontalAnchors(false, false); + control->setHorizontalSizePolicy(StellarX::AxisSizePolicy::FixedSize); + control->setHorizontalAlignPolicy(StellarX::AxisAlignPolicy::Start); +} + +static void SxTestSetDesignY(Control* control) +{ + if (!control) return; + control->setVerticalAnchors(false, false); + control->setVerticalSizePolicy(StellarX::AxisSizePolicy::FixedSize); + control->setVerticalAlignPolicy(StellarX::AxisAlignPolicy::Start); +} + +static void SxTestSetStretchX(Control* control) +{ + if (!control) return; + control->setHorizontalAnchors(true, true); + control->setHorizontalSizePolicy(StellarX::AxisSizePolicy::Stretch); + control->setHorizontalAlignPolicy(StellarX::AxisAlignPolicy::Start); + SxTestSetDesignY(control); +} + +static void SxTestSetLeftFixed(Control* control) +{ + if (!control) return; + control->setHorizontalAnchors(true, false); + control->setHorizontalSizePolicy(StellarX::AxisSizePolicy::FixedSize); + control->setHorizontalAlignPolicy(StellarX::AxisAlignPolicy::Start); + SxTestSetDesignY(control); +} + +static void SxTestSetRightFixed(Control* control) +{ + if (!control) return; + control->setHorizontalAnchors(false, true); + control->setHorizontalSizePolicy(StellarX::AxisSizePolicy::FixedSize); + control->setHorizontalAlignPolicy(StellarX::AxisAlignPolicy::End); + SxTestSetDesignY(control); +} + +static void SxTestSetTopFixed(Control* control) +{ + if (!control) return; + SxTestSetDesignX(control); + control->setVerticalAnchors(true, false); + control->setVerticalSizePolicy(StellarX::AxisSizePolicy::FixedSize); + control->setVerticalAlignPolicy(StellarX::AxisAlignPolicy::Start); +} + +static void SxTestSetBottomFixed(Control* control) +{ + if (!control) return; + SxTestSetDesignX(control); + control->setVerticalAnchors(false, true); + control->setVerticalSizePolicy(StellarX::AxisSizePolicy::FixedSize); + control->setVerticalAlignPolicy(StellarX::AxisAlignPolicy::End); +} + +static void SxTestSetStretchY(Control* control) +{ + if (!control) return; + SxTestSetDesignX(control); + control->setVerticalAnchors(true, true); + control->setVerticalSizePolicy(StellarX::AxisSizePolicy::Stretch); + control->setVerticalAlignPolicy(StellarX::AxisAlignPolicy::Start); +} + +#if 6 == KEY + +int main() +{ + StellarX::SxLogger::setGBK(); + StellarX::SxLogger::Get().enableConsole(true); + StellarX::SxLogger::Get().setMinLevel(StellarX::SxLogLevel::Debug); + StellarX::SxLogger::Get().setLanguage(StellarX::SxLogLanguage::ZhCN); + + Window win(1120, 760, 1, RGB(246, 248, 251), "StellarX KEY6 分支覆盖补充"); + + const COLORREF panelColor = RGB(230, 238, 248); + const COLORREF shapeColor = RGB(244, 235, 218); + const COLORREF tabColor = RGB(226, 242, 235); + const COLORREF msgColor = RGB(240, 229, 245); + const COLORREF hoverColor = RGB(255, 225, 92); + const COLORREF trueColor = RGB(238, 142, 104); + const COLORREF falseColor = RGB(247, 248, 250); + + auto configureButton = [&](Button* button, const std::string& tooltip) + { + button->enableTooltip(true); + button->setTooltipDelay(120); + button->setTooltipText(tooltip); + }; + + auto makeButton = [&](int x, int y, int w, int h, const std::string& text) + { + auto button = std::make_unique