Day3 教学示例:增强版计算器接入 + QMainWindow/QSS 两版参考实现

- calculator_designer_ext(Day2 增强版:表达式求值 + 括号,递归下降解析器)接入 cmake
- calculator_mainwindow(Day3 上午):增强版计算器装进 QMainWindow,
  菜单/状态栏/历史停靠窗,纯代码 + 显式 connect,与 mainwindow_showcase 形成坑卡对照
- calculator_mainwindow_qss(Day3 下午):上午版 + QSS 双主题美化(按键分色/伪状态/深浅切换)
- 验证 mainwindow_showcase(QMainWindow 六大组件综合演示,.ui + .qrc)
- OUTLINE_4DAY.md / ASSIGNMENTS.md Day3 衔接增强版起点 + 参考实现指引
- 补提交 Day2 calculator/ 与 calculator_designer/ 参考实现(CMakeLists 已引用)
- 全量构建 28/28,4 目标离屏自测退出码 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
张宗平
2026-07-07 22:59:05 +08:00
parent ea6f12e7ac
commit 05a685c952
45 changed files with 4599 additions and 6 deletions
+140
View File
@@ -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:0012: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:0012: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:0012:00、16:0017:00 两个答疑窗口,**优先确认基础版验收用例**,挑战版
时间不够可以留到当晚或第二天早上补交,不阻塞后续课程进度。
- 每天验收合格线 = 基础版全部用例通过;挑战版按完成比例加分,不设满分门槛。
- 若某学员基础版都吃力,参照各节「给基础不理想同学的提示」拆解成更小步骤,
必要时可对照教师参考实现(`examples/calculator/``examples/calculator_designer/`
逐段讲解,但**不要直接给学生抄源码**——抄一次治标不治本,下一天同类问题会
重复出现。
+36 -5
View File
@@ -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 @@
| 环节 | 内容 | 素材 |
|---|---|---|
| QMainWindow45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp``dock_widget.cpp` |
| QMainWindow45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp``dock_widget.cpp`;综合演示 [`examples/mainwindow_showcase/`](examples/mainwindow_showcase/README.md)(极简文本编辑器,.ui 表单 + 自动连接,覆盖 wiki ch05 六大组件) |
| QDialog45min 讲 + 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:0012:00 课堂练习:给昨天的计算器套上 QMainWindow——加菜单「帮助→关于」弹
`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。
11:0012: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` 已补两版关系说明。
+87 -1
View File
@@ -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
# 一致。需要 AUTOUICadd_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.15.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。无 QSSQSS 在下午版)。
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:0017: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 增强版计算器(CalculatorDesignerQWidget)装进 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 而非 indexOfresult 文本里一般不含 " = ",但用 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÷0Error 不 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 58954529https://wiki.suncaper.net/display/scu2023/5+QMainWindow)
**5.15.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. 状态栏 API5.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.pnggen_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 位、颜色类型 2RGB)的 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.15.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=offscreen200ms 后自动退出;
// 正常运行(有显示环境)窗口保持打开,不自动退出。仿 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>文件(&amp;F)</string>
</property>
<addaction name="actionNew"/>
<addaction name="actionQuit"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
<string>视图(&amp;V)</string>
</property>
<widget class="QMenu" name="menuToolBarArea">
<property name="title">
<string>工具栏停靠区(&amp;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>帮助(&amp;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>我是浮动窗口(QDockWidget5.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.uiDesigner)。本文件只做:
// - 资源文件应用(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 自动连接,无需手写 connectKISS)。
// ============================================================================
#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