From 05a685c952850dee1e17be9f63df6b6b915b6301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=97=E5=B9=B3?= Date: Tue, 7 Jul 2026 22:59:05 +0800 Subject: [PATCH] =?UTF-8?q?Day3=20=E6=95=99=E5=AD=A6=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=EF=BC=9A=E5=A2=9E=E5=BC=BA=E7=89=88=E8=AE=A1=E7=AE=97=E5=99=A8?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20+=20QMainWindow/QSS=20=E4=B8=A4=E7=89=88?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/teaching/ASSIGNMENTS.md | 140 ++++++++ docs/teaching/OUTLINE_4DAY.md | 41 ++- docs/teaching/examples/CMakeLists.txt | 88 +++++- docs/teaching/examples/calculator/README.md | 51 +++ .../examples/calculator/qt_calculator.cpp | 234 ++++++++++++++ .../examples/calculator_designer/README.md | 59 ++++ .../calculator_designer.ui | 164 ++++++++++ .../qt_calculator_designer.cpp | 202 ++++++++++++ .../calculator_designer_ext/CMakeLists.txt | 49 +++ .../calculatordesigner.cpp | 229 ++++++++++++++ .../calculatordesigner.h | 52 +++ .../expressionevaluator.cpp | 144 +++++++++ .../expressionevaluator.h | 20 ++ .../examples/calculator_designer_ext/main.cpp | 93 ++++++ .../calculator_designer_ext/widget.ui | 200 ++++++++++++ .../examples/calculator_mainwindow/README.md | 76 +++++ .../calculatordesigner.cpp | 248 +++++++++++++++ .../calculatordesigner.h | 66 ++++ .../expressionevaluator.cpp | 145 +++++++++ .../expressionevaluator.h | 22 ++ .../examples/calculator_mainwindow/main.cpp | 28 ++ .../calculator_mainwindow/mainwindow.cpp | 212 +++++++++++++ .../calculator_mainwindow/mainwindow.h | 59 ++++ .../examples/calculator_mainwindow/widget.ui | 200 ++++++++++++ .../calculator_mainwindow_qss/README.md | 61 ++++ .../calculatordesigner.cpp | 248 +++++++++++++++ .../calculatordesigner.h | 66 ++++ .../expressionevaluator.cpp | 145 +++++++++ .../expressionevaluator.h | 22 ++ .../calculator_mainwindow_qss/main.cpp | 24 ++ .../calculator_mainwindow_qss/mainwindow.cpp | 277 ++++++++++++++++ .../calculator_mainwindow_qss/mainwindow.h | 56 ++++ .../calculator_mainwindow_qss/widget.ui | 200 ++++++++++++ .../examples/mainwindow_showcase/README.md | 104 ++++++ .../examples/mainwindow_showcase/gen_icons.py | 83 +++++ .../mainwindow_showcase/icons/about.png | Bin 0 -> 98 bytes .../mainwindow_showcase/icons/app.png | Bin 0 -> 119 bytes .../mainwindow_showcase/icons/dock.png | Bin 0 -> 92 bytes .../mainwindow_showcase/icons/new.png | Bin 0 -> 103 bytes .../mainwindow_showcase/icons/quit.png | Bin 0 -> 103 bytes .../examples/mainwindow_showcase/main.cpp | 22 ++ .../mainwindow_showcase/mainwindow.qrc | 9 + .../mainwindow_showcase/mainwindow.ui | 299 ++++++++++++++++++ .../mainwindow_showcase.cpp | 110 +++++++ .../mainwindow_showcase/mainwindow_showcase.h | 57 ++++ 45 files changed, 4599 insertions(+), 6 deletions(-) create mode 100644 docs/teaching/ASSIGNMENTS.md create mode 100644 docs/teaching/examples/calculator/README.md create mode 100644 docs/teaching/examples/calculator/qt_calculator.cpp create mode 100644 docs/teaching/examples/calculator_designer/README.md create mode 100644 docs/teaching/examples/calculator_designer/calculator_designer.ui create mode 100644 docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp create mode 100644 docs/teaching/examples/calculator_designer_ext/CMakeLists.txt create mode 100644 docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp create mode 100644 docs/teaching/examples/calculator_designer_ext/calculatordesigner.h create mode 100644 docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp create mode 100644 docs/teaching/examples/calculator_designer_ext/expressionevaluator.h create mode 100644 docs/teaching/examples/calculator_designer_ext/main.cpp create mode 100644 docs/teaching/examples/calculator_designer_ext/widget.ui create mode 100644 docs/teaching/examples/calculator_mainwindow/README.md create mode 100644 docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow/calculatordesigner.h create mode 100644 docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow/expressionevaluator.h create mode 100644 docs/teaching/examples/calculator_mainwindow/main.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow/mainwindow.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow/mainwindow.h create mode 100644 docs/teaching/examples/calculator_mainwindow/widget.ui create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/README.md create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/main.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h create mode 100644 docs/teaching/examples/calculator_mainwindow_qss/widget.ui create mode 100644 docs/teaching/examples/mainwindow_showcase/README.md create mode 100644 docs/teaching/examples/mainwindow_showcase/gen_icons.py create mode 100644 docs/teaching/examples/mainwindow_showcase/icons/about.png create mode 100644 docs/teaching/examples/mainwindow_showcase/icons/app.png create mode 100644 docs/teaching/examples/mainwindow_showcase/icons/dock.png create mode 100644 docs/teaching/examples/mainwindow_showcase/icons/new.png create mode 100644 docs/teaching/examples/mainwindow_showcase/icons/quit.png create mode 100644 docs/teaching/examples/mainwindow_showcase/main.cpp create mode 100644 docs/teaching/examples/mainwindow_showcase/mainwindow.qrc create mode 100644 docs/teaching/examples/mainwindow_showcase/mainwindow.ui create mode 100644 docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp create mode 100644 docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h diff --git a/docs/teaching/ASSIGNMENTS.md b/docs/teaching/ASSIGNMENTS.md new file mode 100644 index 0000000..c0a0294 --- /dev/null +++ b/docs/teaching/ASSIGNMENTS.md @@ -0,0 +1,140 @@ +# Day2–4 课堂练习 + 课后作业(分层设计) + +> 配套 [`OUTLINE_4DAY.md`](OUTLINE_4DAY.md) 的任务卡、`slides/day2.html`…`day4.html` +> 里的「练/作业」小节。本文给出可直接打印/投屏的作业单:每项作业拆成 +> **基础版(必做,验收线)** + **挑战版(选做,见能力发挥)** 两层,时间预算按 +> 「基础版 1 小时内可完成、挑战版累计不超过 2 小时」控制,照顾基础不理想的 +> 学员——**先保基础版全员过关,挑战版不做不扣分**。 + +## 分层设计原则 + +1. **基础版是及格线,不是引导线**:题面本身已经是当天知识点的最小闭环,做完 + 基础版=当天目标达成;工程师答疑优先保基础版全员通过。 +2. **挑战版是同一个程序的自然延伸**,不引入新知识点,只要求「更完整/更健壮/ + 更贴近真实使用」——强度落在「把已学的东西用得更彻底」,不是提前预习明天内容。 +3. **验收用现场提问代替翻源码**:给几组输入让学生当场演示结果(下表「验收用例」), + 比对照代码更能测出是真理解还是照抄。 +4. 每天的作业首尾相接(计算器 → 计算器 QMainWindow 化 + 美化 → 数据存储工具), + 挑战版做不完不影响第二天用基础版继续。 + +--- + +## Day2 下午 · 简易计算器 + +对应任务卡 2(`OUTLINE_4DAY.md` Day2 下午作业)。教师参考实现: +[`examples/calculator/`](examples/calculator/README.md)(代码布局版)、 +[`examples/calculator_designer/`](examples/calculator_designer/README.md)(Qt Designer 对照版)。 + +| | 要求 | 时间预算 | +|---|---|---| +| **基础版(必做)** | 数字键 0–9 + 至少一种运算符(+/-/×/÷ 任选其一)+ 等号,能正确显示两个数的运算结果;界面不重叠、按钮可点击;`connect` 全部用新语法且写在构造函数里 | ≈1h | +| **挑战版(选做)** | 补全四则运算全部支持;小数点;清空键 `C`;退格键 `⌫`;除零不崩溃、显示 `Error` 且下一次输入自动清空;连续按运算符实现链式计算(如 `2+3+4=` 得 `9`) | 累计 ≤2h | + +**验收用例**(现场让学生当场演示,不看代码): + +- 基础版:`7 + 3 =` → `10`;`9 - 4 =` → `5`(任选一种运算符即可) +- 挑战版:`6 ÷ 0 =` → `Error`;`4.5 + 0.5 =` → `5`;连续输入 `12` 后按退格 → `1`; + `2 + 3 + 4 =` → `9` + +**给基础不理想同学的提示**:卡在"点击没反应"先查 `connect` 有没有漏写、按钮有没有 +真的加到布局里;卡在"数字显示不对"先只处理个位数,两位数以上的拼接逻辑 +(`display->text() + 新数字`)留到基础版跑通个位数运算之后再加。 + +--- + +## Day3 上午 · 计算器 QMainWindow 化 + +对应 `OUTLINE_4DAY.md` Day3 上午课堂练习(11:00–12:00)。 + +参考实现:[`examples/calculator_mainwindow/`](examples/calculator_mainwindow/README.md) +(以 Day2 增强版 [`calculator_designer_ext/`](examples/calculator_designer_ext/README.md) +为起点;`CalculatorDesigner` 是独立 `QWidget`,`setCentralWidget` 装进 `QMainWindow`, +核心求值逻辑不动,仅加 `evaluated` 信号供历史停靠窗订阅)。 + +| | 要求 | 时间预算 | +|---|---|---| +| **基础版(必做)** | 把 Day2 计算器的键盘 `setCentralWidget` 装进 `QMainWindow`;加菜单栏「帮助→关于」,点击弹出 `QMessageBox::about` 显示一句自我介绍 | ≈40min | +| **挑战版(选做)** | 加一个「历史」停靠窗(`QDockWidget` + `QListWidget`):每次按等号,把这次的算式和结果(如 `7+3=10`)追加成一条历史记录;停靠窗可拖动/浮动/关闭(`QDockWidget` 默认行为,不用额外写代码) | ≤1h | + +**验收用例**:点菜单「帮助→关于」能弹框;挑战版——连续算 3 次不同的算式,历史 +停靠窗里能看到 3 条记录且内容正确。 + +**给基础不理想同学的提示**:`QMainWindow` 不是 `QWidget` 的替代品,是新建一个 +`QMainWindow` 子类,把原来计算器的键盘部分(`QWidget` 或其内容)通过 +`setCentralWidget()` 塞进去;菜单点了没反应先检查 `QAction` 是不是忘了 `connect` +(Day3 上午刚讲的坑卡)。挑战版做不完,历史停靠窗留空 `QLabel` 占位即可, +不影响 Day3 下午继续。 + +--- + +## Day3 下午 · 计算器美化(QSS) + +对应任务卡(`OUTLINE_4DAY.md` Day3 下午作业)。 + +参考实现:[`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md) +(Day3 上午版 + QSS 双主题;与上午版仅 `mainwindow.{h,cpp}` 差异,演示「美化与逻辑正交」)。 + +| | 要求 | 时间预算 | +|---|---|---| +| **基础版(必做)** | 数字键 / 运算键 / 等号键至少用 3 种不同颜色区分(`setObjectName` + QSS ID 选择器 `#objectName{...}`);整体布局随窗口缩放不重叠、不错位 | ≈1h | +| **挑战版(选做)** | 至少 1 个按钮有 `:hover` 或 `:pressed` 视觉反馈;给结果显示框也套样式(圆角边框/字号);额外做一个"深色主题"QSS 字符串,加一个按钮或菜单项用 `setStyleSheet()` 在两套样式间切换 | ≤1h | + +**验收用例**:缩放窗口到很小和很大两种尺寸,按钮不重叠不错位;挑战版——点切换 +主题按钮,界面颜色整体改变。 + +**给基础不理想同学的提示**:QSS 选择器认的是 `objectName`(`setObjectName("btnEquals")` +之后才能用 `#btnEquals{...}`),忘设置 `objectName` 是"样式写了但没生效"的头号原因; +先做好 1 种颜色区分再扩展到 3 种,不要一次性套一大段 QSS 上去调不出问题在哪。 + +--- + +## Day4 上午 · 事件过滤器 / 自定义绘图 + +对应 `OUTLINE_4DAY.md` Day4 上午课堂练习(11:00–12:00)。 + +| | 要求 | 时间预算 | +|---|---|---| +| **基础版(必做,二选一)** | ① 给某个控件装 `installEventFilter()`,鼠标按下时在控制台打印坐标; ② 重写 `paintEvent`,用 `QPainter` 画一个组合图形(棋盘格、简单进度条、或几条交叉线均可) | ≈40min | +| **挑战版(选做,两项整合)** | 在一个自定义 `QWidget` 上:鼠标点击时记录点击坐标(`mousePressEvent` 或事件过滤器都行)到一个 `QVector`,每次点击后 `update()` 触发重绘,`paintEvent` 里把历史上所有点击过的坐标都画出来(比如画小圆点)——点越点越多,图案累积不丢失 | ≤1h | + +**验收用例**:基础版①——点击窗口能在控制台看到坐标打印;基础版②——窗口能看到 +一个图形;挑战版——连续点击 5 个不同位置,5 个点都保留在画面上(不是只显示 +最后一个)。 + +**给基础不理想同学的提示**:基础版明确「二选一」,不要求两项都做——事件拦截和 +自定义绘图是两条独立线索,先吃透一条。忘调用 `update()` 是"画了但界面没刷新" +的头号原因(`paintEvent` 只在 Qt 认为需要重绘时才会被调用,手动改了数据必须 +自己触发 `update()`)。 + +--- + +## Day4 下午 · 本地数据存储小工具 + +对应任务卡(`OUTLINE_4DAY.md` Day4 下午作业)。这是 Day5 综合案例 +[`examples/student_manager/`](examples/student_manager/README.md) 的直接前身, +教师验收时可参考其数据层设计(不要求学生做到那个完整度)。 + +| | 要求 | 时间预算 | +|---|---|---| +| **基础版(必做)** | 「学员信息」表单(姓名/成绩/标签,3 个 `QLineEdit` + 「添加」按钮)把记录追加到列表控件(`QListWidget`/`QTableWidget` 均可);「保存」按钮把全部记录写成 JSON 文件;重启程序后「读取」按钮能把之前保存的记录读回、字段不丢、顺序不乱(至少 2 条) | ≈1.5h | +| **挑战版(选做)** | ① 加「删除选中行」;② 数据文件路径用 `QCoreApplication::applicationDirPath()` 拼接,不用裸相对路径(当天★坑卡的实操版);③ 读取时文件不存在/JSON 格式损坏要给出友好提示而不是崩溃或静默清空(检查 `open()` 返回值和 `QJsonParseError`) | ≤1h | + +**验收用例**:添加 2 条记录 → 保存 → 关闭程序 → 重新打开 → 点「读取」→ 2 条记录 +都在、字段值正确;挑战版——把 JSON 文件手动改坏一个字符再读取,程序不崩溃。 + +**给基础不理想同学的提示**:先固定用一个写死的文件名(比如 `data.json`)把 +「写入→读回」跑通,再考虑路径是否规范(挑战版①);`QJsonDocument::toJson()`/ +`fromJson()` 是配对的两端,写的时候用什么结构(数组套对象),读的时候就按同样 +结构解析回来,两边字段名要完全一致(区分大小写)。 + +--- + +## 给工程师/助教的验收操作提示 + +- 11:00–12:00、16:00–17:00 两个答疑窗口,**优先确认基础版验收用例**,挑战版 + 时间不够可以留到当晚或第二天早上补交,不阻塞后续课程进度。 +- 每天验收合格线 = 基础版全部用例通过;挑战版按完成比例加分,不设满分门槛。 +- 若某学员基础版都吃力,参照各节「给基础不理想同学的提示」拆解成更小步骤, + 必要时可对照教师参考实现(`examples/calculator/`、`examples/calculator_designer/`) + 逐段讲解,但**不要直接给学生抄源码**——抄一次治标不治本,下一天同类问题会 + 重复出现。 diff --git a/docs/teaching/OUTLINE_4DAY.md b/docs/teaching/OUTLINE_4DAY.md index 60eeb8d..5849a84 100644 --- a/docs/teaching/OUTLINE_4DAY.md +++ b/docs/teaching/OUTLINE_4DAY.md @@ -7,6 +7,8 @@ > 因此本文即新基准下的正式教案大纲;此前对齐旧方案书的 5 天版 > [`OUTLINE.md`](OUTLINE.md) 保留作历史参照与素材库(其 24 张概念/坑卡在本文 > 全部复用)。幻灯片 `day1.html`…`day5.html` **已按本文重排**(§9 落地状态)。 +> Day2–4 课堂练习/课后作业的分层(基础版+挑战版)作业单见 +> [`ASSIGNMENTS.md`](ASSIGNMENTS.md)。 ## 0. 基准与显式假设(供随时纠偏) @@ -138,7 +140,7 @@ | 环节 | 内容 | 素材 | |---|---|---| | 信号槽(50min 讲 + 20min 练) | 观察者模式;内置/自定义/lambda 三种写法;新旧语法对比;连接方式 Direct/Queued/Auto | `p03/ch04/builtin_signal_slot.cpp`、`custom_signal_slot.cpp`、`lambda_signal_slot.cpp` | -| 计算器案例(40min 带练) | 数字/运算键盘 + 点击信号槽 + 结果显示;**布局用教师提供的 `QGridLayout` 脚手架「先用后学」**(布局原理 Day3 下午讲透,今天只求能排出键盘) | `p03/ch03` + `p03/ch04` + 脚手架片段(授课时现场给出) | +| 计算器案例(40min 带练) | 数字/运算键盘 + 点击信号槽 + 结果显示;**布局用教师提供的 `QGridLayout` 脚手架「先用后学」**(布局原理 Day3 下午讲透,今天只求能排出键盘) | `p03/ch03` + `p03/ch04` + 教师参考实现 [`examples/calculator/`](examples/calculator/README.md)(另有 [`examples/calculator_designer/`](examples/calculator_designer/README.md) Qt Designer `.ui` 对照版,含「自动连接静默失效」坑点) | 概念/坑卡:**信号槽三种写法[沿用]**(含旧语法静默失败、`QOverload` 消歧义)、外加—— @@ -161,7 +163,7 @@ | 环节 | 内容 | 素材 | |---|---|---| -| QMainWindow(45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp`、`dock_widget.cpp` | +| QMainWindow(45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp`、`dock_widget.cpp`;综合演示 [`examples/mainwindow_showcase/`](examples/mainwindow_showcase/README.md)(极简文本编辑器,.ui 表单 + 自动连接,覆盖 wiki ch05 六大组件) | | QDialog(45min 讲 + 20min 练) | 模态 vs 非模态(三种非模态写法对比:栈/堆/`WA_DeleteOnClose`);标准对话框 `QMessageBox`/`QFileDialog` | `p03/ch06/modal_dialog.cpp`、`modeless_dialog_stack.cpp`、`modeless_dialog_heap.cpp`、`modeless_dialog_deleteonclose.cpp`、`message_box_question.cpp`、`file_dialog.cpp` | 概念/坑卡(本节两张为本文新写,5 天版未覆盖 ch05/ch06): @@ -176,8 +178,15 @@ 析构,窗口一闪而过**——`p03/ch06` 三个 modeless 版本正是这个坑的对照教材,也是 Day2 栈/堆所有权卡的第三次复现。 -11:00–12:00 课堂练习:给昨天的计算器套上 QMainWindow——加菜单「帮助→关于」弹 -`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。 +11:00–12:00 课堂练习:以 Day2 增强版计算器([`examples/calculator_designer_ext/`](examples/calculator_designer_ext/README.md))为起点,套上 QMainWindow——加菜单「帮助→关于」弹 +`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。参考实现: +[`examples/calculator_mainwindow/`](examples/calculator_mainwindow/README.md)(纯代码 +建菜单 + 显式 connect,与 `mainwindow_showcase` 的 `.ui` 自动连接形成坑卡对照)。 + +> 衔接说明:Day2 增强版 `CalculatorDesigner` 已是独立 `QWidget`,Day3 上午只做 +> 「外壳升级」——`setCentralWidget` 把它装进 `QMainWindow`,核心求值逻辑不动。 +> 唯一新增是 `CalculatorDesigner` 加一个 `evaluated` 信号供历史停靠窗订阅(Day2 +> 信号槽知识的复用)。详见参考实现 README。 ### Day 3 下午:布局管理器 / 常用控件(+QSS 美化) @@ -185,7 +194,7 @@ |---|---|---| | 布局管理器(40min 讲 + 15min 练) | QHBox/QVBox/QGrid;嵌套布局;伸缩因子——回收 Day2 计算器脚手架,讲透当时「先用后学」的部分 | `p03/ch08/custom_widget.cpp` | | 常用控件(35min 讲 + 15min 练) | QLabel 文本/HTML/图片、QLineEdit、按钮族;资源系统 `.qrc` | `p03/ch08/label_text_html.cpp`、`label_pixmap.cpp`、`label_movie.cpp` | -| QSS 界面美化(25min 讲 + 20min 练) | 选择器/伪状态/层叠——对应新安排「自适应排版及**美化**」目标 | `docs/teaching/examples/qss_styling/qt_qss_styling.cpp` + [README](examples/qss_styling/README.md) | +| QSS 界面美化(25min 讲 + 20min 练) | 选择器/伪状态/层叠——对应新安排「自适应排版及**美化**」目标 | `docs/teaching/examples/qss_styling/qt_qss_styling.cpp` + [README](examples/qss_styling/README.md);作业线参考 [`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)(上午版 + 双主题 QSS) | 概念/坑卡:**布局管理[沿用]**(同控件不能属两个布局;布局后 `setGeometry` 被 覆盖)、**资源系统 .qrc[沿用]**(路径静默失败/AUTORCC)、**QSS 美化[沿用]** @@ -193,6 +202,8 @@ 16:00–17:00 作业:计算器升级——数字键/运算键/等号键用 QSS 区分颜色,至少 1 个按钮 有 `:hover` 或 `:pressed` 效果;整体布局改为可随窗口缩放(验收同 5 天版 QSS 任务卡)。 +参考实现:[`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)(在 +Day3 上午版基础上只加 QSS,演示「美化与逻辑正交」)。 ### Day 4 上午:Qt 消息机制和事件 / 绘图事件及表格事件 @@ -395,5 +406,25 @@ QThread 提升环节**——若 Day5 案例讲解超时挤掉它,项目期「 3. ✅ **Day5 综合案例参考实现**:`docs/teaching/examples/student_manager/` (QMainWindow + QTableWidget + 对话框 + JSON round-trip + QThread 后台导入; 离屏自测模式自动校验「写入→读回」与导入闭环)。 +3.1 ✅ **Day2 案例参考实现**:`docs/teaching/examples/calculator/`(代码 + `QGridLayout` 版)+ `docs/teaching/examples/calculator_designer/`(Qt Designer + `.ui` 对照版,含自动连接 `on__` 静默失效坑点);两版业务 + 逻辑一致,离屏自测覆盖加减乘除/链式运算/除零/退格 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__` 自动连接,覆盖 + 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` 已补两版关系说明。 diff --git a/docs/teaching/examples/CMakeLists.txt b/docs/teaching/examples/CMakeLists.txt index 7482b23..201f416 100644 --- a/docs/teaching/examples/CMakeLists.txt +++ b/docs/teaching/examples/CMakeLists.txt @@ -25,9 +25,89 @@ add_teaching_example(qt_pitfall_stack_widget pitfall_stack_widget) add_teaching_example(qt_pitfall_lambda_capture pitfall_lambda_capture) add_teaching_example(qt_pitfall_double_connect pitfall_double_connect) add_teaching_example(qt_table_widget_basics table_widget) +# Day2 下午案例参考实现(OUTLINE_4DAY.md Day2 下午「简易计算器」) +add_teaching_example(qt_calculator calculator) + +# Day2 案例的 Qt Designer(.ui) 对照版:界面用表单设计、业务逻辑与 qt_calculator +# 一致。需要 AUTOUIC(add_teaching_example 只开 AUTOMOC),单独写 target。 +add_executable(qt_calculator_designer + calculator_designer/qt_calculator_designer.cpp + calculator_designer/calculator_designer.ui) +set_target_properties(qt_calculator_designer PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF + AUTOMOC ON AUTOUIC ON) +target_link_libraries(qt_calculator_designer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_calculator_designer PRIVATE -Wall -Wextra) + +# Day2 增强版计算器(calculator_designer_ext):在 Designer 版基础上把「立即计算」 +# 升级为「表达式求值」——独立 ExpressionEvaluator 用递归下降解析器实现运算符优先级 +# 与括号(含嵌套、未闭合自动补全)。多源文件 + .ui 表单,单独写 target。 +# 注意:子目录里那个 Qt Creator 导出的 CMakeLists.txt 不参与仓库构建(C++11 / +# 目标名 Calculator / ANDROID 分支均不符仓库 C++17 规范),构建一律走本 target。 +add_executable(qt_calculator_designer_ext + calculator_designer_ext/main.cpp + calculator_designer_ext/calculatordesigner.cpp + calculator_designer_ext/expressionevaluator.cpp + calculator_designer_ext/widget.ui) +target_include_directories(qt_calculator_designer_ext PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/calculator_designer_ext) +set_target_properties(qt_calculator_designer_ext PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF + AUTOMOC ON AUTOUIC ON) +target_link_libraries(qt_calculator_designer_ext PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_calculator_designer_ext PRIVATE -Wall -Wextra) + # Day5 综合案例参考实现(OUTLINE_4DAY.md §3 / §9.3) add_teaching_example(qt_student_manager student_manager) +# QMainWindow 教学综合示例(wiki「5 QMainWindow」pageId 58954529 的 5.1–5.6 全组件): +# form designer(.ui) 定义主窗体结构、.qrc 资源文件提供应用图标。单独写 target +# (标准 add_teaching_example 只开 AUTOMOC,本例还需 AUTOUIC+AUTORCC)。 +add_executable(qt_mainwindow_showcase + mainwindow_showcase/main.cpp + mainwindow_showcase/mainwindow_showcase.cpp + mainwindow_showcase/mainwindow_showcase.h + mainwindow_showcase/mainwindow.ui + mainwindow_showcase/mainwindow.qrc) +set_target_properties(qt_mainwindow_showcase PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF + AUTOMOC ON AUTOUIC ON AUTORCC ON) +target_link_libraries(qt_mainwindow_showcase PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_mainwindow_showcase PRIVATE -Wall -Wextra) + +# Day3 上午版参考实现:把 Day2 增强版计算器装进 QMainWindow(菜单/状态栏/历史停靠窗)。 +# 复用 Day2 的 calculatordesigner + expressionevaluator + widget.ui(自包含复制), +# 新增 mainwindow.{h,cpp} 外壳 + 含 runSelfTest 的 main.cpp。无 QSS(QSS 在下午版)。 +add_executable(qt_calculator_mainwindow + calculator_mainwindow/main.cpp + calculator_mainwindow/mainwindow.cpp + calculator_mainwindow/calculatordesigner.cpp + calculator_mainwindow/expressionevaluator.cpp + calculator_mainwindow/widget.ui) +target_include_directories(qt_calculator_mainwindow PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow) +set_target_properties(qt_calculator_mainwindow PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF + AUTOMOC ON AUTOUIC ON) +target_link_libraries(qt_calculator_mainwindow PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_calculator_mainwindow PRIVATE -Wall -Wextra) + +# Day3 下午版参考实现:上午版 + QSS 双主题美化。复用上午版同款 5 文件(自包含复制), +# 仅 mainwindow.{h,cpp} 加双主题 QSS + 视图菜单「深色主题」、main.cpp 自测加主题断言。 +add_executable(qt_calculator_mainwindow_qss + calculator_mainwindow_qss/main.cpp + calculator_mainwindow_qss/mainwindow.cpp + calculator_mainwindow_qss/calculatordesigner.cpp + calculator_mainwindow_qss/expressionevaluator.cpp + calculator_mainwindow_qss/widget.ui) +target_include_directories(qt_calculator_mainwindow_qss PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow_qss) +set_target_properties(qt_calculator_mainwindow_qss PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF + AUTOMOC ON AUTOUIC ON) +target_link_libraries(qt_calculator_mainwindow_qss PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_calculator_mainwindow_qss PRIVATE -Wall -Wextra) + add_custom_target(all_teaching_examples DEPENDS qt_qss_styling qt_model_view_basics @@ -39,4 +119,10 @@ add_custom_target(all_teaching_examples DEPENDS qt_pitfall_lambda_capture qt_pitfall_double_connect qt_table_widget_basics - qt_student_manager) + qt_calculator + qt_calculator_designer + qt_calculator_designer_ext + qt_student_manager + qt_mainwindow_showcase + qt_calculator_mainwindow + qt_calculator_mainwindow_qss) diff --git a/docs/teaching/examples/calculator/README.md b/docs/teaching/examples/calculator/README.md new file mode 100644 index 0000000..9f1dbcc --- /dev/null +++ b/docs/teaching/examples/calculator/README.md @@ -0,0 +1,51 @@ +# 简易计算器 —— Day2 下午案例参考实现(代码布局版) + +> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「信号槽机制 → +> 项目案例:简易计算器」。教师侧参考答案,手工维护,非生成产物。 +> Designer 表单对照版见 [`../calculator_designer/`](../calculator_designer/README.md)。 + +## 覆盖的知识点(讲解时逐条回指) + +| 环节 | 知识点 | 首次出现 | +|---|---|---| +| 界面骨架 | `QGridLayout` 排数字/运算符键盘 | Day2 下午(脚手架先用后学,Day3 下午讲透原理) | +| 信号槽写法 1 | lambda + 第五参数 context(数字/运算符键批量绑定) | Day2 下午 | +| 信号槽写法 2 | 成员函数指针连接(等号/清空/退格键) | Day2 下午 | +| 坑点规避 | 所有 `connect` 只在构造函数执行一次,不留「重复 connect」隐患 | Day2 ★坑卡 | +| 编码 | 中文/符号按钮文字 `QStringLiteral` + 源文件 UTF-8 | Day2 ★坑卡 | +| 状态机 | 数字/小数点/运算符/等号/清空/退格 六类输入的最小状态机;连续按运算符走「链式计算」;除零给出 `Error` 而不崩溃 | 案例本身 | + +## 验证方式(自测模式) + +```bash +QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator +# 自测流程:直接调用与按钮绑定的同一批处理函数(等价于点击),依次验证 +# 「7+3=10」「6÷0=Error」「4.5+0.5=5」「12 退格→1」「2+3+4=9(链式运算)」 +# 全部通过退出码 0,任一步失败退出码 1 +tools/run_qt.sh qt_calculator # 有 X 环境:完整交互 +``` + +## 课堂讲解建议 + +1. 先跑成品:现场敲一次「7 + 3 =」,再回头拆代码——项目 = 已学知识点的组装; +2. 重点讲两种 connect 写法的取舍:数字/运算符键数量多、逻辑相同,用 + lambda 捕获参数 + 统一槽函数(`onDigit(int)`/`onOperator(QChar)`); + 等号/清空/退格各自唯一,直接用成员函数指针更直观; +3. 现场移除某个按钮的 `connect`,演示"按钮显示正常、点击没反应"——呼应 + QAction 忘 connect 的坑(Day3 会再见到同一类问题); +4. 除零处理是刻意设计的教学点:不用 `assert`/崩溃,而是给出可恢复的错误状态 + (`Error` + 下一次输入自动清空),这是「健壮性」在中初级学员能理解层面的 + 最小示范。 + +## 练习方向(对应任务卡 2 · 课后作业) + +见 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「16:00–17:00 作业」与 +`slides/day2.html` 任务卡 2;本参考实现即该作业的教师侧标准答案,**不要在课上 +直接展示源码给学生抄**,讲解思路后学生自己实现。 + +## 参考 + +- 布局脚手架来源:`p03/ch08/custom_widget.cpp`(`QHBoxLayout`/`QSlider` 联动) +- 信号槽写法来源:`p03/ch04/`(`builtin_signal_slot.cpp`、`custom_signal_slot.cpp`、 + `lambda_signal_slot.cpp`) +- Designer 表单对照版:[`../calculator_designer/`](../calculator_designer/README.md) diff --git a/docs/teaching/examples/calculator/qt_calculator.cpp b/docs/teaching/examples/calculator/qt_calculator.cpp new file mode 100644 index 0000000..11f796d --- /dev/null +++ b/docs/teaching/examples/calculator/qt_calculator.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +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" diff --git a/docs/teaching/examples/calculator_designer/README.md b/docs/teaching/examples/calculator_designer/README.md new file mode 100644 index 0000000..58a1e6b --- /dev/null +++ b/docs/teaching/examples/calculator_designer/README.md @@ -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__()` 的槽(比如 `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(`…`)—— + 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` diff --git a/docs/teaching/examples/calculator_designer/calculator_designer.ui b/docs/teaching/examples/calculator_designer/calculator_designer.ui new file mode 100644 index 0000000..7dcb8c7 --- /dev/null +++ b/docs/teaching/examples/calculator_designer/calculator_designer.ui @@ -0,0 +1,164 @@ + + + CalculatorForm + + + + 0 + 0 + 358 + 320 + + + + 简易计算器(Designer 版) + + + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + + + + + + + 7 + + + + + + + 8 + + + + + + + 9 + + + + + + + ÷ + + + + + + + 4 + + + + + + + 5 + + + + + + + 6 + + + + + + + × + + + + + + + 1 + + + + + + + 2 + + + + + + + 3 + + + + + + + - + + + + + + + 0 + + + + + + + . + + + + + + + = + + + + + + + + + + + + + + + C + + + + + + + + + + + + + + + + + diff --git a/docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp b/docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp new file mode 100644 index 0000000..fed60a6 --- /dev/null +++ b/docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp @@ -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__() +// 的槽连到对应控件的信号。本例改用显式 connect()(更贴近代码版、也更利于 +// 团队协作排查),但这个自动连接特性是 Designer 工作流特有的第三种信号槽写法, +// 坑点见 README:控件改名后自动连接悄悄失效,不报编译错误也不报运行时错误。 +// +// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator_designer +// 自测用例与 ../calculator/ 完全一致,用于验证「界面来源不同、行为一致」。 +// ============================================================================ + +#include +#include +#include +#include + +#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" diff --git a/docs/teaching/examples/calculator_designer_ext/CMakeLists.txt b/docs/teaching/examples/calculator_designer_ext/CMakeLists.txt new file mode 100644 index 0000000..625e841 --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/CMakeLists.txt @@ -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) diff --git a/docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp new file mode 100644 index 0000000..47197ff --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp @@ -0,0 +1,229 @@ +#include "calculatordesigner.h" + +#include + +#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; +} diff --git a/docs/teaching/examples/calculator_designer_ext/calculatordesigner.h b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.h new file mode 100644 index 0000000..efaec27 --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +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; +}; diff --git a/docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp new file mode 100644 index 0000000..85cbe3e --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp @@ -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(); +} diff --git a/docs/teaching/examples/calculator_designer_ext/expressionevaluator.h b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.h new file mode 100644 index 0000000..1a42cb4 --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// 对形如 "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); +}; diff --git a/docs/teaching/examples/calculator_designer_ext/main.cpp b/docs/teaching/examples/calculator_designer_ext/main.cpp new file mode 100644 index 0000000..a48ad8a --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/main.cpp @@ -0,0 +1,93 @@ +#include +#include +#include + +#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(); +} diff --git a/docs/teaching/examples/calculator_designer_ext/widget.ui b/docs/teaching/examples/calculator_designer_ext/widget.ui new file mode 100644 index 0000000..9b7c009 --- /dev/null +++ b/docs/teaching/examples/calculator_designer_ext/widget.ui @@ -0,0 +1,200 @@ + + + CalculatorForm + + + + 0 + 0 + 358 + 320 + + + + 简易计算器(Designer 版) + + + + + + + 0 + 50 + + + + + 16777215 + 80 + + + + true + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + + + + + + + 7 + + + + + + + 8 + + + + + + + 9 + + + + + + + ÷ + + + + + + + 4 + + + + + + + 5 + + + + + + + 6 + + + + + + + × + + + + + + + 1 + + + + + + + 2 + + + + + + + 3 + + + + + + + - + + + + + + + 0 + + + + + + + . + + + + + + + = + + + + + + + + + + + + + + + C + + + + + + + + + + + + + + ( + + + + + + + ) + + + + + + + + + + diff --git a/docs/teaching/examples/calculator_mainwindow/README.md b/docs/teaching/examples/calculator_mainwindow/README.md new file mode 100644 index 0000000..c7debde --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/README.md @@ -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__` 自动连接 | 显式 `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 弹窗验证靠现场演示) diff --git a/docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp new file mode 100644 index 0000000..87d909c --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp @@ -0,0 +1,248 @@ +#include "calculatordesigner.h" + +#include + +#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; +} diff --git a/docs/teaching/examples/calculator_mainwindow/calculatordesigner.h b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.h new file mode 100644 index 0000000..68347d7 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +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; +}; diff --git a/docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp new file mode 100644 index 0000000..4967eab --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp @@ -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(); +} diff --git a/docs/teaching/examples/calculator_mainwindow/expressionevaluator.h b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.h new file mode 100644 index 0000000..a4dbc00 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +// 对形如 "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); +}; diff --git a/docs/teaching/examples/calculator_mainwindow/main.cpp b/docs/teaching/examples/calculator_mainwindow/main.cpp new file mode 100644 index 0000000..1ad6629 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/main.cpp @@ -0,0 +1,28 @@ +// ============================================================================ +// calculator_mainwindow —— Day3 上午版程序入口 +// 对应教案 OUTLINE_4DAY.md Day3 上午「QMainWindow / 对话框 QDialog」。 +// +// 把 Day2 增强版计算器(CalculatorDesigner,QWidget)装进 QMainWindow: +// - 菜单栏(文件/编辑/视图/帮助,纯代码 + 显式 connect) +// - 历史停靠窗(QDockWidget + QListWidget,订阅 evaluated 信号) +// - 状态栏(就绪 / 结果临时提示) +// 离屏自测覆盖 Day2 全部 9 组计算用例 + Day3 新增(历史追加/双击回填/清空)。 +// ============================================================================ +#include +#include + +#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(); +} diff --git a/docs/teaching/examples/calculator_mainwindow/mainwindow.cpp b/docs/teaching/examples/calculator_mainwindow/mainwindow.cpp new file mode 100644 index 0000000..d3a78fc --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/mainwindow.cpp @@ -0,0 +1,212 @@ +// ============================================================================ +// mainwindow.cpp —— Day3 上午版实现 +// 详见 mainwindow.h 顶部注释。本文件只做外壳组装,不含任何计算逻辑。 +// ============================================================================ +#include "mainwindow.h" +#include "calculatordesigner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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( + "计算器(QMainWindow 版)

" + "Day3 上午教学参考实现:把 Day2 增强版计算器(表达式求值 + 括号)" + "装进 QMainWindow,演示菜单栏 / 状态栏 / 历史停靠窗。

" + "对应教案 OUTLINE_4DAY.md Day3 上午。")); +} + +void MainWindow::onClearHistory() { + m_history->clear(); + refreshStatus(QStringLiteral("已清空历史")); +} + +void MainWindow::onToggleHistoryVisible(bool checked) { + m_dock->setVisible(checked); +} + +void MainWindow::onEvaluated(const QString& expr, const QString& result) { + m_history->addItem(expr + QStringLiteral(" = ") + result); + refreshStatus(QStringLiteral("结果: ") + result); +} + +void MainWindow::onHistoryDoubleClicked(QListWidgetItem* item) { + // 条目文本形如 "expr = result",取最后一个 " = " 左侧作为表达式回填。 + // 用 lastIndexOf 而非 indexOf:result 文本里一般不含 " = ",但用 lastIndexOf + // 更稳妥(避免表达式里万一出现 " = ",实际上表达式不会含 =)。 + const QString text = item->text(); + const int idx = text.lastIndexOf(QStringLiteral(" = ")); + if (idx < 0) return; + m_calc->loadExpression(text.left(idx)); + refreshStatus(QStringLiteral("已回填表达式")); +} + +// ------------------------------------------------------------ 自测 ---- +bool MainWindow::runSelfTest() { + auto expect = [](const char* label, bool cond) -> bool { + if (!cond) { qCritical() << "自测失败:" << label; return false; } + qInfo() << "自测通过:" << label; return true; + }; + auto expectEq = [](const char* label, const QString& actual, const QString& expected) -> bool { + if (actual != expected) { + qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected; + return false; + } + qInfo() << "自测通过:" << label << "=>" << actual; + return true; + }; + + // —— Day2 计算 9 组用例(验证升级外壳后计算行为不变)—— + m_calc->onClear(); + m_calc->onDigit(7); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3); m_calc->onEquals(); + if (!expectEq("7 + 3 =", m_calc->displayText(), QStringLiteral("10"))) return false; + if (!expect("历史追加第 1 条", m_history->count() == 1)) return false; + if (!expectEq("历史条目文本", m_history->item(0)->text(), QStringLiteral("7+3 = 10"))) return false; + + m_calc->onClear(); + m_calc->onDigit(6); m_calc->onOperator(QLatin1Char('/')); m_calc->onDigit(0); m_calc->onEquals(); + if (!expectEq("6 ÷ 0 =", m_calc->displayText(), QStringLiteral("Error"))) return false; + if (!expect("除零 Error 不进历史", m_history->count() == 1)) return false; + + m_calc->onClear(); + m_calc->onDigit(4); m_calc->onDot(); m_calc->onDigit(5); + m_calc->onOperator(QLatin1Char('+')); + m_calc->onDigit(0); m_calc->onDot(); m_calc->onDigit(5); + m_calc->onEquals(); + if (!expectEq("4.5 + 0.5 =", m_calc->displayText(), QStringLiteral("5"))) return false; + + m_calc->onClear(); + m_calc->onDigit(1); m_calc->onDigit(2); m_calc->onBackspace(); + if (!expectEq("12 退格", m_calc->displayText(), QStringLiteral("1"))) return false; + + m_calc->onClear(); + m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); + m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('+')); + m_calc->onDigit(4); m_calc->onEquals(); + if (!expectEq("2 + 3 + 4 =", m_calc->displayText(), QStringLiteral("9"))) return false; + + m_calc->onClear(); + m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); + m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('*')); + m_calc->onDigit(4); m_calc->onEquals(); + if (!expectEq("2 + 3 × 4 =", m_calc->displayText(), QStringLiteral("14"))) return false; + + m_calc->onClear(); + m_calc->onLeftParen(); + m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3); + m_calc->onRightParen(); + m_calc->onOperator(QLatin1Char('*')); + m_calc->onDigit(4); m_calc->onEquals(); + if (!expectEq("(2 + 3) × 4 =", m_calc->displayText(), QStringLiteral("20"))) return false; + + m_calc->onClear(); + m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+')); + m_calc->onLeftParen(); + m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('*')); + m_calc->onLeftParen(); + m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(4); + m_calc->onRightParen(); + m_calc->onRightParen(); + m_calc->onEquals(); + if (!expectEq("1 + (2 × (3 + 4)) =", m_calc->displayText(), QStringLiteral("15"))) return false; + + m_calc->onClear(); + m_calc->onLeftParen(); + m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(2); + m_calc->onEquals(); + if (!expectEq("(1 + 2 = 自动补全", m_calc->displayText(), QStringLiteral("3"))) return false; + + // 9 组用例里:第 4 组(12 退格)无 onEquals、第 2 组(6÷0)Error 不 emit, + // 其余 7 次 onEquals 各进历史一条。 + if (!expect("历史累计 7 条", m_history->count() == 7)) return false; + + // —— Day3 新增:历史双击回填 —— + QListWidgetItem* last = m_history->item(m_history->count() - 1); + if (!expectEq("最后一条历史", last->text(), QStringLiteral("(1+2) = 3"))) return false; + onHistoryDoubleClicked(last); + if (!expectEq("双击回填表达式区", m_calc->expressionText(), QStringLiteral("(1+2)"))) return false; + + // —— Day3 新增:清空历史 —— + onClearHistory(); + if (!expect("清空历史后 count==0", m_history->count() == 0)) return false; + + // —— Day3 新增:关于 action 已创建并显式连接 —— + // (QMessageBox::about 是模态弹窗,离屏下不真正触发,GUI 弹窗验证靠现场演示) + if (!expect("关于 action 已创建", m_actAbout != nullptr)) return false; + + return true; +} diff --git a/docs/teaching/examples/calculator_mainwindow/mainwindow.h b/docs/teaching/examples/calculator_mainwindow/mainwindow.h new file mode 100644 index 0000000..cd6b04e --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/mainwindow.h @@ -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__ 自动连接 +// - 本例:纯代码建菜单/停靠窗 + 显式 connect() +// 两种风格并列,前者突出「Designer 快速搭骨架」,后者突出「QAction 必须 +// connect 才生效」(Day3 上午坑卡:菜单显示正常点了没反应)。 +// ============================================================================ +#ifndef CALCULATOR_MAINWINDOW_H +#define CALCULATOR_MAINWINDOW_H + +#include + +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 diff --git a/docs/teaching/examples/calculator_mainwindow/widget.ui b/docs/teaching/examples/calculator_mainwindow/widget.ui new file mode 100644 index 0000000..e446f09 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow/widget.ui @@ -0,0 +1,200 @@ + + + CalculatorForm + + + + 0 + 0 + 358 + 320 + + + + 计算器面板 + + + + + + + 0 + 50 + + + + + 16777215 + 80 + + + + true + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + + + + + + + 7 + + + + + + + 8 + + + + + + + 9 + + + + + + + ÷ + + + + + + + 4 + + + + + + + 5 + + + + + + + 6 + + + + + + + × + + + + + + + 1 + + + + + + + 2 + + + + + + + 3 + + + + + + + - + + + + + + + 0 + + + + + + + . + + + + + + + = + + + + + + + + + + + + + + + C + + + + + + + + + + + + + + ( + + + + + + + ) + + + + + + + + + + diff --git a/docs/teaching/examples/calculator_mainwindow_qss/README.md b/docs/teaching/examples/calculator_mainwindow_qss/README.md new file mode 100644 index 0000000..1d8e8aa --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/README.md @@ -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` 里已定义 + (``),无需再 `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` 米黄背景 + 圆角) +- **挑战版**:深/浅双主题切换 ✓(视图菜单「深色主题」) diff --git a/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp new file mode 100644 index 0000000..87d909c --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp @@ -0,0 +1,248 @@ +#include "calculatordesigner.h" + +#include + +#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; +} diff --git a/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h new file mode 100644 index 0000000..68347d7 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +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; +}; diff --git a/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp new file mode 100644 index 0000000..4967eab --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp @@ -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(); +} diff --git a/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h new file mode 100644 index 0000000..a4dbc00 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +// 对形如 "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); +}; diff --git a/docs/teaching/examples/calculator_mainwindow_qss/main.cpp b/docs/teaching/examples/calculator_mainwindow_qss/main.cpp new file mode 100644 index 0000000..9f08729 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/main.cpp @@ -0,0 +1,24 @@ +// ============================================================================ +// calculator_mainwindow_qss —— Day3 下午版程序入口(上午版 + QSS 双主题) +// 对应教案 OUTLINE_4DAY.md Day3 下午「布局/控件 + QSS 美化」。 +// 与 ../calculator_mainwindow/main.cpp 仅 include 的头不同(本目录自己的), +// 逻辑一致:offscreen 时调 MainWindow::runSelfTest(),按返回值决定退出码。 +// ============================================================================ +#include +#include + +#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(); +} diff --git a/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp new file mode 100644 index 0000000..4d5b4e9 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp @@ -0,0 +1,277 @@ +// ============================================================================ +// mainwindow.cpp —— Day3 下午版实现(上午版 + QSS 双主题) +// 与 ../calculator_mainwindow/mainwindow.cpp 的差异全部集中在本文件的 QSS 部分 +// (见 kLightQss / kDarkQss / applyStyle / onToggleTheme / 视图菜单多一项), +// 菜单/停靠窗/状态栏/历史/自测的计算用例部分一字未改。 +// ============================================================================ +#include "mainwindow.h" +#include "calculatordesigner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ---- 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( + "计算器(QMainWindow + QSS 版)

" + "Day3 下午教学参考实现:在上午版基础上加 QSS 双主题美化——" + "数字/运算/等号/括号/清空退格按键分色,:hover/:pressed 视觉反馈," + "深/浅主题切换。

" + "对应教案 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; +} diff --git a/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h new file mode 100644 index 0000000..c1c65dd --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h @@ -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 + +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 diff --git a/docs/teaching/examples/calculator_mainwindow_qss/widget.ui b/docs/teaching/examples/calculator_mainwindow_qss/widget.ui new file mode 100644 index 0000000..e446f09 --- /dev/null +++ b/docs/teaching/examples/calculator_mainwindow_qss/widget.ui @@ -0,0 +1,200 @@ + + + CalculatorForm + + + + 0 + 0 + 358 + 320 + + + + 计算器面板 + + + + + + + 0 + 50 + + + + + 16777215 + 80 + + + + true + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + + + + + + + 7 + + + + + + + 8 + + + + + + + 9 + + + + + + + ÷ + + + + + + + 4 + + + + + + + 5 + + + + + + + 6 + + + + + + + × + + + + + + + 1 + + + + + + + 2 + + + + + + + 3 + + + + + + + - + + + + + + + 0 + + + + + + + . + + + + + + + = + + + + + + + + + + + + + + + C + + + + + + + + + + + + + + ( + + + + + + + ) + + + + + + + + + + diff --git a/docs/teaching/examples/mainwindow_showcase/README.md b/docs/teaching/examples/mainwindow_showcase/README.md new file mode 100644 index 0000000..43484fe --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/README.md @@ -0,0 +1,104 @@ +# QMainWindow 教学综合示例(qt_mainwindow_showcase) + +覆盖川大 wiki「5 QMainWindow」(pageId 58954529,https://wiki.suncaper.net/display/scu2023/5+QMainWindow) +的 **5.1–5.6 全部六大核心组件**,形态为**极简文本编辑器**(`QTextEdit` 中心部件), +纯组件演示、**无文件 I/O**。主窗体结构用 **Qt Designer(`.ui`)** 设计,应用图标与动作图标 +走 **资源文件(`.qrc`)**。 + +> 仓库已有 `p03/ch05/` 的零散单组件示例(central_widget、dock_widget);本示例是 +> **综合演示**,把六者整合到一个 `QMainWindow` 程序里,并能交互操控它们。 + +## 六大组件 ↔ 代码映射 + +| wiki | 组件 | 代码位置 | 演示的 API | +|---|---|---|---| +| 5.5 | 中心部件 | `mainwindow.ui`:`centralwidget/textEdit`;`.cpp`:`textChanged`/`cursorPositionChanged → updateStatusBar` | `setCentralWidget(QTextEdit)` | +| 5.1 | 菜单栏 | `.ui`:`menubar` + `menuFile/menuView/menuHelp/menuToolBarArea` + 12 个 `QAction`;`.cpp`:`on_action*_triggered/toggled` | `menuBar()`/`addMenu`/`QAction` | +| 5.2 | 工具栏 | `.ui`:`mainToolBar`(复用 `QAction`);`.cpp`:`setMovable`/`setAllowedAreas`/`setVisible` | `addToolBar`/`setAllowedAreas`/`setMovable` | +| 5.3 | 状态栏 | `.cpp`:`addPermanentWidget(statusPosLabel)`/`showMessage` | `addWidget`/`insertWidget`/`removeWidget`(见下) | +| 5.4 | 浮动窗口 | `.ui`:`dockWidget`;`.cpp`:`setFloating`/`setAllowedAreas` | `QDockWidget`/`addDockWidget` | +| 5.6 | 资源文件 | `mainwindow.qrc` + `.ui` 的 `windowIcon` + 各 `QAction` 图标 | `:/icons/*.png` | + +## 关键设计点 + +### 1. QAction 是菜单项与工具按钮的公共抽象(5.1) +12 个 `QAction` 在 `.ui` 的 `MainWindow` widget 内**定义一次**,被菜单和工具栏**共同引用** +(``)——这正是 wiki 强调的「同一动作,加进菜单显示为菜单项、 +加进工具栏显示为工具按钮」。例如 `actionNew` 同时出现在「文件」菜单和主工具栏。 + +### 2. 信号槽:on_\_\ 自动连接 +槽命名遵循 Qt 约定,由 `setupUi()` 触发的 `QMetaObject::connectSlotsByName` **自动连接**, +无需手写 `connect`(KISS)。例如 `on_actionToggleDockFloating_triggered()` 自动连接 +`actionToggleDockFloating` 的 `triggered` 信号。 + +### 3. 视图菜单 = 「操控」各组件的入口 +| 动作 | 行为 | 对应组件/API | +|---|---|---| +| 显示主工具栏 ✓ | `mainToolBar->setVisible(checked)` | 5.2 | +| 显示状态栏 ✓ | `statusBar()->setVisible(checked)` | 5.3 | +| 工具栏可移动 ✓ | `mainToolBar->setMovable(checked)` | 5.2 `setMovable` | +| 停靠窗口浮动/停靠 | `dockWidget->setFloating(!isFloating())` | 5.4 `setFloating` | +| 显示浮动窗口 | `dockWidget->show()` | 5.4 重新显示(被 X 关闭后找回) | +| 关闭浮动窗口 | `dockWidget->hide()` | 5.4 直接隐藏 | +| 工具栏停靠区(子菜单 5 项) | `mainToolBar->setAllowedAreas(...)` | 5.2 `setAllowedAreas` | + +### 4. 状态栏 API(5.3) +运行时演示 `addPermanentWidget`(永久「行:列 字数」标签)+ `showMessage`(动作临时提示)。 +其余三 API 的等价用法(教学参考): + +```cpp +// addWidget:在状态栏左侧添加普通部件(非永久,会挤占临时消息区) +statusBar()->addWidget(new QLabel("就绪")); +// insertWidget:在 index 位置插入部件 +statusBar()->insertWidget(0, new QLabel("插入")); +// removeWidget:移除某部件 +statusBar()->removeWidget(someLabel); +``` + +## 目录结构 +``` +mainwindow_showcase/ +├── mainwindow.ui # Designer 表单:主窗体结构 + 12 个 QAction +├── mainwindow_showcase.h/.cpp # MainWindow 类(动态行为) +├── main.cpp # QApplication + offscreen 自动退出 +├── mainwindow.qrc # 资源清单(5 个图标) +├── gen_icons.py # 图标生成脚本(标准库,无依赖) +├── icons/ # app/new/quit/about/dock.png(gen_icons.py 产物) +└── README.md # 本文档 +``` + +## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2) + +> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64(`E:/Qt/Qt5.14.2/Tools/mingw730_64`), +> 不可用 PATH 中可能存在的更高版本 g++。 + +```bash +# 1) 生成图标 +python docs/teaching/examples/mainwindow_showcase/gen_icons.py + +# 2) 配置(在仓库根;本示例随 docs/teaching/examples 一起被顶层 CMakeLists 收录) +cmake -S . -B build_mw -G "MinGW Makefiles" \ + -DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64 \ + -DCMAKE_CXX_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/g++.exe \ + -DCMAKE_C_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/gcc.exe + +# 3) 构建单目标 +cmake --build build_mw --target qt_mainwindow_showcase + +# 4) 运行(GUI) +PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \ +QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \ + ./build_mw/docs/teaching/examples/qt_mainwindow_showcase.exe + +# 5) 离屏自动退出验证(退出码应为 0) +QT_QPA_PLATFORM=offscreen \ +QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \ +PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \ + ./build_mw/docs/teaching/examples/qt_mainwindow_showcase.exe; echo "exit=$?" +``` + +## 教学要点小结 +- 一个 `QMainWindow` = 菜单栏(≤1)+ 工具栏(多个)+ 状态栏(1)+ 浮动窗口(多个)+ 中心部件(1)。 +- `QAction` 是「动作」的抽象,菜单与工具栏共享。 +- 工具栏/浮动窗口都是**可移动**的,停靠区域可由 `setAllowedAreas` 限定。 +- 资源文件(`.qrc`)把图标编译进可执行文件,运行时用 `:/...` 引用,无需关心磁盘文件。 diff --git a/docs/teaching/examples/mainwindow_showcase/gen_icons.py b/docs/teaching/examples/mainwindow_showcase/gen_icons.py new file mode 100644 index 0000000..70e57b3 --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/gen_icons.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 qt_mainwindow_showcase 所需的极简 PNG 图标。 + +仅用 Python 标准库(zlib + struct)手写最小 PNG 编码器,无第三方依赖。 +幂等:重复运行覆盖生成相同文件。 + + python gen_icons.py # *nix / 已配置 py 启动器的 Windows + py gen_icons.py # Windows 官方 py 启动器 +""" +import os +import struct +import zlib + +HERE = os.path.dirname(os.path.abspath(__file__)) +ICONS_DIR = os.path.join(HERE, "icons") + +# 5×7 点阵字模("1" = 前景白色像素) +GLYPHS = { + "M": ["10001", "11011", "10101", "10001", "10001", "10001", "10001"], + "N": ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + "Q": ["01110", "10001", "10001", "10001", "10101", "10011", "01111"], + "A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + "D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], +} + + +def write_png(path, width, height, rgb_rows): + """写一张 8 位、颜色类型 2(RGB)的 PNG。rgb_rows: 每行 width*3 个 0-255 整数。""" + def chunk(tag, data): + body = tag + data + return (struct.pack(">I", len(data)) + body + + struct.pack(">I", zlib.crc32(body) & 0xffffffff)) + + sig = b"\x89PNG\r\n\x1a\n" + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + raw = b"".join(b"\x00" + bytes(row) for row in rgb_rows) # 每行前加 filter byte(0) + idat = zlib.compress(raw, 9) + with open(path, "wb") as f: + f.write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")) + + +def make_icon(size, bg, letter): + """生成 size×size 的像素矩阵:纯色背景 + 居中白色字母。""" + glyph = GLYPHS[letter] + bw, bh = 5, 7 + pix = 1 if size <= 16 else 2 # 16px 用 1:1 字模,32px 放大 2 倍 + gw, gh = bw * pix, bh * pix + ox = (size - gw) // 2 + oy = (size - gh) // 2 + rows = [] + for y in range(size): + row = [] + for x in range(size): + gx, gy = (x - ox) // pix, (y - oy) // pix + if 0 <= gx < bw and 0 <= gy < bh and glyph[gy][gx] == "1": + row.extend((255, 255, 255)) + else: + row.extend(bg) + rows.append(row) + return rows + + +# (文件名, 尺寸, 背景色 RGB, 字母) +SPECS = [ + ("app.png", 32, (30, 80, 180), "M"), + ("new.png", 16, (40, 150, 70), "N"), + ("quit.png", 16, (200, 50, 50), "Q"), + ("about.png", 16, (220, 140, 30), "A"), + ("dock.png", 16, (130, 60, 180), "D"), +] + + +def main(): + os.makedirs(ICONS_DIR, exist_ok=True) + for name, size, bg, letter in SPECS: + write_png(os.path.join(ICONS_DIR, name), size, size, make_icon(size, bg, letter)) + print(f"wrote {name} ({size}x{size})") + print(f"done -> {ICONS_DIR}") + + +if __name__ == "__main__": + main() diff --git a/docs/teaching/examples/mainwindow_showcase/icons/about.png b/docs/teaching/examples/mainwindow_showcase/icons/about.png new file mode 100644 index 0000000000000000000000000000000000000000..0308a0ab23a5e4c3028956337d0e80fb6ad7b250 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4kiW$h6xih%orFLG(BA$Ln>}1@3^e&E|er8 zB=zqwL!i`ztBF^gd3jGHOt9!Q;XSO9`M{mwoydZ`X2#>?3=9kmp00i_>zopr0Bpe< AjQ{`u literal 0 HcmV?d00001 diff --git a/docs/teaching/examples/mainwindow_showcase/icons/app.png b/docs/teaching/examples/mainwindow_showcase/icons/app.png new file mode 100644 index 0000000000000000000000000000000000000000..4b9d5689521a7b756a45b9c9f8dc31a18bc2a039 GIT binary patch literal 119 zcmeAS@N?(olHy`uVBq!ia0y~yU{C;I4kiW$hCdQ-7c($0*m=4*hE&{2p1^&>Dfoy% z*1;95i^T*I57rwf#MNwwYRHjUb|PQ{gMIaj2!koX7eTT;VHS(UdZG*#h%qo2FVdQ&MBb@0Ix_Sl>h($ literal 0 HcmV?d00001 diff --git a/docs/teaching/examples/mainwindow_showcase/icons/dock.png b/docs/teaching/examples/mainwindow_showcase/icons/dock.png new file mode 100644 index 0000000000000000000000000000000000000000..de26f6fd0c2c800d6a63b2184ad793e30dcd156d GIT binary patch literal 92 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4kiW$h6xih%orFLls#P>Ln>}1XKcFZB$Ooa uN9vz4W0Gk?Dt|felGaNF9#SlV3||a;uf6I@G-6<2VDNPHb6Mw<&;$U)q8SVT literal 0 HcmV?d00001 diff --git a/docs/teaching/examples/mainwindow_showcase/icons/new.png b/docs/teaching/examples/mainwindow_showcase/icons/new.png new file mode 100644 index 0000000000000000000000000000000000000000..dcfd7f6140027978bdb0f0185ecc1a5eaa3c4679 GIT binary patch literal 103 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4kiW$h6xih%orFL^gLZ0Ln>}1UzqXCO(;p? zkJP{a|NE!*`ZLGKZj`#}1TO_8q2qj4f zN&P#_5GXa_YT{Mn0I3O{Nx27}=GQEAS=07#g2@ME2K&i=lONju*w4Vgz~JfX=d#Wz Gp$PyZW*@8o literal 0 HcmV?d00001 diff --git a/docs/teaching/examples/mainwindow_showcase/main.cpp b/docs/teaching/examples/mainwindow_showcase/main.cpp new file mode 100644 index 0000000..20f8a69 --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/main.cpp @@ -0,0 +1,22 @@ +// ============================================================================ +// mainwindow_showcase —— QMainWindow 教学综合示例(程序入口) +// 覆盖 wiki「5 QMainWindow」(pageId 58954529) 的 5.1–5.6 全部六大组件: +// 5.1 菜单栏 · 5.2 工具栏 · 5.3 状态栏 · 5.4 浮动窗口 · 5.5 中心部件 · 5.6 资源文件 +// 形态:极简文本编辑器(QTextEdit 为中心部件),纯组件演示,无文件 I/O。 +// ============================================================================ +#include +#include + +#include "mainwindow_showcase.h" + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + MainWindow w; + w.show(); + // 仅在离屏自动化验证时(QT_QPA_PLATFORM=offscreen)200ms 后自动退出; + // 正常运行(有显示环境)窗口保持打开,不自动退出。仿 p03/ch05 风格。 + if (qgetenv("QT_QPA_PLATFORM") == "offscreen") { + QTimer::singleShot(200, &app, &QApplication::quit); + } + return app.exec(); +} diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow.qrc b/docs/teaching/examples/mainwindow_showcase/mainwindow.qrc new file mode 100644 index 0000000..abc19cf --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/mainwindow.qrc @@ -0,0 +1,9 @@ + + + icons/app.png + icons/new.png + icons/quit.png + icons/about.png + icons/dock.png + + diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow.ui b/docs/teaching/examples/mainwindow_showcase/mainwindow.ui new file mode 100644 index 0000000..2f43d8d --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/mainwindow.ui @@ -0,0 +1,299 @@ + + + MainWindow + + + + 0 + 0 + 720 + 480 + + + + QMainWindow 教学综合示例 + + + + :/icons/app.png:/icons/app.png + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 欢迎使用 QMainWindow 教学综合示例。 +在这里编辑文字,状态栏会实时显示光标行列与字数。 +视图菜单可操控工具栏、状态栏与浮动窗口。 + + + + + + + + + + 0 + 0 + 720 + 22 + + + + + 文件(&F) + + + + + + + 视图(&V) + + + + 工具栏停靠区(&A) + + + + + + + + + + + + + + + + + + 帮助(&H) + + + + + + + + + + + + + 主工具栏 + + + TopToolBarArea + + + false + + + + + + + + + + 浮动窗口 + + + 1 + + + + + + + 我是浮动窗口(QDockWidget,5.4 铆接部件)。 +可拖出浮动,也可停靠到主窗口边缘。 +视图菜单的「停靠窗口浮动/停靠」可切换我的状态。 + + + true + + + + + + + + + + + :/icons/new.png:/icons/new.png + + + 新建 + + + Ctrl+N + + + 清空编辑区(5.5 中心部件) + + + + + + :/icons/quit.png:/icons/quit.png + + + 退出 + + + Ctrl+Q + + + 退出程序 + + + + + true + + + true + + + 显示主工具栏 + + + 显示或隐藏主工具栏(5.2 工具栏) + + + + + true + + + true + + + 显示状态栏 + + + 显示或隐藏状态栏(5.3 状态栏) + + + + + true + + + true + + + 工具栏可移动 + + + 切换工具栏是否可拖动(5.2 setMovable) + + + + + + :/icons/dock.png:/icons/dock.png + + + 停靠窗口浮动/停靠 + + + 切换浮动窗口的浮动状态(5.4 setFloating) + + + + + 显示浮动窗口 + + + 重新显示已关闭的浮动窗口(5.4 show) + + + + + 关闭浮动窗口 + + + 隐藏浮动窗口(5.4 hide) + + + + + 停靠左侧 + + + 限制工具栏只能停靠左侧(5.2 setAllowedAreas) + + + + + 停靠右侧 + + + 限制工具栏只能停靠右侧 + + + + + 停靠顶部 + + + 限制工具栏只能停靠顶部 + + + + + 停靠底部 + + + 限制工具栏只能停靠底部 + + + + + 允许全部区域 + + + 工具栏允许停靠在任意区域 + + + + + + :/icons/about.png:/icons/about.png + + + 关于 + + + 关于本程序 + + + + + + + + diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp new file mode 100644 index 0000000..79e823f --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp @@ -0,0 +1,110 @@ +// ============================================================================ +// mainwindow_showcase.cpp —— MainWindow 实现(动态行为) +// +// 静态结构见 mainwindow.ui(Designer)。本文件只做: +// - 资源文件应用(5.6):setWindowIcon 从 .qrc 加载 +// - 状态栏(5.3):addPermanentWidget + 实时行列/字数 + showMessage 临时提示 +// - 信号槽:on__ 自动连接(connectSlotsByName) +// - 视图菜单:运行时操控工具栏/状态栏/浮动窗口 +// ============================================================================ +#include "mainwindow_showcase.h" +#include "ui_mainwindow.h" // AUTOUIC 从 mainwindow.ui 生成 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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( + "QMainWindow 教学综合示例

" + "一个极简文本编辑器,演示 QMainWindow 的六大核心组件" + "(wiki「5 QMainWindow」pageId 58954529):
" + "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)); +} diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h new file mode 100644 index 0000000..03692af --- /dev/null +++ b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h @@ -0,0 +1,57 @@ +// ============================================================================ +// mainwindow_showcase.h —— MainWindow 类声明 +// +// 主窗体的静态结构(central QTextEdit / menuBar / mainToolBar / statusBar / +// dockWidget / 全部 QAction)在 mainwindow.ui 中用 Qt Designer 设计; +// 本类只负责动态行为:信号槽、运行时操控、状态栏更新、关于对话框。 +// +// 槽命名遵循 Qt 自动连接约定 on__,由 setupUi() 触发的 +// QMetaObject::connectSlotsByName 自动连接,无需手写 connect(KISS)。 +// ============================================================================ +#ifndef MAINWINDOW_SHOWCASE_H +#define MAINWINDOW_SHOWCASE_H + +#include +#include + +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