Compare commits
26 Commits
v2.0.0
..
69c26a35a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 69c26a35a2 | |||
| 255aba688f | |||
| 81341fab2c | |||
| c23b32bb40 | |||
| c57ddaf74f | |||
| 18caf91633 | |||
| 0db94a7d41 | |||
| e514a7dbb9 | |||
| 43cf4ac5f7 | |||
| 225bc35219 | |||
| 3d08bac1b9 | |||
| c218c25310 | |||
| c0eca1d4bd | |||
| 568ffc40eb | |||
| 9155a86a8a | |||
| 2388f22c99 | |||
| 738cf035bb | |||
| b7ad960518 | |||
| 241a564095 | |||
| 97710db9e3 | |||
| f567369300 | |||
| 77a8fe568a | |||
| b07a4ec370 | |||
| 7f8431a18c | |||
| 4a6e153da5 | |||
| dde570ac3c |
+17
-25
@@ -1,29 +1,21 @@
|
||||
# 编译输出目录
|
||||
/build/
|
||||
/bin/
|
||||
/lib/
|
||||
|
||||
# CMake 生成文件
|
||||
/CMakeFiles/
|
||||
/CMakeCache.txt
|
||||
/cmake_install.cmake
|
||||
/Makefile
|
||||
|
||||
# Visual Studio 文件
|
||||
.vs/
|
||||
*.sln
|
||||
*.vcxproj
|
||||
*.vcxproj.filters
|
||||
x64/
|
||||
.codex-temp/
|
||||
.codex-staging/
|
||||
build/
|
||||
/demo/
|
||||
TestResults/
|
||||
*.vcxproj.user
|
||||
|
||||
# 编译产物
|
||||
*.obj
|
||||
*.pdb
|
||||
*.log
|
||||
*.exe
|
||||
*.dll
|
||||
*.lib
|
||||
|
||||
# 临时文件
|
||||
*.cache
|
||||
*.zip
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.iobj
|
||||
*.ipdb
|
||||
*.tlog/
|
||||
*.log
|
||||
*.recipe
|
||||
*.idb
|
||||
*.lastbuildstate
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
# 更新日志
|
||||
|
||||
StellarX 项目所有显著的变化都将被记录在这个文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
|
||||
|
||||
## [v2.0.0] - 2025-09-21
|
||||
|
||||
### 概述
|
||||
v2.0.0 为一次重大升级(major release)。本次发布新增对话框与消息框工厂(Dialog / MessageBox),并对事件分发、API语义与内部资源管理做了若干重要修复和改进。
|
||||
|
||||
部分 API/行为发生不向后兼容的变化(breaking changes)。
|
||||
|
||||
### 新增
|
||||
- **对话框系统**:
|
||||
- 新增 `Dialog` 类,继承自 `Canvas`,用于构建模态与非模态对话框
|
||||
- 新增 `MessageBox` 工厂类,提供 `ShowModal`(同步阻塞)与 `ShowAsync`(异步回调)两种便捷API
|
||||
- 支持六种标准消息框类型:`OK`, `OKCancel`, `YesNo`, `YesNoCancel`, `RetryCancel`, `AbortRetryIgnore`
|
||||
- 自动处理对话框布局、背景保存与恢复、事件传播和生命周期管理
|
||||
|
||||
- **事件系统增强**:
|
||||
- 所有控件的 `handleEvent` 方法现在返回 `bool` 类型,表示是否消费了事件
|
||||
- 引入事件消费机制,支持更精细的事件传播控制
|
||||
- Window 类的事件循环现在优先处理对话框事件
|
||||
|
||||
- **控件状态管理**:
|
||||
- Control 基类新增 `dirty` 标志和 `setDirty()` 方法,统一管理重绘状态
|
||||
- 所有控件现在都实现了 `IsVisible()` 和 `model()` 方法
|
||||
|
||||
- **API 增强**:
|
||||
- Button 类新增 `setButtonFalseColor()` 方法
|
||||
- TextBox 类的 `setMaxCharLen()` 现在接受 `size_t` 类型参数
|
||||
- Window 类新增对话框管理方法和去重检测机制
|
||||
|
||||
### 重大变更(Breaking Changes)
|
||||
- **API 签名变更**:
|
||||
- 所有控件的 `handleEvent(const ExMessage& msg)` 方法从返回 `void` 改为返回 `bool`
|
||||
- Control 基类新增纯虚函数 `IsVisible() const` 和 `model() const`,所有派生类必须实现
|
||||
|
||||
- **资源管理变更**:
|
||||
- Control 基类的样式保存从栈对象改为堆对象,使用指针管理
|
||||
- 增强了资源释放的安全性,所有资源都在析构函数中正确释放
|
||||
|
||||
- **事件处理逻辑**:
|
||||
- Window 的 `runEventLoop()` 方法完全重写,现在优先处理对话框事件
|
||||
- 引入了事件消费机制,事件被消费后不会继续传播
|
||||
|
||||
### 修复(Fixed)
|
||||
- **内存管理**:
|
||||
- 修复 `Button::setFillIma()` 的内存泄漏问题
|
||||
- 修复 Control 基类析构函数中的资源释放问题
|
||||
- 修复 Dialog 类背景图像资源管理问题
|
||||
|
||||
- **布局与渲染**:
|
||||
- 修复 `Table` 组件的分页计算、列宽和行高越界问题
|
||||
- 修复 `Table` 中 `pX` 坐标累加错误导致的布局错乱
|
||||
- 修复 `Canvas::draw()` 中导致子控件不被绘制的 dirty 判定问题
|
||||
- 修复 `TextBox::draw()` 和 `restoreStyle()` 的不对称调用问题
|
||||
|
||||
- **事件处理**:
|
||||
- 修复窗口事件分发逻辑,确保对话框/顶层控件优先处理事件
|
||||
- 修复鼠标移出按钮区域时状态更新不及时的问题
|
||||
- 修复非模态对话框事件处理中的竞争条件
|
||||
|
||||
- **其他问题**:
|
||||
- 修复文本测量和渲染中的潜在错误
|
||||
- 修复对话框关闭后背景恢复不完全的问题
|
||||
- 修复多对话框场景下的 z-order 管理问题
|
||||
|
||||
### 改进(Changed)
|
||||
- **代码质量**:
|
||||
- 重构了大量内部 API,增强异常安全性与可维护性
|
||||
- 使用智能指针和现代 C++ 特性替代裸 new/delete
|
||||
- 统一了代码风格和命名约定
|
||||
|
||||
- **性能优化**:
|
||||
- 优化了事件处理流程,减少不必要的重绘
|
||||
- 改进了对话框背景保存和恢复机制
|
||||
- 减少了内存分配和拷贝操作
|
||||
|
||||
- **文档与示例**:
|
||||
- 为所有新增功能添加了详细的使用示例
|
||||
- 完善了代码注释和 API 文档
|
||||
- 更新了 README 和 CHANGELOG 反映最新变化
|
||||
|
||||
## [1.1.0] - 2025-09-08
|
||||
|
||||
### 新增
|
||||
- **Window 类 API 增强**:
|
||||
- 添加了完整的获取器(getter)方法集,提高类的封装性和可用性
|
||||
- `getHwnd()` - 获取窗口句柄,便于与原生 Windows API 集成
|
||||
- `getWidth()` - 获取窗口宽度
|
||||
- `getHeight()` - 获取窗口高度
|
||||
- `getHeadline()` - 获取窗口标题
|
||||
- `getBkcolor()` - 获取窗口背景颜色
|
||||
- `getBkImage()` - 获取窗口背景图片指针
|
||||
- `getControls()` - 获取控件管理容器的引用,允许迭代和操作已添加的控件
|
||||
|
||||
### 改进
|
||||
- **API 一致性**: 为所有重要属性提供了对称的设置器(setter)和获取器(getter)方法
|
||||
- **代码文档**: 进一步完善了类注释,使其更加清晰和专业
|
||||
|
||||
## [1.0.0] - 2025-09-08
|
||||
|
||||
### 发布摘要
|
||||
首个稳定版本 (Stable Release)
|
||||
|
||||
### 新增
|
||||
|
||||
- StellarX 框架的第一个稳定版本
|
||||
- 完整的控件库:按钮、标签、文本框、画布、表格和窗口
|
||||
- 基于 CMake 的构建系统
|
||||
- 详细的文档和示例代码
|
||||
- **明确声明:本框架专为 Windows 平台设计**
|
||||
|
||||
### Released
|
||||
- **首次发布预编译的二进制库文件**,方便用户快速集成,无需从源码编译。
|
||||
- 提供的发布包包括:
|
||||
- `StellarX-v1.0.0-x64-Windows-msvc-x64.zip`
|
||||
- **编译环境**: Visual Studio 2022 (MSVC v143)
|
||||
- **架构**: x64 (64位)
|
||||
- **运行时库**: `/MD`
|
||||
- **构建模式**: `Release | Debug`
|
||||
- **内容**: 包含所有必要的头文件(`include/`)和静态库文件(``lib/StellarX-Debug.lib StellarX-Release.lib`)
|
||||
|
||||
### 核心特性
|
||||
- 模块化设计,遵循 SOLID 原则
|
||||
- 统一的控件接口(draw() 和 handleEvent())
|
||||
- 支持多种控件形状和样式
|
||||
- 自定义事件处理回调
|
||||
- 轻量级设计,无外部依赖
|
||||
|
||||
## [0.1.0] - 2025-08-15
|
||||
### 新增
|
||||
- 初始项目结构和核心架构
|
||||
- Control 基类和基本事件处理系统
|
||||
- 基础示例和文档设置
|
||||
@@ -1,19 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
# 项目定义
|
||||
project(StellarX VERSION 2.0.0 LANGUAGES CXX)
|
||||
|
||||
# 设置 C++ 标准
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED True)
|
||||
|
||||
# 包含头文件目录(目前头文件都在根目录)
|
||||
include_directories(${CMAKE_SOURCE_DIR})
|
||||
|
||||
# 源文件收集
|
||||
file(GLOB_RECURSE SOURCES
|
||||
"${CMAKE_SOURCE_DIR}/*.cpp"
|
||||
)
|
||||
|
||||
# 生成可执行文件
|
||||
add_executable(StellarX ${SOURCES})
|
||||
@@ -1,93 +0,0 @@
|
||||
# 为 StellarX 做贡献
|
||||
|
||||
感谢您有兴趣为 StellarX 做出贡献!本文档提供了贡献的指南和步骤。
|
||||
|
||||
StellarX 是一个为 **Windows 平台**打造的 C++ GUI 框架,基于 EasyX 图形库。
|
||||
|
||||
## 开发环境设置
|
||||
|
||||
1. 安装 Visual Studio 2019 或更高版本
|
||||
2. 安装对应版本的 EasyX 图形库
|
||||
3. 安装 CMake 3.12 或更高版本
|
||||
4. 克隆项目仓库
|
||||
5. 使用 CMake 生成解决方案并编译
|
||||
|
||||
## 如何贡献
|
||||
|
||||
### 报告错误
|
||||
1. 检查 [Issues](../../issues) 看看这个错误是否已经被报告过。
|
||||
2. 如果没有,请创建一个新的 Issue。
|
||||
3. 使用 "Bug 报告" 模板。
|
||||
4. 提供**清晰的标题和描述**。
|
||||
5. 包括相关的代码片段、截图或重现问题的步骤。
|
||||
|
||||
### 建议功能增强
|
||||
1. 检查现有的 Issue 中是否有你的想法。
|
||||
2. 使用 "功能请求" 模板创建一个新的 Issue。
|
||||
3. 清晰地描述新功能,并解释它为何有用。
|
||||
|
||||
### 提交代码更改 (拉取请求 Pull Requests)
|
||||
1. **Fork** GitHub 上的仓库。
|
||||
2. **Clone** 你 fork 的仓库到本地机器。
|
||||
3. 为你的功能或错误修复创建一个**新分支** (`git checkout -b my-feature-branch`)。
|
||||
4. **进行你的更改**。确保你的代码遵循项目的风格(见下文)。
|
||||
5. **提交你的更改**,并附上清晰描述性的提交信息。
|
||||
6. **Push** 你的分支到你 fork 的 GitHub 仓库 (`git push origin my-feature-branch`)。
|
||||
7. 针对原始 StellarX 仓库的 `main` 分支发起一个**拉取请求 (Pull Request)**。
|
||||
|
||||
## 代码风格指南
|
||||
|
||||
* 遵循项目中现有的代码格式和命名约定。
|
||||
* 为变量、函数和类使用有意义的名称。
|
||||
* 在必要时注释你的代码,特别是复杂的逻辑。
|
||||
* 确保你的代码编译时没有警告。
|
||||
* 彻底测试你的更改。
|
||||
* 使用 **4个空格** 进行缩进(不要使用制表符)
|
||||
- 类名使用 **PascalCase**(如 `ClassName`)
|
||||
- 函数和变量使用 **camelCase**(如 `functionName`, `variableName`)
|
||||
- 常量使用 **UPPER_CASE**(如 `CONSTANT_VALUE`)
|
||||
- 成员变量以 **m_** 前缀(如 `m_memberVariable`)
|
||||
- 控件属性使用有意义的名称,避免缩写
|
||||
- 为所有公共接口添加详细的注释
|
||||
- 遵循 RAII 原则管理资源
|
||||
|
||||
## 示例代码风格
|
||||
|
||||
```c++
|
||||
|
||||
// 好的示例
|
||||
class MyControl : public Control {
|
||||
public:
|
||||
MyControl(int x, int y, int width, int height)
|
||||
: Control(x, y, width, height), m_isActive(false) {}
|
||||
|
||||
void draw() override {
|
||||
// 绘制逻辑
|
||||
}
|
||||
|
||||
private:
|
||||
bool m_isActive;
|
||||
};
|
||||
|
||||
// 不好的示例
|
||||
class my_control: public Control{
|
||||
public:
|
||||
my_control(int x,int y,int w,int h):Control(x,y,w,h),active(false){}
|
||||
void Draw() override{
|
||||
// 绘制逻辑
|
||||
}
|
||||
private:
|
||||
bool active;
|
||||
};
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
请遵循项目的目录结构:
|
||||
- 头文件放在 `include/StellarX/` 目录
|
||||
- 实现文件放在 `src/` 目录
|
||||
- 示例代码放在 `examples/` 目录
|
||||
|
||||
## 问题?
|
||||
|
||||
如果你有任何问题,可以随时开一个 Issue 或联系维护者。
|
||||
@@ -1,219 +0,0 @@
|
||||
# Doxyfile 1.9.1
|
||||
|
||||
# 项目相关配置
|
||||
DOXYFILE_ENCODING = UTF-8
|
||||
PROJECT_NAME = "星垣 (StellarX) GUI Framework"
|
||||
PROJECT_NUMBER = 1.0.0
|
||||
PROJECT_BRIEF = "A lightweight, modular C++ GUI framework for Windows"
|
||||
PROJECT_LOGO =
|
||||
OUTPUT_DIRECTORY = docs
|
||||
CREATE_SUBDIRS = NO
|
||||
ALLOW_UNICODE_NAMES = YES
|
||||
OUTPUT_LANGUAGE = English
|
||||
BRIEF_MEMBER_DESC = YES
|
||||
REPEAT_BRIEF = YES
|
||||
ABBREVIATE_BRIEF =
|
||||
ALWAYS_DETAILED_SEC = NO
|
||||
INLINE_INHERITED_MEMB = NO
|
||||
FULL_PATH_NAMES = YES
|
||||
STRIP_FROM_PATH =
|
||||
STRIP_FROM_INC_PATH =
|
||||
SHORT_NAMES = NO
|
||||
JAVADOC_AUTOBRIEF = YES
|
||||
JAVADOC_BANNER = NO
|
||||
QT_AUTOBRIEF = NO
|
||||
MULTILINE_CPP_IS_BRIEF = NO
|
||||
INHERIT_DOCS = YES
|
||||
SEPARATE_MEMBER_PAGES = NO
|
||||
TAB_SIZE = 4
|
||||
ALIASES =
|
||||
OPTIMIZE_OUTPUT_FOR_C = NO
|
||||
OPTIMIZE_OUTPUT_JAVA = NO
|
||||
OPTIMIZE_FOR_FORTRAN = NO
|
||||
OPTIMIZE_OUTPUT_VHDL = NO
|
||||
EXTENSION_MAPPING =
|
||||
MARKDOWN_SUPPORT = YES
|
||||
|
||||
# 构建类型
|
||||
EXTRACT_ALL = YES
|
||||
EXTRACT_PRIVATE = YES
|
||||
EXTRACT_PACKAGE = YES
|
||||
EXTRACT_STATIC = YES
|
||||
EXTRACT_LOCAL_CLASSES = YES
|
||||
EXTRACT_LOCAL_METHODS = NO
|
||||
EXTRACT_ANON_NSPACES = NO
|
||||
HIDE_UNDOC_MEMBERS = NO
|
||||
HIDE_UNDOC_CLASSES = NO
|
||||
HIDE_FRIEND_COMPOUNDS = NO
|
||||
HIDE_IN_BODY_DOCS = NO
|
||||
INTERNAL_DOCS = NO
|
||||
CASE_SENSE_NAMES = NO
|
||||
HIDE_SCOPE_NAMES = NO
|
||||
HIDE_COMPOUND_REFERENCE= NO
|
||||
SHOW_INCLUDE_FILES = YES
|
||||
SHOW_GROUPED_MEMB_INC = NO
|
||||
FORCE_LOCAL_INCLUDES = NO
|
||||
INLINE_INFO = YES
|
||||
SORT_MEMBER_DOCS = YES
|
||||
SORT_BRIEF_DOCS = NO
|
||||
SORT_MEMBERS_CTOR_1ST = NO
|
||||
SORT_GROUP_NAMES = YES
|
||||
SORT_BY_SCOPE_NAME = YES
|
||||
STRICT_PROTO_MATCHING = NO
|
||||
GENERATE_TODOLIST = YES
|
||||
GENERATE_TESTLIST = YES
|
||||
GENERATE_BUGLIST = YES
|
||||
GENERATE_DEPRECATEDLIST= YES
|
||||
MAX_INITIALIZER_LINES = 30
|
||||
SHOW_USED_FILES = YES
|
||||
SHOW_FILES = YES
|
||||
SHOW_NAMESPACES = YES
|
||||
FILE_VERSION_FILTER =
|
||||
LAYOUT_FILE =
|
||||
CITE_BIB_FILES =
|
||||
|
||||
# 输入配置
|
||||
INPUT = include src README.md
|
||||
INPUT_ENCODING = UTF-8
|
||||
FILE_PATTERNS = *.h *.hpp *.cpp *.c *.cc *.cxx *.c++ *.java *.ii *.ixx *.ipp *.i++ *.inl *.idl *.ddl *.odl *.cs *.php *.php3 *.inc *.m *.mm *.dox *.py *.f90 *.f *.for *.vhd *.vhdl *.md
|
||||
RECURSIVE = YES
|
||||
EXCLUDE =
|
||||
EXCLUDE_SYMLINKS = NO
|
||||
EXCLUDE_PATTERNS =
|
||||
EXCLUDE_SYMBOLS =
|
||||
EXAMPLE_PATH = examples
|
||||
EXAMPLE_PATTERNS = *
|
||||
EXAMPLE_RECURSIVE = NO
|
||||
IMAGE_PATH =
|
||||
INPUT_FILTER =
|
||||
FILTER_PATTERNS =
|
||||
FILTER_SOURCE_FILES = NO
|
||||
FILTER_SOURCE_PATTERNS =
|
||||
USE_MDFILE_AS_MAINPAGE = README.md
|
||||
|
||||
# 源代码浏览配置
|
||||
SOURCE_BROWSER = YES
|
||||
INLINE_SOURCES = NO
|
||||
STRIP_CODE_COMMENTS = YES
|
||||
REFERENCED_BY_RELATION = YES
|
||||
REFERENCES_RELATION = YES
|
||||
REFERENCES_LINK_SOURCE = YES
|
||||
SOURCE_TOOLTIPS = YES
|
||||
USE_HTAGS = NO
|
||||
VERBATIM_HEADERS = YES
|
||||
|
||||
# HTML输出配置
|
||||
GENERATE_HTML = YES
|
||||
HTML_OUTPUT = html
|
||||
HTML_FILE_EXTENSION = .html
|
||||
HTML_HEADER =
|
||||
HTML_FOOTER =
|
||||
HTML_STYLESHEET =
|
||||
HTML_EXTRA_STYLESHEET =
|
||||
HTML_EXTRA_FILES =
|
||||
HTML_COLORSTYLE_HUE = 220
|
||||
HTML_COLORSTYLE_SAT = 100
|
||||
HTML_COLORSTYLE_GAMMA = 80
|
||||
HTML_TIMESTAMP = YES
|
||||
HTML_DYNAMIC_SECTIONS = NO
|
||||
HTML_INDEX_NUM_ENTRIES = 100
|
||||
GENERATE_DOCSET = NO
|
||||
DOCSET_FEEDNAME = "Doxygen generated docs"
|
||||
DOCSET_BUNDLE_ID = org.doxygen.Project
|
||||
DOCSET_PUBLISHER_ID = org.doxygen.Publisher
|
||||
DOCSET_PUBLISHER_NAME = Publisher
|
||||
GENERATE_HTMLHELP = NO
|
||||
GENERATE_CHI = NO
|
||||
GENERATE_QHP = NO
|
||||
GENERATE_ECLIPSEHELP = NO
|
||||
DISABLE_INDEX = NO
|
||||
ENUM_VALUES_PER_LINE = 4
|
||||
GENERATE_TREEVIEW = YES
|
||||
TREEVIEW_WIDTH = 250
|
||||
EXT_LINKS_IN_WINDOW = NO
|
||||
FORMULA_FONTSIZE = 10
|
||||
FORMULA_TRANSPARENT = YES
|
||||
USE_MATHJAX = NO
|
||||
MATHJAX_FORMAT = HTML-CSS
|
||||
MATHJAX_RELPATH =
|
||||
MATHJAX_EXTENSIONS =
|
||||
MATHJAX_CODEFILE =
|
||||
SEARCHENGINE = YES
|
||||
SERVER_BASED_SEARCH = NO
|
||||
EXTERNAL_SEARCH = NO
|
||||
SEARCHDATA_FILE = searchdata.xml
|
||||
EXTERNAL_SEARCH_ID =
|
||||
EXTRA_SEARCH_MAPPINGS =
|
||||
|
||||
# LaTeX输出配置
|
||||
GENERATE_LATEX = NO
|
||||
|
||||
# RTF输出配置
|
||||
GENERATE_RTF = NO
|
||||
|
||||
# Man page输出配置
|
||||
GENERATE_MAN = NO
|
||||
|
||||
# XML输出配置
|
||||
GENERATE_XML = NO
|
||||
|
||||
# DOCBOOK输出配置
|
||||
GENERATE_DOCBOOK = NO
|
||||
|
||||
# 自动生成配置
|
||||
GENERATE_AUTOGEN_DEF = NO
|
||||
|
||||
# 配置生成器
|
||||
ENABLE_PREPROCESSING = YES
|
||||
MACRO_EXPANSION = YES
|
||||
EXPAND_ONLY_PREDEF = NO
|
||||
SEARCH_INCLUDES = YES
|
||||
INCLUDE_PATH =
|
||||
INCLUDE_FILE_PATTERNS =
|
||||
PREDEFINED = WIN32=1 \
|
||||
_WINDOWS=1 \
|
||||
UNICODE=1 \
|
||||
_UNICODE=1
|
||||
EXPAND_AS_DEFINED =
|
||||
SKIP_FUNCTION_MACROS = YES
|
||||
|
||||
# 外部引用
|
||||
TAGFILES =
|
||||
GENERATE_TAGFILE =
|
||||
ALLEXTERNALS = NO
|
||||
EXTERNAL_GROUPS = YES
|
||||
EXTERNAL_PAGES = YES
|
||||
|
||||
# 图表生成
|
||||
CLASS_DIAGRAMS = YES
|
||||
MSCGEN_PATH =
|
||||
DIA_PATH =
|
||||
HIDE_UNDOC_RELATIONS = YES
|
||||
HAVE_DOT = YES
|
||||
DOT_NUM_THREADS = 0
|
||||
DOT_FONTNAME = Helvetica
|
||||
DOT_FONTSIZE = 10
|
||||
DOT_FONTPATH =
|
||||
CLASS_GRAPH = YES
|
||||
COLLABORATION_GRAPH = YES
|
||||
GROUP_GRAPHS = YES
|
||||
UML_LOOK = NO
|
||||
UML_LIMIT_NUM_FIELDS = 10
|
||||
TEMPLATE_RELATIONS = YES
|
||||
INCLUDE_GRAPH = YES
|
||||
INCLUDED_BY_GRAPH = YES
|
||||
CALL_GRAPH = YES
|
||||
CALLER_GRAPH = YES
|
||||
GRAPHICAL_HIERARCHY = YES
|
||||
DIRECTORY_GRAPH = YES
|
||||
DOT_IMAGE_FORMAT = png
|
||||
INTERACTIVE_SVG = NO
|
||||
DOT_PATH =
|
||||
DOTFILE_DIRS =
|
||||
MSCFILE_DIRS =
|
||||
DOT_GRAPH_MAX_NODES = 50
|
||||
MAX_DOT_GRAPH_DEPTH = 0
|
||||
DOT_TRANSPARENT = YES
|
||||
DOT_MULTI_TARGETS = NO
|
||||
GENERATE_LEGEND = YES
|
||||
DOT_CLEANUP = YES
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 我在人间做废物
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,445 +0,0 @@
|
||||
# 星垣 (StellarX) GUI Framework
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> **「繁星为界,轻若尘埃」** —— 一个为Windows平台打造的、极致轻量级、高度模块化的C++原生GUI框架。
|
||||
|
||||
`星垣 (StellarX)` 诞生于对现代GUI框架"过度臃肿"的反抗。它拒绝动辄数百MB的依赖、漫长的编译时间和复杂的学习曲线,选择回归本质:用最精简的代码、最清晰的架构和最高的效率,解决桌面应用开发的核心需求。
|
||||
|
||||
它是一个**纯粹的教学级、工具级框架**,旨在让开发者深入理解GUI原理,并快速构建轻量级Windows工具。
|
||||
|
||||
---
|
||||
|
||||
## 📦 项目结构与设计哲学
|
||||
|
||||
星垣框架采用经典的**面向对象**和**模块化**设计,项目结构清晰规范:
|
||||
|
||||
```markdown
|
||||
StellarX/
|
||||
├── include/ # 头文件目录
|
||||
│ └── StellarX/ # 框架头文件
|
||||
│ ├── StellarX.h # 主包含头文件 - 一键引入整个框架
|
||||
│ ├── CoreTypes.h # ★ 核心 ★ - 所有枚举、结构体的唯一定义源
|
||||
│ ├── Control.h # 抽象基类 - 定义所有控件的统一接口
|
||||
│ ├── Button.h # 按钮控件
|
||||
│ ├── Window.h # 窗口管理
|
||||
│ ├── Label.h # 标签控件
|
||||
│ ├── TextBox.h # 文本框控件
|
||||
│ ├── Canvas.h # 画布容器
|
||||
│ ├── Dialog.h # 对话框控件(v2.0.0新增)
|
||||
│ ├── MessageBox.h # 消息框工厂(v2.0.0新增)
|
||||
│ └── Table.h # 表格控件
|
||||
├── src/ # 源文件目录
|
||||
│ ├── Control.cpp
|
||||
│ ├── Button.cpp
|
||||
│ ├── Window.cpp
|
||||
│ ├── Label.cpp
|
||||
│ ├── TextBox.cpp
|
||||
│ ├── Canvas.cpp
|
||||
│ ├── Table.cpp
|
||||
│ ├── Dialog.cpp # v2.0.0新增
|
||||
│ └── MessageBox.cpp # v2.0.0新增
|
||||
├── examples/ # 示例代码目录
|
||||
│ └── demo.cpp # 基础演示
|
||||
├── docs/ # 文档目录
|
||||
│ └── CODE_OF_CONDUCT.md # 行为准则
|
||||
├── CMakeLists.txt # CMake 构建配置
|
||||
├── CONTRIBUTING.md # 贡献指南
|
||||
├── CHANGELOG.md # 更新日志
|
||||
├── Doxyfile # Doxygen 配置
|
||||
├── LICENSE # MIT 许可证
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
**设计理念:**
|
||||
|
||||
1. **单一职责原则 (SRP)**: 每个类/文件只负责一件事。
|
||||
2. **依赖倒置原则 (DIP)**: 高层模块(如`Window`)不依赖低层模块(如`Button`),二者都依赖其抽象(`Control`)。
|
||||
3. **开闭原则 (OCP)**: 通过继承`Control`基类,可以轻松扩展新的控件,而无需修改现有代码。
|
||||
4. **一致性**: 所有控件共享统一的`draw()`和`handleEvent()`接口。
|
||||
|
||||
## 🚀 核心特性
|
||||
|
||||
- **极致的轻量级**: 核心库编译后仅 ~1.2MB,无任何外部依赖。生成的应用程序小巧玲珑。
|
||||
- **清晰的模块化架构**: 使用`CoreTypes.h`统一管理所有类型,消除重复定义,极大提升可维护性。
|
||||
- **原生C++性能**: 直接基于EasyX和Win32 API,提供接近原生的执行效率,内存占用极低(通常<10MB)。
|
||||
- **完整的控件体系**: 按钮(Button)、标签(Label)、文本框(TextBox)、画布(Canvas)、表格(Table)、对话框(Dialog)与消息框工厂(MessageBox)。
|
||||
- **高度可定制化**: 从控件颜色、形状(矩形、圆角、圆形、椭圆)到填充模式、字体样式,均有详尽枚举支持,可轻松定制。
|
||||
- **简洁直观的API**: 采用经典的面向对象设计,代码即文档,学习成本极低。
|
||||
- **标准项目结构**: 采用标准的include/src分离结构,支持CMake构建,易于集成和使用。
|
||||
- **增强的事件系统**: v2.0.0引入事件消费机制,所有`handleEvent`方法返回`bool`表示是否消费事件,支持更精细的事件传播控制。
|
||||
- **对话框系统**: 新增完整的对话框支持,包括模态和非模态对话框,自动处理背景保存和恢复。
|
||||
|
||||
## ⚡ 快速开始(5分钟上手)
|
||||
|
||||
> **🎯 最新版本下载**
|
||||
> 从 [GitHub Releases](https://github.com/Ysm-04/StellarX/releases/latest) 下载预编译的库文件和头文件,即可快速集成到你的项目中。
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **操作系统**: Windows 10 或更高版本
|
||||
- **编译器**: 支持C++17的编译器 (如: **Visual Studio 2019+**)
|
||||
- **图形库**: [EasyX](https://easyx.cn/) (2022版本或更高,安装时请选择与您编译器匹配的版本)
|
||||
- **构建工具**: CMake 3.12+ (可选,推荐使用)
|
||||
|
||||
### 安装 EasyX
|
||||
1. 访问 [EasyX 官网](https://easyx.cn/) 下载最新版本
|
||||
2. 运行安装程序,选择与您的 Visual Studio 版本匹配的版本
|
||||
3. 安装完成后,无需额外配置,星垣框架会自动链接 EasyX
|
||||
|
||||
### 方法一:使用CMake构建(推荐)
|
||||
|
||||
1. **克隆项目**:
|
||||
```bash
|
||||
git clone https://github.com/Ysm-04/StellarX.git
|
||||
cd StellarX
|
||||
```
|
||||
|
||||
2. **生成构建系统**:
|
||||
```bash
|
||||
mkdir build
|
||||
cd build
|
||||
cmake ..
|
||||
```
|
||||
|
||||
3. **编译项目**:
|
||||
```bash
|
||||
cmake --build .
|
||||
```
|
||||
|
||||
4. **运行示例**:
|
||||
```bash
|
||||
./examples/Demo
|
||||
```
|
||||
|
||||
### 方法二:手动集成到现有项目
|
||||
|
||||
1. **将include和src目录复制**到您的项目中
|
||||
2. **配置包含路径**,确保编译器可以找到`include/StellarX/`目录
|
||||
3. **将所有.cpp文件**添加到您的项目中编译
|
||||
|
||||
### 创建你的第一个星垣应用
|
||||
|
||||
```cpp
|
||||
// 只需包含这一个头文件即可使用所有功能
|
||||
#include "StellarX.h"
|
||||
|
||||
// 程序入口点(请使用WinMain以获得更好的兼容性)
|
||||
int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nShowCmd)
|
||||
{
|
||||
// 1. 创建一个640x480的窗口,背景为白色,标题为"我的应用"
|
||||
Window mainWindow(640, 480, 0, RGB(255, 255, 255), "我的第一个星垣应用");
|
||||
|
||||
// 2. 创建一个按钮 (使用智能指针管理)
|
||||
auto myButton = std::make_unique<Button>(
|
||||
250, 200, 140, 40, // x, y, 宽度, 高度
|
||||
"点击我", // 按钮文本
|
||||
StellarX::ButtonMode::NORMAL,
|
||||
StellarX::ControlShape::ROUND_RECTANGLE
|
||||
);
|
||||
|
||||
// 3. 为按钮设置点击事件(使用Lambda表达式)
|
||||
myButton->setOnClickListener([&mainWindow]() {
|
||||
// 使用消息框工厂创建模态对话框
|
||||
auto result = StellarX::MessageBox::ShowModal(
|
||||
mainWindow,
|
||||
"欢迎使用星垣GUI\r\n作者:我在人间做废物",
|
||||
"问候",
|
||||
StellarX::MessageBoxType::OKCancel
|
||||
);
|
||||
// 处理对话框结果
|
||||
if (result == StellarX::MessageBoxResult::OK) {
|
||||
// 用户点击了确定按钮
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 4. (可选)设置按钮样式
|
||||
myButton->textStyle.nHeight = 20;
|
||||
myButton->textStyle.color = RGB(0, 0, 128); // 深蓝色文字
|
||||
myButton->setButtonBorder(RGB(0, 128, 255)); // 蓝色边框
|
||||
|
||||
// 5. 将按钮添加到窗口
|
||||
mainWindow.addControl(std::move(myButton));
|
||||
|
||||
// 6. 绘制窗口
|
||||
mainWindow.draw();
|
||||
|
||||
// 7. 进入消息循环,等待用户交互
|
||||
mainWindow.runEventLoop();
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
1. **编译并运行!** 您将看到一个带有蓝色圆角按钮的窗口,点击它将会弹出消息框。
|
||||
|
||||
## 📚 核心类型详解 (`CoreTypes.h`)
|
||||
|
||||
星垣框架的所有视觉和行为属性都通过`CoreTypes.h`中定义的精美枚举和结构体来控制。
|
||||
|
||||
### 枚举类型 (Enums)
|
||||
|
||||
| 枚举类型 | 描述 | 常用值 |
|
||||
| :--------------------- | :----------- | :----------------------------------------------------------- |
|
||||
| **`ControlShape`** | 控件几何形状 | `RECTANGLE`, `B_RECTANGLE`, `ROUND_RECTANGLE`, `CIRCLE`, `ELLIPSE`等 |
|
||||
| **`ButtonMode`** | 按钮行为模式 | `NORMAL`(普通), `TOGGLE`(切换), `DISABLED`(禁用) |
|
||||
| **`TextBoxMode`** | 文本框模式 | `INPUT_MODE`(输入), `READONLY_MODE`(只读) |
|
||||
| **`FillMode`** | 图形填充模式 | `SOLID`(实心), `NULL`(空心), `HATCHED`(图案)等 |
|
||||
| **`FillStyle`** | 图案填充样式 | `HORIZONTAL`(水平线), `CROSS`(十字线)等 |
|
||||
| **`LineStyle`** | 边框线型 | `SOLID`(实线), `DASH`(虚线), `DOT`(点线)等 |
|
||||
| **`MessageBoxType`** | 消息框类型 | `OK`, `OKCancel`, `YesNo`, `YesNoCancel`, `RetryCancel`, `AbortRetryIgnore` |
|
||||
| **`MessageBoxResult`** | 消息框结果 | `OK`, `Cancel`, `Yes`, `No`, `Abort`, `Retry`, `Ignore` |
|
||||
|
||||
### 结构体 (Structs)
|
||||
|
||||
| 结构体 | 描述 |
|
||||
| :----------------- | :----------------------------------------------------------- |
|
||||
| **`ControlText`** | 封装了所有文本样式属性,包括字体、大小、颜色、粗体、斜体、下划线、删除线等。 |
|
||||
| **`RouRectangle`** | 定义圆角矩形的圆角椭圆尺寸,包含宽度和高度属性。 |
|
||||
|
||||
**使用示例:**
|
||||
|
||||
cpp
|
||||
|
||||
```c++
|
||||
// 创建一个复杂的文本样式
|
||||
StellarX::ControlText myStyle;
|
||||
myStyle.nHeight = 25; // 字体高度
|
||||
myStyle.lpszFace = _T("微软雅黑"); // 字体
|
||||
myStyle.color = RGB(255, 0, 0); // 红色
|
||||
myStyle.nWeight = FW_BOLD; // 粗体
|
||||
myStyle.bUnderline = true; // 下划线
|
||||
|
||||
// 应用于控件
|
||||
myLabel->textStyle = myStyle;
|
||||
myButton->textStyle = myStyle;
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 🧩 控件库大全
|
||||
|
||||
### 1. 基础控件
|
||||
|
||||
| 控件 | 头文件 | 描述 | 关键特性 |
|
||||
| :---------- | :---------- | :------------ | :------------------------------------------------------ |
|
||||
| **Button** | `Button.h` | 多功能按钮 | 支持多种模式/形状/状态,可设置悬停/点击颜色,自定义回调 |
|
||||
| **Label** | `Label.h` | 文本标签 | 支持背景透明/不透明,自定义字体样式 |
|
||||
| **TextBox** | `TextBox.h` | 输入框/显示框 | 支持输入和只读模式,集成EasyX的`InputBox` |
|
||||
|
||||
### 2. 容器控件
|
||||
|
||||
| 控件 | 头文件 | 描述 |
|
||||
| :--------- | :--------- | :------------------------------------------------------- |
|
||||
| **Canvas** | `Canvas.h` | 容器控件,可作为其他控件的父容器,支持自定义边框和背景。 |
|
||||
| **Window** | `Window.h` | 顶级窗口,所有控件的最终容器,负责消息循环和调度。 |
|
||||
|
||||
### 3. 高级控件
|
||||
|
||||
| 控件 | 头文件 | 描述 | 关键特性 |
|
||||
| :--------- | :--------- | :------- | :----------------------------------------------------------- |
|
||||
| **Table** | `Table.h` | 数据表格 | **框架功能亮点**,支持分页显示、自定义表头和数据、自动计算列宽、翻页按钮。 |
|
||||
| **Dialog** | `Dialog.h` | 对话框类 | 实现完整的对话框功能,支持多种按钮组合和异步结果回调。自动处理布局、背景保存恢复和生命周期管理。 |
|
||||
|
||||
**表格控件示例:**
|
||||
|
||||
cpp
|
||||
|
||||
```c++
|
||||
// 创建一个表格
|
||||
auto myTable = std::make_unique<Table>(50, 50);
|
||||
|
||||
// 设置表头
|
||||
myTable->setHeaders({ "ID", "姓名", "年龄", "职业" });
|
||||
|
||||
// 添加数据行
|
||||
myTable->setData({ "1", "张三", "25", "工程师" });
|
||||
myTable->setData({ "2", "李四", "30", "设计师" });
|
||||
myTable->setData({ "3", "王五", "28", "产品经理" });
|
||||
|
||||
// 设置每页显示2行
|
||||
myTable->setRowsPerPage(2);
|
||||
|
||||
// 设置表格样式
|
||||
myTable->textStyle.nHeight = 16;
|
||||
myTable->setTableBorder(RGB(50, 50, 50));
|
||||
myTable->setTableBk(RGB(240, 240, 240));
|
||||
|
||||
// 添加到窗口
|
||||
mainWindow.addControl(std::move(myTable));
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 4. 静态工厂类
|
||||
|
||||
| 控件 | 头文件 | 描述 | 关键特性 |
|
||||
| :------------- | :------------- | :--------------- | :----------------------------------------------------------- |
|
||||
| **MessageBox** | `MessageBox.h` | 对话框的工厂调用 | 静态方法调用,无需实例化,自动处理模态和非模态的逻辑差异集成到窗口的对话框管理系统中提供去重机制防止重复对话框 |
|
||||
|
||||
**消息框使用示例:**
|
||||
|
||||
cpp
|
||||
|
||||
```c++
|
||||
// 模态消息框(阻塞直到关闭)
|
||||
auto result = StellarX::MessageBox::ShowModal(
|
||||
mainWindow,
|
||||
"确认要执行此操作吗?",
|
||||
"确认",
|
||||
StellarX::MessageBoxType::YesNo
|
||||
);
|
||||
|
||||
if (result == StellarX::MessageBoxResult::Yes)
|
||||
{
|
||||
// 用户选择了"是"
|
||||
}
|
||||
|
||||
// 非模态消息框(异步回调)
|
||||
StellarX::MessageBox::ShowAsync(
|
||||
mainWindow,
|
||||
"操作已完成",
|
||||
"提示",
|
||||
StellarX::MessageBoxType::OK,
|
||||
[](StellarX::MessageBoxResult result) {
|
||||
// 异步处理结果
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 🔧 高级主题与最佳实践
|
||||
|
||||
### 1. 自定义控件
|
||||
|
||||
您可以通过继承`Control`基类来创建自定义控件。只需实现`draw()`和`handleEvent()`两个纯虚函数即可。
|
||||
|
||||
cpp
|
||||
|
||||
```c++
|
||||
class MyCustomControl : public Control {
|
||||
public:
|
||||
MyCustomControl(int x, int y) : Control(x, y, 100, 100) {}
|
||||
|
||||
void draw() override {
|
||||
saveStyle();
|
||||
// 您的自定义绘制逻辑
|
||||
setfillcolor(RGB(255, 100, 100));
|
||||
fillrectangle(x, y, x + width, y + height);
|
||||
restoreStyle();
|
||||
}
|
||||
|
||||
bool handleEvent(const ExMessage& msg) override {
|
||||
// 您的自定义事件处理逻辑
|
||||
if (msg.message == WM_LBUTTONDOWN &&
|
||||
msg.x > x && msg.x < x + width &&
|
||||
msg.y > y && msg.y < y + height) {
|
||||
// 处理点击
|
||||
return true; // 事件已消费
|
||||
}
|
||||
return false; // 事件未消费
|
||||
}
|
||||
|
||||
bool IsVisible() const override { return true; }
|
||||
bool model() const override { return false; }
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 2. 布局管理
|
||||
|
||||
当前版本星垣主要采用**绝对定位**。对于简单布局,您可以通过计算坐标来实现。对于复杂布局,可以考虑:
|
||||
|
||||
- 在`Canvas`中嵌套控件,实现相对定位。
|
||||
- 自行实现简单的流式布局或网格布局管理器。
|
||||
|
||||
### 3. 性能优化
|
||||
|
||||
- **脏矩形渲染**: 框架内部已实现,控件状态改变时`dirty=true`,仅在需要时重绘。
|
||||
- **图像资源**: 使用`IMAGE`对象加载图片后,可重复使用,避免多次加载。
|
||||
- **减少循环内操作**: 在`draw()`和`handleEvent()`中避免进行重型计算。
|
||||
- **事件消费机制**: 合理使用事件消费返回值,避免不必要的事件传播。
|
||||
|
||||
### 4. 对话框使用技巧
|
||||
|
||||
- **模态对话框**: 使用`ShowModal`方法,会阻塞当前线程直到对话框关闭,适合需要用户确认的重要操作。
|
||||
- **非模态对话框**: 使用`ShowAsync`方法,不会阻塞主线程,适合提示性信息或后台任务。
|
||||
- **对话框去重**: 框架内置了非模态对话框去重机制,防止同一消息的多个对话框同时出现。
|
||||
|
||||
## ⚠️ 重要限制与适用场景
|
||||
|
||||
**星垣框架的设计目标是轻便、清晰和教学价值,因此它明确** **不适用于** **以下场景:**
|
||||
|
||||
- **高性能游戏或复杂动画**: 渲染基于EasyX的CPU软件渲染,性能有限。
|
||||
- **需要高DPI缩放的应用**: 对高DPI显示器的支持有限,界面可能显示不正确。
|
||||
- **需要无障碍功能的应用**: 未提供对屏幕阅读器等辅助技术的支持。
|
||||
- **跨平台应用**: 深度依赖Windows API和EasyX,无法直接在Linux/macOS上运行。
|
||||
- **复杂的商业软件前端**: 缺乏高级控件(如树形图、富文本框、选项卡、高级列表等)和成熟的自动布局管理器。
|
||||
|
||||
**如果您需要开发上述类型的应用,请考虑使用以下成熟方案:**
|
||||
|
||||
- **Qt**: 功能极其强大,跨平台,适合大型商业应用。
|
||||
- **wxWidgets**: 原生外观,跨平台。
|
||||
- **Dear ImGui**: 即时模式GUI,非常适合工具和调试界面。
|
||||
- **Web技术栈 (Electron/CEF)**: 适合需要Web技术的场景。
|
||||
|
||||
## 📜 许可证 (License)
|
||||
|
||||
本项目采用 **MIT 许可证**。
|
||||
|
||||
您可以自由地:
|
||||
|
||||
- 使用、复制、修改、合并、出版发行、散布、再授权及销售本框架的副本。
|
||||
- 将其用于私人或商业项目。
|
||||
|
||||
唯一要求是:
|
||||
|
||||
- 请在您的项目中保留原始的版权声明。
|
||||
|
||||
详见项目根目录的 [LICENSE](LICENSE) 文件。
|
||||
|
||||
## 👥 贡献指南 (Contributing)
|
||||
|
||||
我们欢迎任何形式的贡献!如果您想为星垣框架添砖加瓦,请阅读以下指南:
|
||||
|
||||
1. **代码风格**: 请遵循现有的Google C++规范风格(使用空格缩进,大括号换行等)。
|
||||
2. **新增功能**: 必须提供**示例代码**,并更新本README文档的相关部分。
|
||||
3. **提交PR**: 请确保您的代码在提交前已经过测试,并描述清楚您的更改内容和动机。
|
||||
4. **问题反馈**: 如果您发现了Bug或有新的想法,欢迎在GitHub提交Issue。
|
||||
|
||||
详细贡献指南请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## 🙏 致谢 (Acknowledgements)
|
||||
|
||||
- 感谢 [EasyX Graphics Library](https://easyx.cn/) 为这个项目提供了简单易用的图形基础,使得用C++教学GUI编程成为可能。
|
||||
- 感谢所有追求**简洁、高效、清晰**编码理念的开发者,你们是"星垣"诞生的灵感源泉。
|
||||
|
||||
------
|
||||
|
||||
**星辰大海,代码为舟。**
|
||||
|
||||
愿 `星垣 (StellarX)` 能成为您探索GUI世界的一颗可靠基石,无论是用于学习、教学还是创造实用的工具。
|
||||
|
||||
## 📞 支持与反馈
|
||||
|
||||
如果您在使用过程中遇到问题或有任何建议:
|
||||
|
||||
1. 查看 [示例代码](examples/) 获取使用参考
|
||||
2. 查阅 [更新日志](CHANGELOG.md) 了解最新变化
|
||||
3. 在GitHub仓库提交Issue反馈问题
|
||||
|
||||
------
|
||||
|
||||
*星垣框架 - 轻若尘埃,繁星为界*
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-key.ps1"
|
||||
set "runnerExit=%ERRORLEVEL%"
|
||||
|
||||
if not "%runnerExit%"=="0" (
|
||||
echo.
|
||||
echo KEY test launcher failed with exit code %runnerExit%.
|
||||
pause
|
||||
)
|
||||
|
||||
exit /b %runnerExit%
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<!-- Build the reusable StellarX framework implementation as a static library. -->
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<!-- Keep the library build matrix aligned with the consuming app and test projects. -->
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{28B8D78E-85AF-4F14-AD56-D51AB6958941}</ProjectGuid>
|
||||
<RootNamespace>StellarXCore</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<!-- The .lib is linked into executables at build time; no runtime DLL is produced. -->
|
||||
<ConfigurationType>StaticLibrary</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<!-- Normal and sanitizer outputs use separate directories so ASan builds stay isolated. -->
|
||||
<OutDir>$(SolutionDir)build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(SolutionDir)build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
<!-- Input probing is diagnostic-only and must be explicitly enabled at build time. -->
|
||||
<StellarXInputProbe Condition="'$(StellarXInputProbe)'==''">false</StellarXInputProbe>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(EnableAsan)'=='true' And '$(Platform)'=='x64'">
|
||||
<EnableASAN>true</EnableASAN>
|
||||
<OutDir>$(ProjectDir)build\$(Platform)\$(Configuration)-asan\</OutDir>
|
||||
<IntDir>$(ProjectDir)build\obj\$(ProjectName)\$(Platform)\$(Configuration)-asan\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)include\StellarX;$(ProjectDir)src\StellarX\internal;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(StellarXInputProbe)'=='true'">
|
||||
<ClCompile>
|
||||
<PreprocessorDefinitions>STELLARX_ENABLE_INPUT_PROBE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
<!-- Public and internal headers are kept in this project so all consumers use one implementation. -->
|
||||
<ItemGroup>
|
||||
<ClInclude Include="include\StellarX\Button.h" /><ClInclude Include="include\StellarX\Canvas.h" /><ClInclude Include="include\StellarX\Control.h" />
|
||||
<ClInclude Include="include\StellarX\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\FocusManager.h" /><ClInclude Include="include\StellarX\InputEvents.h" /><ClInclude Include="include\StellarX\Label.h" />
|
||||
<ClInclude Include="include\StellarX\MessageBox.h" /><ClInclude Include="include\StellarX\StellarX.h" /><ClInclude Include="src\StellarX\internal\SxEventLoopInternal.h" /><ClInclude Include="src\StellarX\internal\SxInputProbeInternal.h" /><ClInclude Include="src\StellarX\internal\SxNativeInputInternal.h" /><ClInclude Include="include\StellarX\SxLog.h" /><ClInclude Include="src\StellarX\internal\SxTextInternal.h" />
|
||||
<ClInclude Include="include\StellarX\TabControl.h" /><ClInclude Include="include\StellarX\Table.h" /><ClInclude Include="include\StellarX\TextBox.h" /><ClInclude Include="include\StellarX\Window.h" />
|
||||
</ItemGroup>
|
||||
<!-- Framework source files compiled into StellarX.Core.lib. -->
|
||||
<ItemGroup>
|
||||
<ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" />
|
||||
<ClCompile Include="src\StellarX\Dialog.cpp" /><ClCompile Include="src\StellarX\FocusManager.cpp" /><ClCompile Include="src\StellarX\Label.cpp" /><ClCompile Include="src\StellarX\MessageBox.cpp" />
|
||||
<ClCompile Include="src\StellarX\SxInputProbe.cpp" /><ClCompile Include="src\StellarX\SxLog.cpp" /><ClCompile Include="src\StellarX\SxNativeInput.cpp" /><ClCompile Include="src\StellarX\TabControl.cpp" /><ClCompile Include="src\StellarX\Table.cpp" />
|
||||
<ClCompile Include="src\StellarX\TextBox.cpp" /><ClCompile Include="src\StellarX\Window.cpp" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
+1677
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
# 贡献者公约行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
为了营造一个开放和受欢迎的环境,我们作为贡献者和维护者承诺:无论年龄、体型、身体健全与否、民族、性征、性别认同与表达、经验水平、教育程度、社会地位、国籍、个人外貌、种族、宗教信仰、性取向,我们的项目和社区对每一个人都不存在歧视。
|
||||
|
||||
## 我们的标准
|
||||
|
||||
有助于为我们社区创造积极环境的行为包括但不限于:
|
||||
|
||||
* 对他人的体贴和尊重
|
||||
* 尊重不同的观点和经历
|
||||
* 优雅地接受建设性批评
|
||||
* 关注对社区最有利的事情
|
||||
* 对其他社区成员表现出同理心
|
||||
|
||||
......
|
||||
|
||||
## 执行
|
||||
|
||||
虐待、骚扰或其他不可接受行为的实例可通过项目维护团队进行报告。所有投诉都将被认真、迅速地审查和调查,并将得出被认为在情况下必要和适当的回应。
|
||||
|
||||
......
|
||||
|
||||
有关此行为准则的常见问题解答,请参阅 https://www.contributor-covenant.org/faq。翻译版本可在 https://www.contributor-covenant.org/translations 获取。
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* @file demo.cpp
|
||||
* @brief 一个简单的演示程序,展示 StellarX GUI 框架的基本用法。
|
||||
* @description 创建一个带有按钮的窗口,点击按钮会弹出对话框
|
||||
*/
|
||||
|
||||
// 只需包含这一个头文件即可使用所有功能
|
||||
#include "StellarX.h"
|
||||
|
||||
// 程序入口点(请使用WinMain以获得更好的兼容性)
|
||||
int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nShowCmd)
|
||||
{
|
||||
// 1. 创建一个640x480的窗口,背景为白色,标题为"我的应用"
|
||||
Window mainWindow(640, 480, 0, RGB(255, 255, 255), "我的第一个星垣应用");
|
||||
|
||||
// 2. 创建一个按钮 (使用智能指针管理)
|
||||
auto myButton = std::make_unique<Button>(
|
||||
250, 200, 140, 40, // x, y, 宽度, 高度
|
||||
"点击我", // 按钮文本
|
||||
StellarX::ButtonMode::NORMAL,
|
||||
StellarX::ControlShape::ROUND_RECTANGLE
|
||||
);
|
||||
|
||||
// 3. 为按钮设置点击事件(使用Lambda表达式)
|
||||
myButton->setOnClickListener([&mainWindow]() {
|
||||
// 使用消息框工厂创建模态对话框
|
||||
auto result = StellarX::MessageBox::ShowModal(
|
||||
mainWindow,
|
||||
"欢迎使用星垣GUI\r\n作者:我在人间做废物",
|
||||
"问候",
|
||||
StellarX::MessageBoxType::OKCancel
|
||||
);
|
||||
// 处理对话框结果
|
||||
if (result == StellarX::MessageBoxResult::OK) {
|
||||
// 用户点击了确定按钮
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 4. (可选)设置按钮样式
|
||||
myButton->textStyle.nHeight = 20;
|
||||
myButton->textStyle.color = RGB(0, 0, 128); // 深蓝色文字
|
||||
myButton->setButtonBorder(RGB(0, 128, 255)); // 蓝色边框
|
||||
|
||||
// 5. 将按钮添加到窗口
|
||||
mainWindow.addControl(std::move(myButton));
|
||||
|
||||
// 6. 绘制窗口
|
||||
mainWindow.draw();
|
||||
|
||||
// 7. 进入消息循环,等待用户交互
|
||||
mainWindow.runEventLoop();
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.13.35828.75 d17.13
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "imGui-easyX", "imGui-easyX.vcxproj", "{FD33B55B-FD97-4087-8B95-6BF8285AAE73}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StellarX.Core", "StellarX.Core.vcxproj", "{28B8D78E-85AF-4F14-AD56-D51AB6958941}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StellarX.Tests", "tests\StellarX.Tests.vcxproj", "{8779C678-AD5B-4828-A4A8-57D4C4254733}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StellarX.UiFixture", "tests\StellarX.UiFixture.vcxproj", "{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.ThreadProbe", "tests\EasyX.ThreadProbe.vcxproj", "{6D338118-A536-4E4F-A4F5-3C73C5CA107A}"
|
||||
EndProject
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.VisualProbe", "tests\EasyX.VisualProbe.vcxproj", "{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Debug|x64.Build.0 = Debug|x64
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Debug|x86.Build.0 = Debug|Win32
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Release|x64.ActiveCfg = Release|x64
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Release|x64.Build.0 = Release|x64
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Release|x86.ActiveCfg = Release|Win32
|
||||
{FD33B55B-FD97-4087-8B95-6BF8285AAE73}.Release|x86.Build.0 = Release|Win32
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Debug|x64.Build.0 = Debug|x64
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Debug|x86.Build.0 = Debug|Win32
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Release|x64.ActiveCfg = Release|x64
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Release|x64.Build.0 = Release|x64
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Release|x86.ActiveCfg = Release|Win32
|
||||
{28B8D78E-85AF-4F14-AD56-D51AB6958941}.Release|x86.Build.0 = Release|Win32
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Debug|x64.Build.0 = Debug|x64
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Debug|x86.Build.0 = Debug|Win32
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Release|x64.ActiveCfg = Release|x64
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Release|x64.Build.0 = Release|x64
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Release|x86.ActiveCfg = Release|Win32
|
||||
{8779C678-AD5B-4828-A4A8-57D4C4254733}.Release|x86.Build.0 = Release|Win32
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Debug|x64.Build.0 = Debug|x64
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Debug|x86.Build.0 = Debug|Win32
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Release|x64.ActiveCfg = Release|x64
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Release|x64.Build.0 = Release|x64
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Release|x86.ActiveCfg = Release|Win32
|
||||
{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}.Release|x86.Build.0 = Release|Win32
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Debug|x64.Build.0 = Debug|x64
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Debug|x86.Build.0 = Debug|Win32
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x64.ActiveCfg = Release|x64
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x64.Build.0 = Release|x64
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.ActiveCfg = Release|Win32
|
||||
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.Build.0 = Release|Win32
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.Build.0 = Debug|x64
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.Build.0 = Debug|Win32
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.ActiveCfg = Release|x64
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.Build.0 = Release|x64
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.ActiveCfg = Release|Win32
|
||||
{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.Build.0 = Release|Win32
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {8E5EB082-F5AD-4E8A-8809-29A47F920B2B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,175 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="include\StellarX\Button.h" />
|
||||
<ClInclude Include="include\StellarX\Canvas.h" />
|
||||
<ClInclude Include="include\StellarX\Control.h" />
|
||||
<ClInclude Include="include\StellarX\CoreTypes.h" />
|
||||
<ClInclude Include="include\StellarX\Dialog.h" />
|
||||
<ClInclude Include="include\StellarX\FocusManager.h" />
|
||||
<ClInclude Include="include\StellarX\Label.h" />
|
||||
<ClInclude Include="include\StellarX\MessageBox.h" />
|
||||
<ClInclude Include="include\StellarX\StellarX.h" />
|
||||
<ClInclude Include="include\StellarX\SxLog.h" />
|
||||
<ClInclude Include="include\StellarX\TabControl.h" />
|
||||
<ClInclude Include="include\StellarX\Table.h" />
|
||||
<ClInclude Include="include\StellarX\TextBox.h" />
|
||||
<ClInclude Include="include\StellarX\Window.h" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="app\z-testDome.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="StellarX.Core.vcxproj">
|
||||
<Project>{28B8D78E-85AF-4F14-AD56-D51AB6958941}</Project>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{fd33b55b-fd97-4087-8b95-6bf8285aae73}</ProjectGuid>
|
||||
<RootNamespace>imGuieasyX</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)include\StellarX;$(ProjectDir)src\StellarX\internal;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
</ItemDefinitionGroup>
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="Shared">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<PropertyGroup>
|
||||
<KeyScenario Condition="'$(KeyScenario)'==''">6</KeyScenario>
|
||||
<OutDir>$(SolutionDir)build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(SolutionDir)build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>KEY=$(KeyScenario);%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeaderFile />
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>KEY=$(KeyScenario);%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeaderFile />
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>KEY=$(KeyScenario);%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeaderFile />
|
||||
<LanguageStandard_C>stdc17</LanguageStandard_C>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>false</SDLCheck>
|
||||
<PreprocessorDefinitions>KEY=$(KeyScenario);%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeaderFile />
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup>
|
||||
<Filter Include="源文件">
|
||||
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
|
||||
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="头文件">
|
||||
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
|
||||
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="资源文件">
|
||||
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
|
||||
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="include\StellarX\Control.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\Button.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\Window.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\Label.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\TextBox.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\Canvas.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\Table.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\StellarX.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\CoreTypes.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\Dialog.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\FocusManager.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\MessageBox.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\TabControl.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="include\StellarX\SxLog.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="app\z-testDome.cpp">
|
||||
<Filter>源文件</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<ShowAllFiles>false</ShowAllFiles>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
+125
-59
@@ -19,132 +19,198 @@
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
#include"label.h"
|
||||
|
||||
#define DISABLEDCOLOUR RGB(96, 96, 96) //禁用状态颜色
|
||||
#define TEXTMARGINS_X 6
|
||||
#define TEXTMARGINS_Y 4
|
||||
constexpr int bordWith = 1; //边框宽度,用于快照恢复时的偏移计算
|
||||
constexpr int bordHeight = 1; //边框高度,用于快照恢复时的偏移计算
|
||||
|
||||
class Button : public Control
|
||||
{
|
||||
|
||||
std::string text; // 按钮上的文字
|
||||
bool click; // 是否被点击
|
||||
bool hover; // 是否被悬停
|
||||
|
||||
std::string cutText; // 切割后的文本
|
||||
bool needCutText = true; // 是否需要切割文本
|
||||
bool isUseCutText = false; // 是否使用切割文本
|
||||
int padX = TEXTMARGINS_X; // 文本最小左右内边距
|
||||
int padY = TEXTMARGINS_Y; // 文本最小上下内边距
|
||||
|
||||
COLORREF buttonTrueColor; // 按钮被点击后的颜色
|
||||
COLORREF buttonFalseColor; // 按钮未被点击的颜色
|
||||
COLORREF buttonHoverColor; // 按钮被鼠标悬停的颜色
|
||||
COLORREF buttonBorderColor = RGB(0,0,0);// 按钮边框颜色
|
||||
COLORREF buttonBorderColor = RGB(0, 0, 0);// 按钮边框颜色
|
||||
|
||||
StellarX::ButtonMode mode; // 按钮模式
|
||||
StellarX::ControlShape shape; // 按钮形状
|
||||
|
||||
StellarX::FillMode buttonFillMode = StellarX::FillMode::Solid; //按钮填充模式
|
||||
StellarX::FillStyle buttonFillIma = StellarX::FillStyle::BDiagonal; //按钮填充图案
|
||||
IMAGE* buttonFileIMAGE = nullptr; //按钮填充图像
|
||||
|
||||
|
||||
std::unique_ptr<IMAGE> buttonFileIMAGE; //按钮填充图像
|
||||
|
||||
std::function<void()> onClickCallback; //回调函数
|
||||
std::function<void()> onToggleOnCallback; //TOGGLE模式下的回调函数
|
||||
std::function<void()> onToggleOffCallback; //TOGGLE模式下的回调函数
|
||||
|
||||
public:
|
||||
// 必须先构造公开字体样式,再用它初始化旧样式快照。
|
||||
// C++ 按成员声明顺序构造;若顺序反过来,oldStyle 会读取尚未构造的 std::string。
|
||||
StellarX::ControlText textStyle; // 按钮文字样式
|
||||
|
||||
private:
|
||||
StellarX::ControlText oldStyle = textStyle; // 按钮文字样式
|
||||
int oldtext_width = -1;
|
||||
int oldtext_height = -1;
|
||||
int text_width = 0;
|
||||
int text_height = 0;
|
||||
public:
|
||||
StellarX::ControlText textStyle; // 按钮文字样式
|
||||
|
||||
public:
|
||||
// === Tooltip ===
|
||||
bool tipEnabled = false; // 是否启用
|
||||
bool tipVisible = false; // 当前是否显示
|
||||
bool tipFollowCursor = false; // 是否跟随鼠标
|
||||
bool tipUserOverride = false; // 是否用户自定义了tip文本
|
||||
int tipDelayMs = 1000; // 延时(毫秒)
|
||||
int tipOffsetX = 12; // 相对鼠标偏移
|
||||
int tipOffsetY = 18;
|
||||
ULONGLONG tipHoverTick = 0; // 开始悬停的时间戳
|
||||
int lastMouseX = 0; // 最新鼠标位置(用于定位)
|
||||
int lastMouseY = 0;
|
||||
|
||||
//默认按钮颜色
|
||||
std::string tipTextClick; // NORMAL 模式下用
|
||||
std::string tipTextOn; // click==true 时用
|
||||
std::string tipTextOff; // click==false 时用
|
||||
Label tipLabel; // 直接复用Label作为提示
|
||||
|
||||
public:
|
||||
|
||||
// 创建使用默认颜色的按钮。
|
||||
Button(int x, int y, int width, int height, const std::string text,
|
||||
StellarX::ButtonMode mode = StellarX::ButtonMode::NORMAL, StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE);
|
||||
//自定义按钮未被点击和被点击颜色
|
||||
// 创建自定义默认态和点击态颜色的按钮。
|
||||
Button(int x, int y, int width, int height, const std::string text,
|
||||
COLORREF ct, COLORREF cf, StellarX::ButtonMode mode = StellarX::ButtonMode::NORMAL,
|
||||
StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE);
|
||||
//自定义按钮颜色和悬停颜色
|
||||
// 创建自定义默认态、点击态和悬停态颜色的按钮。
|
||||
Button(int x, int y, int width, int height, const std::string text,
|
||||
COLORREF ct, COLORREF cf,COLORREF ch,
|
||||
COLORREF ct, COLORREF cf, COLORREF ch,
|
||||
StellarX::ButtonMode mode = StellarX::ButtonMode::NORMAL, StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE);
|
||||
//析构函数 释放图形指针内存
|
||||
// 析构按钮并释放图像填充资源。
|
||||
~Button();
|
||||
|
||||
//绘制按钮
|
||||
// 绘制按钮本体、文本和必要时的 Tooltip。
|
||||
void draw() override;
|
||||
//按钮事件处理
|
||||
|
||||
// 处理按钮点击、悬停、切换状态和 Tooltip 触发。
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
// 由窗口时钟推进 Tooltip 到期状态,即使没有新的鼠标消息也能触发显示。
|
||||
void tick(ULONGLONG nowMs) override;
|
||||
// 清理按钮的鼠标瞬时状态;用于父容器在 WM_MOUSEMOVE 短路后补做 hover/tooltip 收口。
|
||||
bool clearTransientMouseState() override;
|
||||
// Tooltip 可见时,按钮实际写像素范围不再等于按钮本体,需要把 Tooltip 矩形并入 coverage。
|
||||
// coverage 必须覆盖按钮本体和可见 Tooltip,才能正确恢复旧区域。
|
||||
RECT getManagedRepaintCoverageRect() const override;
|
||||
// Button 的持久绘制范围只包含本体;Tooltip 是临时浮层,不能污染兄弟控件背景快照。
|
||||
RECT getManagedRepaintPersistentCoverageRect() const override;
|
||||
|
||||
//设置回调函数
|
||||
void setOnClickListener(const std::function<void()>&& callback);
|
||||
//设置TOGGLE模式下被点击的回调函数
|
||||
void setOnToggleOnListener(const std::function<void()>&& callback);
|
||||
//设置TOGGLE模式下取消点击的回调函数
|
||||
void setOnToggleOffListener(const std::function<void()>&& callback);
|
||||
//设置按钮模式
|
||||
// 设置 NORMAL 模式下的点击回调
|
||||
void setOnClickListener(std::function<void()> callback);
|
||||
// 设置 TOGGLE 模式下切换到打开状态时的回调
|
||||
void setOnToggleOnListener(std::function<void()> callback);
|
||||
// 设置 TOGGLE 模式下切换到关闭状态时的回调
|
||||
void setOnToggleOffListener(std::function<void()> callback);
|
||||
// 设置按钮模式(NORMAL / TOGGLE / DISABLED)
|
||||
void setbuttonMode(StellarX::ButtonMode mode);
|
||||
//设置圆角矩形椭圆宽度
|
||||
// 设置圆角矩形的圆角宽度
|
||||
void setROUND_RECTANGLEwidth(int width);
|
||||
//设置圆角矩形椭圆高度
|
||||
// 设置圆角矩形的圆角高度
|
||||
void setROUND_RECTANGLEheight(int height);
|
||||
//设置按钮填充模式
|
||||
// 设置按钮填充模式
|
||||
void setFillMode(StellarX::FillMode mode);
|
||||
//设置按钮填充图案
|
||||
// 设置按钮图案填充样式
|
||||
void setFillIma(StellarX::FillStyle ima);
|
||||
//设置按钮填充图像
|
||||
// 设置按钮图像填充资源
|
||||
void setFillIma(std::string imaName);
|
||||
//设置按钮边框颜色
|
||||
// 设置按钮边框颜色
|
||||
void setButtonBorder(COLORREF Border);
|
||||
//设置按钮未被点击颜色
|
||||
// 设置按钮默认态颜色
|
||||
void setButtonFalseColor(COLORREF color);
|
||||
//设置按钮文本
|
||||
// 设置按钮文本(char* 重载)。
|
||||
// 会重新计算文本像素尺寸并让自动 Tooltip 文案同步为新文本;传入指针内容会被拷贝。
|
||||
void setButtonText(const char* text);
|
||||
// 设置按钮文本(std::string 重载)。
|
||||
// 若用户未显式覆盖 Tooltip 文案,默认 Tooltip 也会跟随按钮文本更新。
|
||||
void setButtonText(std::string text);
|
||||
//设置按钮形状
|
||||
// 设置按钮几何形状。
|
||||
void setButtonShape(StellarX::ControlShape shape);
|
||||
// 直接设置按钮点击状态。
|
||||
// NORMAL 设置为 true 时触发 onClick;TOGGLE 仅在状态实际变化时触发 on/off 回调,重复设置同值不会重跑回调。
|
||||
// 回调在 mutation scope 内执行,可以安全排队修改宿主控件树。
|
||||
void setButtonClick(BOOL click);
|
||||
|
||||
//判断按钮是否被点击
|
||||
// 查询按钮当前是否处于点击/选中状态。
|
||||
bool isClicked() const;
|
||||
|
||||
//获取按钮文字
|
||||
// 获取按钮文本。
|
||||
std::string getButtonText() const;
|
||||
// 获取按钮文本的 C 字符串视图;指针仅在下一次修改按钮文本前有效,不应长期保存。
|
||||
const char* getButtonText_c() const;
|
||||
//获取按钮模式
|
||||
// 获取按钮模式。
|
||||
StellarX::ButtonMode getButtonMode() const;
|
||||
//获取按钮形状
|
||||
// 获取按钮形状。
|
||||
StellarX::ControlShape getButtonShape() const;
|
||||
//获取按钮填充模式
|
||||
// 获取按钮填充模式。
|
||||
StellarX::FillMode getFillMode() const;
|
||||
//获取按钮填充图案
|
||||
// 获取按钮图案填充样式。
|
||||
StellarX::FillStyle getFillIma() const;
|
||||
//获取按钮填充图像
|
||||
// 获取按钮图像填充资源;未设置图像时返回 nullptr。
|
||||
IMAGE* getFillImaImage() const;
|
||||
//获取按钮边框颜色
|
||||
// 获取按钮边框颜色。
|
||||
COLORREF getButtonBorder() const;
|
||||
//获取按钮文字颜色
|
||||
// 获取按钮文字颜色。
|
||||
COLORREF getButtonTextColor() const;
|
||||
//获取按钮文字样式
|
||||
// 获取按钮文字样式。
|
||||
StellarX::ControlText getButtonTextStyle() const;
|
||||
//获取按钮宽度
|
||||
int getButtonWidth() const;
|
||||
//获取按钮高度
|
||||
int getButtonHeight() const;
|
||||
//获取按钮横坐标
|
||||
int getButtonX() const;
|
||||
//获取按钮纵坐标
|
||||
int getButtonY() const;
|
||||
|
||||
public:
|
||||
// === Tooltip API===
|
||||
// 开关 Tooltip;关闭时只修改内部启用/显示状态,不负责智能选位。
|
||||
// Tooltip 是按钮内部临时浮层,会并入托管重绘 coverage,但不会成为持久背景快照的一部分。
|
||||
void enableTooltip(bool on);
|
||||
// 查询 Tooltip 当前是否已经显示。
|
||||
bool isTooltipVisible() const { return tipVisible; }
|
||||
// 设置 Tooltip 延时(毫秒);负数会被夹紧为 0。
|
||||
void setTooltipDelay(int ms) { tipDelayMs = (ms < 0 ? 0 : ms); }
|
||||
// 设置 Tooltip 是否跟随鼠标
|
||||
void setTooltipFollowCursor(bool on) { tipFollowCursor = on; }
|
||||
// 设置 Tooltip 偏移量;当前仅在 setTooltipFollowCursor(true) 时参与定位。
|
||||
void setTooltipOffset(int dx, int dy) { tipOffsetX = dx; tipOffsetY = dy; }
|
||||
// 设置 Tooltip 的文字、背景和透明样式。
|
||||
void setTooltipStyle(COLORREF text, COLORREF bk, bool transparent);
|
||||
// 设置 NORMAL 模式下 Tooltip 文本,并阻止自动跟随按钮文本。
|
||||
void setTooltipText(const std::string& s) { tipTextClick = s; tipUserOverride = true; }
|
||||
// 设置 TOGGLE 模式开/关两种状态下的 Tooltip 文本。
|
||||
void setTooltipTextsForToggle(const std::string& onText, const std::string& offText);
|
||||
protected:
|
||||
bool isFocusEnabled() const override
|
||||
{
|
||||
return mode != StellarX::ButtonMode::DISABLED;
|
||||
}
|
||||
private:
|
||||
//初始化按钮
|
||||
// 初始化按钮内部状态、颜色和 Tooltip 默认样式
|
||||
void initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch);
|
||||
//判断鼠标是否在圆形按钮内
|
||||
// 判断鼠标是否在圆形按钮内
|
||||
bool isMouseInCircle(int mouseX, int mouseY, int x, int y, int radius);
|
||||
//判断鼠标是否在椭圆按钮内
|
||||
// 判断鼠标是否在椭圆按钮内
|
||||
bool isMouseInEllipse(int mouseX, int mouseY, int x, int y, int width, int height);
|
||||
|
||||
//检查是否对话框是否可见
|
||||
bool IsVisible() const override { return false; }
|
||||
//获取对话框类型
|
||||
// Button 不是模态对话框,固定返回 false。
|
||||
bool model() const override { return false; }
|
||||
// 按当前按钮宽度和语言特征裁剪文本
|
||||
void cutButtonText();
|
||||
// 统一隐藏 Tooltip 并恢复其背景快照。
|
||||
void hideTooltip();
|
||||
// 在最后一次记录的鼠标位置显示 Tooltip。
|
||||
void showTooltipAtLastMouse();
|
||||
// 根据当前 click 状态选择 Tooltip 文案
|
||||
void refreshTooltipTextForState();
|
||||
};
|
||||
|
||||
|
||||
+97
-24
@@ -4,6 +4,7 @@
|
||||
* @描述:
|
||||
* 作为其他控件的父容器,提供统一的背景和边框样式。
|
||||
* 负责将事件传递给子控件并管理它们的绘制顺序。
|
||||
* 在托管重绘模式下,Canvas 通常作为一组子控件的安全重绘 root。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持四种矩形形状(普通、圆角,各有边框和无边框版本)
|
||||
@@ -14,54 +15,126 @@
|
||||
* @使用场景: 用于分组相关控件、实现复杂布局或作为对话框基础
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
*******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
#include"Table.h"
|
||||
|
||||
class Canvas : public Control
|
||||
{
|
||||
protected:
|
||||
friend class TabControl;
|
||||
protected:
|
||||
std::vector<std::unique_ptr<Control>> controls;
|
||||
// 回调或遍历期间的增删清空先记录在队列中,防止 vector 变更使当前迭代器失效。
|
||||
enum class ControlMutationKind { Add, Remove, Clear };
|
||||
struct PendingControlMutation
|
||||
{
|
||||
ControlMutationKind kind = ControlMutationKind::Clear;
|
||||
StellarX::ControlHandle handle{};
|
||||
std::unique_ptr<Control> control;
|
||||
};
|
||||
std::vector<PendingControlMutation> pendingControlMutations;
|
||||
int mutationScopeDepth = 0;
|
||||
// 应用队列时禁止递归提交,避免回调再次加入变更造成重入。
|
||||
bool applyingControlMutations = false;
|
||||
|
||||
StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE; //容器形状
|
||||
|
||||
StellarX::FillMode canvasFillMode = StellarX::FillMode::Solid; //容器填充模式
|
||||
StellarX::LineStyle canvasLineStyle = StellarX::LineStyle::Solid; //线型
|
||||
int canvaslinewidth = 1; //线宽
|
||||
|
||||
COLORREF canvasBorderClor = RGB(0, 0, 0);//边框颜色
|
||||
COLORREF canvasBkClor = RGB(255,255,255); //背景颜色
|
||||
|
||||
void clearAllControls(); // 清除所有子控件
|
||||
|
||||
//检查是否对话框是否可见
|
||||
bool IsVisible() const override { return false; }
|
||||
//获取对话框类型
|
||||
bool model() const override { return false; }
|
||||
COLORREF canvasBorderClor = RGB(0, 0, 0); //边框颜色
|
||||
COLORREF canvasBkClor = RGB(255, 255, 255); //背景颜色
|
||||
|
||||
public:
|
||||
// 默认构造:创建一个基础矩形容器
|
||||
Canvas();
|
||||
// 指定初始位置和尺寸构造容器
|
||||
Canvas(int x, int y, int width, int height);
|
||||
// 析构 Canvas;子控件由 unique_ptr 自动释放。
|
||||
~Canvas() {}
|
||||
//绘制容器及其子控件
|
||||
|
||||
// 修改容器运行态 X,并同步重算全部受管理子控件的世界坐标
|
||||
void setX(int x)override;
|
||||
// 修改容器运行态 Y,并同步重算全部受管理子控件的世界坐标
|
||||
void setY(int y)override;
|
||||
// 修改容器运行态宽度,并同步重算全部受管理子控件的世界坐标
|
||||
void setWidth(int width) override;
|
||||
// 修改容器运行态高度,并同步重算全部受管理子控件的世界坐标
|
||||
void setHeight(int height) override;
|
||||
|
||||
// 绘制容器背景、边框和全部可见子控件。
|
||||
void draw() override;
|
||||
// 将鼠标和键盘事件按绘制层级分发给子控件。
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
//添加控件
|
||||
void addControl(std::unique_ptr<Control> control);
|
||||
|
||||
//设置容器样式
|
||||
// 推进容器内可见子控件的时钟状态。
|
||||
void tick(ULONGLONG nowMs) override;
|
||||
// 递归清理子树中的 hover / tooltip / 临时按下态,不把 Canvas 自己升级为整块重绘源。
|
||||
bool clearTransientMouseState() override;
|
||||
// 添加一个由 Canvas 托管的子控件并返回稳定句柄。
|
||||
// 若当前处于事件/绘制/tick 事务,unique_ptr 会先进入待提交队列,避免正在遍历的 controls 失效。
|
||||
StellarX::ControlHandle addControl(std::unique_ptr<Control> control);
|
||||
// 按句柄移除子控件;事务期间延迟删除。
|
||||
// 返回 true 表示目标命中且删除请求已排队/提交;返回 false 表示句柄无效或未命中。
|
||||
bool removeControl(StellarX::ControlHandle handle);
|
||||
// 清空全部子控件;事务期间延迟到最外层安全点提交。
|
||||
void clearControls();
|
||||
// 查找当前已提交子控件;不会返回仍在 pending 队列中的对象。
|
||||
Control* findControl(StellarX::ControlHandle handle);
|
||||
// 查找当前已提交子控件的只读视图;不会返回仍在 pending 队列中的对象。
|
||||
const Control* findControl(StellarX::ControlHandle handle) const;
|
||||
// 返回当前已提交子控件句柄快照;句柄只表示身份,不保证未来仍有效。
|
||||
std::vector<StellarX::ControlHandle> getControlHandles() const;
|
||||
// 设置容器几何形状。
|
||||
void setShape(StellarX::ControlShape shape);
|
||||
//设置容器填充模式
|
||||
// 设置容器填充模式。
|
||||
void setCanvasfillMode(StellarX::FillMode mode);
|
||||
//设置容器边框颜色
|
||||
// 设置容器边框颜色。
|
||||
void setBorderColor(COLORREF color);
|
||||
//设置填充颜色
|
||||
// 设置容器背景填充颜色。
|
||||
void setCanvasBkColor(COLORREF color);
|
||||
//设置线形
|
||||
// 设置容器边框线型。
|
||||
void setCanvasLineStyle(StellarX::LineStyle style);
|
||||
//设置线段宽度
|
||||
// 设置容器边框线宽。
|
||||
void setLinewidth(int width);
|
||||
|
||||
// 设置容器可见性,并向子控件递归同步可见状态。
|
||||
void setIsVisible(bool visible) override;
|
||||
// 设置容器脏标记,并按需递归标记子控件。
|
||||
void setDirty(bool dirty) override;
|
||||
// 窗口尺寸变化时作废容器和子树快照。
|
||||
void onWindowResize() override;
|
||||
// 托管模式下登记为 root;非托管模式下走局部或根级重绘。
|
||||
// 子控件视觉变化会提升到 Canvas root,保证背景快照和兄弟覆盖关系一致。
|
||||
void requestRepaint(Control* parent)override;
|
||||
// 判断 Canvas 或其受管理子树是否存在待提交的脏内容。
|
||||
bool hasManagedDirtySubtree() const override;
|
||||
// 获取 Canvas 托管重绘覆盖范围,包含需要随 root 一起重绘的子树区域。
|
||||
RECT getManagedRepaintCoverageRect() const override;
|
||||
// 获取 Canvas 持久绘制范围,用于判断兄弟背景快照是否需要失效。
|
||||
RECT getManagedRepaintPersistentCoverageRect() const override;
|
||||
// 判断当前 Canvas 是否可安全做局部提交
|
||||
bool canCommitManagedPartialRepaint() const override;
|
||||
// 托管收口阶段执行 Canvas 的真正重绘
|
||||
void commitManagedRepaint() override;
|
||||
protected:
|
||||
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const override;
|
||||
void collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const override;
|
||||
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason) override;
|
||||
// 进入 Canvas 子树 mutation transaction;会向父容器/Window 传播事务深度。
|
||||
void beginMutationScope() override;
|
||||
// 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。
|
||||
void endMutationScope() override;
|
||||
// 提交 add/remove/clear 队列,并递归提交已存在子容器的 pending mutation。
|
||||
void flushPendingMutations() override;
|
||||
// 统一解算后,按当前运行态矩形把所有受管理子控件重新映射到新的世界坐标。
|
||||
void applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect) override;
|
||||
private:
|
||||
// 容器自己的几何变化后,需要统一刷新所有子控件的运行态矩形。
|
||||
void relayoutManagedChildren();
|
||||
// Canvas 不是模态对话框,固定返回 false。
|
||||
bool model() const override { return false; };
|
||||
};
|
||||
|
||||
|
||||
+293
-45
@@ -4,18 +4,26 @@
|
||||
* @描述:
|
||||
* 提供控件的基本属性和方法,包括位置、尺寸、重绘标记等。
|
||||
* 实现绘图状态保存和恢复机制,确保控件绘制不影响全局状态。
|
||||
* 同时提供“事件阶段登记、收口阶段统一提交”的托管重绘基础接口。
|
||||
*
|
||||
* @特性:
|
||||
* - 定义控件基本属性(坐标、尺寸、脏标记)
|
||||
* - 提供绘图状态管理(saveStyle/restoreStyle)
|
||||
* - 声明纯虚接口(draw、handleEvent等)
|
||||
* - 支持移动语义,禁止拷贝语义
|
||||
* - 禁止移动语义,禁止拷贝语义
|
||||
*
|
||||
* @使用场景: 作为所有具体控件类的基类,不直接实例化
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
#ifndef _WIN32_WINNT
|
||||
#define _WIN32_WINNT 0x0600
|
||||
#endif
|
||||
#ifndef WINVER
|
||||
#define WINVER _WIN32_WINNT
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <easyx.h>
|
||||
@@ -23,68 +31,308 @@
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <atomic>
|
||||
#include "CoreTypes.h"
|
||||
|
||||
class Window;
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
class FocusManager;
|
||||
}
|
||||
|
||||
class Control
|
||||
{
|
||||
friend class Window;
|
||||
friend class Canvas;
|
||||
friend class Table;
|
||||
friend class TabControl;
|
||||
protected:
|
||||
int x, y; // 左上角坐标
|
||||
int width, height; // 控件尺寸
|
||||
inline static std::atomic<std::uint64_t> nextHandleValue{ 1 };
|
||||
// 句柄随控件构造生成,容器只通过句柄排队删除,避免遍历期间直接持有失效指针。
|
||||
StellarX::ControlHandle handle{ nextHandleValue.fetch_add(1, std::memory_order_relaxed) };
|
||||
std::string id; // 控件ID
|
||||
int localx, x, localy, y; // local* 为设计态父局部坐标,x/y 为运行态世界坐标
|
||||
int localWidth, width, localHeight, height; // local* 为设计态尺寸,width/height 为运行态尺寸
|
||||
Control* parent = nullptr; // 父控件
|
||||
Window* hostWindow = nullptr; // 宿主窗口(顶层由 Window 注入,子控件可沿 parent 回溯)
|
||||
bool dirty = true; // 是否重绘
|
||||
bool show = true; // 是否显示
|
||||
bool eventVisualChanged = false; // 最近一次 handleEvent 是否真的引发了视觉变化(用于上层判断是否需要登记重绘)
|
||||
StellarX::FocusPolicy focusPolicy = StellarX::FocusPolicy::None; // 逻辑焦点入口能力
|
||||
std::int32_t tabIndex = 0; // 同一作用域内的显式 Tab 顺序;相同时保持树顺序
|
||||
bool logicalFocused = false; // 仅由 FocusManager 提交,控件不得自行切换
|
||||
|
||||
/* == 布局模式 == */
|
||||
StellarX::LayoutMode layoutMode = StellarX::LayoutMode::Fixed; // 布局模式
|
||||
StellarX::Anchor anchor_1 = StellarX::Anchor::Top; // 旧版兼容锚点 1
|
||||
StellarX::Anchor anchor_2 = StellarX::Anchor::Right; // 旧版兼容锚点 2
|
||||
// 新布局模型:内部统一使用“水平轴 + 垂直轴”的规格描述,不再直接依赖旧锚点求解。
|
||||
StellarX::LayoutSpec layoutSpec{
|
||||
{ false, true, StellarX::AxisSizePolicy::FixedSize, StellarX::AxisAlignPolicy::End },
|
||||
{ true, false, StellarX::AxisSizePolicy::FixedSize, StellarX::AxisAlignPolicy::Start }
|
||||
};
|
||||
// 控件能力边界:用于限制某些控件在特定轴上是否允许 Stretch。
|
||||
StellarX::LayoutCapability layoutCapability{};
|
||||
|
||||
/* == 背景快照 == */
|
||||
std::unique_ptr<IMAGE> saveBkImage;
|
||||
int saveBkX = 0, saveBkY = 0; // 快照保存起始坐标
|
||||
int saveWidth = 0, saveHeight = 0; // 快照保存尺寸
|
||||
bool hasSnap = false; // 当前是否持有有效快照
|
||||
|
||||
StellarX::RouRectangle rouRectangleSize; // 圆角矩形椭圆宽度和高度
|
||||
|
||||
LOGFONT* currentFont = new LOGFONT(); // 保存当前字体样式和颜色
|
||||
COLORREF* currentColor = new COLORREF();
|
||||
COLORREF* currentBkColor = new COLORREF(); // 保存当前填充色
|
||||
COLORREF* currentBorderColor = new COLORREF(); // 边框颜色
|
||||
LINESTYLE* currentLineStyle = new LINESTYLE(); // 保存当前线型
|
||||
LOGFONT currentFont{}; // 保存当前字体样式和颜色
|
||||
COLORREF currentColor{};
|
||||
COLORREF currentBkColor{}; // 保存当前填充色
|
||||
COLORREF currentBorderColor{}; // 边框颜色
|
||||
LINESTYLE currentLineStyle{}; // 保存当前线型
|
||||
|
||||
public:
|
||||
// 禁止拷贝构造,避免两个对象拥有同一个控件身份和背景快照。
|
||||
Control(const Control&) = delete;
|
||||
// 禁止拷贝赋值,保持控件所有权和句柄唯一性。
|
||||
Control& operator=(const Control&) = delete;
|
||||
Control(Control&&) = default;
|
||||
Control& operator=(Control&&) = default;
|
||||
// 禁止移动构造,避免容器外部移动后破坏父子关系和快照状态。
|
||||
Control(Control&&) = delete;
|
||||
// 禁止移动赋值,控件迁移统一通过 unique_ptr 所有权转移完成。
|
||||
Control& operator=(Control&&) = delete;
|
||||
|
||||
Control() : x(0), y(0), width(100), height(100) {}
|
||||
// 构造默认控件几何,供派生类无参构造使用。
|
||||
Control() : localx(0), x(0), localy(0), y(0), localWidth(100), width(100), height(100), localHeight(100) {}
|
||||
// 构造指定设计态和运行态矩形的基础控件。
|
||||
Control(int x, int y, int width, int height)
|
||||
: x(x), y(y), width(width), height(height) {
|
||||
: localx(x), x(x), localy(y), y(y), localWidth(width), width(width), height(height), localHeight(height) {
|
||||
}
|
||||
|
||||
virtual ~Control() {
|
||||
delete currentFont;
|
||||
delete currentColor;
|
||||
delete currentBkColor;
|
||||
delete currentBorderColor;
|
||||
delete currentLineStyle;
|
||||
|
||||
currentFont = nullptr;
|
||||
currentColor = nullptr;
|
||||
currentBkColor = nullptr;
|
||||
currentBorderColor = nullptr;
|
||||
currentLineStyle = nullptr;
|
||||
|
||||
|
||||
}
|
||||
protected:
|
||||
// 获取位置和尺寸
|
||||
int getX() const { return x; }
|
||||
int getY() const { return y; }
|
||||
int getWidth() const { return width; }
|
||||
int getHeight() const { return height; }
|
||||
int getRight() const { return x + width; }
|
||||
int getBottom() const { return y + height; }
|
||||
public:
|
||||
//设置是否重绘
|
||||
void setDirty(bool dirty) { this->dirty = dirty; }
|
||||
virtual void draw() = 0;
|
||||
virtual bool handleEvent(const ExMessage& msg) = 0;//返回true代表事件已消费
|
||||
//用来检查非模态对话框是否可见,其他控件不用实现
|
||||
virtual bool IsVisible() const = 0;
|
||||
//用来检查对话框是否模态,其他控件不用实现
|
||||
virtual bool model()const = 0;
|
||||
// 控件内部使用的 RAII 事务守卫。
|
||||
// 进入后子类可安全排队修改子树;析构时自动结束事务并在最外层触发提交。
|
||||
class MutationScope
|
||||
{
|
||||
// RAII 事务保证异常路径或提前 return 时也能成对结束嵌套变更范围。
|
||||
Control* owner = nullptr;
|
||||
public:
|
||||
// 进入 owner 的 mutation scope;owner 为空时为空操作。
|
||||
explicit MutationScope(Control* control) : owner(control) { if (owner) owner->beginMutationScope(); }
|
||||
// 禁止拷贝事务守卫,避免重复结束同一事务。
|
||||
MutationScope(const MutationScope&) = delete;
|
||||
// 禁止拷贝赋值事务守卫,事务所有权只能移动。
|
||||
MutationScope& operator=(const MutationScope&) = delete;
|
||||
// 移动事务守卫所有权,确保只有一个对象负责 endMutationScope。
|
||||
MutationScope(MutationScope&& other) noexcept : owner(other.owner) { other.owner = nullptr; }
|
||||
// 自动离开 owner 的 mutation scope。
|
||||
~MutationScope() { if (owner) owner->endMutationScope(); }
|
||||
};
|
||||
|
||||
// 析构控件并释放可能存在的背景快照。
|
||||
virtual ~Control()
|
||||
{
|
||||
discardBackground();
|
||||
}
|
||||
protected:
|
||||
// 向上请求重绘:普通路径交给父容器,托管路径则登记到 Window。
|
||||
// 子类在事件中改变可见像素后应调用它,而不是直接假设自己能立即 draw。
|
||||
virtual void requestRepaint(Control* parent);
|
||||
// 根控件/无父时触发重绘;顶层控件通常通过 Window 托管收口统一处理。
|
||||
virtual void onRequestRepaintAsRoot();
|
||||
// 当前是否处于 Window 托管分发阶段;若为真,则不应立即画。
|
||||
// 该判断用于避免事件遍历中边改状态边回贴背景导致闪烁或快照错乱。
|
||||
bool shouldDeferManagedRepaint() const;
|
||||
protected:
|
||||
// 保存指定区域的背景快照,供局部重绘前后恢复使用。
|
||||
virtual void saveBackground(int x, int y, int w, int h);
|
||||
// 将已保存的背景快照回贴到屏幕。
|
||||
virtual void restBackground();
|
||||
// 回贴旧背景并释放快照
|
||||
void discardBackground();
|
||||
public:
|
||||
// 仅作废快照,不回贴旧背景
|
||||
void invalidateBackgroundSnapshot();
|
||||
//“纯作废快照 + 标脏”,不再在 resize 路径里回贴旧背景
|
||||
virtual void onWindowResize();
|
||||
// 获取当前运行态 X 坐标。
|
||||
int getX() const { return x; }
|
||||
// 获取当前运行态 Y 坐标。
|
||||
int getY() const { return y; }
|
||||
// 获取当前运行态宽度。
|
||||
int getWidth() const { return width; }
|
||||
// 获取当前运行态高度。
|
||||
int getHeight() const { return height; }
|
||||
// 获取当前运行态右边界坐标。
|
||||
int getRight() const { return x + width; }
|
||||
// 获取当前运行态下边界坐标。
|
||||
int getBottom() const { return y + height; }
|
||||
|
||||
// 获取设计态父局部 X 坐标。
|
||||
int getLocalX() const { return localx; }
|
||||
// 获取设计态父局部 Y 坐标。
|
||||
int getLocalY() const { return localy; }
|
||||
// 获取设计态宽度。
|
||||
int getLocalWidth() const { return localWidth; }
|
||||
// 获取设计态高度。
|
||||
int getLocalHeight() const { return localHeight; }
|
||||
// 获取设计态父局部右边界。
|
||||
int getLocalRight() const { return localx + localWidth; }
|
||||
// 获取设计态父局部下边界。
|
||||
int getLocalBottom() const { return localy + localHeight; }
|
||||
|
||||
// 公开几何 setter:
|
||||
// - 面向用户使用
|
||||
// - 默认只修改运行态矩形
|
||||
// - 不允许自动回写设计基线 local*
|
||||
// 若某个复合控件需要在 setter 后维护自己拥有的运行态子树一致性,
|
||||
// 由该控件在 override 中自行处理,但仍不得越权承担统一布局器职责。
|
||||
// 修改运行态 X 坐标,不自动回写设计态 localx。
|
||||
virtual void setX(int x);
|
||||
// 修改运行态 Y 坐标,不自动回写设计态 localy。
|
||||
virtual void setY(int y);
|
||||
// 修改运行态宽度,不自动回写设计态 localWidth。
|
||||
virtual void setWidth(int width);
|
||||
// 修改运行态高度,不自动回写设计态 localHeight。
|
||||
virtual void setHeight(int height);
|
||||
public:
|
||||
|
||||
// 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。
|
||||
virtual void draw() = 0;
|
||||
// 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。
|
||||
// 阶段 1 兼容过渡:EX_MOUSE 永久保留在此入口;EX_KEY 暂时仍按旧路径下发以保持行为,
|
||||
// 计划在阶段 3 KeyboardRouter 一次性换轨后停止下发。派生控件不要再新增对 EX_KEY 的依赖。
|
||||
virtual bool handleEvent(const ExMessage& msg) = 0;
|
||||
// 轻量清理接口:仅用于“没有机会收到本次 WM_MOUSEMOVE”的兄弟分支,
|
||||
// 清掉 hover / tooltip / 临时按下态等鼠标瞬时状态。
|
||||
// 该接口不是事件分发入口,不参与新的命中判断,也不允许回写设计基线。
|
||||
virtual bool clearTransientMouseState();
|
||||
// 由 Window 驱动的单调时钟更新;动画、延时提示等不得依赖额外鼠标消息。
|
||||
virtual void tick(ULONGLONG) {}
|
||||
// 设置控件是否显示;隐藏时派生类可同步清理快照和子控件状态。
|
||||
virtual void setIsVisible(bool show);
|
||||
// 设置父容器指针;通常由容器接管子控件时调用。
|
||||
void setParent(Control* newParent) { parent = newParent; }
|
||||
//设置宿主窗口(通常仅由顶层 Window/对话框注入)
|
||||
virtual void setHostWindow(Window* host) { this->hostWindow = host; }
|
||||
Window* getHostWindow() const; // 获取宿主 Window;子控件可沿 parent 向上回溯
|
||||
RECT getBoundsRect() const; // 获取当前控件外接矩形,用于覆盖/相交判断
|
||||
// 获取本控件“本轮实际可能写到屏幕上的覆盖范围”。
|
||||
// 默认等于控件本体矩形;像 Button 这类会额外绘制 Tooltip 的控件,可 override 后扩大范围。
|
||||
// 托管重绘 coverage、overlay 相交判断统一走这个接口,而不再默认使用控件本体 bounds。
|
||||
virtual RECT getManagedRepaintCoverageRect() const;
|
||||
// 获取会影响后续控件背景快照的“持久绘制范围”。
|
||||
// Tooltip 等临时浮层不应进入该范围,避免上层兄弟补画时把临时浮层抓进背景快照。
|
||||
virtual RECT getManagedRepaintPersistentCoverageRect() const;
|
||||
// 找到本控件对应的托管重绘 root;通常是直接挂在 Window 下的控件或 Dialog。
|
||||
Control* getManagedRepaintRoot();
|
||||
// 找到“root 下面承接本次脏变化的直接子分支”,用于局部提交时缩小重绘范围。
|
||||
Control* getManagedRepaintDirectBranch(Control* root);
|
||||
bool hasValidBackgroundSnapshot() const { return hasSnap && saveBkImage != nullptr; } // 当前是否持有可用于局部恢复的快照
|
||||
// 当前控件或其受管理子树中是否存在待提交的脏内容;容器需要递归检查子树。
|
||||
virtual bool hasManagedDirtySubtree() const;
|
||||
// 当前 root 是否可安全做“局部提交”而非整 root 重画;复杂容器可返回 false 升级为整 root 重绘。
|
||||
virtual bool canCommitManagedPartialRepaint() const;
|
||||
// 托管收口阶段真正执行绘制的入口;只应由 Window 的 flushManagedRepaint 调用。
|
||||
virtual void commitManagedRepaint();
|
||||
// 设置控件脏标记;true 表示下一轮需要重新绘制。
|
||||
virtual void setDirty(bool isDirty) { dirty = isDirty; }
|
||||
// 检查控件当前是否可见。
|
||||
bool IsVisible() const { return show; };
|
||||
// 获取控件类型/调试标识。
|
||||
std::string getId() const { return id; }
|
||||
// 返回稳定句柄而非对象地址;调用方不得据此推断控件当前仍在容器中。
|
||||
StellarX::ControlHandle getHandle() const noexcept { return handle; }
|
||||
// 申请逻辑焦点。回调重入时请求会进入 FocusManager 当前回合的单槽 pending。
|
||||
StellarX::FocusChangeResult requestFocus(
|
||||
StellarX::FocusReason reason = StellarX::FocusReason::Programmatic);
|
||||
// 设置焦点入口策略;修改后若当前焦点不再有效,会立即进入焦点回退。
|
||||
void setFocusPolicy(StellarX::FocusPolicy policy);
|
||||
StellarX::FocusPolicy getFocusPolicy() const noexcept { return focusPolicy; }
|
||||
void setTabIndex(std::int32_t value);
|
||||
std::int32_t getTabIndex() const noexcept { return tabIndex; }
|
||||
bool hasLogicalFocus() const noexcept { return logicalFocused; }
|
||||
// 检查控件当前是否处于待重绘状态。
|
||||
bool isDirty() { return dirty; }
|
||||
//获取控件最近一次事件处理是否引发了视觉变化
|
||||
bool didEventAffectVisual() const { return eventVisualChanged; }
|
||||
// 判断控件是否为模态对话框;普通控件应返回 false。
|
||||
virtual bool model()const = 0;
|
||||
//布局
|
||||
// 设置旧版布局模式(兼容入口)
|
||||
void setLayoutMode(StellarX::LayoutMode layoutMode_);
|
||||
// 设置旧版双锚点输入,并映射到内部统一 LayoutSpec
|
||||
void setAnchor(StellarX::Anchor anchor_1, StellarX::Anchor anchor_2);
|
||||
// 直接设置水平轴布局规格。
|
||||
// 调用该接口后会自动切换到 AnchorToEdges 布局模式;
|
||||
// 这是新布局模型的公开入口,后设置者会覆盖旧 setAnchor() 对水平轴的映射结果。
|
||||
void setHorizontalLayoutSpec(const StellarX::AxisLayoutSpec& spec);
|
||||
// 直接设置垂直轴布局规格。
|
||||
// 调用该接口后会自动切换到 AnchorToEdges 布局模式;
|
||||
// 这是新布局模型的公开入口,后设置者会覆盖旧 setAnchor() 对垂直轴的映射结果。
|
||||
void setVerticalLayoutSpec(const StellarX::AxisLayoutSpec& spec);
|
||||
// 设置水平轴锚定边集合(left / right)。
|
||||
void setHorizontalAnchors(bool left, bool right);
|
||||
// 设置垂直轴锚定边集合(top / bottom)。
|
||||
void setVerticalAnchors(bool top, bool bottom);
|
||||
// 设置水平轴尺寸策略(Stretch / FixedSize)。
|
||||
void setHorizontalSizePolicy(StellarX::AxisSizePolicy policy);
|
||||
// 设置垂直轴尺寸策略(Stretch / FixedSize)。
|
||||
void setVerticalSizePolicy(StellarX::AxisSizePolicy policy);
|
||||
// 设置水平轴固定尺寸位移策略(Start / End / Center / Proportional)。
|
||||
void setHorizontalAlignPolicy(StellarX::AxisAlignPolicy policy);
|
||||
// 设置垂直轴固定尺寸位移策略(Start / End / Center / Proportional)。
|
||||
void setVerticalAlignPolicy(StellarX::AxisAlignPolicy policy);
|
||||
// 获取旧版锚点 1(兼容读取入口)
|
||||
StellarX::Anchor getAnchor_1() const;
|
||||
// 获取旧版锚点 2(兼容读取入口)
|
||||
StellarX::Anchor getAnchor_2() const;
|
||||
// 获取旧版布局模式
|
||||
StellarX::LayoutMode getLayoutMode() const;
|
||||
// 获取水平轴布局规格;返回的是当前生效的新模型状态,不要求可逆回旧 anchor 语义。
|
||||
StellarX::AxisLayoutSpec getHorizontalLayoutSpec() const;
|
||||
// 获取垂直轴布局规格;返回的是当前生效的新模型状态,不要求可逆回旧 anchor 语义。
|
||||
StellarX::AxisLayoutSpec getVerticalLayoutSpec() const;
|
||||
// 获取内部统一布局规格;供 Window / Canvas 等统一解算入口使用。
|
||||
const StellarX::LayoutSpec& getLayoutSpec() const;
|
||||
// 获取控件能力边界;用于判断某个轴是否允许 Stretch。
|
||||
const StellarX::LayoutCapability& getLayoutCapability() const;
|
||||
// 显式将当前运行态矩形提交为新的设计基线。
|
||||
// 这是“运行态 -> 设计态”的受控入口之一:
|
||||
// - 普通 resize / 重排过程中不得自动调用
|
||||
// - 公开 setter 也不得隐式调用
|
||||
// - 仅用于少数需要把当前结果固化为新设计基线的场景
|
||||
void commitCurrentGeometryAsDesignRect();
|
||||
protected:
|
||||
// 焦点参与协议:默认节点只解析 {本控件句柄, 0};复合控件递归转发给真实子 Control。
|
||||
virtual bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const;
|
||||
virtual void collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const;
|
||||
virtual bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason);
|
||||
// 各控件动态提供 enabled 语义;Button 会根据 DISABLED 模式覆盖。
|
||||
virtual bool isFocusEnabled() const { return true; }
|
||||
// 派生控件可响应焦点变化;回调允许安全地请求焦点或排队修改控件树。
|
||||
virtual void onLogicalFocusChanged(bool, StellarX::FocusReason) {}
|
||||
bool isEffectivelyVisible() const;
|
||||
void notifyFocusSubtreeStateChanged();
|
||||
// 容器和窗口在事件、绘制、tick 期间进入事务;具体容器在最外层结束时提交队列。
|
||||
MutationScope enterMutationScope() { return MutationScope(this); }
|
||||
// 进入子树结构变更事务。默认向父容器或宿主 Window 传播,使整棵树共享同一提交边界。
|
||||
virtual void beginMutationScope();
|
||||
// 离开子树结构变更事务。最外层结束后才允许容器提交 pending mutation。
|
||||
virtual void endMutationScope();
|
||||
// 提交子类容器自己的 pending mutation;普通叶子控件保持空实现。
|
||||
virtual void flushPendingMutations() {}
|
||||
// 第 1 层:根据父设计尺寸、父当前尺寸和本控件设计矩形,解算出当前运行态局部矩形。
|
||||
StellarX::ResolvedLayoutRect resolveLayoutRect(int parentDesignW, int parentDesignH,
|
||||
int parentWorldX, int parentWorldY, int parentCurrentW, int parentCurrentH) const;
|
||||
// 内部受控路径:把统一解算结果应用到运行态矩形。不要把公开 setter 当成纯赋值接口来复用。
|
||||
virtual void applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect);
|
||||
// 最底层运行态赋值入口:仅修改运行态矩形,不回写 local*。
|
||||
void applyRuntimeRectDirect(int worldX, int worldY, int width, int height);
|
||||
// 保存当前 EasyX 绘图状态,避免控件绘制污染全局状态。
|
||||
void saveStyle();
|
||||
// 恢复 saveStyle() 保存的 EasyX 绘图状态。
|
||||
void restoreStyle();
|
||||
// 清空本轮事件视觉变化标记。
|
||||
void resetEventVisualChanged() { eventVisualChanged = false; }
|
||||
// 标记本轮事件是否引发视觉变化。
|
||||
void markEventVisualChanged(bool changed = true) { eventVisualChanged = changed; }
|
||||
};
|
||||
@@ -11,6 +11,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "easyx.h"
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @命名空间: StellarX
|
||||
@@ -27,6 +29,146 @@
|
||||
*/
|
||||
namespace StellarX
|
||||
{
|
||||
/**
|
||||
* @结构体名称: ControlHandle
|
||||
* @功能描述: 表示控件在当前进程内的稳定身份句柄
|
||||
*
|
||||
* @详细说明:
|
||||
* 控件由 Window、Canvas、TabControl 等容器持有所有权,外部调用方不应长期保存
|
||||
* 控件对象地址。ControlHandle 用一个不暴露对象地址的整数值表示控件身份,
|
||||
* 用于后续 removeControl、findControl、getControlHandles 等安全查询和删除操作。
|
||||
*
|
||||
* 句柄值为 0 时表示无效句柄;非 0 值由控件构造时单调分配,只保证当前进程
|
||||
* 本次运行期间的身份稳定,不保证跨进程、跨运行或序列化后仍然有效。
|
||||
*
|
||||
* @成员说明:
|
||||
* std::uint64_t value = 0; - 句柄原始值,0 表示无效句柄。
|
||||
*
|
||||
* @使用示例:
|
||||
* StellarX::ControlHandle handle = wnd.addControl(std::move(control));
|
||||
* if (handle) wnd.removeControl(handle);
|
||||
*
|
||||
* @备注:
|
||||
* 控件被移除后,旧句柄仍可安全用于比较或查询,但 findControl 可能返回 nullptr;
|
||||
* 调用方不得把句柄视为对象生命周期仍然存在的证明。
|
||||
*/
|
||||
struct ControlHandle
|
||||
{
|
||||
std::uint64_t value = 0;
|
||||
|
||||
// 判断句柄是否有效;value 为 0 时返回 false。
|
||||
explicit operator bool() const noexcept { return value != 0; }
|
||||
// 比较两个句柄是否指向同一控件身份。
|
||||
bool operator==(const ControlHandle& other) const noexcept { return value == other.value; }
|
||||
// 比较两个句柄是否指向不同控件身份。
|
||||
bool operator!=(const ControlHandle& other) const noexcept { return value != other.value; }
|
||||
};
|
||||
|
||||
/**
|
||||
* @结构体名称: FocusTargetId
|
||||
* @功能描述: 表示一个逻辑焦点参与节点的稳定身份
|
||||
*
|
||||
* @详细说明:
|
||||
* owner 指向拥有该焦点节点的 Control。当前阶段所有焦点节点都是真实 Control,
|
||||
* 因此 subNode 固定为 0;非 0 值预留给未来没有独立 Control 对象的轻量节点。
|
||||
* ControlHandle 在进程内单调分配且不复用,因此本结构不额外复制 generation。
|
||||
*/
|
||||
struct FocusTargetId
|
||||
{
|
||||
ControlHandle owner{};
|
||||
std::uint32_t subNode = 0;
|
||||
|
||||
explicit operator bool() const noexcept { return static_cast<bool>(owner); }
|
||||
bool operator==(const FocusTargetId& other) const noexcept
|
||||
{
|
||||
return owner == other.owner && subNode == other.subNode;
|
||||
}
|
||||
bool operator!=(const FocusTargetId& other) const noexcept { return !(*this == other); }
|
||||
};
|
||||
|
||||
// 标识一个 Window 内的逻辑焦点作用域。0 表示无效,根作用域由 FocusManager 创建。
|
||||
struct FocusScopeId
|
||||
{
|
||||
std::uint64_t value = 0;
|
||||
|
||||
explicit operator bool() const noexcept { return value != 0; }
|
||||
bool operator==(const FocusScopeId& other) const noexcept { return value == other.value; }
|
||||
bool operator!=(const FocusScopeId& other) const noexcept { return value != other.value; }
|
||||
};
|
||||
|
||||
// 控件声明自己允许通过哪些入口获得逻辑焦点。
|
||||
enum class FocusPolicy : std::uint8_t
|
||||
{
|
||||
None = 0,
|
||||
Programmatic = 1u << 0,
|
||||
Pointer = 1u << 1,
|
||||
Tab = 1u << 2
|
||||
};
|
||||
|
||||
constexpr FocusPolicy operator|(FocusPolicy left, FocusPolicy right) noexcept
|
||||
{
|
||||
return static_cast<FocusPolicy>(static_cast<std::uint8_t>(left) |
|
||||
static_cast<std::uint8_t>(right));
|
||||
}
|
||||
|
||||
constexpr FocusPolicy operator&(FocusPolicy left, FocusPolicy right) noexcept
|
||||
{
|
||||
return static_cast<FocusPolicy>(static_cast<std::uint8_t>(left) &
|
||||
static_cast<std::uint8_t>(right));
|
||||
}
|
||||
|
||||
constexpr bool hasFocusPolicy(FocusPolicy value, FocusPolicy flag) noexcept
|
||||
{
|
||||
return (value & flag) != FocusPolicy::None;
|
||||
}
|
||||
|
||||
enum class FocusReason : std::uint8_t
|
||||
{
|
||||
Programmatic,
|
||||
Pointer,
|
||||
TabTraversal,
|
||||
ScopeActivation,
|
||||
ScopeRestore,
|
||||
InvalidationFallback
|
||||
};
|
||||
|
||||
// FocusManager 的可断言结果;ReentrancyLimit 是独立错误,不依赖日志判断。
|
||||
enum class FocusError : std::uint8_t
|
||||
{
|
||||
None,
|
||||
InvalidTarget,
|
||||
NotFocusable,
|
||||
NotVisible,
|
||||
Disabled,
|
||||
OutsideActiveScope,
|
||||
InvalidScope,
|
||||
ReentrancyLimit
|
||||
};
|
||||
|
||||
struct FocusChangeResult
|
||||
{
|
||||
bool changed = false;
|
||||
FocusError error = FocusError::None;
|
||||
FocusTargetId focused{};
|
||||
};
|
||||
|
||||
/**
|
||||
* 焦点参与协议的瞬时快照。
|
||||
* 所有布尔值只对采集时刻有效;任何用户回调结束后都必须重新解析并拉取新快照。
|
||||
*/
|
||||
struct FocusTargetSnapshot
|
||||
{
|
||||
FocusTargetId id{};
|
||||
FocusTargetId logicalParent{};
|
||||
FocusScopeId scope{};
|
||||
FocusPolicy policy = FocusPolicy::None;
|
||||
bool visible = false;
|
||||
bool enabled = false;
|
||||
bool focusable = false;
|
||||
bool tabStop = false;
|
||||
std::int32_t tabIndex = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @枚举类名称: FillStyle
|
||||
* @功能描述: 用来定义控件填充图案的枚举类
|
||||
@@ -131,7 +273,7 @@ namespace StellarX
|
||||
* @成员说明:
|
||||
* int nHeight = 0; - 字体高度
|
||||
* int nWidth = 0; - 字体宽度 如果为0则自适应
|
||||
* LPCTSTR lpszFace = "宋体"; - 字体名称
|
||||
* std::string faceName = "微软雅黑"; - 字体名称
|
||||
* COLORREF color = RGB(0, 0, 0); - 字体颜色
|
||||
* int nEscapement = 0; - 字符串旋转角度
|
||||
* int nOrientation = 0; - 字符旋转角度
|
||||
@@ -144,7 +286,8 @@ namespace StellarX
|
||||
{
|
||||
int nHeight = 0; //- 字体高度
|
||||
int nWidth = 0; //- 字体宽度 如果为0则自适应
|
||||
LPCTSTR lpszFace = "微软雅黑"; //- 字体名称
|
||||
// 由对象拥有字体名,避免从临时字符串或外部缓冲区保存悬空指针。
|
||||
std::string faceName = "微软雅黑"; //- 字体名称
|
||||
COLORREF color = RGB(0, 0, 0); //- 字体颜色
|
||||
int nEscapement = 0; //- 字符串旋转角度
|
||||
int nOrientation = 0; //- 字符旋转角度
|
||||
@@ -153,8 +296,10 @@ namespace StellarX
|
||||
bool bUnderline = false; //- 是否下划线
|
||||
bool bStrikeOut = false; //- 是否删除线
|
||||
|
||||
bool operator!=(const ControlText& text);
|
||||
ControlText& operator=(const ControlText& text);
|
||||
// 比较两份字体样式是否完全一致。
|
||||
bool operator==(const ControlText& text) const;
|
||||
// 比较两份字体样式是否存在任意差异。
|
||||
bool operator!=(const ControlText& text) const { return !(*this == text); }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -195,7 +340,7 @@ namespace StellarX
|
||||
|
||||
/**
|
||||
* @枚举类名称: TextBoxmode
|
||||
* @功能描述: 定义了文本框的两种模式
|
||||
* @功能描述: 定义了文本框的三种模式
|
||||
*
|
||||
* @详细说明:
|
||||
* 需要限制文本框是否接受用户输入时使用
|
||||
@@ -203,11 +348,13 @@ namespace StellarX
|
||||
* @取值说明:
|
||||
* INPUT_MODE, // 用户可输入模式
|
||||
* READONLY_MODE // 只读模式
|
||||
* PASSWORD_MODE // 密码模式
|
||||
*/
|
||||
enum class TextBoxmode
|
||||
{
|
||||
INPUT_MODE, // 用户可输入模式
|
||||
READONLY_MODE // 只读模式
|
||||
READONLY_MODE, // 只读模式
|
||||
PASSWORD_MODE // 密码模式
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -250,7 +397,25 @@ namespace StellarX
|
||||
int ROUND_RECTANGLEheight = 20; //构成圆角矩形的圆角的椭圆的高度。
|
||||
};
|
||||
|
||||
// 消息框类型
|
||||
/**
|
||||
* @枚举名称: MessageBoxType
|
||||
* @功能描述: 定义标准消息框按钮组合
|
||||
*
|
||||
* @详细说明:
|
||||
* MessageBoxType 决定 Dialog / MessageBox 工厂创建哪些功能按钮,
|
||||
* 例如只有确定按钮、确定取消按钮、是否按钮等。
|
||||
*
|
||||
* @取值说明:
|
||||
* OK - 只有确定按钮
|
||||
* OKCancel - 确定和取消按钮
|
||||
* YesNo - 是和否按钮
|
||||
* YesNoCancel - 是、否和取消按钮
|
||||
* RetryCancel - 重试和取消按钮
|
||||
* AbortRetryIgnore - 中止、重试和忽略按钮
|
||||
*
|
||||
* @使用示例:
|
||||
* StellarX::MessageBoxType type = StellarX::MessageBoxType::YesNo;
|
||||
*/
|
||||
enum class MessageBoxType
|
||||
{
|
||||
OK, // 只有确定按钮
|
||||
@@ -261,7 +426,27 @@ namespace StellarX
|
||||
AbortRetryIgnore, // 中止、重试和忽略按钮
|
||||
};
|
||||
|
||||
// 消息框返回值
|
||||
/**
|
||||
* @枚举名称: MessageBoxResult
|
||||
* @功能描述: 定义消息框关闭后的用户选择结果
|
||||
*
|
||||
* @详细说明:
|
||||
* 模态消息框通过 showModal 返回该枚举;非模态消息框通过 showAsync 的回调返回。
|
||||
* 当前取值与 Win32 常见 MessageBox 返回码保持一致,便于迁移和日志排查。
|
||||
*
|
||||
* @取值说明:
|
||||
* OK - 用户点击确定
|
||||
* Cancel - 用户点击取消或关闭对话框
|
||||
* Yes - 用户点击是
|
||||
* No - 用户点击否
|
||||
* Abort - 用户点击中止
|
||||
* Retry - 用户点击重试
|
||||
* Ignore - 用户点击忽略
|
||||
*
|
||||
* @使用示例:
|
||||
* StellarX::MessageBoxResult result =
|
||||
* StellarX::MessageBox::showModal(wnd, "继续执行?", "确认", StellarX::MessageBoxType::YesNo);
|
||||
*/
|
||||
enum class MessageBoxResult
|
||||
{
|
||||
OK = 1, // 确定按钮
|
||||
@@ -272,4 +457,248 @@ namespace StellarX
|
||||
Retry = 4, // 重试按钮
|
||||
Ignore = 5 // 忽略按钮
|
||||
};
|
||||
#if 0 //布局管理器相关 —待实现—
|
||||
/*
|
||||
*
|
||||
*@枚举名称: LayoutKind
|
||||
* @功能描述 : 定义布局管理类型
|
||||
*
|
||||
*@详细说明 :
|
||||
* 根据布局管理类型,控件可以有不同的布局方式。
|
||||
* 用户可以在具体情况下设置布局管理类型。
|
||||
*
|
||||
* @取值说明 :
|
||||
* Absolute:不管,保持子控件自己的坐标(向后兼容)。
|
||||
* HBox: 水平方向排队;支持固定宽、权重分配、对齐、拉伸。
|
||||
* VBox: 竖直方向排队;同上。
|
||||
* Grid(网格):按行列摆;支持固定/自适应/权重行列;支持跨行/跨列;单元内对齐/拉伸。
|
||||
*
|
||||
*/
|
||||
// 布局类型
|
||||
enum class LayoutKind
|
||||
{
|
||||
Absolute = 1,
|
||||
HBox,
|
||||
VBox,
|
||||
Grid,
|
||||
Flow,
|
||||
Stack
|
||||
};
|
||||
|
||||
// 布局参数
|
||||
struct LayoutParams
|
||||
{
|
||||
// 边距左、右、上、下
|
||||
int marginL = 0, marginR = 0, marginT = 0, marginB = 0;
|
||||
// 固定尺寸(>=0 强制;-1 用控件当前尺寸)
|
||||
int fixedW = -1, fixedH = -1;
|
||||
// 主轴权重(HBox=宽度、VBox=高度、Grid见下)
|
||||
float weight = 0.f;
|
||||
// 对齐(非拉伸时生效)
|
||||
enum Align { Start = 0, Center = 1, End = 2, Stretch = 3 };
|
||||
int alignX = Start; // HBox: 次轴=Y;VBox: 次轴=X;Grid: 单元内
|
||||
int alignY = Start; // Grid :控制单元内垂直(HBox / VBox通常只用 alignX)
|
||||
// Grid 专用(可先不做)
|
||||
int gridRow = 0, gridCol = 0, rowSpan = 1, colSpan = 1;
|
||||
// Flow 专用(可先不做)
|
||||
int flowBreak = 0; // 1=强制换行
|
||||
};
|
||||
#endif
|
||||
|
||||
/*
|
||||
* @枚举名称: TabPlacement
|
||||
* @功能描述: 定义了选项卡页签的不同位置
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据选项卡页签的位置,选项卡页签可以有不同的布局方式。
|
||||
*
|
||||
* @成员说明:
|
||||
* Top, - 选项卡页签位于顶部
|
||||
* Bottom, - 选项卡页签位于底部
|
||||
* Left, - 选项卡页签位于左侧
|
||||
* Right - 选项卡页签位于右侧
|
||||
*
|
||||
* @使用示例:
|
||||
* TabPlacement placement = TabPlacement::Top;
|
||||
*/
|
||||
enum class TabPlacement
|
||||
{
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right
|
||||
};
|
||||
/*
|
||||
* @枚举名称: LayoutMode
|
||||
* @功能描述: 定义了两种布局模式
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据不同模式,在窗口拉伸时采用不同的布局策略
|
||||
*
|
||||
* @成员说明:
|
||||
* Fixed, - 固定布局
|
||||
* AnchorToEdges - 锚定布局
|
||||
*
|
||||
* @使用示例:
|
||||
* LayoutMode mode = LayoutMode::Fixed;
|
||||
*/
|
||||
enum class LayoutMode
|
||||
{
|
||||
Fixed,
|
||||
AnchorToEdges
|
||||
};
|
||||
/*
|
||||
* @枚举名称: Anchor
|
||||
* @功能描述: 定义了控件相对于窗口锚定的位置
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据不同的锚定位置,有不同的拉伸策略
|
||||
*
|
||||
* @成员说明:
|
||||
* Top, - 锚定上边,控件上边与窗口上侧距离保持不变
|
||||
* Bottom, - 锚定底边,控件底边与窗口底边距离保持不变
|
||||
* Left, - 锚定左边,控件左边与窗口左侧距离保持不变
|
||||
* Right - 锚定右边,控件上边与窗口右侧距离保持不变
|
||||
*
|
||||
* @使用示例:
|
||||
* Anchor a = Anchor::Top;
|
||||
*/
|
||||
enum class Anchor
|
||||
{
|
||||
NoAnchor = 0,
|
||||
Left = 1,
|
||||
Right,
|
||||
Top,
|
||||
Bottom
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
* @枚举: AxisSizePolicy
|
||||
* @摘要: 单轴尺寸策略,决定该轴是否允许因父容器变化而拉伸
|
||||
* @详细说明:
|
||||
* 该枚举只决定“尺寸是否变化”,不决定“位置如何求解”。
|
||||
* 若需要在固定尺寸下决定控件如何位移,应结合 AxisAlignPolicy 一起使用。
|
||||
*
|
||||
* @成员说明:
|
||||
* Stretch - 允许尺寸随父容器变化而变化
|
||||
* FixedSize - 尺寸保持设计态大小,仅位置按锚点/位移策略变化
|
||||
******************************************************************************/
|
||||
enum class AxisSizePolicy
|
||||
{
|
||||
Stretch,
|
||||
FixedSize
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
* @枚举: AxisAlignPolicy
|
||||
* @摘要: 单轴固定尺寸时的位置策略
|
||||
* @详细说明:
|
||||
* 该策略只在“该轴不拉伸”时生效,用于决定控件在父容器变化后如何重新定位。
|
||||
* 其中 Proportional 只用于位置求解,不参与尺寸缩放。
|
||||
*
|
||||
* @成员说明:
|
||||
* Start - 保持起边距离
|
||||
* End - 保持终边距离
|
||||
* Center - 保持相对父容器中心的偏移关系
|
||||
* Proportional - 保持设计态中的相对位置比例
|
||||
******************************************************************************/
|
||||
enum class AxisAlignPolicy
|
||||
{
|
||||
Start,
|
||||
End,
|
||||
Center,
|
||||
Proportional
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
* @结构体名称: AxisLayoutSpec
|
||||
* @摘要: 描述单个坐标轴上的锚点与解算策略
|
||||
* @详细说明:
|
||||
* 一个控件完整布局由“水平轴 + 垂直轴”两份 AxisLayoutSpec 组成。
|
||||
* anchorStart / anchorEnd 只表达当前轴是否锚定起止边;
|
||||
* sizePolicy 决定该轴是否允许拉伸;
|
||||
* alignPolicy 则用于固定尺寸时的位置求解。
|
||||
*
|
||||
* @成员说明:
|
||||
* bool anchorStart - 是否锚定当前轴起边(水平轴为左边,垂直轴为上边)。
|
||||
* bool anchorEnd - 是否锚定当前轴终边(水平轴为右边,垂直轴为下边)。
|
||||
* AxisSizePolicy sizePolicy - 当前轴尺寸策略,决定是否随父容器变化而拉伸。
|
||||
* AxisAlignPolicy alignPolicy - 固定尺寸时的位置策略。
|
||||
*
|
||||
* @备注:
|
||||
* anchorStart / anchorEnd 不直接决定尺寸,尺寸是否变化以 sizePolicy 为准。
|
||||
******************************************************************************/
|
||||
struct AxisLayoutSpec
|
||||
{
|
||||
bool anchorStart = false;
|
||||
bool anchorEnd = false;
|
||||
AxisSizePolicy sizePolicy = AxisSizePolicy::FixedSize;
|
||||
AxisAlignPolicy alignPolicy = AxisAlignPolicy::Start;
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
* @结构体名称: LayoutSpec
|
||||
* @摘要: 控件完整布局规格,由水平轴和垂直轴两部分组成
|
||||
* @详细说明:
|
||||
* LayoutSpec 是控件对统一布局器的完整声明。水平轴和垂直轴分别独立描述
|
||||
* 锚点、尺寸策略和固定尺寸时的位置策略,Window / Canvas 在 resize 时据此
|
||||
* 解算运行态矩形。
|
||||
*
|
||||
* @成员说明:
|
||||
* AxisLayoutSpec horizontal - 水平轴布局规格。
|
||||
* AxisLayoutSpec vertical - 垂直轴布局规格。
|
||||
******************************************************************************/
|
||||
struct LayoutSpec
|
||||
{
|
||||
AxisLayoutSpec horizontal{};
|
||||
AxisLayoutSpec vertical{};
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
* @结构体名称: LayoutCapability
|
||||
* @摘要: 控件能力边界,声明控件在哪些轴上允许 Stretch
|
||||
* @详细说明:
|
||||
* 规则表负责定义“某种组合如何解算”,LayoutCapability 负责声明
|
||||
* “该控件是否允许采用这种组合”。例如 Table 当前阶段仅允许 X 轴拉伸,
|
||||
* 因此 allowStretchY 会被显式关闭。
|
||||
*
|
||||
* @成员说明:
|
||||
* bool allowStretchX - 是否允许该控件在水平轴使用 Stretch 尺寸策略。
|
||||
* bool allowStretchY - 是否允许该控件在垂直轴使用 Stretch 尺寸策略。
|
||||
*
|
||||
* @备注:
|
||||
* 能力边界用于保护控件自身实现约束;即使调用方设置了 Stretch,
|
||||
* 不被允许的轴也应被布局层拒绝或降级处理。
|
||||
******************************************************************************/
|
||||
struct LayoutCapability
|
||||
{
|
||||
bool allowStretchX = true;
|
||||
bool allowStretchY = true;
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
* @结构体名称: ResolvedLayoutRect
|
||||
* @摘要: 统一布局解算后的运行态矩形结果
|
||||
* @详细说明:
|
||||
* localX/localY 表示解算完成后的“父局部坐标”;
|
||||
* worldX/worldY 表示映射到窗口世界坐标后的最终绘制位置;
|
||||
* width/height 为运行态尺寸。
|
||||
*
|
||||
* @成员说明:
|
||||
* int localX - 解算后的父局部 X 坐标。
|
||||
* int localY - 解算后的父局部 Y 坐标。
|
||||
* int width - 解算后的运行态宽度。
|
||||
* int height - 解算后的运行态高度。
|
||||
* int worldX - 映射到窗口坐标系后的 X 坐标。
|
||||
* int worldY - 映射到窗口坐标系后的 Y 坐标。
|
||||
******************************************************************************/
|
||||
struct ResolvedLayoutRect
|
||||
{
|
||||
int localX = 0;
|
||||
int localY = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int worldX = 0;
|
||||
int worldY = 0;
|
||||
};
|
||||
}
|
||||
+49
-47
@@ -4,6 +4,7 @@
|
||||
* @描述:
|
||||
* 实现完整的对话框功能,支持多种按钮组合和异步结果回调。
|
||||
* 自动处理布局、背景保存恢复和生命周期管理。
|
||||
* 在窗口托管重绘模式下,Dialog 自身也是一个独立的重绘 root。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持六种标准消息框类型(OK、YesNo、YesNoCancel等)
|
||||
@@ -20,7 +21,7 @@
|
||||
#pragma once
|
||||
#include"StellarX.h"
|
||||
|
||||
#define closeButtonWidth 20 //关闭按钮宽度
|
||||
#define closeButtonWidth 30 //关闭按钮宽度
|
||||
#define closeButtonHeight 20 //关闭按钮高度 同时作为对话框标题栏高度
|
||||
#define functionButtonWidth 70 //按钮宽度
|
||||
#define functionButtonHeight 30 //按钮高度
|
||||
@@ -29,32 +30,25 @@
|
||||
#define buttonAreaHeight 50 //按钮区域高度
|
||||
#define titleToTextMargin 10 //标题到文本的距离
|
||||
#define textToBorderMargin 10 //文本到边框的距离
|
||||
|
||||
#define BorderWidth 3 //边框宽度
|
||||
class Dialog : public Canvas
|
||||
{
|
||||
friend class Window;
|
||||
|
||||
Window& hWnd; //窗口引用
|
||||
|
||||
int textWidth = 0; //文本宽度
|
||||
int textHeight = 0; //文本高度
|
||||
int buttonNum = 0; // 按钮数量
|
||||
int BorderWidth = 2; //边框宽度
|
||||
|
||||
IMAGE* saveBkImage = nullptr; // 用于保存背景图像
|
||||
int saveBkX = 0, saveBkY = 0; // 保存背景的位置
|
||||
int saveBkWidth = 0, saveBkHeight = 0; // 保存背景的尺寸
|
||||
|
||||
StellarX::MessageBoxType type = StellarX::MessageBoxType::OK; //对话框类型
|
||||
std::string titleText = "提示"; //标题文本
|
||||
std::unique_ptr<Label> title = nullptr; //标题标签
|
||||
|
||||
std::string message; //提示信息
|
||||
std::vector<std::string> lines; //消息内容按行分割
|
||||
|
||||
|
||||
bool needsInitialization = true; //是否需要初始化
|
||||
bool close = false; //是否关闭
|
||||
bool needsInitialization = true; // 是否需要根据当前内容重新初始化布局和内部按钮
|
||||
bool modal = true; //是否模态
|
||||
bool isVisible = false; //是否可见
|
||||
|
||||
COLORREF backgroundColor = RGB(240, 240, 240); //背景颜色
|
||||
COLORREF borderColor = RGB(100, 100, 100); //边框颜色
|
||||
@@ -63,77 +57,85 @@ class Dialog : public Canvas
|
||||
COLORREF buttonFalseColor = RGB(215, 215, 215); //按钮未被点击颜色
|
||||
COLORREF buttonHoverColor = RGB(224, 224, 224); //按钮悬浮颜色
|
||||
|
||||
|
||||
Button* closeButton = nullptr; //关闭按钮
|
||||
|
||||
StellarX::MessageBoxResult result = StellarX::MessageBoxResult::Cancel; // 对话框结果
|
||||
|
||||
public:
|
||||
bool shouldClose = false; //是否应该关闭
|
||||
bool isCleaning = false; //是否正在清理
|
||||
bool pendingCleanup = false; //延迟清理
|
||||
public:
|
||||
StellarX::ControlText textStyle; // 字体样式
|
||||
// 清理方法声明
|
||||
// 在事件安全点执行延迟清理;用于非模态关闭后统一回收。
|
||||
// Close() 只隐藏并标记待清理,真正释放内部按钮和快照要等到这里执行。
|
||||
void performDelayedCleanup();
|
||||
//工厂模式下调用非模态对话框时用来返回结果
|
||||
// 非模态工厂调用时用于回传结果;回调可能修改宿主控件树,因此 Close() 会在 mutation scope 内触发它。
|
||||
std::function<void(StellarX::MessageBoxResult)> resultCallback;
|
||||
//设置非模态获取结果的回调函数
|
||||
// 设置非模态结果回调;仅在非模态 Dialog 关闭时异步调用。
|
||||
void SetResultCallback(std::function<void(StellarX::MessageBoxResult)> cb);
|
||||
//获取对话框消息,用以去重
|
||||
// 获取标题文本,用于非模态去重
|
||||
std::string GetCaption() const;
|
||||
|
||||
// 获取正文文本,用于非模态去重
|
||||
std::string GetText() const;
|
||||
|
||||
public:
|
||||
// 构造对话框;modal=false 时按非模态浮层工作,由 Window 接管生命周期和层级。
|
||||
Dialog(Window& hWnd, std::string text, std::string message = "对话框", StellarX::MessageBoxType type = StellarX::MessageBoxType::OK, bool modal = true);
|
||||
// 析构对话框并释放内部控件资源。
|
||||
~Dialog();
|
||||
//绘制对话框
|
||||
// 绘制对话框背景、标题、正文和功能按钮。
|
||||
void draw() override;
|
||||
//事件处理
|
||||
// 处理关闭按钮和功能按钮事件。
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
// 设置标题
|
||||
// 设置标题并标记布局需要重建。
|
||||
void SetTitle(const std::string& title);
|
||||
// 设置消息内容
|
||||
// 设置消息内容并标记文本换行和布局需要重建。
|
||||
void SetMessage(const std::string& message);
|
||||
// 设置对话框类型
|
||||
// 设置对话框类型并重建功能按钮组合。
|
||||
void SetType(StellarX::MessageBoxType type);
|
||||
// 设置模态属性
|
||||
// 设置模态属性,决定 Show() 使用阻塞循环还是交给 Window 主循环。
|
||||
void SetModal(bool modal);
|
||||
// 设置对话框结果
|
||||
// 设置对话框结果,通常由功能按钮回调写入。
|
||||
void SetResult(StellarX::MessageBoxResult result);
|
||||
|
||||
// 获取对话框结果
|
||||
StellarX::MessageBoxResult GetResult() const;
|
||||
// 获取模态属性
|
||||
bool getModal() const;
|
||||
// 检查是否可见
|
||||
bool IsVisible() const override;
|
||||
//获取对话框类型
|
||||
|
||||
// 返回当前对话框是否为模态;Window 用它区分阻塞式对话框和托管浮层。
|
||||
bool model() const override;
|
||||
|
||||
// 显示对话框
|
||||
// 显示对话框。模态对话框会进入自己的消息循环;非模态对话框只标记可见,由 Window 主循环驱动。
|
||||
void Show();
|
||||
// 关闭对话框
|
||||
// 关闭对话框。
|
||||
// 该调用只设置隐藏、结果和待清理标志,并请求整场景重绘;对象释放延迟到 performDelayedCleanup()。
|
||||
void Close();
|
||||
// 宿主窗口变化时仅重新居中,不拉伸 Dialog 自身
|
||||
void recenterInHostWindow();
|
||||
|
||||
private:
|
||||
// 初始化按钮
|
||||
// 根据当前 MessageBoxType 初始化功能按钮。
|
||||
void initButtons();
|
||||
// 初始化关闭按钮
|
||||
// 初始化标题栏关闭按钮。
|
||||
void initCloseButton();
|
||||
// 初始化标题
|
||||
void initTitle();
|
||||
// 按行分割消息内容
|
||||
// 按当前消息内容和宽度约束分割显示行。
|
||||
void splitMessageLines();
|
||||
// 获取文本大小
|
||||
// 计算当前标题和正文所需的文本尺寸。
|
||||
void getTextSize();
|
||||
//初始化对话框尺寸
|
||||
// 标记需要重新布局并按需清空旧控件
|
||||
void invalidateLayout(bool clearChildren);
|
||||
// 根据文本和按钮数量初始化对话框尺寸。
|
||||
void initDialogSize();
|
||||
// 初始化对话框
|
||||
void initializeDialog();
|
||||
// 依据当前 Dialog 的 x/y/width/height 重新创建标题和按钮
|
||||
void rebuildChrome();
|
||||
// 禁止走普通 Canvas 添加路径;Dialog 内部控件由自身 chrome 重建逻辑统一维护
|
||||
void addControl(std::unique_ptr<Control> control);
|
||||
// 判断当前 Dialog 是否可安全做局部提交。
|
||||
bool canCommitManagedPartialRepaint() const override;
|
||||
// 托管收口阶段执行 Dialog 的真正重绘。
|
||||
void commitManagedRepaint() override;
|
||||
|
||||
|
||||
// 清除所有控件
|
||||
// 清除 Dialog 内部所有 chrome 控件。
|
||||
void clearControls();
|
||||
//创建对话框按钮
|
||||
// 创建标准对话框功能按钮。
|
||||
std::unique_ptr<Button> createDialogButton(int x, int y, const std::string& text);
|
||||
// 请求宿主窗口重绘 Dialog root;非托管模式下立即更新内部按钮。
|
||||
void requestRepaint(Control* parent) override;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/*******************************************************************************
|
||||
* FocusManager.h - per-Window logical focus manager.
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
|
||||
#include "CoreTypes.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
class Window;
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
class FocusManager
|
||||
{
|
||||
struct FocusRequest
|
||||
{
|
||||
FocusTargetId target{};
|
||||
FocusReason reason = FocusReason::Programmatic;
|
||||
};
|
||||
|
||||
struct ScopeEntry
|
||||
{
|
||||
FocusScopeId id{};
|
||||
FocusTargetId root{};
|
||||
FocusTargetId initialFocus{};
|
||||
FocusTargetId restoreFocus{};
|
||||
};
|
||||
|
||||
::Window* window = nullptr;
|
||||
FocusTargetId activeTarget{};
|
||||
// Stable identity of the target whose focus callback is currently running.
|
||||
// pushScope() uses it because activeTarget is intentionally empty during FocusLost.
|
||||
FocusTargetId callbackFocusContext{};
|
||||
bool transitionActive = false;
|
||||
bool pendingPresent = false;
|
||||
FocusRequest pending{};
|
||||
FocusError lastError = FocusError::None;
|
||||
std::vector<ScopeEntry> scopes;
|
||||
std::vector<ControlHandle> invalidatedSubtrees;
|
||||
std::uint64_t nextScopeValue = 2;
|
||||
|
||||
static constexpr std::size_t MaxReentrantRounds = 64;
|
||||
|
||||
bool querySnapshot(FocusTargetId target, FocusTargetSnapshot& snapshot) const;
|
||||
bool isUnderInvalidatedSubtree(FocusTargetId target) const;
|
||||
bool isWithinSubtree(FocusTargetId target, FocusTargetId root) const;
|
||||
bool isWithinActiveScope(FocusTargetId target) const;
|
||||
FocusError validateTarget(FocusTargetId target, FocusReason reason,
|
||||
FocusTargetSnapshot* snapshot = nullptr) const;
|
||||
FocusError validateCurrentTarget(FocusTargetId target,
|
||||
FocusTargetSnapshot* snapshot = nullptr) const;
|
||||
FocusPolicy requiredPolicy(FocusReason reason) const noexcept;
|
||||
FocusChangeResult performTransition(FocusTargetId target, FocusReason reason);
|
||||
FocusTargetId findInvalidationFallback(FocusTargetId invalidTarget) const;
|
||||
std::vector<FocusTargetSnapshot> collectOrderedTargets(bool tabStopsOnly) const;
|
||||
void queueReentrantRequest(FocusTargetId target, FocusReason reason) noexcept;
|
||||
void normalizeInvalidScopes();
|
||||
|
||||
public:
|
||||
explicit FocusManager(::Window& owner);
|
||||
~FocusManager() = default;
|
||||
|
||||
FocusManager(const FocusManager&) = delete;
|
||||
FocusManager& operator=(const FocusManager&) = delete;
|
||||
|
||||
// Normal calls switch immediately. Reentrant calls replace the per-round pending slot.
|
||||
FocusChangeResult requestFocus(FocusTargetId target,
|
||||
FocusReason reason = FocusReason::Programmatic);
|
||||
FocusChangeResult clearFocus(FocusReason reason = FocusReason::Programmatic)
|
||||
{
|
||||
return requestFocus({}, reason);
|
||||
}
|
||||
|
||||
FocusTargetId focusedTarget() const noexcept { return activeTarget; }
|
||||
bool hasFocus(FocusTargetId target) const noexcept
|
||||
{
|
||||
return static_cast<bool>(target) && activeTarget == target;
|
||||
}
|
||||
FocusError getLastError() const noexcept { return lastError; }
|
||||
FocusScopeId rootScope() const noexcept;
|
||||
FocusScopeId activeScope() const noexcept;
|
||||
|
||||
// Valid Tab stops in the active scope, ordered by tabIndex and stable tree order.
|
||||
std::vector<FocusTargetSnapshot> collectTabStops() const;
|
||||
|
||||
// Stage 2 provides scope mechanics; real Dialog integration remains in stage 4.
|
||||
FocusScopeId pushScope(FocusTargetId scopeRoot, FocusTargetId initialFocus = {});
|
||||
bool popScope(FocusScopeId scope);
|
||||
|
||||
// Called when removal is queued, before physical destruction at mutation flush.
|
||||
void notifySubtreePendingRemoval(ControlHandle rootHandle);
|
||||
void notifySubtreesPendingRemoval(const std::vector<ControlHandle>& rootHandles);
|
||||
// Revalidates active focus after visibility, enabled-state, or policy changes.
|
||||
void notifySubtreeStateChanged(ControlHandle rootHandle);
|
||||
// Clears invalidation markers after the outer mutation flush.
|
||||
void onMutationCommitted();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*******************************************************************************
|
||||
* StellarX native keyboard, text, IME, and activation event model.
|
||||
* Stage 1 only captures, normalizes, queues, and observes these events.
|
||||
* Focus, routing, and control behavior are intentionally out of scope.
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
enum class KeyAction : std::uint8_t
|
||||
{
|
||||
Down,
|
||||
Up
|
||||
};
|
||||
|
||||
// Snapshot modifiers in WndProc; delayed consumers must not reread global state.
|
||||
struct KeyModifiers
|
||||
{
|
||||
bool leftControl = false;
|
||||
bool rightControl = false;
|
||||
bool leftShift = false;
|
||||
bool rightShift = false;
|
||||
bool leftAlt = false;
|
||||
bool rightAlt = false;
|
||||
|
||||
bool control() const noexcept { return leftControl || rightControl; }
|
||||
bool shift() const noexcept { return leftShift || rightShift; }
|
||||
bool alt() const noexcept { return leftAlt || rightAlt; }
|
||||
};
|
||||
|
||||
struct KeyEvent
|
||||
{
|
||||
KeyAction action = KeyAction::Down;
|
||||
|
||||
UINT virtualKey = 0; // Raw WndProc VK; VK_PROCESSKEY is never rewritten.
|
||||
UINT physicalVirtualKey = 0; // Left/right-normalized modifier VK.
|
||||
UINT scanCode = 0;
|
||||
|
||||
std::uint16_t repeatCount = 0;
|
||||
bool extended = false;
|
||||
bool wasDown = false;
|
||||
bool altContext = false; // Raw lParam bit 29; modifiers.alt() is the semantic snapshot.
|
||||
bool systemKey = false;
|
||||
|
||||
// Snapshot of the standard IME lifecycle axis at capture time.
|
||||
// It never overrides the per-key ownership expressed by VK_PROCESSKEY.
|
||||
bool imeComposingAtCapture = false;
|
||||
|
||||
KeyModifiers modifiers{};
|
||||
|
||||
bool isAutoRepeat() const noexcept
|
||||
{
|
||||
return action == KeyAction::Down && wasDown;
|
||||
}
|
||||
|
||||
bool isImeOwnedAtCapture() const noexcept
|
||||
{
|
||||
// Per-key ownership and IME lifecycle are intentionally independent axes.
|
||||
// A normal key captured during a standard composition remains a normal key;
|
||||
// routing may suppress it because of the lifecycle snapshot, but must not
|
||||
// rewrite its ownership.
|
||||
return virtualKey == VK_PROCESSKEY;
|
||||
}
|
||||
};
|
||||
|
||||
enum class TextSource : std::uint8_t
|
||||
{
|
||||
ImeResult,
|
||||
UnicodeChar,
|
||||
UnicodeScalar,
|
||||
AnsiCharFallback,
|
||||
Clipboard
|
||||
};
|
||||
|
||||
struct TextInputEvent
|
||||
{
|
||||
std::wstring text;
|
||||
TextSource source = TextSource::UnicodeChar;
|
||||
std::uint64_t compositionId = 0; // Zero for non-IME input.
|
||||
};
|
||||
|
||||
enum class CompositionStage : std::uint8_t
|
||||
{
|
||||
Start,
|
||||
Update,
|
||||
Commit,
|
||||
Cancel,
|
||||
End
|
||||
};
|
||||
|
||||
struct CompositionEvent
|
||||
{
|
||||
CompositionStage stage = CompositionStage::Start;
|
||||
std::uint64_t compositionId = 0;
|
||||
std::wstring compositionText;
|
||||
std::uint32_t caretOffset = 0;
|
||||
};
|
||||
|
||||
struct WindowActivationEvent
|
||||
{
|
||||
bool keyboardFocusActive = false;
|
||||
HWND otherWindow = nullptr;
|
||||
};
|
||||
|
||||
using NativeInputPayload = std::variant<
|
||||
KeyEvent,
|
||||
TextInputEvent,
|
||||
CompositionEvent,
|
||||
WindowActivationEvent>;
|
||||
|
||||
struct NativeInputEvent
|
||||
{
|
||||
std::uint64_t sequence = 0;
|
||||
DWORD messageTime = 0;
|
||||
HWND hwnd = nullptr;
|
||||
NativeInputPayload payload{};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*******************************************************************************
|
||||
* @类: Label
|
||||
* @摘要: 简单文本标签控件,用于显示静态文本
|
||||
* @描述:
|
||||
* 提供基本的文本显示功能,支持透明背景和自定义样式。
|
||||
* 不支持用户交互,专注于文本呈现。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持背景透明/不透明模式
|
||||
* - 完整的文本样式控制(字体、颜色、效果)
|
||||
* - 自动适应文本内容
|
||||
* - 轻量级无事件处理开销
|
||||
*
|
||||
* @使用场景: 显示说明文字、标题、状态信息等静态内容
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
|
||||
class Label : public Control
|
||||
{
|
||||
std::string text; //标签文本
|
||||
COLORREF textBkColor; //标签背景颜色
|
||||
bool textBkDisap = false; //标签背景是否透明
|
||||
std::string lastMeasuredText; // 最近一次参与尺寸测量的文本
|
||||
StellarX::ControlText lastMeasuredStyle; // 最近一次参与尺寸测量的字体样式
|
||||
bool contentMeasureValid = false; // 当前内容尺寸缓存是否有效
|
||||
|
||||
// Label 不处理输入事件,固定返回 false。
|
||||
bool handleEvent(const ExMessage&) override { return false; }
|
||||
// Label 不是模态对话框,固定返回 false。
|
||||
bool model() const override { return false; };
|
||||
// 根据当前文本和字体样式刷新运行态尺寸。
|
||||
// 这是 Label 的内容驱动几何入口:
|
||||
// - 覆盖文本变化
|
||||
// - 覆盖字体样式变化
|
||||
// - 只更新运行态宽高,不自动提交设计基线 local*
|
||||
void refreshContentDrivenRuntimeGeometry();
|
||||
public:
|
||||
// 标签字体样式保持公开,便于用户直接改字段。
|
||||
// 但直接修改 textStyle 后,必须显式调用 setDirty(true),
|
||||
// 才会触发内容驱动尺寸刷新并让新的运行态宽高生效。
|
||||
StellarX::ControlText textStyle; //标签文本样式
|
||||
public:
|
||||
// 默认构造:创建一个内容驱动尺寸的标签
|
||||
Label();
|
||||
// 指定位置、文本和基础样式构造标签
|
||||
Label(int x, int y, std::string text = "标签", COLORREF textcolor = BLACK, COLORREF bkColor = RGB(255, 255, 255));
|
||||
|
||||
// 绘制标签文本
|
||||
void draw() override;
|
||||
// 隐藏 Label 并恢复背景;主要供 Button Tooltip 关闭时复用。
|
||||
void hide();
|
||||
// Label 的内容尺寸由文本和字体样式共同决定。
|
||||
// 因此只要进入 dirty 状态,就在绘制前先刷新一次运行态尺寸,
|
||||
// 避免 draw() 阶段继续承担新的几何决策。
|
||||
void setDirty(bool dirty) override;
|
||||
// 设置标签背景是否透明
|
||||
void setTextdisap(bool key);
|
||||
// 设置标签背景颜色
|
||||
void setTextBkColor(COLORREF color);
|
||||
// 设置标签文本,并触发内容驱动尺寸刷新。
|
||||
// Label 的布局系统只负责位置;运行态宽高仍由文本和 textStyle 测量结果决定。
|
||||
void setText(std::string text);
|
||||
protected:
|
||||
// 统一布局器只负责安置 Label 的运行态位置;
|
||||
// 宽高仍由 Label 自己的内容驱动路径决定。
|
||||
void applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect) override;
|
||||
};
|
||||
@@ -27,14 +27,17 @@ namespace StellarX
|
||||
class MessageBox
|
||||
{
|
||||
public:
|
||||
// 模态:阻塞直到关闭,返回结果
|
||||
static MessageBoxResult ShowModal(Window& wnd,
|
||||
// 模态:直接创建并显示对话框,阻塞到用户关闭后返回结果。
|
||||
// 调用期间会运行 Dialog 自己的模态事件循环。
|
||||
static MessageBoxResult showModal(Window& wnd,
|
||||
const std::string& text,
|
||||
const std::string& caption = "提示",
|
||||
MessageBoxType type = MessageBoxType::OK);
|
||||
|
||||
// 非模态:立即返回,通过回调异步获取结果
|
||||
static void ShowAsync(Window& wnd,
|
||||
// 非模态:创建后立即返回,由 Window 托管生命周期和绘制层级。
|
||||
// 若同一窗口中已存在相同 caption/text 的可见非模态对话框,则不会重复创建。
|
||||
// 用户关闭时通过 onResult 异步返回结果;回调允许排队修改宿主控件树。
|
||||
static void showAsync(Window& wnd,
|
||||
const std::string& text,
|
||||
const std::string& caption = "提示",
|
||||
MessageBoxType type = MessageBoxType::OK,
|
||||
|
||||
+17
-11
@@ -1,17 +1,19 @@
|
||||
/*******************************************************************************
|
||||
* @文件: StellarX.h
|
||||
* @摘要: 星垣(StellarX) GUI框架 - 主包含头文件
|
||||
* @版本: v2.0.0
|
||||
* @版本: v3.1.0
|
||||
* @描述:
|
||||
* 一个为Windows平台打造的轻量级、模块化C++ GUI框架。
|
||||
* 基于EasyX图形库,提供简洁易用的API和丰富的控件。
|
||||
*
|
||||
* 通过包含此单一头文件,即可使用框架的所有功能。
|
||||
* 内部包含顺序经过精心设计,确保所有依赖关系正确解析。
|
||||
* 内部头文件包含顺序由框架维护,用户代码不应依赖该顺序。
|
||||
*
|
||||
* @作者: 我在人间做废物
|
||||
* @邮箱: [3150131407@qq.com] | [ysm3150131407@gmail.com]
|
||||
* @仓库: [https://github.com/Ysm-04/StellarX]
|
||||
* @官网:https://stellarx-gui.top/
|
||||
* @仓库: https://github.com/Ysm-04/StellarX
|
||||
* @博客:https://blog.stellarx-gui.top/
|
||||
*
|
||||
* @许可证: MIT License
|
||||
* @版权: Copyright (c) 2025 我在人间做废物
|
||||
@@ -19,23 +21,27 @@
|
||||
* @使用说明:
|
||||
* 只需包含此文件即可使用框架所有功能。
|
||||
* 示例: #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
|
||||
|
||||
#include "CoreTypes.h"
|
||||
#include "FocusManager.h"
|
||||
#include "InputEvents.h"
|
||||
#include "SxLog.h"
|
||||
#include "Control.h"
|
||||
#include"Button.h"
|
||||
#include"Canvas.h"
|
||||
#include"Window.h"
|
||||
#include"Button.h"
|
||||
#include"Label.h"
|
||||
#include"TextBox.h"
|
||||
#include"Canvas.h"
|
||||
#include"Table.h"
|
||||
#include"Dialog.h"
|
||||
#include"MessageBox.h"
|
||||
#include"TabControl.h"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
#pragma once
|
||||
|
||||
/********************************************************************************
|
||||
* @文件: SxLog.h
|
||||
* @摘要: StellarX 日志系统对外接口定义(控制台/文件输出 + 级别过滤 + Tag过滤 + 中英文选择)
|
||||
* @描述:
|
||||
* 该日志系统采用“宏 + RAII(析构提交)”的方式实现:
|
||||
* - 调用端通过 SX_LOGD/SX_LOGI... 写日志
|
||||
* - 宏内部先 shouldLog 短路过滤,未命中时不构造对象、不拼接字符串
|
||||
* - 命中时构造 SxLogLine,使用 operator<< 拼接内容
|
||||
* - 语句结束时 SxLogLine 析构,统一提交到 SxLogger::logLine 输出
|
||||
*
|
||||
* 输出通道(Sink)目前提供:
|
||||
* - ConsoleSink: 写入 std::cout(不走 WinAPI 调试输出通道)
|
||||
* - FileSink: 写入文件,支持按字节阈值滚动
|
||||
*
|
||||
* @特性:
|
||||
* - 日志级别:Trace/Debug/Info/Warn/Error/Fatal/Off
|
||||
* - Tag 过滤:None/Whitelist/Blacklist
|
||||
* - 可选前缀:时间戳/级别/Tag/线程ID/源码位置
|
||||
* - 中英文选择:SX_T(zh, en) / setLanguage
|
||||
* - 文件滚动:rotateBytes > 0 时按阈值滚动
|
||||
*
|
||||
* @使用场景:
|
||||
* - 排查重绘链路、脏标记传播、Tab 切换、Table 数据刷新等时序问题
|
||||
* - 输出可复现日志,配合回归验证
|
||||
*
|
||||
* @注意:
|
||||
* - SX_T 仅做“字符串选择”,不做编码转换
|
||||
* - 控制台显示是否乱码由“终端 codepage/字体/环境”决定
|
||||
* - 该头文件只声明接口,实现位于 SxLog.cpp
|
||||
*
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
********************************************************************************/
|
||||
|
||||
// SxLog.h - header-only interface (implementation in SxLog.cpp)
|
||||
// Pure standard library: std::cout and optional file sink.
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifndef SX_LOG_ENABLE
|
||||
#define SX_LOG_ENABLE 1
|
||||
#endif
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
/* ========================= 日志级别 ========================= */
|
||||
// 说明:
|
||||
// - minLevel 表示最低输出级别,小于 minLevel 的日志会被 shouldLog 直接过滤
|
||||
// - Off 表示全局关闭
|
||||
enum class SxLogLevel : int
|
||||
{
|
||||
Trace = 0, // 最细粒度:高频路径追踪(谨慎开启)
|
||||
Debug = 1, // 调试信息:状态变化/关键分支
|
||||
Info = 2, // 业务信息:关键流程节点
|
||||
Warn = 3, // 警告:非致命但异常的情况
|
||||
Error = 4, // 错误:功能失败、需要关注
|
||||
Fatal = 5, // 致命:通常意味着无法继续运行
|
||||
Off = 6 // 关闭全部日志
|
||||
};
|
||||
|
||||
/* ========================= 语言选择 ========================= */
|
||||
// 说明:仅用于 SX_T 选择输出哪一段文本,不做编码转换
|
||||
enum class SxLogLanguage : int
|
||||
{
|
||||
ZhCN = 0, // 中文
|
||||
EnUS = 1 // 英文
|
||||
};
|
||||
|
||||
/* ========================= Tag 过滤模式 ========================= */
|
||||
// None : 不过滤,全部输出
|
||||
// Whitelist : 只输出 tagList 中包含的 tag
|
||||
// Blacklist : 输出除 tagList 以外的 tag
|
||||
enum class SxTagFilterMode : int
|
||||
{
|
||||
None = 0,
|
||||
Whitelist = 1,
|
||||
Blacklist = 2
|
||||
};
|
||||
|
||||
/* ========================= 日志配置 ========================= */
|
||||
// 说明:SxLogger 内部持有该配置,shouldLog 与 logLine 都依赖它
|
||||
struct SxLogConfig
|
||||
{
|
||||
SxLogLevel minLevel = SxLogLevel::Info; // 最低输出级别
|
||||
|
||||
bool showTimestamp = true; // 是否输出时间戳前缀
|
||||
bool showLevel = true; // 是否输出级别前缀
|
||||
bool showTag = true; // 是否输出 tag 前缀
|
||||
bool showThreadId = false; // 是否输出线程ID(排查并发时开启)
|
||||
bool showSource = false; // 是否输出源码位置(file:line func)
|
||||
bool autoFlush = true; // 每行写完是否 flush(排查问题更稳,性能略差)
|
||||
|
||||
SxTagFilterMode tagFilterMode = SxTagFilterMode::None; // Tag 过滤模式
|
||||
std::vector<std::string> tagList; // Tag 列表(白名单/黑名单)
|
||||
|
||||
bool fileEnabled = false; // 文件输出是否启用(enableFile 成功才为 true)
|
||||
std::string filePath; // 文件路径
|
||||
bool fileAppend = true; // 是否追加写入
|
||||
std::size_t rotateBytes = 0; // 滚动阈值(0 表示不滚动)
|
||||
};
|
||||
|
||||
/* ========================= Sink 接口 ========================= */
|
||||
// 说明:
|
||||
// - Sink 负责“把完整的一行日志写到某个地方”
|
||||
// - SxLogger 负责过滤/格式化/分发
|
||||
class ILogSink
|
||||
{
|
||||
public:
|
||||
virtual ~ILogSink() = default;
|
||||
|
||||
// 返回 Sink 名称,用于调试识别(例如 "console"/"file")
|
||||
virtual const char* name() const = 0;
|
||||
|
||||
// 写入一整行(调用方保证 line 已包含换行或按约定追加换行)
|
||||
virtual void writeLine(const std::string& line) = 0;
|
||||
|
||||
// 刷新缓冲(可选实现)
|
||||
virtual void flush() {}
|
||||
};
|
||||
|
||||
/* ========================= 控制台输出 Sink ========================= */
|
||||
// 作用:把日志写入指定输出流(默认用 std::cout)
|
||||
class ConsoleSink : public ILogSink
|
||||
{
|
||||
public:
|
||||
// 绑定一个输出流引用(常见用法:std::cout)
|
||||
explicit ConsoleSink(std::ostream& os) : out(os) {}
|
||||
|
||||
// 返回控制台 sink 的固定名称。
|
||||
const char* name() const override { return "console"; }
|
||||
|
||||
// 写入一行(不自动追加换行,换行由上层统一拼接)
|
||||
void writeLine(const std::string& line) override { out << line; }
|
||||
|
||||
// 立即 flush(当 autoFlush=true 时由 SxLogger 调用)
|
||||
void flush() override { out.flush(); }
|
||||
|
||||
private:
|
||||
std::ostream& out; // 输出流引用(不负责生命周期)
|
||||
};
|
||||
|
||||
/* ========================= 文件输出 Sink ========================= */
|
||||
// 作用:把日志写入文件,支持按字节阈值滚动
|
||||
class FileSink : public ILogSink
|
||||
{
|
||||
public:
|
||||
// 构造一个尚未打开文件的文件 sink。
|
||||
FileSink() = default;
|
||||
|
||||
// 返回文件 sink 的固定名称。
|
||||
const char* name() const override { return "file"; }
|
||||
|
||||
// 打开文件
|
||||
// path : 文件路径
|
||||
// append : true 追加写;false 清空重写
|
||||
bool open(const std::string& path, bool append);
|
||||
|
||||
// 关闭文件(安全可重复调用)
|
||||
void close();
|
||||
|
||||
// 查询文件是否处于打开状态
|
||||
bool isOpen() const;
|
||||
|
||||
// 设置滚动阈值(字节)
|
||||
// bytes = 0 表示不滚动
|
||||
void setRotateBytes(std::size_t bytes) { rotateBytes = bytes; }
|
||||
|
||||
// 写入一行,并在需要时触发滚动
|
||||
void writeLine(const std::string& line) override;
|
||||
|
||||
// flush 文件缓冲
|
||||
void flush() override;
|
||||
|
||||
private:
|
||||
// 检查并执行滚动
|
||||
// 返回值:是否发生滚动(或是否重新打开)
|
||||
bool rotateIfNeeded();
|
||||
|
||||
std::ofstream ofs; // 文件输出流
|
||||
std::string filePath; // 当前文件路径
|
||||
bool appendMode = true; // 是否追加模式(用于 reopen)
|
||||
std::size_t rotateBytes = 0; // 滚动阈值
|
||||
// 时间戳精度不足时用递增序号补足文件名唯一性,避免连续滚动互相覆盖。
|
||||
std::uint64_t rotateSequence = 0;
|
||||
};
|
||||
|
||||
/* ========================= 日志中心 SxLogger ========================= */
|
||||
// 作用:
|
||||
// - 保存配置(SxLogConfig)
|
||||
// - 过滤(level/tag/sink enabled)
|
||||
// - 格式化前缀(时间/级别/tag/线程/源码位置)
|
||||
// - 分发到 console/file 等 sink
|
||||
class SxLogger
|
||||
{
|
||||
public:
|
||||
// 仅用于 Windows 控制台:把 codepage 切到 GBK,解决中文乱码。
|
||||
// 不使用 WinAPI:内部通过 system("chcp 936") 实现
|
||||
// 注意:这只影响终端解释输出字节的方式,不影响源码文件编码
|
||||
static void setGBK();
|
||||
|
||||
// 获取全局单例
|
||||
// 说明:函数内静态对象,C++11 起保证线程安全初始化
|
||||
static SxLogger& Get();
|
||||
|
||||
// 设置最低输出级别
|
||||
void setMinLevel(SxLogLevel level);
|
||||
|
||||
// 获取最低输出级别
|
||||
SxLogLevel getMinLevel() const;
|
||||
|
||||
// 设置语言(用于 SX_T 选择)
|
||||
void setLanguage(SxLogLanguage lang);
|
||||
|
||||
// 获取当前语言
|
||||
SxLogLanguage getLanguage() const;
|
||||
|
||||
// 设置 Tag 过滤
|
||||
// mode: None/Whitelist/Blacklist
|
||||
// tags: 过滤列表(精确匹配)
|
||||
void setTagFilter(SxTagFilterMode mode, const std::vector<std::string>& tags);
|
||||
|
||||
// 清空 Tag 过滤(恢复 None)
|
||||
void clearTagFilter();
|
||||
|
||||
// 开关控制台输出
|
||||
void enableConsole(bool enable);
|
||||
|
||||
// 开启文件输出
|
||||
// path : 文件路径
|
||||
// append : 追加写/清空写
|
||||
// rotateBytes: 滚动阈值(0 不滚动)
|
||||
// 返回值:是否打开成功
|
||||
bool enableFile(const std::string& path, bool append = true, std::size_t rotateBytes = 0);
|
||||
|
||||
// 关闭文件输出(不影响控制台输出)
|
||||
void disableFile();
|
||||
|
||||
// 快速判定是否需要输出(宏层面的短路依赖它)
|
||||
// 说明:
|
||||
// - shouldLog 一定要“副作用为 0”
|
||||
// - 若返回 false,调用端不会创建 SxLogLine,也不会拼接字符串
|
||||
bool shouldLog(SxLogLevel level, const char* tag) const;
|
||||
|
||||
// 输出一条完整日志
|
||||
// 说明:这是统一出口,SxLogLine 析构最终会走到这里
|
||||
void logLine(
|
||||
SxLogLevel level,
|
||||
const char* tag,
|
||||
const char* file,
|
||||
int line,
|
||||
const char* func,
|
||||
const std::string& msg);
|
||||
|
||||
// 获取配置副本(避免外部直接改内部 cfg)
|
||||
SxLogConfig getConfigCopy() const;
|
||||
|
||||
// 批量设置配置(整体替换);新文件 sink 打开成功后才替换旧配置。
|
||||
bool setConfig(const SxLogConfig& cfg);
|
||||
|
||||
// 工具:把级别转为字符串(用于前缀)
|
||||
static const char* levelToString(SxLogLevel level);
|
||||
|
||||
// 工具:生成本地时间戳字符串(用于前缀与文件滚动名)
|
||||
static std::string makeTimestampLocal();
|
||||
|
||||
private:
|
||||
// 构造日志单例的初始配置和默认 sink。
|
||||
SxLogger();
|
||||
|
||||
// 判断 tag 是否允许输出(根据 Tag 过滤模式与 tagList)
|
||||
static bool tagAllowed(const SxLogConfig& cfg, const char* tag);
|
||||
|
||||
// 生成前缀(调用方需已持有锁)
|
||||
std::string formatPrefixUnlocked(
|
||||
const SxLogConfig& cfg,
|
||||
SxLogLevel level,
|
||||
const char* tag,
|
||||
const char* file,
|
||||
int line,
|
||||
const char* func) const;
|
||||
|
||||
mutable std::mutex mtx; // 保护 cfg 与 sink 写入,确保多线程行级一致性
|
||||
SxLogConfig cfg; // 当前配置
|
||||
std::atomic<SxLogLanguage> lang; // 语言开关(仅影响 SX_T 选择)
|
||||
|
||||
std::unique_ptr<ConsoleSink> consoleSink; // 控制台 sink(enableConsole 控制)
|
||||
std::unique_ptr<FileSink> fileSink; // 文件 sink(enableFile 控制)
|
||||
};
|
||||
|
||||
/* ========================= 双语选择辅助 ========================= */
|
||||
// 说明:
|
||||
// - 只做“选择 zhCN 或 enUS”,不做编码转换
|
||||
// - 输出显示是否正常由终端环境决定
|
||||
// 根据当前日志语言选择中文或英文文本。
|
||||
inline const char* SxT(const char* zhCN, const char* enUS)
|
||||
{
|
||||
return (SxLogger::Get().getLanguage() == SxLogLanguage::ZhCN) ? zhCN : enUS;
|
||||
}
|
||||
|
||||
#if defined(__cpp_char8_t) && (__cpp_char8_t >= 201811L)
|
||||
// 说明:
|
||||
// - C++20 的 u8"xxx" 是 char8_t*,为了兼容调用端,这里提供重载
|
||||
// - reinterpret_cast 只是改指针类型,不做 UTF-8 -> GBK 转码
|
||||
// 兼容 C++20 char8_t 字符串字面量的双语选择入口。
|
||||
inline const char* SxT(const char8_t* zhCN, const char* enUS)
|
||||
{
|
||||
return (SxLogger::Get().getLanguage() == SxLogLanguage::ZhCN)
|
||||
? reinterpret_cast<const char*>(zhCN)
|
||||
: enUS;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ========================= RAII 日志行对象 ========================= */
|
||||
// 作用:
|
||||
// - 构造时记录 level/tag/源码位置
|
||||
// - operator<< 拼接内容
|
||||
// - 析构时统一提交给 SxLogger::logLine 输出
|
||||
//
|
||||
// 设计意义:
|
||||
// - 避免调用端忘记写换行
|
||||
// - 保证一行日志作为整体写出
|
||||
class SxLogLine
|
||||
{
|
||||
public:
|
||||
// 构造:记录元信息(不输出)
|
||||
SxLogLine(SxLogLevel level, const char* tag, const char* file, int line, const char* func);
|
||||
|
||||
// 析构:提交输出(真正写出发生在这里)
|
||||
~SxLogLine();
|
||||
|
||||
// 拼接内容(流式写法)
|
||||
template<typename T>
|
||||
// 向当前日志行追加一个可流式输出的值。
|
||||
SxLogLine& operator<<(const T& v)
|
||||
{
|
||||
ss << v;
|
||||
return *this;
|
||||
}
|
||||
|
||||
private:
|
||||
SxLogLevel lvl; // 日志级别
|
||||
const char* tg; // Tag(不拥有内存)
|
||||
const char* srcFile; // 源文件名(来自 __FILE__)
|
||||
int srcLine; // 行号(来自 __LINE__)
|
||||
const char* srcFunc; // 函数名(来自 __func__)
|
||||
std::ostringstream ss; // 内容拼接缓冲
|
||||
};
|
||||
|
||||
/* ========================= RAII 作用域计时对象 ========================= */
|
||||
// 作用:
|
||||
// - 仅在 shouldLog(Trace, tag) 为 true 时启用计时
|
||||
// - 析构时输出耗时(微秒)
|
||||
//
|
||||
// 使用建议:
|
||||
// - 只在需要定位性能瓶颈时开启 Trace
|
||||
// - name 建议传入常量字符串,便于检索
|
||||
class SxLogScope
|
||||
{
|
||||
public:
|
||||
// 构造:根据 shouldLog 决定是否启用计时
|
||||
SxLogScope(SxLogLevel level, const char* tag, const char* file, int line, const char* func, const char* name);
|
||||
|
||||
// 析构:若启用则输出耗时
|
||||
~SxLogScope();
|
||||
|
||||
private:
|
||||
bool enabled = false; // 是否启用(未启用则析构无输出)
|
||||
SxLogLevel lvl = SxLogLevel::Trace; // 级别(通常用 Trace)
|
||||
const char* tg = nullptr; // Tag
|
||||
const char* srcFile = nullptr; // 源文件
|
||||
int srcLine = 0; // 行号
|
||||
const char* srcFunc = nullptr; // 函数
|
||||
const char* scopeName = nullptr; // 作用域名
|
||||
std::chrono::steady_clock::time_point t0; // 起始时间点
|
||||
};
|
||||
|
||||
} // namespace StellarX
|
||||
|
||||
#if SX_LOG_ENABLE
|
||||
|
||||
// SX_T:双语选择宏,调用 SxT 根据当前语言选择输出
|
||||
#define SX_T(zh, en) ::StellarX::SxT(zh, en)
|
||||
|
||||
// 日志宏说明:
|
||||
// 1) 先 shouldLog 短路过滤,未命中则不会构造 SxLogLine,也不会执行 else 分支的表达式
|
||||
// 2) 命中则构造临时 SxLogLine,并允许继续使用 operator<< 拼接
|
||||
// 3) 语句结束时临时对象析构,触发真正输出
|
||||
#define SX_LOG_TRACE(tag) if(!::StellarX::SxLogger::Get().shouldLog(::StellarX::SxLogLevel::Trace, tag)) ; else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Trace, tag, __FILE__, __LINE__, __func__)
|
||||
#define SX_LOGD(tag) if(!::StellarX::SxLogger::Get().shouldLog(::StellarX::SxLogLevel::Debug, tag)) ; else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Debug, tag, __FILE__, __LINE__, __func__)
|
||||
#define SX_LOGI(tag) if(!::StellarX::SxLogger::Get().shouldLog(::StellarX::SxLogLevel::Info, tag)) ; else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Info, tag, __FILE__, __LINE__, __func__)
|
||||
#define SX_LOGW(tag) if(!::StellarX::SxLogger::Get().shouldLog(::StellarX::SxLogLevel::Warn, tag)) ; else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Warn, tag, __FILE__, __LINE__, __func__)
|
||||
#define SX_LOGE(tag) if(!::StellarX::SxLogger::Get().shouldLog(::StellarX::SxLogLevel::Error, tag)) ; else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Error, tag, __FILE__, __LINE__, __func__)
|
||||
#define SX_LOGF(tag) if(!::StellarX::SxLogger::Get().shouldLog(::StellarX::SxLogLevel::Fatal, tag)) ; else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Fatal, tag, __FILE__, __LINE__, __func__)
|
||||
|
||||
// 作用域耗时统计宏:默认用 Trace 级别
|
||||
#define SX_TRACE_SCOPE(tag, nameLiteral) ::StellarX::SxLogScope sx_scope_##__LINE__(::StellarX::SxLogLevel::Trace, tag, __FILE__, __LINE__, __func__, nameLiteral)
|
||||
|
||||
#else
|
||||
|
||||
// 关闭日志时的兼容宏:保证调用端代码不需要改动
|
||||
#define SX_T(zh, en) (en)
|
||||
#define SX_LOG_TRACE(tag) if(true) {} else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Off, tag, "", 0, "")
|
||||
#define SX_LOGD(tag) if(true) {} else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Off, tag, "", 0, "")
|
||||
#define SX_LOGI(tag) if(true) {} else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Off, tag, "", 0, "")
|
||||
#define SX_LOGW(tag) if(true) {} else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Off, tag, "", 0, "")
|
||||
#define SX_LOGE(tag) if(true) {} else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Off, tag, "", 0, "")
|
||||
#define SX_LOGF(tag) if(true) {} else ::StellarX::SxLogLine(::StellarX::SxLogLevel::Off, tag, "", 0, "")
|
||||
#define SX_TRACE_SCOPE(tag, nameLiteral) do {} while(0)
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,124 @@
|
||||
/*******************************************************************************
|
||||
* @类: TabControl
|
||||
* @摘要: 选项卡容器控件,管理“页签按钮 + 对应页面(Canvas)”
|
||||
* @描述:
|
||||
* 提供页签栏布局(上/下/左/右)、选中切换、页内容区域定位;
|
||||
* 与 Button 一起工作,支持窗口大小变化、可见性联动与脏区重绘。
|
||||
* 在托管重绘模式下,TabControl 作为“页签栏 + 当前页面”的统一重绘 root。
|
||||
*
|
||||
* @特性:
|
||||
* - 页签栏四向排列(Top / Bottom / Left / Right)
|
||||
* - 一键添加“页签+页”或为指定页添加子控件
|
||||
* - 获取/设置当前激活页签索引
|
||||
* - 自适应窗口变化,重算页签与页面区域
|
||||
* - 与 Button 的 TOGGLE 模式联动显示/隐藏页面
|
||||
*
|
||||
* @使用场景: 在同一区域内承载多张页面,使用页签进行快速切换
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "CoreTypes.h"
|
||||
#include "Button.h"
|
||||
#include "Canvas.h"
|
||||
#define BUTMINHEIGHT 15 //页签按钮最小尺寸,过小会导致显示问题
|
||||
#define BUTMINWIDTH 30 //页签按钮最小尺寸,过小会导致显示问题
|
||||
class TabControl :public Canvas
|
||||
{
|
||||
int tabBarHeight = BUTMINWIDTH; //页签栏高度
|
||||
bool IsFirstDraw = true; //首次绘制标记
|
||||
int defaultActivation = -1; //默认激活页签索引
|
||||
StellarX::TabPlacement tabPlacement = StellarX::TabPlacement::Top; //页签排列方式
|
||||
std::vector<std::pair<std::unique_ptr<Button>, std::unique_ptr<Canvas>>> controls; //页签/页列表
|
||||
// addTab 在事务期间暂存完整的按钮/页面对,提交时保持两者的索引一致。
|
||||
std::vector<std::pair<std::unique_ptr<Button>, std::unique_ptr<Canvas>>> pendingTabs;
|
||||
|
||||
private:
|
||||
using Canvas::addControl; // 禁止外部误用
|
||||
void addControl(std::unique_ptr<Control>) = delete; // 精准禁用该重载
|
||||
private:
|
||||
// 初始化页签按钮位置和尺寸
|
||||
inline void initTabBar();
|
||||
// 初始化页面 Canvas 的位置和尺寸
|
||||
inline void initTabPage();
|
||||
// 统一刷新 TabControl 当前运行态下的页签栏和页面区布局。
|
||||
void refreshRuntimeLayout();
|
||||
// 立即加入一组已校验的页签按钮和页面 Canvas,并建立内部回调联动。
|
||||
size_t addTabDirect(std::unique_ptr<Button> button, std::unique_ptr<Canvas> page);
|
||||
public:
|
||||
// 默认构造:创建一个空 TabControl
|
||||
TabControl();
|
||||
// 指定初始位置和尺寸构造 TabControl
|
||||
TabControl(int x, int y, int width, int height);
|
||||
// 析构 TabControl;页签按钮和页面由 unique_ptr 自动释放。
|
||||
~TabControl();
|
||||
|
||||
// 修改 TabControl 运行态 X,并刷新页签栏和页面区布局。
|
||||
void setX(int x)override;
|
||||
// 修改 TabControl 运行态 Y,并刷新页签栏和页面区布局。
|
||||
void setY(int y)override;
|
||||
// 修改 TabControl 运行态宽度,并刷新页签栏和页面区布局。
|
||||
void setWidth(int width) override;
|
||||
// 修改 TabControl 运行态高度,并刷新页签栏和页面区布局。
|
||||
void setHeight(int height) override;
|
||||
|
||||
// 绘制页签栏和当前可见页面。
|
||||
void draw() override;
|
||||
// 处理页签按钮和当前页面的事件分发。
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
// 只推进当前页按钮和页面中的时钟状态,避免不可见页签产生无效工作。
|
||||
void tick(ULONGLONG nowMs) override;
|
||||
// 只转发清理页签按钮和当前可见页面中的鼠标临时状态,不升级为整块 TabControl 重绘。
|
||||
bool clearTransientMouseState() override;
|
||||
|
||||
// 添加一个“页签按钮 + 页面 Canvas”组合并返回页签索引。
|
||||
// button/page 为空时返回 size_t(-1)。事务期间返回包含 pendingTabs 的逻辑索引,
|
||||
// 因此调用方可以立即把控件追加到刚创建但尚未提交的页面中。
|
||||
size_t addTab(std::unique_ptr<Button> button, std::unique_ptr<Canvas> page);
|
||||
// 向指定页追加控件。成功时返回空指针;失败时把控件所有权原样返还调用方。
|
||||
// pageIndex 可指向已提交页面,也可指向 addTab 在同一事务中返回的 pending 逻辑索引。
|
||||
std::unique_ptr<Control> tryAddToPage(size_t pageIndex, std::unique_ptr<Control> control);
|
||||
// 按页签文本追加控件;若存在重复标题,只匹配第一个命中的页签。
|
||||
std::unique_ptr<Control> tryAddToPage(const std::string& tabText, std::unique_ptr<Control> control);
|
||||
// 设置页签位置,并重新计算页签栏和页面区布局。
|
||||
void setTabPlacement(StellarX::TabPlacement placement);
|
||||
// 设置页签栏高度;当页签在左右两侧时表示页签栏宽度。
|
||||
void setTabBarHeight(int height);
|
||||
// 设置 TabControl 可见性,并同步页签按钮和页面可见状态。
|
||||
void setIsVisible(bool visible) override;
|
||||
// 窗口尺寸变化时作废页签按钮和页面快照。
|
||||
void onWindowResize() override;
|
||||
// 获取当前激活页签索引;没有激活页时返回 -1。
|
||||
int getActiveIndex() const;
|
||||
// 设置当前激活页签索引。
|
||||
// 首次绘制前仅记录默认激活项;绘制后索引必须命中已提交页签。重复激活当前页会直接返回 true。
|
||||
bool setActiveIndex(int idx);
|
||||
// 获取页签数量
|
||||
int count() const;
|
||||
// 通过页签文本查找索引;重复标题时返回第一个命中项,未命中返回 -1。
|
||||
int indexOf(const std::string& tabText) const;
|
||||
// 设置 TabControl 及其当前页签/页面的脏状态。
|
||||
void setDirty(bool dirty) override;
|
||||
// 请求父控件重绘;托管模式下登记 TabControl 为 root。
|
||||
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:
|
||||
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const override;
|
||||
void collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const override;
|
||||
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason) override;
|
||||
// 将回调期间加入的页签和页内控件在外层事务结束时一次性提交。
|
||||
void flushPendingMutations() override;
|
||||
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
|
||||
void applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect) override;
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/*******************************************************************************
|
||||
* @类: Table
|
||||
* @摘要: 高级表格控件,支持分页显示和大数据量展示
|
||||
* @描述:
|
||||
* 提供完整的数据表格功能,包括表头、数据行、分页导航等。
|
||||
* 自动计算列宽行高,支持自定义样式和交互。
|
||||
*
|
||||
* @特性:
|
||||
* - 自动分页和页码计算
|
||||
* - 可配置的每页行数
|
||||
* - 自定义边框样式和填充模式
|
||||
* - 翻页按钮和页码显示
|
||||
* - 背景缓存优化渲染性能
|
||||
*
|
||||
* @使用场景: 数据展示、报表生成、记录浏览等表格需求
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
#include "Button.h"
|
||||
#include "Label.h"
|
||||
|
||||
// === Table metrics (layout) ===
|
||||
#define TABLE_PAD_X 10 // 单元格左右内边距
|
||||
#define TABLE_PAD_Y 5 // 单元格上下内边距
|
||||
#define TABLE_COL_GAP 20 // 列间距(列与列之间)
|
||||
#define TABLE_HEADER_EXTRA 10 // 表头额外高度(若不想复用 pad 计算)
|
||||
#define TABLE_ROW_EXTRA 10 // 行额外高度(同上;或直接用 2*TABLE_PAD_Y)
|
||||
#define TABLE_BTN_GAP 12 // 页码与按钮的水平间距
|
||||
#define TABLE_BTN_PAD_H 12 // 按钮水平 padding
|
||||
#define TABLE_BTN_PAD_V 0 // 按钮垂直 padding(initButton)
|
||||
#define TABLE_BTN_TEXT_PAD_V 8 // 计算页脚高度时的按钮文字垂直 padding(initTextWaH)
|
||||
#define TABLE_FOOTER_PAD 16 // 页脚额外高度(底部留白)
|
||||
#define TABLE_FOOTER_BLANK 8 // 页脚顶部留白
|
||||
#define TABLE_PAGE_TEXT_OFFSET_X (-40) // 页码文本的临时水平修正
|
||||
|
||||
// === Table defaults (theme) ===
|
||||
#define TABLE_DEFAULT_ROWS_PER_PAGE 5
|
||||
#define TABLE_DEFAULT_BORDER_WIDTH 1
|
||||
#define TABLE_DEFAULT_BORDER_COLOR RGB(0,0,0)
|
||||
#define TABLE_DEFAULT_BG_COLOR RGB(255,255,255)
|
||||
|
||||
// === Strings (i18n ready) ===
|
||||
#define TABLE_STR_PREV "上一页"
|
||||
#define TABLE_STR_NEXT "下一页"
|
||||
#define TABLE_STR_PAGE_PREFIX "第"
|
||||
#define TABLE_STR_PAGE_MID "页/共"
|
||||
#define TABLE_STR_PAGE_SUFFIX "页"
|
||||
|
||||
class Table :public Control
|
||||
{
|
||||
private:
|
||||
std::vector<std::vector<std::string>> data; // 表格数据
|
||||
std::vector<std::string> headers; // 表格表头
|
||||
std::string pageNumtext = "页码标签"; // 页码标签文本
|
||||
|
||||
int tableBorderWidth = TABLE_DEFAULT_BORDER_WIDTH; // 边框宽度
|
||||
|
||||
std::vector<int> colWidths; // 每列的宽度
|
||||
std::vector<int> lineHeights; // 每行的高度
|
||||
|
||||
int rowsPerPage = TABLE_DEFAULT_ROWS_PER_PAGE; // 每页显示的行数
|
||||
int currentPage = 1; // 当前页码
|
||||
int totalPages = 1; // 总页数
|
||||
|
||||
bool isShowPageButton = true; // 是否显示翻页按钮
|
||||
bool isNeedDrawHeaders = true; // 是否需要绘制表头(暂时废弃,单做保留,后期优化可能用到)
|
||||
bool isNeedCellSize = true; // 是否需要计算单元格尺寸
|
||||
bool isNeedButtonAndPageNum = true; // 是否需要计算翻页按钮和页码信息
|
||||
|
||||
std::unique_ptr<Button> prevButton; // 上一页按钮
|
||||
std::unique_ptr<Button> nextButton; // 下一页按钮
|
||||
std::unique_ptr<Label> pageNum; //页码文本
|
||||
|
||||
int dX = x, dY = y; // 单元格的开始坐标
|
||||
int uX = x, uY = y; // 单元格的结束坐标
|
||||
|
||||
int pX = 0; //标签左上角坐标
|
||||
int pY = 0; //标签左上角坐标
|
||||
|
||||
StellarX::FillMode tableFillMode = StellarX::FillMode::Solid; //填充模式
|
||||
StellarX::LineStyle tableLineStyle = StellarX::LineStyle::Solid; // 线型
|
||||
COLORREF tableBorderClor = TABLE_DEFAULT_BORDER_COLOR; // 表格边框颜色
|
||||
COLORREF tableBkClor = TABLE_DEFAULT_BG_COLOR; // 表格背景颜色
|
||||
|
||||
void initTextWaH(); // 初始化文本像素宽度和高度,并据此更新列宽/行高缓存
|
||||
void initButton(); // 初始化或刷新上一页/下一页按钮
|
||||
void initPageNum(); // 初始化或刷新页码标签
|
||||
|
||||
void drawTable(); // 绘制当前页的数据行
|
||||
void drawHeader(); // 绘制表头区域
|
||||
void drawPageNum(); // 绘制页码信息
|
||||
void drawButton(); // 绘制翻页按钮
|
||||
// 统一解算后的内部应用入口:
|
||||
// Table 需要复用自己已有的 setWidth/setX 等副作用逻辑,因此单独接管应用过程。
|
||||
void applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect) override;
|
||||
private:
|
||||
// Table 不是模态对话框,固定返回 false。
|
||||
bool model() const override { return false; };
|
||||
public:
|
||||
StellarX::ControlText textStyle; // 文本样式
|
||||
// 修改表格运行态 X,并同步内部页脚元素位置
|
||||
void setX(int x) override;
|
||||
// 修改表格运行态 Y,并同步内部页脚元素位置
|
||||
void setY(int y) override;
|
||||
// 修改表格运行态宽度;当前支持横向拉伸。
|
||||
// 会重新计算列宽、页脚控件位置和托管重绘 coverage。
|
||||
void setWidth(int width) override;
|
||||
// 修改表格运行态高度;当前版本作为行为边界明确忽略纵向 Stretch。
|
||||
// 表格高度由表头、行高、页脚和按钮布局共同计算,外部写入会被有意丢弃。
|
||||
void setHeight(int height) override;
|
||||
public:
|
||||
// 构造一个基础表格,后续通过表头和数据初始化内容
|
||||
Table(int x, int y);
|
||||
// 析构表格并释放内部按钮、页码标签等资源。
|
||||
~Table();
|
||||
|
||||
// 绘制表格背景、表头、当前页数据、页码和翻页按钮。
|
||||
void draw() override;
|
||||
// 处理翻页按钮事件,并在页码变化时请求重绘。
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
// 只清理分页按钮的 hover / tooltip / 临时按下态,不触发表格整体重绘。
|
||||
bool clearTransientMouseState() override;
|
||||
// Table 重绘时会一并绘制页码 Label 和分页按钮,coverage 需要把这些内部绘制单元并入。
|
||||
RECT getManagedRepaintCoverageRect() const override;
|
||||
// 持久 coverage 排除分页按钮 Tooltip 等临时浮层。
|
||||
RECT getManagedRepaintPersistentCoverageRect() const override;
|
||||
|
||||
// 设置表头;会按新的表头列数规范化已有数据行,并重新计算分页和列宽。
|
||||
void setHeaders(std::initializer_list<std::string> headers);
|
||||
// 设置表格数据(单行追加)。
|
||||
// 该接口是 append 语义,不会替换已有数据;如需覆盖请先 clearData() 或 resetTable()。
|
||||
void setData(std::vector<std::string> data);
|
||||
// 设置表格数据(多行追加);如需覆盖请先 clearData() 或 resetTable()
|
||||
void setData(std::initializer_list<std::vector<std::string>> data);
|
||||
// 设置每页显示的行数;小于 1 会被夹紧为 1,并同步修正当前页和总页数。
|
||||
void setRowsPerPage(int rows);
|
||||
// 设置是否显示翻页按钮。
|
||||
void showPageButton(bool isShow);
|
||||
// 设置表格边框颜色。
|
||||
void setTableBorder(COLORREF color);
|
||||
// 设置表格背景颜色。
|
||||
void setTableBk(COLORREF color);
|
||||
// 设置表格填充模式。
|
||||
void setTableFillMode(StellarX::FillMode mode);
|
||||
// 设置表格边框线型。
|
||||
void setTableLineStyle(StellarX::LineStyle style);
|
||||
// 设置表格边框宽度。
|
||||
void setTableBorderWidth(int width);
|
||||
// 清空表头并标记尺寸缓存失效。
|
||||
void clearHeaders();
|
||||
// 清空表格数据并重置分页状态。
|
||||
void clearData();
|
||||
// 清空表头和数据,恢复为空表状态。
|
||||
void resetTable();
|
||||
// 窗口变化时丢弃背景快照并标记表格需要重绘。
|
||||
void onWindowResize() override;
|
||||
|
||||
//************************** 获取属性 *****************************/
|
||||
|
||||
// 获取当前页码。
|
||||
int getCurrentPage() const;
|
||||
// 获取总页数。
|
||||
int getTotalPages() const;
|
||||
// 获取每页显示的行数。
|
||||
int getRowsPerPage() const;
|
||||
// 获取是否显示翻页按钮。
|
||||
bool getShowPageButton() const;
|
||||
// 获取表格边框颜色。
|
||||
COLORREF getTableBorder() const;
|
||||
// 获取表格背景颜色。
|
||||
COLORREF getTableBk() const;
|
||||
// 获取表格填充模式。
|
||||
StellarX::FillMode getTableFillMode() const;
|
||||
// 获取表格边框线型。
|
||||
StellarX::LineStyle getTableLineStyle() const;
|
||||
// 获取表头副本。
|
||||
std::vector<std::string> getHeaders() const;
|
||||
// 获取表格数据副本。
|
||||
std::vector<std::vector<std::string>> getData() const;
|
||||
// 获取表格边框宽度。
|
||||
int getTableBorderWidth() const;
|
||||
// 获取表格当前运行态尺寸;高度为内部内容计算结果,不受 setHeight() 外部写入驱动。
|
||||
int getTableWidth() const;
|
||||
// 获取表格当前运行态高度。
|
||||
int getTableHeight() const;
|
||||
protected:
|
||||
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const override;
|
||||
void collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const override;
|
||||
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason) override;
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/*******************************************************************************
|
||||
* @类: TextBox
|
||||
* @摘要: 文本框控件,支持输入和只读两种模式
|
||||
* @描述:
|
||||
* 提供文本输入和显示功能,集成EasyX的InputBox用于数据输入。
|
||||
* 支持有限的形状样式和视觉定制。
|
||||
*
|
||||
* @特性:
|
||||
* - 两种工作模式:输入模式和只读模式
|
||||
* - 最大字符长度限制
|
||||
* - 集成系统输入框简化文本输入
|
||||
* - 支持四种矩形形状变体
|
||||
*
|
||||
* @使用场景: 数据输入、文本显示、表单字段等
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
|
||||
class TextBox : public Control
|
||||
{
|
||||
std::string text; //文本
|
||||
StellarX::TextBoxmode mode; //模式
|
||||
StellarX::ControlShape shape; //形状
|
||||
bool click = false; //是否点击
|
||||
size_t maxCharLen = 10;//最大字符长度
|
||||
COLORREF textBoxBkClor = RGB(255, 255, 255); //背景颜色
|
||||
COLORREF textBoxBorderClor = RGB(0, 0, 0); //边框颜色
|
||||
|
||||
public:
|
||||
StellarX::ControlText textStyle; //文本样式
|
||||
|
||||
// 构造文本框;支持输入模式和只读模式
|
||||
TextBox(int x, int y, int width, int height, std::string text = "", StellarX::TextBoxmode mode = StellarX::TextBoxmode::INPUT_MODE, StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE);
|
||||
// 绘制文本框
|
||||
void draw() override;
|
||||
// 处理输入框鼠标交互和文本录入
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
// 设置文本框模式。
|
||||
// PASSWORD_MODE 只改变显示形态:内部仍保存真实文本,绘制时临时生成掩码。
|
||||
void setMode(StellarX::TextBoxmode mode);
|
||||
// 设置可输入最大长度;当前按 std::string 字节长度限制,不等同于 Unicode 字符数。
|
||||
// len 为 0 时保持原值;截断逻辑会尽量落在完整 MBCS/GBK 字符边界上。
|
||||
void setMaxCharLen(size_t len);
|
||||
// 设置文本框形状;仅支持矩形/圆角矩形,圆形和椭圆会回退为 RECTANGLE。
|
||||
void setTextBoxshape(StellarX::ControlShape shape);
|
||||
// 设置文本框边框颜色。
|
||||
void setTextBoxBorder(COLORREF color);
|
||||
// 设置文本框背景颜色。
|
||||
void setTextBoxBk(COLORREF color);
|
||||
// 设置当前文本。
|
||||
// 超过 maxCharLen 时按字节长度截断;已绘制且存在父控件时会请求父级托管重绘。
|
||||
void setText(std::string text);
|
||||
|
||||
// 获取当前真实文本;PASSWORD_MODE 下也返回未掩码内容。
|
||||
std::string getText() const;
|
||||
|
||||
private:
|
||||
// TextBox 不是模态对话框,固定返回 false。
|
||||
bool model() const override { return false; };
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Window(头文件)
|
||||
*
|
||||
* 设计目标:
|
||||
* - 提供一个基于 Win32 + EasyX 的“可拉伸且稳定不抖”的窗口容器。
|
||||
* - 通过消息过程子类化(WndProcThunk)接管关键消息(WM_SIZING/WM_SIZE/...)。
|
||||
* - 将“几何变化记录(pendingW/H)”与“统一收口重绘(needResizeDirty)”解耦。
|
||||
* - 在事件分发阶段只改状态并登记重绘 root,在事件尾统一提交,减少分离绘制造成的闪烁。
|
||||
*
|
||||
* 关键点(与 .cpp 中实现对应):
|
||||
* - isSizing:处于交互拉伸阶段时,冻结重绘;松手后统一重绘,防止抖动。
|
||||
* - WM_SIZING:只做“最小尺寸夹紧”,不回滚矩形、不做对齐;把其余交给系统。
|
||||
* - WM_GETMINMAXINFO:按最小“客户区”换算到“窗口矩形”,提供系统层最小轨迹值。
|
||||
* - runEventLoop:输入事件先分发给对话框/控件,控件只登记重绘请求;真正绘制在分发或 resize 收口时统一处理。
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "Control.h"
|
||||
#include "FocusManager.h"
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <windows.h>
|
||||
|
||||
class Window;
|
||||
|
||||
namespace StellarX::Internal
|
||||
{
|
||||
class NativeInputContext;
|
||||
struct NativeInputRuntimeSnapshot;
|
||||
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
|
||||
}
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
class FocusManager;
|
||||
}
|
||||
|
||||
class Window
|
||||
{
|
||||
friend class Dialog;
|
||||
friend class StellarX::FocusManager;
|
||||
friend StellarX::Internal::NativeInputRuntimeSnapshot
|
||||
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept;
|
||||
|
||||
// —— 尺寸状态 ——(绘制尺寸与待应用尺寸分离;收口时一次性更新)
|
||||
int width; // 当前有效宽(已应用到画布/控件的客户区宽)
|
||||
int height; // 当前有效高(已应用到画布/控件的客户区高)
|
||||
int localwidth; // 基准宽(创建时的宽度)
|
||||
int localheight; // 基准高(创建是的高度)
|
||||
int pendingW; // 待应用宽(WM_SIZE/拉伸中记录用)
|
||||
int pendingH; // 待应用高
|
||||
int minClientW; // 业务设定的最小客户区宽(用于 GETMINMAXINFO 与 SIZING 夹紧)
|
||||
int minClientH; // 业务设定的最小客户区高
|
||||
int windowMode = NULL; // EasyX 初始化模式(EX_SHOWCONSOLE/EX_TOPMOST/...)
|
||||
bool needResizeDirty = false; // 统一收口重绘标志(置位后在事件环末尾处理)
|
||||
bool isSizing = false; // 是否处于拖拽阶段(ENTER/EXIT SIZEMOVE 切换)
|
||||
|
||||
// —— 原生窗口句柄与子类化钩子 ——(子类化 EasyX 的窗口过程以拦截关键消息)
|
||||
HWND hWnd = NULL; // EasyX 初始化后的窗口句柄
|
||||
WNDPROC oldWndProc = nullptr; // 保存旧过程(CallWindowProc 回落)
|
||||
bool procHooked = false; // 避免重复子类化
|
||||
// EasyX 的 WndProc 与宿主事件循环分属不同线程;只有 WndProc 完成 WM_NCDESTROY
|
||||
// 转发后才发布 false,避免宿主提前析构仍在执行 WndProcThunk 的 Window 对象。
|
||||
std::atomic<bool> nativeWindowAlive{ false };
|
||||
// 阶段 1 只建立采集、规范化与跨线程队列;宿主安全点只观测排空,不做业务路由。
|
||||
std::unique_ptr<StellarX::Internal::NativeInputContext> nativeInputContext;
|
||||
bool nativeInputFailureReported = false;
|
||||
std::uint64_t nativeInputRejectedTextReported = 0;
|
||||
std::uint64_t nativeInputImeQueryFailuresReported = 0;
|
||||
std::uint64_t nativeInputDiscardedFragmentsReported = 0;
|
||||
bool hasLiveNativeWindow() const noexcept;
|
||||
bool drainNativeInputAtSafePoint();
|
||||
static LRESULT CALLBACK WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l); // 静态过程分发到 this
|
||||
|
||||
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
|
||||
bool useComposited = true; // 是否启用 WS_EX_COMPOSITED(部分机器可能增加一帧观感延迟)
|
||||
std::string headline; // 窗口标题文本
|
||||
COLORREF wBkcolor = BLACK; // 纯色背景(无背景图时使用)
|
||||
std::unique_ptr<IMAGE> background; // 背景图对象指针(存在时优先绘制)
|
||||
std::string bkImageFile; // 背景图文件路径(loadimage 用)
|
||||
|
||||
// —— 控件/对话框 ——(容器内的普通控件与非模态对话框)
|
||||
std::vector<std::unique_ptr<Control>> controls; // 普通顶层控件;绘制顺序也决定层级顺序
|
||||
std::vector<std::unique_ptr<Control>> dialogs; // 非模态对话框;始终位于普通控件之上
|
||||
std::unique_ptr<StellarX::FocusManager> focusManager; // Window 内逻辑焦点唯一所有者
|
||||
// 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 controls。
|
||||
// add/remove/clear 在事件、绘制和回调期间只入队;最外层 mutation scope 结束后再提交。
|
||||
enum class ControlMutationKind { Add, Remove, Clear };
|
||||
struct PendingControlMutation
|
||||
{
|
||||
ControlMutationKind kind = ControlMutationKind::Clear;
|
||||
StellarX::ControlHandle handle{};
|
||||
std::unique_ptr<Control> control;
|
||||
};
|
||||
std::vector<PendingControlMutation> pendingControlMutations;
|
||||
int mutationScopeDepth = 0; // 允许嵌套的结构变更事务深度
|
||||
// 队列提交期间保持该标志,避免提交过程中再次递归提交同一队列。
|
||||
bool applyingControlMutations = false;
|
||||
bool fullSceneRedrawRequested = false; // 非局部变化请求整场景重绘;在事件收口安全点消费
|
||||
bool managedDispatchActive = false; // 事件分发期:控件只改状态并登记重绘,不立即画
|
||||
bool managedSceneDirty = false; // 当前分发轮次是否已经登记了至少一笔托管重绘请求
|
||||
struct ManagedRepaintItem // 托管重绘项:记录由哪个控件发起、需要重绘的根控件和覆盖范围(用于后续判断哪些对话框需要补画)
|
||||
{
|
||||
Control* root = nullptr; // 顶层重绘根(直接挂在 Window 下的控件,或 Dialog 自身)
|
||||
RECT coverage{}; // 本轮脏区覆盖范围;用于判断哪些上层 Dialog 需要补画
|
||||
};
|
||||
std::vector<ManagedRepaintItem> managedRepaintItems; // 本轮事件分发累计的重绘项
|
||||
|
||||
public:
|
||||
bool dialogClose = false; // 项目内使用的状态位,对话框关闭标志
|
||||
mutable bool dialogOpen = false; // 项目内使用的状态位,对话框打开标志
|
||||
|
||||
// —— 构造/析构 ——(仅初始化成员;实际样式与子类化在 draw() 中完成)
|
||||
// 创建纯色背景窗口
|
||||
Window(int width, int height, int mode);
|
||||
// 创建指定背景色窗口
|
||||
Window(int width, int height, int mode, COLORREF bkcloc);
|
||||
// 创建带标题与背景色的窗口
|
||||
Window(int width, int height, int mode, COLORREF bkcloc, std::string headline);
|
||||
// 析构窗口并释放背景、控件与子类化资源
|
||||
~Window();
|
||||
|
||||
// —— 绘制与事件循环 ——(draw* 完成一次全量绘制;runEventLoop 驱动事件与统一收口)
|
||||
void draw(); // 以纯色背景执行一次全量绘制
|
||||
void draw(std::string pImgFile); // 以背景图执行一次全量绘制
|
||||
int runEventLoop(); // 主事件循环(PeekMessage + 统一收口重绘)
|
||||
|
||||
// —— 背景/标题设置 ——(更换背景、背景色与标题;立即触发一次批量绘制)
|
||||
void setBkImage(std::string pImgFile); // 设置窗口背景图
|
||||
void setBkcolor(COLORREF c); // 设置窗口纯色背景
|
||||
void setHeadline(std::string headline); // 设置窗口标题
|
||||
|
||||
// —— 控件/对话框管理 ——(添加到容器,或做存在性判断)
|
||||
// 添加普通顶层控件并返回稳定句柄。
|
||||
// 在事件回调、绘制或其它 mutation transaction 中调用时,control 会先进入待提交队列;
|
||||
// 调用方仍可立即保存返回句柄,但对象要到最外层事务结束后才真正出现在 controls 中。
|
||||
StellarX::ControlHandle addControl(std::unique_ptr<Control> control);
|
||||
// 按句柄移除顶层控件;事务期间延迟删除,避免遍历中的对象或迭代器失效。
|
||||
// 返回 true 表示目标命中且删除请求已排队/提交;返回 false 表示句柄无效或未命中。
|
||||
bool removeControl(StellarX::ControlHandle handle);
|
||||
// 清空所有普通顶层控件;事务期间延迟到安全点提交。
|
||||
void clearControls();
|
||||
// 查找当前已提交的控件;不会暴露 pending 队列中的对象。
|
||||
Control* findControl(StellarX::ControlHandle handle);
|
||||
// 查找当前已提交控件的只读视图;不会暴露 pending 队列中的对象。
|
||||
const Control* findControl(StellarX::ControlHandle handle) const;
|
||||
// 返回当前已提交控件的句柄快照;调用方不得据此推断控件之后仍然存在。
|
||||
std::vector<StellarX::ControlHandle> getControlHandles() const;
|
||||
// 获取当前 Window 的通用逻辑焦点管理器。
|
||||
StellarX::FocusManager& getFocusManager();
|
||||
const StellarX::FocusManager& getFocusManager() const;
|
||||
// 添加非模态对话框;Window 接管生命周期并在事件安全点清理关闭对象。
|
||||
void addDialog(std::unique_ptr<Control> dialogs);
|
||||
//非模态对话框去重
|
||||
bool hasNonModalDialogWithCaption(const std::string& caption, const std::string& message) const; // 检查是否已有同 caption/message 的非模态对话框
|
||||
|
||||
// —— 访问器 ——(只读接口,供外部查询当前窗口/标题/背景等)
|
||||
HWND getHwnd() const; // 获取窗口句柄
|
||||
int getWidth() const; // 获取当前有效客户区宽度
|
||||
int getHeight() const; // 获取当前有效客户区高度
|
||||
int getPendingWidth() const; // 获取待应用宽度
|
||||
int getPendingHeight() const; // 获取待应用高度
|
||||
std::string getHeadline() const; // 获取窗口标题
|
||||
COLORREF getBkcolor() const; // 获取纯色背景
|
||||
IMAGE* getBkImage() const; // 获取背景图对象
|
||||
std::string getBkImageFile() const; // 获取背景图路径
|
||||
|
||||
// —— 尺寸调整 / 托管重绘 ——(事件阶段登记,收口阶段提交)
|
||||
|
||||
// 执行一次 resize 收口 + 统一重绘;只在 pending 尺寸或全场景脏标记存在时真正绘制。
|
||||
void pumpResizeIfNeeded();
|
||||
// 供模态 Dialog 上报宿主窗口尺寸变化;只记录待应用尺寸,实际布局/重绘由窗口收口统一完成。
|
||||
void scheduleResizeFromModal(int w, int h);
|
||||
// 当前是否处于“事件只改状态,不立即画”的阶段。
|
||||
// 控件在该阶段应请求托管重绘,而不是直接回贴背景或立即 draw。
|
||||
bool isManagedDispatchActive() const;
|
||||
// 记录一笔由 source 发起的托管重绘请求。
|
||||
// Window 会提升到对应 root,并在事件收口时合并 coverage 后统一提交。
|
||||
void requestManagedRepaint(Control* source);
|
||||
// 记录一笔托管重绘请求,并显式并入状态清理前的旧覆盖范围。
|
||||
// 用于 hover/tooltip 等状态切换,保证旧区域和新区域都会被清理。
|
||||
void requestManagedRepaint(Control* source, const RECT& previousCoverage);
|
||||
// 在事件收口阶段提交本轮登记的 root 重绘;会补画覆盖在脏区之上的非模态对话框。
|
||||
void flushManagedRepaint();
|
||||
// 进入结构变更事务。允许嵌套,调用者必须保证 begin/end 成对。
|
||||
// 事务内的顶层控件变更只入队,不立即修改 controls/dialogs。
|
||||
void beginMutationScope();
|
||||
// 离开结构变更事务。最外层退出且当前不在托管分发阶段时,会提交整棵控件树的 pending mutation。
|
||||
void endMutationScope();
|
||||
// 判断当前是否处于不允许立即改动控件树的阶段,包括事件分发、显式事务或队列提交过程。
|
||||
bool isMutationTransactionActive() const;
|
||||
// 请求下一安全点执行整场景重绘;用于 Dialog 关闭、全局覆盖关系变化等无法安全局部修复的场景。
|
||||
void requestFullSceneRedraw();
|
||||
private:
|
||||
// FocusManager 专用的递归焦点参与协议;不复用只查直接子级的 findControl()。
|
||||
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const;
|
||||
void collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const;
|
||||
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason);
|
||||
// 提交 Window 顶层控件队列;只允许在最外层事务安全点调用。
|
||||
void flushPendingControlMutations();
|
||||
// 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。
|
||||
void flushPendingMutationsInTree();
|
||||
// 将全部普通控件和非模态对话框标脏,用于强制全场景重绘前统一失效缓存。
|
||||
void markAllControlsDirty();
|
||||
// 根据最终窗口尺寸和控件布局规格,解算并应用控件运行态矩形。
|
||||
void adaptiveLayout(std::unique_ptr<Control>& c, const int finalH, const int finalW);
|
||||
// resize / 初次绘制 / 对话框开关这类全局场景的整场景重绘
|
||||
void redrawScene(bool forceControlsDirty, bool forceDialogsDirty);
|
||||
// 绘制窗口背景;存在背景图时优先贴图,否则填充纯色背景。
|
||||
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();
|
||||
// 找出需要补画到最上层的对话框
|
||||
void collectManagedDialogOverlays(Control* repaintRoot, const RECT& coverage, std::vector<Control*>& overlays);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
/*******************************************************************************
|
||||
* @类: Label
|
||||
* @摘要: 简单文本标签控件,用于显示静态文本
|
||||
* @描述:
|
||||
* 提供基本的文本显示功能,支持透明背景和自定义样式。
|
||||
* 不支持用户交互,专注于文本呈现。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持背景透明/不透明模式
|
||||
* - 完整的文本样式控制(字体、颜色、效果)
|
||||
* - 自动适应文本内容
|
||||
* - 轻量级无事件处理开销
|
||||
*
|
||||
* @使用场景: 显示说明文字、标题、状态信息等静态内容
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
|
||||
class Label : public Control
|
||||
{
|
||||
std::string text; //标签文本
|
||||
COLORREF textColor; //标签文本颜色
|
||||
COLORREF textBkColor; //标签背景颜色
|
||||
bool textBkDisap = false; //标签背景是否透明
|
||||
|
||||
|
||||
|
||||
//标签事件处理(标签无事件)不实现具体代码
|
||||
bool handleEvent(const ExMessage& msg) override { return false; }
|
||||
public:
|
||||
StellarX::ControlText textStyle; //标签文本样式
|
||||
public:
|
||||
Label();
|
||||
Label(int x, int y, std::string text = "标签",COLORREF textcolor = BLACK, COLORREF bkColor= RGB(255,255,255));
|
||||
|
||||
void draw() override;
|
||||
//设置标签背景是否透明
|
||||
void setTextdisap(bool key);
|
||||
//设置标签文本颜色
|
||||
void setTextColor(COLORREF color);
|
||||
//设置标签背景颜色
|
||||
void setTextBkColor(COLORREF color);
|
||||
//设置标签文本
|
||||
void setText(std::string text);
|
||||
|
||||
|
||||
private:
|
||||
//检查是否对话框是否可见
|
||||
bool IsVisible() const override { return false; }
|
||||
//获取对话框类型
|
||||
bool model() const override { return false; }
|
||||
|
||||
};
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/*******************************************************************************
|
||||
* @类: Table
|
||||
* @摘要: 高级表格控件,支持分页显示和大数据量展示
|
||||
* @描述:
|
||||
* 提供完整的数据表格功能,包括表头、数据行、分页导航等。
|
||||
* 自动计算列宽行高,支持自定义样式和交互。
|
||||
*
|
||||
* @特性:
|
||||
* - 自动分页和页码计算
|
||||
* - 可配置的每页行数
|
||||
* - 自定义边框样式和填充模式
|
||||
* - 翻页按钮和页码显示
|
||||
* - 背景缓存优化渲染性能
|
||||
*
|
||||
* @使用场景: 数据展示、报表生成、记录浏览等表格需求
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
#include "Button.h"
|
||||
#include "Label.h"
|
||||
|
||||
class Table :public Control
|
||||
{
|
||||
private:
|
||||
std::vector<std::vector<std::string>> data; // 表格数据
|
||||
std::vector<std::string> headers; // 表格表头
|
||||
std::string pageNumtext = "页码标签"; // 页码标签文本
|
||||
|
||||
int tableBorderWidth = 1; // 边框宽度
|
||||
|
||||
std::vector<int> colWidths; // 每列的宽度
|
||||
std::vector<int> lineHeights; // 每行的高度
|
||||
|
||||
IMAGE* saveBkImage = nullptr;
|
||||
|
||||
int rowsPerPage = 5; // 每页显示的行数
|
||||
int currentPage = 1; // 当前页码
|
||||
int totalPages = 1; // 总页数
|
||||
|
||||
bool isShowPageButton = true; // 是否显示翻页按钮
|
||||
bool isNeedDrawHeaders = true; // 是否需要绘制表头
|
||||
bool isNeedCellSize = true; // 是否需要计算单元格尺寸
|
||||
|
||||
Button* prevButton = nullptr; // 上一页按钮
|
||||
Button* nextButton = nullptr; // 下一页按钮
|
||||
Label* pageNum = nullptr; //页码文本
|
||||
|
||||
int dX = x, dY = y; // 单元格的开始坐标
|
||||
int uX = x, uY = y; // 单元格的结束坐标
|
||||
|
||||
int pX = 0; //标签左上角坐标
|
||||
int pY = 0; //标签左上角坐标
|
||||
|
||||
StellarX::FillMode tableFillMode = StellarX::FillMode::Solid; //填充模式
|
||||
StellarX::LineStyle tableLineStyle = StellarX::LineStyle::Solid; // 线型
|
||||
COLORREF tableBorderClor = RGB(0, 0, 0); // 表格边框颜色
|
||||
COLORREF tableBkClor = RGB(255, 255, 255); // 表格背景颜色
|
||||
|
||||
void initTextWaH(); //初始化文本像素宽度和高度
|
||||
void initButton(); //初始化翻页按钮
|
||||
void initPageNum(); //初始化页码标签
|
||||
|
||||
void drawTable(); //绘制当前页
|
||||
void drawHeader(); //绘制表头
|
||||
void drawPageNum(); //绘制页码信息
|
||||
void drawButton(); //绘制翻页按钮
|
||||
|
||||
//检查是否对话框是否可见
|
||||
bool IsVisible() const override { return false; }
|
||||
//获取对话框类型
|
||||
bool model() const override { return false; }
|
||||
public:
|
||||
StellarX::ControlText textStyle; // 文本样式
|
||||
|
||||
public:
|
||||
Table(int x, int y);
|
||||
~Table();
|
||||
|
||||
// 绘制表格
|
||||
void draw() override;
|
||||
//事件处理
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
|
||||
//设置表头
|
||||
void setHeaders(std::initializer_list<std::string> headers);
|
||||
//设置表格数据
|
||||
void setData(std::vector<std::string> data);
|
||||
void setData(std::initializer_list<std::vector<std::string>> data);
|
||||
//设置每页显示的行数
|
||||
void setRowsPerPage(int rows);
|
||||
//设置是否显示翻页按钮
|
||||
void showPageButton(bool isShow);
|
||||
//设置表格边框颜色
|
||||
void setTableBorder(COLORREF color);
|
||||
//设置表格背景颜色
|
||||
void setTableBk(COLORREF color);
|
||||
//设置填充模式
|
||||
void setTableFillMode(StellarX::FillMode mode);
|
||||
//设置线型
|
||||
void setTableLineStyle(StellarX::LineStyle style);
|
||||
//设置边框宽度
|
||||
void setTableBorderWidth(int width);
|
||||
|
||||
//************************** 获取属性 *****************************/
|
||||
|
||||
//获取当前页码
|
||||
int getCurrentPage() const;
|
||||
//获取总页数
|
||||
int getTotalPages() const;
|
||||
//获取每页显示的行数
|
||||
int getRowsPerPage() const;
|
||||
//获取是否显示翻页按钮
|
||||
bool getShowPageButton() const;
|
||||
//获取表格边框颜色
|
||||
COLORREF getTableBorder() const;
|
||||
//获取表格背景颜色
|
||||
COLORREF getTableBk() const;
|
||||
//获取填充模式
|
||||
StellarX::FillMode getTableFillMode() const;
|
||||
//获取线型
|
||||
StellarX::LineStyle getTableLineStyle() const;
|
||||
//获取表头
|
||||
std::vector<std::string> getHeaders () const;
|
||||
//获取表格数据
|
||||
std::vector<std::vector<std::string>> getData() const;
|
||||
//获取表格边框宽度
|
||||
int getTableBorderWidth() const;
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/*******************************************************************************
|
||||
* @类: TextBox
|
||||
* @摘要: 文本框控件,支持输入和只读两种模式
|
||||
* @描述:
|
||||
* 提供文本输入和显示功能,集成EasyX的InputBox用于数据输入。
|
||||
* 支持有限的形状样式和视觉定制。
|
||||
*
|
||||
* @特性:
|
||||
* - 两种工作模式:输入模式和只读模式
|
||||
* - 最大字符长度限制
|
||||
* - 集成系统输入框简化文本输入
|
||||
* - 支持四种矩形形状变体
|
||||
*
|
||||
* @使用场景: 数据输入、文本显示、表单字段等
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
|
||||
|
||||
class TextBox : public Control
|
||||
{
|
||||
std::string text; //文本
|
||||
StellarX::TextBoxmode mode; //模式
|
||||
StellarX::ControlShape shape; //形状
|
||||
bool click = false; //是否点击
|
||||
size_t maxCharLen = 10;//最大字符长度
|
||||
COLORREF textBoxBkClor = RGB(255, 255, 255); //背景颜色
|
||||
COLORREF textBoxBorderClor = RGB(0,0,0); //边框颜色
|
||||
|
||||
|
||||
//检查是否对话框是否可见
|
||||
bool IsVisible() const override { return false; }
|
||||
//获取对话框类型
|
||||
bool model() const override { return false; }
|
||||
public:
|
||||
StellarX::ControlText textStyle; //文本样式
|
||||
|
||||
TextBox(int x, int y, int width, int height, std::string text = "", StellarX::TextBoxmode mode = StellarX::TextBoxmode::INPUT_MODE, StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE);
|
||||
void draw() override;
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
//设置模式
|
||||
void setMode(StellarX::TextBoxmode mode);
|
||||
//设置可输入最大字符长度
|
||||
void setMaxCharLen(size_t len);
|
||||
//设置形状
|
||||
void setTextBoxshape(StellarX::ControlShape shape);
|
||||
//设置边框颜色
|
||||
void setTextBoxBorder(COLORREF color);
|
||||
//设置背景颜色
|
||||
void setTextBoxBk(COLORREF color);
|
||||
|
||||
//获取文本
|
||||
std::string getText() const;
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
|
||||
#pragma once
|
||||
#include"Control.h"
|
||||
/*******************************************************************************
|
||||
* @类: Window
|
||||
* @摘要: 应用程序主窗口类,管理窗口生命周期和消息循环
|
||||
* @描述:
|
||||
* 创建和管理应用程序主窗口,作为所有控件的根容器。
|
||||
* 处理消息分发、事件循环和渲染调度。
|
||||
*
|
||||
* @特性:
|
||||
* - 多种窗口模式配置(双缓冲、控制台等)
|
||||
* - 背景图片和颜色支持
|
||||
* - 集成的对话框管理系统
|
||||
* - 完整的消息处理循环
|
||||
* - 控件和对话框的生命周期管理
|
||||
*
|
||||
* @使用场景: 应用程序主窗口,GUI程序的入口和核心
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
class Window
|
||||
{
|
||||
int width; //窗口宽度
|
||||
int height; //窗口高度
|
||||
int windowMode = NULL; //窗口模式
|
||||
HWND hWnd = NULL; //窗口句柄
|
||||
std::string headline; //窗口标题
|
||||
COLORREF wBkcolor = BLACK; //窗口背景
|
||||
IMAGE* background = nullptr; //窗口背景图片
|
||||
std::vector<std::unique_ptr<Control>> controls; //控件管理
|
||||
std::vector<std::unique_ptr<Control>> dialogs; //对话框管理
|
||||
|
||||
|
||||
public:
|
||||
bool dialogClose = false; //是否有对话框关闭
|
||||
|
||||
Window(int width, int height, int mode);
|
||||
Window(int width, int height, int mode, COLORREF bkcloc);
|
||||
Window(int width, int height, int mode , COLORREF bkcloc, std::string headline = "窗口");
|
||||
~Window();
|
||||
//绘制窗口
|
||||
void draw();
|
||||
void draw(std::string pImgFile);
|
||||
//事件循环
|
||||
void runEventLoop();
|
||||
//设置窗口背景图片
|
||||
|
||||
void setBkImage(std::string pImgFile);
|
||||
//设置窗口背景颜色
|
||||
void setBkcolor(COLORREF c);
|
||||
//设置窗口标题
|
||||
void setHeadline(std::string headline);
|
||||
//添加控件
|
||||
void addControl(std::unique_ptr<Control> control);
|
||||
//添加对话框
|
||||
void addDialog(std::unique_ptr<Control> dialogs);
|
||||
//检查是否已有对话框显示用于去重,防止工厂模式调用非模态对话框,多次打开污染对话框背景快照
|
||||
bool hasNonModalDialogWithCaption(const std::string& caption) const;
|
||||
|
||||
//获取窗口句柄
|
||||
HWND getHwnd() const;
|
||||
//获取窗口宽度
|
||||
int getWidth() const;
|
||||
//获取窗口高度
|
||||
int getHeight() const;
|
||||
//获取窗口标题
|
||||
std::string getHeadline() const;
|
||||
//获取窗口背景颜色
|
||||
COLORREF getBkcolor() const;
|
||||
//获取窗口背景图片
|
||||
IMAGE* getBkImage() const;
|
||||
//获取控件管理
|
||||
std::vector<std::unique_ptr<Control>>& getControls();
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$resolved = Resolve-Path -LiteralPath $Path
|
||||
$item = Get-Item -LiteralPath $resolved
|
||||
$file = if ($item.PSIsContainer) {
|
||||
Get-ChildItem -LiteralPath $item.FullName -Filter '*.jsonl' -File |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
else {
|
||||
$item
|
||||
}
|
||||
if (-not $file) { throw "No JSONL file found: $Path" }
|
||||
|
||||
$records = New-Object System.Collections.Generic.List[object]
|
||||
$lineNumber = 0
|
||||
Get-Content -LiteralPath $file.FullName | ForEach-Object {
|
||||
$lineNumber++
|
||||
try { $records.Add(($_ | ConvertFrom-Json)) }
|
||||
catch { throw "Invalid JSONL at line $lineNumber in $($file.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
$sequenceStrict = $true
|
||||
for ($index = 1; $index -lt $records.Count; $index++) {
|
||||
if ([uint64]$records[$index].sequence -le [uint64]$records[$index - 1].sequence) {
|
||||
$sequenceStrict = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$immQueries = @($records | Where-Object recordType -eq 'imm_query')
|
||||
$successfulResults = @($immQueries | Where-Object immQueryStatus -eq 'success')
|
||||
$echoSummaries = @($records | Where-Object recordType -eq 'echo_correlation')
|
||||
$textLeaks = @($records | Where-Object {
|
||||
$_.messageCategory -eq 'text' -and $_.wParamHex -ne $null
|
||||
})
|
||||
$lost = @($records | Where-Object recordType -eq 'records_lost')
|
||||
$lostTotal = 0
|
||||
foreach ($lostRecord in $lost) {
|
||||
if ($null -ne $lostRecord.lostCount) {
|
||||
$lostTotal += [int]$lostRecord.lostCount
|
||||
}
|
||||
else {
|
||||
$lostTotal++
|
||||
}
|
||||
}
|
||||
$attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1
|
||||
$closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0
|
||||
$sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0
|
||||
$experimentConfigs = @($records | Where-Object recordType -eq 'experiment_config')
|
||||
if ($experimentConfigs.Count -gt 1) {
|
||||
throw "More than one experiment_config record was found: $($experimentConfigs.Count)"
|
||||
}
|
||||
$experimentConfig = $experimentConfigs | Select-Object -First 1
|
||||
$experimentTargetEnters = @()
|
||||
$experimentTargetLeaves = @()
|
||||
$experimentEnters = @()
|
||||
$experimentLeaves = @()
|
||||
$matchingEasyX = @()
|
||||
$easyxVisibility = $null
|
||||
$experimentValidity = 'not_configured'
|
||||
$imePositionAttempts = @()
|
||||
$imePositionSuccesses = @()
|
||||
$imePositionResultRecords = @()
|
||||
$imePositionOrphans = @()
|
||||
$runtimeFailOpenEnters = @()
|
||||
$runtimeFailOpenLeaves = @()
|
||||
$runtimeFailOpenReason = $null
|
||||
if ($experimentConfig) {
|
||||
$schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
|
||||
$experimentTargetEnters = @($records | Where-Object {
|
||||
$_.recordType -eq 'wndproc_enter' -and
|
||||
$_.experimentId -eq $experimentConfig.experimentId -and
|
||||
$_.ruleId -eq $experimentConfig.ruleId
|
||||
})
|
||||
$targetCallIds = @($experimentTargetEnters | Select-Object -ExpandProperty wndProcCallId)
|
||||
$experimentTargetLeaves = @($records | Where-Object {
|
||||
$_.recordType -eq 'wndproc_leave' -and
|
||||
$targetCallIds -contains $_.wndProcCallId
|
||||
})
|
||||
$runtimeFailOpenEnters = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true)
|
||||
$runtimeFailOpenCallIds = @($runtimeFailOpenEnters | Select-Object -ExpandProperty wndProcCallId)
|
||||
$runtimeFailOpenLeaves = @($experimentTargetLeaves | Where-Object {
|
||||
$runtimeFailOpenCallIds -contains $_.wndProcCallId -and $_.failOpenApplied -eq $true
|
||||
})
|
||||
$runtimeFailOpenRecords = @($runtimeFailOpenEnters) + @($runtimeFailOpenLeaves)
|
||||
$runtimeFailOpenReasons = @($runtimeFailOpenRecords |
|
||||
Select-Object -ExpandProperty failOpenReason -Unique)
|
||||
if ($runtimeFailOpenReasons.Count -eq 1) {
|
||||
$runtimeFailOpenReason = [string]$runtimeFailOpenReasons[0]
|
||||
}
|
||||
$experimentEnters = @($experimentTargetEnters | Where-Object ruleMatched -eq $true)
|
||||
$matchedCallIds = @($experimentEnters | Select-Object -ExpandProperty wndProcCallId)
|
||||
$experimentLeaves = @($records | Where-Object {
|
||||
$_.recordType -eq 'wndproc_leave' -and
|
||||
$matchedCallIds -contains $_.wndProcCallId
|
||||
})
|
||||
$easyXMessageType = if ([string]$experimentConfig.targetMessage -match 'UP$') {
|
||||
'WM_KEYUP'
|
||||
}
|
||||
else {
|
||||
'WM_KEYDOWN'
|
||||
}
|
||||
$targetScanSpecified = $null -ne $experimentConfig.targetScanCode
|
||||
$matchingEasyX = @($records | Where-Object {
|
||||
$_.recordType -eq 'easyx_key' -and
|
||||
$_.easyxMessageType -eq $easyXMessageType -and
|
||||
[int]$_.easyxVkCode -eq [int]$experimentConfig.targetVirtualKey -and
|
||||
(-not $targetScanSpecified -or
|
||||
[int]$_.easyxScanCode -eq [int]$experimentConfig.targetScanCode)
|
||||
})
|
||||
$imePositionResultRecords = @($records | Where-Object recordType -eq 'ime_position')
|
||||
# 兼容本修复前把定位结果直接挂在 wndproc_enter 上的 schema v2 日志。
|
||||
$legacyImePositionAttempts = @($records | Where-Object {
|
||||
$_.recordType -eq 'wndproc_enter' -and $_.imePositionAttempted -eq $true
|
||||
})
|
||||
$imePositionAttempts = @($imePositionResultRecords) + @($legacyImePositionAttempts)
|
||||
$imePositionSuccesses = @($imePositionAttempts | Where-Object {
|
||||
$_.imePositionContextAvailable -eq $true -and
|
||||
$_.compositionPositionSucceeded -eq $true -and
|
||||
$_.candidatePositionSucceeded -eq $true
|
||||
})
|
||||
$sequenceLookup = @{}
|
||||
foreach ($entry in $records) { $sequenceLookup[[string]$entry.sequence] = $true }
|
||||
$imePositionOrphans = @($imePositionResultRecords | Where-Object {
|
||||
$null -eq $_.parentSequence -or
|
||||
-not $sequenceLookup.ContainsKey([string]$_.parentSequence) -or
|
||||
[uint64]$_.parentSequence -ge [uint64]$_.sequence
|
||||
})
|
||||
$easyxVisibility = if ($matchingEasyX.Count -eq 0) {
|
||||
'absent'
|
||||
}
|
||||
elseif ($matchingEasyX.Count -eq 1) {
|
||||
'present_once'
|
||||
}
|
||||
else {
|
||||
'present_multiple'
|
||||
}
|
||||
|
||||
if ($schemaVersions.Count -ne 1 -or [int]$schemaVersions[0] -ne 2) {
|
||||
$experimentValidity = 'invalid_schema'
|
||||
}
|
||||
elseif (-not $sequenceStrict) {
|
||||
$experimentValidity = 'inconclusive_sequence'
|
||||
}
|
||||
elseif ($textLeaks.Count -gt 0) {
|
||||
$experimentValidity = 'inconclusive_text_leak'
|
||||
}
|
||||
elseif ($lostTotal -gt 0) {
|
||||
$experimentValidity = 'inconclusive_capture_loss'
|
||||
}
|
||||
elseif ($imePositionOrphans.Count -gt 0) {
|
||||
$experimentValidity = 'inconclusive_ime_position_link'
|
||||
}
|
||||
elseif (-not $closeObserved) {
|
||||
$experimentValidity = 'inconclusive_close_missing'
|
||||
}
|
||||
elseif (-not $sessionEndObserved) {
|
||||
$experimentValidity = 'inconclusive_session_end_missing'
|
||||
}
|
||||
elseif (-not $experimentConfig.configurationValid) {
|
||||
$experimentValidity = 'invalid_configuration'
|
||||
}
|
||||
elseif (-not $experimentConfig.ruleEligible) {
|
||||
$experimentValidity = 'ineligible_fail_open'
|
||||
}
|
||||
elseif ([string]$experimentConfig.inputSource -eq 'Manual' -and
|
||||
(-not $experimentConfig.imePositionConfigured -or $imePositionSuccesses.Count -eq 0)) {
|
||||
$experimentValidity = 'inconclusive_ime_position'
|
||||
}
|
||||
elseif ($experimentTargetEnters.Count -ne [int]$experimentConfig.maxMatches) {
|
||||
$experimentValidity = 'unexpected_target_count'
|
||||
}
|
||||
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||
$experimentEnters.Count -eq 0 -and
|
||||
$runtimeFailOpenEnters.Count -eq [int]$experimentConfig.maxMatches -and
|
||||
$runtimeFailOpenLeaves.Count -eq $runtimeFailOpenEnters.Count -and
|
||||
$runtimeFailOpenReasons.Count -eq 1 -and
|
||||
-not [string]::IsNullOrWhiteSpace($runtimeFailOpenReason) -and
|
||||
@($runtimeFailOpenLeaves | Where-Object {
|
||||
$_.actualAction -ne 'ObserveAndForward' -or $_.forwarded -ne $true
|
||||
}).Count -eq 0) {
|
||||
$experimentValidity = 'runtime_fail_open'
|
||||
}
|
||||
elseif ($experimentEnters.Count -ne [int]$experimentConfig.maxMatches) {
|
||||
$experimentValidity = 'unexpected_match_count'
|
||||
}
|
||||
elseif ($experimentLeaves.Count -ne $experimentEnters.Count) {
|
||||
$experimentValidity = 'missing_leave_record'
|
||||
}
|
||||
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndConsume').Count -gt 0) {
|
||||
$experimentValidity = 'consume_action_mismatch'
|
||||
}
|
||||
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||
@($experimentLeaves | Where-Object forwarded -eq $true).Count -gt 0) {
|
||||
$experimentValidity = 'consume_was_forwarded'
|
||||
}
|
||||
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
|
||||
@($experimentLeaves | Where-Object forwardResultHex -ne '0x0000000000000000').Count -gt 0) {
|
||||
$experimentValidity = 'consume_result_nonzero'
|
||||
}
|
||||
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
|
||||
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndForward').Count -gt 0) {
|
||||
$experimentValidity = 'forward_action_mismatch'
|
||||
}
|
||||
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
|
||||
@($experimentLeaves | Where-Object forwarded -ne $true).Count -gt 0) {
|
||||
$experimentValidity = 'forward_was_not_forwarded'
|
||||
}
|
||||
else {
|
||||
$experimentValidity = 'valid'
|
||||
}
|
||||
}
|
||||
|
||||
$correlationCounts = [ordered]@{}
|
||||
foreach ($entry in $echoSummaries) {
|
||||
$key = "$($entry.echoChannel):$($entry.correlationStatus)"
|
||||
if (-not $correlationCounts.Contains($key)) { $correlationCounts[$key] = 0 }
|
||||
$correlationCounts[$key]++
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
file = $file.FullName
|
||||
recordCount = $records.Count
|
||||
schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
|
||||
sequenceStrict = $sequenceStrict
|
||||
recordsLost = $lostTotal
|
||||
lossRecordCount = $lost.Count
|
||||
textRawWParamLeaks = $textLeaks.Count
|
||||
isWindowUnicode = $attach.isWindowUnicode
|
||||
ansiCodePage = $attach.ansiCodePage
|
||||
wndProcThreadIds = @($records | Where-Object sourceBackend -eq 'Win32WndProc' |
|
||||
Select-Object -ExpandProperty threadId -Unique)
|
||||
hostThreadIds = @($records | Where-Object sourceBackend -eq 'HostEventLoop' |
|
||||
Select-Object -ExpandProperty threadId -Unique)
|
||||
imeStartCount = @($records | Where-Object messageName -eq 'WM_IME_STARTCOMPOSITION').Count
|
||||
imeCompositionCount = @($records | Where-Object messageName -eq 'WM_IME_COMPOSITION').Count
|
||||
imeEndCount = @($records | Where-Object messageName -eq 'WM_IME_ENDCOMPOSITION').Count
|
||||
inputLanguageChangeRequestCount = @($records |
|
||||
Where-Object messageName -eq 'WM_INPUTLANGCHANGEREQUEST').Count
|
||||
inputLanguageChangeCount = @($records | Where-Object messageName -eq 'WM_INPUTLANGCHANGE').Count
|
||||
immQueryCount = $immQueries.Count
|
||||
successfulResultCount = $successfulResults.Count
|
||||
successfulUtf16Lengths = @($successfulResults | Select-Object -ExpandProperty utf16Length)
|
||||
successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount)
|
||||
digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count
|
||||
correlationCounts = [pscustomobject]$correlationCounts
|
||||
closeObserved = $closeObserved
|
||||
sessionEndObserved = $sessionEndObserved
|
||||
experimentConfigured = $null -ne $experimentConfig
|
||||
experimentId = if ($experimentConfig) { $experimentConfig.experimentId } else { $null }
|
||||
ruleId = if ($experimentConfig) { $experimentConfig.ruleId } else { $null }
|
||||
ruleEligible = if ($experimentConfig) { [bool]$experimentConfig.ruleEligible } else { $null }
|
||||
experimentFailOpenReason = if ($experimentConfig) { $experimentConfig.failOpenReason } else { $null }
|
||||
runtimeFailOpenReason = $runtimeFailOpenReason
|
||||
requestedAction = if ($experimentConfig) { $experimentConfig.requestedAction } else { $null }
|
||||
targetMessage = if ($experimentConfig) { $experimentConfig.targetMessage } else { $null }
|
||||
targetVirtualKey = if ($experimentConfig) { $experimentConfig.targetVirtualKey } else { $null }
|
||||
targetScanCode = if ($experimentConfig) { $experimentConfig.targetScanCode } else { $null }
|
||||
baselineId = if ($experimentConfig) { $experimentConfig.baselineId } else { $null }
|
||||
targetObservationCount = $experimentTargetEnters.Count
|
||||
targetLeaveCount = $experimentTargetLeaves.Count
|
||||
failOpenObservationCount = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true).Count
|
||||
failOpenForwardedCount = @($experimentTargetLeaves | Where-Object {
|
||||
$_.failOpenApplied -eq $true -and $_.forwarded -eq $true
|
||||
}).Count
|
||||
ruleMatchCount = $experimentEnters.Count
|
||||
consumedMatchCount = @($experimentEnters | Where-Object actualAction -eq 'ObserveAndConsume').Count
|
||||
forwardedMatchCount = @($experimentLeaves | Where-Object forwarded -eq $true).Count
|
||||
matchingEasyXKeyCount = $matchingEasyX.Count
|
||||
easyxVisibility = $easyxVisibility
|
||||
imePositionAttemptCount = $imePositionAttempts.Count
|
||||
imePositionSuccessCount = $imePositionSuccesses.Count
|
||||
imePositionReady = $imePositionSuccesses.Count -gt 0
|
||||
imePositionResultRecordCount = $imePositionResultRecords.Count
|
||||
imePositionOrphanCount = $imePositionOrphans.Count
|
||||
experimentValidity = $experimentValidity
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SessionId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StartedAtUtc,
|
||||
[int]$ProbeExitCode = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-FailureCategory {
|
||||
param([object]$Case)
|
||||
if (-not $Case.evidenceComplete) { return 'evidence_missing' }
|
||||
if (-not $Case.targetChanged) { return 'surface_unreachable' }
|
||||
if (-not $Case.nonTargetUnchanged) { return 'surface_cross_write' }
|
||||
if (-not $Case.gdiStateRestored) { return 'gdi_state_not_restored' }
|
||||
if (-not $Case.wideContentPreservedAfterEasyXFollowUp) {
|
||||
return 'measurement_relation_invalid'
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function New-WideTextCases {
|
||||
param([object]$Raw, [object[]]$EvidenceFiles)
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($case in @($Raw.result.cases)) {
|
||||
$caseFailure = Get-FailureCategory -Case $case
|
||||
$caseEvidence = @($EvidenceFiles | Where-Object {
|
||||
$_.path -like "evidence/$($case.testCaseId)_*"
|
||||
})
|
||||
$phases = @('after_draw')
|
||||
if ($case.batchState -eq 'inside') { $phases += @('after_flush', 'after_end') }
|
||||
foreach ($text in @($case.textTests)) {
|
||||
$textFailure = $caseFailure
|
||||
if (-not $textFailure -and (-not $text.isValidUtf16 -or
|
||||
-not $text.getTextExtentPoint32W -or -not $text.textOutW -or
|
||||
[int]$text.drawTextWHeight -le 0)) {
|
||||
$textFailure = 'measurement_relation_invalid'
|
||||
}
|
||||
foreach ($phase in $phases) {
|
||||
$fingerprint = switch ($phase) {
|
||||
'after_draw' { $case.observations.afterDraw; break }
|
||||
'after_flush' { $case.observations.afterFlush; break }
|
||||
'after_end' { $case.observations.afterEnd; break }
|
||||
}
|
||||
$status = if ($textFailure) { 'fail' } else { 'pass' }
|
||||
$items.Add([pscustomobject][ordered]@{
|
||||
testCaseId = "$($case.testCaseId)/$($text.testStringId)/$phase"
|
||||
testStringId = $text.testStringId
|
||||
codePoints = @($text.codePoints)
|
||||
utf16Units = @($text.utf16Units)
|
||||
surfaceId = $case.surfaceId
|
||||
batchState = $case.batchState
|
||||
observationPhase = $phase
|
||||
hdc = $fingerprint.hdc
|
||||
hbitmap = $fingerprint.hbitmap
|
||||
hfont = $fingerprint.hfont
|
||||
bitmapWidth = $fingerprint.bitmapWidth
|
||||
bitmapHeight = $fingerprint.bitmapHeight
|
||||
planes = $fingerprint.planes
|
||||
bitsPerPixel = $fingerprint.bitsPerPixel
|
||||
stride = $fingerprint.stride
|
||||
dibSection = $fingerprint.dibSection
|
||||
logFont = $fingerprint.logFont
|
||||
gdiStateBefore = $case.before.gdiState
|
||||
gdiStateAfter = $fingerprint.gdiState
|
||||
apiResults = [pscustomobject][ordered]@{
|
||||
getTextExtentPoint32W = $text.getTextExtentPoint32W
|
||||
textOutW = $text.textOutW
|
||||
drawTextWHeight = $text.drawTextWHeight
|
||||
}
|
||||
logicalExtent = $text.logicalExtent
|
||||
textOutInkBounds = $text.textOutInkBounds
|
||||
drawTextInkBounds = $text.drawTextInkBounds
|
||||
targetPixelsBefore = $case.before.pixelDigest
|
||||
targetPixelsAfter = $fingerprint.pixelDigest
|
||||
controlPixelsBefore = $case.nonTargetBefore.pixelDigest
|
||||
controlPixelsAfter = $case.nonTargetAfterDraw.pixelDigest
|
||||
easyXFollowUp = [pscustomobject][ordered]@{
|
||||
wideContentPreserved = $case.wideContentPreservedAfterEasyXFollowUp
|
||||
surfaceFingerprint = $case.observations.afterEasyXFollowUp
|
||||
}
|
||||
automatedStatus = $status
|
||||
manualStatus = 'not_reviewed'
|
||||
failureCategory = $textFailure
|
||||
evidenceFiles = $caseEvidence
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return $items.ToArray()
|
||||
}
|
||||
|
||||
function New-DpiCoordinateCases {
|
||||
param([object]$Raw, [object[]]$EvidenceFiles)
|
||||
$failure = $null
|
||||
if ($EvidenceFiles.Count -lt @($Raw.evidenceFiles).Count) { $failure = 'evidence_missing' }
|
||||
elseif (-not $Raw.result.gdiStateRestored) { $failure = 'gdi_state_not_restored' }
|
||||
elseif (-not $Raw.result.roundTripsExact) { $failure = 'coordinate_transform_invalid' }
|
||||
elseif (-not $Raw.result.gdiExtentSuccess -or -not $Raw.result.gdiTextSuccess) {
|
||||
$failure = 'measurement_relation_invalid'
|
||||
}
|
||||
$status = if ($failure) { 'fail' } else { 'pass' }
|
||||
$decision = if ($Raw.result.implicitScalingObserved) {
|
||||
'requires_explicit_transform_validation'
|
||||
}
|
||||
else {
|
||||
'direct_hwnd_relative_coordinates_supported_at_observed_dpi'
|
||||
}
|
||||
return @([pscustomobject][ordered]@{
|
||||
testCaseId = 'dpi-coordinate-current-environment'
|
||||
surfaceId = 'main'
|
||||
batchState = $Raw.result.batchState
|
||||
observationPhase = 'after_end'
|
||||
dpiAwareness = [pscustomobject][ordered]@{
|
||||
process = $Raw.environment.processAwareness
|
||||
thread = $Raw.environment.threadAwareness
|
||||
window = $Raw.environment.windowAwareness
|
||||
}
|
||||
windowDpi = $Raw.environment.dpiForWindow
|
||||
clientRect = $Raw.environment.clientRect
|
||||
windowScreenRect = $Raw.environment.windowRect
|
||||
surfaceSize = [pscustomobject]@{ width = 960; height = 720 }
|
||||
surfaceFingerprint = $Raw.result.surfaceFingerprintAfterDraw
|
||||
surfaceBounds = $Raw.result.surfaceBounds
|
||||
clientBounds = $Raw.result.clientBounds
|
||||
coordinateRoundTrips = @($Raw.result.coordinateRoundTrips)
|
||||
surfaceMarkerSizesAgree = $Raw.result.surfaceMarkerSizesAgree
|
||||
clientMatchesSurface = $Raw.result.clientMatchesSurface
|
||||
implicitScalingObserved = $Raw.result.implicitScalingObserved
|
||||
coordinateDecision = $decision
|
||||
automatedStatus = $status
|
||||
manualStatus = 'not_reviewed'
|
||||
failureCategory = $failure
|
||||
evidenceFiles = $EvidenceFiles
|
||||
})
|
||||
}
|
||||
|
||||
function New-ImePositionCases {
|
||||
param([object]$Raw, [object[]]$EvidenceFiles)
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($anchor in @($Raw.result.anchors)) {
|
||||
$events = @($Raw.result.imeEvents | Where-Object {
|
||||
$_.syntheticAnchorId -eq $anchor.syntheticAnchorId -and $_.apiAttempted
|
||||
})
|
||||
$successful = @($events | Where-Object {
|
||||
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
|
||||
})
|
||||
$preconfigureEvents = @($events | Where-Object {
|
||||
$_.positionTrigger -eq 'anchor_change' -or $_.stage -eq 'anchor_preconfigure'
|
||||
})
|
||||
$successfulPreconfigure = @($preconfigureEvents | Where-Object {
|
||||
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
|
||||
})
|
||||
$lifecycleEvents = @($events | Where-Object {
|
||||
$_.positionTrigger -eq 'ime_lifecycle' -or
|
||||
$_.stage -eq 'ime_start' -or $_.stage -eq 'ime_composition'
|
||||
})
|
||||
$successfulLifecycle = @($lifecycleEvents | Where-Object {
|
||||
$_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
|
||||
})
|
||||
$latest = $events | Select-Object -Last 1
|
||||
$failure = if ($successful.Count -gt 0) { $null } else { 'ime_position_invalid' }
|
||||
$status = if ($failure) { 'fail' } else { 'pass' }
|
||||
$anchorEvidence = @($EvidenceFiles | Where-Object {
|
||||
$_.path -like "evidence/$($anchor.syntheticAnchorId)_*" -or
|
||||
$_.path -like "manual-evidence/$($anchor.syntheticAnchorId)_*"
|
||||
})
|
||||
$items.Add([pscustomobject][ordered]@{
|
||||
testCaseId = "ime-position/$($anchor.syntheticAnchorId)"
|
||||
syntheticAnchorId = $anchor.syntheticAnchorId
|
||||
anchorCoordinates = [pscustomobject][ordered]@{
|
||||
controlOrigin = $anchor.controlOrigin
|
||||
controlLocalCaretTopLeft = $anchor.controlLocalCaretTopLeft
|
||||
controlLocalCaretBottom = $anchor.controlLocalCaretBottom
|
||||
controlLocalLineRect = $anchor.controlLocalLineRect
|
||||
easyxSurfaceCaretTopLeft = $anchor.easyxSurfaceCaretTopLeft
|
||||
easyxSurfaceCaretBottom = $anchor.easyxSurfaceCaretBottom
|
||||
easyxSurfaceLineRect = $anchor.easyxSurfaceLineRect
|
||||
targetHwndRelativePoint = $anchor.targetHwndRelativePoint
|
||||
diagnosticScreenPoint = if ($latest) { $latest.diagnosticScreenPoint } else { $null }
|
||||
}
|
||||
compositionForm = if ($latest) { $latest.compositionForm } else { $null }
|
||||
candidateForm = if ($latest) { $latest.candidateForm } else { $null }
|
||||
imeId = if ($latest) { $latest.imeDescriptionUtf16 } else { @() }
|
||||
imeFileName = if ($latest) { $latest.imeFileNameUtf16 } else { @() }
|
||||
hkl = if ($latest) { $latest.hkl } else { $null }
|
||||
apiAttemptCount = $events.Count
|
||||
successfulApiAttemptCount = $successful.Count
|
||||
preconfigureApiAttemptCount = $preconfigureEvents.Count
|
||||
successfulPreconfigureApiAttemptCount = $successfulPreconfigure.Count
|
||||
lifecycleApiAttemptCount = $lifecycleEvents.Count
|
||||
successfulLifecycleApiAttemptCount = $successfulLifecycle.Count
|
||||
positionDisposition = 'not_observable'
|
||||
automatedStatus = $status
|
||||
manualStatus = 'not_reviewed'
|
||||
failureCategory = $failure
|
||||
evidenceFiles = $anchorEvidence
|
||||
})
|
||||
}
|
||||
return $items.ToArray()
|
||||
}
|
||||
|
||||
$resolved = Resolve-Path -LiteralPath $Path
|
||||
$item = Get-Item -LiteralPath $resolved
|
||||
$directory = if ($item.PSIsContainer) { $item.FullName } else { $item.DirectoryName }
|
||||
$rawPath = if ($item.PSIsContainer) { Join-Path $directory 'raw-result.json' } else { $item.FullName }
|
||||
if (-not (Test-Path -LiteralPath $rawPath)) { throw "Raw result not found: $rawPath" }
|
||||
|
||||
$raw = Get-Content -Raw -LiteralPath $rawPath | ConvertFrom-Json
|
||||
if ([int]$raw.rawSchemaVersion -ne 1) { throw "Unsupported raw schema: $($raw.rawSchemaVersion)" }
|
||||
if ($raw.mode -notin @('wide-text', 'dpi-coordinate', 'ime-position')) {
|
||||
throw "Unsupported probe mode: $($raw.mode)"
|
||||
}
|
||||
|
||||
$evidence = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($entry in @($raw.evidenceFiles)) {
|
||||
$relativePath = [string]$entry.path
|
||||
$nativeRelative = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
|
||||
$fullPath = Join-Path $directory $nativeRelative
|
||||
if (-not (Test-Path -LiteralPath $fullPath)) { continue }
|
||||
$file = Get-Item -LiteralPath $fullPath
|
||||
$evidence.Add([pscustomobject][ordered]@{
|
||||
path = $relativePath
|
||||
purpose = $entry.purpose
|
||||
captureBackend = $entry.captureBackend
|
||||
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
|
||||
byteLength = $file.Length
|
||||
crop = $null
|
||||
})
|
||||
}
|
||||
$rawEvidencePresent = $evidence.Count
|
||||
$manualEvidenceDirectory = Join-Path $directory 'manual-evidence'
|
||||
if (Test-Path -LiteralPath $manualEvidenceDirectory) {
|
||||
Get-ChildItem -LiteralPath $manualEvidenceDirectory -Filter '*.png' -File | ForEach-Object {
|
||||
$evidence.Add([pscustomobject][ordered]@{
|
||||
path = 'manual-evidence/' + $_.Name
|
||||
purpose = 'manually cropped IME position evidence'
|
||||
captureBackend = 'manual_crop'
|
||||
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash
|
||||
byteLength = $_.Length
|
||||
crop = 'user_supplied'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$evidenceArray = $evidence.ToArray()
|
||||
$testCases = if ($raw.mode -eq 'wide-text') {
|
||||
New-WideTextCases -Raw $raw -EvidenceFiles $evidenceArray
|
||||
}
|
||||
elseif ($raw.mode -eq 'dpi-coordinate') {
|
||||
New-DpiCoordinateCases -Raw $raw -EvidenceFiles $evidenceArray
|
||||
}
|
||||
else {
|
||||
New-ImePositionCases -Raw $raw -EvidenceFiles $evidenceArray
|
||||
}
|
||||
|
||||
$overallManualStatus = 'not_reviewed'
|
||||
$sourceManualReview = $null
|
||||
$manualReviewSummary = $null
|
||||
$manualReviewPath = Join-Path $directory 'manual-review-v1.json'
|
||||
if (Test-Path -LiteralPath $manualReviewPath) {
|
||||
$manualReview = Get-Content -Raw -LiteralPath $manualReviewPath | ConvertFrom-Json
|
||||
if ([int]$manualReview.manualReviewSchemaVersion -ne 1) {
|
||||
throw "Unsupported manual review schema: $($manualReview.manualReviewSchemaVersion)"
|
||||
}
|
||||
if ([string]$manualReview.mode -ne [string]$raw.mode) {
|
||||
throw "Manual review mode does not match raw result: $($manualReview.mode)"
|
||||
}
|
||||
$allowedManualStatuses = @('pass', 'fail', 'inconclusive', 'not_reviewed', 'not_available')
|
||||
$allowedPositionDispositions = @('as_requested', 'system_adjusted', 'not_observable')
|
||||
$reviewedIds = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($reviewCase in @($manualReview.testCases)) {
|
||||
$testCaseId = [string]$reviewCase.testCaseId
|
||||
if ($reviewedIds.Contains($testCaseId)) { throw "Duplicate manual review case: $testCaseId" }
|
||||
$reviewedIds.Add($testCaseId)
|
||||
$matchingCases = @($testCases | Where-Object testCaseId -eq $testCaseId)
|
||||
if ($matchingCases.Count -ne 1) { throw "Unknown manual review case: $testCaseId" }
|
||||
$manualStatus = [string]$reviewCase.manualStatus
|
||||
$positionDisposition = [string]$reviewCase.positionDisposition
|
||||
if ($manualStatus -notin $allowedManualStatuses) {
|
||||
throw "Unsupported manual status for ${testCaseId}: $manualStatus"
|
||||
}
|
||||
if ($positionDisposition -notin $allowedPositionDispositions) {
|
||||
throw "Unsupported position disposition for ${testCaseId}: $positionDisposition"
|
||||
}
|
||||
$targetCase = $matchingCases[0]
|
||||
if ($manualStatus -eq 'pass' -and $targetCase.automatedStatus -ne 'pass') {
|
||||
throw "Manual pass cannot override automated failure: $testCaseId"
|
||||
}
|
||||
$reviewEvidence = @($reviewCase.evidenceFiles)
|
||||
if ($manualStatus -eq 'pass' -and $reviewEvidence.Count -eq 0) {
|
||||
throw "Manual pass requires explicit evidence files: $testCaseId"
|
||||
}
|
||||
foreach ($relativePath in $reviewEvidence) {
|
||||
$relative = [string]$relativePath
|
||||
if ($relative -notlike 'manual-evidence/*') {
|
||||
throw "Manual review evidence must be under manual-evidence: $relative"
|
||||
}
|
||||
$matchingEvidence = @($targetCase.evidenceFiles | Where-Object path -eq $relative)
|
||||
if ($matchingEvidence.Count -ne 1) {
|
||||
throw "Manual review evidence is missing or belongs to another case: $relative"
|
||||
}
|
||||
}
|
||||
if ($manualStatus -eq 'pass' -and $positionDisposition -eq 'not_observable') {
|
||||
throw "Manual pass requires an observable position disposition: $testCaseId"
|
||||
}
|
||||
$targetCase.manualStatus = $manualStatus
|
||||
$targetCase.positionDisposition = $positionDisposition
|
||||
$targetCase | Add-Member -NotePropertyName manualReviewNotes `
|
||||
-NotePropertyValue ([string]$reviewCase.notes) -Force
|
||||
}
|
||||
if ($reviewedIds.Count -ne @($testCases).Count) {
|
||||
throw 'Manual review must cover every manifest test case'
|
||||
}
|
||||
$caseManualStatuses = @($testCases | ForEach-Object { $_.manualStatus })
|
||||
$derivedManualStatus = if ($caseManualStatuses -contains 'fail') {
|
||||
'fail'
|
||||
}
|
||||
elseif ($caseManualStatuses -contains 'inconclusive') {
|
||||
'inconclusive'
|
||||
}
|
||||
elseif ($caseManualStatuses -contains 'not_reviewed') {
|
||||
'not_reviewed'
|
||||
}
|
||||
elseif ($caseManualStatuses -contains 'not_available') {
|
||||
if (@($caseManualStatuses | Where-Object { $_ -ne 'not_available' }).Count -eq 0) {
|
||||
'not_available'
|
||||
}
|
||||
else {
|
||||
'inconclusive'
|
||||
}
|
||||
}
|
||||
else {
|
||||
'pass'
|
||||
}
|
||||
if ([string]$manualReview.overallStatus -ne $derivedManualStatus) {
|
||||
throw "Manual review overall status mismatch: expected $derivedManualStatus"
|
||||
}
|
||||
$overallManualStatus = $derivedManualStatus
|
||||
$sourceManualReview = [pscustomobject][ordered]@{
|
||||
path = 'manual-review-v1.json'
|
||||
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $manualReviewPath).Hash
|
||||
}
|
||||
$manualReviewSummary = [pscustomobject][ordered]@{
|
||||
reviewedAtUtc = $manualReview.reviewedAtUtc
|
||||
reviewer = $manualReview.reviewer
|
||||
expectedImeName = $manualReview.expectedImeName
|
||||
actualImeName = $manualReview.actualImeName
|
||||
identitySource = $manualReview.identitySource
|
||||
notes = $manualReview.notes
|
||||
}
|
||||
}
|
||||
|
||||
$failedCases = @($testCases | Where-Object automatedStatus -eq 'fail')
|
||||
$allEvidencePresent = $rawEvidencePresent -eq @($raw.evidenceFiles).Count
|
||||
$overallFailure = $null
|
||||
if (-not $allEvidencePresent) { $overallFailure = 'evidence_missing' }
|
||||
elseif ($failedCases.Count -gt 0) { $overallFailure = $failedCases[0].failureCategory }
|
||||
elseif (-not $raw.completed -or $ProbeExitCode -ne 0) { $overallFailure = 'inconclusive_probe_exit' }
|
||||
$overallStatus = if ($overallFailure) { 'fail' } else { 'pass' }
|
||||
|
||||
$manifest = [pscustomobject][ordered]@{
|
||||
visualProbeSchemaVersion = 1
|
||||
sessionId = $SessionId
|
||||
startedAtUtc = $StartedAtUtc
|
||||
completedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
mode = $raw.mode
|
||||
processId = $raw.environment.processId
|
||||
threadId = $raw.environment.threadId
|
||||
osBuild = $raw.environment.osBuild
|
||||
easyxVersion = $raw.environment.easyxVersion
|
||||
probeExecutableExitCode = $ProbeExitCode
|
||||
dpiConfiguration = $raw.dpiConfiguration
|
||||
environment = $raw.environment
|
||||
automatedStatus = $overallStatus
|
||||
manualStatus = $overallManualStatus
|
||||
failureCategory = $overallFailure
|
||||
sourceRawResult = [pscustomobject][ordered]@{
|
||||
path = 'raw-result.json'
|
||||
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $rawPath).Hash
|
||||
}
|
||||
sourceManualReview = $sourceManualReview
|
||||
manualReview = $manualReviewSummary
|
||||
evidenceFiles = $evidenceArray
|
||||
testCases = @($testCases)
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $directory 'manifest-v1.json'
|
||||
$manifestJson = $manifest | ConvertTo-Json -Depth 100
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8Bom)
|
||||
$manifestHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $manifestPath).Hash
|
||||
$sidecarPath = $manifestPath + '.sha256'
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[IO.File]::WriteAllText($sidecarPath, "$manifestHash *manifest-v1.json`r`n", $utf8NoBom)
|
||||
|
||||
[pscustomobject][ordered]@{
|
||||
mode = $raw.mode
|
||||
manifestPath = $manifestPath
|
||||
manifestSha256 = $manifestHash
|
||||
automatedStatus = $overallStatus
|
||||
manualStatus = $overallManualStatus
|
||||
testCaseCount = @($testCases).Count
|
||||
evidenceCount = $evidence.Count
|
||||
dpiForWindow = $raw.environment.dpiForWindow
|
||||
windowAwareness = $raw.environment.windowAwareness
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Debug',
|
||||
[ValidateSet('x64', 'Win32')]
|
||||
[string]$Platform = 'x64'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$probe = Join-Path $root "build\$Platform\$Configuration\EasyX.ThreadProbe.exe"
|
||||
if (-not (Test-Path -LiteralPath $probe)) {
|
||||
throw "Thread probe not built: $probe"
|
||||
}
|
||||
|
||||
$resultRoot = Join-Path $root 'TestResults\easyx-thread-probe'
|
||||
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
|
||||
$resultPath = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID.json")
|
||||
|
||||
& $probe $resultPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "EasyX thread probe failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $resultPath)) {
|
||||
throw "Thread probe result not found: $resultPath"
|
||||
}
|
||||
|
||||
$result = Get-Content -Raw -LiteralPath $resultPath | ConvertFrom-Json
|
||||
if (-not $result.completed) {
|
||||
throw "Thread probe did not complete: $resultPath"
|
||||
}
|
||||
|
||||
$result | Add-Member -NotePropertyName resultPath -NotePropertyValue $resultPath
|
||||
$result
|
||||
@@ -0,0 +1,41 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('MicrosoftPinyin', 'Sogou')]
|
||||
[string]$ImeName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('ObserveAndForward', 'ObserveAndConsume')]
|
||||
[string]$Action,
|
||||
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
|
||||
[string]$Message = 'WM_KEYDOWN',
|
||||
[ValidateRange(1, 255)]
|
||||
[int]$VirtualKey = 229,
|
||||
[ValidateRange(-1, 255)]
|
||||
[int]$ScanCode = 1,
|
||||
[string]$OperatorKeyLabel = 'Escape',
|
||||
[ValidateSet('IdleOnly', 'ComposingOnly')]
|
||||
[string]$ImeCondition = 'ComposingOnly',
|
||||
[ValidateSet('none', 'ime_condition_not_met')]
|
||||
[string]$ExpectedFailOpenReason = 'none',
|
||||
[string]$BaselineSet,
|
||||
[switch]$ConfirmImeIdentity
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$runner = Join-Path $PSScriptRoot 'run-input-consume-probe.ps1'
|
||||
$arguments = @{
|
||||
Action = $Action
|
||||
Message = $Message
|
||||
VirtualKey = $VirtualKey
|
||||
ScanCode = $ScanCode
|
||||
OperatorKeyLabel = $OperatorKeyLabel
|
||||
InputSource = 'Manual'
|
||||
ImeCondition = $ImeCondition
|
||||
ImeName = $ImeName
|
||||
ConfirmImeIdentity = [bool]$ConfirmImeIdentity
|
||||
ExpectedFailOpenReason = $ExpectedFailOpenReason
|
||||
}
|
||||
if ($BaselineSet) {
|
||||
$arguments.BaselineSet = $BaselineSet
|
||||
}
|
||||
|
||||
& $runner @arguments
|
||||
@@ -0,0 +1,576 @@
|
||||
param(
|
||||
[ValidateSet('Both', 'ObserveAndForward', 'ObserveAndConsume')]
|
||||
[string]$Action = 'Both',
|
||||
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
|
||||
[string]$Message = 'WM_KEYDOWN',
|
||||
[ValidateRange(1, 255)]
|
||||
[int]$VirtualKey = 0x87,
|
||||
[ValidateRange(-1, 255)]
|
||||
[int]$ScanCode = -1,
|
||||
[string]$OperatorKeyLabel,
|
||||
[string]$BaselineSet,
|
||||
[ValidateRange(1, 65535)]
|
||||
[int]$ExpectedAnsiCodePage = 936,
|
||||
[string]$RuleBaselineId,
|
||||
[ValidateSet('none', 'baseline_id_mismatch', 'ime_condition_not_met')]
|
||||
[string]$ExpectedFailOpenReason = 'none',
|
||||
[ValidateSet('SendInput', 'Manual')]
|
||||
[string]$InputSource = 'SendInput',
|
||||
[ValidateSet('IdleOnly', 'ComposingOnly')]
|
||||
[string]$ImeCondition = 'IdleOnly',
|
||||
[ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
|
||||
[string]$ImeName = 'unspecified',
|
||||
[switch]$ConfirmImeIdentity
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$project = Join-Path $root 'tests\StellarX.UiFixture.vcxproj'
|
||||
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
|
||||
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||
$expectedBaselineId = 'sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5'
|
||||
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
|
||||
if ($ExpectedFailOpenReason -ne 'none' -and $Action -ne 'ObserveAndConsume') {
|
||||
throw 'A fail-open negative run must request only ObserveAndConsume'
|
||||
}
|
||||
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and -not $RuleBaselineId) {
|
||||
throw 'A baseline mismatch run must pass -RuleBaselineId with an intentionally different value'
|
||||
}
|
||||
if ($InputSource -eq 'SendInput' -and $ImeCondition -ne 'IdleOnly') {
|
||||
throw 'Automatic SendInput runs are limited to IdleOnly; Composition requires real manual IME input'
|
||||
}
|
||||
if ($InputSource -eq 'Manual' -and $Action -eq 'Both') {
|
||||
throw 'Manual runs must select one action so each process contains exactly one operator exercise'
|
||||
}
|
||||
if ($InputSource -eq 'Manual' -and ($ImeName -eq 'unspecified' -or -not $ConfirmImeIdentity)) {
|
||||
throw 'Manual runs require -ImeName and the explicit -ConfirmImeIdentity switch'
|
||||
}
|
||||
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and $InputSource -ne 'SendInput') {
|
||||
throw 'The baseline mismatch negative check uses the controlled SendInput path'
|
||||
}
|
||||
if ($ExpectedFailOpenReason -eq 'ime_condition_not_met' -and
|
||||
($InputSource -ne 'Manual' -or $ImeCondition -ne 'ComposingOnly')) {
|
||||
throw 'The IME condition runtime fail-open check requires Manual + ComposingOnly'
|
||||
}
|
||||
$effectiveScanCode = if ($ScanCode -lt 0) { 256 } else { $ScanCode }
|
||||
$operatorKeyDisplay = if ([string]::IsNullOrWhiteSpace($OperatorKeyLabel)) {
|
||||
"VK=$VirtualKey"
|
||||
}
|
||||
else {
|
||||
$OperatorKeyLabel.Trim()
|
||||
}
|
||||
|
||||
function Get-Sha256Text([string]$text) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($text)
|
||||
return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '')
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-BaselineSet([string]$requestedPath) {
|
||||
if ($requestedPath) {
|
||||
return (Resolve-Path -LiteralPath $requestedPath).Path
|
||||
}
|
||||
$candidates = @(Get-ChildItem -LiteralPath (Join-Path $root 'TestResults\VisualProbe') `
|
||||
-Filter 'baseline-set-v1.json' -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending)
|
||||
foreach ($candidate in $candidates) {
|
||||
try {
|
||||
$candidateValue = Get-Content -Raw -LiteralPath $candidate.FullName | ConvertFrom-Json
|
||||
if ([string]$candidateValue.combinedBaselineId -eq $expectedBaselineId) {
|
||||
return $candidate.FullName
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# 无效历史产物不参与当前基线选择,继续寻找下一个可解析候选。
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw 'No matching 0C baseline-set-v1.json was found; pass -BaselineSet explicitly'
|
||||
}
|
||||
|
||||
function Read-VerifiedBaseline([string]$path) {
|
||||
$resolvedPath = (Resolve-Path -LiteralPath $path).Path
|
||||
$sidecarPath = $resolvedPath + '.sha256'
|
||||
if (-not (Test-Path -LiteralPath $sidecarPath)) { throw "Baseline sidecar missing: $sidecarPath" }
|
||||
$actualBaselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPath).Hash
|
||||
$sidecarHash = ((Get-Content -Raw -LiteralPath $sidecarPath).Trim() -split '\s+')[0].ToUpperInvariant()
|
||||
if ($actualBaselineHash -ne $sidecarHash) { throw 'Baseline-set SHA-256 does not match its sidecar' }
|
||||
|
||||
$baseline = Get-Content -Raw -LiteralPath $resolvedPath | ConvertFrom-Json
|
||||
if ([int]$baseline.visualProbeBaselineSchemaVersion -ne 1) { throw 'Unsupported visual baseline schema' }
|
||||
if ([string]$baseline.automatedStatus -ne 'pass') { throw '0C baseline automatedStatus is not pass' }
|
||||
$directory = Split-Path -Parent $resolvedPath
|
||||
$drawingPath = Join-Path $directory ([string]$baseline.drawingBaselineManifest)
|
||||
$coordinatePath = Join-Path $directory ([string]$baseline.coordinateBaselineManifest)
|
||||
if (-not (Test-Path -LiteralPath $drawingPath)) { throw "Drawing baseline missing: $drawingPath" }
|
||||
if (-not (Test-Path -LiteralPath $coordinatePath)) { throw "Coordinate baseline missing: $coordinatePath" }
|
||||
$drawingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $drawingPath).Hash
|
||||
$coordinateHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $coordinatePath).Hash
|
||||
if ($drawingHash -ne [string]$baseline.drawingBaselineSha256) { throw 'Drawing baseline SHA-256 mismatch' }
|
||||
if ($coordinateHash -ne [string]$baseline.coordinateBaselineSha256) { throw 'Coordinate baseline SHA-256 mismatch' }
|
||||
$combined = 'sha256:' + (Get-Sha256Text "$drawingHash`:$coordinateHash")
|
||||
if ($combined -ne [string]$baseline.combinedBaselineId) { throw 'Combined baseline ID mismatch' }
|
||||
if ($combined -ne $expectedBaselineId) { throw "Unsupported 0C baseline ID: $combined" }
|
||||
|
||||
$coordinate = Get-Content -Raw -LiteralPath $coordinatePath | ConvertFrom-Json
|
||||
$awarenessMap = @{ unaware = 0; system = 1; 'per-monitor' = 2; 'per-monitor-v2' = 2 }
|
||||
$awarenessName = [string]$baseline.dpiAwareness
|
||||
if (-not $awarenessMap.ContainsKey($awarenessName)) {
|
||||
throw "Unsupported DPI awareness in baseline: $awarenessName"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
path = $resolvedPath
|
||||
sha256 = $actualBaselineHash
|
||||
combinedBaselineId = $combined
|
||||
dpiForWindow = [int]$baseline.dpiForWindow
|
||||
dpiAwareness = [int]$awarenessMap[$awarenessName]
|
||||
windowUnicode = [bool]$coordinate.environment.windowUnicode
|
||||
drawingSha256 = $drawingHash
|
||||
coordinateSha256 = $coordinateHash
|
||||
}
|
||||
}
|
||||
|
||||
# Windows PowerShell 5.1 能精确收口宿主注入的 PATH/Path 重复键;只影响当前 runner。
|
||||
$savedPath = $env:Path
|
||||
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
|
||||
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
|
||||
|
||||
$baselineInfo = Read-VerifiedBaseline (Resolve-BaselineSet $BaselineSet)
|
||||
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 `
|
||||
/p:StellarXInputProbe=true /v:minimal
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Input consume probe fixture build failed' }
|
||||
if (-not (Test-Path -LiteralPath $fixture)) { throw "Fixture not found: $fixture" }
|
||||
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class StellarXInputConsumeNative {
|
||||
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT {
|
||||
public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] private struct KEYBDINPUT {
|
||||
public ushort Vk; public ushort Scan; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
|
||||
}
|
||||
[StructLayout(LayoutKind.Explicit)] private struct INPUTUNION {
|
||||
[FieldOffset(0)] public MOUSEINPUT Mouse;
|
||||
[FieldOffset(0)] public KEYBDINPUT Keyboard;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] private struct INPUT {
|
||||
public uint Type; public INPUTUNION Data;
|
||||
}
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern IntPtr FindWindowW(string className, string title);
|
||||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr h);
|
||||
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr h, int command);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern bool GetWindowRect(IntPtr h, out RECT rect);
|
||||
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT point);
|
||||
[DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] private static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern bool AttachThreadInput(uint attach, uint attachTo, bool value);
|
||||
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint message, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size);
|
||||
public static IntPtr FindWindowByTitle(string title) { return FindWindowW(null, title); }
|
||||
public static uint GetWindowProcessId(IntPtr h) {
|
||||
uint processId;
|
||||
if (GetWindowThreadProcessId(h, out processId) == 0 || processId == 0) throw new Win32Exception();
|
||||
return processId;
|
||||
}
|
||||
public static bool ActivateWindow(IntPtr h) {
|
||||
uint ignored;
|
||||
uint currentThread = GetCurrentThreadId();
|
||||
uint targetThread = GetWindowThreadProcessId(h, out ignored);
|
||||
IntPtr foreground = GetForegroundWindow();
|
||||
uint foregroundThread = foreground == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foreground, out ignored);
|
||||
bool attachedForeground = foregroundThread != 0 && foregroundThread != currentThread &&
|
||||
AttachThreadInput(currentThread, foregroundThread, true);
|
||||
bool attachedTarget = targetThread != 0 && targetThread != currentThread && targetThread != foregroundThread &&
|
||||
AttachThreadInput(currentThread, targetThread, true);
|
||||
try {
|
||||
ShowWindow(h, 9);
|
||||
BringWindowToTop(h);
|
||||
SetForegroundWindow(h);
|
||||
return GetForegroundWindow() == h;
|
||||
}
|
||||
finally {
|
||||
if (attachedTarget) AttachThreadInput(currentThread, targetThread, false);
|
||||
if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false);
|
||||
}
|
||||
}
|
||||
public static long ClickWindowForActivation(IntPtr h) {
|
||||
RECT rect;
|
||||
POINT original;
|
||||
if (!GetWindowRect(h, out rect) || !GetCursorPos(out original)) throw new Win32Exception();
|
||||
const uint noMoveNoSize = 0x0001 | 0x0002;
|
||||
SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, noMoveNoSize);
|
||||
int x = rect.Left + Math.Max(16, (rect.Right - rect.Left) / 2);
|
||||
int y = rect.Top + Math.Max(40, (rect.Bottom - rect.Top) / 2);
|
||||
try {
|
||||
if (!SetCursorPos(x, y)) throw new Win32Exception();
|
||||
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
|
||||
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
|
||||
int outsideX = rect.Left > 48 ? rect.Left - 32 : rect.Right + 32;
|
||||
int outsideY = rect.Top > 48 ? rect.Top - 32 : rect.Bottom + 32;
|
||||
SetCursorPos(outsideX, outsideY);
|
||||
}
|
||||
finally {
|
||||
SetWindowPos(h, new IntPtr(-2), 0, 0, 0, 0, noMoveNoSize);
|
||||
}
|
||||
return ((long)original.X << 32) | (uint)original.Y;
|
||||
}
|
||||
public static void RestoreCursorPosition(long packed) {
|
||||
SetCursorPos((int)(packed >> 32), (int)(packed & 0xffffffffL));
|
||||
}
|
||||
private static void SendKey(ushort virtualKey, bool up) {
|
||||
var inputs = new INPUT[1];
|
||||
inputs[0].Type = 1; inputs[0].Data.Keyboard.Vk = virtualKey;
|
||||
if (up) inputs[0].Data.Keyboard.Flags = 0x0002;
|
||||
if (SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception();
|
||||
}
|
||||
public static void SendKeyDown(ushort virtualKey) { SendKey(virtualKey, false); }
|
||||
public static void SendKeyUp(ushort virtualKey) { SendKey(virtualKey, true); }
|
||||
}
|
||||
'@
|
||||
|
||||
function Wait-Window([string]$title, [int]$timeoutMs = 15000) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||||
$window = [StellarXInputConsumeNative]::FindWindowByTitle($title)
|
||||
if ($window -ne [IntPtr]::Zero) {
|
||||
$fixturePid = [StellarXInputConsumeNative]::GetWindowProcessId($window)
|
||||
try {
|
||||
$fixtureProcess = [Diagnostics.Process]::GetProcessById([int]$fixturePid)
|
||||
return [pscustomobject]@{
|
||||
window = $window
|
||||
process = $fixtureProcess
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# 窗口可能正处于销毁边界;继续等待同标题的有效新实例。
|
||||
$fixtureProcess = $null
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
throw "Window not found: $title"
|
||||
}
|
||||
|
||||
function Invoke-ProbeRun([string]$requestedAction, [string]$sessionRoot, [string]$experimentId) {
|
||||
$actionSlug = if ($requestedAction -eq 'ObserveAndForward') { 'forward' } else { 'consume' }
|
||||
$runDirectory = Join-Path $sessionRoot $actionSlug
|
||||
$fixtureDirectory = Join-Path $runDirectory 'fixture'
|
||||
New-Item -ItemType Directory -Force -Path $fixtureDirectory | Out-Null
|
||||
$scanSlug = if ($ScanCode -lt 0) { 'scan-any' } else { "scan-$ScanCode" }
|
||||
$ruleId = "$($Message.ToLowerInvariant())-vk$VirtualKey-$scanSlug-$($ImeCondition.ToLowerInvariant())-$actionSlug"
|
||||
$windowUnicode = if ($baselineInfo.windowUnicode) { '1' } else { '0' }
|
||||
$effectiveRuleBaselineId = if ($RuleBaselineId) { $RuleBaselineId } else { $baselineInfo.combinedBaselineId }
|
||||
$launchParts = @(
|
||||
'set "STELLARX_INPUT_PROBE=1"',
|
||||
'set "STELLARX_INPUT_PROBE_0B=1"',
|
||||
"set `"STELLARX_INPUT_PROBE_DIR=$runDirectory`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=$experimentId`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_RULE_ID=$ruleId`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_ACTION=$requestedAction`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_MESSAGE=$Message`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=$VirtualKey`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_SCAN_CODE=$effectiveScanCode`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_IME_CONDITION=$ImeCondition`"",
|
||||
'set "STELLARX_INPUT_PROBE_0B_MAX_MATCHES=1"',
|
||||
"set `"STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=$InputSource`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_BASELINE_ID=$effectiveRuleBaselineId`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=$windowUnicode`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=$ExpectedAnsiCodePage`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=$($baselineInfo.dpiForWindow)`"",
|
||||
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=$($baselineInfo.dpiAwareness)`""
|
||||
)
|
||||
if ($InputSource -eq 'Manual') {
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_IDENTITY_CONFIRMED=1"'
|
||||
$launchParts += "set `"STELLARX_INPUT_PROBE_0B_IME_NAME=$ImeName`""
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_X=225"'
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_Y=282"'
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT=200"'
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_TOP=255"'
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_RIGHT=680"'
|
||||
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_BOTTOM=284"'
|
||||
}
|
||||
$launchParts += ('"{0}" input-consume-probe "{1}"' -f $fixture, $fixtureDirectory)
|
||||
$launchCommand = $launchParts -join ' & '
|
||||
|
||||
$title = 'StellarX.UI.input-consume-probe'
|
||||
if ([StellarXInputConsumeNative]::FindWindowByTitle($title) -ne [IntPtr]::Zero) {
|
||||
throw "A stale fixture window is already open: $title"
|
||||
}
|
||||
$launcherProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/c', $launchCommand) -PassThru
|
||||
$window = [IntPtr]::Zero
|
||||
$fixtureProcess = $null
|
||||
$savedCursorPosition = $null
|
||||
$keyDownSent = $false
|
||||
$keyUpSent = $false
|
||||
try {
|
||||
$windowState = Wait-Window $title
|
||||
$window = $windowState.window
|
||||
$fixtureProcess = $windowState.process
|
||||
if ($InputSource -eq 'Manual') {
|
||||
Write-Host "Manual 0B window ready: $ImeName / $ImeCondition / $requestedAction"
|
||||
Write-Host 'Complete the printed checklist in the EasyX window, then close it.'
|
||||
[void]$fixtureProcess.WaitForExit()
|
||||
}
|
||||
else {
|
||||
Start-Sleep -Milliseconds 400
|
||||
$windowShell = New-Object -ComObject WScript.Shell
|
||||
[void]$windowShell.AppActivate($fixtureProcess.Id)
|
||||
Start-Sleep -Milliseconds 400
|
||||
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
|
||||
[void][StellarXInputConsumeNative]::ActivateWindow($window)
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
|
||||
$savedCursorPosition = [StellarXInputConsumeNative]::ClickWindowForActivation($window)
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
|
||||
throw 'Fixture did not become the foreground window'
|
||||
}
|
||||
[StellarXInputConsumeNative]::SendKeyDown([uint16]$VirtualKey)
|
||||
$keyDownSent = $true
|
||||
Start-Sleep -Milliseconds 250
|
||||
[StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey)
|
||||
$keyUpSent = $true
|
||||
Start-Sleep -Milliseconds 800
|
||||
if (-not [StellarXInputConsumeNative]::PostMessage($window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) {
|
||||
throw 'Failed to post WM_CLOSE to the input consume fixture'
|
||||
}
|
||||
if (-not $fixtureProcess.WaitForExit(10000)) { throw 'Fixture did not exit after WM_CLOSE' }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($keyDownSent -and -not $keyUpSent) {
|
||||
try { [StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey) }
|
||||
catch { Write-Verbose 'Failed to release synthetic key during cleanup' }
|
||||
}
|
||||
if ($fixtureProcess) { $fixtureProcess.Refresh() }
|
||||
if ($fixtureProcess -and -not $fixtureProcess.HasExited -and $window -ne [IntPtr]::Zero) {
|
||||
[void][StellarXInputConsumeNative]::PostMessage(
|
||||
$window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
|
||||
[void]$fixtureProcess.WaitForExit(3000)
|
||||
$fixtureProcess.Refresh()
|
||||
}
|
||||
if ($fixtureProcess -and -not $fixtureProcess.HasExited) {
|
||||
Stop-Process -Id $fixtureProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$launcherProcess.Refresh()
|
||||
if (-not $launcherProcess.HasExited) {
|
||||
[void]$launcherProcess.WaitForExit(3000)
|
||||
$launcherProcess.Refresh()
|
||||
}
|
||||
if (-not $launcherProcess.HasExited) {
|
||||
Stop-Process -Id $launcherProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($null -ne $savedCursorPosition) {
|
||||
[StellarXInputConsumeNative]::RestoreCursorPosition([long]$savedCursorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
$summary = & (Join-Path $PSScriptRoot 'analyze-input-probe.ps1') -Path $runDirectory
|
||||
if ($ExpectedFailOpenReason -eq 'none') {
|
||||
if ($summary.experimentValidity -ne 'valid') {
|
||||
throw "Invalid $requestedAction experiment: $($summary.experimentValidity)"
|
||||
}
|
||||
}
|
||||
elseif ($ExpectedFailOpenReason -eq 'baseline_id_mismatch') {
|
||||
$failOpenValid =
|
||||
$summary.experimentValidity -eq 'ineligible_fail_open' -and
|
||||
$summary.experimentFailOpenReason -eq $ExpectedFailOpenReason -and
|
||||
$summary.targetObservationCount -eq 1 -and
|
||||
$summary.targetLeaveCount -eq 1 -and
|
||||
$summary.failOpenObservationCount -eq 1 -and
|
||||
$summary.failOpenForwardedCount -eq 1 -and
|
||||
$summary.ruleMatchCount -eq 0
|
||||
if (-not $failOpenValid) {
|
||||
throw "Invalid fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.experimentFailOpenReason)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$failOpenValid =
|
||||
$summary.experimentValidity -eq 'runtime_fail_open' -and
|
||||
$summary.runtimeFailOpenReason -eq $ExpectedFailOpenReason -and
|
||||
$summary.targetObservationCount -eq 1 -and
|
||||
$summary.targetLeaveCount -eq 1 -and
|
||||
$summary.failOpenObservationCount -eq 1 -and
|
||||
$summary.failOpenForwardedCount -eq 1 -and
|
||||
$summary.ruleMatchCount -eq 0 -and
|
||||
$summary.consumedMatchCount -eq 0 -and
|
||||
$summary.matchingEasyXKeyCount -eq 1
|
||||
if (-not $failOpenValid) {
|
||||
throw "Invalid runtime fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.runtimeFailOpenReason)"
|
||||
}
|
||||
}
|
||||
$actualFailOpenReason = if ($summary.experimentFailOpenReason -and
|
||||
$summary.experimentFailOpenReason -ne 'none') {
|
||||
$summary.experimentFailOpenReason
|
||||
}
|
||||
else {
|
||||
$summary.runtimeFailOpenReason
|
||||
}
|
||||
[pscustomobject][ordered]@{
|
||||
action = $requestedAction
|
||||
runDirectory = $runDirectory
|
||||
file = $summary.file
|
||||
fixtureProcessId = if ($fixtureProcess) { $fixtureProcess.Id } else { $null }
|
||||
sequenceStrict = $summary.sequenceStrict
|
||||
recordsLost = $summary.recordsLost
|
||||
ruleMatchCount = $summary.ruleMatchCount
|
||||
consumedMatchCount = $summary.consumedMatchCount
|
||||
forwardedMatchCount = $summary.forwardedMatchCount
|
||||
matchingEasyXKeyCount = $summary.matchingEasyXKeyCount
|
||||
easyxVisibility = $summary.easyxVisibility
|
||||
imePositionAttemptCount = $summary.imePositionAttemptCount
|
||||
imePositionSuccessCount = $summary.imePositionSuccessCount
|
||||
imePositionReady = $summary.imePositionReady
|
||||
imePositionResultRecordCount = $summary.imePositionResultRecordCount
|
||||
imePositionOrphanCount = $summary.imePositionOrphanCount
|
||||
closeObserved = $summary.closeObserved
|
||||
sessionEndObserved = $summary.sessionEndObserved
|
||||
expectedFailOpenReason = $ExpectedFailOpenReason
|
||||
actualFailOpenReason = $actualFailOpenReason
|
||||
experimentFailOpenReason = $summary.experimentFailOpenReason
|
||||
runtimeFailOpenReason = $summary.runtimeFailOpenReason
|
||||
targetObservationCount = $summary.targetObservationCount
|
||||
targetLeaveCount = $summary.targetLeaveCount
|
||||
failOpenObservationCount = $summary.failOpenObservationCount
|
||||
failOpenForwardedCount = $summary.failOpenForwardedCount
|
||||
experimentValidity = $summary.experimentValidity
|
||||
}
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||
$comparisonId = [Guid]::NewGuid().ToString()
|
||||
$sessionRoot = Join-Path $root "TestResults\InputProbe\0B\$stamp-$comparisonId"
|
||||
New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
|
||||
$actions = if ($Action -eq 'Both') {
|
||||
@('ObserveAndForward', 'ObserveAndConsume')
|
||||
}
|
||||
else {
|
||||
@($Action)
|
||||
}
|
||||
$results = @()
|
||||
if ($InputSource -eq 'Manual') {
|
||||
Write-Host "Manual IME consume probe: $ImeName"
|
||||
$scanDescription = if ($ScanCode -lt 0) { 'any scan code' } else { "scanCode=$ScanCode" }
|
||||
Write-Host " Rule: $Message VK=$VirtualKey, $scanDescription, $ImeCondition, $Action"
|
||||
Write-Host ' 1. Confirm the selected input method really matches -ImeName, then click the EasyX window.'
|
||||
if ($ImeCondition -eq 'ComposingOnly') {
|
||||
Write-Host ' 2. Type only ceshi until the composition/candidate UI is visible.'
|
||||
Write-Host " 3. Press $operatorKeyDisplay exactly once while composition is active; release it normally."
|
||||
}
|
||||
else {
|
||||
Write-Host " 2. Before starting composition, press $operatorKeyDisplay exactly once; release it normally."
|
||||
Write-Host ' 3. Then type only ceshi to confirm ordinary candidate behavior still works.'
|
||||
}
|
||||
Write-Host ' 4. Check candidate, commit/cancel behavior and position against the accepted 0C baseline.'
|
||||
Write-Host ' 5. Close the EasyX window. The runner will validate privacy-safe structural evidence.'
|
||||
}
|
||||
foreach ($requestedAction in $actions) {
|
||||
Write-Host "Running 0B $requestedAction $Message VK=$VirtualKey scan=$effectiveScanCode"
|
||||
$results += Invoke-ProbeRun $requestedAction $sessionRoot $comparisonId
|
||||
}
|
||||
|
||||
$forward = $results | Where-Object action -eq 'ObserveAndForward' | Select-Object -First 1
|
||||
$consume = $results | Where-Object action -eq 'ObserveAndConsume' | Select-Object -First 1
|
||||
$allRunsComplete = @($results | Where-Object {
|
||||
-not $_.sequenceStrict -or
|
||||
$_.recordsLost -gt 0 -or
|
||||
-not $_.closeObserved -or
|
||||
-not $_.sessionEndObserved
|
||||
}).Count -eq 0
|
||||
$comparisonStatus = if (-not $allRunsComplete) {
|
||||
'inconclusive'
|
||||
}
|
||||
elseif ($ExpectedFailOpenReason -ne 'none' -and @($results | Where-Object {
|
||||
$_.actualFailOpenReason -ne $ExpectedFailOpenReason -or
|
||||
$_.failOpenObservationCount -ne 1 -or
|
||||
$_.failOpenForwardedCount -ne 1
|
||||
}).Count -gt 0) {
|
||||
'inconclusive'
|
||||
}
|
||||
elseif ($Action -eq 'Both' -and (-not $forward -or -not $consume)) {
|
||||
'inconclusive'
|
||||
}
|
||||
elseif ($Action -eq 'Both' -and $forward.matchingEasyXKeyCount -lt 1) {
|
||||
'inconclusive'
|
||||
}
|
||||
else {
|
||||
'pass'
|
||||
}
|
||||
$observedRelation = if (-not $consume) {
|
||||
'not_measured'
|
||||
}
|
||||
elseif ($consume.experimentValidity -eq 'runtime_fail_open') {
|
||||
'easyx_visible_after_fail_open_forward'
|
||||
}
|
||||
elseif ($consume.easyxVisibility -eq 'absent') {
|
||||
'easyx_suppressed_by_wndproc_consume'
|
||||
}
|
||||
elseif ($consume.easyxVisibility -eq 'present_once' -or $consume.easyxVisibility -eq 'present_multiple') {
|
||||
'easyx_still_visible_after_wndproc_consume'
|
||||
}
|
||||
else {
|
||||
'inconclusive'
|
||||
}
|
||||
|
||||
$comparison = [pscustomobject][ordered]@{
|
||||
inputConsumeProbeSchemaVersion = 1
|
||||
comparisonId = $comparisonId
|
||||
createdAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
targetMessage = $Message
|
||||
targetVirtualKey = $VirtualKey
|
||||
targetScanCode = if ($ScanCode -lt 0) { $null } else { $ScanCode }
|
||||
inputSource = $InputSource
|
||||
imeCondition = $ImeCondition
|
||||
imeName = if ($InputSource -eq 'Manual') { $ImeName } else { $null }
|
||||
imeIdentityConfirmed = $InputSource -eq 'Manual' -and $ConfirmImeIdentity
|
||||
expectedFailOpenReason = $ExpectedFailOpenReason
|
||||
baselineSet = $baselineInfo.path
|
||||
baselineSetSha256 = $baselineInfo.sha256
|
||||
combinedBaselineId = $baselineInfo.combinedBaselineId
|
||||
drawingBaselineSha256 = $baselineInfo.drawingSha256
|
||||
coordinateBaselineSha256 = $baselineInfo.coordinateSha256
|
||||
automatedStatus = $comparisonStatus
|
||||
observedRelation = $observedRelation
|
||||
finalVerdict = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
|
||||
'MustForward'
|
||||
}
|
||||
else {
|
||||
'Inconclusive'
|
||||
}
|
||||
finalVerdictReason = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
|
||||
'The candidate UI was visible, but the HWND exposed no reliable composing state; the consume request fail-opened to Forward.'
|
||||
}
|
||||
elseif ($InputSource -eq 'Manual') {
|
||||
'Structural capture only; operator review of IME behavior and 0C position evidence is still required.'
|
||||
}
|
||||
else {
|
||||
'Core EX_KEY relation only; the required message and IME matrix is not complete.'
|
||||
}
|
||||
runs = @($results)
|
||||
}
|
||||
$comparisonPath = Join-Path $sessionRoot 'comparison-v1.json'
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[IO.File]::WriteAllText($comparisonPath,
|
||||
($comparison | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
|
||||
$comparisonHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $comparisonPath).Hash
|
||||
$comparison | Format-List
|
||||
Write-Host "Comparison: $comparisonPath"
|
||||
Write-Host "Comparison SHA-256: $comparisonHash"
|
||||
if ($comparisonStatus -ne 'pass') { throw '0B automatic comparison is inconclusive' }
|
||||
@@ -0,0 +1,76 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('MicrosoftPinyin', 'Sogou')]
|
||||
[string]$ImeName,
|
||||
[switch]$InputLanguageOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||
$project = Join-Path $root 'tests\StellarX.UiFixture.vcxproj'
|
||||
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
|
||||
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
|
||||
|
||||
$pathKeyCount = @(
|
||||
[Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys |
|
||||
Where-Object { [string]$_ -ieq 'Path' }
|
||||
).Count
|
||||
if ($pathKeyCount -gt 1) {
|
||||
# Codex 等宿主可能注入同时存在的 PATH/Path;MSBuild 启动 CL 时会因重复键失败。
|
||||
# cmd 的 set 精确删除第一份 PATH 后仍保留 Path,只影响这个构建子进程。
|
||||
$buildCommand = 'set "PATH=" & "' + $msbuild + '" "' + $project +
|
||||
'" /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:StellarXInputProbe=true /v:minimal'
|
||||
& $env:ComSpec /d /c $buildCommand
|
||||
}
|
||||
else {
|
||||
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:StellarXInputProbe=true /v:minimal
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Input probe fixture build failed' }
|
||||
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||
$runDir = Join-Path $root "TestResults\InputProbe\manual-$stamp-$ImeName"
|
||||
$fixtureDir = Join-Path $runDir 'fixture'
|
||||
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $fixtureDir | Out-Null
|
||||
|
||||
Write-Host "Manual IME probe: $ImeName"
|
||||
if ($InputLanguageOnly) {
|
||||
Write-Host 'Input-language-only checklist:'
|
||||
Write-Host ' 1. Click the EasyX window so it owns keyboard focus'
|
||||
Write-Host ' 2. Use Win+Space to select a different language/keyboard layout, for example English (US)'
|
||||
Write-Host ' Switching only between two Chinese IMEs stays in the same HKL and may not emit request/change'
|
||||
Write-Host ' 3. Type A, then use Win+Space to return to the original input method/layout'
|
||||
}
|
||||
else {
|
||||
Write-Host 'Use only these non-sensitive strings:'
|
||||
Write-Host ' 1. zhongwentest -> choose a Chinese candidate and commit'
|
||||
Write-Host ' 2. haha -> commit the same Chinese character twice'
|
||||
Write-Host ' 3. ceshi -> press Escape before committing'
|
||||
Write-Host ' 4. Type a candidate sequence and select it with a number key'
|
||||
Write-Host ' 5. Use Win+Space to select a different language/keyboard layout (for example English (US)), then type A'
|
||||
}
|
||||
Write-Host 'Close the EasyX window when finished. The script will then print a privacy-safe summary.'
|
||||
|
||||
$launchCommand = 'set "STELLARX_INPUT_PROBE=1" & set "STELLARX_INPUT_PROBE_DIR=' + $runDir +
|
||||
'" & "' + $fixture + '" input-probe "' + $fixtureDir + '"'
|
||||
& $env:ComSpec /d /c $launchCommand
|
||||
$exitCode = $LASTEXITCODE
|
||||
$summary = & (Join-Path $PSScriptRoot 'analyze-input-probe.ps1') -Path $runDir
|
||||
|
||||
# EX_SHOWCONSOLE 下由脚本托管的 EasyX 进程在原生标题栏关闭时,部分控制台宿主会把
|
||||
# 正常窗口关闭映射为 STATUS_CONTROL_C_EXIT。必须先验证探测日志完整性和关闭证据,
|
||||
# 不能无条件忽略这个非零退出码,否则真实崩溃也可能被误报为成功。
|
||||
$controlCExit = -1073741510 # 0xC000013A
|
||||
$validNativeClose = $summary.sequenceStrict -and
|
||||
$summary.closeObserved -and
|
||||
$summary.textRawWParamLeaks -eq 0
|
||||
$summary | Format-List
|
||||
Write-Host "Result directory: $runDir"
|
||||
if ($exitCode -ne 0 -and -not ($exitCode -eq $controlCExit -and $validNativeClose)) {
|
||||
throw "Input probe fixture exited with code $exitCode"
|
||||
}
|
||||
|
||||
if ($exitCode -eq $controlCExit) {
|
||||
Write-Host 'Fixture exit code: 0xC000013A (accepted after validating native-close probe records).'
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
param(
|
||||
[int]$Key
|
||||
)
|
||||
|
||||
# Interactive launcher for the manual KEY1-KEY6 UI regression scenarios.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$project = Join-Path $root 'imGui-easyX.vcxproj'
|
||||
$executable = Join-Path $root 'build\x64\Debug\imGui-easyX.exe'
|
||||
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||
$hasKeyArgument = $PSBoundParameters.ContainsKey('Key')
|
||||
|
||||
function Show-KeyMenu {
|
||||
Write-Host ''
|
||||
Write-Host 'StellarX manual UI regression scenarios'
|
||||
Write-Host ' 1 - TabControl and Table redraw'
|
||||
Write-Host ' 2 - Layout, resize, and bit operations'
|
||||
Write-Host ' 3 - Legacy application smoke test'
|
||||
Write-Host ' 4 - Dialog and MessageBox behavior'
|
||||
Write-Host ' 5 - Nested Canvas, Tooltip, and overlay redraw'
|
||||
Write-Host ' 6 - Shape hit-test and uncovered branches'
|
||||
Write-Host ' 0 - Exit'
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Invoke-KeyScenario([int]$scenario) {
|
||||
if ($scenario -lt 1 -or $scenario -gt 6) {
|
||||
throw "KEY must be an integer from 1 to 6: $scenario"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $msbuild)) {
|
||||
throw "MSBuild not found: $msbuild"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $project)) {
|
||||
throw "Project not found: $project"
|
||||
}
|
||||
|
||||
Write-Host "Building KEY$scenario (Debug|x64)..." -ForegroundColor Cyan
|
||||
& $msbuild $project /m /t:Rebuild /p:Configuration=Debug /p:Platform=x64 /p:KeyScenario=$scenario /p:EnableAsan=false /v:minimal
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "KEY$scenario build failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $executable)) {
|
||||
throw "Built executable not found: $executable"
|
||||
}
|
||||
|
||||
Write-Host "Launching KEY$scenario. Close the UI window to return to the menu." -ForegroundColor Green
|
||||
$process = Start-Process -FilePath $executable -WorkingDirectory $root -PassThru -Wait
|
||||
if ($process.ExitCode -ne 0) {
|
||||
throw "KEY$scenario exited with code $($process.ExitCode)."
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasKeyArgument) {
|
||||
try {
|
||||
Invoke-KeyScenario $Key
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
[Console]::Error.WriteLine("ERROR: $($_.Exception.Message)")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
while ($true) {
|
||||
Show-KeyMenu
|
||||
$rawSelection = Read-Host 'Select a KEY scenario'
|
||||
$selection = -1
|
||||
if (-not [int]::TryParse($rawSelection, [ref]$selection) -or $selection -lt 0 -or $selection -gt 6) {
|
||||
Write-Host 'Invalid selection. Enter a number from 0 to 6.' -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
if ($selection -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-KeyScenario $selection
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
|
||||
param(
|
||||
[int]$Repeat = 1,
|
||||
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')]
|
||||
[string[]]$OnlyScenario = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
|
||||
$resultRoot = Join-Path $root 'TestResults\ui'
|
||||
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
# EasyX 控件不是原生子窗口,因此使用 HWND、SendInput 和 PrintWindow 驱动与取证。
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class StellarXUiNative {
|
||||
// 这些结构只覆盖 SendInput、客户区坐标转换和窗口枚举所需的 Win32 ABI。
|
||||
private delegate bool EnumWindowsProc(IntPtr h, IntPtr parameter);
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
[StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT {
|
||||
public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
|
||||
}
|
||||
[StructLayout(LayoutKind.Explicit)] private struct INPUTUNION {
|
||||
[FieldOffset(0)] public MOUSEINPUT Mouse;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] private struct INPUT {
|
||||
public uint Type; public INPUTUNION Data;
|
||||
}
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern bool GetClientRect(IntPtr h, out RECT rect);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern bool ClientToScreen(IntPtr h, ref POINT point);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint msg, IntPtr w, IntPtr l);
|
||||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point);
|
||||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr targetDc, uint flags);
|
||||
[DllImport("user32.dll")] private static extern int GetSystemMetrics(int index);
|
||||
[DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size);
|
||||
[DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter);
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern int GetWindowTextW(IntPtr h, StringBuilder text, int maxCount);
|
||||
public static IntPtr FindProcessWindow(int processId, string title) {
|
||||
// 同时匹配 PID 和完整标题,避免测试机上其他 EasyX 窗口被误绑定。
|
||||
IntPtr result = IntPtr.Zero;
|
||||
EnumWindows(delegate(IntPtr h, IntPtr parameter) {
|
||||
uint ownerProcessId;
|
||||
GetWindowThreadProcessId(h, out ownerProcessId);
|
||||
if (ownerProcessId != (uint)processId) return true;
|
||||
var text = new StringBuilder(512);
|
||||
GetWindowTextW(h, text, text.Capacity);
|
||||
if (!String.Equals(text.ToString(), title, StringComparison.Ordinal)) return true;
|
||||
result = h;
|
||||
return false;
|
||||
}, IntPtr.Zero);
|
||||
return result;
|
||||
}
|
||||
public static POINT GetClientPoint(IntPtr h, int x, int y) {
|
||||
var point = new POINT { X = x, Y = y };
|
||||
if (!ClientToScreen(h, ref point)) throw new Win32Exception();
|
||||
return point;
|
||||
}
|
||||
public static RECT GetClientRectangle(IntPtr h) {
|
||||
RECT rect;
|
||||
if (!GetClientRect(h, out rect)) throw new Win32Exception();
|
||||
return rect;
|
||||
}
|
||||
public static void ClickLeftMouse() {
|
||||
// 用成对的按下/抬起事件模拟用户点击,事件会进入真实 EasyX 消息循环。
|
||||
var inputs = new INPUT[2];
|
||||
inputs[0].Type = 0;
|
||||
inputs[0].Data.Mouse.Flags = 0x0002;
|
||||
inputs[1].Type = 0;
|
||||
inputs[1].Data.Mouse.Flags = 0x0004;
|
||||
if (SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT))) != 2) throw new Win32Exception();
|
||||
}
|
||||
public static void MoveMouseToScreen(int x, int y) {
|
||||
// SendInput 的绝对坐标按虚拟屏幕换算,兼容多显示器和非零屏幕原点。
|
||||
int left = GetSystemMetrics(76);
|
||||
int top = GetSystemMetrics(77);
|
||||
int width = GetSystemMetrics(78);
|
||||
int height = GetSystemMetrics(79);
|
||||
var input = new INPUT[1];
|
||||
input[0].Type = 0;
|
||||
input[0].Data.Mouse.Dx = (int)Math.Round((x - left) * 65535.0 / Math.Max(1, width - 1));
|
||||
input[0].Data.Mouse.Dy = (int)Math.Round((y - top) * 65535.0 / Math.Max(1, height - 1));
|
||||
input[0].Data.Mouse.Flags = 0xC001;
|
||||
if (SendInput(1, input, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception();
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Wait-Window([Diagnostics.Process]$process, [string]$title, [int]$timeoutMs = 15000) {
|
||||
# fixture 启动后异步创建窗口;超时或提前退出都应报告明确原因。
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||||
$process.Refresh()
|
||||
if ($process.HasExited) {
|
||||
throw "Fixture exited before creating '$title' (exit code $($process.ExitCode))"
|
||||
}
|
||||
$h = [StellarXUiNative]::FindProcessWindow($process.Id, $title)
|
||||
if ($h -ne [IntPtr]::Zero) { return $h }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
throw "Window not found: $title"
|
||||
}
|
||||
|
||||
function Assert-NativeCloseExitsProcess([string]$fixturePath, [string]$runDir, [string]$scenario) {
|
||||
# 模拟标题栏关闭命令。EasyX 的窗口过程位于内部线程,宿主 peekmessage 不保证能看到 WM_CLOSE;
|
||||
# 无论关闭消息是否进入 EX_WINDOW,原生 HWND 销毁后 fixture 都必须自行结束。
|
||||
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
|
||||
# EasyX 在重定向标准输出的子进程中可能返回 0xC000013A;关闭回归不需要捕获输出,
|
||||
# 因而保持与用户直接运行应用相同的控制台继承方式。
|
||||
$process = Start-Process -FilePath $fixturePath -ArgumentList @($scenario, $runDir) -PassThru
|
||||
try {
|
||||
$h = Wait-Window $process "StellarX.UI.$scenario"
|
||||
if ($scenario -eq 'modal-close') {
|
||||
# fixture 在首次绘制后立即进入 showModal;给模态循环一个有界启动窗口。
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
if (-not [StellarXUiNative]::PostMessage($h, 0x0112, [IntPtr]0xF060, [IntPtr]::Zero)) {
|
||||
throw 'Failed to post SC_CLOSE to fixture'
|
||||
}
|
||||
if (-not $process.WaitForExit(5000)) {
|
||||
$process.Kill()
|
||||
$process.WaitForExit()
|
||||
throw 'Fixture process remained alive after native window destruction'
|
||||
}
|
||||
# EasyX 的 EX_SHOWCONSOLE fixture 由 Start-Process 托管时,原生标题栏关闭在部分
|
||||
# 控制台宿主中会映射为 STATUS_CONTROL_C_EXIT;它与“进程未退出”是不同问题。
|
||||
if ($process.ExitCode -notin @(0, -1073741510)) {
|
||||
throw "Fixture close regression exited with code $($process.ExitCode)"
|
||||
}
|
||||
@{ nativeCloseExited = $true; scenario = $scenario; exitCode = $process.ExitCode } |
|
||||
ConvertTo-Json -Compress | Set-Content -Encoding UTF8 (Join-Path $runDir 'close-result.json')
|
||||
}
|
||||
finally {
|
||||
if (-not $process.HasExited) {
|
||||
$process.Kill()
|
||||
$process.WaitForExit()
|
||||
}
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Client-Point([IntPtr]$h, [int]$x, [int]$y) {
|
||||
# 将 fixture 客户区坐标转换为 SendInput 所需的全局屏幕坐标。
|
||||
return [StellarXUiNative]::GetClientPoint($h, $x, $y)
|
||||
}
|
||||
|
||||
function Move-Mouse([IntPtr]$h, [int]$x, [int]$y) {
|
||||
# 输入坐标以客户区为单位,先转换到屏幕坐标再激活目标窗口。
|
||||
$p = Client-Point $h $x $y
|
||||
[void][StellarXUiNative]::SetForegroundWindow($h)
|
||||
[StellarXUiNative]::MoveMouseToScreen($p.X, $p.Y)
|
||||
Start-Sleep -Milliseconds 80
|
||||
}
|
||||
|
||||
function Click-Mouse([IntPtr]$h, [int]$x, [int]$y) {
|
||||
# 点击后等待一小段时间,让 EasyX 批量处理消息并完成统一重绘收口。
|
||||
Move-Mouse $h $x $y
|
||||
[StellarXUiNative]::ClickLeftMouse()
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
|
||||
function Post-KeyWave([IntPtr]$h, [int]$pairCount = 1000) {
|
||||
# F24 is outside normal text entry and was already proven to cross both the
|
||||
# WndProc and EasyX EX_KEY observation planes during stage 0B.
|
||||
$keyDownLParam = [IntPtr]([long]0x00760001)
|
||||
$keyUpLParam = [IntPtr]([long]0xC0760001)
|
||||
for ($index = 0; $index -lt $pairCount; $index++) {
|
||||
if (-not [StellarXUiNative]::PostMessage($h, 0x0100, [IntPtr]0x87, $keyDownLParam)) {
|
||||
throw "Failed to post F24 key-down at pair $index"
|
||||
}
|
||||
if (-not [StellarXUiNative]::PostMessage($h, 0x0101, [IntPtr]0x87, $keyUpLParam)) {
|
||||
throw "Failed to post F24 key-up at pair $index"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Capture-Client([IntPtr]$h, [string]$path) {
|
||||
# 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。
|
||||
$rect = [StellarXUiNative]::GetClientRectangle($h)
|
||||
$bitmap = New-Object Drawing.Bitmap ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top)
|
||||
$graphics = [Drawing.Graphics]::FromImage($bitmap)
|
||||
$dc = $graphics.GetHdc()
|
||||
try {
|
||||
if (-not [StellarXUiNative]::PrintWindow($h, $dc, 1)) {
|
||||
throw 'PrintWindow failed'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$graphics.ReleaseHdc($dc)
|
||||
$graphics.Dispose()
|
||||
}
|
||||
$bitmap.Save($path, [Drawing.Imaging.ImageFormat]::Png)
|
||||
return $bitmap
|
||||
}
|
||||
|
||||
function Wait-Result([string]$path, [int]$timeoutMs = 15000) {
|
||||
# 等待 fixture 写出 JSON 检查点,并把协议内容解析为 PowerShell 对象。
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||||
if (Test-Path $path) { return Get-Content -Raw $path | ConvertFrom-Json }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
throw "Result timeout: $path"
|
||||
}
|
||||
|
||||
function Wait-TrueResultProperty([string]$path, [string]$property, [int]$timeoutMs = 15000) {
|
||||
# 对话框等异步场景可能多次更新 JSON,直到目标状态真正变为 true 才继续。
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||||
if (Test-Path $path) {
|
||||
try {
|
||||
$result = Get-Content -Raw $path | ConvertFrom-Json
|
||||
if ($result.$property -eq $true) { return $result }
|
||||
}
|
||||
catch {
|
||||
# fixture 可能正处于 truncate 与 flush 之间,解析失败时继续轮询到场景超时。
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
throw "Result property timeout: $path ($property)"
|
||||
}
|
||||
|
||||
function Assert-Rgb($pixel, [int]$r, [int]$g, [int]$b, [string]$message) {
|
||||
# 关键像素用精确颜色断言,避开文本抗锯齿导致的非确定性。
|
||||
if ($pixel.R -ne $r -or $pixel.G -ne $g -or $pixel.B -ne $b) {
|
||||
throw "$message actual=($($pixel.R),$($pixel.G),$($pixel.B)) expected=($r,$g,$b)"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-NotRgb($pixel, [int]$r, [int]$g, [int]$b, [string]$message) {
|
||||
# 用于验证关闭或移出后,目标像素不再保留指定的控件颜色。
|
||||
if ($pixel.R -eq $r -and $pixel.G -eq $g -and $pixel.B -eq $b) {
|
||||
throw "$message actual=($($pixel.R),$($pixel.G),$($pixel.B))"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegionContainsRgb([Drawing.Bitmap]$bitmap, [int]$left, [int]$top,
|
||||
[int]$right, [int]$bottom, [int]$r, [int]$g, [int]$b, [string]$message) {
|
||||
for ($y = $top; $y -lt $bottom; $y++) {
|
||||
for ($x = $left; $x -lt $right; $x++) {
|
||||
$pixel = $bitmap.GetPixel($x, $y)
|
||||
if ($pixel.R -eq $r -and $pixel.G -eq $g -and $pixel.B -eq $b) { return }
|
||||
}
|
||||
}
|
||||
throw $message
|
||||
}
|
||||
|
||||
function Assert-RegionsDiffer([Drawing.Bitmap]$before, [Drawing.Bitmap]$after,
|
||||
[int]$left, [int]$top, [int]$right, [int]$bottom, [string]$message) {
|
||||
for ($y = $top; $y -lt $bottom; $y++) {
|
||||
for ($x = $left; $x -lt $right; $x++) {
|
||||
if ($before.GetPixel($x, $y).ToArgb() -ne $after.GetPixel($x, $y).ToArgb()) { return }
|
||||
}
|
||||
}
|
||||
throw $message
|
||||
}
|
||||
|
||||
function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
|
||||
[string]$capturePath, [int]$timeoutMs = 15000) {
|
||||
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$actual = $null
|
||||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||||
$probe = Capture-Client $h $capturePath
|
||||
$actual = $probe.GetPixel($x, $y)
|
||||
$matched = $actual.R -eq $r -and $actual.G -eq $g -and $actual.B -eq $b
|
||||
$probe.Dispose()
|
||||
if ($matched) { return }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
throw "Pixel timeout at ($x,$y) actual=($($actual.R),$($actual.G),$($actual.B)) expected=($r,$g,$b)"
|
||||
}
|
||||
|
||||
$savedCursor = New-Object StellarXUiNative+POINT
|
||||
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
|
||||
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
|
||||
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')
|
||||
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
|
||||
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
|
||||
|
||||
try {
|
||||
# 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。
|
||||
for ($iteration = 1; $iteration -le $Repeat; $iteration++) {
|
||||
foreach ($scenario in $scenarios) {
|
||||
$runDir = Join-Path $sessionRoot "run-$iteration-$scenario"
|
||||
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
|
||||
$resultFile = Join-Path $runDir "$scenario.json"
|
||||
$stdoutFile = Join-Path $runDir 'fixture.stdout.log'
|
||||
$stderrFile = Join-Path $runDir 'fixture.stderr.log'
|
||||
$process = Start-Process -FilePath $fixture -ArgumentList @($scenario, $runDir) -PassThru `
|
||||
-RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
|
||||
$h = [IntPtr]::Zero
|
||||
try {
|
||||
# 先绑定唯一 HWND,再根据场景注入动作并等待 fixture 的 JSON 检查点。
|
||||
$h = Wait-Window $process "StellarX.UI.$scenario"
|
||||
[void][StellarXUiNative]::SetForegroundWindow($h)
|
||||
switch ($scenario) {
|
||||
'ownership' { Click-Mouse $h 80 40; $result = Wait-Result $resultFile; if (-not $result.removed) { throw 'ownership result failed' } }
|
||||
'geometry' { Click-Mouse $h 80 40; $result = Wait-Result $resultFile; if ($result.x -ne 220 -or $result.textWidth -ne 80 -or $result.canvasWidth -ne 80 -or $result.labelVisible) { throw 'geometry result failed' }; Move-Mouse $h 460 300 }
|
||||
'tooltip' { Move-Mouse $h 80 40; Wait-ClientRgb $h 200 61 255 255 255 (Join-Path $runDir 'tooltip-wait.png') }
|
||||
'tab' { Click-Mouse $h 230 35; Click-Mouse $h 395 238; $result = Wait-Result $resultFile; if ($result.active -ne 1 -or $result.firstSelected -or -not $result.secondSelected -or $result.firstPageVisible -or -not $result.secondPageVisible) { throw 'tab uniqueness result failed' } }
|
||||
'table' { Click-Mouse $h 395 268; $result = Wait-Result $resultFile; if ($result.width -le 40 -or $result.coverageRight -le $result.coverageLeft -or $result.coverageBottom -le $result.coverageTop) { throw 'table minimum width or coverage failed' } }
|
||||
'focus' {
|
||||
$initialFocus = Capture-Client $h (Join-Path $runDir 'focus-textbox.png')
|
||||
try {
|
||||
Assert-RegionContainsRgb $initialFocus 20 78 182 118 32 96 192 'TextBox focus frame was not rendered'
|
||||
Click-Mouse $h 80 40
|
||||
Move-Mouse $h 470 310
|
||||
$buttonFocus = Capture-Client $h (Join-Path $runDir 'focus-button.png')
|
||||
try {
|
||||
Assert-RegionContainsRgb $buttonFocus 20 20 140 60 32 32 32 'Button focus frame was not rendered'
|
||||
Assert-RegionsDiffer $initialFocus $buttonFocus 20 20 140 60 'Button focus visual did not change after pointer focus'
|
||||
}
|
||||
finally { $buttonFocus.Dispose() }
|
||||
}
|
||||
finally { $initialFocus.Dispose() }
|
||||
Click-Mouse $h 405 278
|
||||
$result = Wait-Result $resultFile
|
||||
if (-not $result.buttonFocusObserved -or $result.tableTabStops -ne 1 -or
|
||||
-not $result.activePageChildPresent -or $result.inactivePageChildPresent -or
|
||||
-not $result.allSubNodesZero) {
|
||||
throw 'focus tree integration result failed'
|
||||
}
|
||||
}
|
||||
'dialog' {
|
||||
Click-Mouse $h 90 40
|
||||
Start-Sleep -Milliseconds 350
|
||||
Click-Mouse $h 320 120
|
||||
$result = Wait-TrueResultProperty $resultFile 'upperClosed'
|
||||
if ($result.lowerClosed) { throw 'lower dialog closed with upper dialog' }
|
||||
$lowerBitmap = Capture-Client $h (Join-Path $runDir 'lower-visible.png')
|
||||
Assert-NotRgb ($lowerBitmap.GetPixel(150, 120)) 30 50 70 'lower dialog was not visible after upper close'
|
||||
$lowerBitmap.Dispose()
|
||||
Click-Mouse $h 240 185
|
||||
$result = Wait-TrueResultProperty $resultFile 'lowerClosed'
|
||||
}
|
||||
'input-shadow' {
|
||||
$expectedKeyEvents = 4000
|
||||
# Keep every burst below the observed EasyX internal queue ceiling
|
||||
# (63 usable entries in this environment).
|
||||
# This measures sustained draining rather than overflow behavior of a
|
||||
# third-party queue that the framework cannot resize.
|
||||
foreach ($wave in 1..50) {
|
||||
Post-KeyWave $h 20
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
$process.Refresh()
|
||||
$memoryAfterWarmup = $process.PrivateMemorySize64
|
||||
foreach ($wave in 1..50) {
|
||||
Post-KeyWave $h 20
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
$process.Refresh()
|
||||
$memoryAfterSecondWave = $process.PrivateMemorySize64
|
||||
Click-Mouse $h 100 61
|
||||
$result = Wait-Result $resultFile
|
||||
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
|
||||
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
|
||||
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) {
|
||||
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)"
|
||||
}
|
||||
if ($result.keyEvents -ne $expectedKeyEvents) { throw "normalized key count mismatch: $($result.keyEvents)" }
|
||||
if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' }
|
||||
if (-not $result.sequenceStrict -or $result.faulted) { throw 'native input queue reported ordering or fail-stop failure' }
|
||||
if ($result.rejectedTextSequences -ne 0 -or $result.imeQueryFailures -ne 0 -or
|
||||
$result.discardedDecoderFragments -ne 0) {
|
||||
throw 'key-only input shadow reported a text normalization anomaly'
|
||||
}
|
||||
$memoryGrowth = $memoryAfterSecondWave - $memoryAfterWarmup
|
||||
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
|
||||
@{
|
||||
expectedKeyEvents = $expectedKeyEvents
|
||||
memoryAfterWarmup = $memoryAfterWarmup
|
||||
memoryAfterSecondWave = $memoryAfterSecondWave
|
||||
memoryGrowth = $memoryGrowth
|
||||
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json')
|
||||
}
|
||||
}
|
||||
$bitmap = Capture-Client $h (Join-Path $runDir 'screen.png')
|
||||
if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') {
|
||||
Assert-Rgb ($bitmap.GetPixel(80, 40)) 30 50 70 "$scenario old region was not restored"
|
||||
}
|
||||
if ($scenario -eq 'geometry') {
|
||||
Assert-Rgb ($bitmap.GetPixel(260, 40)) 80 160 220 'moved button was not rendered'
|
||||
Assert-Rgb ($bitmap.GetPixel(25, 90)) 30 50 70 'hidden label left stale pixels'
|
||||
Assert-Rgb ($bitmap.GetPixel(30, 130)) 30 50 70 'moved TextBox left stale pixels'
|
||||
Assert-Rgb ($bitmap.GetPixel(230, 130)) 230 230 210 'moved TextBox was not rendered'
|
||||
Assert-Rgb ($bitmap.GetPixel(30, 190)) 30 50 70 'moved Canvas left stale pixels'
|
||||
Assert-Rgb ($bitmap.GetPixel(230, 190)) 40 120 90 'moved Canvas was not rendered'
|
||||
}
|
||||
if ($scenario -eq 'tooltip') {
|
||||
Assert-Rgb ($bitmap.GetPixel(200, 61)) 255 255 255 'stationary tooltip did not appear'
|
||||
Move-Mouse $h 460 300
|
||||
$clearedBitmap = Capture-Client $h (Join-Path $runDir 'tooltip-cleared.png')
|
||||
Assert-Rgb ($clearedBitmap.GetPixel(200, 61)) 30 50 70 'tooltip was not cleared after mouse exit'
|
||||
$clearedBitmap.Dispose()
|
||||
}
|
||||
if ($scenario -eq 'tab') {
|
||||
Assert-Rgb ($bitmap.GetPixel(50, 80)) 110 70 120 'active tab page pixels are incorrect'
|
||||
}
|
||||
if ($scenario -eq 'table') {
|
||||
$outsideX = [Math]::Min(479, [int]$result.coverageRight + 5)
|
||||
Assert-Rgb ($bitmap.GetPixel($outsideX, 30)) 30 50 70 'table drew outside reported horizontal coverage'
|
||||
}
|
||||
if ($scenario -eq 'dialog') {
|
||||
Assert-Rgb ($bitmap.GetPixel(240, 150)) 30 50 70 'dialog pixels remained after both dialogs closed'
|
||||
}
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
catch {
|
||||
# 失败时保留驱动错误、fixture 输出和客户区截图,便于定位像素或状态问题。
|
||||
@{ scenario = $scenario; message = $_.Exception.Message; processId = $process.Id; hwnd = $h.ToInt64() } |
|
||||
ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'driver-error.json')
|
||||
if ($h -ne [IntPtr]::Zero) {
|
||||
try {
|
||||
$failureBitmap = Capture-Client $h (Join-Path $runDir 'failure.png')
|
||||
$failureBitmap.Dispose()
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
# 正常和异常路径都发送 WM_CLOSE;若 fixture 未退出,再按超时兜底终止其进程。
|
||||
if (-not $process.HasExited) {
|
||||
if ($h -ne [IntPtr]::Zero) {
|
||||
[void][StellarXUiNative]::PostMessage($h, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
|
||||
}
|
||||
if (-not $process.WaitForExit(2000)) { $process.Kill() }
|
||||
}
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Assert-NativeCloseExitsProcess $fixture `
|
||||
(Join-Path $sessionRoot "run-$iteration-native-close") 'native-close'
|
||||
Assert-NativeCloseExitsProcess $fixture `
|
||||
(Join-Path $sessionRoot "run-$iteration-modal-native-close") 'modal-close'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
[void][StellarXUiNative]::SetCursorPos($savedCursor.X, $savedCursor.Y)
|
||||
}
|
||||
|
||||
Write-Host "UI tests passed: $Repeat iteration(s)"
|
||||
@@ -0,0 +1,115 @@
|
||||
param(
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Debug',
|
||||
[ValidateSet('x64', 'Win32')]
|
||||
[string]$Platform = 'x64',
|
||||
[ValidateSet('All', 'wide-text', 'dpi-coordinate', 'ime-position')]
|
||||
[string]$Mode = 'All',
|
||||
[ValidateSet('inherit', 'unaware', 'system', 'per-monitor', 'per-monitor-v2')]
|
||||
[string]$DpiAwareness = 'inherit',
|
||||
[ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
|
||||
[string]$ImeName = 'unspecified'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||
$project = Join-Path $root 'tests\EasyX.VisualProbe.vcxproj'
|
||||
$probe = Join-Path $root "build\$Platform\$Configuration\EasyX.VisualProbe.exe"
|
||||
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
|
||||
|
||||
$pathKeyCount = @(
|
||||
[Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys |
|
||||
Where-Object { [string]$_ -ieq 'Path' }
|
||||
).Count
|
||||
if ($pathKeyCount -gt 1) {
|
||||
$buildCommand = 'set "PATH=" & "' + $msbuild + '" "' + $project +
|
||||
'" /m /t:Build /p:Configuration=' + $Configuration + ' /p:Platform=' + $Platform + ' /v:minimal'
|
||||
& $env:ComSpec /d /c $buildCommand
|
||||
}
|
||||
else {
|
||||
& $msbuild $project /m /t:Build /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Visual probe build failed' }
|
||||
if (-not (Test-Path -LiteralPath $probe)) { throw "Visual probe executable not found: $probe" }
|
||||
|
||||
$sessionId = [guid]::NewGuid().ToString()
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||
$sessionRoot = Join-Path $root "TestResults\VisualProbe\$stamp-$sessionId"
|
||||
New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
|
||||
$modes = if ($Mode -eq 'All') { @('wide-text', 'dpi-coordinate') } else { @($Mode) }
|
||||
$summaries = New-Object System.Collections.Generic.List[object]
|
||||
$probeFailed = $false
|
||||
|
||||
foreach ($probeMode in $modes) {
|
||||
$startedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
$runDirectory = Join-Path $sessionRoot $probeMode
|
||||
New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null
|
||||
if ($probeMode -eq 'ime-position') {
|
||||
$manualEvidence = Join-Path $runDirectory 'manual-evidence'
|
||||
New-Item -ItemType Directory -Force -Path $manualEvidence | Out-Null
|
||||
Write-Host "Manual IME position probe: $ImeName"
|
||||
Write-Host ' 1. Select the requested IME and click the EasyX window.'
|
||||
Write-Host ' 2. Press F1, type ceshi, inspect the candidate position, then press Escape.'
|
||||
Write-Host ' 3. Repeat with F2 and F3. Use only the fixed non-sensitive input ceshi.'
|
||||
Write-Host ' 4. At F3, commit once with Space; switch back to F1, type ceshi, and verify the old F3 position is not reused.'
|
||||
Write-Host ' 5. To close 0C-3, save tightly cropped screenshots with anchor-id prefixes under:'
|
||||
Write-Host " $manualEvidence"
|
||||
Write-Host ' 6. Close the EasyX window when all three anchors and the post-commit F1 regression have been exercised.'
|
||||
}
|
||||
Write-Host "Running visual probe mode: $probeMode"
|
||||
& $probe --mode $probeMode --output $runDirectory --dpi-awareness $DpiAwareness --ime-name $ImeName
|
||||
$exitCode = $LASTEXITCODE
|
||||
$rawPath = Join-Path $runDirectory 'raw-result.json'
|
||||
if (-not (Test-Path -LiteralPath $rawPath)) {
|
||||
throw "Visual probe did not produce raw-result.json (exit $exitCode): $runDirectory"
|
||||
}
|
||||
$summary = & (Join-Path $PSScriptRoot 'analyze-visual-probe.ps1') `
|
||||
-Path $runDirectory -SessionId $sessionId -StartedAtUtc $startedAtUtc -ProbeExitCode $exitCode
|
||||
$summaries.Add($summary)
|
||||
if ($exitCode -ne 0 -or $summary.automatedStatus -ne 'pass') { $probeFailed = $true }
|
||||
}
|
||||
|
||||
if ($summaries.Count -eq 2) {
|
||||
$drawing = $summaries | Where-Object mode -eq 'wide-text' | Select-Object -First 1
|
||||
$coordinate = $summaries | Where-Object mode -eq 'dpi-coordinate' | Select-Object -First 1
|
||||
$combinedInput = "$($drawing.manifestSha256):$($coordinate.manifestSha256)"
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$combinedBytes = [Text.Encoding]::UTF8.GetBytes($combinedInput)
|
||||
$combinedHash = ([BitConverter]::ToString($sha.ComputeHash($combinedBytes))).Replace('-', '')
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
$baseline = [pscustomobject][ordered]@{
|
||||
visualProbeBaselineSchemaVersion = 1
|
||||
sessionId = $sessionId
|
||||
createdAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
drawingBaselineManifest = 'wide-text/manifest-v1.json'
|
||||
coordinateBaselineManifest = 'dpi-coordinate/manifest-v1.json'
|
||||
drawingBaselineSha256 = $drawing.manifestSha256
|
||||
coordinateBaselineSha256 = $coordinate.manifestSha256
|
||||
combinedBaselineId = "sha256:$combinedHash"
|
||||
dpiAwareness = $coordinate.windowAwareness
|
||||
dpiForWindow = $coordinate.dpiForWindow
|
||||
imeIdentity = $null
|
||||
hkl = $null
|
||||
automatedStatus = if ($probeFailed) { 'fail' } else { 'pass' }
|
||||
manualStatus = 'not_reviewed'
|
||||
}
|
||||
$baselinePath = Join-Path $sessionRoot 'baseline-set-v1.json'
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[IO.File]::WriteAllText($baselinePath,
|
||||
($baseline | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
|
||||
$baselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $baselinePath).Hash
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[IO.File]::WriteAllText($baselinePath + '.sha256',
|
||||
"$baselineHash *baseline-set-v1.json`r`n", $utf8NoBom)
|
||||
Write-Host "Combined baseline: $baselinePath"
|
||||
Write-Host "Combined baseline ID: $($baseline.combinedBaselineId)"
|
||||
}
|
||||
|
||||
$summaries | Format-Table mode, automatedStatus, manualStatus, testCaseCount, evidenceCount, dpiForWindow
|
||||
Write-Host "Result directory: $sessionRoot"
|
||||
if ($probeFailed) { throw "One or more visual probe modes did not pass; evidence was preserved in $sessionRoot" }
|
||||
@@ -0,0 +1,91 @@
|
||||
param(
|
||||
# Unit/Render/UI 可单独执行;NonUI 跳过界面操作;All 是完整回归。
|
||||
[ValidateSet('Unit', 'Render', 'UI', 'NonUI', 'All', 'CI')]
|
||||
[string]$Suite = 'Unit',
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Debug',
|
||||
[ValidateSet('x64', 'Win32')]
|
||||
[string]$Platform = 'x64'
|
||||
)
|
||||
|
||||
# 统一测试入口:负责构建指定矩阵,并把所有诊断结果集中到 TestResults。
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# 暂时清空再恢复 PATH,避免 MSBuild/VSTest 被其他工具链中的同名程序劫持。
|
||||
$savedPath = $env:Path
|
||||
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
|
||||
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
|
||||
$vstest = if ($env:STELLARX_VSTEST) { $env:STELLARX_VSTEST } else { 'D:\vs2022\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe' }
|
||||
$results = Join-Path $root 'TestResults'
|
||||
# 测试输出目录允许未跟踪生成 TRX、JSON、日志和截图,不写入源码目录。
|
||||
New-Item -ItemType Directory -Force -Path $results | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
|
||||
if (-not (Test-Path -LiteralPath $vstest)) { throw "VSTest not found: $vstest" }
|
||||
|
||||
function Build-Project([string]$project, [string]$config, [string]$arch, [bool]$asan = $false) {
|
||||
# ASan 使用 Rebuild,确保运行库和插桩设置不会沿用上一种构建配置。
|
||||
$asanValue = if ($asan) { 'true' } else { 'false' }
|
||||
$target = if ($asan) { 'Rebuild' } else { 'Build' }
|
||||
& $msbuild $project /m /t:$target /p:Configuration=$config /p:Platform=$arch /p:EnableAsan=$asanValue /v:minimal
|
||||
if ($LASTEXITCODE -ne 0) { throw "Build failed: $project ($config|$arch)" }
|
||||
}
|
||||
|
||||
function Run-Unit([string]$config, [string]$arch, [bool]$asan = $false) {
|
||||
# Native Unit Test 在隔离进程中运行,单个 UAF 回归不会直接拖垮 VSTest 宿主。
|
||||
$project = Join-Path $root 'tests\StellarX.Tests.vcxproj'
|
||||
Build-Project $project $config $arch $asan
|
||||
$outputConfig = if ($asan) { "$config-asan" } else { $config }
|
||||
$dll = Join-Path $root "build\$arch\$outputConfig\StellarX.Tests.dll"
|
||||
$trxName = "unit-$arch-$config" + $(if ($asan) { '-asan' } else { '' }) + '.trx'
|
||||
$savedAsanOptions = $env:ASAN_OPTIONS
|
||||
try {
|
||||
# 只对 ASan 用例启用遇错即停;退出后恢复调用者原有环境变量。
|
||||
if ($asan) { $env:ASAN_OPTIONS = 'halt_on_error=1:detect_leaks=0' }
|
||||
& $vstest $dll /InIsolation /Logger:"trx;LogFileName=$trxName" /ResultsDirectory:$results
|
||||
if ($LASTEXITCODE -ne 0) { throw "Unit tests failed: $dll" }
|
||||
}
|
||||
finally {
|
||||
$env:ASAN_OPTIONS = $savedAsanOptions
|
||||
}
|
||||
}
|
||||
|
||||
function Run-UI([int]$repeat = 1) {
|
||||
# EasyX 依赖交互式桌面,UI fixture 始终固定使用 Debug x64 并串行运行。
|
||||
Build-Project (Join-Path $root 'tests\StellarX.UiFixture.vcxproj') 'Debug' 'x64'
|
||||
& (Join-Path $PSScriptRoot 'run-ui-tests.ps1') -Repeat $repeat
|
||||
if ($LASTEXITCODE -ne 0) { throw 'UI tests failed' }
|
||||
}
|
||||
|
||||
function Build-KeyScenarios {
|
||||
# KEY1-KEY6 是应用现有主路径的编译/链接冒烟,不在这里模拟鼠标交互。
|
||||
$project = Join-Path $root 'imGui-easyX.vcxproj'
|
||||
foreach ($key in 1..6) {
|
||||
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:KeyScenario=$key /p:EnableAsan=false /v:minimal
|
||||
if ($LASTEXITCODE -ne 0) { throw "KEY$key build failed" }
|
||||
}
|
||||
}
|
||||
|
||||
function Run-NonUI {
|
||||
# 提交前非交互矩阵:覆盖内存安全、多架构、Release 和现有 KEY 编译场景。
|
||||
Run-Unit 'Debug' 'x64'
|
||||
Run-Unit 'Debug' 'x64' $true
|
||||
Run-Unit 'Release' 'x64'
|
||||
Run-Unit 'Debug' 'Win32'
|
||||
Build-KeyScenarios
|
||||
}
|
||||
|
||||
switch ($Suite) {
|
||||
# Render 当前复用真实 UI fixture 的像素检查;All 在 NonUI 基础上追加三轮 UI。
|
||||
'Unit' { Run-Unit $Configuration $Platform }
|
||||
'Render' { Run-UI 1 }
|
||||
'UI' { Run-UI 1 }
|
||||
'NonUI' { Run-NonUI }
|
||||
'All' { Run-NonUI; Run-UI 3 }
|
||||
'CI' {
|
||||
Run-Unit 'Debug' 'x64'
|
||||
Run-Unit 'Release' 'x64'
|
||||
Run-Unit 'Debug' 'Win32'
|
||||
if ($env:STELLARX_UI_TESTS -eq '1') { Run-UI 1 }
|
||||
}
|
||||
}
|
||||
-421
@@ -1,421 +0,0 @@
|
||||
#include "Button.h"
|
||||
|
||||
Button::Button(int x, int y, int width, int height, const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape)
|
||||
: Control(x, y, width, height)
|
||||
{
|
||||
initButton(text, mode, shape, RGB(202, 255, 255), RGB(171, 196, 220), RGB(255, 255, 0));
|
||||
}
|
||||
|
||||
Button::Button(int x, int y, int width, int height, const std::string text, COLORREF ct, COLORREF cf, StellarX::ButtonMode mode, StellarX::ControlShape shape)
|
||||
: Control(x, y, width, height)
|
||||
{
|
||||
initButton(text, mode, shape, ct, cf, RGB(255, 255, 0));
|
||||
}
|
||||
|
||||
Button::Button(int x, int y, int width, int height, const std::string text, COLORREF ct, COLORREF cf, COLORREF ch, StellarX::ButtonMode mode, StellarX::ControlShape shape)
|
||||
: Control(x, y, width, height)
|
||||
{
|
||||
initButton(text, mode, shape, ct, cf, ch);
|
||||
}
|
||||
|
||||
void Button::initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch)
|
||||
{
|
||||
this->text = text;
|
||||
this->mode = mode;
|
||||
this->shape = shape;
|
||||
this->buttonTrueColor = ct;
|
||||
this->buttonFalseColor = cf;
|
||||
this->buttonHoverColor = ch;
|
||||
this->click = false;
|
||||
this->hover = false;
|
||||
}
|
||||
|
||||
|
||||
Button::~Button()
|
||||
{
|
||||
if (buttonFileIMAGE)
|
||||
delete buttonFileIMAGE;
|
||||
buttonFileIMAGE = nullptr;
|
||||
}
|
||||
|
||||
void Button::draw()
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
//保存当前样式和颜色
|
||||
saveStyle();
|
||||
|
||||
if (StellarX::ButtonMode::DISABLED == mode) //设置禁用按钮色
|
||||
{
|
||||
setfillcolor(RGB(96, 96, 96));
|
||||
textStyle.bStrikeOut = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 点击状态优先级最高,然后是悬停状态,最后是默认状态
|
||||
if (click)
|
||||
setfillcolor(buttonTrueColor);
|
||||
else if (hover)
|
||||
setfillcolor(buttonHoverColor);
|
||||
else
|
||||
setfillcolor(buttonFalseColor);
|
||||
}
|
||||
|
||||
//设置字体背景色透明
|
||||
setbkmode(TRANSPARENT);
|
||||
//边框颜色
|
||||
setlinecolor(buttonBorderColor);
|
||||
if (this->textStyle != oldStyle)
|
||||
{
|
||||
//设置字体颜色
|
||||
settextcolor(textStyle.color);
|
||||
|
||||
//设置字体样式
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut); //设置字体样式
|
||||
}
|
||||
//设置按钮填充模式
|
||||
setfillstyle((int)buttonFillMode, (int)buttonFillIma, buttonFileIMAGE);
|
||||
|
||||
//获取字符串像素高度和宽度
|
||||
if ((this->oldtext_width != this->text_width || this->oldtext_height != this->text_height)
|
||||
|| (-1 == oldtext_width && oldtext_height == -1))
|
||||
{
|
||||
this->oldtext_width = this->text_width = textwidth(LPCTSTR(this->text.c_str()));
|
||||
this->oldtext_height = this->text_height = textheight(LPCTSTR(this->text.c_str()));
|
||||
}
|
||||
|
||||
|
||||
//根据按钮形状绘制
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
fillrectangle(x, y, x + width, y + height);//有边框填充矩形
|
||||
outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
solidrectangle(x, y, x + width, y + height);//无边框填充矩形
|
||||
outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
fillroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//有边框填充圆角矩形
|
||||
outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
solidroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//无边框填充圆角矩形
|
||||
outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
fillcircle(x + width / 2, y + height / 2, min(width, height) / 2);//有边框填充圆形
|
||||
outtextxy(x + width / 2 - text_width / 2, y + height / 2 - text_height / 2, LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
solidcircle(x + width / 2, y + height / 2, min(width, height) / 2);//无边框填充圆形
|
||||
outtextxy(x + width / 2 - text_width / 2, y + height / 2 - text_height / 2, LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
fillellipse(x, y, x + width, y + height);//有边框填充椭圆
|
||||
outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
solidellipse(x, y, x + width, y + height);//无边框填充椭圆
|
||||
outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
}
|
||||
|
||||
restoreStyle();//恢复默认字体样式和颜色
|
||||
|
||||
dirty = false; //标记按钮不需要重绘
|
||||
}
|
||||
}
|
||||
// 处理鼠标事件,检测点击和悬停状态
|
||||
// 根据按钮模式和形状进行不同的处理
|
||||
bool Button::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
bool oldHover = hover;
|
||||
bool oldClick = click;
|
||||
bool consume = false;//是否消耗事件
|
||||
|
||||
// 检测悬停状态(根据不同形状)
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
hover = (msg.x > x && msg.x < (x + width) && msg.y > y && msg.y < (y + height));
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
hover = isMouseInCircle(msg.x, msg.y, x + width / 2, y + height / 2, min(width, height) / 2);
|
||||
break;
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
hover = isMouseInEllipse(msg.x, msg.y, x, y, x + width, y + height);
|
||||
break;
|
||||
}
|
||||
|
||||
// 处理鼠标点击事件
|
||||
if (msg.message == WM_LBUTTONDOWN && hover && mode != StellarX::ButtonMode::DISABLED)
|
||||
{
|
||||
if (mode == StellarX::ButtonMode::NORMAL)
|
||||
{
|
||||
click = true;
|
||||
dirty = true;
|
||||
consume = true;
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
// TOGGLE模式在鼠标释放时处理
|
||||
}
|
||||
}
|
||||
// NORMAL 模式:鼠标在按钮上释放时才触发点击回调,如果移出区域则取消点击状态。
|
||||
// TOGGLE 模式:在释放时切换状态,并触发相应的开/关回调。
|
||||
else if (msg.message == WM_LBUTTONUP && hover && mode != StellarX::ButtonMode::DISABLED)
|
||||
{
|
||||
if (mode == StellarX::ButtonMode::NORMAL && click)
|
||||
{
|
||||
if (onClickCallback) onClickCallback();
|
||||
click = false;
|
||||
dirty = true;
|
||||
consume = true;
|
||||
// 清除消息队列中积压的鼠标和键盘消息,防止本次点击事件被重复处理
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
click = !click;
|
||||
if (click && onToggleOnCallback) onToggleOnCallback();
|
||||
else if (!click && onToggleOffCallback) onToggleOffCallback();
|
||||
dirty = true;
|
||||
consume = true;
|
||||
// 清除消息队列中积压的鼠标和键盘消息,防止本次点击事件被重复处理
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
}
|
||||
}
|
||||
// 处理鼠标移出区域的情况
|
||||
|
||||
else if (msg.message == WM_MOUSEMOVE)
|
||||
{
|
||||
if (!hover && mode == StellarX::ButtonMode::NORMAL && click)
|
||||
{
|
||||
click = false;
|
||||
dirty = true;
|
||||
}
|
||||
else if (hover != oldHover)
|
||||
{
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果状态发生变化,标记需要重绘
|
||||
if (hover != oldHover || click != oldClick)
|
||||
{
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// 如果需要重绘,立即执行
|
||||
if (dirty)
|
||||
{
|
||||
draw();
|
||||
}
|
||||
return consume;
|
||||
}
|
||||
|
||||
void Button::setOnClickListener(const std::function<void()>&& callback)
|
||||
{
|
||||
this->onClickCallback = callback;
|
||||
}
|
||||
|
||||
void Button::setOnToggleOnListener(const std::function<void()>&& callback)
|
||||
{
|
||||
this->onToggleOnCallback = callback;
|
||||
}
|
||||
void Button::setOnToggleOffListener(const std::function<void()>&& callback)
|
||||
{
|
||||
this->onToggleOffCallback = callback;
|
||||
}
|
||||
|
||||
void Button::setbuttonMode(StellarX::ButtonMode mode)
|
||||
{
|
||||
//取值范围参考 buttMode的枚举注释
|
||||
this->mode = mode;
|
||||
}
|
||||
|
||||
void Button::setROUND_RECTANGLEwidth(int width)
|
||||
{
|
||||
rouRectangleSize.ROUND_RECTANGLEwidth = width;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
|
||||
}
|
||||
|
||||
void Button::setROUND_RECTANGLEheight(int height)
|
||||
{
|
||||
rouRectangleSize.ROUND_RECTANGLEheight = height;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
bool Button::isClicked() const
|
||||
{
|
||||
return this->click;
|
||||
}
|
||||
|
||||
void Button::setFillMode(StellarX::FillMode mode)
|
||||
{
|
||||
buttonFillMode = mode;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
void Button::setFillIma(StellarX::FillStyle ima)
|
||||
{
|
||||
buttonFillIma = ima;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setFillIma(std::string imaNAme)
|
||||
{
|
||||
if (buttonFileIMAGE)
|
||||
{
|
||||
delete buttonFileIMAGE;
|
||||
buttonFileIMAGE = nullptr;
|
||||
}
|
||||
buttonFileIMAGE = new IMAGE;
|
||||
loadimage(buttonFileIMAGE, imaNAme.c_str(),width,height);
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
|
||||
void Button::setButtonBorder(COLORREF Border)
|
||||
{
|
||||
buttonBorderColor = Border;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonFalseColor(COLORREF color)
|
||||
{
|
||||
this->buttonFalseColor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonText(const char* text)
|
||||
{
|
||||
this->text = std::string(text);
|
||||
this->text_width = textwidth(LPCTSTR(this->text.c_str()));
|
||||
this->text_height = textheight(LPCTSTR(this->text.c_str()));
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonText(std::string text)
|
||||
{
|
||||
this->text = text;
|
||||
this->text_width = textwidth(LPCTSTR(this->text.c_str()));
|
||||
this->text_height = textheight(LPCTSTR(this->text.c_str()));
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
void Button::setButtonShape(StellarX::ControlShape shape)
|
||||
{
|
||||
this->shape = shape;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
|
||||
std::string Button::getButtonText() const
|
||||
{
|
||||
return this->text;
|
||||
}
|
||||
|
||||
const char* Button::getButtonText_c() const
|
||||
{
|
||||
return this->text.c_str();
|
||||
}
|
||||
|
||||
StellarX::ButtonMode Button::getButtonMode() const
|
||||
{
|
||||
return this->mode;
|
||||
}
|
||||
|
||||
StellarX::ControlShape Button::getButtonShape() const
|
||||
{
|
||||
return this->shape;
|
||||
}
|
||||
|
||||
StellarX::FillMode Button::getFillMode() const
|
||||
{
|
||||
return this->buttonFillMode;
|
||||
}
|
||||
|
||||
StellarX::FillStyle Button::getFillIma() const
|
||||
{
|
||||
return this->buttonFillIma;
|
||||
}
|
||||
|
||||
IMAGE* Button::getFillImaImage() const
|
||||
{
|
||||
return this->buttonFileIMAGE;
|
||||
}
|
||||
|
||||
COLORREF Button::getButtonBorder() const
|
||||
{
|
||||
return this->buttonBorderColor;
|
||||
}
|
||||
|
||||
COLORREF Button::getButtonTextColor() const
|
||||
{
|
||||
return this->textStyle.color;
|
||||
}
|
||||
|
||||
StellarX::ControlText Button::getButtonTextStyle() const
|
||||
{
|
||||
return this->textStyle;
|
||||
}
|
||||
|
||||
int Button::getButtonWidth() const
|
||||
{
|
||||
return this->width;
|
||||
}
|
||||
|
||||
int Button::getButtonHeight() const
|
||||
{
|
||||
return this->height;
|
||||
}
|
||||
|
||||
int Button::getButtonX() const
|
||||
{
|
||||
return this->x;
|
||||
}
|
||||
|
||||
int Button::getButtonY() const
|
||||
{
|
||||
return this->y;
|
||||
}
|
||||
|
||||
|
||||
bool Button::isMouseInCircle(int mouseX, int mouseY, int x, int y, int radius)
|
||||
{
|
||||
double dis = sqrt(pow(mouseX - x, 2) + pow(mouseY - y, 2));
|
||||
if (dis <= radius)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Button::isMouseInEllipse(int mouseX, int mouseY, int x, int y, int width, int height)
|
||||
{
|
||||
int centerX = (x + width) / 2;
|
||||
int centerY = (y + height) / 2;
|
||||
int majorAxis = (width - x) / 2;
|
||||
int minorAxis = (height - y) / 2;
|
||||
double dx = mouseX - centerX;
|
||||
double dy = mouseY - centerY;
|
||||
double normalizedDistance = (dx * dx) / (majorAxis * majorAxis) + (dy * dy) / (minorAxis * minorAxis);
|
||||
|
||||
// 判断鼠标是否在椭圆内
|
||||
if (normalizedDistance <= 1.0)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
#include "Canvas.h"
|
||||
|
||||
void Canvas::clearAllControls()
|
||||
{
|
||||
controls.clear();
|
||||
}
|
||||
|
||||
Canvas::Canvas()
|
||||
:Control(0,0,100,100)
|
||||
{
|
||||
}
|
||||
|
||||
Canvas::Canvas(int x, int y, int width, int height)
|
||||
:Control(x, y, width, height) {}
|
||||
|
||||
void Canvas::draw()
|
||||
{
|
||||
if (!dirty)return;
|
||||
saveStyle();
|
||||
|
||||
setlinecolor(canvasBorderClor);//设置线色
|
||||
setfillcolor(canvasBkClor);//设置填充色
|
||||
setfillstyle((int)canvasFillMode);//设置填充模式
|
||||
setlinestyle((int)canvasLineStyle, canvaslinewidth);
|
||||
|
||||
//根据画布形状绘制
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
fillrectangle(x,y,x+width,y+height);//有边框填充矩形
|
||||
break;
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
solidrectangle(x, y, x + width, y + height);//无边框填充矩形
|
||||
break;
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
fillroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//有边框填充圆角矩形
|
||||
break;
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
solidroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//无边框填充圆角矩形
|
||||
break;
|
||||
}
|
||||
// 绘制所有子控件
|
||||
for (auto& control : controls)
|
||||
control->draw();
|
||||
|
||||
|
||||
restoreStyle();
|
||||
dirty = false; //标记画布不需要重绘
|
||||
}
|
||||
|
||||
bool Canvas::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
for (auto& control : controls)
|
||||
if (control->handleEvent(msg))
|
||||
return true;//事件被消费短路传递,立即返回true 否则返回false
|
||||
return false;
|
||||
}
|
||||
|
||||
void Canvas::addControl(std::unique_ptr<Control> control)
|
||||
{
|
||||
controls.push_back(std::move(control));
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setShape(StellarX::ControlShape shape)
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
this->shape = shape;
|
||||
dirty = true;
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
this->shape = StellarX::ControlShape::RECTANGLE;
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::setCanvasfillMode(StellarX::FillMode mode)
|
||||
{
|
||||
this->canvasFillMode = mode;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setBorderColor(COLORREF color)
|
||||
{
|
||||
this->canvasBorderClor = color;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setCanvasBkColor(COLORREF color)
|
||||
{
|
||||
this->canvasBkClor = color;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setCanvasLineStyle(StellarX::LineStyle style)
|
||||
{
|
||||
this->canvasLineStyle = style;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
|
||||
void Canvas::setLinewidth(int width)
|
||||
{
|
||||
this->canvaslinewidth = width;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#include "Control.h"
|
||||
#include<assert.h>
|
||||
|
||||
StellarX::ControlText& StellarX::ControlText::operator=(const ControlText& text)
|
||||
{
|
||||
{
|
||||
nHeight = text.nHeight;
|
||||
nWidth = text.nWidth;
|
||||
lpszFace = text.lpszFace;
|
||||
color = text.color;
|
||||
nEscapement = text.nEscapement;
|
||||
nOrientation = text.nOrientation;
|
||||
nWeight = text.nWeight;
|
||||
bItalic = text.bItalic;
|
||||
bUnderline = text.bUnderline;
|
||||
bStrikeOut = text.bStrikeOut;
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
|
||||
bool StellarX::ControlText::operator!=(const ControlText& text)
|
||||
{
|
||||
if(nHeight != text.nHeight)
|
||||
return true;
|
||||
else if (nWidth != text.nWidth)
|
||||
return true;
|
||||
else if (lpszFace != text.lpszFace)
|
||||
return true;
|
||||
else if (color != text.color)
|
||||
return true;
|
||||
else if (nEscapement != text.nEscapement)
|
||||
return true;
|
||||
else if (nOrientation != text.nOrientation)
|
||||
return true;
|
||||
else if (nWeight != text.nWeight)
|
||||
return true;
|
||||
else if (bItalic != text.bItalic)
|
||||
return true;
|
||||
else if (bUnderline != text.bUnderline)
|
||||
return true;
|
||||
else if (bStrikeOut != text.bStrikeOut)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
// 保存当前的绘图状态(字体、颜色、线型等)
|
||||
// 在控件绘制前调用,确保不会影响全局绘图状态
|
||||
void Control::saveStyle()
|
||||
{
|
||||
|
||||
gettextstyle(currentFont); // 获取当前字体样式
|
||||
*currentColor = gettextcolor(); // 获取当前字体颜色
|
||||
*currentBorderColor = getlinecolor(); //保存当前边框颜色
|
||||
getlinestyle(currentLineStyle); //保存当前线型
|
||||
*currentBkColor = getfillcolor(); //保存当前填充色
|
||||
}
|
||||
// 恢复之前保存的绘图状态
|
||||
// 在控件绘制完成后调用,恢复全局绘图状态
|
||||
void Control::restoreStyle()
|
||||
{
|
||||
settextstyle(currentFont); // 恢复默认字体样式
|
||||
settextcolor(*currentColor); // 恢复默认字体颜色
|
||||
setfillcolor(*currentBkColor);
|
||||
setlinestyle(currentLineStyle);
|
||||
setlinecolor(*currentBorderColor);
|
||||
setfillstyle(BS_SOLID);//恢复填充
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
#include "MessageBox.h"
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
MessageBoxResult MessageBox::ShowModal(Window& wnd,const std::string& text,const std::string& caption,
|
||||
MessageBoxType type)
|
||||
{
|
||||
Dialog dlg(wnd, caption, text, type, true); // 模态
|
||||
dlg.Show();
|
||||
return dlg.GetResult();
|
||||
}
|
||||
|
||||
void MessageBox::ShowAsync(Window& wnd,const std::string& text,const std::string& caption,MessageBoxType type,
|
||||
std::function<void(MessageBoxResult)> onResult)
|
||||
{
|
||||
//去重,如果窗口内已有相同的对话框被触发,则不再创建
|
||||
if (wnd.hasNonModalDialogWithCaption(caption))
|
||||
{
|
||||
std::cout << "\a" << std::endl;
|
||||
return;
|
||||
}
|
||||
auto dlg = std::make_unique<Dialog>(wnd, caption, text, type, false); // 非模态
|
||||
Dialog* dlgPtr = dlg.get();
|
||||
|
||||
// 设置回调
|
||||
if (onResult)
|
||||
dlgPtr->SetResultCallback(std::move(onResult));
|
||||
// 交给 Window 管理生命周期
|
||||
wnd.addDialog(std::move(dlg));
|
||||
dlgPtr->Show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
#include "Button.h"
|
||||
#include "SxLog.h"
|
||||
#include "Window.h"
|
||||
|
||||
Button::Button(int x, int y, int width, int height, const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape)
|
||||
: Control(x, y, width, height)
|
||||
{
|
||||
initButton(text, mode, shape, RGB(202, 255, 255), RGB(171, 196, 220), RGB(255, 255, 0));
|
||||
}
|
||||
|
||||
Button::Button(int x, int y, int width, int height, const std::string text, COLORREF ct, COLORREF cf, StellarX::ButtonMode mode, StellarX::ControlShape shape)
|
||||
: Control(x, y, width, height)
|
||||
{
|
||||
initButton(text, mode, shape, ct, cf, RGB(255, 255, 0));
|
||||
}
|
||||
|
||||
Button::Button(int x, int y, int width, int height, const std::string text, COLORREF ct, COLORREF cf, COLORREF ch, StellarX::ButtonMode mode, StellarX::ControlShape shape)
|
||||
: Control(x, y, width, height)
|
||||
{
|
||||
initButton(text, mode, shape, ct, cf, ch);
|
||||
}
|
||||
// ====== GBK/MBCS 安全:字符边界与省略号裁切 ======
|
||||
static inline int gbk_char_len(const std::string& s, size_t i)
|
||||
{
|
||||
unsigned char b = (unsigned char)s[i];
|
||||
if (b <= 0x7F) return 1; // ASCII
|
||||
if (b >= 0x81 && b <= 0xFE && i + 1 < s.size())
|
||||
{
|
||||
unsigned char b2 = (unsigned char)s[i + 1];
|
||||
if (b2 >= 0x40 && b2 <= 0xFE && b2 != 0x7F) return 2; // 合法双字节
|
||||
}
|
||||
return 1; // 容错
|
||||
}
|
||||
|
||||
static inline void rtrim_spaces_gbk(std::string& s)
|
||||
{
|
||||
while (!s.empty() && s.back() == ' ') s.pop_back(); // ASCII 空格
|
||||
while (s.size() >= 2)
|
||||
{ // 全角空格 A1 A1
|
||||
unsigned char a = (unsigned char)s[s.size() - 2];
|
||||
unsigned char b = (unsigned char)s[s.size() - 1];
|
||||
if (a == 0xA1 && b == 0xA1) s.resize(s.size() - 2);
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
static inline bool is_ascii_only(const std::string& s)
|
||||
{
|
||||
for (unsigned char c : s) if (c > 0x7F) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static inline bool is_word_boundary_char(unsigned char c)
|
||||
{
|
||||
return c == ' ' || c == '-' || c == '_' || c == '/' || c == '\\' || c == '.' || c == ':';
|
||||
}
|
||||
|
||||
// 英文优先策略:优先在“词边界”回退,再退化到逐字符;省略号为 "..."
|
||||
static std::string ellipsize_ascii_pref(const std::string& text, int maxW)
|
||||
{
|
||||
if (maxW <= 0) return "";
|
||||
if (textwidth(LPCTSTR(text.c_str())) <= maxW) return text;
|
||||
|
||||
const std::string ell = "...";
|
||||
int ellW = textwidth(LPCTSTR(ell.c_str()));
|
||||
if (ellW > maxW)
|
||||
{ // 连 ... 都放不下
|
||||
std::string e = ell;
|
||||
while (!e.empty() && textwidth(LPCTSTR(e.c_str())) > maxW) e.pop_back();
|
||||
return e; // 可能是 ".."、"." 或 ""
|
||||
}
|
||||
const int limit = maxW - ellW;
|
||||
|
||||
// 先找到能放下的最长前缀
|
||||
size_t i = 0, lastFit = 0;
|
||||
while (i < text.size())
|
||||
{
|
||||
int clen = gbk_char_len(text, i);
|
||||
size_t j = text.size() < i + (size_t)clen ? text.size() : i + (size_t)clen;
|
||||
int w = textwidth(LPCTSTR(text.substr(0, j).c_str()));
|
||||
if (w <= limit) { lastFit = j; i = j; }
|
||||
else break;
|
||||
}
|
||||
if (lastFit == 0) return ell;
|
||||
|
||||
// 在已适配前缀范围内,向左找最近的词边界
|
||||
size_t cutPos = lastFit;
|
||||
for (size_t k = lastFit; k > 0; --k)
|
||||
{
|
||||
unsigned char c = (unsigned char)text[k - 1];
|
||||
if (c <= 0x7F && is_word_boundary_char(c)) { cutPos = k - 1; break; }
|
||||
}
|
||||
|
||||
std::string head = text.substr(0, cutPos);
|
||||
rtrim_spaces_gbk(head);
|
||||
head += ell;
|
||||
return head;
|
||||
}
|
||||
|
||||
// 中文优先策略:严格逐“字符”(1/2字节)回退;省略号用全角 "…"
|
||||
static std::string ellipsize_cjk_pref(const std::string& text, int maxW, const char* ellipsis = "…")
|
||||
{
|
||||
if (maxW <= 0) return "";
|
||||
if (textwidth(LPCTSTR(text.c_str())) <= maxW) return text;
|
||||
|
||||
std::string ell = ellipsis ? ellipsis : "…";
|
||||
int ellW = textwidth(LPCTSTR(ell.c_str()));
|
||||
if (ellW > maxW)
|
||||
{ // 连省略号都放不下
|
||||
std::string e = ell;
|
||||
while (!e.empty() && textwidth(LPCTSTR(e.c_str())) > maxW) e.pop_back();
|
||||
return e;
|
||||
}
|
||||
const int limit = maxW - ellW;
|
||||
|
||||
size_t i = 0, lastFit = 0;
|
||||
while (i < text.size())
|
||||
{
|
||||
int clen = gbk_char_len(text, i);
|
||||
size_t j = text.size() < i + (size_t)clen ? text.size() : i + (size_t)clen;
|
||||
int w = textwidth(LPCTSTR(text.substr(0, j).c_str()));
|
||||
if (w <= limit) { lastFit = j; i = j; }
|
||||
else break;
|
||||
}
|
||||
if (lastFit == 0) return ell;
|
||||
|
||||
std::string head = text.substr(0, lastFit);
|
||||
rtrim_spaces_gbk(head);
|
||||
head += ell;
|
||||
return head;
|
||||
}
|
||||
|
||||
void Button::setTooltipStyle(COLORREF text, COLORREF bk, bool transparent)
|
||||
{
|
||||
tipLabel.textStyle.color = text;
|
||||
tipLabel.setTextBkColor(bk);
|
||||
tipLabel.setTextdisap(transparent);
|
||||
}
|
||||
|
||||
void Button::setTooltipTextsForToggle(const std::string& onText, const std::string& offText)
|
||||
{
|
||||
tipTextOn = onText;
|
||||
tipTextOff = offText;
|
||||
tipUserOverride = true;
|
||||
}
|
||||
|
||||
void Button::enableTooltip(bool on)
|
||||
{
|
||||
if (tipEnabled == on)
|
||||
return;
|
||||
if (!on)
|
||||
hideTooltip();
|
||||
tipEnabled = on;
|
||||
if (on && hover)
|
||||
tipHoverTick = GetTickCount64();
|
||||
}
|
||||
|
||||
void Button::initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch)
|
||||
{
|
||||
this->id = "Button";
|
||||
this->text = text;
|
||||
this->mode = mode;
|
||||
this->shape = shape;
|
||||
this->buttonTrueColor = ct;
|
||||
this->buttonFalseColor = cf;
|
||||
this->buttonHoverColor = ch;
|
||||
this->click = false;
|
||||
this->hover = false;
|
||||
this->focusPolicy = StellarX::FocusPolicy::Programmatic |
|
||||
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
|
||||
|
||||
// === Tooltip 默认:文本=按钮文本;白底黑字;不透明;用当前按钮字体样式 ===
|
||||
tipTextClick = tipTextOn = tipTextOff = this->text;
|
||||
tipLabel.setText(tipTextClick);
|
||||
tipLabel.textStyle.color = (RGB(167, 170, 172));
|
||||
tipLabel.setTextBkColor(RGB(255, 255, 255));
|
||||
tipLabel.setTextdisap(false);
|
||||
tipLabel.textStyle = this->textStyle; // 复用按钮字体样式
|
||||
}
|
||||
|
||||
Button::~Button() = default;
|
||||
|
||||
void Button::draw()
|
||||
{
|
||||
if (!dirty || !show)return;
|
||||
|
||||
//保存当前样式和颜色
|
||||
saveStyle();
|
||||
StellarX::ControlText drawTextStyle = textStyle;
|
||||
|
||||
if (StellarX::ButtonMode::DISABLED == mode) //设置禁用按钮色
|
||||
{
|
||||
setfillcolor(DISABLEDCOLOUR);
|
||||
// 禁用态删除线只属于本次绘制效果,不回写用户公开 textStyle。
|
||||
drawTextStyle.bStrikeOut = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 点击状态优先级最高,然后是悬停状态,最后是默认状态
|
||||
COLORREF col = click ? buttonTrueColor : (hover ? buttonHoverColor : buttonFalseColor);
|
||||
setfillcolor(col);
|
||||
}
|
||||
//
|
||||
//设置字体背景色透明
|
||||
setbkmode(TRANSPARENT);
|
||||
//边框颜色
|
||||
setlinecolor(buttonBorderColor);
|
||||
|
||||
//设置字体颜色
|
||||
settextcolor(drawTextStyle.color);
|
||||
//设置字体样式
|
||||
settextstyle(drawTextStyle.nHeight, drawTextStyle.nWidth, drawTextStyle.faceName.c_str(),
|
||||
drawTextStyle.nEscapement, drawTextStyle.nOrientation, drawTextStyle.nWeight,
|
||||
drawTextStyle.bItalic, drawTextStyle.bUnderline, drawTextStyle.bStrikeOut);
|
||||
|
||||
if (needCutText)
|
||||
cutButtonText();
|
||||
|
||||
//获取字符串像素高度和宽度
|
||||
if ((this->oldtext_width != this->text_width || this->oldtext_height != this->text_height)
|
||||
|| (-1 == oldtext_width && oldtext_height == -1))
|
||||
{
|
||||
if (isUseCutText)
|
||||
{
|
||||
this->oldtext_width = this->text_width = textwidth(LPCTSTR(this->cutText.c_str()));
|
||||
this->oldtext_height = this->text_height = textheight(LPCTSTR(this->cutText.c_str()));
|
||||
}
|
||||
else
|
||||
{
|
||||
this->oldtext_width = this->text_width = textwidth(LPCTSTR(this->text.c_str()));
|
||||
this->oldtext_height = this->text_height = textheight(LPCTSTR(this->text.c_str()));
|
||||
}
|
||||
}
|
||||
|
||||
//设置按钮填充模式
|
||||
setfillstyle((int)buttonFillMode, (int)buttonFillIma, buttonFileIMAGE.get());
|
||||
const int snapshotWidth = this->width + bordWith;
|
||||
const int snapshotHeight = this->height + bordHeight;
|
||||
// 快照尺寸与按钮实际 coverage 使用同一组值,避免边框或右下边界没有被恢复。
|
||||
if ((saveBkX != this->x) || (saveBkY != this->y) || (!hasSnap) ||
|
||||
(saveWidth != snapshotWidth) || (saveHeight != snapshotHeight) || !saveBkImage)
|
||||
saveBackground(this->x, this->y, snapshotWidth, snapshotHeight);
|
||||
// 恢复背景(清除旧内容)
|
||||
restBackground();
|
||||
//根据按钮形状绘制
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE://有边框填充矩形
|
||||
fillrectangle(x, y, x + width, y + height);
|
||||
isUseCutText ? outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(cutText.c_str()))
|
||||
: outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_RECTANGLE://无边框填充矩形
|
||||
solidrectangle(x, y, x + width, y + height);
|
||||
isUseCutText ? outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(cutText.c_str()))
|
||||
: outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE://有边框填充圆角矩形
|
||||
fillroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);
|
||||
isUseCutText ? outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(cutText.c_str()))
|
||||
: outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE://无边框填充圆角矩形
|
||||
solidroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);
|
||||
isUseCutText ? outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(cutText.c_str()))
|
||||
: outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE://有边框填充圆形
|
||||
fillcircle(x + width / 2, y + height / 2, min(width, height) / 2);
|
||||
isUseCutText ? outtextxy(x + width / 2 - text_width / 2, y + height / 2 - text_height / 2, LPCTSTR(cutText.c_str()))
|
||||
: outtextxy(x + width / 2 - text_width / 2, y + height / 2 - text_height / 2, LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_CIRCLE://无边框填充圆形
|
||||
solidcircle(x + width / 2, y + height / 2, min(width, height) / 2);
|
||||
isUseCutText ? outtextxy(x + width / 2 - text_width / 2, y + height / 2 - text_height / 2, LPCTSTR(cutText.c_str()))
|
||||
: outtextxy(x + width / 2 - text_width / 2, y + height / 2 - text_height / 2, LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::ELLIPSE://有边框填充椭圆
|
||||
fillellipse(x, y, x + width, y + height);
|
||||
isUseCutText ? outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(cutText.c_str()))
|
||||
: outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_ELLIPSE://无边框填充椭圆
|
||||
solidellipse(x, y, x + width, y + height);
|
||||
isUseCutText ? outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(cutText.c_str()))
|
||||
: outtextxy((x + (width - text_width) / 2), (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
}
|
||||
|
||||
if (logicalFocused && width > 6 && height > 6)
|
||||
{
|
||||
setlinecolor(RGB(32, 32, 32));
|
||||
setlinestyle(PS_DOT, 1);
|
||||
rectangle(x + 3, y + 3, x + width - 3, y + height - 3);
|
||||
}
|
||||
|
||||
restoreStyle();//恢复默认字体样式和颜色
|
||||
dirty = false; //标记按钮不需要重绘
|
||||
|
||||
if (tipEnabled && tipVisible)
|
||||
tipLabel.draw();
|
||||
}
|
||||
// 处理鼠标事件,检测点击和悬停状态
|
||||
// 根据按钮模式和形状进行不同的处理
|
||||
bool Button::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show)
|
||||
return false;
|
||||
auto mutationScope = enterMutationScope();
|
||||
resetEventVisualChanged();
|
||||
|
||||
bool oldHover = hover;// 注意:只在状态变化时记录,避免 WM_MOUSEMOVE 刷屏
|
||||
bool oldClick = click;
|
||||
const bool oldTipVisible = tipVisible;
|
||||
|
||||
bool consume = false;//是否消耗事件
|
||||
const bool isMouseMessage =
|
||||
msg.message == WM_MOUSEMOVE ||
|
||||
msg.message == WM_LBUTTONDOWN ||
|
||||
msg.message == WM_LBUTTONUP;
|
||||
// 记录鼠标位置(用于tip定位)
|
||||
if (isMouseMessage)
|
||||
{
|
||||
lastMouseX = msg.x;
|
||||
lastMouseY = msg.y;
|
||||
}
|
||||
// 检测悬停状态(根据不同形状)
|
||||
if (isMouseMessage)
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
hover = (msg.x > x && msg.x < (x + width) && msg.y > y && msg.y < (y + height));
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
hover = isMouseInCircle(msg.x, msg.y, x + width / 2, y + height / 2, min(width, height) / 2);
|
||||
break;
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
hover = isMouseInEllipse(msg.x, msg.y, x, y, x + width, y + height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 处理鼠标点击事件
|
||||
if (msg.message == WM_LBUTTONDOWN && hover && mode != StellarX::ButtonMode::DISABLED)
|
||||
{
|
||||
requestFocus(StellarX::FocusReason::Pointer);
|
||||
if (mode == StellarX::ButtonMode::NORMAL)
|
||||
{
|
||||
click = true;
|
||||
SX_LOGD("Button") << SX_T("被点击: ","lbtn - down:")<< "id = " << id <<" text = "<<text << " mode = " << (int)mode;
|
||||
|
||||
dirty = true;
|
||||
consume = true;
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
// TOGGLE模式在鼠标释放时处理
|
||||
}
|
||||
}
|
||||
// NORMAL 模式:鼠标在按钮上释放时才触发点击回调,如果移出区域则取消点击状态。
|
||||
// TOGGLE 模式:在释放时切换状态,并触发相应的开/关回调。
|
||||
else if (msg.message == WM_LBUTTONUP && hover && mode != StellarX::ButtonMode::DISABLED)
|
||||
{
|
||||
hideTooltip(); // 隐藏悬停提示
|
||||
if (mode == StellarX::ButtonMode::NORMAL && click)
|
||||
{
|
||||
if (onClickCallback) onClickCallback();
|
||||
SX_LOGI("Button") << "click: id=" << id << " (NORMAL) callback=" << (onClickCallback ? "Y" : "N");
|
||||
|
||||
click = false;
|
||||
dirty = true;
|
||||
consume = true;
|
||||
hideTooltip();
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
click = !click;
|
||||
if (click && onToggleOnCallback) onToggleOnCallback();
|
||||
else if (!click && onToggleOffCallback) onToggleOffCallback();
|
||||
SX_LOGI("Button") << "toggle: id=" << id
|
||||
<< " " << (oldClick ? 1 : 0) << "->" << (click ? 1 : 0)
|
||||
<< " onCb=" << (onToggleOnCallback ? "Y" : "N")
|
||||
<< " offCb=" << (onToggleOffCallback ? "Y" : "N");
|
||||
|
||||
dirty = true;
|
||||
consume = true;
|
||||
refreshTooltipTextForState();
|
||||
hideTooltip();
|
||||
}
|
||||
}
|
||||
// 处理鼠标移出区域的情况
|
||||
|
||||
else if (msg.message == WM_MOUSEMOVE)
|
||||
{
|
||||
if (!hover && mode == StellarX::ButtonMode::NORMAL && click)
|
||||
{
|
||||
click = false;
|
||||
dirty = true;
|
||||
}
|
||||
else if (hover != oldHover)
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (tipEnabled)
|
||||
{
|
||||
if (hover && !oldHover)
|
||||
{
|
||||
// 刚刚进入悬停:开计时,暂不显示
|
||||
tipHoverTick = GetTickCount64();
|
||||
tipVisible = false;
|
||||
}
|
||||
if (!hover && oldHover)
|
||||
{
|
||||
// 刚移出:立即隐藏
|
||||
hideTooltip();
|
||||
}
|
||||
if (hover && !tipVisible)
|
||||
{
|
||||
// 到点就显示
|
||||
if (GetTickCount64() - tipHoverTick >= (ULONGLONG)tipDelayMs)
|
||||
showTooltipAtLastMouse();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果状态发生变化,标记需要重绘
|
||||
const bool stateChanged = (hover != oldHover || click != oldClick);
|
||||
if (stateChanged)
|
||||
dirty = true;
|
||||
const bool tipVisibilityChanged = (tipVisible != oldTipVisible);
|
||||
if (tipVisibilityChanged)
|
||||
dirty = true;
|
||||
|
||||
// 事件吞噬规则:
|
||||
// - 鼠标移动:只有“当前命中按钮”时才吞掉,避免前一个按钮在清 hover 时截断消息,
|
||||
// 导致后一个真正命中的按钮收不到 WM_MOUSEMOVE。
|
||||
// 这里的前提是:父容器会保证“没有拿到真实消息的后续兄弟”还能走一遍
|
||||
// clearTransientMouseState(),从而清掉旧 hover / tooltip。
|
||||
// - 鼠标按下/抬起:命中按钮区域时吞掉,避免点击穿透到底层控件。
|
||||
if (msg.message == WM_MOUSEMOVE)
|
||||
{
|
||||
if (hover)
|
||||
consume = true;
|
||||
}
|
||||
else if ((msg.message == WM_LBUTTONDOWN || msg.message == WM_LBUTTONUP) && hover)
|
||||
{
|
||||
consume = true;
|
||||
}
|
||||
markEventVisualChanged(stateChanged || tipVisibilityChanged);
|
||||
|
||||
// 如果需要重绘,立即执行
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
|
||||
return consume;
|
||||
}
|
||||
|
||||
void Button::tick(ULONGLONG nowMs)
|
||||
{
|
||||
// tick 只处理已经命中的按钮;到期后显示 Tooltip,并把重绘交给父容器统一提交。
|
||||
if (!show || !tipEnabled || !hover || tipVisible)
|
||||
return;
|
||||
if (nowMs - tipHoverTick < static_cast<ULONGLONG>(tipDelayMs))
|
||||
return;
|
||||
showTooltipAtLastMouse();
|
||||
dirty = true;
|
||||
markEventVisualChanged(true);
|
||||
requestRepaint(parent);
|
||||
}
|
||||
|
||||
bool Button::clearTransientMouseState()
|
||||
{
|
||||
if (!show)
|
||||
return false;
|
||||
|
||||
bool hoverChanged = false;
|
||||
bool normalClickChanged = false;
|
||||
bool bodyVisualChanged = false;
|
||||
bool tooltipChanged = false;
|
||||
|
||||
if (hover)
|
||||
{
|
||||
hover = false;
|
||||
hoverChanged = true;
|
||||
bodyVisualChanged = true;
|
||||
}
|
||||
|
||||
// NORMAL 模式下的按下态只属于一次鼠标交互过程;
|
||||
// 如果本次 WM_MOUSEMOVE 已经被同层更上层的兄弟控件消费,
|
||||
// 当前按钮就不应继续保留这笔临时按下视觉。
|
||||
if (mode == StellarX::ButtonMode::NORMAL && click)
|
||||
{
|
||||
click = false;
|
||||
normalClickChanged = true;
|
||||
bodyVisualChanged = true;
|
||||
}
|
||||
|
||||
if (tipVisible)
|
||||
{
|
||||
hideTooltip();
|
||||
tooltipChanged = true;
|
||||
}
|
||||
|
||||
const bool stateChanged = bodyVisualChanged || tooltipChanged;
|
||||
if (!stateChanged)
|
||||
return false;
|
||||
|
||||
SX_LOG_TRACE("Event")
|
||||
<< SX_T("清理按钮鼠标临时状态:id=", "clear button transient mouse state: id=")
|
||||
<< id
|
||||
<< SX_T(" hover=", " hover=") << (hoverChanged ? 1 : 0)
|
||||
<< SX_T(" tooltip=", " tooltip=") << (tooltipChanged ? 1 : 0)
|
||||
<< SX_T(" normalClick=", " normalClick=") << (normalClickChanged ? 1 : 0);
|
||||
|
||||
markEventVisualChanged(true);
|
||||
|
||||
// 只有按钮本体视觉真正变化时,才请求按钮自身区域重绘;
|
||||
// 若只是 Tooltip 消失,hideTooltip() 已经通过回贴快照清掉了悬浮层区域,
|
||||
// 不再额外重绘按钮本体,避免高频鼠标移动时的无意义重画。
|
||||
if (bodyVisualChanged)
|
||||
{
|
||||
dirty = true;
|
||||
requestRepaint(parent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
RECT Button::getManagedRepaintCoverageRect() const
|
||||
{
|
||||
RECT coverage = getBoundsRect();
|
||||
coverage.right += bordWith;
|
||||
coverage.bottom += bordHeight;
|
||||
|
||||
if (!tipEnabled || !tipVisible)
|
||||
return coverage;
|
||||
|
||||
const RECT tipRect = tipLabel.getManagedRepaintCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, tipRect.left);
|
||||
coverage.top = (std::min)(coverage.top, tipRect.top);
|
||||
coverage.right = (std::max)(coverage.right, tipRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, tipRect.bottom);
|
||||
return coverage;
|
||||
}
|
||||
|
||||
RECT Button::getManagedRepaintPersistentCoverageRect() const
|
||||
{
|
||||
// Tooltip 属于临时浮层,只参与完整 coverage 和上层 overlay 判断;
|
||||
// 兄弟控件是否需要作废背景快照,只应由按钮本体这类持久绘制范围决定。
|
||||
RECT coverage = getBoundsRect();
|
||||
coverage.right += bordWith;
|
||||
coverage.bottom += bordHeight;
|
||||
return coverage;
|
||||
}
|
||||
|
||||
void Button::setOnClickListener(std::function<void()> callback)
|
||||
{
|
||||
this->onClickCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void Button::setOnToggleOnListener(std::function<void()> callback)
|
||||
{
|
||||
this->onToggleOnCallback = std::move(callback);
|
||||
}
|
||||
void Button::setOnToggleOffListener(std::function<void()> callback)
|
||||
{
|
||||
this->onToggleOffCallback = std::move(callback);
|
||||
}
|
||||
|
||||
void Button::setbuttonMode(StellarX::ButtonMode mode)
|
||||
{
|
||||
//取值范围参考 buttMode的枚举注释
|
||||
this->mode = mode;
|
||||
dirty = true; // 标记需要重绘
|
||||
notifyFocusSubtreeStateChanged();
|
||||
}
|
||||
|
||||
void Button::setROUND_RECTANGLEwidth(int width)
|
||||
{
|
||||
rouRectangleSize.ROUND_RECTANGLEwidth = width;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
void Button::setROUND_RECTANGLEheight(int height)
|
||||
{
|
||||
rouRectangleSize.ROUND_RECTANGLEheight = height;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
bool Button::isClicked() const
|
||||
{
|
||||
return this->click;
|
||||
}
|
||||
|
||||
void Button::setFillMode(StellarX::FillMode mode)
|
||||
{
|
||||
this->buttonFillMode = mode;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
void Button::setFillIma(StellarX::FillStyle ima)
|
||||
{
|
||||
buttonFillIma = ima;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setFillIma(std::string imaNAme)
|
||||
{
|
||||
if (buttonFileIMAGE)
|
||||
{
|
||||
buttonFileIMAGE.reset();
|
||||
}
|
||||
buttonFileIMAGE = std::make_unique<IMAGE>();
|
||||
loadimage(buttonFileIMAGE.get(), imaNAme.c_str(), width, height);
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonBorder(COLORREF Border)
|
||||
{
|
||||
buttonBorderColor = Border;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonFalseColor(COLORREF color)
|
||||
{
|
||||
this->buttonFalseColor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonText(const char* text)
|
||||
{
|
||||
this->text = std::string(text);
|
||||
this->text_width = textwidth(LPCTSTR(this->text.c_str()));
|
||||
this->text_height = textheight(LPCTSTR(this->text.c_str()));
|
||||
this->dirty = true;
|
||||
this->needCutText = true;
|
||||
if (!tipUserOverride)
|
||||
tipTextClick = tipTextOn = tipTextOff = text;
|
||||
}
|
||||
|
||||
void Button::setButtonText(std::string text)
|
||||
{
|
||||
this->text = text;
|
||||
this->text_width = textwidth(LPCTSTR(this->text.c_str()));
|
||||
this->text_height = textheight(LPCTSTR(this->text.c_str()));
|
||||
this->dirty = true; // 标记需要重绘
|
||||
this->needCutText = true;
|
||||
if (!tipUserOverride)
|
||||
tipTextClick = tipTextOn = tipTextOff = text;
|
||||
}
|
||||
|
||||
void Button::setButtonShape(StellarX::ControlShape shape)
|
||||
{
|
||||
this->shape = shape;
|
||||
this->dirty = true;
|
||||
this->needCutText = true;
|
||||
}
|
||||
|
||||
//允许通过外部函数修改按钮的点击状态,并执行相应的回调函数
|
||||
void Button::setButtonClick(BOOL click)
|
||||
{
|
||||
auto mutationScope = enterMutationScope();
|
||||
const bool targetClick = (click != FALSE);
|
||||
// TOGGLE 状态若没有发生变化,就不应重复触发 onToggleOn/OffCallback。
|
||||
// 否则像 TabControl 这种“外部再次激活已激活页签”的场景,
|
||||
// 会把页面显示/隐藏、快照失效、重绘链整条重复执行一遍,最终破坏可见页的背景恢复语义。
|
||||
if (mode == StellarX::ButtonMode::TOGGLE && this->click == targetClick)
|
||||
return;
|
||||
|
||||
this->click = targetClick;
|
||||
|
||||
if (mode == StellarX::ButtonMode::NORMAL && click)
|
||||
{
|
||||
if (onClickCallback) onClickCallback();
|
||||
dirty = true;
|
||||
hideTooltip();
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
if (click && onToggleOnCallback) onToggleOnCallback();
|
||||
else if (!click && onToggleOffCallback) onToggleOffCallback();
|
||||
dirty = true;
|
||||
refreshTooltipTextForState();
|
||||
hideTooltip();
|
||||
}
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
}
|
||||
|
||||
std::string Button::getButtonText() const
|
||||
{
|
||||
return this->text;
|
||||
}
|
||||
|
||||
const char* Button::getButtonText_c() const
|
||||
{
|
||||
return this->text.c_str();
|
||||
}
|
||||
|
||||
StellarX::ButtonMode Button::getButtonMode() const
|
||||
{
|
||||
return this->mode;
|
||||
}
|
||||
|
||||
StellarX::ControlShape Button::getButtonShape() const
|
||||
{
|
||||
return this->shape;
|
||||
}
|
||||
|
||||
StellarX::FillMode Button::getFillMode() const
|
||||
{
|
||||
return this->buttonFillMode;
|
||||
}
|
||||
|
||||
StellarX::FillStyle Button::getFillIma() const
|
||||
{
|
||||
return this->buttonFillIma;
|
||||
}
|
||||
|
||||
IMAGE* Button::getFillImaImage() const
|
||||
{
|
||||
return this->buttonFileIMAGE.get();
|
||||
}
|
||||
|
||||
COLORREF Button::getButtonBorder() const
|
||||
{
|
||||
return this->buttonBorderColor;
|
||||
}
|
||||
|
||||
COLORREF Button::getButtonTextColor() const
|
||||
{
|
||||
return this->textStyle.color;
|
||||
}
|
||||
|
||||
StellarX::ControlText Button::getButtonTextStyle() const
|
||||
{
|
||||
return this->textStyle;
|
||||
}
|
||||
|
||||
bool Button::isMouseInCircle(int mouseX, int mouseY, int x, int y, int radius)
|
||||
{
|
||||
double dis = sqrt(pow(mouseX - x, 2) + pow(mouseY - y, 2));
|
||||
if (dis <= radius)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Button::isMouseInEllipse(int mouseX, int mouseY, int x, int y, int width, int height)
|
||||
{
|
||||
int centerX = (x + width) / 2;
|
||||
int centerY = (y + height) / 2;
|
||||
int majorAxis = (width - x) / 2;
|
||||
int minorAxis = (height - y) / 2;
|
||||
double dx = mouseX - centerX;
|
||||
double dy = mouseY - centerY;
|
||||
double normalizedDistance = (dx * dx) / (majorAxis * majorAxis) + (dy * dy) / (minorAxis * minorAxis);
|
||||
|
||||
// 判断鼠标是否在椭圆内
|
||||
if (normalizedDistance <= 1.0)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
void Button::cutButtonText()
|
||||
{
|
||||
const int contentW = 1 > this->width - 2 * padX ? 1 : this->width - 2 * padX;
|
||||
// 放得下:不截断,直接用原文
|
||||
if (textwidth(LPCTSTR(this->text.c_str())) <= contentW) {
|
||||
isUseCutText = false;
|
||||
needCutText = false;
|
||||
cutText.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// 放不下:按语言偏好裁切(ASCII→词边界;CJK→逐字符,不撕裂双字节)
|
||||
if (is_ascii_only(this->text))
|
||||
{
|
||||
cutText = ellipsize_ascii_pref(this->text, contentW); // "..."
|
||||
}
|
||||
else
|
||||
{
|
||||
cutText = ellipsize_cjk_pref(this->text, contentW, "…"); // 全角省略号
|
||||
}
|
||||
isUseCutText = true;
|
||||
needCutText = false;
|
||||
}
|
||||
|
||||
void Button::hideTooltip()
|
||||
{
|
||||
// 隐藏提示时先回贴其独立快照,再作废状态,避免 Tooltip 像素留在按钮之外。
|
||||
if (tipVisible)
|
||||
{
|
||||
tipVisible = false;
|
||||
// Tooltip 是 Button 的内置浮层,不属于独立控件树节点。
|
||||
// 因此在隐藏时应直接回贴它自己的背景快照并作废,
|
||||
// 不能仅仅作废快照,否则当本轮只重绘按钮本体区域时,
|
||||
// Tooltip 占用的那块屏幕可能无人擦除,最终表现为“鼠标移开后提示框残留”。
|
||||
tipLabel.hide(); // 还原快照 + 作废快照,立即清掉 Tooltip 自身绘制区域
|
||||
tipHoverTick = GetTickCount64(); // 重置计时基线
|
||||
}
|
||||
}
|
||||
|
||||
void Button::showTooltipAtLastMouse()
|
||||
{
|
||||
// Tooltip 的位置和文案在真正显示时计算,保证 Toggle 状态与最后一次鼠标位置一致。
|
||||
if (!tipEnabled || tipVisible)
|
||||
return;
|
||||
tipVisible = true;
|
||||
const int tipX = tipFollowCursor ? lastMouseX + tipOffsetX : lastMouseX;
|
||||
const int tipY = tipFollowCursor ? lastMouseY + tipOffsetY : y + height;
|
||||
if (tipUserOverride)
|
||||
{
|
||||
if (mode == StellarX::ButtonMode::NORMAL)
|
||||
tipLabel.setText(tipTextClick);
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
tipLabel.setText(click ? tipTextOn : tipTextOff);
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
tipLabel.setText(click ? tipTextOn : tipTextOff);
|
||||
}
|
||||
tipLabel.setX(tipX);
|
||||
tipLabel.setY(tipY);
|
||||
tipLabel.setDirty(true);
|
||||
}
|
||||
|
||||
void Button::refreshTooltipTextForState()
|
||||
{
|
||||
if (tipUserOverride) return; // 用户显式设置过 tipText,保持不变
|
||||
if (mode == StellarX::ButtonMode::NORMAL)
|
||||
tipLabel.setText(tipTextClick);
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
tipLabel.setText(click ? tipTextOn : tipTextOff);
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
#include "Canvas.h"
|
||||
#include "SxLog.h"
|
||||
#include "Window.h"
|
||||
#include <algorithm>
|
||||
|
||||
static bool SxIsNoisyMsg(UINT m)
|
||||
{
|
||||
return m == WM_MOUSEMOVE;
|
||||
}
|
||||
|
||||
static const char* SxCanvasMsgName(UINT m)
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case WM_MOUSEMOVE: return "WM_MOUSEMOVE";
|
||||
case WM_LBUTTONDOWN: return "WM_LBUTTONDOWN";
|
||||
case WM_LBUTTONUP: return "WM_LBUTTONUP";
|
||||
case WM_KEYDOWN: return "WM_KEYDOWN";
|
||||
case WM_KEYUP: return "WM_KEYUP";
|
||||
default: return "WM_UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
enum class SxCanvasOverlayRedrawMode
|
||||
{
|
||||
None,
|
||||
RefreshSnapshot
|
||||
};
|
||||
|
||||
bool SxCanvasRectValid(const RECT& rc)
|
||||
{
|
||||
return rc.right > rc.left && rc.bottom > rc.top;
|
||||
}
|
||||
|
||||
bool SxCanvasRectsIntersect(const RECT& a, const RECT& b)
|
||||
{
|
||||
return a.left < b.right && a.right > b.left &&
|
||||
a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
|
||||
RECT SxCanvasUnionRect(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;
|
||||
}
|
||||
}
|
||||
|
||||
Canvas::Canvas()
|
||||
:Control(0, 0, 100, 100)
|
||||
{
|
||||
this->id = "Canvas";
|
||||
// Canvas 是通用容器,当前阶段显式允许双轴 Stretch。
|
||||
this->layoutCapability.allowStretchX = true;
|
||||
this->layoutCapability.allowStretchY = true;
|
||||
}
|
||||
|
||||
Canvas::Canvas(int x, int y, int width, int height)
|
||||
:Control(x, y, width, height)
|
||||
{
|
||||
this->id = "Canvas";
|
||||
this->layoutCapability.allowStretchX = true;
|
||||
this->layoutCapability.allowStretchY = true;
|
||||
}
|
||||
|
||||
void Canvas::relayoutManagedChildren()
|
||||
{
|
||||
// Canvas 负责子控件从“父局部设计矩形”到“当前世界矩形”的转换。
|
||||
// 当 Canvas 自己的位置或尺寸变化后,所有受它管理的子控件都要重新走一次统一解算。
|
||||
for (auto& child : controls)
|
||||
{
|
||||
const StellarX::ResolvedLayoutRect rect =
|
||||
child->resolveLayoutRect(localWidth, localHeight, x, y, width, height);
|
||||
child->applyResolvedLayoutRect(rect);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::setX(int x)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
// 公开 setter 在 Canvas 上不能再视为“单纯改自己的 x”:
|
||||
// 一旦容器移动,子控件的世界坐标也必须整体重算。
|
||||
applyRuntimeRectDirect(x, y, width, height);
|
||||
relayoutManagedChildren();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::setY(int y)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(this->x, y, width, height);
|
||||
relayoutManagedChildren();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::setWidth(int width)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(x, y, (std::max)(0, width), height);
|
||||
relayoutManagedChildren();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::setHeight(int height)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(x, y, width, (std::max)(0, height));
|
||||
relayoutManagedChildren();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect)
|
||||
{
|
||||
// 统一解算器已经给出当前运行态世界矩形;
|
||||
// Canvas 在应用自身矩形后,还必须继续刷新全部子控件。
|
||||
applyRuntimeRectDirect(rect.worldX, rect.worldY, rect.width, rect.height);
|
||||
relayoutManagedChildren();
|
||||
}
|
||||
|
||||
void Canvas::beginMutationScope()
|
||||
{
|
||||
++mutationScopeDepth;
|
||||
Control::beginMutationScope();
|
||||
}
|
||||
|
||||
void Canvas::endMutationScope()
|
||||
{
|
||||
if (mutationScopeDepth > 0)
|
||||
--mutationScopeDepth;
|
||||
Window* host = getHostWindow();
|
||||
if (mutationScopeDepth == 0 && !host)
|
||||
flushPendingMutations();
|
||||
// 向外层传播结束事务可能触发当前 Canvas 被删除,因此这里必须是本函数的最后一步。
|
||||
Control::endMutationScope();
|
||||
}
|
||||
|
||||
void Canvas::flushPendingMutations()
|
||||
{
|
||||
// 先按入队顺序应用 Add/Remove/Clear,再递归提交子容器,保证一次回调的结果可预测。
|
||||
if (applyingControlMutations)
|
||||
return;
|
||||
applyingControlMutations = true;
|
||||
bool changed = false;
|
||||
for (auto& mutation : pendingControlMutations)
|
||||
{
|
||||
switch (mutation.kind)
|
||||
{
|
||||
case ControlMutationKind::Add:
|
||||
if (mutation.control)
|
||||
{
|
||||
mutation.control->setParent(this);
|
||||
const StellarX::ResolvedLayoutRect rect = mutation.control->resolveLayoutRect(
|
||||
localWidth, localHeight, x, y, width, height);
|
||||
mutation.control->applyResolvedLayoutRect(rect);
|
||||
controls.push_back(std::move(mutation.control));
|
||||
changed = true;
|
||||
}
|
||||
break;
|
||||
case ControlMutationKind::Remove:
|
||||
{
|
||||
auto it = std::find_if(controls.begin(), controls.end(), [&](const auto& item)
|
||||
{ return item && item->getHandle() == mutation.handle; });
|
||||
if (it != controls.end())
|
||||
{
|
||||
controls.erase(it);
|
||||
changed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ControlMutationKind::Clear:
|
||||
if (!controls.empty())
|
||||
{
|
||||
controls.clear();
|
||||
changed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingControlMutations.clear();
|
||||
for (auto& child : controls)
|
||||
child->flushPendingMutations();
|
||||
applyingControlMutations = false;
|
||||
if (changed)
|
||||
{
|
||||
// 子树结构变化会使既有快照失去可信的覆盖关系,升级为宿主窗口的一次整场景重绘。
|
||||
dirty = true;
|
||||
if (auto* host = getHostWindow()) host->requestFullSceneRedraw();
|
||||
else if (parent) parent->setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::draw()
|
||||
{
|
||||
auto mutationScope = enterMutationScope();
|
||||
if (!dirty || !show)
|
||||
{
|
||||
for (auto& control : controls)
|
||||
if (auto c = dynamic_cast<Table*>(control.get()))
|
||||
c->draw();
|
||||
return;
|
||||
}
|
||||
saveStyle();
|
||||
setlinecolor(canvasBorderClor);//设置线色
|
||||
if (StellarX::FillMode::Null != canvasFillMode)
|
||||
setfillcolor(canvasBkClor);//设置填充色
|
||||
setfillstyle((int)canvasFillMode);//设置填充模式
|
||||
setlinestyle((int)canvasLineStyle, canvaslinewidth);
|
||||
|
||||
// 在绘制画布之前,先恢复并更新背景快照:
|
||||
// 1. 如果已有快照,则先回贴旧快照以清除之前的内容。
|
||||
// 2. 当坐标或尺寸变化,或缓存图像无效时,丢弃旧快照并重新抓取新的背景。
|
||||
int margin = canvaslinewidth > 1 ? canvaslinewidth : 1;
|
||||
if (hasSnap)
|
||||
{
|
||||
// 恢复旧快照,清除上一次绘制
|
||||
restBackground();
|
||||
// 如果位置或尺寸变了,或没有有效缓存,则重新抓取
|
||||
if (!saveBkImage || saveBkX != this->x - margin || saveBkY != this->y - margin || saveWidth != this->width + margin * 2 || saveHeight != this->height + margin * 2)
|
||||
{
|
||||
invalidateBackgroundSnapshot();
|
||||
saveBackground(this->x - margin, this->y - margin, this->width + margin * 2, this->height + margin * 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 首次绘制或没有快照时直接抓取背景
|
||||
saveBackground(this->x - margin, this->y - margin, this->width + margin * 2, this->height + margin * 2);
|
||||
}
|
||||
// 再次恢复最新快照,确保绘制区域干净
|
||||
restBackground();
|
||||
//根据画布形状绘制
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
fillrectangle(x, y, x + width, y + height);//有边框填充矩形
|
||||
break;
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
solidrectangle(x, y, x + width, y + height);//无边框填充矩形
|
||||
break;
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
fillroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//有边框填充圆角矩形
|
||||
break;
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
solidroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//无边框填充圆角矩形
|
||||
break;
|
||||
}
|
||||
// 绘制所有子控件
|
||||
for (auto& control : controls)
|
||||
{
|
||||
control->setDirty(true);
|
||||
control->draw();
|
||||
}
|
||||
|
||||
restoreStyle();
|
||||
dirty = false; //标记画布不需要重绘
|
||||
}
|
||||
|
||||
bool Canvas::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show) return false;
|
||||
auto mutationScope = enterMutationScope();
|
||||
resetEventVisualChanged();
|
||||
|
||||
bool consumed = false;
|
||||
bool anyDirty = false;
|
||||
bool anyVisualChanged = false;
|
||||
Control* firstConsumer = nullptr;
|
||||
|
||||
if (msg.message == WM_MOUSEMOVE)
|
||||
{
|
||||
// WM_MOUSEMOVE 需要特殊处理:
|
||||
// - 第一个命中的兄弟分支收到真实消息;
|
||||
// - 后续兄弟不再重新命中,只清理旧 hover / tooltip 等临时状态。
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
Control* c = it->get();
|
||||
if (!consumed)
|
||||
{
|
||||
bool cConsumed = c->handleEvent(msg);
|
||||
if (c->didEventAffectVisual()) anyVisualChanged = true;
|
||||
if (cConsumed)
|
||||
{
|
||||
firstConsumer = c;
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 后续兄弟只走临时状态清理,不会再进入自己的 handleEvent()。
|
||||
// Tooltip 隐藏会先回贴旧快照,再改变 coverage;因此必须先保存旧覆盖范围,
|
||||
// 避免登记重绘时丢失旧 Tooltip 区域,导致上层 overlay 补画判断不完整。
|
||||
const RECT previousCoverage = c->getManagedRepaintCoverageRect();
|
||||
if (c->clearTransientMouseState())
|
||||
{
|
||||
if (Window* host = getHostWindow())
|
||||
host->requestManagedRepaint(c, previousCoverage);
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
Control* c = it->get();
|
||||
bool cConsumed = c->handleEvent(msg);
|
||||
|
||||
if (c->isDirty()) anyDirty = true;
|
||||
if (c->didEventAffectVisual()) anyVisualChanged = true;
|
||||
|
||||
if (cConsumed)
|
||||
{
|
||||
firstConsumer = c;
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstConsumer && !SxIsNoisyMsg(msg.message))
|
||||
{
|
||||
SX_LOGD("Event") << SX_T("Canvas 消耗消息: ","Canvas consumed: ")
|
||||
<< SxCanvasMsgName(msg.message)
|
||||
<< SX_T(" 子控件 id=", " childId=") << firstConsumer->getId();
|
||||
}
|
||||
|
||||
if (anyDirty)
|
||||
{
|
||||
// 只要任一子控件因本次事件进入 dirty,就把这笔重绘继续向上汇报。
|
||||
// 在托管模式下,这不会立即绘制,而是登记为 Canvas 对应的重绘 root。
|
||||
if (!SxIsNoisyMsg(msg.message))
|
||||
SX_LOGD("Dirty") << SX_T("Canvas检测有控件为脏状态 -> 请求重绘, ","Canvas anyDirty -> requestRepaint, ")<<"id = " << id;
|
||||
requestRepaint(parent);
|
||||
}
|
||||
markEventVisualChanged(anyVisualChanged);
|
||||
|
||||
return consumed;
|
||||
}
|
||||
|
||||
bool Canvas::clearTransientMouseState()
|
||||
{
|
||||
bool changed = false;
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
Control* child = it->get();
|
||||
if (!child->IsVisible())
|
||||
continue;
|
||||
if (child->clearTransientMouseState())
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
||||
StellarX::ControlHandle Canvas::addControl(std::unique_ptr<Control> control)
|
||||
{
|
||||
if (!control)
|
||||
return {};
|
||||
const StellarX::ControlHandle handle = control->getHandle();
|
||||
Window* host = getHostWindow();
|
||||
if (!applyingControlMutations && (mutationScopeDepth > 0 || (host && host->isMutationTransactionActive())))
|
||||
{
|
||||
// 事务内只转移所有权并返回句柄,实际挂接父指针和布局在提交阶段完成。
|
||||
PendingControlMutation mutation;
|
||||
mutation.kind = ControlMutationKind::Add;
|
||||
mutation.handle = handle;
|
||||
mutation.control = std::move(control);
|
||||
pendingControlMutations.push_back(std::move(mutation));
|
||||
return handle;
|
||||
}
|
||||
control->setParent(this);
|
||||
// 新子控件加入容器时,立刻按“当前容器运行态矩形”解算一次,
|
||||
// 避免后续第一次 draw / resize 前 world 坐标仍停留在设计态。
|
||||
const StellarX::ResolvedLayoutRect rect =
|
||||
control->resolveLayoutRect(localWidth, localHeight, this->x, this->y, this->width, this->height);
|
||||
control->applyResolvedLayoutRect(rect);
|
||||
SX_LOGI("Canvas")
|
||||
<< SX_T("添加子控件:父=Canvas 子id=", "addControl: parent=Canvas childId=")
|
||||
<< control->getId()
|
||||
<< SX_T(" 相对坐标=(", " local=(")
|
||||
<< control->getLocalX() << "," << control->getLocalY()
|
||||
<< SX_T(") 绝对坐标=(", ") abs=(")
|
||||
<< control->getX() << "," << control->getY()
|
||||
<< ")";
|
||||
|
||||
|
||||
controls.push_back(std::move(control));
|
||||
dirty = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
void Canvas::tick(ULONGLONG nowMs)
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
auto mutationScope = enterMutationScope();
|
||||
for (auto& control : controls)
|
||||
if (control && control->IsVisible()) control->tick(nowMs);
|
||||
}
|
||||
|
||||
bool Canvas::removeControl(StellarX::ControlHandle handle)
|
||||
{
|
||||
if (!handle)
|
||||
return false;
|
||||
bool exists = std::find_if(controls.begin(), controls.end(), [&](const auto& item)
|
||||
{ return item && item->getHandle() == handle; }) != controls.end();
|
||||
for (const auto& mutation : pendingControlMutations)
|
||||
{
|
||||
if (mutation.kind == ControlMutationKind::Clear)
|
||||
exists = false;
|
||||
else if (mutation.handle == handle)
|
||||
exists = mutation.kind == ControlMutationKind::Add;
|
||||
}
|
||||
if (!exists)
|
||||
return false;
|
||||
Window* host = getHostWindow();
|
||||
if (!applyingControlMutations && (mutationScopeDepth > 0 || (host && host->isMutationTransactionActive())))
|
||||
{
|
||||
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
|
||||
if (host) host->getFocusManager().notifySubtreePendingRemoval(handle);
|
||||
return true;
|
||||
}
|
||||
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
|
||||
if (host) host->getFocusManager().notifySubtreePendingRemoval(handle);
|
||||
flushPendingMutations();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Canvas::clearControls()
|
||||
{
|
||||
Window* host = getHostWindow();
|
||||
pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr });
|
||||
if (host)
|
||||
{
|
||||
std::vector<StellarX::ControlHandle> removedHandles;
|
||||
removedHandles.reserve(controls.size());
|
||||
for (const auto& control : controls)
|
||||
if (control) removedHandles.push_back(control->getHandle());
|
||||
host->getFocusManager().notifySubtreesPendingRemoval(removedHandles);
|
||||
}
|
||||
if (!applyingControlMutations && mutationScopeDepth == 0 && (!host || !host->isMutationTransactionActive()))
|
||||
flushPendingMutations();
|
||||
}
|
||||
|
||||
Control* Canvas::findControl(StellarX::ControlHandle handle)
|
||||
{
|
||||
auto it = std::find_if(controls.begin(), controls.end(), [&](const auto& item)
|
||||
{ return item && item->getHandle() == handle; });
|
||||
return it == controls.end() ? nullptr : it->get();
|
||||
}
|
||||
|
||||
const Control* Canvas::findControl(StellarX::ControlHandle handle) const
|
||||
{
|
||||
auto it = std::find_if(controls.begin(), controls.end(), [&](const auto& item)
|
||||
{ return item && item->getHandle() == handle; });
|
||||
return it == controls.end() ? nullptr : it->get();
|
||||
}
|
||||
|
||||
std::vector<StellarX::ControlHandle> Canvas::getControlHandles() const
|
||||
{
|
||||
std::vector<StellarX::ControlHandle> handles;
|
||||
handles.reserve(controls.size());
|
||||
for (const auto& control : controls)
|
||||
if (control) handles.push_back(control->getHandle());
|
||||
return handles;
|
||||
}
|
||||
|
||||
bool Canvas::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const
|
||||
{
|
||||
if (Control::queryFocusTargetSnapshot(target, snapshot))
|
||||
return true;
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
if (*it && (*it)->queryFocusTargetSnapshot(target, snapshot)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void Canvas::collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
|
||||
{
|
||||
Control::collectFocusTargetSnapshots(snapshots);
|
||||
for (const auto& control : controls)
|
||||
if (control) control->collectFocusTargetSnapshots(snapshots);
|
||||
}
|
||||
|
||||
bool Canvas::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason)
|
||||
{
|
||||
if (Control::applyFocusState(target, focused, reason))
|
||||
return true;
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
if (*it && (*it)->applyFocusState(target, focused, reason)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void Canvas::setShape(StellarX::ControlShape shape)
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
this->shape = shape;
|
||||
dirty = true;
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
this->shape = StellarX::ControlShape::RECTANGLE;
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Canvas::setCanvasfillMode(StellarX::FillMode mode)
|
||||
{
|
||||
this->canvasFillMode = mode;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setBorderColor(COLORREF color)
|
||||
{
|
||||
this->canvasBorderClor = color;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setCanvasBkColor(COLORREF color)
|
||||
{
|
||||
this->canvasBkClor = color;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setCanvasLineStyle(StellarX::LineStyle style)
|
||||
{
|
||||
this->canvasLineStyle = style;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setLinewidth(int width)
|
||||
{
|
||||
this->canvaslinewidth = width;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Canvas::setIsVisible(bool visible)
|
||||
{
|
||||
this->show = visible;
|
||||
dirty = true;
|
||||
for (auto& control : controls)
|
||||
{
|
||||
control->setIsVisible(visible);
|
||||
}
|
||||
if (!visible)
|
||||
discardBackground();
|
||||
// Canvas overrides Control::setIsVisible to propagate visibility to descendants,
|
||||
// so it must also preserve the base focus-invalidation contract after the subtree settles.
|
||||
notifyFocusSubtreeStateChanged();
|
||||
}
|
||||
|
||||
void Canvas::setDirty(bool dirty)
|
||||
{
|
||||
this->dirty = dirty;
|
||||
for (auto& control : controls)
|
||||
control->setDirty(dirty);
|
||||
}
|
||||
|
||||
void Canvas::onWindowResize()
|
||||
{
|
||||
// resize 语义已收口:
|
||||
// Canvas 不再在这里重新解算布局,只负责丢快照、标脏,并向子控件传播“环境已变化”。
|
||||
Control::onWindowResize();
|
||||
for (auto& child : controls)
|
||||
child->onWindowResize();
|
||||
}
|
||||
|
||||
void Canvas::requestRepaint(Control* parent)
|
||||
{
|
||||
if (shouldDeferManagedRepaint())
|
||||
{
|
||||
// 托管路径:由 Window 统一决定这次是否只重画本 Canvas,还是升级为补画 Dialog / 整体场景。
|
||||
if (auto* host = getHostWindow())
|
||||
host->requestManagedRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this == parent)
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
|
||||
// 关键护栏:
|
||||
// - Canvas 自己是脏的 / 没有快照 / 缓存图为空
|
||||
// => 禁止局部重绘,直接升级为一次完整 draw(先把 dirty 置真,避免 draw() 早退)
|
||||
if (dirty || !hasSnap || !saveBkImage)
|
||||
{
|
||||
SX_LOG_TRACE("Dirty")
|
||||
<< SX_T("Canvas 局部重绘降级为全量重绘: id=", "Canvas partial->full draw: id=")
|
||||
<< id
|
||||
<< " dirty=" << (dirty ? 1 : 0)
|
||||
<< " hasSnap=" << (hasSnap ? 1 : 0);
|
||||
|
||||
this->dirty = true;
|
||||
this->draw();
|
||||
return;
|
||||
}
|
||||
|
||||
SX_LOG_TRACE("Dirty") << SX_T("Canvas 请求局部重绘:id=", "Canvas::requestRepaint(partial): id=") << id;
|
||||
|
||||
RECT paintCoverage{};
|
||||
bool hasPaintCoverage = false;
|
||||
RECT persistentCoverage{};
|
||||
bool hasPersistentCoverage = false;
|
||||
auto commitManagedChild = [&](Control* child, SxCanvasOverlayRedrawMode overlayMode)
|
||||
{
|
||||
if (!child || !child->IsVisible())
|
||||
return;
|
||||
|
||||
const bool directDirty = child->isDirty();
|
||||
const bool subtreeDirty = child->hasManagedDirtySubtree();
|
||||
|
||||
if (overlayMode == SxCanvasOverlayRedrawMode::RefreshSnapshot)
|
||||
{
|
||||
// overlay 补画必须先作废旧快照:
|
||||
// 下层兄弟的持久内容刚刚已经写过像素,若继续沿用旧快照,会把旧背景再贴回来。
|
||||
child->invalidateBackgroundSnapshot();
|
||||
child->setDirty(true);
|
||||
child->draw();
|
||||
}
|
||||
else if (directDirty)
|
||||
{
|
||||
child->draw();
|
||||
}
|
||||
else if (subtreeDirty)
|
||||
{
|
||||
// 这次真正脏的是“child 下面的子树”,而不是 child 自身。
|
||||
// 例如嵌套 Canvas 中,第二层/第三层按钮脏了,但第一层 Canvas 自己并不 dirty。
|
||||
// 这里必须把这条直接子分支提交下去,否则深层按钮状态永远没有机会被真正画出来。
|
||||
child->commitManagedRepaint();
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const RECT childPaintRect = child->getManagedRepaintCoverageRect();
|
||||
if (!hasPaintCoverage)
|
||||
{
|
||||
paintCoverage = childPaintRect;
|
||||
hasPaintCoverage = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
paintCoverage = SxCanvasUnionRect(paintCoverage, childPaintRect);
|
||||
}
|
||||
|
||||
const RECT childPersistentRect = child->getManagedRepaintPersistentCoverageRect();
|
||||
if (!hasPersistentCoverage)
|
||||
{
|
||||
persistentCoverage = childPersistentRect;
|
||||
hasPersistentCoverage = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
persistentCoverage = SxCanvasUnionRect(persistentCoverage, childPersistentRect);
|
||||
}
|
||||
};
|
||||
|
||||
for (auto& control : controls)
|
||||
{
|
||||
Control* child = control.get();
|
||||
if (!child->IsVisible())
|
||||
continue;
|
||||
|
||||
if (child->hasManagedDirtySubtree())
|
||||
{
|
||||
commitManagedChild(child, SxCanvasOverlayRedrawMode::None);
|
||||
}
|
||||
else if (hasPaintCoverage && SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), paintCoverage))
|
||||
{
|
||||
// 位于本次累计 coverage 上方、且发生相交的兄弟控件,需要补画回最上层。
|
||||
// 但只有下层“持久内容”影响到它时,才允许作废并重新抓背景快照;
|
||||
// 如果只是被 Tooltip 等临时浮层覆盖,则跳过兄弟补画,避免透明控件回贴旧快照擦掉 Tooltip。
|
||||
const bool persistentHit = hasPersistentCoverage &&
|
||||
SxCanvasRectsIntersect(child->getManagedRepaintPersistentCoverageRect(), persistentCoverage);
|
||||
if (persistentHit)
|
||||
commitManagedChild(child, SxCanvasOverlayRedrawMode::RefreshSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SX_LOG_TRACE("Dirty") << SX_T("Canvas 请求根级重绘:id=", "Canvas::requestRepaint(root): id=") << id;
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
|
||||
bool Canvas::hasManagedDirtySubtree() const
|
||||
{
|
||||
if (dirty)
|
||||
return true;
|
||||
|
||||
for (const auto& child : controls)
|
||||
{
|
||||
if (!child->IsVisible())
|
||||
continue;
|
||||
if (child->hasManagedDirtySubtree())
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
RECT Canvas::getManagedRepaintCoverageRect() const
|
||||
{
|
||||
// Canvas::draw() 会先写自身背景,再强制绘制全部可见子控件。
|
||||
// 因此它的实际 coverage 不能只看本体矩形,还要把可见子控件 coverage 递归并入。
|
||||
RECT coverage = getBoundsRect();
|
||||
for (const auto& child : controls)
|
||||
{
|
||||
if (!child->IsVisible())
|
||||
continue;
|
||||
coverage = SxCanvasUnionRect(coverage, child->getManagedRepaintCoverageRect());
|
||||
}
|
||||
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 只有在“自己本体不脏 + 仍持有有效背景快照”时,
|
||||
// 才能安全地做局部提交(即只更新内部脏子控件)。
|
||||
return show && !dirty && hasValidBackgroundSnapshot();
|
||||
}
|
||||
|
||||
void Canvas::commitManagedRepaint()
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
|
||||
if (canCommitManagedPartialRepaint())
|
||||
{
|
||||
// 快照完好:沿用 Canvas 自己已有的局部重绘逻辑。
|
||||
requestRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自身已经脏了,或快照失效:必须升级为整 Canvas 重画。
|
||||
this->dirty = true;
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
#include "Control.h"
|
||||
#include "FocusManager.h"
|
||||
#include "SxLog.h"
|
||||
#include<assert.h>
|
||||
#include "Window.h"
|
||||
#include <algorithm>
|
||||
|
||||
namespace
|
||||
{
|
||||
// 单轴解算后的临时结果:只描述“在父局部坐标系中的位置和尺寸”。
|
||||
struct LayoutAxisResult
|
||||
{
|
||||
int pos = 0;
|
||||
int size = 0;
|
||||
};
|
||||
|
||||
int ClampNonNegative(int value)
|
||||
{
|
||||
return value < 0 ? 0 : value;
|
||||
}
|
||||
|
||||
int RoundDiv(long long numerator, long long denominator)
|
||||
{
|
||||
if (denominator == 0)
|
||||
return 0;
|
||||
if (numerator >= 0)
|
||||
return static_cast<int>((numerator + denominator / 2) / denominator);
|
||||
return -static_cast<int>(((-numerator) + denominator / 2) / denominator);
|
||||
}
|
||||
|
||||
// 旧接口兼容层:
|
||||
// 将 setAnchor(a1, a2) 的双锚点输入映射为单轴 LayoutSpec。
|
||||
// 这里只负责“从旧输入翻译成新模型”,不直接参与后续求解。
|
||||
StellarX::AxisLayoutSpec BuildLegacyAxisSpec(StellarX::Anchor anchor1, StellarX::Anchor anchor2,
|
||||
StellarX::Anchor startAnchor, StellarX::Anchor endAnchor)
|
||||
{
|
||||
StellarX::AxisLayoutSpec spec{};
|
||||
spec.anchorStart = (anchor1 == startAnchor || anchor2 == startAnchor);
|
||||
spec.anchorEnd = (anchor1 == endAnchor || anchor2 == endAnchor);
|
||||
|
||||
if (spec.anchorStart && spec.anchorEnd)
|
||||
{
|
||||
spec.sizePolicy = StellarX::AxisSizePolicy::Stretch;
|
||||
spec.alignPolicy = StellarX::AxisAlignPolicy::Start;
|
||||
}
|
||||
else if (spec.anchorEnd)
|
||||
{
|
||||
spec.sizePolicy = StellarX::AxisSizePolicy::FixedSize;
|
||||
spec.alignPolicy = StellarX::AxisAlignPolicy::End;
|
||||
}
|
||||
else
|
||||
{
|
||||
spec.sizePolicy = StellarX::AxisSizePolicy::FixedSize;
|
||||
spec.alignPolicy = StellarX::AxisAlignPolicy::Start;
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// 单轴统一解算器:
|
||||
// 输入设计态父尺寸、当前父尺寸、子控件设计态位置/尺寸,以及当前轴策略,
|
||||
// 输出该轴在“父局部坐标系”下的运行态位置与尺寸。
|
||||
LayoutAxisResult ResolveAxis(int parentDesignSize, int parentCurrentSize, int childDesignPos, int childDesignSize,
|
||||
const StellarX::AxisLayoutSpec& spec, bool allowStretch)
|
||||
{
|
||||
LayoutAxisResult result{};
|
||||
const int startMargin = childDesignPos;
|
||||
const int endMargin = parentDesignSize - (childDesignPos + childDesignSize);
|
||||
|
||||
const bool wantsStretch = spec.sizePolicy == StellarX::AxisSizePolicy::Stretch;
|
||||
const bool canStretch = allowStretch && spec.anchorStart && spec.anchorEnd;
|
||||
|
||||
if (wantsStretch && canStretch)
|
||||
{
|
||||
// 双边锚定 + Stretch:
|
||||
// 保持设计态中的起边距和终边距,让尺寸随父容器变化。
|
||||
result.pos = startMargin;
|
||||
result.size = ClampNonNegative(parentCurrentSize - startMargin - endMargin);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 其余情况一律按固定尺寸处理;即便调用方请求 Stretch,
|
||||
// 只要锚点条件或控件能力边界不满足,也会在这里自然降级为 FixedSize。
|
||||
result.size = ClampNonNegative(childDesignSize);
|
||||
|
||||
if (spec.anchorStart && !spec.anchorEnd)
|
||||
{
|
||||
// 仅锚定起边:保持起边距,尺寸不变。
|
||||
result.pos = startMargin;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!spec.anchorStart && spec.anchorEnd)
|
||||
{
|
||||
// 仅锚定终边:保持终边距,尺寸不变。
|
||||
result.pos = parentCurrentSize - endMargin - result.size;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (spec.anchorStart && spec.anchorEnd)
|
||||
{
|
||||
// 双边锚定但尺寸固定:
|
||||
// 位置由 alignPolicy 决定,用于表达“只位移、不拉伸”的场景。
|
||||
switch (spec.alignPolicy)
|
||||
{
|
||||
case StellarX::AxisAlignPolicy::End:
|
||||
result.pos = parentCurrentSize - endMargin - result.size;
|
||||
break;
|
||||
case StellarX::AxisAlignPolicy::Center:
|
||||
// 保持相对父容器中心的偏移关系。
|
||||
result.pos = childDesignPos + (parentCurrentSize - parentDesignSize) / 2;
|
||||
break;
|
||||
case StellarX::AxisAlignPolicy::Proportional:
|
||||
{
|
||||
// 保持设计态中的相对位置比例。
|
||||
// 注意:这里只调整位置,不改变尺寸。
|
||||
const int designTravel = parentDesignSize - result.size;
|
||||
const int currentTravel = parentCurrentSize - result.size;
|
||||
if (designTravel <= 0 || currentTravel <= 0)
|
||||
result.pos = startMargin;
|
||||
else
|
||||
result.pos = RoundDiv(static_cast<long long>(startMargin) * currentTravel, designTravel);
|
||||
break;
|
||||
}
|
||||
case StellarX::AxisAlignPolicy::Start:
|
||||
default:
|
||||
result.pos = startMargin;
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 无锚点:退回设计态位置,尺寸保持设计值。
|
||||
result.pos = childDesignPos;
|
||||
return result;
|
||||
}
|
||||
|
||||
const char* SxAxisName(bool horizontal)
|
||||
{
|
||||
return horizontal ? "X" : "Y";
|
||||
}
|
||||
|
||||
void LogAxisDowngradeIfNeeded(const std::string& id, bool horizontal,
|
||||
const StellarX::AxisLayoutSpec& spec, bool allowStretch)
|
||||
{
|
||||
if (spec.sizePolicy != StellarX::AxisSizePolicy::Stretch)
|
||||
return;
|
||||
|
||||
if (!spec.anchorStart || !spec.anchorEnd)
|
||||
{
|
||||
SX_LOGD("Layout")
|
||||
<< SX_T("布局降级:id=", "layout downgrade: id=") << id
|
||||
<< SX_T(" axis=", " axis=") << SxAxisName(horizontal)
|
||||
<< SX_T(" 原因=Stretch 但未同时锚定起止边", " reason=stretch without dual anchors");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowStretch)
|
||||
{
|
||||
SX_LOGD("Layout")
|
||||
<< SX_T("能力边界拦截:id=", "layout capability intercept: id=") << id
|
||||
<< SX_T(" axis=", " axis=") << SxAxisName(horizontal)
|
||||
<< SX_T(" 原因=当前控件禁止该轴 Stretch", " reason=stretch disabled by capability");
|
||||
}
|
||||
}
|
||||
|
||||
void ActivateExplicitLayoutSpecMode(StellarX::LayoutMode& mode)
|
||||
{
|
||||
// 新公开布局 API 明确属于“按边约束解算”的语义层。
|
||||
// 一旦调用,说明外部希望控件进入统一锚定布局模型,
|
||||
// 因此直接切换到 AnchorToEdges,避免出现“策略已设但 layoutMode 仍是 Fixed”的半失效状态。
|
||||
mode = StellarX::LayoutMode::AnchorToEdges;
|
||||
}
|
||||
}
|
||||
|
||||
bool StellarX::ControlText::operator==(const ControlText& text) const
|
||||
{
|
||||
return nHeight == text.nHeight &&
|
||||
nWidth == text.nWidth &&
|
||||
faceName == text.faceName &&
|
||||
color == text.color &&
|
||||
nEscapement == text.nEscapement &&
|
||||
nOrientation == text.nOrientation &&
|
||||
nWeight == text.nWeight &&
|
||||
bItalic == text.bItalic &&
|
||||
bUnderline == text.bUnderline &&
|
||||
bStrikeOut == text.bStrikeOut;
|
||||
}
|
||||
void Control::setIsVisible(bool show)
|
||||
{
|
||||
SX_LOGD("Control") << SX_T("重置可见状态: id=", "setIsVisible: id=")
|
||||
<< id
|
||||
<< " show=" << (show ? 1 : 0);
|
||||
|
||||
if (this->show == show)
|
||||
return;
|
||||
|
||||
this->show = show;
|
||||
this->dirty = true;
|
||||
|
||||
if (!show)
|
||||
{
|
||||
// 隐藏:擦除自己在屏幕上的内容,并释放快照
|
||||
discardBackground();
|
||||
notifyFocusSubtreeStateChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示:不在这里 requestRepaint(避免父容器快照未就绪时子控件抢跑 draw,污染快照)
|
||||
// 仅向上标脏,让事件收口阶段由容器统一重绘。
|
||||
if (parent)
|
||||
parent->setDirty(true);
|
||||
notifyFocusSubtreeStateChanged();
|
||||
}
|
||||
|
||||
StellarX::FocusChangeResult Control::requestFocus(StellarX::FocusReason reason)
|
||||
{
|
||||
if (auto* host = getHostWindow())
|
||||
return host->getFocusManager().requestFocus({ handle, 0 }, reason);
|
||||
return { false, StellarX::FocusError::InvalidTarget, {} };
|
||||
}
|
||||
|
||||
void Control::setFocusPolicy(StellarX::FocusPolicy policy)
|
||||
{
|
||||
if (focusPolicy == policy)
|
||||
return;
|
||||
focusPolicy = policy;
|
||||
notifyFocusSubtreeStateChanged();
|
||||
}
|
||||
|
||||
void Control::setTabIndex(std::int32_t value)
|
||||
{
|
||||
if (tabIndex == value)
|
||||
return;
|
||||
tabIndex = value;
|
||||
notifyFocusSubtreeStateChanged();
|
||||
}
|
||||
|
||||
bool Control::isEffectivelyVisible() const
|
||||
{
|
||||
const Control* cursor = this;
|
||||
while (cursor)
|
||||
{
|
||||
if (!cursor->show)
|
||||
return false;
|
||||
cursor = cursor->parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Control::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const
|
||||
{
|
||||
if (target.owner != handle || target.subNode != 0)
|
||||
return false;
|
||||
snapshot = {};
|
||||
snapshot.id = target;
|
||||
if (parent)
|
||||
snapshot.logicalParent = { parent->getHandle(), 0 };
|
||||
snapshot.policy = focusPolicy;
|
||||
snapshot.visible = isEffectivelyVisible();
|
||||
snapshot.enabled = isFocusEnabled();
|
||||
snapshot.focusable = focusPolicy != StellarX::FocusPolicy::None;
|
||||
snapshot.tabStop = StellarX::hasFocusPolicy(focusPolicy, StellarX::FocusPolicy::Tab);
|
||||
snapshot.tabIndex = tabIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Control::collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
|
||||
{
|
||||
StellarX::FocusTargetSnapshot snapshot;
|
||||
if (queryFocusTargetSnapshot({ handle, 0 }, snapshot) && snapshot.focusable)
|
||||
snapshots.push_back(snapshot);
|
||||
}
|
||||
|
||||
bool Control::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason)
|
||||
{
|
||||
if (target.owner != handle || target.subNode != 0)
|
||||
return false;
|
||||
if (logicalFocused == focused)
|
||||
return true;
|
||||
logicalFocused = focused;
|
||||
dirty = true;
|
||||
requestRepaint(parent);
|
||||
// 这是本函数最后一次访问 this;回调可以排队删除当前控件。
|
||||
onLogicalFocusChanged(focused, reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Control::notifyFocusSubtreeStateChanged()
|
||||
{
|
||||
if (auto* host = getHostWindow())
|
||||
host->getFocusManager().notifySubtreeStateChanged(handle);
|
||||
}
|
||||
|
||||
void Control::onWindowResize()
|
||||
{
|
||||
SX_LOGD("Layout") << SX_T("尺寸变化:id=", "onWindowResize: id=") << id
|
||||
<< SX_T(" -> 丢背景快照 + 标脏", " -> discardSnap + dirty");
|
||||
|
||||
// 自己:丢快照 + 标脏
|
||||
invalidateBackgroundSnapshot();
|
||||
setDirty(true);
|
||||
}
|
||||
void Control::setLayoutMode(StellarX::LayoutMode layoutMode_)
|
||||
{
|
||||
this->layoutMode = layoutMode_;
|
||||
}
|
||||
void Control::setAnchor(StellarX::Anchor anchor_1, StellarX::Anchor anchor_2)
|
||||
{
|
||||
this->anchor_1 = anchor_1;
|
||||
this->anchor_2 = anchor_2;
|
||||
// 旧 API 只作为兼容输入层存在:
|
||||
// 这里把历史上的 anchor_1 / anchor_2 映射为新的水平/垂直轴布局规格,
|
||||
// 后续统一解算全部以 layoutSpec 为准。
|
||||
this->layoutSpec.horizontal = BuildLegacyAxisSpec(anchor_1, anchor_2, StellarX::Anchor::Left, StellarX::Anchor::Right);
|
||||
this->layoutSpec.vertical = BuildLegacyAxisSpec(anchor_1, anchor_2, StellarX::Anchor::Top, StellarX::Anchor::Bottom);
|
||||
}
|
||||
void Control::setHorizontalLayoutSpec(const StellarX::AxisLayoutSpec& spec)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.horizontal = spec;
|
||||
}
|
||||
|
||||
void Control::setVerticalLayoutSpec(const StellarX::AxisLayoutSpec& spec)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.vertical = spec;
|
||||
}
|
||||
|
||||
void Control::setHorizontalAnchors(bool left, bool right)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.horizontal.anchorStart = left;
|
||||
this->layoutSpec.horizontal.anchorEnd = right;
|
||||
}
|
||||
|
||||
void Control::setVerticalAnchors(bool top, bool bottom)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.vertical.anchorStart = top;
|
||||
this->layoutSpec.vertical.anchorEnd = bottom;
|
||||
}
|
||||
|
||||
void Control::setHorizontalSizePolicy(StellarX::AxisSizePolicy policy)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.horizontal.sizePolicy = policy;
|
||||
}
|
||||
|
||||
void Control::setVerticalSizePolicy(StellarX::AxisSizePolicy policy)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.vertical.sizePolicy = policy;
|
||||
}
|
||||
|
||||
void Control::setHorizontalAlignPolicy(StellarX::AxisAlignPolicy policy)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.horizontal.alignPolicy = policy;
|
||||
}
|
||||
|
||||
void Control::setVerticalAlignPolicy(StellarX::AxisAlignPolicy policy)
|
||||
{
|
||||
ActivateExplicitLayoutSpecMode(this->layoutMode);
|
||||
this->layoutSpec.vertical.alignPolicy = policy;
|
||||
}
|
||||
|
||||
StellarX::Anchor Control::getAnchor_1() const
|
||||
{
|
||||
return this->anchor_1;
|
||||
}
|
||||
StellarX::Anchor Control::getAnchor_2() const
|
||||
{
|
||||
return this->anchor_2;
|
||||
}
|
||||
StellarX::LayoutMode Control::getLayoutMode() const
|
||||
{
|
||||
return this->layoutMode;
|
||||
}
|
||||
StellarX::AxisLayoutSpec Control::getHorizontalLayoutSpec() const
|
||||
{
|
||||
return layoutSpec.horizontal;
|
||||
}
|
||||
|
||||
StellarX::AxisLayoutSpec Control::getVerticalLayoutSpec() const
|
||||
{
|
||||
return layoutSpec.vertical;
|
||||
}
|
||||
|
||||
const StellarX::LayoutSpec& Control::getLayoutSpec() const
|
||||
{
|
||||
return layoutSpec;
|
||||
}
|
||||
|
||||
const StellarX::LayoutCapability& Control::getLayoutCapability() const
|
||||
{
|
||||
return layoutCapability;
|
||||
}
|
||||
|
||||
void Control::setX(int x)
|
||||
{
|
||||
// 先保存旧 coverage 并回贴旧快照,再写入新坐标;这样移动控件不会留下旧像素。
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn)
|
||||
discardBackground();
|
||||
this->x = x;
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Control::setY(int y)
|
||||
{
|
||||
// Y 方向与 X 方向使用同一生命周期顺序,旧区域和新区域都由托管重绘统一收口。
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn)
|
||||
discardBackground();
|
||||
this->y = y;
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Control::setWidth(int width)
|
||||
{
|
||||
// 尺寸变化会改变快照大小,必须先释放旧快照,避免新绘制复用错误尺寸的背景。
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn)
|
||||
discardBackground();
|
||||
this->width = (std::max)(0, width);
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Control::setHeight(int height)
|
||||
{
|
||||
// 高度调整同样登记变化前的 coverage,供 Window 在提交阶段覆盖旧、新两个区域。
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn)
|
||||
discardBackground();
|
||||
this->height = (std::max)(0, height);
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
bool Control::clearTransientMouseState()
|
||||
{
|
||||
// 基类默认没有 hover / tooltip / 临时按下态等鼠标瞬时状态。
|
||||
// 具体控件若需要在“未收到本次 WM_MOUSEMOVE”时做清理,重写此接口即可。
|
||||
return false;
|
||||
}
|
||||
|
||||
void Control::commitCurrentGeometryAsDesignRect()
|
||||
{
|
||||
// 该接口是“显式提交新的设计基线”的唯一入口之一。
|
||||
// 普通布局解算、父容器重排、窗口 resize 均不得自动回写 local*,
|
||||
// 否则会导致设计基线漂移,后续解算越来越不稳定。
|
||||
SX_LOGD("Layout")
|
||||
<< SX_T("提交设计基线:id=", "commit design rect: id=") << id
|
||||
<< SX_T(" world=(", " world=(") << x << "," << y << "," << width << "," << height << ")"
|
||||
<< SX_T(" parentLocalBefore=(", " parentLocalBefore=(")
|
||||
<< localx << "," << localy << "," << localWidth << "," << localHeight << ")";
|
||||
localx = parent ? (x - parent->getX()) : x;
|
||||
localy = parent ? (y - parent->getY()) : y;
|
||||
localWidth = width;
|
||||
localHeight = height;
|
||||
SX_LOGD("Layout")
|
||||
<< SX_T("提交设计基线完成:id=", "commit design rect done: id=") << id
|
||||
<< SX_T(" parentLocalAfter=(", " parentLocalAfter=(")
|
||||
<< localx << "," << localy << "," << localWidth << "," << localHeight << ")";
|
||||
}
|
||||
|
||||
StellarX::ResolvedLayoutRect Control::resolveLayoutRect(int parentDesignW, int parentDesignH,
|
||||
int parentWorldX, int parentWorldY, int parentCurrentW, int parentCurrentH) const
|
||||
{
|
||||
StellarX::ResolvedLayoutRect rect{};
|
||||
|
||||
if (layoutMode != StellarX::LayoutMode::AnchorToEdges)
|
||||
{
|
||||
// 非锚点布局模式:直接使用设计态矩形,再映射到当前世界坐标。
|
||||
rect.localX = localx;
|
||||
rect.localY = localy;
|
||||
rect.width = localWidth;
|
||||
rect.height = localHeight;
|
||||
rect.worldX = parentWorldX + rect.localX;
|
||||
rect.worldY = parentWorldY + rect.localY;
|
||||
return rect;
|
||||
}
|
||||
|
||||
// 第 1 层:先在父局部坐标系内分别解算水平轴和垂直轴。
|
||||
LogAxisDowngradeIfNeeded(id, true, layoutSpec.horizontal, layoutCapability.allowStretchX);
|
||||
LogAxisDowngradeIfNeeded(id, false, layoutSpec.vertical, layoutCapability.allowStretchY);
|
||||
const LayoutAxisResult horizontal = ResolveAxis(parentDesignW, parentCurrentW, localx, localWidth,
|
||||
layoutSpec.horizontal, layoutCapability.allowStretchX);
|
||||
const LayoutAxisResult vertical = ResolveAxis(parentDesignH, parentCurrentH, localy, localHeight,
|
||||
layoutSpec.vertical, layoutCapability.allowStretchY);
|
||||
|
||||
// 第 2 层:把父局部矩形映射到世界坐标。
|
||||
rect.localX = horizontal.pos;
|
||||
rect.localY = vertical.pos;
|
||||
rect.width = horizontal.size;
|
||||
rect.height = vertical.size;
|
||||
rect.worldX = parentWorldX + rect.localX;
|
||||
rect.worldY = parentWorldY + rect.localY;
|
||||
return rect;
|
||||
}
|
||||
|
||||
void Control::applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect)
|
||||
{
|
||||
// 默认实现只应用运行态世界矩形。
|
||||
// 若某个控件还需要在应用后继续刷新内部布局,可重写此函数。
|
||||
applyRuntimeRectDirect(rect.worldX, rect.worldY, rect.width, rect.height);
|
||||
}
|
||||
|
||||
void Control::applyRuntimeRectDirect(int worldX, int worldY, int width, int height)
|
||||
{
|
||||
// 最底层运行态赋值入口:
|
||||
// 只修改当前世界坐标和运行态尺寸,不触碰设计基线 local*。
|
||||
this->x = worldX;
|
||||
this->y = worldY;
|
||||
this->width = width;
|
||||
this->height = height;
|
||||
dirty = true;
|
||||
}
|
||||
// 保存当前的绘图状态(字体、颜色、线型等)
|
||||
// 在控件绘制前调用,确保不会影响全局绘图状态
|
||||
void Control::saveStyle()
|
||||
{
|
||||
|
||||
gettextstyle(¤tFont); // 获取当前字体样式
|
||||
currentColor = gettextcolor(); // 获取当前字体颜色
|
||||
currentBorderColor = getlinecolor(); //保存当前边框颜色
|
||||
getlinestyle(¤tLineStyle); //保存当前线型
|
||||
currentBkColor = getfillcolor(); //保存当前填充色
|
||||
}
|
||||
// 恢复之前保存的绘图状态
|
||||
// 在控件绘制完成后调用,恢复全局绘图状态
|
||||
void Control::restoreStyle()
|
||||
{
|
||||
settextstyle(¤tFont); // 恢复默认字体样式
|
||||
settextcolor(currentColor); // 恢复默认字体颜色
|
||||
setfillcolor(currentBkColor);
|
||||
setlinestyle(¤tLineStyle);
|
||||
setlinecolor(currentBorderColor);
|
||||
setfillstyle(BS_SOLID);//恢复填充
|
||||
}
|
||||
|
||||
void Control::requestRepaint(Control* parent)
|
||||
{
|
||||
if (shouldDeferManagedRepaint())
|
||||
{
|
||||
// 托管路径:当前正在 Window 的事件分发阶段,不能立即绘制;
|
||||
// 这里只登记 source,真正的 root 选择由 Window 在 requestManagedRepaint 中完成。
|
||||
if (auto* host = getHostWindow())
|
||||
host->requestManagedRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - 常规路径:子控件调用 requestRepaint(this->parent),然后 parent 负责局部重绘(Canvas/TabControl override)
|
||||
// - 兜底路径:如果某个“容器控件”没 override requestRepaint,就会出现 parent==this 的递归风险
|
||||
// 此时我们改为向更上层冒泡,直到根重绘。
|
||||
if (parent == this)
|
||||
{
|
||||
SX_LOGW("Dirty")
|
||||
<< SX_T("requestRepaint(默认容器兜底):id=", "requestRepaint(default-container-fallback): id=")
|
||||
<< id
|
||||
<< SX_T(",parent==this,向上层 parent 继续冒泡", " parent==this, bubble to upper parent");
|
||||
|
||||
if (this->parent) this->parent->requestRepaint(this->parent);
|
||||
else onRequestRepaintAsRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
SX_LOG_TRACE("Dirty") << SX_T("请求重绘:id=","requestRepaint: id=") << id << " parent=" << (parent ? parent->getId() : "null");
|
||||
|
||||
if (parent) parent->requestRepaint(parent); // 交给容器处理(容器可局部重绘)
|
||||
else onRequestRepaintAsRoot(); // 根兜底
|
||||
}
|
||||
|
||||
void Control::onRequestRepaintAsRoot()
|
||||
{
|
||||
if (shouldDeferManagedRepaint())
|
||||
{
|
||||
// 即使已经冒泡到 root,只要还在托管分发期,也不能直接绘制;
|
||||
// 仍然回到 Window 做统一提交。
|
||||
if (auto* host = getHostWindow())
|
||||
host->requestManagedRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
SX_LOG_TRACE("Dirty")
|
||||
<< SX_T("触发根重绘:id=", "onRequestRepaintAsRoot: id=") << id
|
||||
<< SX_T("(从根节点开始重画)", " (root repaint)");
|
||||
|
||||
|
||||
discardBackground();
|
||||
setDirty(true);
|
||||
draw(); // 只有“无父”时才允许立即画,不会被谁覆盖
|
||||
}
|
||||
|
||||
bool Control::shouldDeferManagedRepaint() const
|
||||
{
|
||||
Window* host = getHostWindow();
|
||||
return host && (host->isManagedDispatchActive() || host->isMutationTransactionActive());
|
||||
}
|
||||
|
||||
void Control::beginMutationScope()
|
||||
{
|
||||
// 子控件不单独维护顶层事务,沿 parent 链把计数交给 Canvas 或 Window。
|
||||
if (parent)
|
||||
parent->beginMutationScope();
|
||||
else if (auto* host = getHostWindow())
|
||||
host->beginMutationScope();
|
||||
}
|
||||
|
||||
void Control::endMutationScope()
|
||||
{
|
||||
// 与 beginMutationScope 对称;最外层 root 结束后才有资格提交待处理队列。
|
||||
if (parent)
|
||||
parent->endMutationScope();
|
||||
else if (auto* host = getHostWindow())
|
||||
host->endMutationScope();
|
||||
}
|
||||
|
||||
// 获取宿主 Window:
|
||||
// - 顶层控件由 Window/addDialog 直接注入;
|
||||
// - 子控件没有直接注入时,沿 parent 链向上回溯即可。
|
||||
Window* Control::getHostWindow() const
|
||||
{
|
||||
if (hostWindow)
|
||||
return hostWindow;
|
||||
return parent ? parent->getHostWindow() : nullptr;
|
||||
}
|
||||
|
||||
// 托管重绘 root 选择规则:
|
||||
// - 对于直接挂在 Window 下的控件,root 就是它自己;
|
||||
// - 对于嵌套在 Canvas/TabControl/Dialog 内的子控件,沿 parent 向上找到与宿主 Window 同属一棵树的最上层控件。
|
||||
Control* Control::getManagedRepaintRoot()
|
||||
{
|
||||
Control* root = this;
|
||||
Window* host = getHostWindow();
|
||||
while (root->parent && root->parent->getHostWindow() == host)
|
||||
root = root->parent;
|
||||
return root;
|
||||
}
|
||||
|
||||
Control* Control::getManagedRepaintDirectBranch(Control* root)
|
||||
{
|
||||
if (!root)
|
||||
return nullptr;
|
||||
|
||||
Control* branch = this;
|
||||
while (branch->parent && branch->parent != root)
|
||||
branch = branch->parent;
|
||||
return branch;
|
||||
}
|
||||
|
||||
RECT Control::getBoundsRect() const
|
||||
{
|
||||
RECT rc{};
|
||||
rc.left = x;
|
||||
rc.top = y;
|
||||
rc.right = x + width;
|
||||
rc.bottom = y + height;
|
||||
return rc;
|
||||
}
|
||||
|
||||
RECT Control::getManagedRepaintCoverageRect() const
|
||||
{
|
||||
// 基类默认认为“实际绘制 coverage == 控件本体矩形”。
|
||||
// 复合控件或带浮层的控件会 override,把附加绘制区域一并并入。
|
||||
return getBoundsRect();
|
||||
}
|
||||
|
||||
RECT Control::getManagedRepaintPersistentCoverageRect() const
|
||||
{
|
||||
// 基类默认认为持久绘制范围等于控件本体。
|
||||
// 只有 Tooltip 这类临时浮层需要从持久范围中剔除。
|
||||
return getBoundsRect();
|
||||
}
|
||||
|
||||
bool Control::canCommitManagedPartialRepaint() const
|
||||
{
|
||||
// 基类默认不承诺自己能安全做局部提交;
|
||||
// 只有 Canvas / TabControl / Dialog 这类“拥有完整背景语义”的 root 才会 override 为 true。
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Control::hasManagedDirtySubtree() const
|
||||
{
|
||||
// 基类只感知自身是否为脏;
|
||||
// 容器类会 override 为“自身或其受管理子树是否有脏内容”。
|
||||
return dirty;
|
||||
}
|
||||
|
||||
void Control::commitManagedRepaint()
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
// 基类兜底:如果没有更具体的容器实现,就按根级重绘处理。
|
||||
if (dirty)
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
|
||||
void Control::saveBackground(int x, int y, int w, int h)
|
||||
{
|
||||
|
||||
if (w <= 0 || h <= 0) return;
|
||||
saveBkX = x; saveBkY = y; saveWidth = w; saveHeight = h;
|
||||
// 快照描述的是下一次局部恢复所需的屏幕背景,位置或尺寸变化时不能继续复用旧缓存。
|
||||
if (saveBkImage)
|
||||
{
|
||||
//尺寸变了才重建,避免反复 new/delete
|
||||
if (saveBkImage->getwidth() != w || saveBkImage->getheight() != h)
|
||||
{
|
||||
SX_LOGD("Snap") <<SX_T("重新保存背景快照:id=", "saveBackground rebuild: id=") << id << " size=(" << w << "x" << h << ")";
|
||||
|
||||
saveBkImage.reset();
|
||||
}
|
||||
}
|
||||
else
|
||||
SX_LOGD("Snap") << SX_T("保存背景快照:id=", "saveBackground rebuild: id=") << id << " size=(" << w << "x" << h << ")";
|
||||
if (!saveBkImage) saveBkImage = std::make_unique<IMAGE>(w, h);
|
||||
|
||||
SetWorkingImage(nullptr); // ★抓屏幕
|
||||
getimage(saveBkImage.get(), x, y, w, h);
|
||||
hasSnap = true;
|
||||
}
|
||||
|
||||
void Control::restBackground()
|
||||
{
|
||||
if (!hasSnap || !saveBkImage) return;
|
||||
// 直接回贴屏幕(与抓取一致)
|
||||
SetWorkingImage(nullptr);
|
||||
putimage(saveBkX, saveBkY, saveBkImage.get());
|
||||
}
|
||||
|
||||
void Control::discardBackground()
|
||||
{
|
||||
if (saveBkImage)
|
||||
{
|
||||
restBackground();
|
||||
SX_LOGD("Snap") << SX_T("丢弃背景快照:id=","discardBackground: id=") << id << " hasSnap=" << (hasSnap ? 1 : 0);
|
||||
saveBkImage.reset();
|
||||
}
|
||||
hasSnap = false; saveWidth = saveHeight = 0;
|
||||
}
|
||||
|
||||
void Control::invalidateBackgroundSnapshot()
|
||||
{
|
||||
if (saveBkImage)
|
||||
{
|
||||
SX_LOGD("Snap") << SX_T("作废背景快照:id=", "invalidateBackgroundSnapshot: id=") << id
|
||||
<< " hasSnap=" << (hasSnap ? 1 : 0);
|
||||
saveBkImage.reset();
|
||||
}
|
||||
hasSnap = false;
|
||||
saveBkX = saveBkY = 0;
|
||||
saveWidth = saveHeight = 0;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
#include "Dialog.h"
|
||||
#include "SxLog.h"
|
||||
#include "SxEventLoopInternal.h"
|
||||
#include "SxInputProbeInternal.h"
|
||||
|
||||
Dialog::Dialog(Window& h,std::string text,std::string message, StellarX::MessageBoxType type, bool modal)
|
||||
: Canvas(),message(message), type(type), modal(modal), hWnd(h), titleText(text)
|
||||
Dialog::Dialog(Window& h, std::string text, std::string message, StellarX::MessageBoxType type, bool modal)
|
||||
: Canvas(), message(message), type(type), modal(modal), hWnd(h), titleText(text)
|
||||
{
|
||||
initializeDialog();
|
||||
this->id = "Dialog";
|
||||
// Dialog 当前阶段只做“居中 + 自管内部 chrome”,
|
||||
// 不参与外部 Stretch;宿主窗口变化时只重新居中。
|
||||
this->layoutCapability.allowStretchX = false;
|
||||
this->layoutCapability.allowStretchY = false;
|
||||
setHostWindow(&hWnd);
|
||||
show = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Dialog::~Dialog()
|
||||
{
|
||||
}
|
||||
|
||||
void Dialog::draw()
|
||||
{
|
||||
if(!isVisible)
|
||||
if (!show)
|
||||
{
|
||||
// 如果对话框不可见且需要清理,执行清理
|
||||
if (pendingCleanup && !isCleaning)
|
||||
@@ -24,50 +31,37 @@ void Dialog::draw()
|
||||
return;
|
||||
}
|
||||
// 如果需要初始化,则执行初始化
|
||||
if (needsInitialization && isVisible)
|
||||
if (needsInitialization && show)
|
||||
{
|
||||
initDialogSize();
|
||||
needsInitialization = false;
|
||||
}
|
||||
|
||||
if (dirty && isVisible)
|
||||
if (dirty && show)
|
||||
{
|
||||
// 保存当前绘图状态
|
||||
saveStyle();
|
||||
|
||||
|
||||
// 保存背景(仅在第一次绘制时)
|
||||
if (saveBkImage == nullptr) {
|
||||
saveBkX = x - BorderWidth;
|
||||
saveBkY = y - BorderWidth;
|
||||
saveBkWidth = width + 2 * BorderWidth;
|
||||
saveBkHeight = height + 2 * BorderWidth;
|
||||
saveBkImage = new IMAGE(saveBkWidth, saveBkHeight);
|
||||
getimage(saveBkImage, saveBkX, saveBkY, saveBkWidth, saveBkHeight);
|
||||
}
|
||||
|
||||
Canvas::setBorderColor(this->borderColor);
|
||||
Canvas::setLinewidth(this->BorderWidth);
|
||||
Canvas::setLinewidth(BorderWidth);
|
||||
Canvas::setCanvasBkColor(this->backgroundColor);
|
||||
Canvas::setShape(StellarX::ControlShape::ROUND_RECTANGLE);
|
||||
|
||||
//设置所有控件为脏状态
|
||||
for(auto& c :this->controls)
|
||||
c->setDirty(true);
|
||||
|
||||
Canvas::draw();
|
||||
|
||||
//绘制消息文本
|
||||
settextcolor(textStyle.color);
|
||||
setbkmode(TRANSPARENT);
|
||||
|
||||
//设置字体样式
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
|
||||
outtextxy(x + 5, y + 5, LPCTSTR(titleText.c_str()));
|
||||
|
||||
int ty = y + closeButtonHeight + titleToTextMargin; // 文本起始Y坐标
|
||||
for (auto line:lines)
|
||||
for (auto& line : lines)
|
||||
{
|
||||
int tx = this->x + ((this->width - textwidth(line.c_str())) / 2); // 文本起始X坐标
|
||||
outtextxy(tx, ty, LPCTSTR(line.c_str()));
|
||||
@@ -81,11 +75,11 @@ void Dialog::draw()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool Dialog::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
bool consume = false;
|
||||
if (!isVisible)
|
||||
resetEventVisualChanged();
|
||||
if (!show)
|
||||
{
|
||||
if (pendingCleanup && !isCleaning)
|
||||
{
|
||||
@@ -97,12 +91,20 @@ bool Dialog::handleEvent(const ExMessage& msg)
|
||||
// 如果正在清理或标记为待清理,则不处理事件
|
||||
if (pendingCleanup || isCleaning)
|
||||
return false;
|
||||
// 模态对话框:点击对话框外部区域时,发出提示音(\a)并吞噬该事件,不允许操作背景内容。
|
||||
if (modal && msg.message == WM_LBUTTONUP &&
|
||||
(msg.x < x || msg.x > x + width || msg.y < y || msg.y > y + height))
|
||||
|
||||
const bool isMouseMessage =
|
||||
msg.message == WM_MOUSEMOVE ||
|
||||
msg.message == WM_LBUTTONDOWN ||
|
||||
msg.message == WM_LBUTTONUP;
|
||||
const bool insideDialog =
|
||||
msg.x >= x && msg.x <= x + width &&
|
||||
msg.y >= y && msg.y <= y + height;
|
||||
|
||||
// 模态对话框区域外鼠标事件不允许落到底层背景。
|
||||
if (modal && isMouseMessage && !insideDialog)
|
||||
{
|
||||
if (msg.message == WM_LBUTTONUP)
|
||||
std::cout << "\a" << std::endl;
|
||||
// 模态对话框不允许点击外部区域
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -110,6 +112,10 @@ bool Dialog::handleEvent(const ExMessage& msg)
|
||||
if (!consume)
|
||||
consume = Canvas::handleEvent(msg);
|
||||
|
||||
// 对话框矩形范围内的鼠标事件一律由对话框吞掉,避免穿透到底层控件。
|
||||
if (isMouseMessage && insideDialog)
|
||||
consume = true;
|
||||
|
||||
// 每次事件处理后检查是否需要执行延迟清理
|
||||
if (pendingCleanup && !isCleaning)
|
||||
performDelayedCleanup();
|
||||
@@ -119,27 +125,19 @@ bool Dialog::handleEvent(const ExMessage& msg)
|
||||
void Dialog::SetTitle(const std::string& title)
|
||||
{
|
||||
this->titleText = title;
|
||||
if (this->title)
|
||||
{
|
||||
this->title->setText(title);
|
||||
}
|
||||
dirty = true;
|
||||
invalidateLayout(true);
|
||||
}
|
||||
|
||||
void Dialog::SetMessage(const std::string& message)
|
||||
{
|
||||
this->message = message;
|
||||
splitMessageLines();
|
||||
getTextSize();
|
||||
dirty = true;
|
||||
invalidateLayout(true);
|
||||
}
|
||||
|
||||
void Dialog::SetType(StellarX::MessageBoxType type)
|
||||
{
|
||||
this->type = type;
|
||||
// 重新初始化按钮
|
||||
initButtons();
|
||||
dirty = true;
|
||||
invalidateLayout(true);
|
||||
}
|
||||
|
||||
void Dialog::SetModal(bool modal)
|
||||
@@ -147,7 +145,6 @@ void Dialog::SetModal(bool modal)
|
||||
this->modal = modal;
|
||||
}
|
||||
|
||||
|
||||
void Dialog::SetResult(StellarX::MessageBoxResult result)
|
||||
{
|
||||
this->result = result;
|
||||
@@ -158,16 +155,6 @@ StellarX::MessageBoxResult Dialog::GetResult() const
|
||||
return this->result;
|
||||
}
|
||||
|
||||
bool Dialog::getModal() const
|
||||
{
|
||||
return modal;
|
||||
}
|
||||
|
||||
bool Dialog::IsVisible() const
|
||||
{
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
bool Dialog::model() const
|
||||
{
|
||||
return modal;
|
||||
@@ -177,83 +164,178 @@ void Dialog::Show()
|
||||
{
|
||||
if (pendingCleanup)
|
||||
performDelayedCleanup();
|
||||
SX_LOGI("Dialog") << SX_T("对话框弹出:是否模态=","Dialog::Show: modal=") << (modal ? 1 : 0);
|
||||
|
||||
isVisible = true;
|
||||
show = true;
|
||||
dirty = true;
|
||||
needsInitialization = true;
|
||||
close = false;
|
||||
shouldClose = false;
|
||||
|
||||
hWnd.dialogOpen = true;// 通知窗口有对话框打开
|
||||
|
||||
if (modal)
|
||||
{
|
||||
// 模态对话框需要阻塞当前线程直到对话框关闭
|
||||
while (isVisible && !close)
|
||||
if (!hWnd.hasLiveNativeWindow())
|
||||
{
|
||||
// 处理消息
|
||||
ExMessage msg;
|
||||
if (peekmessage(&msg, EX_MOUSE | EX_KEY))
|
||||
{
|
||||
handleEvent(msg);
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要关闭
|
||||
if (shouldClose)
|
||||
// 记录当前窗口客户区尺寸,供轮询对比
|
||||
RECT rc0{};
|
||||
if (!GetClientRect(hWnd.getHwnd(), &rc0))
|
||||
{
|
||||
Close();
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
return;
|
||||
}
|
||||
int lastW = rc0.right - rc0.left;
|
||||
int lastH = rc0.bottom - rc0.top;
|
||||
|
||||
while (show)
|
||||
{
|
||||
if (!hWnd.drainNativeInputAtSafePoint())
|
||||
{
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
break;
|
||||
}
|
||||
if (!hWnd.hasLiveNativeWindow())
|
||||
{
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// 重绘
|
||||
// 模态对话框保留自己的轮询循环,但消息同样批量处理并在每轮末尾统一绘制。
|
||||
int processedMessages = 0;
|
||||
// ① 轮询窗口尺寸(不依赖 WM_SIZE)
|
||||
RECT rc{};
|
||||
if (!GetClientRect(hWnd.getHwnd(), &rc))
|
||||
{
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
break;
|
||||
}
|
||||
const int cw = rc.right - rc.left;
|
||||
const int ch = rc.bottom - rc.top;
|
||||
|
||||
if (cw != lastW || ch != lastH)
|
||||
{
|
||||
lastW = cw;
|
||||
lastH = ch;
|
||||
SX_LOGD("Resize") <<SX_T("模态对话框检测到窗口大小变化:(", "Modal dialog detected window size change: (") << cw << "x" << ch << ")";
|
||||
|
||||
// 通知父窗口:有新尺寸 → 标记 needResizeDirty
|
||||
hWnd.scheduleResizeFromModal(cw, ch);
|
||||
|
||||
// 立即统一收口:父窗重绘 背景+普通控件(不会画到这只模态)
|
||||
hWnd.pumpResizeIfNeeded();
|
||||
|
||||
// 这只模态只重新居中,不参与拉伸;背景快照需要在新位置重抓。
|
||||
recenterInHostWindow();
|
||||
}
|
||||
|
||||
// ② 处理这只对话框的鼠标/键盘(沿用原来 EX_MOUSE | EX_KEY)
|
||||
ExMessage msg;
|
||||
auto probeBatch = StellarX::Internal::inputProbeHostBatchBegin(hWnd.getHwnd(), true);
|
||||
const auto batch = StellarX::Internal::drainMessageBatch(msg,
|
||||
[](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY); },
|
||||
[&](const ExMessage& next) {
|
||||
StellarX::Internal::inputProbeObserveEasyXMessage(hWnd.getHwnd(), probeBatch, next);
|
||||
handleEvent(next);
|
||||
return show;
|
||||
});
|
||||
processedMessages = static_cast<int>(batch.processed);
|
||||
StellarX::Internal::inputProbeHostBatchEnd(
|
||||
hWnd.getHwnd(), probeBatch, batch.processed, batch.stopped);
|
||||
if (!hWnd.drainNativeInputAtSafePoint())
|
||||
{
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
break;
|
||||
}
|
||||
if (!hWnd.hasLiveNativeWindow())
|
||||
{
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
break;
|
||||
}
|
||||
|
||||
// ③ 最后一笔:只画这只模态,保证永远在最上层
|
||||
if (dirty)
|
||||
{
|
||||
draw();
|
||||
FlushBatchDraw();
|
||||
BeginBatchDraw();
|
||||
this->draw(); // 注意:不要 requestRepaint(parent),只画自己
|
||||
EndBatchDraw();
|
||||
dirty = false;
|
||||
}
|
||||
if (!hWnd.isMutationTransactionActive())
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
|
||||
if (batch.shouldSleep())
|
||||
Sleep(1);
|
||||
}
|
||||
|
||||
// 避免CPU占用过高
|
||||
Sleep(10);
|
||||
}
|
||||
|
||||
// 模态对话框关闭后执行清理
|
||||
if (pendingCleanup && !isCleaning)
|
||||
{
|
||||
performDelayedCleanup();
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 非模态对话框只需标记为可见,由主循环处理
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void Dialog::Close()
|
||||
{
|
||||
if (!isVisible) return;
|
||||
if (!show) return;
|
||||
|
||||
isVisible = false;
|
||||
close = true;
|
||||
// 关闭只改变可见状态并登记一次全场景收口;对象和快照清理延后到安全阶段执行。
|
||||
show = false;
|
||||
dirty = true;
|
||||
pendingCleanup = true; // 只标记需要清理,不立即执行
|
||||
auto& c = hWnd.getControls();
|
||||
for(auto& control:c)
|
||||
control->setDirty(true);
|
||||
notifyFocusSubtreeStateChanged();
|
||||
hWnd.dialogClose = true;
|
||||
hWnd.requestFullSceneRedraw();
|
||||
|
||||
// 工厂模式下非模态触发回调 返回结果
|
||||
if (resultCallback&& !modal)
|
||||
if (resultCallback && !modal)
|
||||
{
|
||||
// 回调可能继续修改宿主控件树,因此也纳入 MutationScope,禁止遍历期间立即销毁对象。
|
||||
auto mutationScope = enterMutationScope();
|
||||
resultCallback(this->result);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
void Dialog::recenterInHostWindow()
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
|
||||
// 尚未完成首次初始化时,保持“延迟初始化”语义,由首次 draw 统一创建子控件。
|
||||
if (needsInitialization || width <= 0 || height <= 0)
|
||||
{
|
||||
dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const int newX = (hWnd.getWidth() - width) / 2;
|
||||
const int newY = (hWnd.getHeight() - height) / 2;
|
||||
invalidateBackgroundSnapshot();
|
||||
|
||||
x = newX;
|
||||
y = newY;
|
||||
rebuildChrome();
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Dialog::initButtons()
|
||||
{
|
||||
controls.clear();
|
||||
|
||||
switch (this->type)
|
||||
{
|
||||
case StellarX::MessageBoxType::OK: // 只有确定按钮
|
||||
@@ -265,6 +347,7 @@ void Dialog::initButtons()
|
||||
okbutton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::OK);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
|
||||
okbutton->textStyle = this->textStyle;
|
||||
@@ -286,8 +369,8 @@ void Dialog::initButtons()
|
||||
this->Close(); });
|
||||
|
||||
auto cancelButton = createDialogButton(
|
||||
(okButton.get()->getButtonX() + okButton.get()->getButtonWidth() + buttonMargin),
|
||||
okButton.get()->getButtonY(),
|
||||
(okButton.get()->getX() + okButton.get()->getWidth() + buttonMargin),
|
||||
okButton.get()->getY(),
|
||||
"取消"
|
||||
);
|
||||
cancelButton->setOnClickListener([this]()
|
||||
@@ -317,8 +400,8 @@ void Dialog::initButtons()
|
||||
this->Close(); });
|
||||
|
||||
auto noButton = createDialogButton(
|
||||
(yesButton.get()->getButtonX() + yesButton.get()->getButtonWidth() + buttonMargin),
|
||||
yesButton.get()->getButtonY(),
|
||||
(yesButton.get()->getX() + yesButton.get()->getWidth() + buttonMargin),
|
||||
yesButton.get()->getY(),
|
||||
"否"
|
||||
);
|
||||
noButton->setOnClickListener([this]()
|
||||
@@ -348,8 +431,8 @@ void Dialog::initButtons()
|
||||
this->Close(); });
|
||||
|
||||
auto noButton = createDialogButton(
|
||||
yesButton.get()->getButtonX() + yesButton.get()->getButtonWidth() + buttonMargin,
|
||||
yesButton.get()->getButtonY(),
|
||||
yesButton.get()->getX() + yesButton.get()->getWidth() + buttonMargin,
|
||||
yesButton.get()->getY(),
|
||||
"否"
|
||||
);
|
||||
noButton->setOnClickListener([this]()
|
||||
@@ -359,8 +442,8 @@ void Dialog::initButtons()
|
||||
this->Close(); });
|
||||
|
||||
auto cancelButton = createDialogButton(
|
||||
noButton.get()->getButtonX() + noButton.get()->getButtonWidth() + buttonMargin,
|
||||
noButton.get()->getButtonY(),
|
||||
noButton.get()->getX() + noButton.get()->getWidth() + buttonMargin,
|
||||
noButton.get()->getY(),
|
||||
"取消"
|
||||
);
|
||||
cancelButton->setOnClickListener([this]()
|
||||
@@ -373,7 +456,6 @@ void Dialog::initButtons()
|
||||
noButton->textStyle = this->textStyle;
|
||||
cancelButton->textStyle = this->textStyle;
|
||||
|
||||
|
||||
this->addControl(std::move(yesButton));
|
||||
this->addControl(std::move(noButton));
|
||||
this->addControl(std::move(cancelButton));
|
||||
@@ -393,8 +475,8 @@ void Dialog::initButtons()
|
||||
this->Close(); });
|
||||
|
||||
auto cancelButton = createDialogButton(
|
||||
retryButton.get()->getButtonX() + retryButton.get()->getButtonWidth() + buttonMargin,
|
||||
retryButton.get()->getButtonY(),
|
||||
retryButton.get()->getX() + retryButton.get()->getWidth() + buttonMargin,
|
||||
retryButton.get()->getY(),
|
||||
"取消"
|
||||
);
|
||||
cancelButton->setOnClickListener([this]()
|
||||
@@ -413,7 +495,7 @@ void Dialog::initButtons()
|
||||
case StellarX::MessageBoxType::AbortRetryIgnore: // 中止、重试和忽略按钮
|
||||
{
|
||||
auto abortButton = createDialogButton(
|
||||
(this->x + (this->width - (functionButtonWidth * buttonNum + buttonMargin* (buttonNum-1))) / 2),
|
||||
(this->x + (this->width - (functionButtonWidth * buttonNum + buttonMargin * (buttonNum - 1))) / 2),
|
||||
((this->y + (this->height - buttonAreaHeight)) + (buttonAreaHeight - functionButtonHeight) / 2),
|
||||
"中止"
|
||||
);
|
||||
@@ -424,8 +506,8 @@ void Dialog::initButtons()
|
||||
this->Close();
|
||||
});
|
||||
auto retryButton = createDialogButton(
|
||||
abortButton.get()->getButtonX() + abortButton.get()->getButtonWidth() + buttonMargin,
|
||||
abortButton.get()->getButtonY(),
|
||||
abortButton.get()->getX() + abortButton.get()->getWidth() + buttonMargin,
|
||||
abortButton.get()->getY(),
|
||||
"重试"
|
||||
);
|
||||
retryButton->setOnClickListener([this]()
|
||||
@@ -435,8 +517,8 @@ void Dialog::initButtons()
|
||||
this->Close();
|
||||
});
|
||||
auto ignoreButton = createDialogButton(
|
||||
retryButton.get()->getButtonX() + retryButton.get()->getButtonWidth() + buttonMargin,
|
||||
retryButton.get()->getButtonY(),
|
||||
retryButton.get()->getX() + retryButton.get()->getWidth() + buttonMargin,
|
||||
retryButton.get()->getY(),
|
||||
"忽略"
|
||||
);
|
||||
ignoreButton.get()->setOnClickListener([this]()
|
||||
@@ -463,8 +545,8 @@ void Dialog::initCloseButton()
|
||||
//初始化关闭按钮
|
||||
auto but = std::make_unique<Button>
|
||||
(
|
||||
(this->x + this->width - closeButtonWidth) - 3, (this->y+3), closeButtonWidth-1, closeButtonHeight,
|
||||
"×", // 按钮文本
|
||||
(this->x + this->width - closeButtonWidth) - 3, (this->y + 3), closeButtonWidth - 1, closeButtonHeight,
|
||||
"X", // 按钮文本
|
||||
RGB(255, 0, 0), // 按钮被点击颜色
|
||||
this->canvasBkClor, // 按钮背景颜色
|
||||
RGB(255, 0, 0), // 按钮被悬停颜色
|
||||
@@ -472,6 +554,7 @@ void Dialog::initCloseButton()
|
||||
StellarX::ControlShape::B_RECTANGLE
|
||||
);
|
||||
but.get()->setButtonFalseColor(this->backgroundColor);
|
||||
but.get()->enableTooltip(false);
|
||||
but->setOnClickListener([this]() {
|
||||
this->SetResult(StellarX::MessageBoxResult::Cancel);
|
||||
this->hWnd.dialogClose = true;
|
||||
@@ -481,15 +564,6 @@ void Dialog::initCloseButton()
|
||||
this->addControl(std::move(but));
|
||||
}
|
||||
|
||||
void Dialog::initTitle()
|
||||
{
|
||||
this->title = std::make_unique<Label>(this->x+5,this->y+5,titleText,textStyle.color);
|
||||
title->setTextdisap(true);
|
||||
title->textStyle = this->textStyle;
|
||||
|
||||
this->addControl(std::move(title));
|
||||
}
|
||||
|
||||
void Dialog::splitMessageLines()
|
||||
{
|
||||
lines.clear(); // 先清空现有的行
|
||||
@@ -497,14 +571,17 @@ void Dialog::splitMessageLines()
|
||||
std::string currentLine;
|
||||
for (size_t i = 0; i < message.length(); i++) {
|
||||
// 处理 换行符 \r\n \n \r
|
||||
if (i + 1 < message.length() && (message[i] == '\r' || message[i] == '\n')||(message[i] == '\r' && message[i+1] == '\n'))
|
||||
const bool hasNext = (i + 1 < message.length());
|
||||
const bool isLineBreak = (message[i] == '\r' || message[i] == '\n');
|
||||
const bool isCrLf = hasNext && message[i] == '\r' && message[i + 1] == '\n';
|
||||
if (isLineBreak || isCrLf)
|
||||
{
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
currentLine.clear();
|
||||
}
|
||||
|
||||
if (message[i] == '\r' && message[i + 1] == '\n')
|
||||
if (isCrLf)
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -513,12 +590,14 @@ void Dialog::splitMessageLines()
|
||||
}
|
||||
|
||||
// 添加最后一行(如果有内容)
|
||||
if (!currentLine.empty()) {
|
||||
if (!currentLine.empty())
|
||||
{
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
|
||||
// 如果消息为空,至少添加一个空行
|
||||
if (lines.empty()) {
|
||||
if (lines.empty())
|
||||
{
|
||||
lines.push_back("");
|
||||
}
|
||||
}
|
||||
@@ -526,10 +605,12 @@ void Dialog::splitMessageLines()
|
||||
void Dialog::getTextSize()
|
||||
{
|
||||
saveStyle();
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
for (auto text : lines)
|
||||
this->textHeight = 0;
|
||||
this->textWidth = 0;
|
||||
for (auto& text : lines)
|
||||
{
|
||||
int w = textwidth(LPCTSTR(text.c_str()));
|
||||
int h = textheight(LPCTSTR(text.c_str()));
|
||||
@@ -538,12 +619,34 @@ void Dialog::getTextSize()
|
||||
if (this->textWidth < w)
|
||||
this->textWidth = w;
|
||||
}
|
||||
|
||||
restoreStyle();
|
||||
}
|
||||
|
||||
void Dialog::invalidateLayout(bool clearChildren)
|
||||
{
|
||||
if (clearChildren)
|
||||
clearControls();
|
||||
|
||||
this->textWidth = 0;
|
||||
this->textHeight = 0;
|
||||
this->buttonNum = 0;
|
||||
this->needsInitialization = true;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Dialog::rebuildChrome()
|
||||
{
|
||||
clearControls();
|
||||
initButtons();
|
||||
initCloseButton();
|
||||
}
|
||||
|
||||
// 计算逻辑:对话框宽度取【文本区域最大宽度】和【按钮区域总宽度】中的较大值。
|
||||
// 对话框高度 = 标题栏 + 文本区 + 按钮区 + 各种间距。
|
||||
void Dialog::initDialogSize()
|
||||
{
|
||||
this->textStyle.nHeight = 20;
|
||||
splitMessageLines(); // 分割消息行
|
||||
getTextSize(); // 获取文本最大尺寸
|
||||
|
||||
@@ -566,18 +669,26 @@ void Dialog::initDialogSize()
|
||||
|
||||
// 计算按钮区域宽度
|
||||
int buttonAreaWidth = buttonNum * functionButtonWidth +
|
||||
(buttonNum > 0 ? (buttonNum +1) * buttonMargin : 0);
|
||||
(buttonNum > 0 ? (buttonNum + 1) * buttonMargin : 0);
|
||||
|
||||
// 计算文本区域宽度(包括边距)
|
||||
int textAreaWidth = textWidth + textToBorderMargin * 2 ;
|
||||
int textAreaWidth = textWidth + textToBorderMargin * 2;
|
||||
saveStyle();
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
int titleAreaWidth = textwidth(LPCTSTR(titleText.c_str())) + textToBorderMargin * 2 + closeButtonWidth + buttonMargin;
|
||||
restoreStyle();
|
||||
|
||||
// 对话框宽度取两者中的较大值,并确保最小宽度
|
||||
// 对话框宽度取文本、标题和按钮区域中的较大值,并确保最小宽度
|
||||
this->width = buttonAreaWidth > textAreaWidth ? buttonAreaWidth : textAreaWidth;
|
||||
if (titleAreaWidth > this->width)
|
||||
this->width = titleAreaWidth;
|
||||
this->width = this->width > 200 ? this->width : 200;
|
||||
|
||||
// 计算对话框高度
|
||||
// 高度 = 标题栏高度 + 文本区域高度 + 按钮区域高度 + 间距
|
||||
int textAreaHeight = textHeight * (int)lines.size() + 5*((int)lines.size()-1); // 文本行高+行间距
|
||||
int textAreaHeight = textHeight * (int)lines.size() + 5 * ((int)lines.size() - 1); // 文本行高+行间距
|
||||
this->height = closeButtonHeight + // 标题栏高度
|
||||
titleToTextMargin + // 标题到文本的间距
|
||||
textAreaHeight + // 文本区域高度
|
||||
@@ -587,22 +698,14 @@ void Dialog::initDialogSize()
|
||||
this->x = (hWnd.getWidth() - this->width) / 2;
|
||||
this->y = (hWnd.getHeight() - this->height) / 2;
|
||||
|
||||
//this->textStyle.nWidth = 10;
|
||||
this->textStyle.nHeight = 20;
|
||||
|
||||
initButtons(); // 初始化按钮
|
||||
initTitle(); // 初始化标题标签
|
||||
initCloseButton(); // 初始化关闭按钮
|
||||
rebuildChrome();
|
||||
}
|
||||
|
||||
void Dialog::initializeDialog()
|
||||
void Dialog::addControl(std::unique_ptr<Control> control)
|
||||
{
|
||||
needsInitialization = true;
|
||||
pendingCleanup = false;
|
||||
isCleaning = false;
|
||||
close = false;
|
||||
isVisible = false;
|
||||
|
||||
control->setParent(this);
|
||||
controls.push_back(std::move(control));
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// 延迟清理策略:由于对话框绘制时保存了背景快照,必须在对话框隐藏后、
|
||||
@@ -614,27 +717,19 @@ void Dialog::performDelayedCleanup()
|
||||
|
||||
isCleaning = true;
|
||||
|
||||
// 清除所有控件
|
||||
for (auto& control : controls)
|
||||
control->invalidateBackgroundSnapshot();
|
||||
controls.clear();
|
||||
|
||||
// 重置指针
|
||||
closeButton = nullptr;
|
||||
title.reset();
|
||||
|
||||
// 释放背景图像资源
|
||||
if (saveBkImage)
|
||||
{
|
||||
// 恢复背景
|
||||
putimage(saveBkX, saveBkY, saveBkImage);
|
||||
delete saveBkImage;
|
||||
saveBkImage = nullptr;
|
||||
}
|
||||
|
||||
// 清理阶段不直接写屏;Window 会在安全点统一重绘剩余场景。
|
||||
invalidateBackgroundSnapshot();
|
||||
hWnd.requestFullSceneRedraw();
|
||||
// 重置状态
|
||||
needsInitialization = true;
|
||||
pendingCleanup = false;
|
||||
isCleaning = false;
|
||||
shouldClose = false;
|
||||
}
|
||||
|
||||
void Dialog::SetResultCallback(std::function<void(StellarX::MessageBoxResult)> cb)
|
||||
@@ -647,12 +742,19 @@ std::string Dialog::GetCaption() const
|
||||
return titleText;
|
||||
}
|
||||
|
||||
std::string Dialog::GetText() const
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
void Dialog::clearControls()
|
||||
{
|
||||
for (auto& control : controls)
|
||||
control->invalidateBackgroundSnapshot();
|
||||
|
||||
controls.clear();
|
||||
// 重置按钮指针
|
||||
closeButton = nullptr;
|
||||
title.reset(); // 释放标题资源
|
||||
}
|
||||
|
||||
std::unique_ptr<Button> Dialog::createDialogButton(int x, int y, const std::string& text)
|
||||
@@ -670,4 +772,47 @@ std::unique_ptr<Button> Dialog::createDialogButton(int x, int y, const std::stri
|
||||
return btn;
|
||||
}
|
||||
|
||||
void Dialog::requestRepaint(Control* parent)
|
||||
{
|
||||
if (shouldDeferManagedRepaint())
|
||||
{
|
||||
// 非模态 Dialog 在 Window 主循环中也走托管提交;
|
||||
// 这样底层控件和对话框的绘制顺序由 Window 统一收口控制。
|
||||
if (auto* host = getHostWindow())
|
||||
host->requestManagedRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this == parent)
|
||||
{
|
||||
for (auto& control : controls)
|
||||
if (control->isDirty() && control->IsVisible())
|
||||
control->draw();
|
||||
}
|
||||
else
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
|
||||
bool Dialog::canCommitManagedPartialRepaint() const
|
||||
{
|
||||
// Dialog 只有在“自身底板不脏 + 仍持有有效背景快照”时,
|
||||
// 才能安全地只更新内部按钮,而不重画整个对话框底板。
|
||||
return show && !dirty && hasValidBackgroundSnapshot();
|
||||
}
|
||||
|
||||
void Dialog::commitManagedRepaint()
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
|
||||
if (canCommitManagedPartialRepaint())
|
||||
{
|
||||
// 背景快照完好:沿用 Dialog 自己已有的局部重绘路径。
|
||||
requestRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 对话框底板本身已脏,或快照失效:必须整 Dialog 重画。
|
||||
this->dirty = true;
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
#include "FocusManager.h"
|
||||
#include "SxLog.h"
|
||||
#include "Window.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace
|
||||
{
|
||||
class WindowMutationGuard
|
||||
{
|
||||
Window* window = nullptr;
|
||||
public:
|
||||
explicit WindowMutationGuard(Window* owner) : window(owner)
|
||||
{
|
||||
if (window) window->beginMutationScope();
|
||||
}
|
||||
~WindowMutationGuard()
|
||||
{
|
||||
if (window) window->endMutationScope();
|
||||
}
|
||||
};
|
||||
|
||||
class FocusCallbackContextGuard
|
||||
{
|
||||
StellarX::FocusTargetId& slot;
|
||||
public:
|
||||
FocusCallbackContextGuard(StellarX::FocusTargetId& targetSlot,
|
||||
StellarX::FocusTargetId target) : slot(targetSlot)
|
||||
{
|
||||
slot = target;
|
||||
}
|
||||
~FocusCallbackContextGuard()
|
||||
{
|
||||
slot = {};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
FocusManager::FocusManager(::Window& owner) : window(&owner)
|
||||
{
|
||||
ScopeEntry root;
|
||||
root.id.value = 1;
|
||||
scopes.push_back(root);
|
||||
}
|
||||
|
||||
FocusScopeId FocusManager::rootScope() const noexcept
|
||||
{
|
||||
return scopes.empty() ? FocusScopeId{} : scopes.front().id;
|
||||
}
|
||||
|
||||
FocusScopeId FocusManager::activeScope() const noexcept
|
||||
{
|
||||
return scopes.empty() ? FocusScopeId{} : scopes.back().id;
|
||||
}
|
||||
|
||||
bool FocusManager::querySnapshot(FocusTargetId target, FocusTargetSnapshot& snapshot) const
|
||||
{
|
||||
if (!window || !target)
|
||||
return false;
|
||||
if (!window->queryFocusTargetSnapshot(target, snapshot))
|
||||
return false;
|
||||
|
||||
snapshot.scope = rootScope();
|
||||
for (auto it = scopes.rbegin(); it != scopes.rend(); ++it)
|
||||
{
|
||||
if (it->root && isWithinSubtree(target, it->root))
|
||||
{
|
||||
snapshot.scope = it->id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FocusManager::isWithinSubtree(FocusTargetId target, FocusTargetId root) const
|
||||
{
|
||||
if (!target || !root)
|
||||
return false;
|
||||
FocusTargetId cursor = target;
|
||||
for (std::size_t guard = 0; cursor && guard < MaxReentrantRounds * 4; ++guard)
|
||||
{
|
||||
if (cursor == root)
|
||||
return true;
|
||||
FocusTargetSnapshot snapshot;
|
||||
if (!window || !window->queryFocusTargetSnapshot(cursor, snapshot))
|
||||
return false;
|
||||
if (snapshot.logicalParent == cursor)
|
||||
return false;
|
||||
cursor = snapshot.logicalParent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FocusManager::isUnderInvalidatedSubtree(FocusTargetId target) const
|
||||
{
|
||||
for (const ControlHandle root : invalidatedSubtrees)
|
||||
{
|
||||
if (isWithinSubtree(target, FocusTargetId{ root, 0 }))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FocusManager::isWithinActiveScope(FocusTargetId target) const
|
||||
{
|
||||
if (scopes.empty() || !scopes.back().root)
|
||||
return true;
|
||||
return isWithinSubtree(target, scopes.back().root);
|
||||
}
|
||||
|
||||
FocusPolicy FocusManager::requiredPolicy(FocusReason reason) const noexcept
|
||||
{
|
||||
switch (reason)
|
||||
{
|
||||
case FocusReason::Pointer:
|
||||
return FocusPolicy::Pointer;
|
||||
case FocusReason::TabTraversal:
|
||||
case FocusReason::InvalidationFallback:
|
||||
return FocusPolicy::Tab;
|
||||
case FocusReason::Programmatic:
|
||||
case FocusReason::ScopeActivation:
|
||||
case FocusReason::ScopeRestore:
|
||||
default:
|
||||
return FocusPolicy::Programmatic;
|
||||
}
|
||||
}
|
||||
|
||||
FocusError FocusManager::validateCurrentTarget(FocusTargetId target,
|
||||
FocusTargetSnapshot* result) const
|
||||
{
|
||||
if (!target)
|
||||
return FocusError::InvalidTarget;
|
||||
FocusTargetSnapshot snapshot;
|
||||
if (!querySnapshot(target, snapshot) || isUnderInvalidatedSubtree(target))
|
||||
return FocusError::InvalidTarget;
|
||||
if (!snapshot.focusable)
|
||||
return FocusError::NotFocusable;
|
||||
if (!snapshot.visible)
|
||||
return FocusError::NotVisible;
|
||||
if (!snapshot.enabled)
|
||||
return FocusError::Disabled;
|
||||
if (!isWithinActiveScope(target))
|
||||
return FocusError::OutsideActiveScope;
|
||||
if (result) *result = snapshot;
|
||||
return FocusError::None;
|
||||
}
|
||||
|
||||
FocusError FocusManager::validateTarget(FocusTargetId target, FocusReason reason,
|
||||
FocusTargetSnapshot* result) const
|
||||
{
|
||||
const FocusError currentError = validateCurrentTarget(target, result);
|
||||
if (currentError != FocusError::None)
|
||||
return currentError;
|
||||
FocusTargetSnapshot snapshot;
|
||||
if (!result)
|
||||
{
|
||||
if (!querySnapshot(target, snapshot))
|
||||
return FocusError::InvalidTarget;
|
||||
result = &snapshot;
|
||||
}
|
||||
if (!hasFocusPolicy(result->policy, requiredPolicy(reason)))
|
||||
return FocusError::NotFocusable;
|
||||
return FocusError::None;
|
||||
}
|
||||
|
||||
void FocusManager::queueReentrantRequest(FocusTargetId target, FocusReason reason) noexcept
|
||||
{
|
||||
pending.target = target;
|
||||
pending.reason = reason;
|
||||
pendingPresent = true;
|
||||
}
|
||||
|
||||
FocusChangeResult FocusManager::performTransition(FocusTargetId target, FocusReason reason)
|
||||
{
|
||||
FocusChangeResult result;
|
||||
result.focused = activeTarget;
|
||||
|
||||
if (target)
|
||||
{
|
||||
result.error = validateTarget(target, reason);
|
||||
if (result.error != FocusError::None)
|
||||
return result;
|
||||
}
|
||||
if (target == activeTarget)
|
||||
return result;
|
||||
|
||||
const FocusTargetId oldTarget = activeTarget;
|
||||
activeTarget = {};
|
||||
if (oldTarget)
|
||||
{
|
||||
FocusCallbackContextGuard callbackContext(callbackFocusContext, oldTarget);
|
||||
window->applyFocusState(oldTarget, false, reason);
|
||||
}
|
||||
|
||||
// onFocusLost may delete, hide, or disable the new target. Pull a fresh snapshot.
|
||||
if (target)
|
||||
{
|
||||
result.error = validateTarget(target, reason);
|
||||
if (result.error != FocusError::None)
|
||||
{
|
||||
result.changed = static_cast<bool>(oldTarget);
|
||||
result.focused = activeTarget;
|
||||
return result;
|
||||
}
|
||||
activeTarget = target;
|
||||
FocusCallbackContextGuard callbackContext(callbackFocusContext, target);
|
||||
window->applyFocusState(target, true, reason);
|
||||
}
|
||||
|
||||
result.changed = oldTarget != activeTarget;
|
||||
result.focused = activeTarget;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<FocusTargetSnapshot> FocusManager::collectOrderedTargets(bool tabStopsOnly) const
|
||||
{
|
||||
std::vector<FocusTargetSnapshot> snapshots;
|
||||
if (!window)
|
||||
return snapshots;
|
||||
window->collectFocusTargetSnapshots(snapshots);
|
||||
|
||||
std::vector<FocusTargetSnapshot> filtered;
|
||||
filtered.reserve(snapshots.size());
|
||||
for (auto snapshot : snapshots)
|
||||
{
|
||||
FocusTargetSnapshot current;
|
||||
if (validateCurrentTarget(snapshot.id, ¤t) != FocusError::None)
|
||||
continue;
|
||||
if (tabStopsOnly && !current.tabStop)
|
||||
continue;
|
||||
filtered.push_back(current);
|
||||
}
|
||||
std::stable_sort(filtered.begin(), filtered.end(), [](const auto& left, const auto& right)
|
||||
{ return left.tabIndex < right.tabIndex; });
|
||||
return filtered;
|
||||
}
|
||||
|
||||
std::vector<FocusTargetSnapshot> FocusManager::collectTabStops() const
|
||||
{
|
||||
return collectOrderedTargets(true);
|
||||
}
|
||||
|
||||
FocusTargetId FocusManager::findInvalidationFallback(FocusTargetId invalidTarget) const
|
||||
{
|
||||
std::vector<FocusTargetSnapshot> all;
|
||||
if (window) window->collectFocusTargetSnapshots(all);
|
||||
std::stable_sort(all.begin(), all.end(), [](const auto& left, const auto& right)
|
||||
{ return left.tabIndex < right.tabIndex; });
|
||||
|
||||
auto invalidIt = std::find_if(all.begin(), all.end(), [&](const auto& item)
|
||||
{ return item.id == invalidTarget; });
|
||||
const std::size_t invalidIndex = invalidIt == all.end()
|
||||
? 0 : static_cast<std::size_t>(std::distance(all.begin(), invalidIt));
|
||||
|
||||
for (std::size_t i = invalidIndex + (invalidIt == all.end() ? 0 : 1); i < all.size(); ++i)
|
||||
{
|
||||
if (validateTarget(all[i].id, FocusReason::InvalidationFallback) == FocusError::None)
|
||||
return all[i].id;
|
||||
}
|
||||
for (std::size_t i = invalidIndex; i > 0; --i)
|
||||
{
|
||||
if (validateTarget(all[i - 1].id, FocusReason::InvalidationFallback) == FocusError::None)
|
||||
return all[i - 1].id;
|
||||
}
|
||||
|
||||
if (!scopes.empty() && scopes.back().initialFocus &&
|
||||
validateTarget(scopes.back().initialFocus, FocusReason::ScopeActivation) == FocusError::None)
|
||||
return scopes.back().initialFocus;
|
||||
return {};
|
||||
}
|
||||
|
||||
FocusChangeResult FocusManager::requestFocus(FocusTargetId target, FocusReason reason)
|
||||
{
|
||||
if (transitionActive)
|
||||
{
|
||||
queueReentrantRequest(target, reason);
|
||||
return { false, FocusError::None, activeTarget };
|
||||
}
|
||||
|
||||
WindowMutationGuard mutation(window);
|
||||
transitionActive = true;
|
||||
lastError = FocusError::None;
|
||||
FocusRequest request{ target, reason };
|
||||
FocusChangeResult aggregate{ false, FocusError::None, activeTarget };
|
||||
bool continueTransition = true;
|
||||
std::size_t round = 0;
|
||||
|
||||
while (continueTransition && round < MaxReentrantRounds)
|
||||
{
|
||||
++round;
|
||||
pendingPresent = false;
|
||||
const FocusChangeResult step = performTransition(request.target, request.reason);
|
||||
aggregate.changed = aggregate.changed || step.changed;
|
||||
if (round == 1 && step.error != FocusError::None)
|
||||
aggregate.error = step.error;
|
||||
|
||||
const bool activeStillValid = !activeTarget ||
|
||||
validateCurrentTarget(activeTarget) == FocusError::None;
|
||||
bool selectedNext = false;
|
||||
|
||||
if (pendingPresent)
|
||||
{
|
||||
const FocusRequest latest = pending;
|
||||
pendingPresent = false;
|
||||
// Single-slot latest-wins: validate only the final request; never revive older requests.
|
||||
if (!latest.target || validateTarget(latest.target, latest.reason) == FocusError::None)
|
||||
{
|
||||
request = latest;
|
||||
selectedNext = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedNext && !activeStillValid)
|
||||
{
|
||||
const FocusTargetId invalid = activeTarget;
|
||||
request = { findInvalidationFallback(invalid), FocusReason::InvalidationFallback };
|
||||
selectedNext = true;
|
||||
}
|
||||
continueTransition = selectedNext;
|
||||
}
|
||||
|
||||
if (continueTransition)
|
||||
{
|
||||
pendingPresent = false;
|
||||
lastError = FocusError::ReentrancyLimit;
|
||||
aggregate.error = FocusError::ReentrancyLimit;
|
||||
SX_LOGE("Focus") << "focus reentrancy limit reached: rounds=" << MaxReentrantRounds;
|
||||
}
|
||||
else if (aggregate.error != FocusError::None)
|
||||
{
|
||||
lastError = aggregate.error;
|
||||
}
|
||||
|
||||
transitionActive = false;
|
||||
aggregate.focused = activeTarget;
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
FocusScopeId FocusManager::pushScope(FocusTargetId scopeRoot, FocusTargetId initialFocus)
|
||||
{
|
||||
FocusTargetSnapshot rootSnapshot;
|
||||
if (!scopeRoot || !querySnapshot(scopeRoot, rootSnapshot) || isUnderInvalidatedSubtree(scopeRoot))
|
||||
{
|
||||
lastError = FocusError::InvalidScope;
|
||||
return {};
|
||||
}
|
||||
|
||||
ScopeEntry entry;
|
||||
entry.id.value = nextScopeValue++;
|
||||
entry.root = scopeRoot;
|
||||
entry.initialFocus = initialFocus;
|
||||
entry.restoreFocus = callbackFocusContext ? callbackFocusContext : activeTarget;
|
||||
scopes.push_back(entry);
|
||||
|
||||
FocusTargetId target = initialFocus;
|
||||
if (!target || validateTarget(target, FocusReason::ScopeActivation) != FocusError::None)
|
||||
{
|
||||
const auto stops = collectTabStops();
|
||||
target = stops.empty() ? FocusTargetId{} : stops.front().id;
|
||||
}
|
||||
requestFocus(target, FocusReason::ScopeActivation);
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
bool FocusManager::popScope(FocusScopeId scope)
|
||||
{
|
||||
if (scopes.size() <= 1 || scopes.back().id != scope)
|
||||
{
|
||||
lastError = FocusError::InvalidScope;
|
||||
return false;
|
||||
}
|
||||
const FocusTargetId restore = scopes.back().restoreFocus;
|
||||
scopes.pop_back();
|
||||
if (restore && validateTarget(restore, FocusReason::ScopeRestore) == FocusError::None)
|
||||
requestFocus(restore, FocusReason::ScopeRestore);
|
||||
else
|
||||
{
|
||||
const auto stops = collectTabStops();
|
||||
requestFocus(stops.empty() ? FocusTargetId{} : stops.front().id,
|
||||
FocusReason::ScopeRestore);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void FocusManager::normalizeInvalidScopes()
|
||||
{
|
||||
while (scopes.size() > 1)
|
||||
{
|
||||
FocusTargetSnapshot snapshot;
|
||||
if (querySnapshot(scopes.back().root, snapshot) &&
|
||||
!isUnderInvalidatedSubtree(scopes.back().root))
|
||||
break;
|
||||
scopes.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
void FocusManager::notifySubtreePendingRemoval(ControlHandle rootHandle)
|
||||
{
|
||||
if (!rootHandle)
|
||||
return;
|
||||
notifySubtreesPendingRemoval({ rootHandle });
|
||||
}
|
||||
|
||||
void FocusManager::notifySubtreesPendingRemoval(const std::vector<ControlHandle>& rootHandles)
|
||||
{
|
||||
for (const ControlHandle rootHandle : rootHandles)
|
||||
{
|
||||
if (!rootHandle)
|
||||
continue;
|
||||
if (std::find(invalidatedSubtrees.begin(), invalidatedSubtrees.end(), rootHandle) == invalidatedSubtrees.end())
|
||||
invalidatedSubtrees.push_back(rootHandle);
|
||||
}
|
||||
normalizeInvalidScopes();
|
||||
|
||||
bool activeRemoved = false;
|
||||
for (const ControlHandle rootHandle : rootHandles)
|
||||
{
|
||||
if (rootHandle && activeTarget &&
|
||||
isWithinSubtree(activeTarget, FocusTargetId{ rootHandle, 0 }))
|
||||
{
|
||||
activeRemoved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!activeRemoved)
|
||||
return;
|
||||
if (transitionActive)
|
||||
return;
|
||||
const FocusTargetId invalid = activeTarget;
|
||||
requestFocus(findInvalidationFallback(invalid), FocusReason::InvalidationFallback);
|
||||
}
|
||||
|
||||
void FocusManager::notifySubtreeStateChanged(ControlHandle rootHandle)
|
||||
{
|
||||
if (!rootHandle || !activeTarget ||
|
||||
!isWithinSubtree(activeTarget, FocusTargetId{ rootHandle, 0 }))
|
||||
return;
|
||||
if (validateCurrentTarget(activeTarget) == FocusError::None || transitionActive)
|
||||
return;
|
||||
const FocusTargetId invalid = activeTarget;
|
||||
requestFocus(findInvalidationFallback(invalid), FocusReason::InvalidationFallback);
|
||||
}
|
||||
|
||||
void FocusManager::onMutationCommitted()
|
||||
{
|
||||
invalidatedSubtrees.clear();
|
||||
normalizeInvalidScopes();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
#include "Label.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Label 的内容尺寸刷新不依赖 EasyX 当前绘图上下文,
|
||||
// 直接使用 GDI 依据“文本 + 字体样式”测量宽高。
|
||||
// 这样无论是文本变化、字体样式变化,还是控件在窗口正式绘制前进入 dirty,
|
||||
// 都可以先把运行态尺寸收口好,而不必等到 draw() 再决定几何。
|
||||
void MeasureLabelText(const std::string& text, const StellarX::ControlText& style, int& width, int& height)
|
||||
{
|
||||
// 使用独立的兼容 DC 测量文本,不依赖当前 EasyX 工作图像,也不会改动屏幕状态。
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
HDC hdc = CreateCompatibleDC(nullptr);
|
||||
if (!hdc)
|
||||
return;
|
||||
|
||||
LOGFONTA fontDesc{};
|
||||
fontDesc.lfHeight = style.nHeight;
|
||||
fontDesc.lfWidth = style.nWidth;
|
||||
fontDesc.lfEscapement = style.nEscapement;
|
||||
fontDesc.lfOrientation = style.nOrientation;
|
||||
fontDesc.lfWeight = style.nWeight;
|
||||
fontDesc.lfItalic = style.bItalic ? TRUE : FALSE;
|
||||
fontDesc.lfUnderline = style.bUnderline ? TRUE : FALSE;
|
||||
fontDesc.lfStrikeOut = style.bStrikeOut ? TRUE : FALSE;
|
||||
fontDesc.lfCharSet = DEFAULT_CHARSET;
|
||||
fontDesc.lfOutPrecision = OUT_DEFAULT_PRECIS;
|
||||
fontDesc.lfClipPrecision = CLIP_DEFAULT_PRECIS;
|
||||
fontDesc.lfQuality = DEFAULT_QUALITY;
|
||||
fontDesc.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE;
|
||||
lstrcpynA(fontDesc.lfFaceName, style.faceName.empty() ? "微软雅黑" : style.faceName.c_str(), LF_FACESIZE);
|
||||
|
||||
HFONT font = CreateFontIndirectA(&fontDesc);
|
||||
HFONT oldFont = nullptr;
|
||||
if (font)
|
||||
oldFont = (HFONT)SelectObject(hdc, font);
|
||||
|
||||
if (text.empty())
|
||||
{
|
||||
TEXTMETRICA tm{};
|
||||
if (GetTextMetricsA(hdc, &tm))
|
||||
height = tm.tmHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
SIZE size{};
|
||||
if (GetTextExtentPoint32A(hdc, text.c_str(), (int)text.size(), &size))
|
||||
{
|
||||
width = size.cx;
|
||||
height = size.cy;
|
||||
}
|
||||
if (height == 0)
|
||||
{
|
||||
TEXTMETRICA tm{};
|
||||
if (GetTextMetricsA(hdc, &tm))
|
||||
height = tm.tmHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (font)
|
||||
{
|
||||
if (oldFont)
|
||||
SelectObject(hdc, oldFont);
|
||||
DeleteObject(font);
|
||||
}
|
||||
DeleteDC(hdc);
|
||||
}
|
||||
}
|
||||
|
||||
Label::Label()
|
||||
:Control(0, 0, 0, 0)
|
||||
{
|
||||
this->id = "Label";
|
||||
// Label 当前阶段明确走“内容驱动尺寸”语义:
|
||||
// 外部布局器只安置位置,不通过 Stretch 改写它的宽高。
|
||||
this->layoutCapability.allowStretchX = false;
|
||||
this->layoutCapability.allowStretchY = false;
|
||||
this->text = "默认标签";
|
||||
textStyle.color = RGB(0, 0, 0);
|
||||
textBkColor = RGB(255, 255, 255);; //默认白色背景
|
||||
}
|
||||
|
||||
Label::Label(int x, int y, std::string text, COLORREF textcolor, COLORREF bkColor)
|
||||
:Control(x, y, 0, 0)
|
||||
{
|
||||
this->id = "Label";
|
||||
this->layoutCapability.allowStretchX = false;
|
||||
this->layoutCapability.allowStretchY = false;
|
||||
this->text = text;
|
||||
textStyle.color = textcolor;
|
||||
textBkColor = bkColor; //默认白色背景
|
||||
}
|
||||
|
||||
void Label::refreshContentDrivenRuntimeGeometry()
|
||||
{
|
||||
// Label 的尺寸来源于“当前文本 + 当前字体样式”。
|
||||
// 这里只更新运行态 width/height,不自动回写 localWidth/localHeight,
|
||||
// 避免内容变化把设计基线偷偷带偏。
|
||||
if (contentMeasureValid &&
|
||||
lastMeasuredText == text &&
|
||||
!(lastMeasuredStyle != textStyle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有文本或字体样式发生变化才重新测量,避免每次普通重绘都创建 GDI 资源。
|
||||
int newWidth = 0;
|
||||
int newHeight = 0;
|
||||
MeasureLabelText(text, textStyle, newWidth, newHeight);
|
||||
|
||||
const bool sizeChanged = (newWidth != this->width) || (newHeight != this->height);
|
||||
if (sizeChanged && hasSnap)
|
||||
{
|
||||
// 运行态尺寸变化时,先恢复旧背景,避免文本缩短后旧像素残留在屏幕上。
|
||||
discardBackground();
|
||||
}
|
||||
|
||||
this->width = newWidth;
|
||||
this->height = newHeight;
|
||||
lastMeasuredText = text;
|
||||
lastMeasuredStyle = textStyle;
|
||||
contentMeasureValid = true;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Label::draw()
|
||||
{
|
||||
if (dirty && show)
|
||||
{
|
||||
saveStyle();
|
||||
if (textBkDisap)
|
||||
setbkmode(TRANSPARENT); //设置背景透明
|
||||
else
|
||||
{
|
||||
setbkmode(OPAQUE); //设置背景不透明
|
||||
setbkcolor(textBkColor); //设置背景颜色
|
||||
}
|
||||
settextcolor(textStyle.color);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut); //设置字体样式
|
||||
if ((saveBkX != this->x) || (saveBkY != this->y) || (!hasSnap) || (saveWidth != this->width) || (saveHeight != this->height) || !saveBkImage)
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
// 恢复背景(清除旧内容)
|
||||
restBackground();
|
||||
outtextxy(x, y, LPCTSTR(text.c_str()));
|
||||
restoreStyle();
|
||||
dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
void Label::hide()
|
||||
{
|
||||
discardBackground(); // 还原并释放快照
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
void Label::setDirty(bool dirty)
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
// 只要 Label 进入脏状态,就在绘制前把内容驱动尺寸同步到运行态。
|
||||
// 这样文本变化和字体样式变化都会在 draw() 之前完成几何刷新。
|
||||
refreshContentDrivenRuntimeGeometry();
|
||||
}
|
||||
this->dirty = dirty;
|
||||
}
|
||||
|
||||
void Label::setTextdisap(bool key)
|
||||
{
|
||||
textBkDisap = key;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Label::setTextBkColor(COLORREF color)
|
||||
{
|
||||
textBkColor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Label::setText(std::string text)
|
||||
{
|
||||
this->text = text;
|
||||
refreshContentDrivenRuntimeGeometry();
|
||||
}
|
||||
|
||||
void Label::applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect)
|
||||
{
|
||||
// 通用布局器给出的 rect.width/rect.height 只代表“外部几何解算结果”。
|
||||
// Label 当前阶段的语义是内容驱动尺寸,因此这里只接位置,不接外部宽高,
|
||||
// 宽高继续由 refreshContentDrivenRuntimeGeometry() 按文本和字体样式刷新。
|
||||
refreshContentDrivenRuntimeGeometry();
|
||||
applyRuntimeRectDirect(rect.worldX, rect.worldY, this->width, this->height);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "MessageBox.h"
|
||||
#include "SxLog.h"
|
||||
namespace StellarX
|
||||
{
|
||||
MessageBoxResult MessageBox::showModal(Window& wnd, const std::string& text, const std::string& caption,
|
||||
MessageBoxType type)
|
||||
{
|
||||
Dialog dlg(wnd, caption, text, type, true); // 模态
|
||||
SX_LOGI("MessageBox") << "show: Message=" << dlg.GetText()
|
||||
<< " modal=" << (dlg.model() ? 1 : 0);
|
||||
|
||||
dlg.Show();
|
||||
return dlg.GetResult();
|
||||
}
|
||||
|
||||
void MessageBox::showAsync(Window& wnd, const std::string& text, const std::string& caption, MessageBoxType type,
|
||||
std::function<void(MessageBoxResult)> onResult)
|
||||
{
|
||||
//去重,如果窗口内已有相同的对话框被触发,则不再创建
|
||||
if (wnd.hasNonModalDialogWithCaption(caption, text))
|
||||
{
|
||||
std::cout << "\a" << std::endl;
|
||||
return;
|
||||
}
|
||||
auto dlg = std::make_unique<Dialog>(wnd, caption, text,
|
||||
type, false); // 非模态
|
||||
SX_LOGI("MessageBox") << "show: Message=" << dlg->GetText()
|
||||
<< " modal=" << (dlg->model() ? 1 : 0);
|
||||
Dialog* dlgPtr = dlg.get();
|
||||
// 设置回调
|
||||
if (onResult)
|
||||
dlgPtr->SetResultCallback(std::move(onResult));
|
||||
// 交给 Window 管理生命周期
|
||||
wnd.addDialog(std::move(dlg));
|
||||
dlgPtr->Show();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
||||
#include "SxLog.h"
|
||||
#include <cstdlib>
|
||||
#include <clocale>
|
||||
#include <filesystem>
|
||||
#include <system_error>
|
||||
|
||||
/********************************************************************************
|
||||
* @文件: SxLog.cpp
|
||||
* @摘要: StellarX 日志系统实现(过滤/格式化/输出/文件滚动/RAII提交/作用域计时)
|
||||
* @描述:
|
||||
* 该实现文件主要包含 4 个关键点:
|
||||
* 1) FileSink: 文件打开、写入、flush 与按阈值滚动
|
||||
* 2) SxLogger: shouldLog 过滤、formatPrefix 前缀拼接、logLine 统一输出出口
|
||||
* 3) SxLogLine: 析构提交(RAII)确保“一条语句输出一整行”
|
||||
* 4) SxLogScope: 按需启用计时,析构输出耗时
|
||||
*
|
||||
* @实现难点提示:
|
||||
* - shouldLog 必须“零副作用”,否则宏短路会带来不可预测行为
|
||||
* - logLine 是统一出口,必须保证行级一致性,且避免在持锁状态下递归打日志
|
||||
* - 文件滚动要处理文件名安全性与跨平台 rename 行为差异
|
||||
* - 时间戳生成需要兼容 Windows 与 POSIX(localtime_s/localtime_r)
|
||||
********************************************************************************/
|
||||
|
||||
namespace StellarX
|
||||
{
|
||||
// -------- FileSink --------
|
||||
|
||||
// 打开文件输出
|
||||
// 难点:
|
||||
// - 需要支持追加与清空两种模式
|
||||
// - open 前先 close,避免重复打开导致句柄泄漏
|
||||
bool FileSink::open(const std::string& path, bool append)
|
||||
{
|
||||
close();
|
||||
filePath = path;
|
||||
appendMode = append;
|
||||
|
||||
std::ios::openmode mode = std::ios::out;
|
||||
mode |= (append ? std::ios::app : std::ios::trunc);
|
||||
|
||||
ofs.open(path.c_str(), mode);
|
||||
return ofs.is_open();
|
||||
}
|
||||
|
||||
// 关闭文件输出(可重复调用)
|
||||
void FileSink::close()
|
||||
{
|
||||
if (ofs.is_open()) ofs.close();
|
||||
}
|
||||
|
||||
// 查询是否已打开
|
||||
bool FileSink::isOpen() const
|
||||
{
|
||||
return ofs.is_open();
|
||||
}
|
||||
|
||||
// 写入一整行
|
||||
// 难点:
|
||||
// - 写入后若启用 rotateBytes,需要及时检测文件大小是否到阈值
|
||||
void FileSink::writeLine(const std::string& line)
|
||||
{
|
||||
if (!ofs.is_open()) return;
|
||||
ofs << line;
|
||||
if (rotateBytes > 0) rotateIfNeeded();
|
||||
}
|
||||
|
||||
// flush 文件缓冲
|
||||
void FileSink::flush()
|
||||
{
|
||||
if (ofs.is_open()) ofs.flush();
|
||||
}
|
||||
|
||||
// 滚动文件
|
||||
// 难点:
|
||||
// 1) tellp() 返回的是当前写指针位置,通常可近似视为文件大小
|
||||
// 2) 时间戳用于文件名时需要做字符清洗,避免出现不友好字符
|
||||
// 3) rename 行为与权限/占用有关,失败时需要保证不崩溃(此处选择“尽力而为”)
|
||||
bool FileSink::rotateIfNeeded()
|
||||
{
|
||||
if (!ofs.is_open() || rotateBytes == 0) return false;
|
||||
|
||||
const std::streampos pos = ofs.tellp();
|
||||
if (pos < 0) return false;
|
||||
|
||||
const std::size_t size = static_cast<std::size_t>(pos);
|
||||
if (size < rotateBytes) return false;
|
||||
|
||||
ofs.flush();
|
||||
ofs.close();
|
||||
|
||||
// xxx.log -> xxx.log.YYYYmmdd_HHMMSS
|
||||
// 说明:
|
||||
// - makeTimestampLocal 形如 "2026-01-09 12:34:56"
|
||||
// - 文件名中把 '-' ' ' ':' 替换为 '_',只保留数字与 '_',降低环境差异
|
||||
const std::string ts = SxLogger::makeTimestampLocal();
|
||||
std::string safeTs;
|
||||
safeTs.reserve(ts.size());
|
||||
for (char ch : ts)
|
||||
{
|
||||
if (ch >= '0' && ch <= '9') safeTs.push_back(ch);
|
||||
else if (ch == '-' || ch == ' ' || ch == ':') safeTs.push_back('_');
|
||||
}
|
||||
if (safeTs.empty()) safeTs = "rotated";
|
||||
|
||||
// 毫秒和序号共同组成后缀,解决同一秒甚至同一毫秒内多次滚动的冲突。
|
||||
const auto now = std::chrono::system_clock::now();
|
||||
const auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch()).count() % 1000;
|
||||
std::ostringstream suffix;
|
||||
suffix << safeTs << "_" << std::setw(3) << std::setfill('0') << millis;
|
||||
|
||||
std::filesystem::path rotated;
|
||||
std::error_code ec;
|
||||
do
|
||||
{
|
||||
rotated = filePath + "." + suffix.str() + "." + std::to_string(rotateSequence++);
|
||||
ec.clear();
|
||||
} while (std::filesystem::exists(rotated, ec) && !ec);
|
||||
|
||||
ec.clear();
|
||||
std::filesystem::rename(filePath, rotated, ec);
|
||||
if (ec)
|
||||
{
|
||||
// 重命名失败时必须保留原文件,并以追加模式恢复写入,绝不能截断。
|
||||
ofs.open(filePath.c_str(), std::ios::out | std::ios::app);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有滚动文件已安全落盘后,才创建新的空日志文件。
|
||||
return open(filePath, false);
|
||||
}
|
||||
|
||||
// -------- SxLogger --------
|
||||
|
||||
|
||||
|
||||
// 设置 Windows 控制台 codepage(只执行一次)
|
||||
// 难点:
|
||||
// - 只影响终端解释输出字节的方式,不影响源码文件编码
|
||||
// - 使用 once_flag 避免重复 system 调用造成噪声与性能浪费
|
||||
//
|
||||
void SxLogger::setGBK()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
static std::once_flag once;
|
||||
std::call_once(once, []() {
|
||||
// 切到chcp 936(GBK),避免中文日志在 CP936 控制台下乱码
|
||||
// 说明:这不是 WinAPI;是执行系统命令
|
||||
std::system("chcp 936 >nul");
|
||||
|
||||
// 补充说明:
|
||||
// - chcp 936 实际是设置为 CP936(GBK)
|
||||
// - 如果你的终端本身是 UTF-8 环境,调用它可能反而改变显示行为
|
||||
// - 该函数建议只在“明确需要 GBK 控制台输出”的场景调用
|
||||
|
||||
// 尝试让 C/C++ 运行库按 UTF-8 工作(对部分流输出有帮助)
|
||||
// std::setlocale(LC_ALL, ".UTF8");
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
// 获取单例
|
||||
// 难点:
|
||||
// - 作为全局入口,初始化必须线程安全
|
||||
// - C++11 起函数内静态对象初始化由标准保证线程安全
|
||||
SxLogger& SxLogger::Get()
|
||||
{
|
||||
static SxLogger inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// 构造:设置默认语言
|
||||
SxLogger::SxLogger()
|
||||
: lang(SxLogLanguage::ZhCN)
|
||||
{
|
||||
}
|
||||
|
||||
// 设置最低输出级别
|
||||
void SxLogger::setMinLevel(SxLogLevel level)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
cfg.minLevel = level;
|
||||
}
|
||||
|
||||
// 获取最低输出级别
|
||||
SxLogLevel SxLogger::getMinLevel() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
return cfg.minLevel;
|
||||
}
|
||||
|
||||
// 设置语言
|
||||
// 难点:
|
||||
// - 语言只影响 SX_T 的字符串选择
|
||||
// - 这里用 atomic relaxed,避免频繁加锁
|
||||
void SxLogger::setLanguage(SxLogLanguage l)
|
||||
{
|
||||
lang.store(l, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// 获取语言
|
||||
SxLogLanguage SxLogger::getLanguage() const
|
||||
{
|
||||
return lang.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// 设置 Tag 过滤
|
||||
// 难点:
|
||||
// - 当前实现是 vector<string> 线性匹配,适合 tag 数量不大
|
||||
// - 若未来 tag 很多,可考虑 unordered_set 优化(但会增加依赖与复杂度)
|
||||
void SxLogger::setTagFilter(SxTagFilterMode mode, const std::vector<std::string>& tags)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
cfg.tagFilterMode = mode;
|
||||
cfg.tagList = tags;
|
||||
}
|
||||
|
||||
// 清空 Tag 过滤
|
||||
void SxLogger::clearTagFilter()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
cfg.tagFilterMode = SxTagFilterMode::None;
|
||||
cfg.tagList.clear();
|
||||
}
|
||||
|
||||
// 开关控制台输出
|
||||
// 难点:
|
||||
// - ConsoleSink 持有 ostream 引用,不管理其生命周期
|
||||
void SxLogger::enableConsole(bool enable)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
if (enable)
|
||||
{
|
||||
if (!consoleSink) consoleSink.reset(new ConsoleSink(std::cout));
|
||||
}
|
||||
else
|
||||
{
|
||||
consoleSink.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// 开启文件输出
|
||||
// 难点:
|
||||
// - enableFile 成功与否决定 cfg.fileEnabled
|
||||
// - 需要把 rotateBytes 同步到 FileSink
|
||||
bool SxLogger::enableFile(const std::string& path, bool append, std::size_t rotateBytes_)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
|
||||
if (fileSink) fileSink->flush();
|
||||
auto candidate = std::make_unique<FileSink>();
|
||||
candidate->setRotateBytes(rotateBytes_);
|
||||
if (!candidate->open(path, append))
|
||||
return false;
|
||||
fileSink = std::move(candidate);
|
||||
cfg.fileEnabled = true;
|
||||
cfg.filePath = path;
|
||||
cfg.fileAppend = append;
|
||||
cfg.rotateBytes = rotateBytes_;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 关闭文件输出
|
||||
void SxLogger::disableFile()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
if (fileSink) fileSink->close();
|
||||
fileSink.reset();
|
||||
cfg.fileEnabled = false;
|
||||
}
|
||||
|
||||
// 获取配置副本
|
||||
// 难点:
|
||||
// - 返回副本避免外部拿到内部引用后绕过锁修改
|
||||
SxLogConfig SxLogger::getConfigCopy() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// 设置配置(整体替换)
|
||||
bool SxLogger::setConfig(const SxLogConfig& c)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
std::unique_ptr<FileSink> candidate;
|
||||
if (c.fileEnabled)
|
||||
{
|
||||
// 先创建候选 sink;任意打开失败都直接返回,保留旧 sink 和旧配置。
|
||||
if (c.filePath.empty())
|
||||
return false;
|
||||
if (fileSink) fileSink->flush();
|
||||
candidate = std::make_unique<FileSink>();
|
||||
candidate->setRotateBytes(c.rotateBytes);
|
||||
if (!candidate->open(c.filePath, c.fileAppend))
|
||||
return false;
|
||||
}
|
||||
|
||||
// 候选 sink 已经可用后才关闭旧 sink,确保配置切换不会出现短暂的无输出状态。
|
||||
if (fileSink)
|
||||
fileSink->close();
|
||||
fileSink = std::move(candidate);
|
||||
cfg = c;
|
||||
cfg.fileEnabled = (fileSink != nullptr);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 级别转字符串
|
||||
const char* SxLogger::levelToString(SxLogLevel level)
|
||||
{
|
||||
switch (level)
|
||||
{
|
||||
case SxLogLevel::Trace: return "TRACE";
|
||||
case SxLogLevel::Debug: return "DEBUG";
|
||||
case SxLogLevel::Info: return "INFO ";
|
||||
case SxLogLevel::Warn: return "WARN ";
|
||||
case SxLogLevel::Error: return "ERROR";
|
||||
case SxLogLevel::Fatal: return "FATAL";
|
||||
default: return "OFF ";
|
||||
}
|
||||
}
|
||||
|
||||
// 判断 tag 是否允许输出
|
||||
// 难点:
|
||||
// - 精确匹配 tag 字符串
|
||||
// - tag==nullptr 时默认允许,避免“无 tag 日志被误杀”
|
||||
bool SxLogger::tagAllowed(const SxLogConfig& c, const char* tag)
|
||||
{
|
||||
if (c.tagFilterMode == SxTagFilterMode::None) return true;
|
||||
if (!tag) return true;
|
||||
|
||||
bool found = false;
|
||||
for (const auto& t : c.tagList)
|
||||
{
|
||||
if (t == tag) { found = true; break; }
|
||||
}
|
||||
|
||||
if (c.tagFilterMode == SxTagFilterMode::Whitelist) return found;
|
||||
if (c.tagFilterMode == SxTagFilterMode::Blacklist) return !found;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 快速判定是否需要输出(宏短路依赖)
|
||||
// 难点:
|
||||
// 1) 必须无副作用:返回 false 时调用端不会构造对象也不会拼接
|
||||
// 2) 过滤维度要完整:级别、tag、sink 是否启用
|
||||
// 3) 当前实现加锁保证 cfg 与 sink 状态一致;代价是高频路径会有锁开销
|
||||
bool SxLogger::shouldLog(SxLogLevel level, const char* tag) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
if (cfg.minLevel == SxLogLevel::Off) return false;
|
||||
if (level < cfg.minLevel) return false;
|
||||
if (!tagAllowed(cfg, tag)) return false;
|
||||
if (!consoleSink && !cfg.fileEnabled) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 生成本地时间戳字符串
|
||||
// 难点:
|
||||
// - Windows 与 POSIX 的线程安全 localtime API 不同
|
||||
std::string SxLogger::makeTimestampLocal()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const auto now = system_clock::now();
|
||||
const std::time_t t = system_clock::to_time_t(now);
|
||||
|
||||
std::tm tmv{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tmv, &t);
|
||||
#else
|
||||
localtime_r(&t, &tmv);
|
||||
#endif
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tmv, "%Y-%m-%d %H:%M:%S");
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
// 拼接日志前缀(调用方已持锁)
|
||||
// 难点:
|
||||
// - 前缀拼接必须与配置项严格对应,且尽量避免多余开销
|
||||
// - showSource 会输出 (file:line func),对定位时序问题很有价值
|
||||
std::string SxLogger::formatPrefixUnlocked(
|
||||
const SxLogConfig& c,
|
||||
SxLogLevel level,
|
||||
const char* tag,
|
||||
const char* file,
|
||||
int line,
|
||||
const char* func) const
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
if (c.showTimestamp) oss << "[" << makeTimestampLocal() << "] ";
|
||||
if (c.showLevel) oss << "[" << levelToString(level) << "] ";
|
||||
if (c.showTag && tag) oss << "[" << tag << "] ";
|
||||
|
||||
if (c.showThreadId)
|
||||
{
|
||||
oss << "[T:" << std::this_thread::get_id() << "] ";
|
||||
}
|
||||
|
||||
if (c.showSource && file && func)
|
||||
{
|
||||
oss << "(" << file << ":" << line << " " << func << ") ";
|
||||
}
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
// 统一输出出口
|
||||
// 难点:
|
||||
// 1) 行级一致性:必须把 prefix + msg + "\n" 当作整体写入
|
||||
// 2) 线程安全:持锁写入可避免不同线程日志互相穿插
|
||||
// 3) 避免重入:在持锁期间不要再调用 SX_LOG...(会导致死锁)
|
||||
void SxLogger::logLine(
|
||||
SxLogLevel level,
|
||||
const char* tag,
|
||||
const char* file,
|
||||
int line,
|
||||
const char* func,
|
||||
const std::string& msg)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
|
||||
if (cfg.minLevel == SxLogLevel::Off) return;
|
||||
if (level < cfg.minLevel) return;
|
||||
if (!tagAllowed(cfg, tag)) return;
|
||||
|
||||
const std::string prefix = formatPrefixUnlocked(cfg, level, tag, file, line, func);
|
||||
const std::string lineText = prefix + msg + "\n";
|
||||
|
||||
if (consoleSink) consoleSink->writeLine(lineText);
|
||||
|
||||
if (cfg.fileEnabled && fileSink && fileSink->isOpen())
|
||||
{
|
||||
fileSink->writeLine(lineText);
|
||||
}
|
||||
|
||||
if (cfg.autoFlush)
|
||||
{
|
||||
if (consoleSink) consoleSink->flush();
|
||||
if (cfg.fileEnabled && fileSink) fileSink->flush();
|
||||
}
|
||||
}
|
||||
|
||||
// -------- SxLogLine --------
|
||||
|
||||
// 构造:只记录元信息
|
||||
SxLogLine::SxLogLine(SxLogLevel level, const char* tag, const char* file, int line, const char* func)
|
||||
: lvl(level), tg(tag), srcFile(file), srcLine(line), srcFunc(func)
|
||||
{
|
||||
}
|
||||
|
||||
// 析构:提交输出
|
||||
// 难点:
|
||||
// - 这是 RAII 设计的核心:保证语句结束时日志自动落地
|
||||
// - 也要求调用端不要把临时对象跨语句保存(宏用法本身也不支持那样做)
|
||||
SxLogLine::~SxLogLine()
|
||||
{
|
||||
SxLogger::Get().logLine(lvl, tg, srcFile, srcLine, srcFunc, ss.str());
|
||||
}
|
||||
|
||||
// -------- SxLogScope --------
|
||||
|
||||
// 构造:按需启用计时
|
||||
// 难点:
|
||||
// - 只有 shouldLog 为 true 才记录起点,避免在未输出场景做无意义计时
|
||||
SxLogScope::SxLogScope(SxLogLevel level, const char* tag, const char* file, int line, const char* func, const char* name)
|
||||
: lvl(level), tg(tag), srcFile(file), srcLine(line), srcFunc(func), scopeName(name)
|
||||
{
|
||||
enabled = SxLogger::Get().shouldLog(lvl, tg);
|
||||
if (enabled) t0 = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
// 析构:输出耗时
|
||||
// 难点:
|
||||
// - steady_clock 用于衡量耗时,避免系统时间调整造成跳变
|
||||
SxLogScope::~SxLogScope()
|
||||
{
|
||||
if (!enabled) return;
|
||||
const auto t1 = std::chrono::steady_clock::now();
|
||||
const auto us = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << "SCOPE " << (scopeName ? scopeName : "") << " cost=" << us << "us";
|
||||
SxLogger::Get().logLine(lvl, tg, srcFile, srcLine, srcFunc, oss.str());
|
||||
}
|
||||
|
||||
} // namespace StellarX
|
||||
@@ -0,0 +1,975 @@
|
||||
#include "SxNativeInputInternal.h"
|
||||
|
||||
#include <imm.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cwchar>
|
||||
#include <new>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
#pragma comment(lib, "imm32.lib")
|
||||
|
||||
namespace StellarX::Internal
|
||||
{
|
||||
namespace
|
||||
{
|
||||
std::atomic<std::uint64_t> nextNativeInputSequence{ 0 };
|
||||
|
||||
static_assert(std::is_nothrow_move_constructible_v<TextInputEvent>,
|
||||
"TextInputEvent must transfer its wstring without allocating during queue publication.");
|
||||
static_assert(std::is_nothrow_move_constructible_v<CompositionEvent>,
|
||||
"CompositionEvent must transfer its wstring without allocating during queue publication.");
|
||||
static_assert(std::is_nothrow_move_constructible_v<NativeInputEvent>,
|
||||
"NativeInputEvent must be nothrow move constructible.");
|
||||
static_assert(std::is_nothrow_move_assignable_v<NativeInputEvent>,
|
||||
"NativeInputEvent must be nothrow move assignable.");
|
||||
static_assert(sizeof(wchar_t) == 2, "The Windows input path requires UTF-16 wchar_t.");
|
||||
|
||||
bool isHighSurrogate(wchar_t value) noexcept
|
||||
{
|
||||
return value >= 0xD800 && value <= 0xDBFF;
|
||||
}
|
||||
|
||||
bool isLowSurrogate(wchar_t value) noexcept
|
||||
{
|
||||
return value >= 0xDC00 && value <= 0xDFFF;
|
||||
}
|
||||
|
||||
bool isValidUtf16(const std::wstring& text) noexcept
|
||||
{
|
||||
for (std::size_t index = 0; index < text.size(); ++index)
|
||||
{
|
||||
const wchar_t current = text[index];
|
||||
if (isHighSurrogate(current))
|
||||
{
|
||||
if (index + 1 >= text.size() || !isLowSurrogate(text[index + 1]))
|
||||
return false;
|
||||
++index;
|
||||
}
|
||||
else if (isLowSurrogate(current))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
UINT codePageForLayout(HKL layout) noexcept
|
||||
{
|
||||
if (layout)
|
||||
{
|
||||
wchar_t buffer[16]{};
|
||||
const LANGID language = LOWORD(reinterpret_cast<ULONG_PTR>(layout));
|
||||
const LCID locale = MAKELCID(language, SORT_DEFAULT);
|
||||
if (GetLocaleInfoW(locale, LOCALE_IDEFAULTANSICODEPAGE,
|
||||
buffer, static_cast<int>(std::size(buffer))) > 0)
|
||||
{
|
||||
wchar_t* end = nullptr;
|
||||
const unsigned long value = std::wcstoul(buffer, &end, 10);
|
||||
if (end != buffer && value > 0 && value <= (std::numeric_limits<UINT>::max)())
|
||||
return static_cast<UINT>(value);
|
||||
}
|
||||
}
|
||||
return GetACP();
|
||||
}
|
||||
|
||||
KeyModifierSnapshot captureKeyModifiers() noexcept
|
||||
{
|
||||
auto isDown = [](int virtualKey) noexcept
|
||||
{
|
||||
return (GetKeyState(virtualKey) & 0x8000) != 0;
|
||||
};
|
||||
|
||||
KeyModifierSnapshot snapshot;
|
||||
snapshot.leftControl = isDown(VK_LCONTROL);
|
||||
snapshot.rightControl = isDown(VK_RCONTROL);
|
||||
snapshot.leftShift = isDown(VK_LSHIFT);
|
||||
snapshot.rightShift = isDown(VK_RSHIFT);
|
||||
snapshot.leftAlt = isDown(VK_LMENU);
|
||||
snapshot.rightAlt = isDown(VK_RMENU);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
UINT physicalVirtualKey(UINT virtualKey, UINT scanCode, bool extended) noexcept
|
||||
{
|
||||
switch (virtualKey)
|
||||
{
|
||||
case VK_SHIFT:
|
||||
return scanCode == 0x36 ? VK_RSHIFT : VK_LSHIFT;
|
||||
case VK_CONTROL:
|
||||
return extended ? VK_RCONTROL : VK_LCONTROL;
|
||||
case VK_MENU:
|
||||
return extended ? VK_RMENU : VK_LMENU;
|
||||
default:
|
||||
return virtualKey;
|
||||
}
|
||||
}
|
||||
|
||||
bool queryCompositionString(HIMC context, DWORD index, std::wstring& text)
|
||||
{
|
||||
const LONG byteCount = ImmGetCompositionStringW(context, index, nullptr, 0);
|
||||
if (byteCount < 0 || (byteCount % static_cast<LONG>(sizeof(wchar_t))) != 0)
|
||||
return false;
|
||||
|
||||
const std::size_t unitCount = static_cast<std::size_t>(byteCount) / sizeof(wchar_t);
|
||||
text.assign(unitCount, L'\0');
|
||||
if (byteCount == 0)
|
||||
return true;
|
||||
|
||||
const LONG copied = ImmGetCompositionStringW(context, index, text.data(), byteCount);
|
||||
return copied == byteCount;
|
||||
}
|
||||
|
||||
bool convertAnsiStrict(UINT codePage, const unsigned char* bytes,
|
||||
std::size_t byteCount, std::wstring& text)
|
||||
{
|
||||
if (!bytes || byteCount == 0 || byteCount > 2)
|
||||
return false;
|
||||
|
||||
wchar_t buffer[2]{};
|
||||
const int converted = MultiByteToWideChar(codePage, MB_ERR_INVALID_CHARS,
|
||||
reinterpret_cast<const char*>(bytes), static_cast<int>(byteCount),
|
||||
buffer, static_cast<int>(std::size(buffer)));
|
||||
if (converted <= 0)
|
||||
return false;
|
||||
text.assign(buffer, buffer + converted);
|
||||
return isValidUtf16(text);
|
||||
}
|
||||
}
|
||||
|
||||
NativeInputQueuePushResult NativeInputQueue::push(NativeInputEvent&& event) noexcept
|
||||
{
|
||||
const NativeInputQueuePushResult forced = forcedPushResult_.exchange(
|
||||
NativeInputQueuePushResult::Queued, std::memory_order_acq_rel);
|
||||
if (forced != NativeInputQueuePushResult::Queued)
|
||||
return forced;
|
||||
|
||||
try
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
events_.emplace_back(std::move(event));
|
||||
++pushed_;
|
||||
maximumDepth_ = (std::max)(maximumDepth_, events_.size());
|
||||
return NativeInputQueuePushResult::Queued;
|
||||
}
|
||||
catch (const std::bad_alloc&)
|
||||
{
|
||||
return NativeInputQueuePushResult::AllocationFailure;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return NativeInputQueuePushResult::UnexpectedFailure;
|
||||
}
|
||||
}
|
||||
|
||||
NativeInputQueuePopResult NativeInputQueue::tryPop(NativeInputEvent& event) noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (events_.empty())
|
||||
return NativeInputQueuePopResult::Empty;
|
||||
event = std::move(events_.front());
|
||||
events_.pop_front();
|
||||
++popped_;
|
||||
return NativeInputQueuePopResult::Popped;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return NativeInputQueuePopResult::UnexpectedFailure;
|
||||
}
|
||||
}
|
||||
|
||||
NativeInputQueueStats NativeInputQueue::stats() const noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
NativeInputQueueStats snapshot;
|
||||
snapshot.pushed = pushed_;
|
||||
snapshot.popped = popped_;
|
||||
snapshot.currentDepth = events_.size();
|
||||
snapshot.maximumDepth = maximumDepth_;
|
||||
return snapshot;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void NativeInputQueue::forceNextPushResultForTesting(NativeInputQueuePushResult result) noexcept
|
||||
{
|
||||
forcedPushResult_.store(result, std::memory_order_release);
|
||||
}
|
||||
|
||||
KeyEvent makeKeyEvent(UINT message, WPARAM wParam, LPARAM lParam,
|
||||
const KeyModifierSnapshot& modifierSnapshot, bool imeLifecycleActive) noexcept
|
||||
{
|
||||
const auto raw = static_cast<std::uint32_t>(static_cast<ULONG_PTR>(lParam));
|
||||
KeyEvent event;
|
||||
event.action = message == WM_KEYUP || message == WM_SYSKEYUP
|
||||
? KeyAction::Up : KeyAction::Down;
|
||||
event.virtualKey = static_cast<UINT>(wParam);
|
||||
event.repeatCount = static_cast<std::uint16_t>(raw & 0xFFFFu);
|
||||
event.scanCode = (raw >> 16) & 0xFFu;
|
||||
event.extended = (raw & (1u << 24)) != 0;
|
||||
event.altContext = (raw & (1u << 29)) != 0;
|
||||
event.wasDown = (raw & (1u << 30)) != 0;
|
||||
event.systemKey = message == WM_SYSKEYDOWN || message == WM_SYSKEYUP;
|
||||
event.imeComposingAtCapture = imeLifecycleActive;
|
||||
event.physicalVirtualKey = physicalVirtualKey(event.virtualKey, event.scanCode, event.extended);
|
||||
event.modifiers.leftControl = modifierSnapshot.leftControl;
|
||||
event.modifiers.rightControl = modifierSnapshot.rightControl;
|
||||
event.modifiers.leftShift = modifierSnapshot.leftShift;
|
||||
event.modifiers.rightShift = modifierSnapshot.rightShift;
|
||||
event.modifiers.leftAlt = modifierSnapshot.leftAlt;
|
||||
event.modifiers.rightAlt = modifierSnapshot.rightAlt;
|
||||
return event;
|
||||
}
|
||||
|
||||
struct NativeInputContext::Impl
|
||||
{
|
||||
enum class EchoDisposition : std::uint8_t
|
||||
{
|
||||
NotEcho,
|
||||
Suppress
|
||||
};
|
||||
|
||||
struct EchoState
|
||||
{
|
||||
bool active = false;
|
||||
bool windowUnicode = false;
|
||||
std::uint64_t compositionId = 0;
|
||||
std::wstring utf16;
|
||||
std::string ansi;
|
||||
std::size_t imeCharMatched = 0;
|
||||
std::size_t charMatched = 0;
|
||||
};
|
||||
|
||||
explicit Impl(HWND window)
|
||||
: hwnd(window), windowUnicode(window && IsWindowUnicode(window) != FALSE)
|
||||
{
|
||||
DWORD processId = 0;
|
||||
windowThreadId = window ? GetWindowThreadProcessId(window, &processId) : GetCurrentThreadId();
|
||||
keyboardLayout = GetKeyboardLayout(windowThreadId);
|
||||
ansiCodePage = codePageForLayout(keyboardLayout);
|
||||
}
|
||||
|
||||
HWND hwnd = nullptr;
|
||||
bool windowUnicode = false;
|
||||
DWORD windowThreadId = 0;
|
||||
HKL keyboardLayout = nullptr;
|
||||
UINT ansiCodePage = CP_ACP;
|
||||
std::optional<unsigned char> pendingLead;
|
||||
std::optional<wchar_t> pendingHighSurrogate;
|
||||
|
||||
bool imeLifecycleActive = false;
|
||||
bool compositionCommitted = false;
|
||||
bool compositionCancelled = false;
|
||||
std::uint64_t nextCompositionId = 0;
|
||||
std::uint64_t activeCompositionId = 0;
|
||||
EchoState echo;
|
||||
|
||||
NativeInputQueue queue;
|
||||
NativeInputObservationStats observations;
|
||||
|
||||
std::atomic<bool> faultClaimed{ false };
|
||||
std::atomic<bool> faultReady{ false };
|
||||
NativeInputFaultSnapshot faultSnapshot{};
|
||||
std::atomic<std::uint64_t> rejectedTextSequences{ 0 };
|
||||
std::atomic<std::uint64_t> imeQueryFailures{ 0 };
|
||||
std::atomic<std::uint64_t> discardedDecoderFragments{ 0 };
|
||||
|
||||
bool hasFault() const noexcept
|
||||
{
|
||||
return faultReady.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
void publishFault(NativeInputFaultCode code, std::uint64_t sequence,
|
||||
UINT message, HWND window) noexcept
|
||||
{
|
||||
bool expected = false;
|
||||
if (!faultClaimed.compare_exchange_strong(expected, true,
|
||||
std::memory_order_acq_rel, std::memory_order_acquire))
|
||||
return;
|
||||
|
||||
faultSnapshot.code = code;
|
||||
faultSnapshot.sequence = sequence;
|
||||
faultSnapshot.message = message;
|
||||
faultSnapshot.hwnd = window;
|
||||
faultReady.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void clearTextDecoderState() noexcept
|
||||
{
|
||||
if (pendingLead || pendingHighSurrogate)
|
||||
discardedDecoderFragments.fetch_add(1, std::memory_order_relaxed);
|
||||
pendingLead.reset();
|
||||
pendingHighSurrogate.reset();
|
||||
}
|
||||
|
||||
void clearEcho() noexcept
|
||||
{
|
||||
echo = {};
|
||||
}
|
||||
|
||||
void snapshotKeyboardLayout(HKL layout) noexcept
|
||||
{
|
||||
const HKL actual = layout ? layout : GetKeyboardLayout(0);
|
||||
const UINT actualCodePage = codePageForLayout(actual);
|
||||
if (actual != keyboardLayout || actualCodePage != ansiCodePage)
|
||||
{
|
||||
if (pendingLead)
|
||||
discardedDecoderFragments.fetch_add(1, std::memory_order_relaxed);
|
||||
pendingLead.reset();
|
||||
keyboardLayout = actual;
|
||||
ansiCodePage = actualCodePage;
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t allocateCompositionId() noexcept
|
||||
{
|
||||
do
|
||||
{
|
||||
++nextCompositionId;
|
||||
} while (nextCompositionId == 0);
|
||||
activeCompositionId = nextCompositionId;
|
||||
return activeCompositionId;
|
||||
}
|
||||
|
||||
template <typename Payload>
|
||||
bool enqueue(HWND window, UINT message, Payload&& payload) noexcept
|
||||
{
|
||||
if (hasFault())
|
||||
return false;
|
||||
|
||||
NativeInputEvent event;
|
||||
event.sequence = nextNativeInputSequence.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
event.messageTime = static_cast<DWORD>(GetMessageTime());
|
||||
event.hwnd = window;
|
||||
event.payload = std::forward<Payload>(payload);
|
||||
const std::uint64_t sequence = event.sequence;
|
||||
const NativeInputQueuePushResult result = queue.push(std::move(event));
|
||||
if (result == NativeInputQueuePushResult::Queued)
|
||||
return true;
|
||||
|
||||
publishFault(result == NativeInputQueuePushResult::AllocationFailure
|
||||
? NativeInputFaultCode::OutOfMemory : NativeInputFaultCode::QueueFailure,
|
||||
sequence, message, window);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ensureComposition(HWND window, UINT message)
|
||||
{
|
||||
if (imeLifecycleActive)
|
||||
return true;
|
||||
|
||||
imeLifecycleActive = true;
|
||||
compositionCommitted = false;
|
||||
compositionCancelled = false;
|
||||
allocateCompositionId();
|
||||
CompositionEvent start;
|
||||
start.stage = CompositionStage::Start;
|
||||
start.compositionId = activeCompositionId;
|
||||
return enqueue(window, message, std::move(start));
|
||||
}
|
||||
|
||||
void beginEcho(const std::wstring& text, std::uint64_t compositionId)
|
||||
{
|
||||
clearEcho();
|
||||
if (text.empty())
|
||||
return;
|
||||
|
||||
echo.active = true;
|
||||
echo.windowUnicode = windowUnicode;
|
||||
echo.compositionId = compositionId;
|
||||
echo.utf16 = text;
|
||||
if (windowUnicode)
|
||||
return;
|
||||
|
||||
const bool supportsDefaultTracking = ansiCodePage != CP_UTF7 && ansiCodePage != CP_UTF8;
|
||||
const DWORD conversionFlags = supportsDefaultTracking ? WC_NO_BEST_FIT_CHARS : 0;
|
||||
BOOL usedDefault = FALSE;
|
||||
BOOL* usedDefaultPointer = supportsDefaultTracking ? &usedDefault : nullptr;
|
||||
const int required = WideCharToMultiByte(ansiCodePage, conversionFlags,
|
||||
text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, usedDefaultPointer);
|
||||
if (required <= 0)
|
||||
{
|
||||
// Without a deterministic expected byte sequence there is no safe
|
||||
// content-based suppression rule. End correlation on the first echo.
|
||||
echo.ansi.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
echo.ansi.assign(static_cast<std::size_t>(required), '\0');
|
||||
usedDefault = FALSE;
|
||||
const int converted = WideCharToMultiByte(ansiCodePage, conversionFlags,
|
||||
text.data(), static_cast<int>(text.size()), echo.ansi.data(), required,
|
||||
nullptr, usedDefaultPointer);
|
||||
if (converted != required)
|
||||
echo.ansi.clear();
|
||||
}
|
||||
|
||||
bool matchBytes(const unsigned char* bytes, std::size_t count,
|
||||
std::size_t& matched) const noexcept
|
||||
{
|
||||
if (matched + count > echo.ansi.size())
|
||||
return false;
|
||||
for (std::size_t index = 0; index < count; ++index)
|
||||
{
|
||||
if (static_cast<unsigned char>(echo.ansi[matched + index]) != bytes[index])
|
||||
return false;
|
||||
}
|
||||
matched += count;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool matchUtf16(wchar_t value, std::size_t& matched) const noexcept
|
||||
{
|
||||
if (matched >= echo.utf16.size() || echo.utf16[matched] != value)
|
||||
return false;
|
||||
++matched;
|
||||
return true;
|
||||
}
|
||||
|
||||
EchoDisposition correlateEcho(UINT message, WPARAM wParam) noexcept
|
||||
{
|
||||
if (!echo.active || (message != WM_IME_CHAR && message != WM_CHAR))
|
||||
return EchoDisposition::NotEcho;
|
||||
|
||||
std::size_t& matched = message == WM_IME_CHAR
|
||||
? echo.imeCharMatched : echo.charMatched;
|
||||
bool matches = false;
|
||||
if (echo.windowUnicode)
|
||||
{
|
||||
matches = matchUtf16(static_cast<wchar_t>(wParam), matched);
|
||||
}
|
||||
else if (message == WM_IME_CHAR)
|
||||
{
|
||||
unsigned char bytes[2]{};
|
||||
std::size_t count = 1;
|
||||
if ((wParam & 0xFF00u) != 0)
|
||||
{
|
||||
bytes[0] = static_cast<unsigned char>((wParam >> 8) & 0xFFu);
|
||||
bytes[1] = static_cast<unsigned char>(wParam & 0xFFu);
|
||||
count = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
bytes[0] = static_cast<unsigned char>(wParam & 0xFFu);
|
||||
}
|
||||
matches = matchBytes(bytes, count, matched);
|
||||
}
|
||||
else
|
||||
{
|
||||
const unsigned char byte = static_cast<unsigned char>(wParam & 0xFFu);
|
||||
matches = matchBytes(&byte, 1, matched);
|
||||
}
|
||||
|
||||
if (matches)
|
||||
return EchoDisposition::Suppress;
|
||||
|
||||
// Only an adjacent, unit-for-unit match is an echo. End correlation on
|
||||
// mismatch so later equal user input is never removed by content alone.
|
||||
clearEcho();
|
||||
return EchoDisposition::NotEcho;
|
||||
}
|
||||
|
||||
std::optional<std::wstring> decodeUnicodeUnit(wchar_t value)
|
||||
{
|
||||
if (pendingHighSurrogate)
|
||||
{
|
||||
const wchar_t high = *pendingHighSurrogate;
|
||||
pendingHighSurrogate.reset();
|
||||
if (isLowSurrogate(value))
|
||||
{
|
||||
std::wstring text;
|
||||
text.push_back(high);
|
||||
text.push_back(value);
|
||||
return text;
|
||||
}
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
if (isHighSurrogate(value))
|
||||
{
|
||||
pendingHighSurrogate = value;
|
||||
return std::nullopt;
|
||||
}
|
||||
if (isLowSurrogate(value))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::wstring(1, value);
|
||||
}
|
||||
|
||||
std::optional<std::wstring> decodeAnsiChar(unsigned char byte)
|
||||
{
|
||||
if (pendingLead)
|
||||
{
|
||||
const unsigned char bytes[2]{ *pendingLead, byte };
|
||||
pendingLead.reset();
|
||||
std::wstring text;
|
||||
if (!convertAnsiStrict(ansiCodePage, bytes, 2, text))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
return std::nullopt;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
if (IsDBCSLeadByteEx(ansiCodePage, byte) != FALSE)
|
||||
{
|
||||
pendingLead = byte;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::wstring text;
|
||||
if (!convertAnsiStrict(ansiCodePage, &byte, 1, text))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
return std::nullopt;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::optional<std::wstring> decodeImeChar(WPARAM wParam)
|
||||
{
|
||||
if (windowUnicode)
|
||||
return decodeUnicodeUnit(static_cast<wchar_t>(wParam));
|
||||
|
||||
unsigned char bytes[2]{};
|
||||
std::size_t count = 1;
|
||||
if ((wParam & 0xFF00u) != 0)
|
||||
{
|
||||
bytes[0] = static_cast<unsigned char>((wParam >> 8) & 0xFFu);
|
||||
bytes[1] = static_cast<unsigned char>(wParam & 0xFFu);
|
||||
count = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
bytes[0] = static_cast<unsigned char>(wParam & 0xFFu);
|
||||
}
|
||||
|
||||
std::wstring text;
|
||||
if (!convertAnsiStrict(ansiCodePage, bytes, count, text))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
return std::nullopt;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
bool collectKey(HWND window, UINT message, WPARAM wParam, LPARAM lParam) noexcept
|
||||
{
|
||||
if (echo.active)
|
||||
clearEcho();
|
||||
KeyEvent event = makeKeyEvent(message, wParam, lParam,
|
||||
captureKeyModifiers(), imeLifecycleActive);
|
||||
return enqueue(window, message, std::move(event));
|
||||
}
|
||||
|
||||
bool collectText(HWND window, UINT message, WPARAM wParam)
|
||||
{
|
||||
if (correlateEcho(message, wParam) == EchoDisposition::Suppress)
|
||||
return true;
|
||||
|
||||
std::optional<std::wstring> decoded;
|
||||
TextSource source = TextSource::AnsiCharFallback;
|
||||
if (message == WM_IME_CHAR)
|
||||
{
|
||||
decoded = decodeImeChar(wParam);
|
||||
source = windowUnicode ? TextSource::UnicodeChar : TextSource::AnsiCharFallback;
|
||||
}
|
||||
else if (windowUnicode)
|
||||
{
|
||||
decoded = decodeUnicodeUnit(static_cast<wchar_t>(wParam));
|
||||
source = TextSource::UnicodeChar;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (wParam > 0xFFu)
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
return true;
|
||||
}
|
||||
decoded = decodeAnsiChar(static_cast<unsigned char>(wParam & 0xFFu));
|
||||
}
|
||||
|
||||
if (!decoded || decoded->empty())
|
||||
return true;
|
||||
TextInputEvent event;
|
||||
event.text = std::move(*decoded);
|
||||
event.source = source;
|
||||
event.compositionId = message == WM_IME_CHAR ? activeCompositionId : 0;
|
||||
return enqueue(window, message, std::move(event));
|
||||
}
|
||||
|
||||
bool collectImeComposition(HWND window, LPARAM lParam)
|
||||
{
|
||||
if (!ensureComposition(window, WM_IME_COMPOSITION))
|
||||
return false;
|
||||
|
||||
const DWORD flags = static_cast<DWORD>(lParam);
|
||||
const bool hasCompositionString = (flags & GCS_COMPSTR) != 0;
|
||||
const bool hasResultString = (flags & GCS_RESULTSTR) != 0;
|
||||
const DWORD anyGcsMask = GCS_COMPREADSTR | GCS_COMPREADATTR | GCS_COMPREADCLAUSE |
|
||||
GCS_COMPSTR | GCS_COMPATTR | GCS_COMPCLAUSE | GCS_CURSORPOS | GCS_DELTASTART |
|
||||
GCS_RESULTREADSTR | GCS_RESULTREADCLAUSE | GCS_RESULTSTR | GCS_RESULTCLAUSE;
|
||||
|
||||
HIMC context = nullptr;
|
||||
if (hasCompositionString || hasResultString)
|
||||
context = ImmGetContext(window);
|
||||
|
||||
bool success = true;
|
||||
if ((hasCompositionString || hasResultString) && !context)
|
||||
imeQueryFailures.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
if (hasCompositionString && context)
|
||||
{
|
||||
std::wstring composition;
|
||||
if (!queryCompositionString(context, GCS_COMPSTR, composition))
|
||||
{
|
||||
imeQueryFailures.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
else if (!isValidUtf16(composition))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
else
|
||||
{
|
||||
CompositionEvent update;
|
||||
update.stage = CompositionStage::Update;
|
||||
update.compositionId = activeCompositionId;
|
||||
update.compositionText = std::move(composition);
|
||||
const LONG caret = ImmGetCompositionStringW(context, GCS_CURSORPOS, nullptr, 0);
|
||||
update.caretOffset = caret >= 0 ? static_cast<std::uint32_t>(caret) : 0;
|
||||
success = enqueue(window, WM_IME_COMPOSITION, std::move(update)) && success;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasResultString && context)
|
||||
{
|
||||
std::wstring result;
|
||||
if (!queryCompositionString(context, GCS_RESULTSTR, result))
|
||||
{
|
||||
imeQueryFailures.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
else if (!isValidUtf16(result))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
else
|
||||
{
|
||||
compositionCommitted = true;
|
||||
CompositionEvent commit;
|
||||
commit.stage = CompositionStage::Commit;
|
||||
commit.compositionId = activeCompositionId;
|
||||
success = enqueue(window, WM_IME_COMPOSITION, std::move(commit)) && success;
|
||||
beginEcho(result, activeCompositionId);
|
||||
if (!result.empty())
|
||||
{
|
||||
TextInputEvent text;
|
||||
text.text = std::move(result);
|
||||
text.source = TextSource::ImeResult;
|
||||
text.compositionId = activeCompositionId;
|
||||
success = enqueue(window, WM_IME_COMPOSITION, std::move(text)) && success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context)
|
||||
ImmReleaseContext(window, context);
|
||||
|
||||
if ((flags & anyGcsMask) == 0 && !compositionCommitted && !compositionCancelled)
|
||||
{
|
||||
compositionCancelled = true;
|
||||
CompositionEvent cancel;
|
||||
cancel.stage = CompositionStage::Cancel;
|
||||
cancel.compositionId = activeCompositionId;
|
||||
success = enqueue(window, WM_IME_COMPOSITION, std::move(cancel)) && success;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
NativeInputCollectResult collect(HWND window, UINT message, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
NativeInputCollectResult result;
|
||||
if (message == WM_UNICHAR && wParam == UNICODE_NOCHAR)
|
||||
{
|
||||
result.handledSynchronously = true;
|
||||
result.result = TRUE;
|
||||
return result;
|
||||
}
|
||||
if (hasFault())
|
||||
return result;
|
||||
|
||||
switch (message)
|
||||
{
|
||||
case WM_KEYDOWN:
|
||||
case WM_KEYUP:
|
||||
case WM_SYSKEYDOWN:
|
||||
case WM_SYSKEYUP:
|
||||
collectKey(window, message, wParam, lParam);
|
||||
break;
|
||||
|
||||
case WM_CHAR:
|
||||
case WM_IME_CHAR:
|
||||
collectText(window, message, wParam);
|
||||
break;
|
||||
|
||||
case WM_UNICHAR:
|
||||
{
|
||||
const std::uint32_t scalar = static_cast<std::uint32_t>(wParam);
|
||||
if (scalar > 0x10FFFFu || (scalar >= 0xD800u && scalar <= 0xDFFFu))
|
||||
{
|
||||
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
|
||||
break;
|
||||
}
|
||||
TextInputEvent text;
|
||||
if (scalar <= 0xFFFFu)
|
||||
{
|
||||
text.text.assign(1, static_cast<wchar_t>(scalar));
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::uint32_t value = scalar - 0x10000u;
|
||||
text.text.push_back(static_cast<wchar_t>(0xD800u + (value >> 10)));
|
||||
text.text.push_back(static_cast<wchar_t>(0xDC00u + (value & 0x3FFu)));
|
||||
}
|
||||
text.source = TextSource::UnicodeScalar;
|
||||
if (enqueue(window, message, std::move(text)))
|
||||
{
|
||||
result.handledSynchronously = true;
|
||||
result.result = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_IME_STARTCOMPOSITION:
|
||||
clearTextDecoderState();
|
||||
clearEcho();
|
||||
if (imeLifecycleActive)
|
||||
{
|
||||
if (!compositionCommitted && !compositionCancelled)
|
||||
{
|
||||
CompositionEvent cancel;
|
||||
cancel.stage = CompositionStage::Cancel;
|
||||
cancel.compositionId = activeCompositionId;
|
||||
enqueue(window, message, std::move(cancel));
|
||||
}
|
||||
CompositionEvent end;
|
||||
end.stage = CompositionStage::End;
|
||||
end.compositionId = activeCompositionId;
|
||||
enqueue(window, message, std::move(end));
|
||||
}
|
||||
imeLifecycleActive = false;
|
||||
ensureComposition(window, message);
|
||||
break;
|
||||
|
||||
case WM_IME_COMPOSITION:
|
||||
collectImeComposition(window, lParam);
|
||||
break;
|
||||
|
||||
case WM_IME_ENDCOMPOSITION:
|
||||
if (imeLifecycleActive)
|
||||
{
|
||||
if (!compositionCommitted && !compositionCancelled)
|
||||
{
|
||||
compositionCancelled = true;
|
||||
CompositionEvent cancel;
|
||||
cancel.stage = CompositionStage::Cancel;
|
||||
cancel.compositionId = activeCompositionId;
|
||||
enqueue(window, message, std::move(cancel));
|
||||
}
|
||||
CompositionEvent end;
|
||||
end.stage = CompositionStage::End;
|
||||
end.compositionId = activeCompositionId;
|
||||
enqueue(window, message, std::move(end));
|
||||
}
|
||||
imeLifecycleActive = false;
|
||||
compositionCommitted = false;
|
||||
compositionCancelled = false;
|
||||
activeCompositionId = 0;
|
||||
clearTextDecoderState();
|
||||
// Do not clear result correlation before forwarding END to the old
|
||||
// window procedure. Some IMEs generate nested or immediately-following
|
||||
// WM_IME_CHAR/WM_CHAR echoes while END is being processed. The next
|
||||
// physical key, composition, focus transition, language change, or a
|
||||
// mismatching character closes this short-lived echo window.
|
||||
break;
|
||||
|
||||
case WM_SETFOCUS:
|
||||
clearTextDecoderState();
|
||||
clearEcho();
|
||||
snapshotKeyboardLayout(nullptr);
|
||||
{
|
||||
WindowActivationEvent activation;
|
||||
activation.keyboardFocusActive = true;
|
||||
activation.otherWindow = reinterpret_cast<HWND>(wParam);
|
||||
enqueue(window, message, std::move(activation));
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_KILLFOCUS:
|
||||
clearTextDecoderState();
|
||||
clearEcho();
|
||||
{
|
||||
WindowActivationEvent activation;
|
||||
activation.keyboardFocusActive = false;
|
||||
activation.otherWindow = reinterpret_cast<HWND>(wParam);
|
||||
enqueue(window, message, std::move(activation));
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_INPUTLANGCHANGE:
|
||||
if (pendingLead)
|
||||
discardedDecoderFragments.fetch_add(1, std::memory_order_relaxed);
|
||||
pendingLead.reset();
|
||||
clearEcho();
|
||||
snapshotKeyboardLayout(reinterpret_cast<HKL>(lParam));
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
NativeInputContext::NativeInputContext(HWND hwnd)
|
||||
: impl_(std::make_unique<Impl>(hwnd))
|
||||
{
|
||||
}
|
||||
|
||||
NativeInputContext::~NativeInputContext() = default;
|
||||
|
||||
NativeInputCollectResult NativeInputContext::collect(
|
||||
HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept
|
||||
{
|
||||
if (!impl_)
|
||||
return {};
|
||||
try
|
||||
{
|
||||
return impl_->collect(hwnd, message, wParam, lParam);
|
||||
}
|
||||
catch (const std::bad_alloc&)
|
||||
{
|
||||
impl_->publishFault(NativeInputFaultCode::OutOfMemory, 0, message, hwnd);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
impl_->publishFault(NativeInputFaultCode::UnexpectedException, 0, message, hwnd);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::size_t NativeInputContext::drainObserved(std::size_t limit) noexcept
|
||||
{
|
||||
if (!impl_ || limit == 0)
|
||||
return 0;
|
||||
|
||||
std::size_t drained = 0;
|
||||
while (drained < limit)
|
||||
{
|
||||
NativeInputEvent event;
|
||||
const NativeInputQueuePopResult result = impl_->queue.tryPop(event);
|
||||
if (result == NativeInputQueuePopResult::Empty)
|
||||
break;
|
||||
if (result == NativeInputQueuePopResult::UnexpectedFailure)
|
||||
{
|
||||
impl_->publishFault(NativeInputFaultCode::QueueFailure, 0, 0, impl_->hwnd);
|
||||
break;
|
||||
}
|
||||
|
||||
if (impl_->observations.lastSequence != 0 &&
|
||||
event.sequence <= impl_->observations.lastSequence)
|
||||
{
|
||||
impl_->observations.sequenceStrict = false;
|
||||
impl_->publishFault(NativeInputFaultCode::SequenceViolation,
|
||||
event.sequence, 0, event.hwnd);
|
||||
break;
|
||||
}
|
||||
impl_->observations.lastSequence = event.sequence;
|
||||
++impl_->observations.observed;
|
||||
if (std::holds_alternative<KeyEvent>(event.payload))
|
||||
++impl_->observations.keyEvents;
|
||||
else if (std::holds_alternative<TextInputEvent>(event.payload))
|
||||
++impl_->observations.textEvents;
|
||||
else if (std::holds_alternative<CompositionEvent>(event.payload))
|
||||
++impl_->observations.compositionEvents;
|
||||
else if (std::holds_alternative<WindowActivationEvent>(event.payload))
|
||||
++impl_->observations.activationEvents;
|
||||
++drained;
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
|
||||
bool NativeInputContext::hasFault() const noexcept
|
||||
{
|
||||
return impl_ && impl_->hasFault();
|
||||
}
|
||||
|
||||
NativeInputFaultSnapshot NativeInputContext::fault() const noexcept
|
||||
{
|
||||
if (!impl_ || !impl_->faultReady.load(std::memory_order_acquire))
|
||||
return {};
|
||||
return impl_->faultSnapshot;
|
||||
}
|
||||
|
||||
NativeInputQueueStats NativeInputContext::queueStats() const noexcept
|
||||
{
|
||||
return impl_ ? impl_->queue.stats() : NativeInputQueueStats{};
|
||||
}
|
||||
|
||||
NativeInputObservationStats NativeInputContext::observationStats() const noexcept
|
||||
{
|
||||
return impl_ ? impl_->observations : NativeInputObservationStats{};
|
||||
}
|
||||
|
||||
NativeInputNormalizationStats NativeInputContext::normalizationStats() const noexcept
|
||||
{
|
||||
NativeInputNormalizationStats snapshot;
|
||||
if (!impl_)
|
||||
return snapshot;
|
||||
snapshot.rejectedTextSequences = impl_->rejectedTextSequences.load(std::memory_order_relaxed);
|
||||
snapshot.imeQueryFailures = impl_->imeQueryFailures.load(std::memory_order_relaxed);
|
||||
snapshot.discardedDecoderFragments = impl_->discardedDecoderFragments.load(std::memory_order_relaxed);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
bool NativeInputContext::tryPopForTesting(NativeInputEvent& event) noexcept
|
||||
{
|
||||
return impl_ && impl_->queue.tryPop(event) == NativeInputQueuePopResult::Popped;
|
||||
}
|
||||
|
||||
void NativeInputContext::forceNextQueuePushResultForTesting(
|
||||
NativeInputQueuePushResult result) noexcept
|
||||
{
|
||||
if (impl_)
|
||||
impl_->queue.forceNextPushResultForTesting(result);
|
||||
}
|
||||
|
||||
void NativeInputContext::setAnsiCodePageForTesting(UINT codePage) noexcept
|
||||
{
|
||||
if (impl_)
|
||||
{
|
||||
impl_->pendingLead.reset();
|
||||
impl_->ansiCodePage = codePage;
|
||||
impl_->windowUnicode = false;
|
||||
}
|
||||
}
|
||||
|
||||
void NativeInputContext::seedEchoForTesting(
|
||||
const std::wstring& text, std::uint64_t compositionId)
|
||||
{
|
||||
if (impl_)
|
||||
impl_->beginEcho(text, compositionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
#include "TabControl.h"
|
||||
#include "SxLog.h"
|
||||
#include "Window.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
enum class SxTabOverlayRedrawMode
|
||||
{
|
||||
None,
|
||||
RefreshSnapshot
|
||||
};
|
||||
|
||||
bool SxTabRectsIntersect(const RECT& a, const RECT& b)
|
||||
{
|
||||
return a.left < b.right && a.right > b.left &&
|
||||
a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
|
||||
RECT SxTabUnionRect(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;
|
||||
}
|
||||
}
|
||||
inline void TabControl::initTabBar()
|
||||
{
|
||||
if (controls.empty())return;
|
||||
int butW = max(this->width / (int)controls.size(), BUTMINWIDTH);
|
||||
int butH = max(this->height / (int)controls.size(), BUTMINHEIGHT);
|
||||
|
||||
if (this->tabPlacement == StellarX::TabPlacement::Top || this->tabPlacement == StellarX::TabPlacement::Bottom)
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setHeight(tabBarHeight);
|
||||
c.first->setWidth(butW);
|
||||
}
|
||||
else if (this->tabPlacement == StellarX::TabPlacement::Left || this->tabPlacement == StellarX::TabPlacement::Right)
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setHeight(butH);
|
||||
c.first->setWidth(tabBarHeight);
|
||||
}
|
||||
int i = 0;
|
||||
switch (this->tabPlacement)
|
||||
{
|
||||
case StellarX::TabPlacement::Top:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setX(this->x + i * butW);
|
||||
c.first->setY(this->y);
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case StellarX::TabPlacement::Bottom:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setX(this->x + i * butW);
|
||||
c.first->setY(this->y + this->height - tabBarHeight);
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case StellarX::TabPlacement::Left:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setX(this->x);
|
||||
c.first->setY(this->y + i * butH);
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
case StellarX::TabPlacement::Right:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setX(this->x + this->width - tabBarHeight);
|
||||
c.first->setY(this->y + i * butH);
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inline void TabControl::initTabPage()
|
||||
{
|
||||
if (controls.empty())return;
|
||||
// TabControl 内部页签页仍然保留专用布局:
|
||||
// 这里负责把“当前选项卡容器矩形”拆分成页签栏和页面区,
|
||||
// 不把这部分细节下放到通用布局解算器里。
|
||||
// 但页面内容子树的世界坐标映射职责,继续归页面 Canvas 自己管理。
|
||||
// 因此这里不再手工遍历页内子控件做 setX/setY,
|
||||
// 只安置每个页面 Canvas 的矩形,让 Canvas 通过自身 relayoutManagedChildren() 收口。
|
||||
switch (this->tabPlacement)
|
||||
{
|
||||
case StellarX::TabPlacement::Top:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.second->setX(this->x);
|
||||
c.second->setY(this->y + tabBarHeight);
|
||||
c.second->setWidth(this->width);
|
||||
c.second->setHeight(this->height - tabBarHeight);
|
||||
}
|
||||
break;
|
||||
case StellarX::TabPlacement::Bottom:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.second->setX(this->x);
|
||||
c.second->setY(this->y);
|
||||
c.second->setWidth(this->width);
|
||||
c.second->setHeight(this->height - tabBarHeight);
|
||||
}
|
||||
break;
|
||||
case StellarX::TabPlacement::Left:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.second->setX(this->x + tabBarHeight);
|
||||
c.second->setY(this->y);
|
||||
c.second->setWidth(this->width - tabBarHeight);
|
||||
c.second->setHeight(this->height);
|
||||
}
|
||||
break;
|
||||
case StellarX::TabPlacement::Right:
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.second->setX(this->x);
|
||||
c.second->setY(this->y);
|
||||
c.second->setWidth(this->width - tabBarHeight);
|
||||
c.second->setHeight(this->height);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::refreshRuntimeLayout()
|
||||
{
|
||||
// 这是 TabControl 的内部专用布局入口:
|
||||
// 外层先通过统一解算得到 TabControl 自身矩形,
|
||||
// 再由这里继续安置页签按钮和页面区。
|
||||
initTabBar();
|
||||
initTabPage();
|
||||
}
|
||||
|
||||
TabControl::TabControl() :Canvas()
|
||||
{
|
||||
this->id = "TabControl";
|
||||
// TabControl 作为外层容器,当前阶段显式允许双轴 Stretch;
|
||||
// 内部页签栏和页面区仍由自己的专用布局逻辑管理。
|
||||
this->layoutCapability.allowStretchX = true;
|
||||
this->layoutCapability.allowStretchY = true;
|
||||
}
|
||||
|
||||
TabControl::TabControl(int x, int y, int width, int height)
|
||||
: Canvas(x, y, width, height)
|
||||
{
|
||||
this->id = "TabControl";
|
||||
this->layoutCapability.allowStretchX = true;
|
||||
this->layoutCapability.allowStretchY = true;
|
||||
}
|
||||
|
||||
TabControl::~TabControl()
|
||||
{
|
||||
}
|
||||
|
||||
void TabControl::setX(int x)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(x, y, width, height);
|
||||
refreshRuntimeLayout();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::setY(int y)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(x, y, width, height);
|
||||
refreshRuntimeLayout();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::setWidth(int width)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(x, y, (std::max)(0, width), height);
|
||||
refreshRuntimeLayout();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::setHeight(int height)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
applyRuntimeRectDirect(x, y, width, (std::max)(0, height));
|
||||
refreshRuntimeLayout();
|
||||
onWindowResize();
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect)
|
||||
{
|
||||
// TabControl 作为外层控件接入统一解算;
|
||||
// 但页签栏和页面区仍由自身专用逻辑继续排布。
|
||||
applyRuntimeRectDirect(rect.worldX, rect.worldY, rect.width, rect.height);
|
||||
refreshRuntimeLayout();
|
||||
}
|
||||
|
||||
void TabControl::draw()
|
||||
{
|
||||
if (!dirty || !show)return;
|
||||
auto mutationScope = enterMutationScope();
|
||||
// 绘制画布背景和基本形状及其子画布控件
|
||||
Canvas::draw();
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.second->setDirty(true);
|
||||
c.second->draw();
|
||||
}
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setDirty(true);
|
||||
c.first->draw();
|
||||
}
|
||||
|
||||
// 首次绘制时处理默认激活页签;空容器保持待定状态,不能访问最后一个元素。
|
||||
if (IsFirstDraw && !controls.empty())
|
||||
{
|
||||
if (defaultActivation >= 0 && defaultActivation < (int)controls.size())
|
||||
controls[defaultActivation].first->setButtonClick(true);
|
||||
else if (defaultActivation >= (int)controls.size())//索引越界则激活最后一个
|
||||
controls[controls.size() - 1].first->setButtonClick(true);
|
||||
IsFirstDraw = false;//避免重复处理
|
||||
}
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
bool TabControl::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show)return false;
|
||||
auto mutationScope = enterMutationScope();
|
||||
resetEventVisualChanged();
|
||||
bool consume = false;
|
||||
bool anyVisualChanged = false;
|
||||
|
||||
// TabControl 的同层页签按钮/页面区事件分发顺序,
|
||||
// 与 Window / Canvas 保持一致:按倒序处理,后加入者视为更靠上层。
|
||||
if (msg.message == WM_MOUSEMOVE)
|
||||
{
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
if (!consume)
|
||||
{
|
||||
if (it->first->handleEvent(msg))
|
||||
{
|
||||
consume = true;
|
||||
}
|
||||
if (it->first->didEventAffectVisual())
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
else if (it->first->clearTransientMouseState())
|
||||
{
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
if (!it->second->IsVisible())
|
||||
continue;
|
||||
|
||||
if (!consume)
|
||||
{
|
||||
if (it->second->handleEvent(msg))
|
||||
{
|
||||
consume = true;
|
||||
}
|
||||
if (it->second->didEventAffectVisual())
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
else if (it->second->clearTransientMouseState())
|
||||
{
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
if (it->first->handleEvent(msg))
|
||||
{
|
||||
consume = true;
|
||||
break;
|
||||
}
|
||||
if (!consume)
|
||||
{
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
if (it->second->IsVisible())
|
||||
if (it->second->handleEvent(msg))
|
||||
{
|
||||
consume = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
markEventVisualChanged(anyVisualChanged || dirty);
|
||||
return consume;
|
||||
}
|
||||
|
||||
bool TabControl::clearTransientMouseState()
|
||||
{
|
||||
bool changed = false;
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
if (it->first->IsVisible() && it->first->clearTransientMouseState())
|
||||
changed = true;
|
||||
|
||||
if (it->second->IsVisible() && it->second->clearTransientMouseState())
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool TabControl::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
|
||||
StellarX::FocusTargetSnapshot& snapshot) const
|
||||
{
|
||||
if (Canvas::queryFocusTargetSnapshot(target, snapshot))
|
||||
return true;
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
if (it->first && it->first->queryFocusTargetSnapshot(target, snapshot)) return true;
|
||||
if (it->second && it->second->queryFocusTargetSnapshot(target, snapshot)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void TabControl::collectFocusTargetSnapshots(
|
||||
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
|
||||
{
|
||||
Canvas::collectFocusTargetSnapshots(snapshots);
|
||||
for (const auto& tab : controls)
|
||||
{
|
||||
if (tab.first) tab.first->collectFocusTargetSnapshots(snapshots);
|
||||
if (tab.second) tab.second->collectFocusTargetSnapshots(snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
bool TabControl::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
|
||||
StellarX::FocusReason reason)
|
||||
{
|
||||
if (Canvas::applyFocusState(target, focused, reason))
|
||||
return true;
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
if (it->first && it->first->applyFocusState(target, focused, reason)) return true;
|
||||
if (it->second && it->second->applyFocusState(target, focused, reason)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t TabControl::addTab(std::unique_ptr<Button> button, std::unique_ptr<Canvas> page)
|
||||
{
|
||||
if (!button || !page)
|
||||
return static_cast<size_t>(-1);
|
||||
Window* host = getHostWindow();
|
||||
if (mutationScopeDepth > 0 || (host && host->isMutationTransactionActive()))
|
||||
{
|
||||
// 返回“逻辑索引”而不是物理 vector 下标,使回调可以立即向待提交页面添加控件。
|
||||
const size_t index = controls.size() + pendingTabs.size();
|
||||
pendingTabs.emplace_back(std::move(button), std::move(page));
|
||||
dirty = true;
|
||||
return index;
|
||||
}
|
||||
return addTabDirect(std::move(button), std::move(page));
|
||||
}
|
||||
|
||||
size_t TabControl::addTabDirect(std::unique_ptr<Button> button, std::unique_ptr<Canvas> page)
|
||||
{
|
||||
if (!button || !page)
|
||||
return static_cast<size_t>(-1);
|
||||
// 只有在事务提交阶段才真正加入 controls,并在这里一次性建立按钮、页面和回调关系。
|
||||
controls.emplace_back(std::move(button), std::move(page));
|
||||
initTabBar();
|
||||
initTabPage();
|
||||
size_t idx = controls.size() - 1;
|
||||
controls[idx].first->setParent(this);
|
||||
controls[idx].first->enableTooltip(true);
|
||||
controls[idx].first->setbuttonMode(StellarX::ButtonMode::TOGGLE);
|
||||
|
||||
controls[idx].first->setOnToggleOnListener([this, idx]()
|
||||
{
|
||||
int prevIdx = -1;
|
||||
for (size_t i = 0; i < controls.size(); ++i)
|
||||
{
|
||||
if (controls[i].second->IsVisible())
|
||||
{
|
||||
prevIdx = (int)i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (size_t i = 0; i < controls.size(); ++i)
|
||||
{
|
||||
if (i != idx && controls[i].first->isClicked())
|
||||
controls[i].first->setButtonClick(false);
|
||||
}
|
||||
|
||||
|
||||
SX_LOGI("Tab") << SX_T("激活选项卡:","activate tab: ") << prevIdx << "->" << (int)idx
|
||||
<< " text=" << controls[idx].first->getButtonText();
|
||||
controls[idx].second->onWindowResize();
|
||||
controls[idx].second->setIsVisible(true);
|
||||
dirty = true;
|
||||
|
||||
|
||||
});
|
||||
controls[idx].first->setOnToggleOffListener([this, idx]()
|
||||
{
|
||||
SX_LOGI("Tab") << SX_T("关闭选项卡:id=","deactivate tab: idx=") << (int)idx
|
||||
<< " text=" << controls[idx].first->getButtonText();
|
||||
|
||||
controls[idx].second->setIsVisible(false);
|
||||
dirty = true;
|
||||
});
|
||||
controls[idx].second->setParent(this);
|
||||
controls[idx].second->setLinewidth(canvaslinewidth);
|
||||
controls[idx].second->setIsVisible(false);
|
||||
dirty = true;
|
||||
return idx;
|
||||
}
|
||||
|
||||
void TabControl::tick(ULONGLONG nowMs)
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
auto mutationScope = enterMutationScope();
|
||||
for (auto& tab : controls)
|
||||
{
|
||||
if (tab.first && tab.first->IsVisible()) tab.first->tick(nowMs);
|
||||
if (tab.second && tab.second->IsVisible()) tab.second->tick(nowMs);
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<Control> TabControl::tryAddToPage(size_t pageIndex, std::unique_ptr<Control> control)
|
||||
{
|
||||
// 返回非空 unique_ptr 表示索引无效且调用方仍拥有控件;成功路径才会消耗所有权。
|
||||
if (!control)
|
||||
return control;
|
||||
Canvas* page = nullptr;
|
||||
if (pageIndex < controls.size())
|
||||
page = controls[pageIndex].second.get();
|
||||
else
|
||||
{
|
||||
const size_t pendingIndex = pageIndex - controls.size();
|
||||
if (pendingIndex < pendingTabs.size())
|
||||
page = pendingTabs[pendingIndex].second.get();
|
||||
}
|
||||
if (!page)
|
||||
return control;
|
||||
control->setDirty(true);
|
||||
control->setIsVisible(page->IsVisible());
|
||||
page->addControl(std::move(control));
|
||||
dirty = true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void TabControl::flushPendingMutations()
|
||||
{
|
||||
// 先提交 Canvas 自身的页内控件,再提交完整页签;两层队列都在同一外层收口完成。
|
||||
Canvas::flushPendingMutations();
|
||||
if (!pendingTabs.empty())
|
||||
{
|
||||
auto tabs = std::move(pendingTabs);
|
||||
pendingTabs.clear();
|
||||
for (auto& tab : tabs)
|
||||
addTabDirect(std::move(tab.first), std::move(tab.second));
|
||||
}
|
||||
for (auto& tab : controls)
|
||||
if (tab.second) tab.second->flushPendingMutations();
|
||||
}
|
||||
|
||||
std::unique_ptr<Control> TabControl::tryAddToPage(const std::string& tabText, std::unique_ptr<Control> control)
|
||||
{
|
||||
const int idx = indexOf(tabText);
|
||||
if (idx < 0)
|
||||
return control;
|
||||
return tryAddToPage(static_cast<size_t>(idx), std::move(control));
|
||||
}
|
||||
|
||||
void TabControl::setTabPlacement(StellarX::TabPlacement placement)
|
||||
{
|
||||
this->tabPlacement = placement;
|
||||
setDirty(true);
|
||||
initTabBar();
|
||||
initTabPage();
|
||||
}
|
||||
|
||||
void TabControl::setTabBarHeight(int height)
|
||||
{
|
||||
tabBarHeight = height;
|
||||
setDirty(true);
|
||||
initTabBar();
|
||||
initTabPage();
|
||||
}
|
||||
|
||||
void TabControl::setIsVisible(bool visible)
|
||||
{
|
||||
// 先让基类 Canvas 处理自己的回贴/丢快照逻辑
|
||||
Canvas::setIsVisible(visible);
|
||||
for (auto& tab : controls)
|
||||
{
|
||||
if(true == visible)
|
||||
{
|
||||
tab.first->setIsVisible(visible);
|
||||
//页也要跟着关/开,否则它们会保留旧的 saveBkImage
|
||||
if (tab.first->isClicked())
|
||||
tab.second->setIsVisible(true);
|
||||
else
|
||||
tab.second->setIsVisible(false);
|
||||
tab.second->setDirty(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
tab.first->setIsVisible(visible);
|
||||
tab.second->setIsVisible(visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::onWindowResize()
|
||||
{
|
||||
// 本轮不再在 onWindowResize 中重做页签布局,
|
||||
// 这里只负责失效快照、标脏,并把 resize 语义向页签按钮和页面传递。
|
||||
Control::onWindowResize();
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->onWindowResize();
|
||||
c.second->onWindowResize();
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
int TabControl::getActiveIndex() const
|
||||
{
|
||||
int idx = -1;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
idx++;
|
||||
if (c.first->isClicked())
|
||||
return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool TabControl::setActiveIndex(int idx)
|
||||
{
|
||||
// 首次绘制前仅记录待激活索引,真正页签尚未加入时也不访问 controls。
|
||||
if (idx < 0)
|
||||
return false;
|
||||
if (IsFirstDraw)
|
||||
{
|
||||
defaultActivation = idx;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 外部重复激活“已经处于激活状态”的页签时,不应再把整条 onToggleOn 链重跑一遍。
|
||||
// 否则当前可见页面会重复执行 onWindowResize()/setIsVisible(true),
|
||||
// 对页内像 Table 这种会越出页面边界绘制的控件,容易把快照链再次扰乱,留下残影。
|
||||
if (idx >= 0 && idx < static_cast<int>(controls.size()))
|
||||
{
|
||||
if (getActiveIndex() == idx)
|
||||
return true;
|
||||
controls[idx].first->setButtonClick(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int TabControl::count() const
|
||||
{
|
||||
return (int)controls.size();
|
||||
}
|
||||
|
||||
int TabControl::indexOf(const std::string& tabText) const
|
||||
{
|
||||
int idx = -1;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
idx++;
|
||||
if (c.first->getButtonText() == tabText)
|
||||
return idx;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
void TabControl::setDirty(bool dirty)
|
||||
{
|
||||
this->dirty = dirty;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setDirty(dirty);
|
||||
c.second->setDirty(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::requestRepaint(Control* parent)
|
||||
{
|
||||
if (shouldDeferManagedRepaint())
|
||||
{
|
||||
// 托管路径:TabControl 作为“页签栏 + 当前页面”的统一重绘 root 登记到 Window。
|
||||
if (auto* host = getHostWindow())
|
||||
host->requestManagedRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this == parent)
|
||||
{
|
||||
RECT paintCoverage{};
|
||||
bool hasPaintCoverage = false;
|
||||
RECT persistentCoverage{};
|
||||
bool hasPersistentCoverage = false;
|
||||
auto commitTabUnit = [&](Control* unit, SxTabOverlayRedrawMode overlayMode)
|
||||
{
|
||||
if (!unit || !unit->IsVisible())
|
||||
return;
|
||||
|
||||
const bool directDirty = unit->isDirty();
|
||||
const bool subtreeDirty = unit->hasManagedDirtySubtree();
|
||||
|
||||
if (overlayMode == SxTabOverlayRedrawMode::RefreshSnapshot)
|
||||
{
|
||||
// 下层单元的持久内容已经写过像素,上层页签/页面作为 overlay 补画时,
|
||||
// 必须先丢掉旧快照,重新抓取当前背景后再画,否则会把旧背景再贴回来。
|
||||
unit->invalidateBackgroundSnapshot();
|
||||
unit->setDirty(true);
|
||||
unit->draw();
|
||||
}
|
||||
else if (directDirty)
|
||||
{
|
||||
unit->draw();
|
||||
}
|
||||
else if (subtreeDirty)
|
||||
{
|
||||
unit->commitManagedRepaint();
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const RECT paintRect = unit->getManagedRepaintCoverageRect();
|
||||
if (!hasPaintCoverage)
|
||||
{
|
||||
paintCoverage = paintRect;
|
||||
hasPaintCoverage = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
paintCoverage = SxTabUnionRect(paintCoverage, paintRect);
|
||||
}
|
||||
|
||||
const RECT persistentRect = unit->getManagedRepaintPersistentCoverageRect();
|
||||
if (!hasPersistentCoverage)
|
||||
{
|
||||
persistentCoverage = persistentRect;
|
||||
hasPersistentCoverage = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
persistentCoverage = SxTabUnionRect(persistentCoverage, persistentRect);
|
||||
}
|
||||
};
|
||||
|
||||
// 局部重绘必须和 draw() 维持同一套顺序:
|
||||
// 先页面,再页签按钮。
|
||||
// 否则页签按钮上的 Tooltip 会被后画的页面盖掉,
|
||||
// 表现为“有页面打开时 Tooltip 看不到,所有页关闭时才正常”。
|
||||
for (auto& control : controls)
|
||||
{
|
||||
Control* page = control.second.get();
|
||||
if (!page->IsVisible())
|
||||
continue;
|
||||
|
||||
if (page->hasManagedDirtySubtree())
|
||||
{
|
||||
commitTabUnit(page, SxTabOverlayRedrawMode::None);
|
||||
}
|
||||
else if (hasPaintCoverage && SxTabRectsIntersect(page->getManagedRepaintCoverageRect(), paintCoverage))
|
||||
{
|
||||
const bool persistentHit = hasPersistentCoverage &&
|
||||
SxTabRectsIntersect(page->getManagedRepaintPersistentCoverageRect(), persistentCoverage);
|
||||
if (persistentHit)
|
||||
commitTabUnit(page, SxTabOverlayRedrawMode::RefreshSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& control : controls)
|
||||
{
|
||||
Control* button = control.first.get();
|
||||
if (!button->IsVisible())
|
||||
continue;
|
||||
|
||||
if (button->hasManagedDirtySubtree())
|
||||
{
|
||||
commitTabUnit(button, SxTabOverlayRedrawMode::None);
|
||||
}
|
||||
else if (hasPaintCoverage && SxTabRectsIntersect(button->getManagedRepaintCoverageRect(), paintCoverage))
|
||||
{
|
||||
const bool persistentHit = hasPersistentCoverage &&
|
||||
SxTabRectsIntersect(button->getManagedRepaintPersistentCoverageRect(), persistentCoverage);
|
||||
if (persistentHit)
|
||||
commitTabUnit(button, SxTabOverlayRedrawMode::RefreshSnapshot);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
|
||||
bool TabControl::hasManagedDirtySubtree() const
|
||||
{
|
||||
if (dirty)
|
||||
return true;
|
||||
|
||||
for (const auto& control : controls)
|
||||
{
|
||||
if (control.first->IsVisible() && control.first->hasManagedDirtySubtree())
|
||||
return true;
|
||||
if (control.second->IsVisible() && control.second->hasManagedDirtySubtree())
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
RECT TabControl::getManagedRepaintCoverageRect() const
|
||||
{
|
||||
// TabControl 的 draw() 会写自身背景、全部页签按钮以及全部页面。
|
||||
// 因此 coverage 也必须按“页签按钮 + 页面”递归并集,避免内部 Tooltip 等附加绘制区域被漏算。
|
||||
RECT coverage = getBoundsRect();
|
||||
for (const auto& control : controls)
|
||||
{
|
||||
if (control.first->IsVisible())
|
||||
coverage = SxTabUnionRect(coverage, control.first->getManagedRepaintCoverageRect());
|
||||
if (control.second->IsVisible())
|
||||
coverage = SxTabUnionRect(coverage, control.second->getManagedRepaintCoverageRect());
|
||||
}
|
||||
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 只有在自己本体不脏且背景快照有效时,才允许只更新脏页签/脏页面。
|
||||
return show && !dirty && hasValidBackgroundSnapshot();
|
||||
}
|
||||
|
||||
void TabControl::commitManagedRepaint()
|
||||
{
|
||||
if (!show)
|
||||
return;
|
||||
|
||||
if (canCommitManagedPartialRepaint())
|
||||
{
|
||||
// 页签栏和页面基线都还有效:沿用原有局部重绘逻辑。
|
||||
requestRepaint(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自身布局或背景已经变化:升级为整 TabControl 重画。
|
||||
this->dirty = true;
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
// TextBox.cpp
|
||||
#include "TextBox.h"
|
||||
#include "SxLog.h"
|
||||
#include "SxTextInternal.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
void SxTextBoxTrimTrailingSpaces(std::string& s)
|
||||
{
|
||||
while (!s.empty() && s.back() == ' ')
|
||||
s.pop_back();
|
||||
|
||||
while (s.size() >= 2)
|
||||
{
|
||||
unsigned char a = static_cast<unsigned char>(s[s.size() - 2]);
|
||||
unsigned char b = static_cast<unsigned char>(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)
|
||||
{
|
||||
// maxBytes 保持公开 API 的字节长度语义,但截断点必须落在完整 MBCS 字符边界。
|
||||
size_t i = 0;
|
||||
size_t lastSafe = 0;
|
||||
while (i < text.size())
|
||||
{
|
||||
const size_t next = i + StellarX::Internal::mbcCharLength(text, i);
|
||||
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 size_t next = i + StellarX::Internal::mbcCharLength(text, i);
|
||||
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)
|
||||
{
|
||||
this->id = "TextBox";
|
||||
// TextBox 当前阶段的默认布局策略:
|
||||
// - 水平方向允许通过锚点语义拉伸
|
||||
// - 垂直方向当前实现保持固定尺寸,不在本轮引入随高度变化的视觉自适应
|
||||
this->layoutCapability.allowStretchX = true;
|
||||
this->layoutCapability.allowStretchY = false;
|
||||
this->focusPolicy = StellarX::FocusPolicy::Programmatic |
|
||||
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
|
||||
}
|
||||
|
||||
void TextBox::draw()
|
||||
{
|
||||
if (dirty && show)
|
||||
{
|
||||
saveStyle();
|
||||
setfillcolor(textBoxBkClor);
|
||||
setlinecolor(textBoxBorderClor);
|
||||
StellarX::ControlText drawStyle = textStyle;
|
||||
if (drawStyle.nHeight > height)
|
||||
drawStyle.nHeight = height;
|
||||
if (drawStyle.nWidth > width)
|
||||
drawStyle.nWidth = width;
|
||||
settextstyle(drawStyle.nHeight, drawStyle.nWidth, drawStyle.faceName.c_str(),
|
||||
drawStyle.nEscapement, drawStyle.nOrientation, drawStyle.nWeight,
|
||||
drawStyle.bItalic, drawStyle.bUnderline, drawStyle.bStrikeOut);
|
||||
|
||||
settextcolor(drawStyle.color);
|
||||
setbkmode(TRANSPARENT);
|
||||
|
||||
int text_width = 0;
|
||||
int text_height = 0;
|
||||
std::string pwdText;
|
||||
std::string displayText; // 用于显示的文本(可能被截断)
|
||||
|
||||
if (StellarX::TextBoxmode::PASSWORD_MODE == mode)
|
||||
{
|
||||
pwdText = StellarX::Internal::makePasswordMask(text);
|
||||
displayText = pwdText;
|
||||
}
|
||||
else
|
||||
{
|
||||
displayText = text;
|
||||
}
|
||||
|
||||
// 计算可用宽度(留出左右边距)
|
||||
int availableWidth = width - 20; // 左右各10像素边距
|
||||
|
||||
// 截断文本以适应可用宽度
|
||||
int currentWidth = textwidth(LPCTSTR(displayText.c_str()));
|
||||
if (currentWidth > availableWidth && availableWidth > 0)
|
||||
{
|
||||
// 按 GBK/MBCS 字符边界裁切,避免中文文本被截断到半个字节。
|
||||
displayText = SxTextBoxEllipsizeMbc(displayText, availableWidth);
|
||||
currentWidth = textwidth(LPCTSTR(displayText.c_str()));
|
||||
}
|
||||
|
||||
text_width = currentWidth;
|
||||
text_height = textheight(LPCTSTR(displayText.c_str()));
|
||||
|
||||
if ((saveBkX != this->x) || (saveBkY != this->y) || (!hasSnap) || (saveWidth != this->width) || (saveHeight != this->height) || !saveBkImage)
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
// 恢复背景(清除旧内容)
|
||||
restBackground();
|
||||
//根据形状绘制
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
fillrectangle(x, y, x + width, y + height);//有边框填充矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
solidrectangle(x, y, x + width, y + height);//无边框填充矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
fillroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//有边框填充圆角矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
solidroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//无边框填充圆角矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str()));
|
||||
break;
|
||||
}
|
||||
if (logicalFocused && width > 6 && height > 6)
|
||||
{
|
||||
setlinecolor(RGB(32, 96, 192));
|
||||
setlinestyle(PS_DOT, 1);
|
||||
rectangle(x + 3, y + 3, x + width - 3, y + height - 3);
|
||||
}
|
||||
restoreStyle();
|
||||
dirty = false; //标记不需要重绘
|
||||
}
|
||||
}
|
||||
|
||||
bool TextBox::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show) return false;
|
||||
auto mutationScope = enterMutationScope();
|
||||
resetEventVisualChanged();
|
||||
|
||||
bool hover = false;
|
||||
bool consume = false;
|
||||
const bool isMouseMessage =
|
||||
msg.message == WM_MOUSEMOVE ||
|
||||
msg.message == WM_LBUTTONDOWN ||
|
||||
msg.message == WM_LBUTTONUP;
|
||||
|
||||
if (isMouseMessage)
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
hover = (msg.x > x && msg.x < (x + width) && msg.y > y && msg.y < (y + height));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hover && msg.message == WM_LBUTTONDOWN)
|
||||
{
|
||||
requestFocus(StellarX::FocusReason::Pointer);
|
||||
consume = true;
|
||||
}
|
||||
|
||||
if (hover && msg.message == WM_LBUTTONUP)
|
||||
{
|
||||
click = true;
|
||||
|
||||
const size_t oldLen = text.size();
|
||||
SX_LOGI("TextBox") << SX_T("激活:id=","activate: id=") << id << " mode=" << (int)mode << " oldLen=" << oldLen;
|
||||
|
||||
if (StellarX::TextBoxmode::INPUT_MODE == mode)
|
||||
{
|
||||
std::vector<char> temp(maxCharLen + 1, '\0');
|
||||
dirty = InputBox(temp.data(), (int)maxCharLen + 1, "输入框", NULL, text.c_str(), NULL, NULL, false);
|
||||
if (dirty) text = temp.data();
|
||||
consume = true;
|
||||
}
|
||||
else if (StellarX::TextBoxmode::READONLY_MODE == mode)
|
||||
{
|
||||
dirty = false;
|
||||
InputBox(NULL, (int)maxCharLen, "输出框(输入无效!)", NULL, text.c_str(), NULL, NULL, false);
|
||||
consume = true;
|
||||
}
|
||||
else if (StellarX::TextBoxmode::PASSWORD_MODE == mode)
|
||||
{
|
||||
std::vector<char> temp(maxCharLen + 1, '\0');
|
||||
// 不记录明文,只记录长度变化
|
||||
dirty = InputBox(temp.data(), (int)maxCharLen + 1, "输入框\n不可见输入,覆盖即可", NULL, NULL, NULL, NULL, false);
|
||||
if (dirty) text = temp.data();
|
||||
consume = true;
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
{
|
||||
SX_LOGI("TextBox") << SX_T("文本已更改: id=","text changed: id=") << id
|
||||
<< " oldLen=" << oldLen << " newLen=" << text.size();
|
||||
}
|
||||
else
|
||||
{
|
||||
SX_LOGD("TextBox") << SX_T("文本无变化:id=","no change: id=") << id;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
markEventVisualChanged(dirty);
|
||||
|
||||
if (click)
|
||||
click = false;
|
||||
|
||||
return consume;
|
||||
}
|
||||
|
||||
|
||||
void TextBox::setMode(StellarX::TextBoxmode mode)
|
||||
{
|
||||
this->mode = mode;
|
||||
this->dirty = true;
|
||||
notifyFocusSubtreeStateChanged();
|
||||
}
|
||||
|
||||
void TextBox::setMaxCharLen(size_t len)
|
||||
{
|
||||
if (len > 0)
|
||||
maxCharLen = len;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void TextBox::setTextBoxshape(StellarX::ControlShape shape)
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
this->shape = shape;
|
||||
this->dirty = true;
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
this->shape = StellarX::ControlShape::RECTANGLE;
|
||||
this->dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void TextBox::setTextBoxBorder(COLORREF color)
|
||||
{
|
||||
textBoxBorderClor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void TextBox::setTextBoxBk(COLORREF color)
|
||||
{
|
||||
textBoxBkClor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void TextBox::setText(std::string text)
|
||||
{
|
||||
if(text == this->text)
|
||||
return; // 文本未改变,无需更新和重绘
|
||||
if (text.size() > maxCharLen)
|
||||
text = SxTextBoxTruncateByMbcBoundary(text, maxCharLen);
|
||||
this->text = text;
|
||||
this->dirty = true; // 标记需要重绘,不论是否窗口图形上下文是否已初始化,等第一次绘制时由窗口真正调用 draw() 来重绘显示文本
|
||||
|
||||
//有父控件时请求父控件重绘,无父控件时直接重绘,确保文本更新后界面正确刷新显示
|
||||
if (nullptr != parent)
|
||||
{
|
||||
//通过hasSnap是否持有有效快照,判断控件是否已经绘制过,避免在控件未绘制前/窗口图形上下文未初始化调用draw()导致的错误
|
||||
if (hasSnap)
|
||||
requestRepaint(parent);
|
||||
}
|
||||
else
|
||||
if (hasSnap)
|
||||
draw();
|
||||
|
||||
}
|
||||
|
||||
std::string TextBox::getText() const
|
||||
{
|
||||
return this->text;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
// 窗口实现和测试共用的内部事件循环工具。
|
||||
// 本文件不属于 StellarX 公共 API。
|
||||
namespace StellarX::Internal
|
||||
{
|
||||
// 限制单轮处理量,避免持续的消息流长期占用循环而导致重绘得不到执行。
|
||||
constexpr std::size_t MessageBatchLimit = 128;
|
||||
|
||||
struct MessageBatchResult
|
||||
{
|
||||
// 本批次成功获取并交给处理器的消息数量。
|
||||
std::size_t processed = 0;
|
||||
// 处理器请求提前停止时为 true,例如处理 WM_CLOSE 后结束循环。
|
||||
bool stopped = false;
|
||||
|
||||
// 空批次表示当前轮次空闲,可以进行短暂休眠。
|
||||
bool shouldSleep() const noexcept { return processed == 0; }
|
||||
};
|
||||
|
||||
// 获取并处理最多 `limit` 条消息;处理器返回 false 时提前停止。
|
||||
// 没有可用消息时,获取器必须返回 false。每次获取到消息都复用同一个
|
||||
// message 对象,从而不要求本工具绑定某一种具体的消息队列类型。
|
||||
template <typename Message, typename Fetch, typename Handle>
|
||||
MessageBatchResult drainMessageBatch(Message& message, Fetch&& fetch, Handle&& handle,
|
||||
std::size_t limit = MessageBatchLimit)
|
||||
{
|
||||
MessageBatchResult result;
|
||||
while (result.processed < limit && fetch(message))
|
||||
{
|
||||
++result.processed;
|
||||
if (!handle(message))
|
||||
{
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <windows.h>
|
||||
|
||||
struct ExMessage;
|
||||
|
||||
namespace StellarX::Internal
|
||||
{
|
||||
struct InputProbeWndProcToken
|
||||
{
|
||||
std::uint64_t callId = 0;
|
||||
std::uint64_t enterSequence = 0;
|
||||
std::uint64_t compositionId = 0;
|
||||
std::uint32_t callDepth = 0;
|
||||
UINT message = 0;
|
||||
bool depthCounted = false;
|
||||
bool recorded = false;
|
||||
bool experimentTagged = false;
|
||||
bool ruleMatched = false;
|
||||
bool observeAndConsume = false;
|
||||
bool failOpenApplied = false;
|
||||
std::uint8_t failOpenReason = 0;
|
||||
std::uint32_t matchOrdinal = 0;
|
||||
};
|
||||
|
||||
struct InputProbeBatchToken
|
||||
{
|
||||
std::uint64_t batchId = 0;
|
||||
std::uint64_t startedQpcTicks = 0;
|
||||
std::uint32_t dispatchDepth = 0;
|
||||
std::uint32_t modalDepth = 0;
|
||||
bool active = false;
|
||||
bool modal = false;
|
||||
bool materialized = false;
|
||||
};
|
||||
|
||||
#if defined(STELLARX_ENABLE_INPUT_PROBE)
|
||||
void inputProbeAttach(HWND hwnd) noexcept;
|
||||
void inputProbeDetach(HWND hwnd) noexcept;
|
||||
|
||||
InputProbeWndProcToken inputProbeWndProcEnter(
|
||||
HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept;
|
||||
LRESULT inputProbeWndProcLeave(
|
||||
const InputProbeWndProcToken& token, LRESULT result, bool forwardedToOldProc) noexcept;
|
||||
|
||||
InputProbeBatchToken inputProbeHostBatchBegin(HWND hwnd, bool modal) noexcept;
|
||||
void inputProbeObserveEasyXMessage(
|
||||
HWND hwnd, InputProbeBatchToken& batch, const ExMessage& message) noexcept;
|
||||
void inputProbeHostBatchEnd(
|
||||
HWND hwnd, InputProbeBatchToken& batch, std::size_t processed, bool stopped) noexcept;
|
||||
void inputProbeFlushAtSafePoint(HWND hwnd) noexcept;
|
||||
#else
|
||||
inline void inputProbeAttach(HWND) noexcept {}
|
||||
inline void inputProbeDetach(HWND) noexcept {}
|
||||
|
||||
inline InputProbeWndProcToken inputProbeWndProcEnter(
|
||||
HWND, UINT, WPARAM, LPARAM) noexcept
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
inline LRESULT inputProbeWndProcLeave(
|
||||
const InputProbeWndProcToken&, LRESULT result, bool) noexcept
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
inline InputProbeBatchToken inputProbeHostBatchBegin(HWND, bool) noexcept
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
inline void inputProbeObserveEasyXMessage(
|
||||
HWND, InputProbeBatchToken&, const ExMessage&) noexcept {}
|
||||
|
||||
inline void inputProbeHostBatchEnd(
|
||||
HWND, InputProbeBatchToken&, std::size_t, bool) noexcept {}
|
||||
|
||||
inline void inputProbeFlushAtSafePoint(HWND) noexcept {}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
#pragma once
|
||||
|
||||
#include "InputEvents.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
class Window;
|
||||
|
||||
namespace StellarX::Internal
|
||||
{
|
||||
enum class NativeInputQueuePushResult : std::uint8_t
|
||||
{
|
||||
Queued,
|
||||
AllocationFailure,
|
||||
UnexpectedFailure
|
||||
};
|
||||
|
||||
enum class NativeInputQueuePopResult : std::uint8_t
|
||||
{
|
||||
Popped,
|
||||
Empty,
|
||||
UnexpectedFailure
|
||||
};
|
||||
|
||||
struct NativeInputQueueStats
|
||||
{
|
||||
std::uint64_t pushed = 0;
|
||||
std::uint64_t popped = 0;
|
||||
std::size_t currentDepth = 0;
|
||||
std::size_t maximumDepth = 0;
|
||||
};
|
||||
|
||||
// Correctness-first cross-thread SPSC queue. The mutex provides happens-before.
|
||||
class NativeInputQueue
|
||||
{
|
||||
public:
|
||||
NativeInputQueue() = default;
|
||||
NativeInputQueue(const NativeInputQueue&) = delete;
|
||||
NativeInputQueue& operator=(const NativeInputQueue&) = delete;
|
||||
|
||||
NativeInputQueuePushResult push(NativeInputEvent&& event) noexcept;
|
||||
NativeInputQueuePopResult tryPop(NativeInputEvent& event) noexcept;
|
||||
NativeInputQueueStats stats() const noexcept;
|
||||
|
||||
// Deterministic fail-stop injection for unit tests only.
|
||||
void forceNextPushResultForTesting(NativeInputQueuePushResult result) noexcept;
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex_;
|
||||
std::deque<NativeInputEvent> events_;
|
||||
std::uint64_t pushed_ = 0;
|
||||
std::uint64_t popped_ = 0;
|
||||
std::size_t maximumDepth_ = 0;
|
||||
std::atomic<NativeInputQueuePushResult> forcedPushResult_{ NativeInputQueuePushResult::Queued };
|
||||
};
|
||||
|
||||
enum class NativeInputFaultCode : std::uint8_t
|
||||
{
|
||||
None,
|
||||
OutOfMemory,
|
||||
QueueFailure,
|
||||
SequenceViolation,
|
||||
UnexpectedException
|
||||
};
|
||||
|
||||
struct NativeInputFaultSnapshot
|
||||
{
|
||||
NativeInputFaultCode code = NativeInputFaultCode::None;
|
||||
std::uint64_t sequence = 0;
|
||||
UINT message = 0;
|
||||
HWND hwnd = nullptr;
|
||||
};
|
||||
|
||||
struct NativeInputObservationStats
|
||||
{
|
||||
std::uint64_t observed = 0;
|
||||
std::uint64_t keyEvents = 0;
|
||||
std::uint64_t textEvents = 0;
|
||||
std::uint64_t compositionEvents = 0;
|
||||
std::uint64_t activationEvents = 0;
|
||||
std::uint64_t lastSequence = 0;
|
||||
bool sequenceStrict = true;
|
||||
};
|
||||
|
||||
// Producer-side normalization anomalies are explicit diagnostics. They do
|
||||
// not become replacement text and are never silently counted as success.
|
||||
struct NativeInputNormalizationStats
|
||||
{
|
||||
std::uint64_t rejectedTextSequences = 0;
|
||||
std::uint64_t imeQueryFailures = 0;
|
||||
std::uint64_t discardedDecoderFragments = 0;
|
||||
};
|
||||
|
||||
struct NativeInputRuntimeSnapshot
|
||||
{
|
||||
NativeInputQueueStats queue{};
|
||||
NativeInputObservationStats observations{};
|
||||
NativeInputNormalizationStats normalization{};
|
||||
NativeInputFaultSnapshot fault{};
|
||||
bool faulted = false;
|
||||
};
|
||||
|
||||
struct NativeInputCollectResult
|
||||
{
|
||||
bool handledSynchronously = false;
|
||||
LRESULT result = 0;
|
||||
};
|
||||
|
||||
struct KeyModifierSnapshot
|
||||
{
|
||||
bool leftControl = false;
|
||||
bool rightControl = false;
|
||||
bool leftShift = false;
|
||||
bool rightShift = false;
|
||||
bool leftAlt = false;
|
||||
bool rightAlt = false;
|
||||
};
|
||||
|
||||
KeyEvent makeKeyEvent(UINT message, WPARAM wParam, LPARAM lParam,
|
||||
const KeyModifierSnapshot& modifiers, bool imeLifecycleActive) noexcept;
|
||||
|
||||
// One context per Window: WndProc produces and the innermost host loop consumes.
|
||||
class NativeInputContext
|
||||
{
|
||||
public:
|
||||
explicit NativeInputContext(HWND hwnd);
|
||||
~NativeInputContext();
|
||||
NativeInputContext(const NativeInputContext&) = delete;
|
||||
NativeInputContext& operator=(const NativeInputContext&) = delete;
|
||||
|
||||
NativeInputCollectResult collect(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept;
|
||||
std::size_t drainObserved(std::size_t limit) noexcept;
|
||||
|
||||
bool hasFault() const noexcept;
|
||||
NativeInputFaultSnapshot fault() const noexcept;
|
||||
NativeInputQueueStats queueStats() const noexcept;
|
||||
NativeInputObservationStats observationStats() const noexcept;
|
||||
NativeInputNormalizationStats normalizationStats() const noexcept;
|
||||
|
||||
// Test-only observation seams; none participates in production routing.
|
||||
bool tryPopForTesting(NativeInputEvent& event) noexcept;
|
||||
void forceNextQueuePushResultForTesting(NativeInputQueuePushResult result) noexcept;
|
||||
void setAnsiCodePageForTesting(UINT codePage) noexcept;
|
||||
void seedEchoForTesting(const std::wstring& text, std::uint64_t compositionId);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
// Internal fixture seam. It exposes counters only and never returns queued text.
|
||||
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
|
||||
|
||||
constexpr int NativeInputFailureExitCode = -2;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
// 文本控件使用的内部 MBCS 辅助工具。
|
||||
// 按项目约定的 GBK 风格字节规则处理,并且不作为公共 API 暴露。
|
||||
namespace StellarX::Internal
|
||||
{
|
||||
// 返回从 `index` 开始的逻辑字符所占的字节数。
|
||||
// ASCII 字符占一个字节,合法的首字节和尾字节组合占两个字节。
|
||||
// 非法或不完整的输入按一个字节处理,确保调用方始终能够向前推进。
|
||||
inline std::size_t mbcCharLength(const std::string& text, std::size_t index) noexcept
|
||||
{
|
||||
if (index >= text.size())
|
||||
return 0;
|
||||
|
||||
const auto first = static_cast<unsigned char>(text[index]);
|
||||
if (first <= 0x7F)
|
||||
return 1;
|
||||
|
||||
if (first >= 0x81 && first <= 0xFE && index + 1 < text.size())
|
||||
{
|
||||
const auto second = static_cast<unsigned char>(text[index + 1]);
|
||||
if (second >= 0x40 && second <= 0xFE && second != 0x7F)
|
||||
return 2;
|
||||
}
|
||||
|
||||
// 非法或不完整的输入消耗一个字节,确保调用方始终能够向前推进。
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 为每个逻辑 MBCS 字符生成一个掩码字符,同时保留原始字节数语义,
|
||||
// 用原文本的字节长度为结果预留容量。
|
||||
inline std::string makePasswordMask(const std::string& text)
|
||||
{
|
||||
std::string result;
|
||||
result.reserve(text.size());
|
||||
for (std::size_t index = 0; index < text.size();)
|
||||
{
|
||||
result.push_back('*');
|
||||
index += mbcCharLength(text, index);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#include "Label.h"
|
||||
|
||||
Label::Label()
|
||||
:Control(0, 0, 0, 0)
|
||||
{
|
||||
this->text = "默认标签";
|
||||
textColor = RGB(0,0,0);
|
||||
textBkColor = RGB(255, 255, 255);; //默认白色背景
|
||||
}
|
||||
|
||||
Label::Label(int x, int y, std::string text, COLORREF textcolor, COLORREF bkColor)
|
||||
:Control(x, y, 0, 0)
|
||||
{
|
||||
this->text = text;
|
||||
textColor = textcolor;
|
||||
textBkColor = bkColor; //默认白色背景
|
||||
}
|
||||
|
||||
void Label::draw()
|
||||
{
|
||||
if(dirty)
|
||||
{
|
||||
saveStyle();
|
||||
if (textBkDisap)
|
||||
setbkmode(TRANSPARENT); //设置背景透明
|
||||
else
|
||||
{
|
||||
setbkmode(OPAQUE); //设置背景不透明
|
||||
setbkcolor(textBkColor); //设置背景颜色
|
||||
}
|
||||
settextcolor(textColor);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut); //设置字体样式
|
||||
outtextxy(x, y, LPCTSTR(text.c_str()));
|
||||
restoreStyle();
|
||||
dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
void Label::setTextdisap(bool key)
|
||||
{
|
||||
textBkDisap = key;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Label::setTextColor(COLORREF color)
|
||||
{
|
||||
textColor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Label::setTextBkColor(COLORREF color)
|
||||
{
|
||||
textBkColor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Label::setText(std::string text)
|
||||
{
|
||||
this->text = text;
|
||||
this->dirty = true;
|
||||
}
|
||||
-445
@@ -1,445 +0,0 @@
|
||||
#include "Table.h"
|
||||
// 绘制表格的当前页
|
||||
// 使用双循环绘制行和列,考虑分页偏移
|
||||
void Table::drawTable()
|
||||
{
|
||||
dX = x;
|
||||
dY = uY;
|
||||
uY = dY + lineHeights.at(0) + 10;
|
||||
|
||||
for (size_t i = (currentPage * rowsPerPage - rowsPerPage); i < (currentPage*rowsPerPage) && i < data.size(); i++)
|
||||
{
|
||||
for (size_t j = 0; j < data[i].size(); j++)
|
||||
{
|
||||
uX = dX + colWidths.at(j) + 20;
|
||||
fillrectangle(dX, dY, uX, uY);
|
||||
outtextxy(dX + 10, dY + 5, LPCTSTR(data[i][j].c_str()));
|
||||
dX += this->colWidths.at(j) + 20;
|
||||
}
|
||||
dX = x;
|
||||
dY = uY;
|
||||
uY = dY + lineHeights.at(0) + 10;
|
||||
|
||||
}
|
||||
uY = y + lineHeights.at(0) + 10;
|
||||
}
|
||||
|
||||
void Table::drawHeader()
|
||||
{
|
||||
|
||||
uY = dY + lineHeights.at(0) + 10;
|
||||
for(size_t i = 0; i < headers.size(); i++)
|
||||
{
|
||||
uX = dX + colWidths.at(i) + 20;
|
||||
fillrectangle(dX, dY, uX, uY);
|
||||
outtextxy(dX + 10, dY + 5, LPCTSTR(headers[i].c_str()));
|
||||
dX += this->colWidths.at(i) + 20;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
// 遍历所有数据单元和表头,计算每列的最大宽度和每行的最大高度,
|
||||
// 为后续绘制表格单元格提供尺寸依据。此计算在数据变更时自动触发。
|
||||
void Table::initTextWaH()
|
||||
{
|
||||
this->colWidths.resize(this->headers.size());
|
||||
this->lineHeights.resize(this->headers.size());
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
//计算数据尺寸
|
||||
for (size_t i = 0; i < data.size(); i++)
|
||||
{
|
||||
for (size_t j = 0; j < data[i].size(); j++)
|
||||
{
|
||||
width = textwidth(LPCTSTR(data[i][j].c_str()));
|
||||
height = textheight(LPCTSTR(data[i][j].c_str()));
|
||||
if (width > this->colWidths.at(j))
|
||||
this->colWidths.at(j) = width;
|
||||
if (height > this->lineHeights[j])
|
||||
this->lineHeights.at(j) = height;
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < this->headers.size(); i++)
|
||||
{
|
||||
width = textwidth(LPCTSTR(headers[i].c_str()));
|
||||
height = textheight(LPCTSTR(headers[i].c_str()));
|
||||
if (width > this->colWidths.at(i))
|
||||
this->colWidths.at(i) = width;
|
||||
if (height > this->lineHeights[i])
|
||||
this->lineHeights.at(i) = height;
|
||||
}
|
||||
|
||||
// 计算表格总宽度和高度
|
||||
this->width = 0;
|
||||
for (size_t i = 0; i < colWidths.size(); i++)
|
||||
this->width += colWidths.at(i) + 20;
|
||||
LINESTYLE currentStyle;
|
||||
|
||||
this->width += tableBorderWidth;
|
||||
|
||||
this->height = lineHeights.at(0) * (rowsPerPage + 1) + rowsPerPage * 10+20 ; // 表头+数据行+页码区域
|
||||
|
||||
|
||||
// 由于表格绘制会覆盖原有区域,需要先保存背景以便在下次绘制前恢复,
|
||||
// 避免重叠绘制造成的残影问题。
|
||||
// 如果背景图像不存在或尺寸不匹配,创建或重新创建
|
||||
if (saveBkImage == nullptr) {
|
||||
saveBkImage = new IMAGE(width, height);
|
||||
}
|
||||
else if (saveBkImage->getwidth() != width || saveBkImage->getheight() != height) {
|
||||
delete saveBkImage;
|
||||
saveBkImage = new IMAGE(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
void Table::initButton()
|
||||
{
|
||||
int x1, x2;
|
||||
int y1, y2;
|
||||
x1 = pX - 72;
|
||||
x2 = pX + textwidth(LPCTSTR(pageNumtext.c_str())) + 10;
|
||||
y1 = y2 = pY;
|
||||
this->prevButton = new Button(x1, y1, 62, textheight(LPCTSTR(pageNumtext.c_str())), "上一页", RGB(0, 0, 0), RGB(255, 255, 255));
|
||||
this->nextButton = new Button(x2, y2, 62, textheight(LPCTSTR(pageNumtext.c_str())), "下一页", RGB(0, 0, 0), RGB(255, 255, 255));
|
||||
this->prevButton->textStyle = this->textStyle;
|
||||
this->nextButton->textStyle = this->textStyle;
|
||||
this->prevButton->setFillMode(tableFillMode);
|
||||
this->nextButton->setFillMode(tableFillMode);
|
||||
prevButton->setOnClickListener([this]()
|
||||
{if (this->currentPage > 1)
|
||||
{
|
||||
this->currentPage--;
|
||||
this->dirty = true;
|
||||
this->draw();
|
||||
} });
|
||||
|
||||
nextButton->setOnClickListener([this]()
|
||||
{if (this->currentPage < (this->totalPages))
|
||||
{
|
||||
this->currentPage++;
|
||||
this->dirty = true;
|
||||
this->draw();
|
||||
}});
|
||||
}
|
||||
|
||||
void Table::initPageNum()
|
||||
{
|
||||
if (0 == pY)
|
||||
pY = uY + lineHeights.at(0) * rowsPerPage + rowsPerPage * 10+20;
|
||||
for (size_t i = 0; i < colWidths.size(); i++)
|
||||
this->pX += colWidths.at(i) + 20;
|
||||
this->pX -= textwidth(LPCTSTR(pageNumtext.c_str()));
|
||||
this->pX /= 2;
|
||||
this->pX += x;
|
||||
this->pageNum = new Label(this->pX, pY, pageNumtext);
|
||||
pageNum->textStyle = this->textStyle;
|
||||
if (StellarX::FillMode::Null == tableFillMode)
|
||||
pageNum->setTextdisap(true);
|
||||
}
|
||||
|
||||
void Table::drawPageNum()
|
||||
{
|
||||
if (nullptr == pageNum)
|
||||
initPageNum();
|
||||
pageNumtext = "第";
|
||||
pageNumtext+= std::to_string(currentPage);
|
||||
pageNumtext += "页/共";
|
||||
pageNumtext += std::to_string(totalPages);
|
||||
pageNumtext += "页";
|
||||
pageNum->setText(pageNumtext);
|
||||
pageNum->draw();
|
||||
|
||||
}
|
||||
|
||||
void Table::drawButton()
|
||||
{
|
||||
if (nullptr == prevButton || nullptr == nextButton)
|
||||
initButton();
|
||||
prevButton->draw();
|
||||
nextButton->draw();
|
||||
|
||||
}
|
||||
|
||||
Table::Table(int x, int y)
|
||||
:Control(x, y, 0, 0)
|
||||
{
|
||||
}
|
||||
|
||||
Table::~Table()
|
||||
{
|
||||
if (this->prevButton)
|
||||
delete this->prevButton;
|
||||
if (this->nextButton)
|
||||
delete this->nextButton;
|
||||
if (this->pageNum)
|
||||
delete this->pageNum;
|
||||
if (this->saveBkImage)
|
||||
delete this->saveBkImage;
|
||||
this->prevButton = nullptr;
|
||||
this->nextButton = nullptr;
|
||||
this->pageNum = nullptr;
|
||||
this->saveBkImage = nullptr;
|
||||
}
|
||||
|
||||
void Table::draw()
|
||||
{
|
||||
if (this->dirty)
|
||||
{
|
||||
// 先保存当前绘图状态
|
||||
saveStyle();
|
||||
|
||||
// 设置表格样式
|
||||
setfillcolor(tableBkClor);
|
||||
setlinecolor(tableBorderClor);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
settextcolor(textStyle.color);
|
||||
setlinestyle((int)tableLineStyle, tableBorderWidth);
|
||||
setfillstyle((int)tableFillMode);
|
||||
setbkmode(TRANSPARENT);
|
||||
|
||||
// 是否需要计算单元格尺寸
|
||||
if (isNeedCellSize)
|
||||
{
|
||||
initTextWaH();
|
||||
isNeedCellSize = false;
|
||||
}
|
||||
|
||||
// 优化性能:仅在首次绘制或表格尺寸变化时捕获背景图像。
|
||||
// 由于表格绘制会覆盖原有区域,需要先保存背景以便在下次绘制前恢复,
|
||||
// 避免重叠绘制造成的残影问题。
|
||||
static bool firstDraw = true;
|
||||
if (firstDraw || isNeedDrawHeaders)
|
||||
{
|
||||
// 确保在绘制任何表格内容之前捕获背景
|
||||
if (saveBkImage)
|
||||
{
|
||||
// 临时恢复样式,确保捕获正确的背景
|
||||
restoreStyle();
|
||||
if(tableBorderWidth>1)
|
||||
getimage(saveBkImage, this->x- tableBorderWidth, this->y- tableBorderWidth, this->width+ tableBorderWidth, this->height+ tableBorderWidth);
|
||||
else
|
||||
getimage(saveBkImage, this->x, this->y, this->width, this->height);
|
||||
saveStyle(); // 重新保存样式
|
||||
|
||||
// 重新设置表格样式
|
||||
setfillcolor(tableBkClor);
|
||||
setlinecolor(tableBorderClor);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
settextcolor(textStyle.color);
|
||||
setlinestyle((int)tableLineStyle, tableBorderWidth);
|
||||
setfillstyle((int)tableFillMode);
|
||||
setbkmode(TRANSPARENT);
|
||||
}
|
||||
firstDraw = false;
|
||||
}
|
||||
|
||||
// 恢复背景(清除旧内容)
|
||||
if (saveBkImage)
|
||||
{
|
||||
if (tableBorderWidth > 1)
|
||||
putimage(this->x - tableBorderWidth, this->y - tableBorderWidth, saveBkImage);
|
||||
else
|
||||
putimage(this->x,this->y,this->saveBkImage);
|
||||
}
|
||||
|
||||
// 绘制表头
|
||||
|
||||
dX = x;
|
||||
dY = y;
|
||||
drawHeader();
|
||||
this->isNeedDrawHeaders = false;
|
||||
|
||||
|
||||
// 绘制当前页
|
||||
drawTable();
|
||||
|
||||
// 绘制页码标签
|
||||
drawPageNum();
|
||||
|
||||
// 绘制翻页按钮
|
||||
if (this->isShowPageButton)
|
||||
drawButton();
|
||||
|
||||
// 恢复绘图状态
|
||||
restoreStyle();
|
||||
dirty = false; // 标记不需要重绘
|
||||
}
|
||||
}
|
||||
|
||||
bool Table::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
bool consume = false;
|
||||
if(!this->isShowPageButton)
|
||||
return consume;
|
||||
else
|
||||
{
|
||||
consume = prevButton->handleEvent(msg);
|
||||
if (!consume)
|
||||
consume = nextButton->handleEvent(msg);
|
||||
}
|
||||
return consume;
|
||||
}
|
||||
|
||||
void Table::setHeaders(std::initializer_list<std::string> headers)
|
||||
{
|
||||
this->headers.clear();
|
||||
for (auto lis : headers)
|
||||
this->headers.push_back(lis);
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
isNeedDrawHeaders = true; // 标记需要重新绘制表头
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Table::setData( std::vector<std::string> data)
|
||||
{
|
||||
if (data.size() < headers.size())
|
||||
for (int i = 0; data.size() <= headers.size(); i++)
|
||||
data.push_back("");
|
||||
this->data.push_back(data);
|
||||
totalPages = ((int)this->data.size() + rowsPerPage - 1) / rowsPerPage;
|
||||
if (totalPages < 1)
|
||||
totalPages = 1;
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Table::setData( std::initializer_list<std::vector<std::string>> data)
|
||||
{
|
||||
for (auto lis : data)
|
||||
if (lis.size() < headers.size())
|
||||
{
|
||||
for (size_t i = lis.size(); i< headers.size(); i++)
|
||||
lis.push_back("");
|
||||
this->data.push_back(lis);
|
||||
}
|
||||
else
|
||||
this->data.push_back(lis);
|
||||
|
||||
totalPages = ((int)this->data.size() + rowsPerPage - 1) / rowsPerPage;
|
||||
if (totalPages < 1)
|
||||
totalPages = 1;
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Table::setRowsPerPage(int rows)
|
||||
{
|
||||
this->rowsPerPage = rows;
|
||||
totalPages = ((int)data.size() + rowsPerPage - 1) / rowsPerPage;
|
||||
if (totalPages < 1)
|
||||
totalPages = 1;
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Table::showPageButton(bool isShow)
|
||||
{
|
||||
this->isShowPageButton = isShow;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Table::setTableBorder(COLORREF color)
|
||||
{
|
||||
this->tableBorderClor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Table::setTableBk(COLORREF color)
|
||||
{
|
||||
this->tableBkClor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Table::setTableFillMode(StellarX::FillMode mode)
|
||||
{
|
||||
if (StellarX::FillMode::Solid == mode || StellarX::FillMode::Null == mode)
|
||||
this->tableFillMode = mode;
|
||||
else
|
||||
this->tableFillMode = StellarX::FillMode::Solid;
|
||||
|
||||
this->prevButton->textStyle = this->textStyle;
|
||||
this->nextButton->textStyle = this->textStyle;
|
||||
this->prevButton->setFillMode(tableFillMode);
|
||||
this->nextButton->setFillMode(tableFillMode);
|
||||
if (StellarX::FillMode::Null == tableFillMode)
|
||||
pageNum->setTextdisap(true);
|
||||
this->prevButton->setDirty(true);
|
||||
this->nextButton->setDirty(true);
|
||||
this->prevButton->setDirty(true);
|
||||
this->nextButton->setDirty(true);
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Table::setTableLineStyle(StellarX::LineStyle style)
|
||||
{
|
||||
this->tableLineStyle = style;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Table::setTableBorderWidth(int width)
|
||||
{
|
||||
this->tableBorderWidth = width;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
int Table::getCurrentPage() const
|
||||
{
|
||||
return this->currentPage;
|
||||
}
|
||||
|
||||
int Table::getTotalPages() const
|
||||
{
|
||||
return this->totalPages;
|
||||
}
|
||||
|
||||
int Table::getRowsPerPage() const
|
||||
{
|
||||
return this->rowsPerPage;
|
||||
}
|
||||
|
||||
bool Table::getShowPageButton() const
|
||||
{
|
||||
return this->isShowPageButton;
|
||||
}
|
||||
|
||||
COLORREF Table::getTableBorder() const
|
||||
{
|
||||
return this->tableBorderClor;
|
||||
}
|
||||
|
||||
COLORREF Table::getTableBk() const
|
||||
{
|
||||
return this->tableBkClor;
|
||||
}
|
||||
|
||||
StellarX::FillMode Table::getTableFillMode() const
|
||||
{
|
||||
return this->tableFillMode;
|
||||
}
|
||||
|
||||
StellarX::LineStyle Table::getTableLineStyle() const
|
||||
{
|
||||
return this->tableLineStyle;
|
||||
}
|
||||
|
||||
std::vector<std::string> Table::getHeaders() const
|
||||
{
|
||||
return this->headers;
|
||||
}
|
||||
|
||||
std::vector<std::vector<std::string>> Table::getData() const
|
||||
{
|
||||
return this->data;
|
||||
}
|
||||
|
||||
int Table::getTableBorderWidth() const
|
||||
{
|
||||
return this->tableBorderWidth;
|
||||
}
|
||||
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
// TextBox.cpp
|
||||
#include "TextBox.h"
|
||||
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
void TextBox::draw()
|
||||
{
|
||||
if(dirty)
|
||||
{
|
||||
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);
|
||||
|
||||
settextcolor(textStyle.color);
|
||||
setbkmode(TRANSPARENT);
|
||||
int text_width = textwidth(LPCTSTR(text.c_str()));
|
||||
int text_height = textheight(LPCTSTR(text.c_str()));
|
||||
|
||||
|
||||
//根据形状绘制
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
fillrectangle(x,y,x+width,y+height);//有边框填充矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
solidrectangle(x, y, x + width, y + height);//无边框填充矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
fillroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//有边框填充圆角矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
solidroundrect(x, y, x + width, y + height, rouRectangleSize.ROUND_RECTANGLEwidth, rouRectangleSize.ROUND_RECTANGLEheight);//无边框填充圆角矩形
|
||||
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(text.c_str()));
|
||||
break;
|
||||
}
|
||||
restoreStyle();
|
||||
dirty = false; //标记不需要重绘
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bool TextBox::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
bool hover = false;
|
||||
bool oldClick = click;
|
||||
bool consume = false;
|
||||
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
hover = (msg.x > x && msg.x < (x + width) && msg.y > y && msg.y < (y + height));//判断鼠标是否在矩形按钮内
|
||||
consume = false;
|
||||
break;
|
||||
}
|
||||
if (hover && msg.message == WM_LBUTTONUP)
|
||||
{
|
||||
click = true;
|
||||
if(StellarX::TextBoxmode::INPUT_MODE == mode)
|
||||
{
|
||||
dirty = InputBox(LPTSTR(text.c_str()), (int)maxCharLen, "输入框", NULL, text.c_str(), NULL, NULL, false);
|
||||
consume = true;
|
||||
}
|
||||
else if (StellarX::TextBoxmode::READONLY_MODE == mode)
|
||||
{
|
||||
dirty = false;
|
||||
InputBox(NULL, (int)maxCharLen, "输出框(输入无效!)", NULL, text.c_str(), NULL, NULL, false);
|
||||
consume = true;
|
||||
}
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
}
|
||||
if (dirty)
|
||||
draw();
|
||||
|
||||
if (click)
|
||||
click = false;
|
||||
return consume;
|
||||
}
|
||||
|
||||
void TextBox::setMode(StellarX::TextBoxmode mode)
|
||||
{
|
||||
this->mode = mode;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void TextBox::setMaxCharLen(size_t len)
|
||||
{
|
||||
if (len > 0)
|
||||
maxCharLen = len;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void TextBox::setTextBoxshape(StellarX::ControlShape shape)
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case StellarX::ControlShape::RECTANGLE:
|
||||
case StellarX::ControlShape::B_RECTANGLE:
|
||||
case StellarX::ControlShape::ROUND_RECTANGLE:
|
||||
case StellarX::ControlShape::B_ROUND_RECTANGLE:
|
||||
this->shape = shape;
|
||||
this->dirty = true;
|
||||
break;
|
||||
case StellarX::ControlShape::CIRCLE:
|
||||
case StellarX::ControlShape::B_CIRCLE:
|
||||
case StellarX::ControlShape::ELLIPSE:
|
||||
case StellarX::ControlShape::B_ELLIPSE:
|
||||
this->shape = StellarX::ControlShape::RECTANGLE;
|
||||
this->dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void TextBox::setTextBoxBorder(COLORREF color)
|
||||
{
|
||||
textBoxBorderClor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void TextBox::setTextBoxBk(COLORREF color)
|
||||
{
|
||||
textBoxBkClor = color;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
std::string TextBox::getText() const
|
||||
{
|
||||
return this->text;
|
||||
}
|
||||
|
||||
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
#include "Window.h"
|
||||
#include"Dialog.h"
|
||||
|
||||
Window::Window(int width, int height, int mode)
|
||||
{
|
||||
this->width = width;
|
||||
this->height = height;
|
||||
this->windowMode = mode;
|
||||
}
|
||||
|
||||
Window::Window(int width, int height, int mode, COLORREF bkcloc)
|
||||
{
|
||||
this->width = width;
|
||||
this->height = height;
|
||||
this->windowMode = mode;
|
||||
this->wBkcolor = bkcloc;
|
||||
}
|
||||
|
||||
Window::Window(int width, int height, int mode, COLORREF bkcloc, std::string headline)
|
||||
{
|
||||
this->width = width;
|
||||
this->height = height;
|
||||
this->windowMode = mode;
|
||||
this->wBkcolor = bkcloc;
|
||||
this->headline = headline;
|
||||
}
|
||||
|
||||
Window::~Window()
|
||||
{
|
||||
if (background)
|
||||
delete background;
|
||||
background = nullptr;
|
||||
closegraph(); // 确保关闭图形上下文
|
||||
}
|
||||
|
||||
void Window::draw()
|
||||
{
|
||||
hWnd = initgraph(width, height, windowMode);
|
||||
SetWindowText(hWnd,headline.c_str());
|
||||
setbkcolor(wBkcolor);
|
||||
cleardevice();
|
||||
BeginBatchDraw(); // 开始批量绘制
|
||||
// 绘制所有子控件
|
||||
for (auto& control : controls)
|
||||
control->draw();
|
||||
EndBatchDraw(); // 结束批量绘制
|
||||
}
|
||||
// 使用背景图片绘制窗口
|
||||
// @参数 pImgFile: 图片文件路径,支持常见图片格式
|
||||
// @备注: 会拉伸图片以适应窗口尺寸
|
||||
void Window::draw(std::string pImgFile)
|
||||
{
|
||||
this->background = new IMAGE(width, height);
|
||||
hWnd = initgraph(width, height, windowMode);
|
||||
SetWindowText(hWnd, headline.c_str());
|
||||
loadimage(background, pImgFile.c_str(), width, height, true);
|
||||
putimage(0,0, background);
|
||||
|
||||
// 绘制所有子控件
|
||||
BeginBatchDraw(); // 开始批量绘制
|
||||
for (auto& control : controls)
|
||||
control->draw();
|
||||
for(auto& d: dialogs)
|
||||
d->draw();
|
||||
EndBatchDraw(); // 结束批量绘制
|
||||
|
||||
}
|
||||
// 运行主事件循环,处理用户输入和窗口消息
|
||||
// 此方法会阻塞直到窗口关闭
|
||||
// 主消息循环优先级:对话框 > 普通控件。
|
||||
// 重绘策略:为保证视觉一致性,每次有对话框状态变化(打开/关闭)时,
|
||||
// 会强制重绘所有控件。先绘制普通控件,再绘制对话框(确保对话框在最上层)。
|
||||
void Window::runEventLoop()
|
||||
{
|
||||
ExMessage msg;
|
||||
bool running = true;
|
||||
|
||||
while (running)
|
||||
{
|
||||
bool consume = false;// 是否处理了消息
|
||||
|
||||
// 处理所有消息
|
||||
if (peekmessage(&msg, EX_MOUSE | EX_KEY | EX_WINDOW, true))
|
||||
{
|
||||
if (msg.message == WM_CLOSE)
|
||||
{
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// 优先处理对话框事件
|
||||
for (auto& d : dialogs)
|
||||
{
|
||||
if (d->IsVisible() && !d->model())
|
||||
consume = d->handleEvent(msg);
|
||||
if (consume)
|
||||
break;
|
||||
}
|
||||
|
||||
if(!consume)
|
||||
for (auto& c : controls)
|
||||
{
|
||||
consume = c->handleEvent(msg);
|
||||
if (consume)
|
||||
break;
|
||||
}
|
||||
}
|
||||
//如果有对话框打开或者关闭强制重绘
|
||||
bool needredraw = false;
|
||||
for (auto& d : dialogs)
|
||||
{
|
||||
needredraw = d->IsVisible();
|
||||
if (needredraw)break;
|
||||
}
|
||||
if (needredraw || dialogClose)
|
||||
{
|
||||
// 对话框关闭后,需要手动合成一个鼠标移动消息并分发给所有普通控件,
|
||||
// 以便它们能及时更新悬停状态(hover),否则悬停状态可能保持错误状态。
|
||||
// 先把当前鼠标位置转换为客户区坐标,并合成一次 WM_MOUSEMOVE,先分发给控件更新 hover 状态
|
||||
POINT pt;
|
||||
if (GetCursorPos(&pt))
|
||||
{
|
||||
ScreenToClient(this->hWnd, &pt);
|
||||
ExMessage mm;
|
||||
mm.message = WM_MOUSEMOVE;
|
||||
mm.x =(short) pt.x;
|
||||
mm.y =(short) pt.y;
|
||||
// 只分发给 window 层控件(因为 dialog 已经关闭或即将关闭)
|
||||
for (auto& c : controls)
|
||||
c->handleEvent(mm);
|
||||
}
|
||||
|
||||
BeginBatchDraw();
|
||||
// 先绘制普通控件
|
||||
for (auto& c : controls)
|
||||
c->draw();
|
||||
// 然后绘制对话框(确保对话框在最上层)
|
||||
for (auto& d : dialogs)
|
||||
{
|
||||
if (!d->model() && d->IsVisible())
|
||||
d->setDirty(true);
|
||||
d->draw();
|
||||
}
|
||||
EndBatchDraw();
|
||||
needredraw = false;
|
||||
|
||||
}
|
||||
|
||||
// 避免CPU占用过高
|
||||
Sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
void Window::setBkImage(std::string pImgFile)
|
||||
{
|
||||
if(nullptr == background)
|
||||
this->background = new IMAGE;
|
||||
else
|
||||
delete background;
|
||||
this->background = new IMAGE;
|
||||
loadimage(background, pImgFile.c_str(), width, height, true);
|
||||
putimage(0, 0, background);
|
||||
}
|
||||
|
||||
void Window::setBkcolor(COLORREF c)
|
||||
{
|
||||
wBkcolor = c;
|
||||
}
|
||||
|
||||
void Window::setHeadline(std::string headline)
|
||||
{
|
||||
this->headline = headline;
|
||||
SetWindowText(this->hWnd, headline.c_str());
|
||||
}
|
||||
|
||||
void Window::addControl(std::unique_ptr<Control> control)
|
||||
{
|
||||
this->controls.push_back(std::move(control));
|
||||
}
|
||||
|
||||
void Window::addDialog(std::unique_ptr<Control> dialogs)
|
||||
{
|
||||
this->dialogs.push_back(std::move(dialogs));
|
||||
}
|
||||
|
||||
bool Window::hasNonModalDialogWithCaption(const std::string& caption) const {
|
||||
for (const auto& dptr : dialogs) {
|
||||
if (!dptr) continue;
|
||||
// 只检查 Dialog 类型的控件
|
||||
Dialog* d = dynamic_cast<Dialog*>(dptr.get());
|
||||
//检查是否有非模态对话框可见,并且消息内容一致
|
||||
if (d && d->IsVisible() && !d->model() && d->GetCaption() == caption)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
HWND Window::getHwnd() const
|
||||
{
|
||||
return hWnd;
|
||||
}
|
||||
|
||||
int Window::getWidth() const
|
||||
{
|
||||
return this->width;
|
||||
}
|
||||
|
||||
int Window::getHeight() const
|
||||
{
|
||||
return this->height;
|
||||
}
|
||||
|
||||
std::string Window::getHeadline() const
|
||||
{
|
||||
return this->headline;
|
||||
}
|
||||
|
||||
COLORREF Window::getBkcolor() const
|
||||
{
|
||||
return this->wBkcolor;
|
||||
}
|
||||
|
||||
IMAGE* Window::getBkImage() const
|
||||
{
|
||||
return this->background;
|
||||
}
|
||||
|
||||
std::vector<std::unique_ptr<Control>>& Window::getControls()
|
||||
{
|
||||
return this->controls;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{6D338118-A536-4E4F-A4F5-3C73C5CA107A}</ProjectGuid>
|
||||
<RootNamespace>EasyXThreadProbe</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="EasyXThreadProbe.cpp" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}</ProjectGuid>
|
||||
<RootNamespace>EasyXVisualProbe</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
<TargetName>EasyX.VisualProbe</TargetName>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<PreprocessorDefinitions>WIN32_LEAN_AND_MEAN;NOMINMAX;_WIN32_WINNT=0x0A00;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="visual-probe\DpiCoordinateProbe.cpp" />
|
||||
<ClCompile Include="visual-probe\ImePositionProbe.cpp" />
|
||||
<ClCompile Include="visual-probe\VisualProbeCommon.cpp" />
|
||||
<ClCompile Include="visual-probe\VisualProbeMain.cpp" />
|
||||
<ClCompile Include="visual-probe\WideTextProbe.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="visual-probe\VisualProbeCommon.h" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,137 @@
|
||||
#include <easyx.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <windows.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr UINT ProbeMessage = WM_APP + 0x41;
|
||||
std::atomic<DWORD> probeWndProcThread{ 0 };
|
||||
std::atomic<DWORD> keyWndProcThread{ 0 };
|
||||
std::atomic<unsigned int> keyWndProcCount{ 0 };
|
||||
HANDLE probeObserved = nullptr;
|
||||
WNDPROC previousWndProc = nullptr;
|
||||
|
||||
LRESULT CALLBACK ProbeWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
if (message == ProbeMessage)
|
||||
{
|
||||
probeWndProcThread.store(GetCurrentThreadId(), std::memory_order_release);
|
||||
if (probeObserved)
|
||||
SetEvent(probeObserved);
|
||||
}
|
||||
else if (message == WM_KEYDOWN || message == WM_KEYUP)
|
||||
{
|
||||
keyWndProcThread.store(GetCurrentThreadId(), std::memory_order_release);
|
||||
keyWndProcCount.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
return previousWndProc
|
||||
? CallWindowProcA(previousWndProc, hwnd, message, wParam, lParam)
|
||||
: DefWindowProcA(hwnd, message, wParam, lParam);
|
||||
}
|
||||
|
||||
bool postFixedKey(HWND hwnd)
|
||||
{
|
||||
constexpr LPARAM KeyDownLParam = 1 | (0x1E << 16);
|
||||
constexpr LPARAM KeyUpLParam = KeyDownLParam | (1LL << 30) | (1LL << 31);
|
||||
return PostMessageA(hwnd, WM_KEYDOWN, 'A', KeyDownLParam) != FALSE &&
|
||||
PostMessageA(hwnd, WM_KEYUP, 'A', KeyUpLParam) != FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const std::string outputPath = argc > 1 ? argv[1] : "easyx-thread-probe.json";
|
||||
const DWORD processId = GetCurrentProcessId();
|
||||
const DWORD mainThreadBeforeInit = GetCurrentThreadId();
|
||||
HWND hwnd = initgraph(320, 180, 1);
|
||||
const DWORD mainThreadAfterInit = GetCurrentThreadId();
|
||||
DWORD windowProcessId = 0;
|
||||
const DWORD windowOwnerThread = GetWindowThreadProcessId(hwnd, &windowProcessId);
|
||||
const bool windowUnicode = IsWindowUnicode(hwnd) != FALSE;
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
|
||||
probeObserved = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
previousWndProc = reinterpret_cast<WNDPROC>(
|
||||
SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&ProbeWndProc)));
|
||||
const DWORD subclassError = previousWndProc ? ERROR_SUCCESS : GetLastError();
|
||||
|
||||
bool probePosted = false;
|
||||
bool probeDelivered = false;
|
||||
bool keyPosted = false;
|
||||
DWORD peekCallerThread = 0;
|
||||
unsigned int easyxKeyCount = 0;
|
||||
if (previousWndProc && probeObserved)
|
||||
{
|
||||
probePosted = PostMessageA(hwnd, ProbeMessage, 0, 0) != FALSE;
|
||||
probeDelivered = probePosted && WaitForSingleObject(probeObserved, 5000) == WAIT_OBJECT_0;
|
||||
keyPosted = postFixedKey(hwnd);
|
||||
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
|
||||
while (std::chrono::steady_clock::now() < deadline && easyxKeyCount < 2)
|
||||
{
|
||||
ExMessage message{};
|
||||
peekCallerThread = GetCurrentThreadId();
|
||||
if (peekmessage(&message, EX_KEY, true))
|
||||
{
|
||||
if ((message.message == WM_KEYDOWN || message.message == WM_KEYUP) &&
|
||||
message.vkcode == 'A')
|
||||
{
|
||||
++easyxKeyCount;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DWORD observedProbeThread = probeWndProcThread.load(std::memory_order_acquire);
|
||||
const DWORD observedKeyThread = keyWndProcThread.load(std::memory_order_acquire);
|
||||
const unsigned int observedKeyWndProcCount = keyWndProcCount.load(std::memory_order_relaxed);
|
||||
const bool separateWindowThread = windowOwnerThread != 0 && windowOwnerThread != mainThreadAfterInit;
|
||||
const bool wndProcMatchesOwner = observedProbeThread != 0 && observedProbeThread == windowOwnerThread;
|
||||
const bool keyCrossedBothBackends = keyPosted && observedKeyWndProcCount >= 2 && easyxKeyCount >= 2;
|
||||
const bool completed = hwnd != nullptr && previousWndProc != nullptr && probeDelivered &&
|
||||
wndProcMatchesOwner && keyCrossedBothBackends;
|
||||
|
||||
{
|
||||
std::ofstream output(outputPath, std::ios::trunc);
|
||||
output << "{\n"
|
||||
<< " \"schemaVersion\": 1,\n"
|
||||
<< " \"processId\": " << processId << ",\n"
|
||||
<< " \"windowProcessId\": " << windowProcessId << ",\n"
|
||||
<< " \"mainThreadBeforeInit\": " << mainThreadBeforeInit << ",\n"
|
||||
<< " \"mainThreadAfterInit\": " << mainThreadAfterInit << ",\n"
|
||||
<< " \"windowOwnerThread\": " << windowOwnerThread << ",\n"
|
||||
<< " \"probeWndProcThread\": " << observedProbeThread << ",\n"
|
||||
<< " \"keyWndProcThread\": " << observedKeyThread << ",\n"
|
||||
<< " \"peekCallerThread\": " << peekCallerThread << ",\n"
|
||||
<< " \"windowUnicode\": " << (windowUnicode ? "true" : "false") << ",\n"
|
||||
<< " \"subclassError\": " << subclassError << ",\n"
|
||||
<< " \"probePosted\": " << (probePosted ? "true" : "false") << ",\n"
|
||||
<< " \"probeDelivered\": " << (probeDelivered ? "true" : "false") << ",\n"
|
||||
<< " \"keyPosted\": " << (keyPosted ? "true" : "false") << ",\n"
|
||||
<< " \"keyWndProcCount\": " << observedKeyWndProcCount << ",\n"
|
||||
<< " \"easyxKeyCount\": " << easyxKeyCount << ",\n"
|
||||
<< " \"separateWindowThread\": " << (separateWindowThread ? "true" : "false") << ",\n"
|
||||
<< " \"wndProcMatchesOwner\": " << (wndProcMatchesOwner ? "true" : "false") << ",\n"
|
||||
<< " \"keyCrossedBothBackends\": " << (keyCrossedBothBackends ? "true" : "false") << ",\n"
|
||||
<< " \"completed\": " << (completed ? "true" : "false") << "\n"
|
||||
<< "}\n";
|
||||
}
|
||||
|
||||
if (previousWndProc)
|
||||
SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(previousWndProc));
|
||||
if (probeObserved)
|
||||
CloseHandle(probeObserved);
|
||||
closegraph();
|
||||
return completed ? 0 : 2;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{8779C678-AD5B-4828-A4A8-57D4C4254733}</ProjectGuid>
|
||||
<RootNamespace>StellarXTests</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(EnableAsan)'=='true' And '$(Platform)'=='x64'">
|
||||
<EnableASAN>true</EnableASAN>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)-asan\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)-asan\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level4</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
<PreprocessorDefinitions>STELLARX_TESTING;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)..\include\StellarX;$(ProjectDir)..\src\StellarX\internal;$(VCInstallDir)Auxiliary\VS\UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<AdditionalDependencies>Microsoft.VisualStudio.TestTools.CppUnitTestFramework.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
<AdditionalLibraryDirectories Condition="'$(Platform)'=='x64'">$(VCInstallDir)Auxiliary\VS\UnitTest\lib\x64;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||
<AdditionalLibraryDirectories Condition="'$(Platform)'=='Win32'">$(VCInstallDir)Auxiliary\VS\UnitTest\lib\x86;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup><ClCompile Include="StellarXTests.cpp" /></ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellarX.Core.vcxproj"><Project>{28B8D78E-85AF-4F14-AD56-D51AB6958941}</Project></ProjectReference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32"><Configuration>Debug</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32"><Configuration>Release</Configuration><Platform>Win32</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64"><Configuration>Debug</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64"><Configuration>Release</Configuration><Platform>x64</Platform></ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion><Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{D0AF4B0B-7379-4A20-BF10-68CC8DA4D905}</ProjectGuid>
|
||||
<RootNamespace>StellarXUiFixture</RootNamespace><WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Debug'">true</UseDebugLibraries>
|
||||
<UseDebugLibraries Condition="'$(Configuration)'=='Release'">false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset><CharacterSet>MultiByte</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<PropertyGroup>
|
||||
<OutDir>$(ProjectDir)..\build\$(Platform)\$(Configuration)\</OutDir>
|
||||
<IntDir>$(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\</IntDir>
|
||||
</PropertyGroup>
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile><WarningLevel>Level4</WarningLevel><SDLCheck>true</SDLCheck><ConformanceMode>true</ConformanceMode><LanguageStandard>stdcpp17</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader><AdditionalIncludeDirectories>$(ProjectDir)..\include\StellarX;$(ProjectDir)..\src\StellarX\internal;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories></ClCompile>
|
||||
<Link><SubSystem>Console</SubSystem><GenerateDebugInformation>true</GenerateDebugInformation></Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup><ClCompile Include="UiFixture.cpp" /></ItemGroup>
|
||||
<ItemGroup><ProjectReference Include="..\StellarX.Core.vcxproj"><Project>{28B8D78E-85AF-4F14-AD56-D51AB6958941}</Project></ProjectReference></ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
#include "StellarX.h"
|
||||
|
||||
#include "SxNativeInputInternal.h"
|
||||
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace
|
||||
{
|
||||
// 场景使用固定纯色,脚本只断言关键像素,不依赖字体抗锯齿或完整截图。
|
||||
constexpr COLORREF Background = RGB(30, 50, 70);
|
||||
constexpr COLORREF ButtonColor = RGB(80, 160, 220);
|
||||
constexpr COLORREF ClickColor = RGB(220, 90, 70);
|
||||
constexpr COLORREF HoverColor = RGB(250, 200, 80);
|
||||
|
||||
void WriteResult(const std::filesystem::path& resultDir, const std::string& scenario,
|
||||
const std::string& payload)
|
||||
{
|
||||
// JSON 是脚本与 fixture 之间的稳定协议;先建目录,再一次性覆盖当前检查点。
|
||||
std::filesystem::create_directories(resultDir);
|
||||
std::ofstream output(resultDir / (scenario + ".json"), std::ios::trunc);
|
||||
output << payload;
|
||||
}
|
||||
|
||||
std::unique_ptr<Button> MakeButton(int x, int y, int w, int h, const std::string& text)
|
||||
{
|
||||
// 统一按钮颜色,使 UI 回归可以区分背景、悬停和点击后的绘制结果。
|
||||
return std::make_unique<Button>(x, y, w, h, text, ClickColor, ButtonColor, HoverColor);
|
||||
}
|
||||
|
||||
class KeyboardObservationControl final : public Control
|
||||
{
|
||||
public:
|
||||
KeyboardObservationControl() : Control(0, 0, 1, 1) { id = "KeyboardObservation"; }
|
||||
void draw() override { dirty = false; }
|
||||
bool handleEvent(const ExMessage& message) override
|
||||
{
|
||||
if (message.message == WM_KEYDOWN || message.message == WM_SYSKEYDOWN)
|
||||
{
|
||||
++keyEventCount;
|
||||
++keyDownCount;
|
||||
}
|
||||
else if (message.message == WM_KEYUP || message.message == WM_SYSKEYUP)
|
||||
{
|
||||
++keyEventCount;
|
||||
++keyUpCount;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool model() const override { return false; }
|
||||
|
||||
std::uint64_t keyEventCount = 0;
|
||||
std::uint64_t keyDownCount = 0;
|
||||
std::uint64_t keyUpCount = 0;
|
||||
};
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
// fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。
|
||||
const std::string scenario = argc > 1 ? argv[1] : "geometry";
|
||||
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
|
||||
const bool consumeProbeScenario = scenario == "input-consume-probe";
|
||||
const int windowWidth = consumeProbeScenario ? 720 : 480;
|
||||
const int windowHeight = consumeProbeScenario ? 420 : 320;
|
||||
Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario);
|
||||
|
||||
if (scenario == "ownership")
|
||||
{
|
||||
// 点击后删除自身并新增状态标签,覆盖回调期间删除和新增的生命周期路径。
|
||||
auto button = MakeButton(20, 20, 120, 40, "remove self");
|
||||
Button* raw = button.get();
|
||||
const auto handle = window.addControl(std::move(button));
|
||||
raw->setOnClickListener([&]() {
|
||||
window.removeControl(handle);
|
||||
auto status = std::make_unique<Label>(20, 90, "removed safely", WHITE, Background);
|
||||
status->setTextdisap(true);
|
||||
window.addControl(std::move(status));
|
||||
WriteResult(resultDir, scenario, "{\"removed\":true}");
|
||||
});
|
||||
}
|
||||
else if (scenario == "geometry")
|
||||
{
|
||||
// 一次点击同时移动、缩放和隐藏多个控件,用于检查旧区域是否被完整恢复。
|
||||
auto button = MakeButton(20, 20, 120, 40, "move");
|
||||
Button* raw = button.get();
|
||||
auto label = std::make_unique<Label>(20, 85, "hide label", WHITE, RGB(90, 130, 70));
|
||||
Label* labelRaw = label.get();
|
||||
auto textBox = std::make_unique<TextBox>(20, 120, 120, 36, "resize");
|
||||
TextBox* textBoxRaw = textBox.get();
|
||||
textBoxRaw->setTextBoxBk(RGB(230, 230, 210));
|
||||
auto canvas = std::make_unique<Canvas>(20, 175, 120, 50);
|
||||
Canvas* canvasRaw = canvas.get();
|
||||
canvasRaw->setCanvasBkColor(RGB(40, 120, 90));
|
||||
|
||||
raw->setOnClickListener([&, raw, labelRaw, textBoxRaw, canvasRaw]() {
|
||||
raw->setX(220);
|
||||
labelRaw->setIsVisible(false);
|
||||
textBoxRaw->setX(220);
|
||||
textBoxRaw->setWidth(80);
|
||||
canvasRaw->setX(220);
|
||||
canvasRaw->setWidth(80);
|
||||
WriteResult(resultDir, scenario,
|
||||
"{\"moved\":true,\"x\":220,\"textWidth\":80,\"canvasWidth\":80,\"labelVisible\":false}");
|
||||
});
|
||||
window.addControl(std::move(button));
|
||||
window.addControl(std::move(label));
|
||||
window.addControl(std::move(textBox));
|
||||
window.addControl(std::move(canvas));
|
||||
}
|
||||
else if (scenario == "tooltip")
|
||||
{
|
||||
// 场景不再发送第二次鼠标移动,专门验证 Window tick 能让静止提示到期显示。
|
||||
auto button = MakeButton(20, 20, 120, 40, "tooltip");
|
||||
button->enableTooltip(true);
|
||||
button->setTooltipDelay(200);
|
||||
button->setTooltipText("stationary tip");
|
||||
window.addControl(std::move(button));
|
||||
}
|
||||
else if (scenario == "input-probe" || scenario == "input-consume-probe")
|
||||
{
|
||||
// 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。
|
||||
const bool consumeProbe = scenario == "input-consume-probe";
|
||||
const std::array<std::string, 5> instructions = consumeProbe
|
||||
? std::array<std::string, 5>{
|
||||
"StellarX stage 0B isolated consume probe",
|
||||
"This window is driven by the bounded diagnostic runner.",
|
||||
"Only one message, key and action rule is active.",
|
||||
"No control receives business keyboard routing.",
|
||||
"The runner will close this window after capture."
|
||||
}
|
||||
: std::array<std::string, 5>{
|
||||
"StellarX input probe - manual IME run",
|
||||
"1. Click this window, then switch to the requested IME.",
|
||||
"2. Type only the agreed non-sensitive test strings.",
|
||||
"3. Cover commit, candidate select, cancel and language switch.",
|
||||
"4. Close the window after the checklist is complete."
|
||||
};
|
||||
int y = 30;
|
||||
for (const std::string& line : instructions)
|
||||
{
|
||||
auto label = std::make_unique<Label>(20, y, line, WHITE, Background);
|
||||
label->setTextdisap(true);
|
||||
window.addControl(std::move(label));
|
||||
y += 42;
|
||||
}
|
||||
if (consumeProbe)
|
||||
{
|
||||
// 与 runner 传给 IMM32 的固定客户区坐标完全一致:行区 (200,255)-(680,284),
|
||||
// caret bottom 为 (225,282)。仅用于人工确认候选框没有落回桌面边缘。
|
||||
auto anchorLabel = std::make_unique<Label>(20, 260, "IME anchor:", WHITE, Background);
|
||||
anchorLabel->setTextdisap(true);
|
||||
window.addControl(std::move(anchorLabel));
|
||||
auto lineRegion = std::make_unique<Canvas>(200, 255, 480, 29);
|
||||
lineRegion->setCanvasBkColor(RGB(45, 70, 95));
|
||||
window.addControl(std::move(lineRegion));
|
||||
auto caret = std::make_unique<Canvas>(224, 258, 2, 24);
|
||||
caret->setCanvasBkColor(HoverColor);
|
||||
window.addControl(std::move(caret));
|
||||
}
|
||||
}
|
||||
else if (scenario == "input-shadow")
|
||||
{
|
||||
// Stage 1 keeps legacy EX_KEY delivery while the normalized queue is only
|
||||
// observed and discarded at host safe points.
|
||||
auto observer = std::make_unique<KeyboardObservationControl>();
|
||||
KeyboardObservationControl* observerRaw = observer.get();
|
||||
window.addControl(std::move(observer));
|
||||
|
||||
auto verify = MakeButton(20, 40, 160, 42, "verify input shadow");
|
||||
verify->setOnClickListener([&, observerRaw]() {
|
||||
const auto snapshot = StellarX::Internal::inspectWindowNativeInputForTesting(window);
|
||||
WriteResult(resultDir, scenario,
|
||||
std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) +
|
||||
",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) +
|
||||
",\"legacyKeyUpCount\":" + std::to_string(observerRaw->keyUpCount) +
|
||||
",\"pushed\":" + std::to_string(snapshot.queue.pushed) +
|
||||
",\"popped\":" + std::to_string(snapshot.queue.popped) +
|
||||
",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) +
|
||||
",\"maximumDepth\":" + std::to_string(snapshot.queue.maximumDepth) +
|
||||
",\"observed\":" + std::to_string(snapshot.observations.observed) +
|
||||
",\"keyEvents\":" + std::to_string(snapshot.observations.keyEvents) +
|
||||
",\"rejectedTextSequences\":" + std::to_string(snapshot.normalization.rejectedTextSequences) +
|
||||
",\"imeQueryFailures\":" + std::to_string(snapshot.normalization.imeQueryFailures) +
|
||||
",\"discardedDecoderFragments\":" + std::to_string(snapshot.normalization.discardedDecoderFragments) +
|
||||
",\"sequenceStrict\":" + (snapshot.observations.sequenceStrict ? "true" : "false") +
|
||||
",\"faulted\":" + (snapshot.faulted ? "true" : "false") + "}");
|
||||
});
|
||||
window.addControl(std::move(verify));
|
||||
}
|
||||
else if (scenario == "native-close")
|
||||
{
|
||||
// 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。
|
||||
}
|
||||
else if (scenario == "modal-close")
|
||||
{
|
||||
// 窗口首次绘制后立即进入真正的模态循环,由 UI 驱动关闭宿主原生窗口。
|
||||
// 该场景用于验证模态循环不会在 HWND 已销毁后继续轮询。
|
||||
}
|
||||
else if (scenario == "tab")
|
||||
{
|
||||
// 两个页签故意使用相同标题,验证索引身份而不是文本匹配决定激活状态。
|
||||
auto tabs = std::make_unique<TabControl>(20, 20, 300, 180);
|
||||
TabControl* raw = tabs.get();
|
||||
raw->setActiveIndex(0);
|
||||
auto firstButton = MakeButton(0, 0, 100, 30, "same");
|
||||
Button* firstButtonRaw = firstButton.get();
|
||||
auto secondButton = MakeButton(0, 0, 100, 30, "same");
|
||||
Button* secondButtonRaw = secondButton.get();
|
||||
auto firstPage = std::make_unique<Canvas>(0, 0, 300, 150);
|
||||
Canvas* firstPageRaw = firstPage.get();
|
||||
firstPageRaw->setCanvasBkColor(RGB(40, 100, 80));
|
||||
auto secondPage = std::make_unique<Canvas>(0, 0, 300, 150);
|
||||
Canvas* secondPageRaw = secondPage.get();
|
||||
secondPageRaw->setCanvasBkColor(RGB(110, 70, 120));
|
||||
raw->addTab(std::move(firstButton), std::move(firstPage));
|
||||
raw->addTab(std::move(secondButton), std::move(secondPage));
|
||||
window.addControl(std::move(tabs));
|
||||
auto verify = MakeButton(340, 220, 110, 36, "verify");
|
||||
verify->setOnClickListener([&, raw, firstButtonRaw, secondButtonRaw, firstPageRaw, secondPageRaw]() {
|
||||
WriteResult(resultDir, scenario, std::string("{\"active\":") +
|
||||
std::to_string(raw->getActiveIndex()) +
|
||||
",\"firstSelected\":" + (firstButtonRaw->isClicked() ? "true" : "false") +
|
||||
",\"secondSelected\":" + (secondButtonRaw->isClicked() ? "true" : "false") +
|
||||
",\"firstPageVisible\":" + (firstPageRaw->IsVisible() ? "true" : "false") +
|
||||
",\"secondPageVisible\":" + (secondPageRaw->IsVisible() ? "true" : "false") + "}");
|
||||
});
|
||||
window.addControl(std::move(verify));
|
||||
}
|
||||
else if (scenario == "table")
|
||||
{
|
||||
// 长表头和长单元格配合极窄宽度,验证最小宽度夹紧及 coverage 报告范围。
|
||||
auto table = std::make_unique<Table>(20, 20);
|
||||
Table* raw = table.get();
|
||||
raw->setHeaders({ "very-long-header", "second-long-header", "third" });
|
||||
raw->setData({ { "very-long-cell-value", "another-long-value", "value" } });
|
||||
window.addControl(std::move(table));
|
||||
auto resize = MakeButton(340, 250, 110, 36, "narrow");
|
||||
resize->setOnClickListener([&, raw]() {
|
||||
raw->setWidth(40);
|
||||
const RECT coverage = raw->getManagedRepaintCoverageRect();
|
||||
WriteResult(resultDir, scenario, std::string("{\"width\":") +
|
||||
std::to_string(raw->getWidth()) +
|
||||
",\"coverageLeft\":" + std::to_string(coverage.left) +
|
||||
",\"coverageTop\":" + std::to_string(coverage.top) +
|
||||
",\"coverageRight\":" + std::to_string(coverage.right) +
|
||||
",\"coverageBottom\":" + std::to_string(coverage.bottom) + "}");
|
||||
});
|
||||
window.addControl(std::move(resize));
|
||||
}
|
||||
else if (scenario == "focus")
|
||||
{
|
||||
// Stage 2 integration: real focus visuals, Table child eligibility, and active Tab page filtering.
|
||||
auto button = MakeButton(20, 20, 120, 40, "focus button");
|
||||
Button* buttonRaw = button.get();
|
||||
bool buttonFocusObserved = false;
|
||||
button->setOnClickListener([&]() { buttonFocusObserved = buttonRaw->hasLogicalFocus(); });
|
||||
window.addControl(std::move(button));
|
||||
|
||||
auto textBox = std::make_unique<TextBox>(20, 80, 160, 36, "focus text");
|
||||
TextBox* textBoxRaw = textBox.get();
|
||||
window.addControl(std::move(textBox));
|
||||
|
||||
auto table = std::make_unique<Table>(210, 20);
|
||||
const auto tableHandle = table->getHandle();
|
||||
table->setHeaders({ "value" });
|
||||
table->setRowsPerPage(1);
|
||||
table->setData({ { "one" }, { "two" } });
|
||||
window.addControl(std::move(table));
|
||||
|
||||
auto tabs = std::make_unique<TabControl>(20, 145, 300, 140);
|
||||
tabs->setActiveIndex(0);
|
||||
auto firstPage = std::make_unique<Canvas>(0, 0, 300, 110);
|
||||
auto firstPageButton = MakeButton(10, 40, 110, 30, "active child");
|
||||
const auto firstPageChildHandle = firstPageButton->getHandle();
|
||||
firstPage->addControl(std::move(firstPageButton));
|
||||
tabs->addTab(MakeButton(0, 0, 100, 30, "first"), std::move(firstPage));
|
||||
|
||||
auto secondPage = std::make_unique<Canvas>(0, 0, 300, 110);
|
||||
auto secondPageButton = MakeButton(10, 40, 110, 30, "inactive child");
|
||||
const auto secondPageChildHandle = secondPageButton->getHandle();
|
||||
secondPage->addControl(std::move(secondPageButton));
|
||||
tabs->addTab(MakeButton(100, 0, 100, 30, "second"), std::move(secondPage));
|
||||
window.addControl(std::move(tabs));
|
||||
|
||||
auto verify = MakeButton(350, 260, 110, 36, "verify");
|
||||
verify->setOnClickListener([&, buttonRaw]() {
|
||||
const auto stops = window.getFocusManager().collectTabStops();
|
||||
std::size_t tableTabStops = 0;
|
||||
bool activePageChildPresent = false;
|
||||
bool inactivePageChildPresent = false;
|
||||
bool allSubNodesZero = true;
|
||||
for (const auto& stop : stops)
|
||||
{
|
||||
if (stop.logicalParent.owner == tableHandle) ++tableTabStops;
|
||||
if (stop.id.owner == firstPageChildHandle) activePageChildPresent = true;
|
||||
if (stop.id.owner == secondPageChildHandle) inactivePageChildPresent = true;
|
||||
if (stop.id.subNode != 0) allSubNodesZero = false;
|
||||
}
|
||||
WriteResult(resultDir, scenario,
|
||||
std::string("{\"buttonFocusObserved\":") + (buttonFocusObserved ? "true" : "false") +
|
||||
",\"tableTabStops\":" + std::to_string(tableTabStops) +
|
||||
",\"activePageChildPresent\":" + (activePageChildPresent ? "true" : "false") +
|
||||
",\"inactivePageChildPresent\":" + (inactivePageChildPresent ? "true" : "false") +
|
||||
",\"allSubNodesZero\":" + (allSubNodesZero ? "true" : "false") + "}");
|
||||
});
|
||||
window.addControl(std::move(verify));
|
||||
textBoxRaw->requestFocus();
|
||||
}
|
||||
else if (scenario == "dialog")
|
||||
{
|
||||
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。
|
||||
struct DialogState { bool upperClosed = false; bool lowerClosed = false; };
|
||||
auto state = std::make_shared<DialogState>();
|
||||
auto writeDialogState = [&, state]() {
|
||||
WriteResult(resultDir, scenario, std::string("{\"upperClosed\":") +
|
||||
(state->upperClosed ? "true" : "false") + ",\"lowerClosed\":" +
|
||||
(state->lowerClosed ? "true" : "false") + "}");
|
||||
};
|
||||
auto open = MakeButton(20, 20, 140, 40, "open dialogs");
|
||||
open->setOnClickListener([&, state, writeDialogState]() {
|
||||
StellarX::MessageBox::showAsync(window, "lower", "lower", StellarX::MessageBoxType::OK,
|
||||
[state, writeDialogState](StellarX::MessageBoxResult) { state->lowerClosed = true; writeDialogState(); });
|
||||
StellarX::MessageBox::showAsync(window, "upper", "upper", StellarX::MessageBoxType::OK,
|
||||
[state, writeDialogState](StellarX::MessageBoxResult) { state->upperClosed = true; writeDialogState(); });
|
||||
});
|
||||
window.addControl(std::move(open));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 未知场景按参数错误处理,避免脚本误把未覆盖场景当成成功。
|
||||
WriteResult(resultDir, scenario, "{\"error\":\"unknown scenario\"}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
window.draw();
|
||||
if (scenario == "modal-close")
|
||||
{
|
||||
(void)StellarX::MessageBox::showModal(
|
||||
window, "close the native host window", "modal close", StellarX::MessageBoxType::OK);
|
||||
}
|
||||
return window.runEventLoop();
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
#include "VisualProbeCommon.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
namespace VisualProbe
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr COLORREF Background = RGB(238, 241, 246);
|
||||
constexpr COLORREF EasyXColor = RGB(220, 45, 70);
|
||||
constexpr COLORREF GdiColor = RGB(20, 150, 70);
|
||||
|
||||
struct RoundTripResult
|
||||
{
|
||||
POINT client{};
|
||||
POINT screen{};
|
||||
POINT clientRoundTrip{};
|
||||
int errorX = 0;
|
||||
int errorY = 0;
|
||||
bool converted = false;
|
||||
};
|
||||
|
||||
std::string normalizedRelative(const std::filesystem::path& path)
|
||||
{
|
||||
std::string value = path.generic_string();
|
||||
std::replace(value.begin(), value.end(), '\\', '/');
|
||||
return value;
|
||||
}
|
||||
|
||||
bool addSurfaceEvidence(
|
||||
const Options& options,
|
||||
HDC hdc,
|
||||
const std::string& fileName,
|
||||
const std::string& purpose,
|
||||
std::vector<EvidenceFile>& evidence)
|
||||
{
|
||||
const auto relative = std::filesystem::path("evidence") / fileName;
|
||||
const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight,
|
||||
options.outputDirectory / relative);
|
||||
if (saved) evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" });
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool addClientEvidence(
|
||||
const Options& options,
|
||||
HWND hwnd,
|
||||
const std::string& fileName,
|
||||
const std::string& purpose,
|
||||
std::vector<EvidenceFile>& evidence)
|
||||
{
|
||||
const auto relative = std::filesystem::path("evidence") / fileName;
|
||||
Sleep(30);
|
||||
std::string backend;
|
||||
const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
|
||||
if (saved) evidence.push_back({ normalizedRelative(relative), purpose, backend });
|
||||
return saved;
|
||||
}
|
||||
|
||||
RoundTripResult roundTrip(HWND hwnd, POINT client)
|
||||
{
|
||||
RoundTripResult result;
|
||||
result.client = client;
|
||||
result.screen = client;
|
||||
if (!ClientToScreen(hwnd, &result.screen)) return result;
|
||||
result.clientRoundTrip = result.screen;
|
||||
if (!ScreenToClient(hwnd, &result.clientRoundTrip)) return result;
|
||||
result.converted = true;
|
||||
result.errorX = result.clientRoundTrip.x - result.client.x;
|
||||
result.errorY = result.clientRoundTrip.y - result.client.y;
|
||||
return result;
|
||||
}
|
||||
|
||||
int boundsWidth(const PixelBounds& bounds)
|
||||
{
|
||||
return bounds.present ? bounds.right - bounds.left + 1 : 0;
|
||||
}
|
||||
|
||||
int boundsHeight(const PixelBounds& bounds)
|
||||
{
|
||||
return bounds.present ? bounds.bottom - bounds.top + 1 : 0;
|
||||
}
|
||||
|
||||
void writeRoundTrip(std::ostream& output, const RoundTripResult& result)
|
||||
{
|
||||
output << "{\"converted\":";
|
||||
writeBoolean(output, result.converted);
|
||||
output << ",\"client\":";
|
||||
writePointJson(output, result.client);
|
||||
output << ",\"screen\":";
|
||||
writePointJson(output, result.screen);
|
||||
output << ",\"clientRoundTrip\":";
|
||||
writePointJson(output, result.clientRoundTrip);
|
||||
output << ",\"error\":{\"x\":" << result.errorX
|
||||
<< ",\"y\":" << result.errorY << "}}";
|
||||
}
|
||||
}
|
||||
|
||||
int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
|
||||
{
|
||||
HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
|
||||
if (!hwnd)
|
||||
{
|
||||
std::cerr << "initgraph failed\n";
|
||||
return 3;
|
||||
}
|
||||
SetWindowTextA(hwnd, "StellarX 0C DPI Coordinate Probe");
|
||||
ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||
SetForegroundWindow(hwnd);
|
||||
Sleep(80);
|
||||
const EnvironmentSnapshot environment = captureEnvironment(hwnd);
|
||||
SetWorkingImage(nullptr);
|
||||
HDC hdc = GetImageHDC(nullptr);
|
||||
std::vector<EvidenceFile> evidence;
|
||||
bool evidenceComplete = true;
|
||||
|
||||
BeginBatchDraw();
|
||||
setbkcolor(Background);
|
||||
cleardevice();
|
||||
setlinestyle(PS_SOLID, 1);
|
||||
setlinecolor(EasyXColor);
|
||||
rectangle(100, 100, 240, 180);
|
||||
line(100, 140, 240, 140);
|
||||
line(170, 100, 170, 180);
|
||||
settextcolor(EasyXColor);
|
||||
setbkmode(TRANSPARENT);
|
||||
settextstyle(28, 0, "Microsoft YaHei UI");
|
||||
const char* easyXText = "StellarX DPI";
|
||||
outtextxy(100, 250, easyXText);
|
||||
const SIZE easyXLogicalExtent{ textwidth(easyXText), textheight(easyXText) };
|
||||
|
||||
const GdiState stateBefore = captureGdiState(hdc);
|
||||
const int saved = SaveDC(hdc);
|
||||
HPEN pen = CreatePen(PS_SOLID, 1, GdiColor);
|
||||
HGDIOBJ previousPen = pen ? SelectObject(hdc, pen) : nullptr;
|
||||
HBRUSH previousBrush = static_cast<HBRUSH>(SelectObject(hdc, GetStockObject(NULL_BRUSH)));
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
SetTextColor(hdc, GdiColor);
|
||||
Rectangle(hdc, 500, 100, 641, 181);
|
||||
MoveToEx(hdc, 500, 140, nullptr);
|
||||
LineTo(hdc, 641, 140);
|
||||
MoveToEx(hdc, 570, 100, nullptr);
|
||||
LineTo(hdc, 570, 181);
|
||||
const std::wstring gdiText = L"StellarX DPI";
|
||||
SIZE gdiLogicalExtent{};
|
||||
const bool gdiExtentSuccess = GetTextExtentPoint32W(hdc, gdiText.data(),
|
||||
static_cast<int>(gdiText.size()), &gdiLogicalExtent) != FALSE;
|
||||
const bool gdiTextSuccess = TextOutW(hdc, 500, 250, gdiText.data(),
|
||||
static_cast<int>(gdiText.size())) != FALSE;
|
||||
if (previousBrush) SelectObject(hdc, previousBrush);
|
||||
if (previousPen) SelectObject(hdc, previousPen);
|
||||
if (saved) RestoreDC(hdc, saved);
|
||||
if (pen) DeleteObject(pen);
|
||||
const GdiState stateAfter = captureGdiState(hdc);
|
||||
const bool gdiStateRestored = saved != 0 && sameGdiState(stateBefore, stateAfter);
|
||||
|
||||
const SurfaceFingerprint afterDraw = captureSurfaceFingerprint(hdc, CanvasWidth, CanvasHeight);
|
||||
const RECT easyXMarkerRegion{ 90, 90, 250, 190 };
|
||||
const RECT gdiMarkerRegion{ 490, 90, 650, 190 };
|
||||
const RECT easyXTextRegion{ 90, 240, 400, 310 };
|
||||
const RECT gdiTextRegion{ 490, 240, 800, 310 };
|
||||
const PixelBounds easyXMarkerSurface = findColorBounds(hdc, easyXMarkerRegion, EasyXColor);
|
||||
const PixelBounds gdiMarkerSurface = findColorBounds(hdc, gdiMarkerRegion, GdiColor);
|
||||
const PixelBounds easyXTextSurface = findChangedBounds(hdc, easyXTextRegion, Background);
|
||||
const PixelBounds gdiTextSurface = findChangedBounds(hdc, gdiTextRegion, Background);
|
||||
evidenceComplete &= addSurfaceEvidence(options, hdc, "dpi_after_draw_surface.png",
|
||||
"DPI surface after EasyX and raw GDI drawing", evidence);
|
||||
evidenceComplete &= addClientEvidence(options, hwnd, "dpi_before_flush_client.png",
|
||||
"DPI client before FlushBatchDraw", evidence);
|
||||
|
||||
FlushBatchDraw();
|
||||
evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_flush_client.png",
|
||||
"DPI client after FlushBatchDraw", evidence);
|
||||
EndBatchDraw();
|
||||
evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_end_client.png",
|
||||
"DPI client after EndBatchDraw", evidence);
|
||||
|
||||
PixelBounds easyXMarkerClient;
|
||||
PixelBounds gdiMarkerClient;
|
||||
HDC clientDc = GetDC(hwnd);
|
||||
if (clientDc)
|
||||
{
|
||||
easyXMarkerClient = findColorBounds(clientDc, easyXMarkerRegion, EasyXColor);
|
||||
gdiMarkerClient = findColorBounds(clientDc, gdiMarkerRegion, GdiColor);
|
||||
ReleaseDC(hwnd, clientDc);
|
||||
}
|
||||
|
||||
const std::array<POINT, 4> anchorPoints{
|
||||
POINT{ 0, 0 }, POINT{ 100, 100 }, POINT{ 500, 250 },
|
||||
POINT{ CanvasWidth - 1, CanvasHeight - 1 }
|
||||
};
|
||||
std::vector<RoundTripResult> roundTrips;
|
||||
bool roundTripsExact = true;
|
||||
for (const POINT point : anchorPoints)
|
||||
{
|
||||
roundTrips.push_back(roundTrip(hwnd, point));
|
||||
const auto& result = roundTrips.back();
|
||||
roundTripsExact = roundTripsExact && result.converted &&
|
||||
result.errorX == 0 && result.errorY == 0;
|
||||
}
|
||||
|
||||
const bool surfaceMarkerSizesAgree = easyXMarkerSurface.present && gdiMarkerSurface.present &&
|
||||
std::abs(boundsWidth(easyXMarkerSurface) - boundsWidth(gdiMarkerSurface)) <= 2 &&
|
||||
std::abs(boundsHeight(easyXMarkerSurface) - boundsHeight(gdiMarkerSurface)) <= 2;
|
||||
const bool clientMatchesSurface = easyXMarkerClient.present && gdiMarkerClient.present &&
|
||||
std::abs(boundsWidth(easyXMarkerClient) - boundsWidth(easyXMarkerSurface)) <= 1 &&
|
||||
std::abs(boundsHeight(easyXMarkerClient) - boundsHeight(easyXMarkerSurface)) <= 1 &&
|
||||
std::abs(boundsWidth(gdiMarkerClient) - boundsWidth(gdiMarkerSurface)) <= 1 &&
|
||||
std::abs(boundsHeight(gdiMarkerClient) - boundsHeight(gdiMarkerSurface)) <= 1;
|
||||
const bool implicitScalingObserved = !surfaceMarkerSizesAgree || !clientMatchesSurface;
|
||||
const bool completed = evidenceComplete && gdiStateRestored && gdiExtentSuccess &&
|
||||
gdiTextSuccess && easyXMarkerSurface.present && gdiMarkerSurface.present &&
|
||||
easyXTextSurface.present && gdiTextSurface.present && roundTripsExact;
|
||||
|
||||
std::ostringstream result;
|
||||
result << "{\"batchState\":\"inside\",\"surfaceFingerprintAfterDraw\":";
|
||||
writeSurfaceFingerprintJson(result, afterDraw);
|
||||
result << ",\"gdiStateRestored\":";
|
||||
writeBoolean(result, gdiStateRestored);
|
||||
result << ",\"easyXLogicalExtent\":";
|
||||
writeSizeJson(result, easyXLogicalExtent);
|
||||
result << ",\"gdiLogicalExtent\":";
|
||||
writeSizeJson(result, gdiLogicalExtent);
|
||||
result << ",\"gdiExtentSuccess\":";
|
||||
writeBoolean(result, gdiExtentSuccess);
|
||||
result << ",\"gdiTextSuccess\":";
|
||||
writeBoolean(result, gdiTextSuccess);
|
||||
result << ",\"surfaceBounds\":{\"easyXMarker\":";
|
||||
writeBoundsJson(result, easyXMarkerSurface);
|
||||
result << ",\"gdiMarker\":";
|
||||
writeBoundsJson(result, gdiMarkerSurface);
|
||||
result << ",\"easyXText\":";
|
||||
writeBoundsJson(result, easyXTextSurface);
|
||||
result << ",\"gdiText\":";
|
||||
writeBoundsJson(result, gdiTextSurface);
|
||||
result << "},\"clientBounds\":{\"easyXMarker\":";
|
||||
writeBoundsJson(result, easyXMarkerClient);
|
||||
result << ",\"gdiMarker\":";
|
||||
writeBoundsJson(result, gdiMarkerClient);
|
||||
result << "},\"surfaceMarkerSizesAgree\":";
|
||||
writeBoolean(result, surfaceMarkerSizesAgree);
|
||||
result << ",\"clientMatchesSurface\":";
|
||||
writeBoolean(result, clientMatchesSurface);
|
||||
result << ",\"implicitScalingObserved\":";
|
||||
writeBoolean(result, implicitScalingObserved);
|
||||
result << ",\"roundTripsExact\":";
|
||||
writeBoolean(result, roundTripsExact);
|
||||
result << ",\"coordinateRoundTrips\":[";
|
||||
for (std::size_t index = 0; index < roundTrips.size(); ++index)
|
||||
{
|
||||
if (index) result << ',';
|
||||
writeRoundTrip(result, roundTrips[index]);
|
||||
}
|
||||
result << "]}";
|
||||
SetWorkingImage(nullptr);
|
||||
closegraph();
|
||||
|
||||
std::string error;
|
||||
if (!writeRawResult(options, dpiConfiguration, environment, completed,
|
||||
evidence, result.str(), error))
|
||||
{
|
||||
std::cerr << error << '\n';
|
||||
return 4;
|
||||
}
|
||||
return completed ? 0 : 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
#include "VisualProbeCommon.h"
|
||||
|
||||
#include <imm.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
#pragma comment(lib, "Imm32.lib")
|
||||
|
||||
namespace VisualProbe
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct Anchor
|
||||
{
|
||||
const char* id;
|
||||
POINT controlOrigin;
|
||||
POINT localCaretTopLeft;
|
||||
POINT localCaretBottom;
|
||||
RECT localLineRect;
|
||||
};
|
||||
|
||||
struct ImePositionEvent
|
||||
{
|
||||
std::uint64_t sequence = 0;
|
||||
std::string stage;
|
||||
std::string positionTrigger = "none";
|
||||
std::string anchorId;
|
||||
DWORD threadId = 0;
|
||||
std::uintptr_t hkl = 0;
|
||||
std::wstring imeDescription;
|
||||
std::wstring imeFileName;
|
||||
bool apiAttempted = false;
|
||||
bool compositionActiveAtAttempt = false;
|
||||
bool contextAvailable = false;
|
||||
bool compositionSucceeded = false;
|
||||
bool candidateSucceeded = false;
|
||||
POINT hwndRelativePoint{};
|
||||
RECT hwndRelativeLineRect{};
|
||||
POINT screenPoint{};
|
||||
};
|
||||
|
||||
struct ImePositionState
|
||||
{
|
||||
HWND hwnd = nullptr;
|
||||
WNDPROC previousWndProc = nullptr;
|
||||
std::atomic<int> activeAnchor{ 1 };
|
||||
std::atomic<bool> compositionActive{ false };
|
||||
std::atomic<bool> closeRequested{ false };
|
||||
std::atomic<std::uint64_t> nextSequence{ 0 };
|
||||
std::mutex eventsMutex;
|
||||
std::vector<ImePositionEvent> events;
|
||||
};
|
||||
|
||||
constexpr std::array<Anchor, 3> Anchors{
|
||||
Anchor{ "anchor_top_left_safe", POINT{ 80, 125 }, POINT{ 30, 35 }, POINT{ 30, 61 }, RECT{ 10, 30, 250, 66 } },
|
||||
Anchor{ "anchor_center", POINT{ 350, 305 }, POINT{ 130, 55 }, POINT{ 130, 81 }, RECT{ 10, 50, 260, 86 } },
|
||||
Anchor{ "anchor_bottom_right_safe", POINT{ 650, 520 }, POINT{ 180, 45 }, POINT{ 180, 71 }, RECT{ 10, 40, 230, 76 } }
|
||||
};
|
||||
|
||||
ImePositionState* activeState = nullptr;
|
||||
|
||||
POINT surfaceCaret(const Anchor& anchor) noexcept
|
||||
{
|
||||
return POINT{
|
||||
anchor.controlOrigin.x + anchor.localCaretBottom.x,
|
||||
anchor.controlOrigin.y + anchor.localCaretBottom.y
|
||||
};
|
||||
}
|
||||
|
||||
std::wstring imeDescription(HKL hkl)
|
||||
{
|
||||
const UINT length = ImmGetDescriptionW(hkl, nullptr, 0);
|
||||
if (!length) return {};
|
||||
std::wstring value(length + 1, L'\0');
|
||||
const UINT written = ImmGetDescriptionW(hkl, value.data(), static_cast<UINT>(value.size()));
|
||||
value.resize(written);
|
||||
return value;
|
||||
}
|
||||
|
||||
std::wstring imeFileName(HKL hkl)
|
||||
{
|
||||
const UINT length = ImmGetIMEFileNameW(hkl, nullptr, 0);
|
||||
if (!length) return {};
|
||||
std::wstring value(length + 1, L'\0');
|
||||
const UINT written = ImmGetIMEFileNameW(hkl, value.data(), static_cast<UINT>(value.size()));
|
||||
value.resize(written);
|
||||
return value;
|
||||
}
|
||||
|
||||
void appendEvent(ImePositionState& state, ImePositionEvent event)
|
||||
{
|
||||
event.sequence = state.nextSequence.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
std::lock_guard<std::mutex> lock(state.eventsMutex);
|
||||
state.events.push_back(std::move(event));
|
||||
}
|
||||
|
||||
void recordLifecycle(ImePositionState& state, const char* stage)
|
||||
{
|
||||
const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire);
|
||||
ImePositionEvent event;
|
||||
event.stage = stage;
|
||||
event.anchorId = Anchors[static_cast<std::size_t>(anchorIndex)].id;
|
||||
event.threadId = GetCurrentThreadId();
|
||||
const HKL hkl = GetKeyboardLayout(0);
|
||||
event.hkl = reinterpret_cast<std::uintptr_t>(hkl);
|
||||
event.imeDescription = imeDescription(hkl);
|
||||
event.imeFileName = imeFileName(hkl);
|
||||
appendEvent(state, std::move(event));
|
||||
}
|
||||
|
||||
void configureImePosition(ImePositionState& state, const char* stage, const char* positionTrigger)
|
||||
{
|
||||
const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire);
|
||||
const Anchor& anchor = Anchors[static_cast<std::size_t>(anchorIndex)];
|
||||
ImePositionEvent event;
|
||||
event.stage = stage;
|
||||
event.positionTrigger = positionTrigger;
|
||||
event.anchorId = anchor.id;
|
||||
event.threadId = GetCurrentThreadId();
|
||||
const HKL hkl = GetKeyboardLayout(0);
|
||||
event.hkl = reinterpret_cast<std::uintptr_t>(hkl);
|
||||
event.imeDescription = imeDescription(hkl);
|
||||
event.imeFileName = imeFileName(hkl);
|
||||
event.apiAttempted = true;
|
||||
event.compositionActiveAtAttempt = state.compositionActive.load(std::memory_order_acquire);
|
||||
event.hwndRelativePoint = surfaceCaret(anchor);
|
||||
event.hwndRelativeLineRect = RECT{
|
||||
anchor.controlOrigin.x + anchor.localLineRect.left,
|
||||
anchor.controlOrigin.y + anchor.localLineRect.top,
|
||||
anchor.controlOrigin.x + anchor.localLineRect.right,
|
||||
anchor.controlOrigin.y + anchor.localLineRect.bottom
|
||||
};
|
||||
event.screenPoint = event.hwndRelativePoint;
|
||||
ClientToScreen(state.hwnd, &event.screenPoint);
|
||||
|
||||
HIMC context = ImmGetContext(state.hwnd);
|
||||
event.contextAvailable = context != 0;
|
||||
if (context)
|
||||
{
|
||||
COMPOSITIONFORM composition{};
|
||||
composition.dwStyle = CFS_POINT;
|
||||
composition.ptCurrentPos = event.hwndRelativePoint;
|
||||
event.compositionSucceeded = ImmSetCompositionWindow(context, &composition) != FALSE;
|
||||
|
||||
CANDIDATEFORM candidate{};
|
||||
candidate.dwIndex = 0;
|
||||
candidate.dwStyle = CFS_EXCLUDE;
|
||||
candidate.ptCurrentPos = event.hwndRelativePoint;
|
||||
candidate.rcArea = event.hwndRelativeLineRect;
|
||||
event.candidateSucceeded = ImmSetCandidateWindow(context, &candidate) != FALSE;
|
||||
ImmReleaseContext(state.hwnd, context);
|
||||
}
|
||||
appendEvent(state, std::move(event));
|
||||
}
|
||||
|
||||
LRESULT CALLBACK ProbeWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
ImePositionState* state = activeState;
|
||||
if (!state)
|
||||
return DefWindowProcA(hwnd, message, wParam, lParam);
|
||||
|
||||
if (message == WM_KEYDOWN && wParam >= VK_F1 && wParam <= VK_F3)
|
||||
{
|
||||
state->activeAnchor.store(static_cast<int>(wParam - VK_F1), std::memory_order_release);
|
||||
configureImePosition(*state, "anchor_preconfigure", "anchor_change");
|
||||
return 0;
|
||||
}
|
||||
else if (message == WM_IME_STARTCOMPOSITION)
|
||||
{
|
||||
state->compositionActive.store(true, std::memory_order_release);
|
||||
configureImePosition(*state, "ime_start", "ime_lifecycle");
|
||||
}
|
||||
else if (message == WM_IME_COMPOSITION)
|
||||
{
|
||||
configureImePosition(*state, "ime_composition", "ime_lifecycle");
|
||||
}
|
||||
else if (message == WM_IME_ENDCOMPOSITION)
|
||||
{
|
||||
recordLifecycle(*state, "ime_end");
|
||||
state->compositionActive.store(false, std::memory_order_release);
|
||||
}
|
||||
else if (message == WM_INPUTLANGCHANGE)
|
||||
{
|
||||
recordLifecycle(*state, "input_language_change");
|
||||
}
|
||||
else if (message == WM_CLOSE)
|
||||
{
|
||||
recordLifecycle(*state, "close_requested");
|
||||
state->closeRequested.store(true, std::memory_order_release);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return state->previousWndProc
|
||||
? CallWindowProcA(state->previousWndProc, hwnd, message, wParam, lParam)
|
||||
: DefWindowProcA(hwnd, message, wParam, lParam);
|
||||
}
|
||||
|
||||
void drawAnchorScreen(int activeAnchorIndex)
|
||||
{
|
||||
SetWorkingImage(nullptr);
|
||||
BeginBatchDraw();
|
||||
setbkcolor(RGB(238, 241, 246));
|
||||
cleardevice();
|
||||
setbkmode(TRANSPARENT);
|
||||
settextstyle(22, 0, "Microsoft YaHei UI");
|
||||
settextcolor(RGB(30, 45, 70));
|
||||
outtextxy(30, 25, "IME position probe: F1/F2/F3 select anchor; close window when done");
|
||||
outtextxy(30, 58, "Use only fixed non-sensitive input: ceshi; inspect candidate position; Esc cancels");
|
||||
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||
{
|
||||
const Anchor& anchor = Anchors[index];
|
||||
const bool selected = static_cast<int>(index) == activeAnchorIndex;
|
||||
const COLORREF color = selected ? RGB(220, 60, 45) : RGB(45, 125, 190);
|
||||
const POINT caret = surfaceCaret(anchor);
|
||||
setlinecolor(color);
|
||||
setlinestyle(PS_SOLID, selected ? 3 : 1);
|
||||
rectangle(anchor.controlOrigin.x, anchor.controlOrigin.y,
|
||||
anchor.controlOrigin.x + anchor.localLineRect.right,
|
||||
anchor.controlOrigin.y + anchor.localLineRect.bottom + 12);
|
||||
line(caret.x, caret.y - 26, caret.x, caret.y + 4);
|
||||
line(caret.x - 5, caret.y, caret.x + 5, caret.y);
|
||||
settextcolor(color);
|
||||
const std::string label = std::string("F") + std::to_string(index + 1) + " " + anchor.id;
|
||||
outtextxy(anchor.controlOrigin.x + 8, anchor.controlOrigin.y + 5, label.c_str());
|
||||
}
|
||||
EndBatchDraw();
|
||||
}
|
||||
|
||||
std::string normalizedRelative(const std::filesystem::path& path)
|
||||
{
|
||||
std::string value = path.generic_string();
|
||||
std::replace(value.begin(), value.end(), '\\', '/');
|
||||
return value;
|
||||
}
|
||||
|
||||
void writeAnchor(std::ostream& output, const Anchor& anchor)
|
||||
{
|
||||
const POINT surfaceTopLeft{
|
||||
anchor.controlOrigin.x + anchor.localCaretTopLeft.x,
|
||||
anchor.controlOrigin.y + anchor.localCaretTopLeft.y
|
||||
};
|
||||
const POINT surfaceBottom = surfaceCaret(anchor);
|
||||
RECT surfaceLine{
|
||||
anchor.controlOrigin.x + anchor.localLineRect.left,
|
||||
anchor.controlOrigin.y + anchor.localLineRect.top,
|
||||
anchor.controlOrigin.x + anchor.localLineRect.right,
|
||||
anchor.controlOrigin.y + anchor.localLineRect.bottom
|
||||
};
|
||||
output << "{\"syntheticAnchorId\":" << jsonString(anchor.id)
|
||||
<< ",\"controlOrigin\":";
|
||||
writePointJson(output, anchor.controlOrigin);
|
||||
output << ",\"controlLocalCaretTopLeft\":";
|
||||
writePointJson(output, anchor.localCaretTopLeft);
|
||||
output << ",\"controlLocalCaretBottom\":";
|
||||
writePointJson(output, anchor.localCaretBottom);
|
||||
output << ",\"controlLocalLineRect\":";
|
||||
writeRectJson(output, anchor.localLineRect);
|
||||
output << ",\"easyxSurfaceCaretTopLeft\":";
|
||||
writePointJson(output, surfaceTopLeft);
|
||||
output << ",\"easyxSurfaceCaretBottom\":";
|
||||
writePointJson(output, surfaceBottom);
|
||||
output << ",\"easyxSurfaceLineRect\":";
|
||||
writeRectJson(output, surfaceLine);
|
||||
output << ",\"targetHwndRelativePoint\":";
|
||||
writePointJson(output, surfaceBottom);
|
||||
output << '}';
|
||||
}
|
||||
|
||||
void writeEvent(std::ostream& output, const ImePositionEvent& event)
|
||||
{
|
||||
output << "{\"sequence\":" << event.sequence
|
||||
<< ",\"stage\":" << jsonString(event.stage)
|
||||
<< ",\"positionTrigger\":" << jsonString(event.positionTrigger)
|
||||
<< ",\"syntheticAnchorId\":" << jsonString(event.anchorId)
|
||||
<< ",\"threadId\":" << event.threadId
|
||||
<< ",\"hkl\":" << jsonString(hexValue(event.hkl))
|
||||
<< ",\"imeDescriptionUtf16\":" << utf16UnitsJson(event.imeDescription)
|
||||
<< ",\"imeFileNameUtf16\":" << utf16UnitsJson(event.imeFileName)
|
||||
<< ",\"apiAttempted\":";
|
||||
writeBoolean(output, event.apiAttempted);
|
||||
output << ",\"compositionActiveAtAttempt\":";
|
||||
writeBoolean(output, event.compositionActiveAtAttempt);
|
||||
output << ",\"contextAvailable\":";
|
||||
writeBoolean(output, event.contextAvailable);
|
||||
output << ",\"compositionForm\":{\"style\":" << CFS_POINT
|
||||
<< ",\"apiResult\":";
|
||||
writeBoolean(output, event.compositionSucceeded);
|
||||
output << "},\"candidateForm\":{\"style\":" << CFS_EXCLUDE
|
||||
<< ",\"index\":0,\"apiResult\":";
|
||||
writeBoolean(output, event.candidateSucceeded);
|
||||
output << ",\"excludeRect\":";
|
||||
writeRectJson(output, event.hwndRelativeLineRect);
|
||||
output << "},\"targetHwndRelativePoint\":";
|
||||
writePointJson(output, event.hwndRelativePoint);
|
||||
output << ",\"diagnosticScreenPoint\":";
|
||||
writePointJson(output, event.screenPoint);
|
||||
output << '}';
|
||||
}
|
||||
}
|
||||
|
||||
int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
|
||||
{
|
||||
HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
|
||||
if (!hwnd)
|
||||
{
|
||||
std::cerr << "initgraph failed\n";
|
||||
return 3;
|
||||
}
|
||||
SetWindowTextA(hwnd, "StellarX 0C IME Position Probe");
|
||||
ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||
SetForegroundWindow(hwnd);
|
||||
Sleep(80);
|
||||
const EnvironmentSnapshot environment = captureEnvironment(hwnd);
|
||||
DWORD windowProcessId = 0;
|
||||
const DWORD windowOwnerThread = GetWindowThreadProcessId(hwnd, &windowProcessId);
|
||||
|
||||
ImePositionState state;
|
||||
state.hwnd = hwnd;
|
||||
activeState = &state;
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
state.previousWndProc = reinterpret_cast<WNDPROC>(SetWindowLongPtrA(
|
||||
hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&ProbeWndProc)));
|
||||
const DWORD subclassError = state.previousWndProc ? ERROR_SUCCESS : GetLastError();
|
||||
|
||||
std::vector<EvidenceFile> evidence;
|
||||
bool evidenceComplete = state.previousWndProc != nullptr;
|
||||
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||
{
|
||||
drawAnchorScreen(static_cast<int>(index));
|
||||
Sleep(40);
|
||||
const auto relative = std::filesystem::path("evidence") /
|
||||
(std::string(Anchors[index].id) + "_client.png");
|
||||
std::string backend;
|
||||
const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
|
||||
evidenceComplete = evidenceComplete && saved;
|
||||
if (saved)
|
||||
evidence.push_back({ normalizedRelative(relative), "synthetic anchor client evidence", backend });
|
||||
}
|
||||
state.activeAnchor.store(1, std::memory_order_release);
|
||||
drawAnchorScreen(1);
|
||||
|
||||
int lastDrawnAnchor = 1;
|
||||
const ULONGLONG loopStarted = GetTickCount64();
|
||||
while (!state.closeRequested.load(std::memory_order_acquire) && IsWindow(hwnd))
|
||||
{
|
||||
if (options.autoCloseMilliseconds > 0 &&
|
||||
GetTickCount64() - loopStarted >= options.autoCloseMilliseconds)
|
||||
{
|
||||
recordLifecycle(state, "auto_close");
|
||||
state.closeRequested.store(true, std::memory_order_release);
|
||||
break;
|
||||
}
|
||||
const int currentAnchor = state.activeAnchor.load(std::memory_order_acquire);
|
||||
if (currentAnchor != lastDrawnAnchor)
|
||||
{
|
||||
drawAnchorScreen(currentAnchor);
|
||||
lastDrawnAnchor = currentAnchor;
|
||||
}
|
||||
Sleep(16);
|
||||
}
|
||||
|
||||
if (state.previousWndProc && IsWindow(hwnd))
|
||||
SetWindowLongPtrA(hwnd, GWLP_WNDPROC,
|
||||
reinterpret_cast<LONG_PTR>(state.previousWndProc));
|
||||
activeState = nullptr;
|
||||
if (IsWindow(hwnd)) closegraph();
|
||||
|
||||
std::vector<ImePositionEvent> events;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.eventsMutex);
|
||||
events = state.events;
|
||||
}
|
||||
std::array<bool, Anchors.size()> anchorSucceeded{};
|
||||
bool imeLifecycleObserved = false;
|
||||
bool imeLifecycleSucceeded = false;
|
||||
for (const auto& event : events)
|
||||
{
|
||||
if (event.positionTrigger == "ime_lifecycle") imeLifecycleObserved = true;
|
||||
if (event.positionTrigger == "ime_lifecycle" && event.contextAvailable &&
|
||||
event.compositionSucceeded && event.candidateSucceeded)
|
||||
{
|
||||
imeLifecycleSucceeded = true;
|
||||
}
|
||||
if (!event.apiAttempted || !event.compositionSucceeded || !event.candidateSucceeded)
|
||||
continue;
|
||||
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||
{
|
||||
if (event.anchorId == Anchors[index].id) anchorSucceeded[index] = true;
|
||||
}
|
||||
}
|
||||
bool allAnchorsSucceeded = true;
|
||||
for (const bool value : anchorSucceeded) allAnchorsSucceeded = allAnchorsSucceeded && value;
|
||||
const bool completed = evidenceComplete && allAnchorsSucceeded && imeLifecycleSucceeded;
|
||||
|
||||
std::ostringstream result;
|
||||
result << "{\"expectedImeName\":" << jsonString(options.expectedImeName)
|
||||
<< ",\"windowOwnerThreadId\":" << windowOwnerThread
|
||||
<< ",\"windowProcessId\":" << windowProcessId
|
||||
<< ",\"subclassInstalled\":";
|
||||
writeBoolean(result, state.previousWndProc != nullptr);
|
||||
result << ",\"subclassError\":" << subclassError
|
||||
<< ",\"allAnchorsApiSucceeded\":";
|
||||
writeBoolean(result, allAnchorsSucceeded);
|
||||
result << ",\"imeLifecycleObserved\":";
|
||||
writeBoolean(result, imeLifecycleObserved);
|
||||
result << ",\"imeLifecycleSucceeded\":";
|
||||
writeBoolean(result, imeLifecycleSucceeded);
|
||||
result << ",\"anchors\":[";
|
||||
for (std::size_t index = 0; index < Anchors.size(); ++index)
|
||||
{
|
||||
if (index) result << ',';
|
||||
writeAnchor(result, Anchors[index]);
|
||||
}
|
||||
result << "],\"imeEvents\":[";
|
||||
for (std::size_t index = 0; index < events.size(); ++index)
|
||||
{
|
||||
if (index) result << ',';
|
||||
writeEvent(result, events[index]);
|
||||
}
|
||||
result << "]}";
|
||||
|
||||
std::string error;
|
||||
if (!writeRawResult(options, dpiConfiguration, environment, completed,
|
||||
evidence, result.str(), error))
|
||||
{
|
||||
std::cerr << error << '\n';
|
||||
return 4;
|
||||
}
|
||||
return completed ? 0 : 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
#include "VisualProbeCommon.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
|
||||
namespace VisualProbe
|
||||
{
|
||||
namespace
|
||||
{
|
||||
using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(DPI_AWARENESS_CONTEXT);
|
||||
using GetDpiForWindowFn = UINT(WINAPI*)(HWND);
|
||||
using GetThreadDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)();
|
||||
using GetWindowDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)(HWND);
|
||||
using GetAwarenessFromDpiAwarenessContextFn = DPI_AWARENESS(WINAPI*)(DPI_AWARENESS_CONTEXT);
|
||||
using GetProcessDpiAwarenessFn = HRESULT(WINAPI*)(HANDLE, int*);
|
||||
|
||||
std::string awarenessName(int awareness)
|
||||
{
|
||||
switch (awareness)
|
||||
{
|
||||
case 0: return "unaware";
|
||||
case 1: return "system";
|
||||
case 2: return "per-monitor";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
std::string contextAwarenessName(DPI_AWARENESS_CONTEXT context) noexcept
|
||||
{
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
const auto getAwareness = user32 ? reinterpret_cast<GetAwarenessFromDpiAwarenessContextFn>(
|
||||
GetProcAddress(user32, "GetAwarenessFromDpiAwarenessContext")) : nullptr;
|
||||
if (!getAwareness || !context)
|
||||
return "not_available";
|
||||
return awarenessName(static_cast<int>(getAwareness(context)));
|
||||
}
|
||||
|
||||
DPI_AWARENESS_CONTEXT requestedContext(const std::string& requested) noexcept
|
||||
{
|
||||
if (requested == "unaware") return DPI_AWARENESS_CONTEXT_UNAWARE;
|
||||
if (requested == "system") return DPI_AWARENESS_CONTEXT_SYSTEM_AWARE;
|
||||
if (requested == "per-monitor") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE;
|
||||
if (requested == "per-monitor-v2") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::uint64_t fnv1a(const std::vector<std::uint32_t>& pixels) noexcept
|
||||
{
|
||||
std::uint64_t value = 1469598103934665603ull;
|
||||
for (const std::uint32_t pixel : pixels)
|
||||
{
|
||||
for (unsigned int shift = 0; shift < 32; shift += 8)
|
||||
{
|
||||
value ^= static_cast<unsigned char>((pixel >> shift) & 0xFFu);
|
||||
value *= 1099511628211ull;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> readPixels(
|
||||
HDC hdc,
|
||||
int width,
|
||||
int height,
|
||||
int sourceX = 0,
|
||||
int sourceY = 0) noexcept
|
||||
{
|
||||
std::vector<std::uint32_t> pixels;
|
||||
if (!hdc || width <= 0 || height <= 0)
|
||||
return pixels;
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader.biSize = sizeof(info.bmiHeader);
|
||||
info.bmiHeader.biWidth = width;
|
||||
info.bmiHeader.biHeight = -height;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
HDC memoryDc = CreateCompatibleDC(hdc);
|
||||
if (!memoryDc)
|
||||
return pixels;
|
||||
HBITMAP bitmap = CreateDIBSection(hdc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
if (!bitmap || !bits)
|
||||
{
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
DeleteDC(memoryDc);
|
||||
return pixels;
|
||||
}
|
||||
HGDIOBJ previous = SelectObject(memoryDc, bitmap);
|
||||
const bool copied = previous && BitBlt(memoryDc, 0, 0, width, height,
|
||||
hdc, sourceX, sourceY, SRCCOPY) != FALSE;
|
||||
if (copied)
|
||||
{
|
||||
try
|
||||
{
|
||||
const auto* first = static_cast<const std::uint32_t*>(bits);
|
||||
pixels.assign(first, first + static_cast<std::size_t>(width) * height);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
pixels.clear();
|
||||
}
|
||||
}
|
||||
if (previous) SelectObject(memoryDc, previous);
|
||||
DeleteObject(bitmap);
|
||||
DeleteDC(memoryDc);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
bool xformEqual(const XFORM& left, const XFORM& right) noexcept
|
||||
{
|
||||
constexpr float Epsilon = 0.0001f;
|
||||
return std::fabs(left.eM11 - right.eM11) < Epsilon &&
|
||||
std::fabs(left.eM12 - right.eM12) < Epsilon &&
|
||||
std::fabs(left.eM21 - right.eM21) < Epsilon &&
|
||||
std::fabs(left.eM22 - right.eM22) < Epsilon &&
|
||||
std::fabs(left.eDx - right.eDx) < Epsilon &&
|
||||
std::fabs(left.eDy - right.eDy) < Epsilon;
|
||||
}
|
||||
|
||||
std::string captureOsBuild() noexcept
|
||||
{
|
||||
using RtlGetVersionFn = LONG(WINAPI*)(OSVERSIONINFOW*);
|
||||
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
const auto rtlGetVersion = ntdll ? reinterpret_cast<RtlGetVersionFn>(
|
||||
GetProcAddress(ntdll, "RtlGetVersion")) : nullptr;
|
||||
OSVERSIONINFOW version{};
|
||||
version.dwOSVersionInfoSize = sizeof(version);
|
||||
if (!rtlGetVersion || rtlGetVersion(&version) != 0)
|
||||
return "not_available";
|
||||
std::ostringstream output;
|
||||
output << version.dwMajorVersion << '.' << version.dwMinorVersion
|
||||
<< '.' << version.dwBuildNumber;
|
||||
return output.str();
|
||||
}
|
||||
}
|
||||
|
||||
bool parseOptions(int argc, char** argv, Options& options, std::string& error)
|
||||
{
|
||||
for (int index = 1; index < argc; ++index)
|
||||
{
|
||||
const std::string argument = argv[index];
|
||||
if ((argument == "--mode" || argument == "--output" || argument == "--dpi-awareness" ||
|
||||
argument == "--ime-name" || argument == "--auto-close-ms") &&
|
||||
index + 1 >= argc)
|
||||
{
|
||||
error = "Missing value for " + argument;
|
||||
return false;
|
||||
}
|
||||
if (argument == "--mode") options.mode = argv[++index];
|
||||
else if (argument == "--output") options.outputDirectory = argv[++index];
|
||||
else if (argument == "--dpi-awareness") options.requestedDpiAwareness = argv[++index];
|
||||
else if (argument == "--ime-name") options.expectedImeName = argv[++index];
|
||||
else if (argument == "--auto-close-ms")
|
||||
{
|
||||
try
|
||||
{
|
||||
options.autoCloseMilliseconds = static_cast<unsigned int>(std::stoul(argv[++index]));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
error = "Invalid auto-close duration";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
error = "Unknown argument: " + argument;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (options.mode != "wide-text" && options.mode != "dpi-coordinate" &&
|
||||
options.mode != "ime-position")
|
||||
{
|
||||
error = "Mode must be wide-text, dpi-coordinate, or ime-position";
|
||||
return false;
|
||||
}
|
||||
if (options.outputDirectory.empty())
|
||||
{
|
||||
error = "Output directory is required";
|
||||
return false;
|
||||
}
|
||||
const std::vector<std::string> awarenessValues{
|
||||
"inherit", "unaware", "system", "per-monitor", "per-monitor-v2"
|
||||
};
|
||||
if (std::find(awarenessValues.begin(), awarenessValues.end(), options.requestedDpiAwareness) ==
|
||||
awarenessValues.end())
|
||||
{
|
||||
error = "Unsupported DPI awareness: " + options.requestedDpiAwareness;
|
||||
return false;
|
||||
}
|
||||
const std::vector<std::string> imeNames{ "unspecified", "MicrosoftPinyin", "Sogou" };
|
||||
if (std::find(imeNames.begin(), imeNames.end(), options.expectedImeName) == imeNames.end())
|
||||
{
|
||||
error = "Unsupported IME name: " + options.expectedImeName;
|
||||
return false;
|
||||
}
|
||||
std::error_code fileError;
|
||||
std::filesystem::create_directories(options.outputDirectory / "evidence", fileError);
|
||||
if (fileError)
|
||||
{
|
||||
error = "Cannot create output directory: " + fileError.message();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept
|
||||
{
|
||||
DpiConfiguration result;
|
||||
result.requested = requested;
|
||||
if (requested == "inherit")
|
||||
{
|
||||
result.applied = true;
|
||||
return result;
|
||||
}
|
||||
result.attempted = true;
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
const auto setContext = user32 ? reinterpret_cast<SetProcessDpiAwarenessContextFn>(
|
||||
GetProcAddress(user32, "SetProcessDpiAwarenessContext")) : nullptr;
|
||||
if (!setContext)
|
||||
{
|
||||
result.error = ERROR_PROC_NOT_FOUND;
|
||||
return result;
|
||||
}
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
result.applied = setContext(requestedContext(requested)) != FALSE;
|
||||
result.error = result.applied ? ERROR_SUCCESS : GetLastError();
|
||||
return result;
|
||||
}
|
||||
|
||||
EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept
|
||||
{
|
||||
EnvironmentSnapshot snapshot;
|
||||
snapshot.processId = GetCurrentProcessId();
|
||||
snapshot.threadId = GetCurrentThreadId();
|
||||
snapshot.windowUnicode = hwnd && IsWindowUnicode(hwnd) != FALSE;
|
||||
snapshot.osBuild = captureOsBuild();
|
||||
const TCHAR* easyxVersion = GetEasyXVer();
|
||||
snapshot.easyxVersion = easyxVersion ? easyxVersion : "not_available";
|
||||
if (hwnd)
|
||||
{
|
||||
GetClientRect(hwnd, &snapshot.clientRect);
|
||||
GetWindowRect(hwnd, &snapshot.windowRect);
|
||||
}
|
||||
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
const auto getDpi = user32 ? reinterpret_cast<GetDpiForWindowFn>(
|
||||
GetProcAddress(user32, "GetDpiForWindow")) : nullptr;
|
||||
const auto getThreadContext = user32 ? reinterpret_cast<GetThreadDpiAwarenessContextFn>(
|
||||
GetProcAddress(user32, "GetThreadDpiAwarenessContext")) : nullptr;
|
||||
const auto getWindowContext = user32 ? reinterpret_cast<GetWindowDpiAwarenessContextFn>(
|
||||
GetProcAddress(user32, "GetWindowDpiAwarenessContext")) : nullptr;
|
||||
snapshot.dpiForWindow = getDpi && hwnd ? getDpi(hwnd) : 0;
|
||||
snapshot.threadAwareness = contextAwarenessName(getThreadContext ? getThreadContext() : nullptr);
|
||||
snapshot.windowAwareness = contextAwarenessName(
|
||||
getWindowContext && hwnd ? getWindowContext(hwnd) : nullptr);
|
||||
|
||||
HMODULE shcore = LoadLibraryW(L"shcore.dll");
|
||||
const auto getProcessAwareness = shcore ? reinterpret_cast<GetProcessDpiAwarenessFn>(
|
||||
GetProcAddress(shcore, "GetProcessDpiAwareness")) : nullptr;
|
||||
int processAwareness = -1;
|
||||
if (getProcessAwareness && SUCCEEDED(getProcessAwareness(nullptr, &processAwareness)))
|
||||
snapshot.processAwareness = awarenessName(processAwareness);
|
||||
else
|
||||
snapshot.processAwareness = "not_available";
|
||||
if (shcore) FreeLibrary(shcore);
|
||||
|
||||
HDC screen = GetDC(nullptr);
|
||||
if (screen)
|
||||
{
|
||||
snapshot.deviceDpiX = GetDeviceCaps(screen, LOGPIXELSX);
|
||||
snapshot.deviceDpiY = GetDeviceCaps(screen, LOGPIXELSY);
|
||||
ReleaseDC(nullptr, screen);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
GdiState captureGdiState(HDC hdc) noexcept
|
||||
{
|
||||
GdiState state;
|
||||
if (!hdc) return state;
|
||||
state.mapMode = GetMapMode(hdc);
|
||||
GetViewportOrgEx(hdc, &state.viewportOrigin);
|
||||
GetWindowOrgEx(hdc, &state.windowOrigin);
|
||||
GetViewportExtEx(hdc, &state.viewportExtent);
|
||||
GetWindowExtEx(hdc, &state.windowExtent);
|
||||
state.graphicsMode = GetGraphicsMode(hdc);
|
||||
state.worldTransformAvailable = GetWorldTransform(hdc, &state.worldTransform) != FALSE;
|
||||
state.backgroundMode = GetBkMode(hdc);
|
||||
state.textColor = GetTextColor(hdc);
|
||||
state.backgroundColor = GetBkColor(hdc);
|
||||
state.textAlign = GetTextAlign(hdc);
|
||||
state.textCharacterExtra = GetTextCharacterExtra(hdc);
|
||||
state.clipType = GetClipBox(hdc, &state.clipBox);
|
||||
return state;
|
||||
}
|
||||
|
||||
bool sameGdiState(const GdiState& left, const GdiState& right) noexcept
|
||||
{
|
||||
return left.mapMode == right.mapMode &&
|
||||
left.viewportOrigin.x == right.viewportOrigin.x &&
|
||||
left.viewportOrigin.y == right.viewportOrigin.y &&
|
||||
left.windowOrigin.x == right.windowOrigin.x &&
|
||||
left.windowOrigin.y == right.windowOrigin.y &&
|
||||
left.viewportExtent.cx == right.viewportExtent.cx &&
|
||||
left.viewportExtent.cy == right.viewportExtent.cy &&
|
||||
left.windowExtent.cx == right.windowExtent.cx &&
|
||||
left.windowExtent.cy == right.windowExtent.cy &&
|
||||
left.graphicsMode == right.graphicsMode &&
|
||||
left.worldTransformAvailable == right.worldTransformAvailable &&
|
||||
(!left.worldTransformAvailable || xformEqual(left.worldTransform, right.worldTransform)) &&
|
||||
left.backgroundMode == right.backgroundMode &&
|
||||
left.textColor == right.textColor &&
|
||||
left.backgroundColor == right.backgroundColor &&
|
||||
left.textAlign == right.textAlign &&
|
||||
left.textCharacterExtra == right.textCharacterExtra &&
|
||||
left.clipType == right.clipType &&
|
||||
EqualRect(&left.clipBox, &right.clipBox) != FALSE;
|
||||
}
|
||||
|
||||
SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept
|
||||
{
|
||||
SurfaceFingerprint fingerprint;
|
||||
fingerprint.hdc = reinterpret_cast<std::uintptr_t>(hdc);
|
||||
fingerprint.hbitmap = reinterpret_cast<std::uintptr_t>(
|
||||
hdc ? GetCurrentObject(hdc, OBJ_BITMAP) : nullptr);
|
||||
fingerprint.hfont = reinterpret_cast<std::uintptr_t>(
|
||||
hdc ? GetCurrentObject(hdc, OBJ_FONT) : nullptr);
|
||||
if (fingerprint.hfont)
|
||||
{
|
||||
fingerprint.logFontAvailable = GetObjectW(
|
||||
reinterpret_cast<HFONT>(fingerprint.hfont),
|
||||
sizeof(fingerprint.logFont), &fingerprint.logFont) == sizeof(fingerprint.logFont);
|
||||
}
|
||||
if (fingerprint.hbitmap)
|
||||
{
|
||||
BITMAP bitmap{};
|
||||
if (GetObject(reinterpret_cast<HBITMAP>(fingerprint.hbitmap), sizeof(bitmap), &bitmap) ==
|
||||
sizeof(bitmap))
|
||||
{
|
||||
fingerprint.bitmapInfoAvailable = true;
|
||||
fingerprint.bitmapWidth = bitmap.bmWidth;
|
||||
fingerprint.bitmapHeight = bitmap.bmHeight;
|
||||
fingerprint.planes = bitmap.bmPlanes;
|
||||
fingerprint.bitsPerPixel = bitmap.bmBitsPixel;
|
||||
fingerprint.stride = bitmap.bmWidthBytes;
|
||||
}
|
||||
DIBSECTION section{};
|
||||
fingerprint.dibSection = GetObject(reinterpret_cast<HBITMAP>(fingerprint.hbitmap),
|
||||
sizeof(section), §ion) == sizeof(section);
|
||||
}
|
||||
const auto pixels = readPixels(hdc, width, height);
|
||||
fingerprint.pixelDigest = fnv1a(pixels);
|
||||
fingerprint.gdiState = captureGdiState(hdc);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept
|
||||
{
|
||||
const int width = region.right - region.left;
|
||||
const int height = region.bottom - region.top;
|
||||
return fnv1a(readPixels(hdc, width, height, region.left, region.top));
|
||||
}
|
||||
|
||||
PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept
|
||||
{
|
||||
PixelBounds bounds;
|
||||
for (int y = region.top; y < region.bottom; ++y)
|
||||
{
|
||||
for (int x = region.left; x < region.right; ++x)
|
||||
{
|
||||
if (GetPixel(hdc, x, y) != color) continue;
|
||||
if (!bounds.present)
|
||||
{
|
||||
bounds.present = true;
|
||||
bounds.left = bounds.right = x;
|
||||
bounds.top = bounds.bottom = y;
|
||||
}
|
||||
else
|
||||
{
|
||||
bounds.left = (std::min)(bounds.left, x);
|
||||
bounds.top = (std::min)(bounds.top, y);
|
||||
bounds.right = (std::max)(bounds.right, x);
|
||||
bounds.bottom = (std::max)(bounds.bottom, y);
|
||||
}
|
||||
++bounds.pixelCount;
|
||||
}
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept
|
||||
{
|
||||
PixelBounds bounds;
|
||||
for (int y = region.top; y < region.bottom; ++y)
|
||||
{
|
||||
for (int x = region.left; x < region.right; ++x)
|
||||
{
|
||||
const COLORREF pixel = GetPixel(hdc, x, y);
|
||||
if (pixel == CLR_INVALID || pixel == background) continue;
|
||||
if (!bounds.present)
|
||||
{
|
||||
bounds.present = true;
|
||||
bounds.left = bounds.right = x;
|
||||
bounds.top = bounds.bottom = y;
|
||||
}
|
||||
else
|
||||
{
|
||||
bounds.left = (std::min)(bounds.left, x);
|
||||
bounds.top = (std::min)(bounds.top, y);
|
||||
bounds.right = (std::max)(bounds.right, x);
|
||||
bounds.bottom = (std::max)(bounds.bottom, y);
|
||||
}
|
||||
++bounds.pixelCount;
|
||||
}
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept
|
||||
{
|
||||
if (!source || width <= 0 || height <= 0) return false;
|
||||
try
|
||||
{
|
||||
IMAGE image(width, height);
|
||||
HDC destination = GetImageHDC(&image);
|
||||
if (!destination || BitBlt(destination, 0, 0, width, height, source, 0, 0, SRCCOPY) == FALSE)
|
||||
return false;
|
||||
saveimage(path.string().c_str(), &image);
|
||||
return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool captureClientImage(
|
||||
HWND hwnd,
|
||||
const std::filesystem::path& path,
|
||||
std::string* captureBackend) noexcept
|
||||
{
|
||||
if (!hwnd) return false;
|
||||
RECT client{};
|
||||
if (!GetClientRect(hwnd, &client)) return false;
|
||||
const int width = client.right - client.left;
|
||||
const int height = client.bottom - client.top;
|
||||
try
|
||||
{
|
||||
IMAGE image(width, height);
|
||||
HDC destination = GetImageHDC(&image);
|
||||
if (!destination) return false;
|
||||
PatBlt(destination, 0, 0, width, height, BLACKNESS);
|
||||
bool captured = false;
|
||||
HDC clientDc = GetDC(hwnd);
|
||||
if (clientDc)
|
||||
{
|
||||
captured = BitBlt(destination, 0, 0, width, height,
|
||||
clientDc, 0, 0, SRCCOPY) != FALSE;
|
||||
ReleaseDC(hwnd, clientDc);
|
||||
}
|
||||
if (captured)
|
||||
{
|
||||
if (captureBackend) *captureBackend = "client_getdc_bitblt";
|
||||
}
|
||||
else
|
||||
{
|
||||
captured = PrintWindow(hwnd, destination, PW_CLIENTONLY) != FALSE;
|
||||
if (captured && captureBackend) *captureBackend = "printwindow_fallback";
|
||||
}
|
||||
if (!captured) return false;
|
||||
saveimage(path.string().c_str(), &image);
|
||||
return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring makeUtf16(const std::vector<std::uint32_t>& codePoints, bool& valid)
|
||||
{
|
||||
std::wstring text;
|
||||
valid = true;
|
||||
for (const std::uint32_t codePoint : codePoints)
|
||||
{
|
||||
if (codePoint > 0x10FFFFu || (codePoint >= 0xD800u && codePoint <= 0xDFFFu))
|
||||
{
|
||||
valid = false;
|
||||
continue;
|
||||
}
|
||||
if (codePoint <= 0xFFFFu)
|
||||
text.push_back(static_cast<wchar_t>(codePoint));
|
||||
else
|
||||
{
|
||||
const std::uint32_t adjusted = codePoint - 0x10000u;
|
||||
text.push_back(static_cast<wchar_t>(0xD800u + (adjusted >> 10)));
|
||||
text.push_back(static_cast<wchar_t>(0xDC00u + (adjusted & 0x3FFu)));
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string utf16UnitsJson(const std::wstring& text)
|
||||
{
|
||||
std::ostringstream output;
|
||||
output << '[';
|
||||
for (std::size_t index = 0; index < text.size(); ++index)
|
||||
{
|
||||
if (index) output << ',';
|
||||
output << static_cast<unsigned int>(static_cast<std::uint16_t>(text[index]));
|
||||
}
|
||||
output << ']';
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string codePointsJson(const std::vector<std::uint32_t>& codePoints)
|
||||
{
|
||||
std::ostringstream output;
|
||||
output << '[';
|
||||
for (std::size_t index = 0; index < codePoints.size(); ++index)
|
||||
{
|
||||
if (index) output << ',';
|
||||
output << codePoints[index];
|
||||
}
|
||||
output << ']';
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string jsonString(const std::string& value)
|
||||
{
|
||||
std::ostringstream output;
|
||||
output << '"';
|
||||
for (const unsigned char character : value)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case '"': output << "\\\""; break;
|
||||
case '\\': output << "\\\\"; break;
|
||||
case '\b': output << "\\b"; break;
|
||||
case '\f': output << "\\f"; break;
|
||||
case '\n': output << "\\n"; break;
|
||||
case '\r': output << "\\r"; break;
|
||||
case '\t': output << "\\t"; break;
|
||||
default:
|
||||
if (character < 0x20u)
|
||||
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
||||
<< static_cast<unsigned int>(character) << std::dec;
|
||||
else
|
||||
output << static_cast<char>(character);
|
||||
break;
|
||||
}
|
||||
}
|
||||
output << '"';
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::string hexValue(std::uint64_t value)
|
||||
{
|
||||
std::ostringstream output;
|
||||
output << "0x" << std::hex << std::uppercase << value;
|
||||
return output.str();
|
||||
}
|
||||
|
||||
void writeBoolean(std::ostream& output, bool value)
|
||||
{
|
||||
output << (value ? "true" : "false");
|
||||
}
|
||||
|
||||
void writeRectJson(std::ostream& output, const RECT& rect)
|
||||
{
|
||||
output << "{\"left\":" << rect.left << ",\"top\":" << rect.top
|
||||
<< ",\"right\":" << rect.right << ",\"bottom\":" << rect.bottom << '}';
|
||||
}
|
||||
|
||||
void writePointJson(std::ostream& output, const POINT& point)
|
||||
{
|
||||
output << "{\"x\":" << point.x << ",\"y\":" << point.y << '}';
|
||||
}
|
||||
|
||||
void writeSizeJson(std::ostream& output, const SIZE& size)
|
||||
{
|
||||
output << "{\"cx\":" << size.cx << ",\"cy\":" << size.cy << '}';
|
||||
}
|
||||
|
||||
void writeBoundsJson(std::ostream& output, const PixelBounds& bounds)
|
||||
{
|
||||
output << "{\"present\":";
|
||||
writeBoolean(output, bounds.present);
|
||||
output << ",\"left\":" << bounds.left << ",\"top\":" << bounds.top
|
||||
<< ",\"right\":" << bounds.right << ",\"bottom\":" << bounds.bottom
|
||||
<< ",\"pixelCount\":" << bounds.pixelCount << '}';
|
||||
}
|
||||
|
||||
void writeGdiStateJson(std::ostream& output, const GdiState& state)
|
||||
{
|
||||
output << "{\"mapMode\":" << state.mapMode << ",\"viewportOrigin\":";
|
||||
writePointJson(output, state.viewportOrigin);
|
||||
output << ",\"windowOrigin\":";
|
||||
writePointJson(output, state.windowOrigin);
|
||||
output << ",\"viewportExtent\":";
|
||||
writeSizeJson(output, state.viewportExtent);
|
||||
output << ",\"windowExtent\":";
|
||||
writeSizeJson(output, state.windowExtent);
|
||||
output << ",\"graphicsMode\":" << state.graphicsMode
|
||||
<< ",\"worldTransformAvailable\":";
|
||||
writeBoolean(output, state.worldTransformAvailable);
|
||||
output << ",\"worldTransform\":{\"m11\":" << state.worldTransform.eM11
|
||||
<< ",\"m12\":" << state.worldTransform.eM12
|
||||
<< ",\"m21\":" << state.worldTransform.eM21
|
||||
<< ",\"m22\":" << state.worldTransform.eM22
|
||||
<< ",\"dx\":" << state.worldTransform.eDx
|
||||
<< ",\"dy\":" << state.worldTransform.eDy << "}"
|
||||
<< ",\"backgroundMode\":" << state.backgroundMode
|
||||
<< ",\"textColor\":" << state.textColor
|
||||
<< ",\"backgroundColor\":" << state.backgroundColor
|
||||
<< ",\"textAlign\":" << state.textAlign
|
||||
<< ",\"textCharacterExtra\":" << state.textCharacterExtra
|
||||
<< ",\"clipType\":" << state.clipType << ",\"clipBox\":";
|
||||
writeRectJson(output, state.clipBox);
|
||||
output << '}';
|
||||
}
|
||||
|
||||
void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint)
|
||||
{
|
||||
output << "{\"hdc\":" << jsonString(hexValue(fingerprint.hdc))
|
||||
<< ",\"hbitmap\":" << jsonString(hexValue(fingerprint.hbitmap))
|
||||
<< ",\"hfont\":" << jsonString(hexValue(fingerprint.hfont))
|
||||
<< ",\"bitmapInfoAvailable\":";
|
||||
writeBoolean(output, fingerprint.bitmapInfoAvailable);
|
||||
output << ",\"bitmapWidth\":" << fingerprint.bitmapWidth
|
||||
<< ",\"bitmapHeight\":" << fingerprint.bitmapHeight
|
||||
<< ",\"planes\":" << fingerprint.planes
|
||||
<< ",\"bitsPerPixel\":" << fingerprint.bitsPerPixel
|
||||
<< ",\"stride\":" << fingerprint.stride
|
||||
<< ",\"dibSection\":";
|
||||
writeBoolean(output, fingerprint.dibSection);
|
||||
output << ",\"logFontAvailable\":";
|
||||
writeBoolean(output, fingerprint.logFontAvailable);
|
||||
output << ",\"logFont\":{"
|
||||
<< "\"height\":" << fingerprint.logFont.lfHeight
|
||||
<< ",\"width\":" << fingerprint.logFont.lfWidth
|
||||
<< ",\"weight\":" << fingerprint.logFont.lfWeight
|
||||
<< ",\"italic\":" << static_cast<unsigned int>(fingerprint.logFont.lfItalic)
|
||||
<< ",\"charset\":" << static_cast<unsigned int>(fingerprint.logFont.lfCharSet)
|
||||
<< ",\"faceNameUtf16\":[";
|
||||
for (std::size_t index = 0;
|
||||
index < LF_FACESIZE && fingerprint.logFont.lfFaceName[index]; ++index)
|
||||
{
|
||||
if (index) output << ',';
|
||||
output << static_cast<unsigned int>(fingerprint.logFont.lfFaceName[index]);
|
||||
}
|
||||
output << "]}";
|
||||
output << ",\"pixelDigest\":" << jsonString(hexValue(fingerprint.pixelDigest))
|
||||
<< ",\"gdiState\":";
|
||||
writeGdiStateJson(output, fingerprint.gdiState);
|
||||
output << '}';
|
||||
}
|
||||
|
||||
bool writeRawResult(
|
||||
const Options& options,
|
||||
const DpiConfiguration& dpiConfiguration,
|
||||
const EnvironmentSnapshot& environment,
|
||||
bool completed,
|
||||
const std::vector<EvidenceFile>& evidence,
|
||||
const std::string& resultJson,
|
||||
std::string& error)
|
||||
{
|
||||
const auto path = options.outputDirectory / "raw-result.json";
|
||||
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||
if (!output)
|
||||
{
|
||||
error = "Cannot open raw result: " + path.string();
|
||||
return false;
|
||||
}
|
||||
output << "{\n \"rawSchemaVersion\":1,\n \"mode\":" << jsonString(options.mode)
|
||||
<< ",\n \"completed\":";
|
||||
writeBoolean(output, completed);
|
||||
output << ",\n \"dpiConfiguration\":{\"requested\":"
|
||||
<< jsonString(dpiConfiguration.requested) << ",\"attempted\":";
|
||||
writeBoolean(output, dpiConfiguration.attempted);
|
||||
output << ",\"applied\":";
|
||||
writeBoolean(output, dpiConfiguration.applied);
|
||||
output << ",\"error\":" << dpiConfiguration.error << "},\n \"environment\":{"
|
||||
<< "\"processId\":" << environment.processId
|
||||
<< ",\"threadId\":" << environment.threadId
|
||||
<< ",\"windowUnicode\":";
|
||||
writeBoolean(output, environment.windowUnicode);
|
||||
output << ",\"dpiForWindow\":" << environment.dpiForWindow
|
||||
<< ",\"deviceDpiX\":" << environment.deviceDpiX
|
||||
<< ",\"deviceDpiY\":" << environment.deviceDpiY
|
||||
<< ",\"processAwareness\":" << jsonString(environment.processAwareness)
|
||||
<< ",\"threadAwareness\":" << jsonString(environment.threadAwareness)
|
||||
<< ",\"windowAwareness\":" << jsonString(environment.windowAwareness)
|
||||
<< ",\"osBuild\":" << jsonString(environment.osBuild)
|
||||
<< ",\"easyxVersion\":" << jsonString(environment.easyxVersion)
|
||||
<< ",\"clientRect\":";
|
||||
writeRectJson(output, environment.clientRect);
|
||||
output << ",\"windowRect\":";
|
||||
writeRectJson(output, environment.windowRect);
|
||||
output << "},\n \"evidenceFiles\":[";
|
||||
for (std::size_t index = 0; index < evidence.size(); ++index)
|
||||
{
|
||||
if (index) output << ',';
|
||||
output << "{\"path\":" << jsonString(evidence[index].relativePath)
|
||||
<< ",\"purpose\":" << jsonString(evidence[index].purpose)
|
||||
<< ",\"captureBackend\":" << jsonString(evidence[index].captureBackend) << '}';
|
||||
}
|
||||
output << "],\n \"result\":" << resultJson << "\n}\n";
|
||||
if (!output.good())
|
||||
{
|
||||
error = "Failed writing raw result: " + path.string();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#pragma once
|
||||
|
||||
#include <easyx.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <ostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace VisualProbe
|
||||
{
|
||||
constexpr int CanvasWidth = 960;
|
||||
constexpr int CanvasHeight = 720;
|
||||
|
||||
struct Options
|
||||
{
|
||||
std::string mode;
|
||||
std::filesystem::path outputDirectory;
|
||||
std::string requestedDpiAwareness = "inherit";
|
||||
std::string expectedImeName = "unspecified";
|
||||
unsigned int autoCloseMilliseconds = 0;
|
||||
};
|
||||
|
||||
struct DpiConfiguration
|
||||
{
|
||||
std::string requested = "inherit";
|
||||
bool attempted = false;
|
||||
bool applied = false;
|
||||
DWORD error = ERROR_SUCCESS;
|
||||
};
|
||||
|
||||
struct EvidenceFile
|
||||
{
|
||||
std::string relativePath;
|
||||
std::string purpose;
|
||||
std::string captureBackend;
|
||||
};
|
||||
|
||||
struct PixelBounds
|
||||
{
|
||||
bool present = false;
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
int right = 0;
|
||||
int bottom = 0;
|
||||
std::uint64_t pixelCount = 0;
|
||||
};
|
||||
|
||||
struct GdiState
|
||||
{
|
||||
int mapMode = 0;
|
||||
POINT viewportOrigin{};
|
||||
POINT windowOrigin{};
|
||||
SIZE viewportExtent{};
|
||||
SIZE windowExtent{};
|
||||
int graphicsMode = 0;
|
||||
bool worldTransformAvailable = false;
|
||||
XFORM worldTransform{};
|
||||
int backgroundMode = 0;
|
||||
COLORREF textColor = CLR_INVALID;
|
||||
COLORREF backgroundColor = CLR_INVALID;
|
||||
UINT textAlign = GDI_ERROR;
|
||||
int textCharacterExtra = 0;
|
||||
RECT clipBox{};
|
||||
int clipType = ERROR;
|
||||
};
|
||||
|
||||
struct SurfaceFingerprint
|
||||
{
|
||||
std::uintptr_t hdc = 0;
|
||||
std::uintptr_t hbitmap = 0;
|
||||
std::uintptr_t hfont = 0;
|
||||
int bitmapWidth = 0;
|
||||
int bitmapHeight = 0;
|
||||
int planes = 0;
|
||||
int bitsPerPixel = 0;
|
||||
int stride = 0;
|
||||
bool bitmapInfoAvailable = false;
|
||||
bool dibSection = false;
|
||||
bool logFontAvailable = false;
|
||||
LOGFONTW logFont{};
|
||||
std::uint64_t pixelDigest = 0;
|
||||
GdiState gdiState{};
|
||||
};
|
||||
|
||||
struct EnvironmentSnapshot
|
||||
{
|
||||
DWORD processId = 0;
|
||||
DWORD threadId = 0;
|
||||
bool windowUnicode = false;
|
||||
UINT dpiForWindow = 0;
|
||||
int deviceDpiX = 0;
|
||||
int deviceDpiY = 0;
|
||||
std::string processAwareness;
|
||||
std::string threadAwareness;
|
||||
std::string windowAwareness;
|
||||
std::string osBuild;
|
||||
std::string easyxVersion;
|
||||
RECT clientRect{};
|
||||
RECT windowRect{};
|
||||
};
|
||||
|
||||
bool parseOptions(int argc, char** argv, Options& options, std::string& error);
|
||||
DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept;
|
||||
EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept;
|
||||
GdiState captureGdiState(HDC hdc) noexcept;
|
||||
bool sameGdiState(const GdiState& left, const GdiState& right) noexcept;
|
||||
SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept;
|
||||
std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept;
|
||||
PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept;
|
||||
PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept;
|
||||
bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept;
|
||||
bool captureClientImage(
|
||||
HWND hwnd,
|
||||
const std::filesystem::path& path,
|
||||
std::string* captureBackend = nullptr) noexcept;
|
||||
std::wstring makeUtf16(const std::vector<std::uint32_t>& codePoints, bool& valid);
|
||||
std::string utf16UnitsJson(const std::wstring& text);
|
||||
std::string codePointsJson(const std::vector<std::uint32_t>& codePoints);
|
||||
std::string jsonString(const std::string& value);
|
||||
std::string hexValue(std::uint64_t value);
|
||||
void writeBoolean(std::ostream& output, bool value);
|
||||
void writeRectJson(std::ostream& output, const RECT& rect);
|
||||
void writePointJson(std::ostream& output, const POINT& point);
|
||||
void writeSizeJson(std::ostream& output, const SIZE& size);
|
||||
void writeBoundsJson(std::ostream& output, const PixelBounds& bounds);
|
||||
void writeGdiStateJson(std::ostream& output, const GdiState& state);
|
||||
void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint);
|
||||
bool writeRawResult(
|
||||
const Options& options,
|
||||
const DpiConfiguration& dpiConfiguration,
|
||||
const EnvironmentSnapshot& environment,
|
||||
bool completed,
|
||||
const std::vector<EvidenceFile>& evidence,
|
||||
const std::string& resultJson,
|
||||
std::string& error);
|
||||
|
||||
int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
|
||||
int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
|
||||
int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "VisualProbeCommon.h"
|
||||
|
||||
#include <iostream>
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
VisualProbe::Options options;
|
||||
std::string error;
|
||||
if (!VisualProbe::parseOptions(argc, argv, options, error))
|
||||
{
|
||||
std::cerr << error << "\n"
|
||||
<< "Usage: EasyX.VisualProbe.exe --mode wide-text|dpi-coordinate|ime-position "
|
||||
<< "--output <directory> [--dpi-awareness inherit|unaware|system|per-monitor|per-monitor-v2] "
|
||||
<< "[--ime-name unspecified|MicrosoftPinyin|Sogou] [--auto-close-ms N]\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
const VisualProbe::DpiConfiguration dpiConfiguration =
|
||||
VisualProbe::applyRequestedDpiAwareness(options.requestedDpiAwareness);
|
||||
if (options.mode == "wide-text")
|
||||
return VisualProbe::runWideTextProbe(options, dpiConfiguration);
|
||||
if (options.mode == "dpi-coordinate")
|
||||
return VisualProbe::runDpiCoordinateProbe(options, dpiConfiguration);
|
||||
if (options.mode == "ime-position")
|
||||
return VisualProbe::runImePositionProbe(options, dpiConfiguration);
|
||||
|
||||
std::cerr << "Unsupported mode: " << options.mode << '\n';
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
#include "VisualProbeCommon.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
|
||||
namespace VisualProbe
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr COLORREF MainBackground = RGB(22, 34, 56);
|
||||
constexpr COLORREF WorkingBackground = RGB(64, 28, 46);
|
||||
constexpr COLORREF EasyXMarker = RGB(30, 210, 235);
|
||||
constexpr COLORREF WideTextColor = RGB(246, 224, 64);
|
||||
|
||||
struct TextTest
|
||||
{
|
||||
const char* id;
|
||||
std::vector<std::uint32_t> codePoints;
|
||||
};
|
||||
|
||||
struct TextApiResult
|
||||
{
|
||||
std::string id;
|
||||
std::vector<std::uint32_t> codePoints;
|
||||
std::wstring text;
|
||||
bool validUtf16 = false;
|
||||
bool extentSuccess = false;
|
||||
SIZE extent{};
|
||||
bool textOutSuccess = false;
|
||||
int drawTextHeight = 0;
|
||||
PixelBounds textOutBounds{};
|
||||
PixelBounds drawTextBounds{};
|
||||
};
|
||||
|
||||
struct WideCaseResult
|
||||
{
|
||||
std::string id;
|
||||
std::string surfaceId;
|
||||
std::string batchState;
|
||||
SurfaceFingerprint before{};
|
||||
SurfaceFingerprint afterDraw{};
|
||||
SurfaceFingerprint afterFlush{};
|
||||
SurfaceFingerprint afterEnd{};
|
||||
SurfaceFingerprint afterEasyXFollowUp{};
|
||||
SurfaceFingerprint nonTargetBefore{};
|
||||
SurfaceFingerprint nonTargetAfterDraw{};
|
||||
bool targetChanged = false;
|
||||
bool nonTargetUnchanged = false;
|
||||
bool gdiStateRestored = false;
|
||||
bool flushApplicable = false;
|
||||
bool endApplicable = false;
|
||||
bool explicitWorkingComposition = false;
|
||||
bool wideContentPreservedAfterEasyXFollowUp = false;
|
||||
bool evidenceComplete = true;
|
||||
std::vector<TextApiResult> textResults;
|
||||
};
|
||||
|
||||
std::vector<TextTest> fixedTests()
|
||||
{
|
||||
return {
|
||||
{ "ascii", { 0x0053, 0x0074, 0x0065, 0x006C, 0x006C, 0x0061, 0x0072,
|
||||
0x0058, 0x0020, 0x0030, 0x0043, 0x0020, 0x0050, 0x0072, 0x006F, 0x0062, 0x0065 } },
|
||||
{ "cjk_bmp", { 0x661F, 0x57A3, 0x7ED8, 0x5236, 0x6D4B, 0x8BD5 } },
|
||||
{ "supplementary", { 0x1F600 } },
|
||||
{ "combining_nfd", { 0x0065, 0x0301 } },
|
||||
{ "mixed", { 0x661F, 0x57A3, 0x0020, 0x0041, 0x1F600, 0x0042,
|
||||
0x0020, 0x0065, 0x0301 } }
|
||||
};
|
||||
}
|
||||
|
||||
void checkpoint(const Options& options, const std::string& value) noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
std::ofstream output(options.outputDirectory / "progress.log",
|
||||
std::ios::binary | std::ios::app);
|
||||
output << GetTickCount64() << ' ' << value << '\n';
|
||||
output.flush();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
void clearSurface(IMAGE* image, COLORREF background)
|
||||
{
|
||||
SetWorkingImage(image);
|
||||
setbkcolor(background);
|
||||
cleardevice();
|
||||
}
|
||||
|
||||
std::string normalizedRelative(const std::filesystem::path& path)
|
||||
{
|
||||
std::string value = path.generic_string();
|
||||
std::replace(value.begin(), value.end(), '\\', '/');
|
||||
return value;
|
||||
}
|
||||
|
||||
bool addSurfaceEvidence(
|
||||
const Options& options,
|
||||
HDC hdc,
|
||||
const std::string& fileName,
|
||||
const std::string& purpose,
|
||||
std::vector<EvidenceFile>& evidence)
|
||||
{
|
||||
const std::filesystem::path relative = std::filesystem::path("evidence") / fileName;
|
||||
const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight,
|
||||
options.outputDirectory / relative);
|
||||
if (saved)
|
||||
evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" });
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool addClientEvidence(
|
||||
const Options& options,
|
||||
HWND hwnd,
|
||||
const std::string& fileName,
|
||||
const std::string& purpose,
|
||||
std::vector<EvidenceFile>& evidence)
|
||||
{
|
||||
const std::filesystem::path relative = std::filesystem::path("evidence") / fileName;
|
||||
Sleep(30);
|
||||
std::string backend;
|
||||
const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
|
||||
if (saved)
|
||||
evidence.push_back({ normalizedRelative(relative), purpose, backend });
|
||||
return saved;
|
||||
}
|
||||
|
||||
void drawEasyXBaseline()
|
||||
{
|
||||
setfillcolor(EasyXMarker);
|
||||
solidrectangle(20, 20, 170, 58);
|
||||
settextcolor(EasyXMarker);
|
||||
setbkmode(TRANSPARENT);
|
||||
settextstyle(22, 0, "Microsoft YaHei UI");
|
||||
outtextxy(20, 68, "EasyX baseline");
|
||||
}
|
||||
|
||||
std::vector<TextApiResult> drawWideText(HDC hdc, COLORREF background, bool& restored)
|
||||
{
|
||||
std::vector<TextApiResult> results;
|
||||
const GdiState before = captureGdiState(hdc);
|
||||
const int saved = SaveDC(hdc);
|
||||
HFONT font = CreateFontW(-28, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
|
||||
DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI");
|
||||
HGDIOBJ previousFont = font ? SelectObject(hdc, font) : nullptr;
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
SetTextColor(hdc, WideTextColor);
|
||||
|
||||
const auto tests = fixedTests();
|
||||
for (std::size_t index = 0; index < tests.size(); ++index)
|
||||
{
|
||||
TextApiResult result;
|
||||
result.id = tests[index].id;
|
||||
result.codePoints = tests[index].codePoints;
|
||||
result.text = makeUtf16(result.codePoints, result.validUtf16);
|
||||
const int y = 120 + static_cast<int>(index) * 105;
|
||||
result.extentSuccess = GetTextExtentPoint32W(hdc, result.text.data(),
|
||||
static_cast<int>(result.text.size()), &result.extent) != FALSE;
|
||||
result.textOutSuccess = TextOutW(hdc, 20, y, result.text.data(),
|
||||
static_cast<int>(result.text.size())) != FALSE;
|
||||
RECT drawRect{ 430, y, 930, y + 70 };
|
||||
result.drawTextHeight = DrawTextW(hdc, result.text.data(),
|
||||
static_cast<int>(result.text.size()), &drawRect,
|
||||
DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX);
|
||||
const RECT textOutRegion{ 15, y - 3, 410, y + 70 };
|
||||
const RECT drawTextRegion{ 425, y - 3, 940, y + 70 };
|
||||
result.textOutBounds = findChangedBounds(hdc, textOutRegion, background);
|
||||
result.drawTextBounds = findChangedBounds(hdc, drawTextRegion, background);
|
||||
results.push_back(std::move(result));
|
||||
}
|
||||
|
||||
if (previousFont) SelectObject(hdc, previousFont);
|
||||
if (saved) RestoreDC(hdc, saved);
|
||||
if (font) DeleteObject(font);
|
||||
const GdiState after = captureGdiState(hdc);
|
||||
restored = saved != 0 && sameGdiState(before, after);
|
||||
return results;
|
||||
}
|
||||
|
||||
WideCaseResult runCase(
|
||||
const Options& options,
|
||||
HWND hwnd,
|
||||
IMAGE& working,
|
||||
bool targetMain,
|
||||
bool insideBatch,
|
||||
std::vector<EvidenceFile>& evidence)
|
||||
{
|
||||
WideCaseResult result;
|
||||
result.surfaceId = targetMain ? "main" : "working";
|
||||
result.batchState = insideBatch ? "inside" : "outside";
|
||||
result.id = result.surfaceId + "_" + result.batchState;
|
||||
result.flushApplicable = insideBatch;
|
||||
result.endApplicable = insideBatch;
|
||||
checkpoint(options, result.id + ":begin");
|
||||
|
||||
clearSurface(nullptr, MainBackground);
|
||||
clearSurface(&working, WorkingBackground);
|
||||
checkpoint(options, result.id + ":surfaces_cleared");
|
||||
IMAGE* targetImage = targetMain ? nullptr : &working;
|
||||
SetWorkingImage(nullptr);
|
||||
HDC mainDc = GetImageHDC(nullptr);
|
||||
HDC workingDc = GetImageHDC(&working);
|
||||
HDC targetDc = targetMain ? mainDc : workingDc;
|
||||
HDC nonTargetDc = targetMain ? workingDc : mainDc;
|
||||
const COLORREF targetBackground = targetMain ? MainBackground : WorkingBackground;
|
||||
|
||||
result.before = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||
result.nonTargetBefore = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight);
|
||||
checkpoint(options, result.id + ":before_captured");
|
||||
SetWorkingImage(nullptr);
|
||||
if (insideBatch) BeginBatchDraw();
|
||||
SetWorkingImage(targetImage);
|
||||
drawEasyXBaseline();
|
||||
result.textResults = drawWideText(targetDc, targetBackground, result.gdiStateRestored);
|
||||
checkpoint(options, result.id + ":draw_complete");
|
||||
result.afterDraw = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||
result.nonTargetAfterDraw = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight);
|
||||
checkpoint(options, result.id + ":after_draw_captured");
|
||||
result.targetChanged = result.before.pixelDigest != result.afterDraw.pixelDigest;
|
||||
result.nonTargetUnchanged =
|
||||
result.nonTargetBefore.pixelDigest == result.nonTargetAfterDraw.pixelDigest;
|
||||
|
||||
result.evidenceComplete &= addSurfaceEvidence(options, targetDc,
|
||||
result.id + "_after_draw_target.png", result.id + " target after draw", evidence);
|
||||
result.evidenceComplete &= addSurfaceEvidence(options, nonTargetDc,
|
||||
result.id + "_after_draw_non_target.png", result.id + " non-target after draw", evidence);
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_after_draw_client.png", result.id + " client after draw", evidence);
|
||||
checkpoint(options, result.id + ":after_draw_evidence");
|
||||
|
||||
if (insideBatch)
|
||||
{
|
||||
SetWorkingImage(nullptr);
|
||||
FlushBatchDraw();
|
||||
result.afterFlush = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_after_flush_client.png", result.id + " client after flush", evidence);
|
||||
EndBatchDraw();
|
||||
checkpoint(options, result.id + ":batch_ended");
|
||||
result.afterEnd = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_after_end_client.png", result.id + " client after end", evidence);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.afterFlush = result.afterDraw;
|
||||
result.afterEnd = result.afterDraw;
|
||||
}
|
||||
std::vector<std::uint64_t> protectedRegionDigests;
|
||||
for (std::size_t index = 0; index < result.textResults.size(); ++index)
|
||||
{
|
||||
const int y = 120 + static_cast<int>(index) * 105;
|
||||
const RECT protectedRegion{ 15, y - 3, 940, y + 70 };
|
||||
protectedRegionDigests.push_back(
|
||||
capturePixelRegionDigest(targetDc, protectedRegion));
|
||||
}
|
||||
|
||||
SetWorkingImage(targetImage);
|
||||
setfillcolor(EasyXMarker);
|
||||
solidrectangle(760, 20, 900, 55);
|
||||
result.afterEasyXFollowUp = captureSurfaceFingerprint(
|
||||
targetDc, CanvasWidth, CanvasHeight);
|
||||
result.wideContentPreservedAfterEasyXFollowUp = true;
|
||||
for (std::size_t index = 0; index < result.textResults.size(); ++index)
|
||||
{
|
||||
const int y = 120 + static_cast<int>(index) * 105;
|
||||
const RECT protectedRegion{ 15, y - 3, 940, y + 70 };
|
||||
result.wideContentPreservedAfterEasyXFollowUp =
|
||||
result.wideContentPreservedAfterEasyXFollowUp &&
|
||||
protectedRegionDigests[index] ==
|
||||
capturePixelRegionDigest(targetDc, protectedRegion);
|
||||
}
|
||||
result.evidenceComplete &= addSurfaceEvidence(options, targetDc,
|
||||
result.id + "_after_easyx_follow_up_target.png",
|
||||
result.id + " target after EasyX follow-up drawing", evidence);
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_after_easyx_follow_up_client.png",
|
||||
result.id + " client after EasyX follow-up drawing", evidence);
|
||||
checkpoint(options, result.id + ":easyx_follow_up_complete");
|
||||
|
||||
if (!targetMain)
|
||||
{
|
||||
SetWorkingImage(nullptr);
|
||||
BeginBatchDraw();
|
||||
putimage(0, 0, &working);
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_composition_before_flush.png",
|
||||
result.id + " explicit putimage before flush", evidence);
|
||||
FlushBatchDraw();
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_composition_after_flush.png",
|
||||
result.id + " explicit putimage after flush", evidence);
|
||||
EndBatchDraw();
|
||||
result.evidenceComplete &= addClientEvidence(options, hwnd,
|
||||
result.id + "_composition_after_end.png",
|
||||
result.id + " explicit putimage after end", evidence);
|
||||
result.explicitWorkingComposition = true;
|
||||
}
|
||||
SetWorkingImage(nullptr);
|
||||
checkpoint(options, result.id + ":end");
|
||||
return result;
|
||||
}
|
||||
|
||||
void writeTextResult(std::ostream& output, const TextApiResult& result)
|
||||
{
|
||||
output << "{\"testStringId\":" << jsonString(result.id)
|
||||
<< ",\"codePoints\":" << codePointsJson(result.codePoints)
|
||||
<< ",\"utf16Units\":" << utf16UnitsJson(result.text)
|
||||
<< ",\"isValidUtf16\":";
|
||||
writeBoolean(output, result.validUtf16);
|
||||
output << ",\"getTextExtentPoint32W\":";
|
||||
writeBoolean(output, result.extentSuccess);
|
||||
output << ",\"logicalExtent\":";
|
||||
writeSizeJson(output, result.extent);
|
||||
output << ",\"textOutW\":";
|
||||
writeBoolean(output, result.textOutSuccess);
|
||||
output << ",\"drawTextWHeight\":" << result.drawTextHeight
|
||||
<< ",\"textOutInkBounds\":";
|
||||
writeBoundsJson(output, result.textOutBounds);
|
||||
output << ",\"drawTextInkBounds\":";
|
||||
writeBoundsJson(output, result.drawTextBounds);
|
||||
output << '}';
|
||||
}
|
||||
|
||||
void writeCaseResult(std::ostream& output, const WideCaseResult& result)
|
||||
{
|
||||
output << "{\"testCaseId\":" << jsonString(result.id)
|
||||
<< ",\"surfaceId\":" << jsonString(result.surfaceId)
|
||||
<< ",\"batchState\":" << jsonString(result.batchState)
|
||||
<< ",\"targetChanged\":";
|
||||
writeBoolean(output, result.targetChanged);
|
||||
output << ",\"nonTargetUnchanged\":";
|
||||
writeBoolean(output, result.nonTargetUnchanged);
|
||||
output << ",\"gdiStateRestored\":";
|
||||
writeBoolean(output, result.gdiStateRestored);
|
||||
output << ",\"flushApplicable\":";
|
||||
writeBoolean(output, result.flushApplicable);
|
||||
output << ",\"endApplicable\":";
|
||||
writeBoolean(output, result.endApplicable);
|
||||
output << ",\"explicitWorkingComposition\":";
|
||||
writeBoolean(output, result.explicitWorkingComposition);
|
||||
output << ",\"wideContentPreservedAfterEasyXFollowUp\":";
|
||||
writeBoolean(output, result.wideContentPreservedAfterEasyXFollowUp);
|
||||
output << ",\"evidenceComplete\":";
|
||||
writeBoolean(output, result.evidenceComplete);
|
||||
output << ",\"observations\":{\"afterDraw\":";
|
||||
writeSurfaceFingerprintJson(output, result.afterDraw);
|
||||
output << ",\"afterFlush\":";
|
||||
writeSurfaceFingerprintJson(output, result.afterFlush);
|
||||
output << ",\"afterEnd\":";
|
||||
writeSurfaceFingerprintJson(output, result.afterEnd);
|
||||
output << ",\"afterEasyXFollowUp\":";
|
||||
writeSurfaceFingerprintJson(output, result.afterEasyXFollowUp);
|
||||
output << "},\"before\":";
|
||||
writeSurfaceFingerprintJson(output, result.before);
|
||||
output << ",\"nonTargetBefore\":";
|
||||
writeSurfaceFingerprintJson(output, result.nonTargetBefore);
|
||||
output << ",\"nonTargetAfterDraw\":";
|
||||
writeSurfaceFingerprintJson(output, result.nonTargetAfterDraw);
|
||||
output << ",\"textTests\":[";
|
||||
for (std::size_t index = 0; index < result.textResults.size(); ++index)
|
||||
{
|
||||
if (index) output << ',';
|
||||
writeTextResult(output, result.textResults[index]);
|
||||
}
|
||||
output << "]}";
|
||||
}
|
||||
}
|
||||
|
||||
int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
|
||||
{
|
||||
checkpoint(options, "probe:before_initgraph");
|
||||
HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
|
||||
if (!hwnd)
|
||||
{
|
||||
std::cerr << "initgraph failed\n";
|
||||
return 3;
|
||||
}
|
||||
checkpoint(options, "probe:after_initgraph");
|
||||
SetWindowTextA(hwnd, "StellarX 0C Wide Text Probe");
|
||||
ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||
SetForegroundWindow(hwnd);
|
||||
Sleep(80);
|
||||
const EnvironmentSnapshot environment = captureEnvironment(hwnd);
|
||||
IMAGE working(CanvasWidth, CanvasHeight);
|
||||
checkpoint(options, "probe:environment_and_working_ready");
|
||||
std::vector<EvidenceFile> evidence;
|
||||
std::vector<WideCaseResult> cases;
|
||||
cases.push_back(runCase(options, hwnd, working, true, false, evidence));
|
||||
cases.push_back(runCase(options, hwnd, working, true, true, evidence));
|
||||
cases.push_back(runCase(options, hwnd, working, false, false, evidence));
|
||||
cases.push_back(runCase(options, hwnd, working, false, true, evidence));
|
||||
checkpoint(options, "probe:all_cases_complete");
|
||||
|
||||
bool completed = true;
|
||||
for (const auto& item : cases)
|
||||
{
|
||||
completed = completed && item.targetChanged && item.nonTargetUnchanged &&
|
||||
item.gdiStateRestored && item.wideContentPreservedAfterEasyXFollowUp &&
|
||||
item.evidenceComplete;
|
||||
for (const auto& text : item.textResults)
|
||||
{
|
||||
completed = completed && text.validUtf16 && text.extentSuccess &&
|
||||
text.textOutSuccess && text.drawTextHeight > 0;
|
||||
}
|
||||
}
|
||||
|
||||
std::ostringstream result;
|
||||
result << "{\"caseCount\":" << cases.size() << ",\"cases\":[";
|
||||
for (std::size_t index = 0; index < cases.size(); ++index)
|
||||
{
|
||||
if (index) result << ',';
|
||||
writeCaseResult(result, cases[index]);
|
||||
}
|
||||
result << "]}";
|
||||
SetWorkingImage(nullptr);
|
||||
closegraph();
|
||||
|
||||
std::string error;
|
||||
if (!writeRawResult(options, dpiConfiguration, environment, completed,
|
||||
evidence, result.str(), error))
|
||||
{
|
||||
std::cerr << error << '\n';
|
||||
return 4;
|
||||
}
|
||||
return completed ? 0 : 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# 修改总览-20260409
|
||||
|
||||
## 说明
|
||||
|
||||
- 本目录用于记录本轮协作中对框架做过的修改。
|
||||
- 记录范围覆盖:
|
||||
- 早期未建 Git 基线前的修改
|
||||
- 已提交到仓库的阶段性修改
|
||||
- 当前工作区中尚未提交的第二阶段重绘架构与注释整理
|
||||
- 其中相对最原始版本、但未落到 Git 基线的那部分内容,依据协作过程与当前代码状态回溯整理。
|
||||
|
||||
## 阶段概览
|
||||
|
||||
1. 初始基线
|
||||
- 提交:`dde570a`
|
||||
- 含义:建立仓库后的第一版基线
|
||||
|
||||
2. 第一阶段:基础治理与确定性问题修复
|
||||
- 主要内容:
|
||||
- 资源所有权收紧
|
||||
- 析构顺序修正
|
||||
- `Table / Label / Dialog / Button / TextBox` 等基础问题修复
|
||||
|
||||
3. 第二阶段:快照与对话框行为收口
|
||||
- 提交:`4a6e153`
|
||||
- 提交:`7f8431a`
|
||||
- 主要内容:
|
||||
- 快照“作废”和“回贴”语义拆分
|
||||
- `Dialog` 在窗口变化时只重新居中
|
||||
- 模态/非模态对话框的残影、穿透、关闭后 hover 清理等问题修复
|
||||
|
||||
4. 第三阶段:统一提交重绘(第一版)
|
||||
- 提交:`b07a4ec`
|
||||
- 主要内容:
|
||||
- 将事件阶段的“改状态”与绘制阶段的“提交”分离
|
||||
- 窗口开始托管控件在事件分发期间的重绘请求
|
||||
|
||||
5. 第四阶段:统一提交重绘(第二版,当前工作区)
|
||||
- 状态:未提交
|
||||
- 主要内容:
|
||||
- 从“整场景统一重绘”推进到“按 root 登记并选择性补画对话框”
|
||||
- 为 `Canvas / TabControl / Dialog` 建立托管重绘提交语义
|
||||
- 补充相关注释
|
||||
|
||||
6. 测试与文档
|
||||
- 主要内容:
|
||||
- `KEY == 4` 综合回归用例
|
||||
- 日志降噪
|
||||
- 模板体系与开发记录目录建立
|
||||
|
||||
7. 第五阶段:全量缺陷修复与自动化验证(当前工作区)
|
||||
- 主要内容:
|
||||
- 新增 `StellarX.Core` 核心静态库,拆分框架实现与应用入口
|
||||
- 修复控件所有权、空页签、快照、几何残影、Dialog、Table、日志、MBCS、Tooltip 和事件循环问题
|
||||
- 新增 Native Unit Test、x64 ASan、EasyX UI Fixture 和 PowerShell 自动化测试入口
|
||||
- 完成 Debug x64 / ASan、Release x64、Debug Win32 逻辑测试,以及 UI 三轮回归
|
||||
|
||||
## 记录索引
|
||||
|
||||
### BUG
|
||||
|
||||
- [BUG-20260409-0001 对话框重绘、快照残留与遮挡交互异常](./BUG/BUG-20260409-0001-对话框重绘快照与遮挡交互异常.md)
|
||||
- [BUG-20260409-0002 基础控件生命周期与边界条件问题](./BUG/BUG-20260409-0002-基础控件生命周期与边界条件问题.md)
|
||||
|
||||
### Fix
|
||||
|
||||
- [Fix-BUG-20260409-0001 对话框重绘、快照残留与遮挡交互异常](./Fix/Fix-BUG-20260409-0001-对话框重绘快照与遮挡交互异常.md)
|
||||
- [Fix-BUG-20260409-0002 基础控件生命周期与边界条件问题](./Fix/Fix-BUG-20260409-0002-基础控件生命周期与边界条件问题.md)
|
||||
|
||||
### 功能变更
|
||||
|
||||
- [Feature-20260409-0001 基础资源所有权与生命周期收口](./功能变更/Feature-20260409-0001-基础资源所有权与生命周期收口.md)
|
||||
- [Feature-20260409-0002 Dialog 与 MessageBox 行为调整](./功能变更/Feature-20260409-0002-Dialog与MessageBox行为调整.md)
|
||||
- [Feature-20260409-0003 输入事件、hover 与遮挡交互调整](./功能变更/Feature-20260409-0003-输入事件与遮挡交互调整.md)
|
||||
- [Feature-20260409-0004 测试用例与可观测性调整](./功能变更/Feature-20260409-0004-测试用例与可观测性调整.md)
|
||||
- [Feature-20260409-0005 开发记录与模板体系整理](./功能变更/Feature-20260409-0005-开发记录与模板体系整理.md)
|
||||
- [Feature-20260915-0001 全量缺陷修复与 API 调整](./功能变更/Feature-20260915-0001-全量缺陷修复与API调整.md)
|
||||
|
||||
### 模块
|
||||
|
||||
- [Module-20260409-0001 Window 托管重绘与覆盖合成机制](./模块/Module-20260409-0001-Window托管重绘与覆盖合成机制.md)
|
||||
- [Module-20260915-0001 StellarX 核心库与自动化测试基础设施](./模块/Module-20260915-0001-StellarX核心库与自动化测试基础设施.md)
|
||||
|
||||
### 配套文档
|
||||
|
||||
- [MIGRATION.md](./MIGRATION.md):破坏性 API 迁移说明
|
||||
- [TESTING.md](./TESTING.md):测试入口、覆盖范围和诊断产物说明
|
||||
|
||||
## 覆盖关系
|
||||
|
||||
### 核心代码文件覆盖
|
||||
|
||||
- `Window.h / Window.cpp`
|
||||
- 覆盖于:
|
||||
- `BUG-20260409-0001`
|
||||
- `Fix-BUG-20260409-0001`
|
||||
- `Feature-20260409-0002`
|
||||
- `Feature-20260409-0003`
|
||||
- `Module-20260409-0001`
|
||||
|
||||
- `Control.h / Control.cpp`
|
||||
- 覆盖于:
|
||||
- `BUG-20260409-0002`
|
||||
- `Fix-BUG-20260409-0002`
|
||||
- `Feature-20260409-0001`
|
||||
- `Module-20260409-0001`
|
||||
|
||||
- `Canvas.h / Canvas.cpp`
|
||||
- 覆盖于:
|
||||
- `BUG-20260409-0001`
|
||||
- `Fix-BUG-20260409-0001`
|
||||
- `Feature-20260409-0003`
|
||||
- `Module-20260409-0001`
|
||||
|
||||
- `TabControl.h / TabControl.cpp`
|
||||
- 覆盖于:
|
||||
- `Feature-20260409-0003`
|
||||
- `Feature-20260409-0004`
|
||||
- `Module-20260409-0001`
|
||||
|
||||
- `Dialog.h / Dialog.cpp`
|
||||
- 覆盖于:
|
||||
- `BUG-20260409-0001`
|
||||
- `Fix-BUG-20260409-0001`
|
||||
- `Feature-20260409-0002`
|
||||
- `Module-20260409-0001`
|
||||
|
||||
- `Button.cpp / TextBox.cpp`
|
||||
- 覆盖于:
|
||||
- `BUG-20260409-0001`
|
||||
- `Fix-BUG-20260409-0001`
|
||||
- `Feature-20260409-0003`
|
||||
|
||||
- `Table.cpp / Table.h / Label.cpp / MessageBox.cpp`
|
||||
- 覆盖于:
|
||||
- `BUG-20260409-0002`
|
||||
- `Fix-BUG-20260409-0002`
|
||||
- `Feature-20260409-0001`
|
||||
- `Feature-20260409-0002`
|
||||
|
||||
- `z-testDome.cpp`
|
||||
- 覆盖于:
|
||||
- `Feature-20260409-0004`
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 当前工作区在 `77a8fe5` 之后还有未提交修改。
|
||||
- 这些未提交修改主要是:
|
||||
- 第二阶段托管重绘
|
||||
- 注释补充与校正
|
||||
- 记录与模板体系整理
|
||||
|
||||
## 第五阶段覆盖关系
|
||||
|
||||
- `StellarX.Core.vcxproj`
|
||||
- 统一承载框架源码和公共/内部头文件。
|
||||
- 关联:
|
||||
- [Module-20260915-0001 StellarX 核心库与自动化测试基础设施](./模块/Module-20260915-0001-StellarX核心库与自动化测试基础设施.md)
|
||||
- [Feature-20260915-0001 全量缺陷修复与 API 调整](./功能变更/Feature-20260915-0001-全量缺陷修复与API调整.md)
|
||||
|
||||
- `tests / scripts`
|
||||
- 覆盖逻辑单元测试、ASan、EasyX UI Fixture、关键像素断言和诊断产物。
|
||||
- 关联:
|
||||
- [TESTING.md](./TESTING.md)
|
||||
- [Module-20260915-0001 StellarX 核心库与自动化测试基础设施](./模块/Module-20260915-0001-StellarX核心库与自动化测试基础设施.md)
|
||||
|
||||
- `Control / Window / Canvas / Button / Label / TextBox / Table / Dialog / TabControl / SxLogger`
|
||||
- 本批次高、中、低严重度问题和验证用例统一记录于:
|
||||
- [Feature-20260915-0001 全量缺陷修复与 API 调整](./功能变更/Feature-20260915-0001-全量缺陷修复与API调整.md)
|
||||
@@ -0,0 +1,55 @@
|
||||
# 补充记录-20260409-窗口最大化收口保护
|
||||
|
||||
## 说明
|
||||
|
||||
- 本文件用于补充记录 2026-04-09 当天新增的窗口最大化收口问题。
|
||||
- 对应修改发生在 `f567369` 快照之后,当前仍位于工作区未提交状态。
|
||||
|
||||
## 问题概述
|
||||
|
||||
- 现象:
|
||||
- 某些窗口初始尺寸下,直接点击最大化会出现黑背景、控件残影。
|
||||
- 若先手动拖拽窗口略微放大,再最大化,则问题消失。
|
||||
- 影响用例:
|
||||
- `KEY == 1`
|
||||
- `KEY == 2`
|
||||
- 不受影响用例:
|
||||
- `KEY == 3`
|
||||
- `KEY == 4`
|
||||
|
||||
## 根因
|
||||
|
||||
- [`Window.cpp`](D:/programming/imGUI-easyX/imGui-easyX/src/StellarX/Window.cpp) 的 `runEventLoop()` resize 收口中存在一段历史“跨度保护”。
|
||||
- 该保护把“宽高变化超过 1000”视为异常帧,直接终止收口:
|
||||
- 不再执行 `adaptiveLayout`
|
||||
- 不再执行 `Resize`
|
||||
- 不再执行 `redrawScene(true, true)`
|
||||
- 因此新暴露区域不会被完整刷新,最终表现为黑背景与残影。
|
||||
|
||||
## 本次新增记录
|
||||
|
||||
- BUG:
|
||||
- [`BUG-20260409-0003 直接最大化触发收口保护导致黑背景与残影`](./BUG/BUG-20260409-0003-直接最大化触发收口保护导致黑背景与残影.md)
|
||||
- Fix:
|
||||
- [`Fix-BUG-20260409-0003 直接最大化触发收口保护导致黑背景与残影`](./Fix/Fix-BUG-20260409-0003-直接最大化触发收口保护导致黑背景与残影.md)
|
||||
- 功能变更:
|
||||
- [`Feature-20260409-0006 窗口最大化收口保护调整`](./功能变更/Feature-20260409-0006-窗口最大化收口保护调整.md)
|
||||
|
||||
## 修改摘要
|
||||
|
||||
- 将“跨度过大即跳过”改为“仅非法尺寸才跳过”。
|
||||
- 非法尺寸标准:
|
||||
- `actualWidth <= 0`
|
||||
- `actualHeight <= 0`
|
||||
- `actualWidth > max(10000, virtualScreenWidth * 2)`
|
||||
- `actualHeight > max(10000, virtualScreenHeight * 2)`
|
||||
- 对“大跨度但合法”的 resize 保留 `DEBUG` 日志,但继续执行完整收口。
|
||||
- 顺手删除了 `Window.cpp` 中未使用的 `ExMessage mm;` 遗留变量。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 代码修改:已完成,待提交
|
||||
- 文档记录:已补充
|
||||
- 验证状态:
|
||||
- 已完成源码级编译验证
|
||||
- 待用户做 GUI 回归
|
||||
@@ -0,0 +1,53 @@
|
||||
# BUG-20251209-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20251209-0001
|
||||
- 标题: TabControl 由不可见设置为可见时绘制错乱
|
||||
- 状态:已修复
|
||||
- 严重性:S3
|
||||
- 优先级:P2
|
||||
- 模块: TabControl
|
||||
- 版本 / 分支: V2.3.1
|
||||
- 环境: Visual Studio / Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20251209-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 调用 `TabControl::setIsVisible()` 将选项卡由不可见变为可见时,会绘制出未激活的页面。
|
||||
- 影响范围: 多页面 TabControl 在隐藏后重新显示时可能出现页面叠加,造成绘制错乱并影响用户体验。
|
||||
- 期望结果: TabControl 可见后只绘制当前激活页;如果没有激活页,则不绘制任何页。
|
||||
- 实际结果: 所有页面被绘制出来,导致未激活页和激活页重叠。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] 存在至少两个页面的 TabControl。
|
||||
- 复现步骤:
|
||||
|
||||
1. 将 TabControl 设置为不可见。
|
||||
2. 通过事件或回调将 TabControl 设置为可见。
|
||||
3. 观察页面绘制结果。
|
||||
|
||||
- 复现概率:必现
|
||||
- 最小复现 Demo:[可选] TabControl 隐藏 / 显示切换场景
|
||||
- 证据:PDF 历史记录描述隐藏后恢复显示会绘制所有页;初步定位到 `TabControl::setIsVisible()` 的可见性传播逻辑。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `TabControl::setIsVisible()`
|
||||
- 触发条件: TabControl 从不可见切换为可见时,对子页面统一传播可见状态。
|
||||
- 相关线索:
|
||||
- `show` 状态恢复为 true 后,非激活页也参与绘制。
|
||||
- TabControl 的页面可见性没有与当前激活状态保持一致。
|
||||
- 最近相关改动:[可选]
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2025-12-09
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v2.3.2
|
||||
- 验证版本:[可选] v2.3.2
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20251209`;PDF 未包含 `-XXXX` 后缀,入库时补充序号。
|
||||
@@ -0,0 +1,60 @@
|
||||
# BUG-20251220-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20251220-0001
|
||||
- 标题: TabControl::setActiveIndex 绘制前调用崩溃
|
||||
- 状态:已修复
|
||||
- 严重性:S1
|
||||
- 优先级:P0
|
||||
- 模块: TabControl
|
||||
- 版本 / 分支: v2.3.1
|
||||
- 环境: Visual Studio / Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20251220-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 在 TabControl 首次绘制前或进入主事件循环前调用 `setActiveIndex()`,会触发空指针访问并导致程序崩溃。
|
||||
- 影响范围: 影响默认激活页设置流程,启动阶段即可崩溃,严重影响使用。
|
||||
- 期望结果: `setActiveIndex()` 可在绘制前记录默认激活页,并在 TabControl 第一次绘制时完成默认激活。
|
||||
- 实际结果: 绘制前调用会直接崩溃,无法设置默认激活页。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] TabControl 尚未绘制。
|
||||
- 复现步骤:
|
||||
|
||||
1. 创建 TabControl 对象。
|
||||
2. 在首次绘制前调用 `setActiveIndex(0)`。
|
||||
3. 运行程序并观察崩溃。
|
||||
|
||||
- 复现概率:必现
|
||||
- 最小复现 Demo:
|
||||
|
||||
```cpp
|
||||
auto ta = std::make_unique<TabControl>(10, 10, 1100, 300);
|
||||
ta->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
ta->setActiveIndex(0);
|
||||
```
|
||||
|
||||
- 证据:PDF 历史记录描述绘制前调用会造成空指针访问。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `TabControl::setActiveIndex()`
|
||||
- 触发条件: TabControl / 页签按钮尚未完成绘制和初始化时,提前执行激活页切换逻辑。
|
||||
- 相关线索:
|
||||
- 默认激活应属于延迟到首次绘制后的行为。
|
||||
- 绘制前直接触发页签按钮点击回调,会访问尚未初始化的内部状态。
|
||||
- 最近相关改动:[可选]
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2025-12-20
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v2.3.2
|
||||
- 验证版本:[可选] v2.3.2
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20251220`;PDF 未包含 `-XXXX` 后缀,入库时补充序号。
|
||||
@@ -0,0 +1,66 @@
|
||||
# BUG-20260109-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260109-0001
|
||||
- 标题: TextBox::setText 进入事件循环前调用崩溃
|
||||
- 状态:已修复
|
||||
- 严重性:S1
|
||||
- 优先级:P0
|
||||
- 模块: TextBox
|
||||
- 版本 / 分支: v2.3.2(基础版 EasyX + Win32)
|
||||
- 环境: Windows 10/11 x64 + Visual Studio 2022(MSVC)Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20260109-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 在 Window 尚未创建或图形上下文尚未初始化时调用 `TextBox::setText()` 设置默认文本,会触发 `TextBox::draw()`,进而进入依赖 EasyX 图形上下文的绘制 / 抓屏逻辑,导致访问冲突或中断。
|
||||
- 影响范围: 所有“先创建控件并设置默认文本,再创建 Window / 挂载控件树”的初始化流程。
|
||||
- 期望结果: `setText()` 只更新控件状态并标记需要重绘,不应在生命周期未就绪时主动绘制。
|
||||
- 实际结果: `setText()` 内部直接调用 `draw()`,绘制链路在图形上下文未就绪时崩溃。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] Window / EasyX 图形上下文尚未初始化。
|
||||
- 复现步骤:
|
||||
|
||||
1. 在创建 Window 或首次 `draw()` 前创建 TextBox。
|
||||
2. 调用 `TextBox::setText()` 设置默认文本。
|
||||
3. 随后才创建 Window 或将 TextBox 添加到 Canvas / Window。
|
||||
4. 观察程序在 `setText()` 阶段崩溃。
|
||||
|
||||
- 复现概率:必现
|
||||
- 最小复现 Demo:
|
||||
|
||||
```cpp
|
||||
Window window(1024, 768, NULL, RGB(255, 255, 255), "StellarX Application");
|
||||
|
||||
auto initLibPathBox = std::make_unique<TextBox>(230, 230, 700, 30);
|
||||
initLibPathBox->setText("textText");
|
||||
|
||||
window.addControl(std::move(initLibPathBox));
|
||||
window.draw();
|
||||
return window.runEventLoop();
|
||||
```
|
||||
|
||||
- 证据:PDF 历史记录描述 `setText()` 触发 `draw()` 后进入 `saveBackground()` / `getimage()` 等图形上下文相关逻辑。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `TextBox::setText()`
|
||||
- 触发条件: 控件属性设置发生在 Window 绘制 / EasyX 图形上下文初始化之前。
|
||||
- 相关线索:
|
||||
- setter 不应带有依赖图形上下文的即时绘制副作用。
|
||||
- `draw()` 会进入背景快照与图像抓取链路。
|
||||
- 最近相关改动:[可选]
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-01-09
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v3.0.0
|
||||
- 验证版本:[可选] v3.0.0
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20260109`;同日另有 Table 重影问题,入库时按顺序拆为 `0001` 与 `0002`。
|
||||
@@ -0,0 +1,60 @@
|
||||
# BUG-20260109-0002
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260109-0002
|
||||
- 标题: TabControl 切换后 Table 新旧内容重叠
|
||||
- 状态:已修复
|
||||
- 严重性:S2
|
||||
- 优先级:P1
|
||||
- 模块: TabControl / Canvas / Table / 背景快照 / 脏标记重绘链路
|
||||
- 版本 / 分支: 基础版(EasyX + Win32)/ v2.3.2
|
||||
- 环境: Windows x64 / Visual Studio 2022 / EasyX(Debug / Release 均可能)
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20260109-0002
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 运行 TabControl 示例后,切换到“按钮1”页签并触发“重置表格数据”,随后打开 / 关闭其他页签再切回“按钮1”,Table 稳定出现旧内容与新内容叠加;偶发情况下重置数据当次即出现重影。
|
||||
- 影响范围: 使用背景快照擦除与局部重绘组合的容器 / 控件,尤其是 Table 透明文字背景与 Null 填充模式场景。
|
||||
- 期望结果: 切换页签、关闭页签、重置数据后,Table 只显示当前数据,不残留旧像素。
|
||||
- 实际结果: Table 区域出现旧行文本像素残留,与新内容叠在同一位置。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] TabControl 示例中包含 Table 和“重置表格数据”按钮。
|
||||
- 复现步骤:
|
||||
|
||||
1. 运行测试程序(TabControl 示例)。
|
||||
2. 切换到“按钮1”页签。
|
||||
3. 点击“重置表格数据”(清空并重新 `setData()`)。
|
||||
4. 随意切换到其他页签,可伴随关闭页签操作。
|
||||
5. 再切回“按钮1”。
|
||||
6. 观察 Table 区域是否出现旧内容与新内容重叠。
|
||||
|
||||
- 复现概率:必现;重置数据当次直接重影为偶现。
|
||||
- 最小复现 Demo:`z-testDome.cpp` 中 TabControl + Table + 重置按钮场景;Null FillMode 与透明文字背景更明显。
|
||||
- 证据:日志显示 Canvas 快照刚丢弃或未就绪时,Table 可见性恢复立即触发局部重绘,随后 `saveBackground()` 把已绘制像素截入背景快照。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置:
|
||||
- `Control::setIsVisible(true)`
|
||||
- `Canvas::requestRepaint()`
|
||||
- `TabControl::requestRepaint()`
|
||||
- 触发条件: 页签切换 / 关闭期间,父容器快照未就绪或 dirty 未清理时,子控件抢跑执行局部绘制。
|
||||
- 相关线索:
|
||||
- `show=true` 时立即向上 `requestRepaint()`。
|
||||
- `Canvas::requestRepaint()` 在 `hasSnap=0` / `dirty=1` 状态下仍允许对子控件局部 `draw()`。
|
||||
- 页签切换期间尺寸变化会触发丢背景快照和标脏。
|
||||
- 最近相关改动:[可选] `Control::setIsVisible(true)` 变为立即请求重绘。
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-01-09
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v3.0.0
|
||||
- 验证版本:[可选] v3.0.0
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20260109`;同日另有 TextBox 生命周期问题,入库时按顺序拆为 `0001` 与 `0002`。
|
||||
@@ -0,0 +1,53 @@
|
||||
# BUG-20260122-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260122-0001
|
||||
- 标题: Window::runEventLoop 普通控件事件分发顺序
|
||||
- 状态:已修复
|
||||
- 严重性:S3
|
||||
- 优先级:P2
|
||||
- 模块: Window
|
||||
- 版本 / 分支: v3.0.0
|
||||
- 环境: Windows 10/11 x64 + Visual Studio 2022(MSVC)Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20260122-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: `Window::runEventLoop()` 对普通控件分发事件时采用正序遍历 `controls`,控件重叠时下层控件可能抢走点击 / 悬停事件,导致行为与绘制顺序不一致。
|
||||
- 影响范围: 普通控件存在重叠时,事件命中顺序可能不符合 Z 序直觉。
|
||||
- 期望结果: 上层控件优先接收事件,下层控件不应抢走事件。
|
||||
- 实际结果: 正序遍历导致下层控件可能先处理事件。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] Window 中存在相互重叠的普通控件。
|
||||
- 复现步骤:
|
||||
|
||||
1. 创建存在重叠区域的普通控件。
|
||||
2. 在重叠区域触发点击或悬停事件。
|
||||
3. 观察是否由下层控件优先处理事件。
|
||||
|
||||
- 复现概率:必现(按 PDF 记录;实际复现未提供)
|
||||
- 最小复现 Demo:[可选] 无
|
||||
- 证据:PDF 历史记录指出该问题来自之前版本误将逆序分发改为正序分发。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `Window::runEventLoop()`
|
||||
- 触发条件: 普通控件重叠,且事件落在重叠区域。
|
||||
- 相关线索:
|
||||
- 普通控件绘制顺序与事件分发顺序应保持 Z 序一致。
|
||||
- `Window::controls` 正序遍历会让较早加入的下层控件先处理事件。
|
||||
- 最近相关改动:[可选] 某个版本中普通控件遍历顺序被错误修改。
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-01-22
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v3.0.1
|
||||
- 验证版本:[可选] v3.0.1
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20260122`;PDF 未包含 `-XXXX` 后缀,入库时补充序号。
|
||||
@@ -0,0 +1,53 @@
|
||||
# BUG-20260203-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260203-0001
|
||||
- 标题: 对话框打开时错误下发合成 WM_MOUSEMOVE
|
||||
- 状态:已修复
|
||||
- 严重性:S3
|
||||
- 优先级:P2
|
||||
- 模块: Window
|
||||
- 版本 / 分支: v3.0.0
|
||||
- 环境: Windows 10/11 x64 + Visual Studio 2022(MSVC)Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20260203-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 对话框打开时会触发本应只在对话框关闭时执行的合成 `WM_MOUSEMOVE`,并下发给所有可见控件以刷新 hover 状态。
|
||||
- 影响范围: 对话框打开路径产生额外事件下发,带来性能影响和不必要的 hover 状态刷新。
|
||||
- 期望结果: 合成 `WM_MOUSEMOVE` 只在对话框被关闭后下发一次。
|
||||
- 实际结果: 对话框打开和关闭时都会下发合成 `WM_MOUSEMOVE`。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] 存在可弹出的对话框。
|
||||
- 复现步骤:
|
||||
|
||||
1. 启动事件循环。
|
||||
2. 触发对话框弹出。
|
||||
3. 观察日志中是否在对话框打开阶段出现合成 `WM_MOUSEMOVE` 下发记录。
|
||||
|
||||
- 复现概率:必现
|
||||
- 最小复现 Demo:[可选] 弹出一个对话框即可。
|
||||
- 证据:PDF 日志显示对话框弹出后出现“对话框关闭,合成WM_MOUSEMOVE已下发”。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `Window::runEventLoop()`
|
||||
- 触发条件: 对话框打开路径触发重绘判断时,也执行了对话框关闭后才需要的鼠标移动合成逻辑。
|
||||
- 相关线索:
|
||||
- 相关变量为 `dialogClose`。
|
||||
- 合成 `WM_MOUSEMOVE` 的执行缺少对 `dialogClose` 为真的限定。
|
||||
- 最近相关改动:[可选]
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-02-03
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v3.0.1
|
||||
- 验证版本:[可选] v3.0.1
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20260203`;PDF 未包含 `-XXXX` 后缀,入库时补充序号。
|
||||
@@ -0,0 +1,53 @@
|
||||
# BUG-20260206-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260206-0001
|
||||
- 标题: 对话框打开时持续触发全量重绘
|
||||
- 状态:已修复
|
||||
- 严重性:S3
|
||||
- 优先级:P2
|
||||
- 模块: Window
|
||||
- 版本 / 分支: v3.0.0 / master
|
||||
- 环境: Windows 10/11 x64 + Visual Studio 2022(MSVC)Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20260206-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 对话框保持打开时持续触发全量重绘,重复重绘所有打开的对话框和可见控件。
|
||||
- 影响范围: 对话框打开期间产生不必要的全量绘制,造成性能影响。
|
||||
- 期望结果: 只在对话框打开或关闭时触发必要重绘,确保对话框顶层显示并清除关闭残留。
|
||||
- 实际结果: 重绘前以“是否有对话框可见或关闭”为依据,导致对话框保持可见时持续触发重绘。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] 存在可弹出的对话框。
|
||||
- 复现步骤:
|
||||
|
||||
1. 触发对话框弹出。
|
||||
2. 保持对话框打开。
|
||||
3. 观察日志中是否持续输出全量重绘相关记录。
|
||||
|
||||
- 复现概率:必现
|
||||
- 最小复现 Demo:触发任意对话框即可。
|
||||
- 证据:PDF 日志中同一时间片持续出现“对话框打开/关闭,触发全量重绘”。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `Window::runEventLoop()`
|
||||
- 触发条件: 对话框处于可见状态且事件循环持续运行。
|
||||
- 相关线索:
|
||||
- 相关变量为 `needredraw`。
|
||||
- 重绘判断基于“是否存在可见对话框”,而不是“是否刚刚打开 / 关闭对话框”。
|
||||
- 最近相关改动:[可选]
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-02-06
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v3.0.1
|
||||
- 验证版本:[可选] v3.0.1
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20260206`;PDF 未包含 `-XXXX` 后缀,入库时补充序号。
|
||||
@@ -0,0 +1,54 @@
|
||||
# BUG-20260217-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260217-0001
|
||||
- 标题: TabControl::getActiveIndex 无激活页返回最后索引
|
||||
- 状态:已修复
|
||||
- 严重性:S2
|
||||
- 优先级:P1
|
||||
- 模块: TabControl
|
||||
- 版本 / 分支: v3.0.0 / master
|
||||
- 环境: Windows 10/11 x64 + Visual Studio 2022(MSVC)Debug / Release
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: Fix-BUG-20260217-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象: 当 TabControl 的页签对列表不为空,但所有页签均未激活 / 未打开时,调用 `getActiveIndex()` 会返回最后一个页签的索引。
|
||||
- 影响范围: 用户通过 `getActiveIndex()` 判断当前激活页时,可能拿到错误索引。
|
||||
- 期望结果: 无页签激活时返回 `-1`;有页签激活时返回对应索引。
|
||||
- 实际结果: 无页签激活时返回最后一个页签索引。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选] TabControl 页签列表不为空,但所有页签都未激活。
|
||||
- 复现步骤:
|
||||
|
||||
1. 创建含有页签的 TabControl。
|
||||
2. 保持所有页签未激活 / 未打开。
|
||||
3. 调用 `TabControl::getActiveIndex()`。
|
||||
4. 观察返回值。
|
||||
|
||||
- 复现概率:必现
|
||||
- 最小复现 Demo:[可选]
|
||||
- 证据:PDF 历史记录标记为代码分析发现。
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置: `TabControl::getActiveIndex()`
|
||||
- 触发条件: 页签列表非空且无任何页签处于激活状态。
|
||||
- 相关线索:
|
||||
- 旧逻辑未处理“列表非空但全部未激活”的兜底分支。
|
||||
- 返回值应能区分无激活页与最后一页激活。
|
||||
- 最近相关改动:[可选]
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-02-17
|
||||
- 最后更新时间: 2026-09-19(从 PDF 整理入库)
|
||||
- 修复版本: v3.0.1
|
||||
- 验证版本:[可选] v3.0.1
|
||||
- 备注:[可选] 来源原始 ID 为 `BUG-20260217`;PDF 未包含 `-XXXX` 后缀,入库时补充序号。
|
||||
@@ -0,0 +1,73 @@
|
||||
# BUG-20260409-0001
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260409-0001
|
||||
- 标题: 对话框重绘、快照残留与遮挡交互异常
|
||||
- 状态:已修复 / 待持续回归
|
||||
- 严重性:S2
|
||||
- 优先级:P0
|
||||
- 模块:Window / Dialog / Canvas / Button / TextBox
|
||||
- 版本 / 分支:`master`
|
||||
- 环境:Windows + EasyX + VS2022
|
||||
- 发现人:协作过程静态审查与测试用例回归
|
||||
- 关联 Fix ID:Fix-BUG-20260409-0001
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象:
|
||||
- 非模态对话框打开后,底层被遮挡控件仍可能收到鼠标事件。
|
||||
- 模态或非模态对话框在窗口尺寸变化后,可能出现背景快照残留。
|
||||
- 模态对话框标题区域曾出现抓到底层背景的问题。
|
||||
- 对话框关闭后,底层按钮 hover 状态可能不能立即恢复。
|
||||
- 多个按钮快速 hover 切换时,对话框补画链容易闪烁。
|
||||
- 影响范围:
|
||||
- 对话框交互正确性
|
||||
- 重绘层级稳定性
|
||||
- hover 与点击行为一致性
|
||||
- 期望结果:
|
||||
- 对话框始终位于正确层级。
|
||||
- 被对话框覆盖的底层控件不会误交互。
|
||||
- resize / 打开 / 关闭过程中无残影、无错位。
|
||||
- 实际结果:
|
||||
- 曾出现穿透、残影、错误快照、关闭后 hover 残留、频繁补画闪烁等问题。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:存在非模态或模态对话框,且下层有可交互控件
|
||||
- 复现步骤:
|
||||
|
||||
1. 打开包含按钮、Canvas、TabControl 的测试窗口。
|
||||
2. 弹出非模态或模态对话框。
|
||||
3. 在对话框遮挡区域附近快速移动鼠标,或拖动窗口大小。
|
||||
|
||||
- 复现概率:高概率 / 偶现(不同问题不同)
|
||||
- 最小复现 Demo:`z-testDome.cpp` 中 `KEY == 2 / 3 / 4`
|
||||
- 证据:日志、静态推演、用户回归观察
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置:
|
||||
- `Dialog::handleEvent`
|
||||
- `Dialog::draw / recenterInHostWindow / clearControls`
|
||||
- `Window::runEventLoop / pumpResizeIfNeeded / flushManagedRepaint`
|
||||
- `Button::handleEvent`
|
||||
- 触发条件:
|
||||
- 对话框覆盖底层控件
|
||||
- 窗口 resize
|
||||
- hover 高频切换
|
||||
- 对话框打开/关闭后的层级切换
|
||||
- 相关线索:
|
||||
- “快照作废”和“回贴旧背景”语义混用
|
||||
- 事件阶段与绘制阶段原先分离不彻底
|
||||
- 非模态对话框区域内鼠标事件吞掉后,底层控件拿不到离开消息
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间:本轮协作初期
|
||||
- 最后更新时间:2026-04-09
|
||||
- 修复版本:[当前工作区]
|
||||
- 验证版本:[当前工作区]
|
||||
- 备注:快速划过多个按钮时一帧内偶发双高亮的问题暂未彻底消除,属于已接受的底层限制
|
||||
@@ -0,0 +1,71 @@
|
||||
# BUG-20260409-0002
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260409-0002
|
||||
- 标题: 基础控件生命周期与边界条件问题
|
||||
- 状态:已修复 / 待持续回归
|
||||
- 严重性:S2
|
||||
- 优先级:P1
|
||||
- 模块:Control / Window / Table / Label / Button / TextBox / MessageBox
|
||||
- 版本 / 分支:`master`
|
||||
- 环境:Windows + EasyX + VS2022
|
||||
- 发现人:静态审查
|
||||
- 关联 Fix ID:Fix-BUG-20260409-0002
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象:
|
||||
- `Window` 析构时可能先关闭图形环境,再让控件析构访问绘图接口。
|
||||
- `Table` 在空表头、空数据、列数不一致时存在越界或空状态问题。
|
||||
- `Label::setText()` 后尺寸与背景快照不能及时同步。
|
||||
- `MessageBox` 外层曾直接干预 `Dialog` 初始化流程。
|
||||
- 一些资源所有权和析构职责不够清晰。
|
||||
- 影响范围:
|
||||
- 基础稳定性
|
||||
- 边界输入正确性
|
||||
- 维护成本
|
||||
- 期望结果:
|
||||
- 生命周期安全、边界输入可防御、接口语义清晰
|
||||
- 实际结果:
|
||||
- 存在潜在崩溃、越界、状态不一致和维护负担
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:特定表格输入、动态修改文本、窗口关闭、MessageBox 使用链
|
||||
- 复现步骤:
|
||||
|
||||
1. 静态审查相关代码路径。
|
||||
2. 构造空表头、长于表头的数据行、动态变更 Label 文本。
|
||||
3. 观察关闭窗口或销毁相关对象时的析构顺序。
|
||||
|
||||
- 复现概率:高概率 / 必现(不同问题不同)
|
||||
- 最小复现 Demo:[可选]
|
||||
- 证据:静态分析、日志、编译验证
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置:
|
||||
- `Window::~Window`
|
||||
- `Control` 析构与背景快照恢复链
|
||||
- `Table::initTextWaH / setData / drawHeader / drawTable`
|
||||
- `Label::setText`
|
||||
- `MessageBox::showAsync / showModal`
|
||||
- 触发条件:
|
||||
- 边界数据输入
|
||||
- 动态文本变化
|
||||
- 对象销毁 / 窗口关闭
|
||||
- 相关线索:
|
||||
- 所有权层次混乱
|
||||
- 空状态防御不足
|
||||
- 接口暴露超出真实职责
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间:本轮协作初期
|
||||
- 最后更新时间:2026-04-09
|
||||
- 修复版本:[当前工作区]
|
||||
- 验证版本:[当前工作区]
|
||||
- 备注:字符集相关问题暂未处理,仍按 MBCS 假设工作
|
||||
@@ -0,0 +1,68 @@
|
||||
# BUG-20260409-0003
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260409-0003
|
||||
- 标题: 直接最大化触发收口保护,导致黑背景与控件残影
|
||||
- 状态:已修复 / 待回归验证
|
||||
- 严重性:S2
|
||||
- 优先级:P0
|
||||
- 模块:Window / Resize / Scene Redraw
|
||||
- 版本 / 分支:`master`
|
||||
- 环境:Windows 桌面 + EasyX + VS2022
|
||||
- 发现人:用户反馈
|
||||
- 关联 Fix ID:`Fix-BUG-20260409-0003`
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象:
|
||||
- 某些用例在窗口初始大小下直接点击最大化后,新暴露出的区域显示为黑色背景。
|
||||
- 已有控件可能出现残留、残影或旧内容未被覆盖。
|
||||
- 若先手动拖动边框略微放大,再执行最大化,则问题消失。
|
||||
- 影响范围:
|
||||
- 顶层窗口 resize 收口
|
||||
- 背景完整重绘
|
||||
- 控件 resize 后的快照/标脏收口
|
||||
- 期望结果:
|
||||
- 无论是拖拽 resize 还是直接最大化,只要 `WM_SIZE` 进入收口流程,都应完成一次完整 redraw。
|
||||
- 实际结果:
|
||||
- 某些“大跨度但合法”的最大化尺寸变化,会在收口中途被直接跳过,导致背景和控件未完成刷新。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 复现条件:
|
||||
- 初始窗口尺寸与最大化后的客户区尺寸跨度较大。
|
||||
- 典型用例:
|
||||
1. `KEY == 1`:`1200x400 -> 2160x1417`
|
||||
2. `KEY == 2`:`700x510 -> 2160x1417`
|
||||
3. `KEY == 3 / 4`:初始尺寸较大时不复现
|
||||
- 复现概率:高
|
||||
- 证据:
|
||||
- `WM_SIZE:待处理=(2160x1417), isSizing=0`
|
||||
- `调整窗口尺寸开始:width=... height=...`
|
||||
- `尺寸调整被跨度保护跳过:old=(...) new=(...) diff=(...)`
|
||||
- 异常日志中缺失 `尺寸调整已完成`
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置:
|
||||
- [`Window.cpp`](D:/programming/imGUI-easyX/imGui-easyX/src/StellarX/Window.cpp) 的 `runEventLoop()` resize 收口逻辑
|
||||
- 可疑代码特征:
|
||||
- 使用 `abs(finalW - width) > 1000 || abs(finalH - height) > 1000` 作为跳过条件
|
||||
- 命中后直接:
|
||||
- `needResizeDirty = false`
|
||||
- `continue`
|
||||
- 根因判断:
|
||||
- 该分支把“跨度较大的合法最大化”误判为异常帧
|
||||
- 导致后续 `Resize / adaptiveLayout / redrawScene / done log` 全部被跳过
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间:2026-04-09
|
||||
- 最后更新时间:2026-04-09
|
||||
- 修复版本:当前工作区
|
||||
- 验证版本:待用户回归
|
||||
- 备注:
|
||||
- 当前证据表明问题与 DPI 不是同一级根因,更直接的是跨度阈值误杀。
|
||||
@@ -0,0 +1,68 @@
|
||||
# BUG-20260410-0004
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: `BUG-20260410-0004`
|
||||
- 标题: 按钮 Tooltip 显示后,鼠标移出按钮区域提示框不消失
|
||||
- 状态:已修复
|
||||
- 严重性:S3
|
||||
- 优先级:P1
|
||||
- 模块: Button / Tooltip / 托管重绘
|
||||
- 版本 / 分支: 当前工作区 / 下一版本开发中
|
||||
- 环境: Windows + EasyX,本地 GUI Demo
|
||||
- 发现人: 用户
|
||||
- 关联 Fix ID: `Fix-BUG-20260410-0004`
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象:
|
||||
- 按钮启用 Tooltip 后,提示框成功显示。
|
||||
- 鼠标离开按钮区域后,Tooltip 仍残留在屏幕上,不会及时消失。
|
||||
- 影响范围:
|
||||
- 所有启用了 Tooltip 的 `Button`
|
||||
- 典型场景包括 `KEY == 4` 的综合回归用例
|
||||
- 期望结果:
|
||||
- 鼠标离开按钮后,Tooltip 应立即回贴并消失,不留下残影
|
||||
- 实际结果:
|
||||
- Tooltip 逻辑状态已切换为隐藏,但屏幕上的提示框区域未被及时擦除
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:
|
||||
- 使用启用 Tooltip 的按钮
|
||||
- 复现步骤:
|
||||
|
||||
1. 运行带 Tooltip 按钮的 Demo
|
||||
2. 将鼠标悬停在按钮上,等待 Tooltip 显示
|
||||
3. 将鼠标移出按钮区域,观察 Tooltip 是否立即消失
|
||||
|
||||
- 复现概率:高概率
|
||||
- 最小复现 Demo:
|
||||
- `z-testDome.cpp` 中 `KEY == 4`
|
||||
- 证据:现象观察 + 代码链路分析
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置:
|
||||
- [`Button.cpp`](D:/programming/imGUI-easyX/imGui-easyX/src/StellarX/Button.cpp)
|
||||
- `Button::hideTooltip()`
|
||||
- 触发条件:
|
||||
- Tooltip 已经显示
|
||||
- 鼠标离开按钮,进入 Tooltip 隐藏分支
|
||||
- 相关线索:
|
||||
- Tooltip 不是独立控件树成员,而是 `Button::draw()` 末尾补画的内置浮层
|
||||
- 隐藏时若只作废快照而不回贴 Tooltip 自己的背景,按钮本体重绘并不会覆盖 Tooltip 占用区域
|
||||
- 最近相关改动:
|
||||
- 事件改状态 / Window 托管重绘收口
|
||||
- 布局系统第一阶段重构
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-04-10
|
||||
- 最后更新时间: 2026-04-10
|
||||
- 修复版本: 待定
|
||||
- 验证版本: 当前工作区
|
||||
- 备注:
|
||||
- 本文档仅记录问题本身,具体修复方案见关联 Fix 文档
|
||||
@@ -0,0 +1,73 @@
|
||||
# BUG-20260415-0005
|
||||
|
||||
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- ID: BUG-20260415-0005
|
||||
- 标题: 局部重绘未补画上层兄弟导致遮挡错误
|
||||
- 状态:已修复
|
||||
- 严重性:S2
|
||||
- 优先级:P1
|
||||
- 模块: Window / Canvas / TabControl 局部重绘与合成
|
||||
- 版本 / 分支: 当前工作区
|
||||
- 环境: Windows + EasyX
|
||||
- 发现人: 用户回归测试
|
||||
- 关联 Fix ID:[可选]
|
||||
- `Fix-BUG-20260415-0005`
|
||||
|
||||
## 问题描述
|
||||
|
||||
- 现象:
|
||||
- 下层控件发生局部重绘后,位于其上方且与 coverage 相交的兄弟控件没有被补画回来。
|
||||
- 表现为上层 `Canvas / Dialog / 页 / 页签按钮` 被“切掉”或被下层重新盖住。
|
||||
- 影响范围:
|
||||
- 顶层普通控件之间
|
||||
- `Canvas` 直接子控件之间
|
||||
- `TabControl` 页签按钮与页面之间
|
||||
- 期望结果:
|
||||
- 任意局部重绘提交后,父容器应按实际绘制顺序把 coverage 上方相交的兄弟重新合成回来。
|
||||
- 实际结果:
|
||||
- 仅 dirty root 或 dirty child 被重画,上层兄弟未恢复。
|
||||
|
||||
## 复现信息
|
||||
|
||||
- 前置条件:[可选]
|
||||
- 使用 `KEY5` 测试场景
|
||||
- 复现步骤:
|
||||
|
||||
1. 进入 `KEY5`
|
||||
2. 触发下层控件局部重绘,例如 `Table` 翻页或相交子控件 hover / click
|
||||
3. 观察上层相交兄弟区域
|
||||
|
||||
- 复现概率:高概率
|
||||
- 最小复现 Demo:[可选]
|
||||
- `KEY5` 中 `Table` 与顶层粉色浮层重叠场景
|
||||
- 同父 `Canvas` 兄弟相交场景
|
||||
- 证据:截图 / 日志 / 调用栈 / 录屏 / 断点观察
|
||||
- 断点观察显示 dirty root 已提交,但上层 sibling 未重新执行 draw
|
||||
|
||||
## 初步分析
|
||||
|
||||
- 疑似位置:
|
||||
- [`Window.cpp`](D:/programming/imGUI-easyX/imGui-easyX/src/StellarX/Window.cpp)
|
||||
- [`Canvas.cpp`](D:/programming/imGUI-easyX/imGui-easyX/src/StellarX/Canvas.cpp)
|
||||
- [`TabControl.cpp`](D:/programming/imGUI-easyX/imGui-easyX/src/StellarX/TabControl.cpp)
|
||||
- 触发条件:
|
||||
- 局部重绘提交写入了下层像素,但没有做 overlay 兄弟补画
|
||||
- 相关线索:
|
||||
- 该问题不依赖“是否回贴背景快照”,直接重绘同样会破坏上层合成结果
|
||||
- `Dialog` 之前之所以需要补画,本质上是同一类问题
|
||||
- 最近相关改动:[可选]
|
||||
- 第二阶段开始引入更严格的局部重绘与 overlay 收口后,问题被系统性暴露
|
||||
|
||||
## 跟踪信息
|
||||
|
||||
- 首次发现时间: 2026-04-15
|
||||
- 最后更新时间: 2026-04-15
|
||||
- 修复版本:[可选]
|
||||
- 当前工作区
|
||||
- 验证版本:[可选]
|
||||
- 当前工作区
|
||||
- 备注:[可选]
|
||||
- 后续仍需继续关注 `Dialog` 旧 synthetic move 机制与新模型的统一。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user