Day4 教学示例补三个:QDialog 综合 / QLineEdit 小眼睛 / 文件 IO(+顶层构建注册)

- qdialog_showcase:wiki「6 对话框 QDialog」6.2–6.5 综合演示。含两套实现:
  · Designer 表单版(dialog.{h,cpp,ui} + resources.qrc + source.gif,QMovie 播 GIF)
  · 纯代码版(qt_dialog_showcase.cpp,单文件,QWidget 布满按钮逐个演示 QDialog 行为)
- lineedit_eye_toggle:子类化 QLineEdit,paintEvent 自绘小眼睛、mouseEvent 命中切换
  echoMode,并与内置 clear button 动态避让(读 objectName=="clearButton" 子 widget 几何)。
  含独立子目录 CMakeLists.txt 供单独打开(顶层 add_subdirectory 不递归)。
- file_io_demo:QFile/QTextStream/QDataStream/QFileInfo/QDir/QTextCodec/QJson 五主题
  综合示例,每个按钮产出一个文件落到 demo_output/ 便于课堂手动查看。

构建注册:examples/CMakeLists.txt 用 add_teaching_example 注册前两个纯代码示例,
mandelbrot 多源文件单独 add_executable;calculator_mainwindow_qss 加 themes.qrc 与
AUTORCC;all_teaching_examples 依赖列表同步补齐。

附 lineedit_eye_toggle 设计文档(docs/superpowers/specs)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
张宗平
2026-07-09 16:51:09 +08:00
parent 993aa30a1c
commit 01b264fe97
16 changed files with 1694 additions and 3 deletions
@@ -0,0 +1,30 @@
# =============================================================================
# QLineEdit 自绘小眼睛 / echo mode 切换 —— 独立 CMake 工程
#
# 本文件供「单独打开 / 独立构建」使用:
# cmake -S . -B build -DCMAKE_PREFIX_PATH=<Qt5.14.2> -DCMAKE_CXX_COMPILER=<g++>
# 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)
@@ -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<QWidget*>(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)。
@@ -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 <QAction>
#include <QApplication>
#include <QCheckBox>
#include <QEvent>
#include <QFormLayout>
#include <QGroupBox>
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QMouseEvent>
#include <QPaintEvent>
#include <QPainter>
#include <QPixmap>
#include <QRect>
#include <QStyle>
#include <QTimer>
#include <QVBoxLayout>
#include <QDebug>
#include <iostream>
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<QWidget *>("")) {
;
}
for (auto& child : children) {
qDebug() << child->objectName() << child->metaObject()->className() << endl;
// if (child->objectName().endsWith("clearaction")) {
// cbFound = true;
// cb = qobject_cast<QWidget *>(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("② addActionechoMode → %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"