diff --git a/docs/superpowers/specs/2026-07-08-lineedit-eye-toggle-design.md b/docs/superpowers/specs/2026-07-08-lineedit-eye-toggle-design.md new file mode 100644 index 0000000..30ff0a4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-lineedit-eye-toggle-design.md @@ -0,0 +1,100 @@ +# QLineEdit 自绘小眼睛 / echo mode 切换 教学示例 —— 设计 + +- **日期**:2026-07-08 +- **对应课程**:Day4 上午「消息机制和事件 + 绘图事件」(OUTLINE_4DAY.md §2 Day4 上午) +- **目标产物**:`docs/teaching/examples/lineedit_eye_toggle/`,交付为两种形态—— + ① 仓库统一构建里的 `qt_lineedit_eye_toggle` target; + ② 可独立 `cmake -S . -B build` 构建的单独 CMake 工程。 + +## 1. 背景与目标 + +需要一个 Day4 上午课堂演示示例,**同时覆盖两个主题**:组件绘制(`paintEvent`)、 +事件捕捉(`mousePressEvent` / `mouseMoveEvent`)。载体是 QLineEdit 扩展——在右侧加一个 +小眼睛图标,点击动态切换 `echoMode`(`Password` ↔ `Normal`),并显式处理与 QLineEdit +内置 clear button 的位置冲突。 + +> 选 QLineEdit 而非自造控件:密码框是「自绘内嵌图标 + 点击切换」最真实的工业场景, +> 且复用 QLineEdit 的文本/编辑能力,让示例聚焦在「绘制 + 事件」本身。 + +## 2. 关键设计决策 + +### 2.1 组织形态:梯度对照版 +窗口分三区,先简后深,让学生看清「为什么」: + +| 区 | 内容 | 教学作用 | +|---|---|---| +| ① | 4 种 echo mode 静态对照(Normal/NoEcho/Password/PasswordEchoOnEdit) | 先给 echo mode 全貌,眼睛只是切换载体 | +| ② | `addAction(QIcon, TrailingPosition)` 一行版 | Qt 内置做法,1 行实现——「为什么还要自绘」的引子 | +| ③ | `PasswordLineEdit : public QLineEdit` 自绘版 + 「启用 clear button」复选框 | **主题落地**:自绘 + 事件捕获 + clear button 避让 | + +### 2.2 实现方式锁定:子类化 + 自绘 + 自捕获 +`addAction` 那条路由 Qt 全权处理位置/点击/避让,**碰不到**绘制与事件这两个主题。 +故核心交付物必须是子类化 QLineEdit、覆写 `paintEvent`/`mouse*Event`。addAction 仅作 +②区对照存在。 + +### 2.3 clear button 避让:读 clearButton 子 widget 几何 +QLineEdit 内置 clear button 的宽度随主题/DPI/字号变化,**不能硬编码偏移**(坑卡)。 +clear button 是 QLineEdit 的一个 `objectName=="clearButton"` 直接子 widget(Qt5 私有 +`QLineEditIconButton`),用 `findChild("clearButton")` 读其 `geometry()`,眼睛紧贴 +其左侧;未启用或无文本时贴右边。 +> 注:初版误用 `QStyle::subControlRect(CC_LineEdit, …, SC_LineEditClearButton)`,编译期即报错 +> ——`QStyle` 没有这两个成员,QLineEdit 不经 complex-control 绘制,无对应 subcontrol。读子 widget 是唯一可靠途径。 + +### 2.4 眼睛图标:QPainter 纯代码绘制 +- 不引入 `.qrc` 资源(YAGNI),完全契合「组件绘制」主题。 +- 两态:`closed=true`(密码态)= 眼眶椭圆 + 斜杠;`closed=false`(明文态)= 眼眶椭圆 + 实心瞳孔。 +- 绘制逻辑抽成自由函数 `paintEye(QPainter*, QRect, closed, hover)`,③区自绘与②区 + `QIcon` pixmap 共用(DRY,并让对照更明显)。 +- hover 态描边加深为蓝色(`#0078D7`),需 `setMouseTracking(true)` 才能在无按键时收到 move。 + +### 2.5 双形态 CMake(源文件零重复) +- **唯一源文件**:`qt_lineedit_eye_toggle.cpp`。 +- **两个 CMake 上下文**引用它: + - 独立工程:`lineedit_eye_toggle/CMakeLists.txt`(手写规范版,C++17,非 Qt Creator 模板)。 + - 仓库集成:`examples/CMakeLists.txt` 的 `add_teaching_example(qt_lineedit_eye_toggle lineedit_eye_toggle)`。 +- **零污染**:顶层 `CMakeLists.txt:58` `add_subdirectory(docs/teaching/examples)` 不递归, + 子目录 `CMakeLists.txt` 永不被统一构建触发;`examples/CMakeLists.txt` 无 GLOB。 + 不计入 README/CLAUDE.md 里「173 目标」的生成器计数。 + +## 3. `PasswordLineEdit` 子类要点 + +``` +构造: setEchoMode(Password); setMouseTracking(true); setTextMargins(0,0,iconSize+8,0) +paintEvent: base::paintEvent 先画原生; 再 recomputeEyeRect(); QPainter 画眼睛 +mousePressEvent: recomputeEyeRect(); 命中眼睛 → 切 m_passwordMode + setEchoMode + accept; + 否则 base::mousePressEvent(光标定位/双击选词不受影响) +mouseMoveEvent: recomputeEyeRect(); hit-test 更新 m_eyeHover + setCursor; base 保留拖选 +leaveEvent: 清 m_eyeHover 局部刷新 +recomputeEyeRect: findChild("clearButton") 读其几何 → 眼睛贴其左 / 贴右 +信号: passwordModeChanged(bool) 供窗口状态栏显示 +``` + +**关键不变量**:命中眼睛时 `e->accept()` 并 `return`,**不**调基类——否则 QLineEdit 会 +把点击当光标定位;非命中时务必调基类,保留原生编辑行为。这是「事件捕获」教学点的核心。 + +## 4. 文件清单 + +``` +docs/teaching/examples/lineedit_eye_toggle/ +├── qt_lineedit_eye_toggle.cpp # 唯一源文件(paintEye + PasswordLineEdit + ShowcaseWidget + main) +├── CMakeLists.txt # 独立顶层工程(手写规范版,可 cmake -S . -B build) +└── README.md # 教学点 / 目录结构 / 构建(集成+独立两条)/ 坑卡 +``` +另:`docs/teaching/examples/CMakeLists.txt` 注册目标 + 加入 `all_teaching_examples` 依赖列表。 + +## 5. 验证标准(可证伪) + +1. **集成构建**:`cmake --build build_mw --target qt_lineedit_eye_toggle` 绿。 +2. **独立构建**:`cmake -S lineedit_eye_toggle -B lineedit_eye_toggle/build ... && cmake --build` 绿。 +3. **离屏**:两路径产物 `QT_QPA_PLATFORM=offscreen` 跑 200ms 后退出码 0。 +4. **真实交互**(本机 Qt 实跑,截图为证):③区眼睛可见;点击切 `Password↔Normal`; + 勾选 clear button 后眼睛正确让到其左侧不重叠;hover 高亮生效。 + +## 6. 显式假设 + +- 眼睛用 QPainter 代码绘制,不引入资源文件(契合主题 + YAGNI)。 +- 独立 `CMakeLists.txt` 手写规范版(C++17 / `cmake_minimum_required(3.16)` / 无 ANDROID 噪音), + 不沿用子目录里 Qt Creator 导出的低质量模板。 +- ①区 4 个对照框 `setReadOnly(true)` 防误改;`PasswordEchoOnEdit` 的「编辑时明文」效果在 + README 注明需取消只读才能体验。 +- **不提交 git**(项目红线:未经请求不操作 git)。 diff --git a/docs/teaching/examples/CMakeLists.txt b/docs/teaching/examples/CMakeLists.txt index 201f416..7349485 100644 --- a/docs/teaching/examples/CMakeLists.txt +++ b/docs/teaching/examples/CMakeLists.txt @@ -99,15 +99,47 @@ add_executable(qt_calculator_mainwindow_qss calculator_mainwindow_qss/mainwindow.cpp calculator_mainwindow_qss/calculatordesigner.cpp calculator_mainwindow_qss/expressionevaluator.cpp - calculator_mainwindow_qss/widget.ui) + calculator_mainwindow_qss/widget.ui + calculator_mainwindow_qss/themes.qrc) 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) + AUTOMOC ON AUTOUIC ON AUTORCC 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) +# QDialog 教学综合示例(wiki「6 对话框 QDialog」pageId 183664881 的 6.2–6.5): +# QWidget 布满 pushbutton,每个按钮演示一种 QDialog 行为。纯代码(无 .ui), +# 单文件,走 add_teaching_example。 +add_teaching_example(qt_dialog_showcase qdialog_showcase) + +# QLineEdit 自绘小眼睛 / echo mode 切换(Day4 上午:组件绘制 + 事件捕捉 + clear button 避让): +# 子类化 QLineEdit,paintEvent 自绘眼睛、mouseEvent 命中切换 echoMode,与内置 clear button +# 动态避让(读 objectName=="clearButton" 子 widget 几何)。子目录另有独立 CMakeLists.txt +# 供单独打开(不走这里:顶层 add_subdirectory 不递归)。 +add_teaching_example(qt_lineedit_eye_toggle lineedit_eye_toggle) + +# Day4 综合演示示例(事件机制 + QPainter 绘图 + 多线程):Mandelbrot 分形渲染器。 +# 三段式对比(主线程卡死 / 后台单线程 / 后台多线程),滚轮缩放 + 拖拽平移 + 分块并行。 +# 多源文件(无 .ui/.qrc),单独写 target;子目录另有独立 CMakeLists.txt 供单独打开 +# (不走这里:顶层 add_subdirectory 不递归),与 lineedit_eye_toggle 同模式。 +add_executable(qt_mandelbrot_renderer + mandelbrot_renderer/main.cpp + mandelbrot_renderer/mandelbrot_widget.cpp + mandelbrot_renderer/mandelbrot_strip_task.cpp) +target_include_directories(qt_mandelbrot_renderer PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/mandelbrot_renderer) +set_target_properties(qt_mandelbrot_renderer PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF AUTOMOC ON) +target_link_libraries(qt_mandelbrot_renderer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_mandelbrot_renderer PRIVATE -Wall -Wextra) + +# Qt5 文件操作教学综合示例(QFile/QTextStream/QDataStream/QFileInfo/QDir/QTextCodec/QJson): +# 一个 QWidget 布满按钮,5 个主题(基础IO/编码/文本vs二进制/异常处理/QJson),每个按钮 +# 产生一个文件落到 demo_output/(可执行文件同目录),方便课堂手动打开查看。纯代码单文件。 +add_teaching_example(qt_file_io_demo file_io_demo) + add_custom_target(all_teaching_examples DEPENDS qt_qss_styling qt_model_view_basics @@ -125,4 +157,8 @@ add_custom_target(all_teaching_examples DEPENDS qt_student_manager qt_mainwindow_showcase qt_calculator_mainwindow - qt_calculator_mainwindow_qss) + qt_calculator_mainwindow_qss + qt_dialog_showcase + qt_lineedit_eye_toggle + qt_mandelbrot_renderer + qt_file_io_demo) diff --git a/docs/teaching/examples/file_io_demo/CMakeLists.txt b/docs/teaching/examples/file_io_demo/CMakeLists.txt new file mode 100644 index 0000000..e2017c4 --- /dev/null +++ b/docs/teaching/examples/file_io_demo/CMakeLists.txt @@ -0,0 +1,30 @@ +# ============================================================================= +# Qt5 文件操作教学示例 —— 独立 CMake 工程 +# +# 本文件供「单独打开 / 独立构建」使用: +# cmake -S . -B build -DCMAKE_PREFIX_PATH= -DCMAKE_CXX_COMPILER= +# cmake --build build +# +# 仓库统一构建【不走】这里:顶层 CMakeLists.txt 只 +# add_subdirectory(docs/teaching/examples) 且不递归,统一构建走 +# examples/CMakeLists.txt 的 add_teaching_example,源文件同为 +# qt_file_io_demo.cpp(零重复)。本目录不计入仓库「173 目标」计数。 +# +# 说明:本工程为手写规范版(C++17 / cmake 3.16 / 无 ANDROID 噪音), +# 区别于其它子目录里 Qt Creator 自动导出的低质量模板。 +# ============================================================================= +cmake_minimum_required(VERSION 3.16) + +project(qt_file_io_demo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_AUTOMOC ON) + +find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED) +message(STATUS "Found Qt5 ${Qt5Core_VERSION_STRING} at ${Qt5Core_DIR}") + +add_executable(qt_file_io_demo qt_file_io_demo.cpp) +target_link_libraries(qt_file_io_demo PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_file_io_demo PRIVATE -Wall -Wextra) diff --git a/docs/teaching/examples/file_io_demo/README.md b/docs/teaching/examples/file_io_demo/README.md new file mode 100644 index 0000000..0d20eb7 --- /dev/null +++ b/docs/teaching/examples/file_io_demo/README.md @@ -0,0 +1,108 @@ +# qt_file_io_demo —— Qt5 文件操作教学示例 + +一个 `QWidget` 布满按钮,分 5 个主题演示 Qt5 文件操作。每个按钮产生一个文件,落到 +**可执行文件同目录的 `demo_output/`** 子目录下,界面顶部 `statusLabel` 始终显示绝对路径, +方便课堂逐个手动打开查看文件内容。 + +> 这是对仓库 `json_io`(控制台、仅内存序列化)的 GUI 补充:把 **QJson + 真实文件 I/O** +> 与其它文件操作主题一起,放进一个可点击演示的界面。 + +## 构建 + +仓库统一构建(走 `examples/CMakeLists.txt` 的 `add_teaching_example`): + +```bash +cmake -S . -B build -G Ninja -DBUILD_QT_PART=ON \ + -DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64 -DCMAKE_BUILD_TYPE=Debug +cmake --build build --target qt_file_io_demo +tools/run_qt.sh qt_file_io_demo +``` + +单独打开(用本目录的独立 `CMakeLists.txt`): + +```bash +cmake -S . -B build -DCMAKE_PREFIX_PATH= -DCMAKE_CXX_COMPILER= +cmake --build build +``` + +离屏自动化验证(仿 p03 风格,200ms 自动退出、退出码 0): + +```bash +QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_file_io_demo +``` + +## 界面布局 + +``` +┌─ statusLabel(显示绝对路径 + 每个演示的结果摘要)─────────────┐ +├─ ① 基础文件操作 QFile 写/读/追加、QFileInfo、QDir │ +├─ ② 字符集编码 UTF-8 / Latin-1 / GBK / UTF-16LE / 乱码对比 │ +├─ ③ 文本vs二进制 QTextStream / QDataStream / QIODevice::Text │ +├─ ④ 异常处理 Qt 返回值式 | C++ try/catch(对照) │ +├─ ⑤ QJson 文件 写 / 读 / 改写 │ +├─ [在文件管理器中打开 demo_output] [清空 demo_output] │ +└──────────────────────────────────────────────────────────────┘ +``` + +## 各主题要点与产出文件 + +### ① 基础文件操作 +| 按钮 | 产出文件 | 要点 | +|---|---|---| +| QFile 写文本 | `01_qfile_write.txt` | `QTextStream` 写;**坑点:Qt5 默认 codec 是系统区域编码(中文 Windows 常为 GBK),写中文必须显式 `setCodec("UTF-8")`** | +| QFile 读文本 | (读上一个) | 读端同样要 `setCodec("UTF-8")`,否则跨平台乱码 | +| 追加 vs 覆盖 | `01_append.txt` | `WriteOnly` 每次清空;`Append` 在末尾追加 | +| QFileInfo 信息 | `01_fileinfo.txt` | `exists / size / absoluteFilePath / absolutePath` | +| QDir 列目录 | — | `entryList(QDir::Files)` 列出 `demo_output` 内文件 | + +### ② 字符集编码 +| 按钮 | 产出文件 | 要点 | +|---|---|---| +| 写 UTF-8 | `02_utf8.txt` | `QString::toUtf8()`:中文每字 3 字节、ASCII 每字 1 字节 | +| 写 UTF-8 + BOM | `02_utf8_bom.txt` | 头部 `EF BB BF`(十六进制可见) | +| 写 Latin-1 | `02_latin1.txt` | `toLatin1()` 只能表 `U+0000~U+00FF`,中文超出变 `?` | +| 写 GBK | `02_gbk.txt` | `QTextCodec::codecForName("GBK")`;**未装 CJK codec 插件时返回 `nullptr`,界面列出当前可用编码并给出提示**。本仓库 MinGW Qt 未含 `plugins/codecs/`,故此按钮走降级提示(这本身就是教学点);如需真正写 GBK,用 Qt Maintenance Tool 勾选安装 codecs 插件,或把 `plugins/codecs/` 拷到 Qt 的 plugins 目录 | +| 写 UTF-16LE | `02_utf16le.txt` | 手动写 `FF FE` BOM + 每字符固定 2 字节(低字节在前) | +| 乱码:错码读回 | (读 `02_utf8.txt`) | UTF-8 字节用 Latin-1 强解 → mojibake,说明「读写编码须一致」 | + +> 用编辑器(VS Code / Notepad++)切换编码打开这些文件,可直观看到「同一段中文在不同编码下的字节差异」与乱码成因。 + +### ③ 文本方式 vs 二进制方式 +| 按钮 | 产出文件 | 要点 | +|---|---|---| +| 文本写结构 | `03_struct_text.txt` | `QTextStream` 写 `name=张三 / age=20 / score=95.5`:可读但格式松散 | +| 二进制写结构 | `03_struct_bin.dat` | `QDataStream` 写 `qint32 + double + QString`:紧凑、带类型、可精确还原(程序内立即读回验证) | +| 对比大小 | — | `QFileInfo` 报告两文件字节数;用十六进制工具看 `.dat` 内部布局 | +| QIODevice::Text 换行 | `03_nl_text.txt` / `03_nl_bin.txt` | Windows 下 `Text` 标志把 `\n` 转 `\r\n`,不加则原样 `\n`;Linux 下两者相同 | + +> `QDataStream` **务必 `setVersion(Qt_5_14)`**,否则 Qt 升级后二进制格式可能不兼容。 + +### ④ 异常与错误处理(两范式对照) +| 按钮 | 要点 | +|---|---| +| Qt: 打开不存在文件 | **Qt 范式**:`open()` 返回 `false` + `error()` / `errorString()`,不抛异常 | +| Qt: 只读模式写 | 只读 `QFile` 调 `write()` 返回 `-1` + `errorString()` | +| C++: ifstream 异常 | **C++ 范式**:`in.exceptions(failbit\|badbit)` 后打开失败抛 `std::ios_base::failure`,`try/catch` 捕获 | +| C++: throw + catch | 自定义异常 + `catch` 顺序(派生类在前、基类在后) | +| JSON: 损坏解析 | `04_broken.json`:`QJsonDocument::fromJson` 不抛异常,用 `QJsonParseError` 拿出错偏移与描述 | + +> 核心对照:**Qt 几乎不用 C++ 异常**,错误经返回值/API 暴露;标准库流默认也不抛,需显式开启。 +> 教学 LLVM 式「异常 vs 返回值」两种错误处理哲学的绝佳切入点。 + +### ⑤ QJson 文件读写 +| 按钮 | 产出文件 | 要点 | +|---|---|---| +| 写 JSON 文件 | `05_student.json` | `QJsonObject` + `QJsonArray` → `QJsonDocument::toJson(Indented)` → 写文件 | +| 读 JSON 文件 | (读上一个) | `fromJson` → `object()` → 按键取值,`toArray()` 遍历数组 | +| 修改后回写 | (覆盖上一个) | 改 `score`、增 `grade`、删 `enrolled`,覆盖回写 | + +## demo_output 在哪? + +`QCoreApplication::applicationDirPath() + "/demo_output"` —— 即**可执行文件旁边**。 +`tools/run_qt.sh` 运行时通常在 `build/` 下,所以路径形如 `build/qt_file_io_demo` 同级。 +界面底部「在文件管理器中打开 demo_output」按钮可一键定位。 + +## 相关课程点 + +对应「C++ 方向 Day3」文件与数据格式章节;与 `json_io`(控制台 QJson 内存演示)、 +`p03/ch11`(wiki 的 QFile/QTextStream/QDataStream)互为补充。 diff --git a/docs/teaching/examples/file_io_demo/qt_file_io_demo.cpp b/docs/teaching/examples/file_io_demo/qt_file_io_demo.cpp new file mode 100644 index 0000000..0c30a9a --- /dev/null +++ b/docs/teaching/examples/file_io_demo/qt_file_io_demo.cpp @@ -0,0 +1,500 @@ +// ============================================================================ +// qt_file_io_demo —— Qt5 文件操作教学综合示例(非 wiki 生成,手工维护) +// +// 覆盖 5 个主题,每组按钮对应一个场景;所有演示文件落到可执行文件同目录的 +// demo_output/ 子目录,statusLabel 始终显示绝对路径,方便课堂手动打开查看: +// ① 基础文件操作:QFile 写/读/追加、QFileInfo、QDir 列目录 +// ② 字符集编码:UTF-8 / Latin-1 / GBK / UTF-16LE + BOM + 「错码读回」乱码对比 +// ③ 文本方式 vs 二进制方式:QTextStream 文本、QDataStream 二进制、QIODevice::Text +// ④ 异常与错误处理:Qt 返回值式(open/errorString)+ C++ try/catch(ifstream 异常)两范式 +// ⑤ QJson 文件读写:构造→落盘 / 读盘→解析 / 改写 / 损坏解析 +// +// 形态:一个 QWidget 布满按钮(仿 qt_dialog_showcase),纯代码、单文件。 +// 离屏验证:QT_QPA_PLATFORM=offscreen 时 200ms 自动退出(仿 p03 风格,不点按钮 +// 不触发任何文件写入)。 +// +// 编码演示设计要点:优先用 QString::toUtf8()/toLatin1()(Qt 内置、不依赖 CJK codec +// 插件),GBK 通过 QTextCodec 尝试,未安装 codec 则给出明确提示并优雅降级 —— 这 +// 本身就是 Linux 上精简 Qt 安装的真实坑点。 +// ============================================================================ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include // std::ifstream(C++ 异常演示) +#include // std::ios_base::failure +#include // std::runtime_error +#include + +class FileIoDemo : public QWidget { +public: + explicit FileIoDemo(QWidget *parent = nullptr) : QWidget(parent) { + // demo_output 放在可执行文件同目录,路径稳定可预测;statusLabel 始终显示绝对路径。 + m_dir = QCoreApplication::applicationDirPath() + QStringLiteral("/demo_output"); + QDir().mkpath(m_dir); + + auto *root = new QVBoxLayout(this); + + m_status = new QLabel( + QStringLiteral("演示文件目录:%1\n点击下方按钮,每个按钮产生一个文件,可手动打开查看。") + .arg(m_dir)); + m_status->setWordWrap(true); + m_status->setMinimumHeight(54); + m_status->setFrameShape(QFrame::Box); + root->addWidget(m_status); + + buildBasicGroup(root); // ① + buildEncodingGroup(root); // ② + buildTextBinGroup(root); // ③ + buildErrorGroup(root); // ④ + buildJsonGroup(root); // ⑤ + + // 底部:打开 / 清空 demo_output + auto *bottom = new QHBoxLayout; + auto *btnOpen = new QPushButton(QStringLiteral("在文件管理器中打开 demo_output"), this); + auto *btnClear = new QPushButton(QStringLiteral("清空 demo_output"), this); + bottom->addWidget(btnOpen); + bottom->addWidget(btnClear); + root->addLayout(bottom); + connect(btnOpen, &QPushButton::clicked, this, &FileIoDemo::openDemoDir); + connect(btnClear, &QPushButton::clicked, this, &FileIoDemo::clearDemoDir); + + setWindowTitle(QStringLiteral("Qt5 文件操作教学示例(5 主题 × 多场景)")); + resize(700, 760); + } + +private: + QLabel *m_status = nullptr; + QString m_dir; // demo_output 绝对路径 + + // ---- 通用 helpers -------------------------------------------------------- + template + void addBtn(QGridLayout *g, int row, int col, const QString &text, F &&slot) { + auto *btn = new QPushButton(text, this); + g->addWidget(btn, row, col); + connect(btn, &QPushButton::clicked, this, std::forward(slot)); + } + + void setStatus(const QString &s) { m_status->setText(s); } + + QString path(const QString &fname) const { return m_dir + QLatin1Char('/') + fname; } + + // 写原始字节;extra 可叠加 QIODevice::Text / QIODevice::Append + bool writeBytes(const QString &fname, const QByteArray &bytes, + QIODevice::OpenMode extra = QIODevice::NotOpen) { + QFile f(path(fname)); + if (!f.open(QIODevice::WriteOnly | extra)) { + setStatus(QStringLiteral("写入失败:%1\n错误:%2").arg(path(fname), f.errorString())); + return false; + } + f.write(bytes); + f.close(); + return true; + } + + // 读原始字节 + bool readBytes(const QString &fname, QByteArray *out, + QIODevice::OpenMode extra = QIODevice::NotOpen) { + QFile f(path(fname)); + if (!f.open(QIODevice::ReadOnly | extra)) { + setStatus(QStringLiteral("读取失败:%1\n错误:%2").arg(path(fname), f.errorString())); + return false; + } + *out = f.readAll(); + f.close(); + return true; + } + + // 汇报某文件信息(名称/大小/绝对路径),可选附注,用于 statusLabel + QString report(const QString &fname, const QString ¬e = QString()) { + QFileInfo fi(path(fname)); + const QString base = QStringLiteral("✅ %1(%2 字节)\n绝对路径:%3") + .arg(fname).arg(fi.size()).arg(fi.absoluteFilePath()); + return note.isEmpty() ? base : base + QStringLiteral("\n") + note; + } + + // ---- ① 基础文件操作 ------------------------------------------------------ + void buildBasicGroup(QVBoxLayout *root) { + auto *g = new QGroupBox(QStringLiteral("① 基础文件操作(QFile / QTextStream / QFileInfo / QDir)")); + auto *l = new QGridLayout(g); + addBtn(l, 0, 0, QStringLiteral("QFile 写文本"), [this] { basicWriteText(); }); + addBtn(l, 0, 1, QStringLiteral("QFile 读文本"), [this] { basicReadText(); }); + addBtn(l, 0, 2, QStringLiteral("追加 vs 覆盖"), [this] { basicAppend(); }); + addBtn(l, 1, 0, QStringLiteral("QFileInfo 信息"), [this] { basicFileInfo(); }); + addBtn(l, 1, 1, QStringLiteral("QDir 列目录"), [this] { basicDirList(); }); + root->addWidget(g); + } + void basicWriteText() { + // 坑点:Qt5 的 QTextStream 默认 codec 是「系统区域编码」(中文 Windows 常为 GBK), + // 不是 UTF-8。写中文必须显式 setCodec("UTF-8"),否则文件编码随系统而变。 + QFile f(path("01_qfile_write.txt")); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + setStatus(QStringLiteral("打开失败:%1").arg(f.errorString())); return; + } + QTextStream out(&f); + out.setCodec(QTextCodec::codecForName("UTF-8")); + out << QStringLiteral("第一行:Qt5 文件操作\n"); + out << QStringLiteral("第二行:当前时间 ") << QDateTime::currentDateTime().toString(Qt::ISODate) << '\n'; + out << QStringLiteral("第三行:中文测试 —— 你好,世界\n"); + f.close(); + setStatus(report("01_qfile_write.txt", QStringLiteral("用 QTextStream 写,显式 setCodec UTF-8。"))); + } + void basicReadText() { + QFile f(path("01_qfile_write.txt")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + setStatus(QStringLiteral("读取失败(请先点「QFile 写文本」):%1").arg(f.errorString())); return; + } + QTextStream in(&f); + in.setCodec(QTextCodec::codecForName("UTF-8")); + const QString all = in.readAll(); + f.close(); + setStatus(QStringLiteral("读到内容(%1 个字符):\n%2").arg(all.length()).arg(all)); + } + void basicAppend() { + // WriteOnly 每次清空覆盖;Append 在末尾追加、保留原内容 + QFile f(path("01_append.txt")); + if (!f.open(QIODevice::WriteOnly | QIODevice::Append)) { setStatus(f.errorString()); return; } + QTextStream out(&f); + out.setCodec(QTextCodec::codecForName("UTF-8")); + out << QStringLiteral("append @ ") + << QDateTime::currentDateTime().toString(Qt::ISODateWithMs) << '\n'; + f.close(); + + QFile r(path("01_append.txt")); + r.open(QIODevice::ReadOnly | QIODevice::Text); + setStatus(report("01_append.txt", + QStringLiteral("当前全部内容:\n") + QString::fromUtf8(r.readAll()))); + } + void basicFileInfo() { + writeBytes("01_fileinfo.txt", "hello\n"); + const QFileInfo fi(path("01_fileinfo.txt")); + setStatus(QStringLiteral("文件名:%1\n存在:%2 大小:%3 字节\n绝对路径:%4\n所在目录:%5") + .arg(fi.fileName()) + .arg(fi.exists() ? QStringLiteral("是") : QStringLiteral("否")) + .arg(fi.size()).arg(fi.absoluteFilePath()).arg(fi.absolutePath())); + } + void basicDirList() { + const QDir d(m_dir); + const QStringList files = d.entryList(QDir::Files, QDir::Name); + setStatus(QStringLiteral("demo_output 内文件数:%1\n%2") + .arg(files.size()).arg(files.join('\n'))); + } + + // ---- ② 字符集编码 -------------------------------------------------------- + void buildEncodingGroup(QVBoxLayout *root) { + auto *g = new QGroupBox(QStringLiteral("② 字符集编码(UTF-8 / Latin-1 / GBK / UTF-16LE + BOM + 乱码)")); + auto *l = new QGridLayout(g); + addBtn(l, 0, 0, QStringLiteral("写 UTF-8"), [this] { encUtf8(); }); + addBtn(l, 0, 1, QStringLiteral("写 UTF-8 + BOM"), [this] { encUtf8Bom(); }); + addBtn(l, 0, 2, QStringLiteral("写 Latin-1"), [this] { encLatin1(); }); + addBtn(l, 1, 0, QStringLiteral("写 GBK"), [this] { encGbk(); }); + addBtn(l, 1, 1, QStringLiteral("写 UTF-16LE"), [this] { encUtf16Le(); }); + addBtn(l, 1, 2, QStringLiteral("乱码:错码读回"), [this] { encMismatch(); }); + root->addWidget(g); + } + void encUtf8() { + const QString s = QStringLiteral("你好,Qt\tASCII: Hello\n"); // 含中文 + ASCII + 制表符 + writeBytes("02_utf8.txt", s.toUtf8()); + setStatus(report("02_utf8.txt", QStringLiteral("toUtf8():中文每字 3 字节,ASCII 每字 1 字节。"))); + } + void encUtf8Bom() { + QByteArray b; + b.append((char)0xEF).append((char)0xBB).append((char)0xBF); // UTF-8 BOM = EF BB BF + b.append(QStringLiteral("带 BOM 的 UTF-8\n").toUtf8()); + writeBytes("02_utf8_bom.txt", b); + setStatus(report("02_utf8_bom.txt", QStringLiteral("头部 3 字节 EF BB BF 即 UTF-8 BOM(十六进制可见)。"))); + } + void encLatin1() { + // Latin-1(ISO-8859-1)只能表示 U+0000~U+00FF;中文超出范围,toLatin1() 会替换成 '?' + const QString s = QStringLiteral("Hello 世界"); + writeBytes("02_latin1.txt", s.toLatin1()); + setStatus(report("02_latin1.txt", + QStringLiteral("toLatin1() 把超出 0xFF 的「世界」替换成 '?',文件里只剩 ASCII 部分。"))); + } + void encGbk() { + // GBK 是中文 Windows 默认编码,需 Qt 的 CJK codec 插件(plugins/codecs)。 + // 精简 Qt 安装常不含该插件,codecForName 返回 nullptr —— 这里优雅降级, + // 并列出当前实际可用的编码,让学生看清「为什么 GBK 用不了」。 + QTextCodec *gbk = QTextCodec::codecForName("GBK"); + if (!gbk) { + const QList codecs = QTextCodec::availableCodecs(); + QStringList names; + for (const QByteArray &c : codecs) names << QString::fromLatin1(c); + setStatus(QStringLiteral("⚠️ 当前 Qt 构建未提供 GBK codec 插件\n" + "(QTextCodec::codecForName(\"GBK\") 返回 nullptr)。\n" + "这是真实坑点:精简 Qt 安装常不含 CJK codec 插件。\n" + "当前可用编码(%1 个):%2\n" + "可改用 toUtf8()/toLatin1()(内置)演示编码原理,见其余按钮。") + .arg(names.size()).arg(names.join(QStringLiteral(", ")))); + return; + } + const QString s = QStringLiteral("你好,GBK 编码\n"); + writeBytes("02_gbk.txt", gbk->fromUnicode(s)); + setStatus(report("02_gbk.txt", + QStringLiteral("GBK:中文每字 2 字节。用编辑器以 GBK 打开才正常,以 UTF-8 打开会乱码。"))); + } + void encUtf16Le() { + const QString s = QStringLiteral("UTF-16LE 你好\n"); + QByteArray b; + b.append((char)0xFF).append((char)0xFE); // UTF-16LE BOM = FF FE + const QChar *p = s.unicode(); + for (int i = 0; i < s.length(); ++i) { + const ushort u = p[i].unicode(); + b.append((char)(u & 0xFF)); // 低字节在前 = Little Endian + b.append((char)((u >> 8) & 0xFF)); + } + writeBytes("02_utf16le.txt", b); + setStatus(report("02_utf16le.txt", QStringLiteral("FF FE 为 UTF-16LE BOM;之后每字符固定 2 字节。"))); + } + void encMismatch() { + if (!QFile::exists(path("02_utf8.txt"))) encUtf8(); + QByteArray raw; + if (!readBytes("02_utf8.txt", &raw)) return; + // 故意用 Latin-1 解码 UTF-8 字节:多字节中文被拆成多个单字节字符 = 乱码(mojibake) + const QString mojibake = QString::fromLatin1(raw); + setStatus(QStringLiteral("02_utf8.txt 用 Latin-1 强行解码(编码不匹配)→ 乱码:\n%1\n" + "结论:读文件必须用与写入一致的编码。").arg(mojibake)); + } + + // ---- ③ 文本方式 vs 二进制方式 ------------------------------------------- + void buildTextBinGroup(QVBoxLayout *root) { + auto *g = new QGroupBox( + QStringLiteral("③ 文本方式 vs 二进制方式(QTextStream / QIODevice::Text / QDataStream)")); + auto *l = new QGridLayout(g); + addBtn(l, 0, 0, QStringLiteral("文本写结构"), [this] { tbStructText(); }); + addBtn(l, 0, 1, QStringLiteral("二进制写结构"), [this] { tbStructBin(); }); + addBtn(l, 0, 2, QStringLiteral("对比大小"), [this] { tbCompare(); }); + addBtn(l, 1, 0, QStringLiteral("QIODevice::Text 换行"), [this] { tbNewline(); }); + root->addWidget(g); + } + void tbStructText() { + // 文本方式:人可读,但格式松散、解析需约定分隔符、浮点精度受 << 默认值影响 + QFile f(path("03_struct_text.txt")); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { setStatus(f.errorString()); return; } + QTextStream out(&f); + out.setCodec(QTextCodec::codecForName("UTF-8")); + out << "name=张三\nage=20\nscore=95.5\n"; + f.close(); + setStatus(report("03_struct_text.txt", QStringLiteral("文本:可读,但程序解析需自己处理格式。"))); + } + void tbStructBin() { + // 二进制方式(QDataStream):紧凑、带类型长度、可精确还原;是 Qt 推荐的跨平台二进制格式 + QFile f(path("03_struct_bin.dat")); + if (!f.open(QIODevice::WriteOnly)) { setStatus(f.errorString()); return; } + QDataStream out(&f); + out.setVersion(QDataStream::Qt_5_14); // 固定版本,避免 Qt 升级后格式不兼容 + out.setByteOrder(QDataStream::LittleEndian); + out << qint32(20) << double(95.5) << QStringLiteral("张三"); + f.close(); + + // 立即读回验证二进制可精确还原 + QFile r(path("03_struct_bin.dat")); + r.open(QIODevice::ReadOnly); + QDataStream in(&r); + in.setVersion(QDataStream::Qt_5_14); + in.setByteOrder(QDataStream::LittleEndian); + qint32 age = 0; double score = 0; QString rname; + in >> age >> score >> rname; + r.close(); + setStatus(report("03_struct_bin.dat", + QStringLiteral("读回验证 → age=%1 score=%2 name=%3(二进制可精确还原)") + .arg(age).arg(score, 0, 'f', 1).arg(rname))); + } + void tbCompare() { + if (!QFile::exists(path("03_struct_text.txt"))) tbStructText(); + if (!QFile::exists(path("03_struct_bin.dat"))) tbStructBin(); + const QFileInfo ft(path("03_struct_text.txt")), fb(path("03_struct_bin.dat")); + setStatus(QStringLiteral("文本 03_struct_text.txt:%1 字节\n二进制 03_struct_bin.dat:%2 字节\n" + "用十六进制工具看 .dat:int=4B、double=8B、QString 带长度前缀 + UTF-16。") + .arg(ft.size()).arg(fb.size())); + } + void tbNewline() { + // QIODevice::Text:Windows 上写 '\n' 会被转成 "\r\n";Linux 上不转换。 + // 不加 Text 标志(纯二进制写法)则始终原样 '\n'。这是跨平台文本文件的经典差异。 + const QByteArray body = "AAAA\nBBBB\nCCCC\n"; + writeBytes("03_nl_text.txt", body, QIODevice::Text); // 文本模式 + writeBytes("03_nl_bin.txt", body); // 二进制模式(无转换) + setStatus(QStringLiteral("已写 03_nl_text.txt(QIODevice::Text)与 03_nl_bin.txt(无 Text)。\n" + "Windows 下前者含 \\r\\n、后者只 \\n;Linux 下两者相同(平台差异,见注释)。")); + } + + // ---- ④ 异常与错误处理 ---------------------------------------------------- + void buildErrorGroup(QVBoxLayout *root) { + auto *g = new QGroupBox( + QStringLiteral("④ 异常与错误处理(Qt 返回值式 | C++ try/catch 两范式对照)")); + auto *l = new QGridLayout(g); + addBtn(l, 0, 0, QStringLiteral("Qt: 打开不存在文件"), [this] { errQtOpenMissing(); }); + addBtn(l, 0, 1, QStringLiteral("Qt: 只读模式写"), [this] { errQtReadOnlyWrite(); }); + addBtn(l, 0, 2, QStringLiteral("C++: ifstream 异常"), [this] { errCppIfstream(); }); + addBtn(l, 1, 0, QStringLiteral("C++: throw + catch"), [this] { errCppThrow(); }); + addBtn(l, 1, 1, QStringLiteral("JSON: 损坏解析"), [this] { errJsonBroken(); }); + root->addWidget(g); + } + void errQtOpenMissing() { + // Qt 范式:不抛异常,靠 open() 返回值 + errorString() + QFile f(path("这个文件不存在.txt")); + if (f.open(QIODevice::ReadOnly)) { + f.close(); + setStatus(QStringLiteral("意外:文件竟存在。")); + } else { + setStatus(QStringLiteral("【Qt 返回值式】open() 返回 false\n错误码:%1\n错误描述:%2\n" + "(Qt 设计上几乎不用 C++ 异常,错误经返回值/API 暴露)") + .arg(f.error()).arg(f.errorString())); + } + } + void errQtReadOnlyWrite() { + writeBytes("04_readonly.txt", "data\n"); + QFile f(path("04_readonly.txt")); + f.open(QIODevice::ReadOnly); // 故意只读 + const qint64 n = f.write("try overwrite"); // 只读模式下写 + const QString es = f.errorString(); + f.close(); + setStatus(QStringLiteral("【Qt 返回值式】只读模式下 write() 返回 %1(-1 表示失败)\nerrorString():%2") + .arg(n).arg(es)); + } + void errCppIfstream() { + // C++ 范式:标准库流默认不抛异常;调用 exceptions() 开启后,打开失败抛 std::ios_base::failure + std::ifstream in; + in.exceptions(std::ios::failbit | std::ios::badbit); + try { + in.open(path("这个文件不存在.txt").toStdString()); // 不存在 → failbit → 抛异常 + setStatus(QStringLiteral("意外:文件竟存在。")); + } catch (const std::ios_base::failure &e) { + setStatus(QStringLiteral("【C++ 异常式】捕获 std::ios_base::failure\nwhat():%1\n" + "(对比:Qt 的 QFile 同样情况只返回 false,不抛异常)") + .arg(QString::fromStdString(e.what()))); + } catch (const std::exception &e) { + setStatus(QStringLiteral("捕获 std::exception:%1").arg(QString::fromStdString(e.what()))); + } + } + void errCppThrow() { + // 演示自定义异常 + 多层 catch 顺序(派生类在前、基类在后) + try { + throw std::runtime_error("演示:文件内容校验失败,主动抛出"); + } catch (const std::runtime_error &e) { + setStatus(QStringLiteral("【C++ 异常式】捕获 std::runtime_error:\n%1\n" + "要点:catch 顺序从派生类到基类,否则被基类提前吞掉。") + .arg(QString::fromStdString(e.what()))); + } + } + void errJsonBroken() { + // 先写一段语法错误的 JSON(键未加引号、对象未闭合) + writeBytes("04_broken.json", "{ name: \"张三\", score: 95 "); + QByteArray raw; + if (!readBytes("04_broken.json", &raw)) return; + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); + if (doc.isNull()) { + setStatus(QStringLiteral("【JSON 解析】失败(QJsonDocument 不抛异常)。\n" + "错误偏移:%1\n错误描述:%2\n" + "(JSON 解析用 QJsonParseError 拿精确出错位置,而非 try/catch)") + .arg(err.offset).arg(err.errorString())); + } else { + setStatus(QStringLiteral("意外:JSON 竟合法。")); + } + } + + // ---- ⑤ QJson 文件读写 ---------------------------------------------------- + void buildJsonGroup(QVBoxLayout *root) { + auto *g = new QGroupBox(QStringLiteral("⑤ QJson 文件读写(QJsonDocument / Object / Array)")); + auto *l = new QGridLayout(g); + addBtn(l, 0, 0, QStringLiteral("写 JSON 文件"), [this] { jsonWrite(); }); + addBtn(l, 0, 1, QStringLiteral("读 JSON 文件"), [this] { jsonRead(); }); + addBtn(l, 1, 0, QStringLiteral("修改后回写"), [this] { jsonModify(); }); + root->addWidget(g); + } + void jsonWrite() { + QJsonObject student; + student["name"] = QStringLiteral("张三"); + student["score"] = 92; + student["enrolled"] = true; + QJsonArray tags; + tags.append(QStringLiteral("Qt")); + tags.append(QStringLiteral("C++")); + tags.append(QStringLiteral("多线程")); + student["tags"] = tags; + const QJsonDocument doc(student); + writeBytes("05_student.json", doc.toJson(QJsonDocument::Indented)); + setStatus(report("05_student.json", + QStringLiteral("QJsonObject → QJsonDocument → toJson(Indented) 缩进美化。"))); + } + void jsonRead() { + if (!QFile::exists(path("05_student.json"))) jsonWrite(); + QByteArray raw; + if (!readBytes("05_student.json", &raw)) return; + const QJsonDocument doc = QJsonDocument::fromJson(raw); + if (doc.isNull() || !doc.isObject()) { + setStatus(QStringLiteral("解析失败,请先点「写 JSON 文件」。")); return; + } + const QJsonObject obj = doc.object(); + const QJsonArray tags = obj["tags"].toArray(); + QStringList tagList; + for (const QJsonValue &v : tags) tagList << v.toString(); + setStatus(QStringLiteral("读 05_student.json:\nname = %1\nscore = %2\nenrolled = %3\ntags = [%4]") + .arg(obj["name"].toString()) + .arg(obj["score"].toInt()) + .arg(obj["enrolled"].toBool() ? QStringLiteral("true") : QStringLiteral("false")) + .arg(tagList.join(", "))); + } + void jsonModify() { + if (!QFile::exists(path("05_student.json"))) jsonWrite(); + QByteArray raw; + if (!readBytes("05_student.json", &raw)) return; + QJsonObject obj = QJsonDocument::fromJson(raw).object(); + obj["score"] = 100; // 改 + obj["grade"] = QStringLiteral("A+"); // 增 + obj.remove("enrolled"); // 删 + writeBytes("05_student.json", QJsonDocument(obj).toJson(QJsonDocument::Indented)); + setStatus(report("05_student.json", + QStringLiteral("已改 score=100、新增 grade=A+、删除 enrolled,覆盖回写。"))); + } + + // ---- 底部按钮 ------------------------------------------------------------ + void openDemoDir() { + QDesktopServices::openUrl(QUrl::fromLocalFile(m_dir)); + } + void clearDemoDir() { + const QDir d(m_dir); + int n = 0; + for (const QFileInfo &fi : d.entryInfoList(QDir::Files)) { + if (QFile::remove(fi.absoluteFilePath())) ++n; + } + setStatus(QStringLiteral("已清空 demo_output,删除 %1 个文件。").arg(n)); + } +}; + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + FileIoDemo w; + w.show(); + // 离屏自动化验证时 200ms 后自动退出(仿 p03 风格); + // 正常运行窗口保持、不自动退出;不点按钮则不触发任何文件写入。 + if (qgetenv("QT_QPA_PLATFORM") == "offscreen") { + QTimer::singleShot(200, &app, &QApplication::quit); + } + return app.exec(); +} diff --git a/docs/teaching/examples/lineedit_eye_toggle/CMakeLists.txt b/docs/teaching/examples/lineedit_eye_toggle/CMakeLists.txt new file mode 100644 index 0000000..1505b2d --- /dev/null +++ b/docs/teaching/examples/lineedit_eye_toggle/CMakeLists.txt @@ -0,0 +1,30 @@ +# ============================================================================= +# QLineEdit 自绘小眼睛 / echo mode 切换 —— 独立 CMake 工程 +# +# 本文件供「单独打开 / 独立构建」使用: +# cmake -S . -B build -DCMAKE_PREFIX_PATH= -DCMAKE_CXX_COMPILER= +# cmake --build build +# +# 仓库统一构建【不走】这里:顶层 CMakeLists.txt 只 +# add_subdirectory(docs/teaching/examples) 且不递归,统一构建走 +# examples/CMakeLists.txt 的 add_teaching_example,源文件同为 +# qt_lineedit_eye_toggle.cpp(零重复)。本目录不计入仓库「173 目标」计数。 +# +# 说明:本工程为手写规范版(C++17 / cmake 3.16 / 无 ANDROID 噪音), +# 区别于其它子目录里 Qt Creator 自动导出的低质量模板。 +# ============================================================================= +cmake_minimum_required(VERSION 3.16) + +project(qt_lineedit_eye_toggle LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_AUTOMOC ON) + +find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED) +message(STATUS "Found Qt5 ${Qt5Core_VERSION_STRING} at ${Qt5Core_DIR}") + +add_executable(qt_lineedit_eye_toggle qt_lineedit_eye_toggle.cpp) +target_link_libraries(qt_lineedit_eye_toggle PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets) +target_compile_options(qt_lineedit_eye_toggle PRIVATE -Wall -Wextra) diff --git a/docs/teaching/examples/lineedit_eye_toggle/README.md b/docs/teaching/examples/lineedit_eye_toggle/README.md new file mode 100644 index 0000000..c92292c --- /dev/null +++ b/docs/teaching/examples/lineedit_eye_toggle/README.md @@ -0,0 +1,101 @@ +# QLineEdit 自绘小眼睛 / echo mode 切换(qt_lineedit_eye_toggle) + +Day4 上午教学示例(OUTLINE_4DAY.md Day4 上午「消息机制和事件 + 绘图事件」),**同时覆盖两个主题**: + +- **组件绘制**——覆写 `paintEvent`,用 QPainter 在 QLineEdit 右侧自绘眼睛图标; +- **事件捕捉**——覆写 `mousePressEvent` / `mouseMoveEvent`,几何 hit-test 命中眼睛时切换 + `echoMode`,且不阻断基类的光标定位 / 拖选。 + +载体是 QLineEdit 密码框:右侧一个自绘小眼睛,点击在 `Password` ↔ `Normal` 间切换,并显式 +处理与 QLineEdit 内置 clear button 的位置冲突。 + +## 三区对照(先简后深) + +| 区 | 内容 | 教学作用 | +|---|---|---| +| ① echo mode 四态 | Normal / NoEcho / Password / PasswordEchoOnEdit 静态对照 | 先给 echo mode 全貌,眼睛只是切换载体 | +| ② addAction 一行版 | `addAction(QIcon, TrailingPosition)` 加眼睛,点击切换 | Qt 内置、1 行实现——「为什么还要自绘」的引子 | +| ③ 自绘版主秀 | `PasswordLineEdit` 自绘眼睛 + 「启用 clear button」复选框 | 主题落地 + clear button 避让 | + +## 关键教学点 + +### 1. 组件绘制 +- `paintEvent` **先调基类**画原生内容(文本 / 边框 / clear button),再用 QPainter 叠加眼睛。 + 顺序不能反——基类画完后 clear button 几何才最终确定,眼睛避让才准。 +- 眼睛用 QPainter 纯代码绘制(眼眶椭圆 + 瞳孔 / 斜杠两态),无外部资源。绘制逻辑抽成自由函数 + `paintEye()`,③区自绘与②区 `QIcon` pixmap 共用(DRY,且对照更直观)。 + +### 2. 事件捕捉 +- `mousePressEvent`:命中眼睛矩形 → 切 `echoMode` + `e->accept()` + `return`(**不**调基类, + 否则 QLineEdit 把这次点击当光标定位);非命中 → 调基类(光标定位 / 双击选词 / 右键菜单照常)。 + 这是「自绘交互区与原生编辑行为共存」的核心。 +- `mouseMoveEvent` + `setMouseTracking(true)`:实现 hover 高亮。**坑卡**:`mouseTracking` 默认关, + 不按鼠标键就收不到 `mouseMoveEvent`,hover 高亮出不来。 + +### 3. clear button 避让(读 clearButton 子 widget 几何) +QLineEdit 内置 clear button 的宽度随主题 / DPI / 字号变化,**不能硬编码偏移**(硬编码是反面教材, +换主题即错位)。正确做法:clear button 是 QLineEdit 的一个 `objectName=="clearButton"` 直接子 widget +(Qt5 私有 `QLineEditIconButton`),用 `findChild` 读它的真实几何: + +```cpp +if (QWidget* cb = findChild(QStringLiteral("clearButton"), + Qt::FindDirectChildrenOnly)) { + rightEdge = cb->x() - 4; // 眼睛贴 clear button 左侧,留 4px 缝 +} +``` + +眼睛紧贴其左侧;未启用 clear button 或无文本(clear button 不占位)时贴右边。勾选③区复选框即可实时 +观察眼睛从「贴右」移到「贴 clear button 左侧」、两者不重叠。 + +> 为什么不用 `QStyle::subControlRect`?QLineEdit 不经 QStyle 的 complex-control 绘制, +> `QStyle` 没有 `CC_LineEdit` / `SC_LineEditClearButton`,无对应 subcontrol 可查——读子 widget 是唯一可靠途径。 + +## 目录结构 +``` +lineedit_eye_toggle/ +├── qt_lineedit_eye_toggle.cpp # 唯一源文件(paintEye + PasswordLineEdit + ShowcaseWidget + main) +├── CMakeLists.txt # 独立顶层工程(可单独 cmake -S . -B build,不走仓库统一构建) +└── README.md # 本文档 +``` + +## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2) + +> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64,不可用 PATH 中更高版本的 g++。 + +### A. 集成构建(仓库统一构建树) +```bash +# 配置(在仓库根) +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 + +cmake --build build_mw --target qt_lineedit_eye_toggle + +# 运行(GUI,点 ②③ 的眼睛、勾选 clear button 体验) +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_lineedit_eye_toggle.exe + +# 离屏自动退出验证(不交互,退出码应为 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_lineedit_eye_toggle.exe; echo "exit=$?" +``` + +### B. 独立构建(单独 cmake 工程) +```bash +cd docs/teaching/examples/lineedit_eye_toggle +cmake -S . -B build -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 +cmake --build build +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/qt_lineedit_eye_toggle.exe +``` + +> 两条路径编译同一份 `qt_lineedit_eye_toggle.cpp`,行为一致。集成路径进入仓库统一构建; +> 独立路径可把这个目录单独拷走即用(仅需本目录三文件 + 一个 Qt 5.14.x)。 diff --git a/docs/teaching/examples/lineedit_eye_toggle/qt_lineedit_eye_toggle.cpp b/docs/teaching/examples/lineedit_eye_toggle/qt_lineedit_eye_toggle.cpp new file mode 100644 index 0000000..bb9ed31 --- /dev/null +++ b/docs/teaching/examples/lineedit_eye_toggle/qt_lineedit_eye_toggle.cpp @@ -0,0 +1,304 @@ +// ============================================================================= +// QLineEdit 自绘小眼睛 / echo mode 切换 —— Day4 上午教学示例 +// 对应课程:OUTLINE_4DAY.md Day4 上午「消息机制和事件 + 绘图事件」 +// +// 覆盖两个主题: +// 1) 组件绘制 —— 覆写 paintEvent,用 QPainter 在 QLineEdit 右侧自绘眼睛图标 +// 2) 事件捕捉 —— 覆写 mousePressEvent / mouseMoveEvent,几何 hit-test 命中眼睛 +// 时切换 echoMode,且不阻断基类的光标定位/拖选行为 +// +// 三区对照(先简后深): +// ① echo mode 四态静态对照(Normal / NoEcho / Password / PasswordEchoOnEdit) +// ② addAction 一行版(Qt 内置,作「为什么还要自绘」的引子) +// ③ 自绘版主秀:PasswordLineEdit 自绘眼睛 + clear button 避让(读 clearButton 子 widget 几何) +// ============================================================================= +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace std; +// ----------------------------------------------------------------------------- +// 眼睛图标绘制:PasswordLineEdit::paintEvent 与 ②区 QIcon pixmap 共用(DRY)。 +// closed=true → 当前隐藏(密码)态:眼眶椭圆 + 斜杠(「看不见」) +// closed=false → 当前明文态:眼眶椭圆 + 实心瞳孔(「能看见」) +// hover=true → 描边加深为蓝色,给即时反馈 +// ----------------------------------------------------------------------------- +static void paintEye(QPainter* p, const QRect& r, bool closed, bool hover) +{ + p->setRenderHint(QPainter::Antialiasing, true); + const QColor stroke = hover ? QColor(QStringLiteral("#0078D7")) + : QColor(QStringLiteral("#5a5a5a")); + // 眼眶:左右各留 10%、上下各留 20% 的内边距,画一个扁椭圆(杏仁眼简化形) + const QRect eye = r.adjusted(r.width() * 10 / 100, r.height() * 20 / 100, + -(r.width() * 10 / 100), -(r.height() * 20 / 100)); + p->setPen(QPen(stroke, hover ? 2.0 : 1.6)); + p->setBrush(Qt::NoBrush); + p->drawEllipse(eye); + + if (closed) { + // 密码态:一条斜杠盖住眼睛,直观表示「内容隐藏中」 + p->drawLine(eye.topLeft(), eye.bottomRight()); + } else { + // 明文态:实心瞳孔,直观表示「内容可见」 + p->setPen(Qt::NoPen); + p->setBrush(stroke); + const QPoint c = eye.center(); + const int pr = eye.height() / 4; + p->drawEllipse(c, pr, pr); + } +} + +// 用同一套绘制逻辑生成 QIcon(供 ②区 addAction 用,与 ③区自绘像素一致) +static QIcon makeEyeIcon(const QStyle* st, bool closed) +{ + const int sz = st->pixelMetric(QStyle::PM_SmallIconSize); + QPixmap pm(sz, sz); + pm.fill(Qt::transparent); + QPainter p(&pm); + paintEye(&p, QRect(0, 0, sz, sz), closed, /*hover=*/false); + return QIcon(pm); +} + +// ============================================================================= +// PasswordLineEdit:自绘版主秀 +// ============================================================================= +class PasswordLineEdit : public QLineEdit { + Q_OBJECT +public: + explicit PasswordLineEdit(QWidget* parent = nullptr) : QLineEdit(parent) + { + setEchoMode(QLineEdit::Password); + // ★ mouseTracking 默认关:不按鼠标键收不到 mouseMoveEvent → hover 高亮出不来。 + setMouseTracking(true); + // 右侧给眼睛预留宽度,避免文字被眼睛遮挡(clear button 的文本避让由 Qt 内部处理)。 + const int sz = style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, this); + setTextMargins(0, 0, sz + 8, 0); + } + + bool passwordMode() const { return m_passwordMode; } + +signals: + void passwordModeChanged(bool password); + +protected: + // 【组件绘制】先让基类画原生内容(文本 / 边框 / clear button),再叠加自绘的眼睛。 + // 顺序关键:必须先画基类,paint 后 clear button 几何才最终确定,recomputeEyeRect 才准。 + void paintEvent(QPaintEvent* e) override + { + QLineEdit::paintEvent(e); + recomputeEyeRect(); + QPainter p(this); + paintEye(&p, m_eyeRect, /*closed=*/m_passwordMode, m_eyeHover); + } + + // 【事件捕捉】命中眼睛 → 切 echoMode 并 accept 阻断;否则交基类保留编辑行为。 + void mousePressEvent(QMouseEvent* e) override + { + recomputeEyeRect(); + if (e->button() == Qt::LeftButton && m_eyeRect.contains(e->pos())) { + m_passwordMode = !m_passwordMode; + setEchoMode(m_passwordMode ? QLineEdit::Password : QLineEdit::Normal); + emit passwordModeChanged(m_passwordMode); + update(); + e->accept(); // ★ accept + return:不让基类把这次点击当光标定位 + return; + } + QLineEdit::mousePressEvent(e); // 非命中:光标定位 / 双击选词 / 右键菜单照常 + } + + // hover 高亮:命中眼睛变手势指针,离开变文本光标 + void mouseMoveEvent(QMouseEvent* e) override + { + recomputeEyeRect(); + const bool over = m_eyeRect.contains(e->pos()); + if (over != m_eyeHover) { + m_eyeHover = over; + update(m_eyeRect); // 局部刷新,避免整控件重绘 + } + setCursor(over ? Qt::PointingHandCursor : Qt::IBeamCursor); + QLineEdit::mouseMoveEvent(e); // 保留文本拖选 + } + + void leaveEvent(QEvent* e) override + { + if (m_eyeHover) { + m_eyeHover = false; + update(m_eyeRect); + } + QLineEdit::leaveEvent(e); + } + +private: + // 【clear button 避让】读 QLineEdit 内置 clear button 子 widget 的真实几何, + // 眼睛贴其左侧;未启用时贴右边。【注】QStyle 没有 CC_LineEdit / SC_LineEditClearButton + // ——QLineEdit 不经 QStyle 的 complex control 绘制,无对应 subcontrol 可查,故改读子 widget。 + // clear button 的宽度随主题/DPI/字号变化,且仅在有文本时显示占位,故同时判文本。 + void recomputeEyeRect() + { + const int sz = style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, this); + int rightEdge = width() - 6; // 默认:贴右边内边距 + if (!text().isEmpty()) { + const auto& children = this->children(); + auto cbFound = false; + QWidget *cb = nullptr; + for (auto & child : findChildren("")) { + + ; + } + for (auto& child : children) { + qDebug() << child->objectName() << child->metaObject()->className() << endl; +// if (child->objectName().endsWith("clearaction")) { +// cbFound = true; +// cb = qobject_cast(child); +// if (cbFound) { +// rightEdge = rightEdge - sz - 4; // 有 clear button:眼睛贴其左侧,留 4px 缝 +// } else { + +// qDebug() << "cast failed" << endl; +// } +// break; +// } + } + } + m_eyeRect = QRect(rightEdge - sz, (height() - sz) / 2, sz, sz); + } + + bool m_passwordMode = true; + bool m_eyeHover = false; + QRect m_eyeRect; +}; + +// ============================================================================= +// ShowcaseWidget:三区对照窗口 +// ============================================================================= +class ShowcaseWidget : public QWidget { + Q_OBJECT +public: + explicit ShowcaseWidget(QWidget* parent = nullptr) : QWidget(parent) + { + setWindowTitle(QStringLiteral("QLineEdit 自绘小眼睛 / echo mode 切换")); + auto* root = new QVBoxLayout(this); + const QString sample = QStringLiteral("hello123"); + + // ---- ① echo mode 四态静态对照 ------------------------------------------------- + { + auto* g = new QGroupBox( + QStringLiteral("① echo mode 四态对照(文本 \"hello123\")"), this); + auto* f = new QFormLayout(g); + auto* eNormal = makeRO(sample, QLineEdit::Normal); + auto* eNoEcho = makeRO(sample, QLineEdit::NoEcho); + auto* ePwd = makeRO(sample, QLineEdit::Password); + auto* ePwdOnEdit = makeRO(sample, QLineEdit::PasswordEchoOnEdit); + f->addRow(QStringLiteral("Normal(明文)"), eNormal); + f->addRow(QStringLiteral("NoEcho(完全无显示,光标仍可移动)"), eNoEcho); + f->addRow(QStringLiteral("Password(密文圆点)"), ePwd); + // PasswordEchoOnEdit 的「编辑时明文」需取消只读后键入才显现(见 README)。 + f->addRow(QStringLiteral("PasswordEchoOnEdit(失焦密文/编辑明文)"), ePwdOnEdit); + root->addWidget(g); + } + + // ---- ② addAction 一行版(Qt 内置,对照) ------------------------------------- + { + auto* g = new QGroupBox( + QStringLiteral("② addAction 一行版(Qt 内置,对照「为什么还要自绘」)"), this); + auto* f = new QFormLayout(g); + auto* edit = new QLineEdit(this); + edit->setEchoMode(QLineEdit::Password); + edit->setText(sample); + // 一行即可:Qt 自动处理位置 / 点击 / 与 clear button 的避让。但它碰不到「绘制/事件」主题。 + QAction* eyeAct = edit->addAction(makeEyeIcon(style(), /*closed=*/true), + QLineEdit::TrailingPosition); + eyeAct->setToolTip(QStringLiteral("点击切换显示")); + connect(eyeAct, &QAction::triggered, this, [this, edit, eyeAct]() { + const bool toNormal = edit->echoMode() == QLineEdit::Password; + edit->setEchoMode(toNormal ? QLineEdit::Normal : QLineEdit::Password); + eyeAct->setIcon(makeEyeIcon(style(), /*closed=*/!toNormal)); + log(QStringLiteral("② addAction:echoMode → %1") + .arg(toNormal ? QStringLiteral("Normal") : QStringLiteral("Password"))); + }); + f->addRow(QStringLiteral("密码框 + 眼睛(内置 action)"), edit); + root->addWidget(g); + } + + // ---- ③ 自绘版主秀 ------------------------------------------------------------- + { + auto* g = new QGroupBox( + QStringLiteral("③ 自绘版主秀:paintEvent 自绘 + mouseEvent 捕获 + clear button 避让"), + this); + auto* f = new QFormLayout(g); + m_pwdEdit = new PasswordLineEdit(this); + m_pwdEdit->setText(sample); + connect(m_pwdEdit, &PasswordLineEdit::passwordModeChanged, this, [this](bool pwd) { + log(QStringLiteral("③ 自绘:echoMode → %1") + .arg(pwd ? QStringLiteral("Password") : QStringLiteral("Normal"))); + }); + auto* cbClear = new QCheckBox( + QStringLiteral("启用 QLineEdit 内置 clear button(观察眼睛如何避让到其左侧)"), this); + cbClear->setChecked(false); + connect(cbClear, &QCheckBox::toggled, m_pwdEdit, &QLineEdit::setClearButtonEnabled); + connect(cbClear, &QCheckBox::toggled, this, [this](bool on) { + log(QStringLiteral("③ clear button %1") + .arg(on ? QStringLiteral("开启") : QStringLiteral("关闭"))); + }); + f->addRow(QStringLiteral("密码框 + 自绘眼睛"), m_pwdEdit); + f->addRow(QString(), cbClear); + root->addWidget(g); + } + + m_status = new QLabel(QStringLiteral("事件日志:就绪。点击 ②③ 的眼睛,或勾选 clear button。"), this); + m_status->setStyleSheet(QStringLiteral("padding:4px; color:#333;")); + m_status->setWordWrap(true); + root->addWidget(m_status); + root->addStretch(); + } + +private: + void log(const QString& s) { m_status->setText(QStringLiteral("事件日志:%1").arg(s)); } + + static QLineEdit* makeRO(const QString& text, QLineEdit::EchoMode mode) + { + auto* e = new QLineEdit(text); + e->setEchoMode(mode); + e->setReadOnly(true); + return e; + } + + PasswordLineEdit* m_pwdEdit = nullptr; + QLabel* m_status = nullptr; +}; + +// ============================================================================= +// main +// ============================================================================= +int main(int argc, char* argv[]) +{ + QApplication app(argc, argv); + ShowcaseWidget w; + w.resize(540, 440); + w.show(); + + // 离屏自动化验证:200ms 后自动退出,退出码 0 表示窗口正常构建与渲染。 + if (qEnvironmentVariable("QT_QPA_PLATFORM") == QStringLiteral("offscreen")) { + QTimer::singleShot(200, qApp, &QCoreApplication::quit); + } + return app.exec(); +} + +#include "qt_lineedit_eye_toggle.moc" diff --git a/docs/teaching/examples/qdialog_showcase/CMakeLists.txt b/docs/teaching/examples/qdialog_showcase/CMakeLists.txt new file mode 100644 index 0000000..222e4a7 --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.5) + +project(qt_dialog_showcase 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(qt_dialog_showcase + qt_dialog_showcase.cpp + dialog.cpp + dialog.h + dialog.ui + ) +else() +add_executable(qt_dialog_showcase + dialog.cpp + dialog.h + dialog.ui + resources.qrc + qt_dialog_showcase.cpp) +endif() + +target_link_libraries(qt_dialog_showcase PRIVATE Qt5::Widgets) diff --git a/docs/teaching/examples/qdialog_showcase/README.md b/docs/teaching/examples/qdialog_showcase/README.md new file mode 100644 index 0000000..6285f2c --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/README.md @@ -0,0 +1,78 @@ +# QDialog 教学综合示例(qt_dialog_showcase) + +参考川大 wiki「6 对话框 QDialog」(pageId 183664881) 的 **6.2–6.5**,形态为一个 +**QWidget 布满 pushbutton**:每个按钮触发一种 `QDialog` 行为。纯代码(无 `.ui`),主类不 +定义信号槽,全部用 lambda 连接 `QPushButton::clicked`。 + +> 仓库 `p03/ch06` 已有零散单行为示例(modal_dialog / modeless_dialog_* / message_box_* / +> file_dialog);本示例是**综合演示**,把页面的全部行为聚到一个窗口对照体验。 + +## 16 个按钮 ↔ 页面行为 + +### ① 模态 / 非模态(6.3) +| 按钮 | 行为 | 关键 API / 要点 | +|---|---|---| +| 模态 exec | `QDialog::exec()` | 应用级模态,阻塞整个应用直到关闭 | +| 窗口模态 open | `QDialog::open()` | 窗口级模态,仅阻塞父窗口 | +| 非模态 show | `new QDialog->show()` | 非模态,立即返回;**栈对象会一闪而过**(6.3 的坑) | +| 非模态+删除关闭 | `setAttribute(WA_DeleteOnClose)+show()` | 关闭即 delete,避免无 parent 堆对象泄漏 | + +### ② QMessageBox(6.4) +| 按钮 | 行为 | +|---|---| +| about / aboutQt / critical / information / question / warning | 六个 static 函数 | +| 自定义消息框 | 实例 API:`setIcon / setText / setInformativeText / setDetailedText / setStandardButtons / setDefaultButton` + `exec` | + +### ③ 标准对话框(6.2 / 6.5) +| 按钮 | 行为 | +|---|---| +| 打开文件 | `QFileDialog::getOpenFileName` | +| 保存文件 | `QFileDialog::getSaveFileName` | +| 选颜色 | `QColorDialog::getColor` | +| 选字体 | `QFontDialog::getFont` | +| 输入文字 | `QInputDialog::getText` | + +底部 `QLabel` 显示标准对话框 / question / 自定义消息框的返回值。 + +## 关键教学点 +- **exec vs open vs show**:三种模态/非模态实现,对应 6.3。`exec()` 阻塞整个应用; + `open()` 阻塞父窗口;`show()` 不阻塞。 +- **栈对象坑(6.3)**:`QDialog d; d.show();` 在函数返回时 `d` 析构,对话框立即消失 → + 必须用堆对象(`new`)。 +- **WA_DeleteOnClose(6.3)**:无 parent 的堆对象靠它在关闭时自动 `delete`,避免内存泄漏。 +- **QMessageBox static vs 实例(6.4)**:static(about / question / …)调用最简;实例 API + 可设详细文本(`setDetailedText` 出现「显示详情...」按钮)与任意按钮组合。 +- **标准对话框统一风格(6.2/6.5)**:`QFileDialog / QColorDialog / QFontDialog / QInputDialog` + 都是 `get...` static,返回用户选择,`isEmpty()`/`isValid()`/`&ok` 判断是否取消。 + +## 目录结构 +``` +qdialog_showcase/ +├── qt_dialog_showcase.cpp # DialogShowcase : QWidget(无 Q_OBJECT,纯 lambda)+ main +└── README.md # 本文档 +``` + +## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2) +> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64,不可用 PATH 中更高版本的 g++。 + +```bash +# 配置(在仓库根) +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 + +# 构建单目标 +cmake --build build_mw --target qt_dialog_showcase + +# 运行(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_dialog_showcase.exe + +# 离屏自动退出验证(不点按钮,退出码应为 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_dialog_showcase.exe; echo "exit=$?" +``` diff --git a/docs/teaching/examples/qdialog_showcase/dialog.cpp b/docs/teaching/examples/qdialog_showcase/dialog.cpp new file mode 100644 index 0000000..0570ad7 --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/dialog.cpp @@ -0,0 +1,20 @@ +#include "dialog.h" +#include "ui_dialog.h" +#include +#include + +Dialog::Dialog(QWidget *parent) : + QDialog(parent), + ui(new Ui::Dialog) +{ + ui->setupUi(this); + this->pMovie = new QMovie(":/source.gif"); + this->pMovie->start(); + ui->label->setMovie(this->pMovie); +} + +Dialog::~Dialog() +{ + delete this->pMovie; + delete ui; +} diff --git a/docs/teaching/examples/qdialog_showcase/dialog.h b/docs/teaching/examples/qdialog_showcase/dialog.h new file mode 100644 index 0000000..a95ed6f --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/dialog.h @@ -0,0 +1,23 @@ +#ifndef DIALOG_H +#define DIALOG_H + +#include + +namespace Ui { +class Dialog; +} + +class Dialog : public QDialog +{ + Q_OBJECT + +public: + explicit Dialog(QWidget *parent = nullptr); + ~Dialog(); + +private: + Ui::Dialog *ui; + QMovie *pMovie; +}; + +#endif // DIALOG_H diff --git a/docs/teaching/examples/qdialog_showcase/dialog.ui b/docs/teaching/examples/qdialog_showcase/dialog.ui new file mode 100644 index 0000000..989750f --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/dialog.ui @@ -0,0 +1,123 @@ + + + Dialog + + + + 0 + 0 + 640 + 480 + + + + Dialog + + + + + 10 + 440 + 621 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + 120 + 70 + 100 + 100 + + + + + 100 + 100 + + + + QFrame::NoFrame + + + + + + :/source.gif + + + true + + + + + + 140 + 190 + 200 + 241 + + + + + 200 + 200 + + + + MarioGif + + + Qt::PlainText + + + true + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/docs/teaching/examples/qdialog_showcase/qt_dialog_showcase.cpp b/docs/teaching/examples/qdialog_showcase/qt_dialog_showcase.cpp new file mode 100644 index 0000000..136cc59 --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/qt_dialog_showcase.cpp @@ -0,0 +1,188 @@ +// ============================================================================ +// qdialog_showcase —— QDialog 教学综合示例 +// 参考川大 wiki「6 对话框 QDialog」(pageId 183664881) 的 6.2–6.5: +// 6.3 模态/非模态(exec / open / show + WA_DeleteOnClose) +// 6.4 QMessageBox(六个 static + 实例 API) +// 6.2/6.5 标准对话框(QFileDialog / QColorDialog / QFontDialog / QInputDialog) +// 形态:一个 QWidget 布满 pushbutton,每个按钮触发一种 QDialog 行为。 +// 纯代码(无 .ui);主类不定义信号槽,全部用 lambda 连接 QPushButton::clicked。 +// ============================================================================ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "dialog.h" + +class DialogShowcase : public QWidget { +public: + explicit DialogShowcase(QWidget *parent = nullptr) : QWidget(parent) { + auto *root = new QVBoxLayout(this); + + // 反馈标签:显示标准对话框 / question 的返回值 + statusLabel = new QLabel(QStringLiteral("点击下方按钮,体验不同的 QDialog 行为。")); + statusLabel->setWordWrap(true); + statusLabel->setMinimumHeight(36); + + // ① 模态/非模态(6.3) ------------------------------------------------- + auto *g1 = new QGroupBox(QStringLiteral("① 模态/非模态(6.3)")); + auto *l1 = new QGridLayout(g1); + addBtn(l1, 0, 0, QStringLiteral("模态 exec"), [this] { + QDialog d(this); + d.setWindowTitle(QStringLiteral("模态对话框(exec)")); + d.exec(); // 应用级模态:阻塞整个应用,直到关闭 + setStatus(QStringLiteral("exec() 返回 —— 应用级模态已关闭")); + }); + addBtn(l1, 0, 1, QStringLiteral("窗口模态 open"), [this] { + auto *d = new QDialog(this); + d->setWindowTitle(QStringLiteral("窗口级模态(open)")); + d->setAttribute(Qt::WA_DeleteOnClose); + d->open(); // 窗口级模态:仅阻塞父窗口,其余窗口仍可交互 + setStatus(QStringLiteral("open() 弹出窗口级模态(仅阻塞父窗口)")); + }); + addBtn(l1, 1, 0, QStringLiteral("非模态 show"), [this] { + // 必须用堆对象:栈对象在 show() 返回后随作用域结束而析构,对话框一闪而过 + // (页面 6.3 强调的坑)。这里给 parent this,由 Qt 对象树托管生命周期。 + auto *d = new QDialog(this); + d->setWindowTitle(QStringLiteral("非模态对话框(show)")); + d->show(); // 非模态:立即返回、不阻塞,可与主窗口同时操作 + setStatus(QStringLiteral("show() 非模态弹出(不阻塞)")); + }); + addBtn(l1, 1, 1, QStringLiteral("非模态+删除关闭"), [this] { + // 无 parent 的堆对象:靠 WA_DeleteOnClose 在关闭时自动 delete,避免泄漏 + auto *d = new QDialog; + d->setWindowTitle(QStringLiteral("WA_DeleteOnClose")); + d->setAttribute(Qt::WA_DeleteOnClose); + d->show(); + setStatus(QStringLiteral("show() + WA_DeleteOnClose:关闭即销毁")); + }); + + // ② QMessageBox(6.4) ------------------------------------------------- + auto *g2 = new QGroupBox(QStringLiteral("② QMessageBox(6.4)")); + auto *l2 = new QGridLayout(g2); + addBtn(l2, 0, 0, QStringLiteral("about"), [this] { + QMessageBox::about(this, QStringLiteral("about"), + QStringLiteral("这是 about 对话框(只有一个 OK)。")); + }); + addBtn(l2, 0, 1, QStringLiteral("aboutQt"), [this] { + QMessageBox::aboutQt(this, QStringLiteral("aboutQt")); + }); + addBtn(l2, 0, 2, QStringLiteral("critical"), [this] { + QMessageBox::critical(this, QStringLiteral("critical"), QStringLiteral("严重错误!")); + }); + addBtn(l2, 1, 0, QStringLiteral("information"), [this] { + QMessageBox::information(this, QStringLiteral("information"), QStringLiteral("提示信息。")); + }); + addBtn(l2, 1, 1, QStringLiteral("question"), [this] { + const auto r = QMessageBox::question(this, QStringLiteral("question"), + QStringLiteral("你确定吗?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::Yes); + setStatus(QStringLiteral("question 返回:%1") + .arg(r == QMessageBox::Yes ? QStringLiteral("Yes") : QStringLiteral("No"))); + }); + addBtn(l2, 1, 2, QStringLiteral("warning"), [this] { + QMessageBox::warning(this, QStringLiteral("warning"), QStringLiteral("警告!")); + }); + addBtn(l2, 2, 0, QStringLiteral("自定义消息框"), [this] { + QMessageBox box(QMessageBox::Question, QStringLiteral("自定义消息框"), + QStringLiteral("文档已被修改。"), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, this); + box.setInformativeText(QStringLiteral("是否保存修改?")); + box.setDetailedText(QStringLiteral("这里是详细差异信息……")); + box.setDefaultButton(QMessageBox::Save); + const int r = box.exec(); + const QString name = + (r == QMessageBox::Save) ? QStringLiteral("保存") : + (r == QMessageBox::Discard) ? QStringLiteral("丢弃") : + QStringLiteral("取消"); + setStatus(QStringLiteral("自定义消息框返回:%1").arg(name)); + }); + + // ③ 标准对话框(6.2 / 6.5) ------------------------------------------- + auto *g3 = new QGroupBox(QStringLiteral("③ 标准对话框(6.2 / 6.5)")); + auto *l3 = new QGridLayout(g3); + addBtn(l3, 0, 0, QStringLiteral("打开文件"), [this] { + const QString f = QFileDialog::getOpenFileName(this, QStringLiteral("打开文件")); + if (!f.isEmpty()) setStatus(QStringLiteral("打开文件:%1").arg(f)); + }); + addBtn(l3, 0, 1, QStringLiteral("保存文件"), [this] { + const QString f = QFileDialog::getSaveFileName(this, QStringLiteral("保存文件")); + if (!f.isEmpty()) setStatus(QStringLiteral("保存文件:%1").arg(f)); + }); + addBtn(l3, 1, 0, QStringLiteral("选颜色"), [this] { + const QColor c = QColorDialog::getColor(Qt::white, this, QStringLiteral("选颜色")); + if (c.isValid()) + setStatus(QStringLiteral("选中颜色 RGB(%1,%2,%3)") + .arg(c.red()).arg(c.green()).arg(c.blue())); + }); + addBtn(l3, 1, 1, QStringLiteral("选字体"), [this] { + bool ok = false; + const QFont f = QFontDialog::getFont(&ok, font(), this, QStringLiteral("选字体")); + if (ok) setStatus(QStringLiteral("选中字体:%1").arg(f.family())); + }); + addBtn(l3, 2, 0, QStringLiteral("输入文字"), [this] { + bool ok = false; + const QString s = QInputDialog::getText(this, QStringLiteral("输入文字"), + QStringLiteral("请输入:"), + QLineEdit::Normal, QString(), &ok); + if (ok) setStatus(QStringLiteral("输入:%1").arg(s)); + }); + addBtn(l3, 2, 0, QStringLiteral("输入文字"), [this] { + bool ok = false; + const QString s = QInputDialog::getText(this, QStringLiteral("输入文字"), + QStringLiteral("请输入:"), + QLineEdit::Normal, QString(), &ok); + if (ok) setStatus(QStringLiteral("输入:%1").arg(s)); + }); + addBtn(l3, 2, 1, QStringLiteral("自定义对话框"), [] { + QDialog *customDialog = new Dialog(); + customDialog->setAttribute(Qt::WA_DeleteOnClose); + customDialog->exec(); + }); + root->addWidget(g1); + root->addWidget(g2); + root->addWidget(g3); + root->addWidget(statusLabel); + + setWindowTitle(QStringLiteral("QDialog 教学综合示例")); + resize(520, 430); + } + +private: + QLabel *statusLabel = nullptr; + + // 在 QGridLayout(row, col) 放一个按钮,clicked 连到 lambda + template + void addBtn(QGridLayout *layout, int row, int col, const QString &text, F &&slot) { + auto *btn = new QPushButton(text, this); + layout->addWidget(btn, row, col); + connect(btn, &QPushButton::clicked, this, std::forward(slot)); + } + + void setStatus(const QString &s) { statusLabel->setText(s); } +}; + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + DialogShowcase w; + w.show(); + // 仅在离屏自动化验证时 200ms 后自动退出(仿 p03 风格); + // 正常运行窗口保持,不自动退出。不点按钮则不触发任何对话框。 + if (qgetenv("QT_QPA_PLATFORM") == "offscreen") { + QTimer::singleShot(200, &app, &QApplication::quit); + } + return app.exec(); +} diff --git a/docs/teaching/examples/qdialog_showcase/resources.qrc b/docs/teaching/examples/qdialog_showcase/resources.qrc new file mode 100644 index 0000000..e6fc9af --- /dev/null +++ b/docs/teaching/examples/qdialog_showcase/resources.qrc @@ -0,0 +1,5 @@ + + + source.gif + + diff --git a/docs/teaching/examples/qdialog_showcase/source.gif b/docs/teaching/examples/qdialog_showcase/source.gif new file mode 100644 index 0000000..304234a Binary files /dev/null and b/docs/teaching/examples/qdialog_showcase/source.gif differ