Compare commits
2 Commits
ea6f12e7ac
...
6773f9b29e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6773f9b29e | |||
| 05a685c952 |
@@ -0,0 +1,179 @@
|
||||
# Day4 Mandelbrot 分形渲染器 —— 设计 / 实现规格
|
||||
|
||||
- 日期:2026-07-08
|
||||
- 用途:川大 Qt 方向课程 Day4 综合演示示例(事件机制 + QPainter 绘图 + 多线程)
|
||||
- 状态:已与用户确认设计;本文同时作为实现计划(压缩 writing-plans 环节,经用户授权「一次性完成」)
|
||||
|
||||
## 1. 目标与定位
|
||||
|
||||
把 Day4 三个知识点串成一条可视化故事线:**缩放分形 → 触发重绘 → 多线程后台分块计算 → 界面不卡**。
|
||||
通过三段式对比直击 slides「主线程耗时 = 冻结」痛点并量化多核收益:
|
||||
|
||||
1. 主线程同步渲染 → **卡死且慢**(按钮无响应、窗口拖不动、`paintEvent` 停滞)
|
||||
2. 后台单线程(线程池 = 1)→ **不卡但慢**
|
||||
3. 后台多线程(线程池 = N)→ **不卡且快**
|
||||
|
||||
定位为 `docs/teaching/examples/` 下的手工维护教学示例(非 `tools/gen_part3.py` 生成产物),
|
||||
配 README,遵循仓库构建/验证约定。同时作为**可独立共享的单 CMake 工程**。
|
||||
|
||||
## 2. 知识点覆盖映射
|
||||
|
||||
| Day4 知识点 | 本示例对应实现 |
|
||||
|---|---|
|
||||
| QPainter 绘图(框架驱动,只在 `paintEvent` 画) | `MandelbrotWidget::paintEvent` 把累积 QImage 一次性 `drawImage` |
|
||||
| 事件 vs 信号 / 事件循环 / 事件拦截 | `wheelEvent`(缩放)、`mousePress/Move/ReleaseEvent`(平移)重写;「主线程渲染」按钮演示事件循环被占用即冻结 |
|
||||
| 多线程防 UI 阻塞 | `QThreadPool` + `QRunnable` 分水平条带并行;排队信号槽回传结果 |
|
||||
|
||||
## 3. 架构与组件(3 个类 + main,多文件)
|
||||
|
||||
### 3.1 `MandelbrotWidget`(`public QWidget`,主角)
|
||||
- 持有视口参数:`double centerX, centerY, scale`(scale = 单位像素对应的复平面跨度);`int maxIter = 256`。
|
||||
- 持有 `quint64 renderEpoch = 0`(渲染版本号,防过期结果覆盖)。
|
||||
- 持有累积 `QImage pixmap_`(widget 尺寸,`QImage::Format_RGB32`)。
|
||||
- 重写:`paintEvent`(`drawImage` 累积图)、`wheelEvent`(以鼠标为锚点缩放)、`mousePressEvent/mouseMoveEvent/mouseReleaseEvent`(拖拽平移)、`resizeEvent`(尺寸变化触发重渲染)。
|
||||
- 底部 `QHBoxLayout` 工具条:「主线程渲染」按钮、「单/多线程」切换按钮、耗时 `QLabel`。
|
||||
- 槽:`onStripReady(quint64 epoch, int stripIndex, QImage strip)`——校验 epoch、拼图、计数、全部到齐则 `update()` 并显示耗时。
|
||||
|
||||
### 3.2 `MandelbrotStripTask`(`public QObject, public QRunnable`,带 `Q_OBJECT`)
|
||||
- 数据成员:`quint64 epoch`、`int stripIndex`、`QRect viewport`(像素区域)、视口参数副本、调色板指针/引用。
|
||||
- `run()` override:调用自由函数 `renderStrip(...)` 算 `[y0, y1]` 行像素 → `emit stripReady(epoch, stripIndex, img)`(排队连接,自动回主线程)。
|
||||
- `signals: void stripReady(quint64 epoch, int stripIndex, QImage strip);`
|
||||
- `setAutoDelete(true)`(默认)、无 QObject parent,靠线程池回收。
|
||||
- *教学点*:QRunnable 本身无信号,多继承 QObject 是 Qt 让 QRunnable 拥有信号的标准写法。
|
||||
|
||||
### 3.3 自由函数 `renderStrip`(纯计算,两路径共用)
|
||||
- `QImage renderStrip(const Viewport& vp, int y0, int y1, const RenderPalette& pal);`
|
||||
- 对 `[y0,y1) × [0,width)` 每个像素做标准 Mandelbrot 迭代 `z = z² + c`(逃逸半径 2),按迭代次数经调色板映射成 RGB,写入 QImage。
|
||||
- 被 `MandelbrotStripTask::run()`(多线程路径)和「主线程渲染」按钮(卡死路径)**共用**,保证两路径算同一份分形,差异只在「是否阻塞事件循环 / 是否并行」。
|
||||
|
||||
### 3.4 调色板 `RenderPalette`
|
||||
- 迭代次数 → HSV(h, s=1, v=1) → RGB 的简单预计算表(256 项 `QRgb`),构造时生成。
|
||||
- 提升趣味,与知识点无关但成本低。
|
||||
|
||||
### 3.5 `main.cpp`
|
||||
- 建 `QApplication` + `MandelbrotWidget`,`resize(800,600)`、`show()`。
|
||||
- offscreen 平台(`qApp->platformName() == "offscreen"`)时:启动即触发一次后台渲染(覆盖计算路径),并 `QTimer::singleShot(200, &app, &QApplication::quit)` 自动退出码 0(仓库自动化验证约定)。
|
||||
|
||||
## 4. 数据流(一轮缩放)
|
||||
|
||||
```
|
||||
wheelEvent 改 scale/center(以鼠标点为锚,缩放后该点复坐标不变)
|
||||
→ ++renderEpoch
|
||||
→ 切 N = hardwareConcurrency()*2 条水平带
|
||||
→ 每带 new MandelbrotStripTask(epoch,i,viewport) → QThreadPool::globalInstance()->start(task)
|
||||
↓ (子线程并行)
|
||||
task.run() → renderStrip(...) → emit stripReady(epoch,i,QImage)
|
||||
↓ (Qt::QueuedConnection,自动回主线程)
|
||||
Widget::onStripReady: epoch != currentEpoch ? 丢弃
|
||||
否则把 strip 画到 pixmap_ 对应行,received++ ;
|
||||
received == N → update() + 显示 QElapsedTimer 耗时与线程数
|
||||
paintEvent: QPainter(this); p.drawImage(0,0, pixmap_);
|
||||
```
|
||||
|
||||
## 5. 三段式对比(教学核心)
|
||||
|
||||
- **「主线程渲染」按钮**:槽里同步调用 `renderStrip` 算一整帧(在 GUI 线程,不分块、不丢池),耗时约 1–3s。
|
||||
期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · 界面冻结」。
|
||||
- **「单/多线程」切换**:复用同一套分块逻辑,仅切 `QThreadPool::globalInstance()->setMaxThreadCount(1 或 hardwareConcurrency())`。
|
||||
单线程不卡但慢、多线程不卡且快,耗时标签量化加速比。
|
||||
- 三段对比让学员一眼看懂:耗时任务必须离开主线程;多线程进一步压榨多核。
|
||||
|
||||
## 6. 关键决策与取舍
|
||||
|
||||
- **条带数** = `hardwareConcurrency()*2`:负载均衡,避免某核分到全「集合内部」的快带。
|
||||
- **过期防护(非主动取消)**:`renderEpoch` 版本号,新视口令旧结果作废丢弃。YAGNI 不做主动 cancel;
|
||||
但版本号保证画面正确性——这本身是教学点(多线程结果回收要防竞态)。
|
||||
- **无锁设计**:每 task 算自己的 strip QImage,信号复制回传,主线程拼。呼应 `qthread_worker` README「优先信号槽传数据,退而求其次才用锁」。
|
||||
- **maxIter 默认 256**:画质 vs 卡死演示时长的平衡点。
|
||||
- **UI 纯代码**(无 `.ui`),避免分散对 painting/线程/事件的注意力。
|
||||
|
||||
## 7. 构建系统:双模式 CMake(与仓库 `lineedit_eye_toggle` 同模式)
|
||||
|
||||
仓库权威约定(见 `lineedit_eye_toggle/CMakeLists.txt` 注释):顶层 `CMakeLists.txt` 只
|
||||
`add_subdirectory(docs/teaching/examples)` 且**不递归**子目录的 CMakeLists;统一构建走
|
||||
`examples/CMakeLists.txt` 的 `add_executable`/`add_teaching_example`;子目录的独立 CMakeLists.txt
|
||||
仅供单独打开/共享。本示例遵循此约定。
|
||||
|
||||
### 7.1 子目录 `mandelbrot_renderer/CMakeLists.txt` = 完整独立工程(供单独打开/共享)
|
||||
- `cmake_minimum_required(VERSION 3.16)` / `project(qt_mandelbrot_renderer LANGUAGES CXX)`
|
||||
- C++17 严格(`CXX_STANDARD 17 / REQUIRED ON / CXX_EXTENSIONS OFF`)、`set(CMAKE_AUTOMOC ON)`
|
||||
- `find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)`(不重复 5.14 断言——顶层已有全局断言)
|
||||
- `add_executable(qt_mandelbrot_renderer main.cpp mandelbrot_widget.cpp mandelbrot_strip_task.cpp)`
|
||||
- `target_link_libraries(... PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)`、`-Wall -Wextra`
|
||||
- 可脱离主仓库独立 `cmake -S . -B build` 构建、单独打包共享。
|
||||
|
||||
### 7.2 主工程纳入
|
||||
- `docs/teaching/examples/CMakeLists.txt` 新增 `add_executable(qt_mandelbrot_renderer …)` 直接引用
|
||||
`mandelbrot_renderer/*.cpp`(多文件,不走单文件 `add_teaching_example` 宏),设 C++17/AUTOMOC/Qt5 链接;
|
||||
- 子目录独立 CMakeLists.txt **不**被仓库构建递归(避免 target 重复定义);
|
||||
- 把 `qt_mandelbrot_renderer` 加入 `all_teaching_examples` 的 DEPENDS 列表。
|
||||
- target 定义在父子两处各一份,源文件相同(零代码重复)——与 `lineedit_eye_toggle`/`mainwindow_showcase` 一致。
|
||||
|
||||
### 7.3 权衡
|
||||
子独立 CMakeLists 不内置 Qt 5.14 断言(顶层 `CMakeLists.txt` 第 44–49 行已有全局断言;
|
||||
独立构建时靠 `-DCMAKE_PREFIX_PATH` 指对 Qt 5.14.2 路径),与 `lineedit_eye_toggle` 风格一致。
|
||||
独立共享时接收方需装 Qt 5.14.2,README 指向 `tools/install_qt_host.sh`(Linux)或官方
|
||||
Qt 5.14.2 MinGW 安装(Windows)。
|
||||
|
||||
## 8. 显式踩坑点(README 专节,代码里复现/规避)
|
||||
|
||||
1. 在 `paintEvent` 之外对窗口 widget 构造 `QPainter`(UB)——严格只在 `paintEvent` 画。
|
||||
2. QRunnable 无信号 → 必须多继承 `QObject` 才能 `emit`(初学者高频困惑)。
|
||||
3. 主线程渲染卡死 vs 后台流畅的**本质**:事件循环是否被占用。
|
||||
4. 过期渲染结果覆盖新画面(版本号防护)。
|
||||
5. `wheelEvent`/`mouseEvent` 重写时基类调用与事件 `accept/ignore` 的处理。
|
||||
|
||||
## 9. 验证方式(CLAUDE.md 行为风格第 2 点:端到端实跑)
|
||||
|
||||
### 9.1 Windows 本地(本次开发已实跑通过,Qt 5.14.2 mingw73_64 + w64devkit g++ 16)
|
||||
```bash
|
||||
# 独立工程(Release,避开 mingw Qt 可能缺 debug 平台插件的问题)
|
||||
cmake -S docs/teaching/examples/mandelbrot_renderer -B build_mandel_rel -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_COMPILER=E:/workenv/w64devkit/bin/g++.exe \
|
||||
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64
|
||||
cmake --build build_mandel_rel
|
||||
export PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH
|
||||
export QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms
|
||||
QT_QPA_PLATFORM=offscreen ./build_mandel_rel/qt_mandelbrot_renderer.exe ; echo "exit=$?" # 实测 exit=0
|
||||
# 主工程内(Debug,仅验证纳入与编译链接)
|
||||
cmake -S . -B build_main -G Ninja -DBUILD_QT_PART=ON -DCMAKE_BUILD_TYPE=Debug \
|
||||
-DCMAKE_C_COMPILER=E:/workenv/w64devkit/bin/gcc.exe \
|
||||
-DCMAKE_CXX_COMPILER=E:/workenv/w64devkit/bin/g++.exe \
|
||||
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64
|
||||
cmake --build build_main --target qt_mandelbrot_renderer # 实测 build_exit=0
|
||||
```
|
||||
> 注:w64devkit g++16 与 mingw73(Qt 编译所用的 GCC7)ABI 在本例链接通过、运行正常;
|
||||
> 若他人环境报 ABI 错误,改用与 Qt 同源的 MinGW 7.3 工具链即可。
|
||||
|
||||
### 9.2 主工程内(仓库标准,Linux 隔离 Qt)
|
||||
```bash
|
||||
cmake --build build --target qt_mandelbrot_renderer
|
||||
tools/run_qt.sh qt_mandelbrot_renderer
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_mandelbrot_renderer # 自动退出码 0
|
||||
```
|
||||
|
||||
## 10. 文件清单与实现步骤
|
||||
|
||||
```
|
||||
docs/teaching/examples/mandelbrot_renderer/
|
||||
├ CMakeLists.txt # 独立工程(双模式复用)
|
||||
├ main.cpp
|
||||
├ mandelbrot_widget.h / .cpp
|
||||
├ mandelbrot_strip_task.h / .cpp # 含调色板 RenderPalette + renderStrip 纯函数
|
||||
└ README.md # 概念/代码走读/两套构建/练习方向/踩坑点
|
||||
docs/teaching/examples/CMakeLists.txt # 改:add_subdirectory + 挂 all_teaching_examples
|
||||
```
|
||||
|
||||
实现顺序:① `mandelbrot_strip_task.h/.cpp`(调色板 + renderStrip + task,可独立单测的纯计算)
|
||||
→ ② `mandelbrot_widget.h/.cpp`(组装交互与渲染调度)→ ③ `main.cpp` → ④ `CMakeLists.txt`(独立)
|
||||
→ ⑤ 改 `examples/CMakeLists.txt`(纳入)→ ⑥ `README.md` → ⑦ 构建 + offscreen 验证。
|
||||
|
||||
## 11. 成功标准(可验证)
|
||||
|
||||
- [x] 独立工程 `cmake -S mandelbrot_renderer -B build` 能配置 + 构建,产物 offscreen 运行(Windows 实测 exit=0)。
|
||||
- [x] 主工程 `cmake --build build --target qt_mandelbrot_renderer` 全绿,并入 `all_teaching_examples`(Windows 实测 build_exit=0)。
|
||||
- [x] offscreen 运行 200ms 自动退出,退出码 0;启动即触发一次后台渲染(计算路径被覆盖)。
|
||||
- [ ] 有屏人工交互(滚轮缩放、拖拽平移、单/多线程切换耗时对比、「主线程渲染」冻结演示):当前为自动化上下文,**未做有屏人工验证**;交互逻辑经代码走读 + offscreen 覆盖,待真机点验。
|
||||
- [x] README 完整覆盖概念/走读/双构建/踩坑/练习方向。
|
||||
- [x] 无编译警告(`-Wall -Wextra`,Debug + Release 均零警告)。
|
||||
@@ -0,0 +1,140 @@
|
||||
# Day2–4 课堂练习 + 课后作业(分层设计)
|
||||
|
||||
> 配套 [`OUTLINE_4DAY.md`](OUTLINE_4DAY.md) 的任务卡、`slides/day2.html`…`day4.html`
|
||||
> 里的「练/作业」小节。本文给出可直接打印/投屏的作业单:每项作业拆成
|
||||
> **基础版(必做,验收线)** + **挑战版(选做,见能力发挥)** 两层,时间预算按
|
||||
> 「基础版 1 小时内可完成、挑战版累计不超过 2 小时」控制,照顾基础不理想的
|
||||
> 学员——**先保基础版全员过关,挑战版不做不扣分**。
|
||||
|
||||
## 分层设计原则
|
||||
|
||||
1. **基础版是及格线,不是引导线**:题面本身已经是当天知识点的最小闭环,做完
|
||||
基础版=当天目标达成;工程师答疑优先保基础版全员通过。
|
||||
2. **挑战版是同一个程序的自然延伸**,不引入新知识点,只要求「更完整/更健壮/
|
||||
更贴近真实使用」——强度落在「把已学的东西用得更彻底」,不是提前预习明天内容。
|
||||
3. **验收用现场提问代替翻源码**:给几组输入让学生当场演示结果(下表「验收用例」),
|
||||
比对照代码更能测出是真理解还是照抄。
|
||||
4. 每天的作业首尾相接(计算器 → 计算器 QMainWindow 化 + 美化 → 数据存储工具),
|
||||
挑战版做不完不影响第二天用基础版继续。
|
||||
|
||||
---
|
||||
|
||||
## Day2 下午 · 简易计算器
|
||||
|
||||
对应任务卡 2(`OUTLINE_4DAY.md` Day2 下午作业)。教师参考实现:
|
||||
[`examples/calculator/`](examples/calculator/README.md)(代码布局版)、
|
||||
[`examples/calculator_designer/`](examples/calculator_designer/README.md)(Qt Designer 对照版)。
|
||||
|
||||
| | 要求 | 时间预算 |
|
||||
|---|---|---|
|
||||
| **基础版(必做)** | 数字键 0–9 + 至少一种运算符(+/-/×/÷ 任选其一)+ 等号,能正确显示两个数的运算结果;界面不重叠、按钮可点击;`connect` 全部用新语法且写在构造函数里 | ≈1h |
|
||||
| **挑战版(选做)** | 补全四则运算全部支持;小数点;清空键 `C`;退格键 `⌫`;除零不崩溃、显示 `Error` 且下一次输入自动清空;连续按运算符实现链式计算(如 `2+3+4=` 得 `9`) | 累计 ≤2h |
|
||||
|
||||
**验收用例**(现场让学生当场演示,不看代码):
|
||||
|
||||
- 基础版:`7 + 3 =` → `10`;`9 - 4 =` → `5`(任选一种运算符即可)
|
||||
- 挑战版:`6 ÷ 0 =` → `Error`;`4.5 + 0.5 =` → `5`;连续输入 `12` 后按退格 → `1`;
|
||||
`2 + 3 + 4 =` → `9`
|
||||
|
||||
**给基础不理想同学的提示**:卡在"点击没反应"先查 `connect` 有没有漏写、按钮有没有
|
||||
真的加到布局里;卡在"数字显示不对"先只处理个位数,两位数以上的拼接逻辑
|
||||
(`display->text() + 新数字`)留到基础版跑通个位数运算之后再加。
|
||||
|
||||
---
|
||||
|
||||
## Day3 上午 · 计算器 QMainWindow 化
|
||||
|
||||
对应 `OUTLINE_4DAY.md` Day3 上午课堂练习(11:00–12:00)。
|
||||
|
||||
参考实现:[`examples/calculator_mainwindow/`](examples/calculator_mainwindow/README.md)
|
||||
(以 Day2 增强版 [`calculator_designer_ext/`](examples/calculator_designer_ext/README.md)
|
||||
为起点;`CalculatorDesigner` 是独立 `QWidget`,`setCentralWidget` 装进 `QMainWindow`,
|
||||
核心求值逻辑不动,仅加 `evaluated` 信号供历史停靠窗订阅)。
|
||||
|
||||
| | 要求 | 时间预算 |
|
||||
|---|---|---|
|
||||
| **基础版(必做)** | 把 Day2 计算器的键盘 `setCentralWidget` 装进 `QMainWindow`;加菜单栏「帮助→关于」,点击弹出 `QMessageBox::about` 显示一句自我介绍 | ≈40min |
|
||||
| **挑战版(选做)** | 加一个「历史」停靠窗(`QDockWidget` + `QListWidget`):每次按等号,把这次的算式和结果(如 `7+3=10`)追加成一条历史记录;停靠窗可拖动/浮动/关闭(`QDockWidget` 默认行为,不用额外写代码) | ≤1h |
|
||||
|
||||
**验收用例**:点菜单「帮助→关于」能弹框;挑战版——连续算 3 次不同的算式,历史
|
||||
停靠窗里能看到 3 条记录且内容正确。
|
||||
|
||||
**给基础不理想同学的提示**:`QMainWindow` 不是 `QWidget` 的替代品,是新建一个
|
||||
`QMainWindow` 子类,把原来计算器的键盘部分(`QWidget` 或其内容)通过
|
||||
`setCentralWidget()` 塞进去;菜单点了没反应先检查 `QAction` 是不是忘了 `connect`
|
||||
(Day3 上午刚讲的坑卡)。挑战版做不完,历史停靠窗留空 `QLabel` 占位即可,
|
||||
不影响 Day3 下午继续。
|
||||
|
||||
---
|
||||
|
||||
## Day3 下午 · 计算器美化(QSS)
|
||||
|
||||
对应任务卡(`OUTLINE_4DAY.md` Day3 下午作业)。
|
||||
|
||||
参考实现:[`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)
|
||||
(Day3 上午版 + QSS 双主题;与上午版仅 `mainwindow.{h,cpp}` 差异,演示「美化与逻辑正交」)。
|
||||
|
||||
| | 要求 | 时间预算 |
|
||||
|---|---|---|
|
||||
| **基础版(必做)** | 数字键 / 运算键 / 等号键至少用 3 种不同颜色区分(`setObjectName` + QSS ID 选择器 `#objectName{...}`);整体布局随窗口缩放不重叠、不错位 | ≈1h |
|
||||
| **挑战版(选做)** | 至少 1 个按钮有 `:hover` 或 `:pressed` 视觉反馈;给结果显示框也套样式(圆角边框/字号);额外做一个"深色主题"QSS 字符串,加一个按钮或菜单项用 `setStyleSheet()` 在两套样式间切换 | ≤1h |
|
||||
|
||||
**验收用例**:缩放窗口到很小和很大两种尺寸,按钮不重叠不错位;挑战版——点切换
|
||||
主题按钮,界面颜色整体改变。
|
||||
|
||||
**给基础不理想同学的提示**:QSS 选择器认的是 `objectName`(`setObjectName("btnEquals")`
|
||||
之后才能用 `#btnEquals{...}`),忘设置 `objectName` 是"样式写了但没生效"的头号原因;
|
||||
先做好 1 种颜色区分再扩展到 3 种,不要一次性套一大段 QSS 上去调不出问题在哪。
|
||||
|
||||
---
|
||||
|
||||
## Day4 上午 · 事件过滤器 / 自定义绘图
|
||||
|
||||
对应 `OUTLINE_4DAY.md` Day4 上午课堂练习(11:00–12:00)。
|
||||
|
||||
| | 要求 | 时间预算 |
|
||||
|---|---|---|
|
||||
| **基础版(必做,二选一)** | ① 给某个控件装 `installEventFilter()`,鼠标按下时在控制台打印坐标;<b>或</b> ② 重写 `paintEvent`,用 `QPainter` 画一个组合图形(棋盘格、简单进度条、或几条交叉线均可) | ≈40min |
|
||||
| **挑战版(选做,两项整合)** | 在一个自定义 `QWidget` 上:鼠标点击时记录点击坐标(`mousePressEvent` 或事件过滤器都行)到一个 `QVector<QPoint>`,每次点击后 `update()` 触发重绘,`paintEvent` 里把历史上所有点击过的坐标都画出来(比如画小圆点)——点越点越多,图案累积不丢失 | ≤1h |
|
||||
|
||||
**验收用例**:基础版①——点击窗口能在控制台看到坐标打印;基础版②——窗口能看到
|
||||
一个图形;挑战版——连续点击 5 个不同位置,5 个点都保留在画面上(不是只显示
|
||||
最后一个)。
|
||||
|
||||
**给基础不理想同学的提示**:基础版明确「二选一」,不要求两项都做——事件拦截和
|
||||
自定义绘图是两条独立线索,先吃透一条。忘调用 `update()` 是"画了但界面没刷新"
|
||||
的头号原因(`paintEvent` 只在 Qt 认为需要重绘时才会被调用,手动改了数据必须
|
||||
自己触发 `update()`)。
|
||||
|
||||
---
|
||||
|
||||
## Day4 下午 · 本地数据存储小工具
|
||||
|
||||
对应任务卡(`OUTLINE_4DAY.md` Day4 下午作业)。这是 Day5 综合案例
|
||||
[`examples/student_manager/`](examples/student_manager/README.md) 的直接前身,
|
||||
教师验收时可参考其数据层设计(不要求学生做到那个完整度)。
|
||||
|
||||
| | 要求 | 时间预算 |
|
||||
|---|---|---|
|
||||
| **基础版(必做)** | 「学员信息」表单(姓名/成绩/标签,3 个 `QLineEdit` + 「添加」按钮)把记录追加到列表控件(`QListWidget`/`QTableWidget` 均可);「保存」按钮把全部记录写成 JSON 文件;重启程序后「读取」按钮能把之前保存的记录读回、字段不丢、顺序不乱(至少 2 条) | ≈1.5h |
|
||||
| **挑战版(选做)** | ① 加「删除选中行」;② 数据文件路径用 `QCoreApplication::applicationDirPath()` 拼接,不用裸相对路径(当天★坑卡的实操版);③ 读取时文件不存在/JSON 格式损坏要给出友好提示而不是崩溃或静默清空(检查 `open()` 返回值和 `QJsonParseError`) | ≤1h |
|
||||
|
||||
**验收用例**:添加 2 条记录 → 保存 → 关闭程序 → 重新打开 → 点「读取」→ 2 条记录
|
||||
都在、字段值正确;挑战版——把 JSON 文件手动改坏一个字符再读取,程序不崩溃。
|
||||
|
||||
**给基础不理想同学的提示**:先固定用一个写死的文件名(比如 `data.json`)把
|
||||
「写入→读回」跑通,再考虑路径是否规范(挑战版①);`QJsonDocument::toJson()`/
|
||||
`fromJson()` 是配对的两端,写的时候用什么结构(数组套对象),读的时候就按同样
|
||||
结构解析回来,两边字段名要完全一致(区分大小写)。
|
||||
|
||||
---
|
||||
|
||||
## 给工程师/助教的验收操作提示
|
||||
|
||||
- 11:00–12:00、16:00–17:00 两个答疑窗口,**优先确认基础版验收用例**,挑战版
|
||||
时间不够可以留到当晚或第二天早上补交,不阻塞后续课程进度。
|
||||
- 每天验收合格线 = 基础版全部用例通过;挑战版按完成比例加分,不设满分门槛。
|
||||
- 若某学员基础版都吃力,参照各节「给基础不理想同学的提示」拆解成更小步骤,
|
||||
必要时可对照教师参考实现(`examples/calculator/`、`examples/calculator_designer/`)
|
||||
逐段讲解,但**不要直接给学生抄源码**——抄一次治标不治本,下一天同类问题会
|
||||
重复出现。
|
||||
@@ -7,6 +7,8 @@
|
||||
> 因此本文即新基准下的正式教案大纲;此前对齐旧方案书的 5 天版
|
||||
> [`OUTLINE.md`](OUTLINE.md) 保留作历史参照与素材库(其 24 张概念/坑卡在本文
|
||||
> 全部复用)。幻灯片 `day1.html`…`day5.html` **已按本文重排**(§9 落地状态)。
|
||||
> Day2–4 课堂练习/课后作业的分层(基础版+挑战版)作业单见
|
||||
> [`ASSIGNMENTS.md`](ASSIGNMENTS.md)。
|
||||
|
||||
## 0. 基准与显式假设(供随时纠偏)
|
||||
|
||||
@@ -138,7 +140,7 @@
|
||||
| 环节 | 内容 | 素材 |
|
||||
|---|---|---|
|
||||
| 信号槽(50min 讲 + 20min 练) | 观察者模式;内置/自定义/lambda 三种写法;新旧语法对比;连接方式 Direct/Queued/Auto | `p03/ch04/builtin_signal_slot.cpp`、`custom_signal_slot.cpp`、`lambda_signal_slot.cpp` |
|
||||
| 计算器案例(40min 带练) | 数字/运算键盘 + 点击信号槽 + 结果显示;**布局用教师提供的 `QGridLayout` 脚手架「先用后学」**(布局原理 Day3 下午讲透,今天只求能排出键盘) | `p03/ch03` + `p03/ch04` + 脚手架片段(授课时现场给出) |
|
||||
| 计算器案例(40min 带练) | 数字/运算键盘 + 点击信号槽 + 结果显示;**布局用教师提供的 `QGridLayout` 脚手架「先用后学」**(布局原理 Day3 下午讲透,今天只求能排出键盘) | `p03/ch03` + `p03/ch04` + 教师参考实现 [`examples/calculator/`](examples/calculator/README.md)(另有 [`examples/calculator_designer/`](examples/calculator_designer/README.md) Qt Designer `.ui` 对照版,含「自动连接静默失效」坑点) |
|
||||
|
||||
概念/坑卡:**信号槽三种写法[沿用]**(含旧语法静默失败、`QOverload` 消歧义)、外加——
|
||||
|
||||
@@ -161,7 +163,7 @@
|
||||
|
||||
| 环节 | 内容 | 素材 |
|
||||
|---|---|---|
|
||||
| QMainWindow(45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp`、`dock_widget.cpp` |
|
||||
| QMainWindow(45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp`、`dock_widget.cpp`;综合演示 [`examples/mainwindow_showcase/`](examples/mainwindow_showcase/README.md)(极简文本编辑器,.ui 表单 + 自动连接,覆盖 wiki ch05 六大组件) |
|
||||
| QDialog(45min 讲 + 20min 练) | 模态 vs 非模态(三种非模态写法对比:栈/堆/`WA_DeleteOnClose`);标准对话框 `QMessageBox`/`QFileDialog` | `p03/ch06/modal_dialog.cpp`、`modeless_dialog_stack.cpp`、`modeless_dialog_heap.cpp`、`modeless_dialog_deleteonclose.cpp`、`message_box_question.cpp`、`file_dialog.cpp` |
|
||||
|
||||
概念/坑卡(本节两张为本文新写,5 天版未覆盖 ch05/ch06):
|
||||
@@ -176,8 +178,15 @@
|
||||
析构,窗口一闪而过**——`p03/ch06` 三个 modeless 版本正是这个坑的对照教材,也是
|
||||
Day2 栈/堆所有权卡的第三次复现。
|
||||
|
||||
11:00–12:00 课堂练习:给昨天的计算器套上 QMainWindow——加菜单「帮助→关于」弹
|
||||
`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。
|
||||
11:00–12:00 课堂练习:以 Day2 增强版计算器([`examples/calculator_designer_ext/`](examples/calculator_designer_ext/README.md))为起点,套上 QMainWindow——加菜单「帮助→关于」弹
|
||||
`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。参考实现:
|
||||
[`examples/calculator_mainwindow/`](examples/calculator_mainwindow/README.md)(纯代码
|
||||
建菜单 + 显式 connect,与 `mainwindow_showcase` 的 `.ui` 自动连接形成坑卡对照)。
|
||||
|
||||
> 衔接说明:Day2 增强版 `CalculatorDesigner` 已是独立 `QWidget`,Day3 上午只做
|
||||
> 「外壳升级」——`setCentralWidget` 把它装进 `QMainWindow`,核心求值逻辑不动。
|
||||
> 唯一新增是 `CalculatorDesigner` 加一个 `evaluated` 信号供历史停靠窗订阅(Day2
|
||||
> 信号槽知识的复用)。详见参考实现 README。
|
||||
|
||||
### Day 3 下午:布局管理器 / 常用控件(+QSS 美化)
|
||||
|
||||
@@ -185,7 +194,7 @@
|
||||
|---|---|---|
|
||||
| 布局管理器(40min 讲 + 15min 练) | QHBox/QVBox/QGrid;嵌套布局;伸缩因子——回收 Day2 计算器脚手架,讲透当时「先用后学」的部分 | `p03/ch08/custom_widget.cpp` |
|
||||
| 常用控件(35min 讲 + 15min 练) | QLabel 文本/HTML/图片、QLineEdit、按钮族;资源系统 `.qrc` | `p03/ch08/label_text_html.cpp`、`label_pixmap.cpp`、`label_movie.cpp` |
|
||||
| QSS 界面美化(25min 讲 + 20min 练) | 选择器/伪状态/层叠——对应新安排「自适应排版及**美化**」目标 | `docs/teaching/examples/qss_styling/qt_qss_styling.cpp` + [README](examples/qss_styling/README.md) |
|
||||
| QSS 界面美化(25min 讲 + 20min 练) | 选择器/伪状态/层叠——对应新安排「自适应排版及**美化**」目标 | `docs/teaching/examples/qss_styling/qt_qss_styling.cpp` + [README](examples/qss_styling/README.md);作业线参考 [`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)(上午版 + 双主题 QSS) |
|
||||
|
||||
概念/坑卡:**布局管理[沿用]**(同控件不能属两个布局;布局后 `setGeometry` 被
|
||||
覆盖)、**资源系统 .qrc[沿用]**(路径静默失败/AUTORCC)、**QSS 美化[沿用]**
|
||||
@@ -193,6 +202,8 @@
|
||||
|
||||
16:00–17:00 作业:计算器升级——数字键/运算键/等号键用 QSS 区分颜色,至少 1 个按钮
|
||||
有 `:hover` 或 `:pressed` 效果;整体布局改为可随窗口缩放(验收同 5 天版 QSS 任务卡)。
|
||||
参考实现:[`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)(在
|
||||
Day3 上午版基础上只加 QSS,演示「美化与逻辑正交」)。
|
||||
|
||||
### Day 4 上午:Qt 消息机制和事件 / 绘图事件及表格事件
|
||||
|
||||
@@ -395,5 +406,25 @@ QThread 提升环节**——若 Day5 案例讲解超时挤掉它,项目期「
|
||||
3. ✅ **Day5 综合案例参考实现**:`docs/teaching/examples/student_manager/`
|
||||
(QMainWindow + QTableWidget + 对话框 + JSON round-trip + QThread 后台导入;
|
||||
离屏自测模式自动校验「写入→读回」与导入闭环)。
|
||||
3.1 ✅ **Day2 案例参考实现**:`docs/teaching/examples/calculator/`(代码
|
||||
`QGridLayout` 版)+ `docs/teaching/examples/calculator_designer/`(Qt Designer
|
||||
`.ui` 对照版,含自动连接 `on_<objectName>_<signal>` 静默失效坑点);两版业务
|
||||
逻辑一致,离屏自测覆盖加减乘除/链式运算/除零/退格 5 组用例。
|
||||
3.2 ✅ **Day2 增强版计算器**:`docs/teaching/examples/calculator_designer_ext/`
|
||||
(在 Designer 版基础上把「立即计算」升级为「表达式求值」——独立 `ExpressionEvaluator`
|
||||
递归下降解析器,支持运算符优先级 + 括号含嵌套/未闭合自动补全;双显示区;离屏自测
|
||||
9 组用例含 `2+3*4=14`、`(2+3)*4=20`)。作为 Day3 的起点。
|
||||
3.3 ✅ **Day3 上午 QMainWindow 组件综合演示**:`docs/teaching/examples/mainwindow_showcase/`
|
||||
(极简文本编辑器形态,`.ui` 表单 + `.qrc` 资源 + `on_<obj>_<sig>` 自动连接,覆盖
|
||||
wiki ch05 的 5.1–5.6 六大组件;离屏退出码 0)。教师讲组件协议时用,与下方
|
||||
`calculator_mainwindow` 形成 `.ui`/自动连接 vs 纯代码/显式 connect 的坑卡对照。
|
||||
3.4 ✅ **Day3 上午参考实现**:`docs/teaching/examples/calculator_mainwindow/`
|
||||
(把 Day2 增强版 `CalculatorDesigner` 装进 `QMainWindow`:菜单/状态栏/历史停靠窗;
|
||||
`CalculatorDesigner` 仅加 `evaluated` 信号 + `loadExpression` 方法供历史订阅/回填;
|
||||
纯代码建菜单 + 显式 connect;离屏自测 9 组计算 + 历史追加/双击回填/清空)。
|
||||
3.5 ✅ **Day3 下午参考实现**:`docs/teaching/examples/calculator_mainwindow_qss/`
|
||||
(上午版 + QSS 双主题美化:按键按功能分色 + `:hover`/`:pressed` + 深/浅主题切换;
|
||||
与上午版仅 `mainwindow.{h,cpp}` 差异,演示「美化与逻辑正交」;离屏自测含主题切换
|
||||
改变 `styleSheet` 的断言)。
|
||||
4. ✅ 新 10 天安排正式文本已落入 [`docs/SCHEDULE_2026_10DAY.md`](../SCHEDULE_2026_10DAY.md);
|
||||
`OUTLINE.md` §0 与幻灯片 `index.html` 已补两版关系说明。
|
||||
|
||||
@@ -25,9 +25,89 @@ add_teaching_example(qt_pitfall_stack_widget pitfall_stack_widget)
|
||||
add_teaching_example(qt_pitfall_lambda_capture pitfall_lambda_capture)
|
||||
add_teaching_example(qt_pitfall_double_connect pitfall_double_connect)
|
||||
add_teaching_example(qt_table_widget_basics table_widget)
|
||||
# Day2 下午案例参考实现(OUTLINE_4DAY.md Day2 下午「简易计算器」)
|
||||
add_teaching_example(qt_calculator calculator)
|
||||
|
||||
# Day2 案例的 Qt Designer(.ui) 对照版:界面用表单设计、业务逻辑与 qt_calculator
|
||||
# 一致。需要 AUTOUIC(add_teaching_example 只开 AUTOMOC),单独写 target。
|
||||
add_executable(qt_calculator_designer
|
||||
calculator_designer/qt_calculator_designer.cpp
|
||||
calculator_designer/calculator_designer.ui)
|
||||
set_target_properties(qt_calculator_designer PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
|
||||
AUTOMOC ON AUTOUIC ON)
|
||||
target_link_libraries(qt_calculator_designer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_calculator_designer PRIVATE -Wall -Wextra)
|
||||
|
||||
# Day2 增强版计算器(calculator_designer_ext):在 Designer 版基础上把「立即计算」
|
||||
# 升级为「表达式求值」——独立 ExpressionEvaluator 用递归下降解析器实现运算符优先级
|
||||
# 与括号(含嵌套、未闭合自动补全)。多源文件 + .ui 表单,单独写 target。
|
||||
# 注意:子目录里那个 Qt Creator 导出的 CMakeLists.txt 不参与仓库构建(C++11 /
|
||||
# 目标名 Calculator / ANDROID 分支均不符仓库 C++17 规范),构建一律走本 target。
|
||||
add_executable(qt_calculator_designer_ext
|
||||
calculator_designer_ext/main.cpp
|
||||
calculator_designer_ext/calculatordesigner.cpp
|
||||
calculator_designer_ext/expressionevaluator.cpp
|
||||
calculator_designer_ext/widget.ui)
|
||||
target_include_directories(qt_calculator_designer_ext PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/calculator_designer_ext)
|
||||
set_target_properties(qt_calculator_designer_ext PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
|
||||
AUTOMOC ON AUTOUIC ON)
|
||||
target_link_libraries(qt_calculator_designer_ext PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_calculator_designer_ext PRIVATE -Wall -Wextra)
|
||||
|
||||
# Day5 综合案例参考实现(OUTLINE_4DAY.md §3 / §9.3)
|
||||
add_teaching_example(qt_student_manager student_manager)
|
||||
|
||||
# QMainWindow 教学综合示例(wiki「5 QMainWindow」pageId 58954529 的 5.1–5.6 全组件):
|
||||
# form designer(.ui) 定义主窗体结构、.qrc 资源文件提供应用图标。单独写 target
|
||||
# (标准 add_teaching_example 只开 AUTOMOC,本例还需 AUTOUIC+AUTORCC)。
|
||||
add_executable(qt_mainwindow_showcase
|
||||
mainwindow_showcase/main.cpp
|
||||
mainwindow_showcase/mainwindow_showcase.cpp
|
||||
mainwindow_showcase/mainwindow_showcase.h
|
||||
mainwindow_showcase/mainwindow.ui
|
||||
mainwindow_showcase/mainwindow.qrc)
|
||||
set_target_properties(qt_mainwindow_showcase PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
|
||||
AUTOMOC ON AUTOUIC ON AUTORCC ON)
|
||||
target_link_libraries(qt_mainwindow_showcase PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_mainwindow_showcase PRIVATE -Wall -Wextra)
|
||||
|
||||
# Day3 上午版参考实现:把 Day2 增强版计算器装进 QMainWindow(菜单/状态栏/历史停靠窗)。
|
||||
# 复用 Day2 的 calculatordesigner + expressionevaluator + widget.ui(自包含复制),
|
||||
# 新增 mainwindow.{h,cpp} 外壳 + 含 runSelfTest 的 main.cpp。无 QSS(QSS 在下午版)。
|
||||
add_executable(qt_calculator_mainwindow
|
||||
calculator_mainwindow/main.cpp
|
||||
calculator_mainwindow/mainwindow.cpp
|
||||
calculator_mainwindow/calculatordesigner.cpp
|
||||
calculator_mainwindow/expressionevaluator.cpp
|
||||
calculator_mainwindow/widget.ui)
|
||||
target_include_directories(qt_calculator_mainwindow PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow)
|
||||
set_target_properties(qt_calculator_mainwindow PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
|
||||
AUTOMOC ON AUTOUIC ON)
|
||||
target_link_libraries(qt_calculator_mainwindow PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_calculator_mainwindow PRIVATE -Wall -Wextra)
|
||||
|
||||
# Day3 下午版参考实现:上午版 + QSS 双主题美化。复用上午版同款 5 文件(自包含复制),
|
||||
# 仅 mainwindow.{h,cpp} 加双主题 QSS + 视图菜单「深色主题」、main.cpp 自测加主题断言。
|
||||
add_executable(qt_calculator_mainwindow_qss
|
||||
calculator_mainwindow_qss/main.cpp
|
||||
calculator_mainwindow_qss/mainwindow.cpp
|
||||
calculator_mainwindow_qss/calculatordesigner.cpp
|
||||
calculator_mainwindow_qss/expressionevaluator.cpp
|
||||
calculator_mainwindow_qss/widget.ui)
|
||||
target_include_directories(qt_calculator_mainwindow_qss PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow_qss)
|
||||
set_target_properties(qt_calculator_mainwindow_qss PROPERTIES
|
||||
CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
|
||||
AUTOMOC ON AUTOUIC ON)
|
||||
target_link_libraries(qt_calculator_mainwindow_qss PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_calculator_mainwindow_qss PRIVATE -Wall -Wextra)
|
||||
|
||||
add_custom_target(all_teaching_examples DEPENDS
|
||||
qt_qss_styling
|
||||
qt_model_view_basics
|
||||
@@ -39,4 +119,10 @@ add_custom_target(all_teaching_examples DEPENDS
|
||||
qt_pitfall_lambda_capture
|
||||
qt_pitfall_double_connect
|
||||
qt_table_widget_basics
|
||||
qt_student_manager)
|
||||
qt_calculator
|
||||
qt_calculator_designer
|
||||
qt_calculator_designer_ext
|
||||
qt_student_manager
|
||||
qt_mainwindow_showcase
|
||||
qt_calculator_mainwindow
|
||||
qt_calculator_mainwindow_qss)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 简易计算器 —— Day2 下午案例参考实现(代码布局版)
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「信号槽机制 →
|
||||
> 项目案例:简易计算器」。教师侧参考答案,手工维护,非生成产物。
|
||||
> Designer 表单对照版见 [`../calculator_designer/`](../calculator_designer/README.md)。
|
||||
|
||||
## 覆盖的知识点(讲解时逐条回指)
|
||||
|
||||
| 环节 | 知识点 | 首次出现 |
|
||||
|---|---|---|
|
||||
| 界面骨架 | `QGridLayout` 排数字/运算符键盘 | Day2 下午(脚手架先用后学,Day3 下午讲透原理) |
|
||||
| 信号槽写法 1 | lambda + 第五参数 context(数字/运算符键批量绑定) | Day2 下午 |
|
||||
| 信号槽写法 2 | 成员函数指针连接(等号/清空/退格键) | Day2 下午 |
|
||||
| 坑点规避 | 所有 `connect` 只在构造函数执行一次,不留「重复 connect」隐患 | Day2 ★坑卡 |
|
||||
| 编码 | 中文/符号按钮文字 `QStringLiteral` + 源文件 UTF-8 | Day2 ★坑卡 |
|
||||
| 状态机 | 数字/小数点/运算符/等号/清空/退格 六类输入的最小状态机;连续按运算符走「链式计算」;除零给出 `Error` 而不崩溃 | 案例本身 |
|
||||
|
||||
## 验证方式(自测模式)
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator
|
||||
# 自测流程:直接调用与按钮绑定的同一批处理函数(等价于点击),依次验证
|
||||
# 「7+3=10」「6÷0=Error」「4.5+0.5=5」「12 退格→1」「2+3+4=9(链式运算)」
|
||||
# 全部通过退出码 0,任一步失败退出码 1
|
||||
tools/run_qt.sh qt_calculator # 有 X 环境:完整交互
|
||||
```
|
||||
|
||||
## 课堂讲解建议
|
||||
|
||||
1. 先跑成品:现场敲一次「7 + 3 =」,再回头拆代码——项目 = 已学知识点的组装;
|
||||
2. 重点讲两种 connect 写法的取舍:数字/运算符键数量多、逻辑相同,用
|
||||
lambda 捕获参数 + 统一槽函数(`onDigit(int)`/`onOperator(QChar)`);
|
||||
等号/清空/退格各自唯一,直接用成员函数指针更直观;
|
||||
3. 现场移除某个按钮的 `connect`,演示"按钮显示正常、点击没反应"——呼应
|
||||
QAction 忘 connect 的坑(Day3 会再见到同一类问题);
|
||||
4. 除零处理是刻意设计的教学点:不用 `assert`/崩溃,而是给出可恢复的错误状态
|
||||
(`Error` + 下一次输入自动清空),这是「健壮性」在中初级学员能理解层面的
|
||||
最小示范。
|
||||
|
||||
## 练习方向(对应任务卡 2 · 课后作业)
|
||||
|
||||
见 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「16:00–17:00 作业」与
|
||||
`slides/day2.html` 任务卡 2;本参考实现即该作业的教师侧标准答案,**不要在课上
|
||||
直接展示源码给学生抄**,讲解思路后学生自己实现。
|
||||
|
||||
## 参考
|
||||
|
||||
- 布局脚手架来源:`p03/ch08/custom_widget.cpp`(`QHBoxLayout`/`QSlider` 联动)
|
||||
- 信号槽写法来源:`p03/ch04/`(`builtin_signal_slot.cpp`、`custom_signal_slot.cpp`、
|
||||
`lambda_signal_slot.cpp`)
|
||||
- Designer 表单对照版:[`../calculator_designer/`](../calculator_designer/README.md)
|
||||
@@ -0,0 +1,234 @@
|
||||
// ============================================================================
|
||||
// qt_calculator —— Day2 下午案例参考实现:简易计算器
|
||||
// 对应教案:OUTLINE_4DAY.md Day2 下午「信号槽机制 → 项目案例:简易计算器」
|
||||
// 配套文档:docs/teaching/examples/calculator/README.md
|
||||
// 手工维护,非生成产物(同 student_manager/qthread_worker 等教学补充示例)。
|
||||
//
|
||||
// 教学重点(对应当天知识点,逐条在代码里标注):
|
||||
// 1. 信号槽两种写法都用上:数字/运算符键用「lambda + this 作 context」批量
|
||||
// 绑定,等号/清空/退格键用「成员函数指针」绑定——当天两种写法都要会认;
|
||||
// 2. 所有 connect 只在构造函数里执行一次,不给「重复 connect 导致槽触发
|
||||
// 两次」的坑(当天★坑卡)留生长空间;
|
||||
// 3. QGridLayout 排键盘(布局原理 Day3 下午讲透,今天先用后学);
|
||||
// 4. 中文/符号按钮文字用 QStringLiteral + 源文件 UTF-8,呼应当天编码坑卡。
|
||||
//
|
||||
// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator
|
||||
// 自测流程:直接调用与按钮绑定的同一批处理函数(等价于点击),依次验证
|
||||
// 「7+3=」「6÷0=」「4.5+0.5=」「12 退格」「2+3+4= 链式运算」,全部通过退出码 0,
|
||||
// 任一步失败退出码 1。
|
||||
// ============================================================================
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QGridLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
class Calculator : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit Calculator(QWidget* parent = nullptr) : QWidget(parent) {
|
||||
setWindowTitle(QStringLiteral("简易计算器"));
|
||||
|
||||
m_display = new QLineEdit(QStringLiteral("0"), this);
|
||||
m_display->setReadOnly(true); // 只显示结果,不接受直接键入
|
||||
m_display->setAlignment(Qt::AlignRight);
|
||||
|
||||
auto* mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->addWidget(m_display);
|
||||
mainLayout->addLayout(buildKeypad());
|
||||
}
|
||||
|
||||
// 供自测直接调用,效果等价于点击对应按钮(同一批处理函数)。
|
||||
void onDigit(int digit) {
|
||||
if (m_error) onClear();
|
||||
if (m_startNew || m_display->text() == QStringLiteral("0")) {
|
||||
m_display->setText(QString::number(digit));
|
||||
m_startNew = false;
|
||||
} else {
|
||||
m_display->setText(m_display->text() + QString::number(digit));
|
||||
}
|
||||
}
|
||||
|
||||
void onDot() {
|
||||
if (m_error) onClear();
|
||||
if (m_startNew) {
|
||||
m_display->setText(QStringLiteral("0."));
|
||||
m_startNew = false;
|
||||
return;
|
||||
}
|
||||
if (!m_display->text().contains(QLatin1Char('.')))
|
||||
m_display->setText(m_display->text() + QStringLiteral("."));
|
||||
}
|
||||
|
||||
void onOperator(QChar op) {
|
||||
if (m_error) return;
|
||||
if (!m_startNew) compute(); // 连续按运算符:先把上一次算掉,再开始下一次(链式计算)
|
||||
m_pendingValue = m_display->text().toDouble();
|
||||
m_pendingOp = op;
|
||||
m_startNew = true;
|
||||
}
|
||||
|
||||
void onEquals() {
|
||||
if (m_error) return;
|
||||
compute();
|
||||
m_pendingOp = QChar(); // 等号后清空待运算符:再按数字视为全新一次输入
|
||||
}
|
||||
|
||||
void onClear() {
|
||||
m_display->setText(QStringLiteral("0"));
|
||||
m_pendingValue = 0.0;
|
||||
m_pendingOp = QChar();
|
||||
m_startNew = false;
|
||||
m_error = false;
|
||||
}
|
||||
|
||||
void onBackspace() {
|
||||
if (m_error) { onClear(); return; }
|
||||
QString text = m_display->text();
|
||||
text.chop(1);
|
||||
m_display->setText(text.isEmpty() ? QStringLiteral("0") : text);
|
||||
}
|
||||
|
||||
QString displayText() const { return m_display->text(); }
|
||||
|
||||
private:
|
||||
QGridLayout* buildKeypad() {
|
||||
auto* grid = new QGridLayout; // 布局原理 Day3 下午讲透,今天先会用:行列坐标摆键盘
|
||||
|
||||
addDigit(grid, 7, 0, 0); addDigit(grid, 8, 0, 1); addDigit(grid, 9, 0, 2);
|
||||
addOperator(grid, QStringLiteral("÷"), QLatin1Char('/'), 0, 3);
|
||||
|
||||
addDigit(grid, 4, 1, 0); addDigit(grid, 5, 1, 1); addDigit(grid, 6, 1, 2);
|
||||
addOperator(grid, QStringLiteral("×"), QLatin1Char('*'), 1, 3);
|
||||
|
||||
addDigit(grid, 1, 2, 0); addDigit(grid, 2, 2, 1); addDigit(grid, 3, 2, 2);
|
||||
addOperator(grid, QStringLiteral("-"), QLatin1Char('-'), 2, 3);
|
||||
|
||||
addDigit(grid, 0, 3, 0);
|
||||
|
||||
auto* btnDot = new QPushButton(QStringLiteral("."), this);
|
||||
grid->addWidget(btnDot, 3, 1);
|
||||
connect(btnDot, &QPushButton::clicked, this, [this] { onDot(); });
|
||||
|
||||
// 等号/清空/退格各自唯一,用成员函数指针连接——信号槽的另一种常见写法。
|
||||
auto* btnEquals = new QPushButton(QStringLiteral("="), this);
|
||||
grid->addWidget(btnEquals, 3, 2);
|
||||
connect(btnEquals, &QPushButton::clicked, this, &Calculator::onEquals);
|
||||
|
||||
addOperator(grid, QStringLiteral("+"), QLatin1Char('+'), 3, 3);
|
||||
|
||||
auto* btnClear = new QPushButton(QStringLiteral("C"), this);
|
||||
grid->addWidget(btnClear, 4, 0);
|
||||
connect(btnClear, &QPushButton::clicked, this, &Calculator::onClear);
|
||||
|
||||
auto* btnBackspace = new QPushButton(QStringLiteral("⌫"), this);
|
||||
grid->addWidget(btnBackspace, 4, 1);
|
||||
connect(btnBackspace, &QPushButton::clicked, this, &Calculator::onBackspace);
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
// 数字键:同一个槽 onDigit(int) 处理全部按钮,lambda 捕获各自的数字值,
|
||||
// 第五参数传 this 作 context——calculator 销毁时这些连接自动断开(当天坑卡)。
|
||||
void addDigit(QGridLayout* grid, int digit, int row, int col) {
|
||||
auto* btn = new QPushButton(QString::number(digit), this);
|
||||
grid->addWidget(btn, row, col);
|
||||
connect(btn, &QPushButton::clicked, this, [this, digit] { onDigit(digit); });
|
||||
}
|
||||
|
||||
// 运算符键:同理,lambda + context,一个槽 onOperator(QChar) 处理全部。
|
||||
void addOperator(QGridLayout* grid, const QString& label, QChar op, int row, int col) {
|
||||
auto* btn = new QPushButton(label, this);
|
||||
grid->addWidget(btn, row, col);
|
||||
connect(btn, &QPushButton::clicked, this, [this, op] { onOperator(op); });
|
||||
}
|
||||
|
||||
void compute() {
|
||||
if (m_pendingOp.isNull()) return; // 还没有待运算符(本次是第一个运算符)
|
||||
const double rhs = m_display->text().toDouble();
|
||||
double result = 0.0;
|
||||
if (m_pendingOp == QLatin1Char('+')) result = m_pendingValue + rhs;
|
||||
else if (m_pendingOp == QLatin1Char('-')) result = m_pendingValue - rhs;
|
||||
else if (m_pendingOp == QLatin1Char('*')) result = m_pendingValue * rhs;
|
||||
else if (m_pendingOp == QLatin1Char('/')) {
|
||||
if (rhs == 0.0) { showError(); return; } // 除零:不崩,给出明确的错误状态
|
||||
result = m_pendingValue / rhs;
|
||||
}
|
||||
m_display->setText(QString::number(result, 'g', 12));
|
||||
m_pendingValue = result;
|
||||
m_startNew = true;
|
||||
}
|
||||
|
||||
void showError() {
|
||||
m_display->setText(QStringLiteral("Error"));
|
||||
m_error = true;
|
||||
m_startNew = true;
|
||||
}
|
||||
|
||||
QLineEdit* m_display = nullptr;
|
||||
double m_pendingValue = 0.0;
|
||||
QChar m_pendingOp;
|
||||
bool m_startNew = false;
|
||||
bool m_error = false;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ 自测入口 ----
|
||||
|
||||
static bool expectEqual(const char* label, const QString& actual, const QString& expected) {
|
||||
if (actual != expected) {
|
||||
qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
|
||||
return false;
|
||||
}
|
||||
qInfo() << "自测通过:" << label << "=>" << actual;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool runSelfTest(Calculator* calc) {
|
||||
calc->onClear();
|
||||
calc->onDigit(7); calc->onOperator(QLatin1Char('+')); calc->onDigit(3); calc->onEquals();
|
||||
if (!expectEqual("7 + 3 =", calc->displayText(), QStringLiteral("10"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(6); calc->onOperator(QLatin1Char('/')); calc->onDigit(0); calc->onEquals();
|
||||
if (!expectEqual("6 ÷ 0 =", calc->displayText(), QStringLiteral("Error"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(4); calc->onDot(); calc->onDigit(5);
|
||||
calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(0); calc->onDot(); calc->onDigit(5);
|
||||
calc->onEquals();
|
||||
if (!expectEqual("4.5 + 0.5 =", calc->displayText(), QStringLiteral("5"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(1); calc->onDigit(2); calc->onBackspace();
|
||||
if (!expectEqual("12 退格", calc->displayText(), QStringLiteral("1"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(3); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(4); calc->onEquals();
|
||||
if (!expectEqual("2 + 3 + 4 =(链式运算)", calc->displayText(), QStringLiteral("9"))) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
Calculator calc;
|
||||
calc.resize(240, 320);
|
||||
calc.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(0, &calc, [&calc] {
|
||||
const bool ok = runSelfTest(&calc);
|
||||
QCoreApplication::exit(ok ? 0 : 1);
|
||||
});
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "qt_calculator.moc"
|
||||
@@ -0,0 +1,59 @@
|
||||
# 简易计算器 —— Qt Designer 表单对照版
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「项目案例:简易
|
||||
> 计算器」。与 [`../calculator/`](../calculator/README.md)(纯代码 `QGridLayout`
|
||||
> 版)对照阅读:**界面搭建方式换了,业务逻辑一字不差**——`onDigit`/`onOperator`/
|
||||
> `onEquals`/`onClear`/`onBackspace`/`compute` 全部照搬。手工维护,非生成产物。
|
||||
|
||||
## 与代码版的唯一区别:界面从哪来
|
||||
|
||||
| | 代码版 `qt_calculator.cpp` | Designer 版 `qt_calculator_designer.cpp` |
|
||||
|---|---|---|
|
||||
| 控件树+布局 | `buildKeypad()` 手写 `new QPushButton` + `QGridLayout::addWidget` | `calculator_designer.ui`(Designer 表单 XML)+ `uic` 编译期生成 `ui_calculator_designer.h` + `setupUi(this)` 一次建好 |
|
||||
| 找控件 | 局部变量/成员指针 | `ui->btn7`、`ui->display` ……(objectName 对应成员名) |
|
||||
| 构建开关 | `AUTOMOC ON` | `AUTOMOC ON` **+ `AUTOUIC ON`**(CMake 自动跑 `uic`) |
|
||||
| 业务逻辑 | 状态机 | 完全相同 |
|
||||
|
||||
## ★ 新增知识点:Designer 表单的「自动连接」
|
||||
|
||||
`ui->setupUi(this)` 内部会调用 `QMetaObject::connectSlotsByName(this)`:只要
|
||||
类里有名为 `on_<objectName>_<signal>()` 的槽(比如 `on_btnClear_clicked()`),
|
||||
Qt 会**自动**把表单里 `objectName` 为 `btnClear` 的控件的 `clicked()` 信号连上去,
|
||||
不需要手写 `connect()`。这是信号槽的第三种写法(前两种见 `../calculator/`)。
|
||||
|
||||
**本示例故意不用这个自动连接**,全部改成显式 `connect()`——原因是当天的坑卡:
|
||||
|
||||
- 坑:表单里把按钮 `objectName` 从 `btnClear` 改成 `btnReset`,`on_btnClear_clicked()`
|
||||
这个槽**不报编译错误、不报运行时错误**,只是从此再也不会被调用——`connectSlotsByName`
|
||||
找不到匹配的 `objectName` 时只是静默跳过。这是「静默失败」坑点家族的第三个成员
|
||||
(前两个是旧 `SIGNAL()/SLOT()` 字符串语法、重复 `connect`)。
|
||||
- 结论:显式 `connect()` 虽然多写几行,但改名/删控件时编译器/IDE 能帮你追踪引用,
|
||||
排查成本远低于依赖命名约定的隐式连接。项目里**推荐显式 connect**,`on_xxx_yyy()`
|
||||
自动连接更适合 Qt Designer 快速原型阶段。
|
||||
|
||||
## 验证方式(自测模式)
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator_designer
|
||||
# 自测用例与 ../calculator/ 完全一致:「7+3=10」「6÷0=Error」「4.5+0.5=5」
|
||||
# 「12 退格→1」「2+3+4=9(链式运算)」,全部通过退出码 0
|
||||
tools/run_qt.sh qt_calculator_designer # 有 X 环境:完整交互
|
||||
```
|
||||
|
||||
## 课堂讲解建议
|
||||
|
||||
1. 先展示 `.ui` 文件本身是纯 XML(`<widget class="QPushButton" name="btn7">…`)——
|
||||
Qt Designer 只是这份 XML 的图形化编辑器,不是什么黑魔法;
|
||||
2. 对照 `../calculator/qt_calculator.cpp` 的 `buildKeypad()`,逐行说明
|
||||
`uic` 把这份 XML 翻译成了等价的 C++ 建控件代码(`build/` 里能翻出真实生成的
|
||||
`ui_calculator_designer.h` 验证);
|
||||
3. 演示自动连接的坑:把 `.ui` 里某个按钮的 `name` 改掉,重新构建,点击按钮
|
||||
没反应——现场复现「改名静默失效」;
|
||||
4. 讨论:两种方式怎么选?—— 快速原型/美术同学调界面用 Designer 更直观;
|
||||
逻辑复杂、控件数量随数据动态变化(比如 Day5 的表格行)就必须手写代码搭建,
|
||||
`.ui` 表单描述不了「运行时循环创建 N 个控件」。
|
||||
|
||||
## 参考
|
||||
|
||||
- 代码布局对照版:[`../calculator/`](../calculator/README.md)
|
||||
- Qt 官方 Widgets 表单机制:`uic`(User Interface Compiler)+ `QMetaObject::connectSlotsByName`
|
||||
@@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>CalculatorForm</class>
|
||||
<widget class="QWidget" name="CalculatorForm">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>358</width>
|
||||
<height>320</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>简易计算器(Designer 版)</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="display">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="keypadLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="btn7">
|
||||
<property name="text">
|
||||
<string>7</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="btn8">
|
||||
<property name="text">
|
||||
<string>8</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="btn9">
|
||||
<property name="text">
|
||||
<string>9</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="btnDiv">
|
||||
<property name="text">
|
||||
<string>÷</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="btn4">
|
||||
<property name="text">
|
||||
<string>4</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="btn5">
|
||||
<property name="text">
|
||||
<string>5</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="btn6">
|
||||
<property name="text">
|
||||
<string>6</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QPushButton" name="btnMul">
|
||||
<property name="text">
|
||||
<string>×</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="btn1">
|
||||
<property name="text">
|
||||
<string>1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="btn2">
|
||||
<property name="text">
|
||||
<string>2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QPushButton" name="btn3">
|
||||
<property name="text">
|
||||
<string>3</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="btnMinus">
|
||||
<property name="text">
|
||||
<string>-</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="btn0">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="btnDot">
|
||||
<property name="text">
|
||||
<string>.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="btnEquals">
|
||||
<property name="text">
|
||||
<string>=</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<widget class="QPushButton" name="btnPlus">
|
||||
<property name="text">
|
||||
<string>+</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QPushButton" name="btnClear">
|
||||
<property name="text">
|
||||
<string>C</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QPushButton" name="btnBackspace">
|
||||
<property name="text">
|
||||
<string>⌫</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,202 @@
|
||||
// ============================================================================
|
||||
// qt_calculator_designer —— Day2 案例参考实现(Qt Designer 表单版)
|
||||
// 对应教案:OUTLINE_4DAY.md Day2 下午「项目案例:简易计算器」
|
||||
// 配套文档:docs/teaching/examples/calculator_designer/README.md
|
||||
// 手工维护,非生成产物。
|
||||
//
|
||||
// 与 ../calculator/qt_calculator.cpp(纯代码 QGridLayout 版)对照阅读:
|
||||
// 界面结构完全一致,唯一区别是键盘布局用 Qt Designer 表单
|
||||
// (calculator_designer.ui)设计——编译期由 uic 生成 ui_calculator_designer.h,
|
||||
// setupUi(this) 一次性把控件树 + 布局全部建好;业务逻辑(onDigit/onOperator/
|
||||
// onEquals/...)与代码版完全相同、原样照搬,刻意保持一致以突出「界面搭建方式
|
||||
// 变了,逻辑代码不用变」。
|
||||
//
|
||||
// 教学重点(对照代码版新增的一条):
|
||||
// Designer 表单里控件的 objectName 决定「自动连接」——setupUi() 内部会调用
|
||||
// QMetaObject::connectSlotsByName(this),自动把名为 on_<objectName>_<signal>()
|
||||
// 的槽连到对应控件的信号。本例改用显式 connect()(更贴近代码版、也更利于
|
||||
// 团队协作排查),但这个自动连接特性是 Designer 工作流特有的第三种信号槽写法,
|
||||
// 坑点见 README:控件改名后自动连接悄悄失效,不报编译错误也不报运行时错误。
|
||||
//
|
||||
// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator_designer
|
||||
// 自测用例与 ../calculator/ 完全一致,用于验证「界面来源不同、行为一致」。
|
||||
// ============================================================================
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
#include "ui_calculator_designer.h"
|
||||
|
||||
class CalculatorDesigner : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit CalculatorDesigner(QWidget* parent = nullptr)
|
||||
: QWidget(parent), ui(new Ui::CalculatorForm) {
|
||||
ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
|
||||
|
||||
connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
|
||||
connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
|
||||
connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
|
||||
connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
|
||||
connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
|
||||
connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
|
||||
connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
|
||||
connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
|
||||
connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
|
||||
connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
|
||||
connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
|
||||
|
||||
connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
|
||||
connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
|
||||
connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
|
||||
connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
|
||||
|
||||
connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
|
||||
connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
|
||||
connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
|
||||
}
|
||||
|
||||
~CalculatorDesigner() override { delete ui; }
|
||||
|
||||
// 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator/ 版一字不差。
|
||||
void onDigit(int digit) {
|
||||
if (m_error) onClear();
|
||||
if (m_startNew || ui->display->text() == QStringLiteral("0")) {
|
||||
ui->display->setText(QString::number(digit));
|
||||
m_startNew = false;
|
||||
} else {
|
||||
ui->display->setText(ui->display->text() + QString::number(digit));
|
||||
}
|
||||
}
|
||||
|
||||
void onDot() {
|
||||
if (m_error) onClear();
|
||||
if (m_startNew) {
|
||||
ui->display->setText(QStringLiteral("0."));
|
||||
m_startNew = false;
|
||||
return;
|
||||
}
|
||||
if (!ui->display->text().contains(QLatin1Char('.')))
|
||||
ui->display->setText(ui->display->text() + QStringLiteral("."));
|
||||
}
|
||||
|
||||
void onOperator(QChar op) {
|
||||
if (m_error) return;
|
||||
if (!m_startNew) compute();
|
||||
m_pendingValue = ui->display->text().toDouble();
|
||||
m_pendingOp = op;
|
||||
m_startNew = true;
|
||||
}
|
||||
|
||||
void onEquals() {
|
||||
if (m_error) return;
|
||||
compute();
|
||||
m_pendingOp = QChar();
|
||||
}
|
||||
|
||||
void onClear() {
|
||||
ui->display->setText(QStringLiteral("0"));
|
||||
m_pendingValue = 0.0;
|
||||
m_pendingOp = QChar();
|
||||
m_startNew = false;
|
||||
m_error = false;
|
||||
}
|
||||
|
||||
void onBackspace() {
|
||||
if (m_error) { onClear(); return; }
|
||||
QString text = ui->display->text();
|
||||
text.chop(1);
|
||||
ui->display->setText(text.isEmpty() ? QStringLiteral("0") : text);
|
||||
}
|
||||
|
||||
QString displayText() const { return ui->display->text(); }
|
||||
|
||||
private:
|
||||
void compute() {
|
||||
if (m_pendingOp.isNull()) return;
|
||||
const double rhs = ui->display->text().toDouble();
|
||||
double result = 0.0;
|
||||
if (m_pendingOp == QLatin1Char('+')) result = m_pendingValue + rhs;
|
||||
else if (m_pendingOp == QLatin1Char('-')) result = m_pendingValue - rhs;
|
||||
else if (m_pendingOp == QLatin1Char('*')) result = m_pendingValue * rhs;
|
||||
else if (m_pendingOp == QLatin1Char('/')) {
|
||||
if (rhs == 0.0) { showError(); return; }
|
||||
result = m_pendingValue / rhs;
|
||||
}
|
||||
ui->display->setText(QString::number(result, 'g', 12));
|
||||
m_pendingValue = result;
|
||||
m_startNew = true;
|
||||
}
|
||||
|
||||
void showError() {
|
||||
ui->display->setText(QStringLiteral("Error"));
|
||||
m_error = true;
|
||||
m_startNew = true;
|
||||
}
|
||||
|
||||
Ui::CalculatorForm* ui;
|
||||
double m_pendingValue = 0.0;
|
||||
QChar m_pendingOp;
|
||||
bool m_startNew = false;
|
||||
bool m_error = false;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ 自测入口 ----
|
||||
// 与 ../calculator/qt_calculator.cpp 完全相同的 5 组用例:验证「界面搭建方式
|
||||
// 换了,行为不变」。
|
||||
|
||||
static bool expectEqual(const char* label, const QString& actual, const QString& expected) {
|
||||
if (actual != expected) {
|
||||
qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
|
||||
return false;
|
||||
}
|
||||
qInfo() << "自测通过:" << label << "=>" << actual;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool runSelfTest(CalculatorDesigner* calc) {
|
||||
calc->onClear();
|
||||
calc->onDigit(7); calc->onOperator(QLatin1Char('+')); calc->onDigit(3); calc->onEquals();
|
||||
if (!expectEqual("7 + 3 =", calc->displayText(), QStringLiteral("10"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(6); calc->onOperator(QLatin1Char('/')); calc->onDigit(0); calc->onEquals();
|
||||
if (!expectEqual("6 ÷ 0 =", calc->displayText(), QStringLiteral("Error"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(4); calc->onDot(); calc->onDigit(5);
|
||||
calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(0); calc->onDot(); calc->onDigit(5);
|
||||
calc->onEquals();
|
||||
if (!expectEqual("4.5 + 0.5 =", calc->displayText(), QStringLiteral("5"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(1); calc->onDigit(2); calc->onBackspace();
|
||||
if (!expectEqual("12 退格", calc->displayText(), QStringLiteral("1"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(3); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(4); calc->onEquals();
|
||||
if (!expectEqual("2 + 3 + 4 =(链式运算)", calc->displayText(), QStringLiteral("9"))) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
CalculatorDesigner calc;
|
||||
calc.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(0, &calc, [&calc] {
|
||||
const bool ok = runSelfTest(&calc);
|
||||
QCoreApplication::exit(ok ? 0 : 1);
|
||||
});
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
|
||||
#include "qt_calculator_designer.moc"
|
||||
@@ -0,0 +1,49 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
project(Calculator LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# QtCreator supports the following variables for Android, which are identical to qmake Android variables.
|
||||
# Check http://doc.qt.io/qt-5/deployment-android.html for more information.
|
||||
# They need to be set before the find_package(Qt5 ...) call.
|
||||
|
||||
#if(ANDROID)
|
||||
# set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
|
||||
# if (ANDROID_ABI STREQUAL "armeabi-v7a")
|
||||
# set(ANDROID_EXTRA_LIBS
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libcrypto.so
|
||||
# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libssl.so)
|
||||
# endif()
|
||||
#endif()
|
||||
|
||||
find_package(Qt5 COMPONENTS Widgets REQUIRED)
|
||||
|
||||
if(ANDROID)
|
||||
add_library(Calculator SHARED
|
||||
main.cpp
|
||||
calculatordesigner.h
|
||||
calculatordesigner.cpp
|
||||
expressionevaluator.h
|
||||
expressionevaluator.cpp
|
||||
widget.ui
|
||||
)
|
||||
else()
|
||||
add_executable(Calculator
|
||||
main.cpp
|
||||
calculatordesigner.h
|
||||
calculatordesigner.cpp
|
||||
expressionevaluator.h
|
||||
expressionevaluator.cpp
|
||||
widget.ui
|
||||
)
|
||||
endif()
|
||||
|
||||
target_link_libraries(Calculator PRIVATE Qt5::Widgets)
|
||||
@@ -0,0 +1,229 @@
|
||||
#include "calculatordesigner.h"
|
||||
|
||||
#include <QPushButton>
|
||||
|
||||
#include "expressionevaluator.h"
|
||||
#include "ui_widget.h"
|
||||
|
||||
namespace {
|
||||
bool isOperatorChar(QChar ch) {
|
||||
return ch == QLatin1Char('+') || ch == QLatin1Char('-') ||
|
||||
ch == QLatin1Char('*') || ch == QLatin1Char('/');
|
||||
}
|
||||
|
||||
bool isDigitOrDotChar(QChar ch) {
|
||||
return ch.isDigit() || ch == QLatin1Char('.');
|
||||
}
|
||||
} // namespace
|
||||
|
||||
CalculatorDesigner::CalculatorDesigner(QWidget* parent)
|
||||
: QWidget(parent), ui(new Ui::CalculatorForm) {
|
||||
ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
|
||||
|
||||
connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
|
||||
connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
|
||||
connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
|
||||
connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
|
||||
connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
|
||||
connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
|
||||
connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
|
||||
connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
|
||||
connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
|
||||
connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
|
||||
connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
|
||||
|
||||
connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
|
||||
connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
|
||||
connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
|
||||
connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
|
||||
|
||||
connect(ui->btnLeftParen, &QPushButton::clicked, this, &CalculatorDesigner::onLeftParen);
|
||||
connect(ui->btnRightParen, &QPushButton::clicked, this, &CalculatorDesigner::onRightParen);
|
||||
|
||||
connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
|
||||
connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
|
||||
connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
|
||||
|
||||
onClear();
|
||||
}
|
||||
|
||||
CalculatorDesigner::~CalculatorDesigner() { delete ui; }
|
||||
|
||||
void CalculatorDesigner::onDigit(int digit) {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
const QString token = currentOperandToken();
|
||||
if (token == QStringLiteral("0")) {
|
||||
m_expression.chop(1); // 用新数字替换孤立的前导 0,避免出现 "007"
|
||||
} else if (currentOperandDigitCount() >= kMaxOperandLength) {
|
||||
return; // 达到单个运算数的长度上限,忽略后续数字
|
||||
}
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QString::number(digit));
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onDot() {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
const QString token = currentOperandToken();
|
||||
if (token.contains(QLatin1Char('.'))) return; // 当前运算数已有小数点
|
||||
|
||||
const int appended = token.isEmpty() ? 2 : 1; // 空token时需要补一个前导 0
|
||||
if (m_expression.size() + appended > kMaxExpressionLength) return;
|
||||
|
||||
if (token.isEmpty()) m_expression.append(QLatin1Char('0'));
|
||||
m_expression.append(QLatin1Char('.'));
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onOperator(QChar op) {
|
||||
if (m_error) return;
|
||||
if (m_resultShown) {
|
||||
m_expression = m_lastResultText;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
if (m_expression.isEmpty()) {
|
||||
if (op != QLatin1Char('-')) return; // 表达式开头只允许负号(单目负号)
|
||||
} else {
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (last == QLatin1Char('(')) {
|
||||
if (op != QLatin1Char('-')) return; // 左括号后只允许负号
|
||||
} else if (isOperatorChar(last)) {
|
||||
m_expression.chop(1); // 连续按运算符时,用新的运算符替换上一个
|
||||
}
|
||||
}
|
||||
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
m_expression.append(op);
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onLeftParen() {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
if (!m_expression.isEmpty()) {
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (!isOperatorChar(last) && last != QLatin1Char('(')) {
|
||||
return; // 数字或右括号后不支持隐式乘法,忽略
|
||||
}
|
||||
}
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QLatin1Char('('));
|
||||
++m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onRightParen() {
|
||||
if (m_error) return;
|
||||
if (m_resultShown) return;
|
||||
if (m_openParenCount <= 0 || m_expression.isEmpty()) return;
|
||||
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (isOperatorChar(last) || last == QLatin1Char('(')) return; // 括号内需要完整子表达式
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QLatin1Char(')'));
|
||||
--m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onEquals() {
|
||||
if (m_error) return;
|
||||
if (m_expression.isEmpty()) return;
|
||||
|
||||
QString toEvaluate = m_expression;
|
||||
for (int i = 0; i < m_openParenCount; ++i) toEvaluate.append(QLatin1Char(')')); // 自动补全未闭合括号
|
||||
|
||||
const ExpressionEvaluator::Result result = ExpressionEvaluator::evaluate(toEvaluate);
|
||||
if (!result.ok) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
|
||||
const QString resultText = QString::number(result.value, 'g', ExpressionEvaluator::kResultPrecision);
|
||||
m_expression = toEvaluate;
|
||||
m_lastResultText = resultText;
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = true;
|
||||
ui->expressionEdit->setPlainText(m_expression + QStringLiteral(" = ") + resultText);
|
||||
ui->display->setText(resultText);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onClear() {
|
||||
m_expression.clear();
|
||||
m_lastResultText.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
m_error = false;
|
||||
ui->expressionEdit->clear();
|
||||
ui->display->setText(QStringLiteral("0"));
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onBackspace() {
|
||||
if (m_error) { onClear(); return; }
|
||||
if (m_resultShown) return; // 结果已展示,需先清空或继续输入新表达式
|
||||
if (m_expression.isEmpty()) return;
|
||||
|
||||
const QChar removed = m_expression.at(m_expression.size() - 1);
|
||||
m_expression.chop(1);
|
||||
if (removed == QLatin1Char('(')) --m_openParenCount;
|
||||
else if (removed == QLatin1Char(')')) ++m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::displayText() const { return ui->display->text(); }
|
||||
|
||||
QString CalculatorDesigner::expressionText() const { return ui->expressionEdit->toPlainText(); }
|
||||
|
||||
QString CalculatorDesigner::currentOperandToken() const {
|
||||
int start = m_expression.size();
|
||||
while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
|
||||
return m_expression.mid(start);
|
||||
}
|
||||
|
||||
int CalculatorDesigner::currentOperandDigitCount() const {
|
||||
const QString token = currentOperandToken();
|
||||
int count = 0;
|
||||
for (const QChar ch : token) {
|
||||
if (ch.isDigit()) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::lastNumberToken() const {
|
||||
int end = m_expression.size();
|
||||
while (end > 0 && !isDigitOrDotChar(m_expression.at(end - 1))) --end; // 跳过末尾的运算符/括号
|
||||
int start = end;
|
||||
while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
|
||||
return m_expression.mid(start, end - start);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::refreshDisplays() {
|
||||
ui->expressionEdit->setPlainText(m_expression);
|
||||
const QString token = lastNumberToken();
|
||||
ui->display->setText(token.isEmpty() ? QStringLiteral("0") : token);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::showError() {
|
||||
ui->display->setText(QStringLiteral("Error"));
|
||||
m_error = true;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <QChar>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
namespace Ui {
|
||||
class CalculatorForm;
|
||||
}
|
||||
QT_END_NAMESPACE
|
||||
|
||||
class CalculatorDesigner : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit CalculatorDesigner(QWidget* parent = nullptr);
|
||||
~CalculatorDesigner() override;
|
||||
|
||||
// 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator/ 版一字不差。
|
||||
void onDigit(int digit);
|
||||
void onDot();
|
||||
void onOperator(QChar op);
|
||||
void onLeftParen();
|
||||
void onRightParen();
|
||||
void onEquals();
|
||||
void onClear();
|
||||
void onBackspace();
|
||||
|
||||
QString displayText() const;
|
||||
QString expressionText() const;
|
||||
|
||||
private:
|
||||
// 单个运算数(数字串)允许的最大长度,要求不得小于 10 位。
|
||||
static constexpr int kMaxOperandLength = 15;
|
||||
static_assert(kMaxOperandLength >= 10, "单个运算数的最大长度不得小于 10 位");
|
||||
// 表达式总长度上限,要求不得低于 100 个字符。
|
||||
static constexpr int kMaxExpressionLength = 200;
|
||||
static_assert(kMaxExpressionLength >= 100, "表达式总长度上限不得低于 100 个字符");
|
||||
|
||||
QString currentOperandToken() const;
|
||||
int currentOperandDigitCount() const;
|
||||
QString lastNumberToken() const;
|
||||
void refreshDisplays();
|
||||
void showError();
|
||||
|
||||
Ui::CalculatorForm* ui;
|
||||
QString m_expression; // 当前正在编辑的表达式,例如 "12+3*(4-2"
|
||||
QString m_lastResultText; // 上一次成功计算得到的结果文本
|
||||
int m_openParenCount = 0; // 尚未闭合的左括号数量
|
||||
bool m_resultShown = false; // 显示区当前展示的是计算结果而非正在输入的数字
|
||||
bool m_error = false;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "expressionevaluator.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// 递归下降解析器:expression := term (('+'|'-') term)*
|
||||
// term := factor (('*'|'/') factor)*
|
||||
// factor := ('+'|'-') factor | '(' expression ')' | number
|
||||
// 括号会先被完整求值,天然实现"优先级提升";乘除法在 term 层处理,
|
||||
// 天然高于 expression 层的加减法。
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const QString& text) : m_text(text) {}
|
||||
|
||||
ExpressionEvaluator::Result parse() {
|
||||
ExpressionEvaluator::Result result;
|
||||
skipSpaces();
|
||||
if (atEnd()) {
|
||||
result.errorMessage = QStringLiteral("表达式为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
const double value = parseExpression(ok);
|
||||
skipSpaces();
|
||||
if (!ok) {
|
||||
result.errorMessage = m_error;
|
||||
return result;
|
||||
}
|
||||
if (!atEnd()) {
|
||||
result.errorMessage = QStringLiteral("表达式包含无法识别的字符");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ok = true;
|
||||
result.value = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
private:
|
||||
double parseExpression(bool& ok) {
|
||||
double value = parseTerm(ok);
|
||||
while (ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('+')) {
|
||||
advance();
|
||||
value += parseTerm(ok);
|
||||
} else if (peek() == QLatin1Char('-')) {
|
||||
advance();
|
||||
value -= parseTerm(ok);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double parseTerm(bool& ok) {
|
||||
double value = parseFactor(ok);
|
||||
while (ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('*')) {
|
||||
advance();
|
||||
value *= parseFactor(ok);
|
||||
} else if (peek() == QLatin1Char('/')) {
|
||||
advance();
|
||||
const double rhs = parseFactor(ok);
|
||||
if (!ok) return 0.0;
|
||||
if (rhs == 0.0) {
|
||||
fail(QStringLiteral("除数不能为零"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
value /= rhs;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double parseFactor(bool& ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('+')) {
|
||||
advance();
|
||||
return parseFactor(ok);
|
||||
}
|
||||
if (peek() == QLatin1Char('-')) {
|
||||
advance();
|
||||
return -parseFactor(ok);
|
||||
}
|
||||
if (peek() == QLatin1Char('(')) {
|
||||
advance();
|
||||
const double value = parseExpression(ok);
|
||||
if (!ok) return 0.0;
|
||||
skipSpaces();
|
||||
if (peek() != QLatin1Char(')')) {
|
||||
fail(QStringLiteral("括号不匹配"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
advance();
|
||||
return value;
|
||||
}
|
||||
return parseNumber(ok);
|
||||
}
|
||||
|
||||
double parseNumber(bool& ok) {
|
||||
skipSpaces();
|
||||
const int start = m_pos;
|
||||
bool hasDigits = false;
|
||||
while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
|
||||
if (!atEnd() && peek() == QLatin1Char('.')) {
|
||||
advance();
|
||||
while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
|
||||
}
|
||||
if (!hasDigits) {
|
||||
fail(QStringLiteral("表达式格式错误,缺少数字"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
const QString token = m_text.mid(start, m_pos - start);
|
||||
bool numOk = false;
|
||||
const double value = token.toDouble(&numOk);
|
||||
if (!numOk) {
|
||||
fail(QStringLiteral("数字格式错误"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void skipSpaces() { while (!atEnd() && peek().isSpace()) advance(); }
|
||||
bool atEnd() const { return m_pos >= m_text.size(); }
|
||||
QChar peek() const { return atEnd() ? QChar() : m_text.at(m_pos); }
|
||||
void advance() { ++m_pos; }
|
||||
void fail(const QString& message, bool& ok) { ok = false; m_error = message; }
|
||||
|
||||
const QString& m_text;
|
||||
int m_pos = 0;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ExpressionEvaluator::Result ExpressionEvaluator::evaluate(const QString& expression) {
|
||||
Parser parser(expression);
|
||||
return parser.parse();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
// 对形如 "1+2*(3-4)" 的中缀表达式求值:
|
||||
// 乘除优先级高于加减,括号可提升优先级。
|
||||
class ExpressionEvaluator {
|
||||
public:
|
||||
struct Result {
|
||||
bool ok = false;
|
||||
double value = 0.0;
|
||||
QString errorMessage;
|
||||
};
|
||||
|
||||
// 结果显示精度(有效数字位数),要求不得小于 10。
|
||||
static constexpr int kResultPrecision = 12;
|
||||
static_assert(kResultPrecision >= 10, "结果显示精度不得小于 10 位有效数字");
|
||||
|
||||
static Result evaluate(const QString& expression);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
|
||||
#include "calculatordesigner.h"
|
||||
|
||||
// ------------------------------------------------------------ 自测入口 ----
|
||||
// 与 ../calculator/qt_calculator.cpp 完全相同的 5 组用例:验证「界面搭建方式
|
||||
// 换了,行为不变」。
|
||||
|
||||
static bool expectEqual(const char* label, const QString& actual, const QString& expected) {
|
||||
if (actual != expected) {
|
||||
qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
|
||||
return false;
|
||||
}
|
||||
qInfo() << "自测通过:" << label << "=>" << actual;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool runSelfTest(CalculatorDesigner* calc) {
|
||||
calc->onClear();
|
||||
calc->onDigit(7); calc->onOperator(QLatin1Char('+')); calc->onDigit(3); calc->onEquals();
|
||||
if (!expectEqual("7 + 3 =", calc->displayText(), QStringLiteral("10"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(6); calc->onOperator(QLatin1Char('/')); calc->onDigit(0); calc->onEquals();
|
||||
if (!expectEqual("6 ÷ 0 =", calc->displayText(), QStringLiteral("Error"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(4); calc->onDot(); calc->onDigit(5);
|
||||
calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(0); calc->onDot(); calc->onDigit(5);
|
||||
calc->onEquals();
|
||||
if (!expectEqual("4.5 + 0.5 =", calc->displayText(), QStringLiteral("5"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(1); calc->onDigit(2); calc->onBackspace();
|
||||
if (!expectEqual("12 退格", calc->displayText(), QStringLiteral("1"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(3); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(4); calc->onEquals();
|
||||
if (!expectEqual("2 + 3 + 4 =(链式运算)", calc->displayText(), QStringLiteral("9"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onDigit(3); calc->onOperator(QLatin1Char('*'));
|
||||
calc->onDigit(4); calc->onEquals();
|
||||
if (!expectEqual("2 + 3 × 4 =(乘法优先于加法)", calc->displayText(), QStringLiteral("14"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onLeftParen();
|
||||
calc->onDigit(2); calc->onOperator(QLatin1Char('+')); calc->onDigit(3);
|
||||
calc->onRightParen();
|
||||
calc->onOperator(QLatin1Char('*'));
|
||||
calc->onDigit(4); calc->onEquals();
|
||||
if (!expectEqual("(2 + 3) × 4 =(括号提升优先级)", calc->displayText(), QStringLiteral("20"))) return false;
|
||||
if (!expectEqual("(2 + 3) × 4 = 表达式区文本", calc->expressionText(), QStringLiteral("(2+3)*4 = 20"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onDigit(1); calc->onOperator(QLatin1Char('+'));
|
||||
calc->onLeftParen();
|
||||
calc->onDigit(2); calc->onOperator(QLatin1Char('*'));
|
||||
calc->onLeftParen();
|
||||
calc->onDigit(3); calc->onOperator(QLatin1Char('+')); calc->onDigit(4);
|
||||
calc->onRightParen();
|
||||
calc->onRightParen();
|
||||
calc->onEquals();
|
||||
if (!expectEqual("1 + (2 × (3 + 4)) =(嵌套括号)", calc->displayText(), QStringLiteral("15"))) return false;
|
||||
|
||||
calc->onClear();
|
||||
calc->onLeftParen();
|
||||
calc->onDigit(1); calc->onOperator(QLatin1Char('+')); calc->onDigit(2);
|
||||
calc->onEquals(); // 未手动补齐右括号,应自动闭合后求值
|
||||
if (!expectEqual("(1 + 2 =(自动补全括号)", calc->displayText(), QStringLiteral("3"))) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
CalculatorDesigner calc;
|
||||
calc.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(0, &calc, [&calc] {
|
||||
const bool ok = runSelfTest(&calc);
|
||||
QCoreApplication::exit(ok ? 0 : 1);
|
||||
});
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>CalculatorForm</class>
|
||||
<widget class="QWidget" name="CalculatorForm">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>358</width>
|
||||
<height>320</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>简易计算器(Designer 版)</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<widget class="QTextEdit" name="expressionEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>80</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="display">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="keypadLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="btn7">
|
||||
<property name="text">
|
||||
<string>7</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="btn8">
|
||||
<property name="text">
|
||||
<string>8</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="btn9">
|
||||
<property name="text">
|
||||
<string>9</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="btnDiv">
|
||||
<property name="text">
|
||||
<string>÷</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="btn4">
|
||||
<property name="text">
|
||||
<string>4</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="btn5">
|
||||
<property name="text">
|
||||
<string>5</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="btn6">
|
||||
<property name="text">
|
||||
<string>6</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QPushButton" name="btnMul">
|
||||
<property name="text">
|
||||
<string>×</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="btn1">
|
||||
<property name="text">
|
||||
<string>1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="btn2">
|
||||
<property name="text">
|
||||
<string>2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QPushButton" name="btn3">
|
||||
<property name="text">
|
||||
<string>3</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="btnMinus">
|
||||
<property name="text">
|
||||
<string>-</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="btn0">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="btnDot">
|
||||
<property name="text">
|
||||
<string>.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="btnEquals">
|
||||
<property name="text">
|
||||
<string>=</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<widget class="QPushButton" name="btnPlus">
|
||||
<property name="text">
|
||||
<string>+</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QPushButton" name="btnClear">
|
||||
<property name="text">
|
||||
<string>C</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QPushButton" name="btnBackspace">
|
||||
<property name="text">
|
||||
<string>⌫</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QPushButton" name="btnLeftParen">
|
||||
<property name="text">
|
||||
<string>(</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="3">
|
||||
<widget class="QPushButton" name="btnRightParen">
|
||||
<property name="text">
|
||||
<string>)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,76 @@
|
||||
# Day3 上午参考实现:计算器 → QMainWindow
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day3 上午「QMainWindow / 对话框
|
||||
> QDialog」+ [`ASSIGNMENTS.md`](../../ASSIGNMENTS.md) Day3 上午课堂练习。手工维护,
|
||||
> 非生成产物。
|
||||
|
||||
## 定位:把 Day2 增强版计算器装进 QMainWindow
|
||||
|
||||
以 Day2 增强版计算器 [`../calculator_designer_ext/`](../calculator_designer_ext/README.md)
|
||||
的 `CalculatorDesigner`(`QWidget`,含表达式求值 + 括号)为**中心部件**,给它套上
|
||||
`QMainWindow` 外壳:菜单栏 / 状态栏 / 历史停靠窗。**计算器面板本身的输入与求值逻辑
|
||||
一字未改**——这正是「界面外壳升级、核心部件不动」的关注点分离。
|
||||
|
||||
## 与 Day2 增强版的差异(仅两处,集中在 `calculatordesigner.{h,cpp}`)
|
||||
|
||||
| 改动 | 位置 | 目的 |
|
||||
|---|---|---|
|
||||
| 新增 `evaluated(expr, result)` 信号 | `calculatordesigner.h/.cpp` | 按 `=` 求值成功后发出,供历史停靠窗订阅(演示「给已有部件加信号,与外壳通信」) |
|
||||
| 新增 `loadExpression(expr)` 方法 | `calculatordesigner.h/.cpp` | 把一条历史表达式回填到编辑区,配合历史双击 |
|
||||
|
||||
`expressionevaluator.{h,cpp}` / `widget.ui` 与 Day2 增强版完全一致(自包含复制,便于
|
||||
学员单读 Day3 代码不跳目录)。
|
||||
|
||||
## 与 [`../mainwindow_showcase/`](../mainwindow_showcase/README.md) 的分工对照
|
||||
|
||||
两者同为 Day3 上午教学载体,风格刻意对立,覆盖不同教学诉求:
|
||||
|
||||
| | `mainwindow_showcase` | 本例 `calculator_mainwindow` |
|
||||
|---|---|---|
|
||||
| 形态 | 极简文本编辑器(`QTextEdit` 中心) | 计算器(`CalculatorDesigner` 中心) |
|
||||
| 主窗体结构来源 | `.ui` 表单(Designer) | 纯代码 |
|
||||
| 信号槽 | `on_<obj>_<sig>` 自动连接 | 显式 `connect()` |
|
||||
| 教学侧重 | QMainWindow 六大组件齐全(含资源 `.qrc`) | 作业线应用 + 显式 connect 坑卡 |
|
||||
| 用法 | 教师讲「组件协议」时演示 | 学员课堂练习对照、作业验收参考 |
|
||||
|
||||
**坑卡对照**:`mainwindow_showcase` 用自动连接,控件改名静默失效;本例用显式 `connect`,
|
||||
`QAction` 忘 connect 时菜单点了没反应——两种「静默失败」在同一节课形成对照。
|
||||
|
||||
## 教学要点(对应 OUTLINE_4DAY.md Day3 上午)
|
||||
|
||||
1. **中心部件必须 `setCentralWidget`**:`mainwindow.cpp` 构造函数里 `setCentralWidget(m_calc)`。
|
||||
直接往 `QMainWindow` 上 `addWidget` 是 Day3 上午坑卡——布局行为不可预期。
|
||||
2. **菜单 + QAction + 显式 connect**:`buildMenu()` 逐菜单逐 `QAction` 建,每个都 `connect`。
|
||||
`m_actAbout` 若忘 `connect`,菜单显示正常、点了没反应。
|
||||
3. **模态对话框**:`onAbout()` 用 `QMessageBox::about`,内部 `exec()` 阻塞等关闭。
|
||||
4. **非模态停靠窗**:`QDockWidget` + `addDockWidget(Qt::RightDockWidgetArea, ...)`,
|
||||
可拖动 / 浮动 / 关闭(`QDockWidget` 默认行为)。与 `p03/ch06` 三个 modeless 对照文件
|
||||
呼应「非模态对象必须活过函数作用域」——停靠窗是 `new` 在堆上 + parent=`this`。
|
||||
5. **信号槽跨对象通信**:`CalculatorDesigner::evaluated` → `MainWindow::onEvaluated`,
|
||||
外壳订阅核心部件的信号,二者解耦。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
# 构建(仓库根;Qt 5.14.2)
|
||||
cmake --build build --target qt_calculator_mainwindow
|
||||
|
||||
# 离屏自测(9 组计算用例 + 历史追加/双击回填/清空,退出码 0 即全过)
|
||||
PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
QT_QPA_PLATFORM=offscreen \
|
||||
./build/docs/teaching/examples/qt_calculator_mainwindow.exe; echo "exit=$?"
|
||||
|
||||
# 有 GUI 环境直接看窗口
|
||||
./build/docs/teaching/examples/qt_calculator_mainwindow.exe
|
||||
```
|
||||
|
||||
## 自测用例
|
||||
|
||||
`MainWindow::runSelfTest()`(offscreen 自动执行)覆盖:
|
||||
|
||||
- Day2 全部 9 组计算用例(验证升级外壳后计算行为不变)
|
||||
- 按等号后历史停靠窗追加 `"expr = result"` 条目
|
||||
- 除零 `Error` **不**进历史(`evaluated` 只在求值成功时 emit)
|
||||
- 历史双击 → 表达式回填到编辑区
|
||||
- 「清空历史」后列表为空
|
||||
- 「关于」action 已创建并显式连接(GUI 弹窗验证靠现场演示)
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "calculatordesigner.h"
|
||||
|
||||
#include <QPushButton>
|
||||
|
||||
#include "expressionevaluator.h"
|
||||
#include "ui_widget.h"
|
||||
|
||||
namespace {
|
||||
bool isOperatorChar(QChar ch) {
|
||||
return ch == QLatin1Char('+') || ch == QLatin1Char('-') ||
|
||||
ch == QLatin1Char('*') || ch == QLatin1Char('/');
|
||||
}
|
||||
|
||||
bool isDigitOrDotChar(QChar ch) {
|
||||
return ch.isDigit() || ch == QLatin1Char('.');
|
||||
}
|
||||
} // namespace
|
||||
|
||||
CalculatorDesigner::CalculatorDesigner(QWidget* parent)
|
||||
: QWidget(parent), ui(new Ui::CalculatorForm) {
|
||||
ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
|
||||
|
||||
connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
|
||||
connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
|
||||
connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
|
||||
connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
|
||||
connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
|
||||
connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
|
||||
connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
|
||||
connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
|
||||
connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
|
||||
connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
|
||||
connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
|
||||
|
||||
connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
|
||||
connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
|
||||
connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
|
||||
connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
|
||||
|
||||
connect(ui->btnLeftParen, &QPushButton::clicked, this, &CalculatorDesigner::onLeftParen);
|
||||
connect(ui->btnRightParen, &QPushButton::clicked, this, &CalculatorDesigner::onRightParen);
|
||||
|
||||
connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
|
||||
connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
|
||||
connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
|
||||
|
||||
onClear();
|
||||
}
|
||||
|
||||
CalculatorDesigner::~CalculatorDesigner() { delete ui; }
|
||||
|
||||
void CalculatorDesigner::onDigit(int digit) {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
const QString token = currentOperandToken();
|
||||
if (token == QStringLiteral("0")) {
|
||||
m_expression.chop(1); // 用新数字替换孤立的前导 0,避免出现 "007"
|
||||
} else if (currentOperandDigitCount() >= kMaxOperandLength) {
|
||||
return; // 达到单个运算数的长度上限,忽略后续数字
|
||||
}
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QString::number(digit));
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onDot() {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
const QString token = currentOperandToken();
|
||||
if (token.contains(QLatin1Char('.'))) return; // 当前运算数已有小数点
|
||||
|
||||
const int appended = token.isEmpty() ? 2 : 1; // 空token时需要补一个前导 0
|
||||
if (m_expression.size() + appended > kMaxExpressionLength) return;
|
||||
|
||||
if (token.isEmpty()) m_expression.append(QLatin1Char('0'));
|
||||
m_expression.append(QLatin1Char('.'));
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onOperator(QChar op) {
|
||||
if (m_error) return;
|
||||
if (m_resultShown) {
|
||||
m_expression = m_lastResultText;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
if (m_expression.isEmpty()) {
|
||||
if (op != QLatin1Char('-')) return; // 表达式开头只允许负号(单目负号)
|
||||
} else {
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (last == QLatin1Char('(')) {
|
||||
if (op != QLatin1Char('-')) return; // 左括号后只允许负号
|
||||
} else if (isOperatorChar(last)) {
|
||||
m_expression.chop(1); // 连续按运算符时,用新的运算符替换上一个
|
||||
}
|
||||
}
|
||||
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
m_expression.append(op);
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onLeftParen() {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
if (!m_expression.isEmpty()) {
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (!isOperatorChar(last) && last != QLatin1Char('(')) {
|
||||
return; // 数字或右括号后不支持隐式乘法,忽略
|
||||
}
|
||||
}
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QLatin1Char('('));
|
||||
++m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onRightParen() {
|
||||
if (m_error) return;
|
||||
if (m_resultShown) return;
|
||||
if (m_openParenCount <= 0 || m_expression.isEmpty()) return;
|
||||
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (isOperatorChar(last) || last == QLatin1Char('(')) return; // 括号内需要完整子表达式
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QLatin1Char(')'));
|
||||
--m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onEquals() {
|
||||
if (m_error) return;
|
||||
if (m_expression.isEmpty()) return;
|
||||
|
||||
QString toEvaluate = m_expression;
|
||||
for (int i = 0; i < m_openParenCount; ++i) toEvaluate.append(QLatin1Char(')')); // 自动补全未闭合括号
|
||||
|
||||
const ExpressionEvaluator::Result result = ExpressionEvaluator::evaluate(toEvaluate);
|
||||
if (!result.ok) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
|
||||
const QString resultText = QString::number(result.value, 'g', ExpressionEvaluator::kResultPrecision);
|
||||
m_expression = toEvaluate;
|
||||
m_lastResultText = resultText;
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = true;
|
||||
ui->expressionEdit->setPlainText(m_expression + QStringLiteral(" = ") + resultText);
|
||||
ui->display->setText(resultText);
|
||||
|
||||
// 【新增】通知外壳(历史停靠窗):本次求值的表达式与结果。
|
||||
emit evaluated(m_expression, resultText);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onClear() {
|
||||
m_expression.clear();
|
||||
m_lastResultText.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
m_error = false;
|
||||
ui->expressionEdit->clear();
|
||||
ui->display->setText(QStringLiteral("0"));
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onBackspace() {
|
||||
if (m_error) { onClear(); return; }
|
||||
if (m_resultShown) return; // 结果已展示,需先清空或继续输入新表达式
|
||||
if (m_expression.isEmpty()) return;
|
||||
|
||||
const QChar removed = m_expression.at(m_expression.size() - 1);
|
||||
m_expression.chop(1);
|
||||
if (removed == QLatin1Char('(')) --m_openParenCount;
|
||||
else if (removed == QLatin1Char(')')) ++m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::displayText() const { return ui->display->text(); }
|
||||
|
||||
QString CalculatorDesigner::expressionText() const { return ui->expressionEdit->toPlainText(); }
|
||||
|
||||
// 【新增】把一条历史表达式回填到编辑区:重置状态机并把表达式原样载入,
|
||||
// 未闭合括号数按字符重新统计。调用后用户可直接按 = 重新求值,或继续编辑。
|
||||
void CalculatorDesigner::loadExpression(const QString& expr) {
|
||||
if (m_error) onClear();
|
||||
m_expression = expr;
|
||||
m_openParenCount = 0;
|
||||
for (const QChar ch : m_expression) {
|
||||
if (ch == QLatin1Char('(')) ++m_openParenCount;
|
||||
else if (ch == QLatin1Char(')')) --m_openParenCount;
|
||||
}
|
||||
if (m_openParenCount < 0) m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
m_lastResultText.clear();
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::currentOperandToken() const {
|
||||
int start = m_expression.size();
|
||||
while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
|
||||
return m_expression.mid(start);
|
||||
}
|
||||
|
||||
int CalculatorDesigner::currentOperandDigitCount() const {
|
||||
const QString token = currentOperandToken();
|
||||
int count = 0;
|
||||
for (const QChar ch : token) {
|
||||
if (ch.isDigit()) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::lastNumberToken() const {
|
||||
int end = m_expression.size();
|
||||
while (end > 0 && !isDigitOrDotChar(m_expression.at(end - 1))) --end; // 跳过末尾的运算符/括号
|
||||
int start = end;
|
||||
while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
|
||||
return m_expression.mid(start, end - start);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::refreshDisplays() {
|
||||
ui->expressionEdit->setPlainText(m_expression);
|
||||
const QString token = lastNumberToken();
|
||||
ui->display->setText(token.isEmpty() ? QStringLiteral("0") : token);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::showError() {
|
||||
ui->display->setText(QStringLiteral("Error"));
|
||||
m_error = true;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <QChar>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
namespace Ui {
|
||||
class CalculatorForm;
|
||||
}
|
||||
QT_END_NAMESPACE
|
||||
|
||||
// CalculatorDesigner —— 计算器面板(QWidget)。
|
||||
// 【Day3 calculator_mainwindow】相对 ../calculator_designer_ext/ 版仅两处改动:
|
||||
// ① 新增 evaluated(expression,result) 信号:按 = 求值成功后发出,供 MainWindow
|
||||
// 的历史停靠窗订阅(演示「给已有部件加信号,与外壳通信」)。
|
||||
// ② 新增 loadExpression(expr):把一条历史表达式回填到编辑区(演示「部件对外
|
||||
// 提供 API」,配合历史双击回填)。
|
||||
// 其余输入校验/状态机/求值逻辑一字未改。
|
||||
class CalculatorDesigner : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit CalculatorDesigner(QWidget* parent = nullptr);
|
||||
~CalculatorDesigner() override;
|
||||
|
||||
// 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator_designer_ext/ 版一字不差。
|
||||
void onDigit(int digit);
|
||||
void onDot();
|
||||
void onOperator(QChar op);
|
||||
void onLeftParen();
|
||||
void onRightParen();
|
||||
void onEquals();
|
||||
void onClear();
|
||||
void onBackspace();
|
||||
|
||||
QString displayText() const;
|
||||
QString expressionText() const;
|
||||
|
||||
// 【新增】把一条历史表达式回填到编辑区(历史双击时调用)。
|
||||
void loadExpression(const QString& expr);
|
||||
|
||||
signals:
|
||||
// 【新增】按 = 求值成功后发出,参数为(自动补全括号后的表达式, 结果文本)。
|
||||
void evaluated(const QString& expression, const QString& result);
|
||||
|
||||
private:
|
||||
// 单个运算数(数字串)允许的最大长度,要求不得小于 10 位。
|
||||
static constexpr int kMaxOperandLength = 15;
|
||||
static_assert(kMaxOperandLength >= 10, "单个运算数的最大长度不得小于 10 位");
|
||||
// 表达式总长度上限,要求不得低于 100 个字符。
|
||||
static constexpr int kMaxExpressionLength = 200;
|
||||
static_assert(kMaxExpressionLength >= 100, "表达式总长度上限不得低于 100 个字符");
|
||||
|
||||
QString currentOperandToken() const;
|
||||
int currentOperandDigitCount() const;
|
||||
QString lastNumberToken() const;
|
||||
void refreshDisplays();
|
||||
void showError();
|
||||
|
||||
Ui::CalculatorForm* ui;
|
||||
QString m_expression; // 当前正在编辑的表达式,例如 "12+3*(4-2"
|
||||
QString m_lastResultText; // 上一次成功计算得到的结果文本
|
||||
int m_openParenCount = 0; // 尚未闭合的左括号数量
|
||||
bool m_resultShown = false; // 显示区当前展示的是计算结果而非正在输入的数字
|
||||
bool m_error = false;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
#include "expressionevaluator.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// 递归下降解析器:expression := term (('+'|'-') term)*
|
||||
// term := factor (('*'|'/') factor)*
|
||||
// factor := ('+'|'-') factor | '(' expression ')' | number
|
||||
// 括号会先被完整求值,天然实现"优先级提升";乘除法在 term 层处理,
|
||||
// 天然高于 expression 层的加减法。
|
||||
// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差。
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const QString& text) : m_text(text) {}
|
||||
|
||||
ExpressionEvaluator::Result parse() {
|
||||
ExpressionEvaluator::Result result;
|
||||
skipSpaces();
|
||||
if (atEnd()) {
|
||||
result.errorMessage = QStringLiteral("表达式为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
const double value = parseExpression(ok);
|
||||
skipSpaces();
|
||||
if (!ok) {
|
||||
result.errorMessage = m_error;
|
||||
return result;
|
||||
}
|
||||
if (!atEnd()) {
|
||||
result.errorMessage = QStringLiteral("表达式包含无法识别的字符");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ok = true;
|
||||
result.value = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
private:
|
||||
double parseExpression(bool& ok) {
|
||||
double value = parseTerm(ok);
|
||||
while (ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('+')) {
|
||||
advance();
|
||||
value += parseTerm(ok);
|
||||
} else if (peek() == QLatin1Char('-')) {
|
||||
advance();
|
||||
value -= parseTerm(ok);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double parseTerm(bool& ok) {
|
||||
double value = parseFactor(ok);
|
||||
while (ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('*')) {
|
||||
advance();
|
||||
value *= parseFactor(ok);
|
||||
} else if (peek() == QLatin1Char('/')) {
|
||||
advance();
|
||||
const double rhs = parseFactor(ok);
|
||||
if (!ok) return 0.0;
|
||||
if (rhs == 0.0) {
|
||||
fail(QStringLiteral("除数不能为零"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
value /= rhs;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double parseFactor(bool& ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('+')) {
|
||||
advance();
|
||||
return parseFactor(ok);
|
||||
}
|
||||
if (peek() == QLatin1Char('-')) {
|
||||
advance();
|
||||
return -parseFactor(ok);
|
||||
}
|
||||
if (peek() == QLatin1Char('(')) {
|
||||
advance();
|
||||
const double value = parseExpression(ok);
|
||||
if (!ok) return 0.0;
|
||||
skipSpaces();
|
||||
if (peek() != QLatin1Char(')')) {
|
||||
fail(QStringLiteral("括号不匹配"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
advance();
|
||||
return value;
|
||||
}
|
||||
return parseNumber(ok);
|
||||
}
|
||||
|
||||
double parseNumber(bool& ok) {
|
||||
skipSpaces();
|
||||
const int start = m_pos;
|
||||
bool hasDigits = false;
|
||||
while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
|
||||
if (!atEnd() && peek() == QLatin1Char('.')) {
|
||||
advance();
|
||||
while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
|
||||
}
|
||||
if (!hasDigits) {
|
||||
fail(QStringLiteral("表达式格式错误,缺少数字"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
const QString token = m_text.mid(start, m_pos - start);
|
||||
bool numOk = false;
|
||||
const double value = token.toDouble(&numOk);
|
||||
if (!numOk) {
|
||||
fail(QStringLiteral("数字格式错误"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void skipSpaces() { while (!atEnd() && peek().isSpace()) advance(); }
|
||||
bool atEnd() const { return m_pos >= m_text.size(); }
|
||||
QChar peek() const { return atEnd() ? QChar() : m_text.at(m_pos); }
|
||||
void advance() { ++m_pos; }
|
||||
void fail(const QString& message, bool& ok) { ok = false; m_error = message; }
|
||||
|
||||
const QString& m_text;
|
||||
int m_pos = 0;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ExpressionEvaluator::Result ExpressionEvaluator::evaluate(const QString& expression) {
|
||||
Parser parser(expression);
|
||||
return parser.parse();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
// 对形如 "1+2*(3-4)" 的中缀表达式求值:
|
||||
// 乘除优先级高于加减,括号可提升优先级。
|
||||
// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差,
|
||||
// 求值逻辑独立于界面外壳——Day3 把 QWidget 换成 QMainWindow,本类不动。
|
||||
class ExpressionEvaluator {
|
||||
public:
|
||||
struct Result {
|
||||
bool ok = false;
|
||||
double value = 0.0;
|
||||
QString errorMessage;
|
||||
};
|
||||
|
||||
// 结果显示精度(有效数字位数),要求不得小于 10。
|
||||
static constexpr int kResultPrecision = 12;
|
||||
static_assert(kResultPrecision >= 10, "结果显示精度不得小于 10 位有效数字");
|
||||
|
||||
static Result evaluate(const QString& expression);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// ============================================================================
|
||||
// calculator_mainwindow —— Day3 上午版程序入口
|
||||
// 对应教案 OUTLINE_4DAY.md Day3 上午「QMainWindow / 对话框 QDialog」。
|
||||
//
|
||||
// 把 Day2 增强版计算器(CalculatorDesigner,QWidget)装进 QMainWindow:
|
||||
// - 菜单栏(文件/编辑/视图/帮助,纯代码 + 显式 connect)
|
||||
// - 历史停靠窗(QDockWidget + QListWidget,订阅 evaluated 信号)
|
||||
// - 状态栏(就绪 / 结果临时提示)
|
||||
// 离屏自测覆盖 Day2 全部 9 组计算用例 + Day3 新增(历史追加/双击回填/清空)。
|
||||
// ============================================================================
|
||||
#include <QApplication>
|
||||
#include <QTimer>
|
||||
|
||||
#include "mainwindow.h"
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
MainWindow w;
|
||||
w.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(0, &w, [&w] {
|
||||
const bool ok = w.runSelfTest();
|
||||
QCoreApplication::exit(ok ? 0 : 1);
|
||||
});
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// ============================================================================
|
||||
// mainwindow.cpp —— Day3 上午版实现
|
||||
// 详见 mainwindow.h 顶部注释。本文件只做外壳组装,不含任何计算逻辑。
|
||||
// ============================================================================
|
||||
#include "mainwindow.h"
|
||||
#include "calculatordesigner.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QDebug>
|
||||
#include <QDockWidget>
|
||||
#include <QKeySequence>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
|
||||
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
setWindowTitle(QStringLiteral("计算器(QMainWindow 版)"));
|
||||
|
||||
// ★ 中心部件必须显式安放(Day3 上午坑卡:直接往 QMainWindow 上 addWidget
|
||||
// 布局行为不可预期)。CalculatorDesigner 是 QWidget,原样塞进去即可。
|
||||
m_calc = new CalculatorDesigner(this);
|
||||
setCentralWidget(m_calc);
|
||||
|
||||
buildMenu();
|
||||
buildDock();
|
||||
buildStatusBar();
|
||||
|
||||
// 计算器求值成功 → 历史停靠窗追加一条(Day2 信号槽的复用:给已有部件
|
||||
// 加 evaluated 信号,外壳订阅它)。
|
||||
connect(m_calc, &CalculatorDesigner::evaluated, this, &MainWindow::onEvaluated);
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() = default;
|
||||
|
||||
// ---- 菜单栏(纯代码 + 显式 connect,对照 mainwindow_showcase 的 .ui 自动连接)----
|
||||
void MainWindow::buildMenu() {
|
||||
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("文件(&F)"));
|
||||
m_actQuit = fileMenu->addAction(QStringLiteral("退出(&Q)"));
|
||||
m_actQuit->setShortcut(QKeySequence(QStringLiteral("Ctrl+Q")));
|
||||
connect(m_actQuit, &QAction::triggered, this, &QWidget::close);
|
||||
|
||||
QMenu* editMenu = menuBar()->addMenu(QStringLiteral("编辑(&E)"));
|
||||
m_actClearHistory = editMenu->addAction(QStringLiteral("清空历史(&C)"));
|
||||
connect(m_actClearHistory, &QAction::triggered, this, &MainWindow::onClearHistory);
|
||||
|
||||
QMenu* viewMenu = menuBar()->addMenu(QStringLiteral("视图(&V)"));
|
||||
m_actToggleHistory = viewMenu->addAction(QStringLiteral("显示历史(&H)"));
|
||||
m_actToggleHistory->setCheckable(true);
|
||||
m_actToggleHistory->setChecked(true);
|
||||
connect(m_actToggleHistory, &QAction::toggled, this, &MainWindow::onToggleHistoryVisible);
|
||||
|
||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("帮助(&H)"));
|
||||
m_actAbout = helpMenu->addAction(QStringLiteral("关于(&A)"));
|
||||
// ★ 显式 connect:忘了这一行,菜单显示正常、点了没反应(Day3 上午坑卡)。
|
||||
connect(m_actAbout, &QAction::triggered, this, &MainWindow::onAbout);
|
||||
}
|
||||
|
||||
// ---- 历史停靠窗 ----
|
||||
void MainWindow::buildDock() {
|
||||
m_history = new QListWidget(this);
|
||||
m_history->setAlternatingRowColors(true);
|
||||
m_dock = new QDockWidget(QStringLiteral("历史"), this);
|
||||
m_dock->setObjectName(QStringLiteral("historyDock")); // 供 saveState/restoreState
|
||||
m_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea);
|
||||
m_dock->setWidget(m_history);
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_dock);
|
||||
// 双击历史条目 → 回填表达式(QListWidget 信号槽)
|
||||
connect(m_history, &QListWidget::itemDoubleClicked, this, &MainWindow::onHistoryDoubleClicked);
|
||||
}
|
||||
|
||||
void MainWindow::buildStatusBar() {
|
||||
statusBar()->showMessage(QStringLiteral("就绪"));
|
||||
}
|
||||
|
||||
void MainWindow::refreshStatus(const QString& msg) {
|
||||
statusBar()->showMessage(msg, 3000); // 临时提示,3 秒后恢复
|
||||
}
|
||||
|
||||
// ---- 槽 ----
|
||||
void MainWindow::onAbout() {
|
||||
// 标准对话框:QMessageBox::about 是模态的,exec() 内部阻塞等用户关闭。
|
||||
QMessageBox::about(this, QStringLiteral("关于"),
|
||||
QStringLiteral(
|
||||
"<b>计算器(QMainWindow 版)</b><br><br>"
|
||||
"Day3 上午教学参考实现:把 Day2 增强版计算器(表达式求值 + 括号)"
|
||||
"装进 QMainWindow,演示菜单栏 / 状态栏 / 历史停靠窗。<br><br>"
|
||||
"对应教案 OUTLINE_4DAY.md Day3 上午。"));
|
||||
}
|
||||
|
||||
void MainWindow::onClearHistory() {
|
||||
m_history->clear();
|
||||
refreshStatus(QStringLiteral("已清空历史"));
|
||||
}
|
||||
|
||||
void MainWindow::onToggleHistoryVisible(bool checked) {
|
||||
m_dock->setVisible(checked);
|
||||
}
|
||||
|
||||
void MainWindow::onEvaluated(const QString& expr, const QString& result) {
|
||||
m_history->addItem(expr + QStringLiteral(" = ") + result);
|
||||
refreshStatus(QStringLiteral("结果: ") + result);
|
||||
}
|
||||
|
||||
void MainWindow::onHistoryDoubleClicked(QListWidgetItem* item) {
|
||||
// 条目文本形如 "expr = result",取最后一个 " = " 左侧作为表达式回填。
|
||||
// 用 lastIndexOf 而非 indexOf:result 文本里一般不含 " = ",但用 lastIndexOf
|
||||
// 更稳妥(避免表达式里万一出现 " = ",实际上表达式不会含 =)。
|
||||
const QString text = item->text();
|
||||
const int idx = text.lastIndexOf(QStringLiteral(" = "));
|
||||
if (idx < 0) return;
|
||||
m_calc->loadExpression(text.left(idx));
|
||||
refreshStatus(QStringLiteral("已回填表达式"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 自测 ----
|
||||
bool MainWindow::runSelfTest() {
|
||||
auto expect = [](const char* label, bool cond) -> bool {
|
||||
if (!cond) { qCritical() << "自测失败:" << label; return false; }
|
||||
qInfo() << "自测通过:" << label; return true;
|
||||
};
|
||||
auto expectEq = [](const char* label, const QString& actual, const QString& expected) -> bool {
|
||||
if (actual != expected) {
|
||||
qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
|
||||
return false;
|
||||
}
|
||||
qInfo() << "自测通过:" << label << "=>" << actual;
|
||||
return true;
|
||||
};
|
||||
|
||||
// —— Day2 计算 9 组用例(验证升级外壳后计算行为不变)——
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(7); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3); m_calc->onEquals();
|
||||
if (!expectEq("7 + 3 =", m_calc->displayText(), QStringLiteral("10"))) return false;
|
||||
if (!expect("历史追加第 1 条", m_history->count() == 1)) return false;
|
||||
if (!expectEq("历史条目文本", m_history->item(0)->text(), QStringLiteral("7+3 = 10"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(6); m_calc->onOperator(QLatin1Char('/')); m_calc->onDigit(0); m_calc->onEquals();
|
||||
if (!expectEq("6 ÷ 0 =", m_calc->displayText(), QStringLiteral("Error"))) return false;
|
||||
if (!expect("除零 Error 不进历史", m_history->count() == 1)) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(4); m_calc->onDot(); m_calc->onDigit(5);
|
||||
m_calc->onOperator(QLatin1Char('+'));
|
||||
m_calc->onDigit(0); m_calc->onDot(); m_calc->onDigit(5);
|
||||
m_calc->onEquals();
|
||||
if (!expectEq("4.5 + 0.5 =", m_calc->displayText(), QStringLiteral("5"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(1); m_calc->onDigit(2); m_calc->onBackspace();
|
||||
if (!expectEq("12 退格", m_calc->displayText(), QStringLiteral("1"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+'));
|
||||
m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('+'));
|
||||
m_calc->onDigit(4); m_calc->onEquals();
|
||||
if (!expectEq("2 + 3 + 4 =", m_calc->displayText(), QStringLiteral("9"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+'));
|
||||
m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('*'));
|
||||
m_calc->onDigit(4); m_calc->onEquals();
|
||||
if (!expectEq("2 + 3 × 4 =", m_calc->displayText(), QStringLiteral("14"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onLeftParen();
|
||||
m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3);
|
||||
m_calc->onRightParen();
|
||||
m_calc->onOperator(QLatin1Char('*'));
|
||||
m_calc->onDigit(4); m_calc->onEquals();
|
||||
if (!expectEq("(2 + 3) × 4 =", m_calc->displayText(), QStringLiteral("20"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+'));
|
||||
m_calc->onLeftParen();
|
||||
m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('*'));
|
||||
m_calc->onLeftParen();
|
||||
m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(4);
|
||||
m_calc->onRightParen();
|
||||
m_calc->onRightParen();
|
||||
m_calc->onEquals();
|
||||
if (!expectEq("1 + (2 × (3 + 4)) =", m_calc->displayText(), QStringLiteral("15"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onLeftParen();
|
||||
m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(2);
|
||||
m_calc->onEquals();
|
||||
if (!expectEq("(1 + 2 = 自动补全", m_calc->displayText(), QStringLiteral("3"))) return false;
|
||||
|
||||
// 9 组用例里:第 4 组(12 退格)无 onEquals、第 2 组(6÷0)Error 不 emit,
|
||||
// 其余 7 次 onEquals 各进历史一条。
|
||||
if (!expect("历史累计 7 条", m_history->count() == 7)) return false;
|
||||
|
||||
// —— Day3 新增:历史双击回填 ——
|
||||
QListWidgetItem* last = m_history->item(m_history->count() - 1);
|
||||
if (!expectEq("最后一条历史", last->text(), QStringLiteral("(1+2) = 3"))) return false;
|
||||
onHistoryDoubleClicked(last);
|
||||
if (!expectEq("双击回填表达式区", m_calc->expressionText(), QStringLiteral("(1+2)"))) return false;
|
||||
|
||||
// —— Day3 新增:清空历史 ——
|
||||
onClearHistory();
|
||||
if (!expect("清空历史后 count==0", m_history->count() == 0)) return false;
|
||||
|
||||
// —— Day3 新增:关于 action 已创建并显式连接 ——
|
||||
// (QMessageBox::about 是模态弹窗,离屏下不真正触发,GUI 弹窗验证靠现场演示)
|
||||
if (!expect("关于 action 已创建", m_actAbout != nullptr)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// ============================================================================
|
||||
// mainwindow.h —— Day3 上午版:把增强版计算器装进 QMainWindow
|
||||
//
|
||||
// 对应教案 OUTLINE_4DAY.md Day3 上午「QMainWindow / 对话框 QDialog」。
|
||||
// 本类只做「外壳」:菜单栏 / 状态栏 / 历史停靠窗 + 把 CalculatorDesigner
|
||||
// 作为中心部件 setCentralWidget。计算器面板本身的输入/求值逻辑一字未改
|
||||
// (见 calculatordesigner.cpp)——这正是「界面外壳升级、核心部件不动」
|
||||
// 的关注点分离演示。
|
||||
//
|
||||
// 与 mainwindow_showcase 的风格对照(同为 Day3 上午教学载体):
|
||||
// - mainwindow_showcase:.ui 表单定义主窗体结构 + on_<obj>_<sig> 自动连接
|
||||
// - 本例:纯代码建菜单/停靠窗 + 显式 connect()
|
||||
// 两种风格并列,前者突出「Designer 快速搭骨架」,后者突出「QAction 必须
|
||||
// connect 才生效」(Day3 上午坑卡:菜单显示正常点了没反应)。
|
||||
// ============================================================================
|
||||
#ifndef CALCULATOR_MAINWINDOW_H
|
||||
#define CALCULATOR_MAINWINDOW_H
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
class CalculatorDesigner;
|
||||
class QListWidget;
|
||||
class QListWidgetItem;
|
||||
class QDockWidget;
|
||||
class QAction;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
// 离屏自测:复用 Day2 的 9 组计算用例 + Day3 新增(历史追加 / 双击回填 /
|
||||
// 关于对话框触发)。返回全过为 true,供 main.cpp 决定退出码。
|
||||
bool runSelfTest();
|
||||
|
||||
private slots:
|
||||
void onAbout(); // 帮助→关于:QMessageBox::about
|
||||
void onClearHistory(); // 编辑→清空历史
|
||||
void onToggleHistoryVisible(bool checked); // 视图→显示历史
|
||||
void onEvaluated(const QString& expr, const QString& result); // 计算器求值→追加历史
|
||||
void onHistoryDoubleClicked(QListWidgetItem* item); // 双击历史→回填表达式
|
||||
|
||||
private:
|
||||
void buildMenu();
|
||||
void buildDock();
|
||||
void buildStatusBar();
|
||||
void refreshStatus(const QString& msg);
|
||||
|
||||
CalculatorDesigner* m_calc = nullptr;
|
||||
QListWidget* m_history = nullptr;
|
||||
QDockWidget* m_dock = nullptr;
|
||||
QAction* m_actQuit = nullptr;
|
||||
QAction* m_actClearHistory = nullptr;
|
||||
QAction* m_actToggleHistory = nullptr;
|
||||
QAction* m_actAbout = nullptr;
|
||||
};
|
||||
|
||||
#endif // CALCULATOR_MAINWINDOW_H
|
||||
@@ -0,0 +1,200 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>CalculatorForm</class>
|
||||
<widget class="QWidget" name="CalculatorForm">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>358</width>
|
||||
<height>320</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>计算器面板</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<widget class="QTextEdit" name="expressionEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>80</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="display">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="keypadLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="btn7">
|
||||
<property name="text">
|
||||
<string>7</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="btn8">
|
||||
<property name="text">
|
||||
<string>8</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="btn9">
|
||||
<property name="text">
|
||||
<string>9</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="btnDiv">
|
||||
<property name="text">
|
||||
<string>÷</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="btn4">
|
||||
<property name="text">
|
||||
<string>4</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="btn5">
|
||||
<property name="text">
|
||||
<string>5</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="btn6">
|
||||
<property name="text">
|
||||
<string>6</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QPushButton" name="btnMul">
|
||||
<property name="text">
|
||||
<string>×</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="btn1">
|
||||
<property name="text">
|
||||
<string>1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="btn2">
|
||||
<property name="text">
|
||||
<string>2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QPushButton" name="btn3">
|
||||
<property name="text">
|
||||
<string>3</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="btnMinus">
|
||||
<property name="text">
|
||||
<string>-</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="btn0">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="btnDot">
|
||||
<property name="text">
|
||||
<string>.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="btnEquals">
|
||||
<property name="text">
|
||||
<string>=</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<widget class="QPushButton" name="btnPlus">
|
||||
<property name="text">
|
||||
<string>+</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QPushButton" name="btnClear">
|
||||
<property name="text">
|
||||
<string>C</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QPushButton" name="btnBackspace">
|
||||
<property name="text">
|
||||
<string>⌫</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QPushButton" name="btnLeftParen">
|
||||
<property name="text">
|
||||
<string>(</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="3">
|
||||
<widget class="QPushButton" name="btnRightParen">
|
||||
<property name="text">
|
||||
<string>)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,61 @@
|
||||
# Day3 下午参考实现:计算器 QMainWindow + QSS 双主题
|
||||
|
||||
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day3 下午「布局/控件 + QSS 美化」
|
||||
> + [`ASSIGNMENTS.md`](../../ASSIGNMENTS.md) Day3 下午作业。手工维护,非生成产物。
|
||||
|
||||
## 定位:上午版 + QSS 美化
|
||||
|
||||
以 [`../calculator_mainwindow/`](../calculator_mainwindow/README.md)(Day3 上午版)为
|
||||
起点,**只加 QSS**:按键按功能分色、`:hover`/`:pressed` 视觉反馈、深/浅双主题切换。
|
||||
菜单/停靠窗/状态栏/计算逻辑与上午版完全一致——**QSS 与逻辑正交**,这正是 Day3 下午
|
||||
「美化」环节要传达的核心。
|
||||
|
||||
## 与上午版的差异(集中在 `mainwindow.{h,cpp}`)
|
||||
|
||||
| 改动 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| 双主题 QSS 字符串 | `mainwindow.cpp` 顶部 `kLightQss` / `kDarkQss` | 按键分色 + 伪状态 |
|
||||
| 视图菜单「深色主题」 | `buildMenu()` | `checkable` QAction,`toggled` → `onToggleTheme` |
|
||||
| `applyStyle(bool dark)` | `mainwindow.cpp` | `qApp->setStyleSheet(...)` 全局切换 |
|
||||
| 自测加主题断言 | `runSelfTest()` | 验证切换后 `styleSheet()` 含对应主题标识 |
|
||||
|
||||
`calculatordesigner.{h,cpp}` / `expressionevaluator.{h,cpp}` / `widget.ui` 与上午版
|
||||
完全一致(自包含复制)。
|
||||
|
||||
## QSS 教学要点(对应 OUTLINE_4DAY.md Day3 下午)
|
||||
|
||||
1. **ID 选择器认 objectName**:`QPushButton#btnEquals { ... }` 命中的前提是控件
|
||||
`setObjectName("btnEquals")`。本例的 objectName 在 `widget.ui` 里已定义
|
||||
(`<widget class="QPushButton" name="btnEquals">`),无需再 `setObjectName`——
|
||||
这是「样式写了没生效」头号原因(忘设 objectName)的反面教材。
|
||||
2. **按键分色用功能归类**:数字键(`btn0`–`btn9`,`btnDot`)浅蓝、运算键
|
||||
(`btnPlus/Minus/Mul/Div`)橙、等号(`btnEquals`)绿、括号灰、清空/退格红——
|
||||
用 ID 选择器分组,比给每个按钮单独写样式更易维护。
|
||||
3. **伪状态**:`:hover` / `:pressed` 不用手写鼠标事件就能做交互反馈。
|
||||
4. **`qApp->setStyleSheet` 全局 vs `widget->setStyleSheet` 局部**:本例用全局,
|
||||
一次切换全部生效。坑卡:全局样式会被 widget 级样式覆盖,混用易级联失控
|
||||
(OUTLINE_4DAY.md Day3 下午「QSS 级联失控」卡)。
|
||||
5. **主题切换不碰逻辑**:`applyStyle` 只改 `styleSheet`,不动任何业务代码——
|
||||
QSS 与逻辑正交的可维护性演示。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
cmake --build build --target qt_calculator_mainwindow_qss
|
||||
|
||||
# 离屏自测(计算用例 + 主题切换改变 styleSheet 的断言,退出码 0 即全过)
|
||||
PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
QT_QPA_PLATFORM=offscreen \
|
||||
./build/docs/teaching/examples/qt_calculator_mainwindow_qss.exe; echo "exit=$?"
|
||||
|
||||
# 有 GUI 环境:勾选「视图→深色主题」看整体配色切换
|
||||
./build/docs/teaching/examples/qt_calculator_mainwindow_qss.exe
|
||||
```
|
||||
|
||||
## 验收对照(ASSIGNMENTS.md Day3 下午)
|
||||
|
||||
- **基础版**:数字/运算/等号 3 色区分 ✓(本例 5 色,含括号/清空退格)
|
||||
- **基础版**:布局随窗口缩放不重叠 ✓(`QGridLayout` + `QMainWindow` 区域协议)
|
||||
- **挑战版**:`:hover`/`:pressed` ✓
|
||||
- **挑战版**:结果显示框套样式 ✓(`display`/`expressionEdit` 米黄背景 + 圆角)
|
||||
- **挑战版**:深/浅双主题切换 ✓(视图菜单「深色主题」)
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "calculatordesigner.h"
|
||||
|
||||
#include <QPushButton>
|
||||
|
||||
#include "expressionevaluator.h"
|
||||
#include "ui_widget.h"
|
||||
|
||||
namespace {
|
||||
bool isOperatorChar(QChar ch) {
|
||||
return ch == QLatin1Char('+') || ch == QLatin1Char('-') ||
|
||||
ch == QLatin1Char('*') || ch == QLatin1Char('/');
|
||||
}
|
||||
|
||||
bool isDigitOrDotChar(QChar ch) {
|
||||
return ch.isDigit() || ch == QLatin1Char('.');
|
||||
}
|
||||
} // namespace
|
||||
|
||||
CalculatorDesigner::CalculatorDesigner(QWidget* parent)
|
||||
: QWidget(parent), ui(new Ui::CalculatorForm) {
|
||||
ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
|
||||
|
||||
connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
|
||||
connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
|
||||
connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
|
||||
connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
|
||||
connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
|
||||
connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
|
||||
connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
|
||||
connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
|
||||
connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
|
||||
connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
|
||||
connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
|
||||
|
||||
connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
|
||||
connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
|
||||
connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
|
||||
connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
|
||||
|
||||
connect(ui->btnLeftParen, &QPushButton::clicked, this, &CalculatorDesigner::onLeftParen);
|
||||
connect(ui->btnRightParen, &QPushButton::clicked, this, &CalculatorDesigner::onRightParen);
|
||||
|
||||
connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
|
||||
connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
|
||||
connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
|
||||
|
||||
onClear();
|
||||
}
|
||||
|
||||
CalculatorDesigner::~CalculatorDesigner() { delete ui; }
|
||||
|
||||
void CalculatorDesigner::onDigit(int digit) {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
const QString token = currentOperandToken();
|
||||
if (token == QStringLiteral("0")) {
|
||||
m_expression.chop(1); // 用新数字替换孤立的前导 0,避免出现 "007"
|
||||
} else if (currentOperandDigitCount() >= kMaxOperandLength) {
|
||||
return; // 达到单个运算数的长度上限,忽略后续数字
|
||||
}
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QString::number(digit));
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onDot() {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
const QString token = currentOperandToken();
|
||||
if (token.contains(QLatin1Char('.'))) return; // 当前运算数已有小数点
|
||||
|
||||
const int appended = token.isEmpty() ? 2 : 1; // 空token时需要补一个前导 0
|
||||
if (m_expression.size() + appended > kMaxExpressionLength) return;
|
||||
|
||||
if (token.isEmpty()) m_expression.append(QLatin1Char('0'));
|
||||
m_expression.append(QLatin1Char('.'));
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onOperator(QChar op) {
|
||||
if (m_error) return;
|
||||
if (m_resultShown) {
|
||||
m_expression = m_lastResultText;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
if (m_expression.isEmpty()) {
|
||||
if (op != QLatin1Char('-')) return; // 表达式开头只允许负号(单目负号)
|
||||
} else {
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (last == QLatin1Char('(')) {
|
||||
if (op != QLatin1Char('-')) return; // 左括号后只允许负号
|
||||
} else if (isOperatorChar(last)) {
|
||||
m_expression.chop(1); // 连续按运算符时,用新的运算符替换上一个
|
||||
}
|
||||
}
|
||||
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
m_expression.append(op);
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onLeftParen() {
|
||||
if (m_error) onClear();
|
||||
if (m_resultShown) {
|
||||
m_expression.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
}
|
||||
|
||||
if (!m_expression.isEmpty()) {
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (!isOperatorChar(last) && last != QLatin1Char('(')) {
|
||||
return; // 数字或右括号后不支持隐式乘法,忽略
|
||||
}
|
||||
}
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QLatin1Char('('));
|
||||
++m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onRightParen() {
|
||||
if (m_error) return;
|
||||
if (m_resultShown) return;
|
||||
if (m_openParenCount <= 0 || m_expression.isEmpty()) return;
|
||||
|
||||
const QChar last = m_expression.at(m_expression.size() - 1);
|
||||
if (isOperatorChar(last) || last == QLatin1Char('(')) return; // 括号内需要完整子表达式
|
||||
if (m_expression.size() >= kMaxExpressionLength) return;
|
||||
|
||||
m_expression.append(QLatin1Char(')'));
|
||||
--m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onEquals() {
|
||||
if (m_error) return;
|
||||
if (m_expression.isEmpty()) return;
|
||||
|
||||
QString toEvaluate = m_expression;
|
||||
for (int i = 0; i < m_openParenCount; ++i) toEvaluate.append(QLatin1Char(')')); // 自动补全未闭合括号
|
||||
|
||||
const ExpressionEvaluator::Result result = ExpressionEvaluator::evaluate(toEvaluate);
|
||||
if (!result.ok) {
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
|
||||
const QString resultText = QString::number(result.value, 'g', ExpressionEvaluator::kResultPrecision);
|
||||
m_expression = toEvaluate;
|
||||
m_lastResultText = resultText;
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = true;
|
||||
ui->expressionEdit->setPlainText(m_expression + QStringLiteral(" = ") + resultText);
|
||||
ui->display->setText(resultText);
|
||||
|
||||
// 【新增】通知外壳(历史停靠窗):本次求值的表达式与结果。
|
||||
emit evaluated(m_expression, resultText);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onClear() {
|
||||
m_expression.clear();
|
||||
m_lastResultText.clear();
|
||||
m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
m_error = false;
|
||||
ui->expressionEdit->clear();
|
||||
ui->display->setText(QStringLiteral("0"));
|
||||
}
|
||||
|
||||
void CalculatorDesigner::onBackspace() {
|
||||
if (m_error) { onClear(); return; }
|
||||
if (m_resultShown) return; // 结果已展示,需先清空或继续输入新表达式
|
||||
if (m_expression.isEmpty()) return;
|
||||
|
||||
const QChar removed = m_expression.at(m_expression.size() - 1);
|
||||
m_expression.chop(1);
|
||||
if (removed == QLatin1Char('(')) --m_openParenCount;
|
||||
else if (removed == QLatin1Char(')')) ++m_openParenCount;
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::displayText() const { return ui->display->text(); }
|
||||
|
||||
QString CalculatorDesigner::expressionText() const { return ui->expressionEdit->toPlainText(); }
|
||||
|
||||
// 【新增】把一条历史表达式回填到编辑区:重置状态机并把表达式原样载入,
|
||||
// 未闭合括号数按字符重新统计。调用后用户可直接按 = 重新求值,或继续编辑。
|
||||
void CalculatorDesigner::loadExpression(const QString& expr) {
|
||||
if (m_error) onClear();
|
||||
m_expression = expr;
|
||||
m_openParenCount = 0;
|
||||
for (const QChar ch : m_expression) {
|
||||
if (ch == QLatin1Char('(')) ++m_openParenCount;
|
||||
else if (ch == QLatin1Char(')')) --m_openParenCount;
|
||||
}
|
||||
if (m_openParenCount < 0) m_openParenCount = 0;
|
||||
m_resultShown = false;
|
||||
m_lastResultText.clear();
|
||||
refreshDisplays();
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::currentOperandToken() const {
|
||||
int start = m_expression.size();
|
||||
while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
|
||||
return m_expression.mid(start);
|
||||
}
|
||||
|
||||
int CalculatorDesigner::currentOperandDigitCount() const {
|
||||
const QString token = currentOperandToken();
|
||||
int count = 0;
|
||||
for (const QChar ch : token) {
|
||||
if (ch.isDigit()) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QString CalculatorDesigner::lastNumberToken() const {
|
||||
int end = m_expression.size();
|
||||
while (end > 0 && !isDigitOrDotChar(m_expression.at(end - 1))) --end; // 跳过末尾的运算符/括号
|
||||
int start = end;
|
||||
while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
|
||||
return m_expression.mid(start, end - start);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::refreshDisplays() {
|
||||
ui->expressionEdit->setPlainText(m_expression);
|
||||
const QString token = lastNumberToken();
|
||||
ui->display->setText(token.isEmpty() ? QStringLiteral("0") : token);
|
||||
}
|
||||
|
||||
void CalculatorDesigner::showError() {
|
||||
ui->display->setText(QStringLiteral("Error"));
|
||||
m_error = true;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <QChar>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
namespace Ui {
|
||||
class CalculatorForm;
|
||||
}
|
||||
QT_END_NAMESPACE
|
||||
|
||||
// CalculatorDesigner —— 计算器面板(QWidget)。
|
||||
// 【Day3 calculator_mainwindow】相对 ../calculator_designer_ext/ 版仅两处改动:
|
||||
// ① 新增 evaluated(expression,result) 信号:按 = 求值成功后发出,供 MainWindow
|
||||
// 的历史停靠窗订阅(演示「给已有部件加信号,与外壳通信」)。
|
||||
// ② 新增 loadExpression(expr):把一条历史表达式回填到编辑区(演示「部件对外
|
||||
// 提供 API」,配合历史双击回填)。
|
||||
// 其余输入校验/状态机/求值逻辑一字未改。
|
||||
class CalculatorDesigner : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit CalculatorDesigner(QWidget* parent = nullptr);
|
||||
~CalculatorDesigner() override;
|
||||
|
||||
// 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator_designer_ext/ 版一字不差。
|
||||
void onDigit(int digit);
|
||||
void onDot();
|
||||
void onOperator(QChar op);
|
||||
void onLeftParen();
|
||||
void onRightParen();
|
||||
void onEquals();
|
||||
void onClear();
|
||||
void onBackspace();
|
||||
|
||||
QString displayText() const;
|
||||
QString expressionText() const;
|
||||
|
||||
// 【新增】把一条历史表达式回填到编辑区(历史双击时调用)。
|
||||
void loadExpression(const QString& expr);
|
||||
|
||||
signals:
|
||||
// 【新增】按 = 求值成功后发出,参数为(自动补全括号后的表达式, 结果文本)。
|
||||
void evaluated(const QString& expression, const QString& result);
|
||||
|
||||
private:
|
||||
// 单个运算数(数字串)允许的最大长度,要求不得小于 10 位。
|
||||
static constexpr int kMaxOperandLength = 15;
|
||||
static_assert(kMaxOperandLength >= 10, "单个运算数的最大长度不得小于 10 位");
|
||||
// 表达式总长度上限,要求不得低于 100 个字符。
|
||||
static constexpr int kMaxExpressionLength = 200;
|
||||
static_assert(kMaxExpressionLength >= 100, "表达式总长度上限不得低于 100 个字符");
|
||||
|
||||
QString currentOperandToken() const;
|
||||
int currentOperandDigitCount() const;
|
||||
QString lastNumberToken() const;
|
||||
void refreshDisplays();
|
||||
void showError();
|
||||
|
||||
Ui::CalculatorForm* ui;
|
||||
QString m_expression; // 当前正在编辑的表达式,例如 "12+3*(4-2"
|
||||
QString m_lastResultText; // 上一次成功计算得到的结果文本
|
||||
int m_openParenCount = 0; // 尚未闭合的左括号数量
|
||||
bool m_resultShown = false; // 显示区当前展示的是计算结果而非正在输入的数字
|
||||
bool m_error = false;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
#include "expressionevaluator.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// 递归下降解析器:expression := term (('+'|'-') term)*
|
||||
// term := factor (('*'|'/') factor)*
|
||||
// factor := ('+'|'-') factor | '(' expression ')' | number
|
||||
// 括号会先被完整求值,天然实现"优先级提升";乘除法在 term 层处理,
|
||||
// 天然高于 expression 层的加减法。
|
||||
// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差。
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const QString& text) : m_text(text) {}
|
||||
|
||||
ExpressionEvaluator::Result parse() {
|
||||
ExpressionEvaluator::Result result;
|
||||
skipSpaces();
|
||||
if (atEnd()) {
|
||||
result.errorMessage = QStringLiteral("表达式为空");
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
const double value = parseExpression(ok);
|
||||
skipSpaces();
|
||||
if (!ok) {
|
||||
result.errorMessage = m_error;
|
||||
return result;
|
||||
}
|
||||
if (!atEnd()) {
|
||||
result.errorMessage = QStringLiteral("表达式包含无法识别的字符");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ok = true;
|
||||
result.value = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
private:
|
||||
double parseExpression(bool& ok) {
|
||||
double value = parseTerm(ok);
|
||||
while (ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('+')) {
|
||||
advance();
|
||||
value += parseTerm(ok);
|
||||
} else if (peek() == QLatin1Char('-')) {
|
||||
advance();
|
||||
value -= parseTerm(ok);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double parseTerm(bool& ok) {
|
||||
double value = parseFactor(ok);
|
||||
while (ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('*')) {
|
||||
advance();
|
||||
value *= parseFactor(ok);
|
||||
} else if (peek() == QLatin1Char('/')) {
|
||||
advance();
|
||||
const double rhs = parseFactor(ok);
|
||||
if (!ok) return 0.0;
|
||||
if (rhs == 0.0) {
|
||||
fail(QStringLiteral("除数不能为零"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
value /= rhs;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
double parseFactor(bool& ok) {
|
||||
skipSpaces();
|
||||
if (peek() == QLatin1Char('+')) {
|
||||
advance();
|
||||
return parseFactor(ok);
|
||||
}
|
||||
if (peek() == QLatin1Char('-')) {
|
||||
advance();
|
||||
return -parseFactor(ok);
|
||||
}
|
||||
if (peek() == QLatin1Char('(')) {
|
||||
advance();
|
||||
const double value = parseExpression(ok);
|
||||
if (!ok) return 0.0;
|
||||
skipSpaces();
|
||||
if (peek() != QLatin1Char(')')) {
|
||||
fail(QStringLiteral("括号不匹配"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
advance();
|
||||
return value;
|
||||
}
|
||||
return parseNumber(ok);
|
||||
}
|
||||
|
||||
double parseNumber(bool& ok) {
|
||||
skipSpaces();
|
||||
const int start = m_pos;
|
||||
bool hasDigits = false;
|
||||
while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
|
||||
if (!atEnd() && peek() == QLatin1Char('.')) {
|
||||
advance();
|
||||
while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
|
||||
}
|
||||
if (!hasDigits) {
|
||||
fail(QStringLiteral("表达式格式错误,缺少数字"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
const QString token = m_text.mid(start, m_pos - start);
|
||||
bool numOk = false;
|
||||
const double value = token.toDouble(&numOk);
|
||||
if (!numOk) {
|
||||
fail(QStringLiteral("数字格式错误"), ok);
|
||||
return 0.0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void skipSpaces() { while (!atEnd() && peek().isSpace()) advance(); }
|
||||
bool atEnd() const { return m_pos >= m_text.size(); }
|
||||
QChar peek() const { return atEnd() ? QChar() : m_text.at(m_pos); }
|
||||
void advance() { ++m_pos; }
|
||||
void fail(const QString& message, bool& ok) { ok = false; m_error = message; }
|
||||
|
||||
const QString& m_text;
|
||||
int m_pos = 0;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ExpressionEvaluator::Result ExpressionEvaluator::evaluate(const QString& expression) {
|
||||
Parser parser(expression);
|
||||
return parser.parse();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
// 对形如 "1+2*(3-4)" 的中缀表达式求值:
|
||||
// 乘除优先级高于加减,括号可提升优先级。
|
||||
// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差,
|
||||
// 求值逻辑独立于界面外壳——Day3 把 QWidget 换成 QMainWindow,本类不动。
|
||||
class ExpressionEvaluator {
|
||||
public:
|
||||
struct Result {
|
||||
bool ok = false;
|
||||
double value = 0.0;
|
||||
QString errorMessage;
|
||||
};
|
||||
|
||||
// 结果显示精度(有效数字位数),要求不得小于 10。
|
||||
static constexpr int kResultPrecision = 12;
|
||||
static_assert(kResultPrecision >= 10, "结果显示精度不得小于 10 位有效数字");
|
||||
|
||||
static Result evaluate(const QString& expression);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// ============================================================================
|
||||
// calculator_mainwindow_qss —— Day3 下午版程序入口(上午版 + QSS 双主题)
|
||||
// 对应教案 OUTLINE_4DAY.md Day3 下午「布局/控件 + QSS 美化」。
|
||||
// 与 ../calculator_mainwindow/main.cpp 仅 include 的头不同(本目录自己的),
|
||||
// 逻辑一致:offscreen 时调 MainWindow::runSelfTest(),按返回值决定退出码。
|
||||
// ============================================================================
|
||||
#include <QApplication>
|
||||
#include <QTimer>
|
||||
|
||||
#include "mainwindow.h"
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
MainWindow w;
|
||||
w.show();
|
||||
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(0, &w, [&w] {
|
||||
const bool ok = w.runSelfTest();
|
||||
QCoreApplication::exit(ok ? 0 : 1);
|
||||
});
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// ============================================================================
|
||||
// mainwindow.cpp —— Day3 下午版实现(上午版 + QSS 双主题)
|
||||
// 与 ../calculator_mainwindow/mainwindow.cpp 的差异全部集中在本文件的 QSS 部分
|
||||
// (见 kLightQss / kDarkQss / applyStyle / onToggleTheme / 视图菜单多一项),
|
||||
// 菜单/停靠窗/状态栏/历史/自测的计算用例部分一字未改。
|
||||
// ============================================================================
|
||||
#include "mainwindow.h"
|
||||
#include "calculatordesigner.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QDockWidget>
|
||||
#include <QKeySequence>
|
||||
#include <QListWidget>
|
||||
#include <QListWidgetItem>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
|
||||
// ---- QSS:按按键功能分色,ID 选择器精确定位(widget.ui 里已设好 objectName)----
|
||||
// 教学要点:QSS 选择器认的是 objectName——setObjectName("btnEquals") 之后才能用
|
||||
// #btnEquals{...}。本例的 objectName 在 widget.ui 里已定义,无需再 setObjectName。
|
||||
namespace {
|
||||
|
||||
// 浅色主题:数字=浅蓝、运算=橙、等号=绿、括号=灰、清空/退格=红、显示区=米黄
|
||||
const char* kLightQss = R"(
|
||||
QMainWindow { background: #f5f5f5; }
|
||||
QLineEdit#display, QTextEdit#expressionEdit {
|
||||
background: #fffbe6; color: #222;
|
||||
border: 1px solid #d0c8a0; border-radius: 4px;
|
||||
font-size: 18px; padding: 4px;
|
||||
}
|
||||
QPushButton {
|
||||
border: 1px solid #bbb; border-radius: 4px;
|
||||
padding: 8px; font-size: 15px; min-height: 28px;
|
||||
}
|
||||
QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
|
||||
QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
|
||||
QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
|
||||
background: #e3f2fd; border-color: #90caf9; color: #0d47a1;
|
||||
}
|
||||
QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
|
||||
background: #ffe0b2; border-color: #ffb74d; color: #e65100;
|
||||
}
|
||||
QPushButton#btnEquals {
|
||||
background: #c8e6c9; border-color: #81c784; color: #1b5e20; font-weight: bold;
|
||||
}
|
||||
QPushButton#btnLeftParen, QPushButton#btnRightParen {
|
||||
background: #eceff1; border-color: #b0bec5; color: #37474f;
|
||||
}
|
||||
QPushButton#btnClear, QPushButton#btnBackspace {
|
||||
background: #ffcdd2; border-color: #e57373; color: #b71c1c;
|
||||
}
|
||||
QPushButton:hover { background: #ddd; }
|
||||
QPushButton:pressed { background: #bbb; }
|
||||
QListWidget { background: #fafafa; alternate-background-color: #eeeeee; }
|
||||
QStatusBar { background: #e0e0e0; }
|
||||
QMenuBar { background: #eceff1; }
|
||||
QDockWidget { color: #333; }
|
||||
)";
|
||||
|
||||
// 深色主题:整体深底浅字,功能分色保留但调暗,便于和浅色对照验证「切换生效」
|
||||
const char* kDarkQss = R"(
|
||||
QMainWindow { background: #2b2b2b; color: #eee; }
|
||||
QLineEdit#display, QTextEdit#expressionEdit {
|
||||
background: #3a3a2a; color: #fffbe6;
|
||||
border: 1px solid #6b6b4a; border-radius: 4px;
|
||||
font-size: 18px; padding: 4px;
|
||||
}
|
||||
QPushButton {
|
||||
border: 1px solid #555; border-radius: 4px;
|
||||
padding: 8px; font-size: 15px; min-height: 28px; color: #eee;
|
||||
}
|
||||
QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
|
||||
QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
|
||||
QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
|
||||
background: #1565c0; border-color: #42a5f5; color: #e3f2fd;
|
||||
}
|
||||
QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
|
||||
background: #d84315; border-color: #ff7043; color: #ffe0b2;
|
||||
}
|
||||
QPushButton#btnEquals {
|
||||
background: #2e7d32; border-color: #66bb6a; color: #c8e6c9; font-weight: bold;
|
||||
}
|
||||
QPushButton#btnLeftParen, QPushButton#btnRightParen {
|
||||
background: #455a64; border-color: #78909c; color: #eceff1;
|
||||
}
|
||||
QPushButton#btnClear, QPushButton#btnBackspace {
|
||||
background: #c62828; border-color: #ef5350; color: #ffcdd2;
|
||||
}
|
||||
QPushButton:hover { background: #666; }
|
||||
QPushButton:pressed { background: #888; }
|
||||
QListWidget { background: #333; alternate-background-color: #3d3d3d; color: #eee; }
|
||||
QStatusBar { background: #1f1f1f; color: #eee; }
|
||||
QMenuBar { background: #37474f; color: #eee; }
|
||||
QDockWidget { color: #eee; }
|
||||
)";
|
||||
|
||||
} // namespace
|
||||
|
||||
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
setWindowTitle(QStringLiteral("计算器(QMainWindow + QSS 版)"));
|
||||
|
||||
m_calc = new CalculatorDesigner(this);
|
||||
setCentralWidget(m_calc);
|
||||
|
||||
buildMenu();
|
||||
buildDock();
|
||||
buildStatusBar();
|
||||
|
||||
connect(m_calc, &CalculatorDesigner::evaluated, this, &MainWindow::onEvaluated);
|
||||
|
||||
applyStyle(false); // 默认浅色主题
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() = default;
|
||||
|
||||
void MainWindow::buildMenu() {
|
||||
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("文件(&F)"));
|
||||
m_actQuit = fileMenu->addAction(QStringLiteral("退出(&Q)"));
|
||||
m_actQuit->setShortcut(QKeySequence(QStringLiteral("Ctrl+Q")));
|
||||
connect(m_actQuit, &QAction::triggered, this, &QWidget::close);
|
||||
|
||||
QMenu* editMenu = menuBar()->addMenu(QStringLiteral("编辑(&E)"));
|
||||
m_actClearHistory = editMenu->addAction(QStringLiteral("清空历史(&C)"));
|
||||
connect(m_actClearHistory, &QAction::triggered, this, &MainWindow::onClearHistory);
|
||||
|
||||
QMenu* viewMenu = menuBar()->addMenu(QStringLiteral("视图(&V)"));
|
||||
m_actToggleHistory = viewMenu->addAction(QStringLiteral("显示历史(&H)"));
|
||||
m_actToggleHistory->setCheckable(true);
|
||||
m_actToggleHistory->setChecked(true);
|
||||
connect(m_actToggleHistory, &QAction::toggled, this, &MainWindow::onToggleHistoryVisible);
|
||||
|
||||
// 【下午版新增】深色主题切换:checkable,勾上=深色
|
||||
m_actToggleTheme = viewMenu->addAction(QStringLiteral("深色主题(&D)"));
|
||||
m_actToggleTheme->setCheckable(true);
|
||||
m_actToggleTheme->setChecked(false);
|
||||
connect(m_actToggleTheme, &QAction::toggled, this, &MainWindow::onToggleTheme);
|
||||
|
||||
QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("帮助(&H)"));
|
||||
m_actAbout = helpMenu->addAction(QStringLiteral("关于(&A)"));
|
||||
connect(m_actAbout, &QAction::triggered, this, &MainWindow::onAbout);
|
||||
}
|
||||
|
||||
void MainWindow::buildDock() {
|
||||
m_history = new QListWidget(this);
|
||||
m_history->setAlternatingRowColors(true);
|
||||
m_dock = new QDockWidget(QStringLiteral("历史"), this);
|
||||
m_dock->setObjectName(QStringLiteral("historyDock"));
|
||||
m_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea);
|
||||
m_dock->setWidget(m_history);
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_dock);
|
||||
connect(m_history, &QListWidget::itemDoubleClicked, this, &MainWindow::onHistoryDoubleClicked);
|
||||
}
|
||||
|
||||
void MainWindow::buildStatusBar() {
|
||||
statusBar()->showMessage(QStringLiteral("就绪"));
|
||||
}
|
||||
|
||||
void MainWindow::refreshStatus(const QString& msg) {
|
||||
statusBar()->showMessage(msg, 3000);
|
||||
}
|
||||
|
||||
// 【下午版新增】主题切换:用 qApp->setStyleSheet 全局生效(影响所有窗口及其子部件)。
|
||||
// 注意 QSS 级联——qApp 级样式会被 widget 级 setStyleSheet 覆盖;本例只用 qApp 级,
|
||||
// 避免和未来某控件局部样式打架(Day3 下午坑卡:setStyleSheet 级联失控)。
|
||||
void MainWindow::applyStyle(bool dark) {
|
||||
m_dark = dark;
|
||||
qApp->setStyleSheet(QString::fromUtf8(dark ? kDarkQss : kLightQss));
|
||||
}
|
||||
|
||||
void MainWindow::onToggleTheme(bool dark) {
|
||||
applyStyle(dark);
|
||||
refreshStatus(dark ? QStringLiteral("已切换深色主题") : QStringLiteral("已切换浅色主题"));
|
||||
}
|
||||
|
||||
void MainWindow::onAbout() {
|
||||
QMessageBox::about(this, QStringLiteral("关于"),
|
||||
QStringLiteral(
|
||||
"<b>计算器(QMainWindow + QSS 版)</b><br><br>"
|
||||
"Day3 下午教学参考实现:在上午版基础上加 QSS 双主题美化——"
|
||||
"数字/运算/等号/括号/清空退格按键分色,:hover/:pressed 视觉反馈,"
|
||||
"深/浅主题切换。<br><br>"
|
||||
"对应教案 OUTLINE_4DAY.md Day3 下午。"));
|
||||
}
|
||||
|
||||
void MainWindow::onClearHistory() {
|
||||
m_history->clear();
|
||||
refreshStatus(QStringLiteral("已清空历史"));
|
||||
}
|
||||
|
||||
void MainWindow::onToggleHistoryVisible(bool checked) {
|
||||
m_dock->setVisible(checked);
|
||||
}
|
||||
|
||||
void MainWindow::onEvaluated(const QString& expr, const QString& result) {
|
||||
m_history->addItem(expr + QStringLiteral(" = ") + result);
|
||||
refreshStatus(QStringLiteral("结果: ") + result);
|
||||
}
|
||||
|
||||
void MainWindow::onHistoryDoubleClicked(QListWidgetItem* item) {
|
||||
const QString text = item->text();
|
||||
const int idx = text.lastIndexOf(QStringLiteral(" = "));
|
||||
if (idx < 0) return;
|
||||
m_calc->loadExpression(text.left(idx));
|
||||
refreshStatus(QStringLiteral("已回填表达式"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 自测 ----
|
||||
bool MainWindow::runSelfTest() {
|
||||
auto expect = [](const char* label, bool cond) -> bool {
|
||||
if (!cond) { qCritical() << "自测失败:" << label; return false; }
|
||||
qInfo() << "自测通过:" << label; return true;
|
||||
};
|
||||
auto expectEq = [](const char* label, const QString& actual, const QString& expected) -> bool {
|
||||
if (actual != expected) {
|
||||
qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
|
||||
return false;
|
||||
}
|
||||
qInfo() << "自测通过:" << label << "=>" << actual;
|
||||
return true;
|
||||
};
|
||||
|
||||
// —— 计算用例与上午版一致(验证加 QSS 后行为不变)——
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(7); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3); m_calc->onEquals();
|
||||
if (!expectEq("7 + 3 =", m_calc->displayText(), QStringLiteral("10"))) return false;
|
||||
if (!expect("历史追加第 1 条", m_history->count() == 1)) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(6); m_calc->onOperator(QLatin1Char('/')); m_calc->onDigit(0); m_calc->onEquals();
|
||||
if (!expectEq("6 ÷ 0 =", m_calc->displayText(), QStringLiteral("Error"))) return false;
|
||||
if (!expect("除零 Error 不进历史", m_history->count() == 1)) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+'));
|
||||
m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('*'));
|
||||
m_calc->onDigit(4); m_calc->onEquals();
|
||||
if (!expectEq("2 + 3 × 4 =", m_calc->displayText(), QStringLiteral("14"))) return false;
|
||||
|
||||
m_calc->onClear();
|
||||
m_calc->onLeftParen();
|
||||
m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3);
|
||||
m_calc->onRightParen();
|
||||
m_calc->onOperator(QLatin1Char('*'));
|
||||
m_calc->onDigit(4); m_calc->onEquals();
|
||||
if (!expectEq("(2 + 3) × 4 =", m_calc->displayText(), QStringLiteral("20"))) return false;
|
||||
|
||||
// —— 【下午版新增】主题切换确实改变全局 styleSheet ——
|
||||
applyStyle(true);
|
||||
if (!expect("深色主题 styleSheet 非空", !qApp->styleSheet().isEmpty())) return false;
|
||||
if (!expect("深色主题 styleSheet 含深色标识",
|
||||
qApp->styleSheet().contains(QStringLiteral("#2b2b2b")))) return false;
|
||||
applyStyle(false);
|
||||
if (!expect("浅色主题 styleSheet 含浅色标识",
|
||||
qApp->styleSheet().contains(QStringLiteral("#f5f5f5")))) return false;
|
||||
if (!expect("主题状态 m_dark 已回浅", !m_dark)) return false;
|
||||
|
||||
// —— 历史双击回填 + 清空(与上午版一致)——
|
||||
m_calc->onClear();
|
||||
m_calc->onLeftParen();
|
||||
m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(2);
|
||||
m_calc->onEquals();
|
||||
QListWidgetItem* last = m_history->item(m_history->count() - 1);
|
||||
if (!expectEq("最后一条历史", last->text(), QStringLiteral("(1+2) = 3"))) return false;
|
||||
onHistoryDoubleClicked(last);
|
||||
if (!expectEq("双击回填表达式区", m_calc->expressionText(), QStringLiteral("(1+2)"))) return false;
|
||||
onClearHistory();
|
||||
if (!expect("清空历史后 count==0", m_history->count() == 0)) return false;
|
||||
|
||||
if (!expect("关于 action 已创建", m_actAbout != nullptr)) return false;
|
||||
if (!expect("主题 action 已创建", m_actToggleTheme != nullptr)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ============================================================================
|
||||
// mainwindow.h —— Day3 下午版:上午版 + QSS 界面美化(双主题)
|
||||
//
|
||||
// 相对 ../calculator_mainwindow/(上午版)的改动:
|
||||
// ① 视图菜单新增「深色主题」可勾选项;
|
||||
// ② applyStyle(bool dark) 按主题切换 setStyleSheet;
|
||||
// ③ runSelfTest() 增加「主题切换确实改变 styleSheet」的断言。
|
||||
// 计算器面板、菜单/停靠窗/状态栏结构与上午版完全一致——QSS 与逻辑正交,
|
||||
// 正是 Day3 下午「美化」环节要传达的核心点。
|
||||
// ============================================================================
|
||||
#ifndef CALCULATOR_MAINWINDOW_QSS_H
|
||||
#define CALCULATOR_MAINWINDOW_QSS_H
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
class CalculatorDesigner;
|
||||
class QListWidget;
|
||||
class QListWidgetItem;
|
||||
class QDockWidget;
|
||||
class QAction;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
bool runSelfTest();
|
||||
|
||||
private slots:
|
||||
void onAbout();
|
||||
void onClearHistory();
|
||||
void onToggleHistoryVisible(bool checked);
|
||||
void onToggleTheme(bool dark); // 【下午版新增】深/浅主题切换
|
||||
void onEvaluated(const QString& expr, const QString& result);
|
||||
void onHistoryDoubleClicked(QListWidgetItem* item);
|
||||
|
||||
private:
|
||||
void buildMenu();
|
||||
void buildDock();
|
||||
void buildStatusBar();
|
||||
void refreshStatus(const QString& msg);
|
||||
void applyStyle(bool dark); // 【下午版新增】
|
||||
|
||||
CalculatorDesigner* m_calc = nullptr;
|
||||
QListWidget* m_history = nullptr;
|
||||
QDockWidget* m_dock = nullptr;
|
||||
QAction* m_actQuit = nullptr;
|
||||
QAction* m_actClearHistory = nullptr;
|
||||
QAction* m_actToggleHistory = nullptr;
|
||||
QAction* m_actToggleTheme = nullptr; // 【下午版新增】
|
||||
QAction* m_actAbout = nullptr;
|
||||
bool m_dark = false; // 【下午版新增】当前是否深色主题
|
||||
};
|
||||
|
||||
#endif // CALCULATOR_MAINWINDOW_QSS_H
|
||||
@@ -0,0 +1,200 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>CalculatorForm</class>
|
||||
<widget class="QWidget" name="CalculatorForm">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>358</width>
|
||||
<height>320</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>计算器面板</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<widget class="QTextEdit" name="expressionEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>50</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>80</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="display">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="keypadLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="btn7">
|
||||
<property name="text">
|
||||
<string>7</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QPushButton" name="btn8">
|
||||
<property name="text">
|
||||
<string>8</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="btn9">
|
||||
<property name="text">
|
||||
<string>9</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="btnDiv">
|
||||
<property name="text">
|
||||
<string>÷</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="btn4">
|
||||
<property name="text">
|
||||
<string>4</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="btn5">
|
||||
<property name="text">
|
||||
<string>5</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="btn6">
|
||||
<property name="text">
|
||||
<string>6</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QPushButton" name="btnMul">
|
||||
<property name="text">
|
||||
<string>×</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="btn1">
|
||||
<property name="text">
|
||||
<string>1</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="btn2">
|
||||
<property name="text">
|
||||
<string>2</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QPushButton" name="btn3">
|
||||
<property name="text">
|
||||
<string>3</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="btnMinus">
|
||||
<property name="text">
|
||||
<string>-</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QPushButton" name="btn0">
|
||||
<property name="text">
|
||||
<string>0</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="btnDot">
|
||||
<property name="text">
|
||||
<string>.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QPushButton" name="btnEquals">
|
||||
<property name="text">
|
||||
<string>=</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="3">
|
||||
<widget class="QPushButton" name="btnPlus">
|
||||
<property name="text">
|
||||
<string>+</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QPushButton" name="btnClear">
|
||||
<property name="text">
|
||||
<string>C</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QPushButton" name="btnBackspace">
|
||||
<property name="text">
|
||||
<string>⌫</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QPushButton" name="btnLeftParen">
|
||||
<property name="text">
|
||||
<string>(</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="3">
|
||||
<widget class="QPushButton" name="btnRightParen">
|
||||
<property name="text">
|
||||
<string>)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,104 @@
|
||||
# QMainWindow 教学综合示例(qt_mainwindow_showcase)
|
||||
|
||||
覆盖川大 wiki「5 QMainWindow」(pageId 58954529,https://wiki.suncaper.net/display/scu2023/5+QMainWindow)
|
||||
的 **5.1–5.6 全部六大核心组件**,形态为**极简文本编辑器**(`QTextEdit` 中心部件),
|
||||
纯组件演示、**无文件 I/O**。主窗体结构用 **Qt Designer(`.ui`)** 设计,应用图标与动作图标
|
||||
走 **资源文件(`.qrc`)**。
|
||||
|
||||
> 仓库已有 `p03/ch05/` 的零散单组件示例(central_widget、dock_widget);本示例是
|
||||
> **综合演示**,把六者整合到一个 `QMainWindow` 程序里,并能交互操控它们。
|
||||
|
||||
## 六大组件 ↔ 代码映射
|
||||
|
||||
| wiki | 组件 | 代码位置 | 演示的 API |
|
||||
|---|---|---|---|
|
||||
| 5.5 | 中心部件 | `mainwindow.ui`:`centralwidget/textEdit`;`.cpp`:`textChanged`/`cursorPositionChanged → updateStatusBar` | `setCentralWidget(QTextEdit)` |
|
||||
| 5.1 | 菜单栏 | `.ui`:`menubar` + `menuFile/menuView/menuHelp/menuToolBarArea` + 12 个 `QAction`;`.cpp`:`on_action*_triggered/toggled` | `menuBar()`/`addMenu`/`QAction` |
|
||||
| 5.2 | 工具栏 | `.ui`:`mainToolBar`(复用 `QAction`);`.cpp`:`setMovable`/`setAllowedAreas`/`setVisible` | `addToolBar`/`setAllowedAreas`/`setMovable` |
|
||||
| 5.3 | 状态栏 | `.cpp`:`addPermanentWidget(statusPosLabel)`/`showMessage` | `addWidget`/`insertWidget`/`removeWidget`(见下) |
|
||||
| 5.4 | 浮动窗口 | `.ui`:`dockWidget`;`.cpp`:`setFloating`/`setAllowedAreas` | `QDockWidget`/`addDockWidget` |
|
||||
| 5.6 | 资源文件 | `mainwindow.qrc` + `.ui` 的 `windowIcon` + 各 `QAction` 图标 | `:/icons/*.png` |
|
||||
|
||||
## 关键设计点
|
||||
|
||||
### 1. QAction 是菜单项与工具按钮的公共抽象(5.1)
|
||||
12 个 `QAction` 在 `.ui` 的 `MainWindow` widget 内**定义一次**,被菜单和工具栏**共同引用**
|
||||
(`<addaction name="..."/>`)——这正是 wiki 强调的「同一动作,加进菜单显示为菜单项、
|
||||
加进工具栏显示为工具按钮」。例如 `actionNew` 同时出现在「文件」菜单和主工具栏。
|
||||
|
||||
### 2. 信号槽:on_\<objectName\>_\<signal\> 自动连接
|
||||
槽命名遵循 Qt 约定,由 `setupUi()` 触发的 `QMetaObject::connectSlotsByName` **自动连接**,
|
||||
无需手写 `connect`(KISS)。例如 `on_actionToggleDockFloating_triggered()` 自动连接
|
||||
`actionToggleDockFloating` 的 `triggered` 信号。
|
||||
|
||||
### 3. 视图菜单 = 「操控」各组件的入口
|
||||
| 动作 | 行为 | 对应组件/API |
|
||||
|---|---|---|
|
||||
| 显示主工具栏 ✓ | `mainToolBar->setVisible(checked)` | 5.2 |
|
||||
| 显示状态栏 ✓ | `statusBar()->setVisible(checked)` | 5.3 |
|
||||
| 工具栏可移动 ✓ | `mainToolBar->setMovable(checked)` | 5.2 `setMovable` |
|
||||
| 停靠窗口浮动/停靠 | `dockWidget->setFloating(!isFloating())` | 5.4 `setFloating` |
|
||||
| 显示浮动窗口 | `dockWidget->show()` | 5.4 重新显示(被 X 关闭后找回) |
|
||||
| 关闭浮动窗口 | `dockWidget->hide()` | 5.4 直接隐藏 |
|
||||
| 工具栏停靠区(子菜单 5 项) | `mainToolBar->setAllowedAreas(...)` | 5.2 `setAllowedAreas` |
|
||||
|
||||
### 4. 状态栏 API(5.3)
|
||||
运行时演示 `addPermanentWidget`(永久「行:列 字数」标签)+ `showMessage`(动作临时提示)。
|
||||
其余三 API 的等价用法(教学参考):
|
||||
|
||||
```cpp
|
||||
// addWidget:在状态栏左侧添加普通部件(非永久,会挤占临时消息区)
|
||||
statusBar()->addWidget(new QLabel("就绪"));
|
||||
// insertWidget:在 index 位置插入部件
|
||||
statusBar()->insertWidget(0, new QLabel("插入"));
|
||||
// removeWidget:移除某部件
|
||||
statusBar()->removeWidget(someLabel);
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
mainwindow_showcase/
|
||||
├── mainwindow.ui # Designer 表单:主窗体结构 + 12 个 QAction
|
||||
├── mainwindow_showcase.h/.cpp # MainWindow 类(动态行为)
|
||||
├── main.cpp # QApplication + offscreen 自动退出
|
||||
├── mainwindow.qrc # 资源清单(5 个图标)
|
||||
├── gen_icons.py # 图标生成脚本(标准库,无依赖)
|
||||
├── icons/ # app/new/quit/about/dock.png(gen_icons.py 产物)
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2)
|
||||
|
||||
> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64(`E:/Qt/Qt5.14.2/Tools/mingw730_64`),
|
||||
> 不可用 PATH 中可能存在的更高版本 g++。
|
||||
|
||||
```bash
|
||||
# 1) 生成图标
|
||||
python docs/teaching/examples/mainwindow_showcase/gen_icons.py
|
||||
|
||||
# 2) 配置(在仓库根;本示例随 docs/teaching/examples 一起被顶层 CMakeLists 收录)
|
||||
cmake -S . -B build_mw -G "MinGW Makefiles" \
|
||||
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64 \
|
||||
-DCMAKE_CXX_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/g++.exe \
|
||||
-DCMAKE_C_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/gcc.exe
|
||||
|
||||
# 3) 构建单目标
|
||||
cmake --build build_mw --target qt_mainwindow_showcase
|
||||
|
||||
# 4) 运行(GUI)
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
./build_mw/docs/teaching/examples/qt_mainwindow_showcase.exe
|
||||
|
||||
# 5) 离屏自动退出验证(退出码应为 0)
|
||||
QT_QPA_PLATFORM=offscreen \
|
||||
QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
|
||||
PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
|
||||
./build_mw/docs/teaching/examples/qt_mainwindow_showcase.exe; echo "exit=$?"
|
||||
```
|
||||
|
||||
## 教学要点小结
|
||||
- 一个 `QMainWindow` = 菜单栏(≤1)+ 工具栏(多个)+ 状态栏(1)+ 浮动窗口(多个)+ 中心部件(1)。
|
||||
- `QAction` 是「动作」的抽象,菜单与工具栏共享。
|
||||
- 工具栏/浮动窗口都是**可移动**的,停靠区域可由 `setAllowedAreas` 限定。
|
||||
- 资源文件(`.qrc`)把图标编译进可执行文件,运行时用 `:/...` 引用,无需关心磁盘文件。
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""生成 qt_mainwindow_showcase 所需的极简 PNG 图标。
|
||||
|
||||
仅用 Python 标准库(zlib + struct)手写最小 PNG 编码器,无第三方依赖。
|
||||
幂等:重复运行覆盖生成相同文件。
|
||||
|
||||
python gen_icons.py # *nix / 已配置 py 启动器的 Windows
|
||||
py gen_icons.py # Windows 官方 py 启动器
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ICONS_DIR = os.path.join(HERE, "icons")
|
||||
|
||||
# 5×7 点阵字模("1" = 前景白色像素)
|
||||
GLYPHS = {
|
||||
"M": ["10001", "11011", "10101", "10001", "10001", "10001", "10001"],
|
||||
"N": ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
|
||||
"Q": ["01110", "10001", "10001", "10001", "10101", "10011", "01111"],
|
||||
"A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||
"D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
||||
}
|
||||
|
||||
|
||||
def write_png(path, width, height, rgb_rows):
|
||||
"""写一张 8 位、颜色类型 2(RGB)的 PNG。rgb_rows: 每行 width*3 个 0-255 整数。"""
|
||||
def chunk(tag, data):
|
||||
body = tag + data
|
||||
return (struct.pack(">I", len(data)) + body +
|
||||
struct.pack(">I", zlib.crc32(body) & 0xffffffff))
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
raw = b"".join(b"\x00" + bytes(row) for row in rgb_rows) # 每行前加 filter byte(0)
|
||||
idat = zlib.compress(raw, 9)
|
||||
with open(path, "wb") as f:
|
||||
f.write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def make_icon(size, bg, letter):
|
||||
"""生成 size×size 的像素矩阵:纯色背景 + 居中白色字母。"""
|
||||
glyph = GLYPHS[letter]
|
||||
bw, bh = 5, 7
|
||||
pix = 1 if size <= 16 else 2 # 16px 用 1:1 字模,32px 放大 2 倍
|
||||
gw, gh = bw * pix, bh * pix
|
||||
ox = (size - gw) // 2
|
||||
oy = (size - gh) // 2
|
||||
rows = []
|
||||
for y in range(size):
|
||||
row = []
|
||||
for x in range(size):
|
||||
gx, gy = (x - ox) // pix, (y - oy) // pix
|
||||
if 0 <= gx < bw and 0 <= gy < bh and glyph[gy][gx] == "1":
|
||||
row.extend((255, 255, 255))
|
||||
else:
|
||||
row.extend(bg)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
# (文件名, 尺寸, 背景色 RGB, 字母)
|
||||
SPECS = [
|
||||
("app.png", 32, (30, 80, 180), "M"),
|
||||
("new.png", 16, (40, 150, 70), "N"),
|
||||
("quit.png", 16, (200, 50, 50), "Q"),
|
||||
("about.png", 16, (220, 140, 30), "A"),
|
||||
("dock.png", 16, (130, 60, 180), "D"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(ICONS_DIR, exist_ok=True)
|
||||
for name, size, bg, letter in SPECS:
|
||||
write_png(os.path.join(ICONS_DIR, name), size, size, make_icon(size, bg, letter))
|
||||
print(f"wrote {name} ({size}x{size})")
|
||||
print(f"done -> {ICONS_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Binary file not shown.
|
After Width: | Height: | Size: 119 B |
Binary file not shown.
|
After Width: | Height: | Size: 92 B |
Binary file not shown.
|
After Width: | Height: | Size: 103 B |
Binary file not shown.
|
After Width: | Height: | Size: 103 B |
@@ -0,0 +1,22 @@
|
||||
// ============================================================================
|
||||
// mainwindow_showcase —— QMainWindow 教学综合示例(程序入口)
|
||||
// 覆盖 wiki「5 QMainWindow」(pageId 58954529) 的 5.1–5.6 全部六大组件:
|
||||
// 5.1 菜单栏 · 5.2 工具栏 · 5.3 状态栏 · 5.4 浮动窗口 · 5.5 中心部件 · 5.6 资源文件
|
||||
// 形态:极简文本编辑器(QTextEdit 为中心部件),纯组件演示,无文件 I/O。
|
||||
// ============================================================================
|
||||
#include <QApplication>
|
||||
#include <QTimer>
|
||||
|
||||
#include "mainwindow_showcase.h"
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
MainWindow w;
|
||||
w.show();
|
||||
// 仅在离屏自动化验证时(QT_QPA_PLATFORM=offscreen)200ms 后自动退出;
|
||||
// 正常运行(有显示环境)窗口保持打开,不自动退出。仿 p03/ch05 风格。
|
||||
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
|
||||
QTimer::singleShot(200, &app, &QApplication::quit);
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>icons/app.png</file>
|
||||
<file>icons/new.png</file>
|
||||
<file>icons/quit.png</file>
|
||||
<file>icons/about.png</file>
|
||||
<file>icons/dock.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -0,0 +1,299 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>720</width>
|
||||
<height>480</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>QMainWindow 教学综合示例</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<normaloff>:/icons/app.png</normaloff>:/icons/app.png</iconset>
|
||||
</property>
|
||||
<!-- 5.5 中心部件:QTextEdit(编辑器主体) -->
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="centralLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QTextEdit" name="textEdit">
|
||||
<property name="plainText">
|
||||
<string>欢迎使用 QMainWindow 教学综合示例。
|
||||
在这里编辑文字,状态栏会实时显示光标行列与字数。
|
||||
视图菜单可操控工具栏、状态栏与浮动窗口。</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<!-- 5.1 菜单栏:文件 / 视图 / 帮助 -->
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>720</width>
|
||||
<height>22</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>文件(&F)</string>
|
||||
</property>
|
||||
<addaction name="actionNew"/>
|
||||
<addaction name="actionQuit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuView">
|
||||
<property name="title">
|
||||
<string>视图(&V)</string>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuToolBarArea">
|
||||
<property name="title">
|
||||
<string>工具栏停靠区(&A)</string>
|
||||
</property>
|
||||
<addaction name="actionToolBarAreaLeft"/>
|
||||
<addaction name="actionToolBarAreaRight"/>
|
||||
<addaction name="actionToolBarAreaTop"/>
|
||||
<addaction name="actionToolBarAreaBottom"/>
|
||||
<addaction name="actionToolBarAreaAll"/>
|
||||
</widget>
|
||||
<addaction name="actionToggleToolBar"/>
|
||||
<addaction name="actionToggleStatusBar"/>
|
||||
<addaction name="actionToggleToolBarMovable"/>
|
||||
<addaction name="actionToggleDockFloating"/>
|
||||
<addaction name="actionShowDock"/>
|
||||
<addaction name="actionHideDock"/>
|
||||
<addaction name="menuToolBarArea"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>帮助(&H)</string>
|
||||
</property>
|
||||
<addaction name="actionAbout"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuView"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<!-- 5.3 状态栏(.ui 占位,永久标签在 .cpp 用 addPermanentWidget 添加) -->
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<!-- 5.2 工具栏:复用同一批 QAction(QAction 是菜单项与工具按钮的公共抽象) -->
|
||||
<widget class="QToolBar" name="mainToolBar">
|
||||
<property name="windowTitle">
|
||||
<string>主工具栏</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionNew"/>
|
||||
<addaction name="actionQuit"/>
|
||||
<addaction name="actionToggleDockFloating"/>
|
||||
<addaction name="actionAbout"/>
|
||||
</widget>
|
||||
<!-- 5.4 铆接部件(浮动窗口):可拖出浮动、停靠到边缘 -->
|
||||
<widget class="QDockWidget" name="dockWidget">
|
||||
<property name="windowTitle">
|
||||
<string>浮动窗口</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>1</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockContent">
|
||||
<layout class="QVBoxLayout" name="dockLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="dockLabel">
|
||||
<property name="text">
|
||||
<string>我是浮动窗口(QDockWidget,5.4 铆接部件)。
|
||||
可拖出浮动,也可停靠到主窗口边缘。
|
||||
视图菜单的「停靠窗口浮动/停靠」可切换我的状态。</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<!-- 5.1 QAction:菜单项与工具按钮的公共抽象。图标取自 5.6 资源文件 -->
|
||||
<action name="actionNew">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<normaloff>:/icons/new.png</normaloff>:/icons/new.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>新建</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+N</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>清空编辑区(5.5 中心部件)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionQuit">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<normaloff>:/icons/quit.png</normaloff>:/icons/quit.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>退出</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>退出程序</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToggleToolBar">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>显示主工具栏</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>显示或隐藏主工具栏(5.2 工具栏)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToggleStatusBar">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>显示状态栏</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>显示或隐藏状态栏(5.3 状态栏)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToggleToolBarMovable">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>工具栏可移动</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>切换工具栏是否可拖动(5.2 setMovable)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToggleDockFloating">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<normaloff>:/icons/dock.png</normaloff>:/icons/dock.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>停靠窗口浮动/停靠</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>切换浮动窗口的浮动状态(5.4 setFloating)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionShowDock">
|
||||
<property name="text">
|
||||
<string>显示浮动窗口</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>重新显示已关闭的浮动窗口(5.4 show)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionHideDock">
|
||||
<property name="text">
|
||||
<string>关闭浮动窗口</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>隐藏浮动窗口(5.4 hide)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToolBarAreaLeft">
|
||||
<property name="text">
|
||||
<string>停靠左侧</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>限制工具栏只能停靠左侧(5.2 setAllowedAreas)</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToolBarAreaRight">
|
||||
<property name="text">
|
||||
<string>停靠右侧</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>限制工具栏只能停靠右侧</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToolBarAreaTop">
|
||||
<property name="text">
|
||||
<string>停靠顶部</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>限制工具栏只能停靠顶部</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToolBarAreaBottom">
|
||||
<property name="text">
|
||||
<string>停靠底部</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>限制工具栏只能停靠底部</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionToolBarAreaAll">
|
||||
<property name="text">
|
||||
<string>允许全部区域</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>工具栏允许停靠在任意区域</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
<property name="icon">
|
||||
<iconset resource="mainwindow.qrc">
|
||||
<normaloff>:/icons/about.png</normaloff>:/icons/about.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>关于</string>
|
||||
</property>
|
||||
<property name="statusTip">
|
||||
<string>关于本程序</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="mainwindow.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,110 @@
|
||||
// ============================================================================
|
||||
// mainwindow_showcase.cpp —— MainWindow 实现(动态行为)
|
||||
//
|
||||
// 静态结构见 mainwindow.ui(Designer)。本文件只做:
|
||||
// - 资源文件应用(5.6):setWindowIcon 从 .qrc 加载
|
||||
// - 状态栏(5.3):addPermanentWidget + 实时行列/字数 + showMessage 临时提示
|
||||
// - 信号槽:on_<objectName>_<signal> 自动连接(connectSlotsByName)
|
||||
// - 视图菜单:运行时操控工具栏/状态栏/浮动窗口
|
||||
// ============================================================================
|
||||
#include "mainwindow_showcase.h"
|
||||
#include "ui_mainwindow.h" // AUTOUIC 从 mainwindow.ui 生成
|
||||
|
||||
#include <QAction>
|
||||
#include <QDockWidget>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
#include <QTextCursor>
|
||||
#include <QTextEdit>
|
||||
#include <QToolBar>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent), ui(new Ui::MainWindow) {
|
||||
ui->setupUi(this);
|
||||
|
||||
// 5.6 资源文件:应用图标从 mainwindow.qrc 加载(:/icons/app.png)
|
||||
setWindowIcon(QIcon(":/icons/app.png"));
|
||||
|
||||
// 5.3 状态栏:永久标签(addPermanentWidget),实时显示光标行列与字数
|
||||
statusPosLabel = new QLabel(QStringLiteral("行:1 列:1 字数:0"), this);
|
||||
statusBar()->addPermanentWidget(statusPosLabel);
|
||||
|
||||
// 5.5 中心部件 QTextEdit 的内容/光标变化 → 更新状态栏
|
||||
connect(ui->textEdit, &QTextEdit::textChanged, this, &MainWindow::updateStatusBar);
|
||||
connect(ui->textEdit, &QTextEdit::cursorPositionChanged, this, &MainWindow::updateStatusBar);
|
||||
updateStatusBar();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() { delete ui; }
|
||||
|
||||
// ---- 文件菜单 ----------------------------------------------------------
|
||||
void MainWindow::on_actionNew_triggered() {
|
||||
ui->textEdit->clear();
|
||||
statusBar()->showMessage(QStringLiteral("已新建"), 2000); // 5.3 临时提示
|
||||
}
|
||||
|
||||
void MainWindow::on_actionQuit_triggered() { close(); }
|
||||
|
||||
// ---- 视图菜单 —— 运行时操控各组件 --------------------------------------
|
||||
void MainWindow::on_actionToggleToolBar_toggled(bool checked) {
|
||||
ui->mainToolBar->setVisible(checked);
|
||||
}
|
||||
|
||||
void MainWindow::on_actionToggleStatusBar_toggled(bool checked) {
|
||||
statusBar()->setVisible(checked);
|
||||
}
|
||||
|
||||
void MainWindow::on_actionToggleToolBarMovable_toggled(bool checked) {
|
||||
ui->mainToolBar->setMovable(checked); // 5.2 setMovable
|
||||
}
|
||||
|
||||
void MainWindow::on_actionToggleDockFloating_triggered() {
|
||||
ui->dockWidget->setFloating(!ui->dockWidget->isFloating()); // 5.4 setFloating
|
||||
}
|
||||
|
||||
void MainWindow::on_actionShowDock_triggered() {
|
||||
ui->dockWidget->show(); // 5.4 重新显示已关闭的浮动窗口
|
||||
}
|
||||
|
||||
void MainWindow::on_actionHideDock_triggered() {
|
||||
ui->dockWidget->hide(); // 5.4 关闭(隐藏)浮动窗口
|
||||
}
|
||||
|
||||
void MainWindow::on_actionToolBarAreaLeft_triggered() {
|
||||
ui->mainToolBar->setAllowedAreas(Qt::LeftToolBarArea); // 5.2 setAllowedAreas
|
||||
}
|
||||
void MainWindow::on_actionToolBarAreaRight_triggered() {
|
||||
ui->mainToolBar->setAllowedAreas(Qt::RightToolBarArea);
|
||||
}
|
||||
void MainWindow::on_actionToolBarAreaTop_triggered() {
|
||||
ui->mainToolBar->setAllowedAreas(Qt::TopToolBarArea);
|
||||
}
|
||||
void MainWindow::on_actionToolBarAreaBottom_triggered() {
|
||||
ui->mainToolBar->setAllowedAreas(Qt::BottomToolBarArea);
|
||||
}
|
||||
void MainWindow::on_actionToolBarAreaAll_triggered() {
|
||||
ui->mainToolBar->setAllowedAreas(Qt::AllToolBarAreas);
|
||||
}
|
||||
|
||||
// ---- 帮助菜单 ----------------------------------------------------------
|
||||
void MainWindow::on_actionAbout_triggered() {
|
||||
QMessageBox::about(this, QStringLiteral("关于"),
|
||||
QStringLiteral(
|
||||
"<b>QMainWindow 教学综合示例</b><br><br>"
|
||||
"一个极简文本编辑器,演示 QMainWindow 的六大核心组件"
|
||||
"(wiki「5 QMainWindow」pageId 58954529):<br>"
|
||||
"5.1 菜单栏 · 5.2 工具栏 · 5.3 状态栏 · "
|
||||
"5.4 浮动窗口 · 5.5 中心部件 · 5.6 资源文件"));
|
||||
}
|
||||
|
||||
// ---- 状态栏更新 --------------------------------------------------------
|
||||
void MainWindow::updateStatusBar() {
|
||||
const QTextCursor c = ui->textEdit->textCursor();
|
||||
const int line = c.blockNumber() + 1;
|
||||
const int col = c.columnNumber() + 1;
|
||||
const int count = ui->textEdit->toPlainText().size();
|
||||
statusPosLabel->setText(
|
||||
QStringLiteral("行:%1 列:%2 字数:%3").arg(line).arg(col).arg(count));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// ============================================================================
|
||||
// mainwindow_showcase.h —— MainWindow 类声明
|
||||
//
|
||||
// 主窗体的静态结构(central QTextEdit / menuBar / mainToolBar / statusBar /
|
||||
// dockWidget / 全部 QAction)在 mainwindow.ui 中用 Qt Designer 设计;
|
||||
// 本类只负责动态行为:信号槽、运行时操控、状态栏更新、关于对话框。
|
||||
//
|
||||
// 槽命名遵循 Qt 自动连接约定 on_<objectName>_<signal>,由 setupUi() 触发的
|
||||
// QMetaObject::connectSlotsByName 自动连接,无需手写 connect(KISS)。
|
||||
// ============================================================================
|
||||
#ifndef MAINWINDOW_SHOWCASE_H
|
||||
#define MAINWINDOW_SHOWCASE_H
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QLabel>
|
||||
|
||||
QT_BEGIN_NAMESPACE
|
||||
namespace Ui { class MainWindow; }
|
||||
QT_END_NAMESPACE
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
private slots:
|
||||
// 文件菜单
|
||||
void on_actionNew_triggered();
|
||||
void on_actionQuit_triggered();
|
||||
|
||||
// 视图菜单 —— 操控各组件
|
||||
void on_actionToggleToolBar_toggled(bool checked); // 5.2 显示/隐藏工具栏
|
||||
void on_actionToggleStatusBar_toggled(bool checked); // 5.3 显示/隐藏状态栏
|
||||
void on_actionToggleToolBarMovable_toggled(bool checked); // 5.2 工具栏可移动
|
||||
void on_actionToggleDockFloating_triggered(); // 5.4 浮动/停靠切换
|
||||
void on_actionShowDock_triggered(); // 5.4 重新显示浮动窗口
|
||||
void on_actionHideDock_triggered(); // 5.4 关闭浮动窗口
|
||||
void on_actionToolBarAreaLeft_triggered(); // 5.2 setAllowedAreas
|
||||
void on_actionToolBarAreaRight_triggered();
|
||||
void on_actionToolBarAreaTop_triggered();
|
||||
void on_actionToolBarAreaBottom_triggered();
|
||||
void on_actionToolBarAreaAll_triggered();
|
||||
|
||||
// 帮助菜单
|
||||
void on_actionAbout_triggered();
|
||||
|
||||
// 状态栏:根据中心编辑区更新「行:列 字数:N」
|
||||
void updateStatusBar();
|
||||
|
||||
private:
|
||||
Ui::MainWindow *ui;
|
||||
QLabel *statusPosLabel; // 状态栏永久标签(演示 5.3 addPermanentWidget)
|
||||
};
|
||||
|
||||
#endif // MAINWINDOW_SHOWCASE_H
|
||||
@@ -0,0 +1,36 @@
|
||||
# =============================================================================
|
||||
# Day4 Mandelbrot 分形渲染器 —— 独立 CMake 工程
|
||||
#
|
||||
# 本文件供「单独打开 / 独立构建 / 单独打包共享」使用:
|
||||
# cmake -S . -B build -G Ninja \
|
||||
# -DCMAKE_PREFIX_PATH=<Qt5.14.2> -DCMAKE_BUILD_TYPE=Debug
|
||||
# cmake --build build
|
||||
#
|
||||
# 仓库统一构建【不走】这里:顶层 CMakeLists.txt 只
|
||||
# add_subdirectory(docs/teaching/examples) 且不递归,统一构建走
|
||||
# examples/CMakeLists.txt 的 add_executable,源文件同为
|
||||
# main.cpp / mandelbrot_widget.cpp / mandelbrot_strip_task.cpp(零重复)。
|
||||
# 本目录不计入仓库「173 目标」计数。
|
||||
#
|
||||
# 说明:本工程为手写规范版(C++17 / cmake 3.16 / 无 ANDROID 噪音),
|
||||
# 区别于其它子目录里 Qt Creator 自动导出的低质量模板。
|
||||
# =============================================================================
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(qt_mandelbrot_renderer LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)
|
||||
message(STATUS "Found Qt5 ${Qt5Core_VERSION_STRING} at ${Qt5Core_DIR}")
|
||||
|
||||
add_executable(qt_mandelbrot_renderer
|
||||
main.cpp
|
||||
mandelbrot_widget.cpp
|
||||
mandelbrot_strip_task.cpp)
|
||||
target_include_directories(qt_mandelbrot_renderer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(qt_mandelbrot_renderer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
target_compile_options(qt_mandelbrot_renderer PRIVATE -Wall -Wextra)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Day4 Mandelbrot 分形渲染器 —— 事件 / 绘图 / 多线程 综合演示
|
||||
|
||||
> 对应 Day4 课程主线:**事件机制(事件 vs 信号、事件拦截)+ QPainter 绘图 + 多线程防 UI 阻塞**。
|
||||
> 仓库 `p03/` 的 wiki 抓取内容里没有把这三者串起来的综合示例,本示例是手工补写的教学
|
||||
> 补充,**不属于** `tools/gen_part3.py` 的生成产物。
|
||||
|
||||
## 一句话
|
||||
|
||||
滚轮缩放 / 拖拽平移探索 Mandelbrot 分形;分形渲染是 CPU 密集任务,用 `QThreadPool` +
|
||||
`QRunnable` 分水平条带并行计算,主线程 `paintEvent` 只负责画图——**界面永不卡**。
|
||||
再配一个「主线程渲染」按钮,**故意**在 GUI 线程同步算一帧,直观复现 Day4 slides 反复强调的
|
||||
「主线程做耗时操作 = 事件循环停摆 = 整个界面冻结」。
|
||||
|
||||
## 三段式对比(本示例的核心教学价值)
|
||||
|
||||
| 操作 | 谁在算 | 事件循环 | 速度 | 你看到 |
|
||||
|---|---|---|---|---|
|
||||
| 「主线程渲染」按钮 | GUI 线程同步算 | **被占用→冻结** | 慢 | 按钮无响应、窗口拖不动、`paintEvent` 排队 |
|
||||
| 「单线程」模式 | 线程池(1 线程)后台 | 空闲 | 慢 | 界面流畅、可继续缩放 |
|
||||
| 「多线程」模式 | 线程池(N 核)后台 | 空闲 | **快 ~N 倍** | 界面流畅、渲染耗时骤降 |
|
||||
|
||||
三段对比让两个结论同时成立:**耗时任务必须离开主线程**(否则冻结);**多线程进一步压榨多核**(加速比)。
|
||||
|
||||
## 概念映射
|
||||
|
||||
| Day4 知识点 | 本示例实现 |
|
||||
|---|---|
|
||||
| QPainter 绘图(框架驱动,只能在 `paintEvent` 里画) | `MandelbrotWidget::paintEvent` 把累积 `QImage` 一次性 `drawImage` |
|
||||
| 事件处理(重写 `xxxEvent`) | `wheelEvent`(缩放)、`mousePress/Move/ReleaseEvent`(平移)、`resizeEvent` |
|
||||
| 多线程防 UI 阻塞 | `QThreadPool::globalInstance()` + `QRunnable` 分条带并行;排队信号槽回传结果 |
|
||||
|
||||
## 代码走读
|
||||
|
||||
1. **`mandelbrot_strip_task.h/.cpp`** —— 纯计算单元(可独立理解)
|
||||
- `MandelViewport`:复平面视口参数;`MandelPalette`:迭代次数→RGB 调色板。
|
||||
- `renderStrip(vp, pal, y0, y1)`:**纯函数**,渲染视口内 `[y0,y1)` 行像素。多线程 task 与
|
||||
「主线程渲染」按钮**共用**它——保证两路径算同一份分形,差异只在是否阻塞/是否并行。
|
||||
- `MandelbrotStripTask`:`public QObject, public QRunnable` 多继承,`run()` 算完一条带
|
||||
`emit stripReady(epoch, i, QImage)`。**任务持 palette/viewport 的值副本**,自包含、不引用
|
||||
主线程对象(避免悬垂)。
|
||||
2. **`mandelbrot_widget.h/.cpp`** —— 交互与渲染调度
|
||||
- `scheduleRender()`:`++epoch_` → 切 `2×核数` 条带 → 每带 `new MandelbrotStripTask` 丢池。
|
||||
- `onStripReady()`:校验 `epoch == epoch_`(过期丢弃)→ 把条带画到累积 `pixmap_` → `update()`。
|
||||
- `onRenderOnMainThread()`:在 GUI 线程同步循环算整帧,**不丢池** → 事件循环停摆 → 冻结。
|
||||
- `onToggleThreadMode()`:`setMaxThreadCount(1 或 idealThreadCount())`,复用同一套分块逻辑。
|
||||
3. **`main.cpp`** —— offscreen 平台下 200ms 自动退出(仓库自动化验证约定)。
|
||||
|
||||
## 关键机制:为什么界面不卡 / 为什么结果不会错乱
|
||||
|
||||
- **不卡**:耗时计算在 `QRunnable::run()`(子线程)里,主线程事件循环始终空闲,能继续响应
|
||||
鼠标键盘。`stripReady` 信号经**排队连接**自动调度回主线程执行 `onStripReady`——不需要手动加锁。
|
||||
- **不错乱**:连续缩放会产生多轮渲染。每轮 `++epoch_`;旧任务的 `stripReady` 回来时
|
||||
`epoch != epoch_` 直接丢弃。这**不是主动取消**(旧任务仍在跑、算完即丢),但保证了画面正确——
|
||||
**「多线程结果回收要防竞态」**本身就是 Day4 该讲的点。
|
||||
|
||||
## 构建与运行
|
||||
|
||||
### 方式 A:作为主仓库的一部分(仓库标准)
|
||||
|
||||
```bash
|
||||
# Linux(隔离 Qt 5.14.2)
|
||||
cmake -S . -B build -G Ninja -DBUILD_QT_PART=ON \
|
||||
-DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64 -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build --target qt_mandelbrot_renderer
|
||||
tools/run_qt.sh qt_mandelbrot_renderer # 有屏交互
|
||||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_mandelbrot_renderer # 自动退出码 0
|
||||
```
|
||||
|
||||
### 方式 B:作为独立工程(可单独打包共享)
|
||||
|
||||
```bash
|
||||
cmake -S docs/teaching/examples/mandelbrot_renderer -B build_mandel -G Ninja \
|
||||
-DCMAKE_PREFIX_PATH=/path/to/qt/5.14.2/<kit> -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build_mandel
|
||||
./build_mandel/qt_mandelbrot_renderer
|
||||
```
|
||||
> 红线:无论哪种方式,Qt 必须是 5.14.x(本工程 CMakeLists 内置版本断言)。
|
||||
> Linux 装隔离 Qt 见 `tools/install_qt_host.sh`;Windows 用官方 Qt 5.14.2 MinGW 安装包。
|
||||
|
||||
## 踩坑点(代码里复现或规避)
|
||||
|
||||
1. **在 `paintEvent` 之外对【窗口 widget】构造 `QPainter` = 未定义行为**。本例严格区分:
|
||||
对窗口 `this` 画只在 `paintEvent`;对离屏 `QImage pixmap_` 画(`onStripReady` / `onRenderOnMainThread`)
|
||||
是合法的——`QImage` 是 `QPaintDevice`,不受此限制。初学者常把两者混淆。
|
||||
2. **`QRunnable` 没有 `Q_OBJECT`、没有信号**。要让任务发信号,须**多继承 `QObject`**(本例做法),
|
||||
或在 `run()` 里用 `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` 回调。继承 `QThread`
|
||||
重写 `run()` 是另一条路但更易踩坑(见 `qthread_worker` 示例的对照说明)。
|
||||
3. **主线程渲染卡死 vs 后台流畅的本质**:不是「多线程更快」那么简单,而是**事件循环是否被
|
||||
占用**。按钮槽函数同步耗时 = 槽返回前事件循环不跑 = 所有事件(含重绘)排队 = 冻结。
|
||||
4. **过期渲染结果覆盖新画面**:连续缩放时旧任务可能在新一轮之后才返回,必须用版本号
|
||||
(本例 `epoch_`)过滤,否则画面会闪回旧帧。
|
||||
5. **重写 `wheelEvent`/`mouseMoveEvent` 时是否调用基类**:本例需要继续向父传播的调用了
|
||||
`QWidget::xxxEvent(e)`,自处理完毕不需要传播的(如 `wheelEvent` 缩放)则不调——这是
|
||||
`accept()/ignore()` 的实践体现。
|
||||
|
||||
## 练习方向(供任务卡引用)
|
||||
|
||||
- 把 `setMaxThreadCount` 从 1 逐步调到核数,画一张「线程数—渲染耗时」曲线,验证加速比的天花板。
|
||||
- 给「主线程渲染」按钮加一个 `QApplication::processEvents()` 看似「不卡」的伪解法,观察它为什么
|
||||
不可靠(仍是单线程、仍可能丢事件)——理解为什么必须用真线程。
|
||||
- 把渲染中途的旧任务**真正取消**(`QFuture`/`cancel` 标志位),对比当前「算完丢弃」的版本,
|
||||
讨论 CPU 浪费与实现复杂度的权衡。
|
||||
- 给分形加平滑着色(escape time + log 归一化),消除色带。
|
||||
|
||||
## 参考
|
||||
|
||||
- Qt 官方同名示例 *Mandelbrot Example*(Qt Widgets / Painting)——本例改用「QThreadPool 分块 +
|
||||
worker-object 思路」以贴合 Day4 多线程教学,官方版用 `RenderThread` 继承 `QThread`,可对照阅读。
|
||||
- 配套概念示例:`qthread_worker/`(worker-object + moveToThread 最小版)。
|
||||
@@ -0,0 +1,30 @@
|
||||
// ============================================================================
|
||||
// main —— Day4 Mandelbrot 分形渲染器入口
|
||||
// offscreen 平台(QT_QPA_PLATFORM=offscreen)下:show 触发首帧后台渲染,
|
||||
// 200ms 后退出;aboutToQuit 等线程池收尾,避免悬垂任务访问已析构对象。
|
||||
// 仓库自动化验证据此断言退出码 0(CLAUDE.md 行为风格第 2 点)。
|
||||
// ============================================================================
|
||||
#include <QApplication>
|
||||
#include <QtCore>
|
||||
#include <QThreadPool>
|
||||
#include <QTimer>
|
||||
|
||||
#include "mandelbrot_widget.h"
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QApplication app(argc, argv);
|
||||
MandelbrotWidget w;
|
||||
w.resize(820, 640);
|
||||
w.setWindowTitle(QStringLiteral("Day4 · Mandelbrot 分形渲染器(绘图 / 多线程 / 事件)"));
|
||||
w.show();
|
||||
|
||||
if (app.platformName() == QStringLiteral("offscreen")) {
|
||||
// 等所有在途 QRunnable 跑完,再让 widget 析构(任务持值副本,但仍可能
|
||||
// 通过排队信号访问 this——这里确保它们落定)。
|
||||
QObject::connect(&app, &QCoreApplication::aboutToQuit, []() {
|
||||
QThreadPool::globalInstance()->waitForDone();
|
||||
});
|
||||
QTimer::singleShot(200, &app, &QApplication::quit);
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ============================================================================
|
||||
// mandelbrot_strip_task.cpp —— 调色板构造 + renderStrip 纯计算实现
|
||||
// MandelbrotStripTask 的成员全部内联在头文件,本 .cpp 只需让 AUTOMOC 扫到
|
||||
// strip_task.h 的 Q_OBJECT(#include 它即可),并实现非内联的 MandelPalette
|
||||
// 与 renderStrip。
|
||||
// ============================================================================
|
||||
#include "mandelbrot_strip_task.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QtGlobal>
|
||||
|
||||
// 迭代次数 → HSV 色相循环(彩虹环),集合内部点统一黑色。
|
||||
MandelPalette::MandelPalette(int maxIter) {
|
||||
const int n = qMax(1, maxIter);
|
||||
table_.resize(n);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (i == n - 1) { table_[i] = qRgb(0, 0, 0); continue; } // 集合内 → 黑
|
||||
const int hue = static_cast<int>((360.0 * 4.0 * i / n));
|
||||
table_[i] = QColor::fromHsv(hue % 360, 255, 255).rgb();
|
||||
}
|
||||
}
|
||||
|
||||
// 标准 Mandelbrot 迭代 z = z² + c,逃逸半径 2。逐像素写入 RGB32。
|
||||
QImage renderStrip(const MandelViewport& vp, const MandelPalette& pal, int y0, int y1) {
|
||||
const int w = vp.width;
|
||||
const int top = qBound(0, y0, vp.height);
|
||||
const int bot = qBound(0, y1, vp.height);
|
||||
if (w <= 0 || bot <= top) return {};
|
||||
|
||||
QImage img(w, bot - top, QImage::Format_RGB32);
|
||||
const double x0 = vp.centerX - vp.scale * vp.width / 2.0;
|
||||
const double y0c = vp.centerY - vp.scale * vp.height / 2.0;
|
||||
const int maxIter = vp.maxIter;
|
||||
|
||||
for (int py = top; py < bot; ++py) {
|
||||
const double cy = y0c + vp.scale * py;
|
||||
auto* line = reinterpret_cast<QRgb*>(img.scanLine(py - top)); // RGB32 = 每像素 1 个 QRgb
|
||||
for (int px = 0; px < w; ++px) {
|
||||
const double cx = x0 + vp.scale * px;
|
||||
double zx = 0.0, zy = 0.0;
|
||||
int iter = 0;
|
||||
while (iter < maxIter) {
|
||||
const double zx2 = zx * zx;
|
||||
const double zy2 = zy * zy;
|
||||
if (zx2 + zy2 > 4.0) break; // 逃逸
|
||||
zy = 2.0 * zx * zy + cy;
|
||||
zx = zx2 - zy2 + cx;
|
||||
++iter;
|
||||
}
|
||||
line[px] = pal.color(iter >= maxIter ? maxIter - 1 : iter);
|
||||
}
|
||||
}
|
||||
return img;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// ============================================================================
|
||||
// mandelbrot_strip_task —— Day4 Mandelbrot 渲染器的「分块计算 + 调色板」单元
|
||||
// 职责:
|
||||
// * MandelViewport : 视口参数(复平面中心 / 像素跨度 / 尺寸 / 最大迭代)
|
||||
// * MandelPalette : 迭代次数→RGB 的预计算调色板(提升趣味,与知识点无关)
|
||||
// * renderStrip() : 纯计算函数,渲染视口内 [y0,y1) 行像素。多线程 task 与
|
||||
// 「主线程卡死」演示路径共用它——保证两路径算的是同一份分形,
|
||||
// 差异只在「是否阻塞事件循环 / 是否并行」。
|
||||
// * MandelbrotStripTask : 一条水平带的 QRunnable 任务。多继承 QObject+QRunnable
|
||||
// 以获得信号能力(QRunnable 本身无信号——这是初学者高频困惑,
|
||||
// 见 README 踩坑点 2)。
|
||||
// 设计要点:task 持 palette/viewport 的【值副本】,自包含、不引用主线程对象,
|
||||
// 避免主对象先析构导致的悬垂访问(见 README 踩坑点 4)。
|
||||
// ============================================================================
|
||||
#ifndef MANDELBROT_STRIP_TASK_H
|
||||
#define MANDELBROT_STRIP_TASK_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QObject>
|
||||
#include <QRunnable>
|
||||
#include <QRgb>
|
||||
#include <QVector>
|
||||
|
||||
// 视口参数:复平面映射。scale = 每像素对应的复平面跨度。
|
||||
struct MandelViewport {
|
||||
double centerX = -0.5; // 经典 Mandelbrot 主分形居中
|
||||
double centerY = 0.0;
|
||||
double scale = 3.0 / 600.0;
|
||||
int width = 0; // show 后由 widget 尺寸填入
|
||||
int height = 0;
|
||||
int maxIter = 256;
|
||||
};
|
||||
|
||||
// 调色板:迭代次数 → RGB。集合内部(迭代未逃逸)映射为黑。
|
||||
class MandelPalette {
|
||||
public:
|
||||
explicit MandelPalette(int maxIter = 256);
|
||||
QRgb color(int i) const { return table_.at(i); }
|
||||
private:
|
||||
QVector<QRgb> table_;
|
||||
};
|
||||
|
||||
// 纯计算:渲染视口内 [y0, y1) 行像素,返回 (width × (y1-y0)) 的 RGB32 QImage。
|
||||
QImage renderStrip(const MandelViewport& vp, const MandelPalette& pal, int y0, int y1);
|
||||
|
||||
// 一条水平带的多线程任务。多继承 QObject + QRunnable 以拥有信号。
|
||||
// setAutoDelete(true)(默认):run() 结束后由线程池回收;不设 QObject parent,
|
||||
// 避免与 QThreadPool 的自动删除发生双重所有权——见 README 踩坑点 2。
|
||||
class MandelbrotStripTask : public QObject, public QRunnable {
|
||||
Q_OBJECT
|
||||
public:
|
||||
MandelbrotStripTask(quint64 epoch, int stripIndex,
|
||||
const MandelViewport& vp, const MandelPalette& pal,
|
||||
int y0, int y1)
|
||||
: QObject(nullptr), epoch_(epoch), stripIndex_(stripIndex),
|
||||
vp_(vp), pal_(pal), y0_(y0), y1_(y1) { setAutoDelete(true); }
|
||||
|
||||
void run() override {
|
||||
// 算完一条带 → 通过信号把结果「排队」送回主线程(Qt 自动跨线程调度)。
|
||||
emit stripReady(epoch_, stripIndex_, renderStrip(vp_, pal_, y0_, y1_));
|
||||
}
|
||||
|
||||
signals:
|
||||
void stripReady(quint64 epoch, int stripIndex, QImage strip);
|
||||
|
||||
private:
|
||||
quint64 epoch_;
|
||||
int stripIndex_;
|
||||
MandelViewport vp_;
|
||||
MandelPalette pal_; // 值副本:任务自包含
|
||||
int y0_;
|
||||
int y1_;
|
||||
};
|
||||
|
||||
#endif // MANDELBROT_STRIP_TASK_H
|
||||
@@ -0,0 +1,164 @@
|
||||
// ============================================================================
|
||||
// mandelbrot_widget.cpp —— 交互与渲染调度实现(详见 .h)
|
||||
// ============================================================================
|
||||
#include "mandelbrot_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QResizeEvent>
|
||||
#include <QThreadPool>
|
||||
#include <QThread>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWheelEvent>
|
||||
|
||||
MandelbrotWidget::MandelbrotWidget(QWidget* parent)
|
||||
: QWidget(parent), pal_(256) {
|
||||
// 线程池默认按核数;切到「单线程」模式时再 setMaxThreadCount(1)。
|
||||
QThreadPool::globalInstance()->setMaxThreadCount(QThread::idealThreadCount());
|
||||
threadCount_ = QThreadPool::globalInstance()->maxThreadCount();
|
||||
|
||||
auto* btnFreeze = new QPushButton(QStringLiteral("主线程渲染 (将冻结)"));
|
||||
auto* btnToggle = new QPushButton(QStringLiteral("切换 单/多线程"));
|
||||
label_ = new QLabel(QStringLiteral("就绪 · 滚轮缩放 · 左键拖拽平移"));
|
||||
|
||||
auto* bar = new QHBoxLayout;
|
||||
bar->setContentsMargins(8, 4, 8, 4);
|
||||
bar->addWidget(btnFreeze);
|
||||
bar->addWidget(btnToggle);
|
||||
bar->addSpacing(16);
|
||||
bar->addWidget(label_);
|
||||
bar->addStretch();
|
||||
|
||||
auto* root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(0, 0, 0, 0);
|
||||
root->setSpacing(0);
|
||||
root->addStretch(); // 画布占主体(paintEvent 画整个 widget 背景,工具条浮在底部)
|
||||
root->addLayout(bar);
|
||||
|
||||
connect(btnFreeze, &QPushButton::clicked, this, &MandelbrotWidget::onRenderOnMainThread);
|
||||
connect(btnToggle, &QPushButton::clicked, this, &MandelbrotWidget::onToggleThreadMode);
|
||||
}
|
||||
|
||||
// ---- 绘图:只在 paintEvent 内对【窗口 widget】构造 QPainter(核心踩坑点 1)----
|
||||
void MandelbrotWidget::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
if (!pixmap_.isNull()) {
|
||||
p.drawImage(0, 0, pixmap_);
|
||||
} else {
|
||||
p.fillRect(rect(), Qt::black);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 事件处理:滚轮缩放(以鼠标点为锚,缩放后该点复坐标不变)----
|
||||
void MandelbrotWidget::wheelEvent(QWheelEvent* e) {
|
||||
const int dy = e->angleDelta().y();
|
||||
if (dy == 0) { QWidget::wheelEvent(e); return; }
|
||||
const double factor = (dy > 0) ? 0.8 : 1.25; // 上滚放大、下滚缩小
|
||||
|
||||
const QPointF m = e->position();
|
||||
const double mx = vp_.centerX + (m.x() - width() / 2.0) * vp_.scale;
|
||||
const double my = vp_.centerY + (m.y() - height() / 2.0) * vp_.scale;
|
||||
vp_.scale *= factor;
|
||||
vp_.centerX = mx - (m.x() - width() / 2.0) * vp_.scale;
|
||||
vp_.centerY = my - (m.y() - height() / 2.0) * vp_.scale;
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
// ---- 事件处理:左键拖拽平移 ----
|
||||
void MandelbrotWidget::mousePressEvent(QMouseEvent* e) {
|
||||
if (e->button() == Qt::LeftButton) { dragging_ = true; lastMouse_ = e->pos(); }
|
||||
QWidget::mousePressEvent(e);
|
||||
}
|
||||
void MandelbrotWidget::mouseMoveEvent(QMouseEvent* e) {
|
||||
if (dragging_) {
|
||||
const QPoint d = e->pos() - lastMouse_;
|
||||
lastMouse_ = e->pos();
|
||||
vp_.centerX -= d.x() * vp_.scale;
|
||||
vp_.centerY -= d.y() * vp_.scale;
|
||||
scheduleRender();
|
||||
}
|
||||
QWidget::mouseMoveEvent(e);
|
||||
}
|
||||
void MandelbrotWidget::mouseReleaseEvent(QMouseEvent* e) {
|
||||
if (e->button() == Qt::LeftButton) dragging_ = false;
|
||||
QWidget::mouseReleaseEvent(e);
|
||||
}
|
||||
|
||||
void MandelbrotWidget::resizeEvent(QResizeEvent* e) {
|
||||
QWidget::resizeEvent(e);
|
||||
scheduleRender(); // 首次 show 与窗口缩放都经此触发首帧
|
||||
}
|
||||
|
||||
// ---- 多线程调度:切条带丢池,渐进回传 ----
|
||||
void MandelbrotWidget::scheduleRender() {
|
||||
++epoch_; // 令所有在途旧任务的结果作废
|
||||
vp_.width = width();
|
||||
vp_.height = height();
|
||||
if (vp_.width <= 0 || vp_.height <= 0) return;
|
||||
|
||||
pixmap_ = QImage(vp_.width, vp_.height, QImage::Format_RGB32);
|
||||
stripsTotal_ = multiThread_ ? QThread::idealThreadCount() * 2 : 1; // 多线程:2×核数均衡负载
|
||||
stripsDone_ = 0;
|
||||
timer_.start();
|
||||
|
||||
const int N = stripsTotal_;
|
||||
for (int i = 0; i < N; ++i) {
|
||||
const int y0 = vp_.height * i / N;
|
||||
const int y1 = vp_.height * (i + 1) / N;
|
||||
auto* task = new MandelbrotStripTask(epoch_, i, vp_, pal_, y0, y1);
|
||||
// 排队连接:stripReady 自动调度回主线程(接收者 this 所在线程)执行 onStripReady。
|
||||
connect(task, &MandelbrotStripTask::stripReady, this, &MandelbrotWidget::onStripReady);
|
||||
QThreadPool::globalInstance()->start(task);
|
||||
}
|
||||
}
|
||||
|
||||
void MandelbrotWidget::onStripReady(quint64 epoch, int stripIndex, QImage strip) {
|
||||
if (epoch != epoch_) return; // 过期结果丢弃(防竞态覆盖新画面)
|
||||
if (!strip.isNull()) {
|
||||
QPainter p(&pixmap_); // 对【离屏 QImage】构造 QPainter——合法(非窗口 widget)
|
||||
p.drawImage(0, vp_.height * stripIndex / stripsTotal_, strip);
|
||||
}
|
||||
++stripsDone_;
|
||||
update(); // 渐进刷新:能看到条带自上而下并行填满
|
||||
if (stripsDone_ >= stripsTotal_) {
|
||||
label_->setText(QString(QStringLiteral("%1 · %2 ms · %3 线程"))
|
||||
.arg(multiThread_ ? QStringLiteral("多线程渲染")
|
||||
: QStringLiteral("单线程渲染"))
|
||||
.arg(int(timer_.elapsed()))
|
||||
.arg(threadCount_));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 卡死演示:在 GUI 线程同步算整帧,不分块、不丢池 → 事件循环停摆 → 界面冻结 ----
|
||||
void MandelbrotWidget::onRenderOnMainThread() {
|
||||
++epoch_; // 让在途后台任务的结果作废,避免覆盖本帧
|
||||
vp_.width = width();
|
||||
vp_.height = height();
|
||||
if (vp_.width <= 0 || vp_.height <= 0) return;
|
||||
|
||||
timer_.start();
|
||||
pixmap_ = QImage(vp_.width, vp_.height, QImage::Format_RGB32);
|
||||
const int H = vp_.height;
|
||||
const int chunk = 24; // 分块调 renderStrip 只为复用同一纯函数
|
||||
for (int y = 0; y < H; y += chunk) {
|
||||
const QImage s = renderStrip(vp_, pal_, y, qMin(y + chunk, H));
|
||||
QPainter p(&pixmap_);
|
||||
p.drawImage(0, y, s);
|
||||
}
|
||||
// 注意:本函数返回前事件循环不跑——期间按钮无响应、窗口拖不动、paintEvent 排队等待。
|
||||
update();
|
||||
label_->setText(QString(QStringLiteral("主线程渲染 (界面冻结) · %1 ms"))
|
||||
.arg(int(timer_.elapsed())));
|
||||
}
|
||||
|
||||
// ---- 单/多线程切换:复用同一套分块逻辑,仅切线程池大小 ----
|
||||
void MandelbrotWidget::onToggleThreadMode() {
|
||||
multiThread_ = !multiThread_;
|
||||
QThreadPool::globalInstance()->setMaxThreadCount(
|
||||
multiThread_ ? QThread::idealThreadCount() : 1);
|
||||
threadCount_ = QThreadPool::globalInstance()->maxThreadCount();
|
||||
scheduleRender();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// ============================================================================
|
||||
// mandelbrot_widget —— Day4 Mandelbrot 渲染器的交互与渲染调度主体(QWidget)
|
||||
// 职责:
|
||||
// * paintEvent : 把累积 QImage 一次性画到窗口(QPainter 绘图知识点)
|
||||
// * wheelEvent/mouse* : 滚轮缩放(以鼠标为锚)、左键拖拽平移(事件处理知识点)
|
||||
// * scheduleRender() : 切水平条带丢 QThreadPool 并行算(多线程知识点)
|
||||
// * onStripReady() : 收条带、拼图、版本号防过期、耗时显示
|
||||
// * onRenderOnMainThread() : 「主线程渲染」按钮——同步算整帧,演示事件循环被
|
||||
// 占用即界面冻结(直击 Day4 slides「主线程耗时=冻结」痛点)
|
||||
// * onToggleThreadMode() : 复用同一套分块逻辑,切线程池大小,对比单/多线程加速比
|
||||
// ============================================================================
|
||||
#ifndef MANDELBROT_WIDGET_H
|
||||
#define MANDELBROT_WIDGET_H
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QImage>
|
||||
#include <QPoint>
|
||||
#include <QWidget>
|
||||
|
||||
#include "mandelbrot_strip_task.h" // MandelViewport / MandelPalette
|
||||
|
||||
class QLabel;
|
||||
|
||||
class MandelbrotWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MandelbrotWidget(QWidget* parent = nullptr);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent*) override;
|
||||
void wheelEvent(QWheelEvent*) override;
|
||||
void mousePressEvent(QMouseEvent*) override;
|
||||
void mouseMoveEvent(QMouseEvent*) override;
|
||||
void mouseReleaseEvent(QMouseEvent*) override;
|
||||
void resizeEvent(QResizeEvent*) override;
|
||||
|
||||
private slots:
|
||||
void onStripReady(quint64 epoch, int stripIndex, QImage strip);
|
||||
void onRenderOnMainThread(); // 卡死演示
|
||||
void onToggleThreadMode(); // 单/多线程切换
|
||||
|
||||
private:
|
||||
void scheduleRender(); // ++epoch + 分块丢池
|
||||
|
||||
MandelViewport vp_;
|
||||
MandelPalette pal_;
|
||||
QImage pixmap_; // 累积画布(离屏 QImage,paintEvent 外对它构造 QPainter 合法)
|
||||
quint64 epoch_ = 0; // 渲染版本号:新视口令旧结果作废丢弃(防过期覆盖)
|
||||
int stripsTotal_ = 0;
|
||||
int stripsDone_ = 0;
|
||||
QElapsedTimer timer_;
|
||||
bool multiThread_ = true;
|
||||
int threadCount_ = 0;
|
||||
bool dragging_ = false;
|
||||
QPoint lastMouse_;
|
||||
QLabel* label_ = nullptr;
|
||||
};
|
||||
|
||||
#endif // MANDELBROT_WIDGET_H
|
||||
Reference in New Issue
Block a user