diff --git a/todo/README.md b/todo/README.md index 62f0317..4537d19 100644 --- a/todo/README.md +++ b/todo/README.md @@ -6,26 +6,25 @@ ``` 入门 137✅ · 进阶 134✅ · 专家 2/102(01·02 COW 已审结,见 expert.md) -实例库 widget 2/13(status-led✅ + toggle-switch✅),app/model/industrial 空 +实例库 widget 13/13 ✅收齐(status-led✅ + toggle-switch✅ + circle-progress✅ + speed-meter✅ + range-slider✅ + line-chart✅ + editable-table✅ + checkbox-tree✅ + checkbox-list✅ + log-viewer✅ + password-edit✅ + ip-edit✅ + fade-animation✅),app/model/industrial 骨架已立 examples 275✅ · 基建 P0✅ 基本清完 ``` -## 接力点(下次会话从这里接 · 2026-06-16 更) +## 接力点(下次会话从这里接 · 2026-06-25 更) -- **已合入 main(f8349b0)**:status-led 中等档标杆(PR#10) · D3 sweep「需要注意的是」×30 + 死链(PR#11) · Task A 风格违例(值得注意的是/一般来说/大概) · toggle-switch 控件 + 双文档 -- 工作区干净、本地 `main == origin/main` -- **下一步二选一**: - - ① 专家篇 `01-qobject-meta-system`(慢·审核门限·一次一篇·A/B 双 Agent 取证,见 [.claude/production-paradigm.md](../.claude/production-paradigm.md) §2) - - ② 实例库续 `circle-progress`(widget 撑场批)或破 `app/image-viewer`(快·D2 放量·照 status-led/toggle-switch 模板,构建门 + 双文档) +- **分支 `instance/widget-painter-batch`(从 main 切·本机已 commit 未 push)**:widget 栏四批共 11 件全收齐——自绘链 4(circle-progress/speed-meter/range-slider/line-chart) + model/view 2(editable-table/checkbox-tree) + input/display 3(checkbox-list/log-viewer/password-edit) + 收尾 2(ip-edit/fade-animation)。每件 STATIC 库+demo+构建门绿(零 warning)+offscreen 冒烟+Full 导览+Handbook。另含两处修复:**speed-meter 指针角度 bugfix + 小尺寸藏标签**、**editable-table 步长+填宽**。②d 批起 code Agent 预 clang-format(行号不漂、提交一把过)。**widget 栏 13/13 完成,待作者抽审放量**。 +- **已合入 main(f8349b0)**:status-led 标杆 · toggle-switch 控件 + 双文档(上一批) +- 工作区:分支上 11 件 + 2 修复 + todo 更新;`main == origin/main`;widget 栏 13/13 完成 +- **下一步(审完合入后)**:widget 栏已收齐,转战 **app 栏破零**(image-viewer/sqlite-browser/serial-tool)或 model 栏放量(其余 17 照 undo-redo 范式);专家层在 `expert/meta-object-system` 分支另线推进(5 篇未并入 main,README 专家行待合并后统一) - **挂账**:05-other-modules ~25 篇缺踩坑段——按「不编坑」原则,等真写到该模块再补真坑(memory: no-fabricated-pitfalls) -- ⚠ **作者会在终端并行 commit/push/merge**(本会话发生过):AI 改文件前先 `git status`;提交 / push 全归作者;commit / PR **不带任何 AI 署名**(memory: no-ai-attribution / user-handles-all-pushes) +- ⚠ **作者会在终端并行 commit/push/merge**:AI 改文件前先 `git status`;提交 / push 全归作者;commit / PR **不带任何 AI 署名**(memory: no-ai-attribution / user-handles-all-pushes) ## 工作区 → 文件 | 工作区 | 文件 | 内容 | 当下 | |---|---|---|---| | 专家层 | [expert.md](expert.md) | 102 篇源码拆解(每篇带 qt_src 行号证据) | ✅ 01·02 COW 已审结 → 下一篇 qobject | -| 实例库 | [instance-library.md](instance-library.md) | widget/app/model/industrial 成品 + 两套文档 | widget 2/13(status-led + toggle-switch✅)· app/model/industrial 空 | +| 实例库 | [instance-library.md](instance-library.md) | widget/app/model/industrial 成品 + 两套文档 | widget 6/13(+circle/speed/range/line✅·分支待审)· app/model/industrial 骨架已立 | | 基建 | [infra.md](infra.md) | P0/P0.5/P1/P4 + widget 化简 + 地基债 | ✅ P0 基本清完 · 剩 sidebar/结构漂移 | | embedded | [embedded.md](embedded.md) | Layer1 公共基础 + Layer2 板级 | ⚠ 生产方式待定 | | 延后 | [parked.md](parked.md) | community/translation/interactive/CI门… | 不投入 | @@ -33,7 +32,7 @@ examples 275✅ · 基建 P0✅ 基本清完 ## 当下优先 - 专家层:01·02 已审结 → 下一篇 01-qobject-meta-system(D1·审核门限·一次一篇) -- 实例库:widget 2/13 → 续 circle-progress;app/model/industrial 三栏零起步待破 +- 实例库:widget 6/13(自绘链 4 件待审,分支 instance/widget-painter-batch)→ 续 ⑦editable-table 或破 app/image-viewer;app/model/industrial 骨架已立待成品化 - 基建:P0✅ 基本清完(死链 + 需要注意的是×30 + 风格违例已入 main)→ 剩 专家 sidebar 收敛 + 入门结构漂移 ## gate diff --git a/todo/instance-library.md b/todo/instance-library.md index 8e2c93f..c81c525 100644 --- a/todo/instance-library.md +++ b/todo/instance-library.md @@ -21,7 +21,7 @@ 6. industrial 链接 widget 库 .so 复用(speed-meter/circle-progress/line-chart 先落地) ### widget 栏(13·每族代表·自绘递进链) -路径 `widget/<家族>/<控件名>/`。①status-led✅中等档标杆(颜色过渡+呼吸+Q_PROPERTY全+双文档pilot待验格式) ②toggle-switch✅(2026-06-16,滑动动画+拖动/点击+Q_PROPERTY配色+双文档) ③circle-progress ④speed-meter ⑤range-slider ⑥line-chart(纯QPainter) ⑦editable-table ⑧checkbox-tree ⑨checkbox-list ⑩log-viewer ⑪password-edit ⑫ip-edit(P1) ⑬fade-animation(P1)。未覆盖9族首波不碰:calendar/datetime/opengl/specialized/network-widget/data-display/multimedia/map/print。 +路径 `widget/<家族>/<控件名>/`。①status-led✅中等档标杆(颜色过渡+呼吸+Q_PROPERTY全+双文档pilot待验格式) ②toggle-switch✅(2026-06-16,滑动动画+拖动/点击+Q_PROPERTY配色+双文档) ③circle-progress✅(2026-06-25·value/progress解耦+drawArc) ④speed-meter✅(2026-06-25·动画指针+刻度) ⑤range-slider✅(2026-06-25·双柄拖拽) ⑥line-chart✅(纯QPainter·2026-06-25) ⑦editable-table✅(2026-06-25·委托校验+数据往返) ⑧checkbox-tree✅(2026-06-25·三态+父子联动) ⑨checkbox-list✅(2026-06-25·QListWidget勾选+批量守卫) ⑩log-viewer✅(2026-06-25·级别染色+裁旧) ⑪password-edit✅(2026-06-25·显隐+强度) ⑫ip-edit✅(2026-06-25·4段跳焦+0-255校验) ⑬fade-animation✅(2026-06-25·OpacityEffect淡入淡出)。**widget 栏 13/13 收齐**。未覆盖9族首波不碰:calendar/datetime/opengl/specialized/network-widget/data-display/multimedia/map/print。 ### app 栏(7旗舰+1拉伸·撑「真库不是片段」) 路径 `app/<类目>/<应用名>/`。image-viewer★5 / sqlite-browser★5 / json-editor★4 / network-tool★4-5 / cpu-memory-monitor★4 / serial-tool★4-5(补入·嵌入式最高频) / tetris★4-5 / ~~audio-visualizer~~(拉伸·须先做模拟数据发生器)。 @@ -42,8 +42,12 @@ > **[格式 gate]** ✅ status-led 已产 Full 导览 + Handbook(入口+01/02/03+troubleshooting)两套样板(2026-06-14,落 `tutorial/engineering/instances/widget/status-led/`)→ 待作者验格式放量后续 ①✅已完成:status-led 基建化简(降C++17+重命名+STATIC库) + 深度改造(中等档:颜色过渡 QPropertyAnimation + 呼吸 QVariantAnimation + Q_PROPERTY 全 + minimumSizeHint) + 双文档 pilot(成品导览 + 手搓手册 5 文件) → 作者验过格式再放量 -②✅toggle-switch 已落(2026-06-16,代码+构建门+冒烟+Full+Handbook双文档) → 撑场批续:circle→speed-meter→line-chart + image-viewer/sqlite-browser/serial-tool -③覆盖批:password/ip/range-slider/editable-table/checkbox-tree/list/log-viewer/fade + json-editor/network-tool/cpu-mem +②✅toggle-switch 已落(2026-06-16,代码+构建门+冒烟+Full+Handbook双文档) +②b✅自绘链撑场批(2026-06-25·分支 instance/widget-painter-batch):circle-progress+speed-meter+range-slider+line-chart 四件,全 STATIC 库+demo+构建门绿(零warning)+offscreen冒烟+Full导览+Handbook 5文件。Workflow 并行产(speed/range/line),circle-progress 因 429 限流挂→手补。待作者抽审放量 +②c✅model/view 批(2026-06-25·同分支):editable-table(委托校验+数据往返)+checkbox-tree(三态+父子联动) 两件,全 STATIC 库+demo+构建门绿(零warning)+offscreen冒烟+Full+Handbook。Workflow 并行产(本轮无 429)。待作者抽审 +②d✅input/display 批(2026-06-25·同分支):checkbox-list(QListWidget勾选+批量守卫)+log-viewer(QPlainTextEdit级别染色+裁旧)+password-edit(显隐+强度) 三件,全 STATIC 库+demo+构建门绿+offscreen冒烟+Full+Handbook。本批起 code Agent 预 clang-format,行号不漂+提交一把过。待作者抽审 +②e✅widget 收尾批(2026-06-25·同分支):ip-edit(4段跳焦+0-255校验)+fade-animation(QGraphicsOpacityEffect淡入淡出) 两件(P1 也做了),全 STATIC 库+demo+构建门绿+offscreen冒烟+Full+Handbook。**widget 栏 13/13 全部完成**。待作者抽审 +③app/model 放量:json-editor/network-tool/cpu-mem(widget 栏 ⑫⑬ 已完成见②e,widget 栏收齐) ④✅model reference 骨架已落(2026-06-16):undo-redo-framework 库式骨架(`move_command` STATIC + demo) + 三范式已定 → 其余 17 照范式放量 ⑤收尾:tetris;audio-visualizer(须先模拟数据发生器) ⑥industrial pilot:widget 链落地后→hmi-dashboard 成品化(骨架已立,复用契约见下) diff --git a/tutorial/engineering/instances/widget/checkbox-list/handbook/01-skeleton-and-additem.md b/tutorial/engineering/instances/widget/checkbox-list/handbook/01-skeleton-and-additem.md new file mode 100644 index 0000000..06232f5 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-list/handbook/01-skeleton-and-additem.md @@ -0,0 +1,46 @@ +--- +title: "Step 1:组合 QListWidget 骨架 + addItem 装复选框" +description: "继承 QWidget 内含 QListWidget 成员,构造 new 出来 parent=this 托管,addItem 用 setFlags 装复选框、setCheckState 设初值。这步先不接信号。" +--- + +# Step 1:组合 QListWidget 骨架 + addItem 装复选框 + +← [手册首页](./index.md) · 下一步 [Step 2 itemChanged + 状态汇总](./02-itemchanged-and-collect.md) → + +## Step 1:组合 QListWidget 骨架 + addItem 装复选框 + +### 目标 + +屏幕上出现一张**带复选框的扁平清单**,每项前面一个方框,能手动勾上/取消。这一步**先不接 itemChanged**——纯粹是「每项一个独立复选框」的原始状态,勾选互相独立、没有任何联动或转发。信号转发是下一步的事。 + +### 提示 + +- 继承 `QWidget`,加私有成员 `QListWidget* list_`(头里前向声明 `class QListWidget;` 就够,避免头里拖进重头文件) +- 构造里 `list_ = new QListWidget(this)`——`this` 当 parent,交给对象树托管,析构自动回收;顺手 `setUniformItemSizes(true)` 提升绘制性能 +- new 一个 `QVBoxLayout(this)`,`addWidget(list_)`,`setContentsMargins(0,0,0,0)` 去白边,让列表填满本控件 +- 写 `QListWidgetItem* addItem(const QString& text, bool checked = false)`: + - `auto* item = new QListWidgetItem(text, list_)`——构造时把 `list_` 当 parent,挂进列表 + - `item->setFlags(item->flags() | Qt::ItemIsUserCheckable)`——**用或运算加上** `ItemIsUserCheckable`,保留默认的 `ItemIsEnabled|ItemIsSelectable`,别把默认标志全冲掉 + - `item->setCheckState(checked ? Qt::Checked : Qt::Unchecked)` 设初值 +- 这一步**先不连 itemChanged**,纯挂节点。这一步你能看到一张每项带可勾选复选框的清单就算成功 + +### 关键认知 + +- **`ItemIsUserCheckable` 是「显示复选框」的开关**:不加这个标志,`setCheckState` 设了状态也不显示方框——很多新手卡在这里,以为是 `setCheckState` 写错了,其实是 flags 没装。`setFlags` 要用「或上」而非「赋值」,否则会丢掉默认的 enabled/selectable +- **`setCheckState` 会触发 itemChanged**:即便这一步没连槽,它也会发信号——只是没人接。下一步连了槽就要小心初始化期回灌,到时候 `blockSignals` 临时挡一下 + +### 检查点 + +跑出来是一张每项带可勾选复选框的扁平清单,勾哪个是哪个、互不影响 = 骨架对了。这一步不接信号是**正常的**,转发是下一步的事。 + +> QListWidget / QListWidgetItem 不熟?[QListWidget 入门](../../../../../beginner/03-qtwidgets/46-qlistwidget-beginner.md)、进阶 [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。对象树托管机制看 [QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。 + +### 对照答案 + +- 构造 + 布局组合骨架:`src/checkbox_list.cpp:15-22` +- addItem 装复选框(setFlags 或上 ItemIsUserCheckable):`src/checkbox_list.cpp:28-38` +- 头文件前向声明 + 成员声明:`include/checkbox_list.h:17` / `:94` + +--- + +下一步:[Step 2 接 itemChanged 转发 checkedChanged + 状态汇总](./02-itemchanged-and-collect.md)。 diff --git a/tutorial/engineering/instances/widget/checkbox-list/handbook/02-itemchanged-and-collect.md b/tutorial/engineering/instances/widget/checkbox-list/handbook/02-itemchanged-and-collect.md new file mode 100644 index 0000000..0f7dfc1 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-list/handbook/02-itemchanged-and-collect.md @@ -0,0 +1,49 @@ +--- +title: "Step 2:itemChanged 槽 + 状态汇总 checkedTexts/checkedItems" +description: "连 itemChanged,onItemChanged 去重+边界后转发为 checkedChanged;写 checkedTexts/checkedItems 按列表顺序收已勾选项。这一步先把单项转发跑通。" +--- + +# Step 2:itemChanged 槽 + 状态汇总 checkedTexts/checkedItems + +← [Step 1](./01-skeleton-and-additem.md) · [手册首页](./index.md) · 下一步 [Step 3 批量守卫 + 收尾](./03-batch-guard-and-polish.md) → + +这一步把勾选变化转发出去,再补上「把勾选结果收出来」的汇总 API。诀窍在分清两条路:用户点击的勾选要转发,程序化批量改写(下一步才加)要挡住——这一步先把「转发」和「汇总」两件事跑通,批量守卫留到 Step 3。 + +## Step 2:itemChanged 槽 + 状态汇总 + +### 目标 + +连上 `QListWidget::itemChanged`,写 `onItemChanged(QListWidgetItem* item)`:去重 + 空指针边界后,转发为更易用的 `checkedChanged(item, bool)`——外部业务连这个信号就行,不用自己再去读 `checkState`。再写 `checkedTexts()` / `checkedItems()` 两个状态汇总方法,按列表顺序把已勾选项的文本/指针收出来。 + +### 提示(按顺序) + +1. **连信号**:构造里 `connect(list_, &QListWidget::itemChanged, this, &CheckboxList::onItemChanged)`,用函数指针语法别用 SIGNAL/SLOT 宏 +2. **`onItemChanged(QListWidgetItem* item)`**:入口先 `if (item == nullptr) return;`(边界),再 `emit checkedChanged(item, item->checkState() == Qt::Checked)`——把 `Qt::CheckState` 折成 `bool`,外部用起来更顺手 +3. **声明信号**:`signals: void checkedChanged(QListWidgetItem* item, bool checked);` +4. **`QStringList checkedTexts() const`**:先 `list_->count()` 拿总数、`result.reserve(n)` 预分配;`for` 遍历每项,`item != nullptr && item->checkState() == Qt::Checked` 就 `result.append(item->text())`;空列表直接返回空 `QStringList` +5. **`QList checkedItems() const`**:同上,但 append 的是 `item` 指针而非 `text()` +6. **两个方法都要判 `list_ == nullptr`** 早返回空容器——防御性,构造失败时也不崩 + +### 关键认知 + +- **itemChanged 不区分用户点击 vs 程序改写**:这是 Qt Item View 的既定行为。这一步只有 `addItem`(初始化期局部守卫)和用户点击会触发,问题不大;但下一-步加批量方法后,程序化 `setCheckState` 也会走这条链——所以 Step 3 要给批量方法加 `blockSignals` 守卫。这一步先把转发跑通,心里记着「程序化改写也会进来」 +- **汇总按列表顺序遍历,不用 findItems**:`checkedTexts`/`checkedItems` 要保证输出顺序 = 列表显示顺序,所以用索引 `for` 遍历 `list_->item(i)`,别用 `findItems` 或迭代器——顺序和 `reserve` 预分配的简单性最重要 +- **状态用 `==` 比较而非位运算**:本件只用两态,`checkState() == Qt::Checked` 足够;checkbox-tree 才会碰 PartiallyChecked 需要更细的判断 + +### 检查点 + +手动勾几个框,外部连 `checkedChanged` 的槽能收到 `(item, true/false)` = 转发对了;调 `checkedTexts()` 返回的文本列表和你勾的完全一致、顺序也和列表显示顺序一致 = 汇总对了。这一步还没批量方法,`Check all` 按钮做不出来是正常的——下一步补。 + +> 信号槽机制不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)。QListWidget 遍历与状态读取看 [QListWidget 入门](../../../../../beginner/03-qtwidgets/46-qlistwidget-beginner.md)。 + +### 对照答案 + +- itemChanged 连接:`src/checkbox_list.cpp:25` +- onItemChanged 转发 checkedChanged:`src/checkbox_list.cpp:178-184` +- checkedChanged 信号声明:`include/checkbox_list.h:86` +- checkedTexts 顺序汇总:`src/checkbox_list.cpp:109-123` +- checkedItems 顺序汇总:`src/checkbox_list.cpp:125-138` + +--- + +下一步:[Step 3 给批量方法加 blockSignals 守卫 + Q_PROPERTY 收尾](./03-batch-guard-and-polish.md)。 diff --git a/tutorial/engineering/instances/widget/checkbox-list/handbook/03-batch-guard-and-polish.md b/tutorial/engineering/instances/widget/checkbox-list/handbook/03-batch-guard-and-polish.md new file mode 100644 index 0000000..314cc03 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-list/handbook/03-batch-guard-and-polish.md @@ -0,0 +1,53 @@ +--- +title: "Step 3:批量方法 blockSignals 守卫 + Q_PROPERTY 收尾" +description: "checkAll/uncheckAll/invertChecked/addItems 整段 blockSignals 防 itemChanged 回灌雪崩;alternatingRowColors/spacing 两个 Q_PROPERTY 收尾;setItemChecked 单项故意放行。" +--- + +# Step 3:批量方法 blockSignals 守卫 + Q_PROPERTY 收尾 + +← [Step 2](./02-itemchanged-and-collect.md) · [手册首页](./index.md) → + +这一步给控件补上批量操作能力——`checkAll`/`uncheckAll`/`invertChecked` 三键,外加批量 `addItems`。难点不在循环本身,而在「批量 `setCheckState` 每项都回灌 `itemChanged`」这颗雷,整段用 `blockSignals` 守住。再补两个 Q_PROPERTY 收尾。 + +## Step 3:批量方法 blockSignals 守卫 + Q_PROPERTY 收尾 + +### 目标 + +加三个批量方法(全选/全不选/反选)+ 批量 `addItems`,每个都在循环前后整段 `blockSignals(true/false)` 守卫,防 `itemChanged` 回灌成信号洪流;`setItemChecked` 单项故意不守卫允许透传;补 `alternatingRowColors`/`spacing` 两个 Q_PROPERTY,setter 带无变化早返回 + spacing 负值 clamp。 + +### 提示(按顺序) + +1. **`checkAll()` / `uncheckAll()`**:先 `if (list_ == nullptr) return;`,再 `const bool was_blocked = list_->blockSignals(true);` 保存旧值;`for (int i = 0; i < list_->count(); ++i)` 遍历,每项 `item->setCheckState(Qt::Checked)`(或 Unchecked),`item != nullptr` 判空;循环后 `list_->blockSignals(was_blocked)` 恢复 +2. **`invertChecked()`**:同样守卫,但循环里要先读旧态 `const bool on = item->checkState() == Qt::Checked;` 再写 `item->setCheckState(on ? Qt::Unchecked : Qt::Checked);`——读和写都在屏蔽期内完成,否则回灌的 `itemChanged` 会干扰下一项的读 +3. **`addItems(const QStringList& texts)`**:批量初始化版,整段 `blockSignals` 守卫,循环里 `new QListWidgetItem` + `setFlags(ItemIsUserCheckable)` + `setCheckState(Unchecked)` + `addItem` +4. **`setItemChecked(QListWidgetItem* item, Qt::CheckState state)`**:入口 `if (item == nullptr) return;`,然后直接 `item->setCheckState(state)`——**故意不守卫**,允许 `checkedChanged` 透传,外部程序化改单项时就靠它拿通知 +5. **Q_PROPERTY `alternatingRowColors`(bool)**:READ `list_->alternatingRowColors()`、WRITE 里 `if (list_->alternatingRowColors() == enabled) return;` 无变化早返回、再 `setAlternatingRowColors(enabled)` + `emit alternatingRowColorsChanged(enabled)` +6. **Q_PROPERTY `spacing`(int)**:WRITE 入口先 `if (list_ == nullptr || pixels < 0) return;`(负值 clamp),再判无变化早返回,最后 `setSpacing(pixels)` + `emit spacingChanged(pixels)` +7. **`sizeHint()`** override 返回 `{200, 240}`,给布局稳定建议尺寸 + +### 关键认知 + +- **批量 vs 单项的守卫策略正好相反**:批量方法(三键 + addItems)整段守卫、不透传逐项信号;`setItemChecked` 单项故意放行。为什么?批量改写时外部通常不需要逐项通知(按完按钮自己会去读结果),逐项回灌纯粹是噪音 + 性能塌陷;而单项程序化改写时,外部恰恰要靠 `checkedChanged` 知道「这一项被改了」。这是 checkbox-tree 双闸门教训的列表简化版——没有递归就不需要 `is_propagating_` 标志位那道额外闸门 +- **blockSignals 要保存恢复旧值而非硬置 false**:用 `was_blocked` 模式(`const bool was_blocked = list_->blockSignals(true); ...; list_->blockSignals(was_blocked);`)。硬置 false 可能误伤别的信号连接——万一进入前 `list_` 上已有别的被 block 状态,硬清就破坏了调用方的守卫契约 +- **invertChecked 的读旧态必须在屏蔽期内**:反选是「读旧、写反」两步,若不挡信号,写新态触发的 `itemChanged` 回灌会干扰下一次 `checkState()` 的读——读到的可能是被回灌改过的值,反选结果错乱 +- **Q_PROPERTY 无变化早返回是卫生习惯**:外部反复 set 同值,setter 入口先判相等 return,避免发一堆空 NOTIFY。spacing 还多一道负值 clamp,负行距无意义 + +### 检查点 + +按 `Check all`/`Uncheck all`/`Invert selection` 各一次,列表整体翻动平滑无卡顿 = 批量守卫生效;连 `checkedChanged` 的槽在批量操作时**不被**连续刷屏(雪崩挡住了);`setItemChecked(item, Checked)` 单项调用时槽**能**收到通知(单项放行);翻 `alternatingRowColors` 斑马纹显隐 = 属性开关对了;`setSpacing` 传负值被挡掉、传同值不发重复 NOTIFY = clamp + 早返回生效;`checkAll` 后调 `checkedTexts()` 返回全部项 = 批量 + 汇总联动通了。 + +> blockSignals 语义看 [QObject::blockSignals](https://doc.qt.io/qt-6/qobject.html#blockSignals);Q_PROPERTY 机制不熟读 [QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。对照 checkbox-tree 的双闸门升级版:[checkbox-tree 成品导览](../../checkbox-tree/)。 + +### 对照答案 + +- checkAll/uncheckAll 整段守卫:`src/checkbox_list.cpp:63` / `:78` +- invertChecked 读旧态写反态(屏蔽期内):`src/checkbox_list.cpp:92-107` +- addItems 批量初始化守卫:`src/checkbox_list.cpp:41-51` +- setItemChecked 单项不守卫(边界 + 放行):`src/checkbox_list.cpp:53-61` +- alternatingRowColors Q_PROPERTY:`include/checkbox_list.h:35` / setter `src/checkbox_list.cpp:148-157` +- spacing Q_PROPERTY(负值 clamp + 无变化早返回):`include/checkbox_list.h:37` / setter `src/checkbox_list.cpp:163-172` +- sizeHint:`src/checkbox_list.cpp:174-176` + +--- + +成品全部要素都齐了。回到 [手册首页](./index.md) 看进阶挑战(升级成 checkbox-tree 父子联动 / 换 QListView+model / 持久化勾选集),或回 [成品导览](../) 对照完整实现。 diff --git a/tutorial/engineering/instances/widget/checkbox-list/handbook/index.md b/tutorial/engineering/instances/widget/checkbox-list/handbook/index.md new file mode 100644 index 0000000..d1c1f05 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-list/handbook/index.md @@ -0,0 +1,62 @@ +--- +title: "CheckboxList 手搓手册" +description: "从空 main 一行行搓出 CheckboxList:3 步打通组合 QListWidget 骨架、勾选 API 与 itemChanged 转发、批量方法 blockSignals 防信号雪崩。" +--- + +# CheckboxList 手搓手册 + +> **source**:成品答案在 `widget/checkbox-list/`(做完对照)· **related**:model/view 控件递进链(进阶版 [checkbox-tree](../) 做父子联动) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这张勾选清单,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **组合而非继承**:继承 QWidget 内含 QListWidget 成员,构造 new + parent=this 对象树托管,让 view 自己画 +- **QListWidgetItem 两态勾选**:`setFlags(ItemIsUserCheckable)` 装复选框、`setCheckState` 设初值、`checkState` 读态 +- **itemChanged 转发**:用户点击 → 槽函数转发为更易用的 `checkedChanged(item, bool)` +- **blockSignals 防信号雪崩**:批量 `setCheckState` 每项都回灌 `itemChanged`,整段挡信号才能安静地批量改 +- **Q_PROPERTY 暴露开关**:alternatingRowColors / spacing 两个属性可被 Designer 或外部驱动 + +本件是 [checkbox-tree](../) 的扁平简化版——那件要加父子三态联动和双闸门防递归栈溢出,本件没有层级,一道 `blockSignals` 就够,是入门批量守卫的最佳练手件。 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(100, 100); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)、[对象树与所有权](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 组合 QListWidget + addItem 装复选框(先不接信号) | [01](./01-skeleton-and-additem.md) | +| 2 | itemChanged 槽 + 状态汇总 checkedTexts/checkedItems | [02](./02-itemchanged-and-collect.md) | +| 3 | 批量方法 blockSignals 守卫 + Q_PROPERTY 收尾 | [03](./03-batch-guard-and-polish.md) | + +成品对照:`widget/checkbox-list/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **升级成父子联动树**:把本件改成带父子三态联动,就是 [checkbox-tree](../) 的形态。思考:扁平列表加一层 parent/child 关系后,`itemChanged` 槽要补 `propagateDown`(向下传播)和 `recalcUp`(向上回算)两套递归,批量守卫也要升级成 `is_propagating_` 标志位 + `blockSignals` 的双闸门——递归改子项时不挡就会栈溢出。先读 [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。 +- **换 QListView + QStandardItemModel**:QListWidget 是 Item View 的简化封装,项数大了想共享数据就得换 QTreeView+自定义 model(其实扁平就是 QListView)。思考:勾选逻辑从「改 item」挪到「改 model 的 CheckStateRole」,`itemChanged` 换成 `dataChanged` 信号,批量守卫照样用 `blockSignals`。先读 [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。 +- **持久化勾选集**:把 `checkedTexts()` 序列化(按文本而非指针,重启后指针失效),下次启动还原。提示:还原时按文本重新定位 item,再走 `setItemChecked`——注意它故意不守卫会发 `checkedChanged`,还原期要不要临时挡信号避免刷屏要想清楚。 diff --git a/tutorial/engineering/instances/widget/checkbox-list/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/checkbox-list/handbook/troubleshooting.md new file mode 100644 index 0000000..c1f1995 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-list/handbook/troubleshooting.md @@ -0,0 +1,58 @@ +--- +title: "卡住怎么办" +description: "按症状查:批量操作信号刷屏、blockSignals 误伤别的连接、addItem 初始化回灌、invertChecked 反选错乱、setItemChecked 传空崩——给方向指向教程章,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/checkbox-list/`,对照着看。 + +## 按 Check all / 反选后,checkedChanged 槽被刷屏(信号雪崩) + +- 批量方法(`checkAll`/`uncheckAll`/`invertChecked`/`addItems`)里**有没有整段 `blockSignals` 守卫**?批量 `setCheckState` 每项都回灌 `itemChanged` → `onItemChanged` → `checkedChanged`,N 项就是 N 次空转。列表版不崩(无递归),但噪音大、性能塌陷。→ `src/checkbox_list.cpp:68`、`:82`、`:97`、`:43` +- `blockSignals` 是不是**保存旧值、改完恢复**(`was_blocked` 模式)?硬写 false 会误伤别的信号连接。→ `src/checkbox_list.cpp:68` +- `onItemChanged` 入口**有没有判空**?`if (item == nullptr) return;` 防御性兜底,别让空项也走转发。→ `src/checkbox_list.cpp:179` +- 进阶排查:[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)、[QObject::blockSignals](https://doc.qt.io/qt-6/qobject.html#blockSignals) + +## 复选框不显示 / setCheckState 设了但没方框 + +- `addItem` 里**有没有 `setFlags(item->flags() | Qt::ItemIsUserCheckable)`**?不加 `ItemIsUserCheckable`,`setCheckState` 设了状态也不显示方框——经典坑,以为 setCheckState 写错了其实是 flags 没装。→ `src/checkbox_list.cpp:30` +- `setFlags` 是不是用**「或上」**而非「赋值」?`item->setFlags(Qt::ItemIsUserCheckable)` 会把默认的 `ItemIsEnabled|ItemIsSelectable` 全冲掉,项变灰不可选。要用 `item->flags() | Qt::ItemIsUserCheckable`。→ `src/checkbox_list.cpp:30` +- 进阶排查:[Qt::ItemFlag](https://doc.qt.io/qt-6/qt.html#ItemFlag-enum) + +## addItem 预勾选(checked=true)时,外部误收到 checkedChanged + +- `addItem` 初始化 `setCheckState` **有没有局部守卫**?初始化期的 `setCheckState(Qt::Checked)` 也会触发 `itemChanged` → `onItemChanged` → `checkedChanged`,view 不区分程序化还是用户点击,业务会误以为是用户勾选。→ `src/checkbox_list.cpp:33-35` +- 批量版 `addItems` **有没有整段守卫**?同理。→ `src/checkbox_list.cpp:43-50` +- 进阶排查:[QListWidget 入门](../../../../../beginner/03-qtwidgets/46-qlistwidget-beginner.md) + +## 反选(Invert selection)结果错乱 / 部分项没翻对 + +- `invertChecked` 里读旧态 `item->checkState()` 和写反态 `setCheckState(反态)` **是不是都在 `blockSignals` 屏蔽期内**?不挡信号的话,写新态触发的 `itemChanged` 回灌会干扰下一次 `checkState()` 的读——读到的可能已是回灌改过的值。→ `src/checkbox_list.cpp:97-106` +- 循环里**有没有对每项判空**?`item == nullptr` 要 `continue` 跳过。→ `src/checkbox_list.cpp:100-101` + +## setItemChecked 传空指针崩了 / 外部拿不到单项通知 + +- 传 nullptr 崩:`setItemChecked` 入口**有没有 `if (item == nullptr) return;`**?→ `src/checkbox_list.cpp:54` +- 外部程序化改单项却收不到 `checkedChanged`:检查你是不是**误把 `setItemChecked` 也加了 `blockSignals` 守卫**?单项故意**不守卫**,允许 `checkedChanged` 透传——外部就靠它知道某项被改了。批量才守卫。→ `src/checkbox_list.cpp:53-61` +- 和批量守卫对照看:批量挡、单项放,策略相反。→ `src/checkbox_list.cpp:63` vs `:53` + +## Q_PROPERTY 反复 set 同值刷空 NOTIFY / spacing 传负值崩 + +- setter 入口**有没有无变化早返回**?`if (list_->alternatingRowColors() == enabled) return;` / `if (list_->spacing() == pixels) return;`,外部反复 set 同值不发重复 NOTIFY。→ `src/checkbox_list.cpp:152`、`:167` +- `setSpacing` **有没有负值 clamp**?`if (pixels < 0) return;` 挡掉负行距。→ `src/checkbox_list.cpp:164` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) + +## checkedTexts / checkedItems 顺序乱或空列表崩 + +- 汇总是不是**按索引顺序遍历** `list_->item(i)`?要保证输出顺序 = 列表显示顺序,别用 `findItems`。→ `src/checkbox_list.cpp:114-122`、`:130-137` +- 空列表**有没有判 `list_ == nullptr` 早返回空容器**?→ `src/checkbox_list.cpp:111`、`:127` +- 循环里**有没有对每项判空 + 比较状态**?`item != nullptr && item->checkState() == Qt::Checked`。→ `src/checkbox_list.cpp:118`、`:133` + +## moc 报错(Q_PROPERTY 不认识) + +- 头文件**有没有 `Q_OBJECT`**?→ `include/checkbox_list.h:32` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/checkbox-list/index.md b/tutorial/engineering/instances/widget/checkbox-list/index.md new file mode 100644 index 0000000..6521237 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-list/index.md @@ -0,0 +1,172 @@ +--- +title: "CheckboxList 成品导览" +description: "AwesomeQt::CheckboxList 勾选列表成品:封装 QListWidget 做扁平勾选 + 批量操作 + 状态汇总,附架构、设计决策、踩坑与阅读路径。" +--- + +# CheckboxList 成品导览 + +> **source**:`widget/checkbox-list/` **related**:model/view 控件递进链(同档:[checkbox-tree](../checkbox-tree/) 是带父子联动的进阶版)· 教程层 [QListWidget 入门](../../../../beginner/03-qtwidgets/46-qlistwidget-beginner.md) / [Model/View 进阶](../../../../advanced/03-qtwidgets/03-model-view-advanced.md) + +CheckboxList 是一张扁平的勾选清单——每一项前面一个复选框,勾哪个是哪个,互不影响。听起来 `QListWidget` 自带就能干,原生确实给你「每项一个复选框」,可一旦要做「全选/反选」「把勾选的文本收出来」,就撞上同一颗雷:批量改勾选态时,`setCheckState` 每改一项都回灌一次 `itemChanged`,几十上百项下来信号刷成洪流。这份成品把批量操作整段用 `blockSignals` 守住,再封一层「勾选 API + 状态汇总」,开箱即用。 + +它是 [checkbox-tree](../checkbox-tree/) 的简化对照版:那件要做父子三态联动,递归改子项时雷更响(不挡就栈溢出);本件扁平无层级,没有 `propagateDown`/`recalcUp`,雪崩更温和(不崩但噪音大、性能塌陷),解法也就更轻——批量方法整段挡信号即可,不用 `is_propagating_` 标志位那道额外闸门。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::CheckboxList` 控件: + +- **每项带复选框**:`addItem(text, checked=false)` 追加一项并装上复选框,`addItems(texts)` 批量追加 +- **扁平无层级**:勾选互相独立,不做任何父子联动(那是 checkbox-tree 的事) +- **批量操作**:`checkAll()` / `uncheckAll()` / `invertChecked()` 三键搞定,整段 `blockSignals` 守卫 +- **程序化单项**:`setItemChecked(item, state)` 单项改勾选,允许 `checkedChanged` 透传供外部感知 +- **状态汇总**:`checkedTexts()` 取已勾选项的文本(按列表顺序),`checkedItems()` 取项指针 +- **行为开关 Q_PROPERTY**:`alternatingRowColors`(斑马纹)、`spacing`(行距)可在 Designer 或外部直接驱动 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/checkbox-list/demo/checkbox_list_demo +``` + +打开后左侧一张「文件权限」勾选清单(Read / Write / Execute / Delete / Modify permissions / Take ownership / Change attributes),前两项 Read / Write 预先勾上。勾几个框肉眼看,右侧控制面板四按钮 + 一个斑马纹开关:点 `List checked items` 把 `checkedTexts()` 拍出来的勾选文本打到下方只读文本框;`Check all` / `Uncheck all` / `Invert selection` 三键演示批量操作——按下它们你会发现列表项乖乖整体翻动,却没有任何卡顿或刷屏,因为整段改写被 `blockSignals` 罩住了。 + +## 2. 架构总览 + +### 类关系 + +CheckboxList 不自绘,它**组合**一个 QListWidget——构造期 new 出来、`parent=this` 交对象树托管、塞进 QVBoxLayout。本控件只管「勾选 API + 状态汇总」,绘制全交给 view: + +```mermaid +classDiagram + class CheckboxList { + +Q_PROPERTY bool alternatingRowColors + +Q_PROPERTY int spacing + +addItem(text, checked) QListWidgetItem* + +addItems(texts) + +setItemChecked(item, state) + +checkAll() + +uncheckAll() + +invertChecked() + +checkedTexts() QStringList + +checkedItems() QList~QListWidgetItem*~ + -onItemChanged(item) + } + class QListWidget { + 原生勾选框 + itemChanged 信号 + 本控件不重写它的任何虚函数 + } + class QListWidgetItem { + setCheckState(Checked|Unchecked) + ItemIsUserCheckable 标志 + } + CheckboxList o-- QListWidget : list_ + QListWidget "1" o-- "*" QListWidgetItem : 平铺项列表 +``` + +勾选变化靠 `QListWidget::itemChanged` 这一根线驱动:用户点勾选框 → view 发 `itemChanged` → 我们的 `onItemChanged` 去重 + 边界后转发为更易用的 `checkedChanged`。程序化批量改写(`checkAll`/`uncheckAll`/`invertChecked`/`addItems`)不希望逐项回灌,整段被 `blockSignals(true/false)` 罩住,从源头掐断 `itemChanged`。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/checkbox_list.h` | 接口:Q_PROPERTY 两件 + 公有勾选/汇总 API + `onItemChanged` 私有槽声明 | +| `src/checkbox_list.cpp` | 实现:构造组合骨架 + itemChanged 转发 + 批量方法 blockSignals 守卫 + 状态汇总遍历 | +| `demo/checkbox_list_window.cpp` | 演示:文件权限示例项 + 列出勾选 + 全选/全不选/反选 + 斑马纹开关 | + +### 点一下勾选框 vs 批量操作,信号怎么走 + +```mermaid +sequenceDiagram + participant U as 用户/程序 + participant V as QListWidget + participant S as onItemChanged + U->>V: 点某项勾选框(单项) + V->>S: itemChanged(item) + S->>S: item != nullptr 边界 + S->>S: emit checkedChanged(item, on) + Note over S: 单项不守卫,外部靠它感知变化 + U->>V: 调 checkAll()(批量) + V->>V: blockSignals(true) + loop 每一项 + V->>V: item->setCheckState(Checked) + Note over V: itemChanged 被整体挡住,不回灌 + end + V->>V: blockSignals(was_blocked) + Note over S: 批量不发逐项 checkedChanged +``` + +重点在两条路径的对比:单项路径(用户点击、`setItemChecked`)让 `itemChanged` 自由透传到 `onItemChanged` 再 `emit checkedChanged`,外部就靠它知道哪项变了;批量路径(三键 + `addItems`)整段挡信号,几十项一次性改完也不发任何逐项通知——列表版没有 checkbox-tree 的递归,不挡也不会栈溢出,但不挡就是 N 次空转的信号洪流,性能塌陷 + 外部接到一堆噪音。 + +## 3. 关键设计决策 + +**① 组合 QListWidget,不继承也不自绘。** +继承 QWidget、内含一个 `QListWidget* list_` 成员,构造期 `new QListWidget(this)`、`setUniformItemSizes(true)`、塞进 `QVBoxLayout` 且边距清零(`src/checkbox_list.cpp:15-22`)。不重写 paintEvent,让 view 自己画——本控件只做勾选逻辑,QListWidget 的选择/滚动/斑马纹全部白拿。这是 model/view 组合控件的标准定位,和自绘派(status-led)分道扬镳。 + +**② 与 checkbox-tree 严格对照:扁平无层级,砍掉整套联动。** +同源对照件 checkbox-tree 要做父子三态联动,背着 `propagateDown`/`recalcUp`/`aggregateState` 三套递归;本件扁平,这些全部省去,重点落在勾选 API(`addItem`/`setItemChecked`/三键)和状态汇总(`checkedTexts`/`checkedItems`)。一份「勾选清单」该有的能力在这里,不掺它不该有的层级逻辑。 + +**③ 批量方法整段 blockSignals 守卫,单项放行透传。** +`checkAll`/`uncheckAll`/`invertChecked`/`addItems` 四个方法,每个都在进入循环前后 `const bool was_blocked = list_->blockSignals(true); ...; list_->blockSignals(was_blocked);`(`src/checkbox_list.cpp:43`、`:68`、`:82`、`:97`)。用保存旧值、改完恢复的 `was_blocked` 模式而非硬置 false,避免误伤别的信号连接。`setItemChecked` 单项则**故意不守卫**(`src/checkbox_list.cpp:53-61`),允许 `checkedChanged` 透传——外部程序化改某一项时,正好靠它拿到通知。这是 checkbox-tree 双闸门教训的列表简化版:没有递归就不需要 `is_propagating_` 标志位那道额外闸门,一道 `blockSignals` 足够。 + +**④ addItem 用 setFlags 装复选框,setCheckState 初始化时局部守卫。** +`addItem` 里 `new QListWidgetItem(text, list_)` 后 `item->setFlags(item->flags() | Qt::ItemIsUserCheckable)` 装上复选框(保留默认的 `ItemIsEnabled|ItemIsSelectable`,`src/checkbox_list.cpp:28-30`)。紧接着 `setCheckState` 设初值——这一步会触发 `itemChanged`,所以用局部 `blockSignals(true/false)` 守卫防初始化期回灌(`src/checkbox_list.cpp:33-35`)。批量版 `addItems` 则整段守卫一次性初始化(`src/checkbox_list.cpp:43-50`)。 + +**⑤ 两个 Q_PROPERTY 带无变化早返回,spacing 负值 clamp。** +`alternatingRowColors`(bool)和 `spacing`(int)都走 READ/WRITE/NOTIFY 三件套。setter 入口先判「新旧值相等就 return」避免重复发 NOTIFY(`src/checkbox_list.cpp:152`、`:167`)。`setSpacing` 还多一道 `pixels < 0` clamp(`src/checkbox_list.cpp:164`)——负行距无意义,挡掉。这种「无变化早返回」是属性系统的卫生习惯,外部反复 set 同值不会刷一堆空信号。 + +**⑥ sizeHint 返回 200x240,状态汇总用 reserve + 顺序遍历。** +`sizeHint()` 固定返回 `{200, 240}`(`src/checkbox_list.cpp:174`),给布局一个稳定建议尺寸。`checkedTexts`/`checkedItems` 先 `list_->count()` 拿总数,`reserve(n)` 预分配再顺序遍历 append(`src/checkbox_list.cpp:114-122`、`:130-137`),按列表顺序输出、空列表安全返回空容器。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **构造 + 信号连接**(`src/checkbox_list.cpp:15-26`)——先看「内含 QListWidget + 连 itemChanged」这个组合骨架 +2. **`addItem`**(`src/checkbox_list.cpp:28`)——看怎么用 `setFlags` 装复选框 + 初始化 `setCheckState` 的局部守卫 +3. **`addItems`**(`src/checkbox_list.cpp:41`)——批量版,整段 `blockSignals` 守卫,和 `addItem` 的局部守卫对照看 +4. **`checkAll` / `uncheckAll` / `invertChecked`**(`src/checkbox_list.cpp:63` / `:78` / `:92`)——三个批量方法,守卫写法一模一样,`invertChecked` 多一步读旧态 +5. **`setItemChecked`**(`src/checkbox_list.cpp:53`)——单项程序化入口,故意不守卫,和批量方法对照看「为什么单项要放行」 +6. **`checkedTexts` / `checkedItems`**(`src/checkbox_list.cpp:109` / `:125`)——状态汇总遍历 +7. **`onItemChanged`**(`src/checkbox_list.cpp:178`)——itemChanged 转发总入口,仅做空指针边界,转发为 `checkedChanged` +8. **Q_PROPERTY 读写**(`src/checkbox_list.cpp:144-172`)——无变化早返回 + spacing clamp + +入口:`demo/main.cpp` → `demo/checkbox_list_window.cpp` 跑起来,对照读。重点把 `Check all` / `Uncheck all` / `Invert selection` 三键各按一遍,再点 `List checked items` 看 `checkedTexts()` 输出——整个过程平滑无卡顿,就是批量守卫在干活。 + +## 5. 踩坑 + +这几个坑都是实现这个控件时真处理过的,代码里能逐条对上。 + +**坑 1:批量操作不挡信号,itemChanged 刷成洪流** +现象:按一下 `Check all`,列表项整体翻动没错,但外部连着 `checkedChanged` 的槽被连续调用 N 次(N = 列表项数),日志刷屏、性能塌陷。原因:`checkAll`/`uncheckAll`/`invertChecked` 里每调一次 `item->setCheckState`,QListWidget 都发一次 `itemChanged`,`onItemChanged` 又 `emit checkedChanged`——几十上百项就是几十上百次空转。后果是不崩(列表版无递归,不像 checkbox-tree 会栈溢出),但信号噪音巨大、批量改写慢。解法是三个方法整段包 `const bool was_blocked = list_->blockSignals(true); ...; list_->blockSignals(was_blocked);`(`src/checkbox_list.cpp:68`、`:82`、`:97`),从源头掐断 `itemChanged` 回灌。 + +**坑 2:blockSignals 硬置 false,误伤别的信号连接** +现象:批量方法里图省事写 `list_->blockSignals(false)` 收尾,结果后续某个信号连不上了。原因:`blockSignals(true)` 之前 `list_` 上可能已经存在别的被 block 的状态,硬置 false 会把那个状态也清掉,破坏调用方的信号守卫契约。后果是难以排查的信号丢失。解法是 `was_blocked` 模式:先 `const bool was_blocked = list_->blockSignals(true)` 保存旧值,改完 `list_->blockSignals(was_blocked)` 恢复(`src/checkbox_list.cpp:68`)。 + +**坑 3:addItem 初始化 setCheckState 回灌,刚挂的项触发 checkedChanged** +现象:用 `addItem("Read", true)` 预勾选,结果外部接到一个 `checkedChanged` 通知,业务以为用户点了勾选框。原因:`setCheckState(Qt::Checked)` 这一步也会触发 `itemChanged` → `onItemChanged` → `checkedChanged`,和用户点击走同一条信号链,view 不区分「程序化」还是「用户」。后果是初始化阶段被当成用户交互处理,业务逻辑误触发。解法是 `addItem` 内对初始化 `setCheckState` 做局部 `blockSignals(true/false)` 守卫(`src/checkbox_list.cpp:33-35`),批量 `addItems` 同理整段守卫(`src/checkbox_list.cpp:43-50`)。 + +**坑 4:invertChecked 反选时读旧态被自己的写覆盖** +现象:`Invert selection` 按下去,结果部分项没翻成预期状态。原因:反选要先 `item->checkState()` 读旧态再写反态,若整段没挡信号,写新态触发的 `itemChanged` 又回灌进来,干扰下一项的读——读到的可能已经是被回灌改过的值。后果是反选结果错乱。解法是整段 `blockSignals` 守卫(`src/checkbox_list.cpp:97-106`),读旧态和写新态都在屏蔽期内完成,`itemChanged` 不回灌。 + +**坑 5:setItemChecked 传 nullptr 直接崩** +现象:外部传了个空指针进来,控件崩。原因:`setItemChecked(QListWidgetItem* item, ...)` 没判空就 `item->setCheckState`,解引用 null。后果是段错误。解法是入口 `if (item == nullptr) return;`(`src/checkbox_list.cpp:54`),空指针安全返回。`onItemChanged` 同样做空指针边界(`src/checkbox_list.cpp:179`),防御性兜底。 + +## 6. 官方文档 + +- [QListWidget](https://doc.qt.io/qt-6/qlistwidget.html)——被封装的列表视图,itemChanged 信号的来源 +- [QListWidgetItem](https://doc.qt.io/qt-6/qlistwidgetitem.html)——列表项,setCheckState / checkState / setFlags 所在 +- [Qt::CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum)——勾选状态枚举 Checked / Unchecked(本件只用两态,三态 PartiallyChecked 留给 checkbox-tree) +- [Qt::ItemFlag](https://doc.qt.io/qt-6/qt.html#ItemFlag-enum)——`ItemIsUserCheckable` 装复选框、`ItemIsEnabled|ItemIsSelectable` 保留默认可交互 +- [QObject::blockSignals](https://doc.qt.io/qt-6/qobject.html#blockSignals)——批量操作防 itemChanged 回灌的关键闸门 +- [The Property System](https://doc.qt.io/qt-6/properties.html)——alternatingRowColors / spacing 两个 Q_PROPERTY 的机制基础 +- [Model/View Programming](https://doc.qt.io/qt-6/model-view-programming.html)——QListWidget 是 Item View 简化层,想换 QListView+QStandardItemModel 走这里 + +--- + +这套机制(组合 QListWidget + itemChanged 驱动勾选 + 批量方法 blockSignals 守卫 + 状态汇总)不是 CheckboxList 专属——它就是「给带勾选框的扁平列表加批量操作和结果收集」的标准范式。想往上加父子三态联动,就转 [checkbox-tree](../checkbox-tree/) 看那道双闸门怎么升级。想自己搓?[手搓手册](./handbook/)带你从空 main 一行行搓到这个成品,重点啃下批量守卫那道闸门。 diff --git a/tutorial/engineering/instances/widget/checkbox-tree/handbook/01-skeleton-and-additem.md b/tutorial/engineering/instances/widget/checkbox-tree/handbook/01-skeleton-and-additem.md new file mode 100644 index 0000000..d667ad4 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-tree/handbook/01-skeleton-and-additem.md @@ -0,0 +1,38 @@ +--- +title: "Step 1:组合 QTreeWidget 骨架 + addItem 挂节点" +description: "继承 QWidget 内含 QTreeWidget 成员,构造 new 出来 parent=this 托管,addItem 挂带复选框的节点。这步先不联动。" +--- + +# Step 1:组合 QTreeWidget 骨架 + addItem 挂节点 + +← [手册首页](./index.md) · 下一步 [Step 2 父子联动](./02-propagation-and-recalc.md) → + +## Step 1:组合 QTreeWidget 骨架 + addItem 挂节点 + +### 目标 + +屏幕上出现一棵**带复选框的树**,每项前面一个方框,能手动勾上/取消。这步**先不做联动**——勾父不会自动勾子,纯粹是「每项一个独立复选框」的原始状态。联动是下一步的重头戏。 + +### 提示 + +- 继承 `QWidget`,加私有成员 `QTreeWidget* tree_` +- 构造里 `tree_ = new QTreeWidget(this)`——`this` 当 parent,交给对象树托管,析构自动回收 +- `setColumnCount(1)` + `setHeaderHidden(true)` 让它看着像一棵纯勾选树 +- new 一个 `QVBoxLayout(this)`,`addWidget(tree_)`,`setContentsMargins(0,0,0,0)` 去白边 +- 写 `addItem(QTreeWidgetItem* parent, const QString& text, Qt::CheckState state)`:`new QTreeWidgetItem()` → `setText(0, text)` → `setCheckState(0, state)` → `parent==nullptr` 就 `addTopLevelItem`,否则 `parent->addChild` +- 这一步先**不连 itemChanged**,纯挂节点。连了也没槽可接 + +### 检查点 + +跑出来是一棵能展开、每项带可勾选复选框的树,勾哪个是哪个、互不影响 = 骨架对了。勾父项时子项纹丝不动是**正常的**,联动还没写。 + +> QTreeWidget / QTreeWidgetItem 不熟?[Model/View 基础](../../../../../beginner/03-qtwidgets/03-model-view-beginner.md)、进阶 [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。对象树托管机制看 [QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。 + +### 对照答案 + +- 构造 + 布局组合骨架:`src/checkbox_tree.cpp:33-42` +- addItem 挂顶层 vs 挂子项:`src/checkbox_tree.cpp:48-71` + +--- + +下一步是核心:[Step 2 让父子勾选联动起来](./02-propagation-and-recalc.md)。 diff --git a/tutorial/engineering/instances/widget/checkbox-tree/handbook/02-propagation-and-recalc.md b/tutorial/engineering/instances/widget/checkbox-tree/handbook/02-propagation-and-recalc.md new file mode 100644 index 0000000..1472978 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-tree/handbook/02-propagation-and-recalc.md @@ -0,0 +1,52 @@ +--- +title: "Step 2:itemChanged 槽 + 向下传播 + 向上回算(核心)" +description: "连 itemChanged,onItemChanged 里向下传播状态给子孙、从父起逐层向上重算三态。aggregateState 三态推导规则。" +--- + +# Step 2:itemChanged 槽 + 向下传播 + 向上回算(核心) + +← [Step 1](./01-skeleton-and-additem.md) · [手册首页](./index.md) · 下一步 [Step 3 防雪崩闸门 + 收尾](./03-signal-guard-and-polish.md) → + +这一步是整个控件的核心——让父子勾选联动起来。诀窍不在算法本身(向下传播 + 向上回算谁都懂),而在「接到 itemChanged 后干什么、从哪开始算」。 + +## Step 2:itemChanged 槽 + 向下传播 + 向上回算 + +### 目标 + +连上 `QTreeWidget::itemChanged`,写 `onItemChanged(QTreeWidgetItem*, int)`。用户点某项后:向下把该项的 Checked/Unchecked 状态传播给所有子孙;再从**该项的父**起逐层向上重算 tri-state。勾父全选子、子部分勾父变 Partially、子全勾父转 Checked。 + +### 提示(按顺序) + +1. **连信号**:构造里 `connect(tree_, &QTreeWidget::itemChanged, this, &CheckboxTree::onItemChanged)`,用函数指针语法别用 SIGNAL/SLOT 宏 +2. **`propagateDown(QTreeWidgetItem* item, Qt::CheckState state)`**:递归遍历 item 的所有 child,每个 `setCheckState(0, state)`,再对自己递归 `propagateDown(child, state)` 直到叶子 +3. **`aggregateState(const QTreeWidgetItem* item) const`**:遍历 item 的直接子项,数 Checked/Unchecked 计数—— + - 任一子为 PartiallyChecked → 立即返回 PartiallyChecked(短路,别数完) + - 全 Checked → Checked + - 全 Unchecked → Unchecked + - 否则 → PartiallyChecked +4. **`recalcUp(QTreeWidgetItem* item)`**:从 item 起一个 while 循环向上走,每层 `setCheckState(0, aggregateState(当前层))`,然后 `current = current->parent()` 直到 nullptr +5. **`onItemChanged` 串起来**:读 `item->checkState(0)`,若是 Checked/Unchecked 就 `propagateDown(item, state)`,再 `recalcUp(item->parent())` + +### 关键认知 + +- **向下传播只处理 Checked/Unchecked,不处理 PartiallyChecked**:PartiallyChecked 是回算的产物,用户在 QTreeWidget 交互里点不出来(只产生两态),所以 onItemChanged 收到的非 Partially 态才值得传播 +- **recalcUp 从 `item->parent()` 开始,不从 item 本身**:item 的状态已经被用户点击定下了,只有它的祖先需要按子项分布重算。从 item 本身开始会把它刚定好的状态又覆盖一遍 +- **这步先别管信号雪崩**——你能看到勾父时子项联动、父项三态正确就算成功。但大概率你会撞上「勾一下就崩」,那是下一步要解决的 itemChanged 回环问题,先别慌 + +### 检查点 + +勾父项时子项全部跟着勾上 = 向下传播对了;手动把几个子项勾上、父项自动变 PartiallyChecked = 向上回算对了。如果你勾一下就卡死或崩,说明 itemChanged 在递归改子项时被反复触发——**这正是 Step 3 要解决的雪崩**,先去把闸门加上再回来测。 + +> 信号槽机制不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)。三态勾选的 Qt 语义看 [Qt::CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum)。 + +### 对照答案 + +- itemChanged 连接:`src/checkbox_tree.cpp:45` +- onItemChanged 总入口:`src/checkbox_tree.cpp:177` +- propagateDown 递归传播:`src/checkbox_tree.cpp:208` +- aggregateState 三态推导(短路返回混合):`src/checkbox_tree.cpp:235` +- recalcUp 从父起逐层重算:`src/checkbox_tree.cpp:223` + +--- + +下一步:[Step 3 给它加上防雪崩的双闸门 + Q_PROPERTY 联动开关](./03-signal-guard-and-polish.md)。 diff --git a/tutorial/engineering/instances/widget/checkbox-tree/handbook/03-signal-guard-and-polish.md b/tutorial/engineering/instances/widget/checkbox-tree/handbook/03-signal-guard-and-polish.md new file mode 100644 index 0000000..fb5e159 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-tree/handbook/03-signal-guard-and-polish.md @@ -0,0 +1,54 @@ +--- +title: "Step 3:双闸门防信号雪崩 + Q_PROPERTY 联动开关收尾" +description: "is_propagating_ 标志 + blockSignals 守卫双重防 itemChanged 雪崩;propagationEnabled 属性关掉联动退化普通勾选树;API 收尾。" +--- + +# Step 3:双闸门防信号雪崩 + Q_PROPERTY 联动开关收尾 + +← [Step 2](./02-propagation-and-recalc.md) · [手册首页](./index.md) → + +Step 2 你大概率已经撞上「勾一下就崩」——那是 itemChanged 在递归改子项时被反复触发的信号雪崩。这一步把它彻底按住,再给控件加个联动开关收尾。 + +## Step 3:双闸门防信号雪崩 + Q_PROPERTY 收尾 + +### 目标 + +加两道闸门让程序化 setCheckState 不再引发 itemChanged 回环(栈溢出/性能塌陷);再补 `propagationEnabled` 属性,关掉就退化成普通勾选树;最后把 `setItemChecked`/`checkAll`/`uncheckAll`/`checkedItems` 这些 API 补齐。 + +### 提示(按顺序) + +1. **闸门一:成员标志 `is_propagating_`(默认 false)** + - `onItemChanged` 入口先判 `if (is_propagating_) return;`——自己程序化触发的修改直接跳过,不再二次递归 + - 在 `onItemChanged` 真正干活前置 `is_propagating_ = true`,干完置回 `false` +2. **闸门二:`tree_->blockSignals(true/false)` 守卫批量改写** + - `propagateDown`、`recalcUp`、`checkAll` 这些批量 setCheckState 的地方,调用前后包 `blockSignals`,彻底切断信号回环 + - 用「保存旧值、改完恢复」的写法:`const bool was = tree_->blockSignals(true); ...; tree_->blockSignals(was);`,别硬写成 false 把别的信号也永久挡了 +3. **两道闸冗余但都留**:单靠 `is_propagating_` 入口挡,万一某条路径漏置标志就裸奔;单靠 blockSignals 又挡不住「外部直接 setCheckState 后用户再点」的混合触发。两道一起最稳 +4. **Q_PROPERTY `propagationEnabled`**:READ/WRITE/NOTIFY 三件,`setPropagationEnabled` 里先判 `if (propagation_enabled_ == enabled) return;` 去重再 emit +5. **联动关闭分支**:`onItemChanged` 和 `setItemChecked` 开头判 `if (!propagation_enabled_)`,只改自身 + emit signal,短路掉 propagateDown + recalcUp +6. **API 补齐**:`checkedItems()` 从顶层逐棵深度优先收 Checked(写个匿名命名空间的 `collectChecked` 递归辅助,别用 `QTreeWidgetItemIterator` 默认构造——坑见 troubleshooting);`setItemChecked(item, state)` 走完整联动逻辑(非裸 setCheckState);`checkAll/uncheckAll` 顶层逐项置 Checked/Unchecked 再联动 + +### 关键认知 + +- **程序化 setCheckState 一定会再触发 itemChanged**:这是 Qt Item View 的既定行为,不是 bug。你改一个子项 → view 发 itemChanged → 你的槽又被调 → 又改子项……无限递归。不挡必崩 +- **blockSignals 要保存恢复旧值而非硬置 false**:硬写 false 可能误伤别的信号连接,`was_blocked` 模式才安全 +- **is_propagating_ 的边界**:它只在「本控件自己发起的程序化修改」期间为 true,用户点击触发的 itemChanged 进来时它是 false——靠这个区分「谁触发的」。设置时机要精确包住整段批量修改 + +### 检查点 + +快速反复勾选顶层项不崩、不卡 = 双闸门生效;翻 `propagationEnabled` 关掉联动后勾父项子项不动 = 联动开关对了;`checkAll` 后所有项(含深层子孙)全勾、再 `uncheckAll` 全清 = 批量 API 走通;空树调 `checkedItems()` 返回空、`setItemChecked(nullptr, ...)` 不崩 = 边界安全。 + +> blockSignals 语义看 [QObject::blockSignals](https://doc.qt.io/qt-6/qobject.html#blockSignals);Q_PROPERTY 机制不熟读 [QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)、进阶 [属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- is_propagating_ 入口闸门:`src/checkbox_tree.cpp:183`(onItemChanged)/ `:92`(声明) +- blockSignals 守卫批量改写:`src/checkbox_tree.cpp:197`(propagateDown 前)、`:228`(recalcUp 每层)、`:103`(setItemChecked) +- propagationEnabled Q_PROPERTY:`include/checkbox_tree.h:30` / setter `src/checkbox_tree.cpp:155` +- 联动关闭分支:`src/checkbox_tree.cpp:92-99`(setItemChecked)、`:187-190`(onItemChanged) +- collectChecked 匿名命名空间辅助(避开迭代器默认构造):`src/checkbox_tree.cpp:16` +- checkAll/uncheckAll 批量:`src/checkbox_tree.cpp:113` / `:132` + +--- + +成品全部要素都齐了。回到 [手册首页](./index.md) 看进阶挑战(换 QTreeView+model / 惰性联动 / 持久化勾选集),或回 [成品导览](../) 对照完整实现。 diff --git a/tutorial/engineering/instances/widget/checkbox-tree/handbook/index.md b/tutorial/engineering/instances/widget/checkbox-tree/handbook/index.md new file mode 100644 index 0000000..1ff4d9b --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-tree/handbook/index.md @@ -0,0 +1,60 @@ +--- +title: "CheckboxTree 手搓手册" +description: "从空 main 一行行搓出 CheckboxTree:3 步打通组合 QTreeWidget 骨架、itemChanged 父子联动、双闸门防信号雪崩。" +--- + +# CheckboxTree 手搓手册 + +> **source**:成品答案在 `widget/checkbox-tree/`(做完对照)· **related**:model/view 控件递进链 + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这棵勾选树,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **组合而非继承**:继承 QWidget 内含 QTreeWidget 成员,构造 new + parent=this 对象树托管,让 view 自己画 +- **QTreeWidgetItem 三态勾选**:setCheckState / checkState,Checked / Unchecked / PartiallyChecked +- **itemChanged 驱动联动**:用户点击 → 槽函数向下传播状态 + 向上重算三态 +- **blockSignals 防信号雪崩**:程序化 setCheckState 会再次触发 itemChanged,递归改子孙必须挡信号,否则栈溢出 +- **Q_PROPERTY 暴露开关**:propagationEnabled 一个属性在「联动树」和「普通勾选树」之间切换 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(100, 100); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)、[对象树与所有权](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 组合 QTreeWidget + addItem 挂节点(先不联动) | [01](./01-skeleton-and-additem.md) | +| 2 | itemChanged 槽 + 向下传播 + 向上回算(核心) | [02](./02-propagation-and-recalc.md) | +| 3 | 双闸门防雪崩 + Q_PROPERTY 联动开关收尾 | [03](./03-signal-guard-and-polish.md) | + +成品对照:`widget/checkbox-tree/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **改用 QTreeView + QStandardItemModel**:QTreeWidget 是 Item View 的简化封装,模型大了想共享数据就得换 QTreeView + 自定义 model。思考:勾选联动逻辑从「改 item」挪到「改 model 的 CheckStateRole」,itemChanged 换成 dataChanged 信号,传播/回算算法本体不变。先读 [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。 +- **惰性联动**:现在每次勾选都全量向上回算到根。子树很深时可以只在「子树根」记脏标记,真正读取时再回算——拿复杂度换响应。先想清楚:这种优化在「勾选立刻要看到父态变化」的场景下值不值。 +- **持久化勾选集**:把 `checkedItems()` 的结果序列化(按 text 路径而非指针,因为重启后指针失效),下次启动还原。提示:还原时要按路径重新定位 item,再走 `setItemChecked` 触发联动。 diff --git a/tutorial/engineering/instances/widget/checkbox-tree/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/checkbox-tree/handbook/troubleshooting.md new file mode 100644 index 0000000..07f0c62 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-tree/handbook/troubleshooting.md @@ -0,0 +1,56 @@ +--- +title: "卡住怎么办" +description: "按症状查:itemChanged 雪崩栈溢出、QTreeWidgetItemIterator 编译不过、IDE 误报 __or_fn、动态加节点父态不对——给方向指向教程章,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/checkbox-tree/`,对照着看。 + +## 勾一下就崩 / 卡死(itemChanged 雪崩) + +- 递归改子项时**有没有挡 `itemChanged`**?程序化 `setCheckState` 会再触发 itemChanged,槽又调 propagateDown,子子孙孙无限递归到栈溢出。两道闸门:`is_propagating_` 入口挡自身触发(`src/checkbox_tree.cpp:183`),批量改写前后 `tree_->blockSignals(true/false)` 切断回环(`src/checkbox_tree.cpp:197`、`228`)。 +- `is_propagating_` 的**置位/复位有没有精确包住整段批量修改**?漏复位会永久挡住用户触发的联动。→ `src/checkbox_tree.cpp:196-203` +- `blockSignals` 是不是**保存旧值、改完恢复**(`was_blocked` 模式)?硬写 false 可能误伤别的信号连接。→ `src/checkbox_tree.cpp:197` +- 进阶排查:[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)、[QObject::blockSignals](https://doc.qt.io/qt-6/qobject.html#blockSignals) + +## 用 QTreeWidgetItemIterator 写 `it != end` 编译不过 + +- 报 `no matching function for call to 'QTreeWidgetItemIterator::QTreeWidgetItemIterator()'`?Qt6 的 `QTreeWidgetItemIterator` **没有默认构造函数**,造不出 end 哨兵——经典的「以为迭代器都有默认构造」想当然。 +- 改用从 `topLevelItem(i)` 出发的递归辅助函数 `collectChecked`(放匿名命名空间),深度优先收 Checked。顺手去掉 `` 头依赖。→ `src/checkbox_tree.cpp:16` +- 进阶排查:[容器与迭代器](../../../../../beginner/01-qtbase/04-container-beginner.md) + +## collectChecked 上 IDE 飘红(`__or_fn` / `ovl_no_viable_function_in_call`) + +- 这是 IDE/clangd 对 Qt6 `QList` 模板参数推导的**瞬时误报**,不是真实编译错误。 +- 以实际 `cmake --build` 输出为准:编译干净通过、生成 `checkbox_tree_demo` 就是对的,忽略红波浪线,别为假报错动代码。 + +## 勾父项子项没跟着变(联动没生效) + +- itemChanged 信号**连了吗**?构造里 `connect(tree_, &QTreeWidget::itemChanged, this, &CheckboxTree::onItemChanged)`。→ `src/checkbox_tree.cpp:45` +- onItemChanged 里**有没有真的调 propagateDown**?且只在状态为 Checked/Unchecked 时调(PartiallyChecked 是回算产物,不该向下传播)。→ `src/checkbox_tree.cpp:198-200` +- `propagation_enabled_` 是不是被关掉了?关了就是普通勾选树,不联动是预期行为。→ `src/checkbox_tree.cpp:187` + +## 父项三态不对(子全勾父却显示 Partially / 部分勾父却 Checked) + +- `aggregateState` 的**三档判断顺序对不对**?任一子 PartiallyChecked 要**立即短路返回**(`src/checkbox_tree.cpp:256`),不能数完再判断。 +- recalcUp 是不是**从 `item->parent()` 开始**而不是从 item 本身?从 item 本身开始会把它刚定好的状态又按子项分布覆盖一遍。→ `src/checkbox_tree.cpp:202` +- recalcUp 的 while 循环**有没有一直走到根**(`current = current->parent()` 直到 nullptr)?中途断了上层祖先就不重算。→ `src/checkbox_tree.cpp:225-232` + +## 用代码 addItem 加子项后,父项勾选态不更新 + +- addItem 挂子项后**有没有 recalcUp(parent)**?初始化期子项的 setCheckState 被 blockSignals 守卫了(防雪崩),但父态不会自动跟着算,得显式回算一次。→ `src/checkbox_tree.cpp:61` +- 进阶排查:[Model/View 基础](../../../../../beginner/03-qtwidgets/03-model-view-beginner.md) + +## checkAll / uncheckAll 之后深层子孙没跟着变 + +- 批量入口里**有没有对每个顶层项调 propagateDown**?只改顶层 item 的 setCheckState 不会自动传播到深层,得递归写到底。→ `src/checkbox_tree.cpp:123` / `142` +- 批量改写整段**有没有被 blockSignals 守卫 + is_propagating_ 包住**?不挡就崩。→ `src/checkbox_tree.cpp:117-128` + +## moc 报错(Q_PROPERTY 不认识) + +- 头文件**有没有 `Q_OBJECT`**?→ `include/checkbox_tree.h:27` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/checkbox-tree/index.md b/tutorial/engineering/instances/widget/checkbox-tree/index.md new file mode 100644 index 0000000..df81d49 --- /dev/null +++ b/tutorial/engineering/instances/widget/checkbox-tree/index.md @@ -0,0 +1,163 @@ +--- +title: "CheckboxTree 成品导览" +description: "AwesomeQt::CheckboxTree 勾选树成品:封装 QTreeWidget 实现三态勾选 + 父子自动联动,附架构、设计决策、踩坑与阅读路径。" +--- + +# CheckboxTree 成品导览 + +> **source**:`widget/checkbox-tree/` **related**:model/view 控件递进链(同档:status-led / toggle-switch 自绘线)· 教程层 [Model/View 进阶](../../../../advanced/03-qtwidgets/03-model-view-advanced.md) + +CheckboxTree 是一棵会自动联动的勾选树——勾父全选子、子部分勾父变三态、子全勾父转满勾。听起来像 QTreeWidget 自带的功能,其实原生只给你「每项一个复选框」,联动得自己写。难点不在算法(向下传播 + 向上回算谁都懂),而在一个绕不开的雷:`setCheckState` 会再次触发 `itemChanged`,递归改子项时如果不挡信号,链式调用直接雪崩到栈溢出。这份成品把那道闸门做成了双重保险,值得拆开看。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::CheckboxTree` 控件: + +- **每项带复选框**:调用 `addItem(parent, text, state)` 挂节点,`parent=nullptr` 加顶层 +- **父子联动**:勾父向下传播 Checked/Unchecked 给所有子孙;子项一变,从变更节点的父起逐层向上重算三态 +- **三态正确**:子全勾→父 Checked、全不勾→父 Unchecked、部分勾→父 PartiallyChecked +- **联动可关**:`propagationEnabled` 属性一翻,退化成普通勾选树(只改自身不联动) +- **收集结果**:`checkedItems()` 深度优先收所有 Checked(不含 Partially);`checkAll()/uncheckAll()` 一键全选 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/checkbox-tree/demo/checkbox_tree_demo +``` + +demo 里一棵三层示例树(项目 > 模块 > 文件),点勾选肉眼看父子联动 + 三态,「List checked items」按钮把勾选路径打到右侧文本框,外加全选/全不选和联动开关。 + +## 2. 架构总览 + +### 类关系 + +CheckboxTree 不自绘,它**组合**一个 QTreeWidget——构造期 new 出来、`parent=this` 交对象树托管、塞进 QVBoxLayout。本控件只管「数据组织 + 勾选联动逻辑」,绘制全交给 view: + +```mermaid +classDiagram + class CheckboxTree { + +Q_PROPERTY bool propagationEnabled + +Q_PROPERTY int indentation + +addItem(parent, text, state) QTreeWidgetItem* + +checkedItems() QList~QTreeWidgetItem*~ + +setItemChecked(item, state) + +checkAll() + +uncheckAll() + -onItemChanged(item, column) + -propagateDown(item, state) + -recalcUp(item) + -aggregateState(item) Qt::CheckState + } + class QTreeWidget { + 原生勾选框 + itemChanged 信号 + 本控件不重写它的任何虚函数 + } + class QTreeWidgetItem { + setCheckState(0, Checked|Unchecked|PartiallyChecked) + } + CheckboxTree o-- QTreeWidget : tree_ + QTreeWidget "1" o-- "*" QTreeWidgetItem : 父子树 +``` + +联动靠 `QTreeWidget::itemChanged` 这一根线驱动:用户点勾选框 → view 发 `itemChanged` → 我们的 `onItemChanged` 向下传播 + 向上回算。程序化改写(`setItemChecked`/`checkAll`)也走同一套槽,靠 `is_propagating_` 标志区分「谁触发的」。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/checkbox_tree.h` | 接口:Q_PROPERTY 两件 + 公有 API + 三个私有联动辅助函数声明 | +| `src/checkbox_tree.cpp` | 实现:itemChanged 槽 / 向下传播 / 向上回算 / 聚合态算法 / 双闸门防雪崩 | +| `demo/checkbox_tree_window.cpp` | 演示:三层示例树 + 列出勾选 + 全选/全不选 + 联动开关 | + +### 点一下勾选框,联动怎么跑起来 + +```mermaid +sequenceDiagram + participant U as 用户 + participant V as QTreeWidget + participant S as onItemChanged + participant D as propagateDown + participant R as recalcUp + U->>V: 点父项勾选框 + V->>S: itemChanged(父, 0) + S->>S: is_propagating_? 否 → 继续 + S->>V: blockSignals(true) + S->>D: propagateDown(父, Checked) 递归写子孙 + S->>V: blockSignals(false) + S->>R: recalcUp(父.parent()) 逐层重算祖先 + loop 每层祖先 + R->>R: aggregateState 据子项分布推 tri-state + R->>V: blockSignals 守卫下 setCheckState(祖先, agg) + end + S->>S: emit checkStateChanged(父) +``` + +重点:向下传播和向上回算**整段被 `blockSignals(true/false)` 包住**,否则每个被改的子孙都回头发 `itemChanged`,链式递归瞬间失控。 + +## 3. 关键设计决策 + +**① 不重写 paintEvent,组合而非继承 QTreeWidget。** +继承 QWidget、内含一个 `QTreeWidget* tree_` 成员,构造期 `new QTreeWidget(this)`、塞 QVBoxLayout(`src/checkbox_tree.cpp:35-42`)。让 view 自己画,本控件只做联动逻辑——职责干净,且 QTreeWidget 的展开/折叠/滚动/选中全部白拿,不重新发明。代价:暴露给外部只能通过 `treeWidget()` 拿只读访问。 + +**② itemChanged 雪崩用两道闸,双保险防递归栈溢出。** +程序化 `setCheckState` 会再次发射 `itemChanged`,递归改子孙时形成无限递归。解法是双重守卫:成员标志 `is_propagating_`(`include/checkbox_tree.h:92`)在 `onItemChanged` 入口判断,自身触发直接 `return`(`src/checkbox_tree.cpp:183`);外加每次批量 `setCheckState` 前后 `tree_->blockSignals(true/false)` 切断信号回环(`src/checkbox_tree.cpp:197`、`223`)。两道闸冗余但稳——任一道单独失效都能被另一道兜住。 + +**③ 聚合态算法遇 Partially 即短路返回混合。** +`aggregateState`(`src/checkbox_tree.cpp:235`)遍历直接子项统计 Checked/Unchecked 计数,**任一子为 PartiallyChecked 立即返回 PartiallyChecked**(`:256`),不必数完。全 Checked→Checked、全 Unchecked→Unchecked、否则混合。这让多层嵌套的回算效率稳定,深层祖先的重算 O(直接子数) 搞定。 + +**④ recalcUp 从变更节点的父起向上,不从变更节点本身。** +`recalcUp(item->parent())`(`src/checkbox_tree.cpp:202`)——变更节点的状态已由用户点击或 `propagateDown` 确定,只有它的祖先需要重算。从变更节点本身开始会把它刚定好的状态又按子项分布覆盖一遍,逻辑错位。 + +**⑤ propagationEnabled=false 退化成普通勾选树。** +`setItemChecked`/`onItemChanged` 在联动关闭时都短路掉 `propagateDown`+`recalcUp`,只改自身发 signal(`src/checkbox_tree.cpp:92-99`、`:187-190`)。一个属性开关让同一份代码在「联动树」和「普通勾选树」之间切换,不必维护两套实现。 + +**⑥ addItem 加子项时也走一次 recalcUp(parent)。** +动态加节点后立即调 `recalcUp(parent)`(`src/checkbox_tree.cpp:61`),保证用代码往父下塞子项时父态当场正确,不用等用户下一次点击才纠正。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **构造 + 信号连接**(`src/checkbox_tree.cpp:33-46`)——先看「内含 QTreeWidget + 连 itemChanged」这个组合骨架 +2. **`onItemChanged`**(`src/checkbox_tree.cpp:177`)——联动总入口,盯 `is_propagating_` 闸门 + 向下传播 + 向上回算 + 信号守卫 +3. **`propagateDown`**(`src/checkbox_tree.cpp:208`)——递归把状态写到所有子孙,调用方负责守卫 +4. **`aggregateState`**(`src/checkbox_tree.cpp:235`)——聚合态算法核心,三态推导规则 +5. **`recalcUp`**(`src/checkbox_tree.cpp:223`)——从父起逐层向上重算,每层 blockSignals 守卫 +6. **`setItemChecked`**(`src/checkbox_tree.cpp:87`)——程序化入口,和 onItemChanged 对照看双闸门怎么复用 + +入口:`demo/main.cpp` → `demo/checkbox_tree_window.cpp` 跑起来,对照读。 + +## 5. 踩坑 + +这几个坑都是实现这个控件时真处理过的,代码里能逐条对上。 + +**坑 1:递归改子项不挡信号,itemChanged 雪崩到栈溢出** +现象:用户勾一个顶层项,程序卡死或直接崩。原因:`propagateDown` 里每改一个子项的 `setCheckState`,view 都会再发一次 `itemChanged`,而 `onItemChanged` 又会调 `propagateDown`,子子孙孙无穷尽——本控件的核心风险点。后果是栈溢出或性能塌陷。解法是双闸门:`is_propagating_` 在入口挡自身触发(`src/checkbox_tree.cpp:183`),批量改写前后 `tree_->blockSignals(true/false)` 切断信号回环(`src/checkbox_tree.cpp:197`、`228`)。 + +**坑 2:用 QTreeWidgetItemIterator 写 `it != end` 循环,编译不过** +现象:想深度优先遍历全树收勾选项,写了 `const auto end = QTreeWidgetItemIterator()` 当哨兵,报 `no matching function for call to 'QTreeWidgetItemIterator::QTreeWidgetItemIterator()'`。原因:Qt6 的 `QTreeWidgetItemIterator` 没有默认构造函数,造不出 end 哨兵,经典的「以为迭代器都有默认构造」想当然。后果是编译失败。解法是改用从 `topLevelItem(i)` 出发的递归辅助函数 `collectChecked`(放匿名命名空间,`src/checkbox_tree.cpp:16`),彻底绕开迭代器默认构造问题,顺手去掉对 `` 头的依赖。 + +**坑 3:collectChecked 上 IDE 误报 `__or_fn` 无 viable function** +现象:编辑器/clangd 在匿名命名空间的 `collectChecked` 上飘红,提示 `no matching function for call to __or_fn (ovl_no_viable_function_in_call)`,看着像编译错误。原因:IDE 对 Qt6 `QList` 模板参数推导的瞬时误报,不是真实错误。后果其实是零(编译干净通过),但容易被红波浪线带偏去瞎改。解法是以实际 `cmake --build` 输出为准(编译通过、生成 `checkbox_tree_demo`),忽略 IDE 瞬时诊断,别为假报错动代码。 + +**坑 4:动态 addItem 加子项后父态不对** +现象:用代码往一个父下塞几个已勾选的子项,父项却显示 Unchecked,要等用户点一下才纠正。原因:`addItem` 内部对子项的 `setCheckState` 做了 blockSignals 守卫(避免初始化期雪崩),但如果漏了在挂载后回算父态,父就停在旧状态。后果是程序化构建的树初始勾选态视觉错乱。解法是 `addItem` 挂子项后立即 `recalcUp(parent)`(`src/checkbox_tree.cpp:61`),父态当场正确。 + +## 6. 官方文档 + +- [QTreeWidget](https://doc.qt.io/qt-6/qtreewidget.html)——被封装的树视图,itemChanged 信号的来源 +- [QTreeWidgetItem](https://doc.qt.io/qt-6/qtreewidgetitem.html)——树节点,setCheckState / checkState / childCount 所在 +- [Qt::CheckState](https://doc.qt.io/qt-6/qt.html#CheckState-enum)——三态枚举 Checked / Unchecked / PartiallyChecked +- [QObject::blockSignals](https://doc.qt.io/qt-6/qobject.html#blockSignals)——防 itemChanged 雪崩的关键闸门 +- [The Property System](https://doc.qt.io/qt-6/properties.html)——propagationEnabled / indentation 两个 Q_PROPERTY 的机制基础 +- [Model/View Programming](https://doc.qt.io/qt-6/model-view-programming.html)——QTreeWidget 是 Item View 简化层,想换 QTreeView+QStandardItemModel 走这里 + +--- + +这套机制(组合 QTreeWidget + itemChanged 驱动联动 + 双闸门防信号雪崩)不是 CheckboxTree 专属——它就是「给带勾选框的 Item View 加父子联动」的标准范式。任何 QTreeWidget/QListWidget 想做勾选联动都吃这一套。想自己搓?[手搓手册](./handbook/)带你从空 main 一行行搓到这个成品,重点啃下那道防雪崩的闸门。 diff --git a/tutorial/engineering/instances/widget/circle-progress/handbook/01-ring-and-progress.md b/tutorial/engineering/instances/widget/circle-progress/handbook/01-ring-and-progress.md new file mode 100644 index 0000000..ab6cef3 --- /dev/null +++ b/tutorial/engineering/instances/widget/circle-progress/handbook/01-ring-and-progress.md @@ -0,0 +1,64 @@ +--- +title: "Step 1-2:背景环 + 进度弧 + 中心文字" +description: "用 drawArc 画整圈背景环,再画从 12 点钟顺时针铺开的进度弧,中间绘百分比文字。先突变,过渡下一步。" +--- + +# Step 1-2:背景环 + 进度弧 + 中心文字 + +← [手册首页](./index.md) · 下一步 [Step 3-4 平滑过渡](./02-smooth-transition.md) → + +## Step 1:画一个整圈背景环 + +### 目标 + +屏幕上出现一个**浅灰色的圆环**(不是实心圆,是一圈粗线),居中,有粗细。 + +### 提示 + +- 继承 `QWidget`,重写 `protected void paintEvent(QPaintEvent*)` +- `QPainter p(this); p.setRenderHint(QPainter::Antialiasing);` 抗锯齿 +- 算环半径:`r = min(width,height)/2 - 线宽/2 - 2`(留点边距,别贴边) +- 画笔设粗(`pen.setWidthF(10)`)、圆头(`pen.setCapStyle(Qt::RoundCap)`),画笔颜色就是环色 +- `p.setBrush(Qt::NoBrush)`——只描线不填充 +- 一整圈用 `p.drawArc(rect, 0, 360*16)`(整圈 5760,1/16°) + +### 检查点 + +跑出来是个**居中的灰色粗圆环**,缩放窗口环跟着自适应 = drawArc 画整圈对了。 + +> drawArc 不熟?先读 [QPainter 绘图基础](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md)。 + +### 对照答案 + +- 半径算法 + clamp:`src/circle_progress.cpp:165` +- 背景环 drawArc 整圈:`src/circle_progress.cpp:179` + +--- + +## Step 2:进度弧 + 中心百分比文字(先突变) + +### 目标 + +在背景环上叠一段**彩色进度弧**,从 12 点钟顺时针铺开,铺的比例 = value/100。圆环正中间绘百分比文字(如 "60%")。**这步先做突变**——value 直接映射成弧,不要过渡,过渡留到 step 4。 + +### 提示 + +- drawArc 角度体系反直觉:**0°=3 点钟、正值逆时针、单位 1/16°**。12 点钟 = 90° = `90*16` +- 顺时针铺开 = 扫角取**负**。所以进度弧是 `drawArc(rect, 90*16, -span)`,`span = int(progress * 360*16)` +- progress 暂时直接 `= value/100.0`(step 3 才拆成独立属性),先让它能跑 +- 中心文字:`QString::number(int(progress*100)) + "%"`,用 `QFontMetrics` 算宽高,在 `rect.center()` 居中 `drawText` +- value=0 时 progress=0,span=0,弧不画——加个 `if (progress > 0)` 判断,避免画一段 0 长度的弧 + +### 检查点 + +给 value 设 25/50/75/100,弧分别铺 1/4、半圈、3/4、整圈,**从 12 点钟往 3 点钟方向顺时针**,中间文字跟着变 = 角度换算对了。如果弧逆时针铺或从 3 点钟起,回去查扫角正负和起始角(翻 [卡住怎么办](./troubleshooting.md))。 + +### 对照答案 + +- 角度常量 + 约定注释:`src/circle_progress.cpp:22-25` +- 进度弧 drawArc(起始 1440、扫角负):`src/circle_progress.cpp:191` +- 中心文字 drawText:`src/circle_progress.cpp:206` + +--- + +下一步是重头戏:[Step 3-4 把突变弧升级成平滑过渡](./02-smooth-transition.md)。 diff --git a/tutorial/engineering/instances/widget/circle-progress/handbook/02-smooth-transition.md b/tutorial/engineering/instances/widget/circle-progress/handbook/02-smooth-transition.md new file mode 100644 index 0000000..04bee66 --- /dev/null +++ b/tutorial/engineering/instances/widget/circle-progress/handbook/02-smooth-transition.md @@ -0,0 +1,62 @@ +--- +title: "Step 3-4:value/progress 解耦 + 平滑过渡" +description: "把 value(业务值)和 progress(动画产物)拆成两个属性,用 QPropertyAnimation 从当前进度接力到新进度,弧平滑铺开。" +--- + +# Step 3-4:value/progress 解耦 + 平滑过渡 + +← [手册首页](./index.md) · 上一步 [Step 1-2 环与弧](./01-ring-and-progress.md) · 下一步 [Step 5-6 配色与兜底](./03-polish-and-properties.md) → + +## Step 3:把 value 和 progress 拆成两个属性 + +### 目标 + +现在 progress 是临时算的(`value/100.0`)。这一步把它升级成**独立的 Q_PROPERTY**,和 value 分开。两个成员:`value_`(0..100,业务值)和 `progress_`(0..1,动画产物),paintEvent 只认 `progress_`。 + +### 提示 + +- 头文件加两个 Q_PROPERTY:`value`(READ/WRITE/NOTIFY)和 `progress`(READ/WRITE/NOTIFY) +- `progress` 的 WRITE 必须是**纯赋值**的 `setDisplayProgress(double)`:夹到 [0,1] → 赋给 `progress_` → emit → `update()`。**不要**在这里启动画 +- `setValue(int)` 是业务入口:改 `value_`、emit valueChanged,然后(下一步)启动动画 +- 想清楚为什么拆:如果 progress 的 WRITE 指向 setValue,动画驱动 setValue、setValue 又启动画——栈溢出。拆开才安全 + +### 检查点 + +`setDisplayProgress(0.6)` 后弧铺 60%、中间显 "60%";`value()` 和 `progress()` 能分别取到 = 解耦对了。这时候 setValue 还没接动画,setValue 后弧会突变——正常,下一步修。 + +> 属性系统不熟?[Qt 属性系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。 + +### 对照答案 + +- 两个 Q_PROPERTY 声明:`include/circle_progress.h:29-30` +- `value_` / `progress_` 成员:`include/circle_progress.h:79-80` +- setDisplayProgress(纯赋值+emit+update):`src/circle_progress.cpp:77` + +--- + +## Step 4:QPropertyAnimation 让弧平滑铺开 + +### 目标 + +setValue 不再突变,而是 400ms `OutCubic` 把弧从**当前显示进度**接力铺到新进度。 + +### 提示 + +- 成员加 `QPropertyAnimation* progress_anim_`,构造时 `new QPropertyAnimation(this, "progress", this)`——parent=this 对象树托管,**不用 DeleteWhenStopped** +- 构造里设 `setDuration(400)` + `setEasingCurve(QEasingCurve::OutCubic)` +- setValue 里三件套:`progress_anim_->stop()` → `setStartValue(progress_)`(当前显示进度,可能是上次动画中间值)→ `setEndValue(value/100.0)` → `start()` +- 关键是 `setStartValue(progress_)` 用**当前进度**,不是 0、也不是旧 value。这样连点 setValue 弧不会跳回旧目标 +- progress_anim_ 是持久指针,反复 stop()/重配/start() 复用,不每次 new + +### 检查点 + +setValue(60) 后弧花 ~0.4 秒平滑铺到 60%;在动画中途再 setValue(20),弧从**当前中间位置**接力往 20% 收(不跳回 100% 再下来)= 接力逻辑对了。 + +### 对照答案 + +- 动画对象初始化(持久指针、parent=this):`src/circle_progress.cpp:47` +- setValue 的 stop/接力/start 三件套:`src/circle_progress.cpp:55` + +--- + +下一步收尾:[Step 5-6 配色线宽 Q_PROPERTY + 几何 clamp 兜底](./03-polish-and-properties.md)。 diff --git a/tutorial/engineering/instances/widget/circle-progress/handbook/03-polish-and-properties.md b/tutorial/engineering/instances/widget/circle-progress/handbook/03-polish-and-properties.md new file mode 100644 index 0000000..9be5065 --- /dev/null +++ b/tutorial/engineering/instances/widget/circle-progress/handbook/03-polish-and-properties.md @@ -0,0 +1,61 @@ +--- +title: "Step 5-6:配色/线宽 Q_PROPERTY + 几何 clamp 兜底" +description: "把 strokeWidth/progressColor/ringColor/showText 做成 Q_PROPERTY,半径几何 std::max(1.0,...) clamp 兜底,防控件压极小时 drawArc 行为未定义。" +--- + +# Step 5-6:配色/线宽 Q_PROPERTY + 几何 clamp 兜底 + +← [手册首页](./index.md) · 上一步 [Step 3-4 平滑过渡](./02-smooth-transition.md) → + +## Step 5:配色 / 线宽 / 文字开关做成 Q_PROPERTY + +### 目标 + +把 `strokeWidth`、`progressColor`、`ringColor`、`showText` 四个都升级成 Q_PROPERTY,外部能直接 setter 改、能被 Designer / 动画驱动。这样换主题色、调粗细、关文字都不用动控件源码。 + +### 提示 + +- 每个属性一套 READ / WRITE / NOTIFY 三件,setter 里:值变了才动 → 改成员 → `update()` → emit +- `setStrokeWidth` 里夹一下 `std::max(1, width)`,线宽不能 <=0 +- `setShowText(bool)` 关文字后,paintEvent 里 `if (show_text_)` 才画文字 +- 颜色用 `QColor`,Q_PROPERTY 直接支持,不用额外注册 +- setter 里先判 `if (old == new) return;` 去重,避免无变化时白重绘 + 乱发信号 + +### 检查点 + +`setProgressColor(绿)` 弧变绿、`setStrokeWidth(18)` 弧变粗、`setShowText(false)` 中间文字消失 = 四个 Q_PROPERTY 都通。demo 的 Variants 区就是靠这几个 setter 设出来的。 + +### 对照答案 + +- 四个 Q_PROPERTY 声明:`include/circle_progress.h:31-34` +- setStrokeWidth / setProgressColor / setRingColor / setShowText:`src/circle_progress.cpp`(搜对应函数名) + +--- + +## Step 6:几何 clamp 兜底 + +### 目标 + +控件被布局压到极小、或线宽设极大时,半径 `min(w,h)/2 - stroke/2 - 2` 会算成负数或 0。给所有几何尺寸加 `std::max(1.0, ...)` 兜底,保证 drawArc 永远拿到合法矩形。 + +### 提示 + +- 半径算出来后包一层 `std::max(1.0, r)` +- `side = std::max(1, std::min(width(), height()))`——宽高本身也可能极小 +- 线宽 `std::max(1.0, stroke)`,别让线宽比直径还大 +- 文字字号也兜底 `std::max(8.0, side*0.16)`,否则极小控件上字号算成 0 看不见 +- 这是自绘控件的标配收尾:Qt 的 drawArc / drawEllipse 对负宽高矩形行为未定义,不 clamp 极端尺寸下会崩或乱画 + +### 检查点 + +把控件拖到 10×10、线宽拉到 30,弧不崩、不报错、不乱画线 = clamp 兜底到位。 + +### 对照答案 + +- side / 半径 clamp:`src/circle_progress.cpp:165` +- 线宽 clamp:`src/circle_progress.cpp:166` +- 字号 clamp:`src/circle_progress.cpp:199` 附近 + +--- + +到这里 CircleProgress 就搓齐了。回头看:drawArc 画弧 + value/progress 解耦 + QPropertyAnimation 接力 + Q_PROPERTY 收尾 + 几何 clamp——这就是「一个可被动画驱动的自绘控件」的完整范式。下一站 [speed-meter](../../speed-meter/) 会把这套骨架接到指针旋转上,再加刻度,难度又上一档。卡住了翻 [卡住怎么办](./troubleshooting.md)。 diff --git a/tutorial/engineering/instances/widget/circle-progress/handbook/index.md b/tutorial/engineering/instances/widget/circle-progress/handbook/index.md new file mode 100644 index 0000000..75b5c59 --- /dev/null +++ b/tutorial/engineering/instances/widget/circle-progress/handbook/index.md @@ -0,0 +1,62 @@ +--- +title: "CircleProgress 手搓手册" +description: "从空 main 一行行搓出 CircleProgress:6 步打通 drawArc 弧绘制、value/progress 属性解耦、QPropertyAnimation 平滑过渡、几何 clamp 兜底。" +--- + +# CircleProgress 手搓手册 + +> **source**:成品答案在 `widget/circle-progress/`(做完对照)· **related**:自绘控件递进链第 3 环 + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 CircleProgress,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **drawArc 画弧**:1/16° 角度体系、起始角 / 扫角方向、RoundCap 粗弧 +- **业务属性与动画属性解耦**:value(语义)和 progress(绘制)为什么必须分两个 +- **QPropertyAnimation 驱动 double**:进度弧从当前值接力平滑铺开 +- **Q_PROPERTY 全套 + 几何 clamp 兜底**:自绘控件的标准收尾 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(120, 120); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +## 2. 任务清单 + +分 6 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 背景整圈环(drawArc 画一整圈) | [01](./01-ring-and-progress.md) | +| 2 | 进度弧从 12 点钟顺时针铺开 + 中心文字(先突变) | [01](./01-ring-and-progress.md) | +| 3 | value / progress 拆成两个属性 | [02](./02-smooth-transition.md) | +| 4 | QPropertyAnimation 让弧平滑铺开 | [02](./02-smooth-transition.md) | +| 5 | 配色 / 线宽 / 文字开关 Q_PROPERTY | [03](./03-polish-and-properties.md) | +| 6 | 几何 clamp 兜底(压极小时不崩) | [03](./03-polish-and-properties.md) | + +成品对照:`widget/circle-progress/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **进度弧渐变色**:用 `QConicalGradient` 当画笔刷子,让弧从蓝过渡到绿。提示:QPen 可以 setBrush 一个 gradient。 +- **不确定态(加载中转圈)**:加个 `Indeterminate` 模式,弧固定长度、整体旋转(再动画一个起始角属性)。思考:这会跟 progress 动画打架吗?两者写不同变量就正交。 +- **下一站**:speed-meter——把「动画驱动 0..1 进度」换成「动画驱动角度」,再加指针 + 刻度,复用同一套解耦骨架。 diff --git a/tutorial/engineering/instances/widget/circle-progress/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/circle-progress/handbook/troubleshooting.md new file mode 100644 index 0000000..994db99 --- /dev/null +++ b/tutorial/engineering/instances/widget/circle-progress/handbook/troubleshooting.md @@ -0,0 +1,40 @@ +--- +title: 卡住怎么办 +description: CircleProgress 手搓常见错——弧画反、连点跳变、栈溢出、压极小崩——指向成品,不给完整答案 +--- + +# 卡住怎么办 + +按症状找,只指方向。答案在成品 `widget/circle-progress/`。 + +## 弧逆时针铺开 / 从 3 点钟起 + +drawArc 角度体系和直觉相反:0°=3 点钟、正值逆时针、单位 1/16°。你大概把数学角度直接塞进去了。进度弧要「12 点钟起、顺时针」,所以起始角 `90*16`、扫角取**负**。对照 `src/circle_progress.cpp:22` 的约定注释和 `:191` 的 drawArc 调用。 + +## setValue 后弧突变,不平滑 + +progress 的 WRITE 回调 `setDisplayProgress` 里启动了动画,或者根本没接 QPropertyAnimation。setValue 才是启动动画的地方,setDisplayProgress 只管纯赋值+update。对照 `src/circle_progress.cpp:55`(setValue 启动画)和 `:77`(setDisplayProgress 纯赋值)。 + +## 连点 setValue 弧在旧目标和新目标间乱跳 + +动画的 setStartValue 用了 0 或旧 value,没用「当前显示进度」。连点时上次动画还在半路,新动画却从 0 起步,就跳。setStartValue 喂当前 `progress_`(可能是中间值)接力。对照 `src/circle_progress.cpp:55` 的三件套。 + +## 把 progress 的 WRITE 指向 setValue,一跑就栈溢出 + +动画驱动 setValue → setValue 又启动画 → 无限递归。progress 的 WRITE 必须是纯赋值的 setDisplayProgress,不能启动任何动画。对照 `include/circle_progress.h:30` 和 `src/circle_progress.cpp:77`。 + +## 控件拖到极小或线宽拉极大,弧消失 / 崩 + +半径 `min(w,h)/2 - stroke/2 - 2` 算成了负数或 0,drawArc 对负宽高矩形行为未定义。所有几何尺寸包 `std::max(1.0, ...)` 兜底。对照 `src/circle_progress.cpp:165`。 + +## 动画一卡一卡的 + +回调里用了 `repaint()`——它同步立即重绘、不等事件循环,动画自然掉帧。一律 `update()`(异步合并)。对照 `src/circle_progress.cpp` 的 setDisplayProgress。 + +## progress_anim_ 频繁 setValue 后崩 / 悬空 + +动画对象用了 `DeleteWhenStopped`,停了就被 delete,下次 stop() 持的是悬空指针。用持久成员指针 + parent=this 托管,反复 stop()/重配/start() 复用,不用 DeleteWhenStopped。对照 `src/circle_progress.cpp:47`。 + +--- + +实在卡死,成品 `widget/circle-progress/src/circle_progress.cpp` 就是答案——但先自己拼。 diff --git a/tutorial/engineering/instances/widget/circle-progress/index.md b/tutorial/engineering/instances/widget/circle-progress/index.md new file mode 100644 index 0000000..cb315d1 --- /dev/null +++ b/tutorial/engineering/instances/widget/circle-progress/index.md @@ -0,0 +1,135 @@ +--- +title: "CircleProgress 成品导览" +description: "圆形进度环控件成品:背景环 + 12 点钟顺时针进度弧 + 中心百分比文字 + value/progress 解耦的平滑过渡,附架构、设计决策、踩坑与阅读路径。" +--- + +# CircleProgress 成品导览 + +> **source**:`widget/circle-progress/` **related**:自绘控件递进链第 3 环(上一站 toggle-switch · 下一站 speed-meter) + +CircleProgress 是个圆形进度环——下载、安装、任务进度那种「转圈的环 + 中间一个百分比」。它在递进链里刚好接在 toggle-switch 后面:toggle-switch 教的是「一个动画属性 + 鼠标交互」,CircleProgress 教的是「**两个进度属性解耦** + `drawArc` 角度换算」。这套骨架和 status-led / toggle-switch 同源,换的是绘制技术(这次是弧)和动画对象(这次驱动一个 0..1 的 double)。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::CircleProgress` 控件: + +- **value 0..100** 的环形进度,背景整圈环 + 进度弧从 12 点钟顺时针铺开 +- **平滑过渡**:setValue 不是突变,而是 400ms `OutCubic` 把弧从当前进度接力铺到新进度(`QPropertyAnimation` 驱动一个 0..1 的 `progress` 属性) +- **中心百分比文字**:随弧一起动,可关 +- **完整 Q_PROPERTY**:`value` / `progress` / `strokeWidth` / `progressColor` / `ringColor` / `showText` 六个属性全可被动画 / Qt Designer / 外部驱动 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/circle-progress/demo/circle_progress_demo +``` + +## 2. 架构总览 + +### 类关系 + +CircleProgress 自己持一个动画对象,业务态和动画态分两个成员变量: + +```mermaid +classDiagram + class CircleProgress { + +Q_PROPERTY int value + +Q_PROPERTY double progress + +Q_PROPERTY int strokeWidth + +Q_PROPERTY QColor progressColor + +Q_PROPERTY QColor ringColor + +Q_PROPERTY bool showText + +setValue(int) + +setDisplayProgress(double) + -paintEvent(QPaintEvent*) + } + class QPropertyAnimation { + progress 过渡 400ms OutCubic + } + CircleProgress o-- QPropertyAnimation : progress_anim_ +``` + +关键就在 `value_`(业务值,0..100,`include/circle_progress.h:79`)和 `progress_`(动画产物,0..1,`include/circle_progress.h:80`)这两个独立变量。`setValue` 改的是 `value_` 并启动动画,动画每帧写的是 `progress_`,`paintEvent` 按 `progress_` 画弧。两件事写不同地方,所以「连点 setValue」不会让弧在旧目标和新目标之间乱跳。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/circle_progress.h` | 接口:六个 Q_PROPERTY + 公有 API + 成员声明 | +| `src/circle_progress.cpp` | 实现:动画初始化 / value→progress 接力 / drawArc 自绘 | +| `demo/circle_progress_window.cpp` | 演示:静态多档 / 大环+Cycle+Slider / 配色线宽变体 | + +### setValue 到重绘怎么跑 + +```mermaid +sequenceDiagram + participant U as 调用方 + participant C as CircleProgress + participant A as progress_anim_ + participant P as paintEvent + U->>C: setValue(60) + C->>C: value_=60; emit valueChanged + C->>A: stop(); setStartValue(progress_当前); setEndValue(0.6); start() + loop 每帧 + A->>C: setDisplayProgress(插值) + C->>C: progress_=插值; update() + end + C->>P: 合并重绘 + P->>P: drawArc 背景环 + 进度弧(span=progress×5760) + 文字 +``` + +## 3. 关键设计决策 + +**① value 和 progress 拆成两个属性,动画驱动 progress 不驱动 value。** +`value`(`include/circle_progress.h:29`)是业务语义(用户设的 0..100),`progress`(`include/circle_progress.h:30`)才是 `QPropertyAnimation` 每帧写的那个(0..1)。如果让动画直接驱动 value,那 value 的 WRITE 就是 setValue、setValue 又启动画——栈溢出(踩坑③)。拆开后 WRITE 指向 `setDisplayProgress`(纯赋值+emit+update),setValue 只管改 value_ 和发车动画,职责不混。 + +**② 动画对象用持久成员指针,从当前 progress 接力。** +`progress_anim_`(`include/circle_progress.h:86`)在构造时 `new QPropertyAnimation(this, "progress", this)`,parent=this 对象树托管。setValue 里 `stop()/setStartValue(progress_)/setEndValue()/start()`(`src/circle_progress.cpp:55`)——从当前显示进度接力到新目标。不用 `DeleteWhenStopped`,否则频繁 setValue 会反复 new/delete、别处持指针还悬空。 + +**③ 进度弧从 12 点钟顺时针铺开,靠 drawArc 角度换算。** +`QPainter::drawArc` 的角度是 1/16°、0°=3 点钟、正值逆时针(`src/circle_progress.cpp:22` 注释写清)。12 点钟 = 90° → 起始角 1440;顺时针铺开 = 扫角取负。所以进度弧是 `drawArc(arc_rect, 1440, -span)`(`src/circle_progress.cpp:191`),span = progress×5760。这一行是整个控件最容易画反的地方。 + +**④ 几何尺寸全 clamp,防控件被布局压极小时 drawArc 行为未定义。** +半径 `side/2 - stroke/2 - 2` 在控件极小或线宽极大时会算成负/0,`drawArc` 对负宽高矩形行为未定义。所以 `std::max(1.0, ...)` 兜底(`src/circle_progress.cpp:165`)。和 status-led 踩坑⑥同款,自绘控件标配。 + +**⑤ 配色/线宽/文字开关全做 Q_PROPERTY,demo 直接证可用。** +`progressColor` / `ringColor` / `strokeWidth` / `showText` 是真属性,demo 的 Variants 区三个环(绿细 / 橙粗无字 / 紫)就是靠 setter 设出来的,换主题不用改控件源码。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/circle_progress.h` 的 Q_PROPERTY 六件套**(29-34 行)——先看「value 和 progress 是两个属性」 +2. **`setValue`**(`src/circle_progress.cpp:55`)——盯 `stop()+setStartValue(progress_)+start()` 这三行接力 +3. **`setDisplayProgress`**(`src/circle_progress.cpp:77`)——动画每帧回调,纯赋值 + emit + update +4. **`paintEvent`**(`src/circle_progress.cpp:161`)——背景环 drawArc 整圈(179 行)、进度弧 drawArc 带负扫角(191 行)、中心文字(206 行) +5. **角度常量**(`src/circle_progress.cpp:24-25`)——`kStartAngle16=1440`、`kFullCircle16=5760`,配合注释理解 drawArc 角度换算 + +入口:`demo/main.cpp` → `demo/circle_progress_window.cpp` 跑起来,对照读。 + +## 5. 踩坑 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | 进度弧逆时针铺开 / 从 3 点钟起 | drawArc 角度体系(0°=3 点、正值逆时针、1/16°)和直觉相反,直接塞「顺时针 12 点起」会画反 | 视觉错(进度方向不对,非崩溃) | 起始角用 1440(12 点钟),扫角取负(顺时针),换算写注释(`src/circle_progress.cpp:22,191`) | +| ② | 连点 setValue 弧在旧目标/新目标间跳变 | 动画从 0 或旧 value 起步,没从当前显示进度接力 | 视觉跳变(非崩溃) | `setStartValue(progress_)` 从当前显示进度接力(`src/circle_progress.cpp:55`) | +| ③ | 把 progress 的 WRITE 指向 setValue | 动画驱动 setValue → setValue 又启动画 → 无限递归 | **栈溢出** | WRITE 指 `setDisplayProgress`(纯赋值+emit+update),setValue 是业务入口(`src/circle_progress.cpp:77`) | +| ④ | 控件压极小时弧消失 / drawArc 崩 | 半径 `side/2 - stroke/2 - 2` 极小时为负/0 | drawArc 行为未定义 | 半径 `std::max(1.0, ...)` clamp 兜底(`src/circle_progress.cpp:165`) | +| ⑤ | 动画回调里用 repaint() | repaint() 同步立即重绘,不等事件循环 | 动画掉帧 | 一律 `update()`(异步合并,`src/circle_progress.cpp` 的 setDisplayProgress) | + +## 6. 官方文档 + +- [QPainter::drawArc](https://doc.qt.io/qt-6/qpainter.html#drawArc)——画弧(角度 1/16°、方向约定) +- [QPropertyAnimation](https://doc.qt.io/qt-6/qpropertyanimation.html)——属性动画(驱动 progress) +- [Qt 属性系统(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html)——为什么 progress 能被动画驱动 +- [QFontMetrics](https://doc.qt.io/qt-6/qfontmetrics.html)——居中绘制百分比文字 + +--- + +这套机制(业务属性 / 动画属性解耦 + 持久动画指针 + drawArc 自绘)和 status-led、toggle-switch 是同一套范式,换的只是「画什么」。下一站 speed-meter 会把这套骨架接到指针旋转上——动画属性从 0..1 的进度变成角度,再加刻度。想自己搓?[手搓手册](./handbook/)带你从空 main 搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/editable-table/handbook/01-compose-and-columns.md b/tutorial/engineering/instances/widget/editable-table/handbook/01-compose-and-columns.md new file mode 100644 index 0000000..4a29821 --- /dev/null +++ b/tutorial/engineering/instances/widget/editable-table/handbook/01-compose-and-columns.md @@ -0,0 +1,46 @@ +--- +title: "Step 1:组合骨架 + 按列声明类型" +description: "EditableTable 继承 QWidget 组合 QTableWidget,定义 ColumnType 枚举,实现 addColumn / addRow 给每列挂类型、给每行建默认 item。" +--- + +# Step 1:组合骨架 + 按列声明类型 + +← [手册首页](./index.md) · 下一步 [Step 2 委托校验](./02-delegate-and-validation.md) → + +这一步把裸 QTableWidget 包进一个 `AwesomeQt::EditableTable` 类,定义列类型枚举,做出能动态加列、加行的骨架。先不要委托、不要校验——能按类型给每列建出合理默认 item 就行。校验是下一步的事。 + +## Step 1:组合骨架 + ColumnType + addColumn + addRow + +### 目标 + +得到一个 `EditableTable : public QWidget`,构造时 new 出 QTableWidget 挂 parent、塞进 QVBoxLayout。定义 `enum class ColumnType { kText, kInt, kDouble, kCombo, kCheck }` 加 `Q_ENUM`。`addColumn(header, type, min, max, combo)` 往列定义表 `columns_` 追加一条并调 `table_->setColumnCount(+1)`、设表头;`addRow()` 按列类型给每个单元格建出合理默认 item(kCheck 给勾选框、kCombo 给候选项首项、kInt/kDouble 给范围下界、kText 给空串)。 + +### 提示 + +- **组合姿势**:`class EditableTable : public QWidget`,私有成员 `QTableWidget* table_{nullptr}`。构造里 `table_ = new QTableWidget(this)`(parent=this 对象树托管)、`new QVBoxLayout(this)` + `addWidget(table_)`。**别继承 QTableWidget**——那会把一堆不该暴露的 API 放出去,组合才好控边界 +- **列定义表**:私有 `struct ColumnSpec { QString header; ColumnType type; double min; double max; QStringList combo; }`,存进 `QVector columns_`。顺序即列序 +- **addColumn 两步走**:先 `columns_.append(spec)`,再 `setColumnCount(col+1)`,最后 `setHorizontalHeaderItem(col, new QTableWidgetItem(header))`——QTableWidget 要求先有列才能设表头 item +- **addRow 按类型建 item**:kCheck 走 `applyCheckState(row,col,Unchecked)`(给 item 打 `Qt::ItemIsUserCheckable` flag + `setCheckState`);kCombo 建候选项首项;kInt/kDouble 建范围下界(`min<=max` 时取 min);kText 建空串。`setRowCount(row+1)` 后逐列建 item +- **勾选列的 flag**:`item->setFlags(item->flags() | Qt::ItemIsUserCheckable)`,再 `setCheckState(state)`。这一步先不管它怎么触发 dataEdited(step 3 再连 cellChanged) + +### 关键认知——为什么组合不继承 + +继承 QTableWidget 看着省事(直接拥有所有方法),但你会把 `setItem` / `setRowCount` / `setItemDelegate` 这堆内部 API 全暴露给外部,封装形同虚设。组合后 table_ 是私有的,外部只能走你批准的接口(addColumn / addRow / setData / data),要拿 QTableWidget 的方法就加薄透传——边界牢牢攥在自己手里。这也是本批 model/view 控件的统一姿势。 + +### 检查点 + +跑起来出现一张能动态加列加行的表:调 `addColumn("Name", kText)` + `addColumn("Score", kInt, 0, 100)` + `addRow()` 后,表头显示 Name/Score,新行 Score 列默认值是 0(范围下界),勾选列出现可点的复选框。列宽/布局正常 = 组合对了。 + +> QTableWidget / 列行管理不熟?[QTableWidget 入门](../../../../../beginner/03-qtwidgets/50-qtablewidget-beginner.md)。组合控件思路?[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +### 对照答案 + +- 类定义 + ColumnType 枚举 + Q_ENUM:`include/editable_table.h:74` / `include/editable_table.h:85` +- 构造组合 QTableWidget + 布局:`src/editable_table.cpp:151` +- addColumn 追加列定义 + 设表头:`src/editable_table.cpp:186` +- applyCheckState 给勾选列打 flag:`src/editable_table.cpp:204` +- addRow 按类型建默认 item:`src/editable_table.cpp:215` + +--- + +下一步是重头戏:[Step 2 挂上委托做编辑校验](./02-delegate-and-validation.md)。 diff --git a/tutorial/engineering/instances/widget/editable-table/handbook/02-delegate-and-validation.md b/tutorial/engineering/instances/widget/editable-table/handbook/02-delegate-and-validation.md new file mode 100644 index 0000000..d9c9c16 --- /dev/null +++ b/tutorial/engineering/instances/widget/editable-table/handbook/02-delegate-and-validation.md @@ -0,0 +1,50 @@ +--- +title: "Step 2:委托校验(核心)" +description: "实现 detail::ValidatorDelegate,重写 createEditor / setEditorData / setModelData,按列类型挑编辑器、空值兜底、提交阶段直接写——委托的校验前置套路。" +--- + +# Step 2:委托校验(核心) + +← [Step 1](./01-compose-and-columns.md) · [手册首页](./index.md) · 下一步 [Step 3 整表数据往返](./03-data-roundtrip.md) → + +这一步是整个控件的核心——挂上一个自定义委托,让编辑时按列类型弹对应编辑器、数值列自动夹值、空值兜底。诀窍不在委托本身的四个 override(那是 Qt 套路),而在**用回调把列规格喂给委托**,让 detail 层既拿到规格又不依赖父表类型。 + +## Step 2:detail::ValidatorDelegate + createEditor / setEditorData / setModelData + +### 目标 + +做出一个 `AwesomeQt::detail::ValidatorDelegate : public QStyledItemDelegate`(带 Q_OBJECT)。它通过一个 `ColumnSpecProvider` 回调从父表拉某列的 `{type, min, max, combo}` 规格——`createEditor` 据此挑编辑器(kInt→QSpinBox 设范围、kDouble→QDoubleSpinBox、kCombo→QComboBox 填候选项、kCheck→返回 nullptr 不弹编辑器、kText→QLineEdit);`setEditorData` 把当前格的值喂进编辑器,**数值列空值兜底成 `box->minimum()`**、kCombo 用 `findText` 回库首项;`setModelData` 提交时直接写 `box->value()` / `combo->currentText()`,不再二次判空。EditableTable 构造时 new 出委托、注入回调、`table_->setItemDelegate(delegate_)`。 + +### 提示(按顺序) + +1. **委托放 detail 子命名空间**:`namespace AwesomeQt::detail { class ValidatorDelegate : public QStyledItemDelegate { Q_OBJECT ... }; }`。Q_OBJECT 类要进 CMake 的源列表(AUTOMOC 已开),否则 moc 不生成元对象 +2. **回调类型定义**:`using ColumnSpecProvider = std::function;`。返回 false 表示该列无规格、退化为文本。注入用 `setColumnSpecProvider(provider)` +3. **createEditor 的 switch**:用 `columnTypeToInt(ColumnType)` 折成 int 做分支(detail 层不依赖枚举)。kInt 分支里 `box->setRange((int)min, (int)max)`——范围用 double 存、整型列截断成 int;kCheck 直接 `return nullptr`(勾选不走委托编辑器) +4. **setEditorData 兜底**:`qobject_cast` 命中后,`value.toInt(&ok)`,`ok ? parsed : box->minimum()`——**空值落回最小值**,不留空。kDouble 同理。kCombo 用 `findText(value)`,找不到回 index 0 +5. **setModelData 只管写**:`qobject_cast` 命中后直接 `model->setData(index, box->value(), Qt::EditRole)`——**不判空**。因为 setEditorData 已把空值兜成最小值,SpinBox 又自带夹值,`box->value()` 必然合法 +6. **EditableTable 注入回调**:构造里 `delegate_ = new detail::ValidatorDelegate(this)`,`delegate_->setColumnSpecProvider([this](int column, ...){ ... })`——lambda 里查 `columns_` 返回规格。最后 `table_->setItemDelegate(delegate_)` +7. **别忘了 updateEditorGeometry**:`editor->setGeometry(option.rect)`,否则编辑器位置不对 + +### 关键认知——为什么校验前置、为什么用回调 + +**校验前置**:直觉是在 setModelData 判空(提交时拦)。但 QSpinBox / QDoubleSpinBox 本身就有 `[min,max]` 夹值——只要进编辑器时空值被兜成最小值,提交时 `value()` 必然在区间内,setModelData 直接写即可,不用再判。校验点收敛到 setEditorData 一处,逻辑更薄、更不容易漏分支。kCombo 同理靠 `findText` 回库兜底。 + +**用回调而非持父表指针**:委托要知道列规格,最直接的写法是 `EditableTable* owner_` 然后调它的 getter。但这会让 detail 层 include EditableTable 的完整定义、形成「父表持委托、委托持父表」的环。改成吃一个 `std::function` 回调,detail 层只依赖 `int/double/QStringList` 这几个基本类型,连 ColumnType 枚举都不用认识(经 `columnTypeToInt` 折成 int 透传)。耦合降到最低。 + +### 检查点 + +双击 Score 列(kInt 0-100)单元格:弹出 QSpinBox(带上下箭头),输 `9999` 回车后格内值变 100(夹值);清空编辑器再提交,格内值变 0(空值兜底成最小值)。Color 列(kCombo 红/绿/蓝)弹出下拉只能选这三项。Active 列(kCheck)双击**不弹**编辑器,直接点复选框勾选 = 委托分发对了。 + +> 委托机制不熟?[Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。委托 API 细节?[QStyledItemDelegate](https://doc.qt.io/qt-6/qstyleditemdelegate.html)。 + +### 对照答案 + +- ValidatorDelegate 声明 + ColumnSpecProvider 回调类型:`include/editable_table.h:27` / `include/editable_table.h:34` +- createEditor 按列挑编辑器:`src/editable_table.cpp:41` +- setEditorData 空值兜底(kInt/kDouble 落最小值、kCombo findText):`src/editable_table.cpp:80` +- setModelData 直接写不判空:`src/editable_table.cpp:110` +- 构造注入回调 + setItemDelegate:`src/editable_table.cpp:158` / `src/editable_table.cpp:171` + +--- + +下一步:[Step 3 整表数据往返 + Q_PROPERTY 行为开关](./03-data-roundtrip.md)。 diff --git a/tutorial/engineering/instances/widget/editable-table/handbook/03-data-roundtrip.md b/tutorial/engineering/instances/widget/editable-table/handbook/03-data-roundtrip.md new file mode 100644 index 0000000..b5f8696 --- /dev/null +++ b/tutorial/engineering/instances/widget/editable-table/handbook/03-data-roundtrip.md @@ -0,0 +1,60 @@ +--- +title: "Step 3:整表数据往返 + Q_PROPERTY + suppress 去重" +description: "setData 按列类型夹值/兜底回填、data() 按列类型还原 QVariant;补 editable/gridVisible/alternatingRowColors 三个 Q_PROPERTY;用 suppress_signal_ 屏蔽程序化回灌。" +--- + +# Step 3:整表数据往返 + Q_PROPERTY + suppress 去重 + +← [Step 2](./02-delegate-and-validation.md) · [手册首页](./index.md) → + +前两步搭好了「能编辑、能校验」的表。这一步把它变成「能整表存取」的录入控件:外部一次塞一批数据进来、一次取一批出去,中间夹值/兜底全自动。再补三个行为开关 Q_PROPERTY,处理好「程序化填值不该发假编辑通知」的去重。搓完这三件,控件就生产可用了。 + +## Step 3:setData / data() / Q_PROPERTY / suppress_signal_ + +### 目标 + +实现四个东西: + +1. **`setData(QVector>)`**:整表回填。清行留列,按列类型逐格转换——kInt 用 `std::clamp` 夹到 `[min,max]`、kDouble 同理、kCombo 不在候选项回库首项、kCheck 还原 `Qt::Checked/Unchecked`、kText 直写。行数据短于列数时补空占位 item +2. **`data()`**:取回整表,按列类型还原 QVariant——kCheck→bool、kInt/kDouble 转换失败给 `QVariant()`、其余→文本。空表/无列返回空向量 +3. **Q_PROPERTY 三件**:`editable` / `gridVisible` / `alternatingRowColors`,透传到 QTableWidget。setEditable 切 `setEditTriggers(NoEditTriggers vs DoubleClicked|SelectedClicked|...)` +4. **suppress_signal_ 去重**:`cellChanged` 连到私有 `onCellChanged`,里面查 suppress 标志——setData/addRow/clear 程序化改动置 true 不回灌,只有用户真实编辑才 emit `dataEdited` + +### 提示(按顺序) + +1. **setData 入口置 suppress**:`suppress_signal_ = true; table_->setRowCount(0);`(清行留列)。没列直接 return。然后 `setRowCount(rows.size())` 逐行逐列转换填入,结尾 `suppress_signal_ = false` +2. **每列转换分支**:kInt `v.toInt(&ok)` 失败取 min,再 `std::clamp(parsed, (int)min, (int)max)`;kDouble 同理(注意 lo/hi 用 `std::min/max(min,max)` 防 min>max);kCombo `v.toString()` 不在 combo 里就取 `combo.first()`;kCheck `v.typeId()==Bool ? toBool() : toInt()!=0` 还原。kText 直 `v.toString()` +3. **短行补占位**:填完一行后,对 `c = row_data.size()` 到 `col_count` 的列补 `new QTableWidgetItem(QString())`,否则 `table_->item(r,c)` 返回 null +4. **data() 出口兜底**:每格先 `item ? item->text() : QString()`。kCheck 取 `item ? item->checkState() : Unchecked` 还原 bool。kInt/kDouble `toInt(&ok)` 失败 append `QVariant()`(不是 0——区分「非法」和「真 0」) +5. **Q_PROPERTY 透传 + 提前 return 防抖**:setEditable 里 `if (editable_ == editable) return;`(属性去抖),再 `table_->setEditTriggers(...)`、`emit editableChanged`。gridVisible / alternatingRowColors 同理透传 `setShowGrid` / `setAlternatingRowColors` +6. **cellChanged 连线**:`connect(table_, &QTableWidget::cellChanged, this, &EditableTable::onCellChanged)`。onCellChanged 入口:先 `if (suppress_signal_) return;`,再做 row/col 越界 + item null 检查,最后按列类型取值(kCheck 取 checkState,其余取 text)emit `dataEdited(row, col, value)` +7. **薄透传 getter**:补 `currentRow()`(透传 `table_->currentRow()`)和 `resizeColumnsToContents()`(透传同名),给 demo 用——别暴露 table_ 指针 + +### 关键认知——为什么 suppress 必须有 + +`setData` / `addRow` / `clear` 都是程序化建项,每一次 `setItem` 都会触发 `cellChanged`。如果你把 cellChanged 直接连到 `dataEdited` 信号,外部刚 `setData` 一批,立刻接到 N×M 条「假编辑」通知,值还是刚填进去的——业务逻辑被无意义信号刷爆。`suppress_signal_` 就是堵这个洞:程序化填值前置位、后清位,`onCellChanged` 入口先查它,只让 suppress 为 false(用户真实双击编辑)的改动走 emit。这是组合控件配合 view 信号的经典去重套路,凡是「程序化改 model 又监听 model 变化」的场景都要这么干。 + +### 检查点 + +- `setData` 传 `{"Bob", 120, 1.5, "Yellow", false}`:表格显示分数 100、比率 1.0、颜色 Red(回库首项)= 夹值/兜底对了 +- `data()` 拍回来是二维 `QVariant` 向量,kCheck 列是 bool、kInt/kDouble 列是数值 = 出口还原对了 +- 勾上 editable 复选框→表可双击编辑,取消→只读 = Q_PROPERTY 透传对了 +- `setData` 后**没有**接到一堆 dataEdited 假通知;双击单元格改一个值→收到一条 dataEdited = suppress 去重对了 + +> 信号槽 / 去重机制不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)。Q_PROPERTY?[属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- setData 整表回填 + clamp/兜底:`src/editable_table.cpp:261` +- data() 按列类型还原:`src/editable_table.cpp:334` +- 短行补占位 item:`src/editable_table.cpp:327` +- setEditable 切 EditTriggers + 防抖:`src/editable_table.cpp:397` +- cellChanged 连线:`src/editable_table.cpp:174` +- onCellChanged suppress 去重 + 边界 clamp:`src/editable_table.cpp:444` +- 薄透传 currentRow / resizeColumnsToContents:`src/editable_table.cpp:389` / `src/editable_table.cpp:393` + +--- + +搓完了。跑 demo 对照成品:预填的 Bob 超界行被夹值、双击输 9999 被夹回 100、Print Data 拍出来的二维数据按列类型正确还原、只读切换生效 = 你搓的和 repo 一致。 + +想再深?回 [手册首页](./index.md) 看进阶挑战(勾选改三态 / 委托加自定义校验器 / 换自定义 model 后端 / 下一站带过滤排序的高级表格)。 diff --git a/tutorial/engineering/instances/widget/editable-table/handbook/index.md b/tutorial/engineering/instances/widget/editable-table/handbook/index.md new file mode 100644 index 0000000..b531e88 --- /dev/null +++ b/tutorial/engineering/instances/widget/editable-table/handbook/index.md @@ -0,0 +1,68 @@ +--- +title: "EditableTable 手搓手册" +description: "从空壳 QTableWidget 一行行搓出 EditableTable:3 步打通组合控件骨架、按列声明类型 + 委托校验、整表数据往返 + Q_PROPERTY 行为开关。" +--- + +# EditableTable 手搓手册 + +> **source**:成品答案在 `widget/editable-table/`(做完对照)· **related**:model/view 组合控件递进链第 1 环 · 教程层 [QTableWidget 入门](../../../../../beginner/03-qtwidgets/50-qtablewidget-beginner.md) / [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 EditableTable,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **组合而非继承**:把 QTableWidget 当私有成员挂进 QWidget,不重写 paintEvent——model/view 控件的正确组装姿势 +- **自定义委托(QStyledItemDelegate)**:重写 createEditor / setEditorData / setModelData,按列类型分发编辑器并做校验(step 2 重头) +- **按列声明类型 + 委托回调取规格**:用 `std::function` 把列定义喂给委托,避免环引用 +- **整表数据往返**:setData 回填按列类型夹值/兜底,data() 取回按列类型还原 QVariant(step 3) +- **Q_PROPERTY 行为开关 + suppress 去重**:editable 等属性透传 view、cellChanged 程序化回灌的屏蔽 + +## 1. 起点 + +先有个能跑的空壳:一个 QWidget 里塞个空的 QTableWidget。main 里弹窗: + +```cpp +#include +#include +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + auto* layout = new QVBoxLayout(&w); + auto* table = new QTableWidget(&w); + table->setColumnCount(3); + table->setRowCount(2); + layout->addWidget(table); + w.resize(400, 300); + w.show(); + return app.exec(); +} +``` + +弹出一张能双击编辑的空表 = 环境通了,往下走。这一步用的是裸 QTableWidget,还没封装——下一步我们就把它包进 EditableTable 类。QTableWidget 不熟先看 [QTableWidget 入门](../../../../../beginner/03-qtwidgets/50-qtablewidget-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 组合骨架 + 按列声明类型(ColumnType + addColumn + addRow) | [01](./01-compose-and-columns.md) | +| 2 | 委托校验(ValidatorDelegate 按列挑编辑器 + 空值兜底) | [02](./02-delegate-and-validation.md) | +| 3 | 整表数据往返 + Q_PROPERTY 行为开关 + suppress 去重 | [03](./03-data-roundtrip.md) | + +成品对照:`widget/editable-table/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **勾选列改成三态**:现在 kCheck 是二值 `Checked/Unchecked`。想支持 `PartiallyChecked`(半选,表示子项部分勾选),要改 `data()` 出口还原逻辑 + addRow 默认态。思考:半选在整表数据往返里怎么表达?用 `int` 而非 `bool` 吗? +- **委托加自定义校验器**:现在数值列靠 QSpinBox 内置夹值。想要更复杂的规则(比如「分数必须 5 的倍数」),可以在 setModelData 里对 `box->value()` 二次校验,不合法则不写入或恢复旧值。提示:用 `model->data(index)` 取旧值做恢复。 +- **换成自定义模型后端**:QTableWidget 内部是预设的 model + view 二合一。把它换成 `QTableView` + 自定义 `QAbstractTableModel`,委托这层校验逻辑能原样复用——这是从「便捷控件」走向「真正的 model/view」的关键一跳。参考 [Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md)。 +- **下一站**:带过滤/排序的高级表格控件——复用本件的委托,但在 model 层加 `QSortFilterProxyModel`。 diff --git a/tutorial/engineering/instances/widget/editable-table/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/editable-table/handbook/troubleshooting.md new file mode 100644 index 0000000..7fa0705 --- /dev/null +++ b/tutorial/engineering/instances/widget/editable-table/handbook/troubleshooting.md @@ -0,0 +1,55 @@ +--- +title: "卡住怎么办" +description: "按症状查:cpp 漏引 QVBoxLayout、demo 调不到 QTableWidget 方法、委托不生效、setData 回灌假信号、空值写进模型、短行崩——给方向指向成品 file:行号,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/editable-table/`,对照着看。 + +## cpp 编译报 `expected type-specifier before 'QVBoxLayout'` + +- 构造里 `new QVBoxLayout(this)`,但 cpp **只引了 `QTableWidget` / `QTableWidgetItem`,漏了 ``**。Q_OBJECT 类的 cpp 不像头文件那样顺手引布局类,新加布局代码要单独引。→ `src/editable_table.cpp:16` +- 同理检查 `QSpinBox` / `QDoubleSpinBox` / `QComboBox` / `QLineEdit` 这些编辑器头在 cpp 里都引了吗?createEditor 里 new 它们就得 include。→ `src/editable_table.cpp:10-14` +- 进阶排查:[QTableWidget 入门](../../../../../beginner/03-qtwidgets/50-qtablewidget-beginner.md) + +## demo 编译报 `'class AwesomeQt::EditableTable' has no member named 'resizeColumnsToContents'` + +- `resizeColumnsToContents()` 是 **QTableWidget** 的方法,EditableTable 把 `table_` 私有化了,外部直接调不到。→ 加薄透传 `void resizeColumnsToContents()` 调 `table_->resizeColumnsToContents()`:`src/editable_table.cpp:393` +- 同理 demo 想拿当前选中行却写了不存在的 `currentRowOfTable()` 之类笔误方法名——EditableTable 没有「取当前行」接口。→ 加公有 `int currentRow() const` 透传 `table_->currentRow()`:`src/editable_table.cpp:389` +- 别图省事把 `table_` 指针 public 出去——那等于放弃封装,以后所有 QTableWidget API 都会从外部漏进来 + +## 双击单元格不弹自定义编辑器(还是默认文本框) + +- 委托**真的 `setItemDelegate` 挂上了**吗?构造里漏了 `table_->setItemDelegate(delegate_)`。→ `src/editable_table.cpp:171` +- 委托的 `ColumnSpecProvider` 回调**注入了吗**?没注入 createEditor 拉不到列规格、退化成文本。→ `src/editable_table.cpp:159` +- 回调里 `column` 越界返回 false 了?检查 `columns_` 的下标边界。→ `src/editable_table.cpp:161` +- 委托类是 Q_OBJECT 但 CMake 源列表里**没把它列进去**?AUTOMOC 要在源列表里看到它才生成元对象。→ `widget/editable-table/CMakeLists.txt` +- 进阶排查:[Model/View 进阶](../../../../../advanced/03-qtwidgets/03-model-view-advanced.md) + +## 数值列编辑后存了空串 / 非法值 + +- 你是不是在 setModelData 里判空写漏了分支?正确姿势是**校验前置**:setEditorData 把空值兜成 `box->minimum()`,setModelData 只管写 `box->value()`(SpinBox 自带夹值)。→ `src/editable_table.cpp:90` / `src/editable_table.cpp:118` +- 双击输 `9999` 提交后格内没夹回 100?检查 createEditor 里 kInt 分支有没有 `box->setRange((int)min, (int)max)`。→ `src/editable_table.cpp:55` +- 进阶排查:[QSpinBox](https://doc.qt.io/qt-6/qspinbox.html) + +## setData 后外部接到一堆 dataEdited 假通知 + +- 这是**程序化 `setItem` 触发 cellChanged** 被 onCellChanged 当用户编辑 emit 出去了。→ 用 `suppress_signal_` 布尔:setData/addRow/clear 入口置 true、出口置 false,onCellChanged 入口先查它。→ `src/editable_table.cpp:445` +- onCellChanged **连的是 cellChanged 还是 itemChanged**?连 cellChanged(给 row/col 参数,省得自己算)。→ `src/editable_table.cpp:174` +- 进阶排查:[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +## setData 传的某行比列数短,崩或取到脏值 + +- 短行没建 QTableWidgetItem,`table_->item(r,c)` 返回 null。→ setData 末尾对 `row_data.size() < col_count` 的列补空占位 item。→ `src/editable_table.cpp:327` +- data() 出口也要兜底:每格 `item ? item->text() : QString()`,别假设 item 一定存在。→ `src/editable_table.cpp:357` +- 进阶排查:[QTableWidget](https://doc.qt.io/qt-6/qtablewidget.html) + +## moc 报错(Q_PROPERTY / Q_ENUM 不认识) + +- 委托类**有没有 `Q_OBJECT`**?ValidatorDelegate 是 Q_OBJECT 类,漏了 moc 不生成元对象。→ `include/editable_table.h:28` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- Q_ENUM 的 ColumnType **是不是在 EditableTable 类里**、Q_ENUM 紧跟其后?→ `include/editable_table.h:85` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/editable-table/index.md b/tutorial/engineering/instances/widget/editable-table/index.md new file mode 100644 index 0000000..c13d076 --- /dev/null +++ b/tutorial/engineering/instances/widget/editable-table/index.md @@ -0,0 +1,169 @@ +--- +title: "EditableTable 成品导览" +description: "可编辑表格成品:组合 QTableWidget + 按列声明类型 + 委托校验 + 整表数据往返,附架构、设计决策、踩坑与阅读路径。" +--- + +# EditableTable 成品导览 + +> **source**:`widget/editable-table/` **related**:model/view 组合控件递进链第 1 环 · 教程层 [QTableWidget 入门](../../../../beginner/03-qtwidgets/50-qtablewidget-beginner.md) / [Model/View 进阶](../../../../advanced/03-qtwidgets/03-model-view-advanced.md) + +EditableTable 是个可编辑表格——业务里最常见的那种「五列里又有文本、又有整数范围、又有下拉、又有勾选」的录入表。QTableWidget 本身能编辑任意文本,但「这一列只能是 0-100 的整数、那一列只能在 红绿蓝 里选」这种约束它不管。我们用**委托**把这层校验补上,再封一层把整表数据按列类型往返存取,就成了一个开箱即用的录入控件。 + +本件和 status-led / toggle-switch 不是一类——那俩是自绘,这件是**组合**:不重写 paintEvent,让 QTableWidget 自己画,我们在它外面套壳 + 挂委托。这恰恰是 model/view 系统的正确打开方式。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::EditableTable` 控件: + +- **按列声明类型**:`addColumn` 时指定 `kText` / `kInt` / `kDouble` / `kCombo` / `kCheck`,外加数值列的 `[min,max]` 和下拉列的候选项 +- **委托校验**:编辑时按列类型弹出对应编辑器(`QLineEdit` / `QSpinBox` / `QDoubleSpinBox` / `QComboBox`),数值列越界自动夹值、空值兜底成范围最小值,下拉列只能选候选项 +- **勾选列不走委托编辑器**:用 `Qt::ItemIsUserCheckable` 直接在单元格里勾,独立于委托的弹出编辑流程 +- **整表数据往返**:`setData(QVector>)` 一次性回填(越界夹值、类型不符兜底),`data()` 一次性按列类型取回(kCheck 还原成 bool) +- **行为开关 Q_PROPERTY**:`editable` / `gridVisible` / `alternatingRowColors` 三个属性可在 Designer 或外部直接驱动 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/editable-table/demo/editable_table_demo +``` + +打开后你会看到一张五列表:姓名(文本) / 分数(整数 0-100) / 比率(双精度 0-1) / 颜色(下拉 红/绿/蓝) / 启用(勾选)。预填的三行里 Bob 那行是故意超界的(分数 120→100、比率 1.5→1.0、颜色 Yellow→Red 回库),用来证明回填阶段的夹值。双击分数列单元格输个 `9999` 或 `abc`,提交后会被夹回 100——这就是委托在干活。下方「Print Data」把 `data()` 拍出来的二维数据格式化进 QTextEdit,编辑任意单元格都会实时回显。 + +## 2. 架构总览 + +### 类关系 + +EditableTable 是组合而非继承:它拥有一个 `QTableWidget` 当显示与编辑的内核,再拥有一个 `ValidatorDelegate` 当编辑校验器。委托不直接持有父表指针,而是通过一个回调从父表拉列规格——这个解耦是整份代码最关键的一笔。 + +```mermaid +classDiagram + class EditableTable { + +Q_PROPERTY bool editable + +Q_PROPERTY bool gridVisible + +Q_PROPERTY bool alternatingRowColors + +addColumn(header,type,min,max,combo) + +addRow() + +removeRow(row=-1) + +setData(rows) + +data() QVector~QVector~QVariant~~ + +clear() + -columns_ : QVector~ColumnSpec~ + -suppress_signal_ : bool + -onCellChanged(row,col) + } + class ValidatorDelegate { + +setColumnSpecProvider(provider) + +createEditor() + +setEditorData() + +setModelData() + -provider_ : ColumnSpecProvider + } + class QTableWidget { + 编辑与显示内核 + cellChanged 信号 + } + EditableTable o-- QTableWidget : table_ + EditableTable o-- ValidatorDelegate : delegate_ + ValidatorDelegate ..> EditableTable : 经回调拉列规格 (不持指针) +``` + +三个对象的关系是单向的:EditableTable 拥有 `table_` 和 `delegate_`(对象树托管,`new ... this`),而 `delegate_` 反向访问 EditableTable 时走的是 `std::function` 回调(`editable_table.cpp:159`),不在 detail 层留一个指向父表的强引用。这避免了「委托持有父表 → 父表又持有委托」的环。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/editable_table.h` | 接口:ColumnType 枚举 + Q_PROPERTY 三件套 + 公有 API + detail::ValidatorDelegate 声明 | +| `src/editable_table.cpp` | 实现:委托编辑器/校验逻辑 + 列/行管理 + 整表数据往返 + 去重与边界 clamp | +| `demo/editable_table_window.cpp` | 演示:五类型列 + 预填超界行 + 增删清 + 打印数据 + 只读切换 | + +### 一次编辑怎么走完校验 + +```mermaid +sequenceDiagram + participant U as 用户双击单元格 + participant T as QTableWidget + participant D as ValidatorDelegate + participant M as 模型(QTableWidgetItem) + U->>T: 进入编辑态 + T->>D: createEditor(index) + D->>D: 经 provider_ 拉该列规格 + D-->>T: QSpinBox / QDoubleSpinBox / QComboBox / QLineEdit + T->>D: setEditorData(editor, index) + D->>D: 数值列空值兜底成 box.minimum() + U->>U: 改值 (可能超界) + T->>D: setModelData(editor, model, index) + D->>D: 直接写 box.value() (SpinBox 本身已夹值) + D->>M: model.setData(index, value) + M->>T: 触发 cellChanged + T->>EditableTable: onCellChanged(row,col) + Note over EditableTable: suppress_signal_==false → emit dataEdited +``` + +重点在 setModelData 这步:数值列**不再二次判空**,因为 setEditorData 阶段已经把空值兜成最小值,`QSpinBox::value()` 永远在合法区间内。校验被前置,提交阶段只管写。勾选列根本不进这条链——它靠 cellChanged 信号捕捉 `checkState` 变化。 + +## 3. 关键设计决策 + +**① 组合 QTableWidget,不继承也不自绘。** +EditableTable 继承的是 QWidget,把 QTableWidget 当私有成员 `new` 出来挂 parent、塞进布局。不重写 paintEvent,让 view 自己画。这是本批 model/view 组合控件的定位——和自绘派(status-led)分道扬镳。代价是少了一层绘制控制权,收益是白嫖了 QTableWidget 全套的选择/编辑/滚动/表头行为(`editable_table.cpp:151`)。 + +**② 委托独立成 detail::ValidatorDelegate,用回调取列规格而非持父表指针。** +委托挂在 AwesomeQt::detail 子命名空间,是个独立的 Q_OBJECT 类。它要知道某列该弹什么编辑器、范围是多少,但不直接 `EditableTable*`——而是吃一个 `ColumnSpecProvider` 回调(`std::function`),由父表在构造时注入(`editable_table.cpp:159`)。好处是 detail 层不依赖 EditableTable 的完整定义,没有环引用;ColumnType 还经 `columnTypeToInt` 折成 int 透传,detail 层连枚举都不用认识。这种「下游靠回调拿配置」的模式在后续 model/view 控件里会反复出现。 + +**③ 校验前置:数值列空值在 setEditorData 兜底,setModelData 只管写。** +最初的想法是 setModelData 里判空——空就 return 或恢复旧值。实际写下来发现:`QSpinBox` / `QDoubleSpinBox` 本身就有 `[min,max]` 夹值,只要进编辑器时把空值兜成 `box->minimum()`(`editable_table.cpp:90`),提交时 `box->value()` 必然合法,setModelData 直接写即可,不用再判(`editable_table.cpp:118`)。校验点收敛到一处,逻辑更薄。kCombo 同理用 `findText` 回库首项兜底(`editable_table.cpp:101`)。 + +**④ 整表数据往返统一走夹值 + 兜底,绝不抛。** +`setData` 回填时按列类型转换:kInt 用 `std::clamp` 夹到 `[min,max]`、kDouble 同理、kCombo 不在候选项就回库首项、kCheck 还原 `Qt::Checked/Unchecked`(`editable_table.cpp:278`)。`data()` 取回时按列类型还原 QVariant:kCheck→bool、kInt/kDouble 转换失败给 `QVariant()`(`editable_table.cpp:358`)。行数据短于列数时补空占位 `QTableWidgetItem`,避免 `item==null`(`editable_table.cpp:327`)。整条链路对越界/类型不符一律安全夹值或兜底,外部调一次 `setData` 永不崩。 + +**⑤ suppress_signal_ 屏蔽程序化回灌,只让真实编辑发 dataEdited。** +`setData` / `addRow` / `clear` 都是程序化建项,会触发 `cellChanged`。若不拦,`onCellChanged` 会把这些程序化改动当用户编辑重新 emit 出去,外部接到一堆「假编辑」通知。解法是一个 `suppress_signal_` 布尔:程序化填值前置位、后清位,`onCellChanged` 入口先查它(`editable_table.cpp:445`)。只有 suppress 为 false(即用户真实双击编辑)才 emit `dataEdited`。这是组合控件配合 view 信号的经典去重套路。 + +**⑥ 为 demo 需求补薄透传 getter,不暴露 table_ 指针。** +demo 要拿当前选中行删行、要列宽自适应——这俩是 QTableWidget 的方法,EditableTable 没有对应接口。与其把 `table_` 指针 public 出去破坏封装,不如加两个薄透传 `currentRow()`(`editable_table.cpp:389`)和 `resizeColumnsToContents()`(`editable_table.cpp:393`)。一行实现换封装不破,这笔账划算。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/editable_table.h` 的 ColumnType 枚举与 Q_PROPERTY**(85-86 行、78-81 行)——先看「对外暴露哪些类型和属性开关」 +2. **`detail::ValidatorDelegate` 声明**(`include/editable_table.h:27`)——看委托的四个 override 签名和 `ColumnSpecProvider` 回调类型 +3. **构造函数**(`src/editable_table.cpp:151`)——QTableWidget 怎么 new、委托怎么注入回调、cellChanged 怎么连 +4. **委托的 createEditor**(`src/editable_table.cpp:41`)——按列类型挑编辑器的 switch,核心分发逻辑 +5. **setEditorData + setModelData**(`src/editable_table.cpp:80` / `src/editable_table.cpp:110`)——校验前置怎么落地、提交阶段为什么不用判空 +6. **setData**(`src/editable_table.cpp:261`)——整表回填的夹值/兜底全链路 +7. **data()**(`src/editable_table.cpp:334`)——出口怎么按列类型还原 QVariant +8. **onCellChanged**(`src/editable_table.cpp:444`)——suppress 去重 + 边界 clamp + +入口:`demo/main.cpp` → `demo/editable_table_window.cpp` 跑起来,对照读。重点把 demo 里 Bob 那行预填的 `120/1.5/Yellow` 和双击分数列输 `9999` 这两个校验点跑一遍,看委托到底夹回了什么。 + +## 5. 踩坑 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | cpp 编译报 `expected type-specifier before 'QVBoxLayout'` | 构造里 `new QVBoxLayout(this)`,但 cpp 只引了 `QTableWidget` / `QTableWidgetItem` 等,漏了 `QVBoxLayout` | 编译不过 | cpp 顶部补 `#include `(`src/editable_table.cpp:16`)。Q_OBJECT 类的 cpp 不像头文件那样顺手引布局类,新加布局代码要单独引 | +| ② | demo 编译报 `'class AwesomeQt::EditableTable' has no member named 'resizeColumnsToContents'` | `resizeColumnsToContents()` 是 QTableWidget 的方法,EditableTable 把它私有化了,外部直接调不到 | demo 编译不过 | 在 EditableTable 加薄透传 `void resizeColumnsToContents()` 调 `table_->resizeColumnsToContents()`(`src/editable_table.cpp:393`),而不是暴露 `table_` 指针 | +| ③ | demo 里写了 `table_->currentRowOfTable()` 想拿选中行,但方法不存在 | 笔误编造了方法名;EditableTable 一开始没有「取当前行」的接口 | 编译不过 | 加公有 `int currentRow() const` 透传 `table_->currentRow()`(`src/editable_table.cpp:389`),demo 删行改调 `removeRow(table_->currentRow())`——`removeRow` 自带 `-1`/越界删最后一行的兜底 | +| ④ | setData 后外部接到一堆 `dataEdited`,值还是刚填进去的 | 程序化 `setItem` 触发了 `cellChanged`,`onCellChanged` 把它当用户编辑 emit 出去 | 假通知刷屏、业务逻辑被无意义信号干扰 | `suppress_signal_` 布尔:setData/addRow/clear 入口置 true、出口置 false,`onCellChanged` 入口先查它(`src/editable_table.cpp:445`) | +| ⑤ | 数值列双击编辑后清空,提交存了空串 | setModelData 想判空但写漏了分支,或干脆没判 | 模型里混入非法空值,data() 出口 toInt 失败返回 `QVariant()` | 校验前置到 setEditorData:空值兜成 `box->minimum()`(`src/editable_table.cpp:90`),setModelData 只管写 `box->value()`,SpinBox 本身已夹值(`src/editable_table.cpp:118`) | +| ⑥ | setData 传的某行比列数短,data() 取到那行该列崩或返回脏值 | 短行没建 QTableWidgetItem,`table_->item(r,c)` 返回 null | 解引用 null 崩溃,或 data() 出口取到默认值 | setData 末尾对 `row_data.size() < col_count` 的列补空占位 item(`src/editable_table.cpp:327`);data() 出口也做 `item ? item->text() : QString()` 兜底(`src/editable_table.cpp:357`) | + +## 6. 官方文档 + +- [QTableWidget](https://doc.qt.io/qt-6/qtablewidget.html)——可编辑表格控件(本件的显示与编辑内核) +- [QStyledItemDelegate](https://doc.qt.io/qt-6/qstyleditemdelegate.html)——委托基类(ValidatorDelegate 的父类,createEditor/setEditorData/setModelData 三件套) +- [QSpinBox](https://doc.qt.io/qt-6/qspinbox.html) / [QDoubleSpinBox](https://doc.qt.io/qt-6/qdoublespinbox.html)——数值列编辑器(自带 `[min,max]` 夹值) +- [QComboBox](https://doc.qt.io/qt-6/qcombobox.html)——下拉列编辑器(findText 回库兜底) +- [Model/View Programming](https://doc.qt.io/qt-6/model-view-programming.html)——委托在 model/view 体系里的定位 +- [Qt::ItemFlag](https://doc.qt.io/qt-6/qt.html#ItemFlag-enum)——勾选列靠 `Qt::ItemIsUserCheckable` 实现 +- [Qt 属性系统(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html)——editable / gridVisible / alternatingRowColors 三个属性开关的机制 + +--- + +这套机制(组合 QTableWidget + 委托按列分发 + 整表数据往返 + suppress 去重)不是 EditableTable 专属——它就是「一个带类型约束的可录入表格」的标准范式。后面做带自定义模型(QAbstractTableModel)的高级表格控件时,委托这套校验逻辑能原样复用,只是数据后端从 QTableWidget 换成自定义 model。想自己搓?[手搓手册](./handbook/)带你从空壳 QTableWidget 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/fade-animation/handbook/01-effect-and-opacity.md b/tutorial/engineering/instances/widget/fade-animation/handbook/01-effect-and-opacity.md new file mode 100644 index 0000000..bdb3a51 --- /dev/null +++ b/tutorial/engineering/instances/widget/fade-animation/handbook/01-effect-and-opacity.md @@ -0,0 +1,66 @@ +--- +title: "Step 1-2:挂 Effect + opacity 升 Q_PROPERTY" +description: "给控件挂 QGraphicsOpacityEffect,setOpacity 改透明度;再把 opacity 升级为 Q_PROPERTY,让它能被元系统/Designer/动画按名字驱动。" +--- + +# Step 1-2:挂 Effect + opacity 升 Q_PROPERTY + +← [手册首页](./index.md) · 下一步 [Step 3-4 淡入淡出动画](./02-fade-animation.md) → + +这两步先把「透明度」这件事搭起来:先让一个 effect 扛住它,再把 `opacity` 升级成 Qt 认识的属性。动画留到 step 3。 + +## Step 1:挂 QGraphicsOpacityEffect,setOpacity 改透明度 + +### 目标 + +控件出现后,调一个 `setOpacity()`,透明度立刻变(半透明、全透明都能做到)。**这步先做瞬时改值**(直接 set,不要动画),动画下一步。 + +### 提示 + +- 继承 `QWidget`,加私有成员 `QGraphicsOpacityEffect* effect_` +- 构造里 `effect_ = new QGraphicsOpacityEffect(this)`,`effect_->setOpacity(1.0)`(初值全不透明),再 `setGraphicsEffect(effect_)` 把它挂到自身 +- 写 `void setOpacity(qreal value)`:先 clamp 到 [0,1],再 `effect_->setOpacity(value)`。effect 自带 update,不用手动刷 +- 写 `qreal opacity() const { return effect_->opacity(); }` +- 别重写 `paintEvent`——透明度全交给 effect,控件该画啥画啥 + +### 检查点 + +调 `setOpacity(0.3)` 控件变七成透明 = effect 挂对了。 + +> QGraphicsOpacityEffect 不熟?[QPropertyAnimation 属性动画](../../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md) 章节里有特效配合动画的示例。 + +### 对照答案 + +- effect new + setOpacity(1.0) + setGraphicsEffect:`src/fade_animation.cpp:16-18` +- setOpacity 纯赋值 + clamp:`src/fade_animation.cpp:70-81` + +--- + +## Step 2:opacity 升级为 Q_PROPERTY + +### 目标 + +给类加一行 `Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity NOTIFY opacityChanged)`,补上 `opacityChanged` 信号。功能上和 step 1 一样,但 opacity 现在是「Qt 认识的属性」——能被元系统枚举、被 Designer 编辑、**被动画按名字驱动**(这是 step 3 的前提)。 + +### 提示 + +- Q_PROPERTY 三件:READ 一个 getter(step 1 写好了)、WRITE 一个 setter(step 1 写好了)、NOTIFY 一个信号 +- setOpacity 末尾 `emit opacityChanged(value)` +- 顺手把 `fadeDuration` 也做成 Q_PROPERTY:`Q_PROPERTY(int fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY fadeDurationChanged)`,setFadeDuration 里 clamp 兜底(<1 改 1)+ emit +- **关键认知**:WRITE 指 setOpacity(纯赋值),**不是动画入口**。动画由 step 3 的 runFade 启动——别把动画逻辑塞进 setOpacity,否则外部滑块拖一下也会触发动画(见 [troubleshooting](./troubleshooting.md) 的「以为 setOpacity 会触发动画」) + +### 检查点 + +编译过(moc 不报错)+ `metaObject()->propertyCount()` 能数到 opacity 和 fadeDuration = Q_PROPERTY 生效了。外部调 `setOpacity(0.5)` 是**瞬时变值、无动画**(动画还没写)。 + +> Q_PROPERTY 机制不熟?[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)、进阶 [属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- Q_PROPERTY(opacity / fadeDuration) 声明:`include/fade_animation.h:32-33` +- opacityChanged / fadeDurationChanged 信号:`include/fade_animation.h:61-62` +- setFadeDuration clamp 兜底 + emit:`src/fade_animation.cpp:51-60` + +--- + +下一步是重头戏:[Step 3-4 让 QPropertyAnimation 驱动 opacity 做淡入淡出](./02-fade-animation.md)。 diff --git a/tutorial/engineering/instances/widget/fade-animation/handbook/02-fade-animation.md b/tutorial/engineering/instances/widget/fade-animation/handbook/02-fade-animation.md new file mode 100644 index 0000000..9e8c9a8 --- /dev/null +++ b/tutorial/engineering/instances/widget/fade-animation/handbook/02-fade-animation.md @@ -0,0 +1,88 @@ +--- +title: "Step 3-4:QPropertyAnimation 驱动 opacity(核心)" +description: "QPropertyAnimation 驱动 effect 的 opacity 做淡入淡出:持久指针复用、runFade 接力、fadeIn/fadeOut 的 show/hide 时机。" +--- + +# Step 3-4:淡入淡出动画(核心) + +← [Step 1-2](./01-effect-and-opacity.md) · [手册首页](./index.md) · 下一步 [Step 5 demo 收尾](./03-demo-and-polish.md) → + +这两步是整个控件的核心——用一个 `QPropertyAnimation` 写 step 1 挂的那个 effect 的 `opacity`,让透明度随时间过渡。诀窍在两处:**持久指针复用**(防连发悬空)和 **show/hide 时机**(防「先蹦出来再淡」)。 + +## Step 3:QPropertyAnimation 驱动 opacity 做淡入淡出 + +### 目标 + +加一个 `runFade(qreal end, int duration_ms)` 私有方法,把 effect 的 opacity 从**当前值**过渡到 `end`,时长 `duration_ms`。再写 `fadeIn()` / `fadeOut()` 两个入口包它。 + +### 提示(按顺序) + +1. **构造期建持久动画指针**:`fade_anim_ = new QPropertyAnimation(effect_, "opacity", this)`——注意 target 是 `effect_`、property 是 `"opacity"`、parent 是 this 托管。配 `setEasingCurve(QEasingCurve::OutCubic)` +2. **连 finished 回调**:`QObject::connect(fade_anim_, &QPropertyAnimation::finished, this, [...]{ ... emit fadeFinished(...); })`。fadeOut 走完要 `hide()`——用一个成员 bool `fading_out_` 标记,回调里判断它 +3. **写 runFade**: + - `fade_anim_->stop()`(先停) + - `setDuration(duration_ms)`(<1 兜底成 1) + - `setStartValue(effect_->opacity())`——**从当前实时 opacity 接力**,不是从 0 或 1 + - `setEndValue(end)` + - `start()` +4. **动画驱动的是 effect 的 opacity**,所以每帧 Qt 自动调 `effect_->setOpacity(中间值)` + effect 自己 update——你不用写任何逐帧回调 + +### 关键认知 + +- **持久指针,禁 `DeleteWhenStopped`**:和 status-led 同一套教训。`DeleteWhenStopped` 在连发动画时让旧指针悬空。持久指针 + stop/重配/start,从当前值接力争不跳变 +- **setStartValue 取当前 opacity**:快速连点 Fade In / Fade Out 时,透明度连续过渡,不会跳回 0 或 1 + +### 检查点 + +调 `runFade(1.0, 300)`:透明度从当前值平滑过渡到完全不透明 = 动画对了。连点多次不崩、不跳变 = 接力逻辑对了。 + +> 动画框架不熟?[属性动画框架基础](../../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md)、进阶 [动画框架进阶](../../../../../advanced/03-qtwidgets/09-animation-advanced.md)。 + +### 对照答案 + +- fade_anim_ 持久指针 new + easing + 连 finished:`src/fade_animation.cpp:21-29` +- runFade 接力(stop/duration/setStartValue/setEndValue/start):`src/fade_animation.cpp:87-97` + +--- + +## Step 4:fadeIn / fadeOut 入口 + show/hide 时机 + +### 目标 + +把 step 3 的 runFade 包成 `fadeIn(int duration_ms = 300)` 和 `fadeOut(int duration_ms = 300)` 两个对外入口,并处理「动画从正确起点开始」的可见性问题。 + +### 提示 + +**fadeIn**: + +- 不可见时**先 `effect_->setOpacity(0.0)` 再 `show()`**——否则 effect 初值是 1.0,show 会先把全不透明画面渲染出来,再从 0 淡入,视觉上是「先蹦出来再淡」 +- `fading_out_ = false`(标记在淡入,finished 时保持可见) +- `runFade(1.0, duration_ms)` + +**fadeOut**: + +- **先 `show()`**——必须可见才能看到淡出过程 +- `fading_out_ = true`(finished 回调据此 hide) +- `runFade(0.0, duration_ms)` + +### 关键认知 + +- 两个入口的 show 时机**相反**:fadeIn 是「先置透明再 show」,fadeOut 是「先 show 才看得见」 +- finished 回调里 `if (fading_out_) hide();`——fadeOut 走完隐藏,fadeIn 走完保持可见,语义对称 + +### 检查点 + +- 点 Fade In:从全透明平滑渐显到全显示,**没有先闪一下全图** = show 时机对了 +- 点 Fade Out:从当前平滑淡到全透明,结束后控件消失 = fading_out_ + hide 逻辑对了 + +> show/hide 与可见性不熟?[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +### 对照答案 + +- fadeIn(先 setOpacity(0.0) 再 show + runFade):`src/fade_animation.cpp:32-40` +- fadeOut(先 show + fading_out_=true + runFade):`src/fade_animation.cpp:42-49` +- finished 回调按 fading_out_ 决定 hide + emit:`src/fade_animation.cpp:23-29` + +--- + +下一步:[Step 5 把它装进 demo——时长滑块 + 瞬时 opacity 滑块(QSignalBlocker 防环)](./03-demo-and-polish.md)。 diff --git a/tutorial/engineering/instances/widget/fade-animation/handbook/03-demo-and-polish.md b/tutorial/engineering/instances/widget/fade-animation/handbook/03-demo-and-polish.md new file mode 100644 index 0000000..2a12a2b --- /dev/null +++ b/tutorial/engineering/instances/widget/fade-animation/handbook/03-demo-and-polish.md @@ -0,0 +1,67 @@ +--- +title: "Step 5:demo 收尾(时长滑块 + 瞬时 opacity + QSignalBlocker)" +description: "把 FadeWidget 装进 demo:Fade In/Out 按钮、fadeDuration 滑块、瞬时 opacity 滑块;用 QSignalBlocker 解决动画回灌滑块的信号环。" +--- + +# Step 5:demo 收尾(时长滑块 + 瞬时 opacity + QSignalBlocker) + +← [Step 3-4](./02-fade-animation.md) · [手册首页](./index.md) → + +把 step 1-4 的 FadeWidget 装进一个能交互的 demo:两个按钮 + 两个滑块。这一步的重头是**瞬时 opacity 滑块**——它会暴露一个信号环坑,正好用 `QSignalBlocker` 收掉。 + +## Step 5:demo UI(按钮 + 两个滑块) + +### 目标 + +做一个主窗口,里面:①FadeWidget 容器(放带色面板 + 文字,证明内容跟着淡);②「Fade In」/「Fade Out」按钮;③fadeDuration 滑块(100-1500ms);④瞬时 opacity 滑块(绕过动画直接 setOpacity)。 + +### 提示 + +**容器内容**: + +- `FadeWidget` 里放一个带 `QFrame`(设 stylesheet 上色 + 圆角)+ `QLabel` 文字——证明「内容跟着容器一起淡」 + +**按钮**: + +- Fade In 按钮连 `fade_widget_->fadeIn(fade_widget_->fadeDuration())`,Fade Out 同理——用当前 fadeDuration 当时长 + +**fadeDuration 滑块**: + +- `setRange(100, 1500)`,连 `valueChanged` → `setFadeDuration(value)` + 更新数值标签 +- 这条单向同步,没环(setFadeDuration 不回灌滑块) + +**瞬时 opacity 滑块(核心坑)**: + +- `setRange(0, 100)`,valueChanged 里 `setOpacity(value / 100.0)`(整型映射到 0.0..1.0)+ 更新标签 +- **反向同步**:连 `FadeWidget::opacityChanged` → 动画运行时也改 opacity,滑块和标签要跟着走 +- 反向 setValue 滑块时,若不屏蔽信号会触发 `valueChanged`→`setOpacity`→`opacityChanged`→再 setValue,**形成信号环** +- 解法:`QSignalBlocker blocker(slider);` 包住 `slider->setValue(pct)`,且 `if (slider->value() != pct)` 才写,避免无意义写入 + +### 关键认知——为什么 opacity 滑块会成环 + +- 滑块→setOpacity→opacityChanged→滑块:这是一个完整的环 +- fadeDuration 滑块没这问题,因为 `setFadeDuration` 不回灌滑块(它只 emit fadeDurationChanged,demo 没接这个信号去写滑块) +- `QSignalBlocker` 是 RAII:作用域内屏蔽,出了作用域自动恢复——比手动 `blockSignals(true/false)` 安全(不会忘恢复) +- 顺手加 `if (slider->value() != pct)` 判断:即使信号被屏蔽,也避免重复写同一个值 + +### 检查点 + +- 点 Fade In / Fade Out:内容平滑淡入淡出,且 opacity 滑块和标签**实时跟着动画走、不抖动** = QSignalBlocker 防环对了 +- 拖 fadeDuration 滑块:下次淡入淡出变快/变慢 = 时长同步对了 +- 拖 opacity 滑块:透明度**瞬时变**(无动画),证明 setOpacity 是真 Q_PROPERTY + +> 信号槽 / QSignalBlocker 不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)、进阶 [属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- 容器内容(带色 QFrame + 文字):`demo/fade_animation_window.cpp:50-64` +- Fade In / Fade Out 按钮:`demo/fade_animation_window.cpp:69-78` +- fadeDuration 滑块同步:`demo/fade_animation_window.cpp:87-101` +- 瞬时 opacity 滑块:`demo/fade_animation_window.cpp:111-126` +- 反向同步 + QSignalBlocker 防环:`demo/fade_animation_window.cpp:129-137` + +--- + +搓完了。跑 demo 对照成品:淡入淡出平滑、duration 可调、opacity 滑块瞬时生效且不与动画互相抖 = 你搓的和 repo 一致。 + +想再深?回 [手册首页](./index.md) 看进阶挑战(fadeOut 可选不 hide / 组合 geometry+opacity 双动画 / 下一站 toggle-switch)。 diff --git a/tutorial/engineering/instances/widget/fade-animation/handbook/index.md b/tutorial/engineering/instances/widget/fade-animation/handbook/index.md new file mode 100644 index 0000000..d44506c --- /dev/null +++ b/tutorial/engineering/instances/widget/fade-animation/handbook/index.md @@ -0,0 +1,62 @@ +--- +title: "FadeWidget 手搓手册" +description: "从空 main 一行行搓出 FadeWidget:5 步打通 QGraphicsOpacityEffect 承载透明度、QPropertyAnimation 驱动 opacity、持久指针防连发悬空、QSignalBlocker 防反向同步成环。" +--- + +# FadeWidget 手搓手册 + +> **source**:成品答案在 `widget/fade-animation/`(做完对照)· **related**:动画/特效控件递进链(上一站 [status-led](../../status-led/) · 下一站 toggle-switch/circle-progress 待产) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 FadeWidget,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **QGraphicsOpacityEffect**:把透明度这件事彻底外包给特效,控件本身一行绘制都不用写 +- **QPropertyAnimation 驱动 effect 属性**:`new QPropertyAnimation(effect_, "opacity", this)` 让动画直接写 effect 的 `opacity` +- **Q_PROPERTY 的 WRITE 设计**:`setOpacity` 做纯赋值、不调动画,和 status-led 的 `setAnimatedColor` 同一套哲学——动画入口和属性 WRITE 分离 +- **持久动画指针复用**:`stop()` + 重配 + `start()`,禁 `DeleteWhenStopped`,防连发悬空 +- **QSignalBlocker 防反向同步成环**:动画回灌滑块时屏蔽信号,避免 valueChanged→setOpacity→opacityChanged 死循环 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(200, 120); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +## 2. 任务清单 + +分 5 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 挂 QGraphicsOpacityEffect,setOpacity 能改透明度 | [01](./01-effect-and-opacity.md) | +| 2 | opacity 升级为 Q_PROPERTY | [01](./01-effect-and-opacity.md) | +| 3 | QPropertyAnimation 驱动 opacity 做淡入淡出 | [02](./02-fade-animation.md) | +| 4 | fadeIn/fadeOut 入口 + show/hide 时机 | [02](./02-fade-animation.md) | +| 5 | demo:时长滑块 + 瞬时 opacity 滑块(QSignalBlocker 防环) | [03](./03-demo-and-polish.md) | + +成品对照:`widget/fade-animation/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **fadeOut 结束可选不 hide**:现在默认淡出后 `hide()`。加一个属性(比如 `hideOnFadeOut`)让调用方决定要不要保持可见但透明——思考:这对后续「淡出后销毁」语义会有什么影响? +- **组合 QPropertyAnimation 同时改 geometry + opacity**:做一个「边缩小边淡出」的退出动画。提示:两个动画并行,注意都用 `parent=this` 托管。 +- **下一站**:toggle-switch(待产)——换皮复用这套「effect+动画+Q_PROPERTY」骨架,但引入状态机 + 滑动动画。 diff --git a/tutorial/engineering/instances/widget/fade-animation/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/fade-animation/handbook/troubleshooting.md new file mode 100644 index 0000000..2a9ce61 --- /dev/null +++ b/tutorial/engineering/instances/widget/fade-animation/handbook/troubleshooting.md @@ -0,0 +1,44 @@ +--- +title: "卡住怎么办" +description: "按症状查:淡入前先闪全图、连发崩溃、opacity 滑块与动画互相抖、duration=0 动画不动、moc 报错——给方向指向教程章,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/fade-animation/`,对照着看。 + +## 淡入时控件先全显示,再从 0 淡入(视觉跳变) + +- fadeIn 里**有没有在不可见时先 `effect_->setOpacity(0.0)` 再 `show()`**?effect 初值是 1.0,show 会先把全不透明画面渲染出来。→ `src/fade_animation.cpp:33-37` +- 进阶排查:[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)(show 与可见性) + +## 连点 Fade In / Fade Out 多次后崩溃(segfault) + +- fade_anim_ **是不是用了 `DeleteWhenStopped`**?stop 时对象被 delete,下次用就悬空。改成持久成员指针 + `stop()/重配/start()`。→ `src/fade_animation.cpp:21`、`src/fade_animation.cpp:92-96` +- 动画对象的 **parent 是不是设成了 this**?否则对象树不管它,可能提前释放。→ `src/fade_animation.cpp:21` +- 进阶排查:[动画框架进阶](../../../../../advanced/03-qtwidgets/09-animation-advanced.md) + +## demo 里 opacity 滑块被动画拖动时,与用户操作互相抖 / 回灌 + +- 反向同步 `opacityChanged` → setValue 滑块时**有没有用 `QSignalBlocker`**?没屏蔽会触发 valueChanged→setOpacity→opacityChanged 死循环。→ `demo/fade_animation_window.cpp:129-137` +- 是不是忘了 `if (slider->value() != pct)` 判断?重复写同值也容易引发抖动。→ `demo/fade_animation_window.cpp:132-136` +- 进阶排查:[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +## duration 传 0 时动画不动 + +- QPropertyAnimation **时长为 0 不启动**。在 fadeIn / fadeOut / setFadeDuration / runFade 里都做 `<1 兜底成 1`。→ `src/fade_animation.cpp:52-54`、`src/fade_animation.cpp:88-90` + +## 以为调 setOpacity 会触发淡入淡出 + +- 这不是 bug,是设计:`setOpacity` 是 Q_PROPERTY 的 WRITE 回调,**只做纯赋值 + clamp + emit,不调动画**。动画由 fadeIn/fadeOut 经 runFade 启动。→ `src/fade_animation.cpp:70-81` +- 若你把动画逻辑写进 setOpacity,外部滑块拖一下就会触发动画——这正是要避免的。把它拆出去。 +- 进阶排查:[属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md) + +## moc 报错(Q_PROPERTY 不认识) + +- 头文件**有没有 `Q_OBJECT`**?→ `include/fade_animation.h:29` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- Q_PROPERTY 的 qreal 类型 moc 能直接识别,不用额外声明;若换了自定义类型才需要 `Q_DECLARE_METATYPE` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/fade-animation/index.md b/tutorial/engineering/instances/widget/fade-animation/index.md new file mode 100644 index 0000000..35ea128 --- /dev/null +++ b/tutorial/engineering/instances/widget/fade-animation/index.md @@ -0,0 +1,144 @@ +--- +title: "FadeWidget 成品导览" +description: "淡入淡出容器控件成品:QGraphicsOpacityEffect 承载透明度 + QPropertyAnimation 驱动 0↔1,附架构、设计决策、踩坑与阅读路径。" +--- + +# FadeWidget 成品导览 + +> **source**:`widget/fade-animation/` **related**:动画/特效控件递进链(上一站 [status-led](../status-led/) 的 Q_PROPERTY+动画骨架 · 下一站 toggle-switch/circle-progress 待产) · 教程层 [QPropertyAnimation 属性动画](../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md) / [属性系统进阶](../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md) + +FadeWidget 是个能把自己整体淡入、淡出的容器控件。比 status-led 那种自绘特效更轻——它不画任何东西,透明度全交给一个 `QGraphicsOpacityEffect` 扛,自己只负责把动画接到这个 effect 的 `opacity` 属性上。这套写法是 Qt 里做「通知条飘进来、启动画面隐去、视图切换过渡」最省事的范式,所以单独留一件成品。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::FadeWidget` 容器: + +- **自身(连同内容)整体淡入/淡出**:一个 `QGraphicsOpacityEffect` 挂在自身,`QPropertyAnimation` 驱动它的 `opacity` 在 0↔1 间过渡 +- **fadeIn / fadeOut 两入口**:`fadeIn()` 从透明渐显到不透明并保持可见;`fadeOut()` 从当前淡到透明,结束后 `hide()` +- **opacity 是真 Q_PROPERTY**:既能被动画按名字驱动,也能被外部滑块/Designer 瞬时设值——调 `setOpacity()` 不会触发动画,证明它是个正经的属性 +- **可调时长 + 缓动**:默认 300ms `OutCubic`,`setFadeDuration()` 改默认时长,单次调用还能临时传参 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/fade-animation/demo/fade_animation_demo +``` + +## 2. 架构总览 + +### 类关系 + +一个 FadeWidget 「拥有」两个对象:effect 扛透明度,animation 驱动 effect: + +```mermaid +classDiagram + class FadeWidget { + +Q_PROPERTY qreal opacity + +Q_PROPERTY int fadeDuration + +fadeIn(int) + +fadeOut(int) + +setOpacity(qreal) + +setFadeDuration(int) + } + class QGraphicsOpacityEffect { + opacity 0.0..1.0 + 真正改透明度 + update + } + class QPropertyAnimation { + 驱动 effect "opacity" + duration + OutCubic + } + FadeWidget o-- QGraphicsOpacityEffect : effect_ + FadeWidget o-- QPropertyAnimation : fade_anim_ + QPropertyAnimation ..> QGraphicsOpacityEffect : 每帧写 opacity +``` + +两件事写在不同对象上:`effect_` 持有「透明度到底是多少」,`fade_anim_` 持有「透明度随时间怎么变」。`setOpacity()`(Q_PROPERTY 的 WRITE)直接写 `effect_->setOpacity()`——所以动画和外部滑块**写的是同一个 effect 的同一个属性**,天然同步,不用额外做状态桥。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/fade_animation.h` | 接口:Q_PROPERTY 两件(opacity / fadeDuration)+ fadeIn/fadeOut + signals | +| `src/fade_animation.cpp` | 实现:effect 挂载 / 动画初始化 / runFade 接力复用 / finished 回调隐藏 | +| `demo/fade_animation_window.cpp` | 演示:Fade In / Fade Out 按钮 + fadeDuration 滑块 + 瞬时 opacity 滑块(含反向同步防回灌) | + +### 一次淡入怎么跑起来 + +```mermaid +sequenceDiagram + participant U as 调用方 + participant F as FadeWidget + participant A as fade_anim_ + participant E as effect_ + U->>F: fadeIn(300) + F->>E: setOpacity(0.0)(先全透明,防"先蹦出来") + F->>F: show() + F->>A: stop(); setDuration(300); setStartValue(当前opacity); setEndValue(1.0); start() + loop 每帧 + A->>E: 写 opacity(中间值) + E->>E: update() + end + A-->>F: finished 信号 + F->>F: fading_out_==false → 保持可见,emit fadeFinished(false) +``` + +重点:fadeIn 在不可见时**先 `setOpacity(0.0)` 再 `show()`**,否则 effect 初值是 1.0,`show()` 会先把全不透明画面渲染出来——这就是最常见的「先蹦出来再淡」跳变(见踩坑①)。 + +## 3. 关键设计决策 + +**① `opacity` 的 WRITE 是纯赋值,本身不调动画;fadeIn/fadeOut 才是动画入口。** +`setOpacity()` 只做 `effect_->setOpacity()` + clamp [0,1] + `emit opacityChanged`(`src/fade_animation.cpp:70-81`)。动画由 `fadeIn/fadeOut` 调 `runFade()` 启动(`src/fade_animation.cpp:87-97`)。这样外部既可以用滑块瞬时设值(证明 opacity 是真 Q_PROPERTY),也能让动画按名字驱动它——两条路写同一个 effect,不冲突。对比 status-led 的 `setAnimatedColor`:那里 WRITE 也不能启动画,否则无限递归。 + +**② `fade_anim_` 是持久成员指针,parent=this 托管,禁 `DeleteWhenStopped`。** +`new QPropertyAnimation(effect_, "opacity", this)`,配 `setEasingCurve(OutCubic)`(`src/fade_animation.cpp:21-22`)。每次动画用 `stop()` + 重配 `setStartValue`(取当前实时 opacity)+ `setEndValue` + `start()` 复用同一对象(`src/fade_animation.cpp:92-96`)。沿用 status-led 的教训:`DeleteWhenStopped` 在连发动画时会让旧指针悬空,持久指针从当前值接力争、不跳变、不崩。 + +**③ fadeIn 不可见时先 `setOpacity(0.0)` 再 `show()`,fadeOut 必须先 `show()`。** +fadeIn 不可见时若直接 start,effect 初值 1.0 会让画面先全显示再从 0 淡入(`src/fade_animation.cpp:33-37`)。fadeOut 反过来:必须先可见才能看到淡出过程(`src/fade_animation.cpp:44-46`)。这两条是「动画从正确起点开始」的核心。 + +**④ fadeOut 用 `fading_out_` 标记,finished 回调里 `hide()` + emit。** +成员 bool `fading_out_` 标记当前是不是淡出(`include/fade_animation.h:74`)。finished 回调里若在淡出则 `hide()` 营造「消失」语义,再 `emit fadeFinished(fading_out_)`(`src/fade_animation.cpp:23-29`)。fadeIn 不隐藏,保持可见——语义对称。 + +**⑤ duration<1 在 fadeIn / fadeOut / setFadeDuration / runFade 四处兜底成 1。** +0 或负时长会让动画不启动(`src/fade_animation.cpp:52-54`、`src/fade_animation.cpp:88-90`)。统一兜底,语义干净,不用在调用处再判断。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/fade_animation.h` 的 Q_PROPERTY 两件**(32-33 行)——先看「opacity / fadeDuration 两个可驱动属性」,注意 fadeFinished 信号(64 行) +2. **构造函数**(`src/fade_animation.cpp:14-30`)——effect 怎么 new + setGraphicsEffect + 初值 1.0,fade_anim_ 怎么 new + 配 easing + 连 finished 回调 +3. **`setOpacity`**(`src/fade_animation.cpp:70-81`)——Q_PROPERTY 的 WRITE,纯赋值 + clamp + emit,确认它不调动画 +4. **`runFade`**(`src/fade_animation.cpp:87-97`)——动画复用核心,盯 `stop()` + `setStartValue(当前 opacity)` + `setEndValue` + `start()` 这四行 +5. **`fadeIn` / `fadeOut`**(`src/fade_animation.cpp:32-49`)——两个入口,注意 fadeIn 的「先 setOpacity(0.0) 再 show()」和 fadeOut 的「先 show()」 +6. **demo 的反向同步**(`demo/fade_animation_window.cpp:129-137`)——opacityChanged 回灌滑块时用 `QSignalBlocker` 防环 + +入口:`demo/main.cpp` → `demo/fade_animation_window.cpp` 跑起来,对照读。 + +## 5. 踩坑 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | fadeIn 时控件先全显示,再从 0 淡入——视觉跳变 | 不可见状态下直接 start 动画,effect 初值是 1.0,`show()` 先把全不透明画面渲染出来 | 淡入前闪一下全图 | fadeIn 里 `if(!isVisible())` 先 `effect_->setOpacity(0.0)` 再 `show()`(`src/fade_animation.cpp:33-37`) | +| ② | 连点 Fade Out 多次后崩溃 / 动画错乱 | 每次新建动画,或用 `DeleteWhenStopped`,旧指针悬空 | segfault | 持久成员指针 + parent=this,`runFade` 里 `stop()` 后重配 startValue(当前 opacity)/endValue 再 `start()`(`src/fade_animation.cpp:21`、`src/fade_animation.cpp:92-96`) | +| ③ | demo 里 opacity 滑块被动画拖动时,与用户操作互相回灌 | `opacityChanged` 反向 setValue 滑块,又触发 `valueChanged`→`setOpacity`,形成信号环 | 滑块抖动 / 性能浪费 | 反向同步用 `QSignalBlocker` 临时屏蔽滑块信号再 setValue,且判断 `slider->value()!=pct` 才写(`demo/fade_animation_window.cpp:129-137`) | +| ④ | duration 传 0 时动画不动 | QPropertyAnimation 时长为 0 不启动 | 控件永远停在起点 | duration<1 统一兜底成 1(`src/fade_animation.cpp:52-54`、`src/fade_animation.cpp:88-90`) | +| ⑤ | 以为 setOpacity 会触发淡入淡出 | 误以为 Q_PROPERTY 的 WRITE 等于动画入口 | 把动画逻辑写进 setter 导致重复触发 | 认清 `setOpacity` 是纯赋值回调,动画由 fadeIn/fadeOut 经 runFade 启动(`src/fade_animation.cpp:70-81`) | + +## 6. 官方文档 + +- [QGraphicsOpacityEffect](https://doc.qt.io/qt-6/qgraphicsopacityeffect.html)——透明度特效(透明度的真正承载者) +- [QPropertyAnimation](https://doc.qt.io/qt-6/qpropertyanimation.html)——属性动画(驱动 opacity) +- [QWidget::setGraphicsEffect](https://doc.qt.io/qt-6/qwidget.html#setGraphicsEffect)——把 effect 挂到控件上 +- [Qt 属性系统(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html)——为什么 opacity 能被动画按名字驱动 +- [QSignalBlocker](https://doc.qt.io/qt-6/qsignalblocker.html)——临时屏蔽信号,防反向同步成环 + +--- + +这套机制(effect 扛特效 + QPropertyAnimation 驱动 + Q_PROPERTY WRITE 纯赋值)不是 FadeWidget 专属——它是「给一个现成控件加淡入淡出」的标准范式,往任何 widget 上挂一个 QGraphicsOpacityEffect 都能复用。想自己搓?[手搓手册](./handbook/)带你从空 main 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/ip-edit/handbook/01-compose-and-signals.md b/tutorial/engineering/instances/widget/ip-edit/handbook/01-compose-and-signals.md new file mode 100644 index 0000000..42e1b57 --- /dev/null +++ b/tutorial/engineering/instances/widget/ip-edit/handbook/01-compose-and-signals.md @@ -0,0 +1,48 @@ +--- +title: "Step 1:组合骨架 + 4 段构造 + 信号接线" +description: "IpEdit 继承 QWidget 组合 4 个 QLineEdit + 3 个 QLabel,构造里建段、挂 QIntValidator、连 textEdited/textChanged/editingFinished 三条信号,先把 text() 地址拼出来。" +--- + +# Step 1:组合骨架 + 4 段构造 + 信号接线 + +← [手册首页](./index.md) · 下一步 [Step 2 跨段焦点流转](./02-focus-flow.md) → + +这一步把起点那堆裸 `QLineEdit` 包进一个 `AwesomeQt::IpEdit` 类,构造里循环建出 4 段、每段挂 `QIntValidator(0,255)`、连上三条信号。先不要跨段跳焦——那是 step 2 的事;这一步目标是「`text()` 能从 4 个子段现拼出地址、外部能接到 `textChanged`」。 + +## Step 1:组合骨架 + 4 段构造 + QIntValidator + 信号接线 + +### 目标 + +得到一个 `IpEdit : public QWidget`,头文件声明 `static constexpr int kOctetCount = 4`、私有成员 `QLineEdit* octets_[kOctetCount]{}` 和 `QLabel* dots_[kOctetCount - 1]{}`,公有 `text()` / `setText()` / `isValid()` / `clear()` 四件套,信号 `textChanged(QString)` / `editingFinished()` / `placeholderHintChanged(QString)`。构造里循环 4 次建 `QLineEdit`:`setMaxLength(3)` + `setAlignment(Qt::AlignCenter)` + `setValidator(new QIntValidator(0,255,this))` + `setFixedWidth(40)`,存进 `octets_[i]`;段间插 `QLabel(".")`。三条信号接线到位:`textEdited`(满 3 位判断,step 2 才接跳焦,这一步可先空)、`textChanged`(转发成整体地址)、`editingFinished`(末段才发)。 + +### 提示 + +- **组合姿势**:`class IpEdit : public QWidget`,加 `Q_OBJECT`。构造里 `new QHBoxLayout(this)` + `setContentsMargins(0,0,0,0)` + `setSpacing(0)`,循环建段 `addWidget(edit)`、段间 `addWidget(dot)`。**别继承 `QLineEdit`**——你要的是 4 段拼起来,不是一段 +- **validator 共享**:`new QIntValidator(0, 255, this)` 在循环外建一次,4 段共用同一个(parent=this 对象树托管)。挂上后用户在段里敲字母直接被拒,省得 `text()` 再判 +- **头文件别前向声明 Qt 类**:直接 `#include ` / ``(非模板 Qt 公有类 include 安全),别在 `namespace AwesomeQt` 里写 `class QLabel;`——那会被编译器当成命名空间内的类,cpp 里 `new QLabel` 报 `incomplete type`(见踩坑①) +- **`textChanged` 转发**:`connect(edit, &QLineEdit::textChanged, this, [this](const QString&){ emit textChanged(text()); })`——子段一变就重拼整体地址发出去。注意 lambda 捕获 `this`,别捕获 `edit`(构造循环里 `edit` 是局部变量) +- **`editingFinished` 末段才发**:`connect(edit, &QLineEdit::editingFinished, this, [this, i](){ if (i == kOctetCount - 1) emit editingFinished(); })`——按值捕获 `i`,别用不存在的 `senderIndex()`(见踩坑②) + +### 关键认知——为什么地址状态不存成员 + +看着诱人的写法是维护一个 `QString address_` 成员,每次段变了更新它。但这会和 4 个子段的 `text()` 产生两份真相,稍有遗漏就不同步(典型 bug:程序化 `setText` 忘了同步 `address_`、或用户粘贴没触发更新)。成品做法是 `address_` 根本不存——`text()` 每次调用都遍历 4 段现拼,地址永远是子段的真实投影。4 段遍历的开销可忽略,换来的是「永远一致」。 + +### 检查点 + +跑起来出现 4 个居中对齐的输入框、中间用点隔开。在某段敲 `255` 能敲进去、敲 `999` 敲到 `99` 还行第三个 `9` 被 `maxLength(3)` 顶住、敲字母直接进不去(被 `QIntValidator` 拒)。段里打字时外部能接到 `textChanged`(先放个 `qDebug` 或 demo 里挂个 label 回显看)。这一步还没跳焦,满 3 位不会自动跳下段——正常,step 2 才接。 + +> QLineEdit / 信号槽不熟?[QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md) / [信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)。 + +### 对照答案 + +- 类定义 + `kOctetCount` + 成员数组:`include/ip_edit.h:22` / `include/ip_edit.h:31` / `include/ip_edit.h:78` +- 头文件直接 include Qt 类(不前向声明):`include/ip_edit.h:8` +- 构造循环建段 + validator + 信号接线:`src/ip_edit.cpp:28` +- `textEdited` 满位判断(step 2 接跳焦):`src/ip_edit.cpp:37` +- `textChanged` 转发整体地址:`src/ip_edit.cpp:47` +- `editingFinished` 末段才发:`src/ip_edit.cpp:50` +- 段间插点 `QLabel`:`src/ip_edit.cpp:59` + +--- + +下一步是重头戏:[Step 2 重写 keyPressEvent 做跨段焦点流转](./02-focus-flow.md)。 diff --git a/tutorial/engineering/instances/widget/ip-edit/handbook/02-focus-flow.md b/tutorial/engineering/instances/widget/ip-edit/handbook/02-focus-flow.md new file mode 100644 index 0000000..e139686 --- /dev/null +++ b/tutorial/engineering/instances/widget/ip-edit/handbook/02-focus-flow.md @@ -0,0 +1,51 @@ +--- +title: "Step 2:跨段焦点流转(重写 keyPressEvent)" +description: "IpEdit 重写 keyPressEvent:按 '.' 跳下段并 accept() 消费点号、段首退格且段空跳上段继续删末位、回车非末段跳下末段发 editingFinished。满 3 位自动跳下段接进 textEdited lambda。" +--- + +# Step 2:跨段焦点流转(重写 keyPressEvent) + +← [手册首页](./index.md) · 上一步 [Step 1 组合骨架](./01-compose-and-signals.md) · 下一步 [Step 3 地址往返](./03-address-roundtrip.md) → + +这一步是 IpEdit 的灵魂——让 4 段「连起来用」而不是 4 个孤立输入框。重写 `IpEdit::keyPressEvent`,自己定位当前聚焦段、按按键类型分发:`.` 跳下段(消费掉点号)、段首退格且段空跳上段(继续删上段末位)、回车非末段跳下段末段发 `editingFinished`。再把 step 1 留空的 `textEdited` 接上「满 3 位自动跳下段」。 + +## Step 2:keyPressEvent + focusNextOctet/focusPrevOctet + 满位自动跳 + +### 目标 + +`include/ip_edit.h` 的 `protected` 区声明 `void keyPressEvent(QKeyEvent* event) override;`,`private` 区声明 `bool focusNextOctet(int from_index);` / `bool focusPrevOctet(int from_index);` / `QLineEdit* octet(int index) const;`(带边界保护)。`keyPressEvent` 实现四件事:①遍历 `octets_` 找 `hasFocus()` 的段作 `current`,找不到就交父类默认处理;②`Key_Period`/`Key_Comma` 非末段 `focusNextOctet(current)` + `event->accept()`,末段 `emit editingFinished()` + `accept()`;③`Key_Backspace` 且当前段空且光标在段首且非首段,`focusPrevOctet` 后 `prev->backspace()`;④`Key_Return`/`Key_Enter` 非末段跳下、末段发 `editingFinished`。step 1 的 `textEdited` lambda 补上「`t.length()>=3` 且 `toInt` 在 0-255 则 `focusNextOctet(i)`」。 + +### 提示 + +- **定位当前段**:`keyPressEvent` 入口遍历 `octets_`,`octet(i)->hasFocus()` 命中的就是 `current`。`current < 0`(焦点不在任何段上)直接 `QWidget::keyPressEvent(event)` 交默认,别硬处理 +- **`.` 必须消费掉**:跳下段后 `event->accept()`。不 accept 的话事件继续冒泡、子 `QLineEdit` 默认处理会把点号当字符输入,下一段里就冒出一个多余的 `.`。这是本步最容易漏的一行 +- **段首退格的三个条件缺一不可**:`edit->text().isEmpty()`(段空)+ `edit->cursorPosition() == 0`(光标在段首)+ `current > 0`(非首段),三者都满足才跳上段。跳上段后要 `prev->setCursorPosition(prev->text().length())` 把光标置段末、再 `prev->backspace()` 删上段末位——光标不置段末,`backspace()` 删的位置不对 +- **`focusNextOctet` 进段即全选**:跳到下段后 `next->selectAll()`,用户接着输入直接覆盖整段,不用先清空。这是「顺手」的小细节 +- **`octet(index)` 带边界保护**:`index < 0 || index >= kOctetCount` 返回 `nullptr`,`focusNextOctet`/`focusPrevOctet` 拿到 null 直接返回 false——避免越界访问 `octets_` +- **满位自动跳接进 `textEdited` 不接 `textChanged`**:`textEdited` 只在用户输入时触发,`textChanged` 程序化 `setText` 也触发。自动跳焦只该响应用户输入,所以连 `textEdited`,lambda 里按值捕获 `i` + +### 关键认知——为什么不用 focusNextChild + +`QWidget::focusNextChild()` 走的是 Qt 内置 Tab 焦点链,它干不了我们要的三件事:不会拦截 `.`(点号照样被输入)、不会判断「段首退格」上下文(它只往前跳一格)、回车也不归它管。所以必须自己在 `keyPressEvent` 里接管——`focusNextChild` 留给真正的 Tab 键(默认分支不拦,事件交父类,Tab 自然走焦点链)。控制权分层:跨段流转自己管,Tab 走链子。 + +### 检查点 + +跑起来在第一段敲 `192`,满 3 位自动跳到第二段、光标为空(或全选态)。敲完第三段按 `.`,跳到第四段且点号没被输入。在第四段按退格删到空、再按退格,跳回第三段并删掉第三段末位。任意段按回车,非末段跳下、末段(第四段)回车触发 `editingFinished`(外部 label 回显「editingFinished 触发」)。敲字母还是被 `QIntValidator` 拒(这一步不该破坏 step 1 的校验)。 + +> 事件处理不熟?[事件处理进阶](../../../../../advanced/03-qtwidgets/02-event-handling-advanced.md) / [事件系统入门](../../../../../beginner/01-qtbase/07-event-system-beginner.md)。 + +### 对照答案 + +- `keyPressEvent` 声明 + 焦点辅助声明:`include/ip_edit.h:66` / `include/ip_edit.h:72` +- `keyPressEvent` 实现(定位段 + 按键分发):`src/ip_edit.cpp:198` +- `Key_Period`/`Key_Comma` 跳下段 + `accept()` 消费点号:`src/ip_edit.cpp:215` +- `Key_Backspace` 段首退格跳上段 + `backspace()`:`src/ip_edit.cpp:231` +- `Key_Return`/`Key_Enter` 回车分发:`src/ip_edit.cpp:245` +- `focusNextOctet` 进段全选:`src/ip_edit.cpp:267` +- `focusPrevOctet` 置光标段末:`src/ip_edit.cpp:277` +- `octet(index)` 边界保护:`src/ip_edit.cpp:287` +- `textEdited` 满位自动跳:`src/ip_edit.cpp:37` + +--- + +下一步收尾:[Step 3 地址拼装回填 + 边界夹值 + 信号抑制](./03-address-roundtrip.md)。 diff --git a/tutorial/engineering/instances/widget/ip-edit/handbook/03-address-roundtrip.md b/tutorial/engineering/instances/widget/ip-edit/handbook/03-address-roundtrip.md new file mode 100644 index 0000000..1676015 --- /dev/null +++ b/tutorial/engineering/instances/widget/ip-edit/handbook/03-address-roundtrip.md @@ -0,0 +1,48 @@ +--- +title: "Step 3:地址拼装回填 + 边界夹值 + 信号抑制" +description: "IpEdit 实现 text()(空段补 0 拼 a.b.c.d)、setText(SkipEmptyParts 拆分 + std::clamp 夹越界 + 缺段补 0 + QSignalBlocker 抑制)、isValid(全 0 判 false)、clear。" +--- + +# Step 3:地址拼装回填 + 边界夹值 + 信号抑制 + +← [手册首页](./index.md) · 上一步 [Step 2 跨段焦点流转](./02-focus-flow.md) → + +前两步把骨架和交互做完了,这一步补上地址的双向 API——`text()` 取出来、`setText()` 塞进去、`isValid()` 判合法、`clear()` 清空。难点不在拼装本身,而在「程序化回填时别发一堆假信号」和「边界值要老老实实夹」。 + +## Step 3:text() + setText() + isValid() + clear() + +### 目标 + +实现四个公有方法。`text()` 遍历 4 段,每段 `toInt()` 成功取数值、失败补 0,`join('.')` 拼 `"a.b.c.d"`。`setText(const QString& ip)` 先判空串走 `clear()` 返回;否则 `ip.split('.', Qt::SkipEmptyParts)` 拆段,循环 4 段:`i < parts.size()` 取 `parts[i].toInt()`,失败补 0、`std::clamp(val,0,255)` 夹越界、`edit->setText(QString::number(val))`;`i >= parts.size()` 缺段补 `"0"`。**关键**:每段 `setText` 前包 `QSignalBlocker blocker(edit)`,循环结束后统一 `emit textChanged(text())` 一次。`isValid()` 4 段都 0-255、非空、纯数字,额外用 `any` 标志记录是否有非 0 段,全 0 返回 false。`clear()` 每段 `QSignalBlocker` 包住 `edit->clear()`,末尾发一次 `textChanged`。 + +### 提示 + +- **`text()` 兜底失败段**:`raw.toInt(&ok)` 失败(空串、非数字)就 append `"0"` 而不是空串——否则空控件读出来是 `"..."`。这和「空段补 0」的语义一致 +- **`setText` 先判空串**:`if (ip.isEmpty()) { clear(); return; }`。不判的话 `split` 返回空 list,循环全走缺段补 0 分支,结果还是全 0——能跑但多走弯路,且语义上空串就该清空而非填 0 +- **`SkipEmptyParts` 而非 `KeepEmptyParts`**:`"192..1.1"` 这种双点用 `SkipEmptyParts` 拆出 `["192","1","1"]`,第 4 段缺补 0。用 `KeepEmptyParts` 会拆出空串元素,`toInt` 失败补 0 也行但多一道弯 +- **`std::clamp` 夹越界**:`val = std::clamp(val, 0, 255)`——`999` 夹成 `255`、负数夹成 `0`。别忘了 cpp 顶部 `#include ` +- **`QSignalBlocker` 是本步核心**:每段 `edit->setText()` 都会触发子段 `textChanged`,step 1 那条转发 lambda 就会各 emit 一次整体 `textChanged`——4 段就 4 次假通知。`QSignalBlocker blocker(edit)` 在作用域内屏蔽该控件所有信号,出了作用域自动恢复(RAII)。循环里每段包一个,循环外统一发一次真通知 +- **`isValid` 的 `any` 标志**:常规判「4 段非空 + 0-255 + 纯数字」外,加个 `bool any=false`,任一段 `val != 0` 就置 true,最后 `return any`。这样 `"0.0.0.0"` 返回 false(视为未填写)。严格语义场景就把 `any` 摘掉、直接 `return true` + +### 关键认知——为什么信号抑制必须做 + +不做 `QSignalBlocker`,外部接到的 `textChanged` 是这样的:调一次 `setText("192.168.1.1")`,第 1 段 `setText("192")` 触发 → 整体重拼 emit `"0.168.1.1"`(此时只有第 1 段填了,其余还是旧值或空)→ 第 2 段填完又 emit `"192.0.1.1"` …… 中间这几个「半成品」地址全是脏数据,外部回显会闪几次。`QSignalBlocker` 把中间过程全部静音,只在 4 段都填完后发一次「最终态」——干净。`clear()` 同理。 + +### 检查点 + +跑起来用 demo 的预设按钮测边界:按 `setText("999.1.1.1")` 显示 `255.1.1.1`(越界夹 255);按 `setText("1.2.3")` 显示 `1.2.3.0`(缺段补 0);按 `setText("a.b.c")` 显示 `0.0.0.0`(非数字补 0);按 `setText("")` 全清空。每次预设只触发一次外部回显更新(不闪多次)。`isValid` 在填 `192.168.1.1` 时合法、全 0 时非法、任一段空时非法。`clear()` 后 4 段全空、回显清一次。 + +> 信号阻塞不熟?[QSignalBlocker](https://doc.qt.io/qt-6/qsignalblocker.html)。字符串拆分?[QString::split](https://doc.qt.io/qt-6/qstring.html#split)。 + +### 对照答案 + +- `text()` 空段补 0 拼装:`src/ip_edit.cpp:76` +- `setText` 空串走 clear + SkipEmptyParts 拆分:`src/ip_edit.cpp:90` +- 每段 `QSignalBlocker` + `std::clamp` 夹值 + 缺段补 0:`src/ip_edit.cpp:104` +- 末尾统一 `emit textChanged`:`src/ip_edit.cpp:117` +- `isValid` 全 0 判 false(`any` 标志):`src/ip_edit.cpp:123` +- `clear` 每段 `QSignalBlocker` 屏蔽:`src/ip_edit.cpp:149` + +--- + +三步搓完,对照成品 `widget/ip-edit/` 应该功能一致。想再深一层(IPv6、粘贴分发、单段子类化)回 [手册首页](./index.md) 的「进阶挑战」。成品全貌看 [成品导览](../)。 diff --git a/tutorial/engineering/instances/widget/ip-edit/handbook/index.md b/tutorial/engineering/instances/widget/ip-edit/handbook/index.md new file mode 100644 index 0000000..825cdf0 --- /dev/null +++ b/tutorial/engineering/instances/widget/ip-edit/handbook/index.md @@ -0,0 +1,74 @@ +--- +title: "IpEdit 手搓手册" +description: "从空壳 4 个 QLineEdit 一行行搓出 IpEdit:3 步打通组合控件骨架 + 跨段跳焦、0-255 校验与按键流转、地址拼装回填 + 信号抑制。" +--- + +# IpEdit 手搓手册 + +> **source**:成品答案在 `widget/ip-edit/`(做完对照)· **related**:组合控件递进链第 2 环(上一站 status-led) · 教程层 [QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md) / [事件处理进阶](../../../../../advanced/03-qtwidgets/02-event-handling-advanced.md) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 IpEdit,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **组合多个子控件成一个复合输入控件**:4 个 `QLineEdit` + 3 个 `QLabel` 挂进一个 `QWidget`,地址状态不存成员、永远从子段现拼 +- **重写 `keyPressEvent` 做跨子控件焦点流转**:拦截 `.`/`BackSpace`/`Return`,自己定位当前段、决定跳下段还是跳上段(step 2 重头) +- **`textEdited` + lambda 按值捕获段索引**:满 3 位自动跳下段,绕开 `sender()` 反查和未定义槽的坑 +- **地址拼装与回填**:`text()` 空段补 0、`setText` 用 `SkipEmptyParts` 拆分 + `std::clamp` 夹越界 + 缺段补 0(step 3) +- **程序化回填抑制中间信号**:`QSignalBlocker` 包住每段子 `setText`,末尾统一发一次 `textChanged`,避免外部接到一堆假通知 + +## 1. 起点 + +先有个能跑的空壳:一个 `QWidget` 里塞 4 个 `QLineEdit`、中间夹 3 个点。main 里弹窗: + +```cpp +#include +#include +#include +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + auto* layout = new QHBoxLayout(&w); + for (int i = 0; i < 4; ++i) { + auto* edit = new QLineEdit(&w); + edit->setMaxLength(3); + edit->setFixedWidth(40); + layout->addWidget(edit); + if (i < 3) { + layout->addWidget(new QLabel(".", &w)); + } + } + w.resize(240, 32); + w.show(); + return app.exec(); +} +``` + +弹出 4 个能各自打字的输入框、中间有点隔开 = 环境通了,往下走。这一步用的是裸 `QLineEdit`,还没封装、还不能跳焦——下一步我们就把它包进 `IpEdit` 类并接上跨段流转。QLineEdit 不熟先看 [QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 组合骨架 + 4 段构造 + 信号接线 + QIntValidator(先把地址拼出来) | [01](./01-compose-and-signals.md) | +| 2 | 跨段焦点流转(重写 keyPressEvent:`.` 跳下段消费点、段首退格跳上段、回车分发) | [02](./02-focus-flow.md) | +| 3 | 地址拼装回填 + 边界夹值 + 信号抑制(text/setText/isValid/clear) | [03](./03-address-roundtrip.md) | + +成品对照:`widget/ip-edit/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **支持 IPv6**:IPv6 是 8 段十六进制(`::` 缩写),段长从 3 变 4、分隔符从 `.` 变 `:`、validator 从 `QIntValidator(0,255)` 换成正则 `[0-9a-fA-F]{1,4}`。思考:`::` 缩写在 `text()`/`setText()` 里怎么表达?要不要展开成完整 8 段存? +- **粘贴整段地址自动分发**:现在粘贴 `"192.168.1.1"` 进某一格只会塞进那一格。想在任一段粘贴时自动拆分填进 4 段——重写 `keyPressEvent` 拦 `Ctrl+V`,或连 `QLineEdit` 的自定义子类重写 `insert`。提示:用 `QApplication::clipboard()->text()` 取剪贴板内容再 `setText`。 +- **从 `QLineEdit` 子类化做单段**:现在焦点流转全在 `IpEdit::keyPressEvent` 里靠「遍历找 `hasFocus()` 的段」定位当前段。若把单段提成 `class OctetEdit : public QLineEdit`,每个段自己处理自己的按键,`IpEdit` 只管段间跳转——职责更清晰。思考:跨段退格时上段的 `backspace()` 由谁触发? +- **下一站**:MAC 地址框(6 段十六进制)、版本号框(语义化版本 `major.minor.patch`)——复用本件的跳焦骨架,换段数和 validator 即可。 diff --git a/tutorial/engineering/instances/widget/ip-edit/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/ip-edit/handbook/troubleshooting.md new file mode 100644 index 0000000..feb2c99 --- /dev/null +++ b/tutorial/engineering/instances/widget/ip-edit/handbook/troubleshooting.md @@ -0,0 +1,65 @@ +--- +title: "卡住怎么办" +description: "按症状查:cpp 报 incomplete type QLabel、senderIndex 未定义、按点号下段冒出多余点、setText 触发一堆 textChanged、段首退格跳不回、Tab 进控件不亮——给方向指向成品 file:行号,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/ip-edit/`,对照着看。 + +## cpp 编译报 `invalid use of incomplete type 'class AwesomeQt::QLabel'`(`new QLabel` 那行) + +- 头文件里是不是写了 `namespace AwesomeQt { class QLabel; }` 前向声明?编译器会把 `QLabel` 当成 `AwesomeQt` 命名空间内的类,和全局 `::QLabel` 不是同一类型,cpp 里 `new QLabel` 时类型不完整。→ 删掉命名空间内的前向声明,头文件顶部直接 `#include `(QLabel 是非模板 Qt 公有类,include 安全):`include/ip_edit.h:8` +- 同理检查 `QLineEdit` 也别前向声明——它一样要直接 include:`include/ip_edit.h:9` +- 进阶排查:[QLabel](https://doc.qt.io/qt-6/qlabel.html) / [QLineEdit](https://doc.qt.io/qt-6/qlineedit.html) + +## cpp 编译报 `'senderIndex' was not declared in this scope` + +- 是不是在 `onOctetTextChanged` 槽里调了 `senderIndex()` 想取发送者段索引?那函数压根没定义。→ 改在 `connect(textEdited, ...)` 的 lambda 里**按值捕获段索引 `i`**,满位判断内联进 lambda,删掉槽声明与定义:`src/ip_edit.cpp:37` +- 别用 `sender()` 反查再 `qobject_cast`——构造循环里捕获 `i` 干净得多,也避免运行时类型转换 +- 进阶排查:[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +## 按 `.` 跳下段后,下一段里冒出一个多余的 `.` + +- `keyPressEvent` 的 `Key_Period` 分支是不是只 `focusNextOctet(current)` 没 `event->accept()`?没 accept 事件继续冒泡,子 `QLineEdit` 默认处理把点号当字符输入。→ 跳段后 `event->accept()` 消费掉点号:`src/ip_edit.cpp:220` +- 检查 `Key_Comma`(兼容小键盘/中文输入习惯)分支也 accept 了:`src/ip_edit.cpp:216` +- 进阶排查:[QKeyEvent](https://doc.qt.io/qt-6/qkeyevent.html) + +## 调一次 `setText` 外部接到 4 次 `textChanged`(回显闪好几次) + +- `setText` 循环里每段 `edit->setText()` 是不是没屏蔽信号?每段触发子段 `textChanged`、step 1 那条转发 lambda 各 emit 一次整体地址,4 段就 4 次假通知(中间还是半成品地址)。→ 每段回填前包 `QSignalBlocker blocker(edit)`(RAII 出作用域自动恢复),循环结束后统一 `emit textChanged(text())` 一次:`src/ip_edit.cpp:104` / `src/ip_edit.cpp:117` +- `clear()` 同样要屏蔽,否则清 4 段也发 4 次:`src/ip_edit.cpp:155` +- 进阶排查:[QSignalBlocker](https://doc.qt.io/qt-6/qsignalblocker.html) + +## 段首退格跳不回上段,或跳回了却删错位 + +- 三个条件是不是漏了一个?必须同时满足 `edit->text().isEmpty()`(段空)+ `edit->cursorPosition() == 0`(光标在段首)+ `current > 0`(非首段)才跳。漏判「段空」会导致段里有字也被跳走。→ `src/ip_edit.cpp:233` +- 跳回上段后是不是没把光标置段末?`prev->setCursorPosition(prev->text().length())` 把光标移到段末,`prev->backspace()` 才能删掉末位。不置段末,`backspace()` 删的位置不对。→ `src/ip_edit.cpp:237` +- 进阶排查:[QLineEdit::backspace](https://doc.qt.io/qt-6/qlineedit.html#backspace) + +## Tab 进控件后 4 段全不亮,得再按一次 Tab 才进第一段 + +- 壳 `QWidget` 没设焦点代理,Tab 聚焦到壳本身而非子段。→ 构造末尾 `setFocusProxy(octets_[0])` + `setFocusPolicy(Qt::StrongFocus)`:`src/ip_edit.cpp:69` +- 检查 `octets_[0]` 在 `setFocusProxy` 调用时已经建好了(在构造循环之后调):`src/ip_edit.cpp:28` 的循环跑完才有 `octets_[0]` +- 进阶排查:[QWidget::setFocusProxy](https://doc.qt.io/qt-6/qwidget.html#setFocusProxy) + +## 子段里能敲进字母(以为校验失效) + +- 每段是不是漏挂 `QIntValidator`?挂上后非数字根本敲不进。→ 构造里 `edit->setValidator(new QIntValidator(0, 255, this))`:`src/ip_edit.cpp:26`(4 段共用同一个 validator,循环外建一次) +- 注意:`QIntValidator` 只在**用户键盘输入**时拦截;程序化 `setText("abc")` 它拦不住,得靠 `setText` 内部 `toInt` 失败补 0 兜底。→ `src/ip_edit.cpp:108` +- 进阶排查:[QIntValidator](https://doc.qt.io/qt-6/qintvalidator.html) + +## `setText("999.1.1.1")` 显示成 `999.1.1.1`(越界没夹) + +- `setText` 循环里是不是漏了 `std::clamp(val, 0, 255)`?→ 每段取值后 `val = std::clamp(val, 0, 255)`:`src/ip_edit.cpp:111` +- 别忘了 cpp 顶部 `#include `,否则 `std::clamp` 未声明:`src/ip_edit.cpp:9` +- 缺段(如 `"1.2.3"`)补 0 也别漏:`i >= parts.size()` 分支 `edit->setText("0")`:`src/ip_edit.cpp:114` + +## moc 报错(Q_PROPERTY 不认识) + +- 类里**有没有 `Q_OBJECT`**?IpEdit 有 Q_PROPERTY,漏了 moc 不生成元对象。→ `include/ip_edit.h:23` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- Q_PROPERTY 的 `placeholderHint` 三件套(READ/WRITE/NOTIFY)是不是齐全且名字一致?→ `include/ip_edit.h:26` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/ip-edit/index.md b/tutorial/engineering/instances/widget/ip-edit/index.md new file mode 100644 index 0000000..4467f6f --- /dev/null +++ b/tutorial/engineering/instances/widget/ip-edit/index.md @@ -0,0 +1,156 @@ +--- +title: "IpEdit 成品导览" +description: "IPv4 地址输入控件成品:4 个八位段 QLineEdit + 点分隔 + 自动跳焦 + 0-255 校验,附架构、设计决策、踩坑与阅读路径。" +--- + +# IpEdit 成品导览 + +> **source**:`widget/ip-edit/` **related**:组合控件递进链第 2 环(上一站 status-led)· 教程层 [QLineEdit 入门](../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md) / [事件处理进阶](../../../../advanced/03-qtwidgets/02-event-handling-advanced.md) + +IpEdit 是个 IPv4 地址输入框——网络配置面板上那种四段用点隔开的小方块。它本身不复杂,但要做得「顺手」很吃细节:满 3 位自动跳下段、按点号跳段还要把点吃掉、段首退格要跳回上段继续删、`setText("999.1.1.1")` 得夹回 `255.1.1.1`。我们把它当实例库第二件成品,因为它把「组合多个子控件 + 重写按键事件做跨子控件焦点流转 + 程序化回填要抑制中间信号」这套范式占全了——后面凡是要把几个原生控件拼成一个「会用」的复合输入控件,都照这套骨架走。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::IpEdit` 控件: + +- **4 段八位输入**:4 个 `QLineEdit`(`octets_[0..3]`)用 3 个 `QLabel(".")` 分隔,`QHBoxLayout` 排开,每段 `maxLength(3)` + 居中 + `QIntValidator(0,255)` 双保险 +- **跨段焦点流转**:满 3 位合法自动跳下段、按 `.`(兼容 `,`)跳下段且把点号消费掉、段首退格且段空则跳回上段继续删末位、任意段回车非末段跳下段 +- **双向地址 API**:`text()` 拼 `"a.b.c.d"`(空段补 0);`setText()` 按点拆分、越界段 `std::clamp(0,255)`、缺段补 0、非数字段补 0、空串全清 +- **可被 Designer 驱动**:`Q_PROPERTY(QString placeholderHint)` 同步下发到 4 个子段,`textChanged` / `editingFinished` 两个信号对外 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/ip-edit/demo/ip_edit_demo +``` + +## 2. 架构总览 + +### 类关系 + +`IpEdit` 继承 `QWidget`,把 4 个 `QLineEdit` 和 3 个 `QLabel` 当**子控件**用对象树托管,自己只负责拦截按键做跨段焦点流转和地址拼装: + +```mermaid +classDiagram + class IpEdit { + +Q_PROPERTY QString placeholderHint + +QString text() + +void setText(QString) + +bool isValid() + +void clear() + -keyPressEvent(QKeyEvent*) + -focusNextOctet(int) + -focusPrevOctet(int) + } + class QLineEdit { + octets_[0..3] 每段 0-255 + } + class QLabel { + dots_[0..2] 点分隔 + } + class QIntValidator { + 0-255 双保险 + } + IpEdit o-- QLineEdit : octets_[4] + IpEdit o-- QLabel : dots_[3] + QLineEdit ..> QIntValidator : setValidator +``` + +关键点:4 个 `QLineEdit` 是平级兄弟控件,`IpEdit` 自己**不存地址状态**——地址永远从 4 个子段的 `text()` 现拼。这避免了「成员变量与子控件显示不一致」的同步地狱,代价是每次 `text()` 都要遍历 4 段(4 段无所谓,开销可忽略)。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/ip_edit.h` | 接口:Q_PROPERTY(placeholderHint) + 公有 API(text/setText/isValid/clear)+ `keyPressEvent` 重写声明 | +| `src/ip_edit.cpp` | 实现:4 段构造与信号接线 / 地址拼装与回填 / 按键拦截做跨段焦点流转 | +| `demo/ip_edit_window.cpp` | 演示:输入回显 / 预设地址 setText / 清空 / 越界·缺段·非数字·空串边界校验 | + +### 按 `.` 跳段怎么跑起来 + +```mermaid +sequenceDiagram + participant U as 用户 + participant E as IpEdit.keyPressEvent + participant Cur as 当前段 octets_[current] + participant Nxt as 下一段 octets_[current+1] + U->>E: 按下 '.' (Key_Period) + E->>E: 找出 hasFocus() 的段 current + alt 非末段 + E->>Nxt: focusNextOctet → setFocus + selectAll + E->>E: event->accept() 把点号消费掉 + Note over Nxt: 点不会输入到下一段 + else 末段 + E->>E: emit editingFinished() + E->>E: event->accept() + end +``` + +重点在 `event->accept()`——若不消费这个点号,`QLineEdit` 会把它当作正常字符塞进当前段,你就在 IP 框里看到一个多余的 `.`。`accept()` 之后事件不再向上冒泡、也不交给子控件的默认处理,点号就被「吃掉」了。 + +## 3. 关键设计决策 + +**① 焦点流转走 `keyPressEvent`,不靠 `focusNextChild`。** +`focusNextChild` 走的是 Qt 的 Tab 焦点链,它不会帮你拦截 `.`,也不会判断「段首退格」这种上下文。我们在 `IpEdit` 这一层重写 `keyPressEvent`(`src/ip_edit.cpp:198`),自己定位当前聚焦段、按键类型分发:`.`/`,` 跳下段并 `accept()` 消费点号,段首 `BackSpace` 且段空则 `prev->setFocus()` + `prev->backspace()` 继续删上段末位,回车非末段跳下、末段发 `editingFinished`。控制权牢牢在自己手里。 + +**② 满 3 位自动跳下段,用 lambda 按值捕获段索引 `i`。** +构造时给每个 `octets_[i]` 连 `textEdited`,lambda 里直接用捕获的 `i` 做判断(`src/ip_edit.cpp:37`)——满 3 位且 `toInt()` 在 0-255 就 `focusNextOctet(i)`。这绕开了一个坑:初版想用私有 `senderIndex()` 取发送者段索引,可那函数压根没定义,编译直接报错。按值捕获 `i` 既不用 `sender()` 反查、也避免了未定义引用,是 Qt lambda 接线的标准姿势。 + +**③ `text()` 空段补 0、`setText` 用 `SkipEmptyParts` 拆分 + `std::clamp` 夹值。** +`text()`(`src/ip_edit.cpp:76`)遍历 4 段,每段 `toInt()` 失败就补 `"0"`,最后 `join('.')`——所以空控件读出来是 `"0.0.0.0"` 而不是 `"..."`。`setText`(`src/ip_edit.cpp:90`)反过来:`split('.', Qt::SkipEmptyParts)` 拆段,每段 `std::clamp(val,0,255)` 夹越界(`999` → `255`),`toInt()` 失败补 0(`"a"` → `0`),段数不足补 0(`"1.2.3"` → `1.2.3.0`),空串走 `clear()`。每段回填用 `QSignalBlocker` 抑制中间信号、末尾统一发一次 `textChanged`。 + +**④ `isValid()` 把全 0 视为「未填写」判 false。** +`"0.0.0.0"` 语法上合法,但在表单场景里几乎没人真填这个地址,留着它当合法值会让「必填校验」形同虚设。所以 `isValid()`(`src/ip_edit.cpp:123`)除常规的「4 段都 0-255、非空、纯数字」外,额外用一个 `any` 标志记录「是否有任意段非 0」,全 0 就返回 false。要严格语义就把这个 `any` 判断摘掉。 + +**⑤ 头文件直接 `#include ` / ``,不在命名空间里前向声明。** +`QLabel` / `QLineEdit` 是非模板 Qt 公有类,直接 include 安全无开销(`include/ip_edit.h:8`)。初版图省事在 `namespace AwesomeQt { class QLabel; }` 里前向声明,编译器把 `QLabel` 当成 `AwesomeQt` 命名空间内的类,和全局 `::QLabel` 不是同一类型,cpp 里 `new QLabel` 时报 `incomplete type`——见踩坑①。直接 include 一劳永逸。 + +**⑥ STATIC 库 + `add_subdirectory(demo)`,CMake 严格照 status-led 模板。** +`widget/ip-edit/CMakeLists.txt` 只做三件事:`add_library(ip_edit STATIC ...)`、`target_include_directories(... PUBLIC include)`、`target_link_libraries(... PUBLIC Qt6::Core/Gui/Widgets)`。**不在此 set C++ 标准、不 find_package**——这些由根 `widget/CMakeLists.txt` 统一提供,子库重复设会和兄弟库打架。 + +**⑦ `setFocusProxy(octets_[0])` + `StrongFocus`:Tab 进控件直落第一段。** +不加这两行,Tab 聚焦到 `IpEdit` 这个空壳 `QWidget` 上时,4 个子段哪个都不亮,用户得再按一次 Tab 才进第一段。`setFocusProxy`(`src/ip_edit.cpp:69`)把壳的焦点代理给第一段,Tab 一进来光标就在 `octets_[0]` 里。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **构造里的 4 段循环 + 信号接线**(`src/ip_edit.cpp:28`)——先看「4 段怎么建、`textEdited`/`textChanged`/`editingFinished` 三条信号各连什么」 +2. **`keyPressEvent`**(`src/ip_edit.cpp:198`)——跨段焦点流转核心,盯 `Key_Period` 分支的 `accept()` 和 `Key_Backspace` 分支的 `prev->backspace()` +3. **`text()`**(`src/ip_edit.cpp:76`)——地址拼装,空段补 0 的兜底 +4. **`setText()`**(`src/ip_edit.cpp:90`)——回填核心,`SkipEmptyParts` + `std::clamp` + `QSignalBlocker` 三件套怎么配合 +5. **`isValid()`**(`src/ip_edit.cpp:123`)——全 0 判 false 的 `any` 标志 +6. **`focusNextOctet` / `focusPrevOctet` / `octet`**(`src/ip_edit.cpp:267`)——焦点辅助,`octet()` 的边界保护 + +入口:`demo/main.cpp` → `demo/ip_edit_window.cpp` 跑起来,对照读。 + +## 5. 踩坑 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | cpp 编译报 `invalid use of incomplete type 'class AwesomeQt::QLabel'`(line 60 `new QLabel`) | 头文件里写了 `namespace AwesomeQt { class QLabel; }` 前向声明,编译器把 `QLabel` 当成命名空间内的类,与全局 `::QLabel` 不是同一类型,cpp 里 `new QLabel` 时类型不完整 | **编译失败** | 删掉命名空间内的前向声明,头文件顶部直接 `#include `(`include/ip_edit.h:8`) | +| ② | cpp 编译报 `error: 'senderIndex' was not declared` | 初版 `connect(textEdited)` 连私有槽 `onOctetTextChanged`,槽内想取发送者段索引却调了不存在的 `senderIndex()` | **编译失败** | 改在 `connect` 的 lambda 里按值捕获段索引 `i`,满 3 位判断内联进 lambda,删掉槽声明与定义(`src/ip_edit.cpp:37`) | +| ③ | 按 `.` 跳段后,下一段里出现一个多余的 `.` | `keyPressEvent` 里只 `focusNextOctet()` 没 `event->accept()`,事件继续冒泡交给子 `QLineEdit` 默认处理,点号被当字符输入 | 视觉脏数据(如某段里多出一个 `.`) | 跳段后 `event->accept()` 消费点号(`src/ip_edit.cpp:220`) | +| ④ | `setText("192.168.1.1")` 触发了一堆 `textChanged` | 每段 `edit->setText()` 各发一次子段 `textChanged`,外层 lambda 各转发一次,按钮点一下回显闪 4 次 | 假信号、外部回显抖动 | 每段回填包 `QSignalBlocker`,末尾统一 `emit textChanged(text())` 一次(`src/ip_edit.cpp:104`) | +| ⑤ | 段首退格没跳回上段,或跳回了却删不掉上段末位 | 只 `prev->setFocus()` 没把光标置段末、没调 `prev->backspace()`;或忘了判「段空 + 光标在段首」就跳,导致段里有字也被跳走 | 交互别扭(要么跳不回,要么跳回删错位) | 条件齐全(`isEmpty() && cursorPosition()==0 && current>0`)才跳,跳后 `setCursorPosition(length())` + `backspace()`(`src/ip_edit.cpp:233`) | +| ⑥ | Tab 进控件后 4 段全不亮,得再按一次 Tab 才进第一段 | 壳 `QWidget` 没设焦点代理,Tab 聚焦到壳本身而非子段 | 交互卡顿(多一次按键) | `setFocusProxy(octets_[0])` + `setFocusPolicy(Qt::StrongFocus)`(`src/ip_edit.cpp:69`) | +| ⑦ | 子段里直接敲字母居然能输进去(以为校验失效) | `QIntValidator` 只校验「最终能否解释为整数」,单字母在它看来无效会被拒——但若误用了 `QRegularExpressionValidator` 或漏挂 validator 就会放行 | 脏数据进段(实际本控件已正确挂 `QIntValidator`,敲不进字母) | 构造时 `edit->setValidator(new QIntValidator(0,255,this))`(`src/ip_edit.cpp:26`),非数字根本敲不进 | + +## 6. 官方文档 + +- [QLineEdit](https://doc.qt.io/qt-6/qlineedit.html)——单行文本编辑(4 段的基底) +- [QIntValidator](https://doc.qt.io/qt-6/qintvalidator.html)——0-255 整数校验双保险 +- [QKeyEvent](https://doc.qt.io/qt-6/qkeyevent.html)——按键拦截做跨段焦点流转 +- [QWidget::setFocusProxy](https://doc.qt.io/qt-6/qwidget.html#setFocusProxy)——Tab 进控件直落第一段 +- [QSignalBlocker](https://doc.qt.io/qt-6/qsignalblocker.html)——程序化回填抑制中间信号 +- [QString::split](https://doc.qt.io/qt-6/qstring.html#split)——按点拆分地址段 + +--- + +这套机制(组合多个子控件 + 重写按键做跨子控件焦点流转 + 程序化回填抑制信号)不是 IpEdit 专属——它就是「把几个原生输入控件拼成一个顺手的复合输入控件」的标准范式。想做 MAC 地址框、版本号框、电话号码分段框,照这套骨架换分隔符和段数即可。想自己搓?[手搓手册](./handbook/)带你从空 main 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/line-chart/handbook/01-axes-and-data.md b/tutorial/engineering/instances/widget/line-chart/handbook/01-axes-and-data.md new file mode 100644 index 0000000..3ac3f52 --- /dev/null +++ b/tutorial/engineering/instances/widget/line-chart/handbook/01-axes-and-data.md @@ -0,0 +1,41 @@ +--- +title: "Step 1:plot 区 + 坐标轴 + 数据 API" +description: "定 margin 算 plot 矩形,画左 Y 轴和下 X 轴,加 setData/appendPoint/clear 三个数据入口触发 update——折线图的空骨架。" +--- + +# Step 1:plot 区 + 坐标轴 + 数据 API + +← [手册首页](./index.md) · 下一步 [Step 2 auto-scale + 折线](./02-autoscale-and-line.md) → + +## Step 1:画出折线图的空骨架 + +### 目标 + +屏幕上出现一个**左 Y 轴 + 下 X 轴**围出来的 L 形坐标系(还不用画折线),并且能 `setData` 喂数据、控件收到数据就请求重绘。这一步先把「坐标变换的舞台」和「数据入口」搭起来——折线和 auto-scale 留到 step 2。 + +### 提示 + +- 继承 `QWidget`,重写 `protected void paintEvent(QPaintEvent*)`,开头 `QPainter p(this); p.setRenderHint(QPainter::Antialiasing);` +- 定义四个 margin 常量:左留大(48,给 Y 刻度数字)、下留中(28,给 X 标签)、上下右留小(12)。参考 `src/line_chart.cpp:19-22` +- 算 plot 区:`plot_left = kMarginLeft`、`plot_top = kMarginTop`、`plot_width = w - 左 - 右`、`plot_height = h - 上 - 下`、`plot_bottom = plot_top + plot_height`。宽高记得 `std::max(1.0, ...)` clamp(`src/line_chart.cpp:164-165`),否则窗口压极小时算出负值 +- 画坐标轴:左 Y 轴是一条从 `(plot_left, plot_top)` 到 `(plot_left, plot_bottom)` 的竖线,下 X 轴是从 `(plot_left, plot_bottom)` 到 `(plot_left+plot_width, plot_bottom)` 的横线(`src/line_chart.cpp:208-209`),用 `QPen(axisColor, 1)` +- 加数据成员 `QVector values_` 和三个入口:`appendPoint`(`append` + `update`)、`setData`(赋值 + `update`)、`clear`(空就 return、否则 `clear` + `update`)。注意 `clear` 要先判空再 return,省一次无谓重绘(`src/line_chart.cpp:48-54`) + +### 检查点 + +- 跑出来是 L 形坐标系(左竖线 + 下横线),窗口缩放 L 形跟着变、不会出负值 = plot 区算对了 +- `setData({1,2,3})` 后控件没崩(这步还没画折线,所以看不到线很正常),但你能通过调试或 step 2 验证数据进来了 +- `clear()` 后再画不崩 + +> QPainter 不熟?先读 [QPainter 绘图基础](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md) 和 [自定义绘制 Widget 基础](../../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md)。 + +### 对照答案 + +- 四个 margin 常量:`src/line_chart.cpp:19-22` +- plot 区 + clamp:`src/line_chart.cpp:162-166` +- 坐标轴两条线:`src/line_chart.cpp:207-209` +- 数据三个入口 + clear 判空:`src/line_chart.cpp:38-54` + +--- + +下一步是重头戏:[Step 2 加 Y 轴 auto-scale 和真正的折线](./02-autoscale-and-line.md)。 diff --git a/tutorial/engineering/instances/widget/line-chart/handbook/02-autoscale-and-line.md b/tutorial/engineering/instances/widget/line-chart/handbook/02-autoscale-and-line.md new file mode 100644 index 0000000..b8311ed --- /dev/null +++ b/tutorial/engineering/instances/widget/line-chart/handbook/02-autoscale-and-line.md @@ -0,0 +1,53 @@ +--- +title: "Step 2:Y 轴 auto-scale + 折线(核心)" +description: "用 std::minmax_element 算 Y 域,value→像素 to_y 闭包,QPolygonF + drawPolyline 画折线,单点居中 + min==max 防 ±padding 防除零。" +--- + +# Step 2:Y 轴 auto-scale + 折线(核心) + +← [Step 1](./01-axes-and-data.md) · [手册首页](./index.md) · 下一步 [Step 3 网格/点/填充](./03-grid-dots-area.md) → + +这一步是整个控件的核心——把 step 1 的「空坐标系」升级成「真正画出折线」。诀窍不在画线本身,而在**每次 paintEvent 现算 Y 域、用一个 `to_y` 闭包把数据值映射成像素 y**。 + +## Step 2:Y 轴 auto-scale + 画折线 + +### 目标 + +`setData({12, 28, 19, 45, 33})` 后,控件画出一条折线,Y 轴自动按数据的最小最大值缩放(最小值贴底、最大值贴顶),X 轴按索引均匀分布。并且:单点居中、`min==max` 不崩、空数据不崩。 + +### 提示(按顺序) + +1. **Y 轴 auto-scale**:`values_` 非空时,用 `std::minmax_element(values_.constBegin(), values_.constEnd())` 一次扫描同时拿 min/max(`src/line_chart.cpp:174`),比两次串行 `std::min` / `std::max` 干净 +2. **防除零**:拿到 min/max 后判 `qFuzzyCompare(y_min, y_max)`,成立(常量序列或单点)就 `y_min -= kFlatPadding; y_max += kFlatPadding`(`kFlatPadding = 1.0`,`src/line_chart.cpp:177-181`)。否则后面 `to_y` 分母 `y_range` 为零,点画飞 +3. **写 `to_y` 闭包**:`return plot_bottom - (v - y_min) / y_range * plot_height;`(`src/line_chart.cpp:186-188`)。注意是 `plot_bottom - ...`——Y 轴像素坐标往下递增,而数据值往上递增,所以要翻转 +4. **空数据 early return**:算完 Y 域、画完坐标轴后,若 `!has_data` 直接 return(`src/line_chart.cpp:225-227`),别去建空 QPolygonF +5. **建 QPolygonF**:`n==1` 时点居中(`plot_left + plot_width/2`),`n>=2` 时 `x = plot_left + plot_width * i / (n-1)`(`src/line_chart.cpp:233-234`)。**单点单独写分支**,否则 `n-1` 为零 NaN +6. **画线**:`p.setPen(QPen(lineColor, 2)); p.setBrush(Qt::NoBrush); p.drawPolyline(poly);`(`src/line_chart.cpp:253-255`) + +### 关键认知 + +- **auto-scale 每次 paintEvent 现算**,不缓存 min/max。数据一变 `update()` 一调,下一帧 Y 轴自动跟着值域走——这是 demo「追加随机点看 Y 轴漂移」的机制 +- **Y 轴像素要翻转**:`(v - y_min) / y_range` 算出的是「从下往上 0..1 的比例」,但 Qt 坐标系 y 往下递增,所以 `plot_bottom - 比例*plot_height` 才对 +- **三种退化情况都要防**:空数据(return)、单点(居中)、`min==max`(±padding)。任一个漏掉都会出 NaN 或崩溃,这是图表控件最容易踩的坑 + +### 检查点 + +- `setData({12, 28, 19, 45, 33, 52, 40, 61, 55, 48})` 画出一条起伏折线,最小值贴底、最大值贴顶 = auto-scale 对了 +- `setData({5})` 单点居中显示、不飞、不崩 = 单点分支对了 +- `setData({5,5,5})` 常量序列画成一条横线(不是飞到屏外)、不崩 = ±padding 对了 +- `clear()` 后只剩坐标轴、不崩 = 空数据 return 对了 + +> QPainterPath / 坐标变换不熟?[坐标变换](../../../../../beginner/02-qtgui/02-coordinate-transform-beginner.md)。容器遍历用 STL 算法?[容器](../../../../../beginner/01-qtbase/04-container-beginner.md)。 + +### 对照答案 + +- `std::minmax_element` 拿 Y 域:`src/line_chart.cpp:174-176` +- `qFuzzyCompare` + ±padding 防除零:`src/line_chart.cpp:177-181` +- `to_y` 闭包(含 Y 翻转):`src/line_chart.cpp:186-188` +- 空数据 early return:`src/line_chart.cpp:225-227` +- 单点居中 vs 均匀分布分支:`src/line_chart.cpp:233-234` +- drawPolyline 画折线:`src/line_chart.cpp:253-255` + +--- + +下一步:[Step 3 加网格、Y 刻度数字、数据点、线下填充](./03-grid-dots-area.md)。 diff --git a/tutorial/engineering/instances/widget/line-chart/handbook/03-grid-dots-area.md b/tutorial/engineering/instances/widget/line-chart/handbook/03-grid-dots-area.md new file mode 100644 index 0000000..50daf26 --- /dev/null +++ b/tutorial/engineering/instances/widget/line-chart/handbook/03-grid-dots-area.md @@ -0,0 +1,69 @@ +--- +title: "Step 3:网格 / Y 刻度数字 / 数据点 / 线下填充" +description: "showGrid 画横竖网格线,QFontMetrics 量宽右对齐 Y 刻度数字,showDots 画数据点小圆,showArea 用 QPainterPath 闭合到 plot bottom 半透明填充。" +--- + +# Step 3:网格 / Y 刻度数字 / 数据点 / 线下填充 + +← [Step 2](./02-autoscale-and-line.md) · [手册首页](./index.md) → + +折线已经画出来了,这一步给它加四个外观增强:背景网格、Y 轴刻度数字、数据点小圆、线下半透明填充。每个都用一个 `show*` 布尔属性控制,配 Q_PROPERTY 让 demo 能用勾选框实时切。 + +## Step 3:网格 + 刻度数字 + 数据点 + 线下填充 + +### 目标 + +折线上叠:①几条横向 + 竖向淡灰网格线(`showGrid`);②Y 轴左侧一列刻度数字(按 Y 域等分,最大值在上、最小值在下);③每个数据点一个小圆(`showDots`);④折线下方半透明同色填充(`showArea`)。四个开关都能在 demo 里实时勾选切换。 + +### 提示 + +**网格(`showGrid`,`src/line_chart.cpp:191-204`)**: + +- Y 方向 `kGridTickCount`(4)档横线,第 i 条的 y = `plot_top + plot_height * i / kGridTickCount` +- X 方向竖线只在 `n > 1` 时画,第 i 条的 x = `plot_left + plot_width * i / (n-1)`——和折线的 X 公式一致 + +**Y 刻度数字(`src/line_chart.cpp:211-222`)**: + +- 用 `QFontMetrics fm(p.font())`,第 i 档对应的值 v = `y_max - (y_max - y_min) * i / kGridTickCount`(i=0 在顶部对 y_max) +- `QString::number(v, 'f', 1)` 保留一位小数 +- `int text_w = fm.horizontalAdvance(text)` 量实际宽,`drawText(QPointF(plot_left - text_w - 4, y + fm.ascent()/2), text)` 右对齐到 Y 轴左、垂直按 baseline 居中。**别用固定起点 drawText**,否则数字位数一变就溢进折线区 + +**数据点(`showDots`,`src/line_chart.cpp:258-265`)**: + +- `p.setPen(Qt::NoPen); p.setBrush(lineColor);`,遍历 poly 每个点 `p.drawEllipse(pt, r, r)`,`r = 3` + +**线下填充(`showArea`,`src/line_chart.cpp:239-250`)**: + +- **只在 `n >= 2` 时画**(单点无面积可言) +- 用 `QPainterPath area`:`moveTo(poly.first().x(), plot_bottom)` → `addPolygon(poly)` → `lineTo(poly.last().x(), plot_bottom)` → `closeSubpath` +- fill 色 = `lineColor` 复制后 `setAlpha(60)` 半透明,和折线同色系;`p.setPen(Qt::NoPen); p.setBrush(fill); p.drawPath(area);` + +### 关键认知 + +- **四个开关都走 Q_PROPERTY**:`showGrid` / `showDots` / `showArea` 三个 bool + `lineColor` / `axisColor` / `gridColor` 三个 QColor,setter 全是「值未变 return → 赋值 → emit → update」(`src/line_chart.cpp:63-70` 等)。没有动画,所以 setter 不夹业务,也不会有 status-led 那种「WRITE 指错 → 递归栈溢出」的风险 +- **填充用 QPainterPath 而非 QPolygonF**:因为要 `moveTo(首点 x, plot_bottom)` 把起点接到 X 轴、`lineTo(末点 x, plot_bottom)` 把终点接到 X 轴,组成「折线 + 两条垂直边 + X 轴段」的闭合区域。`addPolygon` 加这两条 line 正好把首尾接到底 +- **Y 刻度数字的对齐是视觉关键**:QFontMetrics 量宽这步省不得,左对齐的标签和折线糊一团是这个控件最难看的 bug + +### 检查点 + +- 勾 `showGrid`:横竖淡灰线出现在背景,关掉消失 = 网格对了 +- Y 轴左侧一列数字(最大值在上、最小值在下),数字始终在 Y 轴**左**侧、不溢进折线区 = QFontMetrics 对齐对了 +- 勾 `showDots`:每个数据点出现小圆,关掉只剩线 = 数据点对了 +- 勾 `showArea`:折线下方出现半透明同色填充,关掉消失 = 填充对了 +- 换 `lineColor`:折线、点、填充颜色一起变(填充是同色半透明)= 同色系填充对了 + +> Q_PROPERTY / QPainterPath 不熟?[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)、进阶 [属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。QFontMetrics?[字体与文本渲染](../../../../../beginner/02-qtgui/04-font-text-rendering-beginner.md)。 + +### 对照答案 + +- 网格横竖线:`src/line_chart.cpp:191-204` +- Y 刻度数字 + QFontMetrics 右对齐:`src/line_chart.cpp:211-222` +- 数据点小圆:`src/line_chart.cpp:258-265` +- 线下填充 QPainterPath 闭合 + setAlpha(60):`src/line_chart.cpp:239-250` +- showGrid/showDots/showArea setter(纯赋值+emit+update):`src/line_chart.cpp:102-135` + +--- + +搓完了。跑 demo 对照成品:三栏(静态 + 交互追加随机点 + 外观开关)都能复现 = 你搓的和 repo 一致。 + +想再深?回 [手册首页](./index.md) 看进阶挑战(逐点生长动画 / 双序列对比 / hover tooltip / 下一站圆环进度)。 diff --git a/tutorial/engineering/instances/widget/line-chart/handbook/index.md b/tutorial/engineering/instances/widget/line-chart/handbook/index.md new file mode 100644 index 0000000..f519702 --- /dev/null +++ b/tutorial/engineering/instances/widget/line-chart/handbook/index.md @@ -0,0 +1,61 @@ +--- +title: "LineChart 手搓手册" +description: "从空 main 一行行搓出 LineChart:3 步打通纯 QPainter 折线图骨架、Y 轴 auto-scale、网格/数据点/线下填充与边界防护。" +--- + +# LineChart 手搓手册 + +> **source**:成品答案在 `widget/line-chart/`(做完对照)· **related**:自绘控件递进链(status-led · toggle-switch · **line-chart**)· 教程层 [自定义控件绘制入门](../../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md)、[QPainter 绘图基础](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 LineChart,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **纯 QPainter 自绘图表**:继承 QWidget + 重写 `paintEvent`,用 `drawLine` / `drawPolyline` / `drawEllipse` / `QPainterPath` 画出网格、坐标轴、折线、点、填充 +- **坐标变换**:把「数据值」映射成「像素坐标」的 `value → py` 闭包,理解 plot 区、margin、auto-scale 三者关系 +- **Q_PROPERTY 外观开关**:六个属性(lineColor / axisColor / gridColor / showGrid / showDots / showArea)配 READ / WRITE / NOTIFY,可被 Designer / 外部直接驱动 +- **边界全防护**:空数据 early return、单点居中、`min==max` 防 `±padding` 防除零——这是图表控件和普通自绘控件最不一样的地方 +- **QFontMetrics 做文本对齐**:量刻度数字宽度做右对齐,避免标签溢进折线区 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(100, 100); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步对应控件的一个真实实现阶段。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | plot 区 + 坐标轴 + 数据 API(先画出一个空框架,能 setData 触发重绘) | [01](./01-axes-and-data.md) | +| 2 | Y 轴 auto-scale + 折线(数据值映射成像素,画出真正的折线 + 边界防护) | [02](./02-autoscale-and-line.md) | +| 3 | 网格 / Y 刻度数字 / 数据点 / 线下填充(外观开关 + QFontMetrics 对齐) | [03](./03-grid-dots-area.md) | + +成品对照:`widget/line-chart/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **逐点生长动画**:给折线加一个「点数从 0 长到 n」的 `QPropertyAnimation`,让折线像心电图一样画出来。提示:暴露一个 `int visibleCount` 属性,paintEvent 只画前 `visibleCount` 个点。 +- **双序列对比**:加第二条折线,思考两条线共用一个 Y 域还是各自 auto-scale——这会牵出 `values_` 从单 `QVector` 变成「序列集合」的结构改动。 +- **鼠标 hover 显示数值**:重写 `mouseMoveEvent`,反查最近的数据点、画个 tooltip。提示:要存 poly 的像素坐标或在 hover 时重算。 +- **下一站**:圆环进度(circle-progress)或仪表盘(speed-meter)——换皮复用 QPainter 自绘骨架,但引入角度绘制和指针动画。 diff --git a/tutorial/engineering/instances/widget/line-chart/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/line-chart/handbook/troubleshooting.md new file mode 100644 index 0000000..42f9d80 --- /dev/null +++ b/tutorial/engineering/instances/widget/line-chart/handbook/troubleshooting.md @@ -0,0 +1,53 @@ +--- +title: "卡住怎么办" +description: "按症状查:折线飞屏/NaN、空数据崩溃、Y 刻度数字糊折线、单点画飞、demo 编译失败——给方向指向成品 file:行号,不给完整答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/line-chart/`,对照着看。 + +## 折线画飞 / 点跑到屏幕外 / NaN + +- Y 域退化时**有没有做 ±padding**?单点或常量序列会让 `y_max - y_min == 0`,`to_y` 分母为零得 NaN。判 `qFuzzyCompare(y_min, y_max)` 后给 `y_min -= 1; y_max += 1`。→ `src/line_chart.cpp:177-181` +- X 坐标单点**有没有单独写分支**?`i/(n-1)` 在 `n==1` 时分母为零。单点居中 `plot_left + plot_width/2`。→ `src/line_chart.cpp:233-234` +- `y_range` **是不是在 auto-scale 之外的地方被用到**了?auto-scale 必须在画线之前算完。 +- 进阶排查:[QPainter 绘图基础](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md)、[坐标变换](../../../../../beginner/02-qtgui/02-coordinate-transform-beginner.md) + +## 空数据时崩溃或表现诡异 + +- 空数据**有没有 early return**?画完网格/坐标轴后,若 `!has_data` 直接 return,别去建空 QPolygonF(`poly.first()/last()` 在空 polygon 上行为未定义)。→ `src/line_chart.cpp:225-227` +- `area` 填充**是不是只对 n>=2 才画**?单点没面积,硬画 `addPolygon` + `first()/last()` 可能出问题。→ `src/line_chart.cpp:239` +- 进阶排查:[容器](../../../../../beginner/01-qtbase/04-container-beginner.md) + +## Y 轴刻度数字和折线糊在一起 + +- 数字**是不是用了固定起点 drawText**?左对齐时数字位数一变就往右溢进折线区。用 `QFontMetrics::horizontalAdvance` 量宽,起点设 `plot_left - text_w - 4` 右对齐到 Y 轴左侧。→ `src/line_chart.cpp:219-221` +- 垂直对齐**是不是没做 baseline 修正**?`drawText(QPointF(x, y + fm.ascent()/2), text)` 让数字大致垂直居中在网格线上。→ `src/line_chart.cpp:221` +- 进阶排查:[字体与文本渲染](../../../../../beginner/02-qtgui/04-font-text-rendering-beginner.md) + +## 勾选框切不动外观 / 切了没反应 + +- `showGrid` / `showDots` / `showArea` 的 setter **末尾有没有 `update()`**?没 update 就不重绘。→ `src/line_chart.cpp:107`、`:121`、`:134` +- setter **有没有「值未变就 return」的守卫**?这步不是必须,但能省无谓重绘——注意别把 return 写在 update 后面。→ `src/line_chart.cpp:103-105` +- demo 里 `connect(checkbox, &QCheckBox::toggled, chart, &LineChart::setShowGrid)` 这种**函数指针语法**对得上吗?setter 签名是 `void setShowGrid(bool)`,和 `toggled(bool)` 一致。→ `demo/line_chart_window.cpp:95-97` +- 进阶排查:[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +## demo 编译失败(std::clamp / std::rand 找不到) + +- 用了 `std::clamp` **有没有 `#include `**?用了 `std::rand` **有没有 `#include `**?GCC 在 `-Wall` 下缺这俩头会直接编译失败。→ `demo/line_chart_window.cpp:9-10` +- 提醒:`qreal` 是 `double` 的 typedef,clamp 模板实参推导本身不一定报错,别指望编译器提醒你补头——STL 算法函数的 include 要显式写全。 +- 进阶排查:[C++ 标准库容器与算法](../../../../../beginner/01-qtbase/04-container-beginner.md) + +## moc 报错(Q_PROPERTY 不认识) + +- 头文件**有没有 `Q_OBJECT`**?→ `include/line_chart.h:23` +- CMake **有没有开 AUTOMOC**(根 `widget/CMakeLists.txt` 的 `set(CMAKE_AUTOMOC ON)`)? +- Q_PROPERTY 的 READ/WRITE/NOTIFY 三件**签名对得上**吗?getter `const`、setter 参数类型、signal 参数类型要和声明一致。 +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) + +--- + +实在卡死成品就是答案——但先自己拼。对照 `widget/line-chart/` 的 `src/line_chart.cpp` 逐段看,按 [成品导览](../) 的「怎么读」顺序最省力。 diff --git a/tutorial/engineering/instances/widget/line-chart/index.md b/tutorial/engineering/instances/widget/line-chart/index.md new file mode 100644 index 0000000..20c60d9 --- /dev/null +++ b/tutorial/engineering/instances/widget/line-chart/index.md @@ -0,0 +1,153 @@ +--- +title: "LineChart 成品导览" +description: "AwesomeQt::LineChart 纯 QPainter 折线图控件——Y 轴自动缩放、网格/数据点/线下填充三开关,附架构、设计决策、踩坑与阅读路径。" +--- + +# LineChart 成品导览 + +> **source**:`widget/line-chart/` **related**:自绘控件递进链(status-led · toggle-switch · **line-chart**)· 教程层 [自定义控件绘制入门](../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md)、[QPainter 绘图基础](../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md) + +折线图这种东西,第一反应是 `QtCharts`——拉个 `QChart`、塞个 `QLineSeries`,三行能出图。但真要把它嵌进一个紧凑的自绘仪表盘、跟隔壁自绘的 StatusLED 一个画风、还要按 theme 换色,QtCharts 那套就重了。所以这里我们用纯 `QPainter` 自己画一张折线图:**Y 轴自动缩放、可选网格/数据点/线下填充,整个控件就一个 `paintEvent`**。体积小、零外部依赖,却是「把 QPainter 坐标变换 + 边界防护 + Q_PROPERTY 三件套」一次性吃透的好载体。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::LineChart` 控件: + +- **纯 QPainter 折线图**,不依赖 QtCharts——一个 `paintEvent` 全包 +- **Y 轴自动缩放**:每次 `setData` / `appendPoint` 后,`paintEvent` 取当前数据的最小最大值现算 Y 域,X 轴按索引均匀分布 +- **三个外观开关**:`showGrid`(网格横竖线)、`showDots`(数据点小圆)、`showArea`(线下半透明填充),全走 `Q_PROPERTY`,可被 Designer / 外部直接 set +- **数据 API**:`appendPoint(qreal)` / `setData(QVector)` / `clear()`,外加 `data()` 取回 +- **边界全防护**:空数据不崩(画完坐标轴和空刻度后 early return)、单点居中、`min==max` 给 `±padding` 防除零 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/line-chart/demo/line_chart_demo +``` + +demo 三栏:静态预设数据(开了 area 看线下填充)、交互栏(追加随机点 / 重置 / 清空,追加时看 Y 轴跟着 auto-scale 漂移)、外观开关栏(三个勾选框实时切 grid/dots/area,还换了红色折线)。 + +## 2. 架构总览 + +### 类关系 + +整个控件**没有动画对象、没有定时器**——这是它和 status-led / toggle-switch 最大的区别。一个 `LineChart` 只持一个数据向量 `values_` 加几个外观属性,`paintEvent` 每次重绘时现算一切: + +```mermaid +classDiagram + class LineChart { + +Q_PROPERTY QColor lineColor + +Q_PROPERTY QColor axisColor + +Q_PROPERTY QColor gridColor + +Q_PROPERTY bool showGrid + +Q_PROPERTY bool showDots + +Q_PROPERTY bool showArea + +appendPoint(qreal) + +setData(QVector~qreal~) + +clear() + -paintEvent(QPaintEvent*) + -QVector~qreal~ values_ + } + class QPainter { + 自绘:网格/坐标轴/折线/点/填充 + } + LineChart ..> QPainter : paintEvent 内构造 +``` + +正因为它没有「数据入口」和「动画入口」之分——所有 setter 都是纯赋值 + `emit` + `update()`——结构比带动画的控件干净得多。`update()` 异步请求一次重绘,`paintEvent` 拿当前所有状态(`values_` + 六个属性)现画,画完即走。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/line_chart.h` | 接口:六个 `Q_PROPERTY` + 数据 API + 公有 getter/setter | +| `src/line_chart.cpp` | 实现:auto-scale Y、网格 / 坐标轴 / Y 刻度数字 / 折线 / 数据点 / 线下填充 | +| `demo/line_chart_window.{h,cpp}` | 演示:静态数据 + 交互(追加/重置/清空)+ 外观开关三栏 | + +### 一次重绘怎么跑起来 + +```mermaid +sequenceDiagram + participant U as 调用方 + participant L as LineChart + participant P as paintEvent + U->>L: appendPoint(42) + L->>L: values_.append(42); update() + Note over L: update() 异步,不立即画 + U->>P: 事件循环调度重绘 + P->>P: minmax_element 算 y_min/y_max + P->>P: 单点/min==max? 给 ±padding 防除零 + P->>P: 画网格 → 坐标轴 → Y 刻度数字 + P->>P: values_ 空? early return + P->>P: 建 QPolygonF,画折线/点/填充 +``` + +重点:auto-scale 是**每次 paintEvent 现算**,不缓存 min/max。数据一变 `update()` 一调,下一帧 Y 轴自动跟着数据的值域走——这正是 demo 里「追加随机点看 Y 轴漂移」的机制。 + +## 3. 关键设计决策 + +**① 没有强制动画,所以六个 setter 全是「纯赋值 + emit + update」——无需拆业务/动画两套入口。** +status-led 的 `color` 属性 WRITE 必须指 `setAnimatedColor`(纯赋值)而非 `setStatus`(会启动画),否则动画驱动 setter、setter 又启动画 → 递归栈溢出。这里需求明示「静态即可」,所以 `setLineColor` 这类 setter 直接赋值就完事,没有那层耦合风险。仍保留「值未变就 return」的守卫(`src/line_chart.cpp:64`),避免无谓重绘。 + +**② Y 轴 auto-scale 用 `std::minmax_element` 一次扫描同时拿 min/max。** +替代两次串行的 `std::min_element` + `std::max_element`——一趟扫描拿一对迭代器(`src/line_chart.cpp:174`)。数据量大了能少一半遍历;数据量小看不出差别,但写法更干净。取到后若 `qFuzzyCompare(y_min, y_max)`(常量序列或单点),给 `±kFlatPadding` 展开值域(`src/line_chart.cpp:177-181`),保证后面 `value → 像素` 的分母 `y_range` 恒大于零。 + +**③ X 坐标单点单独写分支,避免 `n-1` 除零。** +`n>=2` 时按 `i/(n-1)` 均匀分布在 plot 区;`n==1` 时点居中(`src/line_chart.cpp:233`)。不单独写、直接套 `i/(n-1)`,单点情况下分母为 0,NaN 会把点画飞。这是个一行 if 省掉一整类崩溃。 + +**④ 线下填充用 `QPainterPath` 闭合到 plot bottom,fill 用折线同色系 `setAlpha(60)`。** +`moveTo(首点 x, plotBottom)` → `addPolygon(poly)` → `lineTo(末点 x, plotBottom)` → `closeSubpath`(`src/line_chart.cpp:241-244`)。半透明同色填充视觉上和折线天然一致,不用再定第二个颜色属性。而且 area 只在 `n>=2` 时画(单点无面积可言,`src/line_chart.cpp:239`),`drawPolyline` / `drawEllipse` 则对任意 `n>=1` 安全。 + +**⑤ Y 刻度数字用 `QFontMetrics` 算宽后右对齐到 Y 轴左侧,不固定起点 drawText。** +直接 `drawText(x, y)` 会左对齐,数字位数一变就往右溢进折线区。这里 `fm.horizontalAdvance(text)` 算出实际宽,再 `drawText(plot_left - text_w - 4, y + fm.ascent()/2)`(`src/line_chart.cpp:221`)右对齐到 Y 轴左、垂直按 baseline 居中。这是自绘里很容易忽略但视觉差别很大的细节。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/line_chart.h` 的六个 `Q_PROPERTY`**(26-31 行)——先看「对外暴露哪些外观属性、数据 API 有哪几个」 +2. **数据入口 `appendPoint` / `setData` / `clear`**(`src/line_chart.cpp:38` / `:43` / `:48`)——就两三行:改数据 + `update()`,理解「数据变 → 触发重绘」这条主线 +3. **`paintEvent` 的 plot 区与 auto-scale**(`src/line_chart.cpp:162-188`)——边距常量、`minmax_element` 算 Y 域、`to_y` 闭包 +4. **网格 + 坐标轴 + Y 刻度数字**(`src/line_chart.cpp:191-222`)——尤其看 `QFontMetrics` 右对齐那段 +5. **空数据 early return**(`src/line_chart.cpp:225-227`)——边界防护的关键 +6. **折线 / 线下填充 / 数据点**(`src/line_chart.cpp:230-265`)——`QPolygonF` 构建、`QPainterPath` 闭合、单点居中分支 + +入口:`demo/main.cpp` → `LineChartWindow` 三栏布局,对照读。 + +## 5. 踩坑 + +这几个坑都是实现这个控件时真处理过的,代码里能逐条对上。 + +**坑 1:Y 轴值域退化(单点 / 常量序列)时除零,点画飞或 NaN。** +当数据只有一个点、或所有值都相等时,`y_max - y_min == 0`,`to_y` 里的分母 `y_range` 为零,`(v - y_min) / y_range` 得 `NaN` 或 `inf`,`drawPolyline` 拿到 NaN 坐标,点直接画飞或干脆不画。后果是常量数据折线消失、单点显示异常。解法是取到 min/max 后判 `qFuzzyCompare(y_min, y_max)`,成立就 `y_min -= kFlatPadding; y_max += kFlatPadding`(`src/line_chart.cpp:177-181`)展开值域,保证分母恒正。 + +**坑 2:数据为空时 QPolygonF 边界行为不可控,可能画到屏幕外。** +`values_` 空时若还往下走建 `QPolygonF`,`poly.first()` / `poly.last()`(area 填充要用)在空 polygon 上行为未定义;网格/坐标轴倒是能画,但折线和填充没意义。后果是空数据时控件表现不可预测,运气好空白、运气差崩。解法是画完网格和坐标轴、空刻度后立即 early return(`src/line_chart.cpp:225-227`),根本不碰 `poly`。 + +**坑 3:Y 刻度数字左对齐溢进折线区,跟折线糊成一团。** +直接 `drawText(x, y)` 默认左对齐,数字两位变三位时起点不变、终点往右挤进 plot 区,和折线重叠。后果是 Y 轴标签和折线互相糊,视觉脏。解法是用 `QFontMetrics::horizontalAdvance` 算每个数字的实际宽,起点设成 `plot_left - text_w - 4` 右对齐到 Y 轴左侧、垂直按 `y + fm.ascent()/2` 做 baseline 居中(`src/line_chart.cpp:219-221`)。 + +**坑 4:X 坐标套 `i/(n-1)` 公式,单点时分母为零。** +均匀分布公式 `plot_width * i / (n-1)` 在 `n==1` 时 `n-1` 为 0,结果 NaN,单点画飞。后果是单数据点显示异常。解法是单点单独写分支:`n==1` 时点居中(`plot_left + plot_width/2`),`n>=2` 才走除法(`src/line_chart.cpp:233-234`)。 + +**坑 5:demo 用了 `std::clamp` / `std::rand` 却漏 `` / ``,编译失败。** +demo 里追加随机点用了 `std::clamp(base + jitter, 0.0, 100.0)` 和 `std::rand()`,初次写漏 include。`qreal` 是 `double` 的 typedef,clamp 模板实参推导本身不太会报,但 GCC 在 `-Wall` 下对缺 `` 的 `std::rand`、缺 `` 的 `std::clamp` 都会直接编译失败。后果是构建一次红。解法是在 `demo/line_chart_window.cpp` 顶部补 `#include ` 和 `#include `(`demo/line_chart_window.cpp:9-10`)。一个插曲:这俩头文件是写完 demo 后 Edit 补的,提醒自己——STL 算法函数别凭感觉用,include 要显式写全。 + +## 6. 官方文档 + +- [QPainter](https://doc.qt.io/qt-6/qpainter.html)——绘图引擎,本控件的全部绘制基础 +- [QPolygonF](https://doc.qt.io/qt-6/qpolygonf.html)——折线点序列,`drawPolyline` 的输入 +- [QPainterPath](https://doc.qt.io/qt-6/qpainterpath.html)——线下填充的闭合路径 +- [QFontMetrics](https://doc.qt.io/qt-6/qfontmetrics.html)——量 Y 刻度数字宽度做右对齐 +- [The Property System(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html)——为什么六个外观属性能被 Designer 驱动 +- [QWidget](https://doc.qt.io/qt-6/qwidget.html)——自绘控件基类,`paintEvent` / `sizeHint` / `update` + +--- + +这套机制(纯 QPainter 坐标变换 + 边界全防护 + Q_PROPERTY 外观开关)就是「一个无动画、数据驱动的自绘图表控件」的标准骨架。和带动画的 status-led 互为对照——这里没有动画耦合的栈溢出风险,但要额外对付「数据退化时的除零」这一类图表专属陷阱。想自己搓?[手搓手册](./handbook/)带你从空 main 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/log-viewer/handbook/01-compose-and-append.md b/tutorial/engineering/instances/widget/log-viewer/handbook/01-compose-and-append.md new file mode 100644 index 0000000..a9b85b5 --- /dev/null +++ b/tutorial/engineering/instances/widget/log-viewer/handbook/01-compose-and-append.md @@ -0,0 +1,94 @@ +--- +title: "Step 1-3:组合骨架 + append 染色 + 时间戳" +description: "把 QPlainTextEdit 组合进 LogViewer(只读 / 等宽 / NoWrap),加 Level 枚举,用 QTextCursor 按级别套前景色 insertText,补时间戳前缀与便捷重载。" +--- + +# Step 1-3:组合骨架 + append 染色 + 时间戳 + +← [手册首页](./index.md) · 下一步 [Step 4-5 属性化 + 自动滚底](./02-autoscroll-and-trim.md) → + +## Step 1:LogViewer 骨架(组合 QPlainTextEdit) + +### 目标 + +做一个 `AwesomeQt::LogViewer : public QWidget`,内部持有一个只读 QPlainTextEdit,弹出来是一只等宽字体、不换行的只读文本框。这一步还不要染色,先让骨架立住。 + +### 提示 + +- 继承 `QWidget`,成员 `QPlainTextEdit* view_` +- 构造里 `view_ = new QPlainTextEdit(this)`——parent 设 this,让对象树托管生命周期 +- `view_->setReadOnly(true)`;`view_->setLineWrapMode(QPlainTextEdit::NoWrap)`——日志一行一行往下堆,不要自动换行 +- 等宽字体用 `QFontDatabase::systemFont(QFontDatabase::FixedFont)`,别按名字 `QFont("Monospace")` 取(系统不一定有)。引 `` 别忘了 +- 一个 `QVBoxLayout(this)`,`setContentsMargins(0,0,0,0)`,`addWidget(view_)` + +### 检查点 + +把 LogViewer 放进窗口 show 出来:一只只读、等宽、不换行的文本框,光标进去改不了字 = 骨架对了。 + +> 自定义控件 / 对象树不熟?[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)、[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)。 + +### 对照答案 + +- 构造组合 view:`src/log_viewer.cpp:18` +- 等宽字体 + NoWrap:`src/log_viewer.cpp:23` / `src/log_viewer.cpp:24` + +--- + +## Step 2:Level 枚举 + append 染色 + +### 目标 + +定义 `enum class Level { Info, Warning, Error }`,加 `Q_ENUM(Level)`。实现 `void append(Level level, const QString& message)`,每条 append 时按级别套前景色:Info 用默认前景色、Warning 暗黄、Error 红。 + +### 提示 + +- 枚举放类里,紧跟 `Q_ENUM(Level)`——moc 要认得这个枚举(后面 Q_PROPERTY / 外部信号槽会用到) +- 写私有 `QColor colorForLevel(Level) const`:Info 返回一个默认构造的 `QColor()`(`isValid()` 为 false,表示「用默认」),Warning 返回 `QColor(180,120,0)`,Error 返回 `QColor(200,0,0)` +- `append` 里:`QTextCursor cursor(view_->document()); cursor.movePosition(QTextCursor::End);` +- 取色后**只对有效色** `QTextCharFormat::setForeground`,Info 不调 setForeground——这样 Info 行跟着 view 默认前景色走,深浅主题都不出错 +- `cursor.insertText(line, fmt)` 一步把文字和颜色写进 document + +关键认知——**为什么 Info 要返回无效色**:硬编码黑或白都会在某个主题下不可读。让 Info 不设格式、用 view 默认前景色,控件在任何配色下都不崩。 + +### 检查点 + +调 `append(Level::Warning, "配置缺失")` 后看到一行暗黄字;`append(Level::Error, "连接失败")` 看到一行红字;`append(Level::Info, "启动完成")` 看到一行默认前景色字。切换系统深浅主题,Info 行始终可读 = 着色对了。 + +> 富文本操纵不熟?[QPlainTextEdit 入门](../../../../../beginner/03-qtwidgets/24-qplaintextedit-beginner.md)、[属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- Level 枚举 + Q_ENUM:`include/log_viewer.h:36-37` +- colorForLevel 三态色:`src/log_viewer.cpp:129` +- append 着色插入(只对有效色 setForeground):`src/log_viewer.cpp:45-54` + +--- + +## Step 3:时间戳前缀 + 便捷重载 + +### 目标 + +每条 append 的行格式是 `[HH:MM:SS] [LEVEL] message`,时间戳用 `QTime::currentTime().toString("HH:mm:ss")`。再加三个便捷重载 `appendInfo / appendWarning / appendError`,省得每次手写 `Level::`。时间戳可关(这一步先写死开,开关留到 step 4 升级 Q_PROPERTY)。 + +### 提示 + +- 拼行:`QString line;` 先按开关决定要不要加 `[HH:mm:ss]` + 空格前缀(这一步先恒加),再拼 `[LEVEL] message` +- 写一个静态 `levelLabel(Level)`:Info→`INFO`、Warning→`WARN`、Error→`ERROR` +- 文末非空时先补一个 `\n`:`if (!cursor.atStart()) cursor.insertText("\n");`——保证新行独立成块,和后面 step 6 的「按块裁旧」直接挂钩 +- `appendInfo` 等就是一行 `append(Level::Info, message)` 的转发 + +### 检查点 + +连续 append 三条不同级别,每行开头都有正确的时间戳和级别标签,三行换行分明不黏一起。空 message 也能 append(只显示时间戳 + 级别)= 格式对了。 + +> QString / 时间格式化不熟?[QString 进阶](../../../../../advanced/01-qtbase/03-qstring-advanced.md)。 + +### 对照答案 + +- 拼行 + 时间戳 + 级别标签:`src/log_viewer.cpp:34-39` +- 文末补 `\n`:`src/log_viewer.cpp:51` +- 便捷重载转发:`src/log_viewer.cpp:65-75` + +--- + +下一步:[Step 4-5 把行为开关升级成 Q_PROPERTY + 自动滚底](./02-autoscroll-and-trim.md)。 diff --git a/tutorial/engineering/instances/widget/log-viewer/handbook/02-autoscroll-and-trim.md b/tutorial/engineering/instances/widget/log-viewer/handbook/02-autoscroll-and-trim.md new file mode 100644 index 0000000..5ff80a0 --- /dev/null +++ b/tutorial/engineering/instances/widget/log-viewer/handbook/02-autoscroll-and-trim.md @@ -0,0 +1,62 @@ +--- +title: "Step 4-5:行为开关 Q_PROPERTY + 自动滚底" +description: "把 maxLines / autoScroll / showTimestamp 升级为 Q_PROPERTY 三件套,append 末尾按 autoScroll 决定要不要 moveCursor(End) + ensureCursorVisible 滚底。" +--- + +# Step 4-5:行为开关 Q_PROPERTY + 自动滚底 + +← [Step 1-3](./01-compose-and-append.md) · 下一步 [Step 6 行数上限裁旧](./03-cap-and-polish.md) → + +## Step 4:行为开关升级为 Q_PROPERTY + +### 目标 + +把三个开关成员 `max_lines_` / `auto_scroll_` / `show_timestamp_` 从裸成员升级成完整 Q_PROPERTY:每个都有 `Q_PROPERTY(... READ ... WRITE ... NOTIFY ...)`、对应的 getter / setter / signal。`append` 里读 `show_timestamp_` 决定要不要加时间戳前缀。 + +### 提示 + +- 三个 `Q_PROPERTY` 放 `Q_OBJECT` 之后:`maxLines`(int, 默认 1000) / `autoScroll`(bool, 默认 true) / `showTimestamp`(bool, 默认 true) +- 每个 setter 走「无变化 early-return」:`if (newVal == member_) return;`——避免无谓的 NOTIFY 触发 +- setter 模板:改成员 → (maxLines 这步先不管裁旧,留到 step 6)→ emit signal +- `show_timestamp_` 默认 true;`append` 里 `if (show_timestamp_)` 包住时间戳拼装那段 +- `setMaxLines` 要把入参夹到 `>= 1`,避免上限 0 时裁成空或被绕过 + +### 检查点 + +外部 `setShowTimestamp(false)` 后再 append,行头没有时间戳了;`setShowTimestamp(true)` 恢复。`setMaxLines(0)` 不会把上限设成 0(被夹成 1)。每个 setter 重复 set 同值不 emit signal = 属性化对了。 + +> Q_PROPERTY / NOTIFY 不熟?[属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- 三个 Q_PROPERTY:`include/log_viewer.h:29-32` +- setShowTimestamp setter + early-return:`src/log_viewer.cpp:113-119` +- setMaxLines clamp:`src/log_viewer.cpp:85-95` + +--- + +## Step 5:自动滚底(autoScroll 开关) + +### 目标 + +`append` 写完行、做完裁旧之后,如果 `auto_scroll_` 为真,就滚到文档末尾让最新行可见。`autoScroll` 关了就不滚,方便用户往上翻看历史时不被打断。 + +### 提示 + +- `append` 末尾:`if (auto_scroll_) { view_->moveCursor(QTextCursor::End); view_->ensureCursorVisible(); }` +- **两件套缺一不可**:只 `moveCursor(End)` 把光标挪到文末,但视口不一定跟着滚;`ensureCursorVisible()` 才保证光标所在行进入可视区 +- 注意滚底在裁旧(step 6)之后调——先裁后滚,滚到的才是最终末尾 + +### 检查点 + +连发几条 append,每次新行都自动出现在视口底部;勾掉 Auto Scroll(调 `setAutoScroll(false)`)再 append,视口不跟着滚、光标停原处,可以往上翻看 = 滚底开关对了。 + +> 事件循环 / 视口滚动不熟?[事件系统进阶](../../../../../advanced/01-qtbase/07-event-system-advanced.md)。 + +### 对照答案 + +- 滚底两件套(auto_scroll_ 门控):`src/log_viewer.cpp:58-62` + +--- + +下一步:[Step 6 给控件装上行数上限裁旧](./03-cap-and-polish.md)。 diff --git a/tutorial/engineering/instances/widget/log-viewer/handbook/03-cap-and-polish.md b/tutorial/engineering/instances/widget/log-viewer/handbook/03-cap-and-polish.md new file mode 100644 index 0000000..ddc0ba2 --- /dev/null +++ b/tutorial/engineering/instances/widget/log-viewer/handbook/03-cap-and-polish.md @@ -0,0 +1,67 @@ +--- +title: "Step 6:行数上限裁旧 + 收尾" +description: "用 document 原生 blockCount 判定超限,trimOldBlocks 从文档头按块连选删除;setMaxLines 改上限后立即裁一次当场收敛;补 lineCount / clear / sizeHint 收尾。" +--- + +# Step 6:行数上限裁旧 + 收尾 + +← [Step 4-5](./02-autoscroll-and-trim.md) · [手册首页](./index.md) → + +## Step 6:行数上限裁旧(maxLines + trimOldBlocks) + +### 目标 + +实现 `trimOldBlocks()`:当前 document 块数超过 `max_lines_` 时,从文档头连选删除多出来的块。`append` 每次写完行后调一次;`setMaxLines` 改上限后也立即调一次,让控件状态当场收敛。 + +### 提示 + +- **不要自维护行计数器**——直接用 `view_->blockCount()`,让 document 当唯一真相源,省掉 clear 后清零、和实际块数漂移这些坑 +- `trimOldBlocks`:`const int count = view_->blockCount();`;`if (count <= max_lines_) return;`;`const int toRemove = count - max_lines_;` +- 删除:`QTextCursor cursor(view_->document()); cursor.movePosition(QTextCursor::Start);` +- 从文档头连续选中 `toRemove` 个块(含每块的行尾换行):循环里 `movePosition(NextBlock, KeepAnchor)` 选到下一块头,再 `movePosition(NextCharacter, KeepAnchor)` 把行尾 `\n` 带进选区 +- 循环结束 `cursor.removeSelectedText()` 一次性删掉整段 +- `setMaxLines` 在 clamp 完、emit 之前调一次 `trimOldBlocks()`——保证上限一改、内容当场符合新约束 + +关键认知——**为什么用块而非字符删**:每条日志是独立一块(step 3 那个 `\n` 保证的),按块删语义干净、不会把半行切掉;而且 `blockCount` 是 QPlainTextEdit 原生维护的,比自己数 `\n` 靠谱。 + +### 检查点 + +默认 `maxLines=1000`,连发 200 条 Info(demo 里 Burst 200 Info 按钮)后,document 块数稳定在 200(远没到上限);把 `setMaxLines` 调到 100 再连发 200 条,块数稳定在 100 附近、顶部旧行被裁、底部始终是最新那条;`setMaxLines(50)` 当场就把现有内容裁到 50 = 裁旧对了。 + +> 对象生命周期 / 富文本操纵不熟?[内存管理进阶](../../../../../advanced/01-qtbase/06-memory-management-advanced.md)、[QPlainTextEdit 入门](../../../../../beginner/03-qtwidgets/24-qplaintextedit-beginner.md)。 + +### 对照答案 + +- trimOldBlocks 块级删除(blockCount 判定 + Start 连选 + removeSelectedText):`src/log_viewer.cpp:154` +- setMaxLines 即时裁旧:`src/log_viewer.cpp:93` +- append 末尾调 trimOldBlocks:`src/log_viewer.cpp:56` + +--- + +## 收尾:lineCount / clear / sizeHint + +### 目标 + +补三个小接口让控件完整可用:`int lineCount() const` 供 demo 观察裁旧行为;`void clear()` 一键清空;`QSize sizeHint() const override` 给布局一个建议尺寸。 + +### 提示 + +- `lineCount`:直接 `return view_->blockCount();`——薄透传,别自己计数 +- `clear`:`view_->clear();`——view 自带,清完 document 块数归零,trimOldBlocks 下次自然不会误删 +- `sizeHint`:返回 `QSize(380, 220)` 这种合理默认,让控件放进布局有个起手尺寸 + +### 检查点 + +demo 里行数回显跟着 `lineCount()` 变化、连发压测时能看到块数收敛在上限;Clear 按钮一按文本框空、行数归零;控件单独放进布局有个合适起手大小 = 收尾对了。 + +### 对照答案 + +- lineCount 透传:`src/log_viewer.cpp:81` +- clear 透传:`src/log_viewer.cpp:77` +- sizeHint:`src/log_viewer.cpp:125` + +--- + +搓完了。跑 demo 对照成品:三级染色 + 自动滚底 + 连发压测裁旧 + Clear 都能复现 = 你搓的和 repo 一致。 + +想再深?回 [手册首页](./index.md) 看进阶挑战(级别过滤 / 搜索高亮 / 多通道分流 / model 后端虚拟化)。 diff --git a/tutorial/engineering/instances/widget/log-viewer/handbook/index.md b/tutorial/engineering/instances/widget/log-viewer/handbook/index.md new file mode 100644 index 0000000..0b160b0 --- /dev/null +++ b/tutorial/engineering/instances/widget/log-viewer/handbook/index.md @@ -0,0 +1,66 @@ +--- +title: "LogViewer 手搓手册" +description: "从一只只读 QPlainTextEdit 一行行搓出 LogViewer:6 步打通组合控件骨架、Q_PROPERTY、级别染色、自动滚底、行数上限裁旧。" +--- + +# LogViewer 手搓手册 + +> **source**:成品答案在 `widget/log-viewer/`(做完对照)· **related**:只读文本组合控件递进链第 1 环 · 教程层 [QPlainTextEdit 入门](../../../../../beginner/03-qtwidgets/24-qplaintextedit-beginner.md) / [日志进阶](../../../../../advanced/01-qtbase/14-logging-advanced.md) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 LogViewer,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **组合控件骨架**:继承 QWidget + 持有 QPlainTextEdit + 挂布局,不自绘 +- **QTextCursor 操纵富文本**:movePosition / insertText / removeSelectedText,按插入设色 +- **Q_PROPERTY 全套**:READ / WRITE / NOTIFY 三件,让属性被外部 / Designer 驱动 +- **QPlainTextEdit 原生 blockCount 裁旧**:不维护自己的计数器,让文档当真相源 +- **主题无关的着色策略**:用无效 QColor 表示「默认前景色」,不在代码里硬编码黑 / 白 + +## 1. 起点 + +先有个能跑的空壳——一只只读 QPlainTextEdit 弹出来。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QPlainTextEdit edit; + edit.setReadOnly(true); + edit.setPlainText("hello log"); + edit.resize(380, 220); + edit.show(); + return app.exec(); +} +``` + +弹出一只只读文本框、显示 hello log = 环境通了,往下走。QPlainTextEdit 不熟先看 [QPlainTextEdit 入门](../../../../../beginner/03-qtwidgets/24-qplaintextedit-beginner.md)。 + +## 2. 任务清单 + +分 6 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | LogViewer 骨架:组合 QPlainTextEdit(只读 + 等宽 + NoWrap) | [01](./01-compose-and-append.md) | +| 2 | append 染色:Level 枚举 + QTextCursor 按级别套前景色 | [01](./01-compose-and-append.md) | +| 3 | 时间戳前缀 + 便捷重载(appendInfo / Warning / Error) | [01](./01-compose-and-append.md) | +| 4 | 行为开关升级为 Q_PROPERTY(maxLines / autoScroll / showTimestamp) | [02](./02-autoscroll-and-trim.md) | +| 5 | 自动滚底(autoScroll 开关) | [02](./02-autoscroll-and-trim.md) | +| 6 | 行数上限裁旧(maxLines + trimOldBlocks) | [03](./03-cap-and-polish.md) | + +成品对照:`widget/log-viewer/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **加级别过滤**:给 LogViewer 加一个 `setLevelFilter(QSet)`,append 时被过滤的级别不进 document(或进了但隐藏)。思考:过滤是 append 时拦截好,还是全存进 document 再靠可见性筛好?各自的取舍。 +- **加搜索高亮**:接一个搜索框,把匹配关键字的行用另一套 QTextCharFormat(比如加背景色)标出来。提示:QTextCursor 能遍历文档、QTextDocument 有 `find` 方法。 +- **多通道分流**:把 stdout 和 stderr 分两路 append,左侧加分通道勾选。思考:这要不要从「单 view」升级成「按通道多 view / tab」,边界在哪。 +- **下一站**:带 model 后端的日志控件——把数据源从直接 append 换成 QAbstractListModel,view 换成 QListView + 自定义 delegate,支持百万行虚拟化。 diff --git a/tutorial/engineering/instances/widget/log-viewer/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/log-viewer/handbook/troubleshooting.md new file mode 100644 index 0000000..de6fb4d --- /dev/null +++ b/tutorial/engineering/instances/widget/log-viewer/handbook/troubleshooting.md @@ -0,0 +1,54 @@ +--- +title: "卡住怎么办" +description: "按症状查:编译报 QFontDatabase、等宽字体不生效、Info 行某主题下不可读、裁旧行数不对、append 不滚底、新行黏一起、setMaxLines 调小不生效——给方向指向教程章,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/log-viewer/`,对照着看。 + +## cpp 编译报 `expected type-specifier before 'QFontDatabase'` / `'QFontDatabase' has not been declared` + +- 构造里用了 `QFontDatabase::systemFont(...)` 设等宽字体,但 cpp 顶部**漏引 ``**?Q_OBJECT 类的 cpp 不像头文件那样顺手引字体类。→ 顶部补 `#include `,对照 `src/log_viewer.cpp:9` +- 是不是误把 `QFontDatabase` 当成只读 `.h` 里需要的,忘在 cpp 引?设字体的代码在 cpp,就得 cpp 引。 + +## 等宽字体没生效,日志列还是参差不齐 + +- 是不是用了 `QFont("Monospace")` 这种按名字取字体的写法?**系统上未必有这个名字的字体**。改用 `QFontDatabase::systemFont(QFontDatabase::FixedFont)` 取系统首选等宽字体。→ `src/log_viewer.cpp:23` +- 是不是干脆没设字体?默认比例字体下时间戳 / 级别列宽不一,看着乱。 +- 进阶排查:[QFontDatabase](https://doc.qt.io/qt-6/qfontdatabase.html) + +## Info 行在深色主题下看不见 / 浅色主题下太刺眼 + +- `colorForLevel` 是不是给 Info 也**硬编码了黑或白**?这会在某个主题下不可读。让 Info 返回默认构造的 `QColor()`(`isValid()==false`),`append` 里只对有效色 `setForeground`,Info 行用 view 默认前景色跟着主题走。→ `src/log_viewer.cpp:129` / `src/log_viewer.cpp:47` +- 主题切换不熟?查 QPalette / `QGuiApplication::palette()`。 + +## 裁旧行数对不上(删多了 / 删不干净 / 行数显示漂移) + +- 是不是**自己维护了一个行计数器**?clear 之后没清零、或和 document 实际块数漂移就会出错。改用 `view_->blockCount()` 当唯一真相源。→ `src/log_viewer.cpp:155` +- 删除循环是不是漏了把行尾 `\n` 带进选区?只 `movePosition(NextBlock, KeepAnchor)` 不够,还得 `movePosition(NextCharacter, KeepAnchor)` 带上换行,否则删不干净。→ `src/log_viewer.cpp:163-169` + +## append 后没滚到底,最新行看不到 + +- `autoScroll` 默认开,但滚底代码**是不是只调了 `moveCursor(End)` 没调 `ensureCursorVisible()`**?前者挪光标、后者才让视口跟着滚,两件套缺一不可。→ `src/log_viewer.cpp:60-61` +- 是不是在裁旧之前滚的底?得先裁后滚,滚到的才是最终末尾。→ `src/log_viewer.cpp:56` 裁旧在 `src/log_viewer.cpp:58` 滚底之前 +- 进阶排查:[QPlainTextEdit](https://doc.qt.io/qt-6/qplaintextedit.html) + +## 新行没独立成块,裁旧时把两行黏一起删 + +- insertText 前**是不是没补 `\n`**?文末非空时直接 insertText,新行会和旧行黏在一块,按块裁旧就错乱。判断 `!cursor.atStart()` 时先补一个 `\n`。→ `src/log_viewer.cpp:51` +- 这步和 step 6 裁旧直接挂钩:`\n` 保证每条日志独立成块,按块删才干净。 + +## `setMaxLines` 调小后旧行没被裁,要等下次 append + +- setter **是不是只改了 `max_lines_`、emit 了信号,没主动裁旧**?不裁旧行就残留在 document 里,控件状态不自洽。clamp 完、emit 前先调一次 `trimOldBlocks()`。→ `src/log_viewer.cpp:93` +- 进阶排查:[属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md) + +## moc 报错(Q_PROPERTY / Q_ENUM 不认识) + +- 头文件**有没有 `Q_OBJECT`**?→ `include/log_viewer.h:26` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- Q_ENUM 的 Level 枚举**是不是在类里**、Q_ENUM 紧跟其后?→ `include/log_viewer.h:36-37` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/log-viewer/index.md b/tutorial/engineering/instances/widget/log-viewer/index.md new file mode 100644 index 0000000..2d93c67 --- /dev/null +++ b/tutorial/engineering/instances/widget/log-viewer/index.md @@ -0,0 +1,161 @@ +--- +title: "LogViewer 成品导览" +description: "滚动日志控件成品:组合 QPlainTextEdit 做只读日志、按级别染色、自动滚底、行数上限裁旧,附架构、设计决策、踩坑与阅读路径。" +--- + +# LogViewer 成品导览 + +> **source**:`widget/log-viewer/` **related**:只读文本组合控件递进链第 1 环 · 教程层 [QPlainTextEdit 入门](../../../../beginner/03-qtwidgets/24-qplaintextedit-beginner.md) / [日志进阶](../../../../advanced/01-qtbase/14-logging-advanced.md) + +LogViewer 是个只读滚动日志——后台服务、串口监视、构建输出里那种一刷到底、关键行高亮、永远不把内存撑爆的文本框。QPlainTextEdit 本身已经能塞文本,但「这条是 Info 那条是 Error、要染色、新行来了要跟着滚到底、攒够多少行得自动扔掉旧行」这些它都不主动管。我们用一层壳把这几件事收拢成 `append(level, message)` 一个入口,就成了一个开箱即用的日志控件。 + +本件和 status-led 不是一类——那是自绘,本件是**组合**:不重写 paintEvent,让 QPlainTextEdit 自己画,我们在它外面套壳 + 挂 QTextCursor。和 editable-table 同属组合派,只是换了一颗内核(QPlainTextEdit 替代 QTableWidget)。做日志输出的正确姿势就是让富文本引擎干富文本的活,我们自己只管「这一行该什么颜色、该不该裁掉」。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::LogViewer` 控件: + +- **三级日志染色**:`Level::Info`(默认前景色) / `Level::Warning`(暗黄 `QColor(180,120,0)`) / `Level::Error`(红 `QColor(200,0,0)`),每条 append 时按级别套前景色,肉眼一眼分级别 +- **自动滚底**:append 后默认滚到文档末尾让最新行可见;`autoScroll` 可关,关了 append 后不滚,方便往上翻看历史 +- **行数上限裁旧**:超出 `maxLines`(默认 1000)时从文档头删旧行,防长时间运行内存无限膨胀 +- **时间戳前缀**:默认每行前缀 `[HH:MM:SS] [LEVEL] message`,`showTimestamp` 可关 +- **完整 Q_PROPERTY**:`maxLines` / `autoScroll` / `showTimestamp` 三个属性全可被外部 / Designer 驱动 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/log-viewer/demo/log_viewer_demo +``` + +打开后你会看到一个只读日志区加一排控制按钮。点 Append Info / Warning / Error 三色行依次落下来;点 Burst 200 Info 连发两百条 Info,超过默认 `maxLines=1000` 时顶部旧行被裁掉,底部始终是最新那条「心跳 #199」,总行数收敛在上限附近、不爆;勾掉 Auto Scroll 再 append,新行虽然加进去了但不滚底,光标停在原处方便往上翻。 + +## 2. 架构总览 + +### 类关系 + +LogViewer 是组合而非继承:它拥有一个 `QPlainTextEdit` 当只读显示内核,自己只持有几个行为开关成员。所有的着色、滚底、裁旧都不重写 view 的方法,而是在 `append` 里用临时 `QTextCursor` 直接操纵 view 的 document。 + +```mermaid +classDiagram + class LogViewer { + +Q_PROPERTY int maxLines + +Q_PROPERTY bool autoScroll + +Q_PROPERTY bool showTimestamp + +append(Level, message) + +appendInfo(message) + +appendWarning(message) + +appendError(message) + +clear() + +lineCount() int + -colorForLevel(Level) QColor + -trimOldBlocks() + -view_ : QPlainTextEdit* + -max_lines_ : int + -auto_scroll_ : bool + -show_timestamp_ : bool + } + class QPlainTextEdit { + 只读显示内核 + document() blockCount() + moveCursor / ensureCursorVisible + } + LogViewer o-- QPlainTextEdit : view_ +``` + +LogViewer 和 `view_` 是单向持有关系:`view_` 在构造里 `new QPlainTextEdit(this)`,由 this 父对象的对象树托管生命周期,控件销毁时 view 跟着销毁,不需要手动 delete。view 没有反向引用 LogViewer——它根本不知道外面套了壳,这层组合是纯单向的。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/log_viewer.h` | 接口:Level 枚举 + Q_PROPERTY 三件套 + 公有 API + 私有着色/裁旧辅助声明 | +| `src/log_viewer.cpp` | 实现:只读 view 初始化 + append 染色插入 + 裁旧 + 行为开关 | +| `demo/log_viewer_window.cpp` | 演示:三级按钮 + 连发压测 + 清空 + autoScroll 切换 + 行数回显 | + +### 一次 append 怎么走完着色与裁旧 + +```mermaid +sequenceDiagram + participant U as 调用方 + participant L as LogViewer + participant V as QPlainTextEdit + participant D as document + U->>L: append(Warning, "配置缺失") + L->>L: 拼装 line="[HH:MM:SS] [WARN] 配置缺失" + L->>D: cursor.movePosition(End) + L->>L: colorForLevel(Warning)=暗黄 → setForeground + L->>D: cursor.insertText(line, fmt) + L->>L: trimOldBlocks() + L->>D: blockCount() > max_lines_? + alt 超限 + L->>D: cursor 从 Start 连选多余块 removeSelectedText + end + alt auto_scroll_ + L->>V: moveCursor(End) + ensureCursorVisible() + end +``` + +重点在 `append` 的三段式:先按 level 套前景色 `insertText`(着色落进 document),再 `trimOldBlocks` 按 document 自己的 `blockCount` 判定要不要裁旧,最后看 `auto_scroll_` 决定滚不滚底。着色是写进 document 的富文本格式,不是临时绘制——所以滚动、选中、复制都不丢颜色,view 重绘也不用我们操心。 + +## 3. 关键设计决策 + +**① 组合 QPlainTextEdit,不继承也不自绘。** +LogViewer 继承的是 QWidget,把 QPlainTextEdit 当私有成员 `new` 出来挂 parent、塞进零边距 QVBoxLayout。不重写 paintEvent,让 view 自己画(`src/log_viewer.cpp:18`)。日志控件要的是「能滚、能选中、能复制、列对齐」,QPlainTextEdit 这几样天生齐全,自己重画纯是重复造轮子还容易丢剪贴板行为。代价是少了一层绘制控制权,但对纯文本日志这种需求,没什么是 view 自带的富文本引擎干不了的。 + +**② `colorForLevel` 对 Info 返回无效 QColor,用默认前景色而非硬编码黑/白。** +最初的想法是 Info 也写死一个颜色。但硬编码黑或白都很危险:深色主题下白字看得清、浅色主题下黑字才看得清。解法是 Info 让 `colorForLevel` 返回一个 `QColor()`(默认构造,`isValid()==false`),`append` 里只对有效色调 `setForeground`(`src/log_viewer.cpp:47`),Info 行就用 view 的默认前景色,跟着主题走。这是让控件在任意配色下都不出错的关键一笔。 + +**③ 着色靠 QTextCursor + QTextCharFormat,按插入设色而非事后批量改。** +QPlainTextEdit 支持每次 `insertText` 带一个 `QTextCharFormat`,所以着色和写文本是同一步完成的(`src/log_viewer.cpp:54`)。相比「先 append 普通文本、再回头遍历 setFormat」的方案,这种写一步染一步的方式不需要额外记住行号区间、也不触发额外的文档变更通知。文末非空时先补一个 `\n` 保证新行独立成块(`src/log_viewer.cpp:51`),这点和裁旧的「按块删」直接挂钩。 + +**④ 裁旧用 document 原生的 `blockCount`,不自维护计数器。** +QPlainTextEdit 的 document 自带块计数,`view_->blockCount()` 随时反映当前行数(`src/log_viewer.cpp:155`)。裁旧就比它和 `max_lines_`,超了多少从文档头连选删除(`src/log_viewer.cpp:163`)。这比自己在 append 里维护一个行计数器省心得多——计数器还得和实际 document 同步、clear 之后还得清零,全是 bug 温床。让文档自己当唯一的真相源,我们的逻辑只是它的旁路观察者。 + +**⑤ `setMaxLines` 改上限后立即 `trimOldBlocks` 一次,当场收敛。** +默认上限 1000 行时已经攒了 800 行,这时外部把 `maxLines` 调到 500——如果不主动裁,旧行要等到下一次 append 才会被裁,期间 document 里会留着超出新上限的内容。解法是 `setMaxLines` 在 clamp 完、emit 之前先调一次 `trimOldBlocks`(`src/log_viewer.cpp:93`),保证上限一改、内容当场符合新约束。这种「setter 即生效」的语义让控件状态始终自洽,外部不用记着「改完还得手动清一次」。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/log_viewer.h` 的 Level 枚举与 Q_PROPERTY**(36-37 行、29-32 行)——先看「对外暴露哪些级别和属性开关」 +2. **构造函数**(`src/log_viewer.cpp:18`)——QPlainTextEdit 怎么 new、只读 / 等宽字体 / NoWrap 怎么设、布局怎么挂 +3. **`append`**(`src/log_viewer.cpp:32`)——核心入口,盯拼装行、moveEnd 补 `\n`、setForeground 插入、裁旧、滚底这五段 +4. **`colorForLevel`**(`src/log_viewer.cpp:129`)——为什么 Info 返回无效色、Warning / Error 的固定色值 +5. **`trimOldBlocks`**(`src/log_viewer.cpp:154`)——裁旧的块级删除逻辑,盯 Start + 连选 NextBlock 的循环 +6. **`setMaxLines`**(`src/log_viewer.cpp:85`)——clamp + 即时裁旧 + emit 的三段 + +入口:`demo/main.cpp` → `demo/log_viewer_window.cpp` 跑起来,对照读。重点把 Burst 200 Info 这个压测点跑一遍,盯着窗口底部的行数回显和顶部旧行的消失,看裁旧到底是怎么收敛的。 + +## 5. 踩坑 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | cpp 编译报 `expected type-specifier before 'QFontDatabase'` 或 `'QFontDatabase' has not been declared` | 构造里用 `QFontDatabase::systemFont(QFontDatabase::FixedFont)` 设等宽字体,但 cpp 顶部漏引 `` | 编译不过 | cpp 顶部补 `#include `(`src/log_viewer.cpp:9`)。Q_OBJECT 类的 cpp 不像头文件那样顺手引字体类,新加字体相关代码要单独引 | +| ② | 等宽字体没生效,日志列还是参差不齐 | 用了 `QFont("Monospace")` 这种按名字取字体的写法,系统上未必有这个名字的字体;或干脆没设字体 | 不同长度的时间戳/级别对不齐,阅读体验差 | 用 `QFontDatabase::systemFont(QFontDatabase::FixedFont)` 取系统首选等宽字体(`src/log_viewer.cpp:23`),不依赖具体字体名 | +| ③ | Info 行在深色主题下看不见 / 浅色主题下太刺眼 | `colorForLevel` 给 Info 也硬编码了一个颜色(黑或白),和主题冲突 | 级别色在某个主题下不可读 | Info 让 `colorForLevel` 返回无效 `QColor()`,`append` 里只对有效色 `setForeground`,Info 行用 view 默认前景色(`src/log_viewer.cpp:129` / `src/log_viewer.cpp:47`) | +| ④ | 裁旧删多了行或删错位置 | 自己维护一个行计数器,忘了 clear 之后清零、或和 document 实际块数漂移 | 日志被多删或裁不干净,行数显示对不上 | 不自维护计数器,裁旧直接判 `view_->blockCount()`(`src/log_viewer.cpp:155`),让 document 当唯一真相源 | +| ⑤ | append 后没滚到底,最新行看不到 | `autoScroll` 默认开但滚底代码漏调,或只调了 `moveCursor(End)` 没调 `ensureCursorVisible()` | 用户得手动往下拉才能看到新日志 | 滚底两件套一起调:`moveCursor(QTextCursor::End)` + `ensureCursorVisible()`(`src/log_viewer.cpp:60`) | +| ⑥ | 新行没独立成块,裁旧时按块删会把两行黏一起删 | 文末非空时直接 insertText,新行没和旧行用 `\n` 分隔 | 裁旧行数对但内容错乱 | insertText 前判断 `!cursor.atStart()` 时先补一个 `\n`(`src/log_viewer.cpp:51`),保证新行独立成块 | +| ⑦ | `setMaxLines` 调小后旧行没被裁,要等下次 append | setter 只改了 `max_lines_` 没主动裁旧 | 控件状态不自洽,document 里残留超限内容 | `setMaxLines` clamp 完、emit 前先调一次 `trimOldBlocks`(`src/log_viewer.cpp:93`) | + +## 6. 官方文档 + +- [QPlainTextEdit](https://doc.qt.io/qt-6/qplaintextedit.html)——只读日志的显示内核(本件的 view 组合对象) +- [QTextCursor](https://doc.qt.io/qt-6/qtextcursor.html)——着色插入与裁旧删除的操纵手柄(movePosition / insertText / removeSelectedText) +- [QTextCharFormat](https://doc.qt.io/qt-6/qtextcharformat.html)——按级别套前景色(setForeground) +- [QTextDocument::blockCount](https://doc.qt.io/qt-6/qtextdocument.html#blockCount)——裁旧的行数判定依据 +- [QFontDatabase::systemFont](https://doc.qt.io/qt-6/qfontdatabase.html#systemFont)——取系统首选等宽字体 +- [QTime::currentTime](https://doc.qt.io/qt-6/qtime.html#currentTime)——时间戳前缀的取时 +- [Qt 属性系统(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html)——maxLines / autoScroll / showTimestamp 三个属性开关的机制 +- [Q_ENUM 与元对象系统](https://doc.qt.io/qt-6/qobject.html#Q_ENUM)——Level 枚举如何被 moc 认识 + +--- + +这套机制(组合 QPlainTextEdit + QTextCursor 着色 + document blockCount 裁旧 + 行为开关 Q_PROPERTY)不是 LogViewer 专属——它就是「一个带格式与上限的只读流式文本控件」的标准范式。后面做带过滤、带搜索高亮、带多通道(stdout/stderr 分流)的高级日志控件时,view 这层原样复用,只是在外面再套过滤与路由逻辑。想自己搓?[手搓手册](./handbook/)带你从一只只读 QPlainTextEdit 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/password-edit/handbook/01-compose-and-toggle.md b/tutorial/engineering/instances/widget/password-edit/handbook/01-compose-and-toggle.md new file mode 100644 index 0000000..d64d9cc --- /dev/null +++ b/tutorial/engineering/instances/widget/password-edit/handbook/01-compose-and-toggle.md @@ -0,0 +1,48 @@ +--- +title: "Step 1:组合骨架 + 显隐切换" +description: "PasswordEdit 继承 QWidget 组合 QLineEdit + QToolButton,定义 Strength 枚举,搭出编辑行布局,接 QToolButton::clicked 翻 textVisible。" +--- + +# Step 1:组合骨架 + 显隐切换 + +← [手册首页](./index.md) · 下一步 [Step 2 强度算法 + 色块染色](./02-strength-and-indicator.md) → + +这一步把裸 QLineEdit + 裸按钮包进一个 `AwesomeQt::PasswordEdit` 类,定义 Strength 枚举,搭出编辑行布局,把按钮的 `clicked` 接到翻显隐态的逻辑上。先不要强度色块、不要信号透传——能点按钮在明文/密文之间切,按钮文案跟着变就行。强度是下一步的事。 + +## Step 1:组合骨架 + Strength + 编辑行 + 显隐切换 + +### 目标 + +得到一个 `PasswordEdit : public QWidget`,构造时 new 出 QLineEdit(`EchoMode::Password`)和 QToolButton(默认文案「显」),用 QHBoxLayout 横排成编辑行塞进 QVBoxLayout。定义 `enum class Strength { kWeak, kMedium, kStrong }` 加 `Q_ENUM`。实现 `textVisible()` / `setTextVisible(bool)`——后者改 QLineEdit 的 echoMode(`Normal`/`Password`)并把按钮文案同步成「隐」/「显」。把 `QToolButton::clicked` 连到一个翻 `textVisible_` 的 lambda。 + +### 提示 + +- **组合姿势**:`class PasswordEdit : public QWidget`,私有成员 `QLineEdit* edit_{nullptr}` 和 `QToolButton* toggle_btn_{nullptr}`。构造里 `edit_ = new QLineEdit(this)`(parent=this 对象树托管)、`toggle_btn_ = new QToolButton(this)`,再用 `new QVBoxLayout(this)` 当根布局,里面塞一个 QHBoxLayout 编辑行。**别继承 QLineEdit**——那会把一堆不该暴露的输入 API 放出去,组合才好控边界 +- **密码模式**:`edit_->setEchoMode(text_visible_ ? QLineEdit::Normal : QLineEdit::Password)`。成员 `bool text_visible_{false}` 默认密文,构造时按它设 echoMode(`src/password_edit.cpp:30`) +- **按钮文案**:默认密文时按钮提示「显」(点了会显示)。建议把文案同步逻辑收进一个私有 `syncToggleText()`:明文时设「隐」、密文时设「显」(`src/password_edit.cpp:180`)。**用文字别用 emoji 图标**——省得扯 icon 资源、跨平台还可能缺字体 +- **按钮体验**:`toggle_btn_->setFocusPolicy(Qt::NoFocus)`(别抢密码框焦点)、`setCursor(Qt::PointingHandCursor)`(手型光标提示可点) +- **Strength 枚举**:`enum class Strength { kWeak, kMedium, kStrong };` 紧跟 `Q_ENUM(Strength)`(`include/password_edit.h:38`)。这一步先定义上,下一步算法要用 +- **clicked 连线**:`connect(toggle_btn_, &QToolButton::clicked, this, [this]{ setTextVisible(!text_visible_); })`(`src/password_edit.cpp:65`)。翻转发给 setTextVisible 处理,不在 lambda 里直接改 echoMode——单一改动入口 +- **setTextVisible 早返**:入口 `if (text_visible_ == visible) return;`(`src/password_edit.cpp:84`)。这一步看着多余(点同一个态没变化),但 step 3 接上勾选框双向联动后这个早返是防无限递归的关键,现在就养成习惯 + +### 关键认知——为什么组合不继承 + +继承 QLineEdit 看着省事(直接拥有密码框所有方法),但你会把 `setText` / `setValidator` / `setMaxLength` 这堆内部 API 全暴露给外部,外部能绕过你的强度逻辑直接塞文本。组合后 `edit_` 是私有的,外部只能走你批准的接口(text / setText / setTextVisible),边界牢牢攥在自己手里。这也是本批 input 型组合控件的统一姿势——和 editable-table 把 QTableWidget 私有化是一个道理。 + +### 检查点 + +跑起来出现一个密码框 + 一个「显」按钮。输入字符显示成圆点(密码模式生效);点「显」按钮,字符变明文、按钮文案变「隐」;再点「隐」,回到圆点、文案变「显」= 显隐切换对了。焦点始终在密码框、点按钮不抢焦点 = `NoFocus` 设对了。 + +> QLineEdit / EchoMode 不熟?[QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md)。组合控件思路?[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +### 对照答案 + +- 类定义 + Strength 枚举 + Q_ENUM:`include/password_edit.h:27` / `include/password_edit.h:38` +- 构造组合 QLineEdit + QToolButton + 布局:`src/password_edit.cpp:27` +- setTextVisible 改 echoMode + 早返:`src/password_edit.cpp:83` +- syncToggleText 同步按钮文案:`src/password_edit.cpp:180` +- clicked 连线翻显隐:`src/password_edit.cpp:65` + +--- + +下一步是重头戏:[Step 2 强度算法 + 色块染色](./02-strength-and-indicator.md)。 diff --git a/tutorial/engineering/instances/widget/password-edit/handbook/02-strength-and-indicator.md b/tutorial/engineering/instances/widget/password-edit/handbook/02-strength-and-indicator.md new file mode 100644 index 0000000..94cb95b --- /dev/null +++ b/tutorial/engineering/instances/widget/password-edit/handbook/02-strength-and-indicator.md @@ -0,0 +1,63 @@ +--- +title: "Step 2:强度算法 + 色块染色(核心)" +description: "实现 computeStrength 纯静态函数(长度 + 字符种类数),下方加 3 个 QLabel 色块用 setStyleSheet 染色,onTextChanged 只在档位真变时刷新。" +--- + +# Step 2:强度算法 + 色块染色(核心) + +← [Step 1](./01-compose-and-toggle.md) · [手册首页](./index.md) · 下一步 [Step 3 信号透传 + setter 去重](./03-signals-and-properties.md) → + +这一步是整个控件的核心——给 PasswordEdit 加上实时强度反馈。诀窍不在染色本身(那是 stylesheet 套路),而在**把强度算法做成纯静态函数独立可测**,外加**只在档位真变时才刷新色块**这个去重——打字过程中强度档没变就不重复染色、不发信号,避免无意义的刷新风暴。 + +## Step 2:computeStrength + 3 色块 + onTextChanged 去重 + +### 目标 + +实现四个东西: + +1. **`computeStrength(const QString&)` 纯静态函数**:统计字符种类(小写/大写/数字/符号各算一类),按「长度 < 6 或种类数 <= 1 → kWeak;种类数 == 2 → kMedium;种类数 >= 3 且长度 >= 8 → kStrong,否则 kMedium」出档。空文本安全归 kWeak +2. **3 个 QLabel 色块**:在编辑行下方加一行 QHBoxLayout,塞 3 个 `QLabel`,固定高度 6px,靠左排列右侧弹性留白 +3. **`updateStrengthIndicator()` 染色**:按 `strength_` 给 3 块染色——亮色随档变(弱红/中黄/强绿)、未亮全灰。用 `setStyleSheet`,不自绘 +4. **`onTextChanged` 去重**:连 `QLineEdit::textChanged` 到私有 `onTextChanged`,里面调 `computeStrength`,**只有新档 != `strength_` 才更新成员 + 刷新色块 + emit `strengthChanged`** + +### 提示(按顺序) + +1. **computeStrength 做成静态纯函数**:`static Strength computeStrength(const QString& text)`,不碰任何成员(`include/password_edit.h:70`)。好处是能独立写单元测试、不依赖控件实例。空文本入口直接 `return Strength::kWeak` 显式兜底(`src/password_edit.cpp:111`),不依赖后续逻辑碰巧覆盖 +2. **种类统计四个布尔**:`bool has_lower, has_upper, has_digit, has_symbol`,遍历 `for (const QChar& ch : text)` 用 `ch.isLower()` / `isUpper()` / `isDigit()` 判,其余一律算符号(`src/password_edit.cpp:118`)。最后 `classes = (has_lower?1:0) + ...` 加起来 +3. **档位判定顺序**:先 `length < 6 || classes <= 1 → kWeak`,再 `classes == 2 → kMedium`,最后 `classes >= 3 && length >= 8 → kStrong`,兜底 return kMedium(`src/password_edit.cpp:133`)。注意「种类 >= 3 但长度 < 8」落 kMedium,不是 kStrong——长度也是强密码的必要条件 +4. **色块样式四套常量**:`static constexpr const char* kStyleOff / kStyleWeak / kStyleMedium / kStyleStrong`,每套是 `"QLabel { background-color: #xxx; border-radius: 2px; }"`(`src/password_edit.cpp:18`)。灰 `#c0c0c0` / 红 `#e53935` / 黄 `#fdd835` / 绿 `#43a047`。固定字号色块,保持组合控件本色 +5. **updateStrengthIndicator 映射亮几块**:`int lit = static_cast(strength_) + 1`(kWeak=1、kMedium=2、kStrong=3),先 switch 选定「亮色」样式串,再 `for (i=0..2) strength_labels_[i]->setStyleSheet(i < lit ? on_style : kStyleOff)`(`src/password_edit.cpp:160`)。前 `lit` 块亮、其余灰 +6. **onTextChanged 去重**:`Strength new_strength = computeStrength(text); if (new_strength != strength_) { strength_ = new_strength; updateStrengthIndicator(); emit strengthChanged(strength_); }`(`src/password_edit.cpp:151`)。**只有档变了才动**——打字过程中档没变(比如从 5 个小写加到 6 个小写,还是 kWeak)就不刷新、不发信号 +7. **构造末尾初始化色块**:成员 `strength_{Strength::kWeak}` 给初值,构造末尾调一次 `updateStrengthIndicator()` 把初始 3 块全染灰(`src/password_edit.cpp:67`)。别让首屏色块状态不确定 + +### 关键认知——为什么 computeStrength 做静态、为什么 onTextChanged 要去重 + +**静态纯函数**:强度算法是纯逻辑(输入字符串、输出档位),不碰任何控件状态。把它做成 `static`、不访问成员,意味着你能脱离 Qt 控件环境直接写单测——`computeStrength("abc")` 该是 kWeak、`computeStrength("Abc12345")` 该是 kStrong,断言一跑就知道算法对不对。如果它访问了 `edit_->text()` 之类的成员,就非得构造整个控件才能测,测试成本暴涨。这也是 editable-table 把列类型转换做成独立函数的同款思路。 + +**onTextChanged 去重**:用户每打一个字符 `QLineEdit::textChanged` 就发一次。如果你的 `onTextChanged` 每次都无条件 `updateStrengthIndicator()` + `emit strengthChanged()`,那么从「5 个小写」打到「8 个小写」这全程强度档一直是 kWeak,却会触发 3 次无意义的色块重染 + 3 条无意义的 `strengthChanged` 信号——色块刷新虽然便宜,但信号会让外部监听者做无谓响应。去重的成本就一个 `!=` 比较,收益是档位稳定期间零刷新、零信号。这是「派生状态只在源状态真变时才传播」的经典套路。 + +### 检查点 + +- 输入 `abc`(3 位纯小写):色块亮红 1 块、灰 2 块 = kWeak 对了 +- 输入 `abcdef`(6 位纯小写,种类 1):还是红 1 块 = 「种类 <= 1 → kWeak」对了(长度够但种类单一) +- 输入 `abcdef12`(小写 + 数字,种类 2,长度 8):亮黄 2 块 = kMedium 对了 +- 输入 `Abcdef12!`(小写 + 大写 + 数字 + 符号,种类 4,长度 9):亮绿 3 块 = kStrong 对了 +- 输入 `Ab1!`(种类 4 但长度 4 < 8):亮黄 2 块 = 「种类 >= 3 但长度 < 8 → kMedium」对了 +- 清空输入框:3 块全灰 = 空文本归 kWeak 对了 +- 从 `abc` 打到 `abcdef`(全程 kWeak)过程中:色块不闪烁、不接到重复 strengthChanged = 去重对了 + +> stylesheet 染色不熟?[Qt Style Sheets](https://doc.qt.io/qt-6/stylesheet.html)。字符判定?[QChar](https://doc.qt.io/qt-6/qchar.html)。 + +### 对照答案 + +- computeStrength 纯静态声明:`include/password_edit.h:70` +- computeStrength 实现(空文本兜底 + 种类统计 + 档位判定):`src/password_edit.cpp:109` +- 四套色块样式常量:`src/password_edit.cpp:18` +- 构造建 3 个 QLabel 色块 + 布局:`src/password_edit.cpp:43` +- updateStrengthIndicator 染色:`src/password_edit.cpp:160` +- onTextChanged 去重 + emit:`src/password_edit.cpp:151` +- 构造末尾初始化染色:`src/password_edit.cpp:67` + +--- + +下一步:[Step 3 信号透传 + setter 去重 + Q_PROPERTY](./03-signals-and-properties.md)。 diff --git a/tutorial/engineering/instances/widget/password-edit/handbook/03-signals-and-properties.md b/tutorial/engineering/instances/widget/password-edit/handbook/03-signals-and-properties.md new file mode 100644 index 0000000..cf7bf9e --- /dev/null +++ b/tutorial/engineering/instances/widget/password-edit/handbook/03-signals-and-properties.md @@ -0,0 +1,65 @@ +--- +title: "Step 3:信号透传 + setter 去重 + Q_PROPERTY" +description: "透传 textChanged 给外部用,补 textVisible/placeholderText/strength 三个 Q_PROPERTY,demo 用勾选框和显隐态双向联动——靠 setter 去重早返防无限递归。" +--- + +# Step 3:信号透传 + setter 去重 + Q_PROPERTY + +← [Step 2](./02-strength-and-indicator.md) · [手册首页](./index.md) → + +前两步搭好了「能输入、能切显隐、能看强度」的密码框。这一步把它变成「外部能干净地监听 + 能被属性系统驱动」的正式控件:把内部 `QLineEdit::textChanged` 透传出去给 demo 回显用,补上三个 Q_PROPERTY,再把 demo 里勾选框和显隐态的双向联动接好——这里有个无限递归的坑,靠 setter 去重早返来填。搓完这三件,控件就生产可用了。 + +## Step 3:textChanged 透传 + 三 Q_PROPERTY + 双向联动 + +### 目标 + +实现四个东西: + +1. **`textChanged(QString)` 信号透传**:在 PasswordEdit 加一个 `textChanged` 信号,构造时把内部 `QLineEdit::textChanged` 连到它,让外部不用 `findChild` 抠内部控件就能监听文本变化 +2. **三个 Q_PROPERTY**:`textVisible(bool READ textVisible WRITE setTextVisible NOTIFY textVisibleChanged)`、`placeholderText(QString READ ... WRITE setPlaceholderText NOTIFY placeholderTextChanged)`、`strength(Strength READ strength NOTIFY strengthChanged)` +3. **placeholderText 完整读写**:`placeholderText()` 透传 `edit_->placeholderText()`、`setPlaceholderText()` 透传 `edit_->setPlaceholderText()` 且做去重早返 + emit +4. **demo 双向联动**:勾选框 `visible_check_` 和 PasswordEdit 显隐态互连——`textVisibleChanged → setChecked` 和 `toggled → setTextVisible`,靠 setter 去重早返防递归 + +### 提示(按顺序) + +1. **textChanged 信号声明 + 透传**:在 signals 段加 `void textChanged(const QString& text)`(`include/password_edit.h:76`)。构造里 `connect(edit_, &QLineEdit::textChanged, this, &PasswordEdit::textChanged)`(`src/password_edit.cpp:64`)。**无条件透传**——和 `onTextChanged`(去重的强度重算)是两条独立连线,文本变化要每一个字符都通知外部,不能去重 +2. **Q_PROPERTY 三件声明**:在类里 `Q_OBJECT` 后加三条 Q_PROPERTY(`include/password_edit.h:31`)。READ/WRITE/NOTIFY 都指向已有的函数和信号。注意 `strength` 是只读属性(只有 READ + NOTIFY,没 WRITE)——强度是算法算出来的,外部不能直接设 +3. **placeholderText 去重早返**:`if (edit_->placeholderText() == text) return;` 再透传 + emit(`src/password_edit.cpp:101`)。和 setTextVisible 一个套路——属性 setter 入口先判等,相同就不动不发信号 +4. **双向联动连线(demo)**:`connect(edit_, &PasswordEdit::textVisibleChanged, visible_check_, &QCheckBox::setChecked)` + `connect(visible_check_, &QCheckBox::toggled, edit_, &PasswordEdit::setTextVisible)`(`demo/password_edit_window.cpp:61`)。两条线方向相反,构成「点眼睛按钮 → 勾选框跟着变 / 点勾选框 → 显隐态跟着变」的双向同步 +5. **为什么不会无限递归**:假设用户点眼睛按钮 → `setTextVisible(true)` → emit `textVisibleChanged(true)` → 勾选框 `setChecked(true)` → 勾选框 emit `toggled(true)` → 又调 `setTextVisible(true)`……但第二次进 `setTextVisible` 时 `text_visible_` 已经是 true,入口 `if (text_visible_ == visible) return`(`src/password_edit.cpp:84`)直接早返,环断掉。**这个早返是双向联动的命根子**,少了就栈溢出 +6. **demo 回显用透传信号**:`connect(edit_, &PasswordEdit::textChanged, this, [this](const QString& text){ echo_label_->setText(text.isEmpty() ? "(空)" : text); })`(`demo/password_edit_window.cpp:81`)。密文态下回显 label 也显示真实文本,验证 `text()` 在密文时也拿得到真值——别用 `findChild` 那种脆弱 hack +7. **strengthChanged 回显档位**:demo 里把 `strengthChanged` 连到一个 lambda,switch 档位设 strength_label 文案「弱/中/强」(`demo/password_edit_window.cpp:64`) + +### 关键认知——为什么信号透传不能省、为什么去重早返是双向联动的命根 + +**信号透传不能省**:组合控件把内部 QLineEdit 私有化了,外部拿不到 `edit_` 指针。但「监听文本变化」是密码框最常见的下游需求(回显、校验、联动)。如果你不给 PasswordEdit 加 `textChanged` 信号,外部只能 `findChild()` 去抠内部控件——这把外部代码和你的内部实现死死耦合:哪天你把 QLineEdit 换成自定义输入框,所有 `findChild` 的地方全崩,且 `findChild` 还可能返回空指针。透传一个信号成本就一行 connect,换来的是外部只依赖你的公开接口、内部随便重构。这是「组合控件要把内部信号的常用需求透传出去」的标准姿势。 + +**去重早返是双向联动的命根**:两个状态(显隐态 + 勾选态)双向同步时,A 变通知 B、B 变又通知 A,天然成环。如果没有「值相同就早返」的闸门,每一边的 setter 都会 emit 信号触发另一边 setter,无限递归直到栈溢出。闸门就在 setter 入口的 `if (current == new) return`——环走到第二次时值已经同步,直接 return,环自然断。这不光是 PasswordEdit 的事,凡是「两个状态双向联动」的场景(spinbox 和 slider 互连、两个 checkbox 互斥联动)都要靠这个早返防递归。养成习惯:**凡是会被双向连线的 setter,入口一律判等早返**。 + +### 检查点 + +- 打字时「实时回显」label 实时显示真实文本(密文态也显示真值,不是圆点)= textChanged 透传对了 +- 点眼睛按钮切显隐:勾选框跟着勾上/取消 = `textVisibleChanged → setChecked` 对了 +- 点勾选框:显隐态跟着切、眼睛按钮文案跟着变 = `toggled → setTextVisible` 对了 +- 反复快速点勾选框 / 眼睛按钮:**不崩、不卡死** = setter 去重早返防住递归了(要是少了早返这里会栈溢出) +- 强度档变化时「当前强度」label 实时变「弱/中/强」= strengthChanged 对了 +- 在 Designer 或外部用 `setProperty("textVisible", true)` 能驱动显隐切换 = Q_PROPERTY 注册对了 + +> 信号槽 / 去重机制不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)。Q_PROPERTY?[属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +### 对照答案 + +- textChanged 信号声明:`include/password_edit.h:76` +- textChanged 透传连线:`src/password_edit.cpp:64` +- Q_PROPERTY 三件声明:`include/password_edit.h:31` +- setTextVisible 去重早返:`src/password_edit.cpp:83` +- setPlaceholderText 去重 + 透传 + emit:`src/password_edit.cpp:101` +- demo 双向联动连线:`demo/password_edit_window.cpp:61` +- demo textChanged 回显:`demo/password_edit_window.cpp:81` +- demo strengthChanged 档位回显:`demo/password_edit_window.cpp:64` + +--- + +搓完了。跑 demo 对照成品:打字时色块从红变黄变绿、回显 label 实时显示真值、眼睛按钮和勾选框怎么点都双向同步且不崩 = 你搓的和 repo 一致。 + +想再深?回 [手册首页](./index.md) 看进阶挑战(强度加权 / 色块改进度条 / 确认密码二次输入 / 显隐态持久化 / 下一站金额框)。 diff --git a/tutorial/engineering/instances/widget/password-edit/handbook/index.md b/tutorial/engineering/instances/widget/password-edit/handbook/index.md new file mode 100644 index 0000000..7f18570 --- /dev/null +++ b/tutorial/engineering/instances/widget/password-edit/handbook/index.md @@ -0,0 +1,72 @@ +--- +title: "PasswordEdit 手搓手册" +description: "从空壳 QLineEdit 一行行搓出 PasswordEdit:3 步打通组合控件骨架、强度算法 + 色块染色、显隐切换 + 信号透传 + setter 去重。" +--- + +# PasswordEdit 手搓手册 + +> **source**:成品答案在 `widget/password-edit/`(做完对照)· **related**:input/display 组合控件递进链第 1 环 · 教程层 [QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md) / [信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 PasswordEdit,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **组合而非继承**:把 QLineEdit 当私有成员挂进 QWidget,不重写 paintEvent——input 型组合控件的正确组装姿势 +- **EchoMode 切显隐**:QLineEdit 的 `Password`/`Normal` 模式切换,外加一个 QToolButton 当开关(step 1 搭骨架) +- **纯静态算法 + 状态去重**:`computeStrength` 做成静态纯函数独立可测,`onTextChanged` 只在档位真变时刷新 + 发信号(step 2 重头) +- **stylesheet 染色色块**:用 QLabel + `setStyleSheet` 给 3 个色块按强度染色,不自绘(step 2) +- **信号透传 + setter 去重**:把内部 `QLineEdit::textChanged` 透传出去给外部用;setter 入口判等早返,让勾选框和显隐态的双向联动不陷入无限递归(step 3) + +## 1. 起点 + +先有个能跑的空壳:一个 QWidget 里塞个密码模式的 QLineEdit,右侧贴个按钮。main 里弹窗: + +```cpp +#include +#include +#include +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + auto* layout = new QHBoxLayout(&w); + auto* edit = new QLineEdit(&w); + edit->setEchoMode(QLineEdit::Password); + auto* btn = new QToolButton(&w); + btn->setText(QStringLiteral("显")); + layout->addWidget(edit); + layout->addWidget(btn); + w.resize(300, 40); + w.show(); + return app.exec(); +} +``` + +弹出一个密码框 + 一个「显」按钮 = 环境通了,往下走。这一步用的是裸 QLineEdit + 裸按钮,还没封装——下一步我们就把它包进 PasswordEdit 类,再把强度色块加上。QLineEdit 不熟先看 [QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 组合骨架 + Strength 枚举 + 显隐切换(QLineEdit 密码模式 + QToolButton + 布局) | [01](./01-compose-and-toggle.md) | +| 2 | 强度算法 + 色块染色(computeStrength 纯函数 + 3 个 QLabel + setStyleSheet) | [02](./02-strength-and-indicator.md) | +| 3 | 信号透传 + setter 去重 + Q_PROPERTY(textChanged 透传 / 双向联动防递归) | [03](./03-signals-and-properties.md) | + +成品对照:`widget/password-edit/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **强度算法加权**:现在只看「长度 + 种类数」。想更贴近真实安全评估,可以给每种字符加权(符号比数字分高)、设长度阈值分档(< 8 直接弱、>= 12 加分)。思考:算法变了,色块染色逻辑要不要改?(答:不用,染色只认 `Strength` 枚举,算法是上游) +- **色块改连续进度条**:现在 3 档离散色块。想做成 0-100 的连续进度条 + 颜色渐变,可以把 QLabel 换成自绘 QWidget 或者用 QProgressBar 套 stylesheet。提示:这就跨进了自绘领域,参考 [status-led 成品导览](../../status-led/) 的自绘范式。 +- **加「确认密码」二次输入**:注册页常见——两个 PasswordEdit,第二个要和第一个比对一致才放行。提示:复用本件的 `textChanged` 透传信号做比对,不需要新控件。 +- **密码可见性记忆**:显隐态切换后想跨会话记住用户偏好(上次选了明文,这次默认明文)。提示:把 `textVisible` 这个 Q_PROPERTY 持久化到 QSettings,构造时读回。 +- **下一站**:带输入格式限制的金额框——复用本件的组合骨架 + setter 去重,把强度算法换成金额校验(小数位、千分位、范围)。 diff --git a/tutorial/engineering/instances/widget/password-edit/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/password-edit/handbook/troubleshooting.md new file mode 100644 index 0000000..4c507fc --- /dev/null +++ b/tutorial/engineering/instances/widget/password-edit/handbook/troubleshooting.md @@ -0,0 +1,61 @@ +--- +title: "卡住怎么办" +description: "按症状查:cpp 漏引布局类、显隐切换不生效、强度档不变色块狂闪、双向联动栈溢出、首屏色块异常、findChild hack 耦合——给方向指向成品 file:行号,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/password-edit/`,对照着看。 + +## cpp 编译报 `expected type-specifier before 'QHBoxLayout'` / `'QVBoxLayout'` + +- 构造里 `new QHBoxLayout` / `new QVBoxLayout`,但 cpp **只引了 `QLineEdit` / `QToolButton`,漏了布局类头**。Q_OBJECT 类的 cpp 不像头文件那样顺手引布局类,新加布局代码要单独引。→ `src/password_edit.cpp:9` / `src/password_edit.cpp:13` +- 同理检查 `QLabel`(色块要用)在 cpp 里引了吗?→ `src/password_edit.cpp:10` +- 进阶排查:[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md) + +## 点眼睛按钮没反应 / echoMode 没切 + +- `QToolButton::clicked` **真的连上了吗**?构造里漏了 `connect(toggle_btn_, &QToolButton::clicked, ...)`。→ `src/password_edit.cpp:65` +- clicked 连的 lambda 里**翻的是 `text_visible_` 吗**?写成 `setTextVisible(visible)`(没取反)就永远只设一个方向。正确是 `setTextVisible(!text_visible_)` +- setTextVisible 里**改的是 echoMode 吗**?漏了 `edit_->setEchoMode(visible ? Normal : Password)`。→ `src/password_edit.cpp:88` +- 进阶排查:[QLineEdit 入门](../../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md) + +## 打字过程中色块疯狂闪烁 / demo 接到一堆重复 strengthChanged + +- `onTextChanged` **是不是无条件刷新了**?正确姿势是先 `computeStrength`,只有 `new_strength != strength_` 才刷新 + emit。→ `src/password_edit.cpp:153` +- 信号去重机制不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +## 勾选框和显隐态双向联动后,点一下程序卡死 / 崩 + +- 这是**双向连线成环 + 没有去重早返**导致无限递归栈溢出。`textVisibleChanged → setChecked → toggled → setTextVisible → textVisibleChanged` 死循环。→ setTextVisible 入口必须 `if (text_visible_ == visible) return` 早返,环走到第二次值已相同直接 return。→ `src/password_edit.cpp:84` +- 双向连线两条都接了吗?`textVisibleChanged → setChecked` + `toggled → setTextVisible` 缺一条就不叫双向,但两条都接就得有早返防环。→ `demo/password_edit_window.cpp:61` + +## 刚启动、还没打字,色块显示异常 / `strength()` 返回脏值 + +- 成员 `strength_` **有没有给初值**?声明应是 `Strength strength_{Strength::kWeak}`。→ `include/password_edit.h:97` +- 构造**末尾调了 `updateStrengthIndicator()` 吗**?没调首屏色块状态不确定。→ `src/password_edit.cpp:67` + +## 空文本时强度档返回意外值 + +- computeStrength **有没有在入口显式处理空文本**?空串 length=0,应直接 `return Strength::kWeak`。→ `src/password_edit.cpp:111` +- 别依赖 `length < 6` 这种后续逻辑碰巧覆盖空串——显式兜底才稳 + +## demo 想回显真实文本,用了 `edit_->findChild()` 拿不到 / 崩 + +- 这是**把 demo 和控件内部成员耦合**的脆弱 hack。`findChild` 可能返回空指针解引用崩,且内部一重构 demo 就废。→ 给 PasswordEdit 加 `textChanged(QString)` 信号,构造透传内部 `QLineEdit::textChanged`,demo 改用 `&PasswordEdit::textChanged`。→ `src/password_edit.cpp:64` / `demo/password_edit_window.cpp:81` +- 同理 demo 想读当前文本应调 `edit_->text()`(公开 API),别 findChild 内部控件 + +## 强度算法算出的档和预期不符 + +- 种类统计是不是**把符号漏了**?`isLower/isUpper/isDigit` 之外的字符(标点、空格、中文)一律算符号,用 else 兜。→ `src/password_edit.cpp:125` +- 档位判定顺序对吗?「种类 >= 3 但长度 < 8」应落 kMedium 不是 kStrong——`classes >= 3 && length >= 8` 两个条件都要满足才是 kStrong。→ `src/password_edit.cpp:141` +- 「种类 == 2」是不是没单独判?种类 2 直接 kMedium,别让它掉到下面的 kStrong 判定。→ `src/password_edit.cpp:137` + +## moc 报错(Q_PROPERTY / Q_ENUM 不认识) + +- 类**有没有 `Q_OBJECT`**?→ `include/password_edit.h:28` +- CMake **有没有开 AUTOMOC**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- Q_ENUM 的 Strength **是不是在 PasswordEdit 类里**、Q_ENUM 紧跟其后?→ `include/password_edit.h:38` / `include/password_edit.h:39` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) diff --git a/tutorial/engineering/instances/widget/password-edit/index.md b/tutorial/engineering/instances/widget/password-edit/index.md new file mode 100644 index 0000000..2bb32ab --- /dev/null +++ b/tutorial/engineering/instances/widget/password-edit/index.md @@ -0,0 +1,164 @@ +--- +title: "PasswordEdit 成品导览" +description: "密码输入控件成品:QLineEdit 密码框 + 显隐切换 + 实时强度指示(弱/中/强三色块),组合而非自绘,附架构、设计决策、踩坑与阅读路径。" +--- + +# PasswordEdit 成品导览 + +> **source**:`widget/password-edit/` **related**:input/display 组合控件递进链第 1 环 · 教程层 [QLineEdit 入门](../../../../beginner/03-qtwidgets/22-qlineedit-beginner.md) / [信号与槽](../../../../beginner/01-qtbase/02-signal-slot-beginner.md) + +PasswordEdit 是个带强度提示的密码框——注册页那种「输入框右侧一个眼睛按钮点一下显明文、下方三色块随你打字从红变黄变绿」的玩意。QLineEdit 本身就有 `EchoMode::Password` 能把字符打成圆点,但「显隐切换」和「实时强度染色」得我们自己组合出来。这件成品的乐趣不在难度,而在**怎么把三个现成控件捏成一个干净的组合控件**,并把「外部想监听文本变化」这个常见需求用对的姿势接出去。 + +本件和 status-led / toggle-switch 不是一类——那俩是自绘,这件和 editable-table 是一脉:**组合**,不重写 paintEvent,让 QLineEdit / QToolButton / QLabel 各画各的,我们在外面搭布局 + 接信号。色块染色也老老实实用 `setStyleSheet`,绝不自己开 paintEvent 画方块。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::PasswordEdit` 控件: + +- **密码输入框**:内部 QLineEdit 走 `EchoMode::Password`,打出来的字符显示成圆点 +- **一键显隐**:右侧 QToolButton 点一下切明文/密文,按钮文案随之变「显」/「隐」(用文字不用图标,省得扯 icon 资源) +- **实时强度三色块**:下方 3 个小色块按强度染色——弱=红亮 1 块、中=黄亮 2 块、强=绿亮 3 块,未亮=灰。强度按「长度 + 字符种类数」算 +- **完整 Q_PROPERTY**:`textVisible` / `placeholderText` / `strength` 三个属性可被 Designer 或外部直接驱动 +- **透传 textChanged 信号**:外部想实时拿文本回显,绑 `&PasswordEdit::textChanged` 即可,不用 `findChild` 去抠内部 QLineEdit + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/password-edit/demo/password_edit_demo +``` + +打开后你会看到密码框 + 眼睛按钮 + 下方三色块。打几个纯字母(短于 6 位)色块亮红 1 块;加几个数字凑到两类、长度够,亮黄 2 块;再混进大写和符号、长度到 8,亮绿 3 块。点眼睛按钮明文/密文切换,右侧「实时回显」始终显示真实内容(验证 `text()` 在密文态也拿得到真值),下方「显示密码」勾选框和眼睛按钮双向联动。 + +## 2. 架构总览 + +### 类关系 + +PasswordEdit 是组合而非继承:它拥有一个 `QLineEdit` 当密码输入内核、一个 `QToolButton` 当显隐开关、三个 `QLabel` 当强度色块。五个子控件全挂 `this` parent 走对象树托管,外面套两层 QHBoxLayout(编辑行 + 强度行)再塞进一个 QVBoxLayout。没有自绘、没有委托,纯信号连线驱动状态。 + +```mermaid +classDiagram + class PasswordEdit { + +Q_PROPERTY bool textVisible + +Q_PROPERTY QString placeholderText + +Q_PROPERTY Strength strength + +text() QString + +setText(text) + +setTextVisible(visible) + +strength() Strength + +setPlaceholderText(text) + +computeStrength(text) Strength + -onTextChanged(text) + -updateStrengthIndicator() + -syncToggleText() + } + class QLineEdit { + EchoMode::Password + textChanged 信号 + } + class QToolButton { + 显/隐 文案 + clicked 信号 + } + class QLabel { + 强度色块 x3 + setStyleSheet 染色 + } + PasswordEdit o-- QLineEdit : edit_ + PasswordEdit o-- QToolButton : toggle_btn_ + PasswordEdit o-- QLabel : strength_labels_~3~ +``` + +三个角色各司其职:QLineEdit 负责输入与文本来源(`textChanged` 是整条链的源头信号),QToolButton 只负责发 `clicked` 去翻 `textVisible`,三个 QLabel 只负责按强度染色被刷新。状态收敛到两个成员变量——`text_visible_`(显隐态)和 `strength_`(强度档),其它都是派生。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/password_edit.h` | 接口:Strength 枚举 + Q_PROPERTY 三件套 + 公有 API + computeStrength 静态声明 | +| `src/password_edit.cpp` | 实现:子控件组装 + 强度算法 + 色块染色样式 + 信号透传 | +| `demo/password_edit_window.cpp` | 演示:显隐切换 + 三色块实时变 + 文本回显 + 勾选框双向联动 | + +### 一次输入怎么走完强度刷新 + +```mermaid +sequenceDiagram + participant U as 用户打字 + participant E as QLineEdit(edit_) + participant P as PasswordEdit + participant L as strength_labels_ + participant D as demo 回显 + U->>E: 输入字符 + E->>P: textChanged(text) + P->>P: onTextChanged: computeStrength(text) + alt strength_ 变化 + P->>P: strength_=新档; updateStrengthIndicator() + P->>L: 3 块按 strength_ 染色 (红/黄/绿/灰) + P-->>D: emit strengthChanged(新档) + end + P-->>D: emit textChanged(text) (透传) +``` + +重点在 `onTextChanged` 里的去重:只有 `computeStrength` 算出的新档和 `strength_` 不同时才刷新色块、发 `strengthChanged`。打字过程中强度档没变就不重复刷新,避免无意义的 setStyleSheet 调用和信号风暴。`textChanged` 则是无条件透传——demo 那个实时回显要的是每一个字符变化都通知,不能去重。 + +## 3. 关键设计决策 + +**① 组合 QLineEdit + QToolButton + QLabel,不自绘。** +PasswordEdit 继承 QWidget,把 QLineEdit(密码模式)当输入内核、QToolButton 当显隐开关、三个 QLabel 当强度色块,全部 `new ... this` 挂对象树。不重写 paintEvent,让子控件各自绘制;色块染色也只动 `setStyleSheet`,不画方块(`src/password_edit.cpp:160`)。这是 input/display 组合控件的定位——和自绘派(status-led)分道扬镳。收益是白嫖 QLineEdit 全套的输入/光标/选中/IME 行为,省掉自己处理键盘事件的麻烦;代价是少了一层视觉定制权。 + +**② 强度算法照规格落地,空文本安全归弱。** +`computeStrength` 是个纯静态函数(`src/password_edit.cpp:109`),统计字符种类(小写/大写/数字/符号各算一类),按「长度 < 6 或种类数 <= 1 → kWeak;种类数 == 2 → kMedium;种类数 >= 3 且长度 >= 8 → kStrong,否则 kMedium」出档。空文本在入口直接 return kWeak(`src/password_edit.cpp:111`),保证刚构造、还没打字时不会取到未初始化值。把它做成静态纯函数还有个好处——能独立写单元测试,不依赖任何控件实例。 + +**③ 色块染色用 setStyleSheet 四套样式,强度档映射成亮几块。** +四个 `static constexpr const char*` 样式串:`kStyleOff`(灰)/`kStyleWeak`(红)/`kStyleMedium`(黄)/`kStyleStrong`(绿)(`src/password_edit.cpp:18`)。`updateStrengthIndicator` 先按 `strength_` 选定「亮色」样式,再用 `lit = strength_ + 1` 算出亮几块(kWeak 亮 1、kMedium 亮 2、kStrong 亮 3),前 `lit` 块上亮色、其余上灰(`src/password_edit.cpp:160`)。这套「档位 = 亮块数」的映射简单到不会错,且色块永远只染当前档对应的单色,不做「红黄绿渐变」那种花活。 + +**④ 为 demo 回显需求补 textChanged 透传信号,拒绝 findChild hack。** +规格里只列了 `strengthChanged`,没列 `textChanged`。但 demo 要在用户输入时实时回显真实文本验证 `text()`,最初的做法是 `edit_->findChild()->textChanged` 去捞内部控件——这把 demo 和控件的内部成员强耦合,且 `findChild` 可能返回空指针不安全。解法是给 PasswordEdit 加一个 `textChanged(QString)` 信号,构造时把内部 `QLineEdit::textChanged` 透传出来(`src/password_edit.cpp:64`)。demo 改用 `&PasswordEdit::textChanged` 干净绑定,内部成员想怎么重构都不影响外部。这是「组合控件要把内部信号的常用需求透传出去」的标准姿势。 + +**⑤ 全部 setter 做去重早返,Q_PROPERTY 配函数指针 connect。** +`setTextVisible` 入口先判 `text_visible_ == visible` 早返(`src/password_edit.cpp:84`),`setPlaceholderText` 同理比对当前值(`src/password_edit.cpp:102`),避免重复发信号。`onTextChanged` 也只在强度档真变时才 emit `strengthChanged`(`src/password_edit.cpp:153`)。信号连线一律函数指针语法(`src/password_edit.cpp:62`),禁用 `SIGNAL/SLOT` 宏——编译期类型检查,笔误直接报错而不是运行期哑连。demo 里勾选框和显隐态的**双向联动**就是靠 `textVisibleChanged → setChecked` 和 `toggled → setTextVisible` 两条线互连(`demo/password_edit_window.cpp:61`),去重早返保证不会无限递归。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/password_edit.h` 的 Q_PROPERTY 三件套与 Strength 枚举**(31-34 行、38 行)——先看「对外暴露哪些属性和档位」 +2. **构造函数**(`src/password_edit.cpp:27`)——五个子控件怎么 new、两层布局怎么搭、三条信号怎么连 +3. **`computeStrength`**(`src/password_edit.cpp:109`)——强度算法的纯逻辑,种类统计 + 档位判定,最该独立读懂的一段 +4. **`onTextChanged`**(`src/password_edit.cpp:151`)——textChanged 怎么触发重算、去重怎么落地 +5. **`updateStrengthIndicator`**(`src/password_edit.cpp:160`)——档位怎么映射成「亮几块」、四套样式怎么选 +6. **`setTextVisible` + `syncToggleText`**(`src/password_edit.cpp:83` / `src/password_edit.cpp:180`)——显隐切换怎么改 echoMode、按钮文案怎么同步 +7. **demo 双向联动**(`demo/password_edit_window.cpp:61`)——勾选框和显隐态两条线互连、去重早返怎么防递归 + +入口:`demo/main.cpp` → `demo/password_edit_window.cpp` 跑起来,对照读。重点把「纯字母短输入 → 加数字 → 加符号大写」这三个强度跳变点和「点眼睛按钮 / 勾选框」双向联动跑一遍,看色块和回显怎么动。 + +## 5. 踩坑 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | demo 想监听密码文本变化回显,用了 `edit_->findChild()->textChanged` | 规格只给了 `strengthChanged`,没给文本变化信号;demo 只能去抠内部控件 | demo 和控件内部成员强耦合,内部一重构 demo 就崩;`findChild` 可能返回空指针解引用崩 | 给 PasswordEdit 加 `textChanged(QString)` 信号,构造时透传内部 `QLineEdit::textChanged`(`src/password_edit.cpp:64`),demo 改用 `&PasswordEdit::textChanged` 干净绑定 | +| ② | 勾选框和显隐态双向联动后,点一下陷入无限递归 / 栈溢出 | `textVisibleChanged → setChecked → toggled → setTextVisible → textVisibleChanged` 形成环,没有去重 | **栈溢出崩溃** | setter 入口判 `text_visible_ == visible` 早返(`src/password_edit.cpp:84`),环走到第二次值已相同直接 return | +| ③ | 打字过程中色块疯狂闪烁 / 接到一堆重复 `strengthChanged` | `onTextChanged` 每次都无条件刷新色块和发信号,哪怕强度档没变 | 无意义的 setStyleSheet 调用刷屏、外部被无意义信号干扰 | `onTextChanged` 里先 `computeStrength`,只有 `new_strength != strength_` 才刷新 + emit(`src/password_edit.cpp:153`) | +| ④ | 刚构造、还没打字,`strength()` 返回未初始化脏值 / 色块显示异常 | 成员 `strength_` 没给初值,或初始没调 `updateStrengthIndicator` | 首屏色块状态不确定 | 成员声明给初值 `strength_{kWeak}`(`include/password_edit.h:97`),构造末尾调一次 `updateStrengthIndicator()` 把初始 3 块全染灰(`src/password_edit.cpp:67`) | +| ⑤ | 空文本时 `computeStrength` 走种类统计返回意外档 / 崩 | 空串循环不执行,种类计数全 0,若没特判会落到 `classes <= 1 → kWeak` 还算对,但若逻辑写成 `length < 6` 先判则空串 length=0 也归弱——边界没显式处理易踩 | 边界行为不可预期 | 空文本入口直接 `return Strength::kWeak` 显式兜底(`src/password_edit.cpp:111`),不依赖后续逻辑碰巧覆盖 | +| ⑥ | 自绘色块想用 paintEvent 画方块,结果和 QLineEdit 的绘制打架 / 尺寸难控 | 误以为色块要自己画 | 偏离组合控件定位,多写一堆绘制代码 | 用 QLabel + `setStyleSheet` 染色即可(`src/password_edit.cpp:18`),色块本质就是个着色的 label,不需要自绘 | + +## 6. 官方文档 + +- [QLineEdit](https://doc.qt.io/qt-6/qlineedit.html)——密码框内核(`EchoMode::Password` 是显隐切换的基础) +- [QLineEdit::EchoMode](https://doc.qt.io/qt-6/qlineedit.html#EchoMode-enum)——密码模式枚举(Normal/Password 切换) +- [QToolButton](https://doc.qt.io/qt-6/qtoolbutton.html)——显隐切换按钮 +- [QLabel](https://doc.qt.io/qt-6/qlabel.html)——强度色块载体(setStyleSheet 染色) +- [QChar](https://doc.qt.io/qt-6/qchar.html)——字符种类判定(isLower/isUpper/isDigit) +- [Qt Style Sheets](https://doc.qt.io/qt-6/stylesheet.html)——色块染色的机制 +- [Qt 属性系统(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html)——textVisible / placeholderText / strength 三个属性的机制 +- [信号与槽(函数指针语法)](https://doc.qt.io/qt-6/signalsandslots.html)——`connect` 用函数指针而非 SIGNAL/SLOT 宏 + +--- + +这套机制(QLineEdit 组合 + 信号透传 + setter 去重 + stylesheet 染色)不是 PasswordEdit 专属——它就是「一个带状态反馈的输入型组合控件」的标准范式。后面做带确认强度的二次输入框、带输入限制的金额框,同一套骨架都能复用,只是把强度算法换成各自的校验逻辑。想自己搓?[手搓手册](./handbook/)带你从空 main 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/range-slider/handbook/01-paint-track-handles.md b/tutorial/engineering/instances/widget/range-slider/handbook/01-paint-track-handles.md new file mode 100644 index 0000000..55a11af --- /dev/null +++ b/tutorial/engineering/instances/widget/range-slider/handbook/01-paint-track-handles.md @@ -0,0 +1,40 @@ +--- +title: 01 画轨道和双手柄 +description: RangeSlider 手搓第 1 步——paintEvent 画圆角轨道 + 选中区间 + 两个圆手柄,并写通值/像素映射 +--- + +# 01 画轨道和双手柄 + +← [手册首页](./index.md) · 下一步 [02 拖动交互](./02-drag-handles.md) → + +> 对照成品:`widget/range-slider/src/range_slider.cpp` 的 `paintEvent`(271 行起)、`trackRect` / `valueToX`(172 / 178 行) + +## 目标 + +画出范围滑块的静态样子:一条圆角轨道(灰色药丸),中间一段高亮(选中区间),左右各一个白色圆形手柄。程序里改 `lower_value_` / `upper_value_`,两个手柄和区间跟着挪。 + +## 提示 + +- 先备好成员:`minimum_` / `maximum_`(默认 0 / 100)、`lower_value_` / `upper_value_`(默认 20 / 80),再加三个配色成员。 +- 写 `trackRect()`:在 `rect()` 基础上左右各缩进「半个手柄直径」。想想为什么缩进——为了让 `minimum` / `maximum` 这两个端点值的圆心正好落在轨道两端,拖到尽头手柄不会越界。手柄直径先定个常量(16px)。 +- 写 `valueToX(int value)`:`trackRect().left() + (value - minimum_) / (maximum_ - minimum_) * trackRect().width()`。**先别管除零**,下一步的约束 step 会补,但留个心眼。 +- `paintEvent` 里:开抗锯齿;轨道条高度取 `kHandleDiameter * 0.55`(比手柄略瘦,手柄才凸出可点);画底层轨道圆角条,再画选中区间(`lower` 到 `upper` 之间那一段),最后两个圆手柄。 +- 区间那段是个 `drawRoundedRect`,圆角半径取「条高度的一半」就是完美药丸两端。 +- 手柄圆心 x = `valueToX(lower_value_)` 和 `valueToX(upper_value_)`,y 取控件中线。 +- `sizeHint` 给个 `{200, 24}`、`minimumSizeHint` 给 `{120, 20}`,别让布局把它压成 0。 +- 不给整段代码,自己拼。 + +## 检查点 + +- 窗口里一条灰色药丸轨道,中间一段高亮,左右各一个白圆手柄,比例协调、四角圆润、无锯齿。 +- 在构造函数里把 `lower_value_` 改成 40 重新跑,下手柄往右挪了,高亮区间变窄——说明 `valueToX` 映射通了。 +- 拉伸窗口,轨道随手柄间距按比例伸缩,手柄圆心始终落在缩进后的轨道两端范围内。 + +对了进 [02 拖动交互](./02-drag-handles.md)。 + +## 对照答案 + +- 轨道 + 区间 + 手柄三层绘制在 `range_slider.cpp:281-302`。 +- `trackRect`(缩进逻辑)在 `range_slider.cpp:172-176`。 +- `valueToX` 映射在 `range_slider.cpp:178-186`。 +- 尺寸契约在 `range_slider.cpp:166-167`。 diff --git a/tutorial/engineering/instances/widget/range-slider/handbook/02-drag-handles.md b/tutorial/engineering/instances/widget/range-slider/handbook/02-drag-handles.md new file mode 100644 index 0000000..8435529 --- /dev/null +++ b/tutorial/engineering/instances/widget/range-slider/handbook/02-drag-handles.md @@ -0,0 +1,41 @@ +--- +title: 02 拖动交互 +description: RangeSlider 手搓第 2 步——双手柄 hit-test + 拖动阈值 + 松手吸附,复用 toggle-switch 三事件模式 +--- + +# 02 拖动交互 + +← [手册首页](./index.md) · 上一步 [01 画轨道和双手柄](./01-paint-track-handles.md) · 下一步 [03 约束与端点夹值](./03-constraints-range.md) → + +> 对照成品:`widget/range-slider/src/range_slider.cpp` 的 `hitTestHandle`(198 行起)、三个 `mouse*Event`(216 / 228 / 249 行)、`xToValue`(188 行) + +## 目标 + +能拖任一手柄,拖动时手柄跟手、区间高亮同步变化;点在手柄附近松手,最近手柄吸到点击位置。这一步先**不碰约束**——只要能拖、能跟手即可,约束留给 step 3。 + +## 提示 + +- 加一个私有 `enum class ActiveHandle { kNone, kLower, kUpper }` 标识当前抓的手柄,再加 `press_x_`(按下时鼠标 x)、`dragging_` 两个状态成员。 +- 写 `xToValue(qreal x)`:`valueToX` 的反函数。`minimum_ + (x - trackRect().left()) / trackRect().width() * (maximum_ - minimum_)`,结果 `std::round` 成 int。**这里要加除零兜底**:`span <= 0` 或 `width <= 0` 时直接返回 `minimum_`,不然窄控件会崩。 +- 写 `hitTestHandle(qreal x)`:算两个手柄当前圆心 x(`valueToX(lower)` / `valueToX(upper)`),算鼠标离两者的距离,离谁近且在容差(14px 左右)内就返回谁,都不在就 `kNone`。两个手柄重合时让 lower 优先(距离相等选 lower)。 +- `mousePressEvent`:左键按下记 `press_x_`、`dragging_ = false`,调 `hitTestHandle` 预选 `active_handle_`。注意这是**预选**,不是定死——真正的抓取等拖过阈值再说。 +- `mouseMoveEvent`:移动距离超过 `kDragThreshold`(4px)才把 `dragging_` 置 true。一旦进入拖动,`v = xToValue(当前x)`,按 `active_handle_` 调对应的 setter(这一步你可以先直接写成员变量赋值 + `update()`,step 3 再升级成带约束的 setter)。 +- `mouseReleaseEvent`:若全程 `!dragging_` 且 `active_handle_` 非 `kNone`,就把预选手柄吸到点击位置(`v = xToValue(x)` 设过去)。收尾把 `active_handle_` 重置成 `kNone`、`dragging_ = false`。 +- 这套三事件 + 阈值,和 toggle-switch 是同一个模板,节奏完全一致——拖过阈值才算拖、纯点击走吸附。 +- 不给整段代码,自己拼。 + +## 检查点 + +- 按住下手柄拖,下手柄跟手,区间左边界实时移动。 +- 按住上手柄拖,上手柄跟手,区间右边界实时移动。 +- 点在离下手柄 14px 内的地方松手(不拖),下手柄吸到点击位置。 +- 手只点一下、手抖 1-2px,不会被判成拖动(手柄不乱跑)。 +- 把窗口缩很窄还能拖不崩——说明 `xToValue` 的除零兜底生效了。 + +对了进 [03 约束与端点夹值](./03-constraints-range.md)。 + +## 对照答案 + +- `xToValue`(含除零兜底)在 `range_slider.cpp:188-196`。 +- `hitTestHandle`(最近优先 + 容差)在 `range_slider.cpp:198-211`。 +- 三事件 + 阈值 + 吸附在 `range_slider.cpp:216-266`。 diff --git a/tutorial/engineering/instances/widget/range-slider/handbook/03-constraints-range.md b/tutorial/engineering/instances/widget/range-slider/handbook/03-constraints-range.md new file mode 100644 index 0000000..83b05dc --- /dev/null +++ b/tutorial/engineering/instances/widget/range-slider/handbook/03-constraints-range.md @@ -0,0 +1,40 @@ +--- +title: 03 约束与端点夹值 +description: RangeSlider 手搓第 3 步——lower<=upper 约束 + 端点 setter 统一夹值 + setRange 一次性设两端 +--- + +# 03 约束与端点夹值 + +← [手册首页](./index.md) · 上一步 [02 拖动交互](./02-drag-handles.md) → + +> 对照成品:`widget/range-slider/src/range_slider.cpp` 的 `setLowerValue` / `setUpperValue`(111 / 125 行)、`setMinimum` / `setMaximum` / `setRange`(34 / 60 / 82 行) + +## 目标 + +把"约束"做扎实:不管外部怎么喂值、怎么改端点,控件永远维持 `lower <= upper` 且两值都在 `[minimum, maximum]` 内。这一步是把 step 2 里直接赋值的地方升级成带约束的 setter,再补全端点 setter 的统一夹值流程。 + +## 提示 + +- **手柄值 setter 内 clamp**:`setLowerValue` 把入参 `std::clamp(value, minimum_, upper_value_ - kHandleGap)`——下界是 minimum,上界是 `upper - gap`,这样 lower 永不越过 upper。`setUpperValue` 对称地夹到 `[lower + gap, maximum_]`。`kHandleGap` 默认 0 允许两手柄重合,想留间距调大它。clamp 后若值没变就 `return`(防无谓 `update`),变了就赋值 + emit 对应的 `*Changed` + `rangeChanged` + `update()`。 +- **`setMinimum` / `setMaximum` 改端点要回夹现有值**:`setMinimum(50)` 之后旧的 `lower = 20` 就跑到区间外了。两个 setter 收尾都得把 `lower_value_` / `upper_value_` 重新 `std::clamp(minimum_, x, maximum_)`,值变了补发 `lowerValueChanged` / `upperValueChanged` / `rangeChanged`,没变只发 `minimumChanged` / `maximumChanged`。别漏这个夹值步骤,否则端点改了约束就破了。额外细节:`setMinimum` 若新值越过当前 maximum,把 maximum 也顶上去(`setMaximum` 对称处理)。 +- **`setRange` 一次性设两端**:分别 `setMinimum` 再 `setMaximum` 会有"刚设完 minimum、maximum 还没跟上"的中间非法态。`setRange` 先静默写两端(`min > max` 自动 `std::swap`,别断言——纠正比报错友好),再走统一夹值流程、按需补发信号。 +- 把 step 2 里直接改成员变量的地方,全换成调这些带约束的 setter。拖动时 `setLowerValue(v)` / `setUpperValue(v)`,约束自动兜住——拖过头会被夹住、不会越过另一手柄。 +- 这一坨 setter 很重复,写完一个照葫芦画瓢即可,关键是想清楚"每个 setter 写完后,不变式还成立吗"。 +- 不给整段代码,自己拼。 + +## 检查点 + +- 拖下手柄往右追上手柄,到达重合点(gap=0)就停,不会越过。 +- 拖上手柄往左追下手柄同理停住。 +- `setLowerValue(999)`(远超 maximum),手柄夹到合法上界,不越界、不崩。 +- `setMinimum(50)`(高于当前 lower=20),lower 被夹到 50,标签 / 信号正确更新。 +- `setRange(100, 0)`(反着传),自动交换成 `[0, 100]`,不报错不崩。 +- demo 的程序化按钮组:连点 `setLowerValue(10)` 再点 `setUpperValue(90)`,每次都落在合法范围内,区间高亮方向正确。 + +搓完了,回 [手册首页](./index.md) 做进阶挑战。 + +## 对照答案 + +- 手柄值 setter 内 clamp 在 `range_slider.cpp:111-121` / `125-134`。 +- `setMinimum` / `setMaximum` 回夹现有值在 `range_slider.cpp:34-56` / `60-80`。 +- `setRange` 一次性设两端 + 自动交换在 `range_slider.cpp:82-104`。 diff --git a/tutorial/engineering/instances/widget/range-slider/handbook/index.md b/tutorial/engineering/instances/widget/range-slider/handbook/index.md new file mode 100644 index 0000000..d4a8b69 --- /dev/null +++ b/tutorial/engineering/instances/widget/range-slider/handbook/index.md @@ -0,0 +1,63 @@ +--- +title: "RangeSlider 手搓手册" +description: "从空 main 一行行搓出 RangeSlider:3 步打通自绘控件骨架、值/像素映射 + 双手柄 hit-test、端点统一夹值约束。" +--- + +# RangeSlider 手搓手册 + +> **source**:成品答案在 `widget/range-slider/`(做完对照)· **related**:自绘控件递进链第 3 环(toggle-switch · status-led)· [自定义控件绘制入门](../../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md) + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 `RangeSlider`,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **自定义控件骨架**:继承 QWidget + 重写 `paintEvent` + 实现 `sizeHint` / `minimumSizeHint` +- **值/像素双向映射**:把逻辑值 `[minimum, maximum]` 和像素 x 互相换算,是所有滑块类控件的命脉 +- **双值约束不变式**:`lower <= upper` 永远成立——setter 内 `std::clamp` + 端点统一夹值流程 +- **双手柄 hit-test**:照搬 toggle-switch 的三事件 + 拖动阈值,扩成"离谁近抓谁" +- **Q_PROPERTY 全套**:4 个值属性 + 3 个配色属性全 READ/WRITE/NOTIFY + +如果你已经搓过 toggle-switch,本手册会轻松很多——三事件 + 拖动阈值那一套原样复用,新增的核心是"双值约束"和"双手柄命中测试"。 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(200, 24); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 画轨道 + 双手柄 + 值/像素映射(静态可调) | [01 画轨道和双手柄](./01-paint-track-handles.md) | +| 2 | 双手柄拖动交互(hit-test + 阈值) | [02 拖动交互](./02-drag-handles.md) | +| 3 | 区间约束 + 端点统一夹值 | [03 约束与端点夹值](./03-constraints-range.md) | + +成品对照:`widget/range-slider/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **强制两手柄最小间距**:把 `kHandleGap` 调成正值(值单位),让两手柄无法重合,体验更接近 Excel 的范围筛选器。思考:这跟 hit-test 的容差该怎么配合才不会"间距大但点不动"? +- **垂直方向**:把 `valueToX` / `xToValue` 换成 y 维度,事件改读 y。体会"映射函数抽象成一对正反函数后,换轴只改一行"的好处。 +- **步进 / 吸附刻度**:拖动时把值 `round` 到整数倍 step。提示:在 `xToValue` 出口处吸附,而不是在 setter 里——这样程序化设值和拖动用同一套吸附。 +- **下一站**:带动画的进度类控件(circle-progress)——本控件没要动画,是想让你专注"约束 + 映射"这两件事;进度控件会反过来,专注动画、约束退居幕后。 diff --git a/tutorial/engineering/instances/widget/range-slider/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/range-slider/handbook/troubleshooting.md new file mode 100644 index 0000000..89759dd --- /dev/null +++ b/tutorial/engineering/instances/widget/range-slider/handbook/troubleshooting.md @@ -0,0 +1,52 @@ +--- +title: 卡住怎么办 +description: RangeSlider 手搓常见错——手柄不跟手、越过彼此、改端点约束破、窄控件崩——指向成品,不给完整答案 +--- + +# 卡住怎么办 + +按症状找,只指方向。答案在成品 `widget/range-slider/`。 + +## 手柄画在固定位置 / 不跟着值走 + +`paintEvent` 里手柄圆心 x 写死成常量了,没用 `valueToX`。圆心 x 必须是 `valueToX(lower_value_)` / `valueToX(upper_value_)`,值变了手柄才挪。对照 `range_slider.cpp:287-288,301-302`。 + +## 拖动时手柄飞出轨道 / 越过另一手柄 + +setter 里没 clamp,直接把 `xToValue` 的结果写进了成员变量。`setLowerValue` 要夹到 `[minimum_, upper_value_ - gap]`、`setUpperValue` 夹到 `[lower_value_ + gap, maximum_]`。对照 `range_slider.cpp:113,126`。 + +## 改完 minimum / maximum 后约束破了(lower 跑到区间外) + +端点 setter 只改了 `minimum_` / `maximum_`,没回夹现有 `lower_value_` / `upper_value_`。setter 收尾要把两值重新 `std::clamp(minimum_, x, maximum_)`,值变了补发信号。对照 `range_slider.cpp:44-53,69-77`。 + +## 分别 setMinimum / setMaximum 时中间态崩 + +先 `setMinimum(80)` 再 `setMaximum(20)`,第一步之后 `minimum > maximum` 就已经非法。用 `setRange` 一次性设两端(反着传会自动 swap),避开中间态。对照 `range_slider.cpp:82-104`。 + +## 两手柄重合后点一下抓错手柄 + +`hitTestHandle` 在两距离相等时没定优先级,或容差太大导致两个都命中。让 lower 优先(`d_lower <= d_upper` 时选 lower),且只有真正在容差内才命中。对照 `range_slider.cpp:204-209`。 + +## 两手柄重合时区间绘制异常 + +`x_upper == x_lower` 时 `drawRoundedRect` 拿到宽度 0 可能退化。区间宽度一律 `std::max(0.0, |x_upper - x_lower|)`。对照 `range_slider.cpp:291`。 + +## 点一下被当成拖动,手柄乱跑 + +没设移动阈值,手抖就被判拖。`mouseMoveEvent` 里移动没超过 `kDragThreshold`(4px)一律不进拖动模式;纯点击走松手吸附。对照 `range_slider.cpp:234-237,254-262`。 + +## 窗口缩很窄时崩 / 手柄飞掉 + +`xToValue` 里 `(x - left) / width` 在 `width <= 0` 或 `span <= 0` 时除零得 NaN。两个映射函数都得判兜底。对照 `range_slider.cpp:181-183,191-193`。 + +## valueToX 把端点手柄画到控件边界外(圆心越界) + +`trackRect` 没缩进。左右各让出半个手柄直径,端点值的圆心才正好落在轨道两端。对照 `range_slider.cpp:172-176`。 + +## 拖动卡顿 / 掉帧 + +setter 里用了 `repaint()` 同步重绘,没走事件循环合并。一律 `update()` 异步重绘。对照 `range_slider.cpp:120,133`。 + +--- + +实在卡死,成品 `widget/range-slider/src/range_slider.cpp` 就是答案——但先自己拼。 diff --git a/tutorial/engineering/instances/widget/range-slider/index.md b/tutorial/engineering/instances/widget/range-slider/index.md new file mode 100644 index 0000000..1f55a75 --- /dev/null +++ b/tutorial/engineering/instances/widget/range-slider/index.md @@ -0,0 +1,155 @@ +--- +title: "RangeSlider 成品导览" +description: "AwesomeQt::RangeSlider 自绘双端范围滑块成品——双手柄 hit-test + 值/像素映射 + 区间高亮 + 完整 Q_PROPERTY,附架构、设计决策、踩坑与阅读路径。" +--- + +# RangeSlider 成品导览 + +> **source**:`widget/range-slider/` **related**:自绘控件递进链第 3 环(toggle-switch · status-led)· [自定义控件绘制入门](../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md) + +`RangeSlider` 是个水平双柄滑块——左右两个手柄各自代表一个值,中间那段就是你选中的范围。单柄的 `QSlider` 只能定一个点,做「价格区间」「年龄过滤」这种"我要的是一个 [下限, 上限] 区间"的需求就得它。听起来比 toggle-switch 多一个手柄而已,但"两个手柄不能越过彼此"这件事,引出了一整套统一的夹值流程,是把 toggle-switch 的交互骨架真正用熟的好练手。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::RangeSlider` 控件: + +- **双端取值**:`lowerValue` / `upperValue` 各占一手柄,取值范围 `[minimum, maximum]`,且始终满足 `lower <= upper` +- **拖动跟手**:拖任一手柄实时跟随鼠标(不用动画,即时映射),选中区间同步高亮 +- **点击吸附**:点在手柄附近松手,最近手柄直接吸到点击位置,比"必须精准点中圆心"更贴滑块手感 +- **完整 Q_PROPERTY**:`minimum` / `maximum` / `lowerValue` / `upperValue` 四值 + `handleColor` / `trackColor` / `rangeColor` 三配色,全可被 Designer / 外部改 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/range-slider/demo/range_slider_demo +``` + +demo 四组:静态区间(lower=20 / upper=80)、交互拖动(QLabel 实时联动)、程序化设值(按钮调 `setLowerValue(10)` / `setUpperValue(90)`,验证约束照样生效)、双配色主题(蓝绿 + 橙)。 + +## 2. 架构总览 + +### 类关系 + +平铺一个控件类,直接继承 `QWidget`,没有托管子对象——所有状态(四个值 + 拖动状态)都是普通成员变量,交互全靠重写三个鼠标事件加两个映射函数。 + +```mermaid +classDiagram + class RangeSlider { + +Q_PROPERTY int minimum + +Q_PROPERTY int maximum + +Q_PROPERTY int lowerValue + +Q_PROPERTY int upperValue + +Q_PROPERTY QColor handleColor + +Q_PROPERTY QColor trackColor + +Q_PROPERTY QColor rangeColor + +setRange(int min, int max) + +setLowerValue(int) + +setUpperValue(int) + -trackRect() QRectF + -valueToX(int) qreal + -xToValue(qreal) int + -hitTestHandle(qreal) ActiveHandle + -paintEvent(QPaintEvent*) + -mousePressEvent(QMouseEvent*) + -mouseMoveEvent(QMouseEvent*) + -mouseReleaseEvent(QMouseEvent*) + } + class ActiveHandle { + <> + kNone + kLower + kUpper + } + RangeSlider *-- ActiveHandle : active_handle_ +``` + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/range_slider.h` | 接口:7 个 Q_PROPERTY + 区间端点 / 手柄值 / 配色 API + 私有 `ActiveHandle` 枚举 | +| `src/range_slider.cpp` | 实现:端点统一夹值、值/像素映射、双手柄 hit-test、自绘轨道/区间/手柄 | +| `demo/range_slider_window.cpp` | 演示:静态 / 交互联动 / 程序化按钮 / 双配色 四组 | + +### 拖一个手柄怎么跑起来 + +```mermaid +sequenceDiagram + participant U as 鼠标 + participant R as RangeSlider + participant H as hitTestHandle + participant P as paintEvent + U->>R: press 在下手柄附近 + R->>H: hitTestHandle(x) → kLower + R->>R: active_handle_=kLower; dragging_=false + U->>R: move 超过 kDragThreshold + R->>R: dragging_=true + R->>R: v=xToValue(x); setLowerValue(v) + R->>R: clamp 到 [minimum, upper-gap]; emit rangeChanged; update() + R->>P: 合并触发重绘 + P->>P: 画轨道 + 区间 + 两手柄(圆心=valueToX) +``` + +## 3. 关键设计决策 + +**① 端点 setter 内部统一夹值,并把 `setRange` 做成一次性设两端。** +`setMinimum` / `setMaximum` 改端点后,现有 `lower` / `upper` 可能落在新区间外或违反 `lower <= upper`,所以两个 setter 收尾都把现有值 `std::clamp` 回 `[minimum, maximum]` 再视情况补发值变更信号(`range_slider.cpp:44-53`、`range_slider.cpp:69-77`)。`setRange` 进一步提供一次性设两端,且 `min > max` 时自动 `std::swap` 而非断言——比起分别 `setMinimum` 再 `setMaximum`,它没有"刚设完一端、另一端还没跟上"的中间非法态(`range_slider.cpp:82-104`)。 + +**② 约束语义直接在 setter 里用 `std::clamp` 实现,喂越界值也安全。** +`setLowerValue` 夹到 `[minimum, upper - gap]`、`setUpperValue` 夹到 `[lower + gap, maximum]`(`range_slider.cpp:113`、`range_slider.cpp:126`)。`gap` 就是 `kHandleGap`,默认 0 允许两手柄重合在同一点;想强制留间距改这一个常量即可。这意味着外部哪怕直接喂个 999,控件也只会夹到合法上界,绝不会让 lower 越过 upper。 + +**③ 交互照搬 toggle-switch 的三事件 + 拖动阈值模式,扩成双手柄 hit-test。** +`mousePressEvent` 用 `hitTestHandle` 预选离鼠标最近且在容差内的手柄(`range_slider.cpp:198-211`),但**实际抓取等拖过 `kDragThreshold`(4px)再定**,避免一次轻微手抖就被当拖动(`range_slider.cpp:234-236`)。`mouseReleaseEvent` 里若全程没拖动,则把最近手柄直接吸到点击位置——这比"必须精准点中圆心才能拖"更贴真实滑块手感(`range_slider.cpp:254-262`)。`active_handle_` 用私有 `enum class ActiveHandle { kNone, kLower, kUpper }` 标识当前抓手(`range_slider.h:88`)。 + +**④ 映射函数除零兜底,端点值圆心正好落在轨道两端。** +`valueToX` / `xToValue` 在 `span <= 0`(即 `minimum == maximum`)或 `trackRect().width() <= 0` 时直接返回左端点 / `minimum`,防控件被布局压极窄时除零导致 NaN 坐标(`range_slider.cpp:181-183`、`range_slider.cpp:191-193`)。`trackRect()` 左右各让出半个手柄直径(`range_slider.cpp:172-176`),于是 `minimum` 和 `maximum` 两个端点的手柄圆心正好落在轨道两端,拖到尽头圆心不会越过控件边界。 + +**⑤ paintEvent 防退化 + 异步重绘。** +轨道条高度取 `kHandleDiameter * 0.55` 并 `std::max` 夹到 `>= 2`(`range_slider.cpp:277`),手柄 16px 凸出可点;区间宽度用 `std::max(0.0, |x_upper - x_lower|)`(`range_slider.cpp:291`),防两手柄重合时 `drawRoundedRect` 拿到非法宽度;手柄加 1px 灰描边提升辨识度。所有重绘走 `update()` 异步合并,拖动跟手且不掉帧。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`include/range_slider.h` 的 7 个 Q_PROPERTY**(26-32 行)——先看对外暴露哪些可驱动属性 +2. **端点统一夹值流程**——`setRange`(`range_slider.cpp:82`)看"一次性设两端 + clamp + 按需补发信号"这套范式,`setMinimum` / `setMaximum` 是它的拆分版 +3. **约束语义**——`setLowerValue`(`range_slider.cpp:111`)盯 `std::clamp(value, minimum_, upper_value_ - kHandleGap)` 这一行,理解 lower 永不越过 upper +4. **几何映射**——`trackRect`(`range_slider.cpp:172`)+ `valueToX` / `xToValue`(`range_slider.cpp:178`、`range_slider.cpp:188`),注意两处除零兜底 +5. **交互状态机**——`hitTestHandle`(`range_slider.cpp:198`)+ 三个 `mouse*Event`(`range_slider.cpp:216` / `228` / `249`),这是最容易踩坑的部分 +6. **自绘**——`paintEvent`(`range_slider.cpp:271`),轨道 / 区间 / 手柄三层 + +入口:`demo/main.cpp` → `RangeSliderWindow` 四组布局,对照读。 + +## 5. 踩坑 + +这几个坑都是实现这个控件时真处理过的,代码里能逐条对上。 + +**坑 1:改了端点,旧值没跟着夹,区间约束被破坏** +现象是 `setMinimum(50)` 之后 `lowerValue` 还停在 20,lower 直接跑到 minimum 之外,更糟的是 lower 可能反超 upper。原因是端点变了但 setter 没重新夹现有值,约束只在 `setLowerValue` 那一刻生效。后果是控件出现 `lower < minimum` 或 `lower > upper` 的非法态,映射函数画出越界坐标、区间高亮方向错乱。解法是端点 setter 收尾统一对 `lower` / `upper` 做 `std::clamp(minimum_, lower, upper)`,值变了就补发 `lowerValueChanged` / `upperValueChanged` / `rangeChanged`,否则只发 `minimumChanged` / `maximumChanged`(`range_slider.cpp:44-54`、`range_slider.cpp:69-79`)。 + +**坑 2:两手柄拖到完全重合后行为混乱** +现象是两个手柄被拖到同一个点后,hit-test 分不清该抓哪个、区间宽度退化成 0、`drawRoundedRect` 拿到非法参数。原因是 lower 与 upper 之间没有任何最小间距约束,`valueToX(lower) == valueToX(upper)` 时命中测试和绘制都退化。后果是重合点上点一下可能抓错手柄,绘制可能出现锯齿或异常。解法是引入 `kHandleGap`(值单位,默认 0 允许重合,需要时调大),`setLowerValue` 夹到 `upper - gap`、`setUpperValue` 夹到 `lower + gap`;`paintEvent` 区间宽度一律 `std::max(0.0, |x_upper - x_lower|)`(`range_slider.cpp:113`、`range_slider.cpp:126`、`range_slider.cpp:291`)。 + +**坑 3:控件被布局压到极窄时除零、坐标变 NaN** +现象是窗口缩到很窄时手柄飞掉或控件完全不画、甚至偶发崩溃。原因是 `minimum == maximum`(span=0)或 `trackRect().width() <= 0` 时直接做除法,`xToValue` 里 `(x - left) / width` 除以 0 得到 NaN,喂给 `drawRoundedRect` 行为未定义。后果是绘制退化或崩溃。解法是两个映射函数都判 `span <= 0` / `width <= 0` 兜底——`valueToX` 返回轨道左端、`xToValue` 返回 `minimum`;轨道条高度也 `std::max` 夹到 `>= 2.0`(`range_slider.cpp:181-183`、`range_slider.cpp:191-193`、`range_slider.cpp:277`)。 + +**坑 4:点一下被当成拖动,手柄乱跳** +和 toggle-switch 同一个坑。现象是手只点一下没拖,最近手柄却没吸过去或位置乱。原因是没设移动阈值,手抖 1-2 像素就被判成拖动,按下时的命中预选和真正的抓取没分开。后果是点击手感错乱。解法是 `mouseMoveEvent` 里移动没超过 `kDragThreshold`(4px)一律不进拖动模式,且 `mouseReleaseEvent` 里若全程没拖过就把预选手柄吸到点击位置(`range_slider.cpp:234-237`、`range_slider.cpp:254-262`)。 + +## 6. 官方文档 + +- [QWidget(自绘控件基类)](https://doc.qt.io/qt-6/qwidget.html) +- [QPainter(绘图引擎)](https://doc.qt.io/qt-6/qpainter.html) +- [The Property System(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html) +- [QMouseEvent(鼠标交互)](https://doc.qt.io/qt-6/qmouseevent.html) +- [QSlider(单柄滑块,对照理解双柄差异)](https://doc.qt.io/qt-6/qslider.html) + +--- + +双柄比单柄多出来的不是"再加一个手柄画一个圆",而是"两个值之间永远得维持一个不变式(lower <= upper)"——这套端点统一夹值 + setter 内 clamp 的范式,往后做任何带约束的双值控件都能照搬。想自己搓?[手搓手册](./handbook/) 带你从空 main 一行行搓到这个成品。 diff --git a/tutorial/engineering/instances/widget/speed-meter/handbook/01-static-gauge.md b/tutorial/engineering/instances/widget/speed-meter/handbook/01-static-gauge.md new file mode 100644 index 0000000..772a405 --- /dev/null +++ b/tutorial/engineering/instances/widget/speed-meter/handbook/01-static-gauge.md @@ -0,0 +1,64 @@ +--- +title: "Step 1:画静态表盘(背景弧 + 主/次刻度)" +description: "重写 paintEvent 画背景弧和刻度——先把屏幕角 β 与 drawArc 两套约定在同一个 paintEvent 里自洽跑通,这是 SpeedMeter 的地基。" +--- + +# Step 1:画静态表盘(背景弧 + 主/次刻度) + +← [手册首页](./index.md) · 下一步 [Step 2 指针与映射](./02-needle-and-mapping.md) → + +## 目标 + +屏幕上出现一个**开口朝下**的弧形表盘:一段浅灰粗弧从左下顺时针扫到右下(270°),弧上铺 11 条主刻度(深灰粗线 + 数字标签),主刻度之间还有更短的次刻度。这步先不画指针、不做动画,**专注把两套角度体系跑通**——这是整件成品最容易绊人的地方,单独拎出来练。 + +## 提示(按顺序) + +**先把角度约定定下来**(这套约定整个控件都用,写进 `.cpp` 顶部注释别忘): + +- 屏幕角 β(cos/sin + rotate 用):3 点钟为 0°、**顺时针为正**——和 QPainter 在 y 朝下屏幕坐标系的旋转方向一致 +- 表盘弧开口朝下:v=0 在 **135°(左下 7:30)**、v=max 在 45°(右下 4:30,= 135+270 取模)、mid 在 270°(顶部) +- 第 i 条主刻度(共 11 条,i=0..10)对应角度 `ang = 135 + (i/10) * 270` + +**画背景弧**——这是第一个坑。`QPainter::drawArc` 的角度是 **1/16°**、0°=3 点钟、**正值逆时针**,和我们的屏幕角 β(顺时针为正)差一个 y 翻转。直接把 β 塞进去会把弧画反方向。正确做法: + +- `p.drawArc(rect, 起始角*16, 扫角*16)` +- 起始角 = 225(正是 β=135°「左下」在 drawArc 约定下的读数),扫角 = **-270**(负扫角 = 顺时针铺开,抵消 drawArc 默认的逆时针) + +**画主刻度**——用屏幕角 β + 三角函数算端点,**不**走 drawArc(β 顺时针为正,cos/sin 在 y 朝下屏幕直接对): + +- `rad = ang * π / 180` +- 外端点 `outer = (cx + tick_outer*cos(rad), cy + tick_outer*sin(rad))` +- 内端点同理、半径换 `tick_major_inner` +- `p.drawLine(inner, outer)` 画粗线 +- 数字标签:往内再缩一点画 `QString::number(i * max/10)`,用 `QFontMetrics` 居中 + +**画次刻度**——每两个主刻度间 5 等分(插 4 条细短线),内端比主刻度更短,画笔细(1px)。 + +**几何兜底**:半径全部 `std::max(1.0, ...)`,`side = std::max(1, std::min(width(), height()))`——控件被布局压极小时防控制行为未定义。 + +## 关键认知——为什么要分两套角度 + +很多人栽在这一步:以为「反正都是角度,drawArc 直接传」——结果弧逆时针铺、刻度顺时针铺,开口朝向反着。根因是 `drawArc` 的角度定义(正值逆时针、1/16°)和屏幕角 β(顺时针为正)**不是一套东西**,差一个 y 翻转。所以成品里刻度/指针端点一律用 β + 三角函数算,**只有**那段粗背景弧单独走 drawArc、并用负扫角修正方向。把这两套的边界在心里画清,后面就不会反复。 + +## 检查点 + +- 跑出来是开口朝下的一段浅灰粗弧,左下端到右下端正好 270° +- 11 条主刻度均匀铺在弧上,每条下面有数字(0/22/44/.../220,假设 maxValue=220) +- 主刻度间夹着更短的次刻度 +- 缩放窗口:弧和刻度整体跟着比例缩,方向不变(不变形) + +对了进下一步。如果弧方向画反、或刻度铺到弧外,翻 [troubleshooting](./troubleshooting.md) 的「弧方向画反」。 + +> QPainter / 坐标系不熟?先读 [QPainter 绘图基础](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md)、[坐标变换](../../../../../beginner/02-qtgui/02-coordinate-transform-beginner.md)、[自定义控件绘制入门](../../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md)。 + +## 对照答案 + +- 角度约定常量 + 注释:`src/speed_meter.cpp:23-36` +- 背景弧 drawArc(负扫角顺时针):`src/speed_meter.cpp:197-208` +- 主刻度 + 数字标签(三角函数算端点,小尺寸自动隐藏):`src/speed_meter.cpp:223-248` +- 次刻度(主刻度间 5 等分):`src/speed_meter.cpp:250-264` +- 几何 clamp 兜底:`src/speed_meter.cpp:188-195` + +--- + +下一步:[Step 2 加指针 + value→角度映射](./02-needle-and-mapping.md)——让针能静态指到一个值,并补底部数字读数。 diff --git a/tutorial/engineering/instances/widget/speed-meter/handbook/02-needle-and-mapping.md b/tutorial/engineering/instances/widget/speed-meter/handbook/02-needle-and-mapping.md new file mode 100644 index 0000000..7cf6fa9 --- /dev/null +++ b/tutorial/engineering/instances/widget/speed-meter/handbook/02-needle-and-mapping.md @@ -0,0 +1,66 @@ +--- +title: "Step 2:指针 + value→角度映射 + 数字读数(静态指值)" +description: "实现 angleForValue 把 value 映射成屏幕角 β,用 save/translate/rotate/restore + QPolygonF 画根粗尖细指针(rotate(β) 直接转,不用修正),补底部数字读数。这步指针静态指值,动画下一步加。" +--- + +# Step 2:指针 + value→角度映射 + 数字读数(静态指值) + +← [Step 1](./01-static-gauge.md) · [手册首页](./index.md) · 下一步 [Step 3 平滑动画](./03-smooth-animation.md) → + +## 目标 + +加一个 `setValue(int)`,针能静态指到那个值;底部居中绘出当前数值。**这步指针不旋转**(直接 set 角度重绘),动画留到 step 3——先把「value 怎么变成角度、指针怎么画」两件事做对。 + +## 提示(按顺序) + +**1. 实现 `angleForValue(int v)`**——value 到屏幕角 β 的映射: + +- 夹值 `v` 到 `[0, maxValue]`,防越界 +- `max = std::max(1, maxValue)` 防 maxValue≤0 除零 +- 返回 `135 + (v / max) * 270`(和 step 1 主刻度的角度公式同源,保证针和刻度对齐) + +为什么是 135 起?屏幕角 β 以 3 点钟为 0°、顺时针为正(和 `rotate`、`cos/sin` 在 y 朝下的屏幕坐标系完全一致):v=0 要指左下 = 135°(7:30 方向),v=max 指右下 = 45°(= 135+270 取模 360,4:30 方向),mid 指顶部 = 270°(12:00)。 + +**2. 加成员 `qreal needle_angle_`** 存当前指针角度(这步直接 `needle_angle_ = angleForValue(value_)` 赋值,下一步动画才每帧改它)。`setValue` 里改 `value_`、更新 `needle_angle_`、`update()`——**这步先做突变**。 + +**3. 画指针**——这是这步的核心。用坐标变换比手算两端 drawLine 省事: + +- `p.save(); p.translate(center); p.rotate(angle); ... p.restore();` +- 因为 β 本身就是 rotate 用的那个屏幕角(顺时针为正),**直接 `p.rotate(needle_angle_)` 就对**,不用加减任何修正 +- 验证:value=0 → rotate 135° 指左下;value=max → rotate 45° 指右下——和刻度对齐 + +**4. 指针用 QPolygonF 画根粗尖细**:在 rotate 后的坐标系里画多边形(水平指向 +x),根部半宽大、尖部半宽小、尾端往中心后方伸一点。`drawPolygon` + `NoPen` + 指针色 brush。画完 restore。 + +**5. 中心轴帽**:一个小实心圆盖在指针根部(`drawEllipse(center, cap_r, cap_r)`),用 `needleColor().darker(130)` 加深一点有层次。 + +**6. 底部数字读数**:当前 value 居中绘在开口下方(`center.y() + gauge_r * 0.55` 附近)。字号 `side * 0.08`(至少 8pt),加粗,用 `QFontMetrics` 水平居中。 + +## 关键认知——为什么 rotate(β) 不用修正 + +`painter.rotate` 的角度是「3 点钟为 0°、顺时针为正」——这正好和 step 1 定的屏幕角 β 同一套。所以指针只要 `rotate(needle_angle_)`,β 是几就转几度,针尖(默认指 +x = 3 点钟)就指到 β 方向,和刻度天然对齐。 + +这套「不用修正」是用坑换来的:早期版本给 `needle_angle_` 用了和 `drawArc` 同一套的「0°=3 点、逆时针为正」约定,结果 cos/sin 在屏幕 y 朝下时和 drawArc 的 y 朝上差一个翻转,刻度和指针被上下镜像——value=0 的针怼到了 value=max 那头。统一到屏幕角 β(顺时针为正)之后这事就消失了。踩坑细节见成品导览的踩坑②。 + +## 检查点 + +- `setValue(0)` 针指左下(135° 那端),`setValue(220)` 针指右下(45° 那端),中间值对应指在弧上正确位置 +- 指针根粗尖细、根部有实心小圆帽 +- 底部数字和 setValue 的值一致、居中、随控件缩放 +- 针的延长线穿过中心(不歪)= rotate 角度对了 + +对了进下一步。如果指针指反端(value=0 指到了 max 那头)或上下镜像,翻 [troubleshooting](./troubleshooting.md) 的「指针指反端 / 上下镜像」。 + +> QPainter 坐标变换不熟?读 [坐标变换](../../../../../beginner/02-qtgui/02-coordinate-transform-beginner.md)、[QFontMetrics 文本测量](../../../../../beginner/02-qtgui/04-font-text-rendering-beginner.md)。 + +## 对照答案 + +- `angleForValue` 映射(135° 起、270° 扫、除零保护):`src/speed_meter.cpp:64-68` +- setValue(这步的突变版可对照成品的逻辑骨架,动画部分 step 3 才加):`src/speed_meter.cpp:74-86` +- 指针坐标变换(save/translate/rotate/restore,rotate(β) 直接转):`src/speed_meter.cpp:266-288` +- 根粗尖细多边形顶点:`src/speed_meter.cpp:278-283` +- 中心轴帽:`src/speed_meter.cpp:290-296` +- 底部数字读数:`src/speed_meter.cpp:298-311` + +--- + +下一步:[Step 3 给指针加平滑旋转动画(value/needleAngle 解耦)](./03-smooth-animation.md)——这是整套控件的核心,让指针转起来还不崩。 diff --git a/tutorial/engineering/instances/widget/speed-meter/handbook/03-smooth-animation.md b/tutorial/engineering/instances/widget/speed-meter/handbook/03-smooth-animation.md new file mode 100644 index 0000000..3174497 --- /dev/null +++ b/tutorial/engineering/instances/widget/speed-meter/handbook/03-smooth-animation.md @@ -0,0 +1,54 @@ +--- +title: "Step 3:平滑旋转动画(value/needleAngle 解耦——核心)" +description: "把 step 2 的突变指针升级成 400ms 平滑旋转:Q_PROPERTY 暴露 needleAngle,QPropertyAnimation 每帧驱动,value 业务属性与 needleAngle 动画属性解耦,持久指针 + 接力防连切跳变与悬空。" +--- + +# Step 3:平滑旋转动画(value/needleAngle 解耦——核心) + +← [Step 2](./02-needle-and-mapping.md) · [手册首页](./index.md) → + +这步是整个控件的核心——把 step 2 的「突变指针」升级成「400ms 平滑旋转」。诀窍和 status-led / toggle-switch 一脉相承:**把「当前指针角度」做成一个 Q_PROPERTY,让 QPropertyAnimation 每帧去写它**,而 value 只当业务入口。 + +## 目标 + +加 `Q_PROPERTY(qreal needleAngle ...)`,setValue 时启动一个 `QPropertyAnimation` 把 needleAngle 从当前角度接力到 `angleForValue(新value)`,指针平滑旋转。Slider 连拖 / 狂点不跳变、不崩。 + +## 提示(按顺序) + +1. **把 needleAngle 声明成 Q_PROPERTY**:`Q_PROPERTY(qreal needleAngle READ needleAngle WRITE setNeedleAngle NOTIFY needleAngleChanged)`,补 `needleAngleChanged` 信号 +2. **`setNeedleAngle(qreal)` 作为 WRITE 回调**——这是动画每帧调的:纯赋值 `needle_angle_ = angle` + `emit needleAngleChanged` + `update()`。**只赋值,不启动画**。用 `qFuzzyCompare` 去重防抖(相邻帧角度几乎相同就不重绘) +3. **改 setValue**:不再直接设 `needle_angle_`,而是 + - `needle_anim_->stop()` + - `setStartValue(needle_angle_)`(从**当前显示角度**接力,不是从上次的目标角度) + - `setEndValue(angleForValue(value))` + - `start()` +4. **needle_anim_ 用持久成员指针**:构造时 `new QPropertyAnimation(this, "needleAngle", this)`(parent=this 托管),配 `setDuration(400)` + `setEasingCurve(QEasingCurve::OutCubic)`,**不要用 `DeleteWhenStopped`**——频繁切换时指针悬空崩 +5. value 也做成 Q_PROPERTY(READ/WRITE/NOTIFY),但 value 的 WRITE 指 setValue(业务入口);needleAngle 的 WRITE 指 setNeedleAngle(纯赋值)——别搞反 + +## 关键认知 + +- **value 与 needleAngle 解耦的根因**:如果 needleAngle 的 WRITE 指向 setValue,动画每帧驱动 setValue → setValue 又启动画 → 无限递归栈溢出。所以 WRITE 必须指那个**纯赋值的回调**(setNeedleAngle),setValue 只是业务入口算映射后启动画。这套和 status-led 的 setAnimatedColor 同构,[troubleshooting](./troubleshooting.md) 有专门一节讲这个崩溃。 +- **从 needle_angle_ 接力**而非从目标角度重启——快速连切(Slider 拖、Random Jump 狂点)时指针从它此刻停的位置直接转到新目标,不会先闪回旧目标。这就是 demo 里 Random Jump 狂点看着顺滑的原因。 +- **持久指针 vs DeleteWhenStopped**:DeleteWhenStopped 在 stop 时 delete 对象,成员指针就悬空了;持久指针 + stop()/重配/start() 复用同一对象,没有 new/delete 抖动、不悬空。 + +## 检查点 + +- `setValue(180)` 后指针 **400ms 平滑旋转**到 180(不是瞬移)= 动画对了 +- 用 QSlider 连续拖:指针连贯追踪、不卡顿不跳变 = 接力逻辑对了 +- Random Jump 狂点:指针平滑改向、不闪回旧目标 = setStartValue 取当前显示值对了 +- 快速反复 setValue 不崩 = 持久指针对了(没用 DeleteWhenStopped) + +搓到这,SpeedMeter 主体就齐了。想再深一层(指针危险区渐红 / 高转速刻度分段配色 / 过冲回弹)回 [手册首页](./index.md) 看进阶挑战。 + +> 动画框架不熟?[属性动画框架基础](../../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md)、进阶 [动画框架进阶](../../../../../advanced/03-qtwidgets/09-animation-advanced.md)。Q_PROPERTY 机制不熟?[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)、进阶 [属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 + +## 对照答案 + +- needleAngle 的 Q_PROPERTY(WRITE 指 setNeedleAngle):`include/speed_meter.h:31` +- needle_anim_ 初始化(持久指针 + parent=this + duration/easing):`src/speed_meter.cpp:54-58` +- setValue 启动过渡(stop/setStart(needle_angle_)/setEnd/start):`src/speed_meter.cpp:74-86` +- setNeedleAngle 回调(qFuzzyCompare 去重 + 赋值 + emit + update):`src/speed_meter.cpp:95-101` + +--- + +搓完了。跑 demo 对照成品:静态几档 + Cycle 接力 + Slider 追踪 + Random Jump 狂点都能复现 = 你搓的和 repo 一致。完整 demo 见 `demo/speed_meter_window.cpp` 四组布局。 diff --git a/tutorial/engineering/instances/widget/speed-meter/handbook/index.md b/tutorial/engineering/instances/widget/speed-meter/handbook/index.md new file mode 100644 index 0000000..d35492a --- /dev/null +++ b/tutorial/engineering/instances/widget/speed-meter/handbook/index.md @@ -0,0 +1,63 @@ +--- +title: "SpeedMeter 手搓手册" +description: "从空 main 一行行搓出 SpeedMeter:3 步打通自绘表盘骨架、value/needleAngle 解耦动画、双角度体系自洽。" +--- + +# SpeedMeter 手搓手册 + +> **source**:成品答案在 `widget/speed-meter/`(做完对照)· **related**:自绘控件递进链([status-led](../../status-led/) · [toggle-switch](../../toggle-switch/) 已产)· 教程:[自定义控件绘制入门](../../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md)、[动画框架入门](../../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md) + +这份手册带你从零搓一个 `AwesomeQt::SpeedMeter`——能 setValue 让指针平滑旋转、带主次刻度和数字读数的速度表盘。前两个控件(status-led / toggle-switch)已经把「Q_PROPERTY + 动画驱动」这套骨架趟平了,SpeedMeter 在它上面重点练一件新东西:**在一个 paintEvent 里把两套角度约定(顺时针的屏幕角 β vs QPainter drawArc 的逆时针 1/16° 角度)各自安顿好、又对得上**。 + +::: tip 这是「手搓手册」 +不是参考手册(查完走),是 workbook(跟着搓)。每个 step 给**目标 → 提示 → 检查点**,成品 repo 当答案钥匙——卡住了去对照,别整段复制。 +::: + +## 0. 你将学到 + +搓完这个 SpeedMeter,你会打通这几样 Qt 能力(每样后面都有教程深挖,这里先用起来): + +- **自绘表盘的几何**:用三角函数(`cos`/`sin`)把角度换算成弧上点的坐标,画背景弧、主次刻度、指针 +- **value / needleAngle 解耦**:业务属性(速度值)和动画属性(绘制角度)分开,setValue 启动动画、动画驱动 setNeedleAngle——这套和 status-led 的 color / setAnimatedColor 完全同构 +- **两套角度约定各自安顿**:刻度/指针/标签用屏幕角 β(3 点为 0°、顺时针为正,cos/sin/rotate 同套),`drawArc` 单独用 1/16° 且正值逆时针;同一个 paintEvent 里不混着用,换算关系注释写死 +- **QPainter 坐标变换画指针**:`save / translate(center) / rotate / restore` + `QPolygonF` 画根粗尖细多边形,比手算两端 drawLine 省事(β 就是 rotate 用的屏幕角,rotate(β) 直接转,不用修正) +- **持久动画对象 + 接力**:stop() / setStartValue(当前显示角度) / start() 复用同一对象,Slider 连拖 / 狂点都不跳变 + +## 1. 起点 + +先有个能跑的空壳。新建最小 Qt Widgets 工程,main 里弹个窗: + +```cpp +#include +#include +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget w; + w.resize(200, 200); + w.show(); + return app.exec(); +} +``` + +弹出空白窗 = 环境通了,往下走。Qt 环境不熟先看 [QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。这里要 `find_package(Qt6 COMPONENTS Widgets)`,链接 `Qt6::Widgets`(QPropertyAnimation 属于 Core,但用到了一起链就行)。 + +## 2. 任务清单 + +分 3 步,每步:**目标 → 提示 → 检查点**。卡住翻 [卡住怎么办](./troubleshooting.md)。 + +| Step | 目标 | 进 | +|---|---|---| +| 1 | 画静态表盘:背景弧 + 主/次刻度(先把角度体系自洽跑通) | [01](./01-static-gauge.md) | +| 2 | value→角度映射 + 指针 + 数字读数(静态指到当前值) | [02](./02-needle-and-mapping.md) | +| 3 | value/needleAngle 解耦 + QPropertyAnimation 接力(指针平滑旋转) | [03](./03-smooth-animation.md) | + +成品对照:`widget/speed-meter/`(按 [成品导览](../) 的「怎么读」顺序对照)。 + +## 3. 进阶挑战(可选) + +搓完基础版想再深一层: + +- **指针换颜色档位**:value 越接近 maxValue 指针越红(绿→黄→红渐变)。提示:paintEvent 里按 `value / maxValue` 在多边形颜色上插值,比单纯红色更有「危险区」视觉提示。思考:这是不是该做成 `Q_PROPERTY` 让外部配色,还是控件内部自己定? +- **刻度分段配色**:高转速区(如 180 以上)刻度/弧段改色。提示:分段 `drawArc` 画不同颜色的弧段,得想清楚每段的角度边界怎么算(别又和屏幕角 β 打架)。 +- **加一个 setValue 的「过冲回弹」**:指针到目标后微微回弹一下再稳住(车表的真实手感)。提示:`QEasingCurve::OutBack` 或自定义曲线,但要确认回弹不会让 angleForValue 之外的角度画出怪样子。 +- **下一站**:circle-progress(待产)——同样是弧 + 角度体系,但用来画进度条而非指针,可以复用这套角度换算注释。 diff --git a/tutorial/engineering/instances/widget/speed-meter/handbook/troubleshooting.md b/tutorial/engineering/instances/widget/speed-meter/handbook/troubleshooting.md new file mode 100644 index 0000000..6ff2120 --- /dev/null +++ b/tutorial/engineering/instances/widget/speed-meter/handbook/troubleshooting.md @@ -0,0 +1,66 @@ +--- +title: "卡住怎么办" +description: "按症状查:弧方向画反、指针偏 90°、指针不转/直接跳、连切崩溃、控件压扁消失、moc 报错——给方向指向教程章,不直接给答案。" +--- + +# 卡住怎么办 + +← [手册首页](./index.md) + +按症状查。每条给方向,不给整段答案——成品 repo 在 `widget/speed-meter/`,对照着看。 + +## 背景弧方向画反(逆时针铺 / 开口朝上) + +- 把屏幕角 β 直接塞进了 `drawArc`,忘了 drawArc 的角度是 **1/16°、0°=3 点、正值逆时针**——和 β(顺时针为正)差一个 y 翻转 +- drawArc 要用 **负扫角**:`drawArc(rect, 225*16, -270*16)`(= kArcStart16/kArcSpan16),负扫角 = 顺时针铺开。→ `src/speed_meter.cpp:207` +- 角度换算注释在 `.cpp` 顶部,对照着看 → `src/speed_meter.cpp:23-36` +- 进阶排查:[QPainter](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md)、[坐标变换](../../../../../beginner/02-qtgui/02-coordinate-transform-beginner.md) + +## 指针指反端 / 刻度上下镜像(value=0 指到了 max 那头) + +- 根因是两套角度约定混了:cos/sin 在屏幕 y 朝下算位置,`drawArc` 用 y 朝上逆时针——差一个 y 翻转。若再给 rotate 叠一个 +90°「修正」,两者叠加就把刻度/指针整体上下镜像,v=0 的针怼到 max 位置 +- 解法:全控件统一用屏幕角 β(3 点为 0°、顺时针为正,cos/sin/rotate 同套),`rotate(needle_angle_)` 直接转不修正;只有 drawArc 单独换算(225*16、-270*16)。→ 映射 `src/speed_meter.cpp:64-68`、rotate `src/speed_meter.cpp:272`(注释 `:268-269`) +- 验证:value=0 → rotate 135° 指左下,value=max → rotate 45° 指右下 + +## 指针不转 / 直接跳(没有平滑过渡) + +- `needleAngle` **声明成 Q_PROPERTY 了吗**?属性名和 `QPropertyAnimation(this, "needleAngle")` 一字不差?moc 没认到属性,动画驱动了个空名字。→ `include/speed_meter.h:31` +- setValue 里**真的 stop + setStart + setEnd + start 了吗**?漏 start 不动,漏 stop 旧动画会和新的叠。→ `src/speed_meter.cpp:82-85` +- setNeedleAngle 里**有 `update()` 吗**?没 update 就不重绘。→ `src/speed_meter.cpp:95-101` +- 动画对象的 **parent 是 this 吗**?否则对象树不管它。→ `src/speed_meter.cpp:56` +- 进阶排查:[属性动画框架基础](../../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md) + +## Slider 连拖 / 狂点时指针闪回旧目标再出发 + +- 动画 `setStartValue` 取的是**上一次的目标角度**还是**当前显示角度(needle_angle_)**?取旧目标会先跳回去再出发 +- 必须 `setStartValue(needle_angle_)`(当前显示值,可能是上一段动画的中间值),每次先 `stop()`。→ `src/speed_meter.cpp:82-83` +- 进阶排查:[动画框架进阶](../../../../../advanced/03-qtwidgets/09-animation-advanced.md) + +## 快速反复 setValue 时崩溃(栈溢出) + +- needleAngle 的 **WRITE 是不是错指向了 `setValue`**?动画每帧驱动 setValue → setValue 又启动画 → 无限递归栈溢出 +- WRITE 必须指 `setNeedleAngle`(纯赋值+emit+update),setValue 只做业务入口算映射。→ `include/speed_meter.h:31` + `src/speed_meter.cpp:95` + +## 频繁切换 / 连切时崩溃(segfault) + +- needle_anim_ **用了 `DeleteWhenStopped` 吗**?stop 时对象被 delete、成员指针悬空。改成持久成员指针 + `stop()/重配/start()`。→ `src/speed_meter.cpp:54-56` +- 动画对象 **parent 设成 this 了吗**?否则对象树不管它、可能提前释放。→ `src/speed_meter.cpp:56` + +## 窗口缩到极小时弧 / 指针 / 轴帽消失或乱画 + +- 半径(`gauge_r`、`needle_len` 等)在控件极小时**可能 ≤0**,`drawArc`/`drawEllipse`/`drawPolygon` 对负尺寸行为未定义 +- 所有半径 `std::max(1.0, ...)`、`side = std::max(1, min(w,h))` 兜底。→ `src/speed_meter.cpp:188-195` + +## demo 编译报 QRandomGenerator 隐式声明 / 未定义 + +- `demo/speed_meter_window.cpp` 用了 `QRandomGenerator::global()->bounded()` 但**没 include 它的头**。include 块加 ``。→ `demo/speed_meter_window.cpp:13` + +## moc 报错(Q_PROPERTY 不认识) + +- 头文件**有 `Q_OBJECT` 吗**?→ `include/speed_meter.h:27` +- CMake **开了 AUTOMOC 吗**(`set(CMAKE_AUTOMOC ON)`)?→ `widget/CMakeLists.txt` +- 进阶排查:[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md) + +--- + +实在卡死,成品 `widget/speed-meter/src/speed_meter.cpp` 就是答案——但先自己拼。 diff --git a/tutorial/engineering/instances/widget/speed-meter/index.md b/tutorial/engineering/instances/widget/speed-meter/index.md new file mode 100644 index 0000000..885620e --- /dev/null +++ b/tutorial/engineering/instances/widget/speed-meter/index.md @@ -0,0 +1,153 @@ +--- +title: "SpeedMeter 成品导览" +description: "速度仪表盘控件成品:动画指针平滑旋转 + 主/次刻度 + 数字读数,value 与 needleAngle 解耦 + 双角度体系自洽,附架构、设计决策、踩坑与阅读路径。" +--- + +# SpeedMeter 成品导览 + +> **source**:`widget/speed-meter/` **related**:自绘控件递进链([status-led](../status-led/) · [toggle-switch](../toggle-switch/) 已产)· 教程:[动画框架入门](../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md)、[自定义控件绘制入门](../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md) + +SpeedMeter 是个指针式速度表盘——汽车仪表那种,0 到 220 的弧形刻度,一根指针指到当前值。比 status-led / toggle-switch 难一截的地方不在动画(那套 `value` / `needleAngle` 解耦骨架前面已经趟平),而在于**两套角度体系要在同一个 paintEvent 里自洽**:刻度/指针用的「屏幕角 β」(cos/sin + rotate,顺时针为正)和 QPainter `drawArc` 的 1/16° 角度(0°=3 点、正值逆时针)不是一套东西,混着用就会把弧画反方向、把指针画到错的那一头。这件成品把那套换算注释写在 `.cpp` 顶部,能当成「角度体系换算」的样板来读。 + +::: tip 本篇是「成品导览」 +想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 +想自己从零搓出来 → 转 [手搓手册](./handbook/)。 +::: + +## 1. 它做什么 + +一个 `AwesomeQt::SpeedMeter` 控件: + +- **指针式表盘**:背景弧开口朝下,从左下(v=0)经顶部扫到右下(v=max),共 270°;11 条主刻度把量程 10 等分,主刻度间再插 4 条次刻度,针尖指向当前值;控件压小到数字标签会互相挤压时,整组标签自动隐藏(弧/刻度/指针/底部读数仍保留) +- **指针平滑旋转**:`setValue` 不直接跳角度,而是让一根 `QPropertyAnimation` 从当前显示角度接力到新目标,400ms `OutCubic` 缓动过渡 +- **中心轴帽 + 底部数字读数**:指针根部一个小实心圆,底部居中绘当前数值,字号随控件尺寸缩放 +- **完整 Q_PROPERTY**:`value` / `needleAngle` / `maxValue` / `needleColor` / `tickColor` / `gaugeColor` 六个属性,可被动画、Qt Designer、外部主题色驱动 + +跑起来看一眼比读十行描述管用: + +```bash +cd widget && cmake -B build && cmake --build build +./build/speed-meter/demo/speed_meter_demo +``` + +demo 开四组:静态几档一排(0/60/120/180/220)、大表 + Cycle 按钮看指针接力、QSlider 0..220 拖动驱动、Random Jump 狂点测连切不跳变。 + +## 2. 架构总览 + +### 类关系 + +一个 SpeedMeter 自己持有一根动画指针,外部业务只跟 `value` 打交道: + +```mermaid +classDiagram + class SpeedMeter { + +Q_PROPERTY int value + +Q_PROPERTY qreal needleAngle + +Q_PROPERTY int maxValue + +Q_PROPERTY QColor needleColor + +Q_PROPERTY QColor tickColor + +Q_PROPERTY QColor gaugeColor + +setValue(int) + +setNeedleAngle(qreal) + +setMaxValue(int) + -angleForValue(int) qreal + -paintEvent(QPaintEvent*) + } + class QPropertyAnimation { + 指针旋转 400ms OutCubic + 驱动 needleAngle + } + SpeedMeter o-- QPropertyAnimation : needle_anim_ +``` + +关键就一个对象:`needle_anim_` 是持久成员指针,构造时 `new QPropertyAnimation(this, "needleAngle", this)`(parent=this 托管释放),切换时 `stop() / 重配 setStartValue(当前角度) / start()` 复用,不 new 新的、不用 `DeleteWhenStopped`。`setValue` 是业务入口(算 value→角度映射后启动动画),`setNeedleAngle` 是动画每帧回调(纯赋值 + emit + update)——这两者解耦,是整套机制的核心,和 status-led / toggle-switch 一脉相承。 + +### 文件职责 + +| 文件 | 职责 | +|---|---| +| `include/speed_meter.h` | 接口:六个 Q_PROPERTY + 公有 API + signals + 持久动画指针成员 | +| `src/speed_meter.cpp` | 实现:角度约定常量 / value→角度映射 / 动画接力 / 自绘(弧+刻度+指针+轴帽+读数) | +| `demo/speed_meter_window.cpp` | 演示:静态几档 / Cycle 接力 / Slider 驱动 / 随机跳变 四组 | + +### setValue 怎么把指针转起来 + +```mermaid +sequenceDiagram + participant U as 调用方 + participant S as SpeedMeter + participant A as needle_anim_ + participant P as paintEvent + U->>S: setValue(180) + S->>S: value_=180; emit valueChanged + S->>A: stop() + S->>A: setStartValue(needle_angle_) // 当前显示角度 + S->>A: setEndValue(angleForValue(180)) + A->>A: start() + loop 每帧 + A->>S: setNeedleAngle(插值角度) + S->>S: needle_angle_=插值; update() + S->>P: 重绘指针 + end +``` + +重点:动画的 `setStartValue` 取的是 `needle_angle_`(**当前显示角度**,可能是上一段动画的中间值),不是 `angleForValue(旧 value)`。这样快速连切时指针从它此刻停的位置直接接力到新目标,不会先闪回旧目标再出发——这就是 demo 里 Random Jump 狂点不跳变的原因。 + +## 3. 关键设计决策 + +**① value 与 needleAngle 解耦:业务语义归 value,绘制角度归 needleAngle。** +`value` 是用户语义(速度是多少),`needleAngle` 是动画属性(针此刻画在哪个角度)。`setValue` 算映射后启动动画,`setNeedleAngle` 纯赋值 + emit + update。这个切分不只是好看——它直接堵住一个经典崩溃:如果 `needleAngle` 的 WRITE 指向 `setValue`,动画每帧驱动 setValue → setValue 又启动画 → 无限递归栈溢出(status-led 踩坑⑦同一性质,这里用纯赋值的 `setNeedleAngle` 当 WRITE 回调规避,见 `include/speed_meter.h:31` 的 WRITE 指 `setNeedleAngle`)。 + +**② 定一套屏幕角约定 β,刻度/指针/标签全在这套里自洽。** +β 以 3 点钟为 0°、顺时针为正(和 `rotate`、`cos/sin` 在 y 朝下的屏幕坐标系完全一致)。映射函数 `angleForValue(v) = 135 + (v/max) * 270`(`src/speed_meter.cpp:64-68`):value=0 → 135°(左下 7:30),value=mid → 270°(顶部 12:00),value=max → 45°(右下 4:30),开口落在 6 点钟方向。主刻度、次刻度、指针端点、数字标签全部走这套 β + 三角函数(`x=cx+r*cos(rad)`、`y=cy+r*sin(rad)`),所以它们天然对齐。 + +**③ 只让 drawArc 单独用 Qt 角度约定,换算注释写死在 .cpp 顶部。** +这是这件成品最容易绊人的地方。`drawArc` 的角度是 1/16°、0°=3 点钟方向、**正值逆时针**——和我们顺时针为正的屏幕角 β 不是一套东西(差一个 y 翻转)。直接把 β 塞进 drawArc 会把弧画反方向。解法:背景弧单独走 drawArc,`drawArc(rect, kArcStart16, kArcSpan16)` 即 `drawArc(rect, 225*16, -270*16)`——起始角 225°(正是 β=135° 那个物理点「左下」在 Qt 约定下的读数)、**负扫角** = 顺时针铺开(`src/speed_meter.cpp:207`)。换算关系在 `.cpp` 顶部(`src/speed_meter.cpp:23-36`)写死,改的时候别漏看。 + +**④ 指针用 save/translate/rotate/restore + QPolygonF,rotate(β) 直接转不用修正。** +指针画成根粗尖细的多边形(`src/speed_meter.cpp:278-283`)。用 `painter.translate(center)` + `painter.rotate(needle_angle_)` 比手算两端点 drawLine 省事;而 `needle_angle_` 存的就是屏幕角 β,rotate 的约定(3 点钟为 0°、顺时针为正)正好和 β 同一套,所以 **rotate(β) 直接转,不用加减任何修正**(`src/speed_meter.cpp:272`)。value=0(135°) → 指左下,value=max(45°) → 指右下,和刻度对齐。早期版本曾给 β 用逆时针约定、又额外 +90° 修正,结果和 cos/sin 的 y 朝下叠加出 bug(见踩坑②),统一到顺时针 β 后就不需要修正了。 + +**⑤ 动画对象持久成员指针 + stop()/接力,不用 DeleteWhenStopped。** +`needle_anim_` 构造时 `new QPropertyAnimation(this, "needleAngle", this)`,parent=this 由对象树托管(`src/speed_meter.cpp:56`)。每次 setValue 都 `stop() / setStartValue(needle_angle_) / start()` 复用同一个对象(`src/speed_meter.cpp:82-85`)。`DeleteWhenStopped` 那种「停了自动 delete」的写法在 Slider 连续拖动 / Random Jump 狂点时反复 new/delete 还可能悬空,持久指针 + 接力更稳。`setNeedleAngle` 还用 `qFuzzyCompare` 去重防抖(`src/speed_meter.cpp:95-101`),相邻两帧角度几乎相同就不重绘。 + +## 4. 怎么读这份 code + +按这个顺序读,最快建立心智: + +1. **`.cpp` 顶部的角度约定注释**(`src/speed_meter.cpp:23-36`)——先记住「屏幕角 β:3 点为 0°、顺时针为正;drawArc 是另一套(0°=3 点、逆时针、1/16°)」,后面所有角度代码都在这套里 +2. **`angleForValue`**(`src/speed_meter.cpp:64-68`)——value 怎么映射成屏幕角 β,盯 135° 起始、270° 扫角、`max_value_<=0` 的除零保护 +3. **`setValue`**(`src/speed_meter.cpp:74-86`)——业务入口,盯 `stop()/setStartValue(needle_angle_)/start()` 这三行接力 +4. **`setNeedleAngle`**(`src/speed_meter.cpp:95-101`)——动画每帧回调,纯赋值 + emit + update(对比 status-led 的 setAnimatedColor 同构) +5. **背景弧绘制**(`src/speed_meter.cpp:197-208`)——drawArc 的负扫角顺时针换算,最容易看反 +6. **指针绘制**(`src/speed_meter.cpp:266-288`)——save/translate/rotate + 多边形,盯 `rotate(needle_angle_)` 直接转(β 即屏幕角) + +入口:`demo/main.cpp` → `demo/speed_meter_window.cpp` 四组布局,对照读。 + +## 5. 踩坑 + +这几个坑都是实现这件成品时真碰到的,代码里能逐条对上。 + +| # | 现象 | 原因 | 后果 | 解法 | +|---|---|---|---|---| +| ① | `drawArc` 画出的背景弧方向和刻度/指针对不上(弧逆时针、针顺时针) | 把屏幕角 β 直接塞进 `drawArc`,忘了它俩角度体系不同(drawArc 正值逆时针、1/16°,和 β 差一个 y 翻转) | 视觉错乱:弧和刻度各画各的,开口朝向错 | drawArc 单独走,用 `kArcStart16=225*16 + kArcSpan16=-270*16`(负扫角=顺时针),换算注释写死在 `.cpp` 顶部(`src/speed_meter.cpp:23-36`、`:207`) | +| ② | value=0 指针指到了 value=max 那头(上下镜像 / 指反端) | cos/sin 在屏幕 y 朝下算位置,和 drawArc 的 y 朝上逆时针约定差一个 y 翻转;若再叠一个 rotate +90°「修正」,两者叠加就把刻度/指针整体镜像 | **指针指反**:v=0 怼到 max 位置,看着像坏了 | 全控件统一用屏幕角 β(顺时针为正,cos/sin/rotate 同套),rotate(β) 直接转不修正;只有 drawArc 单独换算(映射 `src/speed_meter.cpp:64-68`、rotate `:272`) | +| ③ | Slider 连续拖 / Random Jump 狂点时指针闪回旧目标再出发 | 动画 `setStartValue` 取的是上一次的目标角度而非当前显示角度 | 视觉跳变(非崩溃):指针像抽了一下 | `setStartValue(needle_angle_)` 取当前显示值接力,每次先 `stop()`(`src/speed_meter.cpp:74-75`) | +| ④ | `needleAngle` 的 WRITE 若错指向 `setValue` | 动画每帧驱动 setValue → setValue 又启动画 → 无限递归 | **栈溢出崩溃** | WRITE 指 `setNeedleAngle`(纯赋值+emit+update),`setValue` 只做业务入口(`include/speed_meter.h:31` + `src/speed_meter.cpp:95`) | +| ⑤ | 窗口缩到极小时弧/轴帽/指针行为未定义或消失 | 半径 `gauge_r`、`needle_len` 等在控件极小时可能 ≤0,`drawArc`/`drawEllipse`/`drawPolygon` 对负尺寸行为未定义 | 控件压扁后绘制异常 | 所有半径 `std::max(1.0, ...)`、`side = std::max(1, min(w,h))` 兜底(`src/speed_meter.cpp:188-195`) | +| ⑥ | 动画反复 new/delete 或指针悬空 | 用 `DeleteWhenStopped`,stop 后对象被 delete、成员指针悬空 | **segfault**(频繁切换时) | 持久成员指针 + parent=this 托管,`stop()/重配/start()` 复用(`src/speed_meter.cpp:54-56`) | +| ⑦ | demo 编译报 `QRandomGenerator` 隐式声明 / 未定义 | `speed_meter_window.cpp` 用了 `QRandomGenerator::global()->bounded()` 但没 include 它的头 | **编译失败** | include 块加 ``(`demo/speed_meter_window.cpp:13`) | + +## 6. 官方文档 + +- [QPainter(绘图引擎,drawArc / drawPolygon / drawLine / drawEllipse)](https://doc.qt.io/qt-6/qpainter.html) +- [QPainter coordinate system(坐标系与 rotate 变换)](https://doc.qt.io/qt-6/coordsys.html) +- [QPropertyAnimation(属性动画,驱动 needleAngle 旋转)](https://doc.qt.io/qt-6/qpropertyanimation.html) +- [The Property System(Q_PROPERTY)](https://doc.qt.io/qt-6/properties.html) +- [QEasingCurve(OutCubic 缓动曲线)](https://doc.qt.io/qt-6/qeasingcurve.html) +- [QFontMetrics(刻度数字 / 底部读数居中测量)](https://doc.qt.io/qt-6/qfontmetrics.html) +- [QPolygonF(指针根粗尖细多边形)](https://doc.qt.io/qt-6/qpolygonf.html) +- [QWidget(自绘控件基类,paintEvent / sizeHint)](https://doc.qt.io/qt-6/qwidget.html) + +--- + +这套机制——`value` 业务属性与 `needleAngle` 动画属性解耦 + 持久动画对象接力——和 status-led / toggle-switch 是同一套骨架,SpeedMeter 在它上面额外把「双角度体系自洽」这件事讲透了:哪一套角度用三角函数算、哪一套交给 drawArc、两者怎么换算。想把任何带「指针 / 弧 / 扇形」的控件做对,这套换算注释都值得抄一遍。想自己从空 main 一行行搓到这个成品?[手搓手册](./handbook/) 分三步带你走,成品就是答案钥匙。 diff --git a/widget/CMakeLists.txt b/widget/CMakeLists.txt index ae8c423..d9a2d1a 100644 --- a/widget/CMakeLists.txt +++ b/widget/CMakeLists.txt @@ -13,4 +13,14 @@ find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) # 控件子目录:每个控件自洽(STATIC 库 + 独立 demo) add_subdirectory(status-led) add_subdirectory(toggle-switch) -# add_subdirectory(circle-progress) +add_subdirectory(circle-progress) +add_subdirectory(speed-meter) +add_subdirectory(range-slider) +add_subdirectory(line-chart) +add_subdirectory(editable-table) +add_subdirectory(checkbox-tree) +add_subdirectory(checkbox-list) +add_subdirectory(log-viewer) +add_subdirectory(password-edit) +add_subdirectory(ip-edit) +add_subdirectory(fade-animation) diff --git a/widget/checkbox-list/CMakeLists.txt b/widget/checkbox-list/CMakeLists.txt new file mode 100644 index 0000000..ce6b092 --- /dev/null +++ b/widget/checkbox-list/CMakeLists.txt @@ -0,0 +1,19 @@ +# CheckboxList 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(checkbox_list STATIC + include/checkbox_list.h + src/checkbox_list.cpp +) + +target_include_directories(checkbox_list PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(checkbox_list PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/checkbox-list/demo/CMakeLists.txt b/widget/checkbox-list/demo/CMakeLists.txt new file mode 100644 index 0000000..5efdc1a --- /dev/null +++ b/widget/checkbox-list/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# CheckboxList 演示程序:勾选交互 + 状态汇总 + 批量操作 + +qt_add_executable(checkbox_list_demo + main.cpp + checkbox_list_window.cpp + checkbox_list_window.h +) + +target_link_libraries(checkbox_list_demo PRIVATE + checkbox_list +) diff --git a/widget/checkbox-list/demo/checkbox_list_window.cpp b/widget/checkbox-list/demo/checkbox_list_window.cpp new file mode 100644 index 0000000..d45907f --- /dev/null +++ b/widget/checkbox-list/demo/checkbox_list_window.cpp @@ -0,0 +1,97 @@ +/** + * @file checkbox_list_window.cpp + * @brief CheckboxList 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ + +#include "checkbox_list_window.h" + +#include "checkbox_list.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CheckboxListWindow::CheckboxListWindow(QWidget* parent) : QMainWindow(parent) { + setup_ui(); + populate_sample_items(); +} + +void CheckboxListWindow::setup_ui() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* root = new QHBoxLayout(central); + + // —— 左:勾选列表本体 —— + auto* list_group = new QGroupBox("Checkbox List (try toggling the boxes)"); + auto* list_layout = new QVBoxLayout(list_group); + list_ = new AwesomeQt::CheckboxList(list_group); + list_layout->addWidget(list_); + root->addWidget(list_group, 2); + + // —— 右:控制面板 + 输出 —— + auto* side_group = new QGroupBox("Controls"); + auto* side_layout = new QVBoxLayout(side_group); + + auto* list_btn = new QPushButton("List checked items", side_group); + auto* check_all_btn = new QPushButton("Check all", side_group); + auto* uncheck_all_btn = new QPushButton("Uncheck all", side_group); + auto* invert_btn = new QPushButton("Invert selection", side_group); + + alternating_toggle_ = new QCheckBox("Alternating row colors", side_group); + alternating_toggle_->setChecked(false); + + output_ = new QTextEdit(side_group); + output_->setReadOnly(true); + output_->setPlaceholderText("Checked item texts will appear here..."); + + side_layout->addWidget(list_btn); + side_layout->addWidget(check_all_btn); + side_layout->addWidget(uncheck_all_btn); + side_layout->addWidget(invert_btn); + side_layout->addWidget(alternating_toggle_); + side_layout->addWidget(output_, 1); + + root->addWidget(side_group, 1); + + // 函数指针语法连接信号槽。 + connect(list_btn, &QPushButton::clicked, this, &CheckboxListWindow::list_checked_items); + connect(check_all_btn, &QPushButton::clicked, list_, &AwesomeQt::CheckboxList::checkAll); + connect(uncheck_all_btn, &QPushButton::clicked, list_, &AwesomeQt::CheckboxList::uncheckAll); + connect(invert_btn, &QPushButton::clicked, list_, &AwesomeQt::CheckboxList::invertChecked); + connect(alternating_toggle_, &QCheckBox::toggled, list_, + &AwesomeQt::CheckboxList::setAlternatingRowColors); + + setWindowTitle("CheckboxList Demo"); + resize(640, 440); +} + +void CheckboxListWindow::populate_sample_items() { + // 示例:文件权限清单。用 addItems 批量加,前两项预先勾选。 + list_->addItem("Read", true); + list_->addItem("Write", true); + list_->addItem("Execute", false); + list_->addItem("Delete", false); + list_->addItems({"Modify permissions", "Take ownership", "Change attributes"}); +} + +void CheckboxListWindow::list_checked_items() { + // 把 checkedTexts() 输出下方,肉眼核对勾选结果。 + const QStringList checked = list_->checkedTexts(); + if (checked.isEmpty()) { + output_->append("- (no checked items)"); + return; + } + + output_->append(QString("Checked (%1):").arg(checked.size())); + for (const QString& text : checked) { + output_->append("- " + text); + } +} diff --git a/widget/checkbox-list/demo/checkbox_list_window.h b/widget/checkbox-list/demo/checkbox_list_window.h new file mode 100644 index 0000000..58783f7 --- /dev/null +++ b/widget/checkbox-list/demo/checkbox_list_window.h @@ -0,0 +1,29 @@ +/** + * @file checkbox_list_window.h + * @brief CheckboxList 控件演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ +#pragma once + +#include + +class QCheckBox; +class QTextEdit; + +namespace AwesomeQt { +class CheckboxList; +} + +class CheckboxListWindow : public QMainWindow { + public: + explicit CheckboxListWindow(QWidget* parent = nullptr); + + private: + void setup_ui(); + void populate_sample_items(); + void list_checked_items(); + + AwesomeQt::CheckboxList* list_{nullptr}; + QCheckBox* alternating_toggle_{nullptr}; + QTextEdit* output_{nullptr}; +}; diff --git a/widget/checkbox-list/demo/main.cpp b/widget/checkbox-list/demo/main.cpp new file mode 100644 index 0000000..49e4634 --- /dev/null +++ b/widget/checkbox-list/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief CheckboxList 演示程序入口 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ + +#include "checkbox_list_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + CheckboxListWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/checkbox-list/include/checkbox_list.h b/widget/checkbox-list/include/checkbox_list.h new file mode 100644 index 0000000..f615905 --- /dev/null +++ b/widget/checkbox-list/include/checkbox_list.h @@ -0,0 +1,97 @@ +/** + * @file checkbox_list.h + * @brief 勾选列表控件 CheckboxList——封装 QListWidget,每项带复选框的扁平勾选列表 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + * + * 对照 CheckboxTree:本控件扁平无层级,重点在「勾选 API + 状态汇总」, + * 不做父子联动(那是 CheckboxTree 的事)。组合 QListWidget,默认不重写 + * paintEvent,让 view 自己画。 + */ +#pragma once + +#include +#include +#include +#include + +class QListWidget; +class QListWidgetItem; + +namespace AwesomeQt { + +/// @brief 扁平勾选列表:每项一个复选框,提供勾选/批量操作/状态汇总 API。 +/// +/// 设计要点: +/// - 内含一个 `QListWidget*`(构造期 new,parent=this 由对象树托管),本控件不自绘, +/// 只负责「数据 + 交互逻辑」; +/// - 勾选状态变化靠 `QListWidget::itemChanged` 驱动,转发为更易用的 `checkedChanged`; +/// - `checkAll`/`uncheckAll`/`invertChecked` 等批量改写会逐项 `setCheckState`,每改一项 +/// 都触发 `itemChanged`,必须 `blockSignals` 守卫防信号雪崩(同 CheckboxTree 教训, +/// 列表版更简单——没有层级递归)。 +class CheckboxList : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:alternatingRowColors / spacing,可被 Designer / 外部驱动 —— + Q_PROPERTY(bool alternatingRowColors READ alternatingRowColors WRITE setAlternatingRowColors + NOTIFY alternatingRowColorsChanged) + Q_PROPERTY(int spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + + public: + explicit CheckboxList(QWidget* parent = nullptr); + + /// @brief 追加一项并装上复选框。 + /// @param text 显示文本。 + /// @param checked 初始是否勾选,默认未勾选。 + /// @return 新建并已挂入列表的项指针。 + QListWidgetItem* addItem(const QString& text, bool checked = false); + + /// @brief 批量追加多项(默认均未勾选)。空列表忽略。 + void addItems(const QStringList& texts); + + /// @brief 设置某项勾选状态(程序化入口,内部守卫信号)。 + /// @param item 目标项,nullptr 安全返回。 + /// @param state 目标勾选状态。 + void setItemChecked(QListWidgetItem* item, Qt::CheckState state); + + /// @brief 全部勾选。 + void checkAll(); + + /// @brief 全部取消勾选。 + void uncheckAll(); + + /// @brief 反选:勾选的变不勾、不勾的变勾。 + void invertChecked(); + + /// @brief 取所有已勾选项的文本(按列表顺序)。空列表返回空列表。 + QStringList checkedTexts() const; + + /// @brief 取所有处于 Checked 状态的项指针(按列表顺序)。空列表返回空列表。 + QList checkedItems() const; + + /// @brief 暴露内部 view,供外部只读访问(设置表头 / 读选中项等)。 + /// @return 内部 QListWidget 指针(所有权仍归本控件)。 + QListWidget* listView() const; + + // —— Q_PROPERTY 读写 —— + bool alternatingRowColors() const; + void setAlternatingRowColors(bool enabled); + + int spacing() const; + void setSpacing(int pixels); + + QSize sizeHint() const override; + + signals: + /// @brief 某项勾选状态变化(用户点击或 setItemChecked 触发,批量操作不发逐项信号)。 + void checkedChanged(QListWidgetItem* item, bool checked); + void alternatingRowColorsChanged(bool enabled); + void spacingChanged(int pixels); + + private: + /// @brief itemChanged 槽:去重 + 边界,转发为 checkedChanged。 + void onItemChanged(QListWidgetItem* item); + + QListWidget* list_{nullptr}; +}; + +} // namespace AwesomeQt diff --git a/widget/checkbox-list/src/checkbox_list.cpp b/widget/checkbox-list/src/checkbox_list.cpp new file mode 100644 index 0000000..ac02912 --- /dev/null +++ b/widget/checkbox-list/src/checkbox_list.cpp @@ -0,0 +1,186 @@ +/** + * @file checkbox_list.cpp + * @brief 勾选列表控件 CheckboxList 实现——扁平勾选 + 状态汇总 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ + +#include "checkbox_list.h" + +#include +#include +#include + +namespace AwesomeQt { + +CheckboxList::CheckboxList(QWidget* parent) : QWidget(parent) { + // 内含一个 QListWidget,构造期 new、parent=this 由对象树托管,放进布局。 + list_ = new QListWidget(this); + list_->setUniformItemSizes(true); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(list_); + + // 函数指针语法连接,避免 SIGNAL/SLOT 宏。 + connect(list_, &QListWidget::itemChanged, this, &CheckboxList::onItemChanged); +} + +QListWidgetItem* CheckboxList::addItem(const QString& text, bool checked) { + auto* item = new QListWidgetItem(text, list_); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + + // setCheckState 会触发 itemChanged;这里是初始化,守卫防回灌。 + const bool was_blocked = list_->blockSignals(true); + item->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + list_->blockSignals(was_blocked); + + list_->addItem(item); + return item; +} + +void CheckboxList::addItems(const QStringList& texts) { + // 批量逐项初始化,整段 blockSignals 守卫,避免每个 setCheckState 都回灌。 + const bool was_blocked = list_->blockSignals(true); + for (const QString& text : texts) { + auto* item = new QListWidgetItem(text, list_); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Unchecked); + list_->addItem(item); + } + list_->blockSignals(was_blocked); +} + +void CheckboxList::setItemChecked(QListWidgetItem* item, Qt::CheckState state) { + if (item == nullptr) { + return; // 边界:空指针安全返回。 + } + + // setCheckState 会触发 itemChanged → onItemChanged → checkedChanged; + // 单项操作允许信号透传(外部就靠它知道状态变了),不守卫。 + item->setCheckState(state); +} + +void CheckboxList::checkAll() { + if (list_ == nullptr) { + return; + } + // 批量逐项 setCheckState,每项都触发 itemChanged,blockSignals 守卫防雪崩。 + const bool was_blocked = list_->blockSignals(true); + for (int i = 0; i < list_->count(); ++i) { + QListWidgetItem* item = list_->item(i); + if (item != nullptr) { + item->setCheckState(Qt::Checked); + } + } + list_->blockSignals(was_blocked); +} + +void CheckboxList::uncheckAll() { + if (list_ == nullptr) { + return; + } + const bool was_blocked = list_->blockSignals(true); + for (int i = 0; i < list_->count(); ++i) { + QListWidgetItem* item = list_->item(i); + if (item != nullptr) { + item->setCheckState(Qt::Unchecked); + } + } + list_->blockSignals(was_blocked); +} + +void CheckboxList::invertChecked() { + if (list_ == nullptr) { + return; + } + // 反选读旧态再写新态:若不守卫,写 Checked 会触发 itemChanged,逻辑无害但噪音大。 + const bool was_blocked = list_->blockSignals(true); + for (int i = 0; i < list_->count(); ++i) { + QListWidgetItem* item = list_->item(i); + if (item == nullptr) { + continue; + } + const bool on = item->checkState() == Qt::Checked; + item->setCheckState(on ? Qt::Unchecked : Qt::Checked); + } + list_->blockSignals(was_blocked); +} + +QStringList CheckboxList::checkedTexts() const { + QStringList result; + if (list_ == nullptr) { + return result; + } + const int n = list_->count(); + result.reserve(n); + for (int i = 0; i < n; ++i) { + QListWidgetItem* item = list_->item(i); + if (item != nullptr && item->checkState() == Qt::Checked) { + result.append(item->text()); + } + } + return result; +} + +QList CheckboxList::checkedItems() const { + QList result; + if (list_ == nullptr) { + return result; + } + const int n = list_->count(); + for (int i = 0; i < n; ++i) { + QListWidgetItem* item = list_->item(i); + if (item != nullptr && item->checkState() == Qt::Checked) { + result.append(item); + } + } + return result; +} + +QListWidget* CheckboxList::listView() const { + return list_; +} + +bool CheckboxList::alternatingRowColors() const { + return list_ != nullptr && list_->alternatingRowColors(); +} + +void CheckboxList::setAlternatingRowColors(bool enabled) { + if (list_ == nullptr) { + return; + } + if (list_->alternatingRowColors() == enabled) { + return; // 无变化不发 NOTIFY。 + } + list_->setAlternatingRowColors(enabled); + emit alternatingRowColorsChanged(enabled); +} + +int CheckboxList::spacing() const { + return list_ != nullptr ? list_->spacing() : 0; +} + +void CheckboxList::setSpacing(int pixels) { + if (list_ == nullptr || pixels < 0) { + return; // 边界:负值无意义,clamp 掉。 + } + if (list_->spacing() == pixels) { + return; + } + list_->setSpacing(pixels); + emit spacingChanged(pixels); +} + +QSize CheckboxList::sizeHint() const { + return {200, 240}; +} + +void CheckboxList::onItemChanged(QListWidgetItem* item) { + if (item == nullptr) { + return; // 边界:空指针安全返回。 + } + // 转发为更易用的 checkedChanged(item, bool);批量操作的雪崩已由调用方 blockSignals 挡住。 + emit checkedChanged(item, item->checkState() == Qt::Checked); +} + +} // namespace AwesomeQt diff --git a/widget/checkbox-tree/CMakeLists.txt b/widget/checkbox-tree/CMakeLists.txt new file mode 100644 index 0000000..7d382d5 --- /dev/null +++ b/widget/checkbox-tree/CMakeLists.txt @@ -0,0 +1,19 @@ +# CheckboxTree 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(checkbox_tree STATIC + include/checkbox_tree.h + src/checkbox_tree.cpp +) + +target_include_directories(checkbox_tree PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(checkbox_tree PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/checkbox-tree/demo/CMakeLists.txt b/widget/checkbox-tree/demo/CMakeLists.txt new file mode 100644 index 0000000..05c95f7 --- /dev/null +++ b/widget/checkbox-tree/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# CheckboxTree 演示程序:三层示例树 + 父子联动观察 + 已勾选项列出 + 全选/全不选 + 联动开关 + +qt_add_executable(checkbox_tree_demo + main.cpp + checkbox_tree_window.cpp + checkbox_tree_window.h +) + +target_link_libraries(checkbox_tree_demo PRIVATE + checkbox_tree +) diff --git a/widget/checkbox-tree/demo/checkbox_tree_window.cpp b/widget/checkbox-tree/demo/checkbox_tree_window.cpp new file mode 100644 index 0000000..3309eef --- /dev/null +++ b/widget/checkbox-tree/demo/checkbox_tree_window.cpp @@ -0,0 +1,112 @@ +/** + * @file checkbox_tree_window.cpp + * @brief CheckboxTree 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ + +#include "checkbox_tree_window.h" + +#include "checkbox_tree.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CheckboxTreeWindow::CheckboxTreeWindow(QWidget* parent) : QMainWindow(parent) { + setup_ui(); + populate_sample_tree(); +} + +void CheckboxTreeWindow::setup_ui() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* root = new QHBoxLayout(central); + + // —— 左:勾选树本体 —— + auto* tree_group = new QGroupBox("Checkbox Tree (try clicking the boxes)"); + auto* tree_layout = new QVBoxLayout(tree_group); + tree_ = new AwesomeQt::CheckboxTree(tree_group); + tree_layout->addWidget(tree_); + root->addWidget(tree_group, 2); + + // —— 右:控制面板 + 输出 —— + auto* side_group = new QGroupBox("Controls"); + auto* side_layout = new QVBoxLayout(side_group); + + auto* list_btn = new QPushButton("List checked items", side_group); + auto* check_all_btn = new QPushButton("Check all", side_group); + auto* uncheck_all_btn = new QPushButton("Uncheck all", side_group); + + propagation_toggle_ = new QCheckBox("Propagation enabled (parent<->child linkage)", side_group); + propagation_toggle_->setChecked(true); + + output_ = new QTextEdit(side_group); + output_->setReadOnly(true); + output_->setPlaceholderText("Checked item paths will appear here..."); + + side_layout->addWidget(list_btn); + side_layout->addWidget(check_all_btn); + side_layout->addWidget(uncheck_all_btn); + side_layout->addWidget(propagation_toggle_); + side_layout->addWidget(output_, 1); + + root->addWidget(side_group, 1); + + // 函数指针语法连接信号槽。 + connect(list_btn, &QPushButton::clicked, this, &CheckboxTreeWindow::list_checked_items); + connect(check_all_btn, &QPushButton::clicked, tree_, &AwesomeQt::CheckboxTree::checkAll); + connect(uncheck_all_btn, &QPushButton::clicked, tree_, &AwesomeQt::CheckboxTree::uncheckAll); + connect(propagation_toggle_, &QCheckBox::toggled, tree_, + &AwesomeQt::CheckboxTree::setPropagationEnabled); + + setWindowTitle("CheckboxTree Demo"); + resize(720, 480); +} + +void CheckboxTreeWindow::populate_sample_tree() { + // 三层示例树:项目 > 模块 > 文件。用 addItem 让控件自己挂节点 + 走初始化逻辑。 + using AwesomeQt::CheckboxTree; + + auto* project = tree_->addItem(nullptr, "Project AwesomeQt"); + auto* mod_a = tree_->addItem(project, "Module A (Core)"); + auto* mod_b = tree_->addItem(project, "Module B (Widgets)"); + + tree_->addItem(mod_a, "core.h"); + tree_->addItem(mod_a, "core.cpp"); + tree_->addItem(mod_a, "core_p.h"); + + tree_->addItem(mod_b, "widget.cpp"); + tree_->addItem(mod_b, "widget.h"); + tree_->addItem(mod_b, "resources.qrc"); + + project->setExpanded(true); + mod_a->setExpanded(true); + mod_b->setExpanded(true); +} + +void CheckboxTreeWindow::list_checked_items() { + const QList checked = tree_->checkedItems(); + if (checked.isEmpty()) { + output_->append("- (no checked items)"); + return; + } + + // 把每项的祖先路径拼出来,方便肉眼核对父子联动效果。 + for (QTreeWidgetItem* item : checked) { + if (item == nullptr) { + continue; + } + QStringList path; + for (const QTreeWidgetItem* p = item; p != nullptr; p = p->parent()) { + path.prepend(p->text(0)); + } + output_->append("- " + path.join(" > ")); + } +} diff --git a/widget/checkbox-tree/demo/checkbox_tree_window.h b/widget/checkbox-tree/demo/checkbox_tree_window.h new file mode 100644 index 0000000..84cb73b --- /dev/null +++ b/widget/checkbox-tree/demo/checkbox_tree_window.h @@ -0,0 +1,29 @@ +/** + * @file checkbox_tree_window.h + * @brief CheckboxTree 控件演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ +#pragma once + +#include + +class QCheckBox; +class QTextEdit; + +namespace AwesomeQt { +class CheckboxTree; +} + +class CheckboxTreeWindow : public QMainWindow { + public: + explicit CheckboxTreeWindow(QWidget* parent = nullptr); + + private: + void setup_ui(); + void populate_sample_tree(); + void list_checked_items(); + + AwesomeQt::CheckboxTree* tree_{nullptr}; + QCheckBox* propagation_toggle_{nullptr}; + QTextEdit* output_{nullptr}; +}; diff --git a/widget/checkbox-tree/demo/main.cpp b/widget/checkbox-tree/demo/main.cpp new file mode 100644 index 0000000..24072bf --- /dev/null +++ b/widget/checkbox-tree/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief CheckboxTree 演示程序入口 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ + +#include "checkbox_tree_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + CheckboxTreeWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/checkbox-tree/include/checkbox_tree.h b/widget/checkbox-tree/include/checkbox_tree.h new file mode 100644 index 0000000..6f16633 --- /dev/null +++ b/widget/checkbox-tree/include/checkbox_tree.h @@ -0,0 +1,98 @@ +/** + * @file checkbox_tree.h + * @brief 勾选树控件 CheckboxTree——封装 QTreeWidget,三态勾选 + 父子自动联动 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ +#pragma once + +#include +#include +#include + +class QTreeWidget; +class QTreeWidgetItem; + +namespace AwesomeQt { + +/// @brief 勾选树:每项带复选框,父子联动——勾父全选子、子部分勾则父三态。 +/// +/// 设计要点: +/// - 内含一个 `QTreeWidget*`(构造期 new,parent=this 由对象树托管),本控件不自绘, +/// 只负责「数据模型组织 + 勾选联动逻辑」; +/// - 父子联动靠 `QTreeWidget::itemChanged` 驱动:用户点击某项后,向下传播状态给子孙, +/// 再从该项目的父起逐层向上重算 tri-state; +/// - 程序化 `setCheckState` 会再次触发 `itemChanged`,递归改子孙时必须 `blockSignals` +/// 守卫,否则信号雪崩(栈溢出 / 性能塌陷)。 +class CheckboxTree : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:联动开关 / 缩进,可被 Designer / 动画驱动 —— + Q_PROPERTY(bool propagationEnabled READ isPropagationEnabled WRITE setPropagationEnabled NOTIFY + propagationEnabledChanged) + Q_PROPERTY(int indentation READ indentation WRITE setIndentation NOTIFY indentationChanged) + + public: + explicit CheckboxTree(QWidget* parent = nullptr); + + /// @brief 追加一项到指定父下(parent=nullptr 表示顶层)。 + /// @param parent 父节点,传 nullptr 则加到树根。 + /// @param text 该项显示文本(第 0 列)。 + /// @param state 初始勾选状态,默认未勾选。 + /// @return 新建并已挂入树的节点指针。 + QTreeWidgetItem* addItem(QTreeWidgetItem* parent, const QString& text, + Qt::CheckState state = Qt::Unchecked); + + /// @brief 收集所有处于 Checked 状态的项(不含 PartiallyChecked)。 + /// @return 已勾选项列表,空树返回空列表。 + QList checkedItems() const; + + /// @brief 以联动逻辑设置某项的勾选状态(非裸 setCheckState)。 + /// @param item 目标节点,nullptr 安全返回。 + /// @param state 目标状态。 + void setItemChecked(QTreeWidgetItem* item, Qt::CheckState state); + + /// @brief 全部勾选(顶层逐项置 Checked 并触发联动)。 + void checkAll(); + + /// @brief 全部取消勾选(顶层逐项置 Unchecked 并触发联动)。 + void uncheckAll(); + + /// @brief 暴露内部 view,供外部读选中行 / 设置表头等只读访问。 + /// @return 内部 QTreeWidget 指针(所有权仍归本控件)。 + QTreeWidget* treeWidget() const; + + bool isPropagationEnabled() const; + void setPropagationEnabled(bool enabled); + + int indentation() const; + void setIndentation(int pixels); + + QSize sizeHint() const override; + + signals: + /// @brief 任意项勾选状态变化(联动完成后发,已对自身递归修改去重)。 + void checkStateChanged(QTreeWidgetItem* item); + void propagationEnabledChanged(bool enabled); + void indentationChanged(int pixels); + + private: + /// @brief itemChanged 槽:用户点击 / 程序改写的统一入口。 + void onItemChanged(QTreeWidgetItem* item, int column); + + /// @brief 把非 PartiallyChecked 状态递归传播给 item 的全部子孙。 + void propagateDown(QTreeWidgetItem* item, Qt::CheckState state); + + /// @brief 从 item 起逐层向上重算 tri-state(按子项混合度推导父态)。 + void recalcUp(QTreeWidgetItem* item); + + /// @brief 统计某节点直接子项的勾选分布,推导它的聚合态。 + Qt::CheckState aggregateState(const QTreeWidgetItem* item) const; + + /// @brief 是否正在程序化批量改写(用于 onItemChanged 区分用户触发 vs 自身触发)。 + bool is_propagating_{false}; + + QTreeWidget* tree_{nullptr}; + bool propagation_enabled_{true}; +}; + +} // namespace AwesomeQt diff --git a/widget/checkbox-tree/src/checkbox_tree.cpp b/widget/checkbox-tree/src/checkbox_tree.cpp new file mode 100644 index 0000000..6fdf23d --- /dev/null +++ b/widget/checkbox-tree/src/checkbox_tree.cpp @@ -0,0 +1,274 @@ +/** + * @file checkbox_tree.cpp + * @brief 勾选树控件 CheckboxTree 实现——三态勾选 + 父子自动联动 + * @copyright Copyright (c) 2026 AwesomeQt. Licensed under MIT. + */ + +#include "checkbox_tree.h" + +#include +#include +#include + +namespace { + +/// @brief 深度优先收集 item 子树里所有 Checked 节点。 +void collectChecked(QTreeWidgetItem* item, QList& out) { + if (item == nullptr) { + return; + } + if (item->checkState(0) == Qt::Checked) { + out.append(item); + } + const int n = item->childCount(); + for (int i = 0; i < n; ++i) { + collectChecked(item->child(i), out); + } +} + +} // namespace + +namespace AwesomeQt { + +CheckboxTree::CheckboxTree(QWidget* parent) : QWidget(parent) { + // 内含一个 QTreeWidget,构造期 new 出来、parent=this 由对象树托管,放进布局。 + tree_ = new QTreeWidget(this); + tree_->setColumnCount(1); + tree_->setHeaderHidden(true); + tree_->setUniformRowHeights(true); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(tree_); + + // 用函数指针语法连接,避免 SIGNAL/SLOT 宏;column 参数暂不使用,但保留槽签名。 + connect(tree_, &QTreeWidget::itemChanged, this, &CheckboxTree::onItemChanged); +} + +QTreeWidgetItem* CheckboxTree::addItem(QTreeWidgetItem* parent, const QString& text, + Qt::CheckState state) { + auto* item = new QTreeWidgetItem(); + item->setText(0, text); + item->setCheckState(0, state); + + if (parent != nullptr) { + parent->addChild(item); + // 新增子项可能影响父的聚合态,回算一次(守卫信号,避免雪崩)。 + const bool was_blocked = tree_->blockSignals(true); + item->setCheckState(0, state); + tree_->blockSignals(was_blocked); + recalcUp(parent); + } else { + // blockSignals 守卫:setCheckState 会触发 itemChanged,这里只做初始化, + // 真正的联动放给后续用户操作或 setItemChecked。 + const bool was_blocked = tree_->blockSignals(true); + item->setCheckState(0, state); + tree_->blockSignals(was_blocked); + tree_->addTopLevelItem(item); + } + return item; +} + +QList CheckboxTree::checkedItems() const { + QList result; + if (tree_ == nullptr) { + return result; + } + + // 从顶层逐棵深度优先遍历,只收 Checked(不含 PartiallyChecked)。 + const int top_count = tree_->topLevelItemCount(); + for (int i = 0; i < top_count; ++i) { + collectChecked(tree_->topLevelItem(i), result); + } + return result; +} + +void CheckboxTree::setItemChecked(QTreeWidgetItem* item, Qt::CheckState state) { + if (item == nullptr) { + return; // 边界:空指针安全返回。 + } + + if (!propagation_enabled_) { + // 关掉联动就是普通勾选树:只改自身,不向子孙传播、不回算祖先。 + const bool was_blocked = tree_->blockSignals(true); + item->setCheckState(0, state); + tree_->blockSignals(was_blocked); + emit checkStateChanged(item); + return; + } + + // 走完整联动:先向下传播,再向上回算。 + is_propagating_ = true; + const bool was_blocked = tree_->blockSignals(true); + item->setCheckState(0, state); + propagateDown(item, state); + tree_->blockSignals(was_blocked); + recalcUp(item->parent()); + is_propagating_ = false; + + emit checkStateChanged(item); +} + +void CheckboxTree::checkAll() { + if (tree_ == nullptr) { + return; + } + is_propagating_ = true; + const bool was_blocked = tree_->blockSignals(true); + const int top_count = tree_->topLevelItemCount(); + for (int i = 0; i < top_count; ++i) { + QTreeWidgetItem* top = tree_->topLevelItem(i); + if (top != nullptr) { + propagateDown(top, Qt::Checked); + top->setCheckState(0, Qt::Checked); + } + } + tree_->blockSignals(was_blocked); + is_propagating_ = false; + emit checkStateChanged(nullptr); +} + +void CheckboxTree::uncheckAll() { + if (tree_ == nullptr) { + return; + } + is_propagating_ = true; + const bool was_blocked = tree_->blockSignals(true); + const int top_count = tree_->topLevelItemCount(); + for (int i = 0; i < top_count; ++i) { + QTreeWidgetItem* top = tree_->topLevelItem(i); + if (top != nullptr) { + propagateDown(top, Qt::Unchecked); + top->setCheckState(0, Qt::Unchecked); + } + } + tree_->blockSignals(was_blocked); + is_propagating_ = false; + emit checkStateChanged(nullptr); +} + +QTreeWidget* CheckboxTree::treeWidget() const { + return tree_; +} + +bool CheckboxTree::isPropagationEnabled() const { + return propagation_enabled_; +} + +void CheckboxTree::setPropagationEnabled(bool enabled) { + if (propagation_enabled_ == enabled) { + return; + } + propagation_enabled_ = enabled; + emit propagationEnabledChanged(enabled); +} + +int CheckboxTree::indentation() const { + return tree_ != nullptr ? tree_->indentation() : 0; +} + +void CheckboxTree::setIndentation(int pixels) { + if (tree_ == nullptr || pixels < 0) { + return; // 边界:负值无意义,clamp 掉。 + } + tree_->setIndentation(pixels); + emit indentationChanged(pixels); +} + +QSize CheckboxTree::sizeHint() const { + return {260, 320}; +} + +void CheckboxTree::onItemChanged(QTreeWidgetItem* item, int /*column*/) { + if (item == nullptr) { + return; + } + // 自身触发的程序化修改(checkAll / setItemChecked 等)已自行处理联动, + // 这里只响应用户点击,避免二次递归。 + if (is_propagating_) { + return; + } + + if (!propagation_enabled_) { + emit checkStateChanged(item); + return; + } + + const Qt::CheckState state = item->checkState(0); + + // 向下传播:只有确定的 Checked/Unchecked 才传播,PartiallyChecked 是回算产物, + // 用户不可能直接点出 Partially(QTreeWidget 交互只产生两态),故无需处理。 + is_propagating_ = true; + const bool was_blocked = tree_->blockSignals(true); + if (state == Qt::Checked || state == Qt::Unchecked) { + propagateDown(item, state); + } + tree_->blockSignals(was_blocked); + recalcUp(item->parent()); + is_propagating_ = false; + + emit checkStateChanged(item); +} + +void CheckboxTree::propagateDown(QTreeWidgetItem* item, Qt::CheckState state) { + if (item == nullptr) { + return; + } + // 递归把同一状态写到所有子孙;调用方负责 blockSignals 守卫,这里直接改。 + const int child_count = item->childCount(); + for (int i = 0; i < child_count; ++i) { + QTreeWidgetItem* child = item->child(i); + if (child != nullptr) { + child->setCheckState(0, state); + propagateDown(child, state); // 递归到叶子 + } + } +} + +void CheckboxTree::recalcUp(QTreeWidgetItem* item) { + // 从 item(变更节点的父)起逐层向上:每层据子项分布重算自身 tri-state。 + QTreeWidgetItem* current = item; + while (current != nullptr) { + const Qt::CheckState agg = aggregateState(current); + const bool was_blocked = tree_->blockSignals(true); + current->setCheckState(0, agg); + tree_->blockSignals(was_blocked); + current = current->parent(); + } +} + +Qt::CheckState CheckboxTree::aggregateState(const QTreeWidgetItem* item) const { + if (item == nullptr || item->childCount() == 0) { + return item != nullptr ? item->checkState(0) : Qt::Unchecked; + } + + const int child_count = item->childCount(); + int checked = 0; + int unchecked = 0; + + for (int i = 0; i < child_count; ++i) { + const QTreeWidgetItem* child = item->child(i); + if (child == nullptr) { + continue; + } + const Qt::CheckState cs = child->checkState(0); + if (cs == Qt::Checked) { + ++checked; + } else if (cs == Qt::Unchecked) { + ++unchecked; + } else { + // 任一子为 PartiallyChecked 即整体混合。 + return Qt::PartiallyChecked; + } + } + + if (checked == child_count) { + return Qt::Checked; + } + if (unchecked == child_count) { + return Qt::Unchecked; + } + return Qt::PartiallyChecked; // 部分 Checked 部分 Unchecked +} + +} // namespace AwesomeQt diff --git a/widget/circle-progress/CMakeLists.txt b/widget/circle-progress/CMakeLists.txt new file mode 100644 index 0000000..e9a2185 --- /dev/null +++ b/widget/circle-progress/CMakeLists.txt @@ -0,0 +1,19 @@ +# CircleProgress 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(circle_progress STATIC + include/circle_progress.h + src/circle_progress.cpp +) + +target_include_directories(circle_progress PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(circle_progress PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/circle-progress/demo/CMakeLists.txt b/widget/circle-progress/demo/CMakeLists.txt new file mode 100644 index 0000000..6764e8a --- /dev/null +++ b/widget/circle-progress/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# CircleProgress 演示程序:静态多档 + Cycle 过渡 + Slider 驱动 + 配色变体 + +qt_add_executable(circle_progress_demo + main.cpp + circle_progress_window.cpp + circle_progress_window.h +) + +target_link_libraries(circle_progress_demo PRIVATE + circle_progress +) diff --git a/widget/circle-progress/demo/circle_progress_window.cpp b/widget/circle-progress/demo/circle_progress_window.cpp new file mode 100644 index 0000000..962a0aa --- /dev/null +++ b/widget/circle-progress/demo/circle_progress_window.cpp @@ -0,0 +1,131 @@ +/** + * @file circle_progress_window.cpp + * @brief CircleProgress 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "circle_progress_window.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "circle_progress.h" + +CircleProgressWindow::CircleProgressWindow(QWidget* parent) : QMainWindow(parent) { + setupUi(); +} + +QWidget* CircleProgressWindow::setupStaticLayout() { + // 静态多档:25/50/75/100% 一排,初值直接给(不经动画,启动即定格) + auto* group = new QGroupBox("Static Progress"); + auto* grid = new QGridLayout(group); + + const int values[] = {25, 50, 75, 100}; + for (int i = 0; i < 4; ++i) { + auto* ring = new AwesomeQt::CircleProgress(values[i], group); + auto* label = new QLabel(QString("%1%").arg(values[i]), group); + label->setAlignment(Qt::AlignCenter); + grid->addWidget(ring, 0, i, Qt::AlignCenter); + grid->addWidget(label, 1, i, Qt::AlignCenter); + } + return group; +} + +QWidget* CircleProgressWindow::setupInteractiveLayout() { + // 大环 + Cycle 按钮(看 400ms 过渡)+ Slider 实时驱动 + auto* group = new QGroupBox("Interactive Ring"); + auto* layout = new QVBoxLayout(group); + + auto* ring = new AwesomeQt::CircleProgress(0, group); + ring->setStrokeWidth(14); + + auto* value_label = new QLabel("0%", group); + value_label->setAlignment(Qt::AlignCenter); + auto font = value_label->font(); + font.setBold(true); + value_label->setFont(font); + + // 业务值变了就更新标签(progress 是动画中间值,文字跟 value 走更稳) + auto sync_label = [ring, value_label]() { + value_label->setText(QString("%1%").arg(ring->value())); + }; + QObject::connect(ring, &AwesomeQt::CircleProgress::valueChanged, group, + [value_label](int v) { value_label->setText(QString("%1%").arg(v)); }); + + auto* cycle_btn = new QPushButton("Cycle +25%", group); + QObject::connect(cycle_btn, &QPushButton::clicked, group, [ring, sync_label]() { + ring->setValue((ring->value() + 25) % 125); // 0→25→50→75→100→0 循环 + sync_label(); + }); + + auto* slider = new QSlider(Qt::Horizontal, group); + slider->setRange(0, 100); + slider->setValue(0); + QObject::connect(slider, &QSlider::valueChanged, group, [ring](int v) { + ring->setValue(v); // Slider 拖动实时驱动 setValue,看过渡是否流畅 + }); + + layout->addWidget(ring, 0, Qt::AlignCenter); + layout->addWidget(value_label); + layout->addWidget(cycle_btn); + layout->addWidget(slider); + return group; +} + +QWidget* CircleProgressWindow::setupVariantsLayout() { + // 配色/线宽变体:证明 progressColor / ringColor / strokeWidth 是真 Q_PROPERTY + auto* group = new QGroupBox("Variants"); + auto* layout = new QHBoxLayout(group); + + // 绿色细环 + auto* green = new AwesomeQt::CircleProgress(60, group); + green->setProgressColor(QColor(0, 180, 90)); + green->setStrokeWidth(6); + auto* green_label = new QLabel("green / thin", group); + green_label->setAlignment(Qt::AlignCenter); + + // 橙色粗环 + 关文字 + auto* orange = new AwesomeQt::CircleProgress(70, group); + orange->setProgressColor(QColor(255, 140, 0)); + orange->setRingColor(QColor(60, 60, 60)); + orange->setStrokeWidth(18); + orange->setShowText(false); + auto* orange_label = new QLabel("orange / thick / no text", group); + orange_label->setAlignment(Qt::AlignCenter); + + // 紫环 + auto* purple = new AwesomeQt::CircleProgress(40, group); + purple->setProgressColor(QColor(150, 80, 220)); + purple->setStrokeWidth(10); + auto* purple_label = new QLabel("purple", group); + purple_label->setAlignment(Qt::AlignCenter); + + auto add = [&](QWidget* w, QWidget* lbl) { + auto* col = new QVBoxLayout(); + col->addWidget(w, 0, Qt::AlignCenter); + col->addWidget(lbl, 0, Qt::AlignCenter); + layout->addLayout(col); + }; + add(green, green_label); + add(orange, orange_label); + add(purple, purple_label); + return group; +} + +void CircleProgressWindow::setupUi() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* layout = new QVBoxLayout(central); + layout->addWidget(setupStaticLayout()); + layout->addWidget(setupInteractiveLayout()); + layout->addWidget(setupVariantsLayout()); + + setWindowTitle("CircleProgress Widget Demo"); + resize(560, 480); +} diff --git a/widget/circle-progress/demo/circle_progress_window.h b/widget/circle-progress/demo/circle_progress_window.h new file mode 100644 index 0000000..5d34cd4 --- /dev/null +++ b/widget/circle-progress/demo/circle_progress_window.h @@ -0,0 +1,21 @@ +/** + * @file circle_progress_window.h + * @brief CircleProgress 演示主窗口:静态多档 + Cycle 过渡 + Slider 驱动 + 配色变体 + * @copyright Copyright (c) 2026 AwesomeQt + */ +#pragma once + +#include + +class CircleProgressWindow : public QMainWindow { + Q_OBJECT + + public: + explicit CircleProgressWindow(QWidget* parent = nullptr); + + private: + void setupUi(); + QWidget* setupStaticLayout(); + QWidget* setupInteractiveLayout(); + QWidget* setupVariantsLayout(); +}; diff --git a/widget/circle-progress/demo/main.cpp b/widget/circle-progress/demo/main.cpp new file mode 100644 index 0000000..3a682f8 --- /dev/null +++ b/widget/circle-progress/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief CircleProgress 演示程序入口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "circle_progress_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + CircleProgressWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/circle-progress/include/circle_progress.h b/widget/circle-progress/include/circle_progress.h new file mode 100644 index 0000000..4608e88 --- /dev/null +++ b/widget/circle-progress/include/circle_progress.h @@ -0,0 +1,90 @@ +/** + * @file circle_progress.h + * @brief 圆形进度环控件 CircleProgress——背景环 + 进度弧 + 中心百分比文字 + 平滑过渡 + * @copyright Copyright (c) 2026 AwesomeQt + */ +#pragma once + +#include +#include + +class QPropertyAnimation; + +namespace AwesomeQt { + +/// 圆形进度环:value 0..100 的环形进度条。 +/// +/// 设计要点(详见成品导览 index.md): +/// - `value` 是业务属性(0..100),`progress`(0.0..1.0)是动画属性(实际绘制进度)。 +/// setValue 用 QPropertyAnimation 从当前 progress 接力到 value/100,弧平滑铺开; +/// 二者解耦,避免「WRITE 指 setValue→setValue 又启动画→栈溢出」(踩坑③)。 +/// - 进度弧从 12 点钟顺时针铺开。QPainter drawArc 角度是 1/16°、0°=3 点、正值逆时针, +/// 故起始角 1440(=90°×16,12 点钟)、扫角取负(顺时针)。换算注释见 .cpp。 +/// - 动画对象为持久成员指针,stop()/重配 setStartValue(当前 progress)/start() 复用, +/// 不用 DeleteWhenStopped(防连切悬空)。 +class CircleProgress : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:value / progress / strokeWidth / 两色 / showText 均可被动画/Designer 驱动 —— + Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) + Q_PROPERTY(double progress READ progress WRITE setDisplayProgress NOTIFY progressChanged) + Q_PROPERTY(int strokeWidth READ strokeWidth WRITE setStrokeWidth NOTIFY strokeWidthChanged) + Q_PROPERTY( + QColor progressColor READ progressColor WRITE setProgressColor NOTIFY progressColorChanged) + Q_PROPERTY(QColor ringColor READ ringColor WRITE setRingColor NOTIFY ringColorChanged) + Q_PROPERTY(bool showText READ showText WRITE setShowText NOTIFY showTextChanged) + + public: + explicit CircleProgress(QWidget* parent = nullptr); + CircleProgress(int value, QWidget* parent = nullptr); + + /// @brief 设置进度值(业务入口):触发从当前 progress 接力到 value/100 的平滑过渡。 + void setValue(int value); + int value() const; + + /// @brief 当前绘制进度 [0.0, 1.0]。Q_PROPERTY(progress) 的 WRITE 回调, + /// 供 QPropertyAnimation 每帧驱动;外部业务请用 setValue,勿直接调。 + void setDisplayProgress(double progress); + double progress() const; + + /// @brief 设置进度环线宽(>0)。 + void setStrokeWidth(int width); + int strokeWidth() const; + + void setProgressColor(const QColor& color); + QColor progressColor() const; + + void setRingColor(const QColor& color); + QColor ringColor() const; + + void setShowText(bool enabled); + bool showText() const; + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + signals: + void valueChanged(int newValue); + void progressChanged(double newProgress); + void strokeWidthChanged(int newWidth); + void progressColorChanged(const QColor& newColor); + void ringColorChanged(const QColor& newColor); + void showTextChanged(bool enabled); + + protected: + void paintEvent(QPaintEvent* event) override; + + private: + void initAnimation(); + + int value_{0}; // 业务值 0..100 + double progress_{0.0}; // 动画产物 0..1(实际绘制进度) + int stroke_width_{10}; + QColor progress_color_{QColor(50, 160, 255)}; // 进度弧:蓝 + QColor ring_color_{QColor(230, 230, 230)}; // 背景环:浅灰 + bool show_text_{true}; + + QPropertyAnimation* progress_anim_{nullptr}; // 进度过渡(持久,非 DeleteWhenStopped) +}; + +} // namespace AwesomeQt diff --git a/widget/circle-progress/src/circle_progress.cpp b/widget/circle-progress/src/circle_progress.cpp new file mode 100644 index 0000000..d5f6503 --- /dev/null +++ b/widget/circle-progress/src/circle_progress.cpp @@ -0,0 +1,210 @@ +/** + * @file circle_progress.cpp + * @brief CircleProgress 控件实现——背景环 + 进度弧 + 中心文字 + 平滑过渡 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "circle_progress.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace AwesomeQt { + +namespace { +// —— drawArc 角度约定(1/16°,0°=3 点钟,正值逆时针)—— +// 12 点钟 = 90° → 1440(1/16°)。进度顺时针铺开 = 扫角取负。 +constexpr int kStartAngle16 = 90 * 16; // 12 点钟起始角 +constexpr int kFullCircle16 = 360 * 16; // 整圈 5760 +} // namespace + +// ============================================================================ +// 构造 +// ============================================================================ +CircleProgress::CircleProgress(QWidget* parent) : QWidget(parent) { + initAnimation(); + progress_ = std::clamp(value_, 0, 100) / 100.0; // 初值与 value 对齐,不在动画中 +} + +CircleProgress::CircleProgress(int value, QWidget* parent) + : QWidget(parent), value_(std::clamp(value, 0, 100)) { + initAnimation(); + progress_ = value_ / 100.0; +} + +// ============================================================================ +// 动画对象初始化(parent=this,对象树托管释放) +// ============================================================================ +void CircleProgress::initAnimation() { + // 持久指针:stop()/重配/start() 复用,不用 DeleteWhenStopped(防连切悬空/叠加) + progress_anim_ = new QPropertyAnimation(this, "progress", this); + progress_anim_->setDuration(400); + progress_anim_->setEasingCurve(QEasingCurve::OutCubic); +} + +// ============================================================================ +// 业务入口:触发进度弧接力铺开 +// ============================================================================ +void CircleProgress::setValue(int value) { + const int clamped = std::clamp(value, 0, 100); + if (value_ == clamped) { + return; + } + value_ = clamped; + emit valueChanged(clamped); + + // 从当前显示进度(可能是动画中间值)接力到新目标,避免跳回旧目标造成跳变 + progress_anim_->stop(); + progress_anim_->setStartValue(progress_); + progress_anim_->setEndValue(clamped / 100.0); + progress_anim_->start(); +} + +int CircleProgress::value() const { + return value_; +} + +// ============================================================================ +// Q_PROPERTY(progress) 回调:动画每帧驱动写这里(纯赋值 + emit + update) +// ============================================================================ +void CircleProgress::setDisplayProgress(double progress) { + const double clamped = std::clamp(progress, 0.0, 1.0); + if (qFuzzyCompare(progress_, clamped)) { + return; + } + progress_ = clamped; + emit progressChanged(clamped); + update(); // 异步请求重绘,不立即触发 paintEvent +} + +double CircleProgress::progress() const { + return progress_; +} + +// ============================================================================ +// 线宽 / 颜色 / 文字开关 +// ============================================================================ +void CircleProgress::setStrokeWidth(int width) { + const int clamped = std::max(1, width); + if (stroke_width_ == clamped) { + return; + } + stroke_width_ = clamped; + update(); + emit strokeWidthChanged(clamped); +} + +int CircleProgress::strokeWidth() const { + return stroke_width_; +} + +void CircleProgress::setProgressColor(const QColor& color) { + if (progress_color_ == color) { + return; + } + progress_color_ = color; + update(); + emit progressColorChanged(color); +} + +QColor CircleProgress::progressColor() const { + return progress_color_; +} + +void CircleProgress::setRingColor(const QColor& color) { + if (ring_color_ == color) { + return; + } + ring_color_ = color; + update(); + emit ringColorChanged(color); +} + +QColor CircleProgress::ringColor() const { + return ring_color_; +} + +void CircleProgress::setShowText(bool enabled) { + if (show_text_ == enabled) { + return; + } + show_text_ = enabled; + update(); + emit showTextChanged(enabled); +} + +bool CircleProgress::showText() const { + return show_text_; +} + +// ============================================================================ +// 尺寸 +// ============================================================================ +QSize CircleProgress::sizeHint() const { + return QSize(100, 100); +} + +QSize CircleProgress::minimumSizeHint() const { + return QSize(40, 40); +} + +// ============================================================================ +// 自绘:背景整圈环 + 进度弧(12 点钟顺时针)+ 中心百分比文字 +// ============================================================================ +void CircleProgress::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + // —— 几何:半径对 0/负值 clamp,防控件被压极小时 drawArc 行为未定义 —— + const qreal stroke = std::max(1.0, static_cast(stroke_width_)); + const qreal side = std::max(1, std::min(width(), height())); + // 环半径 = 内切圆半径 - 半个线宽 - 2px 边距,clamp 到 >=1 + const qreal r = std::max(1.0, side / 2.0 - stroke / 2.0 - 2.0); + const QRectF arc_rect(width() / 2.0 - r, height() / 2.0 - r, r * 2.0, r * 2.0); + + // —— 背景环:整圈,ringColor —— + { + QPen pen(ring_color_); + pen.setWidthF(stroke); + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawArc(arc_rect, 0, kFullCircle16); + } + + // —— 进度弧:从 12 点钟顺时针铺开 progress 比例 —— + if (progress_ > 0.0) { + QPen pen(progress_color_); + pen.setWidthF(stroke); + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + // 扫角 = progress * 5760,取负 = 顺时针(从 12 点钟往 3 点钟方向铺开) + const int span = static_cast(std::round(progress_ * kFullCircle16)); + p.drawArc(arc_rect, kStartAngle16, -span); + } + + // —— 中心百分比文字 —— + if (show_text_) { + const QString text = QString::number(static_cast(std::round(progress_ * 100))) + "%"; + QFont f = p.font(); + f.setBold(true); + f.setPointSizeF(std::max(8.0, side * 0.16)); // 字号随尺寸缩放但至少 8pt + p.setFont(f); + const QFontMetrics fm(f); + const QRectF text_rect(width() / 2.0 - fm.horizontalAdvance(text) / 2.0, + height() / 2.0 - fm.height() / 2.0, fm.horizontalAdvance(text), + fm.height()); + p.setPen(progress_color_); + p.drawText(text_rect, Qt::AlignCenter, text); + } +} + +} // namespace AwesomeQt diff --git a/widget/editable-table/CMakeLists.txt b/widget/editable-table/CMakeLists.txt new file mode 100644 index 0000000..f7f7316 --- /dev/null +++ b/widget/editable-table/CMakeLists.txt @@ -0,0 +1,19 @@ +# EditableTable 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(editable_table STATIC + include/editable_table.h + src/editable_table.cpp +) + +target_include_directories(editable_table PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(editable_table PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/editable-table/demo/CMakeLists.txt b/widget/editable-table/demo/CMakeLists.txt new file mode 100644 index 0000000..97f146e --- /dev/null +++ b/widget/editable-table/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# EditableTable 演示程序:五类型列 + 增删行 + 打印数据 + 只读切换 + +qt_add_executable(editable_table_demo + main.cpp + editable_table_window.cpp + editable_table_window.h +) + +target_link_libraries(editable_table_demo PRIVATE + editable_table +) diff --git a/widget/editable-table/demo/editable_table_window.cpp b/widget/editable-table/demo/editable_table_window.cpp new file mode 100644 index 0000000..b42e819 --- /dev/null +++ b/widget/editable-table/demo/editable_table_window.cpp @@ -0,0 +1,126 @@ +/** + * @file editable_table_window.cpp + * @brief EditableTable 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "editable_table_window.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "editable_table.h" + +using AwesomeQt::EditableTable; + +EditableTableWindow::EditableTableWindow(QWidget* parent) : QMainWindow(parent) { + setup_ui(); +} + +void EditableTableWindow::setup_ui() { + auto* central = new QWidget(this); + setCentralWidget(central); + auto* root = new QVBoxLayout(central); + + // —— 表格组:五类型列,预填几行 —— + auto* table_group = + new QGroupBox("Editable Table (text / int 0-100 / double 0-1 / combo / check)"); + auto* table_layout = new QVBoxLayout(table_group); + + table_ = new EditableTable(table_group); + table_->setAlternatingRowColors(true); + table_->addColumn("Name", EditableTable::ColumnType::kText); + table_->addColumn("Score", EditableTable::ColumnType::kInt, 0, 100); + table_->addColumn("Ratio", EditableTable::ColumnType::kDouble, 0.0, 1.0); + table_->addColumn("Color", EditableTable::ColumnType::kCombo, 0, 0, {"Red", "Green", "Blue"}); + table_->addColumn("Active", EditableTable::ColumnType::kCheck); + + // 预填几行(演示 setData 整表回填 + 越界/类型不符夹值) + table_->setData({ + {"Alice", 88, 0.42, "Green", true}, + {"Bob", 120, 1.5, "Yellow", false}, // 120→100, 1.5→1.0, Yellow→Red(回库) + {"Carol", 73, 0.66, "Blue", true}, + }); + + table_layout->addWidget(table_); + table_->resizeColumnsToContents(); + root->addWidget(table_group); + + // —— 操作组:增删清 + 打印 + 只读切换 —— + auto* action_group = new QGroupBox("Operations"); + auto* action_layout = new QHBoxLayout(action_group); + + auto* add_btn = new QPushButton("Add Row", action_group); + auto* del_btn = new QPushButton("Remove Selected", action_group); + auto* clear_btn = new QPushButton("Clear", action_group); + auto* dump_btn = new QPushButton("Print Data", action_group); + editable_check_ = new QCheckBox("Editable", action_group); + editable_check_->setChecked(table_->isEditable()); + + action_layout->addWidget(add_btn); + action_layout->addWidget(del_btn); + action_layout->addWidget(clear_btn); + action_layout->addWidget(dump_btn); + action_layout->addStretch(); + action_layout->addWidget(editable_check_); + root->addWidget(action_group); + + // —— 输出区:打印 data() —— + auto* output_group = new QGroupBox("Data Output"); + auto* output_layout = new QVBoxLayout(output_group); + auto* hint = + new QLabel("Tip: double-click an int cell, type 9999 or 'abc' → delegate clamps to 100."); + hint->setWordWrap(true); + text_output_ = new QTextEdit(output_group); + text_output_->setReadOnly(true); + text_output_->setMinimumHeight(140); + output_layout->addWidget(hint); + output_layout->addWidget(text_output_); + root->addWidget(output_group); + + // —— 连线(函数指针语法)—— + connect(add_btn, &QPushButton::clicked, this, [this]() { + table_->addRow(); + dump_data(); + }); + connect(del_btn, &QPushButton::clicked, this, [this]() { + // 删当前选中行;无选中则删最后一行(removeRow 自己处理 -1) + table_->removeRow(table_->currentRow()); + dump_data(); + }); + connect(clear_btn, &QPushButton::clicked, this, [this]() { + table_->clear(); + dump_data(); + }); + connect(dump_btn, &QPushButton::clicked, this, &EditableTableWindow::dump_data); + connect(editable_check_, &QCheckBox::toggled, this, + [this](bool checked) { table_->setEditable(checked); }); + + // 编辑即回显,验证委托校验后的值 + connect(table_, &EditableTable::dataEdited, this, + [this](int row, int col, const QVariant& value) { + text_output_->append( + QString("edited: [%1,%2] = %3").arg(row).arg(col).arg(value.toString())); + }); + + resize(720, 560); + dump_data(); +} + +void EditableTableWindow::dump_data() { + const auto rows = table_->data(); + QString text = QString("rows = %1\n").arg(rows.size()); + for (int r = 0; r < rows.size(); ++r) { + QStringList cells; + for (const auto& cell : rows.at(r)) { + cells << cell.toString(); + } + text += QString("[%1] ").arg(r) + cells.join(" | ") + "\n"; + } + text_output_->setPlainText(text); +} diff --git a/widget/editable-table/demo/editable_table_window.h b/widget/editable-table/demo/editable_table_window.h new file mode 100644 index 0000000..fdf688b --- /dev/null +++ b/widget/editable-table/demo/editable_table_window.h @@ -0,0 +1,29 @@ +/** + * @file editable_table_window.h + * @brief EditableTable 控件演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#pragma once + +#include + +class QCheckBox; +class QTextEdit; + +namespace AwesomeQt { +class EditableTable; +} + +class EditableTableWindow : public QMainWindow { + public: + explicit EditableTableWindow(QWidget* parent = nullptr); + + private: + void setup_ui(); + void dump_data(); // 把 data() 格式化进 text_output_ + + AwesomeQt::EditableTable* table_{nullptr}; + QCheckBox* editable_check_{nullptr}; + QTextEdit* text_output_{nullptr}; +}; diff --git a/widget/editable-table/demo/main.cpp b/widget/editable-table/demo/main.cpp new file mode 100644 index 0000000..e16299c --- /dev/null +++ b/widget/editable-table/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief EditableTable 演示程序入口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "editable_table_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + EditableTableWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/editable-table/include/editable_table.h b/widget/editable-table/include/editable_table.h new file mode 100644 index 0000000..31d4180 --- /dev/null +++ b/widget/editable-table/include/editable_table.h @@ -0,0 +1,163 @@ +/** + * @file editable_table.h + * @brief 可编辑表格控件 EditableTable——按列声明类型 + 委托校验 + 整表数据往返 + * @copyright Copyright (c) 2026 AwesomeQt + * + * 组合 QTableWidget(非自绘)。每列声明一个 ColumnType,编辑时由 + * ValidatorDelegate 选择合适的编辑器并做范围/空值校验,外部可一次性 + * 拿到/回填整表数据。 + */ +#pragma once + +#include +#include +#include +#include + +class QTableWidgetItem; + +namespace AwesomeQt { + +namespace detail { + +/// @brief 按列类型驱动的编辑委托。createEditor 挑编辑器,setModelData 做校验。 +/// +/// 与 EditableTable 解耦:列规格由 columnSpecAt() 回调取,避免在委托里 +/// 持有父表指针的强引用环。 +class ValidatorDelegate : public QStyledItemDelegate { + Q_OBJECT + + public: + /// @brief 取指定模型列的规格(类型 / 范围 / 下拉项)的回调。 + /// + /// 返回 false 表示该列无规格——退化为普通文本编辑。 + using ColumnSpecProvider = + std::function; + + explicit ValidatorDelegate(QObject* parent = nullptr); + + /// @brief 注入列规格来源(通常指向 EditableTable 的列描述表)。 + void setColumnSpecProvider(ColumnSpecProvider provider); + + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + + void setEditorData(QWidget* editor, const QModelIndex& index) const override; + + void setModelData(QWidget* editor, QAbstractItemModel* model, + const QModelIndex& index) const override; + + void updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + + private: + /// @brief 拉取某列规格,失败则按文本处理。 + bool specFor(int column, int& type, double& min, double& max, QStringList& combo) const; + + ColumnSpecProvider provider_; +}; + +} // namespace detail + +/// @brief 可编辑表格:列声明类型 + 委托校验 + 整表数据存取。 +/// +/// 列类型决定编辑器与校验逻辑: +/// - kText → QLineEdit +/// - kInt → QSpinBox,夹值到 [min,max],空值不写入 +/// - kDouble → QDoubleSpinBox,同上 +/// - kCombo → QComboBox(只能在 comboItems 里选) +/// - kCheck → 单元勾选框(Qt::ItemIsUserCheckable),不走委托编辑器 +/// +/// 边界:空表 / 越界行 / 类型不符均安全夹值或忽略,不抛不崩。 +class EditableTable : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:行为开关,可在 Designer / 外部直接驱动 —— + Q_PROPERTY(bool editable READ isEditable WRITE setEditable NOTIFY editableChanged) + Q_PROPERTY(bool gridVisible READ gridVisible WRITE setGridVisible NOTIFY gridVisibleChanged) + Q_PROPERTY(bool alternatingRowColors READ alternatingRowColors WRITE setAlternatingRowColors + NOTIFY alternatingRowColorsChanged) + + public: + /// @brief 列类型枚举 + enum class ColumnType { kText, kInt, kDouble, kCombo, kCheck }; + Q_ENUM(ColumnType) + + explicit EditableTable(QWidget* parent = nullptr); + + /// @brief 追加一列。 + /// @param header 表头文字 + /// @param type 列类型(决定编辑器与校验) + /// @param min 数值列下界(kInt/kDouble 用) + /// @param max 数值列上界(kInt/kDouble 用) + /// @param combo 下拉项(kCombo 用) + void addColumn(const QString& header, ColumnType type, double min = 0, double max = 0, + const QStringList& combo = {}); + + /// @brief 追加一个空行(按列类型给合理默认值)。 + void addRow(); + + /// @brief 删除一行。row=-1 删除最后一行;越界忽略。 + void removeRow(int row = -1); + + /// @brief 整表回填。按列类型转换/夹值,行/列越界部分跳过。 + void setData(const QVector>& rows); + + /// @brief 取整表数据(按列类型还原为 QVariant)。空表返回空向量。 + QVector> data() const; + + /// @brief 清空所有行(保留列定义)。 + void clear(); + + /// @brief 当前选中行号(无选中返回 -1)。供 demo 删行用。 + int currentRow() const; + + /// @brief 让列宽自适应内容(透传 QTableWidget)。 + void resizeColumnsToContents(); + + // —— Q_PROPERTY 读写 —— + void setEditable(bool editable); + bool isEditable() const; + + void setGridVisible(bool visible); + bool gridVisible() const; + + void setAlternatingRowColors(bool enabled); + bool alternatingRowColors() const; + + QSize sizeHint() const override; + + signals: + /// @brief 单元格数据被编辑提交后发出(含委托校验后的值)。 + void dataEdited(int row, int col, const QVariant& value); + void editableChanged(bool editable); + void gridVisibleChanged(bool visible); + void alternatingRowColorsChanged(bool enabled); + + private: + /// @brief 列规格描述。 + struct ColumnSpec { + QString header; + ColumnType type{ColumnType::kText}; + double min{0}; + double max{0}; + QStringList combo; + }; + + /// @brief 把类型当委托回调能用的 int 返回(detail 层不依赖枚举)。 + int columnTypeToInt(ColumnType type) const; + + /// @brief 给某行某列装上勾选框(kCheck 专用)。 + void applyCheckState(int row, int col, Qt::CheckState state); + + /// @brief cellChanged 的去重 + 转发,避免 setData 程序化填值时回灌。 + void onCellChanged(int row, int col); + + QVector columns_; // 列定义,顺序即列序 + QTableWidget* table_{nullptr}; + detail::ValidatorDelegate* delegate_{nullptr}; + bool editable_{true}; + bool suppress_signal_{false}; // setData 回填期间屏蔽 cellChanged 回灌 +}; + +} // namespace AwesomeQt diff --git a/widget/editable-table/src/editable_table.cpp b/widget/editable-table/src/editable_table.cpp new file mode 100644 index 0000000..bb20568 --- /dev/null +++ b/widget/editable-table/src/editable_table.cpp @@ -0,0 +1,458 @@ +/** + * @file editable_table.cpp + * @brief EditableTable 控件实现——委托编辑器 + 校验 + 整表数据往返 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "editable_table.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AwesomeQt { + +namespace detail { + +// ============================================================================ +// ValidatorDelegate —— 按列规格挑编辑器、写回时做范围/空值校验 +// ============================================================================ + +ValidatorDelegate::ValidatorDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + +void ValidatorDelegate::setColumnSpecProvider(ColumnSpecProvider provider) { + provider_ = std::move(provider); +} + +bool ValidatorDelegate::specFor(int column, int& type, double& min, double& max, + QStringList& combo) const { + if (!provider_) { + return false; + } + return provider_(column, type, min, max, combo); +} + +QWidget* ValidatorDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& /*option*/, + const QModelIndex& index) const { + int type = static_cast(EditableTable::ColumnType::kText); + double min = 0, max = 0; + QStringList combo; + if (!specFor(index.column(), type, min, max, combo)) { + type = static_cast(EditableTable::ColumnType::kText); + } + + switch (type) { + case static_cast(EditableTable::ColumnType::kInt): { + auto* box = new QSpinBox(parent); + // 用 double 存范围,整型列截断为 int + box->setRange(static_cast(min), static_cast(max)); + return box; + } + case static_cast(EditableTable::ColumnType::kDouble): { + auto* box = new QDoubleSpinBox(parent); + box->setRange(min, max); + box->setDecimals(3); + // 步长随量程(下限 0.1):默认步长 1.0,在 [0,1] 这种小量程上上下键一次就 + // 到顶、根本调不到小数;typing 仍可输任意 3 位小数。 + box->setSingleStep(std::max(0.1, (max - min) / 10.0)); + return box; + } + case static_cast(EditableTable::ColumnType::kCombo): { + auto* combo_box = new QComboBox(parent); + combo_box->addItems(combo); + return combo_box; + } + case static_cast(EditableTable::ColumnType::kCheck): + // 勾选列由 setCellWidget(flags) 处理,不弹编辑器 + return nullptr; + case static_cast(EditableTable::ColumnType::kText): + default: { + auto* edit = new QLineEdit(parent); + return edit; + } + } +} + +void ValidatorDelegate::setEditorData(QWidget* editor, const QModelIndex& index) const { + if (!editor) { + return; + } + const QString value = index.data(Qt::EditRole).toString(); + + if (auto* box = qobject_cast(editor)) { + bool ok = false; + int parsed = value.toInt(&ok); + box->setValue(ok ? parsed : box->minimum()); // 空值落回最小值,避免留空 + return; + } + if (auto* box = qobject_cast(editor)) { + bool ok = false; + double parsed = value.toDouble(&ok); + box->setValue(ok ? parsed : box->minimum()); + return; + } + if (auto* combo = qobject_cast(editor)) { + int idx = combo->findText(value); + combo->setCurrentIndex(idx >= 0 ? idx : 0); // 不在列表里则回到首项 + return; + } + if (auto* edit = qobject_cast(editor)) { + edit->setText(value); + return; + } +} + +void ValidatorDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, + const QModelIndex& index) const { + if (!editor || !model) { + return; + } + + if (auto* box = qobject_cast(editor)) { + // 数值列空值已被 setEditorData 兜成最小值,这里直接写——保证始终合法 + model->setData(index, box->value(), Qt::EditRole); + return; + } + if (auto* box = qobject_cast(editor)) { + model->setData(index, box->value(), Qt::EditRole); + return; + } + if (auto* combo = qobject_cast(editor)) { + model->setData(index, combo->currentText(), Qt::EditRole); + return; + } + if (auto* edit = qobject_cast(editor)) { + const QString text = edit->text(); + // 文本列允许空串;委托不再额外拦截,空值是否非法由业务决定 + model->setData(index, text, Qt::EditRole); + return; + } +} + +void ValidatorDelegate::updateEditorGeometry(QWidget* editor, const QStyleOptionViewItem& option, + const QModelIndex& /*index*/) const { + if (editor) { + editor->setGeometry(option.rect); + } +} + +} // namespace detail + +// ============================================================================ +// EditableTable —— 组合 QTableWidget,列类型驱动委托 +// ============================================================================ + +EditableTable::EditableTable(QWidget* parent) : QWidget(parent) { + table_ = new QTableWidget(this); // parent=this,对象树托管 + table_->setColumnCount(0); + table_->setRowCount(0); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + // 最后一列拉伸填满表格宽度:否则列按内容宽后右侧留大片空白,没填满 viewport + table_->horizontalHeader()->setStretchLastSection(true); + + // 委托按列规格挑编辑器;规格回调把列定义喂给 detail 层(避免环引用) + delegate_ = new detail::ValidatorDelegate(this); + delegate_->setColumnSpecProvider( + [this](int column, int& type, double& min, double& max, QStringList& combo) { + if (column < 0 || column >= columns_.size()) { + return false; + } + const auto& spec = columns_.at(column); + type = columnTypeToInt(spec.type); + min = spec.min; + max = spec.max; + combo = spec.combo; + return true; + }); + table_->setItemDelegate(delegate_); + + // 用函数指针语法连信号槽(禁 SIGNAL/SLOT 宏) + connect(table_, &QTableWidget::cellChanged, this, &EditableTable::onCellChanged); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(table_); +} + +int EditableTable::columnTypeToInt(ColumnType type) const { + return static_cast(type); +} + +void EditableTable::addColumn(const QString& header, ColumnType type, double min, double max, + const QStringList& combo) { + ColumnSpec spec; + spec.header = header; + spec.type = type; + spec.min = min; + spec.max = max; + spec.combo = combo; + columns_.append(spec); + + const int col = table_->columnCount(); + table_->setColumnCount(col + 1); + + // 列标题(QTableWidget 要求先有列再设 header item) + auto* header_item = new QTableWidgetItem(header); + table_->setHorizontalHeaderItem(col, header_item); +} + +void EditableTable::applyCheckState(int row, int col, Qt::CheckState state) { + QTableWidgetItem* item = table_->item(row, col); + if (!item) { + item = new QTableWidgetItem; + table_->setItem(row, col, item); + } + // 勾选列:可用户勾选 + 文本空,让复选框居中显示 + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(state); +} + +void EditableTable::addRow() { + suppress_signal_ = true; // 程序化建项,屏蔽 cellChanged 回灌 + const int row = table_->rowCount(); + table_->setRowCount(row + 1); + + for (int col = 0; col < columns_.size(); ++col) { + const auto& spec = columns_.at(col); + switch (spec.type) { + case ColumnType::kCheck: + applyCheckState(row, col, Qt::Unchecked); + break; + case ColumnType::kCombo: + if (!spec.combo.isEmpty()) { + auto* item = new QTableWidgetItem(spec.combo.first()); + table_->setItem(row, col, item); + } + break; + case ColumnType::kInt: + case ColumnType::kDouble: { + // 默认值取范围下界,保证一建行就合法 + const double default_val = (spec.min <= spec.max) ? spec.min : 0.0; + auto* item = new QTableWidgetItem(QString::number(default_val, 'f', 3)); + table_->setItem(row, col, item); + break; + } + case ColumnType::kText: + default: + table_->setItem(row, col, new QTableWidgetItem(QString())); + break; + } + } + suppress_signal_ = false; +} + +void EditableTable::removeRow(int row) { + if (table_->rowCount() == 0) { + return; // 空表,忽略 + } + if (row < 0 || row >= table_->rowCount()) { + row = table_->rowCount() - 1; // 越界/默认 → 删最后一行 + } + table_->removeRow(row); +} + +void EditableTable::setData(const QVector>& rows) { + suppress_signal_ = true; + table_->setRowCount(0); // 清行但留列 + + const int col_count = columns_.size(); + if (col_count == 0) { + suppress_signal_ = false; + return; // 没列就没法填 + } + + table_->setRowCount(rows.size()); + for (int r = 0; r < rows.size(); ++r) { + const auto& row_data = rows.at(r); + for (int c = 0; c < col_count && c < row_data.size(); ++c) { + const auto& spec = columns_.at(c); + const QVariant& v = row_data.at(c); + + switch (spec.type) { + case ColumnType::kCheck: { + const bool checked = + (v.typeId() == QMetaType::Bool) ? v.toBool() : (v.toInt() != 0); + applyCheckState(r, c, checked ? Qt::Checked : Qt::Unchecked); + break; + } + case ColumnType::kCombo: { + // 不在下拉项里则取首项兜底 + QString text = v.toString(); + if (!spec.combo.contains(text) && !spec.combo.isEmpty()) { + text = spec.combo.first(); + } + table_->setItem(r, c, new QTableWidgetItem(text)); + break; + } + case ColumnType::kInt: { + bool ok = false; + int parsed = v.toInt(&ok); + if (!ok) { + parsed = static_cast(spec.min); + } + const int lo = static_cast(spec.min); + const int hi = static_cast(spec.max); + parsed = std::clamp(parsed, lo, hi); // 越界夹值 + table_->setItem(r, c, new QTableWidgetItem(QString::number(parsed))); + break; + } + case ColumnType::kDouble: { + bool ok = false; + double parsed = v.toDouble(&ok); + if (!ok) { + parsed = spec.min; + } + const double lo = std::min(spec.min, spec.max); + const double hi = std::max(spec.min, spec.max); + parsed = std::clamp(parsed, lo, hi); + table_->setItem(r, c, new QTableWidgetItem(QString::number(parsed, 'f', 3))); + break; + } + case ColumnType::kText: + default: + table_->setItem(r, c, new QTableWidgetItem(v.toString())); + break; + } + } + // 该行某列没给数据(row_data 比 col_count 短)——填占位项,避免 item==null + for (int c = row_data.size(); c < col_count; ++c) { + table_->setItem(r, c, new QTableWidgetItem(QString())); + } + } + suppress_signal_ = false; +} + +QVector> EditableTable::data() const { + QVector> result; + const int row_count = table_->rowCount(); + const int col_count = columns_.size(); + if (col_count == 0 || row_count == 0) { + return result; // 空表 / 无列 → 空 + } + + for (int r = 0; r < row_count; ++r) { + QVector row; + row.reserve(col_count); + for (int c = 0; c < col_count; ++c) { + const auto& spec = columns_.at(c); + const QTableWidgetItem* item = table_->item(r, c); + + if (spec.type == ColumnType::kCheck) { + const Qt::CheckState state = item ? item->checkState() : Qt::Unchecked; + row.append(state == Qt::Checked); + continue; + } + + // 其余类型按 EditRole 文本还原 + const QString text = item ? item->text() : QString(); + switch (spec.type) { + case ColumnType::kInt: { + bool ok = false; + const int parsed = text.toInt(&ok); + row.append(ok ? QVariant(parsed) : QVariant()); + break; + } + case ColumnType::kDouble: { + bool ok = false; + const double parsed = text.toDouble(&ok); + row.append(ok ? QVariant(parsed) : QVariant()); + break; + } + case ColumnType::kCombo: + case ColumnType::kText: + default: + row.append(text); + break; + } + } + result.append(row); + } + return result; +} + +void EditableTable::clear() { + suppress_signal_ = true; + table_->setRowCount(0); // 留列定义 + suppress_signal_ = false; +} + +int EditableTable::currentRow() const { + return table_->currentRow(); +} + +void EditableTable::resizeColumnsToContents() { + table_->resizeColumnsToContents(); +} + +void EditableTable::setEditable(bool editable) { + if (editable_ == editable) { + return; + } + editable_ = editable; + // 切编辑触发:只读 vs 双击/选中/回车均可编辑 + table_->setEditTriggers( + editable_ ? (QAbstractItemView::DoubleClicked | QAbstractItemView::SelectedClicked | + QAbstractItemView::EditKeyPressed | QAbstractItemView::AnyKeyPressed) + : QAbstractItemView::NoEditTriggers); + emit editableChanged(editable_); +} + +bool EditableTable::isEditable() const { + return editable_; +} + +void EditableTable::setGridVisible(bool visible) { + if (table_->showGrid() == visible) { + return; + } + table_->setShowGrid(visible); + emit gridVisibleChanged(visible); +} + +bool EditableTable::gridVisible() const { + return table_->showGrid(); +} + +void EditableTable::setAlternatingRowColors(bool enabled) { + if (table_->alternatingRowColors() == enabled) { + return; + } + table_->setAlternatingRowColors(enabled); + emit alternatingRowColorsChanged(enabled); +} + +bool EditableTable::alternatingRowColors() const { + return table_->alternatingRowColors(); +} + +QSize EditableTable::sizeHint() const { + return QSize(360, 240); +} + +void EditableTable::onCellChanged(int row, int col) { + if (suppress_signal_) { + return; // setData/addRow 程序化改动,不回灌 + } + if (row < 0 || row >= table_->rowCount() || col < 0 || col >= columns_.size()) { + return; // 边界 clamp + } + const QTableWidgetItem* item = table_->item(row, col); + if (!item) { + return; + } + const auto& spec = columns_.at(col); + QVariant value; + if (spec.type == ColumnType::kCheck) { + value = item->checkState() == Qt::Checked; + } else { + value = item->text(); + } + emit dataEdited(row, col, value); +} + +} // namespace AwesomeQt diff --git a/widget/fade-animation/CMakeLists.txt b/widget/fade-animation/CMakeLists.txt new file mode 100644 index 0000000..fba286d --- /dev/null +++ b/widget/fade-animation/CMakeLists.txt @@ -0,0 +1,19 @@ +# FadeWidget 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(fade_animation STATIC + include/fade_animation.h + src/fade_animation.cpp +) + +target_include_directories(fade_animation PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(fade_animation PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/fade-animation/demo/CMakeLists.txt b/widget/fade-animation/demo/CMakeLists.txt new file mode 100644 index 0000000..3d14a1b --- /dev/null +++ b/widget/fade-animation/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# FadeWidget 演示程序:淡入/淡出按钮 + 时长滑块 + 瞬时透明度滑块 + +qt_add_executable(fade_animation_demo + main.cpp + fade_animation_window.cpp + fade_animation_window.h +) + +target_link_libraries(fade_animation_demo PRIVATE + fade_animation +) diff --git a/widget/fade-animation/demo/fade_animation_window.cpp b/widget/fade-animation/demo/fade_animation_window.cpp new file mode 100644 index 0000000..cc0fbfb --- /dev/null +++ b/widget/fade-animation/demo/fade_animation_window.cpp @@ -0,0 +1,142 @@ +/** + * @file fade_animation_window.cpp + * @brief FadeWidget 控件演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "fade_animation_window.h" + +#include "fade_animation.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AwesomeQt { + +FadeAnimationWindow::FadeAnimationWindow(QWidget* parent) : QMainWindow(parent) { + setup_ui(); +} + +void FadeAnimationWindow::setup_ui() { + auto* central = new QWidget(this); + auto* root = new QVBoxLayout(central); + root->setContentsMargins(12, 12, 12, 12); + root->setSpacing(10); + + root->addWidget(build_fade_group(), /*stretch=*/1); + root->addWidget(build_duration_group()); + root->addWidget(build_opacity_group()); + root->addStretch(); + + setCentralWidget(central); + setWindowTitle(QStringLiteral("FadeWidget Demo")); + resize(420, 360); +} + +QWidget* FadeAnimationWindow::build_fade_group() { + auto* box = new QGroupBox(QStringLiteral("淡入 / 淡出"), this); + auto* layout = new QVBoxLayout(box); + + // 待演示容器:内部放带色面板 + 文字,证明"内容跟着淡"。 + fade_widget_ = new FadeWidget(box); + fade_widget_->setMinimumHeight(80); + + auto* content = new QFrame(fade_widget_); + content->setStyleSheet(QStringLiteral("background-color:#2d7d9a; border-radius:6px;")); + content->setFrameShape(QFrame::NoFrame); + auto* content_layout = new QVBoxLayout(content); + content_layout->setContentsMargins(12, 12, 12, 12); + auto* label = new QLabel( + QStringLiteral("这是一段会被一起淡入淡出的内容。\nQGraphicsOpacityEffect 挂在容器上。"), + content); + label->setStyleSheet(QStringLiteral("color:white; font-size:13px;")); + label->setWordWrap(true); + content_layout->addWidget(label); + + auto* container_layout = new QVBoxLayout(fade_widget_); + container_layout->setContentsMargins(0, 0, 0, 0); + container_layout->addWidget(content); + + layout->addWidget(fade_widget_); + + auto* btn_row = new QHBoxLayout(); + auto* fade_in_btn = new QPushButton(QStringLiteral("Fade In"), box); + auto* fade_out_btn = new QPushButton(QStringLiteral("Fade Out"), box); + btn_row->addWidget(fade_in_btn); + btn_row->addWidget(fade_out_btn); + layout->addLayout(btn_row); + + QObject::connect(fade_in_btn, &QPushButton::clicked, box, + [this]() { fade_widget_->fadeIn(fade_widget_->fadeDuration()); }); + QObject::connect(fade_out_btn, &QPushButton::clicked, box, + [this]() { fade_widget_->fadeOut(fade_widget_->fadeDuration()); }); + + return box; +} + +QWidget* FadeAnimationWindow::build_duration_group() { + auto* box = new QGroupBox(QStringLiteral("fadeDuration(淡入淡出时长)"), this); + auto* layout = new QHBoxLayout(box); + + auto* slider = new QSlider(Qt::Horizontal, box); + slider->setRange(100, 1500); + slider->setValue(fade_widget_->fadeDuration()); + + auto* value_label = new QLabel(QStringLiteral("%1 ms").arg(fade_widget_->fadeDuration()), box); + value_label->setMinimumWidth(70); + + layout->addWidget(slider, /*stretch=*/1); + layout->addWidget(value_label); + + // 同步:滑块 → setFadeDuration → 回填标签。setFadeDuration 内部 clamp 兜底。 + QObject::connect(slider, &QSlider::valueChanged, box, [this, value_label](int value) { + fade_widget_->setFadeDuration(value); + value_label->setText(QStringLiteral("%1 ms").arg(fade_widget_->fadeDuration())); + }); + + return box; +} + +QWidget* FadeAnimationWindow::build_opacity_group() { + auto* box = + new QGroupBox(QStringLiteral("瞬时 opacity(绕过动画,证明是真 Q_PROPERTY)"), this); + auto* layout = new QHBoxLayout(box); + + auto* slider = new QSlider(Qt::Horizontal, box); + // 滑块整型 0..100,映射到 0.0..1.0。 + slider->setRange(0, 100); + slider->setValue(static_cast(fade_widget_->opacity() * 100)); + + auto* value_label = new QLabel(QString::number(fade_widget_->opacity(), 'f', 2), box); + value_label->setMinimumWidth(50); + + layout->addWidget(slider, /*stretch=*/1); + layout->addWidget(value_label); + + // 直接 setOpacity:纯赋值 + update,无动画,证明 opacity 是可外部直接驱动的 Q_PROPERTY。 + QObject::connect(slider, &QSlider::valueChanged, box, [this, value_label](int value) { + fade_widget_->setOpacity(value / 100.0); + value_label->setText(QString::number(fade_widget_->opacity(), 'f', 2)); + }); + + // 反向同步:动画运行时也会改 opacity,标签跟着走。 + QObject::connect(fade_widget_, &FadeWidget::opacityChanged, box, + [slider, value_label](qreal opacity) { + value_label->setText(QString::number(opacity, 'f', 2)); + const int pct = static_cast(opacity * 100); + if (slider->value() != pct) { + QSignalBlocker blocker(slider); // 防止回灌触发再次 setOpacity + slider->setValue(pct); + } + }); + + return box; +} + +} // namespace AwesomeQt diff --git a/widget/fade-animation/demo/fade_animation_window.h b/widget/fade-animation/demo/fade_animation_window.h new file mode 100644 index 0000000..b803c29 --- /dev/null +++ b/widget/fade-animation/demo/fade_animation_window.h @@ -0,0 +1,28 @@ +/** + * @file fade_animation_window.h + * @brief FadeWidget 控件演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#pragma once + +#include + +namespace AwesomeQt { + +class FadeWidget; + +class FadeAnimationWindow : public QMainWindow { + public: + explicit FadeAnimationWindow(QWidget* parent = nullptr); + + private: + void setup_ui(); + QWidget* build_fade_group(); + QWidget* build_duration_group(); + QWidget* build_opacity_group(); + + FadeWidget* fade_widget_{nullptr}; +}; + +} // namespace AwesomeQt diff --git a/widget/fade-animation/demo/main.cpp b/widget/fade-animation/demo/main.cpp new file mode 100644 index 0000000..0e1e5d6 --- /dev/null +++ b/widget/fade-animation/demo/main.cpp @@ -0,0 +1,18 @@ +/** + * @file main.cpp + * @brief FadeWidget demo 入口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include + +#include "fade_animation_window.h" + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + + AwesomeQt::FadeAnimationWindow w; + w.show(); + + return app.exec(); +} diff --git a/widget/fade-animation/include/fade_animation.h b/widget/fade-animation/include/fade_animation.h new file mode 100644 index 0000000..8cda789 --- /dev/null +++ b/widget/fade-animation/include/fade_animation.h @@ -0,0 +1,77 @@ +/** + * @file fade_animation.h + * @brief 淡入淡出容器控件 FadeWidget——QGraphicsOpacityEffect + QPropertyAnimation + * @copyright Copyright (c) 2026 AwesomeQt + * + * 一个可把自身(及其内容)整体淡入/淡出的容器。用一个 QGraphicsOpacityEffect + * 挂在自身,由 QPropertyAnimation 驱动 effect 的 "opacity" 属性在 0↔1 间过渡, + * 实现平滑淡入淡出。常用于通知条 / 启动画面 / 视图切换过渡。 + */ +#pragma once + +#include + +class QGraphicsOpacityEffect; +class QPropertyAnimation; + +namespace AwesomeQt { + +/// @brief 淡入淡出容器:透明度由 QGraphicsOpacityEffect 承载,动画驱动其 opacity。 +/// +/// 设计要点: +/// - `opacity` 是 Q_PROPERTY,WRITE 仅做纯赋值 + 触发 update,本身不调动画; +/// fadeIn/fadeOut 才是驱动动画的入口;外部也可直接 setOpacity 绕过动画瞬时设值。 +/// - `fade_anim_` 为持久成员指针(parent=this 托管),stop()/setStartValue/ +/// setEndValue/start() 复用,避免连发动画时悬空(同 StatusLED 教训,禁 DeleteWhenStopped)。 +/// - fadeOut 完成后默认隐藏自身(连 finished 信号),便于"消失"语义。 +/// - 边界:duration<1 兜底成 1,opacity 始终夹到 [0,1]。 +class FadeWidget : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:opacity / fadeDuration 均可被 Designer / 外部直接驱动 —— + Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity NOTIFY opacityChanged) + Q_PROPERTY(int fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY fadeDurationChanged) + + public: + explicit FadeWidget(QWidget* parent = nullptr); + + /// @brief 淡入:opacity 从当前值(一般 0)过渡到 1,动画结束后确保可见。 + /// @param duration_ms 过渡时长(毫秒),<1 兜底成 1。 + void fadeIn(int duration_ms = 300); + + /// @brief 淡出:opacity 从当前值过渡到 0,动画结束后隐藏自身。 + /// @param duration_ms 过渡时长(毫秒),<1 兜底成 1。 + void fadeOut(int duration_ms = 300); + + /// @brief 设置默认淡入淡出时长(毫秒),供不带参的调用使用。<1 兜底成 1。 + void setFadeDuration(int ms); + int fadeDuration() const; + + /// @brief 当前透明度(0..1,等于挂载 effect 的 opacity)。动画运行时返回实时中间值。 + qreal opacity() const; + + /// @brief 瞬时设置透明度(不启动动画)。这是 Q_PROPERTY(opacity) 的 WRITE 回调, + /// 供外部滑块或 QPropertyAnimation 直接驱动;调它不会触发淡入淡出动画。 + /// @param value 目标透明度,夹到 [0,1]。 + void setOpacity(qreal value); + + QSize sizeHint() const override; + + signals: + void opacityChanged(qreal opacity); + void fadeDurationChanged(int ms); + /// @brief 一次淡入/淡出动画走完(无论目标值)发出。fadeOut 后由此触发 hide()。 + void fadeFinished(bool faded_out); + + private: + /// @brief 配置并启动一次动画:从当前 opacity 接力到 end,时长 duration_ms。 + void runFade(qreal end, int duration_ms); + + QGraphicsOpacityEffect* effect_{nullptr}; // 透明度承载,构造期 new + setGraphicsEffect(this) + QPropertyAnimation* fade_anim_{ + nullptr}; // 持久动画指针,stop/重配/start 复用,非 DeleteWhenStopped + int fade_duration_ms_{300}; // 默认淡入淡出时长 + bool fading_out_{false}; // 标记当前是否在淡出,供 finished 回调决定是否 hide +}; + +} // namespace AwesomeQt diff --git a/widget/fade-animation/src/fade_animation.cpp b/widget/fade-animation/src/fade_animation.cpp new file mode 100644 index 0000000..19cf020 --- /dev/null +++ b/widget/fade-animation/src/fade_animation.cpp @@ -0,0 +1,99 @@ +/** + * @file fade_animation.cpp + * @brief 淡入淡出容器控件 FadeWidget 实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "fade_animation.h" + +#include +#include + +namespace AwesomeQt { + +FadeWidget::FadeWidget(QWidget* parent) : QWidget(parent) { + // 透明度承载:构造期 new 并挂在自身,初值完全不透明。 + effect_ = new QGraphicsOpacityEffect(this); + effect_->setOpacity(1.0); + setGraphicsEffect(effect_); + + // 持久动画指针:驱动 effect 的 "opacity" 属性,parent=this 托管,循环复用。 + fade_anim_ = new QPropertyAnimation(effect_, "opacity", this); + fade_anim_->setEasingCurve(QEasingCurve::OutCubic); + QObject::connect(fade_anim_, &QPropertyAnimation::finished, this, [this]() { + // fadeOut 走完后隐藏自身,营造"消失"语义;fadeIn 走完则保持可见。 + if (fading_out_) { + hide(); + } + emit fadeFinished(fading_out_); + }); +} + +void FadeWidget::fadeIn(int duration_ms) { + if (!isVisible()) { + // 真正的淡入起点:先置全透明再显示,否则会"先蹦出来再淡"。 + effect_->setOpacity(0.0); + show(); + } + fading_out_ = false; + runFade(1.0, duration_ms); +} + +void FadeWidget::fadeOut(int duration_ms) { + // 必须先可见才能看到淡出过程。 + if (!isVisible()) { + show(); + } + fading_out_ = true; + runFade(0.0, duration_ms); +} + +void FadeWidget::setFadeDuration(int ms) { + if (ms < 1) { + ms = 1; // 兜底,避免 0 或负时长导致动画不启动/除零语义混乱 + } + if (ms == fade_duration_ms_) { + return; + } + fade_duration_ms_ = ms; + emit fadeDurationChanged(ms); +} + +int FadeWidget::fadeDuration() const { + return fade_duration_ms_; +} + +qreal FadeWidget::opacity() const { + return effect_->opacity(); +} + +void FadeWidget::setOpacity(qreal value) { + if (value < 0.0) { + value = 0.0; + } else if (value > 1.0) { + value = 1.0; + } + if (qFuzzyCompare(value, effect_->opacity())) { + return; + } + effect_->setOpacity(value); // 纯赋值,effect 自带 update,不触发动画 + emit opacityChanged(value); +} + +QSize FadeWidget::sizeHint() const { + return QSize(200, 120); +} + +void FadeWidget::runFade(qreal end, int duration_ms) { + if (duration_ms < 1) { + duration_ms = 1; + } + // 复用同一指针:先停再配起点(取当前实时 opacity,做到接力争不跳变)。 + fade_anim_->stop(); + fade_anim_->setDuration(duration_ms); + fade_anim_->setStartValue(effect_->opacity()); + fade_anim_->setEndValue(end); + fade_anim_->start(); +} + +} // namespace AwesomeQt diff --git a/widget/ip-edit/CMakeLists.txt b/widget/ip-edit/CMakeLists.txt new file mode 100644 index 0000000..8849bae --- /dev/null +++ b/widget/ip-edit/CMakeLists.txt @@ -0,0 +1,19 @@ +# IpEdit 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(ip_edit STATIC + include/ip_edit.h + src/ip_edit.cpp +) + +target_include_directories(ip_edit PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(ip_edit PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/ip-edit/demo/CMakeLists.txt b/widget/ip-edit/demo/CMakeLists.txt new file mode 100644 index 0000000..8f65f0b --- /dev/null +++ b/widget/ip-edit/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# IpEdit 演示程序:输入 + 回显 + 预设 + 清空 + 校验 + +qt_add_executable(ip_edit_demo + main.cpp + ip_edit_window.cpp + ip_edit_window.h +) + +target_link_libraries(ip_edit_demo PRIVATE + ip_edit +) diff --git a/widget/ip-edit/demo/ip_edit_window.cpp b/widget/ip-edit/demo/ip_edit_window.cpp new file mode 100644 index 0000000..ddfe758 --- /dev/null +++ b/widget/ip-edit/demo/ip_edit_window.cpp @@ -0,0 +1,147 @@ +/** + * @file ip_edit_window.cpp + * @brief IpEdit 演示主窗口实现 + * @copyright Copyright (c) 2026 + */ + +#include "ip_edit_window.h" + +#include "ip_edit.h" + +#include +#include +#include +#include +#include + +IpEditWindow::IpEditWindow(QWidget* parent) : QMainWindow(parent) { + setup_ui(); +} + +QWidget* IpEditWindow::setup_input_layout() { + auto* group = new QGroupBox("IPv4 输入(满 3 位自动跳焦 / 按 . 跳段 / 段首退格回上段)"); + auto* layout = new QVBoxLayout(group); + + auto* ip_edit = new AwesomeQt::IpEdit(group); + layout->addWidget(ip_edit); + + // —— 显示值:回显 text() + isValid() —— + auto* echo_row = new QHBoxLayout(); + auto* echo_label = new QLabel("当前值:(未填写)", group); + echo_label->setMinimumWidth(280); + auto* show_btn = new QPushButton("显示值", group); + + connect(show_btn, &QPushButton::clicked, group, [ip_edit, echo_label]() { + const QString val = ip_edit->text(); + const QString valid = ip_edit->isValid() ? "合法" : "非法"; + echo_label->setText(QString("当前值:%1 [%2]").arg(val, valid)); + }); + + // —— textChanged 实时联动(输入即更新回显)—— + connect(ip_edit, &AwesomeQt::IpEdit::textChanged, group, [echo_label](const QString& addr) { + echo_label->setText(QString("当前值:%1").arg(addr)); + }); + + // —— editingFinished(末段回车/失焦)提示 —— + auto* finished_label = new QLabel("(editingFinished 未触发)", group); + connect(ip_edit, &AwesomeQt::IpEdit::editingFinished, group, [finished_label]() { + finished_label->setText("editingFinished 触发!(末段回车/失焦)"); + }); + + echo_row->addWidget(echo_label); + echo_row->addWidget(show_btn); + echo_row->addStretch(); + layout->addLayout(echo_row); + layout->addWidget(finished_label); + + return group; +} + +QWidget* IpEditWindow::setup_presets_layout() { + auto* group = new QGroupBox("预设地址 + 清空"); + auto* layout = new QHBoxLayout(group); + + // 预设按钮直接 new 一个独立 IpEdit 演示 setText + auto* ip_edit = new AwesomeQt::IpEdit(group); + layout->addWidget(ip_edit); + + auto* b1 = new QPushButton("192.168.1.1", group); + auto* b2 = new QPushButton("10.0.0.1", group); + auto* clear_btn = new QPushButton("清空", group); + + connect(b1, &QPushButton::clicked, group, + [ip_edit]() { ip_edit->setText(QStringLiteral("192.168.1.1")); }); + connect(b2, &QPushButton::clicked, group, + [ip_edit]() { ip_edit->setText(QStringLiteral("10.0.0.1")); }); + connect(clear_btn, &QPushButton::clicked, group, [ip_edit]() { ip_edit->clear(); }); + + layout->addWidget(b1); + layout->addWidget(b2); + layout->addWidget(clear_btn); + layout->addStretch(); + + return group; +} + +QWidget* IpEditWindow::setup_edgecases_layout() { + auto* group = new QGroupBox("边界校验(点按钮模拟程序化 setText)"); + auto* layout = new QVBoxLayout(group); + + auto* hint = new QLabel("用预设按钮塞越界/不足段值,观察 clamp 与补 0:", group); + layout->addWidget(hint); + + auto* ip_edit = new AwesomeQt::IpEdit(group); + layout->addWidget(ip_edit); + + auto* result_label = new QLabel("结果:—", group); + layout->addWidget(result_label); + + auto* row = new QHBoxLayout(); + auto* b999 = new QPushButton("setText(\"999.1.1.1\")", group); // 越界 → 夹 255 + auto* b3 = new QPushButton("setText(\"1.2.3\")", group); // 不足段 → 补 0 + auto* babc = new QPushButton("setText(\"a.b.c\")", group); // 非数字 → 补 0 + auto* bempty = new QPushButton("setText(\"\")", group); // 空串 → 全清 + auto* bshow = new QPushButton("显示值", group); + + connect(b999, &QPushButton::clicked, group, + [ip_edit]() { ip_edit->setText(QStringLiteral("999.1.1.1")); }); + connect(b3, &QPushButton::clicked, group, + [ip_edit]() { ip_edit->setText(QStringLiteral("1.2.3")); }); + connect(babc, &QPushButton::clicked, group, + [ip_edit]() { ip_edit->setText(QStringLiteral("a.b.c")); }); + connect(bempty, &QPushButton::clicked, group, [ip_edit]() { ip_edit->setText(QString()); }); + connect(bshow, &QPushButton::clicked, group, [ip_edit, result_label]() { + const QString valid = ip_edit->isValid() ? "合法" : "非法"; + result_label->setText(QString("结果:%1 [%2]").arg(ip_edit->text(), valid)); + }); + + row->addWidget(b999); + row->addWidget(b3); + row->addWidget(babc); + row->addWidget(bempty); + row->addWidget(bshow); + row->addStretch(); + layout->addLayout(row); + + // 提示:子 QLineEdit 直接输 abc 会被 QIntValidator 拒绝(非数字根本输不进) + auto* note = new QLabel( + "注:直接在段里敲字母会被 QIntValidator 拒绝(输不进);这里只测程序化 setText。", group); + note->setStyleSheet("color: gray;"); + layout->addWidget(note); + + return group; +} + +void IpEditWindow::setup_ui() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* layout = new QVBoxLayout(central); + layout->addWidget(setup_input_layout()); + layout->addWidget(setup_presets_layout()); + layout->addWidget(setup_edgecases_layout()); + layout->addStretch(); + + setWindowTitle("IpEdit Widget Demo"); + resize(520, 320); +} diff --git a/widget/ip-edit/demo/ip_edit_window.h b/widget/ip-edit/demo/ip_edit_window.h new file mode 100644 index 0000000..890a697 --- /dev/null +++ b/widget/ip-edit/demo/ip_edit_window.h @@ -0,0 +1,22 @@ +/** + * @file ip_edit_window.h + * @brief IpEdit 控件演示主窗口 + * @copyright Copyright (c) 2026 + */ + +#pragma once + +#include + +class QLabel; + +class IpEditWindow : public QMainWindow { + public: + explicit IpEditWindow(QWidget* parent = nullptr); + + private: + void setup_ui(); + QWidget* setup_input_layout(); + QWidget* setup_presets_layout(); + QWidget* setup_edgecases_layout(); +}; diff --git a/widget/ip-edit/demo/main.cpp b/widget/ip-edit/demo/main.cpp new file mode 100644 index 0000000..38fd94e --- /dev/null +++ b/widget/ip-edit/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief IpEdit 演示程序入口 + * @copyright Copyright (c) 2026 + */ + +#include "ip_edit_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + IpEditWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/ip-edit/include/ip_edit.h b/widget/ip-edit/include/ip_edit.h new file mode 100644 index 0000000..7421b36 --- /dev/null +++ b/widget/ip-edit/include/ip_edit.h @@ -0,0 +1,83 @@ +/** + * @file ip_edit.h + * @brief IPv4 地址输入控件 IpEdit——4 个八位段 QLineEdit + 点分隔 + 自动跳焦 + 0-255 校验 + * @copyright Copyright (c) 2026 + */ +#pragma once + +#include +#include +#include +#include + +namespace AwesomeQt { + +/// @brief IPv4 地址输入控件:4 个八位段用点分隔,自动跳焦 + 0-255 校验。 +/// +/// 设计要点: +/// - 4 个 QLineEdit(octets_[0..3])+ 3 个 QLabel(".") 分隔,QHBoxLayout 排开; +/// - 每段 maxLength(3) + 居中对齐 + QIntValidator(0,255) 双保险; +/// - 焦点流转三规则:满 3 位合法自动跳下段、按 '.' 跳下段(消费掉点)、段首退格跳上段; +/// - text() 拼成 "a.b.c.d",空段补 0;setText 越界段夹到 0-255、缺段补 0、空串全清。 +class IpEdit : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:placeholderHint 可被 Designer 驱动 —— + Q_PROPERTY(QString placeholderHint READ placeholderHint WRITE setPlaceholderHint NOTIFY + placeholderHintChanged) + + public: + /// @brief 八位段数量(IPv4 固定 4 段) + static constexpr int kOctetCount = 4; + + explicit IpEdit(QWidget* parent = nullptr); + + /// @brief 取当前地址("a.b.c.d",空段补 0) + QString text() const; + + /// @brief 设置地址(按点拆分填入,越界段夹 0-255、缺段补 0、空串全清) + /// @param ip 形如 "192.168.1.1" 的 IPv4 字符串 + void setText(const QString& ip); + + /// @brief 4 段都 0-255 且非全空才算合法 + bool isValid() const; + + /// @brief 清空所有 4 段 + void clear(); + + /// @brief 占位提示文字(同步下发到 4 个子 QLineEdit) + QString placeholderHint() const; + + /// @brief 设置占位提示文字 + void setPlaceholderHint(const QString& hint); + + QSize sizeHint() const override; + + signals: + /// @brief 任意段文本变化时发出(外部回显用) + void textChanged(const QString& fullAddress); + /// @brief 末段回车或整体失焦时发出(同 QLineEdit::editingFinished 语义) + void editingFinished(); + /// @brief 占位提示文字变化时发出 + void placeholderHintChanged(const QString& hint); + + protected: + /// @brief 拦截 '.' / BackSpace 做跨段跳焦(消费掉点号、段首退格回上段) + void keyPressEvent(QKeyEvent* event) override; + + private: + /// @brief 段失焦:若是末段则发 editingFinished(扩展位) + void onOctetEditingFinished(); + /// @brief 聚焦到下一段(存在则 setFocus,返回是否成功) + bool focusNextOctet(int from_index); + /// @brief 聚焦到上一段并把光标移到段末(用于段首退格回跳) + bool focusPrevOctet(int from_index); + /// @brief 取段索引对应 QLineEdit 指针(带边界保护,越界返回 nullptr) + QLineEdit* octet(int index) const; + + QLineEdit* octets_[kOctetCount]{}; // 4 个八位段(对象树托管,parent=this) + QLabel* dots_[kOctetCount - 1]{}; // 3 个点分隔符 + QString placeholder_hint_; +}; + +} // namespace AwesomeQt diff --git a/widget/ip-edit/src/ip_edit.cpp b/widget/ip-edit/src/ip_edit.cpp new file mode 100644 index 0000000..f37754b --- /dev/null +++ b/widget/ip-edit/src/ip_edit.cpp @@ -0,0 +1,294 @@ +/** + * @file ip_edit.cpp + * @brief IpEdit 控件实现——4 段 QLineEdit 跳焦 + 0-255 校验 + * @copyright Copyright (c) 2026 + */ + +#include "ip_edit.h" + +#include // std::clamp + +#include +#include +#include +#include + +namespace AwesomeQt { + +// ============================================================================ +// 构造:4 个 QLineEdit + 3 个点,QHBoxLayout 交错排列 +// ============================================================================ +IpEdit::IpEdit(QWidget* parent) : QWidget(parent) { + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto* validator = new QIntValidator(0, 255, this); // 0-255 双保险(text() 也会校验) + + for (int i = 0; i < kOctetCount; ++i) { + auto* edit = new QLineEdit(this); + edit->setMaxLength(3); + edit->setAlignment(Qt::AlignCenter); + edit->setValidator(validator); + edit->setFixedWidth(40); // 容下 3 位数 + 余量 + octets_[i] = edit; + + // 段文本变化:满 3 位合法自动跳下段(textEdited = 用户输入触发) + connect(edit, &QLineEdit::textEdited, this, [this, i](const QString& t) { + if (t.length() >= 3) { + bool ok = false; + const int val = t.toInt(&ok); + if (ok && val >= 0 && val <= 255) { + focusNextOctet(i); // 满 3 位合法 → 跳下一段 + } + } + }); + // 用 textChanged 兜底(程序 setText 也要发 textChanged) + connect(edit, &QLineEdit::textChanged, this, + [this](const QString&) { emit textChanged(text()); }); + // 段回车 / 失焦:末段发 editingFinished + connect(edit, &QLineEdit::editingFinished, this, [this, i]() { + if (i == kOctetCount - 1) { + emit editingFinished(); + } + }); + + layout->addWidget(edit); + + // 段间插点(最后一段后不插) + if (i < kOctetCount - 1) { + auto* dot = new QLabel(".", this); + dot->setAlignment(Qt::AlignCenter); + dot->setFixedWidth(8); + dots_[i] = dot; + layout->addWidget(dot); + } + } + + layout->addStretch(); // 余下空间右伸,控件左对齐 + setFocusProxy(octets_[0]); // Tab 进入控件时聚焦第一段 + setFocusPolicy(Qt::StrongFocus); +} + +// ============================================================================ +// 取地址:拼 "a.b.c.d",空段补 0 +// ============================================================================ +QString IpEdit::text() const { + QStringList parts; + for (int i = 0; i < kOctetCount; ++i) { + const QString raw = octet(i) ? octet(i)->text() : QString(); + bool ok = false; + const int val = raw.toInt(&ok); + parts.append(ok ? QString::number(val) : QStringLiteral("0")); + } + return parts.join('.'); +} + +// ============================================================================ +// 设置地址:按点拆分,越界段夹 0-255、缺段补 0、空串全清 +// ============================================================================ +void IpEdit::setText(const QString& ip) { + const QStringList parts = ip.split('.', Qt::SkipEmptyParts); + // 空串 → 全清 + if (ip.isEmpty()) { + clear(); + return; + } + + for (int i = 0; i < kOctetCount; ++i) { + QLineEdit* edit = octet(i); + if (!edit) { + continue; + } + // blockSignals 避免每段 setText 各发一次 textChanged;最后统一发一次 + QSignalBlocker blocker(edit); + if (i < parts.size()) { + bool ok = false; + int val = parts[i].toInt(&ok); + if (!ok) { + val = 0; // 非数字段补 0 + } + val = std::clamp(val, 0, 255); // 越界(如 999)夹到 0-255 + edit->setText(QString::number(val)); + } else { + edit->setText(QStringLiteral("0")); // 缺段补 0 + } + } + emit textChanged(text()); // 统一发一次 +} + +// ============================================================================ +// 校验:4 段都 0-255 且非全空 +// ============================================================================ +bool IpEdit::isValid() const { + bool any = false; + for (int i = 0; i < kOctetCount; ++i) { + const QLineEdit* edit = octet(i); + if (!edit) { + return false; + } + const QString raw = edit->text(); + if (raw.isEmpty()) { + return false; // 任一段空即非法 + } + bool ok = false; + const int val = raw.toInt(&ok); + if (!ok || val < 0 || val > 255) { + return false; + } + if (val != 0) { + any = true; + } + } + return any; // 全 0(如 "0.0.0.0")视为未填写,不合法 +} + +// ============================================================================ +// 清空所有 4 段 +// ============================================================================ +void IpEdit::clear() { + for (int i = 0; i < kOctetCount; ++i) { + QLineEdit* edit = octet(i); + if (!edit) { + continue; + } + QSignalBlocker blocker(edit); + edit->clear(); + } + emit textChanged(text()); +} + +// ============================================================================ +// 占位提示 +// ============================================================================ +QString IpEdit::placeholderHint() const { + return placeholder_hint_; +} + +void IpEdit::setPlaceholderHint(const QString& hint) { + if (placeholder_hint_ == hint) { + return; + } + placeholder_hint_ = hint; + for (int i = 0; i < kOctetCount; ++i) { + if (QLineEdit* edit = octet(i)) { + edit->setPlaceholderText(hint); + } + } + emit placeholderHintChanged(hint); +} + +// ============================================================================ +// 尺寸 +// ============================================================================ +QSize IpEdit::sizeHint() const { + return QSize(240, 32); +} + +// ============================================================================ +// 段失焦:末段发 editingFinished(editingFinished signal 已连,这里留扩展位) +// ============================================================================ +void IpEdit::onOctetEditingFinished() { + // 由各段 editingFinished signal 直接处理,此处保留给派生扩展 +} + +// ============================================================================ +// 按键拦截:'.' 跳下段(消费掉点);段首 BackSpace 跳上段 +// ============================================================================ +void IpEdit::keyPressEvent(QKeyEvent* event) { + // 找到当前聚焦段索引 + int current = -1; + for (int i = 0; i < kOctetCount; ++i) { + if (octet(i) && octet(i)->hasFocus()) { + current = i; + break; + } + } + if (current < 0) { + QWidget::keyPressEvent(event); + return; + } + + QLineEdit* edit = octet(current); + + switch (event->key()) { + case Qt::Key_Period: + case Qt::Key_Comma: // 兼容小键盘/中文输入习惯 + // 按 '.' → 跳下一段,消费掉这个点(不输入) + if (current < kOctetCount - 1) { + focusNextOctet(current); + event->accept(); + return; + } + // 末段按点 → 当作回车发 editingFinished + if (current == kOctetCount - 1) { + emit editingFinished(); + event->accept(); + return; + } + break; + + case Qt::Key_Backspace: + // 段首退格且段空 → 跳上段(让它继续删上段末位) + if (edit && edit->text().isEmpty() && edit->cursorPosition() == 0 && current > 0) { + QLineEdit* prev = octet(current - 1); + if (prev) { + prev->setFocus(); + prev->setCursorPosition(prev->text().length()); // 光标置段末 + prev->backspace(); // 删上段末位 + } + event->accept(); + return; + } + break; + + case Qt::Key_Return: + case Qt::Key_Enter: + // 任意段回车:非末段跳下段,末段发 editingFinished + if (current < kOctetCount - 1) { + focusNextOctet(current); + event->accept(); + return; + } + emit editingFinished(); + event->accept(); + return; + + default: + break; + } + + QWidget::keyPressEvent(event); // 其余键走默认(含 Tab 由焦点链处理) +} + +// ============================================================================ +// 焦点流转辅助 +// ============================================================================ +bool IpEdit::focusNextOctet(int from_index) { + QLineEdit* next = octet(from_index + 1); + if (!next) { + return false; + } + next->setFocus(); + next->selectAll(); // 进入即全选,便于覆盖输入 + return true; +} + +bool IpEdit::focusPrevOctet(int from_index) { + QLineEdit* prev = octet(from_index - 1); + if (!prev) { + return false; + } + prev->setFocus(); + prev->setCursorPosition(prev->text().length()); + return true; +} + +QLineEdit* IpEdit::octet(int index) const { + if (index < 0 || index >= kOctetCount) { + return nullptr; // 边界保护:越界返回空指针 + } + return octets_[index]; +} + +} // namespace AwesomeQt diff --git a/widget/line-chart/CMakeLists.txt b/widget/line-chart/CMakeLists.txt new file mode 100644 index 0000000..c5b97cc --- /dev/null +++ b/widget/line-chart/CMakeLists.txt @@ -0,0 +1,19 @@ +# LineChart 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(line_chart STATIC + include/line_chart.h + src/line_chart.cpp +) + +target_include_directories(line_chart PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(line_chart PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/line-chart/demo/CMakeLists.txt b/widget/line-chart/demo/CMakeLists.txt new file mode 100644 index 0000000..5806678 --- /dev/null +++ b/widget/line-chart/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# LineChart 演示程序:静态数据 + 追加随机点 + 重置 + 外观开关 + +qt_add_executable(line_chart_demo + main.cpp + line_chart_window.cpp + line_chart_window.h +) + +target_link_libraries(line_chart_demo PRIVATE + line_chart +) diff --git a/widget/line-chart/demo/line_chart_window.cpp b/widget/line-chart/demo/line_chart_window.cpp new file mode 100644 index 0000000..e69c18b --- /dev/null +++ b/widget/line-chart/demo/line_chart_window.cpp @@ -0,0 +1,119 @@ +/** + * @file line_chart_window.cpp + * @brief LineChart 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "line_chart_window.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "line_chart.h" + +LineChartWindow::LineChartWindow(QWidget* parent) : QMainWindow(parent) { + setup_ui(); +} + +// —— 静态一组预设数据:展示基本折线 + Y auto-scale —— +QWidget* LineChartWindow::setup_static_layout() { + auto* group = new QGroupBox("Static Data (Y auto-scale)", this); + auto* layout = new QVBoxLayout(group); + + auto* chart = new AwesomeQt::LineChart(group); + chart->setData({12, 28, 19, 45, 33, 52, 40, 61, 55, 48}); + // 演示线下填充:开 area,看折线下方淡色填充效果 + chart->setShowArea(true); + + layout->addWidget(chart); + return group; +} + +// —— 交互:追加随机点(看 Y auto-scale 跟着变)/ 重置 —— +QWidget* LineChartWindow::setup_dynamic_layout() { + auto* group = new QGroupBox("Interactive (Append Random / Reset)", this); + auto* layout = new QVBoxLayout(group); + + auto* chart = new AwesomeQt::LineChart(group); + chart->setData({20, 35, 25, 50, 30}); + + auto* ctrl_row = new QHBoxLayout(); + auto* append_btn = new QPushButton("Append Random Point", group); + auto* reset_btn = new QPushButton("Reset", group); + auto* clear_btn = new QPushButton("Clear", group); + + // 追加:取当前末值附近 ±随机,制造连续走势,auto-scale 会随值域变化重算 Y 轴 + connect(append_btn, &QPushButton::clicked, chart, [chart]() { + const QVector& data = chart->data(); + const qreal base = data.isEmpty() ? 50.0 : data.last(); + // 在 base ±20 区间抖动,并 clamp 到 [0, 100],避免 Y 轴飞太远 + const qreal next = std::clamp(base + (std::rand() % 41 - 20), 0.0, 100.0); + chart->appendPoint(next); + }); + + connect(reset_btn, &QPushButton::clicked, chart, + [chart]() { chart->setData({20, 35, 25, 50, 30}); }); + + connect(clear_btn, &QPushButton::clicked, chart, &AwesomeQt::LineChart::clear); + + ctrl_row->addWidget(append_btn); + ctrl_row->addWidget(reset_btn); + ctrl_row->addWidget(clear_btn); + ctrl_row->addStretch(); + + layout->addWidget(chart); + layout->addLayout(ctrl_row); + return group; +} + +// —— 外观开关:showGrid / showDots / showArea,全部走 Q_PROPERTY —— +QWidget* LineChartWindow::setup_options_layout() { + auto* group = new QGroupBox("Appearance Toggles", this); + auto* layout = new QVBoxLayout(group); + + auto* chart = new AwesomeQt::LineChart(group); + chart->setData({8, 22, 15, 40, 28, 60, 45, 70}); + chart->setLineColor(QColor(220, 80, 60)); + + auto* toggles = new QHBoxLayout(); + auto* grid_cb = new QCheckBox("showGrid", group); + grid_cb->setChecked(chart->showGrid()); + auto* dots_cb = new QCheckBox("showDots", group); + dots_cb->setChecked(chart->showDots()); + auto* area_cb = new QCheckBox("showArea", group); + area_cb->setChecked(chart->showArea()); + + // 函数指针语法接 Q_PROPERTY 的 WRITE 经过的 NOTIFY 无关——直接绑定 setter + connect(grid_cb, &QCheckBox::toggled, chart, &AwesomeQt::LineChart::setShowGrid); + connect(dots_cb, &QCheckBox::toggled, chart, &AwesomeQt::LineChart::setShowDots); + connect(area_cb, &QCheckBox::toggled, chart, &AwesomeQt::LineChart::setShowArea); + + toggles->addWidget(grid_cb); + toggles->addWidget(dots_cb); + toggles->addWidget(area_cb); + toggles->addStretch(); + + layout->addWidget(chart); + layout->addLayout(toggles); + return group; +} + +void LineChartWindow::setup_ui() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* layout = new QVBoxLayout(central); + layout->addWidget(setup_static_layout()); + layout->addWidget(setup_dynamic_layout()); + layout->addWidget(setup_options_layout()); + + setWindowTitle("LineChart Widget Demo"); + resize(560, 600); +} diff --git a/widget/line-chart/demo/line_chart_window.h b/widget/line-chart/demo/line_chart_window.h new file mode 100644 index 0000000..1f4e714 --- /dev/null +++ b/widget/line-chart/demo/line_chart_window.h @@ -0,0 +1,20 @@ +/** + * @file line_chart_window.h + * @brief LineChart 控件演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#pragma once + +#include + +class LineChartWindow : public QMainWindow { + public: + explicit LineChartWindow(QWidget* parent = nullptr); + + private: + void setup_ui(); + QWidget* setup_static_layout(); + QWidget* setup_dynamic_layout(); + QWidget* setup_options_layout(); +}; diff --git a/widget/line-chart/demo/main.cpp b/widget/line-chart/demo/main.cpp new file mode 100644 index 0000000..3a1e53e --- /dev/null +++ b/widget/line-chart/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief LineChart 演示程序入口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "line_chart_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + LineChartWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/line-chart/include/line_chart.h b/widget/line-chart/include/line_chart.h new file mode 100644 index 0000000..76b6daa --- /dev/null +++ b/widget/line-chart/include/line_chart.h @@ -0,0 +1,92 @@ +/** + * @file line_chart.h + * @brief 纯 QPainter 折线图控件 LineChart——Y 轴自动缩放、可选网格/数据点/线下填充 + * @copyright Copyright (c) 2026 AwesomeQt + */ +#pragma once + +#include +#include +#include + +namespace AwesomeQt { + +/// 纯 QPainter 折线图(不依赖 QtCharts)。 +/// +/// 设计要点(详见成品导览 index.md): +/// - 数据为 `QVector`,X 轴按索引均匀分布;Y 轴取数据 min..max 自动缩放; +/// - `appendPoint` / `setData` / `clear` 均触发 update(),无强制动画(静态即可); +/// - 属性(lineColor/axisColor/gridColor/showGrid/showDots/showArea)全是 Q_PROPERTY, +/// 配 NOTIFY,可被 Designer / State machine 直接驱动; +/// - 边界全防护:空数据直接 return、单点或 min==max 给 ±padding 防除零。 +class LineChart : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:外观与开关项,配 READ/WRITE/NOTIFY,可被 Designer/State machine 驱动 —— + Q_PROPERTY(QColor lineColor READ lineColor WRITE setLineColor NOTIFY lineColorChanged) + Q_PROPERTY(QColor axisColor READ axisColor WRITE setAxisColor NOTIFY axisColorChanged) + Q_PROPERTY(QColor gridColor READ gridColor WRITE setGridColor NOTIFY gridColorChanged) + Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged) + Q_PROPERTY(bool showDots READ showDots WRITE setShowDots NOTIFY showDotsChanged) + Q_PROPERTY(bool showArea READ showArea WRITE setShowArea NOTIFY showAreaChanged) + + public: + explicit LineChart(QWidget* parent = nullptr); + + /// @brief 追加一个数据点,触发重绘(Y 轴 auto-scale 随之更新) + void appendPoint(qreal value); + + /// @brief 整体替换数据序列,触发重绘 + /// @param values 新的数据序列(可空,清空图表) + void setData(const QVector& values); + + /// @brief 清空所有数据 + void clear(); + + QVector data() const; + + void setLineColor(const QColor& color); + QColor lineColor() const; + + void setAxisColor(const QColor& color); + QColor axisColor() const; + + void setGridColor(const QColor& color); + QColor gridColor() const; + + void setShowGrid(bool on); + bool showGrid() const; + + void setShowDots(bool on); + bool showDots() const; + + void setShowArea(bool on); + bool showArea() const; + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + signals: + void lineColorChanged(const QColor& color); + void axisColorChanged(const QColor& color); + void gridColorChanged(const QColor& color); + void showGridChanged(bool on); + void showDotsChanged(bool on); + void showAreaChanged(bool on); + + protected: + void paintEvent(QPaintEvent* event) override; + + private: + QVector values_; + + QColor line_color_{QColor(30, 120, 220)}; // 折线主色(蓝) + QColor axis_color_{QColor(120, 120, 120)}; // 坐标轴 + QColor grid_color_{QColor(220, 220, 220)}; // 网格(淡灰) + + bool show_grid_{true}; + bool show_dots_{true}; + bool show_area_{false}; +}; + +} // namespace AwesomeQt diff --git a/widget/line-chart/src/line_chart.cpp b/widget/line-chart/src/line_chart.cpp new file mode 100644 index 0000000..655a272 --- /dev/null +++ b/widget/line-chart/src/line_chart.cpp @@ -0,0 +1,267 @@ +/** + * @file line_chart.cpp + * @brief LineChart 控件实现——Y 轴自动缩放、网格/数据点/线下填充自绘 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "line_chart.h" + +#include + +#include +#include +#include +#include + +namespace AwesomeQt { + +// 边距常量(像素):左留 Y 标签、下留 X 标签、上下右留少量内边 +constexpr int kMarginLeft = 48; +constexpr int kMarginRight = 12; +constexpr int kMarginTop = 12; +constexpr int kMarginBottom = 28; +constexpr int kGridTickCount = 4; // Y 轴刻度档数(横线数) +constexpr int kDotRadius = 3; // 数据点半径 +constexpr qreal kFlatPadding = 1.0; // min==max 时的展开余量,防除零 + +// ============================================================================ +// 构造 +// ============================================================================ +LineChart::LineChart(QWidget* parent) : QWidget(parent) { + // 背景默认浅色,避免空数据时一片灰白难辨 + setAutoFillBackground(true); +} + +// ============================================================================ +// 数据入口(业务 API,触发 update) +// ============================================================================ +void LineChart::appendPoint(qreal value) { + values_.append(value); + update(); +} + +void LineChart::setData(const QVector& values) { + values_ = values; + update(); +} + +void LineChart::clear() { + if (values_.isEmpty()) { + return; + } + values_.clear(); + update(); +} + +QVector LineChart::data() const { + return values_; +} + +// ============================================================================ +// Q_PROPERTY WRITE:纯赋值 + emit + update,不夹业务(无动画,故无栈溢出风险) +// ============================================================================ +void LineChart::setLineColor(const QColor& color) { + if (line_color_ == color) { + return; + } + line_color_ = color; + emit lineColorChanged(color); + update(); +} + +QColor LineChart::lineColor() const { + return line_color_; +} + +void LineChart::setAxisColor(const QColor& color) { + if (axis_color_ == color) { + return; + } + axis_color_ = color; + emit axisColorChanged(color); + update(); +} + +QColor LineChart::axisColor() const { + return axis_color_; +} + +void LineChart::setGridColor(const QColor& color) { + if (grid_color_ == color) { + return; + } + grid_color_ = color; + emit gridColorChanged(color); + update(); +} + +QColor LineChart::gridColor() const { + return grid_color_; +} + +void LineChart::setShowGrid(bool on) { + if (show_grid_ == on) { + return; + } + show_grid_ = on; + emit showGridChanged(on); + update(); +} + +bool LineChart::showGrid() const { + return show_grid_; +} + +void LineChart::setShowDots(bool on) { + if (show_dots_ == on) { + return; + } + show_dots_ = on; + emit showDotsChanged(on); + update(); +} + +bool LineChart::showDots() const { + return show_dots_; +} + +void LineChart::setShowArea(bool on) { + if (show_area_ == on) { + return; + } + show_area_ = on; + emit showAreaChanged(on); + update(); +} + +bool LineChart::showArea() const { + return show_area_; +} + +// ============================================================================ +// 尺寸 +// ============================================================================ +QSize LineChart::sizeHint() const { + return QSize(300, 200); +} + +QSize LineChart::minimumSizeHint() const { + return QSize(120, 80); +} + +// ============================================================================ +// 自绘:auto-scale Y + 网格 + 坐标轴 + 折线 + 数据点 + 线下填充 +// ============================================================================ +void LineChart::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + // plot 区:总宽高对 0/负值 clamp(布局压极小时仍能算出合理矩形) + const int w = std::max(1, width()); + const int h = std::max(1, height()); + const qreal plot_left = kMarginLeft; + const qreal plot_top = kMarginTop; + const qreal plot_width = std::max(1.0, static_cast(w - kMarginLeft - kMarginRight)); + const qreal plot_height = std::max(1.0, static_cast(h - kMarginTop - kMarginBottom)); + const qreal plot_bottom = plot_top + plot_height; + + // —— Y 轴 auto-scale:取数据 min..max;单点/min==max 给 ±padding 防除零 —— + qreal y_min = 0.0; + qreal y_max = 0.0; + const bool has_data = !values_.isEmpty(); + if (has_data) { + // minmax_element 一次扫描同时拿最小最大(替代两次遍历) + auto [min_it, max_it] = std::minmax_element(values_.constBegin(), values_.constEnd()); + y_min = *min_it; + y_max = *max_it; + if (qFuzzyCompare(y_min, y_max)) { + // 常量/单点:上下各让一点,否则 value→像素分母为 0 + y_min -= kFlatPadding; + y_max += kFlatPadding; + } + } + const qreal y_range = y_max - y_min; + + // value → 像素 y:plotBottom - (v - yMin) / range * plotHeight + auto to_y = [&](qreal v) { return plot_bottom - (v - y_min) / y_range * plot_height; }; + + // —— 网格:Y 轴 kGridTickCount 档横线 + X 轴竖线(数据点数足够时画) —— + if (show_grid_) { + p.setPen(QPen(grid_color_, 1)); + for (int i = 0; i <= kGridTickCount; ++i) { + const qreal y = plot_top + plot_height * i / kGridTickCount; + p.drawLine(QPointF(plot_left, y), QPointF(plot_left + plot_width, y)); + } + const int n = values_.size(); + if (n > 1) { + for (int i = 0; i < n; ++i) { + const qreal x = plot_left + plot_width * i / (n - 1); + p.drawLine(QPointF(x, plot_top), QPointF(x, plot_bottom)); + } + } + } + + // —— 坐标轴:左 Y 轴 + 下 X 轴 —— + p.setPen(QPen(axis_color_, 1)); + p.drawLine(QPointF(plot_left, plot_top), QPointF(plot_left, plot_bottom)); // 左 Y + p.drawLine(QPointF(plot_left, plot_bottom), + QPointF(plot_left + plot_width, plot_bottom)); // 下 X + + // —— Y 刻度数字:用 QFontMetrics 算文本宽,右对齐到 Y 轴左侧 —— + p.setPen(axis_color_); + QFontMetrics fm(p.font()); + for (int i = 0; i <= kGridTickCount; ++i) { + // i=0 在顶部 → 对应 y_max;i=kGridTickCount 在底部 → 对应 y_min + const qreal v = y_max - (y_max - y_min) * i / kGridTickCount; + const qreal y = plot_top + plot_height * i / kGridTickCount; + const QString text = QString::number(v, 'f', 1); + const int text_w = fm.horizontalAdvance(text); + // 右对齐到 Y 轴左侧、垂直大致居中(baseline 修正) + p.drawText(QPointF(plot_left - text_w - 4, y + fm.ascent() / 2), text); + } + + // —— 空数据:到此结束,不画折线(不崩) —— + if (!has_data) { + return; + } + + // —— 折线 / 线下填充 / 数据点 —— + QPolygonF poly; + const int n = values_.size(); + for (int i = 0; i < n; ++i) { + const qreal x = (n == 1) ? plot_left + plot_width / 2 // 单点居中 + : plot_left + plot_width * i / (n - 1); + poly.append(QPointF(x, to_y(values_[i]))); + } + + // 线下填充:折线下方闭合到 plot bottom,淡色 fill + if (show_area_ && n >= 2) { + QPainterPath area; + area.moveTo(poly.first().x(), plot_bottom); + area.addPolygon(poly); + area.lineTo(poly.last().x(), plot_bottom); + area.closeSubpath(); + QColor fill = line_color_; + fill.setAlpha(60); // 半透明填充,与折线同色系 + p.setPen(Qt::NoPen); + p.setBrush(fill); + p.drawPath(area); + } + + // 折线 + p.setPen(QPen(line_color_, 2)); + p.setBrush(Qt::NoBrush); + p.drawPolyline(poly); + + // 数据点:小圆 + if (show_dots_) { + p.setPen(Qt::NoPen); + p.setBrush(line_color_); + const qreal r = kDotRadius; // 固定半径,布局极小时不放大不模糊 + for (const QPointF& pt : poly) { + p.drawEllipse(pt, r, r); + } + } +} + +} // namespace AwesomeQt diff --git a/widget/log-viewer/CMakeLists.txt b/widget/log-viewer/CMakeLists.txt new file mode 100644 index 0000000..b97dad2 --- /dev/null +++ b/widget/log-viewer/CMakeLists.txt @@ -0,0 +1,19 @@ +# LogViewer 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(log_viewer STATIC + include/log_viewer.h + src/log_viewer.cpp +) + +target_include_directories(log_viewer PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(log_viewer PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/log-viewer/demo/CMakeLists.txt b/widget/log-viewer/demo/CMakeLists.txt new file mode 100644 index 0000000..9ac7ea0 --- /dev/null +++ b/widget/log-viewer/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# LogViewer 演示程序:级别按钮 + 连发压测 + 清空 + autoScroll 切换 + +qt_add_executable(log_viewer_demo + main.cpp + log_viewer_window.cpp + log_viewer_window.h +) + +target_link_libraries(log_viewer_demo PRIVATE + log_viewer +) diff --git a/widget/log-viewer/demo/log_viewer_window.cpp b/widget/log-viewer/demo/log_viewer_window.cpp new file mode 100644 index 0000000..3cf37bc --- /dev/null +++ b/widget/log-viewer/demo/log_viewer_window.cpp @@ -0,0 +1,99 @@ +/** + * @file log_viewer_window.cpp + * @brief LogViewer 演示主窗口实现 + * @copyright Copyright (c) 2026 + */ + +#include "log_viewer_window.h" + +#include +#include +#include +#include +#include +#include + +#include "log_viewer.h" + +using AwesomeQt::LogViewer; + +LogViewerWindow::LogViewerWindow(QWidget* parent) : QMainWindow(parent) { + setupUi(); +} + +void LogViewerWindow::setupUi() { + auto* central = new QWidget(this); + setCentralWidget(central); + + log_ = new LogViewer(central); + + // —— 控制面板:级别按钮 + 连发压测 + 清空 + autoScroll —— + auto* controlGroup = new QGroupBox("Controls", central); + auto* controlLayout = new QVBoxLayout(controlGroup); + + auto* levelRow = new QHBoxLayout(); + auto* infoBtn = new QPushButton("Append Info", controlGroup); + auto* warnBtn = new QPushButton("Append Warning", controlGroup); + auto* errorBtn = new QPushButton("Append Error", controlGroup); + levelRow->addWidget(infoBtn); + levelRow->addWidget(warnBtn); + levelRow->addWidget(errorBtn); + levelRow->addStretch(); + controlLayout->addLayout(levelRow); + + auto* stressRow = new QHBoxLayout(); + auto* stressBtn = new QPushButton("Burst 200 Info", controlGroup); + stressBtn->setToolTip("连发 200 条 Info——测自动滚底 + 行数上限裁旧"); + auto* clearBtn = new QPushButton("Clear", controlGroup); + auto_scroll_check_ = new QCheckBox("Auto Scroll", controlGroup); + auto_scroll_check_->setChecked(log_->autoScroll()); + stressRow->addWidget(stressBtn); + stressRow->addWidget(clearBtn); + stressRow->addStretch(); + stressRow->addWidget(auto_scroll_check_); + controlLayout->addLayout(stressRow); + + auto* statusRow = new QHBoxLayout(); + line_count_label_ = new QLabel("lines: 0", controlGroup); + statusRow->addWidget(line_count_label_); + statusRow->addStretch(); + controlLayout->addLayout(statusRow); + + // —— 信号连接 —— + connect(infoBtn, &QPushButton::clicked, this, + [this]() { log_->appendInfo("系统启动完成,监听端口 8080"); }); + connect(warnBtn, &QPushButton::clicked, this, + [this]() { log_->appendWarning("配置文件缺失,使用默认值"); }); + connect(errorBtn, &QPushButton::clicked, this, + [this]() { log_->appendError("无法连接数据库:connection refused"); }); + + connect(stressBtn, &QPushButton::clicked, this, [this]() { + // 连发 200 条:超过默认 maxLines=1000 不会爆,但底部应始终最新 + for (int i = 0; i < 200; ++i) { + log_->appendInfo(QStringLiteral("心跳 #%1").arg(i)); + } + }); + + connect(clearBtn, &QPushButton::clicked, this, [this]() { log_->clear(); }); + + connect(auto_scroll_check_, &QCheckBox::toggled, this, + [this](bool checked) { log_->setAutoScroll(checked); }); + + // 任何 append 后刷新行数显示 + connect(log_, &LogViewer::maxLinesChanged, this, [this]() { + line_count_label_->setText(QStringLiteral("lines: %1").arg(log_->lineCount())); + }); + + auto* layout = new QVBoxLayout(central); + layout->addWidget(log_); + layout->addWidget(controlGroup); + + setWindowTitle("LogViewer Widget Demo"); + resize(560, 420); + + // 初始播几条 + log_->appendInfo("LogViewer 就绪"); + log_->appendWarning("这是一条警告示例"); + log_->appendError("这是一条错误示例"); + line_count_label_->setText(QStringLiteral("lines: %1").arg(log_->lineCount())); +} diff --git a/widget/log-viewer/demo/log_viewer_window.h b/widget/log-viewer/demo/log_viewer_window.h new file mode 100644 index 0000000..04c098b --- /dev/null +++ b/widget/log-viewer/demo/log_viewer_window.h @@ -0,0 +1,28 @@ +/** + * @file log_viewer_window.h + * @brief LogViewer 控件演示主窗口 + * @copyright Copyright (c) 2026 + */ + +#pragma once + +#include + +class QCheckBox; +class QLabel; + +namespace AwesomeQt { +class LogViewer; +} + +class LogViewerWindow : public QMainWindow { + public: + explicit LogViewerWindow(QWidget* parent = nullptr); + + private: + void setupUi(); + + AwesomeQt::LogViewer* log_{nullptr}; + QCheckBox* auto_scroll_check_{nullptr}; + QLabel* line_count_label_{nullptr}; +}; diff --git a/widget/log-viewer/demo/main.cpp b/widget/log-viewer/demo/main.cpp new file mode 100644 index 0000000..961107a --- /dev/null +++ b/widget/log-viewer/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief LogViewer 演示程序入口 + * @copyright Copyright (c) 2026 + */ + +#include "log_viewer_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + LogViewerWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/log-viewer/include/log_viewer.h b/widget/log-viewer/include/log_viewer.h new file mode 100644 index 0000000..c24aafb --- /dev/null +++ b/widget/log-viewer/include/log_viewer.h @@ -0,0 +1,92 @@ +/** + * @file log_viewer.h + * @brief 滚动日志控件 LogViewer——级别染色 + 自动滚底 + 行数上限裁旧 + * @copyright Copyright (c) 2026 AwesomeQt + * + * 组合 QPlainTextEdit(只读、非自绘)。append 时按级别着色、 + * 自动滚到底;超过 maxLines 时从顶部裁旧行,防内存无限膨胀。 + */ +#pragma once + +#include + +class QPlainTextEdit; + +namespace AwesomeQt { + +/// @brief 滚动日志控件:级别染色 + 自动滚底 + 行数上限。 +/// +/// 封装一个只读 QPlainTextEdit。append 时按 Level 套前景色(Info 默认、 +/// Warning 暗黄、Error 红),可选时间戳前缀;autoScroll 开启时 append 后 +/// 滚到底;blockCount 超过 maxLines 时从文档头删旧行。 +/// +/// 边界:setMaxLines 夹到 >=1;空 message 照常 append(只显示时间戳+级别); +/// 不重写 paintEvent,渲染完全交给 QPlainTextEdit。 +class LogViewer : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:行为开关,可在 Designer / 外部直接驱动 —— + Q_PROPERTY(int maxLines READ maxLines WRITE setMaxLines NOTIFY maxLinesChanged) + Q_PROPERTY(bool autoScroll READ autoScroll WRITE setAutoScroll NOTIFY autoScrollChanged) + Q_PROPERTY( + bool showTimestamp READ showTimestamp WRITE setShowTimestamp NOTIFY showTimestampChanged) + + public: + /// @brief 日志级别。决定 append 时套的前景色。 + enum class Level { Info, Warning, Error }; + Q_ENUM(Level) + + explicit LogViewer(QWidget* parent = nullptr); + + /// @brief 追加一条日志。 + /// @param level 级别(决定颜色) + /// @param message 正文(可为空,此时只显示时间戳+级别) + void append(Level level, const QString& message); + + /// @brief 便捷重载:追加一条 Info 级日志。 + void appendInfo(const QString& message); + /// @brief 便捷重载:追加一条 Warning 级日志。 + void appendWarning(const QString& message); + /// @brief 便捷重载:追加一条 Error 级日志。 + void appendError(const QString& message); + + /// @brief 清空所有日志。 + void clear(); + + /// @brief 当前日志行数(block 数)。供 demo 观察裁旧行为。 + int lineCount() const; + + // —— Q_PROPERTY 读写 —— + void setMaxLines(int maxLines); + int maxLines() const; + + void setAutoScroll(bool autoScroll); + bool autoScroll() const; + + void setShowTimestamp(bool show); + bool showTimestamp() const; + + QSize sizeHint() const override; + + signals: + void maxLinesChanged(int maxLines); + void autoScrollChanged(bool autoScroll); + void showTimestampChanged(bool show); + + private: + /// @brief 按 level 取前景色。Info 返回无效色(用默认),其余返回固定色。 + QColor colorForLevel(Level level) const; + + /// @brief 把 level 转成短标签字符串(INFO / WARN / ERROR)。 + static QString levelLabel(Level level); + + /// @brief 裁旧:若 blockCount 超过 maxLines,从文档头删多余块。 + void trimOldBlocks(); + + QPlainTextEdit* view_{nullptr}; + int max_lines_{1000}; + bool auto_scroll_{true}; + bool show_timestamp_{true}; +}; + +} // namespace AwesomeQt diff --git a/widget/log-viewer/src/log_viewer.cpp b/widget/log-viewer/src/log_viewer.cpp new file mode 100644 index 0000000..b26d260 --- /dev/null +++ b/widget/log-viewer/src/log_viewer.cpp @@ -0,0 +1,173 @@ +/** + * @file log_viewer.cpp + * @brief LogViewer 控件实现——级别染色 + 自动滚底 + 行数上限裁旧 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "log_viewer.h" + +#include +#include +#include +#include +#include +#include + +namespace AwesomeQt { + +LogViewer::LogViewer(QWidget* parent) : QWidget(parent) { + // view_ 由 this 父对象托管,构造即 new、放布局。 + view_ = new QPlainTextEdit(this); + view_->setReadOnly(true); + // 非等宽字体下日志列不齐,换等宽更利于阅读 + view_->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + view_->setLineWrapMode(QPlainTextEdit::NoWrap); + // 中心对齐滚动条:默认即可,这里不强制 + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(view_); +} + +void LogViewer::append(Level level, const QString& message) { + // 拼装行:[HH:MM:SS] [LEVEL] message(时间戳可关) + QString line; + if (show_timestamp_) { + line += + QStringLiteral("[%1] ").arg(QTime::currentTime().toString(QStringLiteral("HH:mm:ss"))); + } + line += QStringLiteral("[%1] %2").arg(levelLabel(level), message); + + // 用 QTextCursor 在文末按 level 套前景色插入,QPlainTextEdit 支持按插入设色 + QTextCursor cursor(view_->document()); + cursor.movePosition(QTextCursor::End); + + QTextCharFormat fmt; + const QColor color = colorForLevel(level); + if (color.isValid()) { + fmt.setForeground(color); + } + // 文末非空时先补一个换行,保证新行独立成块 + if (!cursor.atStart()) { + cursor.insertText(QStringLiteral("\n")); + } + cursor.insertText(line, fmt); + + trimOldBlocks(); + + if (auto_scroll_) { + // 滚到底让最新行可见 + view_->moveCursor(QTextCursor::End); + view_->ensureCursorVisible(); + } +} + +void LogViewer::appendInfo(const QString& message) { + append(Level::Info, message); +} + +void LogViewer::appendWarning(const QString& message) { + append(Level::Warning, message); +} + +void LogViewer::appendError(const QString& message) { + append(Level::Error, message); +} + +void LogViewer::clear() { + view_->clear(); +} + +int LogViewer::lineCount() const { + return view_->blockCount(); +} + +void LogViewer::setMaxLines(int maxLines) { + // 夹到 >=1,避免上限为 0 时裁成空或被绕过 + const int clamped = maxLines < 1 ? 1 : maxLines; + if (clamped == max_lines_) { + return; + } + max_lines_ = clamped; + // 已有内容可能已超新上限,立即裁一次 + trimOldBlocks(); + emit maxLinesChanged(max_lines_); +} + +int LogViewer::maxLines() const { + return max_lines_; +} + +void LogViewer::setAutoScroll(bool autoScroll) { + if (autoScroll == auto_scroll_) { + return; + } + auto_scroll_ = autoScroll; + emit autoScrollChanged(auto_scroll_); +} + +bool LogViewer::autoScroll() const { + return auto_scroll_; +} + +void LogViewer::setShowTimestamp(bool show) { + if (show == show_timestamp_) { + return; + } + show_timestamp_ = show; + emit showTimestampChanged(show); +} + +bool LogViewer::showTimestamp() const { + return show_timestamp_; +} + +QSize LogViewer::sizeHint() const { + return QSize(380, 220); +} + +QColor LogViewer::colorForLevel(Level level) const { + switch (level) { + case Level::Info: + // 返回无效色:用 view 默认前景色 + return QColor(); + case Level::Warning: + return QColor(180, 120, 0); // 暗黄 + case Level::Error: + return QColor(200, 0, 0); // 红 + } + return QColor(); +} + +QString LogViewer::levelLabel(Level level) { + switch (level) { + case Level::Info: + return QStringLiteral("INFO"); + case Level::Warning: + return QStringLiteral("WARN"); + case Level::Error: + return QStringLiteral("ERROR"); + } + return QStringLiteral("INFO"); +} + +void LogViewer::trimOldBlocks() { + const int count = view_->blockCount(); + if (count <= max_lines_) { + return; + } + const int toRemove = count - max_lines_; + QTextCursor cursor(view_->document()); + cursor.movePosition(QTextCursor::Start); + // 从文档头连续选中 toRemove 个块(含每块的行尾换行),整段删除 + for (int i = 0; i < toRemove; ++i) { + cursor.movePosition(QTextCursor::NextBlock, QTextCursor::KeepAnchor); + // 连块尾换行一起选中 + if (cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor)) { + // 已把行尾 \n 带进选区 + } + } + cursor.removeSelectedText(); +} + +} // namespace AwesomeQt diff --git a/widget/password-edit/CMakeLists.txt b/widget/password-edit/CMakeLists.txt new file mode 100644 index 0000000..6fa56f8 --- /dev/null +++ b/widget/password-edit/CMakeLists.txt @@ -0,0 +1,19 @@ +# PasswordEdit 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(password_edit STATIC + include/password_edit.h + src/password_edit.cpp +) + +target_include_directories(password_edit PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(password_edit PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/password-edit/demo/CMakeLists.txt b/widget/password-edit/demo/CMakeLists.txt new file mode 100644 index 0000000..0c2197b --- /dev/null +++ b/widget/password-edit/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# PasswordEdit 演示程序:显隐切换 + 强度实时变 + 回显 + 勾选联动 + +qt_add_executable(password_edit_demo + main.cpp + password_edit_window.cpp + password_edit_window.h +) + +target_link_libraries(password_edit_demo PRIVATE + password_edit +) diff --git a/widget/password-edit/demo/main.cpp b/widget/password-edit/demo/main.cpp new file mode 100644 index 0000000..b938063 --- /dev/null +++ b/widget/password-edit/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief PasswordEdit 演示程序入口 + * @copyright Copyright (c) 2026 + */ + +#include "password_edit_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + PasswordEditWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/password-edit/demo/password_edit_window.cpp b/widget/password-edit/demo/password_edit_window.cpp new file mode 100644 index 0000000..a219251 --- /dev/null +++ b/widget/password-edit/demo/password_edit_window.cpp @@ -0,0 +1,84 @@ +/** + * @file password_edit_window.cpp + * @brief PasswordEdit 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "password_edit_window.h" + +#include "password_edit.h" + +#include +#include +#include +#include +#include +#include + +PasswordEditWindow::PasswordEditWindow(QWidget* parent) : QMainWindow(parent) { + setWindowTitle(QStringLiteral("PasswordEdit 演示 —— 显隐切换 + 实时强度")); + + auto* central = new QWidget(this); + setCentralWidget(central); + + edit_ = new AwesomeQt::PasswordEdit(central); + echo_label_ = new QLabel(QStringLiteral("(空)"), central); + echo_label_->setWordWrap(true); + strength_label_ = new QLabel(QStringLiteral("弱"), central); + visible_check_ = new QCheckBox(QStringLiteral("显示密码"), central); + + auto* demo_layout = new QVBoxLayout; + demo_layout->setSpacing(6); + demo_layout->addWidget(new QLabel(QStringLiteral("密码:"))); + demo_layout->addWidget(edit_); + demo_layout->addSpacing(8); + demo_layout->addWidget(new QLabel(QStringLiteral("实时回显:"))); + demo_layout->addWidget(echo_label_); + demo_layout->addWidget(new QLabel(QStringLiteral("当前强度:"))); + demo_layout->addWidget(strength_label_); + demo_layout->addWidget(visible_check_); + demo_layout->addStretch(); + + auto* demo_box = new QGroupBox(QStringLiteral("①②③④ 演示"), central); + demo_box->setLayout(demo_layout); + + auto* hint_box = new QGroupBox(QStringLiteral("强度提示"), central); + auto* hint_layout = new QVBoxLayout(hint_box); + hint_layout->addWidget(new QLabel(QStringLiteral("输入 < 6 位 / 纯字母 → 弱(红 1 块)\n" + "种类 = 2(如字母+数字)→ 中(黄 2 块)\n" + "种类 >= 3 且 >= 8 位 → 强(绿 3 块)"), + hint_box)); + + auto* root = new QHBoxLayout(central); + root->setContentsMargins(12, 12, 12, 12); + root->setSpacing(12); + root->addWidget(demo_box, 1); + root->addWidget(hint_box, 1); + + resize(520, 280); + + // 信号绑定:函数指针语法 + connect(edit_, &AwesomeQt::PasswordEdit::textVisibleChanged, visible_check_, + &QCheckBox::setChecked); + connect(visible_check_, &QCheckBox::toggled, edit_, &AwesomeQt::PasswordEdit::setTextVisible); + connect(edit_, &AwesomeQt::PasswordEdit::strengthChanged, this, + [this](AwesomeQt::PasswordEdit::Strength s) { + QString name; + switch (s) { + case AwesomeQt::PasswordEdit::Strength::kWeak: + name = QStringLiteral("弱"); + break; + case AwesomeQt::PasswordEdit::Strength::kMedium: + name = QStringLiteral("中"); + break; + case AwesomeQt::PasswordEdit::Strength::kStrong: + name = QStringLiteral("强"); + break; + } + strength_label_->setText(name); + }); + // 文本变化实时回显(密文时也显示真实内容,验证 text() 拿得到) + connect(edit_, &AwesomeQt::PasswordEdit::textChanged, this, [this](const QString& text) { + echo_label_->setText(text.isEmpty() ? QStringLiteral("(空)") : text); + }); +} diff --git a/widget/password-edit/demo/password_edit_window.h b/widget/password-edit/demo/password_edit_window.h new file mode 100644 index 0000000..c7cb04b --- /dev/null +++ b/widget/password-edit/demo/password_edit_window.h @@ -0,0 +1,31 @@ +/** + * @file password_edit_window.h + * @brief PasswordEdit 演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt + * + * 展示:① 显隐切换;② 强度 3 色块实时变;③ 实时回显 text; + * ④ 「显示密码」勾选框联动显隐。 + */ +#pragma once + +#include + +class QCheckBox; +class QLabel; + +namespace AwesomeQt { +class PasswordEdit; +} + +class PasswordEditWindow : public QMainWindow { + Q_OBJECT + + public: + explicit PasswordEditWindow(QWidget* parent = nullptr); + + private: + AwesomeQt::PasswordEdit* edit_{nullptr}; + QLabel* echo_label_{nullptr}; + QLabel* strength_label_{nullptr}; + QCheckBox* visible_check_{nullptr}; +}; diff --git a/widget/password-edit/include/password_edit.h b/widget/password-edit/include/password_edit.h new file mode 100644 index 0000000..ae7c594 --- /dev/null +++ b/widget/password-edit/include/password_edit.h @@ -0,0 +1,101 @@ +/** + * @file password_edit.h + * @brief 密码输入控件 PasswordEdit——显隐切换 + 实时密码强度指示 + * @copyright Copyright (c) 2026 AwesomeQt + * + * 组合 QLineEdit(密码模式)+ QToolButton(显隐切换)+ 3 个 QLabel + * 色块(强度指示),非自绘。强度按「长度 + 字符种类数」实时计算, + * textChanged 触发重算并染色 + 发 strengthChanged。 + */ +#pragma once + +#include + +class QLabel; +class QLineEdit; +class QToolButton; + +namespace AwesomeQt { + +/// @brief 密码输入控件:显隐切换 + 实时强度(弱/中/强)。 +/// +/// 内部组合 QLineEdit(EchoMode::Password)做密码框,右侧 QToolButton +/// 一键显隐,下方 3 个色块按强度染色(弱=红亮 1 块、中=黄亮 2 块、 +/// 强=绿亮 3 块,未亮=灰)。强度算法见 computeStrength()。 +/// +/// 边界:空文本安全(算 kWeak,不崩);显隐切换只改 echoMode 不动内容。 +class PasswordEdit : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:可在 Designer / 外部直接驱动 —— + Q_PROPERTY(bool textVisible READ textVisible WRITE setTextVisible NOTIFY textVisibleChanged) + Q_PROPERTY(QString placeholderText READ placeholderText WRITE setPlaceholderText NOTIFY + placeholderTextChanged) + Q_PROPERTY(Strength strength READ strength NOTIFY strengthChanged) + + public: + /// @brief 密码强度档位 + enum class Strength { kWeak, kMedium, kStrong }; + Q_ENUM(Strength) + + explicit PasswordEdit(QWidget* parent = nullptr); + + /// @brief 取当前输入文本(含显隐切换后的真实内容)。 + QString text() const; + + /// @brief 设置输入文本(会触发 textChanged → 重算强度)。 + void setText(const QString& text); + + /// @brief 当前是否明文显示。 + bool textVisible() const; + + /// @brief 切换明文/密文显示(同时更新切换按钮文案)。 + void setTextVisible(bool visible); + + /// @brief 当前密码强度。 + Strength strength() const; + + /// @brief 占位提示文字。 + QString placeholderText() const; + + /// @brief 设置占位提示文字。 + void setPlaceholderText(const QString& text); + + /// @brief 按长度 + 字符种类数算强度(静态,可独立测试)。 + /// + /// 字符种类:小写 / 大写 / 数字 / 符号 各算一类。 + /// - 长度 < 6 或 种类数 <= 1 → kWeak + /// - 种类数 == 2 → kMedium + /// - 种类数 >= 3 且 长度 >= 8 → kStrong;否则 kMedium + static Strength computeStrength(const QString& text); + + QSize sizeHint() const override; + + signals: + /// @brief 输入文本变化(透传内部 QLineEdit::textChanged)。 + void textChanged(const QString& text); + /// @brief 显隐状态变化。 + void textVisibleChanged(bool visible); + /// @brief 占位文字变化。 + void placeholderTextChanged(const QString& text); + /// @brief 强度变化(输入或 setText 触发)。 + void strengthChanged(Strength strength); + + private: + /// @brief textChanged → 重算 strength_ 并刷新色块 + 发信号。 + void onTextChanged(const QString& text); + + /// @brief 按 strength_ 给 3 个色块染色(弱红/中黄/强绿,未亮灰)。 + void updateStrengthIndicator(); + + /// @brief 同步切换按钮文案(显/隐)到当前可见状态。 + void syncToggleText(); + + QLineEdit* edit_{nullptr}; + QToolButton* toggle_btn_{nullptr}; + QLabel* strength_labels_[3]{nullptr, nullptr, nullptr}; // 强度 3 色块 + Strength strength_{Strength::kWeak}; + bool text_visible_{false}; // 默认密文 +}; + +} // namespace AwesomeQt diff --git a/widget/password-edit/src/password_edit.cpp b/widget/password-edit/src/password_edit.cpp new file mode 100644 index 0000000..958688f --- /dev/null +++ b/widget/password-edit/src/password_edit.cpp @@ -0,0 +1,185 @@ +/** + * @file password_edit.cpp + * @brief PasswordEdit 控件实现——显隐切换 + 实时强度指示 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "password_edit.h" + +#include +#include +#include +#include +#include + +namespace AwesomeQt { + +// 强度色块样式:亮=对应档色,未亮=中性灰。固定字号色块,保持组合控件本色。 +static constexpr const char* kStyleOff = + "QLabel { background-color: #c0c0c0; border-radius: 2px; }"; +static constexpr const char* kStyleWeak = + "QLabel { background-color: #e53935; border-radius: 2px; }"; // 红 +static constexpr const char* kStyleMedium = + "QLabel { background-color: #fdd835; border-radius: 2px; }"; // 黄 +static constexpr const char* kStyleStrong = + "QLabel { background-color: #43a047; border-radius: 2px; }"; // 绿 + +PasswordEdit::PasswordEdit(QWidget* parent) : QWidget(parent) { + // —— 编辑行:密码框 + 显隐切换按钮 —— + edit_ = new QLineEdit(this); + edit_->setEchoMode(text_visible_ ? QLineEdit::Normal : QLineEdit::Password); + edit_->setPlaceholderText(QStringLiteral("请输入密码")); + + toggle_btn_ = new QToolButton(this); + toggle_btn_->setText(QStringLiteral("显")); // 默认密文 → 按钮提示「显」 + toggle_btn_->setFocusPolicy(Qt::NoFocus); + toggle_btn_->setCursor(Qt::PointingHandCursor); + + auto* edit_row = new QHBoxLayout; + edit_row->setContentsMargins(0, 0, 0, 0); + edit_row->addWidget(edit_); + edit_row->addWidget(toggle_btn_); + + // —— 强度行:3 个色块 —— + auto* strength_row = new QHBoxLayout; + strength_row->setContentsMargins(0, 0, 0, 0); + strength_row->setSpacing(4); + for (int i = 0; i < 3; ++i) { + strength_labels_[i] = new QLabel(this); + strength_labels_[i]->setFixedHeight(6); + strength_labels_[i]->setStyleSheet(kStyleOff); + strength_row->addWidget(strength_labels_[i]); + } + strength_row->addStretch(); // 色块靠左,右侧弹性留白 + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(4); + root->addLayout(edit_row); + root->addLayout(strength_row); + + // 函数指针语法,禁 SIGNAL/SLOT 宏 + connect(edit_, &QLineEdit::textChanged, this, &PasswordEdit::onTextChanged); + // 透传文本变化信号,供外部回显用(demo 无需 findChild 内部控件) + connect(edit_, &QLineEdit::textChanged, this, &PasswordEdit::textChanged); + connect(toggle_btn_, &QToolButton::clicked, this, [this] { setTextVisible(!text_visible_); }); + + updateStrengthIndicator(); // 初始空文本 → kWeak,3 块全灰 +} + +QString PasswordEdit::text() const { + return edit_->text(); +} + +void PasswordEdit::setText(const QString& text) { + // setText 会触发 textChanged → onTextChanged 重算强度,无需在此重复 + edit_->setText(text); +} + +bool PasswordEdit::textVisible() const { + return text_visible_; +} + +void PasswordEdit::setTextVisible(bool visible) { + if (text_visible_ == visible) { + return; // 无变化,避免重复发信号 + } + text_visible_ = visible; + edit_->setEchoMode(visible ? QLineEdit::Normal : QLineEdit::Password); + syncToggleText(); + emit textVisibleChanged(visible); +} + +PasswordEdit::Strength PasswordEdit::strength() const { + return strength_; +} + +QString PasswordEdit::placeholderText() const { + return edit_->placeholderText(); +} + +void PasswordEdit::setPlaceholderText(const QString& text) { + if (edit_->placeholderText() == text) { + return; + } + edit_->setPlaceholderText(text); + emit placeholderTextChanged(text); +} + +PasswordEdit::Strength PasswordEdit::computeStrength(const QString& text) { + const int length = text.length(); + if (length == 0) { + return Strength::kWeak; // 空文本安全归弱 + } + + // 统计字符种类(小写/大写/数字/符号 各一类) + int classes = 0; + bool has_lower = false, has_upper = false, has_digit = false, has_symbol = false; + for (const QChar& ch : text) { + if (ch.isLower()) { + has_lower = true; + } else if (ch.isUpper()) { + has_upper = true; + } else if (ch.isDigit()) { + has_digit = true; + } else { + has_symbol = true; // 其余一律算符号 + } + } + classes = + (has_lower ? 1 : 0) + (has_upper ? 1 : 0) + (has_digit ? 1 : 0) + (has_symbol ? 1 : 0); + + // 长度 < 6 或 种类数 <= 1 → 弱 + if (length < 6 || classes <= 1) { + return Strength::kWeak; + } + // 种类数 == 2 → 中 + if (classes == 2) { + return Strength::kMedium; + } + // 种类数 >= 3 且 长度 >= 8 → 强;否则中 + if (classes >= 3 && length >= 8) { + return Strength::kStrong; + } + return Strength::kMedium; +} + +QSize PasswordEdit::sizeHint() const { + return QSize(220, 56); // edit 行 + 强度行 +} + +void PasswordEdit::onTextChanged(const QString& text) { + const Strength new_strength = computeStrength(text); + if (new_strength != strength_) { + strength_ = new_strength; + updateStrengthIndicator(); + emit strengthChanged(strength_); + } +} + +void PasswordEdit::updateStrengthIndicator() { + // 弱=红亮 1 块、中=黄亮 2 块、强=绿亮 3 块,未亮=灰 + const int lit = static_cast(strength_) + 1; // kWeak=1, kMedium=2, kStrong=3 + const char* on_style = kStyleWeak; + switch (strength_) { + case Strength::kWeak: + on_style = kStyleWeak; + break; + case Strength::kMedium: + on_style = kStyleMedium; + break; + case Strength::kStrong: + on_style = kStyleStrong; + break; + } + for (int i = 0; i < 3; ++i) { + strength_labels_[i]->setStyleSheet(i < lit ? on_style : kStyleOff); + } +} + +void PasswordEdit::syncToggleText() { + // 明文时按钮提示「隐」(点了会隐藏),密文时提示「显」 + toggle_btn_->setText(text_visible_ ? QStringLiteral("隐") : QStringLiteral("显")); +} + +} // namespace AwesomeQt diff --git a/widget/range-slider/CMakeLists.txt b/widget/range-slider/CMakeLists.txt new file mode 100644 index 0000000..17258ca --- /dev/null +++ b/widget/range-slider/CMakeLists.txt @@ -0,0 +1,19 @@ +# RangeSlider 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(range_slider STATIC + include/range_slider.h + src/range_slider.cpp +) + +target_include_directories(range_slider PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(range_slider PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/range-slider/demo/CMakeLists.txt b/widget/range-slider/demo/CMakeLists.txt new file mode 100644 index 0000000..5e17f2b --- /dev/null +++ b/widget/range-slider/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# RangeSlider 演示程序:静态展示 + 拖动交互 + 程序化设置 + 多配色 + +qt_add_executable(range_slider_demo + main.cpp + range_slider_window.cpp + range_slider_window.h +) + +target_link_libraries(range_slider_demo PRIVATE + range_slider +) diff --git a/widget/range-slider/demo/main.cpp b/widget/range-slider/demo/main.cpp new file mode 100644 index 0000000..9df3cab --- /dev/null +++ b/widget/range-slider/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief RangeSlider demo 程序入口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include + +#include "range_slider_window.h" + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + RangeSliderWindow w; + w.show(); + return app.exec(); +} diff --git a/widget/range-slider/demo/range_slider_window.cpp b/widget/range-slider/demo/range_slider_window.cpp new file mode 100644 index 0000000..ca60778 --- /dev/null +++ b/widget/range-slider/demo/range_slider_window.cpp @@ -0,0 +1,133 @@ +/** + * @file range_slider_window.cpp + * @brief RangeSlider 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "range_slider_window.h" +#include "range_slider.h" + +#include +#include +#include +#include +#include + +RangeSliderWindow::RangeSliderWindow(QWidget* parent) : QMainWindow(parent) { + setupUi(); +} + +QWidget* RangeSliderWindow::setupStaticLayout() { + auto* group = new QGroupBox("静态区间:lower=20 / upper=80"); + auto* layout = new QVBoxLayout(group); + + auto* slider = new AwesomeQt::RangeSlider(group); + slider->setRange(0, 100); + slider->setLowerValue(20); + slider->setUpperValue(80); + + auto* info = new QLabel("lower=20 upper=80", group); + info->setAlignment(Qt::AlignCenter); + + layout->addWidget(slider); + layout->addWidget(info); + return group; +} + +QWidget* RangeSliderWindow::setupInteractiveLayout() { + auto* group = new QGroupBox("交互:拖动任一手柄,实时显示当前值"); + auto* layout = new QVBoxLayout(group); + + auto* slider = new AwesomeQt::RangeSlider(group); + slider->setRange(0, 100); + slider->setLowerValue(30); + slider->setUpperValue(70); + + auto* state = new QLabel("lower=30 upper=70", group); + state->setAlignment(Qt::AlignCenter); + + // rangeChanged 拖动时每步都发,标签即时跟手 + QObject::connect(slider, &AwesomeQt::RangeSlider::rangeChanged, group, + [state](int lower, int upper) { + state->setText(QString("lower=%1 upper=%2").arg(lower).arg(upper)); + }); + + layout->addWidget(slider); + layout->addWidget(state); + return group; +} + +QWidget* RangeSliderWindow::setupProgrammaticLayout() { + auto* group = new QGroupBox("程序化:按钮移动手柄"); + auto* layout = new QVBoxLayout(group); + + auto* slider = new AwesomeQt::RangeSlider(group); + slider->setRange(0, 100); + slider->setLowerValue(25); + slider->setUpperValue(75); + + auto* state = new QLabel("lower=25 upper=75", group); + state->setAlignment(Qt::AlignCenter); + QObject::connect(slider, &AwesomeQt::RangeSlider::rangeChanged, group, + [state](int lower, int upper) { + state->setText(QString("lower=%1 upper=%2").arg(lower).arg(upper)); + }); + + auto* btn_row = new QHBoxLayout(); + auto* lower_btn = new QPushButton("setLowerValue(10)", group); + auto* upper_btn = new QPushButton("setUpperValue(90)", group); + // 验证程序化接口仍受 lower<=upper 约束保护 + QObject::connect(lower_btn, &QPushButton::clicked, group, + [slider]() { slider->setLowerValue(10); }); + QObject::connect(upper_btn, &QPushButton::clicked, group, + [slider]() { slider->setUpperValue(90); }); + btn_row->addWidget(lower_btn); + btn_row->addWidget(upper_btn); + + layout->addWidget(slider); + layout->addWidget(state); + layout->addLayout(btn_row); + return group; +} + +QWidget* RangeSliderWindow::setupThemedLayout() { + auto* group = new QGroupBox("自定义配色(Q_PROPERTY)"); + auto* layout = new QHBoxLayout(group); + + // 蓝绿主题 + auto* teal = new AwesomeQt::RangeSlider(group); + teal->setRangeColor(QColor(0, 150, 136)); + teal->setHandleColor(QColor(255, 255, 255)); + + // 橙色主题 + auto* orange = new AwesomeQt::RangeSlider(group); + orange->setRangeColor(QColor(255, 120, 50)); + orange->setLowerValue(40); + orange->setUpperValue(60); + + auto add = [&](AwesomeQt::RangeSlider* s, const QString& name) { + auto* col = new QVBoxLayout(); + col->addWidget(s, 0, Qt::AlignCenter); + auto* label = new QLabel(name, group); + label->setAlignment(Qt::AlignCenter); + col->addWidget(label); + layout->addLayout(col); + }; + add(teal, "蓝绿"); + add(orange, "橙"); + return group; +} + +void RangeSliderWindow::setupUi() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* layout = new QVBoxLayout(central); + layout->addWidget(setupStaticLayout()); + layout->addWidget(setupInteractiveLayout()); + layout->addWidget(setupProgrammaticLayout()); + layout->addWidget(setupThemedLayout()); + + setWindowTitle("RangeSlider Widget Demo"); + resize(420, 380); +} diff --git a/widget/range-slider/demo/range_slider_window.h b/widget/range-slider/demo/range_slider_window.h new file mode 100644 index 0000000..bcd98b1 --- /dev/null +++ b/widget/range-slider/demo/range_slider_window.h @@ -0,0 +1,20 @@ +/** + * @file range_slider_window.h + * @brief RangeSlider 演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt + */ +#pragma once + +#include + +class RangeSliderWindow : public QMainWindow { + public: + explicit RangeSliderWindow(QWidget* parent = nullptr); + + private: + void setupUi(); + QWidget* setupStaticLayout(); + QWidget* setupInteractiveLayout(); + QWidget* setupProgrammaticLayout(); + QWidget* setupThemedLayout(); +}; diff --git a/widget/range-slider/include/range_slider.h b/widget/range-slider/include/range_slider.h new file mode 100644 index 0000000..fe15537 --- /dev/null +++ b/widget/range-slider/include/range_slider.h @@ -0,0 +1,114 @@ +/** + * @file range_slider.h + * @brief 双端范围滑块控件 RangeSlider——水平双柄,lower<=upper 约束,区间高亮 + * @copyright Copyright (c) 2026 AwesomeQt + */ +#pragma once + +#include +#include + +namespace AwesomeQt { + +/// 双端范围滑块:lowerValue 与 upperValue 各占一手柄,约束 lower<=upper。 +/// +/// 交互(照 toggle-switch 的三事件 + 拖动阈值模式): +/// - mousePressEvent 做双手柄 hit-test,离谁近且在容差内就抓谁; +/// - mouseMoveEvent 超 kDragThreshold 才算拖(防点击被当拖),拖动即时跟手; +/// - mouseReleaseEvent 收尾重置活动手柄。 +/// +/// 映射:valueToX / xToValue 在 [minimum,maximum] 与轨道像素区间之间换算; +/// trackWidth<=0 时兜底,防控件过窄时除零。 +class RangeSlider : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:四值 + 三配色,均可被 Designer / 动画驱动 —— + Q_PROPERTY(int minimum READ minimum WRITE setMinimum NOTIFY minimumChanged) + Q_PROPERTY(int maximum READ maximum WRITE setMaximum NOTIFY maximumChanged) + Q_PROPERTY(int lowerValue READ lowerValue WRITE setLowerValue NOTIFY lowerValueChanged) + Q_PROPERTY(int upperValue READ upperValue WRITE setUpperValue NOTIFY upperValueChanged) + Q_PROPERTY(QColor handleColor READ handleColor WRITE setHandleColor NOTIFY handleColorChanged) + Q_PROPERTY(QColor trackColor READ trackColor WRITE setTrackColor NOTIFY trackColorChanged) + Q_PROPERTY(QColor rangeColor READ rangeColor WRITE setRangeColor NOTIFY rangeColorChanged) + + public: + explicit RangeSlider(QWidget* parent = nullptr); + + int minimum() const; + /// 设置最小值;现有值会被夹到新区间内(仍保持 lower<=upper)。 + void setMinimum(int min); + + int maximum() const; + /// 设置最大值;现有值会被夹到新区间内(仍保持 lower<=upper)。 + void setMaximum(int max); + + /// @brief 设置区间端点 [min,max];minimum>maximum 时自动交换。 + /// 一次性设两端,避免分别 set 时中间态非法。 + void setRange(int min, int max); + + int lowerValue() const; + /// @brief 设置下手柄值。会夹到 [minimum, upperValue],保持 lower<=upper。 + /// @param value 新的下手柄值。 + void setLowerValue(int value); + + int upperValue() const; + /// @brief 设置上手柄值。会夹到 [lowerValue, maximum],保持 lower<=upper。 + /// @param value 新的上手柄值。 + void setUpperValue(int value); + + QColor handleColor() const; + void setHandleColor(const QColor& c); + QColor trackColor() const; + void setTrackColor(const QColor& c); + QColor rangeColor() const; + void setRangeColor(const QColor& c); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + signals: + void minimumChanged(int min); + void maximumChanged(int max); + void lowerValueChanged(int value); + void upperValueChanged(int value); + /// 任一手柄值变动时发出(拖动过程中每步都发,便于外部即时联动)。 + void rangeChanged(int lower, int upper); + void handleColorChanged(const QColor& c); + void trackColorChanged(const QColor& c); + void rangeColorChanged(const QColor& c); + + protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + + private: + /// 活动手柄标识:kNone 表示当前没抓任何手柄。 + enum class ActiveHandle { kNone, kLower, kUpper }; + + /// 轨道可用矩形(左右各留半个手柄,让手柄圆心正好落在端点值上)。 + QRectF trackRect() const; + /// 值 → 手柄圆心 x。 + qreal valueToX(int value) const; + /// 鼠标 x → 值(已夹到 [minimum,maximum])。 + int xToValue(qreal x) const; + /// 抓取判定:返回离鼠标最近的手柄(要求在容差内,否则 kNone)。 + ActiveHandle hitTestHandle(qreal x) const; + + int minimum_{0}; + int maximum_{100}; + int lower_value_{20}; + int upper_value_{80}; + + QColor handle_color_{QColor(255, 255, 255)}; + QColor track_color_{QColor(200, 200, 200)}; + QColor range_color_{QColor(0, 120, 215)}; + + // 拖动状态 + ActiveHandle active_handle_{ActiveHandle::kNone}; + qreal press_x_{0}; // 按下时的鼠标 x,用于阈值判定 + bool dragging_{false}; +}; + +} // namespace AwesomeQt diff --git a/widget/range-slider/src/range_slider.cpp b/widget/range-slider/src/range_slider.cpp new file mode 100644 index 0000000..d27a3e8 --- /dev/null +++ b/widget/range-slider/src/range_slider.cpp @@ -0,0 +1,326 @@ +/** + * @file range_slider.cpp + * @brief RangeSlider 控件实现——双手柄 hit-test + 值/像素映射 + 自绘轨道/区间/手柄 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "range_slider.h" + +#include + +#include +#include +#include + +namespace AwesomeQt { + +namespace { +constexpr int kHandleDiameter = 16; // 手柄直径(px),同时决定轨道高度 +constexpr int kHitTolerance = 14; // 点击离手柄圆心多少像素内算命中 +constexpr int kDragThreshold = 4; // 移动超过这个像素才算拖动,否则当点击 +constexpr int kHandleGap = 0; // 两手柄允许的最小间距(值单位),防完全重叠 +} // namespace + +RangeSlider::RangeSlider(QWidget* parent) : QWidget(parent) { + setCursor(Qt::PointingHandCursor); + setMouseTracking(false); +} + +// ============================================================================ +// 区间端点 +// ============================================================================ +int RangeSlider::minimum() const { + return minimum_; +} + +void RangeSlider::setMinimum(int min) { + if (min > maximum_) { + // 新最小值越过当前最大值:把最大值也顶上去,保 minimum<=maximum + maximum_ = min; + } + if (min == minimum_) { + return; + } + minimum_ = min; + + // 现有值夹到新区间,并维持 lower<=upper + const int new_lower = std::clamp(lower_value_, minimum_, maximum_); + const int new_upper = std::clamp(upper_value_, minimum_, maximum_); + if (new_lower != lower_value_ || new_upper != upper_value_) { + lower_value_ = new_lower; + upper_value_ = new_upper; + emit lowerValueChanged(lower_value_); + emit upperValueChanged(upper_value_); + emit rangeChanged(lower_value_, upper_value_); + } + emit minimumChanged(minimum_); + update(); +} + +int RangeSlider::maximum() const { + return maximum_; +} + +void RangeSlider::setMaximum(int max) { + if (max < minimum_) { + minimum_ = max; + } + if (max == maximum_) { + return; + } + maximum_ = max; + + const int new_lower = std::clamp(lower_value_, minimum_, maximum_); + const int new_upper = std::clamp(upper_value_, minimum_, maximum_); + if (new_lower != lower_value_ || new_upper != upper_value_) { + lower_value_ = new_lower; + upper_value_ = new_upper; + emit lowerValueChanged(lower_value_); + emit upperValueChanged(upper_value_); + emit rangeChanged(lower_value_, upper_value_); + } + emit maximumChanged(maximum_); + update(); +} + +void RangeSlider::setRange(int min, int max) { + if (min > max) { + std::swap(min, max); // 防止反着传,自动纠正是比断言更友好的契约 + } + // 静默更新端点,再用统一夹值流程发信号,避免分别 set 时的中间非法态 + minimum_ = min; + maximum_ = max; + + const int new_lower = std::clamp(lower_value_, minimum_, maximum_); + const int new_upper = std::clamp(upper_value_, minimum_, maximum_); + const bool value_changed = (new_lower != lower_value_) || (new_upper != upper_value_); + lower_value_ = new_lower; + upper_value_ = new_upper; + + emit minimumChanged(minimum_); + emit maximumChanged(maximum_); + if (value_changed) { + emit lowerValueChanged(lower_value_); + emit upperValueChanged(upper_value_); + emit rangeChanged(lower_value_, upper_value_); + } + update(); +} + +// ============================================================================ +// 手柄值 +// ============================================================================ +int RangeSlider::lowerValue() const { + return lower_value_; +} + +void RangeSlider::setLowerValue(int value) { + // 夹到 [minimum, upper - gap],保证 lower 始终不越过 upper + const int clamped = std::clamp(value, minimum_, upper_value_ - kHandleGap); + if (clamped == lower_value_) { + return; + } + lower_value_ = clamped; + emit lowerValueChanged(lower_value_); + emit rangeChanged(lower_value_, upper_value_); + update(); // 异步合并重绘,拖动跟手且不掉帧 +} + +int RangeSlider::upperValue() const { + return upper_value_; +} + +void RangeSlider::setUpperValue(int value) { + const int clamped = std::clamp(value, lower_value_ + kHandleGap, maximum_); + if (clamped == upper_value_) { + return; + } + upper_value_ = clamped; + emit upperValueChanged(upper_value_); + emit rangeChanged(lower_value_, upper_value_); + update(); +} + +// ============================================================================ +// 配色 +// ============================================================================ +QColor RangeSlider::handleColor() const { + return handle_color_; +} +void RangeSlider::setHandleColor(const QColor& c) { + if (handle_color_ == c) + return; + handle_color_ = c; + update(); + emit handleColorChanged(c); +} + +QColor RangeSlider::trackColor() const { + return track_color_; +} +void RangeSlider::setTrackColor(const QColor& c) { + if (track_color_ == c) + return; + track_color_ = c; + update(); + emit trackColorChanged(c); +} + +QColor RangeSlider::rangeColor() const { + return range_color_; +} +void RangeSlider::setRangeColor(const QColor& c) { + if (range_color_ == c) + return; + range_color_ = c; + update(); + emit rangeColorChanged(c); +} + +// ============================================================================ +// 尺寸 +// ============================================================================ +QSize RangeSlider::sizeHint() const { + return {200, 24}; +} +QSize RangeSlider::minimumSizeHint() const { + return {120, 20}; +} + +// ============================================================================ +// 几何映射 +// ============================================================================ +QRectF RangeSlider::trackRect() const { + const qreal radius = kHandleDiameter / 2.0; + // 左右各留半个手柄:让端点值(min/max)的圆心正好落在轨道两端 + return QRectF(rect()).adjusted(radius, 0, -radius, 0); +} + +qreal RangeSlider::valueToX(int value) const { + const QRectF tr = trackRect(); + const qreal span = static_cast(maximum_ - minimum_); + if (span <= 0.0) { + return tr.left(); // min==max 时无行程,所有值都映射到左端 + } + const qreal t = static_cast(value - minimum_) / span; + return tr.left() + t * tr.width(); +} + +int RangeSlider::xToValue(qreal x) const { + const QRectF tr = trackRect(); + const qreal span = static_cast(maximum_ - minimum_); + if (span <= 0.0 || tr.width() <= 0.0) { + return minimum_; // 除零兜底:控件过窄或区间为空 + } + const qreal t = (x - tr.left()) / tr.width(); + return static_cast(std::round(minimum_ + t * span)); +} + +RangeSlider::ActiveHandle RangeSlider::hitTestHandle(qreal x) const { + const qreal lower_x = valueToX(lower_value_); + const qreal upper_x = valueToX(upper_value_); + const qreal d_lower = std::abs(x - lower_x); + const qreal d_upper = std::abs(x - upper_x); + // 离谁近抓谁;都在容差内才认,否则返回 kNone(不在任何手柄上点击) + if (d_lower <= kHitTolerance && d_lower <= d_upper) { + return ActiveHandle::kLower; + } + if (d_upper <= kHitTolerance) { + return ActiveHandle::kUpper; + } + return ActiveHandle::kNone; +} + +// ============================================================================ +// 鼠标交互(照 toggle-switch 三事件 + 阈值拖动模式) +// ============================================================================ +void RangeSlider::mousePressEvent(QMouseEvent* event) { + if (event->button() != Qt::LeftButton) { + QWidget::mousePressEvent(event); + return; + } + press_x_ = event->position().x(); + dragging_ = false; + // 命中测试:点在手柄容差内就预选该手柄;实际抓取等拖过阈值再定(防误判点击) + active_handle_ = hitTestHandle(press_x_); + event->accept(); +} + +void RangeSlider::mouseMoveEvent(QMouseEvent* event) { + if (!(event->buttons() & Qt::LeftButton)) { + QWidget::mouseMoveEvent(event); + return; + } + const qreal dx = event->position().x() - press_x_; + // 超阈值才进入拖动:避免一次轻微抖动就被当成拖(toggle-switch 踩坑①) + if (!dragging_ && (dx > kDragThreshold || dx < -kDragThreshold)) { + dragging_ = true; + } + if (dragging_) { + const int v = xToValue(event->position().x()); + if (active_handle_ == ActiveHandle::kLower) { + setLowerValue(v); + } else if (active_handle_ == ActiveHandle::kUpper) { + setUpperValue(v); + } + } + event->accept(); +} + +void RangeSlider::mouseReleaseEvent(QMouseEvent* event) { + if (event->button() != Qt::LeftButton) { + QWidget::mouseReleaseEvent(event); + return; + } + // 未拖动即纯点击:把最近手柄直接吸到点击位置(更贴自然的滑块手感) + if (!dragging_ && active_handle_ != ActiveHandle::kNone) { + const int v = xToValue(event->position().x()); + if (active_handle_ == ActiveHandle::kLower) { + setLowerValue(v); + } else { + setUpperValue(v); + } + } + active_handle_ = ActiveHandle::kNone; + dragging_ = false; + event->accept(); +} + +// ============================================================================ +// 自绘:圆角轨道 + 选中区间高亮 + 两圆手柄 +// ============================================================================ +void RangeSlider::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const qreal radius = kHandleDiameter / 2.0; + // 轨道条占控件垂直居中一带,高度比手柄略瘦,让手柄凸出可点 + const qreal bar_height = std::max(2.0, kHandleDiameter * 0.55); + const qreal top = (height() - bar_height) / 2.0; + const QRectF bar_rect(0, top, width(), bar_height); + + // 底层轨道 + p.setPen(Qt::NoPen); + p.setBrush(track_color_); + p.drawRoundedRect(bar_rect, bar_height / 2.0, bar_height / 2.0); + + // 选中区间(lower→upper 之间)高亮覆盖 + const qreal x_lower = valueToX(lower_value_); + const qreal x_upper = valueToX(upper_value_); + // x_lower 可能因 gap 等于 x_upper,clamp 宽度防 drawRoundedRect 退化 + const qreal range_left = std::min(x_lower, x_upper); + const qreal range_w = std::max(0.0, std::abs(x_upper - x_lower)); + p.setBrush(range_color_); + p.drawRoundedRect(QRectF(range_left, top, range_w, bar_height), bar_height / 2.0, + bar_height / 2.0); + + // 两手柄:圆心 x = valueToX,y 居中;给一圈描边提升辨识度 + const qreal cy = height() / 2.0; + QPen outline(QColor(180, 180, 180), 1.0); + p.setBrush(handle_color_); + p.setPen(outline); + p.drawEllipse(QPointF(x_lower, cy), radius, radius); + p.drawEllipse(QPointF(x_upper, cy), radius, radius); +} + +} // namespace AwesomeQt diff --git a/widget/speed-meter/CMakeLists.txt b/widget/speed-meter/CMakeLists.txt new file mode 100644 index 0000000..3f58bf5 --- /dev/null +++ b/widget/speed-meter/CMakeLists.txt @@ -0,0 +1,19 @@ +# SpeedMeter 控件——STATIC 库 + 独立 demo +# 全局配置(C++ 标准 / AUTOMOC / find_package)由根 widget/CMakeLists.txt 提供 + +add_library(speed_meter STATIC + include/speed_meter.h + src/speed_meter.cpp +) + +target_include_directories(speed_meter PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(speed_meter PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Widgets +) + +add_subdirectory(demo) diff --git a/widget/speed-meter/demo/CMakeLists.txt b/widget/speed-meter/demo/CMakeLists.txt new file mode 100644 index 0000000..0dbb73f --- /dev/null +++ b/widget/speed-meter/demo/CMakeLists.txt @@ -0,0 +1,11 @@ +# SpeedMeter 演示程序:静态几档 + Cycle 过渡 + Slider 驱动 + 随机跳变 + +qt_add_executable(speed_meter_demo + main.cpp + speed_meter_window.cpp + speed_meter_window.h +) + +target_link_libraries(speed_meter_demo PRIVATE + speed_meter +) diff --git a/widget/speed-meter/demo/main.cpp b/widget/speed-meter/demo/main.cpp new file mode 100644 index 0000000..ca1f158 --- /dev/null +++ b/widget/speed-meter/demo/main.cpp @@ -0,0 +1,16 @@ +/** + * @file main.cpp + * @brief SpeedMeter 演示程序入口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "speed_meter_window.h" + +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + SpeedMeterWindow window; + window.show(); + return app.exec(); +} diff --git a/widget/speed-meter/demo/speed_meter_window.cpp b/widget/speed-meter/demo/speed_meter_window.cpp new file mode 100644 index 0000000..cb619ed --- /dev/null +++ b/widget/speed-meter/demo/speed_meter_window.cpp @@ -0,0 +1,120 @@ +/** + * @file speed_meter_window.cpp + * @brief SpeedMeter 演示主窗口实现 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "speed_meter_window.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "speed_meter.h" + +namespace { +// 静态档位展示的取值 +constexpr int kStaticSteps[] = {0, 60, 120, 180, 220}; +} // namespace + +SpeedMeterWindow::SpeedMeterWindow(QWidget* parent) : QMainWindow(parent) { + setupUi(); +} + +QWidget* SpeedMeterWindow::setupStaticLayout() { + auto* group = new QGroupBox("Static Speed Meters"); + auto* layout = new QHBoxLayout(group); + + for (int step : kStaticSteps) { + auto* meter = new AwesomeQt::SpeedMeter(group); + meter->setValue(step); // 初值,无动画 + auto* label = new QLabel(QString::number(step), group); + label->setAlignment(Qt::AlignCenter); + + auto* col = new QVBoxLayout(); + col->addWidget(meter, 0, Qt::AlignCenter); + col->addWidget(label, 0, Qt::AlignCenter); + layout->addLayout(col); + } + + return group; +} + +QWidget* SpeedMeterWindow::setupInteractiveLayout() { + auto* group = new QGroupBox("Cycle(看指针过渡)"); + auto* layout = new QHBoxLayout(group); + + auto* meter = new AwesomeQt::SpeedMeter(group); + meter->setFixedSize(180, 180); + + auto* cycle_btn = new QPushButton("Cycle Value", group); + // 在几档间循环,观察指针接力过渡 + static const int seq[] = {0, 60, 120, 180, 220, 160, 40}; + connect(cycle_btn, &QPushButton::clicked, group, [meter]() { + static int idx = 0; + idx = (idx + 1) % 7; + meter->setValue(seq[idx]); + }); + + layout->addWidget(meter, 0, Qt::AlignCenter); + layout->addWidget(cycle_btn); + + return group; +} + +QWidget* SpeedMeterWindow::setupSliderLayout() { + auto* group = new QGroupBox("Slider 驱动(0..220)"); + auto* layout = new QVBoxLayout(group); + + auto* meter = new AwesomeQt::SpeedMeter(group); + meter->setFixedSize(160, 160); + + auto* slider = new QSlider(Qt::Horizontal, group); + slider->setRange(0, 220); + slider->setValue(0); + + // 拖动滑块逐次 setValue——每次都 stop()/接力,看指针连贯追踪 + connect(slider, &QSlider::valueChanged, meter, &AwesomeQt::SpeedMeter::setValue); + + layout->addWidget(meter, 0, Qt::AlignCenter); + layout->addWidget(slider); + + return group; +} + +QWidget* SpeedMeterWindow::setupRandomLayout() { + auto* group = new QGroupBox("随机跳变(测 stop()/接力不跳变)"); + auto* layout = new QHBoxLayout(group); + + auto* meter = new AwesomeQt::SpeedMeter(group); + meter->setFixedSize(160, 160); + + auto* random_btn = new QPushButton("Random Jump", group); + // 连点多次随机跳:动画中途 stop()+重配 setStartValue(当前角度), + // 指针应平滑改向而不闪回旧目标(防跳变的关键) + connect(random_btn, &QPushButton::clicked, group, + [meter]() { meter->setValue(QRandomGenerator::global()->bounded(0, 221)); }); + + layout->addWidget(meter, 0, Qt::AlignCenter); + layout->addWidget(random_btn); + + return group; +} + +void SpeedMeterWindow::setupUi() { + auto* central = new QWidget(this); + setCentralWidget(central); + + auto* layout = new QVBoxLayout(central); + layout->addWidget(setupStaticLayout()); + layout->addWidget(setupInteractiveLayout()); + layout->addWidget(setupSliderLayout()); + layout->addWidget(setupRandomLayout()); + + setWindowTitle("SpeedMeter Widget Demo"); + resize(640, 720); +} diff --git a/widget/speed-meter/demo/speed_meter_window.h b/widget/speed-meter/demo/speed_meter_window.h new file mode 100644 index 0000000..e073db2 --- /dev/null +++ b/widget/speed-meter/demo/speed_meter_window.h @@ -0,0 +1,21 @@ +/** + * @file speed_meter_window.h + * @brief SpeedMeter 控件演示主窗口 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#pragma once + +#include + +class SpeedMeterWindow : public QMainWindow { + public: + explicit SpeedMeterWindow(QWidget* parent = nullptr); + + private: + void setupUi(); + QWidget* setupStaticLayout(); // 静态几档:0/60/120/180/220 + QWidget* setupInteractiveLayout(); // 大表 + Cycle + QWidget* setupSliderLayout(); // QSlider 0..220 驱动 + QWidget* setupRandomLayout(); // 随机跳变(测 stop()/接力不跳变) +}; diff --git a/widget/speed-meter/include/speed_meter.h b/widget/speed-meter/include/speed_meter.h new file mode 100644 index 0000000..42aff38 --- /dev/null +++ b/widget/speed-meter/include/speed_meter.h @@ -0,0 +1,93 @@ +/** + * @file speed_meter.h + * @brief 速度仪表盘控件 SpeedMeter——动画指针 + 主/次刻度 + 数字读数 + * @copyright Copyright (c) 2026 AwesomeQt + */ +#pragma once + +#include +#include + +class QPropertyAnimation; + +namespace AwesomeQt { + +/// 速度仪表盘:value 0..maxValue(默认 220)的指针式表盘。 +/// +/// 设计要点(详见成品导览 index.md): +/// - `value` 是业务属性(用户语义),`needleAngle` 是动画属性(实际绘制角度)。 +/// setValue 计算 value→角度映射后,用 QPropertyAnimation 从当前角度接力到新角度, +/// 指针平滑旋转;二者解耦,避免「WRITE 指 setValue→setValue 又启动画→栈溢出」(踩坑⑦)。 +/// - 角度约定:用「屏幕角 β」(3 点钟为 0°、顺时针为正,与 rotate/cos·sin 在 y 朝下屏幕一致), +/// β(v)=135°+(v/max)×270° → v=0 左下(135°)、mid 顶部(270°)、v=max 右下(45°),开口朝下。 +/// drawArc 单独用 Qt 自带约定(0°=3点、逆时针、1/16°),与 β 的换算见 .cpp 顶部。 +/// - 动画对象为持久成员指针,stop()/重配 setStartValue(当前角度)/start() 复用, +/// 不用 DeleteWhenStopped(防连切悬空)。 +class SpeedMeter : public QWidget { + Q_OBJECT + + // —— Q_PROPERTY:value / needleAngle / maxValue / 三色 均可被动画/Designer 驱动 —— + Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) + Q_PROPERTY(qreal needleAngle READ needleAngle WRITE setNeedleAngle NOTIFY needleAngleChanged) + Q_PROPERTY(int maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) + Q_PROPERTY(QColor needleColor READ needleColor WRITE setNeedleColor NOTIFY needleColorChanged) + Q_PROPERTY(QColor tickColor READ tickColor WRITE setTickColor NOTIFY tickColorChanged) + Q_PROPERTY(QColor gaugeColor READ gaugeColor WRITE setGaugeColor NOTIFY gaugeColorChanged) + + public: + explicit SpeedMeter(QWidget* parent = nullptr); + + /// @brief 设置速度值(业务入口):触发指针从当前角度接力到新角度的平滑旋转。 + void setValue(int value); + int value() const; + + /// @brief 当前指针绘制角度(度,屏幕角 β)。Q_PROPERTY(needleAngle) 的 WRITE 回调, + /// 供 QPropertyAnimation 每帧驱动;外部业务请用 setValue,勿直接调。 + void setNeedleAngle(qreal angle); + qreal needleAngle() const; + + /// @brief 设置量程上限(>0)。改量程后指针角度按新映射重算。 + void setMaxValue(int maxValue); + int maxValue() const; + + void setNeedleColor(const QColor& color); + QColor needleColor() const; + + void setTickColor(const QColor& color); + QColor tickColor() const; + + void setGaugeColor(const QColor& color); + QColor gaugeColor() const; + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + signals: + void valueChanged(int newValue); + void needleAngleChanged(qreal newAngle); + void maxValueChanged(int newMaxValue); + void needleColorChanged(const QColor& newColor); + void tickColorChanged(const QColor& newColor); + void gaugeColorChanged(const QColor& newColor); + + protected: + void paintEvent(QPaintEvent* event) override; + + private: + void initAnimation(); + + /// @brief value → 屏幕角 β(度)映射:β(v)=135°+(v/max)×270°(v=0 左下、v=max 右下)。 + qreal angleForValue(int value) const; + + int value_{0}; + int max_value_{220}; + qreal needle_angle_{135.0}; // 初始指向最小值(135°=左下 7:30) + + QColor needle_color_{QColor(255, 70, 70)}; // 指针:红 + QColor tick_color_{QColor(60, 60, 60)}; // 刻度:深灰 + QColor gauge_color_{QColor(220, 220, 220)}; // 背景弧:浅灰 + + QPropertyAnimation* needle_anim_{nullptr}; // 指针旋转(持久,非 DeleteWhenStopped) +}; + +} // namespace AwesomeQt diff --git a/widget/speed-meter/src/speed_meter.cpp b/widget/speed-meter/src/speed_meter.cpp new file mode 100644 index 0000000..ce6a9fc --- /dev/null +++ b/widget/speed-meter/src/speed_meter.cpp @@ -0,0 +1,315 @@ +/** + * @file speed_meter.cpp + * @brief SpeedMeter 控件实现——动画指针 + 刻度 + 数字读数 + * @copyright Copyright (c) 2026 AwesomeQt + */ + +#include "speed_meter.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace AwesomeQt { + +namespace { +// —— 仪表弧角度约定 —— +// 用「屏幕角 β」统一描述:3 点钟为 0°、顺时针为正(与 QPainter::rotate 及 +// cos/sin 在 y 朝下的屏幕坐标系完全一致)。整条弧: +// value=0 → 135°(左下 7:30) +// value=max → 45°(右下 4:30,= 135°+270° 取模 360) +// value=mid → 270°(顶部 12:00) +// 即 β(v) = kStartScreen + (v/max)*kSweep,开口朝下(6 点钟方向无刻度)。 +// drawArc 用 Qt 自带约定(0°=3 点、逆时针为正),与 β 差一个 y 翻转: +// β=135°(左下)↔ drawArc 起始角 225°,扫角取负(顺时针铺开)。 +constexpr qreal kStartScreen = 135.0; // β(v=0):左下 7:30 +constexpr qreal kSweep = 270.0; // 顺时针扫角 +constexpr int kArcStart16 = 225 * 16; // drawArc 起始角(= -β(0) mod 360,左下) +constexpr int kArcSpan16 = -270 * 16; // drawArc 扫角(负=顺时针) +constexpr int kMajorTickCount = 11; // 主刻度条数(含两端,量程 10 等分) + +constexpr qreal degToRad(qreal deg) { + return deg * M_PI / 180.0; +} +} // namespace + +// ============================================================================ +// 构造 +// ============================================================================ +SpeedMeter::SpeedMeter(QWidget* parent) : QWidget(parent) { + initAnimation(); + needle_angle_ = angleForValue(value_); // 初值与 value 对齐,不在动画中 +} + +// ============================================================================ +// 动画对象初始化(parent=this,对象树托管释放) +// ============================================================================ +void SpeedMeter::initAnimation() { + // 持久指针:stop()/重配/start() 复用,不用 DeleteWhenStopped(防连切悬空/叠加) + needle_anim_ = new QPropertyAnimation(this, "needleAngle", this); + needle_anim_->setDuration(400); + needle_anim_->setEasingCurve(QEasingCurve::OutCubic); +} + +// ============================================================================ +// value → 屏幕角 β 映射 +// ============================================================================ +qreal SpeedMeter::angleForValue(int v) const { + const int max = std::max(1, max_value_); // 防 max_value_<=0 除零 + const int clamped = std::clamp(v, 0, max_value_); // 夹到 [0, max] + // β(v) = 135° + (v/max)*270°:v=0 左下、v=max 右下、mid 顶部,开口朝下 + return kStartScreen + (static_cast(clamped) / max) * kSweep; +} + +// ============================================================================ +// 业务入口:触发指针接力旋转 +// ============================================================================ +void SpeedMeter::setValue(int value) { + if (value_ == value) { + return; + } + value_ = value; + emit valueChanged(value); + + // 从当前显示角度(可能是动画中间值)接力到新目标,避免跳回旧目标造成跳变 + needle_anim_->stop(); + needle_anim_->setStartValue(needle_angle_); + needle_anim_->setEndValue(angleForValue(value)); + needle_anim_->start(); +} + +int SpeedMeter::value() const { + return value_; +} + +// ============================================================================ +// Q_PROPERTY(needleAngle) 回调:动画每帧驱动写这里(纯赋值 + emit + update) +// ============================================================================ +void SpeedMeter::setNeedleAngle(qreal angle) { + if (qFuzzyCompare(needle_angle_, angle)) { + return; + } + needle_angle_ = angle; + emit needleAngleChanged(angle); + update(); // 异步请求重绘,不立即触发 paintEvent +} + +qreal SpeedMeter::needleAngle() const { + return needle_angle_; +} + +// ============================================================================ +// 量程 / 颜色 setter +// ============================================================================ +void SpeedMeter::setMaxValue(int maxValue) { + if (max_value_ == maxValue || maxValue <= 0) { + return; + } + max_value_ = maxValue; + emit maxValueChanged(maxValue); + + // 量程变了,指针角度按新映射重算并接力(不直接跳变) + needle_anim_->stop(); + needle_anim_->setStartValue(needle_angle_); + needle_anim_->setEndValue(angleForValue(value_)); + needle_anim_->start(); + update(); +} + +int SpeedMeter::maxValue() const { + return max_value_; +} + +void SpeedMeter::setNeedleColor(const QColor& color) { + if (needle_color_ == color) { + return; + } + needle_color_ = color; + update(); + emit needleColorChanged(color); +} + +QColor SpeedMeter::needleColor() const { + return needle_color_; +} + +void SpeedMeter::setTickColor(const QColor& color) { + if (tick_color_ == color) { + return; + } + tick_color_ = color; + update(); + emit tickColorChanged(color); +} + +QColor SpeedMeter::tickColor() const { + return tick_color_; +} + +void SpeedMeter::setGaugeColor(const QColor& color) { + if (gauge_color_ == color) { + return; + } + gauge_color_ = color; + update(); + emit gaugeColorChanged(color); +} + +QColor SpeedMeter::gaugeColor() const { + return gauge_color_; +} + +// ============================================================================ +// 尺寸 +// ============================================================================ +QSize SpeedMeter::sizeHint() const { + return QSize(200, 200); +} + +QSize SpeedMeter::minimumSizeHint() const { + return QSize(100, 100); +} + +// ============================================================================ +// 自绘 +// ============================================================================ +void SpeedMeter::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + // —— 几何:所有半径对 0/负值 clamp,防控件被压极小时行为未定义 —— + const qreal side = std::max(1, std::min(width(), height())); + const QPointF center(width() / 2.0, height() / 2.0); + const qreal outer_r = side / 2.0 - 4.0; // 背景弧半径 + const qreal gauge_r = std::max(1.0, outer_r); // clamp + const qreal tick_outer = gauge_r; // 主刻度外端贴弧 + const qreal tick_major_inner = std::max(1.0, gauge_r - 14.0); // 主刻度内端 + const qreal tick_minor_inner = std::max(1.0, gauge_r - 7.0); // 次刻度内端(更短) + const qreal needle_len = std::max(1.0, gauge_r - 22.0); // 指针尖到中心距离 + + // —— 背景弧:drawArc 用 Qt 约定(0°=3点、逆时针为正、单位 1/16°)。 + // kArcStart16/kArcSpan16 已按屏幕角 β 换算好(见顶部约定注释), + // 与下方刻度/指针描述同一物理弧(左下起、顺时针铺到右下,开口朝下)。 + { + QPen pen(gauge_color_); + pen.setWidthF(std::max(1.0, gauge_r * 0.06)); // 粗弧,随尺寸缩放但至少 1px + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + const QRectF arc_rect(center.x() - gauge_r, center.y() - gauge_r, gauge_r * 2, gauge_r * 2); + p.drawArc(arc_rect, kArcStart16, kArcSpan16); + } + + // —— 主刻度(kMajorTickCount 条)+ 数字标签 —— + const QFontMetrics fm(p.font()); + + // —— 标签挤压检测:相邻主刻度数字标签的弧距 < 标签宽 + 留白时,整组隐藏 —— + // 控件被布局压小时,11 个数字标签会挤成一团;藏掉比挤着更易读。 + // 弧距 = label_r × 相邻主刻度夹角(弧度);最宽标签按量程上限的位数估。 + const qreal kLabelGap = 4.0; + const qreal label_r = std::max(1.0, tick_major_inner - 14.0); + const qreal label_arc_step = label_r * degToRad(kSweep / (kMajorTickCount - 1)); + const qreal widest_label_w = fm.horizontalAdvance(QString::number(max_value_)); + const bool show_tick_labels = label_arc_step >= widest_label_w + kLabelGap; + + p.setPen(QPen(tick_color_, 2)); + for (int i = 0; i < kMajorTickCount; ++i) { + // 第 i 个主刻度对应的屏幕角 β(与 angleForValue 同映射) + const qreal t = static_cast(i) / (kMajorTickCount - 1); + const qreal ang = kStartScreen + t * kSweep; + const qreal rad = degToRad(ang); + const QPointF outer(center.x() + tick_outer * std::cos(rad), + center.y() + tick_outer * std::sin(rad)); + const QPointF inner(center.x() + tick_major_inner * std::cos(rad), + center.y() + tick_major_inner * std::sin(rad)); + p.drawLine(inner, outer); + + // 数字标签:在主刻度内端再往内一点,标该刻度对应的 value + // (控件太小时 show_tick_labels=false,整组藏掉防挤压) + if (show_tick_labels) { + const QPointF label_pos(center.x() + label_r * std::cos(rad), + center.y() + label_r * std::sin(rad)); + const int tick_value = static_cast(std::round(t * max_value_)); // 该刻度代表的数值 + const QString label_text = QString::number(tick_value); + p.setPen(tick_color_); + p.drawText(QRectF(label_pos.x() - fm.horizontalAdvance(label_text) / 2.0, + label_pos.y() - fm.height() / 2.0, fm.horizontalAdvance(label_text), + fm.height()), + Qt::AlignCenter, label_text); + p.setPen(QPen(tick_color_, 2)); // 复位为刻度线画笔 + } + } + + // —— 次刻度:每两个主刻度间 5 等分(4 条次刻度) —— + p.setPen(QPen(tick_color_, 1)); + for (int i = 0; i < kMajorTickCount - 1; ++i) { + for (int j = 1; j < 5; ++j) { + const qreal t = + (static_cast(i) + static_cast(j) / 5.0) / (kMajorTickCount - 1); + const qreal ang = kStartScreen + t * kSweep; + const qreal rad = degToRad(ang); + const QPointF outer(center.x() + tick_outer * std::cos(rad), + center.y() + tick_outer * std::sin(rad)); + const QPointF inner(center.x() + tick_minor_inner * std::cos(rad), + center.y() + tick_minor_inner * std::sin(rad)); + p.drawLine(inner, outer); + } + } + + // —— 指针:用 translate(center)+rotate 画根粗尖细的多边形 —— + { + // needle_angle_ 就是屏幕角 β(顺时针为正)。rotate(β) 把指针(默认指 +x=3 点钟) + // 直接转到目标方向:v=0(135°)→左下、v=max(45°)→右下,与刻度/标签同映射。 + p.save(); + p.translate(center); + p.rotate(needle_angle_); + + // 多边形:根在中心附近(宽),尖往内半径方向(窄),水平指向 +x 方向 + const qreal base_w = std::max(1.5, needle_len * 0.04); // 根部半宽 + const qreal tip_w = std::max(0.5, needle_len * 0.015); // 尖部半宽 + const qreal tail = needle_len * 0.12; // 尾端伸出中心一点 + QPolygonF needle; + needle << QPointF(-tail, 0.0) // 尾端中点 + << QPointF(-tail, base_w) // 根部上 + << QPointF(needle_len, tip_w) // 尖部上 + << QPointF(needle_len, -tip_w) // 尖部下 + << QPointF(-tail, -base_w); // 根部下 + p.setPen(Qt::NoPen); + p.setBrush(needle_color_); + p.drawPolygon(needle); + p.restore(); + } + + // —— 中心轴帽:小实心圆 —— + { + const qreal cap_r = std::max(1.0, needle_len * 0.08); + p.setPen(Qt::NoPen); + p.setBrush(needle_color_.darker(130)); + p.drawEllipse(center, cap_r, cap_r); + } + + // —— 底部数字读数:当前 value 居中 —— + { + const QString text = QString::number(value_); + QFont f = p.font(); + f.setBold(true); + f.setPointSizeF(std::max(8.0, side * 0.08)); // 字号随尺寸缩放但至少 8pt + p.setFont(f); + const QFontMetrics fm2(f); + const qreal readout_y = center.y() + gauge_r * 0.55; // 底部偏上一点(开口下方) + const QRectF text_rect(center.x() - fm2.horizontalAdvance(text) / 2.0, + readout_y - fm2.height() / 2.0, fm2.horizontalAdvance(text), + fm2.height()); + p.setPen(needle_color_); + p.drawText(text_rect, Qt::AlignCenter, text); + } +} + +} // namespace AwesomeQt