Compare commits
24 Commits
v2.2.2
..
81341fab2c
| Author | SHA1 | Date | |
|---|---|---|---|
| 81341fab2c | |||
| c23b32bb40 | |||
| c57ddaf74f | |||
| 18caf91633 | |||
| 0db94a7d41 | |||
| e514a7dbb9 | |||
| 43cf4ac5f7 | |||
| 225bc35219 | |||
| 3d08bac1b9 | |||
| c218c25310 | |||
| c0eca1d4bd | |||
| 568ffc40eb | |||
| 9155a86a8a | |||
| 2388f22c99 | |||
| 738cf035bb | |||
| b7ad960518 | |||
| 241a564095 | |||
| 97710db9e3 | |||
| f567369300 | |||
| 77a8fe568a | |||
| b07a4ec370 | |||
| 7f8431a18c | |||
| 4a6e153da5 | |||
| dde570ac3c |
@@ -1,29 +0,0 @@
|
||||
name: Mirror to GitCode
|
||||
on:
|
||||
push:
|
||||
branches: [ "**" ] # 任意分支推送触发
|
||||
create:
|
||||
tags: [ "*" ] # 新建 tag 触发
|
||||
workflow_dispatch: # 支持手动触发
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 必须拿全历史+所有 tag
|
||||
- name: Push --mirror to GitCode
|
||||
env:
|
||||
GITCODE_USER: ${{ secrets.GITCODE_USER }}
|
||||
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
git config --global user.name "mirror-bot"
|
||||
git config --global user.email "mirror-bot@users.noreply.github.com"
|
||||
# 如果你的命名空间不是个人用户,而是组织,请把 ${GITCODE_USER} 换成组织名
|
||||
git remote add gitcode https://${GITCODE_USER}:${GITCODE_TOKEN}@gitcode.com/${GITCODE_USER}/StellarX.git
|
||||
git push --prune --mirror gitcode
|
||||
+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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-255
@@ -1,255 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the StellarX project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
[中文文档](CHANGELOG.md)
|
||||
|
||||
## [v2.2.1] - 2025-11-04
|
||||
|
||||
==This release is a hotfix for v2.2.0==
|
||||
|
||||
### ✅ Fixed
|
||||
|
||||
- The `TabControl` class overrode the base class's `setDirty` method to ensure synchronized update status between the tab and its page list.
|
||||
- The `Canvas` container, special container `TabControl`, and dialog `Dialog` overrode the `requestRepaint` method. When control bubbling propagates upward, the parent pointer is passed. Repaint requests now only bubble up one level to the parent and no longer propagate to the root. Furthermore, the entire parent container is no longer repainted; instead, the parent container repaints only the dirtied child controls, avoiding flickering caused by frequent repaints of the entire container.
|
||||
- The `saveBackground` and `restoreBackground` methods were overridden in `Dialog` to ensure no border remnants remain after the dialog is closed.
|
||||
|
||||
### ⚙️ Changed
|
||||
|
||||
- Completely disabled copy and move semantics for the `Control` class:
|
||||
`Control(const Control&) = delete;`
|
||||
`Control& operator=(const Control&) = delete;`
|
||||
`Control(Control&&) = delete;`
|
||||
`Control& operator=(Control&&) = delete;`
|
||||
|
||||
## [v2.2.0] - 2025-11-02
|
||||
|
||||
**Highlights**: Officially introduces the TabControl, enhances control show/hide and layout responsiveness, and refines the text styling mechanism; fixes several UI details to improve stability.
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **TabControl (tabbed container control)**: Added the `TabControl` class to implement a multi-page tabbed UI. The tab bar supports **top/bottom/left/right** positions via `TabControl::setTabPlacement(...)`. Provides `TabControl::add(std::pair<std::unique_ptr<Button>, std::unique_ptr<Canvas>>&&)` to add a “tab button + page content” pair in one go and automatically manage switching. Each page’s content area is hosted by a `Canvas`; clicking different tabs (Button, TOGGLE mode) switches the visible page **(see API)**. TabControl can automatically adjust tab layout when the window size changes and uses background snapshots to avoid ghosting under transparent themes.
|
||||
- **Control visibility**: All controls now support runtime **show/hide** toggling. The base class adds `Control::setIsVisible(bool)` to control a control’s own visibility; hidden controls no longer participate in drawing and events. Container controls (`Canvas`) override this to implement **cascading hide**: hiding a container automatically hides all its child controls, and showing it restores them. This makes it more convenient to toggle the visibility of a group of UI elements.
|
||||
- **Window resize handling**: Introduces the virtual function `Control::onWindowResize()`, called when the parent window size changes. Controls can implement this to respond to window resizing (e.g., reset background caches, adjust layout). `Canvas` implements this and **recursively notifies child controls** to call `onWindowResize()`, ensuring nested layouts update correctly. This improvement fixes possible misalignment or background issues that occurred after resizing.
|
||||
- **Label text style structure**: The `Label` control now uses a unified `ControlText` style structure to manage fonts and colors. By accessing the public member `Label::textStyle`, you can set font name, size, color, underline, etc., achieving **full text style customization** (replacing the previous `setTextColor` interface). This enables richer formatting when displaying text with Label.
|
||||
- **Dialog de-duplication mechanism**: `Window` adds an internal check to **prevent popping up duplicate dialogs with the same content**. When using `MessageBox::showAsync` for a non-modal popup, the framework checks if a dialog with the same title and message is already open; if so, it avoids creating another one. This prevents multiple identical prompts from appearing in quick succession.
|
||||
|
||||
### ⚙️ Changed
|
||||
|
||||
- **Text color interface adjustment**: `Label` removes the outdated `setTextColor` method; use the public `textStyle.color` to set text color instead. To change a Label’s text color, modify `label.textStyle.color` and redraw. This improves consistency in text property management but may be incompatible with older code and require replacement.
|
||||
- **Tooltip styling and toggle**: The `Button` Tooltip interface is adjusted to support more customization. `Button::setTooltipStyle` can now flexibly set tooltip text color, background color, and transparency; `setTooltipTextsForToggle(onText, offText)` is added so toggle buttons can display different tooltip texts in **ON/OFF** states. The original tooltip-text setting interface remains compatible, but the internal implementation is optimized, fixing the previous issue where toggle-button tooltip text didn’t update.
|
||||
- **Control coordinate system and layout**: Controls now maintain both **global coordinates** and **local coordinates**. `Control` adds members (such as `getLocalX()/getLocalY()`) to get positions relative to the parent container, and a `parent` pointer to the parent container. This makes layout calculations in nested containers more convenient and accurate. In absolute-layout scenarios, a control’s global coordinates are automatically converted and stored as local coordinates when added to a container. Note: in the new version, when moving controls or changing sizes, prefer using the control’s own setters to keep internal coordinates in sync.
|
||||
- **Window resizing defaults**: Resizing is changed from experimental to **enabled by default**. The framework always enables resizable styles for the main window (`WS_THICKFRAME | WS_MAXIMIZEBOX | ...`), so there’s no need to call a separate method to enable it. This simplifies usage and means created windows can be resized by users by default. For scenarios where resizing is not desired, pass specific mode flags at creation time to disable it.
|
||||
|
||||
### ✅ Fixed
|
||||
|
||||
- **Toggle-button tooltip updates**: Fixed an issue where tooltips for toggle-mode buttons did not update promptly when the state changed. With `setTooltipTextsForToggle`, the tooltip now correctly shows the corresponding text when switching between **ON/OFF**.
|
||||
- **Background ghosting and coordinate sync for controls**: Fixed defects where, in some cases, control backgrounds were not refreshed in time and position calculations deviated after window/container resizing. By using `onWindowResize` to uniformly discard and update background snapshots, background ghosting and misalignment during window resizing are avoided, improving overall stability.
|
||||
- **`Control` class background-snapshot memory leak**: The destructor now calls `discardBackground` to free and restore the background snapshot, preventing the memory leak caused by not releasing `*saveBkImage` in the previous version.
|
||||
- **Duplicate dialog pop-ups**: Fixed an issue where repeatedly calling a non-modal message box in quick succession could create multiple identical dialogs. The new de-duplication check ensures that only one non-modal dialog with the same content exists at a time, avoiding UI disruption.
|
||||
- **Other**: Optimized the control drawing/refresh strategy to reduce unnecessary repaints in certain scenarios and improve performance; corrected minor memory-management details to eliminate potential leaks. These enhancements further improve the framework’s performance and reliability.
|
||||
|
||||
## [v2.1.0] - 2025-10-27
|
||||
|
||||
**Focus**: Resizable/maximized window (EasyX reinforced by Win32), first-phase layout manager (HBox/VBox), early Tabs control. We also fixed black borders, maximize “ghosts”, flicker, and the issue where controls only appeared after interaction. Control-level **background snapshot/restore**, **single-line truncation**, and **tooltips** are now standardized.
|
||||
|
||||
### ✨ Added
|
||||
|
||||
- **Bilingual API Documentation (Chinese and English)**
|
||||
- The documentation provides a detailed introduction of each class, including API descriptions, functionalities, and points to note, with a comprehensive explanation of each control.
|
||||
|
||||
- **Window resize/maximize reinforcement (EasyX + Win32)**
|
||||
- `Window::enableResize(bool enable, int minW, int minH)`; toggle at runtime and set min track size.
|
||||
- Subclassed WndProc for `WM_GETMINMAXINFO / WM_SIZE / WM_EXITSIZEMOVE / WM_ERASEBKGND / WM_PAINT` with `WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_CLIPCHILDREN | WS_CLIPSIBLINGS`.
|
||||
- **Full-surface background paint** in `WM_PAINT` (solid/image), removing black borders and maximize ghosts.
|
||||
- **One-shot redraw** on resize with `pendingW/H + needResizeDirty` coalescing.
|
||||
- **Layout manager (phase 1)**
|
||||
- On `Canvas`: `LayoutKind::{Absolute, HBox, VBox, Grid(placeholder), Flow(placeholder), Stack(placeholder)}` with `LayoutParams` (`margins`, `fixedW/fixedH(-1=same)`, `weight`, `Align{Start,Center,End,Stretch}`).
|
||||
- Implemented **HBox/VBox** auto layout inside containers; containers stay absolutely positioned; nesting supported.
|
||||
- **Tabs control (early)**
|
||||
- Decoupled tab strip and page container; background snapshot for transparent themes.
|
||||
- **Button single-line truncation (MBCS; CN/EN aware)**
|
||||
- Pixel-width threshold with `...` avoiding half-glyph artifacts.
|
||||
- **Hover tooltip**
|
||||
- Implemented via `Label` with delay & auto-hide; per-control background snapshot/restore; customizable text with sensible fallback.
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **Window rendering path**
|
||||
- Reduced reliance on “offscreen frame blit”; in `WM_PAINT` use **GDI full background + EasyX batch drawing** (`BeginBatchDraw/EndBatchDraw + FlushBatchDraw`) to suppress flicker.
|
||||
- On resize, only rebuild the scaled background (`zoomBackground`); actual painting happens next frame.
|
||||
- **Control base**
|
||||
- Standardized **captureBackground/restoreBackground**; transparent/stacked visuals are stable.
|
||||
- Unified `dirty`: containers paint their own background when dirty; **children still evaluate/draw** as needed each frame.
|
||||
- **Table**
|
||||
- In transparent mode, pagination widgets now inherit table text/fill style.
|
||||
- Reworked **pagination math + block centering** (header included in available width).
|
||||
- Fixed background snapshot sizing (header inclusion) that caused failed restores.
|
||||
- **Event loop**
|
||||
- Coalesced `WM_SIZE` to the loop tail to avoid redraw storms and pointer-hover lag.
|
||||
|
||||
### ✅ Fixed
|
||||
|
||||
- **Black borders / maximize ghost / flicker**: blocked system background erase; full-surface paint in `WM_PAINT`; cleared clipping to prevent stale fragments.
|
||||
- **Containers not drawn; controls only after interaction**: first-frame & post-input full-tree dirty; children draw even if the container isn’t dirty.
|
||||
- **Table pagination overlap & transparent shadowing**: corrected snapshot area; recomputed coordinates; instant restore + redraw after paging.
|
||||
|
||||
### ⚠️ Breaking
|
||||
|
||||
- If external code accessed `Window` private members (e.g., `dialogs`), use `getControls()` / `getdialogs()`.
|
||||
- Pagination math & header inclusion may shift hard-coded offsets in custom renderers.
|
||||
- Custom controls that don’t restore `SetWorkingImage(nullptr)` before drawing should be reviewed.
|
||||
|
||||
### 📌 Upgrade notes
|
||||
|
||||
1. Migrate manual `cleardevice()+putimage` paths to unified **full-surface background** in `WM_PAINT`.
|
||||
2. For transparent controls, `captureBackground()` before first draw; `restoreBackground()` when hiding/overdrawing.
|
||||
3. For layout, set container `layout.kind = HBox/VBox` and child `LayoutParams` (`margin/fixed/weight/align`).
|
||||
4. Keep a **single** truncation pass for buttons to avoid duplicate `...`.
|
||||
5. Prefer built-in table pagination.
|
||||
|
||||
## [v2.0.1] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- New example: 32-bit register viewer tool implemented based on StellarX (supports bit inversion, left shift, right shift, hexadecimal/decimal signed/unsigned toggle, grouped binary display).
|
||||
- Example path: `examples/register-viewer/`
|
||||
- `TextBox` added `setText` API, allowing external setting of text box content
|
||||
- `TextBox::setText` API modified: immediately calls `draw` method to redraw after setting text
|
||||
- `Button` added `setButtonClick` API, allowing external functions to modify button click state and execute corresponding callback functions
|
||||
- ==All documents updated with corresponding English versions(.en)==
|
||||
|
||||
## [v2.0.0] - 2025-09-21
|
||||
|
||||
### Overview
|
||||
v2.0.0 is a major release. This release introduces dialog and message box factory (Dialog / MessageBox), with several important fixes and improvements to event distribution, API semantics, and internal resource management.
|
||||
|
||||
Some APIs/behaviors have breaking changes that are not backward compatible.
|
||||
|
||||
### Added
|
||||
- **Dialog System**:
|
||||
- Added `Dialog` class, inheriting from `Canvas`, for building modal and non-modal dialogs
|
||||
- Added `MessageBox` factory class, providing two convenient APIs: `ShowModal` (synchronous blocking) and `ShowAsync` (asynchronous callback)
|
||||
- Supports six standard message box types: `OK`, `OKCancel`, `YesNo`, `YesNoCancel`, `RetryCancel`, `AbortRetryIgnore`
|
||||
- Automatically handles dialog layout, background saving and restoration, event propagation, and lifecycle management
|
||||
|
||||
- **Enhanced Event System**:
|
||||
- All controls' `handleEvent` methods now return `bool` type, indicating whether the event was consumed
|
||||
- Introduced event consumption mechanism, supporting finer-grained event propagation control
|
||||
- Window class event loop now prioritizes dialog event processing
|
||||
|
||||
- **Control State Management**:
|
||||
- Control base class added `dirty` flag and `setDirty()` method, uniformly managing redraw state
|
||||
- All controls now implement `IsVisible()` and `model()` methods
|
||||
|
||||
- **API Enhancements**:
|
||||
- Button class added `setButtonFalseColor()` method
|
||||
- TextBox class `setMaxCharLen()` now accepts `size_t` type parameter
|
||||
- Window class added dialog management methods and duplicate detection mechanism
|
||||
|
||||
### Breaking Changes
|
||||
- **API Signature Changes**:
|
||||
- All controls' `handleEvent(const ExMessage& msg)` method changed from returning `void` to returning `bool`
|
||||
- Control base class added pure virtual functions `IsVisible() const` and `model() const`, all derived classes must implement them
|
||||
|
||||
- **Resource Management Changes**:
|
||||
- Control base class style saving changed from stack objects to heap objects, managed using pointers
|
||||
- Enhanced resource release safety, all resources are properly released in destructors
|
||||
|
||||
- **Event Handling Logic**:
|
||||
- Window's `runEventLoop()` method completely rewritten, now prioritizes dialog events
|
||||
- Introduced event consumption mechanism, events do not continue propagating after being consumed
|
||||
|
||||
### Fixed
|
||||
- **Memory Management**:
|
||||
- Fixed memory leak issue in `Button::setFillIma()`
|
||||
- Fixed resource release issues in Control base class destructor
|
||||
- Fixed background image resource management issues in Dialog class
|
||||
|
||||
- **Layout and Rendering**:
|
||||
- Fixed pagination calculation, column width, and row height boundary issues in `Table` component
|
||||
- Fixed layout disorder caused by `pX` coordinate accumulation error in `Table`
|
||||
- Fixed dirty determination issue in `Canvas::draw()` that prevented child controls from being drawn
|
||||
- Fixed asymmetric call issues between `TextBox::draw()` and `restoreStyle()`
|
||||
|
||||
- **Event Handling**:
|
||||
- Fixed window event distribution logic to ensure dialog/top-level controls prioritize event processing
|
||||
- Fixed delayed state updates when mouse moves out of button area
|
||||
- Fixed race conditions in non-modal dialog event handling
|
||||
|
||||
- **Other Issues**:
|
||||
- Fixed potential errors in text measurement and rendering
|
||||
- Fixed incomplete background restoration after dialog closure
|
||||
- Fixed z-order management issues in multi-dialog scenarios
|
||||
|
||||
### Changed
|
||||
- **Code Quality**:
|
||||
- Refactored numerous internal APIs, enhancing exception safety and maintainability
|
||||
- Used smart pointers and modern C++ features to replace raw new/delete
|
||||
- Unified code style and naming conventions
|
||||
|
||||
- **Performance Optimization**:
|
||||
- Optimized event processing flow, reducing unnecessary redraws
|
||||
- Improved dialog background saving and restoration mechanism
|
||||
- Reduced memory allocation and copy operations
|
||||
|
||||
- **Documentation and Examples**:
|
||||
- Added detailed usage examples for all new features
|
||||
- Improved code comments and API documentation
|
||||
- Updated README and CHANGELOG to reflect latest changes
|
||||
|
||||
## [1.1.0] - 2025-09-08
|
||||
|
||||
### Added
|
||||
- **Window Class API Enhancements**:
|
||||
- Added complete getter method set, improving class encapsulation and usability
|
||||
- `getHwnd()` - Get window handle for integration with native Windows API
|
||||
- `getWidth()` - Get window width
|
||||
- `getHeight()` - Get window height
|
||||
- `getHeadline()` - Get window title
|
||||
- `getBkcolor()` - Get window background color
|
||||
- `getBkImage()` - Get window background image pointer
|
||||
- `getControls()` - Get reference to control management container, allowing iteration and manipulation of added controls
|
||||
|
||||
### Improved
|
||||
- **API Consistency**: Provided symmetric setter and getter methods for all important attributes
|
||||
- **Code Documentation**: Further improved class comments, making them clearer and more professional
|
||||
|
||||
## [1.0.0] - 2025-09-08
|
||||
|
||||
### Release Summary
|
||||
First stable release
|
||||
|
||||
### Added
|
||||
|
||||
- First stable version of StellarX framework
|
||||
- Complete control library: Button, Label, TextBox, Canvas, Table, and Window
|
||||
- CMake-based build system
|
||||
- Detailed documentation and example code
|
||||
- **Explicit declaration: This framework is specifically designed for Windows platform**
|
||||
|
||||
### Released
|
||||
- **First release of pre-compiled binary library files**, facilitating quick integration without compiling from source
|
||||
- Provided release packages include:
|
||||
- `StellarX-v1.0.0-x64-Windows-msvc-x64.zip`
|
||||
- **Build Environment**: Visual Studio 2022 (MSVC v143)
|
||||
- **Architecture**: x64 (64-bit)
|
||||
- **Runtime Library**: `/MD`
|
||||
- **Build Modes**: `Release | Debug`
|
||||
- **Contents**: Includes all necessary header files (`include/`) and static library files (`lib/StellarX-Debug.lib StellarX-Release.lib`)
|
||||
|
||||
### Core Features
|
||||
- Modular design following SOLID principles
|
||||
- Unified control interface (`draw()` and `handleEvent()`)
|
||||
- Support for multiple control shapes and styles
|
||||
- Custom event handling callbacks
|
||||
- Lightweight design with no external dependencies
|
||||
|
||||
## [0.1.0] - 2025-08-15
|
||||
### Added
|
||||
- Initial project structure and core architecture
|
||||
- Control base class and basic event handling system
|
||||
- Basic examples and documentation setup
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
# 更新日志
|
||||
|
||||
StellarX 项目所有显著的变化都将被记录在这个文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
[English document](CHANGELOG.en.md)
|
||||
|
||||
## [v2.2.1] - 2025 - 11 - 4
|
||||
|
||||
==此版本为v2.2.0的修复版本==
|
||||
|
||||
### ✅ 修复
|
||||
|
||||
- `TabControl`类重写了基类的`setDirty`方法保证页签+页列表同步更新
|
||||
|
||||
状态
|
||||
|
||||
- `Canvas`容器和特殊容器`TabControl`以及对话框`Dialog`重写`requestRepaint`方法,控件向上冒泡时传递父指针,请求重绘时只向上到父一级,不再传递到根。并且不再重绘整个父容器,而是由父容器重绘标脏的子控件,避免了频繁真个容器重绘导致的频闪
|
||||
|
||||
- `Dialog`中重写了`saveBackground`和`restBackground`方法,保证对话框关闭后不会有边框残留
|
||||
|
||||
### ⚙️ 变更
|
||||
|
||||
- 彻底禁用`Control`的移动构造`Control(const Control&) = delete;`
|
||||
`Control& operator=(const Control&) = delete;`
|
||||
`Control(Control&&) = delete;`
|
||||
`Control& operator=(Control&&) = delete;`
|
||||
|
||||
## [v2.2.0] - 2025-11-02
|
||||
|
||||
**重点**:正式引入选项卡控件(TabControl),增强控件显隐与布局响应能力,并完善文本样式机制;修复若干UI细节问题以提升稳定性。
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **选项卡容器控件 TabControl**:新增 `TabControl` 类,实现多页面选项卡界面。支持页签栏在 **上/下/左/右** 四种位置排列,可通过 `TabControl::setTabPlacement(...)` 设置。提供 `TabControl::add(std::pair<std::unique_ptr<Button>, std::unique_ptr<Canvas>>&&)` 接口,一次性添加“页签按钮+页面内容”对,并自动管理切换显示。各页内容区域由 `Canvas` 承载,点击不同页签(Button,TOGGLE模式)将切换显示对应页面**【见 API】**。TabControl 在窗口大小变化时可自动调整页签布局,并使用背景快照避免透明主题下的叠影问题。
|
||||
- **控件显隐控制**:所有控件现支持运行时**显示/隐藏**切换。基类新增方法 `Control::setIsVisible(bool)` 控制自身可见性,隐藏后控件不参与绘制和事件。容器控件(`Canvas`)重写该方法,实现**级联隐藏**:隐藏容器将自动隐藏其中所有子控件,再次显示时子控件也随之恢复。这使一组界面元素的显隐切换更加方便。
|
||||
- **窗口尺寸变化响应**:引入 `Control::onWindowResize()` 虚函数,当父窗口尺寸改变时调用。各控件可通过实现此函数响应窗口大小调整,例如重置背景缓存、调整布局等。`Canvas` 已实现该函数,会在窗口变化时**递归通知子控件**调用 `onWindowResize()`,确保嵌套布局能正确更新。此改进解决了之前窗口拉伸后子控件可能位置错位或背景异常的问题。
|
||||
- **Label 文本样式结构**:`Label` 控件现在使用统一的 `ControlText` 样式结构管理字体和颜色。可通过访问公有成员 `Label::textStyle` 设置字体名称、大小、颜色、下划线等属性,实现**完整文本样式定制**(替代原先的 setTextColor 接口)。这使 Label 在展示文本时支持更丰富的格式。
|
||||
- **Dialog 防重复机制**:`Window` 新增内部检查函数,**防止重复弹出相同内容的对话框**。在使用 `MessageBox::showAsync` 非模态弹窗时,框架会判断是否已有相同标题和消息的对话框未关闭,若是则避免再次创建。此机制杜绝了短时间内弹出多个相同提示的情况。
|
||||
|
||||
### ⚙️ 变更
|
||||
|
||||
- **文本颜色接口调整**:`Label` 移除了过时的 `setTextColor` 方法,改为通过公有的 `textStyle.color` 设置文字颜色。如需改变 Label 文本颜色,请直接修改 `label.textStyle.color` 并重绘。此改动提升了文本属性管理的一致性,但可能对旧版代码不兼容,需要做相应替换。
|
||||
- **Tooltip 样式与切换**:`Button` 的悬停提示 (Tooltip) 接口调整为支持更多自定义。`Button::setTooltipStyle` 现在可灵活设置提示文字颜色、背景色及透明模式;新增 `setTooltipTextsForToggle(onText, offText)` 用于切换按钮在 **ON/OFF** 两种状态下显示不同的提示文字。原有 Tooltip 文案设置接口仍兼容,但内部实现优化,修正了先前切换按钮提示文字不更新的问题。
|
||||
- **控件坐标系与布局**:控件现在同时维护**全局坐标**和**局部坐标**。`Control` 新增成员(如 `getLocalX()/getLocalY()` 等)用于获取控件相对父容器的位置,以及 `parent` 指针指向父容器。这一改进使得嵌套容器布局计算更加便捷和准确。在绝对布局场景下,控件的全局坐标会在添加进容器时自动转换为本地坐标保存。开发者需要注意,新版中移动控件或调整尺寸应优先使用控件自带的 setter,以确保内部坐标同步更新。
|
||||
- **默认窗口调整**:窗口拉伸功能从实验转为默认支持。框架始终为主窗口启用了可调整大小的样式(`WS_THICKFRAME|WS_MAXIMIZEBOX|...`),不再需要调用独立的方法启用。这一变更简化了使用,同时意味着创建的窗口默认可以被用户拖拽拉伸。对于不希望窗口缩放的场景,可在创建窗口时通过传递特定模式标志来禁止。
|
||||
|
||||
### ✅ 修复
|
||||
|
||||
- **切换按钮 Tooltip 更新**:修正了切换模式按钮在状态改变时悬停提示未及时更新的问题。现在使用 `setTooltipTextsForToggle` 设置不同提示后,按钮在 **ON/OFF** 状态切换时悬停提示文字会正确显示对应内容。
|
||||
- **控件背景残影与坐标同步**:解决了某些情况下窗口或容器尺寸变化后控件背景未及时刷新、位置计算偏差的缺陷。利用 `onWindowResize` 统一丢弃并更新背景快照,避免了拉伸窗口时可能出现的控件背景残影和错位现象,界面稳定性提升。
|
||||
- **`Control`类背景快照内存泄漏**:在析构函数里调用了`discardBackground`释放并恢复背景快照,避免了上一版本未释放`*saveBkImage`造成的内存泄漏
|
||||
- **重复对话框弹出**:修复了快速重复调用非模态消息框可能出现多个相同对话框的问题。新增的去重判断保证相同内容的非模态对话框同一时间只会存在一个,避免用户界面受到干扰。
|
||||
- **其他**:优化了控件绘制刷新策略,减少某些场景下的不必要重绘,提升运行效率;修正少量内存管理细节以消除潜在泄漏。上述改进进一步提高了框架的性能与可靠性。
|
||||
|
||||
## [v2.1.0] - 2025-10-27
|
||||
|
||||
**重点**:窗口可拉伸/最大化补强(EasyX + Win32)、布局管理器(HBox/VBox 第一阶段)、选项卡控件雏形;系统性修复黑边、最大化残影、频闪与“控件需交互才出现”等历史问题。并统一了**背景快照/恢复**、**按钮单行截断**、**Tooltip** 的机制。
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
- **中英文双语 API文档**
|
||||
- 文档详细介绍了每个类,以及API描述、功能和需要注意的地方,详细介绍了每个控件
|
||||
|
||||
- **窗口拉伸 / 最大化补强(在 EasyX 基础上用 Win32 加固)**
|
||||
- 新增 `Window::enableResize(bool enable, int minW, int minH)`;运行时开关可拉伸并设置最小跟踪尺寸。
|
||||
- 子类化窗口过程:处理 `WM_GETMINMAXINFO / WM_SIZE / WM_EXITSIZEMOVE / WM_ERASEBKGND / WM_PAINT`,并启用 `WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_CLIPCHILDREN | WS_CLIPSIBLINGS`,解决 EasyX 窗口原生不可拉伸问题。
|
||||
- **整窗背景绘制**:在 `WM_PAINT` 用 GDI 直写客户区(纯色/图片均支持),彻底消除黑边与最大化残影。
|
||||
- **一次性重绘**:合并连续 `WM_SIZE`,使用 `pendingW/H + needResizeDirty` 在主循环尾部统一置脏重绘,避免死循环与抖动。
|
||||
- **布局管理器(第一阶段)**
|
||||
- 在 `Canvas` 引入布局元数据:`LayoutKind::{Absolute, HBox, VBox, Grid(预留), Flow(预留), Stack(预留)}` 与 `LayoutParams`(`margin{L,R,T,B}`、`fixedW/fixedH(-1=沿用)`、`weight`、`Align{Start,Center,End,Stretch}`)。
|
||||
- 实装 **HBox/VBox** 自动布局(容器内部自动排布;容器本身仍绝对定位,可嵌套)。
|
||||
- **选项卡(Tabs)控件雏形**
|
||||
- 页签条与页面容器解耦;多页签切换;为透明主题提供**背景快照**避免叠影。
|
||||
- **按钮文本单行截断(MBCS:中/英分治)**
|
||||
- 基于像素宽度阈值的 `...` 截断,避免半字节/半汉字。
|
||||
- **悬停提示(Tooltip)**
|
||||
- 以 `Label` 为载体,支持延时出现、自动隐藏、自定义文案(默认回退到按钮文本);使用控件级**背景快照/恢复**。
|
||||
|
||||
### 🔧 变更
|
||||
|
||||
- **Window 渲染路径**
|
||||
- 弱化“离屏 frame→整屏贴图”的依赖;在 `WM_PAINT` 走 **GDI 整窗背景 + EasyX 批量绘制**(`BeginBatchDraw/EndBatchDraw + FlushBatchDraw`)以抑制频闪。
|
||||
- 尺寸变化仅重建 `zoomBackground`(图片背景的缩放副本),显示延后到下一帧。
|
||||
- **Control 基类**
|
||||
- 标准化 **captureBackground/restoreBackground**;统一透明/叠放控件行为。
|
||||
- 统一 `dirty` 语义:容器自身脏才重画背景,但**子控件每帧评估并按需绘制**,不再出现“容器不脏→子控件不画”的空窗。
|
||||
- **Table**
|
||||
- 透明模式下,分页按钮与页码标签跟随表格的文本/填充风格,避免风格漂移。
|
||||
- **分页度量与整体居中**重做:可用宽度含表头;“上一页/下一页+页码”作为**一个块**水平居中。
|
||||
- 修复**背景快照区域**未计入表头导致恢复失败的问题。
|
||||
- **事件循环**
|
||||
- 合并 `WM_SIZE` 到循环尾统一处理,降低输入延迟与重绘风暴。
|
||||
|
||||
### ✅ 修复
|
||||
|
||||
- **黑边 / 最大化残影 / 频闪**:`WM_ERASEBKGND` 返回 1 禁止系统擦背景;`WM_PAINT` 全面覆盖;清空裁剪区防止旧帧残留。
|
||||
- **容器不画、控件需交互后才出现**:首帧与输入后触发**全树置脏**;子控件在容器未脏时也能正常绘制。
|
||||
- **Table 翻页重叠与透明叠影**:修正快照区域、重算坐标并即时恢复+重绘。
|
||||
|
||||
### ⚠️ 可能不兼容
|
||||
|
||||
- 若外部代码直接访问 `Window` 私有成员(如 `dialogs`),请改用 `getControls()` / `getdialogs()`。
|
||||
- `Table` 的分页与表头度量变化可能影响外部硬编码偏移;需要对齐新公式。
|
||||
- 自定义控件若未遵循绘制前 `SetWorkingImage(nullptr)` 的约定,请自查。
|
||||
|
||||
### 📌 升级指引
|
||||
|
||||
1. **窗口背景**:将手工 `cleardevice()+putimage` 迁移到 `WM_PAINT` 的**整窗覆盖**流程。
|
||||
2. **透明控件**:首绘前 `captureBackground()`,隐藏/覆盖时 `restoreBackground()`。
|
||||
3. **布局**:容器设 `layout.kind = HBox/VBox`;子项用 `LayoutParams`(`margin/fixed/weight/align`)。
|
||||
4. **按钮截断**:保持单次截断,避免重复 `...`。
|
||||
5. **表格**:移除自绘分页,使用内置导航。
|
||||
|
||||
## [v2.0.1] - 2025 - 10 - 03
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增示例:基于 StellarX 实现的 32 位寄存器查看工具(支持位取反、左移、右移,十六进制/十进制带符号/无符号切换,分组二进制显示)。
|
||||
- 示例路径:`examples/register-viewer/`
|
||||
- `TextBox`新增`setText`API,可在外部设置文本框内容
|
||||
- `TextBox::setText`API修改:在设置文本后立即调用`draw`方法重绘
|
||||
- `Button`新增`setButtonClick`API,允许通过外部函数修改按钮的点击状态,并执行相应的回调函数
|
||||
- ==所有文档更新对应英文版本==
|
||||
|
||||
## [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,94 +0,0 @@
|
||||
# Contributing to StellarX
|
||||
|
||||
Thank you for your interest in contributing to StellarX! This document provides guidelines and steps for contributing.
|
||||
|
||||
StellarX is a C++ GUI framework built for the **Windows platform**, based on the EasyX graphics library.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
1. Install Visual Studio 2019 or later
|
||||
2. Install the corresponding version of EasyX graphics library
|
||||
3. Install CMake 3.12 or later
|
||||
4. Clone the project repository
|
||||
5. Use CMake to generate the solution and compile
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Reporting Bugs
|
||||
1. Check [Issues](../../issues) to see if the bug has already been reported.
|
||||
2. If not, create a new Issue.
|
||||
3. Use the "Bug Report" template.
|
||||
4. Provide a **clear title and description**.
|
||||
5. Include relevant code snippets, screenshots, or steps to reproduce the issue.
|
||||
|
||||
### Suggesting Enhancements
|
||||
1. Check existing Issues to see if your idea has been suggested.
|
||||
2. Create a new Issue using the "Feature Request" template.
|
||||
3. Clearly describe the new feature and explain why it would be useful.
|
||||
|
||||
### Submitting Code Changes (Pull Requests)
|
||||
1. **Fork** the repository on GitHub.
|
||||
2. **Clone** your forked repository to your local machine.
|
||||
3. Create a **new branch** for your feature or bug fix (`git checkout -b my-feature-branch`).
|
||||
4. **Make your changes**. Ensure your code follows the project's style (see below).
|
||||
5. **Commit your changes** with clear, descriptive commit messages.
|
||||
6. **Push** your branch to your forked GitHub repository (`git push origin my-feature-branch`).
|
||||
7. Open a **Pull Request** against the original StellarX repository's `main` branch.
|
||||
|
||||
## Code Style Guide
|
||||
|
||||
* Follow the existing code formatting and naming conventions in the project.
|
||||
* Use meaningful names for variables, functions, and classes.
|
||||
* Comment your code when necessary, especially for complex logic.
|
||||
* Ensure your code compiles without warnings.
|
||||
* Test your changes thoroughly.
|
||||
* Use **4 spaces** for indentation (no tabs)
|
||||
* Class names use **PascalCase** (e.g., `ClassName`)
|
||||
* Functions and variables use **camelCase** (e.g., `functionName`, `variableName`)
|
||||
* Constants use **UPPER_CASE** (e.g., `CONSTANT_VALUE`)
|
||||
* Member variables use **m_** prefix (e.g., `m_memberVariable`)
|
||||
* Use meaningful names for control properties, avoid abbreviations
|
||||
* Add detailed comments for all public interfaces
|
||||
* Follow RAII principles for resource management
|
||||
|
||||
## Example Code Style
|
||||
|
||||
```c++
|
||||
// Good Example
|
||||
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 {
|
||||
// Drawing logic
|
||||
}
|
||||
|
||||
private:
|
||||
bool m_isActive;
|
||||
};
|
||||
|
||||
// Bad Example
|
||||
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{
|
||||
// Drawing logic
|
||||
}
|
||||
private:
|
||||
bool active;
|
||||
};
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
Please follow the project's directory structure:
|
||||
|
||||
- Header files go in the `include/StellarX/` directory
|
||||
- Implementation files go in the `src/` directory
|
||||
- Example code goes in the `examples/` directory
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have any questions, feel free to open an Issue or contact the maintainers.
|
||||
|
||||
@@ -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.
|
||||
-298
@@ -1,298 +0,0 @@
|
||||
# StellarX GUI Framework README
|
||||
|
||||
[中文README](README.md)
|
||||
|
||||
------
|
||||
|
||||

|
||||
[](https://github.com/Ysm-04/StellarX)
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> **“Bounded by the stars, light as dust.”** — An ultra-lightweight, highly modular, native C++ GUI framework for Windows.
|
||||
|
||||
`StellarX` rejects bloat: no hundreds-of-MB dependencies, no marathon builds, and no steep learning curve. Back to the essence—clean code, clear architecture, and high efficiency to solve the core needs of desktop app development.
|
||||
|
||||
This is a **teaching-grade and tooling-grade** framework that helps developers understand GUI fundamentals and quickly build lightweight utilities.
|
||||
|
||||
------
|
||||
|
||||
## **🆕 v2.2.1 (Hotfix for v2.2.0)**
|
||||
|
||||
- Addressed a flickering issue that occurred when using the Canvas and TabControl containers.
|
||||
- Fixed issues where border remnants and functional buttons could persist after closing a Dialog.
|
||||
|
||||
For details, please refer to the [CHANGELOG.en](CHANGELOG.en.md).
|
||||
|
||||
## What’s new in v2.2.0
|
||||
|
||||
- **New TabControl for multi-page tabbed UIs:** With `TabControl`, it’s easy to create a tabbed layout. Tabs can be arranged on the top, bottom, left, or right, and clicking switches the displayed page. Suitable for settings panels and multi-view switching.
|
||||
- **Enhanced control show/hide and resize responsiveness:** All controls now share a unified interface (`setIsVisible`) to toggle visibility. When a container control is hidden, its child controls automatically hide/show with it. Meanwhile, we introduce `onWindowResize` for controls to respond to window size changes so elements update in sync after resizing, eliminating artifacts or misalignment.
|
||||
- **Refined text-style mechanism:** The Label control now uses a unified `ControlText` style structure. Developers can easily customize font, color, size, etc. (replacing older interfaces, and more flexible). Button Tooltips also support richer customization and different texts for toggle states.
|
||||
- **Other improvements:** Dialog management gains de-duplication to prevent identical prompts from popping up repeatedly. Several bug fixes and refresh optimizations further improve stability.
|
||||
|
||||
See `CHANGELOG.md / CHANGELOG.en.md` for the full list.
|
||||
|
||||
------
|
||||
|
||||
## 📦 Project Structure & Design Philosophy
|
||||
|
||||
StellarX adopts classic **OOP** and **modular** design with a clear structure:
|
||||
|
||||
```
|
||||
StellarX/
|
||||
├── include/
|
||||
│ └── StellarX/
|
||||
│ ├── StellarX.h
|
||||
│ ├── CoreTypes.h # single source of truth (enums/structs)
|
||||
│ ├── Control.h
|
||||
│ ├── Button.h
|
||||
│ ├── Window.h
|
||||
│ ├── Label.h
|
||||
│ ├── TextBox.h
|
||||
│ ├── TabControl.h #v2.2.0
|
||||
│ ├── Canvas.h
|
||||
│ ├── Dialog.h
|
||||
│ ├── MessageBox.h
|
||||
│ └── Table.h
|
||||
├── src/
|
||||
│ ├── Control.cpp
|
||||
│ ├── Button.cpp
|
||||
│ ├── Window.cpp
|
||||
│ ├── Label.cpp
|
||||
│ ├── TextBox.cpp
|
||||
│ ├── Canvas.cpp
|
||||
│ ├── TabControl.cpp #v2.2.0
|
||||
│ ├── Table.cpp
|
||||
│ ├── Dialog.cpp
|
||||
│ └── MessageBox.cpp
|
||||
├── examples/
|
||||
│ └── demo.cpp
|
||||
├── docs/
|
||||
│ └── CODE_OF_CONDUCT.md
|
||||
├── CMakeLists.txt
|
||||
├── CONTRIBUTING.md
|
||||
├── CHANGELOG.md
|
||||
├── CHANGELOG.en.md
|
||||
├── Doxyfile
|
||||
├── LICENSE
|
||||
├──API 文档.md
|
||||
├──API Documentation.en.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Design Philosophy:**
|
||||
|
||||
1. **Single Responsibility (SRP):** each class/file does exactly one thing.
|
||||
2. **Dependency Inversion (DIP):** high-level modules depend on abstractions (`Control`), not concrete controls.
|
||||
3. **Open/Closed (OCP):** extend by inheriting from `Control` without modifying existing code.
|
||||
4. **Consistency:** unified `draw()` / `handleEvent()` across all controls.
|
||||
|
||||
## 🚀 Core Features
|
||||
|
||||
- **Ultra-lightweight:** no heavyweight external dependencies besides EasyX.
|
||||
- **Clear modules:** `CoreTypes.h` unifies types and enums.
|
||||
- **Native performance:** EasyX + Win32 for efficient execution and low memory (often <10 MB).
|
||||
- **Complete control set:** Button, Label, TextBox, Canvas, Table, Dialog, MessageBox, **TabControl**.
|
||||
- **Highly customizable:** colors; shapes (rectangle/rounded/circle/ellipse); fills; fonts—switchable via enums.
|
||||
- **Simple, intuitive API:** OOP design with clear semantics—code as documentation.
|
||||
- **Standard project layout:** split `include/src`, CMake-friendly, easy to integrate or use out of the box.
|
||||
|
||||
------
|
||||
|
||||
## ⚡ Quick Start (5 minutes)
|
||||
|
||||
> Get the prebuilt package from [Releases](https://github.com/Ysm-04/StellarX/releases/latest).
|
||||
|
||||
### Requirements
|
||||
|
||||
- **OS:** Windows 10+
|
||||
- **Compiler:** C++17 (e.g., VS 2019+)
|
||||
- **Graphics:** [EasyX](https://easyx.cn/) 2022+ (matching your compiler)
|
||||
- **Build:** CMake 3.12+ (optional)
|
||||
|
||||
### Install EasyX
|
||||
|
||||
1. Download the latest EasyX
|
||||
2. Install components matching your Visual Studio version
|
||||
3. The framework links automatically—no extra config needed
|
||||
|
||||
### Build with CMake (recommended)
|
||||
|
||||
```
|
||||
git clone https://github.com/Ysm-04/StellarX.git
|
||||
cd StellarX
|
||||
mkdir build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
./examples/Demo
|
||||
```
|
||||
|
||||
### Manual Integration
|
||||
|
||||
- Copy `include` and `src`
|
||||
- Add header search path: `include/StellarX/`
|
||||
- Add all `.cpp` files to your project
|
||||
|
||||
### First Resizable Window
|
||||
|
||||
```
|
||||
#include "StellarX.h"
|
||||
|
||||
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
|
||||
{
|
||||
// Resizing enabled by default; current size is the minimum size
|
||||
Window mainWindow(800, 600, 0, RGB(255,255,255), "My StellarX App");
|
||||
mainWindow.draw();
|
||||
|
||||
// Add your controls...
|
||||
// mainWindow.addControl(std::move(btn));
|
||||
|
||||
mainWindow.runEventLoop();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
> Implementation note: perform **full-window background drawing** (solid/image) during `WM_PAINT`, and combine with EasyX batch drawing to suppress flicker and black edges.
|
||||
|
||||
------
|
||||
|
||||
## 📚 Core Types (excerpt from `CoreTypes.h`)
|
||||
|
||||
### Enums
|
||||
|
||||
| Enum | Description | Common values |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------------ |
|
||||
| `ControlShape` | Geometric shape | `RECTANGLE`, `B_RECTANGLE`, `ROUND_RECTANGLE`, `CIRCLE`, `ELLIPSE` |
|
||||
| `ButtonMode` | Button behavior | `NORMAL`, `TOGGLE`, `DISABLED` |
|
||||
| `TextBoxMode` | TextBox mode | `INPUT_MODE`, `READONLY_MODE` |
|
||||
| `FillMode` | Fill mode | `SOLID`, `NULL`, `HATCHED` |
|
||||
| `FillStyle` | Pattern style | `HORIZONTAL`, `CROSS` |
|
||||
| `LineStyle` | Line style | `SOLID`, `DASH`, `DOT` |
|
||||
| `MessageBoxType` | Message box type | `OK`, `OKCancel`, `YesNo`, ... |
|
||||
| `MessageBoxResult` | Result | `OK`, `Cancel`, `Yes`, `No`, `Abort`, `Retry`, `Ignore` |
|
||||
| `TabPlacement` | Tab position | `Top`, `Bottom`, `Left`, `Right` |
|
||||
|
||||
### Structs
|
||||
|
||||
| Struct | Description |
|
||||
| -------------- | ---------------------------------------------------- |
|
||||
| `ControlText` | Font/size/color/bold/italic/underline/strike-through |
|
||||
| `RouRectangle` | Corner ellipse size for rounded rectangles |
|
||||
|
||||
------
|
||||
|
||||
## 🧩 Controls Library
|
||||
|
||||
### 1) Basic Controls
|
||||
|
||||
| Control | Header | Description | Key Points |
|
||||
| ------- | ----------- | ----------------- | ------------------------------------------------------------ |
|
||||
| Button | `Button.h` | Versatile button | Shapes/modes; hover/pressed colors; callbacks; **single-line truncation** + **Tooltip** (v2.1.0) |
|
||||
| Label | `Label.h` | Text label | Transparent/opaque background; custom fonts |
|
||||
| TextBox | `TextBox.h` | Input/display box | Input/readonly; integrates EasyX `InputBox` |
|
||||
|
||||
### 2) Container Controls
|
||||
|
||||
| Control | Header | Description |
|
||||
| ------- | ---------- | ------------------------------------------------------------ |
|
||||
| Canvas | `Canvas.h` | Parent container with custom border/background; **built-in HBox/VBox auto layout** (v2.1.0) |
|
||||
| Window | `Window.h` | Top-level container with message loop and dispatch; **resizable** (v2.1.0) |
|
||||
|
||||
### 3) Advanced Controls
|
||||
|
||||
| Control | Header | Description | Key Points |
|
||||
| ---------- | -------------- | ----------- | ------------------------------------------------------------ |
|
||||
| Table | `Table.h` | Data grid | Paging/header/auto column width; fixed page-control overlap/ghosting (v2.1.0) |
|
||||
| Dialog | `Dialog.h` | Dialog | Modal/non-modal; auto layout; background save/restore |
|
||||
| TabControl | `TabControl.h` | Tabs | One-click add of “tab + page” pair (pair), or add child controls to a page; uses relative coordinates |
|
||||
|
||||
### 4) Static Factory
|
||||
|
||||
| Control | Header | Description | Key Points |
|
||||
| ---------- | -------------- | ------------------- | -------------------------------------------- |
|
||||
| MessageBox | `MessageBox.h` | Message-box factory | Static API; modal/non-modal; de-dup built in |
|
||||
|
||||
------
|
||||
|
||||
## 📐 Layout Management (HBox/VBox)
|
||||
|
||||
==Reserved, to be implemented==
|
||||
|
||||
------
|
||||
|
||||
## 🗂 Tabs (TabControl)
|
||||
|
||||
- Tab strip (button group) + page container (`Canvas`)
|
||||
- For transparent themes: **background snapshot** switching in the page area to avoid ghosting
|
||||
- API: **see the API documentation**
|
||||
|
||||
------
|
||||
|
||||
## ✂️ Single-line Text Truncation & Button Tooltip
|
||||
|
||||
- **Button truncation:** separate handling for CJK/Latin under MBCS; append `...` based on pixel-width threshold
|
||||
- **Tooltip:** delayed show and auto-hide; default text = button text; customizable; uses control-level **background snapshot/restore**
|
||||
|
||||
------
|
||||
|
||||
## 🧊 Transparent Background & Background Snapshots
|
||||
|
||||
- **General convention:** `captureBackground(rect)` before the first draw; `restoreBackground()` before hiding/covering
|
||||
- **Table:** snapshot region **includes the header**; after page switch, restore immediately + redraw; paging controls centered
|
||||
|
||||
------
|
||||
|
||||
## 🔧 Advanced Topics & Best Practices
|
||||
|
||||
- Custom controls: inherit from `Control`, implement `draw()` / `handleEvent()`
|
||||
- Performance:
|
||||
- **Dirty rectangles:** set `dirty=true` on state changes for on-demand redraw
|
||||
- **Avoid extra `cleardevice()`**: background is centrally handled in `WM_PAINT`
|
||||
- Ensure `SetWorkingImage(nullptr)` before drawing so output goes to the screen
|
||||
- Event consumption: return `true` after handling to stop propagation
|
||||
|
||||
------
|
||||
|
||||
## ⚠️ Applicability & Limits
|
||||
|
||||
- Not suitable for high-performance games or complex animation; re-verify metrics under extreme DPI
|
||||
- No accessibility support yet
|
||||
- Windows-only, not cross-platform
|
||||
- For complex commercial front-ends, consider Qt / wxWidgets / ImGui / Electron
|
||||
|
||||
------
|
||||
|
||||
## 📜 License
|
||||
|
||||
MIT (see `LICENSE`).
|
||||
|
||||
## 👥 Contributing Guidelines
|
||||
|
||||
- Follow the existing C++ style
|
||||
- New features should include examples and README updates
|
||||
- Self-test before submitting and explain the motivation for changes
|
||||
- For bugs/ideas, please open an Issue
|
||||
|
||||
## 🙏 Acknowledgements
|
||||
|
||||
- Thanks to [EasyX](https://easyx.cn/)
|
||||
- Thanks to developers who value **simplicity/efficiency/clarity**
|
||||
|
||||
------
|
||||
|
||||
**Stars and seas, code as the vessel.**
|
||||
|
||||
## 📞 Support & Feedback
|
||||
|
||||
- See [examples/](examples/)
|
||||
- Read the [CHANGELOG](CHANGELOG.md / CHANGELOG.en.md)
|
||||
- Submit an Issue on GitHub
|
||||
@@ -1,308 +0,0 @@
|
||||
# 星垣 (StellarX) GUI Framework
|
||||
|
||||
[English document](README.en.md)
|
||||
|
||||
> 本仓库为 **StellarX** 主仓:开发与 Issue/PR 均在 GitHub 进行。
|
||||
> GitCode 仅为只读镜像:如需反馈请到 GitHub:https://github.com/Ysm-04/StellarX
|
||||
|
||||
[](https://gitcode.com/Ysm-04/StellarX)
|
||||
|
||||
------
|
||||
|
||||

|
||||
[](https://github.com/Ysm-04/StellarX)
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> **「繁星为界,轻若尘埃」** —— 一个为 Windows 平台打造的、极致轻量级、高度模块化的 C++ 原生 GUI 框架。
|
||||
|
||||
`星垣 (StellarX)` 反对臃肿,拒绝动辄数百 MB 的依赖、漫长编译与高门槛学习曲线,回归本质:以精简代码、清晰架构与高效率,解决桌面应用开发的核心需求。
|
||||
|
||||
这是一个**教学级、工具级**框架,帮助开发者深入理解 GUI 原理,并快速构建轻量工具。
|
||||
|
||||
---
|
||||
|
||||
## 🆕v2.2.1(v2.2.0修复版)
|
||||
|
||||
- 解决了使用Canvas和TabControl容器时,出现频闪问题
|
||||
|
||||
- 修复了Dialog对话框关闭时概率出边边框残留和功能按钮残留问题
|
||||
|
||||
详情参考[更新日志](CHANGELOG.md)
|
||||
|
||||
|
||||
|
||||
## V2.2.0 有何变化
|
||||
|
||||
- **新增 TabControl 控件,实现多页面选项卡界面:** 通过 `TabControl` 可以轻松创建选项卡式布局,支持页签在上下左右排列、点击切换显示不同内容页面。适用于设置面板、多视图切换等场景。
|
||||
- **控件显隐与布局响应能力增强:** 现在所有控件都可以使用统一接口动态隐藏或显示(`setIsVisible`),容器控件隐藏时其内部子控件会自动随之隐藏/显示。与此同时,引入控件对窗口尺寸变化的响应机制(`onWindowResize`),窗口拉伸后界面各元素可协调更新,杜绝拉伸过程中出现残影或错位。
|
||||
- **文本样式机制完善:** Label 控件改用统一的文本样式结构 `ControlText`,开发者可方便地设置字体、颜色、大小等属性来定制 Label 的外观(替代旧接口,更加灵活)。Button 的 Tooltip 提示也支持更丰富的定制和针对切换状态的不同提示文本。
|
||||
- **其他改进:** 框架底层的对话框管理增加了防重复弹出相同提示的机制,修复了一些细节 Bug 并优化了刷新效率,进一步提升了稳定性。
|
||||
|
||||
详见 `CHANGELOG.md / CHANGELOG.en.md` 获取完整更新列表。
|
||||
|
||||
---
|
||||
|
||||
## 📦 项目结构与设计哲学
|
||||
|
||||
星垣采用经典 **OOP** 与 **模块化** 设计,结构清晰:
|
||||
|
||||
```markdown
|
||||
StellarX/
|
||||
├── include/
|
||||
│ └── StellarX/
|
||||
│ ├── StellarX.h
|
||||
│ ├── CoreTypes.h # 唯一定义源(枚举/结构体)
|
||||
│ ├── Control.h
|
||||
│ ├── Button.h
|
||||
│ ├── Window.h
|
||||
│ ├── Label.h
|
||||
│ ├── TextBox.h
|
||||
│ ├── TabControl.h #v2.2.0
|
||||
│ ├── Canvas.h
|
||||
│ ├── Dialog.h
|
||||
│ ├── MessageBox.h
|
||||
│ └── Table.h
|
||||
├── src/
|
||||
│ ├── Control.cpp
|
||||
│ ├── Button.cpp
|
||||
│ ├── Window.cpp
|
||||
│ ├── Label.cpp
|
||||
│ ├── TextBox.cpp
|
||||
│ ├── Canvas.cpp
|
||||
│ ├── TabControl.cpp #v2.2.0
|
||||
│ ├── Table.cpp
|
||||
│ ├── Dialog.cpp
|
||||
│ └── MessageBox.cpp
|
||||
├── examples/
|
||||
│ └── demo.cpp
|
||||
├── docs/
|
||||
│ └── CODE_OF_CONDUCT.md
|
||||
├── CMakeLists.txt
|
||||
├── CONTRIBUTING.md
|
||||
├── CHANGELOG.md
|
||||
├── CHANGELOG.en.md
|
||||
├── Doxyfile
|
||||
├── LICENSE
|
||||
├──API 文档.md
|
||||
├──API Documentation.en.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**设计理念:**
|
||||
|
||||
1. **单一职责(SRP)**:每个类/文件只做一件事。
|
||||
2. **依赖倒置(DIP)**:高层模块依赖抽象(`Control`),而非具体控件。
|
||||
3. **开闭原则(OCP)**:继承 `Control` 可扩展新控件,无需修改既有代码。
|
||||
4. **一致性**:所有控件统一 `draw()` / `handleEvent()` 接口。
|
||||
|
||||
## 🚀 核心特性
|
||||
|
||||
- **极致轻量**:除 EasyX 外无外部重量级依赖。
|
||||
- **模块清晰**:`CoreTypes.h` 统一类型与枚举。
|
||||
- **原生性能**:EasyX + Win32,执行高效、内存低占用(常见 <10MB)。
|
||||
- **控件齐全**:Button、Label、TextBox、Canvas、Table、Dialog、MessageBox、**TabControl**。
|
||||
- **高度自定义**:颜色、形状(矩形/圆角/圆/椭圆)、填充、字体等皆有枚举配置,易于切换。
|
||||
- **简单直观 API**:OOP 设计,接口语义明确、调用友好,代码即文档。
|
||||
- **标准工程结构**:include/src 分离,支持 CMake 构建,方便集成到现有项目或开箱即用。
|
||||
|
||||
------
|
||||
|
||||
## ⚡ 快速开始(5 分钟上手)
|
||||
|
||||
> 从 [Releases](https://github.com/Ysm-04/StellarX/releases/latest) 获取预编译包。
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **系统**:Windows 10+
|
||||
- **编译器**:C++17(如 VS 2019+)
|
||||
- **图形库**: [EasyX](https://easyx.cn/) 2022+(与编译器匹配)
|
||||
- **构建**:CMake 3.12+(可选)
|
||||
|
||||
### 安装 EasyX
|
||||
|
||||
1. 下载 EasyX 最新版
|
||||
2. 按 VS 版本安装匹配组件
|
||||
3. 框架会自动链接,无需额外配置
|
||||
|
||||
### CMake 构建(推荐)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Ysm-04/StellarX.git
|
||||
cd StellarX
|
||||
mkdir build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
./examples/Demo
|
||||
```
|
||||
|
||||
### 手动集成
|
||||
|
||||
- 拷贝 `include` 与 `src`
|
||||
- 配置头文件搜索路径:`include/StellarX/`
|
||||
- 将全部 `.cpp` 加入工程
|
||||
|
||||
### 第一个可拉伸窗口
|
||||
|
||||
```c++
|
||||
#include "StellarX.h"
|
||||
|
||||
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
|
||||
{
|
||||
//默认启动拉伸,当前尺寸为最小尺寸
|
||||
Window mainWindow(800, 600, 0, RGB(255,255,255), "我的星垣应用");
|
||||
mainWindow.draw();
|
||||
|
||||
// 添加你的控件...
|
||||
// mainWindow.addControl(std::move(btn));
|
||||
|
||||
mainWindow.runEventLoop();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
> 实现要点:在 `WM_PAINT` 期进行**整窗背景绘制**(纯色/图片),并配合 EasyX 批量绘制抑制频闪与黑边。
|
||||
|
||||
------
|
||||
|
||||
## 📚 核心类型(`CoreTypes.h` 摘要)
|
||||
|
||||
### 枚举
|
||||
|
||||
| 枚举 | 描述 | 常用值 |
|
||||
| ------------------ | ---------- | ------------------------------------------------------------ |
|
||||
| `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`, ... |
|
||||
| `MessageBoxResult` | 结果 | `OK`, `Cancel`, `Yes`, `No`, `Abort`, `Retry`, `Ignore` |
|
||||
| `TabPlacement` | 页签位置 | `Top`,`Bottom`,`Left`,`Right` |
|
||||
|
||||
### 结构体
|
||||
|
||||
| 结构体 | 描述 |
|
||||
| -------------- | -------------------------------------- |
|
||||
| `ControlText` | 字体/字号/颜色/粗体/斜体/下划线/删除线 |
|
||||
| `RouRectangle` | 圆角矩形的角部椭圆尺寸 |
|
||||
|
||||
------
|
||||
|
||||
## 🧩 控件库
|
||||
|
||||
### 1) 基础控件
|
||||
|
||||
| 控件 | 头文件 | 描述 | 关键点 |
|
||||
| ------- | ----------- | ----------- | ------------------------------------------------------------ |
|
||||
| Button | `Button.h` | 多功能按钮 | 形状/模式、悬停/点击色、回调,**单行截断** + **Tooltip**(v2.1.0) |
|
||||
| Label | `Label.h` | 文本标签 | 透明/不透明背景,自定义字体 |
|
||||
| TextBox | `TextBox.h` | 输入/显示框 | 输入/只读,整合 EasyX `InputBox` |
|
||||
|
||||
### 2) 容器控件
|
||||
|
||||
| 控件 | 头文件 | 描述 |
|
||||
| ------ | ---------- | ------------------------------------------------------------ |
|
||||
| Canvas | `Canvas.h` | 父容器,自定义边框/背景,**内置 HBox/VBox 自动布局**(v2.1.0) |
|
||||
| Window | `Window.h` | 顶层容器,消息循环与分发,**可拉伸**(v2.1.0) |
|
||||
|
||||
### 3) 高级控件
|
||||
|
||||
| 控件 | 头文件 | 描述 | 关键点 |
|
||||
| ---------- | -------------- | -------- | ------------------------------------------------------------ |
|
||||
| Table | `Table.h` | 数据表格 | 分页/表头/列宽自动、翻页控件重叠/透明叠影已修复(v2.1.0) |
|
||||
| Dialog | `Dialog.h` | 对话框 | 支持模态/非模态,自动布局与背景保存/恢复 |
|
||||
| TabControl | `TabControl.h` | 选项卡 | 支持一键添加“页签+页”对(pair)也可以单独对某页添加子控件,采用相对坐标 |
|
||||
|
||||
### 4) 静态工厂
|
||||
|
||||
| 控件 | 头文件 | 描述 | 关键点 |
|
||||
| ---------- | -------------- | ---------- | ----------------------------------- |
|
||||
| MessageBox | `MessageBox.h` | 消息框工厂 | 静态 API;支持模态/非模态;内置去重 |
|
||||
|
||||
------
|
||||
|
||||
## 📐 布局管理(HBox/VBox)
|
||||
|
||||
==预留,待实现==
|
||||
|
||||
|
||||
|
||||
------
|
||||
|
||||
## 🗂 选项卡(TabControl)
|
||||
|
||||
- 页签条(按钮组) + 页面容器(`Canvas`)
|
||||
- 透明主题:页面区域**背景快照**切换,避免叠影
|
||||
- API :**查看API文档**
|
||||
|
||||
------
|
||||
|
||||
## ✂️ 文本单行截断 & Button Tooltip
|
||||
|
||||
- **按钮截断**:多字节字符集下**中/英分治**,基于像素宽度阈值追加 `...`
|
||||
- **Tooltip**:延时出现、自动隐藏;默认文字=按钮文本,可自定义;使用控件级**背景快照/恢复**
|
||||
|
||||
------
|
||||
|
||||
## 🧊 透明背景与背景快照
|
||||
|
||||
- **通用约定**:首绘前 `captureBackground(rect)`,隐藏/覆盖前 `restoreBackground()`
|
||||
- **Table**:快照区域**包含表头**;翻页后立即恢复 + 重绘,分页控件整体居中
|
||||
|
||||
------
|
||||
|
||||
## 🔧 高级主题与最佳实践
|
||||
|
||||
- 自定义控件:继承 `Control`,实现 `draw()` / `handleEvent()`
|
||||
- 性能:
|
||||
- **脏矩形**:状态改变时置 `dirty=true`,按需重绘
|
||||
- **避免额外 `cleardevice()`**:背景已由 `WM_PAINT` 统一处理
|
||||
- 绘制前确保 `SetWorkingImage(nullptr)` 将输出落到屏幕
|
||||
- 事件消费:处理后返回 `true` 终止传播
|
||||
|
||||
------
|
||||
|
||||
## ⚠️ 适用与限制
|
||||
|
||||
- 不适合高性能游戏/复杂动画;极端 DPI 需复核度量
|
||||
- 暂无无障碍能力
|
||||
- Windows 专用,不跨平台
|
||||
- 复杂商业前端建议用 Qt / wxWidgets / ImGui / Electron
|
||||
|
||||
------
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
MIT(见 `LICENSE`)。
|
||||
|
||||
## 👥 贡献指南
|
||||
|
||||
- 遵循现有 C++ 风格
|
||||
- 新特性需附示例与 README 更新
|
||||
- 提交前请自测,并说明变更动机
|
||||
- Bug/想法请提 Issue
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- 感谢 [EasyX](https://easyx.cn/)
|
||||
- 感谢推崇**简洁/高效/清晰**的开发者
|
||||
|
||||
------
|
||||
|
||||
**星辰大海,代码为舟。**
|
||||
|
||||
## 📞 支持与反馈
|
||||
|
||||
- 查看 [examples/](examples/)
|
||||
- 查阅 [更新日志](CHANGELOG.md)[CHANGELOG](CHANGELOG.en.md)
|
||||
- 在 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\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\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 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Being considerate and respectful of others
|
||||
* Respecting different viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
...
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly, and will result in a response that is deemed necessary and appropriate to the circumstances.
|
||||
|
||||
...
|
||||
|
||||
For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
# Register Viewer (StellarX example)
|
||||
|
||||
**A 32-bit register visualizer built with StellarX** (Windows + EasyX).
|
||||
Features:
|
||||
- 32 toggle bits (MSB→LSB = 31..0)
|
||||
- Range invert, logical left/right shift
|
||||
- Hex/Decimal display with signed/unsigned toggle
|
||||
- Grouped binary view with "last/current" snapshots
|
||||
- One-click set all 0 / all 1
|
||||
|
||||
## Build & Run
|
||||
1. Ensure StellarX and EasyX are in your include/lib paths.
|
||||
2. Build as a normal C++ Win32 desktop app (e.g., VS 2019+).
|
||||
3. Run the executable.
|
||||
|
||||
This example demonstrates StellarX APIs with a single `main.cpp` (~450 LOC):
|
||||
`Window / Canvas / Button / Label / TextBox` + simple event callbacks.
|
||||
@@ -1,462 +0,0 @@
|
||||
// 本工具基于 StellarX 构建,轻量级的 Windows GUI 框架。
|
||||
#include"StellarX.h"
|
||||
#include <sstream>
|
||||
#include<iomanip>
|
||||
#include<array>
|
||||
|
||||
auto blackColor = RGB(202, 255, 255);
|
||||
char initData[33] = "00000000000000000000000000000000";//初始数据
|
||||
bool gSigned = false; //是否为有符号数
|
||||
void main()
|
||||
{
|
||||
Window mainWindow(700,500,NULL,RGB(255,255,255), "寄存器查看工具 V1.0——我在人间做废物 (同类工具定制:3150131407(Q / V))");
|
||||
|
||||
//选择区控件
|
||||
auto selectionAreaLabel = std::make_unique<Label>(18, 0,"32位选择区");
|
||||
selectionAreaLabel->setTextdisap(true);
|
||||
std::vector<std::unique_ptr<Label>>selectionAreaButtonLabel;
|
||||
std::vector<std::unique_ptr<Button>>selectionAreaButton;
|
||||
std::vector<Button*>selectionAreaButton_ptr;
|
||||
auto selectionArea = std::make_unique <Canvas>(10, 10, 680, 150);
|
||||
|
||||
selectionArea->setCanvasBkColor(blackColor);
|
||||
selectionArea->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
|
||||
for (int y = 0; y < 2; y ++)
|
||||
{
|
||||
std::ostringstream os;
|
||||
for (int x = 0; x <16; x++)
|
||||
{
|
||||
if (0 == y)
|
||||
{
|
||||
selectionAreaButtonLabel.push_back(std::make_unique<Label>(x * 35 + 40 + 28 * (x / 4), 26, "", RGB(208, 208, 208)));
|
||||
os << std::setw(2) << std::setfill('0') << 31 - x;
|
||||
selectionAreaButtonLabel.back()->setText(os.str());
|
||||
selectionAreaButtonLabel.back()->setTextdisap(true);
|
||||
|
||||
selectionAreaButton.push_back(
|
||||
std::make_unique<Button>(x * 35 + 42 + 28 * (x / 4), 58,20,32,"0",
|
||||
blackColor,RGB(171, 196, 220),StellarX::ButtonMode::TOGGLE));
|
||||
selectionAreaButton.back()->textStyle.color = RGB(226, 116, 152);
|
||||
selectionAreaButton.back()->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
selectionAreaButton_ptr.push_back(selectionAreaButton.back().get());
|
||||
int k = 32 - x - 1;
|
||||
//选择区按钮被点击后在二进制0和1之间切换,并更新initData
|
||||
selectionAreaButton_ptr.back()->setOnToggleOnListener([k, btn = selectionAreaButton_ptr.back()]()
|
||||
{
|
||||
btn->setButtonText("1");
|
||||
initData[k] = '1';
|
||||
});
|
||||
selectionAreaButton_ptr.back()->setOnToggleOffListener([k, btn = selectionAreaButton_ptr.back()]()
|
||||
{
|
||||
btn->setButtonText("0");
|
||||
initData[k] = '0';
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
selectionAreaButtonLabel.push_back(std::make_unique<Label>(x * 35 + 40 + 28 * (x / 4), 90, "", RGB(208, 208, 208)));
|
||||
os << std::setw(2) << std::setfill('0') << 15-x;
|
||||
selectionAreaButtonLabel.back()->setText(os.str());
|
||||
selectionAreaButtonLabel.back()->setTextdisap(true);
|
||||
|
||||
selectionAreaButton.push_back(
|
||||
std::make_unique<Button>(x * 35 + 42 + 28 * (x / 4), 120, 20, 32, "0",
|
||||
blackColor, RGB(171, 196, 220), StellarX::ButtonMode::TOGGLE));
|
||||
selectionAreaButton.back()->textStyle.color = RGB(226, 116, 152);
|
||||
selectionAreaButton.back()->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
selectionAreaButton_ptr.push_back(selectionAreaButton.back().get());
|
||||
int k =15 - x;
|
||||
selectionAreaButton.back()->setOnToggleOnListener([k,btn = selectionAreaButton_ptr.back()]()
|
||||
{
|
||||
btn->setButtonText("1");
|
||||
initData[k] = '1';
|
||||
});
|
||||
selectionAreaButton.back()->setOnToggleOffListener([k, btn = selectionAreaButton_ptr.back()]()
|
||||
{
|
||||
btn->setButtonText("0");
|
||||
initData[k] = '0';
|
||||
});
|
||||
}
|
||||
os.str("");
|
||||
os.clear();
|
||||
|
||||
}
|
||||
}
|
||||
selectionArea->addControl(std::move(selectionAreaLabel));
|
||||
for (auto& s : selectionAreaButton)
|
||||
selectionArea->addControl(std::move(s));
|
||||
for (auto& s : selectionAreaButtonLabel)
|
||||
selectionArea->addControl(std::move(s));
|
||||
//功能区控件
|
||||
//功能区总容器
|
||||
auto function = std::make_unique<Canvas>(0, 0, 0, 0);
|
||||
function->setCanvasfillMode(StellarX::FillMode::Null);
|
||||
|
||||
auto bitInvert = std::make_unique<Canvas>(10,170,220,70);
|
||||
auto leftShift = std::make_unique<Canvas>(240, 170, 220, 70);
|
||||
auto rightShift = std::make_unique<Canvas>(470, 170, 220, 70);
|
||||
bitInvert->setCanvasBkColor(blackColor);
|
||||
bitInvert->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
leftShift->setCanvasBkColor(blackColor);
|
||||
leftShift->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
rightShift->setCanvasBkColor(blackColor);
|
||||
rightShift->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
|
||||
|
||||
auto bitInvertLabel = std::make_unique<Label>(18,160,"位取反");
|
||||
bitInvertLabel->setTextdisap(true);
|
||||
auto leftShiftLabel = std::make_unique<Label>(248, 160, "左移位");
|
||||
leftShiftLabel->setTextdisap(true);
|
||||
auto rightShiftLabel = std::make_unique<Label>(478, 160, "右移位");
|
||||
rightShiftLabel->setTextdisap(true);
|
||||
|
||||
// ====== 公用小工具======
|
||||
auto clamp = [](int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); };
|
||||
auto toInt = [](const std::string& s, int def = 0) {
|
||||
try { return std::stoi(s); }
|
||||
catch (...) { return def; }
|
||||
};
|
||||
// bit号(31..0) -> selectionAreaButton下标(0..31)
|
||||
auto vecIndexFromBit = [](int bit) { return 31 - bit; };
|
||||
|
||||
// 读取当前32位点击态
|
||||
auto snapshotBits = [&]() {
|
||||
std::array<bool, 32> a{};
|
||||
for (int b = 0; b < 32; ++b)
|
||||
a[b] = selectionAreaButton_ptr[vecIndexFromBit(b)]->isClicked();
|
||||
return a;
|
||||
};
|
||||
|
||||
// 应用目标态:仅当不同才 setButtonClick
|
||||
auto applyBits = [&](const std::array<bool, 32>& a) {
|
||||
for (int b = 0; b < 32; ++b) {
|
||||
auto btn = selectionAreaButton_ptr[vecIndexFromBit(b)];
|
||||
if (btn->isClicked() != a[b]) btn->setButtonClick(a[b]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
//取反区控件
|
||||
std::array<std::unique_ptr<Label>, 4> bitInvertFunctionLabel;
|
||||
bitInvertFunctionLabel[0] = std::make_unique<Label>(35, 180, "低位");
|
||||
bitInvertFunctionLabel[1] = std::make_unique<Label>(90, 180, "高位");
|
||||
bitInvertFunctionLabel[2] = std::make_unique<Label>(15, 198, "从");
|
||||
bitInvertFunctionLabel[3] = std::make_unique<Label>(75, 198, "到");
|
||||
|
||||
std::array<std::unique_ptr<TextBox>, 2> bitInvertFunctionTextBox;
|
||||
bitInvertFunctionTextBox[0] = std::make_unique<TextBox>(35, 203, 35, 30, "0");
|
||||
bitInvertFunctionTextBox[1] = std::make_unique<TextBox>(95, 203, 35, 30, "0");
|
||||
auto invL = bitInvertFunctionTextBox[0].get();
|
||||
auto invH = bitInvertFunctionTextBox[1].get();
|
||||
auto bitInvertFunctionButton = std::make_unique<Button>(150,195, 70, 35, "位取反",
|
||||
blackColor, RGB(171, 196, 220));
|
||||
bitInvertFunctionButton->textStyle.color = RGB(226, 116, 152);
|
||||
bitInvertFunctionButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
auto bitInvertFunctionButton_ptr = bitInvertFunctionButton.get();
|
||||
|
||||
bitInvert->addControl(std::move(bitInvertFunctionButton));
|
||||
bitInvert->addControl(std::move(bitInvertLabel));
|
||||
for (auto& b : bitInvertFunctionTextBox)
|
||||
{
|
||||
b->setMaxCharLen(3);
|
||||
b->textStyle.color = RGB(226, 116, 152);
|
||||
b->setTextBoxBk(RGB(244, 234, 142));
|
||||
b->setTextBoxshape(StellarX::ControlShape::B_RECTANGLE);
|
||||
bitInvert->addControl(std::move(b));
|
||||
}
|
||||
for (auto& b : bitInvertFunctionLabel)
|
||||
{
|
||||
b->setTextdisap(true);
|
||||
bitInvert->addControl(std::move(b));
|
||||
}
|
||||
//左移控件
|
||||
auto leftShiftFunctionLabel = std::make_unique<Label>(435, 198, "位");
|
||||
leftShiftFunctionLabel->setTextdisap(true);
|
||||
|
||||
auto leftShiftFunctionTextBox = std::make_unique<TextBox>(325, 195, 100, 30, "0");
|
||||
leftShiftFunctionTextBox->setMaxCharLen(3);
|
||||
leftShiftFunctionTextBox->textStyle.color = RGB(226, 116, 152);
|
||||
leftShiftFunctionTextBox->setTextBoxBk(RGB(244, 234, 142));
|
||||
leftShiftFunctionTextBox->setTextBoxshape(StellarX::ControlShape::B_RECTANGLE);
|
||||
auto shlBox = leftShiftFunctionTextBox.get();
|
||||
auto leftShiftFunctionButton = std::make_unique<Button>(250, 195, 60, 30, "左移",
|
||||
blackColor, RGB(171, 196, 220));
|
||||
leftShiftFunctionButton->textStyle.color = RGB(226, 116, 152);
|
||||
leftShiftFunctionButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
auto leftShiftFunctionButton_ptr = leftShiftFunctionButton.get();
|
||||
|
||||
|
||||
leftShift->addControl(std::move(leftShiftFunctionButton));
|
||||
leftShift->addControl(std::move(leftShiftFunctionTextBox));
|
||||
|
||||
leftShift->addControl(std::move(leftShiftLabel));
|
||||
leftShift->addControl(std::move(leftShiftFunctionLabel));
|
||||
|
||||
//右移控件
|
||||
auto rightShiftFunctionLabel = std::make_unique<Label>(665, 198, "位");
|
||||
rightShiftFunctionLabel->setTextdisap(true);
|
||||
auto rightShiftFunctionTextBox = std::make_unique<TextBox>(555, 195, 100, 30, "0");
|
||||
rightShiftFunctionTextBox->setMaxCharLen(3);
|
||||
rightShiftFunctionTextBox->textStyle.color = RGB(226, 116, 152);
|
||||
rightShiftFunctionTextBox->setTextBoxBk(RGB(244, 234, 142));
|
||||
rightShiftFunctionTextBox->setTextBoxshape(StellarX::ControlShape::B_RECTANGLE);
|
||||
auto shrBox = rightShiftFunctionTextBox.get();
|
||||
|
||||
auto rightShiftFunctionButton = std::make_unique<Button>(480, 195, 60, 30, "右移",
|
||||
blackColor, RGB(171, 196, 220));
|
||||
rightShiftFunctionButton->textStyle.color = RGB(226, 116, 152);
|
||||
rightShiftFunctionButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
auto rightShiftFunctionButton_ptr = rightShiftFunctionButton.get();
|
||||
|
||||
|
||||
|
||||
rightShift->addControl(std::move(rightShiftFunctionButton));
|
||||
|
||||
rightShift->addControl(std::move(rightShiftFunctionTextBox));
|
||||
rightShift->addControl(std::move(rightShiftLabel));
|
||||
rightShift->addControl(std::move(rightShiftFunctionLabel));
|
||||
|
||||
function->addControl(std::move(bitInvert));
|
||||
function->addControl(std::move(leftShift));
|
||||
function->addControl(std::move(rightShift));
|
||||
|
||||
//显示区控件
|
||||
//数值显示
|
||||
auto NumericalDisplayArea = std::make_unique<Canvas>(10, 255, 680, 70);
|
||||
NumericalDisplayArea->setCanvasBkColor(blackColor);
|
||||
NumericalDisplayArea->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
|
||||
std::array<std::unique_ptr<Label>, 3> NumericalDisplayAreaLabel;
|
||||
NumericalDisplayAreaLabel[0] = std::make_unique<Label>(18, 245, "数值显示区");
|
||||
NumericalDisplayAreaLabel[1] = std::make_unique<Label>(20, 278, "十六进制");
|
||||
NumericalDisplayAreaLabel[2] = std::make_unique<Label>(330, 278, "十进制");
|
||||
|
||||
std::array<std::unique_ptr<TextBox>, 2> NumericalDisplayAreaTextBox;
|
||||
NumericalDisplayAreaTextBox[0] = std::make_unique<TextBox>(110, 275, 200, 30, "0");
|
||||
NumericalDisplayAreaTextBox[1] = std::make_unique<TextBox>(400, 275, 200, 30, "0");
|
||||
auto hex = NumericalDisplayAreaTextBox[0].get();
|
||||
auto dec = NumericalDisplayAreaTextBox[1].get();
|
||||
|
||||
|
||||
for (auto& b : NumericalDisplayAreaLabel)
|
||||
{
|
||||
b->setTextdisap(true);
|
||||
NumericalDisplayArea->addControl(std::move(b));
|
||||
}
|
||||
for (auto& b : NumericalDisplayAreaTextBox)
|
||||
{
|
||||
b->setMaxCharLen(11);
|
||||
b->textStyle.color = RGB(255, 69, 0);
|
||||
b->setTextBoxBk(RGB(141, 141, 141));
|
||||
b->setTextBoxshape(StellarX::ControlShape::B_RECTANGLE);
|
||||
b->setMode(StellarX::TextBoxmode::READONLY_MODE);
|
||||
NumericalDisplayArea->addControl(std::move(b));
|
||||
}
|
||||
//二进制显示
|
||||
auto BinaryDisplayArea = std::make_unique<Canvas>(10, 335, 680, 110);
|
||||
BinaryDisplayArea->setCanvasBkColor(blackColor);
|
||||
BinaryDisplayArea->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
|
||||
std::array<std::unique_ptr<Label>, 3> BinaryDisplayAreaLabel;
|
||||
BinaryDisplayAreaLabel[0] = std::make_unique<Label>(18, 325, "二进制显示区");
|
||||
BinaryDisplayAreaLabel[1] = std::make_unique<Label>(35, 353, "上次值");
|
||||
BinaryDisplayAreaLabel[2] = std::make_unique<Label>(35, 400, "本次值");
|
||||
|
||||
std::array<std::unique_ptr<TextBox>, 2> BinaryDisplayAreaTextBox;
|
||||
BinaryDisplayAreaTextBox[0] = std::make_unique<TextBox>(110, 350, 520, 30, "0000_0000_0000_0000_0000_0000_0000_0000");
|
||||
BinaryDisplayAreaTextBox[1] = std::make_unique<TextBox>(110, 400, 520, 30, "0000_0000_0000_0000_0000_0000_0000_0000");
|
||||
auto Last = BinaryDisplayAreaTextBox[0].get();
|
||||
auto This = BinaryDisplayAreaTextBox[1].get();
|
||||
|
||||
|
||||
for (auto& b : BinaryDisplayAreaLabel)
|
||||
{
|
||||
b->setTextdisap(true);
|
||||
BinaryDisplayArea->addControl(std::move(b));
|
||||
}
|
||||
for (auto& b : BinaryDisplayAreaTextBox)
|
||||
{
|
||||
b->setMaxCharLen(40);
|
||||
b->textStyle.color = RGB(255, 69, 0);
|
||||
b->setTextBoxBk(RGB(141, 141, 141));
|
||||
b->setTextBoxshape(StellarX::ControlShape::B_RECTANGLE);
|
||||
b->setMode(StellarX::TextBoxmode::READONLY_MODE);
|
||||
BinaryDisplayArea->addControl(std::move(b));
|
||||
}
|
||||
|
||||
// 由位图 bits 生成数值
|
||||
auto valueFromBits = [](const std::array<bool, 32>& bits) -> uint32_t {
|
||||
uint32_t v = 0;
|
||||
for (int b = 0; b < 32; ++b) if (bits[b]) v |= (1u << b);
|
||||
return v;
|
||||
};
|
||||
|
||||
// 由位图 bits 生成 "0000_0000_..._0000"(MSB→LSB:31..0)
|
||||
auto binaryGroupedFromBits = [](const std::array<bool, 32>& bits) -> std::string {
|
||||
std::string s; s.reserve(39);
|
||||
for (int b = 31; b >= 0; --b) {
|
||||
s.push_back(bits[b] ? '1' : '0');
|
||||
if (b % 4 == 0 && b != 0) s.push_back('_');
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
// 用“目标位图 bits”刷新显示区
|
||||
auto refreshDisplaysWithBits = [&](const std::string& prevThis,
|
||||
const std::array<bool, 32>& bits,
|
||||
TextBox* hex, TextBox* dec, TextBox* Last, TextBox* This)
|
||||
{
|
||||
const uint32_t val = valueFromBits(bits);
|
||||
const int32_t s = static_cast<int32_t>(val);
|
||||
char hexbuf[16];
|
||||
std::snprintf(hexbuf, sizeof(hexbuf), "%08X", val);
|
||||
|
||||
hex->setText(hexbuf); // HEX(大写8位)
|
||||
dec->setText(gSigned ? std::to_string(s) : std::to_string(val)); // DEC
|
||||
Last->setText(prevThis); // 上次值 ← 刷新前的本次值
|
||||
This->setText(binaryGroupedFromBits(bits)); // 本次值 ← 由“目标位图”生成
|
||||
};
|
||||
|
||||
|
||||
bitInvertFunctionButton_ptr->setOnClickListener([=, &snapshotBits, &applyBits, &refreshDisplaysWithBits]() {
|
||||
const std::string prevThis = This->getText();
|
||||
|
||||
int L = clamp(toInt(invL->getText(), 0), 0, 31);
|
||||
int H = clamp(toInt(invH->getText(), 0), 0, 31);
|
||||
if (L > H) std::swap(L, H);
|
||||
|
||||
auto cur = snapshotBits();
|
||||
for (int b = L; b <= H; ++b) cur[b] = !cur[b];
|
||||
|
||||
applyBits(cur); // 只改按钮点击态(触发位按钮自回调)
|
||||
refreshDisplaysWithBits(prevThis, cur, hex, dec, Last, This);
|
||||
});
|
||||
|
||||
|
||||
leftShiftFunctionButton_ptr->setOnClickListener([=, &snapshotBits, &applyBits, &refreshDisplaysWithBits]() {
|
||||
const std::string prevThis = This->getText();
|
||||
|
||||
int n = clamp(toInt(shlBox->getText(), 0), 0, 31);
|
||||
auto cur = snapshotBits();
|
||||
std::array<bool, 32> nxt{}; // 默认全 0
|
||||
|
||||
// 逻辑左移:高位丢弃、低位补 0
|
||||
for (int b = 31; b >= 0; --b) nxt[b] = (b >= n) ? cur[b - n] : false;
|
||||
|
||||
applyBits(nxt);
|
||||
refreshDisplaysWithBits(prevThis, nxt, hex, dec, Last, This);
|
||||
});
|
||||
|
||||
rightShiftFunctionButton_ptr->setOnClickListener([=, &snapshotBits, &applyBits, &refreshDisplaysWithBits]() {
|
||||
const std::string prevThis = This->getText();
|
||||
|
||||
int n = clamp(toInt(shrBox->getText(), 0), 0, 31);
|
||||
auto cur = snapshotBits();
|
||||
std::array<bool, 32> nxt{};
|
||||
|
||||
// 逻辑右移:低位丢弃、高位补 0
|
||||
for (int b = 0; b < 32; ++b) nxt[b] = (b + n <= 31) ? cur[b + n] : false;
|
||||
|
||||
applyBits(nxt);
|
||||
refreshDisplaysWithBits(prevThis, nxt, hex, dec, Last, This);
|
||||
});
|
||||
|
||||
//配置区控件clearrectangle(10, 440, 690, 490);
|
||||
auto configuration = std::make_unique<Canvas>(10, 455, 680, 40);
|
||||
configuration->setCanvasBkColor(blackColor);
|
||||
configuration->setShape(StellarX::ControlShape::B_ROUND_RECTANGLE);
|
||||
|
||||
auto configurationLabel = std::make_unique<Label>(20, 445, "配置区");
|
||||
configurationLabel->setTextdisap(true);
|
||||
|
||||
std::array<std::unique_ptr<Button>,2> configurationButton;
|
||||
configurationButton[0] = std::make_unique<Button>(450, 465, 80, 20, "一键置0",
|
||||
blackColor, RGB(171, 196, 220));
|
||||
configurationButton[0]->textStyle.color = RGB(226, 116, 152);
|
||||
configurationButton[0]->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
|
||||
configurationButton[1] = std::make_unique<Button>(550, 465, 80, 20, "一键置1",
|
||||
blackColor, RGB(171, 196, 220));
|
||||
configurationButton[1]->textStyle.color = RGB(226, 116, 152);
|
||||
configurationButton[1]->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
configurationButton[0]->setOnClickListener(
|
||||
[&]() {
|
||||
for (auto& s : selectionAreaButton_ptr)
|
||||
if (s->isClicked()) s->setButtonClick(false);
|
||||
|
||||
// 刷新显示:prevThis 用当前 This 文本
|
||||
const std::string prevThis = This->getText();
|
||||
auto cur = snapshotBits();
|
||||
{
|
||||
char hexbuf[16];
|
||||
uint32_t u = 0; for (int b = 0; b < 32; ++b) if (cur[b]) u |= (1u << b);
|
||||
int32_t s = static_cast<int32_t>(u);
|
||||
std::snprintf(hexbuf, sizeof(hexbuf), "%08X", u);
|
||||
hex->setText(hexbuf);
|
||||
dec->setText(gSigned ? std::to_string(s) : std::to_string(u));
|
||||
Last->setText(prevThis);
|
||||
This->setText(binaryGroupedFromBits(cur));
|
||||
}
|
||||
});
|
||||
|
||||
configurationButton[1]->setOnClickListener(
|
||||
[&]() {
|
||||
for (auto& s : selectionAreaButton_ptr)
|
||||
if (!s->isClicked()) s->setButtonClick(true);
|
||||
|
||||
const std::string prevThis = This->getText();
|
||||
auto cur = snapshotBits();
|
||||
char hexbuf[16];
|
||||
uint32_t u = 0; for (int b = 0; b < 32; ++b) if (cur[b]) u |= (1u << b);
|
||||
int32_t s = static_cast<int32_t>(u);
|
||||
std::snprintf(hexbuf, sizeof(hexbuf), "%08X", u);
|
||||
hex->setText(hexbuf);
|
||||
dec->setText(gSigned ? std::to_string(s) : std::to_string(u));
|
||||
Last->setText(prevThis);
|
||||
This->setText(binaryGroupedFromBits(cur));
|
||||
});
|
||||
|
||||
|
||||
auto signedToggle = std::make_unique<Button>(
|
||||
350, 465, 80, 20, "无符号",
|
||||
blackColor, RGB(171, 196, 220), StellarX::ButtonMode::TOGGLE);
|
||||
signedToggle->textStyle.color = RGB(226, 116, 152);
|
||||
signedToggle->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
auto* signedTogglePtr = signedToggle.get();
|
||||
|
||||
signedTogglePtr->setOnToggleOnListener([&]() {
|
||||
gSigned = true;
|
||||
signedTogglePtr->setButtonText("有符号");
|
||||
|
||||
// 立即刷新十进制显示:用当前位图算出新值,仅改 dec
|
||||
auto cur = snapshotBits();
|
||||
const uint32_t u = [&] { uint32_t v = 0; for (int b = 0; b < 32; ++b) if (cur[b]) v |= (1u << b); return v; }();
|
||||
const int32_t s = static_cast<int32_t>(u);
|
||||
dec->setText(std::to_string(s));
|
||||
});
|
||||
signedTogglePtr->setOnToggleOffListener([&]() {
|
||||
gSigned = false;
|
||||
signedTogglePtr->setButtonText("无符号");
|
||||
|
||||
auto cur = snapshotBits();
|
||||
const uint32_t u = [&] { uint32_t v = 0; for (int b = 0; b < 32; ++b) if (cur[b]) v |= (1u << b); return v; }();
|
||||
dec->setText(std::to_string(u));
|
||||
});
|
||||
|
||||
|
||||
configuration->addControl(std::move(configurationButton[0]));
|
||||
configuration->addControl(std::move(configurationButton[1]));
|
||||
configuration->addControl(std::move(signedToggle));
|
||||
configuration->addControl(std::move(configurationLabel));
|
||||
|
||||
|
||||
mainWindow.addControl(std::move(selectionArea));
|
||||
mainWindow.addControl(std::move(function));
|
||||
mainWindow.addControl(std::move(NumericalDisplayArea));
|
||||
mainWindow.addControl(std::move(BinaryDisplayArea));
|
||||
mainWindow.addControl(std::move(configuration));
|
||||
|
||||
mainWindow.draw();
|
||||
return mainWindow.runEventLoop();
|
||||
}
|
||||
@@ -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,174 @@
|
||||
<?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\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,63 @@
|
||||
<?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\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 |
+167
-150
@@ -21,174 +21,191 @@
|
||||
#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 text; // 按钮上的文字
|
||||
bool click; // 是否被点击
|
||||
bool hover; // 是否被悬停
|
||||
|
||||
std::string cutText; // 切割后的文本
|
||||
bool needCutText = true; // 是否需要切割文本
|
||||
bool isUseCutText = false; // 是否使用切割文本
|
||||
int padX = TEXTMARGINS_X; // 文本最小左右内边距
|
||||
int padY = TEXTMARGINS_Y; // 文本最小上下内边距
|
||||
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 buttonTrueColor; // 按钮被点击后的颜色
|
||||
COLORREF buttonFalseColor; // 按钮未被点击的颜色
|
||||
COLORREF buttonHoverColor; // 按钮被鼠标悬停的颜色
|
||||
COLORREF buttonBorderColor = RGB(0, 0, 0);// 按钮边框颜色
|
||||
|
||||
StellarX::ButtonMode mode; // 按钮模式
|
||||
StellarX::ControlShape shape; // 按钮形状
|
||||
StellarX::ButtonMode mode; // 按钮模式
|
||||
StellarX::ControlShape shape; // 按钮形状
|
||||
|
||||
StellarX::FillMode buttonFillMode = StellarX::FillMode::Solid; //按钮填充模式
|
||||
StellarX::FillStyle buttonFillIma = StellarX::FillStyle::BDiagonal; //按钮填充图案
|
||||
IMAGE* buttonFileIMAGE = nullptr; //按钮填充图像
|
||||
StellarX::FillMode buttonFillMode = StellarX::FillMode::Solid; //按钮填充模式
|
||||
StellarX::FillStyle buttonFillIma = StellarX::FillStyle::BDiagonal; //按钮填充图案
|
||||
std::unique_ptr<IMAGE> buttonFileIMAGE; //按钮填充图像
|
||||
|
||||
|
||||
|
||||
std::function<void()> onClickCallback; //回调函数
|
||||
std::function<void()> onToggleOnCallback; //TOGGLE模式下的回调函数
|
||||
std::function<void()> onToggleOffCallback; //TOGGLE模式下的回调函数
|
||||
std::function<void()> onClickCallback; //回调函数
|
||||
std::function<void()> onToggleOnCallback; //TOGGLE模式下的回调函数
|
||||
std::function<void()> onToggleOffCallback; //TOGGLE模式下的回调函数
|
||||
|
||||
StellarX::ControlText oldStyle = textStyle; // 按钮文字样式
|
||||
int oldtext_width = -1;
|
||||
int oldtext_height = -1;
|
||||
int text_width = 0;
|
||||
int text_height = 0;
|
||||
public:
|
||||
// 必须先构造公开字体样式,再用它初始化旧样式快照。
|
||||
// C++ 按成员声明顺序构造;若顺序反过来,oldStyle 会读取尚未构造的 std::string。
|
||||
StellarX::ControlText textStyle; // 按钮文字样式
|
||||
|
||||
// === 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;
|
||||
private:
|
||||
StellarX::ControlText oldStyle = textStyle; // 按钮文字样式
|
||||
int oldtext_width = -1;
|
||||
int oldtext_height = -1;
|
||||
int text_width = 0;
|
||||
int text_height = 0;
|
||||
|
||||
std::string tipTextClick; //NORMAL 模式下用
|
||||
std::string tipTextOn; // click==true 时用
|
||||
std::string tipTextOff; // click==false 时用
|
||||
Label tipLabel; // 直接复用Label作为提示
|
||||
|
||||
public:
|
||||
StellarX::ControlText textStyle; // 按钮文字样式
|
||||
// === 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;
|
||||
|
||||
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,
|
||||
StellarX::ButtonMode mode = StellarX::ButtonMode::NORMAL, StellarX::ControlShape shape = StellarX::ControlShape::RECTANGLE);
|
||||
//析构函数 释放图形指针内存
|
||||
~Button();
|
||||
std::string tipTextClick; // NORMAL 模式下用
|
||||
std::string tipTextOn; // click==true 时用
|
||||
std::string tipTextOff; // click==false 时用
|
||||
Label tipLabel; // 直接复用Label作为提示
|
||||
|
||||
//绘制按钮
|
||||
void draw() override;
|
||||
//按钮事件处理
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
|
||||
//设置回调函数
|
||||
void setOnClickListener(const std::function<void()>&& callback);
|
||||
//设置TOGGLE模式下被点击的回调函数
|
||||
void setOnToggleOnListener(const std::function<void()>&& callback);
|
||||
//设置TOGGLE模式下取消点击的回调函数
|
||||
void setOnToggleOffListener(const std::function<void()>&& callback);
|
||||
//设置按钮模式
|
||||
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);
|
||||
//设置按钮文本
|
||||
void setButtonText(const char* text);
|
||||
void setButtonText(std::string text);
|
||||
//设置按钮形状
|
||||
void setButtonShape(StellarX::ControlShape shape);
|
||||
//设置按钮点击状态
|
||||
void setButtonClick(BOOL click);
|
||||
public:
|
||||
|
||||
//判断按钮是否被点击
|
||||
bool isClicked() const;
|
||||
|
||||
//获取按钮文字
|
||||
std::string getButtonText() const;
|
||||
const char* getButtonText_c() const;
|
||||
//获取按钮模式
|
||||
StellarX::ButtonMode getButtonMode() const;
|
||||
//获取按钮形状
|
||||
StellarX::ControlShape getButtonShape() const;
|
||||
//获取按钮填充模式
|
||||
StellarX::FillMode getFillMode() const;
|
||||
//获取按钮填充图案
|
||||
StellarX::FillStyle getFillIma() const;
|
||||
//获取按钮填充图像
|
||||
// 创建使用默认颜色的按钮。
|
||||
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,
|
||||
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;
|
||||
|
||||
// 设置 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;
|
||||
// 获取按钮边框颜色。
|
||||
COLORREF getButtonBorder() const;
|
||||
// 获取按钮文字颜色。
|
||||
COLORREF getButtonTextColor() const;
|
||||
// 获取按钮文字样式。
|
||||
StellarX::ControlText getButtonTextStyle() const;
|
||||
public:
|
||||
// === Tooltip API===
|
||||
//设置是否启用提示框
|
||||
void enableTooltip(bool on) { tipEnabled = on; if (!on) tipVisible = false; }
|
||||
//设置提示框延时
|
||||
void setTooltipDelay(int ms) { tipDelayMs = (ms < 0 ? 0 : ms); }
|
||||
//设置提示框是否跟随鼠标
|
||||
void setTooltipFollowCursor(bool on) { tipFollowCursor = on; }
|
||||
//设置提示框位置偏移
|
||||
void setTooltipOffset(int dx, int dy) { tipOffsetX = dx; tipOffsetY = dy; }
|
||||
//设置提示框样式
|
||||
void setTooltipStyle(COLORREF text, COLORREF bk, bool transparent);
|
||||
//设置提示框文本
|
||||
void setTooltipText(const std::string& s){ tipTextClick = s; tipUserOverride = true; }
|
||||
void setTooltipTextsForToggle(const std::string& onText, const std::string& offText);
|
||||
// === 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);
|
||||
private:
|
||||
//初始化按钮
|
||||
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 model() const override { return false; }
|
||||
void cutButtonText();
|
||||
// 统一隐藏&恢复背景
|
||||
void hideTooltip();
|
||||
// 根据当前 click 状态选择文案
|
||||
void refreshTooltipTextForState();
|
||||
|
||||
// 初始化按钮内部状态、颜色和 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);
|
||||
// Button 不是模态对话框,固定返回 false。
|
||||
bool model() const override { return false; }
|
||||
// 按当前按钮宽度和语言特征裁剪文本
|
||||
void cutButtonText();
|
||||
// 统一隐藏 Tooltip 并恢复其背景快照。
|
||||
void hideTooltip();
|
||||
// 在最后一次记录的鼠标位置显示 Tooltip。
|
||||
void showTooltipAtLastMouse();
|
||||
// 根据当前 click 状态选择 Tooltip 文案
|
||||
void refreshTooltipTextForState();
|
||||
};
|
||||
|
||||
|
||||
+107
-43
@@ -4,6 +4,7 @@
|
||||
* @描述:
|
||||
* 作为其他控件的父容器,提供统一的背景和边框样式。
|
||||
* 负责将事件传递给子控件并管理它们的绘制顺序。
|
||||
* 在托管重绘模式下,Canvas 通常作为一组子控件的安全重绘 root。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持四种矩形形状(普通、圆角,各有边框和无边框版本)
|
||||
@@ -18,53 +19,116 @@
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
#include"Table.h"
|
||||
|
||||
class Canvas : public Control
|
||||
{
|
||||
protected:
|
||||
std::vector<std::unique_ptr<Control>> controls;
|
||||
|
||||
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); //背景颜色
|
||||
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();
|
||||
public:
|
||||
Canvas();
|
||||
Canvas(int x, int y, int width, int height);
|
||||
~Canvas() {}
|
||||
//绘制容器及其子控件
|
||||
void draw() override;
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
//添加控件
|
||||
void addControl(std::unique_ptr<Control> control);
|
||||
//设置容器样式
|
||||
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;
|
||||
void requestRepaint(Control* parent)override;
|
||||
//获取子控件列表
|
||||
std::vector<std::unique_ptr<Control>>& getControls() { return controls; }
|
||||
private:
|
||||
//用来检查对话框是否模态,此控件不做实现
|
||||
bool model() const override { return false; };
|
||||
};
|
||||
// 默认构造:创建一个基础矩形容器
|
||||
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 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:
|
||||
// 进入 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; };
|
||||
};
|
||||
|
||||
+263
-102
@@ -4,28 +4,26 @@
|
||||
* @描述:
|
||||
* 提供控件的基本属性和方法,包括位置、尺寸、重绘标记等。
|
||||
* 实现绘图状态保存和恢复机制,确保控件绘制不影响全局状态。
|
||||
* 同时提供“事件阶段登记、收口阶段统一提交”的托管重绘基础接口。
|
||||
*
|
||||
* @特性:
|
||||
* - 定义控件基本属性(坐标、尺寸、脏标记)
|
||||
* - 提供绘图状态管理(saveStyle/restoreStyle)
|
||||
* - 声明纯虚接口(draw、handleEvent等)
|
||||
* - 支持移动语义,禁止拷贝语义
|
||||
* - 禁止移动语义,禁止拷贝语义
|
||||
*
|
||||
* @使用场景: 作为所有具体控件类的基类,不直接实例化
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
#pragma once
|
||||
|
||||
#ifndef _WIN32_WINNT
|
||||
#define _WIN32_WINNT 0x0600
|
||||
#define _WIN32_WINNT 0x0600
|
||||
#endif
|
||||
#ifndef WINVER
|
||||
#define WINVER _WIN32_WINNT
|
||||
#endif
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <easyx.h>
|
||||
@@ -33,113 +31,276 @@
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <atomic>
|
||||
#include "CoreTypes.h"
|
||||
|
||||
class Window;
|
||||
|
||||
class Control
|
||||
{
|
||||
friend class Window;
|
||||
friend class Canvas;
|
||||
protected:
|
||||
std::string id; // 控件ID
|
||||
int localx,x, localy,y; // 左上角坐标
|
||||
int localWidth,width, localHeight,height; // 控件尺寸
|
||||
Control* parent = nullptr; // 父控件
|
||||
bool dirty = true; // 是否重绘
|
||||
bool show = true; // 是否显示
|
||||
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 是否真的引发了视觉变化(用于上层判断是否需要登记重绘)
|
||||
|
||||
/* == 背景快照 == */
|
||||
IMAGE* saveBkImage = nullptr;
|
||||
int saveBkX = 0, saveBkY = 0; // 快照保存起始坐标
|
||||
int saveWidth = 0, saveHeight = 0; // 快照保存尺寸
|
||||
bool hasSnap = false; // 当前是否持有有效快照
|
||||
/* == 布局模式 == */
|
||||
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{};
|
||||
|
||||
StellarX::RouRectangle rouRectangleSize; // 圆角矩形椭圆宽度和高度
|
||||
/* == 背景快照 == */
|
||||
std::unique_ptr<IMAGE> saveBkImage;
|
||||
int saveBkX = 0, saveBkY = 0; // 快照保存起始坐标
|
||||
int saveWidth = 0, saveHeight = 0; // 快照保存尺寸
|
||||
bool hasSnap = false; // 当前是否持有有效快照
|
||||
|
||||
LOGFONT* currentFont = new LOGFONT(); // 保存当前字体样式和颜色
|
||||
COLORREF* currentColor = new COLORREF();
|
||||
COLORREF* currentBkColor = new COLORREF(); // 保存当前填充色
|
||||
COLORREF* currentBorderColor = new COLORREF(); // 边框颜色
|
||||
LINESTYLE* currentLineStyle = new LINESTYLE(); // 保存当前线型
|
||||
StellarX::RouRectangle rouRectangleSize; // 圆角矩形椭圆宽度和高度
|
||||
|
||||
Control(const Control&) = delete;
|
||||
Control& operator=(const Control&) = delete;
|
||||
Control(Control&&) = delete;
|
||||
Control& operator=(Control&&) = delete;
|
||||
LOGFONT currentFont{}; // 保存当前字体样式和颜色
|
||||
COLORREF currentColor{};
|
||||
COLORREF currentBkColor{}; // 保存当前填充色
|
||||
COLORREF currentBorderColor{}; // 边框颜色
|
||||
LINESTYLE currentLineStyle{}; // 保存当前线型
|
||||
|
||||
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)
|
||||
: localx(x), x(x), localy(y), y(y), localWidth(width), width(width), height(height), localHeight(height){}
|
||||
// 禁止拷贝构造,避免两个对象拥有同一个控件身份和背景快照。
|
||||
Control(const Control&) = delete;
|
||||
// 禁止拷贝赋值,保持控件所有权和句柄唯一性。
|
||||
Control& operator=(const Control&) = delete;
|
||||
// 禁止移动构造,避免容器外部移动后破坏父子关系和快照状态。
|
||||
Control(Control&&) = delete;
|
||||
// 禁止移动赋值,控件迁移统一通过 unique_ptr 所有权转移完成。
|
||||
Control& operator=(Control&&) = delete;
|
||||
|
||||
// 构造默认控件几何,供派生类无参构造使用。
|
||||
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)
|
||||
: localx(x), x(x), localy(y), y(y), localWidth(width), width(width), height(height), localHeight(height) {
|
||||
}
|
||||
public:
|
||||
// 控件内部使用的 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:
|
||||
|
||||
virtual ~Control()
|
||||
{
|
||||
delete currentFont;
|
||||
delete currentColor;
|
||||
delete currentBkColor;
|
||||
delete currentBorderColor;
|
||||
delete currentLineStyle;
|
||||
|
||||
currentFont = nullptr;
|
||||
currentColor = nullptr;
|
||||
currentBkColor = nullptr;
|
||||
currentBorderColor = nullptr;
|
||||
currentLineStyle = nullptr;
|
||||
discardBackground();
|
||||
}
|
||||
// 绘制入口。实现应只在 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; }
|
||||
// 检查控件当前是否处于待重绘状态。
|
||||
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:
|
||||
//向上请求重绘
|
||||
virtual void requestRepaint(Control* parent);
|
||||
//根控件/无父时触发重绘
|
||||
virtual void onRequestRepaintAsRoot();
|
||||
protected:
|
||||
//保存背景快照
|
||||
virtual void saveBackground(int x, int y, int w, int h);
|
||||
// putimage 回屏
|
||||
virtual void restBackground();
|
||||
// 释放快照(窗口重绘/尺寸变化后必须作废)
|
||||
void discardBackground();
|
||||
public:
|
||||
//释放快照重新保存,在尺寸变化时更新背景快照避免尺寸变化导致显示错位
|
||||
void updateBackground();
|
||||
//窗口变化丢快照
|
||||
virtual void onWindowResize();
|
||||
// 获取位置和尺寸
|
||||
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; }
|
||||
|
||||
int getLocalX() const { return localx; }
|
||||
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; }
|
||||
|
||||
void setX(int x) { this->x = x; dirty = true; }
|
||||
void setY(int y) { this->y = y; dirty = true; }
|
||||
void setWidth(int width) { this->width = width; dirty = true; }
|
||||
void setHeight(int height) { this->height = height; dirty = true; }
|
||||
public:
|
||||
|
||||
virtual void draw() = 0;
|
||||
virtual bool handleEvent(const ExMessage& msg) = 0;//返回true代表事件已消费
|
||||
//设置是否显示
|
||||
virtual void setIsVisible(bool show);
|
||||
//设置父容器指针
|
||||
void setParent(Control* parent) { this->parent = parent; }
|
||||
//设置是否重绘
|
||||
virtual void setDirty(bool dirty) { this->dirty = dirty; }
|
||||
|
||||
//检查控件是否可见
|
||||
bool IsVisible() const { return show; };
|
||||
//获取控件id
|
||||
std::string getId() const { return id; }
|
||||
//检查是否为脏
|
||||
bool isDirty() { return dirty; }
|
||||
//用来检查对话框是否模态,其他控件不用实现
|
||||
virtual bool model()const = 0;
|
||||
protected:
|
||||
void saveStyle();
|
||||
void restoreStyle();
|
||||
};
|
||||
// 容器和窗口在事件、绘制、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; }
|
||||
};
|
||||
|
||||
+556
-303
@@ -11,6 +11,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "easyx.h"
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* @命名空间: StellarX
|
||||
@@ -27,320 +29,571 @@
|
||||
*/
|
||||
namespace StellarX
|
||||
{
|
||||
/**
|
||||
* @枚举类名称: FillStyle
|
||||
* @功能描述: 用来定义控件填充图案的枚举类
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据此枚举类可以自定义控件填充图案
|
||||
* 可以在控件初始化时设置填充图案
|
||||
* 根据具体情况选择不同的填充图案
|
||||
* 默认填充图案为水平线
|
||||
*
|
||||
* @取值说明:
|
||||
* Horizontal - 水平线
|
||||
* Vertical - 垂直线
|
||||
* FDiagonal - 反斜线
|
||||
* BDiagonal - 正斜线
|
||||
* Cross - 十字
|
||||
* DiagCross - 网格
|
||||
*
|
||||
* @使用示例:
|
||||
* FillStyle var = FillStyle::Horizontal;
|
||||
*
|
||||
* @备注:
|
||||
* 此枚举类仅支持图案填充模式
|
||||
*/
|
||||
enum class FillStyle
|
||||
{
|
||||
Horizontal = HS_HORIZONTAL, // 水平线
|
||||
Vertical = HS_VERTICAL, // 垂直线
|
||||
FDiagonal = HS_FDIAGONAL, // 反斜线
|
||||
BDiagonal = HS_BDIAGONAL, // 正斜线
|
||||
Cross = HS_CROSS, // 十字
|
||||
DiagCross = HS_DIAGCROSS // 网格
|
||||
};
|
||||
/**
|
||||
* @结构体名称: 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;
|
||||
|
||||
/**
|
||||
* @枚举类名称: FillMode
|
||||
* @功能描述: 用来定义控件填充模式的枚举类
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据此枚举类可以自定义控件填充模式
|
||||
* 可以在控件初始化时设置填充模式
|
||||
* 根据具体情况选择不同的填充模式
|
||||
* 默认填充模式为固实填充
|
||||
*
|
||||
* @取值说明:
|
||||
* Solid - 固实填充
|
||||
* Null - 不填充
|
||||
* Hatched - 图案填充
|
||||
* Pattern - 自定义图案填充
|
||||
* DibPattern - 自定义图像填充
|
||||
*
|
||||
* @使用示例:
|
||||
* FillMode var = FillMode::Solid;
|
||||
*/
|
||||
enum class FillMode
|
||||
{
|
||||
Solid = BS_SOLID, //固实填充
|
||||
Null = BS_NULL, // 不填充
|
||||
Hatched = BS_HATCHED, // 图案填充
|
||||
Pattern = BS_PATTERN, // 自定义图案填充
|
||||
DibPattern = BS_DIBPATTERN // 自定义图像填充
|
||||
};
|
||||
// 判断句柄是否有效;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; }
|
||||
};
|
||||
|
||||
/**
|
||||
* @枚举类名称: LineStyle
|
||||
* @功能描述: 此枚举类用来定义控件边框线型
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据此枚举类可以自定义控件边框线型
|
||||
* 可以在控件初始化时设置边框线型
|
||||
* 根据具体情况选择不同的线型
|
||||
* 默认线型为实线
|
||||
*
|
||||
* @取值说明:
|
||||
* Solid // 实线
|
||||
* Dash // 虚线
|
||||
* Dot // 点线
|
||||
* DashDot // 点划线
|
||||
* DashDotDot // 双点划线
|
||||
* Null // 无线
|
||||
*
|
||||
* @使用示例:
|
||||
* LineStyle var = LineStyle::Solid;
|
||||
*/
|
||||
enum class LineStyle
|
||||
{
|
||||
Solid = PS_SOLID, // 实线
|
||||
Dash = PS_DASH, // 虚线
|
||||
Dot = PS_DOT, // 点线
|
||||
DashDot = PS_DASHDOT, // 点划线
|
||||
DashDotDot = PS_DASHDOTDOT, // 双点划线
|
||||
Null = PS_NULL // 无线
|
||||
};
|
||||
/**
|
||||
* @枚举类名称: FillStyle
|
||||
* @功能描述: 用来定义控件填充图案的枚举类
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据此枚举类可以自定义控件填充图案
|
||||
* 可以在控件初始化时设置填充图案
|
||||
* 根据具体情况选择不同的填充图案
|
||||
* 默认填充图案为水平线
|
||||
*
|
||||
* @取值说明:
|
||||
* Horizontal - 水平线
|
||||
* Vertical - 垂直线
|
||||
* FDiagonal - 反斜线
|
||||
* BDiagonal - 正斜线
|
||||
* Cross - 十字
|
||||
* DiagCross - 网格
|
||||
*
|
||||
* @使用示例:
|
||||
* FillStyle var = FillStyle::Horizontal;
|
||||
*
|
||||
* @备注:
|
||||
* 此枚举类仅支持图案填充模式
|
||||
*/
|
||||
enum class FillStyle
|
||||
{
|
||||
Horizontal = HS_HORIZONTAL, // 水平线
|
||||
Vertical = HS_VERTICAL, // 垂直线
|
||||
FDiagonal = HS_FDIAGONAL, // 反斜线
|
||||
BDiagonal = HS_BDIAGONAL, // 正斜线
|
||||
Cross = HS_CROSS, // 十字
|
||||
DiagCross = HS_DIAGCROSS // 网格
|
||||
};
|
||||
|
||||
/**
|
||||
* @结构体名称: ControlText
|
||||
* @功能描述: 控件字体样式 可以自定义不同的样式
|
||||
*
|
||||
* @详细说明:
|
||||
* 主要使用的场景为:需要修改或想自定义控件字体大小,字体样式,颜色等
|
||||
*
|
||||
* @成员说明:
|
||||
* int nHeight = 0; - 字体高度
|
||||
* int nWidth = 0; - 字体宽度 如果为0则自适应
|
||||
* LPCTSTR lpszFace = "宋体"; - 字体名称
|
||||
* COLORREF color = RGB(0, 0, 0); - 字体颜色
|
||||
* int nEscapement = 0; - 字符串旋转角度
|
||||
* int nOrientation = 0; - 字符旋转角度
|
||||
* int nWeight = 0; - 字体粗细 范围0~1000 0表示默认
|
||||
* bool bItalic = false; - 是否斜体
|
||||
* bool bUnderline = false; - 是否下划线
|
||||
* bool bStrikeOut = false; - 是否删除线
|
||||
*/
|
||||
struct ControlText
|
||||
{
|
||||
int nHeight = 0; //- 字体高度
|
||||
int nWidth = 0; //- 字体宽度 如果为0则自适应
|
||||
LPCTSTR lpszFace = "微软雅黑"; //- 字体名称
|
||||
COLORREF color = RGB(0, 0, 0); //- 字体颜色
|
||||
int nEscapement = 0; //- 字符串旋转角度
|
||||
int nOrientation = 0; //- 字符旋转角度
|
||||
int nWeight = 0; //- 字体粗细 范围0~1000 0表示默认
|
||||
bool bItalic = false; //- 是否斜体
|
||||
bool bUnderline = false; //- 是否下划线
|
||||
bool bStrikeOut = false; //- 是否删除线
|
||||
/**
|
||||
* @枚举类名称: FillMode
|
||||
* @功能描述: 用来定义控件填充模式的枚举类
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据此枚举类可以自定义控件填充模式
|
||||
* 可以在控件初始化时设置填充模式
|
||||
* 根据具体情况选择不同的填充模式
|
||||
* 默认填充模式为固实填充
|
||||
*
|
||||
* @取值说明:
|
||||
* Solid - 固实填充
|
||||
* Null - 不填充
|
||||
* Hatched - 图案填充
|
||||
* Pattern - 自定义图案填充
|
||||
* DibPattern - 自定义图像填充
|
||||
*
|
||||
* @使用示例:
|
||||
* FillMode var = FillMode::Solid;
|
||||
*/
|
||||
enum class FillMode
|
||||
{
|
||||
Solid = BS_SOLID, //固实填充
|
||||
Null = BS_NULL, // 不填充
|
||||
Hatched = BS_HATCHED, // 图案填充
|
||||
Pattern = BS_PATTERN, // 自定义图案填充
|
||||
DibPattern = BS_DIBPATTERN // 自定义图像填充
|
||||
};
|
||||
|
||||
bool operator!=(const ControlText& text);
|
||||
ControlText& operator=(const ControlText& text);
|
||||
};
|
||||
/**
|
||||
* @枚举类名称: LineStyle
|
||||
* @功能描述: 此枚举类用来定义控件边框线型
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据此枚举类可以自定义控件边框线型
|
||||
* 可以在控件初始化时设置边框线型
|
||||
* 根据具体情况选择不同的线型
|
||||
* 默认线型为实线
|
||||
*
|
||||
* @取值说明:
|
||||
* Solid // 实线
|
||||
* Dash // 虚线
|
||||
* Dot // 点线
|
||||
* DashDot // 点划线
|
||||
* DashDotDot // 双点划线
|
||||
* Null // 无线
|
||||
*
|
||||
* @使用示例:
|
||||
* LineStyle var = LineStyle::Solid;
|
||||
*/
|
||||
enum class LineStyle
|
||||
{
|
||||
Solid = PS_SOLID, // 实线
|
||||
Dash = PS_DASH, // 虚线
|
||||
Dot = PS_DOT, // 点线
|
||||
DashDot = PS_DASHDOT, // 点划线
|
||||
DashDotDot = PS_DASHDOTDOT, // 双点划线
|
||||
Null = PS_NULL // 无线
|
||||
};
|
||||
|
||||
/**
|
||||
* @枚举名称: ControlShape
|
||||
* @功能描述: 枚举控件的不同几何样式
|
||||
*
|
||||
* @详细说明:
|
||||
* 定义了四种(有无边框算一种)不同的几何样式,可以根据具体需求
|
||||
* 自定义控件的形状。
|
||||
*
|
||||
* @取值说明:
|
||||
* RECTANGLE = 1, //有边框矩形
|
||||
* B_RECTANGLE, //无边框矩形
|
||||
* ROUND_RECTANGLE, //有边框圆角矩形
|
||||
* B_ROUND_RECTANGLE, //无边框圆角矩形
|
||||
* CIRCLE, //有边框圆形
|
||||
* B_CIRCLE, //无边框圆形
|
||||
* ELLIPSE, //有边框椭圆
|
||||
* B_ELLIPSE //无边框椭圆
|
||||
*
|
||||
* @使用示例:
|
||||
* ControlShape shape = ControlShape::ELLIPSE;
|
||||
*
|
||||
* @备注:
|
||||
* 按钮类支持所有形状,部分控件只支持部分形状,具体请参考控件类。
|
||||
*/
|
||||
enum class ControlShape
|
||||
{
|
||||
RECTANGLE = 1, //有边框矩形
|
||||
B_RECTANGLE, //无边框矩形
|
||||
ROUND_RECTANGLE, //有边框圆角矩形
|
||||
B_ROUND_RECTANGLE, //无边框圆角矩形
|
||||
CIRCLE, //有边框圆形
|
||||
B_CIRCLE, //无边框圆形
|
||||
ELLIPSE, //有边框椭圆
|
||||
B_ELLIPSE //无边框椭圆
|
||||
};
|
||||
/**
|
||||
* @结构体名称: ControlText
|
||||
* @功能描述: 控件字体样式 可以自定义不同的样式
|
||||
*
|
||||
* @详细说明:
|
||||
* 主要使用的场景为:需要修改或想自定义控件字体大小,字体样式,颜色等
|
||||
*
|
||||
* @成员说明:
|
||||
* int nHeight = 0; - 字体高度
|
||||
* int nWidth = 0; - 字体宽度 如果为0则自适应
|
||||
* std::string faceName = "微软雅黑"; - 字体名称
|
||||
* COLORREF color = RGB(0, 0, 0); - 字体颜色
|
||||
* int nEscapement = 0; - 字符串旋转角度
|
||||
* int nOrientation = 0; - 字符旋转角度
|
||||
* int nWeight = 0; - 字体粗细 范围0~1000 0表示默认
|
||||
* bool bItalic = false; - 是否斜体
|
||||
* bool bUnderline = false; - 是否下划线
|
||||
* bool bStrikeOut = false; - 是否删除线
|
||||
*/
|
||||
struct ControlText
|
||||
{
|
||||
int nHeight = 0; //- 字体高度
|
||||
int nWidth = 0; //- 字体宽度 如果为0则自适应
|
||||
// 由对象拥有字体名,避免从临时字符串或外部缓冲区保存悬空指针。
|
||||
std::string faceName = "微软雅黑"; //- 字体名称
|
||||
COLORREF color = RGB(0, 0, 0); //- 字体颜色
|
||||
int nEscapement = 0; //- 字符串旋转角度
|
||||
int nOrientation = 0; //- 字符旋转角度
|
||||
int nWeight = 0; //- 字体粗细 范围0~1000 0表示默认
|
||||
bool bItalic = false; //- 是否斜体
|
||||
bool bUnderline = false; //- 是否下划线
|
||||
bool bStrikeOut = false; //- 是否删除线
|
||||
|
||||
/**
|
||||
* @枚举类名称: TextBoxmode
|
||||
* @功能描述: 定义了文本框的两种模式
|
||||
*
|
||||
* @详细说明:
|
||||
* 需要限制文本框是否接受用户输入时使用
|
||||
*
|
||||
* @取值说明:
|
||||
* INPUT_MODE, // 用户可输入模式
|
||||
* READONLY_MODE // 只读模式
|
||||
*/
|
||||
enum class TextBoxmode
|
||||
{
|
||||
INPUT_MODE, // 用户可输入模式
|
||||
READONLY_MODE // 只读模式
|
||||
};
|
||||
// 比较两份字体样式是否完全一致。
|
||||
bool operator==(const ControlText& text) const;
|
||||
// 比较两份字体样式是否存在任意差异。
|
||||
bool operator!=(const ControlText& text) const { return !(*this == text); }
|
||||
};
|
||||
|
||||
/**
|
||||
* @枚举名称: ButtonMode
|
||||
* @功能描述: 定义按钮的工作模式
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据按钮的工作模式,按钮可以有不同的行为。
|
||||
* 用户可以在具体情况下设置按钮的工作模式。
|
||||
*
|
||||
* @取值说明:
|
||||
* NORMAL = 1, - 普通模式,点击后触发回调,但不会保持状态。
|
||||
* TOGGLE, - 切换模式,点击后会在选中和未选中之间切换,触发不同的回调函数。
|
||||
* DISABLED - 禁用模式,按钮不可点击,显示为灰色,文本显示删除线。
|
||||
*
|
||||
* @使用示例:
|
||||
* ButtonMode mode = ButtonMode::NORMAL;
|
||||
*/
|
||||
enum class ButtonMode
|
||||
{
|
||||
NORMAL = 1, //普通模式
|
||||
TOGGLE, //切换模式
|
||||
DISABLED //禁用模式
|
||||
};
|
||||
/**
|
||||
* @枚举名称: ControlShape
|
||||
* @功能描述: 枚举控件的不同几何样式
|
||||
*
|
||||
* @详细说明:
|
||||
* 定义了四种(有无边框算一种)不同的几何样式,可以根据具体需求
|
||||
* 自定义控件的形状。
|
||||
*
|
||||
* @取值说明:
|
||||
* RECTANGLE = 1, //有边框矩形
|
||||
* B_RECTANGLE, //无边框矩形
|
||||
* ROUND_RECTANGLE, //有边框圆角矩形
|
||||
* B_ROUND_RECTANGLE, //无边框圆角矩形
|
||||
* CIRCLE, //有边框圆形
|
||||
* B_CIRCLE, //无边框圆形
|
||||
* ELLIPSE, //有边框椭圆
|
||||
* B_ELLIPSE //无边框椭圆
|
||||
*
|
||||
* @使用示例:
|
||||
* ControlShape shape = ControlShape::ELLIPSE;
|
||||
*
|
||||
* @备注:
|
||||
* 按钮类支持所有形状,部分控件只支持部分形状,具体请参考控件类。
|
||||
*/
|
||||
enum class ControlShape
|
||||
{
|
||||
RECTANGLE = 1, //有边框矩形
|
||||
B_RECTANGLE, //无边框矩形
|
||||
ROUND_RECTANGLE, //有边框圆角矩形
|
||||
B_ROUND_RECTANGLE, //无边框圆角矩形
|
||||
CIRCLE, //有边框圆形
|
||||
B_CIRCLE, //无边框圆形
|
||||
ELLIPSE, //有边框椭圆
|
||||
B_ELLIPSE //无边框椭圆
|
||||
};
|
||||
|
||||
/**
|
||||
* @结构体名称: RouRectangle
|
||||
* @功能描述: 定义了控件圆角矩形样式时圆角的椭圆尺寸
|
||||
*
|
||||
* @详细说明:
|
||||
* 需要修改控件圆角矩形样式时的圆角椭圆。
|
||||
*
|
||||
* @成员说明:
|
||||
* int ROUND_RECTANGLEwidth = 20; //构成圆角矩形的圆角的椭圆的宽度。
|
||||
* int ROUND_RECTANGLEheight = 20; //构成圆角矩形的圆角的椭圆的高度。
|
||||
*/
|
||||
struct RouRectangle
|
||||
{
|
||||
int ROUND_RECTANGLEwidth = 20; //构成圆角矩形的圆角的椭圆的宽度。
|
||||
int ROUND_RECTANGLEheight = 20; //构成圆角矩形的圆角的椭圆的高度。
|
||||
};
|
||||
/**
|
||||
* @枚举类名称: TextBoxmode
|
||||
* @功能描述: 定义了文本框的三种模式
|
||||
*
|
||||
* @详细说明:
|
||||
* 需要限制文本框是否接受用户输入时使用
|
||||
*
|
||||
* @取值说明:
|
||||
* INPUT_MODE, // 用户可输入模式
|
||||
* READONLY_MODE // 只读模式
|
||||
* PASSWORD_MODE // 密码模式
|
||||
*/
|
||||
enum class TextBoxmode
|
||||
{
|
||||
INPUT_MODE, // 用户可输入模式
|
||||
READONLY_MODE, // 只读模式
|
||||
PASSWORD_MODE // 密码模式
|
||||
};
|
||||
|
||||
// 消息框类型
|
||||
enum class MessageBoxType
|
||||
{
|
||||
OK, // 只有确定按钮
|
||||
OKCancel, // 确定和取消按钮
|
||||
YesNo, // 是和否按钮
|
||||
YesNoCancel, // 是、否和取消按钮
|
||||
RetryCancel, // 重试和取消按钮
|
||||
AbortRetryIgnore, // 中止、重试和忽略按钮
|
||||
};
|
||||
/**
|
||||
* @枚举名称: ButtonMode
|
||||
* @功能描述: 定义按钮的工作模式
|
||||
*
|
||||
* @详细说明:
|
||||
* 根据按钮的工作模式,按钮可以有不同的行为。
|
||||
* 用户可以在具体情况下设置按钮的工作模式。
|
||||
*
|
||||
* @取值说明:
|
||||
* NORMAL = 1, - 普通模式,点击后触发回调,但不会保持状态。
|
||||
* TOGGLE, - 切换模式,点击后会在选中和未选中之间切换,触发不同的回调函数。
|
||||
* DISABLED - 禁用模式,按钮不可点击,显示为灰色,文本显示删除线。
|
||||
*
|
||||
* @使用示例:
|
||||
* ButtonMode mode = ButtonMode::NORMAL;
|
||||
*/
|
||||
enum class ButtonMode
|
||||
{
|
||||
NORMAL = 1, //普通模式
|
||||
TOGGLE, //切换模式
|
||||
DISABLED //禁用模式
|
||||
};
|
||||
|
||||
// 消息框返回值
|
||||
enum class MessageBoxResult
|
||||
{
|
||||
OK = 1, // 确定按钮
|
||||
Cancel = 2, // 取消按钮
|
||||
Yes = 6, // 是按钮
|
||||
No = 7, // 否按钮
|
||||
Abort = 3, // 中止按钮
|
||||
Retry = 4, // 重试按钮
|
||||
Ignore = 5 // 忽略按钮
|
||||
};
|
||||
/**
|
||||
* @结构体名称: RouRectangle
|
||||
* @功能描述: 定义了控件圆角矩形样式时圆角的椭圆尺寸
|
||||
*
|
||||
* @详细说明:
|
||||
* 需要修改控件圆角矩形样式时的圆角椭圆。
|
||||
*
|
||||
* @成员说明:
|
||||
* int ROUND_RECTANGLEwidth = 20; //构成圆角矩形的圆角的椭圆的宽度。
|
||||
* int ROUND_RECTANGLEheight = 20; //构成圆角矩形的圆角的椭圆的高度。
|
||||
*/
|
||||
struct RouRectangle
|
||||
{
|
||||
int ROUND_RECTANGLEwidth = 20; //构成圆角矩形的圆角的椭圆的宽度。
|
||||
int ROUND_RECTANGLEheight = 20; //构成圆角矩形的圆角的椭圆的高度。
|
||||
};
|
||||
|
||||
/**
|
||||
* @枚举名称: MessageBoxType
|
||||
* @功能描述: 定义标准消息框按钮组合
|
||||
*
|
||||
* @详细说明:
|
||||
* MessageBoxType 决定 Dialog / MessageBox 工厂创建哪些功能按钮,
|
||||
* 例如只有确定按钮、确定取消按钮、是否按钮等。
|
||||
*
|
||||
* @取值说明:
|
||||
* OK - 只有确定按钮
|
||||
* OKCancel - 确定和取消按钮
|
||||
* YesNo - 是和否按钮
|
||||
* YesNoCancel - 是、否和取消按钮
|
||||
* RetryCancel - 重试和取消按钮
|
||||
* AbortRetryIgnore - 中止、重试和忽略按钮
|
||||
*
|
||||
* @使用示例:
|
||||
* StellarX::MessageBoxType type = StellarX::MessageBoxType::YesNo;
|
||||
*/
|
||||
enum class MessageBoxType
|
||||
{
|
||||
OK, // 只有确定按钮
|
||||
OKCancel, // 确定和取消按钮
|
||||
YesNo, // 是和否按钮
|
||||
YesNoCancel, // 是、否和取消按钮
|
||||
RetryCancel, // 重试和取消按钮
|
||||
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, // 确定按钮
|
||||
Cancel = 2, // 取消按钮
|
||||
Yes = 6, // 是按钮
|
||||
No = 7, // 否按钮
|
||||
Abort = 3, // 中止按钮
|
||||
Retry = 4, // 重试按钮
|
||||
Ignore = 5 // 忽略按钮
|
||||
};
|
||||
#if 0 //布局管理器相关 —待实现—
|
||||
/*
|
||||
*
|
||||
*@枚举名称: LayoutKind
|
||||
* @功能描述 : 定义布局管理类型
|
||||
*
|
||||
*@详细说明 :
|
||||
* 根据布局管理类型,控件可以有不同的布局方式。
|
||||
* 用户可以在具体情况下设置布局管理类型。
|
||||
*
|
||||
* @取值说明 :
|
||||
* Absolute:不管,保持子控件自己的坐标(向后兼容)。
|
||||
* HBox: 水平方向排队;支持固定宽、权重分配、对齐、拉伸。
|
||||
* VBox: 竖直方向排队;同上。
|
||||
* Grid(网格):按行列摆;支持固定/自适应/权重行列;支持跨行/跨列;单元内对齐/拉伸。
|
||||
*
|
||||
*/
|
||||
// 布局类型
|
||||
enum class LayoutKind
|
||||
{
|
||||
Absolute = 1,
|
||||
HBox,
|
||||
VBox,
|
||||
Grid,
|
||||
Flow,
|
||||
Stack
|
||||
};
|
||||
/*
|
||||
*
|
||||
*@枚举名称: 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=强制换行
|
||||
};
|
||||
// 布局参数
|
||||
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
|
||||
};
|
||||
}
|
||||
/*
|
||||
* @枚举名称: 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;
|
||||
};
|
||||
}
|
||||
|
||||
+54
-49
@@ -4,6 +4,7 @@
|
||||
* @描述:
|
||||
* 实现完整的对话框功能,支持多种按钮组合和异步结果回调。
|
||||
* 自动处理布局、背景保存恢复和生命周期管理。
|
||||
* 在窗口托管重绘模式下,Dialog 自身也是一个独立的重绘 root。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持六种标准消息框类型(OK、YesNo、YesNoCancel等)
|
||||
@@ -30,8 +31,10 @@
|
||||
#define titleToTextMargin 10 //标题到文本的距离
|
||||
#define textToBorderMargin 10 //文本到边框的距离
|
||||
#define BorderWidth 3 //边框宽度
|
||||
class Dialog : public Canvas
|
||||
class Dialog : public Canvas
|
||||
{
|
||||
friend class Window;
|
||||
|
||||
Window& hWnd; //窗口引用
|
||||
|
||||
int textWidth = 0; //文本宽度
|
||||
@@ -40,97 +43,99 @@ class Dialog : public Canvas
|
||||
|
||||
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; //是否模态
|
||||
|
||||
COLORREF backgroundColor = RGB(240, 240, 240); //背景颜色
|
||||
COLORREF backgroundColor = RGB(240, 240, 240); //背景颜色
|
||||
COLORREF borderColor = RGB(100, 100, 100); //边框颜色
|
||||
|
||||
COLORREF buttonTrueColor = RGB(211, 190, 190); //按钮被点击颜色
|
||||
COLORREF buttonFalseColor = RGB(215, 215, 215); //按钮未被点击颜色
|
||||
COLORREF buttonHoverColor = RGB(224, 224, 224); //按钮悬浮颜色
|
||||
|
||||
|
||||
Button* closeButton = nullptr; //关闭按钮
|
||||
|
||||
StellarX::MessageBoxResult result = StellarX::MessageBoxResult::Cancel; // 对话框结果
|
||||
|
||||
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;
|
||||
|
||||
// 获取正文文本,用于非模态去重
|
||||
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;
|
||||
|
||||
//获取对话框类型
|
||||
// 返回当前对话框是否为模态;Window 用它区分阻塞式对话框和托管浮层。
|
||||
bool model() const override;
|
||||
|
||||
// 显示对话框
|
||||
// 显示对话框。模态对话框会进入自己的消息循环;非模态对话框只标记可见,由 Window 主循环驱动。
|
||||
void Show();
|
||||
// 关闭对话框
|
||||
void Close();
|
||||
//初始化
|
||||
void setInitialization(bool init);
|
||||
|
||||
// 关闭对话框。
|
||||
// 该调用只设置隐藏、结果和待清理标志,并请求整场景重绘;对象释放延迟到 performDelayedCleanup()。
|
||||
void Close();
|
||||
// 宿主窗口变化时仅重新居中,不拉伸 Dialog 自身
|
||||
void recenterInHostWindow();
|
||||
|
||||
private:
|
||||
// 初始化按钮
|
||||
void initButtons();
|
||||
// 初始化关闭按钮
|
||||
void initCloseButton();
|
||||
// 初始化标题
|
||||
void initTitle();
|
||||
// 按行分割消息内容
|
||||
void splitMessageLines();
|
||||
// 获取文本大小
|
||||
void getTextSize();
|
||||
//初始化对话框尺寸
|
||||
void initDialogSize();
|
||||
void saveBackground(int x, int y, int w, int h)override;
|
||||
// 根据当前 MessageBoxType 初始化功能按钮。
|
||||
void initButtons();
|
||||
// 初始化标题栏关闭按钮。
|
||||
void initCloseButton();
|
||||
// 按当前消息内容和宽度约束分割显示行。
|
||||
void splitMessageLines();
|
||||
// 计算当前标题和正文所需的文本尺寸。
|
||||
void getTextSize();
|
||||
// 标记需要重新布局并按需清空旧控件
|
||||
void invalidateLayout(bool clearChildren);
|
||||
// 根据文本和按钮数量初始化对话框尺寸。
|
||||
void initDialogSize();
|
||||
// 依据当前 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;
|
||||
|
||||
void restBackground()override;
|
||||
|
||||
|
||||
// 清除所有控件
|
||||
void clearControls();
|
||||
//创建对话框按钮
|
||||
// 清除 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,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,13 +27,16 @@ namespace StellarX
|
||||
class MessageBox
|
||||
{
|
||||
public:
|
||||
// 模态:阻塞直到关闭,返回结果
|
||||
// 模态:直接创建并显示对话框,阻塞到用户关闭后返回结果。
|
||||
// 调用期间会运行 Dialog 自己的模态事件循环。
|
||||
static MessageBoxResult showModal(Window& wnd,
|
||||
const std::string& text,
|
||||
const std::string& caption = "提示",
|
||||
MessageBoxType type = MessageBoxType::OK);
|
||||
|
||||
// 非模态:立即返回,通过回调异步获取结果
|
||||
// 非模态:创建后立即返回,由 Window 托管生命周期和绘制层级。
|
||||
// 若同一窗口中已存在相同 caption/text 的可见非模态对话框,则不会重复创建。
|
||||
// 用户关闭时通过 onResult 异步返回结果;回调允许排队修改宿主控件树。
|
||||
static void showAsync(Window& wnd,
|
||||
const std::string& text,
|
||||
const std::string& caption = "提示",
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/*******************************************************************************
|
||||
* @文件: StellarX.h
|
||||
* @摘要: 星垣(StellarX) GUI框架 - 主包含头文件
|
||||
* @版本: v2.2.1
|
||||
* @版本: 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,17 +21,17 @@
|
||||
* @使用说明:
|
||||
* 只需包含此文件即可使用框架所有功能。
|
||||
* 示例: #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 "InputEvents.h"
|
||||
#include "SxLog.h"
|
||||
#include "Control.h"
|
||||
#include"Canvas.h"
|
||||
#include"Window.h"
|
||||
@@ -40,3 +42,5 @@
|
||||
#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
|
||||
@@ -4,6 +4,7 @@
|
||||
* @描述:
|
||||
* 提供页签栏布局(上/下/左/右)、选中切换、页内容区域定位;
|
||||
* 与 Button 一起工作,支持窗口大小变化、可见性联动与脏区重绘。
|
||||
* 在托管重绘模式下,TabControl 作为“页签栏 + 当前页面”的统一重绘 root。
|
||||
*
|
||||
* @特性:
|
||||
* - 页签栏四向排列(Top / Bottom / Left / Right)
|
||||
@@ -21,48 +22,97 @@
|
||||
#include "CoreTypes.h"
|
||||
#include "Button.h"
|
||||
#include "Canvas.h"
|
||||
#define BUTMINHEIGHT 15
|
||||
#define BUTMINWIDTH 30
|
||||
#define BUTMINHEIGHT 15 //页签按钮最小尺寸,过小会导致显示问题
|
||||
#define BUTMINWIDTH 30 //页签按钮最小尺寸,过小会导致显示问题
|
||||
class TabControl :public Canvas
|
||||
{
|
||||
int tabBarHeight = BUTMINWIDTH; //页签栏高度
|
||||
StellarX::TabPlacement tabPlacement = StellarX::TabPlacement::Top ; //页签排列方式
|
||||
std::vector<std::pair<std::unique_ptr<Button>,std::unique_ptr<Canvas>>> controls; //页签/页列表
|
||||
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; // 精准禁用该重载
|
||||
using Canvas::addControl; // 禁止外部误用
|
||||
void addControl(std::unique_ptr<Control>) = delete; // 精准禁用该重载
|
||||
private:
|
||||
inline void initTabBar();
|
||||
inline void initTabPage();
|
||||
// 初始化页签按钮位置和尺寸
|
||||
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(int x,int y,int width,int height);
|
||||
// 指定初始位置和尺寸构造 TabControl
|
||||
TabControl(int x, int y, int width, int height);
|
||||
// 析构 TabControl;页签按钮和页面由 unique_ptr 自动释放。
|
||||
~TabControl();
|
||||
|
||||
void draw() override;
|
||||
bool handleEvent(const ExMessage& msg) override;
|
||||
|
||||
//添加页签+页
|
||||
void add(std::pair<std::unique_ptr<Button> ,std::unique_ptr<Canvas>>&& control);
|
||||
//添加为某个页添加控件
|
||||
void add(std::string tabText,std::unique_ptr<Control> control);
|
||||
//设置页签位置
|
||||
void setTabPlacement(StellarX::TabPlacement placement);
|
||||
//设置页签栏高度 两侧排列时为宽度
|
||||
void setTabBarHeight(int height);
|
||||
//设置不可见后传递给子控件重写
|
||||
void setIsVisible(bool visible) override;
|
||||
void onWindowResize() override;
|
||||
//获取当前激活页签索引
|
||||
int getActiveIndex() const;
|
||||
//设置当前激活页签索引
|
||||
void setActiveIndex(int idx);
|
||||
//获取页签数量
|
||||
int count() const;
|
||||
//通过页签文本返回索引
|
||||
int indexOf(const std::string& tabText) const;
|
||||
void setDirty(bool dirty) override;
|
||||
void requestRepaint(Control* parent)override;
|
||||
};
|
||||
|
||||
// 修改 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:
|
||||
// 将回调期间加入的页签和页内控件在外层事务结束时一次性提交。
|
||||
void flushPendingMutations() override;
|
||||
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
|
||||
void applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect) override;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
#include "Button.h"
|
||||
@@ -49,7 +49,6 @@
|
||||
#define TABLE_STR_PAGE_MID "页/共"
|
||||
#define TABLE_STR_PAGE_SUFFIX "页"
|
||||
|
||||
|
||||
class Table :public Control
|
||||
{
|
||||
private:
|
||||
@@ -64,16 +63,17 @@ private:
|
||||
|
||||
int rowsPerPage = TABLE_DEFAULT_ROWS_PER_PAGE; // 每页显示的行数
|
||||
int currentPage = 1; // 当前页码
|
||||
int totalPages = 1; // 总页数
|
||||
int totalPages = 1; // 总页数
|
||||
|
||||
bool isShowPageButton = true; // 是否显示翻页按钮
|
||||
bool isNeedDrawHeaders = 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; //页码文本
|
||||
|
||||
Button* prevButton = nullptr; // 上一页按钮
|
||||
Button* nextButton = nullptr; // 下一页按钮
|
||||
Label* pageNum = nullptr; //页码文本
|
||||
|
||||
int dX = x, dY = y; // 单元格的开始坐标
|
||||
int uX = x, uY = y; // 单元格的结束坐标
|
||||
|
||||
@@ -85,74 +85,105 @@ private:
|
||||
COLORREF tableBorderClor = TABLE_DEFAULT_BORDER_COLOR; // 表格边框颜色
|
||||
COLORREF tableBkClor = TABLE_DEFAULT_BG_COLOR; // 表格背景颜色
|
||||
|
||||
void initTextWaH(); //初始化文本像素宽度和高度
|
||||
void initButton(); //初始化翻页按钮
|
||||
void initPageNum(); //初始化页码标签
|
||||
void initTextWaH(); // 初始化文本像素宽度和高度,并据此更新列宽/行高缓存
|
||||
void initButton(); // 初始化或刷新上一页/下一页按钮
|
||||
void initPageNum(); // 初始化或刷新页码标签
|
||||
|
||||
void drawTable(); //绘制当前页
|
||||
void drawHeader(); //绘制表头
|
||||
void drawPageNum(); //绘制页码信息
|
||||
void drawButton(); //绘制翻页按钮
|
||||
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::string> getHeaders() const;
|
||||
// 获取表格数据副本。
|
||||
std::vector<std::vector<std::string>> getData() const;
|
||||
//获取表格边框宽度
|
||||
// 获取表格边框宽度。
|
||||
int getTableBorderWidth() const;
|
||||
|
||||
|
||||
// 获取表格当前运行态尺寸;高度为内部内容计算结果,不受 setHeight() 外部写入驱动。
|
||||
int getTableWidth() const;
|
||||
// 获取表格当前运行态高度。
|
||||
int getTableHeight() const;
|
||||
};
|
||||
|
||||
@@ -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,217 @@
|
||||
/**
|
||||
* 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 <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;
|
||||
}
|
||||
|
||||
class Window
|
||||
{
|
||||
friend class Dialog;
|
||||
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; // 非模态对话框;始终位于普通控件之上
|
||||
// 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 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 接管生命周期并在事件安全点清理关闭对象。
|
||||
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:
|
||||
// 提交 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,48 +0,0 @@
|
||||
/*******************************************************************************
|
||||
* @类: Label
|
||||
* @摘要: 简单文本标签控件,用于显示静态文本
|
||||
* @描述:
|
||||
* 提供基本的文本显示功能,支持透明背景和自定义样式。
|
||||
* 不支持用户交互,专注于文本呈现。
|
||||
*
|
||||
* @特性:
|
||||
* - 支持背景透明/不透明模式
|
||||
* - 完整的文本样式控制(字体、颜色、效果)
|
||||
* - 自动适应文本内容
|
||||
* - 轻量级无事件处理开销
|
||||
*
|
||||
* @使用场景: 显示说明文字、标题、状态信息等静态内容
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
|
||||
#pragma once
|
||||
#include "Control.h"
|
||||
|
||||
class Label : public Control
|
||||
{
|
||||
std::string text; //标签文本
|
||||
COLORREF textBkColor; //标签背景颜色
|
||||
bool textBkDisap = false; //标签背景是否透明
|
||||
|
||||
//标签事件处理(标签无事件)不实现具体代码
|
||||
bool handleEvent(const ExMessage& msg) override { return false; }
|
||||
//用来检查对话框是否模态,此控件不做实现
|
||||
bool model() const 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 hide();
|
||||
//设置标签背景是否透明
|
||||
void setTextdisap(bool key);
|
||||
//设置标签背景颜色
|
||||
void setTextBkColor(COLORREF color);
|
||||
//设置标签文本
|
||||
void setText(std::string text);
|
||||
|
||||
};
|
||||
|
||||
@@ -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); //边框颜色
|
||||
|
||||
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);
|
||||
//设置文本
|
||||
void setText(std::string text);
|
||||
|
||||
//获取文本
|
||||
std::string getText() const;
|
||||
|
||||
private:
|
||||
//用来检查对话框是否模态,此控件不做实现
|
||||
bool model() const override { return false; };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
|
||||
#pragma once
|
||||
#include"Control.h"
|
||||
/*******************************************************************************
|
||||
* @类: Window
|
||||
* @摘要: 应用程序主窗口类,管理窗口生命周期和消息循环
|
||||
* @描述:
|
||||
* 创建和管理应用程序主窗口,作为所有控件的根容器。
|
||||
* 处理消息分发、事件循环和渲染调度。
|
||||
*
|
||||
* @特性:
|
||||
* - 多种窗口模式配置(双缓冲、控制台等)
|
||||
* - 背景图片和颜色支持
|
||||
* - 集成的对话框管理系统
|
||||
* - 完整的消息处理循环
|
||||
* - 控件和对话框的生命周期管理
|
||||
*
|
||||
* @使用场景: 应用程序主窗口,GUI程序的入口和核心
|
||||
* @所属框架: 星垣(StellarX) GUI框架
|
||||
* @作者: 我在人间做废物
|
||||
******************************************************************************/
|
||||
class Window
|
||||
{
|
||||
int width; //窗口宽度
|
||||
int height; //窗口高度
|
||||
int windowMode = NULL; //窗口模式
|
||||
|
||||
// --- 尺寸变化去抖用 ---
|
||||
int pendingW;
|
||||
int pendingH;
|
||||
bool needResizeDirty = false;
|
||||
|
||||
HWND hWnd = NULL; //窗口句柄
|
||||
std::string headline; //窗口标题
|
||||
COLORREF wBkcolor = BLACK; //窗口背景
|
||||
IMAGE* background = nullptr; //窗口背景图片
|
||||
std::string bkImageFile; //窗口背景图片文件名
|
||||
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);
|
||||
//事件循环
|
||||
int 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 std::string& message) const;
|
||||
|
||||
//获取窗口句柄
|
||||
HWND getHwnd() const;
|
||||
//获取窗口宽度
|
||||
int getWidth() const;
|
||||
//获取窗口高度
|
||||
int getHeight() const;
|
||||
//获取窗口标题
|
||||
std::string getHeadline() const;
|
||||
//获取窗口背景颜色
|
||||
COLORREF getBkcolor() const;
|
||||
//获取窗口背景图片
|
||||
IMAGE* getBkImage() const;
|
||||
//获取窗口背景图片文件名
|
||||
std::string getBkImageFile() 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,413 @@
|
||||
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
|
||||
param(
|
||||
[int]$Repeat = 1,
|
||||
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', '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 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', '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' } }
|
||||
'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 }
|
||||
}
|
||||
}
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
#include "Canvas.h"
|
||||
|
||||
Canvas::Canvas()
|
||||
:Control(0, 0, 100, 100)
|
||||
{
|
||||
this->id = "Canvas";
|
||||
}
|
||||
|
||||
Canvas::Canvas(int x, int y, int width, int height)
|
||||
:Control(x, y, width, height)
|
||||
{
|
||||
this->id = "Canvas";
|
||||
}
|
||||
|
||||
void Canvas::clearAllControls()
|
||||
{
|
||||
controls.clear();
|
||||
}
|
||||
|
||||
|
||||
void Canvas::draw()
|
||||
{
|
||||
if (!dirty||!show)return;
|
||||
saveStyle();
|
||||
|
||||
setlinecolor(canvasBorderClor);//设置线色
|
||||
setfillcolor(canvasBkClor);//设置填充色
|
||||
setfillstyle((int)canvasFillMode);//设置填充模式
|
||||
setlinestyle((int)canvasLineStyle, canvaslinewidth);
|
||||
|
||||
if ((saveBkX != this->x) || (saveBkY != this->y) || (!hasSnap) || (saveWidth != this->width) || (saveHeight != this->height) || !saveBkImage)
|
||||
saveBackground(x, y, width, height);
|
||||
// 恢复背景(清除旧内容)
|
||||
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;
|
||||
bool consumed = false;
|
||||
bool anyDirty = false;
|
||||
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
consumed |= it->get()->handleEvent(msg);
|
||||
if (it->get()->isDirty()) anyDirty = true;
|
||||
}
|
||||
if (anyDirty) requestRepaint(parent);
|
||||
return consumed;
|
||||
}
|
||||
|
||||
void Canvas::addControl(std::unique_ptr<Control> control)
|
||||
{
|
||||
control->setParent(this);
|
||||
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;
|
||||
}
|
||||
|
||||
void Canvas::setIsVisible(bool visible)
|
||||
{
|
||||
this->show = visible;
|
||||
dirty = true;
|
||||
for (auto& control : controls)
|
||||
{
|
||||
control->setIsVisible(visible);
|
||||
control->setDirty(true);
|
||||
}
|
||||
if (!visible)
|
||||
this->updateBackground();
|
||||
}
|
||||
|
||||
void Canvas::setDirty(bool dirty)
|
||||
{
|
||||
this->dirty = dirty;
|
||||
for(auto& control : controls)
|
||||
control->setDirty(dirty);
|
||||
}
|
||||
|
||||
void Canvas::onWindowResize()
|
||||
{
|
||||
Control::onWindowResize(); // 先处理自己
|
||||
for (auto& ch : controls) // 再转发给所有子控件
|
||||
ch->onWindowResize();
|
||||
}
|
||||
|
||||
void Canvas::requestRepaint(Control* parent)
|
||||
{
|
||||
if (this == parent)
|
||||
{
|
||||
for (auto& control : controls)
|
||||
if (control->isDirty() && control->IsVisible())
|
||||
{
|
||||
control->draw();
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
|
||||
|
||||
|
||||
-136
@@ -1,136 +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::setIsVisible(bool show)
|
||||
{
|
||||
if (!show)
|
||||
this->updateBackground();
|
||||
this->show = show;
|
||||
}
|
||||
void Control::onWindowResize()
|
||||
{
|
||||
// 自己:丢快照 + 标脏
|
||||
discardBackground();
|
||||
setDirty(true);
|
||||
}
|
||||
// 保存当前的绘图状态(字体、颜色、线型等)
|
||||
// 在控件绘制前调用,确保不会影响全局绘图状态
|
||||
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);//恢复填充
|
||||
}
|
||||
|
||||
void Control::requestRepaint(Control* parent)
|
||||
{
|
||||
if (parent) parent->requestRepaint(parent); // 向上冒泡
|
||||
else onRequestRepaintAsRoot(); // 到根控件/窗口兜底
|
||||
}
|
||||
|
||||
void Control::onRequestRepaintAsRoot()
|
||||
{
|
||||
|
||||
discardBackground();
|
||||
setDirty(true);
|
||||
draw(); // 只有“无父”时才允许立即画,不会被谁覆盖
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
delete saveBkImage; saveBkImage = nullptr;
|
||||
}
|
||||
}
|
||||
if (!saveBkImage) saveBkImage = new IMAGE(w, h);
|
||||
|
||||
SetWorkingImage(nullptr); // ★抓屏幕
|
||||
getimage(saveBkImage, x, y, w, h);
|
||||
hasSnap = true;
|
||||
}
|
||||
|
||||
void Control::restBackground()
|
||||
{
|
||||
if (!hasSnap || !saveBkImage) return;
|
||||
// 直接回贴屏幕(与抓取一致)
|
||||
SetWorkingImage(nullptr);
|
||||
putimage(saveBkX, saveBkY, saveBkImage);
|
||||
}
|
||||
|
||||
void Control::discardBackground()
|
||||
{
|
||||
if (saveBkImage)
|
||||
{
|
||||
delete saveBkImage;
|
||||
saveBkImage = nullptr;
|
||||
}
|
||||
hasSnap = false; saveWidth = saveHeight = 0;
|
||||
}
|
||||
|
||||
void Control::updateBackground()
|
||||
{
|
||||
restBackground();
|
||||
discardBackground();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
#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)
|
||||
@@ -22,7 +24,7 @@ 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())
|
||||
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; // 合法双字节
|
||||
@@ -30,10 +32,10 @@ static inline int gbk_char_len(const std::string& s, size_t i)
|
||||
return 1; // 容错
|
||||
}
|
||||
|
||||
static inline void rtrim_spaces_gbk(std::string& s)
|
||||
static inline void rtrim_spaces_gbk(std::string& s)
|
||||
{
|
||||
while (!s.empty() && s.back() == ' ') s.pop_back(); // ASCII 空格
|
||||
while (s.size() >= 2)
|
||||
while (s.size() >= 2)
|
||||
{ // 全角空格 A1 A1
|
||||
unsigned char a = (unsigned char)s[s.size() - 2];
|
||||
unsigned char b = (unsigned char)s[s.size() - 1];
|
||||
@@ -42,26 +44,26 @@ static inline void rtrim_spaces_gbk(std::string& s)
|
||||
}
|
||||
}
|
||||
|
||||
static inline bool is_ascii_only(const std::string& s)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
if (ellW > maxW)
|
||||
{ // 连 ... 都放不下
|
||||
std::string e = ell;
|
||||
while (!e.empty() && textwidth(LPCTSTR(e.c_str())) > maxW) e.pop_back();
|
||||
@@ -71,7 +73,7 @@ static std::string ellipsize_ascii_pref(const std::string& text, int maxW)
|
||||
|
||||
// 先找到能放下的最长前缀
|
||||
size_t i = 0, lastFit = 0;
|
||||
while (i < text.size())
|
||||
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;
|
||||
@@ -83,7 +85,7 @@ static std::string ellipsize_ascii_pref(const std::string& text, int maxW)
|
||||
|
||||
// 在已适配前缀范围内,向左找最近的词边界
|
||||
size_t cutPos = lastFit;
|
||||
for (size_t k = lastFit; k > 0; --k)
|
||||
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; }
|
||||
@@ -96,14 +98,14 @@ static std::string ellipsize_ascii_pref(const std::string& text, int maxW)
|
||||
}
|
||||
|
||||
// 中文优先策略:严格逐“字符”(1/2字节)回退;省略号用全角 "…"
|
||||
static std::string ellipsize_cjk_pref(const std::string& text, int maxW, const char* ellipsis = "…")
|
||||
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)
|
||||
if (ellW > maxW)
|
||||
{ // 连省略号都放不下
|
||||
std::string e = ell;
|
||||
while (!e.empty() && textwidth(LPCTSTR(e.c_str())) > maxW) e.pop_back();
|
||||
@@ -112,7 +114,7 @@ static std::string ellipsize_cjk_pref(const std::string& text, int maxW, const c
|
||||
const int limit = maxW - ellW;
|
||||
|
||||
size_t i = 0, lastFit = 0;
|
||||
while (i < text.size())
|
||||
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;
|
||||
@@ -142,6 +144,17 @@ void Button::setTooltipTextsForToggle(const std::string& onText, const std::stri
|
||||
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";
|
||||
@@ -163,13 +176,7 @@ void Button::initButton(const std::string text, StellarX::ButtonMode mode, Stell
|
||||
tipLabel.textStyle = this->textStyle; // 复用按钮字体样式
|
||||
}
|
||||
|
||||
|
||||
Button::~Button()
|
||||
{
|
||||
if (buttonFileIMAGE)
|
||||
delete buttonFileIMAGE;
|
||||
buttonFileIMAGE = nullptr;
|
||||
}
|
||||
Button::~Button() = default;
|
||||
|
||||
void Button::draw()
|
||||
{
|
||||
@@ -177,11 +184,13 @@ void Button::draw()
|
||||
|
||||
//保存当前样式和颜色
|
||||
saveStyle();
|
||||
StellarX::ControlText drawTextStyle = textStyle;
|
||||
|
||||
if (StellarX::ButtonMode::DISABLED == mode) //设置禁用按钮色
|
||||
{
|
||||
setfillcolor(DISABLEDCOLOUR);
|
||||
textStyle.bStrikeOut = true;
|
||||
// 禁用态删除线只属于本次绘制效果,不回写用户公开 textStyle。
|
||||
drawTextStyle.bStrikeOut = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -196,11 +205,11 @@ void Button::draw()
|
||||
setlinecolor(buttonBorderColor);
|
||||
|
||||
//设置字体颜色
|
||||
settextcolor(textStyle.color);
|
||||
settextcolor(drawTextStyle.color);
|
||||
//设置字体样式
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.lpszFace,
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
settextstyle(drawTextStyle.nHeight, drawTextStyle.nWidth, drawTextStyle.faceName.c_str(),
|
||||
drawTextStyle.nEscapement, drawTextStyle.nOrientation, drawTextStyle.nWeight,
|
||||
drawTextStyle.bItalic, drawTextStyle.bUnderline, drawTextStyle.bStrikeOut);
|
||||
|
||||
if (needCutText)
|
||||
cutButtonText();
|
||||
@@ -222,9 +231,13 @@ void Button::draw()
|
||||
}
|
||||
|
||||
//设置按钮填充模式
|
||||
setfillstyle((int)buttonFillMode, (int)buttonFillIma, buttonFileIMAGE);
|
||||
if ((saveBkX != this->x) || (saveBkY != this->y) || (!hasSnap) || (saveWidth != this->width) || (saveHeight != this->height) || !saveBkImage)
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
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();
|
||||
//根据按钮形状绘制
|
||||
@@ -275,6 +288,8 @@ void Button::draw()
|
||||
restoreStyle();//恢复默认字体样式和颜色
|
||||
dirty = false; //标记按钮不需要重绘
|
||||
|
||||
if (tipEnabled && tipVisible)
|
||||
tipLabel.draw();
|
||||
}
|
||||
// 处理鼠标事件,检测点击和悬停状态
|
||||
// 根据按钮模式和形状进行不同的处理
|
||||
@@ -282,42 +297,53 @@ bool Button::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show)
|
||||
return false;
|
||||
auto mutationScope = enterMutationScope();
|
||||
resetEventVisualChanged();
|
||||
|
||||
bool oldHover = hover;
|
||||
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 (msg.message == WM_MOUSEMOVE)
|
||||
if (isMouseMessage)
|
||||
{
|
||||
lastMouseX = msg.x;
|
||||
lastMouseY = msg.y;
|
||||
}
|
||||
// 检测悬停状态(根据不同形状)
|
||||
switch (shape)
|
||||
if (isMouseMessage)
|
||||
{
|
||||
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;
|
||||
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;
|
||||
SX_LOGD("Button") << SX_T("被点击: ","lbtn - down:")<< "id = " << id <<" text = "<<text << " mode = " << (int)mode;
|
||||
|
||||
dirty = true;
|
||||
consume = true;
|
||||
}
|
||||
@@ -327,31 +353,34 @@ bool Button::handleEvent(const ExMessage& msg)
|
||||
}
|
||||
}
|
||||
// NORMAL 模式:鼠标在按钮上释放时才触发点击回调,如果移出区域则取消点击状态。
|
||||
// TOGGLE 模式:在释放时切换状态,并触发相应的开/关回调。
|
||||
// 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();
|
||||
// 清除消息队列中积压的鼠标和键盘消息,防止本次点击事件被重复处理
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
}
|
||||
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();
|
||||
// 清除消息队列中积压的鼠标和键盘消息,防止本次点击事件被重复处理
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
hideTooltip();
|
||||
}
|
||||
}
|
||||
// 处理鼠标移出区域的情况
|
||||
@@ -367,113 +396,204 @@ bool Button::handleEvent(const ExMessage& msg)
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (tipEnabled)
|
||||
if (tipEnabled)
|
||||
{
|
||||
if (hover && !oldHover)
|
||||
if (hover && !oldHover)
|
||||
{
|
||||
// 刚刚进入悬停:开计时,暂不显示
|
||||
tipHoverTick = GetTickCount64();
|
||||
tipVisible = false;
|
||||
}
|
||||
if (!hover && oldHover)
|
||||
if (!hover && oldHover)
|
||||
{
|
||||
// 刚移出:立即隐藏
|
||||
hideTooltip();
|
||||
}
|
||||
if (hover && !tipVisible)
|
||||
if (hover && !tipVisible)
|
||||
{
|
||||
// 到点就显示
|
||||
if (GetTickCount64() - tipHoverTick >= (ULONGLONG)tipDelayMs)
|
||||
{
|
||||
tipVisible = true;
|
||||
|
||||
// 定位(跟随鼠标 or 相对按钮)
|
||||
int tipX = tipFollowCursor ? (lastMouseX + tipOffsetX) : lastMouseX;
|
||||
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);
|
||||
}
|
||||
if (GetTickCount64() - tipHoverTick >= (ULONGLONG)tipDelayMs)
|
||||
showTooltipAtLastMouse();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果状态发生变化,标记需要重绘
|
||||
if (hover != oldHover || click != oldClick)
|
||||
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);
|
||||
|
||||
if (tipEnabled && tipVisible)
|
||||
tipLabel.draw();
|
||||
|
||||
return consume;
|
||||
}
|
||||
|
||||
void Button::setOnClickListener(const std::function<void()>&& callback)
|
||||
void Button::tick(ULONGLONG nowMs)
|
||||
{
|
||||
this->onClickCallback = callback;
|
||||
// tick 只处理已经命中的按钮;到期后显示 Tooltip,并把重绘交给父容器统一提交。
|
||||
if (!show || !tipEnabled || !hover || tipVisible)
|
||||
return;
|
||||
if (nowMs - tipHoverTick < static_cast<ULONGLONG>(tipDelayMs))
|
||||
return;
|
||||
showTooltipAtLastMouse();
|
||||
dirty = true;
|
||||
markEventVisualChanged(true);
|
||||
requestRepaint(parent);
|
||||
}
|
||||
|
||||
void Button::setOnToggleOnListener(const std::function<void()>&& callback)
|
||||
bool Button::clearTransientMouseState()
|
||||
{
|
||||
this->onToggleOnCallback = callback;
|
||||
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;
|
||||
}
|
||||
void Button::setOnToggleOffListener(const std::function<void()>&& callback)
|
||||
|
||||
RECT Button::getManagedRepaintCoverageRect() const
|
||||
{
|
||||
this->onToggleOffCallback = callback;
|
||||
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)
|
||||
{
|
||||
if (this->mode == StellarX::ButtonMode::DISABLED && mode != StellarX::ButtonMode::DISABLED)
|
||||
textStyle.bStrikeOut = false;
|
||||
//取值范围参考 buttMode的枚举注释
|
||||
this->mode = mode;
|
||||
this->mode = mode;
|
||||
dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
void Button::setROUND_RECTANGLEwidth(int width)
|
||||
{
|
||||
rouRectangleSize.ROUND_RECTANGLEwidth = width;
|
||||
rouRectangleSize.ROUND_RECTANGLEwidth = width;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
|
||||
}
|
||||
|
||||
void Button::setROUND_RECTANGLEheight(int height)
|
||||
{
|
||||
rouRectangleSize.ROUND_RECTANGLEheight = height;
|
||||
rouRectangleSize.ROUND_RECTANGLEheight = height;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
bool Button::isClicked() const
|
||||
{
|
||||
return this->click;
|
||||
return this->click;
|
||||
}
|
||||
|
||||
void Button::setFillMode(StellarX::FillMode mode)
|
||||
{
|
||||
this->buttonFillMode = mode;
|
||||
this->buttonFillMode = mode;
|
||||
this->dirty = true; // 标记需要重绘
|
||||
}
|
||||
|
||||
void Button::setFillIma(StellarX::FillStyle ima)
|
||||
{
|
||||
buttonFillIma = ima;
|
||||
buttonFillIma = ima;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
@@ -481,19 +601,17 @@ void Button::setFillIma(std::string imaNAme)
|
||||
{
|
||||
if (buttonFileIMAGE)
|
||||
{
|
||||
delete buttonFileIMAGE;
|
||||
buttonFileIMAGE = nullptr;
|
||||
buttonFileIMAGE.reset();
|
||||
}
|
||||
buttonFileIMAGE = new IMAGE;
|
||||
loadimage(buttonFileIMAGE, imaNAme.c_str(),width,height);
|
||||
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;
|
||||
this->dirty = true;
|
||||
}
|
||||
|
||||
void Button::setButtonFalseColor(COLORREF color)
|
||||
@@ -504,7 +622,7 @@ void Button::setButtonFalseColor(COLORREF color)
|
||||
|
||||
void Button::setButtonText(const char* text)
|
||||
{
|
||||
this->text = std::string(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;
|
||||
@@ -526,7 +644,7 @@ void Button::setButtonText(std::string text)
|
||||
|
||||
void Button::setButtonShape(StellarX::ControlShape shape)
|
||||
{
|
||||
this->shape = shape;
|
||||
this->shape = shape;
|
||||
this->dirty = true;
|
||||
this->needCutText = true;
|
||||
}
|
||||
@@ -534,15 +652,21 @@ void Button::setButtonShape(StellarX::ControlShape shape)
|
||||
//允许通过外部函数修改按钮的点击状态,并执行相应的回调函数
|
||||
void Button::setButtonClick(BOOL click)
|
||||
{
|
||||
this->click = 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();
|
||||
// 清除消息队列中积压的鼠标和键盘消息,防止本次点击事件被重复处理
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
}
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
{
|
||||
@@ -550,84 +674,69 @@ void Button::setButtonClick(BOOL click)
|
||||
else if (!click && onToggleOffCallback) onToggleOffCallback();
|
||||
dirty = true;
|
||||
refreshTooltipTextForState();
|
||||
hideTooltip();
|
||||
// 清除消息队列中积压的鼠标和键盘消息,防止本次点击事件被重复处理
|
||||
flushmessage(EX_MOUSE | EX_KEY);
|
||||
hideTooltip();
|
||||
}
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
}
|
||||
|
||||
|
||||
std::string Button::getButtonText() const
|
||||
{
|
||||
return this->text;
|
||||
return this->text;
|
||||
}
|
||||
|
||||
const char* Button::getButtonText_c() const
|
||||
{
|
||||
return this->text.c_str();
|
||||
return this->text.c_str();
|
||||
}
|
||||
|
||||
StellarX::ButtonMode Button::getButtonMode() const
|
||||
{
|
||||
return this->mode;
|
||||
return this->mode;
|
||||
}
|
||||
|
||||
StellarX::ControlShape Button::getButtonShape() const
|
||||
{
|
||||
return this->shape;
|
||||
return this->shape;
|
||||
}
|
||||
|
||||
StellarX::FillMode Button::getFillMode() const
|
||||
{
|
||||
return this->buttonFillMode;
|
||||
return this->buttonFillMode;
|
||||
}
|
||||
|
||||
StellarX::FillStyle Button::getFillIma() const
|
||||
{
|
||||
return this->buttonFillIma;
|
||||
return this->buttonFillIma;
|
||||
}
|
||||
|
||||
IMAGE* Button::getFillImaImage() const
|
||||
{
|
||||
return this->buttonFileIMAGE;
|
||||
return this->buttonFileIMAGE.get();
|
||||
}
|
||||
|
||||
COLORREF Button::getButtonBorder() const
|
||||
{
|
||||
return this->buttonBorderColor;
|
||||
return this->buttonBorderColor;
|
||||
}
|
||||
|
||||
COLORREF Button::getButtonTextColor() const
|
||||
{
|
||||
return this->textStyle.color;
|
||||
return this->textStyle.color;
|
||||
}
|
||||
|
||||
StellarX::ControlText Button::getButtonTextStyle() const
|
||||
{
|
||||
return this->textStyle;
|
||||
return this->textStyle;
|
||||
}
|
||||
|
||||
int Button::getButtonWidth() const
|
||||
{
|
||||
return this->width;
|
||||
}
|
||||
|
||||
int Button::getButtonHeight() const
|
||||
{
|
||||
return this->height;
|
||||
}
|
||||
|
||||
|
||||
|
||||
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;
|
||||
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)
|
||||
@@ -636,15 +745,15 @@ bool Button::isMouseInEllipse(int mouseX, int mouseY, int x, int y, int width, i
|
||||
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);
|
||||
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;
|
||||
// 判断鼠标是否在椭圆内
|
||||
if (normalizedDistance <= 1.0)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
void Button::cutButtonText()
|
||||
@@ -659,38 +768,62 @@ void Button::cutButtonText()
|
||||
}
|
||||
|
||||
// 放不下:按语言偏好裁切(ASCII→词边界;CJK→逐字符,不撕裂双字节)
|
||||
if (is_ascii_only(this->text))
|
||||
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;
|
||||
tipLabel.hide(); // 还原快照+作废,防止残影
|
||||
// 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)
|
||||
if (mode == StellarX::ButtonMode::NORMAL)
|
||||
tipLabel.setText(tipTextClick);
|
||||
else if(mode==StellarX::ButtonMode::TOGGLE)
|
||||
else if (mode == StellarX::ButtonMode::TOGGLE)
|
||||
tipLabel.setText(click ? tipTextOn : tipTextOff);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
#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 });
|
||||
return true;
|
||||
}
|
||||
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
|
||||
flushPendingMutations();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Canvas::clearControls()
|
||||
{
|
||||
Window* host = getHostWindow();
|
||||
pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr });
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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,698 @@
|
||||
#include "Control.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();
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示:不在这里 requestRepaint(避免父容器快照未就绪时子控件抢跑 draw,污染快照)
|
||||
// 仅向上标脏,让事件收口阶段由容器统一重绘。
|
||||
if (parent)
|
||||
parent->setDirty(true);
|
||||
}
|
||||
|
||||
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,21 +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)
|
||||
{
|
||||
this->id = "Dialog";
|
||||
// Dialog 当前阶段只做“居中 + 自管内部 chrome”,
|
||||
// 不参与外部 Stretch;宿主窗口变化时只重新居中。
|
||||
this->layoutCapability.allowStretchX = false;
|
||||
this->layoutCapability.allowStretchY = false;
|
||||
setHostWindow(&hWnd);
|
||||
show = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Dialog::~Dialog()
|
||||
{
|
||||
}
|
||||
|
||||
void Dialog::draw()
|
||||
{
|
||||
if(!show)
|
||||
if (!show)
|
||||
{
|
||||
// 如果对话框不可见且需要清理,执行清理
|
||||
if (pendingCleanup && !isCleaning)
|
||||
@@ -24,16 +30,16 @@ void Dialog::draw()
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 如果需要初始化,则执行初始化
|
||||
if (needsInitialization && show)
|
||||
{
|
||||
initDialogSize();
|
||||
needsInitialization = false;
|
||||
}
|
||||
// 如果需要初始化,则执行初始化
|
||||
if (needsInitialization && show)
|
||||
{
|
||||
initDialogSize();
|
||||
needsInitialization = false;
|
||||
}
|
||||
|
||||
if (dirty && show)
|
||||
{
|
||||
// 保存当前绘图状态
|
||||
if (dirty && show)
|
||||
{
|
||||
// 保存当前绘图状态
|
||||
saveStyle();
|
||||
|
||||
Canvas::setBorderColor(this->borderColor);
|
||||
@@ -41,43 +47,38 @@ void Dialog::draw()
|
||||
Canvas::setCanvasBkColor(this->backgroundColor);
|
||||
Canvas::setShape(StellarX::ControlShape::ROUND_RECTANGLE);
|
||||
|
||||
if ((saveBkX != this->x) || (saveBkY != this->y) || (!hasSnap) || (saveWidth != this->width) || (saveHeight != this->height) || !saveBkImage)
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
//设置所有控件为脏状态
|
||||
/*for(auto& c :this->controls)
|
||||
c->setDirty(true);*/
|
||||
restBackground();
|
||||
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()));
|
||||
ty = ty + textheight(LPCTSTR(line.c_str())) + 5; // 每行文本高度加5像素间距
|
||||
}
|
||||
|
||||
// 恢复绘图状态
|
||||
restoreStyle();
|
||||
// 恢复绘图状态
|
||||
restoreStyle();
|
||||
|
||||
dirty = false;
|
||||
}
|
||||
dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
bool Dialog::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
bool consume = false;
|
||||
resetEventVisualChanged();
|
||||
if (!show)
|
||||
{
|
||||
if (pendingCleanup && !isCleaning)
|
||||
@@ -86,22 +87,34 @@ bool Dialog::handleEvent(const ExMessage& msg)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 如果正在清理或标记为待清理,则不处理事件
|
||||
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)
|
||||
{
|
||||
std::cout << "\a" << std::endl;
|
||||
// 模态对话框不允许点击外部区域
|
||||
if (msg.message == WM_LBUTTONUP)
|
||||
std::cout << "\a" << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 将事件传递给子控件处理
|
||||
if (!consume)
|
||||
consume = Canvas::handleEvent(msg);
|
||||
if (!consume)
|
||||
consume = Canvas::handleEvent(msg);
|
||||
|
||||
// 对话框矩形范围内的鼠标事件一律由对话框吞掉,避免穿透到底层控件。
|
||||
if (isMouseMessage && insideDialog)
|
||||
consume = true;
|
||||
|
||||
// 每次事件处理后检查是否需要执行延迟清理
|
||||
if (pendingCleanup && !isCleaning)
|
||||
@@ -112,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)
|
||||
@@ -140,7 +145,6 @@ void Dialog::SetModal(bool modal)
|
||||
this->modal = modal;
|
||||
}
|
||||
|
||||
|
||||
void Dialog::SetResult(StellarX::MessageBoxResult result)
|
||||
{
|
||||
this->result = result;
|
||||
@@ -160,44 +164,125 @@ void Dialog::Show()
|
||||
{
|
||||
if (pendingCleanup)
|
||||
performDelayedCleanup();
|
||||
SX_LOGI("Dialog") << SX_T("对话框弹出:是否模态=","Dialog::Show: modal=") << (modal ? 1 : 0);
|
||||
|
||||
show = true;
|
||||
dirty = true;
|
||||
needsInitialization = true;
|
||||
close = false;
|
||||
shouldClose = false;
|
||||
|
||||
hWnd.dialogOpen = true;// 通知窗口有对话框打开
|
||||
|
||||
if (modal)
|
||||
{
|
||||
{
|
||||
// 模态对话框需要阻塞当前线程直到对话框关闭
|
||||
while (show && !close)
|
||||
if (!hWnd.hasLiveNativeWindow())
|
||||
{
|
||||
|
||||
// 处理消息
|
||||
ExMessage msg;
|
||||
if (peekmessage(&msg, EX_MOUSE | EX_KEY))
|
||||
{
|
||||
handleEvent(msg);
|
||||
show = false;
|
||||
hWnd.dialogOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要关闭
|
||||
if (shouldClose)
|
||||
{
|
||||
Close();
|
||||
break;
|
||||
}
|
||||
// 记录当前窗口客户区尺寸,供轮询对比
|
||||
RECT rc0{};
|
||||
if (!GetClientRect(hWnd.getHwnd(), &rc0))
|
||||
{
|
||||
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)
|
||||
{
|
||||
requestRepaint(parent);
|
||||
FlushBatchDraw();
|
||||
BeginBatchDraw();
|
||||
this->draw(); // 注意:不要 requestRepaint(parent),只画自己
|
||||
EndBatchDraw();
|
||||
dirty = false;
|
||||
}
|
||||
if (!hWnd.isMutationTransactionActive())
|
||||
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
|
||||
|
||||
// 避免CPU占用过高
|
||||
Sleep(10);
|
||||
if (batch.shouldSleep())
|
||||
Sleep(1);
|
||||
}
|
||||
// 模态对话框关闭后执行清理
|
||||
|
||||
if (pendingCleanup && !isCleaning)
|
||||
performDelayedCleanup();
|
||||
}
|
||||
@@ -206,39 +291,50 @@ void Dialog::Show()
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void Dialog::Close()
|
||||
{
|
||||
if (!show) return;
|
||||
|
||||
// 关闭只改变可见状态并登记一次全场景收口;对象和快照清理延后到安全阶段执行。
|
||||
show = false;
|
||||
close = true;
|
||||
dirty = true;
|
||||
pendingCleanup = true; // 只标记需要清理,不立即执行
|
||||
|
||||
hWnd.dialogClose = true;
|
||||
hWnd.requestFullSceneRedraw();
|
||||
|
||||
// 工厂模式下非模态触发回调 返回结果
|
||||
if (resultCallback&& !modal)
|
||||
resultCallback(this->result);
|
||||
|
||||
|
||||
}
|
||||
|
||||
void Dialog::setInitialization(bool init)
|
||||
{
|
||||
if (init)
|
||||
if (resultCallback && !modal)
|
||||
{
|
||||
initDialogSize();
|
||||
saveBackground((x - BorderWidth), (y - BorderWidth), (width + 2 * BorderWidth), (height + 2 * BorderWidth));
|
||||
// 回调可能继续修改宿主控件树,因此也纳入 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: // 只有确定按钮
|
||||
@@ -246,48 +342,49 @@ void Dialog::initButtons()
|
||||
auto okbutton = createDialogButton((this->x + (this->width - (functionButtonWidth * buttonNum + buttonMargin * (buttonNum - 1))) / 2),
|
||||
((this->y + (this->height - buttonAreaHeight)) + (buttonAreaHeight - functionButtonHeight) / 2),
|
||||
"确定"
|
||||
);
|
||||
);
|
||||
okbutton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::OK);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
|
||||
okbutton->textStyle = this->textStyle;
|
||||
|
||||
|
||||
this->addControl(std::move(okbutton));
|
||||
}
|
||||
break;
|
||||
break;
|
||||
case StellarX::MessageBoxType::OKCancel: // 确定和取消按钮
|
||||
{
|
||||
auto okButton = createDialogButton(
|
||||
(this->x + (this->width - (functionButtonWidth * buttonNum + buttonMargin * (buttonNum - 1))) / 2),
|
||||
((this->y + (this->height - buttonAreaHeight)) + (buttonAreaHeight - functionButtonHeight) / 2),
|
||||
"确定"
|
||||
);
|
||||
((this->y + (this->height - buttonAreaHeight)) + (buttonAreaHeight - functionButtonHeight) / 2),
|
||||
"确定"
|
||||
);
|
||||
okButton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::OK);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
auto cancelButton = createDialogButton(
|
||||
(okButton.get()->getX() + okButton.get()->getButtonWidth() + buttonMargin),
|
||||
okButton.get()->getY(),
|
||||
(okButton.get()->getX() + okButton.get()->getWidth() + buttonMargin),
|
||||
okButton.get()->getY(),
|
||||
"取消"
|
||||
);
|
||||
cancelButton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Cancel);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
|
||||
this->Close(); });
|
||||
|
||||
okButton->textStyle = this->textStyle;
|
||||
cancelButton->textStyle = this->textStyle;
|
||||
|
||||
this->addControl(std::move(okButton));
|
||||
this->addControl(std::move(cancelButton));
|
||||
}
|
||||
break;
|
||||
break;
|
||||
case StellarX::MessageBoxType::YesNo: // 是和否按钮
|
||||
{
|
||||
auto yesButton = createDialogButton(
|
||||
@@ -299,10 +396,10 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Yes);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
auto noButton = createDialogButton(
|
||||
(yesButton.get()->getX() + yesButton.get()->getButtonWidth() + buttonMargin),
|
||||
(yesButton.get()->getX() + yesButton.get()->getWidth() + buttonMargin),
|
||||
yesButton.get()->getY(),
|
||||
"否"
|
||||
);
|
||||
@@ -310,7 +407,7 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::No);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
yesButton->textStyle = this->textStyle;
|
||||
noButton->textStyle = this->textStyle;
|
||||
@@ -318,7 +415,7 @@ void Dialog::initButtons()
|
||||
this->addControl(std::move(yesButton));
|
||||
this->addControl(std::move(noButton));
|
||||
}
|
||||
break;
|
||||
break;
|
||||
case StellarX::MessageBoxType::YesNoCancel: // 是、否和取消按钮
|
||||
{
|
||||
auto yesButton = createDialogButton(
|
||||
@@ -330,21 +427,21 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Yes);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
auto noButton = createDialogButton(
|
||||
yesButton.get()->getX() + yesButton.get()->getButtonWidth() + buttonMargin,
|
||||
yesButton.get()->getY(),
|
||||
yesButton.get()->getX() + yesButton.get()->getWidth() + buttonMargin,
|
||||
yesButton.get()->getY(),
|
||||
"否"
|
||||
);
|
||||
noButton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::No);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
auto cancelButton = createDialogButton(
|
||||
noButton.get()->getX() + noButton.get()->getButtonWidth() + buttonMargin,
|
||||
noButton.get()->getX() + noButton.get()->getWidth() + buttonMargin,
|
||||
noButton.get()->getY(),
|
||||
"取消"
|
||||
);
|
||||
@@ -352,18 +449,17 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Cancel);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
yesButton->textStyle = this->textStyle;
|
||||
noButton->textStyle = this->textStyle;
|
||||
cancelButton->textStyle = this->textStyle;
|
||||
|
||||
|
||||
this->addControl(std::move(yesButton));
|
||||
this->addControl(std::move(noButton));
|
||||
this->addControl(std::move(cancelButton));
|
||||
}
|
||||
break;
|
||||
break;
|
||||
case StellarX::MessageBoxType::RetryCancel: // 重试和取消按钮
|
||||
{
|
||||
auto retryButton = createDialogButton(
|
||||
@@ -375,18 +471,18 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Retry);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
|
||||
this->Close(); });
|
||||
|
||||
auto cancelButton = createDialogButton(
|
||||
retryButton.get()->getX() + retryButton.get()->getButtonWidth() + buttonMargin,
|
||||
retryButton.get()->getY(),
|
||||
retryButton.get()->getX() + retryButton.get()->getWidth() + buttonMargin,
|
||||
retryButton.get()->getY(),
|
||||
"取消"
|
||||
);
|
||||
cancelButton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Cancel);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
this->Close(); });
|
||||
|
||||
retryButton->textStyle = this->textStyle;
|
||||
cancelButton->textStyle = this->textStyle;
|
||||
@@ -394,11 +490,11 @@ void Dialog::initButtons()
|
||||
this->addControl(std::move(retryButton));
|
||||
this->addControl(std::move(cancelButton));
|
||||
}
|
||||
break;
|
||||
break;
|
||||
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),
|
||||
"中止"
|
||||
);
|
||||
@@ -406,21 +502,21 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Abort);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close();
|
||||
this->Close();
|
||||
});
|
||||
auto retryButton = createDialogButton(
|
||||
abortButton.get()->getX() + abortButton.get()->getButtonWidth() + buttonMargin,
|
||||
abortButton.get()->getY(),
|
||||
abortButton.get()->getX() + abortButton.get()->getWidth() + buttonMargin,
|
||||
abortButton.get()->getY(),
|
||||
"重试"
|
||||
);
|
||||
retryButton->setOnClickListener([this]()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Retry);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close();
|
||||
this->Close();
|
||||
});
|
||||
auto ignoreButton = createDialogButton(
|
||||
retryButton.get()->getX() + retryButton.get()->getButtonWidth() + buttonMargin,
|
||||
retryButton.get()->getX() + retryButton.get()->getWidth() + buttonMargin,
|
||||
retryButton.get()->getY(),
|
||||
"忽略"
|
||||
);
|
||||
@@ -428,7 +524,7 @@ void Dialog::initButtons()
|
||||
{
|
||||
this->SetResult(StellarX::MessageBoxResult::Ignore);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close();
|
||||
this->Close();
|
||||
});
|
||||
|
||||
abortButton->textStyle = this->textStyle;
|
||||
@@ -439,7 +535,7 @@ void Dialog::initButtons()
|
||||
this->addControl(std::move(retryButton));
|
||||
this->addControl(std::move(ignoreButton));
|
||||
}
|
||||
break;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +544,7 @@ 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, // 按钮背景颜色
|
||||
@@ -462,20 +558,11 @@ void Dialog::initCloseButton()
|
||||
this->SetResult(StellarX::MessageBoxResult::Cancel);
|
||||
this->hWnd.dialogClose = true;
|
||||
this->Close(); });
|
||||
|
||||
|
||||
this->closeButton = but.get();
|
||||
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(); // 先清空现有的行
|
||||
@@ -483,14 +570,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;
|
||||
}
|
||||
@@ -499,13 +589,13 @@ void Dialog::splitMessageLines()
|
||||
}
|
||||
|
||||
// 添加最后一行(如果有内容)
|
||||
if (!currentLine.empty())
|
||||
if (!currentLine.empty())
|
||||
{
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
|
||||
// 如果消息为空,至少添加一个空行
|
||||
if (lines.empty())
|
||||
if (lines.empty())
|
||||
{
|
||||
lines.push_back("");
|
||||
}
|
||||
@@ -514,10 +604,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()));
|
||||
@@ -526,12 +618,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(); // 获取文本最大尺寸
|
||||
|
||||
@@ -554,18 +668,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 + // 文本区域高度
|
||||
@@ -575,38 +697,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::saveBackground(int x, int y, int w, int h)
|
||||
void Dialog::addControl(std::unique_ptr<Control> control)
|
||||
{
|
||||
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)
|
||||
{
|
||||
delete saveBkImage; saveBkImage = nullptr;
|
||||
}
|
||||
}
|
||||
if (!saveBkImage) saveBkImage = new IMAGE(w + BorderWidth*2, h + BorderWidth*2);
|
||||
|
||||
SetWorkingImage(nullptr); // ★抓屏幕
|
||||
getimage(saveBkImage, x - BorderWidth, y - BorderWidth, w + BorderWidth*2, h+ BorderWidth*2);
|
||||
hasSnap = true;
|
||||
}
|
||||
void Dialog::restBackground()
|
||||
{
|
||||
if (!hasSnap || !saveBkImage) return;
|
||||
// 直接回贴屏幕(与抓取一致)
|
||||
SetWorkingImage(nullptr);
|
||||
putimage(saveBkX - BorderWidth, saveBkY - BorderWidth,saveBkImage);
|
||||
control->setParent(this);
|
||||
controls.push_back(std::move(control));
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// 延迟清理策略:由于对话框绘制时保存了背景快照,必须在对话框隐藏后、
|
||||
@@ -618,27 +716,19 @@ void Dialog::performDelayedCleanup()
|
||||
|
||||
isCleaning = true;
|
||||
|
||||
auto& c = hWnd.getControls();
|
||||
for (auto& control : c)
|
||||
control->setDirty(true);
|
||||
|
||||
for (auto& control : controls)
|
||||
control->invalidateBackgroundSnapshot();
|
||||
controls.clear();
|
||||
|
||||
// 重置指针
|
||||
closeButton = nullptr;
|
||||
title.reset();
|
||||
// 释放背景图像资源
|
||||
if (saveBkImage && hasSnap)
|
||||
{
|
||||
restBackground();
|
||||
FlushBatchDraw();
|
||||
discardBackground();
|
||||
}
|
||||
// 清理阶段不直接写屏;Window 会在安全点统一重绘剩余场景。
|
||||
invalidateBackgroundSnapshot();
|
||||
hWnd.requestFullSceneRedraw();
|
||||
// 重置状态
|
||||
needsInitialization = true;
|
||||
pendingCleanup = false;
|
||||
isCleaning = false;
|
||||
shouldClose = false;
|
||||
}
|
||||
|
||||
void Dialog::SetResultCallback(std::function<void(StellarX::MessageBoxResult)> cb)
|
||||
@@ -658,10 +748,12 @@ std::string Dialog::GetText() const
|
||||
|
||||
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)
|
||||
@@ -675,22 +767,51 @@ std::unique_ptr<Button> Dialog::createDialogButton(int x, int y, const std::stri
|
||||
StellarX::ButtonMode::NORMAL,
|
||||
StellarX::ControlShape::RECTANGLE
|
||||
);
|
||||
|
||||
|
||||
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())
|
||||
{
|
||||
if (control->isDirty() && control->IsVisible())
|
||||
control->draw();
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
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,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);
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
#include "MessageBox.h"
|
||||
|
||||
#include "SxLog.h"
|
||||
namespace StellarX
|
||||
{
|
||||
MessageBoxResult MessageBox::showModal(Window& wnd,const std::string& text,const std::string& caption,
|
||||
MessageBoxResult MessageBox::showModal(Window& wnd, const std::string& text, const std::string& caption,
|
||||
MessageBoxType type)
|
||||
{
|
||||
Dialog dlg(wnd, caption, text, type, true); // 模态
|
||||
dlg.setInitialization(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,
|
||||
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;
|
||||
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();
|
||||
dlgPtr->setInitialization(true);
|
||||
// 设置回调
|
||||
if (onResult)
|
||||
dlgPtr->SetResultCallback(std::move(onResult));
|
||||
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,782 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
#include "Table.h"
|
||||
#include "SxLog.h"
|
||||
#include "Window.h"
|
||||
#include <algorithm>
|
||||
|
||||
namespace
|
||||
{
|
||||
int TableMbcCharLen(const std::string& text, size_t index)
|
||||
{
|
||||
const unsigned char lead = static_cast<unsigned char>(text[index]);
|
||||
if (lead <= 0x7f) return 1;
|
||||
if (lead >= 0x81 && lead <= 0xfe && index + 1 < text.size())
|
||||
{
|
||||
const unsigned char trail = static_cast<unsigned char>(text[index + 1]);
|
||||
if (trail >= 0x40 && trail <= 0xfe && trail != 0x7f) return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string EllipsizeTableText(const std::string& text, int maxWidth)
|
||||
{
|
||||
// 逐个 MBCS 字符试探可用宽度,保证省略号不会把双字节字符拆成半个字节。
|
||||
if (maxWidth <= 0) return "";
|
||||
if (textwidth(text.c_str()) <= maxWidth) return text;
|
||||
const std::string ellipsis = "...";
|
||||
const int ellipsisWidth = textwidth(ellipsis.c_str());
|
||||
if (ellipsisWidth > maxWidth) return "";
|
||||
size_t index = 0;
|
||||
size_t lastFit = 0;
|
||||
while (index < text.size())
|
||||
{
|
||||
const size_t next = index + static_cast<size_t>(TableMbcCharLen(text, index));
|
||||
if (next > text.size()) break;
|
||||
if (textwidth(text.substr(0, next).c_str()) > maxWidth - ellipsisWidth) break;
|
||||
lastFit = next;
|
||||
index = next;
|
||||
}
|
||||
return text.substr(0, lastFit) + ellipsis;
|
||||
}
|
||||
|
||||
std::vector<std::string> NormalizeTableRow(std::vector<std::string> row, size_t headerCount)
|
||||
{
|
||||
if (headerCount == 0)
|
||||
return row;
|
||||
|
||||
if (row.size() > headerCount)
|
||||
row.resize(headerCount);
|
||||
else if (row.size() < headerCount)
|
||||
row.resize(headerCount, "");
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
int CalculateTotalPages(size_t rowCount, int rowsPerPage)
|
||||
{
|
||||
if (rowsPerPage < 1)
|
||||
rowsPerPage = 1;
|
||||
|
||||
const int total = static_cast<int>((rowCount + static_cast<size_t>(rowsPerPage) - 1) / static_cast<size_t>(rowsPerPage));
|
||||
return total > 0 ? total : 1;
|
||||
}
|
||||
|
||||
std::string BuildPageNumberText(int currentPage, int totalPages)
|
||||
{
|
||||
return std::string(TABLE_STR_PAGE_PREFIX) + std::to_string(currentPage) +
|
||||
TABLE_STR_PAGE_MID + std::to_string(totalPages) + TABLE_STR_PAGE_SUFFIX;
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制表格的当前页
|
||||
// 使用双循环绘制行和列,考虑分页偏移
|
||||
void Table::drawTable()
|
||||
{
|
||||
if (lineHeights.empty() || colWidths.empty())
|
||||
return;
|
||||
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
|
||||
// 表体从“表头之下”开始
|
||||
dX = x + border;
|
||||
dY = y + border + lineHeights.at(0) + TABLE_HEADER_EXTRA; // 表头高度
|
||||
uY = dY + lineHeights.at(0) + TABLE_ROW_EXTRA;
|
||||
|
||||
size_t startRow = (currentPage - 1) * rowsPerPage;
|
||||
size_t endRow = startRow + (size_t)rowsPerPage < data.size() ? startRow + (size_t)rowsPerPage : data.size();
|
||||
|
||||
for (size_t i = startRow; i < endRow; ++i)
|
||||
{
|
||||
for (size_t j = 0; j < data[i].size(); ++j)
|
||||
{
|
||||
uX = dX + colWidths.at(j) + TABLE_COL_GAP;
|
||||
fillrectangle(dX, dY, uX, uY);
|
||||
const std::string display = EllipsizeTableText(data[i][j],
|
||||
(std::max)(0, colWidths.at(j) - TABLE_PAD_X * 2));
|
||||
outtextxy(dX + TABLE_PAD_X, dY + TABLE_PAD_Y, LPCTSTR(display.c_str()));
|
||||
dX += colWidths.at(j) + TABLE_COL_GAP;
|
||||
}
|
||||
dX = x + border;
|
||||
dY = uY;
|
||||
uY = dY + lineHeights.at(0) + TABLE_ROW_EXTRA;
|
||||
}
|
||||
}
|
||||
|
||||
void Table::drawHeader()
|
||||
{
|
||||
if (headers.empty() || lineHeights.empty() || colWidths.empty())
|
||||
return;
|
||||
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
// 内容区原点 = x+border, y+border
|
||||
dX = x + border;
|
||||
dY = y + border;
|
||||
uY = dY + lineHeights.at(0) + TABLE_HEADER_EXTRA;
|
||||
|
||||
for (size_t i = 0; i < headers.size(); i++)
|
||||
{
|
||||
uX = dX + colWidths.at(i) + TABLE_COL_GAP; // 注意这里是 +20,和表体一致
|
||||
fillrectangle(dX, dY, uX, uY);
|
||||
const std::string display = EllipsizeTableText(headers[i],
|
||||
(std::max)(0, colWidths.at(i) - TABLE_PAD_X * 2));
|
||||
outtextxy(dX + TABLE_PAD_X, dY + TABLE_PAD_Y, LPCTSTR(display.c_str()));
|
||||
dX += colWidths.at(i) + TABLE_COL_GAP; // 列间距 20
|
||||
}
|
||||
}
|
||||
// 遍历所有数据单元和表头,计算每列的最大宽度和每行的最大高度,
|
||||
// 为后续绘制表格单元格提供尺寸依据。此计算在数据变更时自动触发。
|
||||
void Table::initTextWaH()
|
||||
{
|
||||
// 和绘制一致的单元内边距
|
||||
const int padX = TABLE_PAD_X; // 左右 padding
|
||||
const int padY = TABLE_PAD_Y; // 上下 padding
|
||||
const int colGap = TABLE_COL_GAP; // 列间距
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
size_t maxCols = headers.size();
|
||||
for (const auto& row : data)
|
||||
if (row.size() > maxCols)
|
||||
maxCols = row.size();
|
||||
|
||||
// 统计每列最大文本宽 & 每列最大行高(包含数据 + 表头)
|
||||
colWidths.assign(maxCols, 0);
|
||||
lineHeights.assign(maxCols, 0);
|
||||
|
||||
// 先看数据
|
||||
for (size_t i = 0; i < data.size(); ++i)
|
||||
{
|
||||
for (size_t j = 0; j < data[i].size(); ++j)
|
||||
{
|
||||
const int w = textwidth(LPCTSTR(data[i][j].c_str()));
|
||||
const int h = textheight(LPCTSTR(data[i][j].c_str()));
|
||||
if (w > colWidths[j]) colWidths[j] = w;
|
||||
if (h > lineHeights[j]) lineHeights[j] = h;
|
||||
}
|
||||
}
|
||||
// 再用表头更新(谁大取谁)
|
||||
for (size_t j = 0; j < headers.size(); ++j)
|
||||
{
|
||||
const int w = textwidth(LPCTSTR(headers[j].c_str()));
|
||||
const int h = textheight(LPCTSTR(headers[j].c_str()));
|
||||
if (w > colWidths[j]) colWidths[j] = w;
|
||||
if (h > lineHeights[j]) lineHeights[j] = h;
|
||||
}
|
||||
|
||||
// 用“所有列的最大行高”作为一行的基准高度
|
||||
int maxLineH = 0;
|
||||
|
||||
for (int h : lineHeights)
|
||||
if (h > maxLineH)
|
||||
maxLineH = h;
|
||||
if (maxLineH == 0)
|
||||
maxLineH = textheight(LPCTSTR("A"));
|
||||
std::fill(lineHeights.begin(), lineHeights.end(), maxLineH);
|
||||
if (rowsPerPage < 1)
|
||||
rowsPerPage = 1;
|
||||
|
||||
// 列宽包含左右 padding:在计算完最大文本宽度后,加上 2*padX 作为单元格内边距
|
||||
for (size_t j = 0; j < colWidths.size(); ++j) {
|
||||
colWidths[j] += 2 * padX;
|
||||
}
|
||||
|
||||
// 表内容总宽 = Σ(列宽 + 列间距)
|
||||
int contentW = 0;
|
||||
for (size_t j = 0; j < colWidths.size(); ++j)
|
||||
contentW += colWidths[j] + colGap;
|
||||
|
||||
// 表头高 & 行高(与 drawHeader/drawTable 内部一致:+上下 padding)
|
||||
const int headerH = maxLineH + 2 * padY;
|
||||
const int rowH = maxLineH + 2 * padY;
|
||||
const int rowsH = rowH * rowsPerPage;
|
||||
|
||||
// 页脚:
|
||||
const int pageTextH = textheight(LPCTSTR(pageNumtext.c_str()));
|
||||
const int btnTextH = textheight(LPCTSTR("上一页"));
|
||||
const int btnPadV = TABLE_BTN_TEXT_PAD_V;
|
||||
const int btnH = btnTextH + 2 * btnPadV;
|
||||
const int footerPad = TABLE_FOOTER_PAD;
|
||||
const int footerH = (pageTextH > btnH ? pageTextH : btnH) + footerPad;
|
||||
|
||||
// 最终表宽/高:内容 + 对称边框
|
||||
this->width = contentW + (border << 1);
|
||||
this->height = headerH + rowsH + footerH + (border << 1);
|
||||
// 这是 Table 自身受控的“结构尺寸 -> 设计基线”刷新点:
|
||||
// 表格总宽高本来就由表头、表体、页脚和分页按钮共同决定,
|
||||
// 因此这里允许同步 localWidth/localHeight,作为后续锚点解算参考。
|
||||
const bool baselineChanged = (this->localWidth != this->width) || (this->localHeight != this->height);
|
||||
this->localWidth = this->width;
|
||||
this->localHeight = this->height;
|
||||
if (baselineChanged)
|
||||
{
|
||||
SX_LOGD("Layout")
|
||||
<< SX_T("Table 刷新结构设计基线:id=", "Table refresh structural design rect: id=") << id
|
||||
<< SX_T(" size=(", " size=(") << this->width << "," << this->height << ")";
|
||||
}
|
||||
}
|
||||
|
||||
void Table::initButton()
|
||||
{
|
||||
const int gap = TABLE_BTN_GAP;
|
||||
const int padH = TABLE_BTN_PAD_H;
|
||||
const int padV = TABLE_BTN_PAD_V; // 按钮垂直内边距
|
||||
|
||||
int pageW = textwidth(LPCTSTR(pageNumtext.c_str()));
|
||||
int lblH = textheight(LPCTSTR(pageNumtext.c_str()));
|
||||
|
||||
// 统一按钮尺寸(用按钮文字自身宽高 + padding)
|
||||
int prevW = textwidth(LPCTSTR(TABLE_STR_PREV)) + padH * 2;
|
||||
int nextW = textwidth(LPCTSTR(TABLE_STR_NEXT)) + padH * 2;
|
||||
int btnH = lblH + padV * 2;
|
||||
|
||||
// 基于“页码标签”的矩形来摆放:
|
||||
// prev 在页码左侧 gap 处;next 在右侧 gap 处;Y 对齐 pY
|
||||
int prevX = pX - gap - prevW;
|
||||
int nextX = pX + pageW + gap;
|
||||
int btnY = pY; // 和页码同一基线
|
||||
|
||||
if (!prevButton)
|
||||
prevButton = std::make_unique<Button>(prevX, btnY, prevW, btnH, TABLE_STR_PREV, RGB(0, 0, 0), RGB(255, 255, 255));
|
||||
else
|
||||
{
|
||||
prevButton->setX(prevX);
|
||||
prevButton->setY(btnY);
|
||||
}
|
||||
|
||||
if (!nextButton)
|
||||
nextButton = std::make_unique<Button>(nextX, btnY, nextW, btnH, TABLE_STR_NEXT, RGB(0, 0, 0), RGB(255, 255, 255));
|
||||
else
|
||||
{
|
||||
nextButton->setX(nextX);
|
||||
nextButton->setY(btnY);
|
||||
}
|
||||
|
||||
prevButton->setParent(this);
|
||||
nextButton->setParent(this);
|
||||
|
||||
prevButton->textStyle = this->textStyle;
|
||||
nextButton->textStyle = this->textStyle;
|
||||
prevButton->setFillMode(tableFillMode);
|
||||
nextButton->setFillMode(tableFillMode);
|
||||
|
||||
prevButton->setOnClickListener([this]()
|
||||
{
|
||||
int oldPage = currentPage;
|
||||
if (currentPage > 1)
|
||||
{
|
||||
--currentPage;
|
||||
SX_LOGI("Table")
|
||||
<< SX_T("翻页:id=", "page change: id=") << id
|
||||
<< " " << oldPage << "->" << currentPage
|
||||
<< SX_T(" 总页数=", " total=") << totalPages
|
||||
<< SX_T(" 行数=", " rows=") << (int)data.size();
|
||||
|
||||
|
||||
dirty = true;
|
||||
if (pageNum) pageNum->setDirty(true);
|
||||
}
|
||||
});
|
||||
nextButton->setOnClickListener([this]()
|
||||
{
|
||||
int oldPage = currentPage;
|
||||
if (currentPage < totalPages)
|
||||
{
|
||||
++currentPage;
|
||||
SX_LOGI("Table")
|
||||
<< SX_T("翻页:id=", "page change: id=") << id
|
||||
<< " " << oldPage << "->" << currentPage
|
||||
<< SX_T(" 总页数=", " total=") << totalPages
|
||||
<< SX_T(" 行数=", " rows=") << (int)data.size();
|
||||
|
||||
dirty = true;
|
||||
if (pageNum) pageNum->setDirty(true);
|
||||
}
|
||||
});
|
||||
isNeedButtonAndPageNum = false;
|
||||
}
|
||||
|
||||
void Table::initPageNum()
|
||||
{
|
||||
// 统一坐标系
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
const int baseH = lineHeights.empty() ? 0 : lineHeights.at(0);
|
||||
const int headerH = baseH + TABLE_HEADER_EXTRA;
|
||||
const int rowsH = baseH * rowsPerPage + rowsPerPage * TABLE_ROW_EXTRA;
|
||||
|
||||
// 页脚顶部位置(表头 + 可视数据区 之后)
|
||||
pY = y + border + headerH + rowsH + TABLE_FOOTER_BLANK; // +8 顶部留白
|
||||
|
||||
// 按理来说 x + (this->width - textW) / 2;就可以
|
||||
// 但是在绘制时,发现控件偏右,因此减去40
|
||||
int textW = textwidth(LPCTSTR(pageNumtext.c_str()));
|
||||
pX = x + TABLE_PAGE_TEXT_OFFSET_X + (this->width - textW) / 2;
|
||||
|
||||
if (!pageNum)
|
||||
pageNum = std::make_unique<Label>(pX, pY, pageNumtext);
|
||||
else
|
||||
{
|
||||
pageNum->setX(pX);
|
||||
pageNum->setY(pY);
|
||||
}
|
||||
|
||||
pageNum->setParent(this);
|
||||
|
||||
pageNum->textStyle = this->textStyle;
|
||||
if (StellarX::FillMode::Null == tableFillMode)
|
||||
pageNum->setTextdisap(true); // 透明文本
|
||||
}
|
||||
|
||||
void Table::drawPageNum()
|
||||
{
|
||||
pageNumtext = BuildPageNumberText(currentPage, totalPages);
|
||||
if (nullptr == pageNum || isNeedButtonAndPageNum)
|
||||
initPageNum();
|
||||
pageNum->setText(pageNumtext);
|
||||
pageNum->textStyle = this->textStyle;
|
||||
if (StellarX::FillMode::Null == tableFillMode)
|
||||
pageNum->setTextdisap(true);
|
||||
// Table 每次整表重绘前都会先回贴自己的背景快照,
|
||||
// 页码区域会被一起清空。即便页码文本本身没变化,也必须强制把 Label 重新设脏,
|
||||
// 否则 Label::draw() 会因为 dirty=false 直接跳过,导致页码在表格重绘后消失。
|
||||
pageNum->setDirty(true);
|
||||
pageNum->draw();
|
||||
}
|
||||
|
||||
void Table::drawButton()
|
||||
{
|
||||
if ((nullptr == prevButton || nullptr == nextButton) || isNeedButtonAndPageNum)
|
||||
initButton();
|
||||
|
||||
this->prevButton->textStyle = this->textStyle;
|
||||
this->nextButton->textStyle = this->textStyle;
|
||||
this->prevButton->setFillMode(tableFillMode);
|
||||
this->nextButton->setFillMode(tableFillMode);
|
||||
this->prevButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
this->nextButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
this->prevButton->setDirty(true);
|
||||
this->nextButton->setDirty(true);
|
||||
prevButton->draw();
|
||||
nextButton->draw();
|
||||
}
|
||||
|
||||
void Table::setX(int x)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
this->x = x;
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Table::setY(int y)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
this->y = y;
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Table::setWidth(int width)
|
||||
{
|
||||
const RECT previousCoverage = getManagedRepaintCoverageRect();
|
||||
const bool wasDrawn = hasValidBackgroundSnapshot();
|
||||
if (wasDrawn) discardBackground();
|
||||
// 运行态总宽必须和实际列宽严格一致,避免绘制超出快照与 coverage。
|
||||
// 同时考虑表体和分页栏的最小宽度,随后把余数分配到前几列,使列宽总和严格闭合。
|
||||
const int ncols = static_cast<int>(colWidths.size());
|
||||
if (ncols <= 0) {
|
||||
this->width = (std::max)(0, width);
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const int border = (std::max)(0, tableBorderWidth);
|
||||
const int minColumnWidth = TABLE_PAD_X * 2 + 1;
|
||||
const int contentMinWidth = (border << 1) + ncols * (minColumnWidth + TABLE_COL_GAP);
|
||||
const int footerMinWidth = (border << 1) + textwidth(TABLE_STR_PREV) + textwidth(TABLE_STR_NEXT) +
|
||||
textwidth(pageNumtext.c_str()) + TABLE_BTN_PAD_H * 4 + TABLE_BTN_GAP * 2;
|
||||
const int minWidth = isShowPageButton ? (std::max)(contentMinWidth, footerMinWidth) : contentMinWidth;
|
||||
const int targetWidth = (std::max)(width, minWidth);
|
||||
const int targetColumnsWidth = targetWidth - (border << 1) - ncols * TABLE_COL_GAP;
|
||||
const int baseWidth = targetColumnsWidth / ncols;
|
||||
int remainder = targetColumnsWidth % ncols;
|
||||
for (int i = 0; i < ncols; ++i)
|
||||
colWidths[i] = baseWidth + (remainder-- > 0 ? 1 : 0);
|
||||
this->width = targetWidth;
|
||||
// 需要重新布局页脚元素
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
if (wasDrawn)
|
||||
{
|
||||
if (auto* host = getHostWindow()) host->requestManagedRepaint(this, previousCoverage);
|
||||
else requestRepaint(parent);
|
||||
}
|
||||
}
|
||||
|
||||
void Table::setHeight(int height)
|
||||
{
|
||||
// 当前阶段 Table 明确不支持纵向 Stretch。
|
||||
// 高度链路依赖表头、表体、页脚、按钮和页码计算,
|
||||
// 因此这里保持空实现,避免被通用布局层错误拉高/压缩。
|
||||
}
|
||||
|
||||
Table::Table(int x, int y)
|
||||
:Control(x, y, 0, 0)
|
||||
{
|
||||
this->id = "Table";
|
||||
// Table 当前正式能力边界:
|
||||
// 仅允许 X 轴 Stretch,Y 轴固定尺寸。
|
||||
this->layoutCapability.allowStretchX = true;
|
||||
this->layoutCapability.allowStretchY = false;
|
||||
}
|
||||
|
||||
Table::~Table() = default;
|
||||
|
||||
void Table::applyResolvedLayoutRect(const StellarX::ResolvedLayoutRect& rect)
|
||||
{
|
||||
// Table 不能像普通控件那样直接写入 width/height:
|
||||
// 它的 setWidth() 内部会联动列宽与页脚布局,因此这里必须复用原有 setter 副作用。
|
||||
// 同时 Table 的 Y Fixed 是当前版本的正式行为边界:
|
||||
// 即便统一解算器给出了新的 rect.height,这里也不会接收该高度。
|
||||
if (this->x != rect.worldX)
|
||||
setX(rect.worldX);
|
||||
if (this->y != rect.worldY)
|
||||
setY(rect.worldY);
|
||||
if (this->width != rect.width)
|
||||
setWidth(rect.width);
|
||||
if (this->height != rect.height)
|
||||
{
|
||||
SX_LOGD("Layout")
|
||||
<< SX_T("Table 忽略 Y 轴运行态高度写入:id=", "Table ignore runtime Y resize: id=")
|
||||
<< id
|
||||
<< SX_T(" currentH=", " currentH=") << this->height
|
||||
<< SX_T(" requestedH=", " requestedH=") << rect.height;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void Table::draw()
|
||||
{
|
||||
//在这里先初始化保证翻页按钮不为空
|
||||
// 在一些容器中,Table不会被立即绘制可能导致事件事件传递时触发空指针警报
|
||||
// 由于单元格初始化依赖字体数据所以先设置一次字体样式
|
||||
// 先保存当前绘图状态
|
||||
saveStyle();
|
||||
|
||||
// 设置表格样式
|
||||
setfillcolor(tableBkClor);
|
||||
setlinecolor(tableBorderClor);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut);
|
||||
settextcolor(textStyle.color);
|
||||
setlinestyle((int)tableLineStyle, tableBorderWidth);
|
||||
setfillstyle((int)tableFillMode);
|
||||
// 是否需要计算单元格尺寸
|
||||
if (isNeedCellSize)
|
||||
{
|
||||
initTextWaH();
|
||||
isNeedCellSize = false;
|
||||
}
|
||||
restoreStyle();
|
||||
if (this->dirty && this->show)
|
||||
{
|
||||
// 先保存当前绘图状态
|
||||
saveStyle();
|
||||
|
||||
// 设置表格样式
|
||||
setfillcolor(tableBkClor);
|
||||
setlinecolor(tableBorderClor);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
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 (isNeedButtonAndPageNum)
|
||||
{
|
||||
// 数据变化可能同时改变表格宽度、页码位置和两个分页按钮的位置。
|
||||
// 必须在绘制新内容前完成三个内部控件的搬移,让 setX/setY 先回贴旧快照;
|
||||
// 否则后移动的分页按钮会用旧背景覆盖已经画好的新页码。
|
||||
pageNumtext = BuildPageNumberText(currentPage, totalPages);
|
||||
initPageNum();
|
||||
pageNum->textStyle = this->textStyle;
|
||||
pageNum->setText(pageNumtext);
|
||||
pageNum->setDirty(true);
|
||||
|
||||
if (isShowPageButton)
|
||||
initButton();
|
||||
else
|
||||
isNeedButtonAndPageNum = false;
|
||||
}
|
||||
|
||||
if (isNeedDrawHeaders)
|
||||
{
|
||||
// 重新设置表格样式
|
||||
setfillcolor(tableBkClor);
|
||||
setlinecolor(tableBorderClor);
|
||||
settextstyle(textStyle.nHeight, textStyle.nWidth, textStyle.faceName.c_str(),
|
||||
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 (hasSnap)
|
||||
{
|
||||
// 始终先恢复旧背景,清除上一帧内容
|
||||
restBackground();
|
||||
// 当尺寸变化或缓存图像无效时,需要重新截图
|
||||
if (!saveBkImage || saveWidth != this->width || saveHeight != this->height)
|
||||
{
|
||||
invalidateBackgroundSnapshot();
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 首次绘制时无背景缓存,直接抓取
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
}
|
||||
// 恢复最新的背景,保证绘制区域干净
|
||||
restBackground();
|
||||
// 绘制表头
|
||||
|
||||
//if (!headers.empty())
|
||||
drawHeader();
|
||||
// 绘制当前页
|
||||
drawTable();
|
||||
// 绘制页码标签
|
||||
drawPageNum();
|
||||
|
||||
// 绘制翻页按钮
|
||||
if (this->isShowPageButton)
|
||||
drawButton();
|
||||
|
||||
// 恢复绘图状态
|
||||
restoreStyle();
|
||||
dirty = false; // 标记不需要重绘
|
||||
}
|
||||
}
|
||||
|
||||
bool Table::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show)return false;
|
||||
resetEventVisualChanged();
|
||||
bool consume = false;
|
||||
bool anyVisualChanged = false;
|
||||
if (!this->isShowPageButton)
|
||||
return consume;
|
||||
|
||||
if (msg.message == WM_MOUSEMOVE)
|
||||
{
|
||||
// Table 内部分页按钮也按“后绘制者优先”的倒序语义处理。
|
||||
// drawButton() 里是 prev 再 next,因此这里先 next 再 prev。
|
||||
if (nextButton)
|
||||
{
|
||||
if (!consume)
|
||||
{
|
||||
consume = nextButton->handleEvent(msg);
|
||||
if (nextButton->didEventAffectVisual())
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
else if (nextButton->clearTransientMouseState())
|
||||
{
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
}
|
||||
if (prevButton)
|
||||
{
|
||||
if (!consume)
|
||||
{
|
||||
consume = prevButton->handleEvent(msg);
|
||||
if (prevButton->didEventAffectVisual())
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
else if (prevButton->clearTransientMouseState())
|
||||
{
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 点击类消息仍保持“后绘制者优先”的倒序命中语义。
|
||||
// 但和 WM_MOUSEMOVE 一样,分页按钮的视觉状态变化必须向上提升为 Table 重绘,
|
||||
// 否则按钮内部已经变成 hover/click,父级却不知道需要重画整张 Table,
|
||||
// 就会出现“状态实际变了,但屏幕上直到下一次翻页才看见”的现象。
|
||||
if (nextButton)
|
||||
{
|
||||
consume = nextButton->handleEvent(msg);
|
||||
if (nextButton->didEventAffectVisual())
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
if (prevButton && !consume)
|
||||
{
|
||||
consume = prevButton->handleEvent(msg);
|
||||
if (prevButton->didEventAffectVisual())
|
||||
anyVisualChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Table 当前还没有像 TabControl 那样把分页按钮做成可独立提交的内部绘制单元。
|
||||
// 因此只要内部按钮的 hover / click / leave 造成视觉变化,就必须把整张 Table 设脏,
|
||||
// 让父容器通过现有的 Table::draw() 链把页脚区域正确重画出来。
|
||||
if (anyVisualChanged)
|
||||
dirty = true;
|
||||
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
markEventVisualChanged(anyVisualChanged || dirty);
|
||||
return consume;
|
||||
}
|
||||
|
||||
bool Table::clearTransientMouseState()
|
||||
{
|
||||
bool changed = false;
|
||||
if (nextButton && nextButton->clearTransientMouseState())
|
||||
changed = true;
|
||||
if (prevButton && prevButton->clearTransientMouseState())
|
||||
changed = true;
|
||||
return changed;
|
||||
}
|
||||
|
||||
RECT Table::getManagedRepaintCoverageRect() const
|
||||
{
|
||||
// coverage 包含表体、页码和分页按钮的实际绘制范围,而不只包含表格本体。
|
||||
RECT coverage = getBoundsRect();
|
||||
|
||||
if (pageNum && pageNum->IsVisible())
|
||||
{
|
||||
const RECT pageRect = pageNum->getManagedRepaintCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, pageRect.left);
|
||||
coverage.top = (std::min)(coverage.top, pageRect.top);
|
||||
coverage.right = (std::max)(coverage.right, pageRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, pageRect.bottom);
|
||||
}
|
||||
if (prevButton && prevButton->IsVisible())
|
||||
{
|
||||
const RECT prevRect = prevButton->getManagedRepaintCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, prevRect.left);
|
||||
coverage.top = (std::min)(coverage.top, prevRect.top);
|
||||
coverage.right = (std::max)(coverage.right, prevRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, prevRect.bottom);
|
||||
}
|
||||
if (nextButton && nextButton->IsVisible())
|
||||
{
|
||||
const RECT nextRect = nextButton->getManagedRepaintCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, nextRect.left);
|
||||
coverage.top = (std::min)(coverage.top, nextRect.top);
|
||||
coverage.right = (std::max)(coverage.right, nextRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, nextRect.bottom);
|
||||
}
|
||||
|
||||
return coverage;
|
||||
}
|
||||
|
||||
RECT Table::getManagedRepaintPersistentCoverageRect() const
|
||||
{
|
||||
// Table 当前仍按整表绘制保证正确性;分页按钮 Tooltip 属于临时浮层,
|
||||
// 不能进入持久 coverage,否则可能污染外层兄弟控件快照。
|
||||
RECT coverage = getBoundsRect();
|
||||
|
||||
if (pageNum && pageNum->IsVisible())
|
||||
{
|
||||
const RECT pageRect = pageNum->getManagedRepaintPersistentCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, pageRect.left);
|
||||
coverage.top = (std::min)(coverage.top, pageRect.top);
|
||||
coverage.right = (std::max)(coverage.right, pageRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, pageRect.bottom);
|
||||
}
|
||||
if (prevButton && prevButton->IsVisible())
|
||||
{
|
||||
const RECT prevRect = prevButton->getManagedRepaintPersistentCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, prevRect.left);
|
||||
coverage.top = (std::min)(coverage.top, prevRect.top);
|
||||
coverage.right = (std::max)(coverage.right, prevRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, prevRect.bottom);
|
||||
}
|
||||
if (nextButton && nextButton->IsVisible())
|
||||
{
|
||||
const RECT nextRect = nextButton->getManagedRepaintPersistentCoverageRect();
|
||||
coverage.left = (std::min)(coverage.left, nextRect.left);
|
||||
coverage.top = (std::min)(coverage.top, nextRect.top);
|
||||
coverage.right = (std::max)(coverage.right, nextRect.right);
|
||||
coverage.bottom = (std::max)(coverage.bottom, nextRect.bottom);
|
||||
}
|
||||
|
||||
return coverage;
|
||||
}
|
||||
|
||||
void Table::setHeaders(std::initializer_list<std::string> headers)
|
||||
{
|
||||
this->headers.clear();
|
||||
for (auto& lis : headers)
|
||||
this->headers.push_back(lis);
|
||||
|
||||
if (!this->headers.empty())
|
||||
{
|
||||
for (auto& row : this->data)
|
||||
row = NormalizeTableRow(std::move(row), this->headers.size());
|
||||
}
|
||||
|
||||
totalPages = CalculateTotalPages(this->data.size(), rowsPerPage);
|
||||
if (currentPage > totalPages)
|
||||
currentPage = totalPages;
|
||||
|
||||
SX_LOGI("Table") << SX_T("设置表头:id=","setHeaders: id=") << id << SX_T("总数="," count=") << (int)this->headers.size();
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
isNeedDrawHeaders = true; // 标记需要重新绘制表头
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
|
||||
}
|
||||
|
||||
void Table::setData(std::vector<std::string> data)
|
||||
{
|
||||
data = NormalizeTableRow(std::move(data), headers.size());
|
||||
|
||||
this->data.push_back(data);
|
||||
|
||||
totalPages = CalculateTotalPages(this->data.size(), rowsPerPage);
|
||||
if (currentPage > totalPages)
|
||||
currentPage = totalPages;
|
||||
|
||||
isNeedCellSize = true;
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
|
||||
SX_LOGI("Table")
|
||||
<< SX_T("新增Data:id=", "appendRow: id=") << id
|
||||
<< SX_T(" 本行列数=", " cols=") << (int)data.size()
|
||||
<< SX_T(" 数据总行数=", " totalRows=") << (int)this->data.size()
|
||||
<< SX_T(" 总页数=", " totalPages=") << totalPages;
|
||||
}
|
||||
|
||||
|
||||
void Table::setData(std::initializer_list<std::vector<std::string>> data)
|
||||
{
|
||||
int addedRows = 0;
|
||||
for (auto row : data)
|
||||
{
|
||||
this->data.push_back(NormalizeTableRow(std::move(row), headers.size()));
|
||||
++addedRows;
|
||||
}
|
||||
|
||||
totalPages = CalculateTotalPages(this->data.size(), rowsPerPage);
|
||||
if (currentPage > totalPages)
|
||||
currentPage = totalPages;
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
isNeedButtonAndPageNum = true;
|
||||
dirty = true;
|
||||
SX_LOGI("Table")
|
||||
<< SX_T("新增多行Data:id=", "appendRows: id=") << id
|
||||
<< SX_T(" 新增行数=", " addedRows=") << addedRows
|
||||
<< SX_T(" 数据总行数=", " totalRows=") << (int)this->data.size()
|
||||
<< SX_T(" 总页数=", " totalPages=") << totalPages;
|
||||
|
||||
|
||||
}
|
||||
|
||||
void Table::setRowsPerPage(int rows)
|
||||
{
|
||||
this->rowsPerPage = rows;
|
||||
if (this->rowsPerPage < 1)
|
||||
this->rowsPerPage = 1;
|
||||
totalPages = CalculateTotalPages(data.size(), rowsPerPage);
|
||||
if (currentPage > totalPages)
|
||||
currentPage = totalPages;
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
isNeedButtonAndPageNum = 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;
|
||||
if (this->prevButton && this->nextButton && this->pageNum)
|
||||
{
|
||||
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->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;
|
||||
}
|
||||
|
||||
void Table::clearHeaders()
|
||||
{
|
||||
this->headers.clear();
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
isNeedDrawHeaders = true; // 标记需要重新绘制表头
|
||||
isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息
|
||||
dirty = true;
|
||||
SX_LOGI("Table") << SX_T("清除表头:id=","clearHeaders: id=" )<< id;
|
||||
|
||||
}
|
||||
|
||||
void Table::clearData()
|
||||
{
|
||||
this->data.clear();
|
||||
this->currentPage = 1;
|
||||
this->totalPages = CalculateTotalPages(this->data.size(), rowsPerPage);
|
||||
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
|
||||
isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息
|
||||
dirty = true;
|
||||
SX_LOGI("Table") << SX_T("清除表格数据:id=","clearData: id=") << id;
|
||||
}
|
||||
|
||||
void Table::resetTable()
|
||||
{
|
||||
clearHeaders();
|
||||
clearData();
|
||||
}
|
||||
|
||||
void Table::onWindowResize()
|
||||
{
|
||||
Control::onWindowResize(); // 先处理自己
|
||||
if (this->prevButton && this->nextButton && this->pageNum)
|
||||
{
|
||||
prevButton->onWindowResize();
|
||||
nextButton->onWindowResize();
|
||||
pageNum->onWindowResize();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
int Table::getTableWidth() const
|
||||
{
|
||||
int temp = 0;
|
||||
for (auto& w : colWidths)
|
||||
temp += w;
|
||||
return temp;
|
||||
}
|
||||
|
||||
int Table::getTableHeight() const
|
||||
{
|
||||
return this->height;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
restoreStyle();
|
||||
dirty = false; //标记不需要重绘
|
||||
}
|
||||
}
|
||||
|
||||
bool TextBox::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show) return false;
|
||||
resetEventVisualChanged();
|
||||
|
||||
bool hover = false;
|
||||
bool oldClick = click;
|
||||
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_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;
|
||||
}
|
||||
|
||||
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,381 +0,0 @@
|
||||
#include "TabControl.h"
|
||||
|
||||
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;
|
||||
//子控件坐标原点
|
||||
int nX = 0;
|
||||
int nY = 0;
|
||||
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);
|
||||
}
|
||||
nX = this->x;
|
||||
nY = this->y + tabBarHeight;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
for (auto& v : c.second->getControls())
|
||||
{
|
||||
v->setX(v->getLocalX() + nX);
|
||||
v->setY(v->getLocalY() + nY);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
nX = this->x;
|
||||
nY = this->y;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
for (auto& v : c.second->getControls())
|
||||
{
|
||||
v->setX(v->getLocalX() + nX);
|
||||
v->setY(v->getLocalY() + nY);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
nX = this->x + tabBarHeight;
|
||||
nY = this->y;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
for (auto& v : c.second->getControls())
|
||||
{
|
||||
v->setX(v->getLocalX() + nX);
|
||||
v->setY(v->getLocalY() + nY);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
nX = this->x;
|
||||
nY = this->y;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
for (auto& v : c.second->getControls())
|
||||
{
|
||||
v->setX(v->getLocalX() + nX);
|
||||
v->setY(v->getLocalY() + nY);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
TabControl::TabControl():Canvas()
|
||||
{
|
||||
this->id = "TabControl";
|
||||
}
|
||||
|
||||
TabControl::TabControl(int x, int y, int width, int height)
|
||||
: Canvas(x, y, width, height)
|
||||
{
|
||||
this->id = "TabControl";
|
||||
}
|
||||
|
||||
TabControl::~TabControl()
|
||||
{
|
||||
}
|
||||
|
||||
void TabControl::draw()
|
||||
{
|
||||
if (!dirty || !show)return;
|
||||
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();
|
||||
Canvas::draw();
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->setDirty(true);
|
||||
c.first->draw();
|
||||
}
|
||||
for (auto& c : controls)
|
||||
if(c.second->IsVisible())
|
||||
{
|
||||
c.second->setDirty(true);
|
||||
c.second->draw();
|
||||
}
|
||||
dirty = false;
|
||||
|
||||
}
|
||||
|
||||
bool TabControl::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if (!show)return false;
|
||||
bool consume = false;
|
||||
for (auto& c : controls)
|
||||
if (c.first->handleEvent(msg))
|
||||
{
|
||||
consume = true;
|
||||
break;
|
||||
}
|
||||
for (auto& c : controls)
|
||||
if(c.second->IsVisible())
|
||||
if (c.second->handleEvent(msg))
|
||||
{
|
||||
consume = true;
|
||||
break;
|
||||
}
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
return consume;
|
||||
}
|
||||
|
||||
void TabControl::add(std::pair<std::unique_ptr<Button>, std::unique_ptr<Canvas>>&& control)
|
||||
{
|
||||
controls.push_back(std::move(control));
|
||||
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]()
|
||||
{
|
||||
controls[idx].second->setIsVisible(true);
|
||||
controls[idx].second->onWindowResize();
|
||||
for (auto& tab : controls)
|
||||
{
|
||||
if (tab.first->getButtonText() != controls[idx].first->getButtonText())
|
||||
{
|
||||
tab.first->setButtonClick(false);
|
||||
tab.second->setIsVisible(false);
|
||||
}
|
||||
}
|
||||
dirty = true;
|
||||
});
|
||||
controls[idx].first->setOnToggleOffListener([this,idx]()
|
||||
{
|
||||
controls[idx].second->setIsVisible(false);
|
||||
|
||||
dirty = true;
|
||||
});
|
||||
controls[idx].second->setParent(this);
|
||||
controls[idx].second->setLinewidth(canvaslinewidth);
|
||||
controls[idx].second->setIsVisible(false);
|
||||
}
|
||||
|
||||
void TabControl::add(std::string tabText, std::unique_ptr<Control> control)
|
||||
{
|
||||
control->setDirty(true);
|
||||
for (auto& tab : controls)
|
||||
{
|
||||
if (tab.first->getButtonText() == tabText)
|
||||
{
|
||||
control->setParent(tab.second.get());
|
||||
control->setIsVisible( tab.second->IsVisible());
|
||||
tab.second->addControl(std::move(control));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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); // <--- 新增
|
||||
|
||||
this->show = visible;
|
||||
for (auto& tab : controls)
|
||||
{
|
||||
tab.first->setIsVisible(visible);
|
||||
//页也要跟着关/开,否则它们会保留旧的 saveBkImage
|
||||
tab.second->setIsVisible(visible);
|
||||
tab.second->setDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
void TabControl::onWindowResize()
|
||||
{
|
||||
Control::onWindowResize();
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c.first->onWindowResize();
|
||||
c.second->onWindowResize();
|
||||
}
|
||||
}
|
||||
|
||||
int TabControl::getActiveIndex() const
|
||||
{
|
||||
int idx = -1;
|
||||
for (auto& c : controls)
|
||||
{
|
||||
idx++;
|
||||
if (c.first->isClicked())
|
||||
return idx;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
void TabControl::setActiveIndex(int idx)
|
||||
{
|
||||
if (idx < 0 || idx > controls.size() - 1) return;
|
||||
if (controls[idx].first->getButtonMode() == StellarX::ButtonMode::DISABLED)return;
|
||||
if (controls[idx].first->isClicked())
|
||||
{
|
||||
if (controls[idx].second->IsVisible())
|
||||
return;
|
||||
else
|
||||
controls[idx].second->setIsVisible(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
controls[idx].first->setButtonClick(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 idx;
|
||||
}
|
||||
|
||||
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 (this == parent)
|
||||
{
|
||||
for (auto& control : controls)
|
||||
{
|
||||
if (control.first->isDirty() && control.first->IsVisible())
|
||||
{
|
||||
control.first->draw();
|
||||
break;
|
||||
}
|
||||
else if (control.second->isDirty()&&control.second->IsVisible())
|
||||
{
|
||||
control.second->draw();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else
|
||||
onRequestRepaintAsRoot();
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
#include "Label.h"
|
||||
|
||||
Label::Label()
|
||||
:Control(0, 0, 0, 0)
|
||||
{
|
||||
this->id = "Label";
|
||||
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->text = text;
|
||||
textStyle.color = textcolor;
|
||||
textBkColor = bkColor; //默认白色背景
|
||||
}
|
||||
|
||||
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.lpszFace,
|
||||
textStyle.nEscapement, textStyle.nOrientation, textStyle.nWeight,
|
||||
textStyle.bItalic, textStyle.bUnderline, textStyle.bStrikeOut); //设置字体样式
|
||||
if (0 == this->width || 0 == this->height)
|
||||
{
|
||||
this->width = textwidth(text.c_str());
|
||||
this->height = textheight(text.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();
|
||||
outtextxy(x, y, LPCTSTR(text.c_str()));
|
||||
restoreStyle();
|
||||
dirty = false;
|
||||
}
|
||||
}
|
||||
//用于“隐藏提示框”时调用(还原并释放快照)
|
||||
void Label::hide()
|
||||
{
|
||||
restBackground(); // 还原屏幕像素
|
||||
discardBackground(); // 作废快照,防止错贴旧图
|
||||
dirty = false;
|
||||
}
|
||||
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;
|
||||
this->dirty = true;
|
||||
|
||||
}
|
||||
-522
@@ -1,522 +0,0 @@
|
||||
#include "Table.h"
|
||||
// 绘制表格的当前页
|
||||
// 使用双循环绘制行和列,考虑分页偏移
|
||||
void Table::drawTable()
|
||||
{
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
|
||||
// 表体从“表头之下”开始
|
||||
dX = x + border;
|
||||
dY = y + border + lineHeights.at(0) + TABLE_HEADER_EXTRA; // 表头高度
|
||||
uY = dY + lineHeights.at(0) + TABLE_ROW_EXTRA;
|
||||
|
||||
size_t startRow = (currentPage - 1) * rowsPerPage;
|
||||
size_t endRow = startRow + (size_t)rowsPerPage < data.size() ? startRow + (size_t)rowsPerPage : data.size();
|
||||
|
||||
for (size_t i = startRow; i < endRow; ++i)
|
||||
{
|
||||
for (size_t j = 0; j < data[i].size(); ++j)
|
||||
{
|
||||
uX = dX + colWidths.at(j) + TABLE_COL_GAP;
|
||||
fillrectangle(dX, dY, uX, uY);
|
||||
outtextxy(dX + TABLE_PAD_X, dY + TABLE_PAD_Y, LPCTSTR(data[i][j].c_str()));
|
||||
dX += colWidths.at(j) + TABLE_COL_GAP;
|
||||
}
|
||||
dX = x + border;
|
||||
dY = uY;
|
||||
uY = dY + lineHeights.at(0) + TABLE_ROW_EXTRA;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void Table::drawHeader()
|
||||
{
|
||||
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
// 内容区原点 = x+border, y+border
|
||||
dX = x + border;
|
||||
dY = y + border;
|
||||
uY = dY + lineHeights.at(0) + TABLE_HEADER_EXTRA;
|
||||
|
||||
for (size_t i = 0; i < headers.size(); i++)
|
||||
{
|
||||
uX = dX + colWidths.at(i) + TABLE_COL_GAP; // 注意这里是 +20,和表体一致
|
||||
fillrectangle(dX, dY, uX, uY);
|
||||
outtextxy(dX + TABLE_PAD_X, dY + TABLE_PAD_Y, LPCTSTR(headers[i].c_str()));
|
||||
dX += colWidths.at(i) + TABLE_COL_GAP; // 列间距 20
|
||||
}
|
||||
}
|
||||
// 遍历所有数据单元和表头,计算每列的最大宽度和每行的最大高度,
|
||||
// 为后续绘制表格单元格提供尺寸依据。此计算在数据变更时自动触发。
|
||||
void Table::initTextWaH()
|
||||
{
|
||||
// 和绘制一致的单元内边距
|
||||
const int padX = TABLE_PAD_X; // 左右 padding
|
||||
const int padY = TABLE_PAD_Y; // 上下 padding
|
||||
const int colGap = TABLE_COL_GAP; // 列间距
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
|
||||
// 统计每列最大文本宽 & 每列最大行高(包含数据 + 表头)
|
||||
colWidths.assign(headers.size(), 0);
|
||||
lineHeights.assign(headers.size(), 0);
|
||||
|
||||
// 先看数据
|
||||
for (size_t i = 0; i < data.size(); ++i)
|
||||
{
|
||||
for (size_t j = 0; j < data[i].size(); ++j)
|
||||
{
|
||||
const int w = textwidth(LPCTSTR(data[i][j].c_str()));
|
||||
const int h = textheight(LPCTSTR(data[i][j].c_str()));
|
||||
if (w > colWidths[j]) colWidths[j] = w;
|
||||
if (h > lineHeights[j]) lineHeights[j] = h;
|
||||
}
|
||||
}
|
||||
// 再用表头更新(谁大取谁)
|
||||
for (size_t j = 0; j < headers.size(); ++j)
|
||||
{
|
||||
const int w = textwidth(LPCTSTR(headers[j].c_str()));
|
||||
const int h = textheight(LPCTSTR(headers[j].c_str()));
|
||||
if (w > colWidths[j]) colWidths[j] = w;
|
||||
if (h > lineHeights[j]) lineHeights[j] = h;
|
||||
}
|
||||
|
||||
// 用“所有列的最大行高”作为一行的基准高度
|
||||
int maxLineH = 0;
|
||||
|
||||
for (int h : lineHeights)
|
||||
if (h > maxLineH)
|
||||
maxLineH = h;
|
||||
|
||||
// 列的像素宽 = 内容宽 + 左右 padding
|
||||
// 表内容总宽 = Σ(列宽 + 列间距)
|
||||
int contentW = 0;
|
||||
for (size_t j = 0; j < colWidths.size(); ++j)
|
||||
contentW += (colWidths[j] + 2 * padX) + colGap;
|
||||
|
||||
// 表头高 & 行高(与 drawHeader/drawTable 内部一致:+上下 padding)
|
||||
const int headerH = maxLineH + 2 * padY;
|
||||
const int rowH = maxLineH + 2 * padY;
|
||||
const int rowsH = rowH * rowsPerPage;
|
||||
|
||||
// 页脚:
|
||||
const int pageTextH = textheight(LPCTSTR(pageNumtext.c_str()));
|
||||
const int btnTextH = textheight(LPCTSTR("上一页"));
|
||||
const int btnPadV = TABLE_BTN_TEXT_PAD_V;
|
||||
const int btnH = btnTextH + 2 * btnPadV;
|
||||
const int footerPad = TABLE_FOOTER_PAD;
|
||||
const int footerH = (pageTextH > btnH ? pageTextH : btnH) + footerPad;
|
||||
|
||||
// 最终表宽/高:内容 + 对称边框
|
||||
this->width = contentW + (border << 1);
|
||||
this->height = headerH + rowsH + footerH + (border << 1);
|
||||
}
|
||||
|
||||
void Table::initButton()
|
||||
{
|
||||
const int gap = TABLE_BTN_GAP;
|
||||
const int padH = TABLE_BTN_PAD_H;
|
||||
const int padV = TABLE_BTN_PAD_V; // 按钮垂直内边距
|
||||
|
||||
int pageW = textwidth(LPCTSTR(pageNumtext.c_str()));
|
||||
int lblH = textheight(LPCTSTR(pageNumtext.c_str()));
|
||||
|
||||
// 统一按钮尺寸(用按钮文字自身宽高 + padding)
|
||||
int prevW = textwidth(LPCTSTR(TABLE_STR_PREV)) + padH * 2;
|
||||
int nextW = textwidth(LPCTSTR(TABLE_STR_NEXT)) + padH * 2;
|
||||
int btnH = lblH + padV * 2;
|
||||
|
||||
|
||||
// 基于“页码标签”的矩形来摆放:
|
||||
// prev 在页码左侧 gap 处;next 在右侧 gap 处;Y 对齐 pY
|
||||
int prevX = pX - gap - prevW;
|
||||
int nextX = pX + pageW + gap;
|
||||
int btnY = pY; // 和页码同一基线
|
||||
|
||||
if (!prevButton)
|
||||
prevButton = new Button(prevX, btnY, prevW, btnH, TABLE_STR_PREV, RGB(0, 0, 0), RGB(255, 255, 255));
|
||||
else
|
||||
{
|
||||
prevButton->setX(prevX);
|
||||
prevButton->setY(btnY);
|
||||
}
|
||||
|
||||
if (!nextButton)
|
||||
nextButton = new Button(nextX, btnY, nextW, btnH, TABLE_STR_NEXT, RGB(0, 0, 0), RGB(255, 255, 255));
|
||||
else
|
||||
{
|
||||
nextButton->setX(nextX);
|
||||
nextButton->setY(btnY);
|
||||
}
|
||||
|
||||
prevButton->textStyle = this->textStyle;
|
||||
nextButton->textStyle = this->textStyle;
|
||||
prevButton->setFillMode(tableFillMode);
|
||||
nextButton->setFillMode(tableFillMode);
|
||||
|
||||
prevButton->setOnClickListener([this]()
|
||||
{
|
||||
if (currentPage > 1)
|
||||
{
|
||||
--currentPage;
|
||||
dirty = true;
|
||||
if (pageNum) pageNum->setDirty(true);
|
||||
}
|
||||
});
|
||||
nextButton->setOnClickListener([this]()
|
||||
{
|
||||
if (currentPage < totalPages)
|
||||
{
|
||||
++currentPage;
|
||||
dirty = true;
|
||||
if (pageNum) pageNum->setDirty(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void Table::initPageNum()
|
||||
{
|
||||
// 统一坐标系
|
||||
const int border = tableBorderWidth > 0 ? tableBorderWidth : 0;
|
||||
const int baseH = lineHeights.empty() ? 0 : lineHeights.at(0);
|
||||
const int headerH = baseH + TABLE_HEADER_EXTRA;
|
||||
const int rowsH = baseH * rowsPerPage + rowsPerPage * TABLE_ROW_EXTRA;
|
||||
|
||||
// 内容宽度 = sum(colWidths + 20);initTextWaH() 已把 this->width += 2*border
|
||||
// 因此 contentW = this->width - 2*border 更稳妥
|
||||
const int contentW = this->width - (border << 1);
|
||||
|
||||
// 页脚顶部位置(表头 + 可视数据区 之后)
|
||||
pY = y + border + headerH + rowsH + TABLE_FOOTER_BLANK; // +8 顶部留白
|
||||
|
||||
// 按理来说 x + (this->width - textW) / 2;就可以
|
||||
// 但是在绘制时,发现控件偏右,因此减去40
|
||||
int textW = textwidth(LPCTSTR(pageNumtext.c_str()));
|
||||
pX = x + TABLE_PAGE_TEXT_OFFSET_X +(this->width - textW) / 2;
|
||||
|
||||
if (!pageNum)
|
||||
pageNum = new Label(pX, pY, pageNumtext);
|
||||
else
|
||||
{
|
||||
pageNum->setX(pX);
|
||||
pageNum->setY(pY);
|
||||
}
|
||||
|
||||
pageNum->textStyle = this->textStyle;
|
||||
if (StellarX::FillMode::Null == tableFillMode)
|
||||
pageNum->setTextdisap(true); // 透明文本
|
||||
}
|
||||
|
||||
void Table::drawPageNum()
|
||||
{
|
||||
|
||||
pageNumtext = "第";
|
||||
pageNumtext+= std::to_string(currentPage);
|
||||
pageNumtext += "页/共";
|
||||
pageNumtext += std::to_string(totalPages);
|
||||
pageNumtext += "页";
|
||||
if (nullptr == pageNum)
|
||||
initPageNum();
|
||||
pageNum->setText(pageNumtext);
|
||||
pageNum->textStyle = this->textStyle;
|
||||
if (StellarX::FillMode::Null == tableFillMode)
|
||||
pageNum->setTextdisap(true);
|
||||
pageNum->draw();
|
||||
|
||||
}
|
||||
|
||||
void Table::drawButton()
|
||||
{
|
||||
if (nullptr == prevButton || nullptr == nextButton)
|
||||
initButton();
|
||||
|
||||
this->prevButton->textStyle = this->textStyle;
|
||||
this->nextButton->textStyle = this->textStyle;
|
||||
this->prevButton->setFillMode(tableFillMode);
|
||||
this->nextButton->setFillMode(tableFillMode);
|
||||
this->prevButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
this->nextButton->setButtonShape(StellarX::ControlShape::B_RECTANGLE);
|
||||
this->prevButton->setDirty(true);
|
||||
this->nextButton->setDirty(true);
|
||||
prevButton->draw();
|
||||
nextButton->draw();
|
||||
|
||||
}
|
||||
|
||||
Table::Table(int x, int y)
|
||||
:Control(x, y, 0, 0)
|
||||
{
|
||||
this->id = "Table";
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
//在这里先初始化保证翻页按钮不为空
|
||||
// 在一些容器中,Table不会被立即绘制可能导致事件事件传递时触发空指针警报
|
||||
// 由于单元格初始化依赖字体数据所以先设置一次字体样式
|
||||
// 先保存当前绘图状态
|
||||
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);
|
||||
// 是否需要计算单元格尺寸
|
||||
if (isNeedCellSize)
|
||||
{
|
||||
initTextWaH();
|
||||
isNeedCellSize = false;
|
||||
}
|
||||
restoreStyle();
|
||||
if (this->dirty && this->show)
|
||||
{
|
||||
// 先保存当前绘图状态
|
||||
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 (isNeedDrawHeaders)
|
||||
{
|
||||
// 重新设置表格样式
|
||||
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 ((!hasSnap) || (saveWidth != this->width) || (saveHeight != this->height)||!saveBkImage)
|
||||
saveBackground(this->x, this->y, this->width, this->height);
|
||||
// 恢复背景(清除旧内容)
|
||||
restBackground();
|
||||
// 绘制表头
|
||||
|
||||
dX = x;
|
||||
dY = y;
|
||||
drawHeader();
|
||||
this->isNeedDrawHeaders = false;
|
||||
// 绘制当前页
|
||||
drawTable();
|
||||
// 绘制页码标签
|
||||
drawPageNum();
|
||||
|
||||
// 绘制翻页按钮
|
||||
if (this->isShowPageButton)
|
||||
drawButton();
|
||||
|
||||
|
||||
// 恢复绘图状态
|
||||
restoreStyle();
|
||||
dirty = false; // 标记不需要重绘
|
||||
}
|
||||
}
|
||||
|
||||
bool Table::handleEvent(const ExMessage& msg)
|
||||
{
|
||||
if(!show)return false;
|
||||
bool consume = false;
|
||||
if(!this->isShowPageButton)
|
||||
return consume;
|
||||
else
|
||||
{
|
||||
if(prevButton)consume = prevButton->handleEvent(msg);
|
||||
if (nextButton&&!consume)
|
||||
consume = nextButton->handleEvent(msg);
|
||||
}
|
||||
if (dirty)
|
||||
requestRepaint(parent);
|
||||
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;
|
||||
if (this->prevButton && this->nextButton && this->pageNum)
|
||||
{
|
||||
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->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;
|
||||
}
|
||||
|
||||
|
||||
-161
@@ -1,161 +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)
|
||||
{
|
||||
this->id = "TextBox";
|
||||
}
|
||||
|
||||
void TextBox::draw()
|
||||
{
|
||||
if (dirty && show)
|
||||
{
|
||||
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()));
|
||||
|
||||
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(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)
|
||||
requestRepaint(parent);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void TextBox::setText(std::string text)
|
||||
{
|
||||
if(text.size() > maxCharLen)
|
||||
text = text.substr(0, maxCharLen);
|
||||
this->text = text;
|
||||
this->dirty = true;
|
||||
draw();
|
||||
}
|
||||
|
||||
std::string TextBox::getText() const
|
||||
{
|
||||
return this->text;
|
||||
}
|
||||
|
||||
|
||||
-355
@@ -1,355 +0,0 @@
|
||||
#include "Window.h"
|
||||
#include "Dialog.h"
|
||||
#include <windows.h> // 确保包含 Windows API 头文件
|
||||
|
||||
Window::Window(int width, int height, int mode)
|
||||
{
|
||||
|
||||
this->pendingW = this->width = width;
|
||||
this->pendingH = this->height = height;
|
||||
this->windowMode = mode;
|
||||
}
|
||||
|
||||
Window::Window(int width, int height, int mode, COLORREF bkcloc)
|
||||
{
|
||||
this->pendingW = this->width = width;
|
||||
this->pendingH = this->height = height;
|
||||
this->windowMode = mode;
|
||||
this->wBkcolor = bkcloc;
|
||||
}
|
||||
|
||||
Window::Window(int width, int height, int mode, COLORREF bkcloc, std::string headline)
|
||||
{
|
||||
this->pendingW = this->width = width;
|
||||
this->pendingH = this->height = height;
|
||||
this->windowMode = mode;
|
||||
this->wBkcolor = bkcloc;
|
||||
this->headline = headline;
|
||||
}
|
||||
|
||||
Window::~Window()
|
||||
{
|
||||
if (background)
|
||||
delete background;
|
||||
background = nullptr;
|
||||
closegraph(); // 确保关闭图形上下文
|
||||
}
|
||||
|
||||
|
||||
void Window::draw() {
|
||||
// 使用 EasyX 创建基本窗口
|
||||
if (!hWnd)
|
||||
hWnd = initgraph(width, height, windowMode);
|
||||
// **启用窗口拉伸支持**:添加厚边框和最大化按钮样式
|
||||
LONG style = GetWindowLong(hWnd, GWL_STYLE);
|
||||
style |= WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX; // 可调整边框,启用最大化/最小化按钮
|
||||
SetWindowLong(hWnd, GWL_STYLE, style);
|
||||
// 通知窗口样式变化生效
|
||||
SetWindowPos(hWnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
|
||||
// 设置背景色并清屏
|
||||
setbkcolor(wBkcolor);
|
||||
cleardevice();
|
||||
// 初次绘制所有控件(双缓冲)
|
||||
BeginBatchDraw();
|
||||
for (auto& control : controls)
|
||||
{
|
||||
control->draw();
|
||||
}
|
||||
// (如果有初始对话框,也可绘制 dialogs)
|
||||
EndBatchDraw();
|
||||
}
|
||||
|
||||
void Window::draw(std::string imagePath)
|
||||
{
|
||||
// 使用指定图片绘制窗口背景(铺满窗口)
|
||||
this->background = new IMAGE(width, height);
|
||||
bkImageFile = imagePath;
|
||||
if (!hWnd)
|
||||
hWnd = initgraph(width, height, windowMode);
|
||||
SetWindowText(hWnd, headline.c_str());
|
||||
loadimage(background, imagePath.c_str(), width, height, true);
|
||||
if(background)
|
||||
putimage(0, 0, background);
|
||||
else
|
||||
{
|
||||
// 设置背景色并清屏
|
||||
setbkcolor(wBkcolor);
|
||||
cleardevice();
|
||||
}
|
||||
// 同样应用可拉伸样式
|
||||
LONG style = GetWindowLong(hWnd, GWL_STYLE);
|
||||
style |= WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX;
|
||||
SetWindowLong(hWnd, GWL_STYLE, style);
|
||||
SetWindowPos(hWnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
|
||||
// 绘制控件(含对话框)到窗口
|
||||
BeginBatchDraw();
|
||||
for (auto& control : controls)
|
||||
{
|
||||
control->setDirty(true);
|
||||
control->draw();
|
||||
}
|
||||
for (auto& dlg : dialogs) dlg->draw();
|
||||
EndBatchDraw();
|
||||
}
|
||||
|
||||
// 运行主事件循环,处理用户输入和窗口消息
|
||||
// 此方法会阻塞直到窗口关闭
|
||||
// 主消息循环优先级:对话框 > 普通控件。
|
||||
// 重绘策略:为保证视觉一致性,每次有对话框状态变化(打开/关闭)时,
|
||||
// 会强制重绘所有控件。先绘制普通控件,再绘制对话框(确保对话框在最上层)。
|
||||
int 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;
|
||||
return 0;
|
||||
}
|
||||
if (msg.message == WM_SIZE)
|
||||
{
|
||||
if (msg.wParam != SIZE_MINIMIZED)
|
||||
{
|
||||
const int nw = LOWORD(msg.lParam);
|
||||
const int nh = HIWORD(msg.lParam);
|
||||
|
||||
// 仅在尺寸真的变化时标脏
|
||||
if (nw > 0 && nh > 0 || (nw != width || nh != height))
|
||||
{
|
||||
pendingW = nw;
|
||||
pendingH = nh;
|
||||
needResizeDirty = true;
|
||||
}
|
||||
}
|
||||
continue;//在末尾重绘制窗口
|
||||
}
|
||||
// 优先处理对话框事件
|
||||
for (auto it = dialogs.rbegin(); it != dialogs.rend(); ++it)
|
||||
{
|
||||
auto& d = *it;
|
||||
if (d->IsVisible() && !d->model())
|
||||
consume = d->handleEvent(msg);
|
||||
if (consume)
|
||||
break;
|
||||
}
|
||||
|
||||
//普通控件
|
||||
if (!consume)
|
||||
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
|
||||
{
|
||||
consume = (*it)->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;
|
||||
|
||||
}
|
||||
//—— 统一“收口”:尺寸变化后的** 一次性** 重绘 ——
|
||||
if (needResizeDirty)
|
||||
{
|
||||
//确保窗口不会小于初始尺寸
|
||||
if (pendingW >= width && pendingH >= height)
|
||||
Resize(nullptr, pendingW, pendingH);
|
||||
else
|
||||
Resize(nullptr, width, height);
|
||||
if (background)
|
||||
{
|
||||
delete background;
|
||||
background = new IMAGE;
|
||||
loadimage(background, bkImageFile.c_str(), pendingW, pendingH);
|
||||
putimage(0, 0, background);
|
||||
}
|
||||
|
||||
// 标记所有控件/对话框为脏,确保都补一次背景/外观
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c->onWindowResize();
|
||||
c->draw();
|
||||
}
|
||||
for (auto& d : dialogs)
|
||||
{
|
||||
auto dd = dynamic_cast<Dialog*>(d.get());
|
||||
dd->setDirty(true);
|
||||
dd->setInitialization(true);
|
||||
d->draw();
|
||||
}
|
||||
needResizeDirty = false;
|
||||
}
|
||||
|
||||
// 降低占用
|
||||
Sleep(10);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Window::setBkImage(std::string pImgFile)
|
||||
{
|
||||
if(nullptr == background)
|
||||
this->background = new IMAGE;
|
||||
else
|
||||
delete background;
|
||||
this->background = new IMAGE;
|
||||
this->bkImageFile = pImgFile;
|
||||
loadimage(background, pImgFile.c_str(), width, height, true);
|
||||
putimage(0, 0, background);
|
||||
BeginBatchDraw();
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c->setDirty(true);
|
||||
c->draw();
|
||||
}
|
||||
for (auto& c : dialogs)
|
||||
{
|
||||
c->setDirty(true);
|
||||
c->draw();
|
||||
}
|
||||
EndBatchDraw();
|
||||
|
||||
}
|
||||
|
||||
void Window::setBkcolor(COLORREF c)
|
||||
{
|
||||
wBkcolor = c;
|
||||
|
||||
setbkcolor(wBkcolor);
|
||||
cleardevice();
|
||||
// 初次绘制所有控件(双缓冲)
|
||||
BeginBatchDraw();
|
||||
for (auto& c : controls)
|
||||
{
|
||||
c->setDirty(true);
|
||||
c->draw();
|
||||
}
|
||||
for (auto& c : dialogs)
|
||||
{
|
||||
c->setDirty(true);
|
||||
c->draw();
|
||||
}
|
||||
EndBatchDraw();
|
||||
|
||||
}
|
||||
|
||||
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 std::string& message) 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 && d->GetText() == message)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
HWND Window::getHwnd() const
|
||||
{
|
||||
return hWnd;
|
||||
}
|
||||
|
||||
int Window::getWidth() const
|
||||
{
|
||||
return this->pendingW;
|
||||
}
|
||||
|
||||
int Window::getHeight() const
|
||||
{
|
||||
return this->pendingH;
|
||||
}
|
||||
|
||||
std::string Window::getHeadline() const
|
||||
{
|
||||
return this->headline;
|
||||
}
|
||||
|
||||
COLORREF Window::getBkcolor() const
|
||||
{
|
||||
return this->wBkcolor;
|
||||
}
|
||||
|
||||
IMAGE* Window::getBkImage() const
|
||||
{
|
||||
return this->background;
|
||||
}
|
||||
|
||||
std::string Window::getBkImageFile() const
|
||||
{
|
||||
return this->bkImageFile;
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,676 @@
|
||||
#include "CppUnitTest.h"
|
||||
#include "StellarX.h"
|
||||
#include "SxEventLoopInternal.h"
|
||||
#include "SxNativeInputInternal.h"
|
||||
#include "SxTextInternal.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
|
||||
|
||||
namespace
|
||||
{
|
||||
// 轻量测试控件只实现生命周期和事件所需的最小行为,避免把所有权测试绑定到真实绘图。
|
||||
class FakeControl final : public Control
|
||||
{
|
||||
public:
|
||||
FakeControl() : Control(0, 0, 10, 10) { id = "Fake"; }
|
||||
void draw() override { dirty = false; }
|
||||
bool handleEvent(const ExMessage&) override { return false; }
|
||||
bool model() const override { return false; }
|
||||
};
|
||||
|
||||
std::filesystem::path MakeTempLogPath(const char* name)
|
||||
{
|
||||
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
|
||||
return std::filesystem::temp_directory_path() /
|
||||
(std::string("stellarx-") + name + "-" + std::to_string(GetCurrentProcessId()) + ".log");
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellarXNativeTests
|
||||
{
|
||||
// 验证句柄身份和回调期间的延迟变更不会破坏容器遍历或对象生命周期。
|
||||
TEST_CLASS(OwnershipTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(HandlesAreUnique)
|
||||
{
|
||||
// 句柄必须与对象地址解耦,才能在对象被销毁后安全地表示原对象身份。
|
||||
auto first = std::make_unique<FakeControl>();
|
||||
auto second = std::make_unique<FakeControl>();
|
||||
Assert::AreNotEqual(first->getHandle().value, second->getHandle().value);
|
||||
}
|
||||
|
||||
TEST_METHOD(CallbackCanRemoveCurrentControlSafely)
|
||||
{
|
||||
// 回调删除自身后,当前事件栈仍会继续返回,但不能再次访问已释放控件。
|
||||
Window window(200, 120, 1);
|
||||
auto button = std::make_unique<Button>(10, 10, 80, 30, "remove");
|
||||
Button* raw = button.get();
|
||||
const auto handle = window.addControl(std::move(button));
|
||||
raw->setOnClickListener([&]() { Assert::IsTrue(window.removeControl(handle)); });
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
raw->handleEvent(down);
|
||||
raw->handleEvent(up);
|
||||
|
||||
Assert::IsNull(window.findControl(handle));
|
||||
Assert::AreEqual<size_t>(0, window.getControlHandles().size());
|
||||
}
|
||||
|
||||
TEST_METHOD(CallbackCanQueueAddAndClear)
|
||||
{
|
||||
// 同一回调内先添加再清空,提交顺序必须确定且不得让遍历迭代器失效。
|
||||
Window window(200, 120, 1);
|
||||
auto button = std::make_unique<Button>(10, 10, 80, 30, "mutate");
|
||||
Button* raw = button.get();
|
||||
window.addControl(std::move(button));
|
||||
raw->setOnClickListener([&]() {
|
||||
window.addControl(std::make_unique<FakeControl>());
|
||||
window.clearControls();
|
||||
});
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
raw->handleEvent(down);
|
||||
raw->handleEvent(up);
|
||||
Assert::AreEqual<size_t>(0, window.getControlHandles().size());
|
||||
}
|
||||
|
||||
TEST_METHOD(CallbackCanRemoveSiblingAndLeaveRemainingControlUsable)
|
||||
{
|
||||
// 删除兄弟控件后,未受影响的后续控件仍应能够接收下一次点击。
|
||||
Window window(200, 120, 1);
|
||||
auto remover = std::make_unique<Button>(10, 10, 80, 30, "remove sibling");
|
||||
Button* removerRaw = remover.get();
|
||||
window.addControl(std::move(remover));
|
||||
|
||||
auto sibling = std::make_unique<FakeControl>();
|
||||
const auto siblingHandle = window.addControl(std::move(sibling));
|
||||
int remainingClicks = 0;
|
||||
auto remaining = std::make_unique<Button>(100, 10, 80, 30, "remaining");
|
||||
Button* remainingRaw = remaining.get();
|
||||
remainingRaw->setOnClickListener([&]() { ++remainingClicks; });
|
||||
window.addControl(std::move(remaining));
|
||||
|
||||
removerRaw->setOnClickListener([&]() { window.removeControl(siblingHandle); });
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
removerRaw->handleEvent(down);
|
||||
removerRaw->handleEvent(up);
|
||||
Assert::IsNull(window.findControl(siblingHandle));
|
||||
|
||||
down.x = 120; up.x = 120;
|
||||
remainingRaw->handleEvent(down);
|
||||
remainingRaw->handleEvent(up);
|
||||
Assert::AreEqual(1, remainingClicks);
|
||||
}
|
||||
|
||||
TEST_METHOD(QueuedAddCanBeRemovedBeforeTransactionCommit)
|
||||
{
|
||||
// 队列中的新对象也必须拥有可撤销的句柄,提交前移除不能误伤现有对象。
|
||||
Window window(200, 120, 1);
|
||||
auto canvas = std::make_unique<Canvas>(0, 50, 100, 60);
|
||||
Canvas* canvasRaw = canvas.get();
|
||||
window.addControl(std::move(canvas));
|
||||
|
||||
auto trigger = std::make_unique<Button>(10, 10, 80, 30, "queue");
|
||||
Button* triggerRaw = trigger.get();
|
||||
window.addControl(std::move(trigger));
|
||||
triggerRaw->setOnClickListener([&]() {
|
||||
const auto windowHandle = window.addControl(std::make_unique<FakeControl>());
|
||||
Assert::IsTrue(window.removeControl(windowHandle));
|
||||
const auto canvasHandle = canvasRaw->addControl(std::make_unique<FakeControl>());
|
||||
Assert::IsTrue(canvasRaw->removeControl(canvasHandle));
|
||||
});
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
triggerRaw->handleEvent(down);
|
||||
triggerRaw->handleEvent(up);
|
||||
|
||||
Assert::AreEqual<size_t>(2, window.getControlHandles().size());
|
||||
Assert::AreEqual<size_t>(0, canvasRaw->getControlHandles().size());
|
||||
}
|
||||
|
||||
TEST_METHOD(TabAddedFromCallbackCommitsWithPendingPageControl)
|
||||
{
|
||||
// 页签及其页面控件在同一事务中加入时,页面内部的排队变更也必须一并提交。
|
||||
Window window(300, 200, 1);
|
||||
auto tabs = std::make_unique<TabControl>(0, 50, 200, 140);
|
||||
TabControl* tabsRaw = tabs.get();
|
||||
tabsRaw->addTab(std::make_unique<Button>(0, 0, 80, 24, "first"),
|
||||
std::make_unique<Canvas>(0, 0, 200, 110));
|
||||
window.addControl(std::move(tabs));
|
||||
|
||||
auto trigger = std::make_unique<Button>(10, 10, 80, 30, "add tab");
|
||||
Button* triggerRaw = trigger.get();
|
||||
window.addControl(std::move(trigger));
|
||||
triggerRaw->setOnClickListener([&]() {
|
||||
const size_t index = tabsRaw->addTab(
|
||||
std::make_unique<Button>(0, 0, 80, 24, "second"),
|
||||
std::make_unique<Canvas>(0, 0, 200, 110));
|
||||
auto rejected = tabsRaw->tryAddToPage(index, std::make_unique<FakeControl>());
|
||||
Assert::IsNull(rejected.get());
|
||||
});
|
||||
|
||||
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||
ExMessage up = down; up.message = WM_LBUTTONUP;
|
||||
triggerRaw->handleEvent(down);
|
||||
triggerRaw->handleEvent(up);
|
||||
Assert::AreEqual(2, tabsRaw->count());
|
||||
}
|
||||
};
|
||||
|
||||
// 验证 MBCS 文本所有权、空页签边界、提示延时和密码掩码等纯逻辑行为。
|
||||
TEST_CLASS(TextAndTabTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(ButtonConstructsWithChineseTextStyle)
|
||||
{
|
||||
// Button 的字体样式含有 std::string;构造中文按钮可回归成员初始化顺序错误。
|
||||
Button button(10, 10, 80, 30, "圆");
|
||||
Assert::AreEqual(std::string("圆"), button.getButtonText());
|
||||
Assert::AreEqual(std::string("微软雅黑"), button.getButtonTextStyle().faceName);
|
||||
}
|
||||
|
||||
TEST_METHOD(ControlTextOwnsAndComparesFaceName)
|
||||
{
|
||||
// 字体名应复制字符串内容,而不是保存临时缓冲区地址或比较地址。
|
||||
StellarX::ControlText first;
|
||||
first.faceName = std::string("Temporary Font");
|
||||
StellarX::ControlText second = first;
|
||||
Assert::IsTrue(first == second);
|
||||
second.faceName = "Other Font";
|
||||
Assert::IsTrue(first != second);
|
||||
Assert::AreEqual(std::string("Temporary Font"), first.faceName);
|
||||
}
|
||||
|
||||
TEST_METHOD(TabRejectsInvalidPageWithoutConsumingControl)
|
||||
{
|
||||
// 非法页索引必须原样返还控件所有权,空页签也不能越界访问。
|
||||
TabControl tabs(0, 0, 300, 200);
|
||||
Assert::IsTrue(tabs.setActiveIndex(0));
|
||||
auto rejected = tabs.tryAddToPage(0, std::make_unique<FakeControl>());
|
||||
Assert::IsNotNull(rejected.get());
|
||||
|
||||
const size_t page = tabs.addTab(
|
||||
std::make_unique<Button>(0, 0, 80, 24, "same"),
|
||||
std::make_unique<Canvas>(0, 0, 300, 176));
|
||||
Assert::AreEqual<size_t>(0, page);
|
||||
auto accepted = tabs.tryAddToPage(page, std::move(rejected));
|
||||
Assert::IsNull(accepted.get());
|
||||
}
|
||||
|
||||
TEST_METHOD(TooltipAppearsOnTickWithoutAdditionalMouseMove)
|
||||
{
|
||||
// 鼠标停留后的显示由时钟驱动,不应要求额外的 WM_MOUSEMOVE 才能到期。
|
||||
FakeControl repaintSink;
|
||||
Button button(10, 10, 80, 30, "tooltip");
|
||||
button.setParent(&repaintSink);
|
||||
button.enableTooltip(true);
|
||||
button.setTooltipDelay(1000);
|
||||
ExMessage move{}; move.message = WM_MOUSEMOVE; move.x = 20; move.y = 20;
|
||||
Assert::IsTrue(button.handleEvent(move));
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
|
||||
const ULONGLONG afterMove = GetTickCount64();
|
||||
button.tick(afterMove);
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
button.tick(afterMove + 1000);
|
||||
Assert::IsTrue(button.isTooltipVisible());
|
||||
move.x = 200; move.y = 100;
|
||||
Assert::IsFalse(button.handleEvent(move));
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
|
||||
move.x = 20; move.y = 20;
|
||||
button.handleEvent(move);
|
||||
button.tick(GetTickCount64() + 1000);
|
||||
Assert::IsTrue(button.isTooltipVisible());
|
||||
button.enableTooltip(false);
|
||||
Assert::IsFalse(button.isTooltipVisible());
|
||||
}
|
||||
|
||||
TEST_METHOD(PasswordMaskCountsMbcCharactersWithoutOverrun)
|
||||
{
|
||||
// 掩码按 MBCS 逻辑字符计数,同时覆盖不完整和非法字节序列。
|
||||
Assert::AreEqual(std::string("***"), StellarX::Internal::makePasswordMask("abc"));
|
||||
const std::string gbk = std::string("\xD6\xD0\xCE\xC4", 4) + "A";
|
||||
Assert::AreEqual(std::string("***"), StellarX::Internal::makePasswordMask(gbk));
|
||||
const std::string incomplete = std::string("A\x81", 2);
|
||||
Assert::AreEqual(std::string("**"), StellarX::Internal::makePasswordMask(incomplete));
|
||||
const std::string invalidPair = std::string("\x81\x30", 2);
|
||||
Assert::AreEqual(std::string("**"), StellarX::Internal::makePasswordMask(invalidPair));
|
||||
}
|
||||
};
|
||||
|
||||
// 验证消息批处理上限、空闲休眠策略和关闭消息的停止语义。
|
||||
TEST_CLASS(EventLoopTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(ThousandMessagesDrainInEightActiveRounds)
|
||||
{
|
||||
// 每轮最多处理固定数量消息,活动轮不休眠,1000 条消息应在 8 轮内排空。
|
||||
std::vector<ExMessage> messages(1000);
|
||||
size_t next = 0;
|
||||
size_t handled = 0;
|
||||
int rounds = 0;
|
||||
int activeSleeps = 0;
|
||||
ExMessage current{};
|
||||
while (next < messages.size())
|
||||
{
|
||||
const auto batch = StellarX::Internal::drainMessageBatch(current,
|
||||
[&](ExMessage& out) {
|
||||
if (next >= messages.size()) return false;
|
||||
out = messages[next++];
|
||||
return true;
|
||||
},
|
||||
[&](const ExMessage&) { ++handled; return true; });
|
||||
++rounds;
|
||||
if (batch.shouldSleep()) ++activeSleeps;
|
||||
}
|
||||
|
||||
Assert::AreEqual<size_t>(1000, handled);
|
||||
Assert::AreEqual(8, rounds);
|
||||
Assert::AreEqual(0, activeSleeps);
|
||||
const auto idle = StellarX::Internal::drainMessageBatch(current,
|
||||
[](ExMessage&) { return false; }, [](const ExMessage&) { return true; });
|
||||
Assert::IsTrue(idle.shouldSleep());
|
||||
}
|
||||
|
||||
TEST_METHOD(CloseStopsBatchAfterEarlierResize)
|
||||
{
|
||||
// 批次内遇到 WM_CLOSE 后停止继续取消息,之前的 WM_SIZE 仍必须保留。
|
||||
std::vector<ExMessage> messages(3);
|
||||
messages[0].message = WM_SIZE;
|
||||
messages[1].message = WM_CLOSE;
|
||||
messages[2].message = WM_MOUSEMOVE;
|
||||
size_t next = 0;
|
||||
std::vector<UINT> handled;
|
||||
ExMessage current{};
|
||||
const auto batch = StellarX::Internal::drainMessageBatch(current,
|
||||
[&](ExMessage& out) {
|
||||
if (next >= messages.size()) return false;
|
||||
out = messages[next++];
|
||||
return true;
|
||||
},
|
||||
[&](const ExMessage& message) {
|
||||
handled.push_back(message.message);
|
||||
return message.message != WM_CLOSE;
|
||||
});
|
||||
|
||||
Assert::IsTrue(batch.stopped);
|
||||
Assert::AreEqual<size_t>(2, batch.processed);
|
||||
Assert::AreEqual<size_t>(2, handled.size());
|
||||
Assert::AreEqual<UINT>(WM_SIZE, handled[0]);
|
||||
Assert::AreEqual<UINT>(WM_CLOSE, handled[1]);
|
||||
Assert::AreEqual<size_t>(2, next);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1 only validates capture-time snapshots, normalization, queue ordering,
|
||||
// and fail-stop. No test in this class performs focus or control routing.
|
||||
TEST_CLASS(NativeInputTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(KeyLParamAndModifierSnapshotArePreserved)
|
||||
{
|
||||
StellarX::Internal::KeyModifierSnapshot modifiers;
|
||||
modifiers.leftControl = true;
|
||||
modifiers.rightAlt = true;
|
||||
const LPARAM lParam = static_cast<LPARAM>(3u | (0x1Du << 16) |
|
||||
(1u << 24) | (1u << 29) | (1u << 30));
|
||||
const auto event = StellarX::Internal::makeKeyEvent(
|
||||
WM_SYSKEYDOWN, VK_CONTROL, lParam, modifiers, true);
|
||||
|
||||
Assert::IsTrue(event.action == StellarX::KeyAction::Down);
|
||||
Assert::AreEqual<UINT>(VK_CONTROL, event.virtualKey);
|
||||
Assert::AreEqual<UINT>(VK_RCONTROL, event.physicalVirtualKey);
|
||||
Assert::AreEqual<UINT>(0x1D, event.scanCode);
|
||||
Assert::AreEqual<std::uint16_t>(3, event.repeatCount);
|
||||
Assert::IsTrue(event.extended);
|
||||
Assert::IsTrue(event.wasDown);
|
||||
Assert::IsTrue(event.altContext);
|
||||
Assert::IsTrue(event.systemKey);
|
||||
Assert::IsTrue(event.imeComposingAtCapture);
|
||||
Assert::IsTrue(event.modifiers.leftControl);
|
||||
Assert::IsTrue(event.modifiers.rightAlt);
|
||||
Assert::IsTrue(event.isAutoRepeat());
|
||||
}
|
||||
|
||||
TEST_METHOD(ProcessKeyOwnsOnlyItsKeyAndDoesNotStartComposition)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.collect(nullptr, WM_KEYDOWN, VK_PROCESSKEY, 1);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* key = std::get_if<StellarX::KeyEvent>(&event.payload);
|
||||
Assert::IsNotNull(key);
|
||||
Assert::AreEqual<UINT>(VK_PROCESSKEY, key->virtualKey);
|
||||
Assert::IsFalse(key->imeComposingAtCapture);
|
||||
Assert::IsTrue(key->isImeOwnedAtCapture());
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
key = std::get_if<StellarX::KeyEvent>(&event.payload);
|
||||
Assert::IsNotNull(key);
|
||||
Assert::IsFalse(key->imeComposingAtCapture);
|
||||
Assert::IsFalse(key->isImeOwnedAtCapture());
|
||||
}
|
||||
|
||||
TEST_METHOD(CompositionLifecycleIsCapturedIndependentlyFromKeyOwnership)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.collect(nullptr, WM_IME_STARTCOMPOSITION, 0, 0);
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
context.collect(nullptr, WM_IME_ENDCOMPOSITION, 0, 0);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* start = std::get_if<StellarX::CompositionEvent>(&event.payload);
|
||||
Assert::IsNotNull(start);
|
||||
Assert::IsTrue(start->stage == StellarX::CompositionStage::Start);
|
||||
Assert::AreNotEqual<std::uint64_t>(0, start->compositionId);
|
||||
const std::uint64_t compositionId = start->compositionId;
|
||||
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* key = std::get_if<StellarX::KeyEvent>(&event.payload);
|
||||
Assert::IsNotNull(key);
|
||||
Assert::IsTrue(key->imeComposingAtCapture);
|
||||
Assert::IsFalse(key->isImeOwnedAtCapture());
|
||||
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* cancel = std::get_if<StellarX::CompositionEvent>(&event.payload);
|
||||
Assert::IsNotNull(cancel);
|
||||
Assert::IsTrue(cancel->stage == StellarX::CompositionStage::Cancel);
|
||||
Assert::AreEqual(compositionId, cancel->compositionId);
|
||||
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* end = std::get_if<StellarX::CompositionEvent>(&event.payload);
|
||||
Assert::IsNotNull(end);
|
||||
Assert::IsTrue(end->stage == StellarX::CompositionStage::End);
|
||||
Assert::AreEqual(compositionId, end->compositionId);
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(AnsiDbcsLeadAndTrailBecomeOneUtf16Event)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
context.collect(nullptr, WM_CHAR, 0xD6, 1);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
|
||||
context.collect(nullptr, WM_CHAR, 0xD0, 1);
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual<std::size_t>(1, text->text.size());
|
||||
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0x4E2D), text->text[0]);
|
||||
Assert::IsTrue(text->source == StellarX::TextSource::AnsiCharFallback);
|
||||
Assert::AreEqual<std::uint64_t>(0, text->compositionId);
|
||||
}
|
||||
|
||||
TEST_METHOD(UnicodeScalarProducesSurrogatePairAndConsumesMessage)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
const auto query = context.collect(nullptr, WM_UNICHAR, UNICODE_NOCHAR, 0);
|
||||
Assert::IsTrue(query.handledSynchronously);
|
||||
Assert::AreEqual<LRESULT>(TRUE, query.result);
|
||||
|
||||
const auto actual = context.collect(nullptr, WM_UNICHAR, 0x1F600, 0);
|
||||
Assert::IsTrue(actual.handledSynchronously);
|
||||
Assert::AreEqual<LRESULT>(0, actual.result);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual<std::size_t>(2, text->text.size());
|
||||
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0xD83D), text->text[0]);
|
||||
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0xDE00), text->text[1]);
|
||||
Assert::IsTrue(text->source == StellarX::TextSource::UnicodeScalar);
|
||||
}
|
||||
|
||||
TEST_METHOD(InvalidUnicodeScalarIsRejectedAndCounted)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
const auto result = context.collect(nullptr, WM_UNICHAR, 0x110000, 0);
|
||||
Assert::IsFalse(result.handledSynchronously);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
Assert::AreEqual<std::uint64_t>(1,
|
||||
context.normalizationStats().rejectedTextSequences);
|
||||
}
|
||||
|
||||
TEST_METHOD(ImeResultEchoChannelsAreSuppressedWithoutContentWideDedup)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
const std::wstring committed(1, static_cast<wchar_t>(0x4E2D));
|
||||
context.seedEchoForTesting(committed, 7);
|
||||
|
||||
context.collect(nullptr, WM_IME_CHAR, 0xD6D0, 0);
|
||||
context.collect(nullptr, WM_CHAR, 0xD6, 0);
|
||||
context.collect(nullptr, WM_CHAR, 0xD0, 0);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
|
||||
// A new physical key is an echo boundary. Equal later content is real input.
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
context.collect(nullptr, WM_CHAR, 0xD6, 0);
|
||||
context.collect(nullptr, WM_CHAR, 0xD0, 0);
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
Assert::IsNotNull(std::get_if<StellarX::KeyEvent>(&event.payload));
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual(committed, text->text);
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(ImeEndDoesNotDiscardImmediatelyFollowingResultEcho)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
const std::wstring committed(1, static_cast<wchar_t>(0x4E2D));
|
||||
context.seedEchoForTesting(committed, 9);
|
||||
|
||||
context.collect(nullptr, WM_IME_ENDCOMPOSITION, 0, 0);
|
||||
context.collect(nullptr, WM_IME_CHAR, 0xD6D0, 0);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(UnrepresentableAnsiEchoDoesNotSuppressFollowingText)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.setAnsiCodePageForTesting(936);
|
||||
std::wstring committed;
|
||||
committed.push_back(static_cast<wchar_t>(0xD83D));
|
||||
committed.push_back(static_cast<wchar_t>(0xDE00));
|
||||
|
||||
BOOL usedDefault = FALSE;
|
||||
const int required = WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS,
|
||||
committed.data(), static_cast<int>(committed.size()), nullptr, 0,
|
||||
nullptr, &usedDefault);
|
||||
Assert::IsTrue(required > 0);
|
||||
Assert::IsTrue(usedDefault != FALSE);
|
||||
std::string replacement(static_cast<std::size_t>(required), '\0');
|
||||
usedDefault = FALSE;
|
||||
Assert::AreEqual(required, WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS,
|
||||
committed.data(), static_cast<int>(committed.size()), replacement.data(),
|
||||
required, nullptr, &usedDefault));
|
||||
|
||||
context.seedEchoForTesting(committed, 11);
|
||||
for (const unsigned char byte : replacement)
|
||||
context.collect(nullptr, WM_CHAR, byte, 0);
|
||||
context.collect(nullptr, WM_CHAR, 'A', 0);
|
||||
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsTrue(context.tryPopForTesting(event));
|
||||
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
|
||||
Assert::IsNotNull(text);
|
||||
Assert::AreEqual(std::wstring(L"A"), text->text);
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(QueuePreservesStrictOrderAcrossProducerAndConsumerThreads)
|
||||
{
|
||||
constexpr std::uint64_t eventCount = 20000;
|
||||
StellarX::Internal::NativeInputQueue queue;
|
||||
std::atomic<bool> producerDone{ false };
|
||||
std::atomic<bool> failed{ false };
|
||||
|
||||
std::thread producer([&]() {
|
||||
for (std::uint64_t sequence = 1; sequence <= eventCount; ++sequence)
|
||||
{
|
||||
StellarX::NativeInputEvent event;
|
||||
event.sequence = sequence;
|
||||
event.payload = StellarX::KeyEvent{};
|
||||
if (queue.push(std::move(event)) !=
|
||||
StellarX::Internal::NativeInputQueuePushResult::Queued)
|
||||
{
|
||||
failed.store(true, std::memory_order_release);
|
||||
break;
|
||||
}
|
||||
}
|
||||
producerDone.store(true, std::memory_order_release);
|
||||
});
|
||||
|
||||
std::uint64_t expected = 1;
|
||||
while (!producerDone.load(std::memory_order_acquire) || expected <= eventCount)
|
||||
{
|
||||
StellarX::NativeInputEvent event;
|
||||
const auto result = queue.tryPop(event);
|
||||
if (result == StellarX::Internal::NativeInputQueuePopResult::Popped)
|
||||
{
|
||||
if (event.sequence != expected)
|
||||
failed.store(true, std::memory_order_release);
|
||||
++expected;
|
||||
}
|
||||
else if (result == StellarX::Internal::NativeInputQueuePopResult::UnexpectedFailure)
|
||||
{
|
||||
failed.store(true, std::memory_order_release);
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (producerDone.load(std::memory_order_acquire) &&
|
||||
(expected > eventCount || failed.load(std::memory_order_acquire)))
|
||||
break;
|
||||
std::this_thread::yield();
|
||||
}
|
||||
}
|
||||
producer.join();
|
||||
|
||||
Assert::IsFalse(failed.load(std::memory_order_acquire));
|
||||
Assert::AreEqual(eventCount + 1, expected);
|
||||
const auto stats = queue.stats();
|
||||
Assert::AreEqual(eventCount, stats.pushed);
|
||||
Assert::AreEqual(eventCount, stats.popped);
|
||||
Assert::AreEqual<std::size_t>(0, stats.currentDepth);
|
||||
Assert::IsTrue(stats.maximumDepth > 0);
|
||||
}
|
||||
|
||||
TEST_METHOD(AllocationFailureBecomesObservableFailStop)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.forceNextQueuePushResultForTesting(
|
||||
StellarX::Internal::NativeInputQueuePushResult::AllocationFailure);
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
|
||||
Assert::IsTrue(context.hasFault());
|
||||
const auto fault = context.fault();
|
||||
Assert::IsTrue(fault.code == StellarX::Internal::NativeInputFaultCode::OutOfMemory);
|
||||
Assert::AreEqual<UINT>(WM_KEYDOWN, fault.message);
|
||||
StellarX::NativeInputEvent event;
|
||||
Assert::IsFalse(context.tryPopForTesting(event));
|
||||
}
|
||||
|
||||
TEST_METHOD(ObservationDrainIsStrictAndLeavesNoBacklog)
|
||||
{
|
||||
StellarX::Internal::NativeInputContext context(nullptr);
|
||||
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
|
||||
context.collect(nullptr, WM_KEYUP, 'A', static_cast<LPARAM>(1u << 31));
|
||||
context.collect(nullptr, WM_CHAR, 'a', 1);
|
||||
Assert::AreEqual<std::size_t>(3, context.drainObserved(16));
|
||||
|
||||
const auto observations = context.observationStats();
|
||||
Assert::AreEqual<std::uint64_t>(3, observations.observed);
|
||||
Assert::AreEqual<std::uint64_t>(2, observations.keyEvents);
|
||||
Assert::AreEqual<std::uint64_t>(1, observations.textEvents);
|
||||
Assert::IsTrue(observations.sequenceStrict);
|
||||
Assert::AreEqual<std::size_t>(0, context.queueStats().currentDepth);
|
||||
Assert::IsFalse(context.hasFault());
|
||||
}
|
||||
};
|
||||
|
||||
// 验证文件 sink 的原子切换、失败回退和轮换过程不会丢失日志行。
|
||||
TEST_CLASS(LoggingTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(SetConfigOpensAndDisablesFileAtomically)
|
||||
{
|
||||
// 无效的新文件配置不能覆盖当前可用配置,也不能关闭旧 sink。
|
||||
const auto path = MakeTempLogPath("config");
|
||||
std::filesystem::remove(path);
|
||||
auto& logger = StellarX::SxLogger::Get();
|
||||
StellarX::SxLogConfig config;
|
||||
config.fileEnabled = true;
|
||||
config.filePath = path.string();
|
||||
config.fileAppend = false;
|
||||
Assert::IsTrue(logger.setConfig(config));
|
||||
logger.logLine(StellarX::SxLogLevel::Info, "test", __FILE__, __LINE__, __func__, "configured");
|
||||
logger.disableFile();
|
||||
Assert::IsTrue(std::filesystem::exists(path));
|
||||
|
||||
const auto before = logger.getConfigCopy();
|
||||
config.fileEnabled = true;
|
||||
config.filePath.clear();
|
||||
Assert::IsFalse(logger.setConfig(config));
|
||||
Assert::AreEqual(before.filePath, logger.getConfigCopy().filePath);
|
||||
std::filesystem::remove(path);
|
||||
}
|
||||
|
||||
TEST_METHOD(RotationPreservesEveryLine)
|
||||
{
|
||||
// 高频轮换可能发生在同一毫秒内,所有分片和当前文件合计必须保留每一行。
|
||||
const auto path = MakeTempLogPath("rotate");
|
||||
const auto directory = path.parent_path();
|
||||
const auto prefix = path.filename().string() + ".";
|
||||
std::filesystem::remove(path);
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||
if (entry.path().filename().string().rfind(prefix, 0) == 0) std::filesystem::remove(entry.path());
|
||||
|
||||
StellarX::FileSink sink;
|
||||
sink.setRotateBytes(32);
|
||||
Assert::IsTrue(sink.open(path.string(), false));
|
||||
for (int i = 0; i < 30; ++i) sink.writeLine("line-" + std::to_string(i) + "\n");
|
||||
sink.close();
|
||||
|
||||
int lineCount = 0;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||
{
|
||||
const auto name = entry.path().filename().string();
|
||||
if (entry.path() != path && name.rfind(prefix, 0) != 0) continue;
|
||||
{
|
||||
std::ifstream input(entry.path());
|
||||
std::string line;
|
||||
while (std::getline(input, line)) ++lineCount;
|
||||
}
|
||||
std::filesystem::remove(entry.path());
|
||||
}
|
||||
Assert::AreEqual(30, lineCount);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#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 == "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` 后缀,入库时补充序号。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user