From 18a1cb7ee4255b0e22ccf02902817516a25e373f Mon Sep 17 00:00:00 2001
From: Yuki <2622126521@qq.com>
Date: Wed, 29 Jul 2026 10:31:29 +0800
Subject: [PATCH] =?UTF-8?q?Revert=20"[2046]=20Revert=20"=20startup?=
=?UTF-8?q?=E7=9B=B8=E5=85=B3UI=E9=87=8D=E7=BD=AE=E4=B8=BAqml=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=20(#4074)"=20(#4098)"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit d7189279f02a187be407ffbb0ddee9d8d7f70e6a.
---
TeXmacs/misc/themes/liii-night.css | 189 +---
TeXmacs/misc/themes/liii.css | 165 +---
.../progs/startup-tab/startup-tab-file.scm | 2 +-
ai-docs/startup/README.md | 320 +++++++
ai-docs/startup/startup.html | 477 ++++++++++
devel/2046.md | 264 ++++++
src/Mogan/Cache/thumbnail_loader.cpp | 336 ++++---
src/Mogan/Cache/thumbnail_loader.hpp | 97 +-
src/Mogan/TemplateCenter/template_manager.cpp | 17 +-
src/Mogan/TemplateCenter/template_manager.hpp | 11 +-
src/Plugins/Qt/QTMHomePage.cpp | 841 ------------------
src/Plugins/Qt/QTMHomePage.hpp | 141 ---
src/Plugins/Qt/QTMStartupTabWidget.cpp | 373 ++------
src/Plugins/Qt/QTMStartupTabWidget.hpp | 76 +-
src/Plugins/Qt/QTMTemplatePage.cpp | 512 -----------
src/Plugins/Qt/QTMTemplatePage.hpp | 84 --
src/Plugins/Qt/StartupBridge.cpp | 588 ++++++++++++
src/Plugins/Qt/StartupBridge.hpp | 118 +++
src/Plugins/Qt/moganqml.qrc | 42 +-
.../Qt/qml/startup/StartupHomePage.qml | 212 +++++
src/Plugins/Qt/qml/startup/StartupTab.qml | 180 ++++
.../Qt/qml/startup/StartupTemplatePage.qml | 105 +++
.../Qt/qml/startup/atoms/StartupTheme.qml | 116 +++
.../Qt/qml/startup/atoms/StyleCard.qml | 136 +++
.../Qt/qml/startup/atoms/TemplateCard.qml | 107 +++
src/Plugins/Qt/qml/startup/atoms/qmldir | 3 +
src/Plugins/Qt/qml/startup/qmldir | 3 +
tests/Mogan/Startup/startup_bridge_test.cpp | 197 ++++
.../Mogan/Startup/startup_tab_widget_test.cpp | 126 ---
29 files changed, 3244 insertions(+), 2594 deletions(-)
create mode 100644 ai-docs/startup/README.md
create mode 100644 ai-docs/startup/startup.html
create mode 100644 devel/2046.md
delete mode 100644 src/Plugins/Qt/QTMHomePage.cpp
delete mode 100644 src/Plugins/Qt/QTMHomePage.hpp
delete mode 100644 src/Plugins/Qt/QTMTemplatePage.cpp
delete mode 100644 src/Plugins/Qt/QTMTemplatePage.hpp
create mode 100644 src/Plugins/Qt/StartupBridge.cpp
create mode 100644 src/Plugins/Qt/StartupBridge.hpp
create mode 100644 src/Plugins/Qt/qml/startup/StartupHomePage.qml
create mode 100644 src/Plugins/Qt/qml/startup/StartupTab.qml
create mode 100644 src/Plugins/Qt/qml/startup/StartupTemplatePage.qml
create mode 100644 src/Plugins/Qt/qml/startup/atoms/StartupTheme.qml
create mode 100644 src/Plugins/Qt/qml/startup/atoms/StyleCard.qml
create mode 100644 src/Plugins/Qt/qml/startup/atoms/TemplateCard.qml
create mode 100644 src/Plugins/Qt/qml/startup/atoms/qmldir
create mode 100644 src/Plugins/Qt/qml/startup/qmldir
create mode 100644 tests/Mogan/Startup/startup_bridge_test.cpp
delete mode 100644 tests/Mogan/Startup/startup_tab_widget_test.cpp
diff --git a/TeXmacs/misc/themes/liii-night.css b/TeXmacs/misc/themes/liii-night.css
index da639c7bc5..f32b46be56 100644
--- a/TeXmacs/misc/themes/liii-night.css
+++ b/TeXmacs/misc/themes/liii-night.css
@@ -1055,105 +1055,7 @@ QWidget#auxiliary_container {
background-color: rgba(100, 181, 246, 0.1);
}
-/****************************************************************************
-* 启动标签页样式 (Startup Tab) - 墨绿主题 Dark Mode
-* 主色: #215a6a (深墨绿)
-* 辅色: #2791ad (浅墨绿/青色)
-* 文字: 白色 / 黑色
-****************************************************************************/
-
-/* 启动标签页主窗口 - 左侧导航栏
- 使用更具体的选择器覆盖 centralWidget QWidget 的定义 */
-QWidget#centralWidget QWidget#startup-tab-sidebar {
- background-color: #215a6a;
-}
-
-QWidget#centralWidget QWidget#startup-tab-category-container {
- background-color: #215a6a;
-}
-
-/* 导航栏分组标题 */
-QLabel#startup-tab-nav-title {
- color: rgba(255, 255, 255, 0.6);
- font-weight: bold;
-}
-
-QWidget#centralWidget QLabel#startup-tab-nav-title {
- background-color: transparent;
-}
-
-/* 导航按钮 - 普通状态 */
-QPushButton#startup-tab-nav-btn {
- color: #ffffff;
- border: none;
- text-align: center;
-}
-
-QWidget#centralWidget QPushButton#startup-tab-nav-btn {
- background-color: transparent;
-}
-
-QWidget#centralWidget QPushButton#startup-tab-nav-btn:hover {
- background-color: rgba(255, 255, 255, 0.15);
-}
-
-/* 导航按钮 - 选中状态 */
-QWidget#centralWidget QPushButton#startup-tab-nav-btn:checked {
- background-color: #2791ad;
-}
-
-/* 退出按钮 */
-QPushButton#startup-tab-quit-btn {
- color: #ffffff;
- border: 1px solid rgba(255, 255, 255, 0.5);
- text-align: center;
-}
-
-QWidget#centralWidget QPushButton#startup-tab-quit-btn {
- background-color: transparent;
-}
-
-QWidget#centralWidget QPushButton#startup-tab-quit-btn:hover {
- background-color: rgba(255, 255, 255, 0.2);
- border-color: #ffffff;
-}
-
-/* 右侧内容区 */
-QWidget#centralWidget QWidget#startup-tab-content {
- background-color: #2c2c2c;
-}
-
-QWidget#centralWidget QWidget#startup-tab-file-cards,
-QWidget#centralWidget QWidget#startup-tab-template-item,
-QWidget#centralWidget QLabel#startup-tab-template-name,
-QWidget#centralWidget QLabel#startup-tab-template-info {
- background-color: #2c2c2c;
-}
-
-/* 页面描述文字 */
-QLabel#startup-tab-page-desc {
- color: #aaaaaa;
-}
-
-/* 模板网格容器 */
-QWidget#centralWidget QWidget#startup-tab-grid {
- background-color: #2c2c2c;
-}
-
-/* 模板缩略图卡片 */
-QFrame#startup-tab-template-card {
- border: 1px solid #3c3c3c;
-}
-
-QWidget#centralWidget QFrame#startup-tab-template-card {
- background-color: #ffffff;
-}
-
-QFrame#startup-tab-template-card:hover {
- border: 2px solid #2791ad;
-}
-
-/* 模板使用按钮 - Template Use Button */
+/* 模板预览对话框按钮 */
QPushButton#template-use-btn {
color: white;
font-weight: bold;
@@ -1182,95 +1084,6 @@ QLabel#template-preview-desc {
color: #aaaaaa;
}
-QLabel#startup-tab-template-info {
- color: #b5bfca;
-}
-
-QLabel#startup-tab-template-thumbnail {
- background: #4a4f57;
-}
-
-/* Startup tab - file page */
-QFrame#startup-tab-separator {
- color: #4a4f57;
-}
-
-QWidget#centralWidget QLabel#startup-tab-section-title {
- font-weight: 600;
- color: #f0f4f7;
- background-color: #2c2c2c;
-}
-
-QListWidget#recent-docs-list {
- border: 1px solid #4a4f57;
- background-color: #3f434a;
- outline: none;
-}
-
-QListWidget#recent-docs-list::item {
- border: none;
-}
-
-QListWidget#recent-docs-list::item:hover {
- background-color: #4a4f57;
-}
-
-QListWidget#recent-docs-list::item:selected {
- background-color: #4e5d68;
-}
-
-QWidget#startup-tab-recent-item {
- background: transparent;
-}
-
-QLabel#startup-tab-recent-name {
- font-weight: 700;
- color: #f2f6f8;
-}
-
-QWidget#style-card {
- border: 1px solid #3c3c3c;
- border-radius: 8px;
-}
-
-QWidget#style-card:hover {
- border: 1px solid #2791ad;
-}
-
-QLabel#style-card-icon {
- color: #ecf3f6;
- font-weight: 700;
-}
-
-QLabel#style-card-icon-new {
- border-image: url(":/startup-tab/new-file-white.svg") 0 0 0 0 stretch stretch;
-}
-
-QLabel#style-card-icon-open {
- border-image: url(":/startup-tab/open-file-white.svg") 0 0 0 0 stretch stretch;
-}
-
-QLabel#style-card-name {
- color: #e2e8ee;
-}
-
-QFrame#style-card-frame {
- border: 1px solid #3c3c3c;
- background-color: #3a3a3a;
-}
-
-QFrame#style-card-frame:hover {
- border: 1px solid #2791ad;
-}
-
-QLabel#style-card-thumbnail {
- background: #4a4f57;
-}
-
-QLabel#style-card-title {
- color: #e2e8ee;
-}
-
/****************************************************************************
* PDF Preview Widget 样式
****************************************************************************/
diff --git a/TeXmacs/misc/themes/liii.css b/TeXmacs/misc/themes/liii.css
index e61285e652..ee9435849b 100644
--- a/TeXmacs/misc/themes/liii.css
+++ b/TeXmacs/misc/themes/liii.css
@@ -1065,80 +1065,7 @@ QLabel#login-update-title {
background: rgba(255, 255, 255, 0.92);
}
-/****************************************************************************
-* 启动标签页样式 (Startup Tab) - 墨绿主题
-* 主色: #215a6a (深墨绿)
-* 辅色: #2791ad (浅墨绿/青色)
-* 文字: 白色 / 黑色
-****************************************************************************/
-
-/* 启动标签页主窗口 - 左侧导航栏 */
-QWidget#startup-tab-sidebar {
- background-color: #215a6a;
-}
-
-/* 导航栏分组标题 */
-QLabel#startup-tab-nav-title {
- color: rgba(255, 255, 255, 0.6);
- font-weight: bold;
-}
-
-/* 导航按钮 - 普通状态 */
-QPushButton#startup-tab-nav-btn {
- color: #ffffff;
- background-color: transparent;
- border: none;
- text-align: center;
-}
-
-QPushButton#startup-tab-nav-btn:hover {
- background-color: rgba(255, 255, 255, 0.15);
-}
-
-/* 导航按钮 - 选中状态 */
-QPushButton#startup-tab-nav-btn:checked {
- background-color: #2791ad;
-}
-
-/* 退出按钮 */
-QPushButton#startup-tab-quit-btn {
- color: #ffffff;
- background-color: transparent;
- border: 1px solid rgba(255, 255, 255, 0.5);
- text-align: center;
-}
-
-QPushButton#startup-tab-quit-btn:hover {
- background-color: rgba(255, 255, 255, 0.2);
- border-color: #ffffff;
-}
-
-/* 右侧内容区 */
-QWidget#startup-tab-content {
- background-color: #f3f3f3;
-}
-
-/* 页面描述文字 */
-QLabel#startup-tab-page-desc {
- color: #666666;
-}
-
-/* 模板网格容器 */
-QWidget#startup-tab-grid {
- background-color: #f3f3f3;
-}
-
-/* 模板缩略图卡片 */
-QFrame#startup-tab-template-card {
- border: 1px solid #cfcfcf;
- background-color: #ffffff;
-}
-
-QFrame#startup-tab-template-card:hover {
- border: 1px solid #2791ad;
-}
-
-/* 模板使用按钮 - Template Use Button */
+/* 模板预览对话框按钮 */
QPushButton#template-use-btn {
background-color: #2791ad;
color: white;
@@ -1164,96 +1091,6 @@ QLabel#template-preview-desc {
color: #666666;
}
-QLabel#startup-tab-template-info {
- color: #888888;
-}
-
-QLabel#startup-tab-template-thumbnail {
- background: #f5f5f5;
-}
-
-/* Startup tab - file page */
-QFrame#startup-tab-separator {
- color: #d8dde2;
-}
-
-QLabel#startup-tab-section-title {
- font-weight: 600;
- color: #215a6a;
-}
-
-QListWidget#recent-docs-list {
- border: 1px solid #d7dce2;
- background-color: #ffffff;
- outline: none;
-}
-
-QListWidget#recent-docs-list::item {
- border: none;
-}
-
-QListWidget#recent-docs-list::item:hover {
- background-color: #f5f5f5;
-}
-
-QListWidget#recent-docs-list::item:selected {
- background-color: #e8f3f7;
-}
-
-QWidget#startup-tab-recent-item {
- background: transparent;
-}
-
-QLabel#startup-tab-recent-name {
- font-weight: 700;
- color: #1f2933;
-}
-
-QWidget#style-card {
- background-color: #ffffff;
- border: 1px solid #cfcfcf;
- border-radius: 8px;
-}
-
-QWidget#style-card:hover {
- border: 1px solid #2791ad;
-}
-
-QLabel#style-card-icon {
- color: #215a6a;
- font-weight: 700;
-}
-
-QLabel#style-card-icon-new {
- border-image: url(":/startup-tab/new-file.svg") 0 0 0 0 stretch stretch;
-}
-
-QLabel#style-card-icon-open {
- border-image: url(":/startup-tab/open-file.svg") 0 0 0 0 stretch stretch;
-}
-
-QLabel#style-card-name {
- color: #2b3b45;
-}
-
-QFrame#style-card-frame {
- border: 1px solid #cfcfcf;
- background-color: #ffffff;
-}
-
-QFrame#style-card-frame:hover {
- border: 1px solid #2791ad;
-}
-
-QLabel#style-card-thumbnail {
- background: #f5f5f5;
-}
-
-QLabel#style-card-title {
- background-color: #ffffff;
- color: #2b3b45;
-}
-
/****************************************************************************
* PDF Preview Widget 样式
****************************************************************************/
diff --git a/TeXmacs/progs/startup-tab/startup-tab-file.scm b/TeXmacs/progs/startup-tab/startup-tab-file.scm
index 73ce17b009..a05218367b 100644
--- a/TeXmacs/progs/startup-tab/startup-tab-file.scm
+++ b/TeXmacs/progs/startup-tab/startup-tab-file.scm
@@ -49,7 +49,7 @@
;; Get recent document paths with the same filtering and ordering
;; as File -> Recent used
(let* ((raw (string->number (get-preference "startup-tab:max-recent")))
- (nr (if (number? raw) raw 10))
+ (nr (if (number? raw) raw 50))
(nr (max 1 nr))
) ;
(map url->system (recent-file-list nr))
diff --git a/ai-docs/startup/README.md b/ai-docs/startup/README.md
new file mode 100644
index 0000000000..9b911c2878
--- /dev/null
+++ b/ai-docs/startup/README.md
@@ -0,0 +1,320 @@
+# Mogan Startup 启动页设计文档
+
+## 概述
+
+Mogan 启动页是用户打开应用后的第一个界面,采用**左侧导航栏 + 右侧内容区**的双栏布局,旨在帮助用户快速开始工作——新建/打开文档、浏览模板、查看最近文档。
+
+启动页作为特殊标签页(`tmfs://startup-tab`)固定在标签栏最左侧,不可关闭,始终可见。
+
+---
+
+## 整体架构
+
+```mermaid
+block-beta
+ columns 2
+
+ block:titlebar:2
+ columns 1
+ t1["Title Bar (QTMWindow 窗口级)"]
+ end
+
+ block:body:2
+ columns 2
+
+ block:sidebar
+ columns 1
+ s0["Navigation"]
+ s1["● Home"]
+ space
+ s2["模板分类(动态)"]
+ s3["· 精美笔记"]
+ s4["· 论文模板"]
+ s5["· 国自然模版"]
+ s6["· ..."]
+ space
+ s7["[Quit]"]
+ end
+
+ block:content
+ columns 1
+ c0["QStackedWidget (index 0/1)"]
+ space
+ block:pages
+ columns 2
+ block:home
+ columns 1
+ h1["Home Page"]
+ h2["(QTMHomePage)"]
+ h3["- 文档样式卡片"]
+ h4["- 最近文档列表"]
+ end
+ block:template
+ columns 1
+ t1["Template Page"]
+ t2["(QTMTemplatePage)"]
+ t3["- 模板卡片网格"]
+ t4["- 响应式列数"]
+ end
+ end
+ end
+ end
+
+ block:footer:2
+ columns 2
+ f1["QTMStartupTabWidget"]
+ f2["150px 固定宽度 | 自适应剩余宽度"]
+ end
+```
+
+---
+
+## 核心组件
+
+### 1. `QTMStartupTabWidget` — 启动页容器
+
+**文件**:`src/Plugins/Qt/QTMStartupTabWidget.hpp/.cpp`
+
+**职责**:
+- 管理左右双栏布局
+- 维护导航按钮组(`QButtonGroup`,互斥选中)
+- 将导航点击映射到右侧 `QStackedWidget` 的页面切换(`Entry::Home` / `Entry::Template`)
+- 动态加载模板分类作为导航项(通过 `TemplateManager::categoriesLoaded` 信号)
+- 提供 Quit 按钮,调用 `(quit-TeXmacs)`
+
+**入口枚举**:
+```cpp
+enum class Entry { Home, Template };
+```
+
+**关键方法**:
+- `set_current_entry(Entry)` — 切换页面入口
+- `setup_left_sidebar(QVBoxLayout*)` — 构建导航栏
+- `setup_right_content(QStackedWidget*)` — 构建内容区
+- `setupCategoryNavButtons()` — 从 `TemplateManager` 动态创建分类导航按钮
+
+**集成点**:在 `qt_tm_widget.cpp` 中被实例化,当检测到 `tmfs://startup-tab` 时显示该 widget。
+
+---
+
+### 2. `QTMHomePage` — 首页
+
+**文件**:`src/Plugins/Qt/QTMHomePage.hpp/.cpp`
+
+**职责**:展示文档样式卡片和最近文档列表。
+
+**布局结构**:
+
+```mermaid
+block-beta
+ columns 1
+
+ block:section1
+ columns 1
+ title1["Document Style(分区标题)"]
+ block:cards
+ columns 3
+ card1["新建\n(StyleCard)"]
+ card2["打开\n(StyleCard)"]
+ card3["模板\n(StyleCard)"]
+ cardN["..."]
+ end
+ end
+
+ space
+ separator["────────── 分隔线 ──────────"]
+ space
+
+ block:section2
+ columns 1
+ title2["Recent Documents(分区标题)"]
+ block:list
+ columns 1
+ item1["国自然基金模板.tmu 05-08"]
+ item2["考研数学笔记.tmu 05-07"]
+ item3["..."]
+ end
+ end
+```
+
+**StyleCard 双重模式**:
+
+| 模式 | 条件 | 外观 | 示例 |
+|------|------|------|------|
+| **图标模式** | `templateId` 为空 | 大图标 + 名称居中 | 新建文档、打开文档 |
+| **缩略图模式** | `templateId` 非空 | 缩略图 + 标题栏 | 远程加载的推荐模板 |
+
+**数据流**:
+1. 固定样式卡片(`new`、`open`)在构造函数中硬编码
+2. 模板卡片通过 `TemplateManager::recommendTemplatesLoaded` 信号动态追加
+3. 缩略图通过 `ThumbnailLoader` 异步加载
+
+**最近文档**:
+- 从 Scheme 层 `(startup-tab-get-recent-docs)` 获取路径列表
+- 与本地 JSON 文件 `recent-files.json` 合并(补充时间元数据)
+- 支持右键菜单:移除单条、清空列表
+- 点击打开,文件不存在时自动清理并 toast 提示
+
+---
+
+### 3. `QTMTemplatePage` — 模板分类页
+
+**文件**:`src/Plugins/Qt/QTMTemplatePage.hpp/.cpp`
+
+**职责**:按分类展示模板卡片网格,支持预览、下载和使用。
+
+**功能**:
+- 响应式网格布局(`calculateColumnCount()` 根据 viewport 宽度动态计算列数)
+- 缩略图异步加载(`ThumbnailLoader`)
+- 点击卡片弹出预览对话框(`QDialog`):显示 PDF 预览、作者、版本、描述 → 可使用模板
+- `resizeDebounceTimer_`(200ms)防抖,避免拖拽窗口时频繁重建网格
+- 空状态提示:`"Loading templates..."` / `"No templates available."`
+
+---
+
+### 4. `TemplateManager` — 模板数据中心
+
+**文件**:`src/Mogan/TemplateCenter/template_manager.hpp/.cpp`
+
+**职责**:模板元数据管理,协调本地缓存和远程 API。
+
+**数据模型**(`template_types.hpp`):
+```cpp
+struct TemplateCategory {
+ QString id, name, nameEn, description, icon;
+ int order, templateCount;
+};
+
+struct TemplateMetadata {
+ QString id, name, description, category, author, version, license;
+ QString thumbnailUrl, previewUrl, fileUrl;
+ qint64 fileSize;
+ QString fileMd5;
+ QDateTime createdAt, updatedAt;
+ QString language;
+ QStringList tags;
+ QString moganMinVersion;
+ int downloadCount;
+ double rating;
+ QString localPath;
+ bool isLocal;
+};
+```
+
+**关键信号**:
+- `categoriesLoaded()` → `QTMStartupTabWidget::setupCategoryNavButtons()`
+- `templatesLoaded()` → `QTMTemplatePage::onTemplatesLoaded()`
+- `recommendTemplatesLoaded()` → `QTMHomePage::onRecommendTemplatesLoaded()`
+
+**离线模式**:`setOffline(true)` 后所有远程请求在 API 层短路,供测试场景使用。
+
+---
+
+### 5. Scheme 层 — 文件操作绑定
+
+**文件**:`TeXmacs/progs/startup-tab/startup-tab-file.scm`
+
+**导出的函数**:
+| 函数 | 用途 |
+|------|------|
+| `(new-document-with-style style-id)` | 创建指定样式的新文档 |
+| `(startup-tab-get-recent-docs)` | 获取最近文档路径列表 |
+| `(startup-tab-add-recent-doc path)` | 添加/刷新最近文档 |
+| `(startup-tab-clear-recent-doc path)` | 移除指定最近文档 |
+| `(startup-tab-clear-all-recent)` | 清空所有最近文档 |
+
+---
+
+## 主题样式
+
+启动页的视觉样式定义在主题 CSS 中:
+- **liii.css**(亮色主题):`TeXmacs/misc/themes/liii.css`
+- **liii-night.css**(暗色主题):`TeXmacs/misc/themes/liii-night.css`
+
+关键样式对象:
+| ObjectName | 用途 |
+|------------|------|
+| `#startup-tab-sidebar` | 左侧导航栏背景 (#215a6a) |
+| `#startup-tab-nav-btn` | 导航按钮(普通/悬停/选中) |
+| `#startup-tab-quit-btn` | 退出按钮 |
+| `#startup-tab-content` | 右侧内容区背景 (#f3f3f3) |
+| `#startup-tab-section-title` | 分区标题 |
+| `#startup-tab-separator` | 分隔线 |
+| `#startup-tab-template-card` | 模板卡片(普通/悬停) |
+| `#startup-tab-template-name` | 模板名称 |
+| `#startup-tab-template-info` | 模板作者/版本信息 |
+
+---
+
+## 数据流总结
+
+```mermaid
+flowchart TD
+ A["用户打开应用"] --> B["qt_tm_widget 检测\ntmfs://startup-tab"]
+ B --> C["创建 QTMStartupTabWidget"]
+
+ C --> D["setup_left_sidebar()"]
+ C --> E["setup_right_content()"]
+
+ D --> D1["创建 Home 导航按钮"]
+ D --> D2["连接 categoriesLoaded\n→ 动态创建分类按钮"]
+
+ E --> E1["[0] QTMHomePage"]
+ E --> E2["[1] QTMTemplatePage"]
+
+ E1 --> E1a["固定样式卡片: new, open"]
+ E1 --> E1b["连接 recommendTemplatesLoaded\n→ 追加推荐模板卡片"]
+ E1 --> E1c["连接 ThumbnailLoader\n→ 异步加载缩略图"]
+
+ E2 --> E2a["连接 templatesLoaded\n→ 刷新模板网格"]
+ E2 --> E2b["连接 ThumbnailLoader\n→ 异步加载缩略图"]
+ E2 --> E2c["点击卡片 → 预览对话框\n→ 下载/使用模板"]
+```
+
+---
+
+## HTML 原型
+
+`ai-docs/startup/startup.html` 是该设计的 HTML 原型,用于快速预览和迭代 UI 设计。
+
+原型与生产代码的对应关系:
+
+| HTML 原型 | C++ 实现 |
+|-----------|----------|
+| `.sidebar` + `.nav-item` | `QTMStartupTabWidget` 左侧导航栏 |
+| `#page-home` → 快速开始卡片 | `QTMHomePage` + `StyleCard`(图标模式) |
+| `#page-home` → 模板推荐卡片 | `QTMHomePage` + `StyleCard`(缩略图模式) |
+| `#page-home` → 最近文档列表 | `QTMHomePage` + `QListWidget` |
+| `#page-thesis` 卡片网格 | `QTMTemplatePage` 响应式网格 |
+| `.nav-label` 分组标题 | `#startup-tab-nav-title` QLabel |
+| `.exit-btn` | `#startup-tab-quit-btn` |
+
+原型中当前未在生产代码中对应的设计元素:
+- **键盘快捷键提示**(`Ctrl+N` / `Ctrl+O` 徽章)— 原型中添加了,但 C++ 代码中未显示
+- **模板卡片的差异化配色**(笔记=暖色、国自然=蓝色、论文=紫色、考研=红色)— C++ 代码统一使用白色卡片+缩略图
+- **最近文档的文件类型图标**(.tmu / .pdf / 笔记)— C++ 代码仅显示文件名+时间
+- **"浏览全部模板 →" 链接** — C++ 代码通过侧边栏分类导航实现
+
+---
+
+## 文件清单
+
+| 文件路径 | 说明 |
+|----------|------|
+| `src/Plugins/Qt/QTMStartupTabWidget.hpp` | 启动页容器声明 |
+| `src/Plugins/Qt/QTMStartupTabWidget.cpp` | 启动页容器实现 |
+| `src/Plugins/Qt/QTMHomePage.hpp` | 首页声明 |
+| `src/Plugins/Qt/QTMHomePage.cpp` | 首页实现 |
+| `src/Plugins/Qt/QTMTemplatePage.hpp` | 模板分类页声明 |
+| `src/Plugins/Qt/QTMTemplatePage.cpp` | 模板分类页实现 |
+| `src/Plugins/Qt/QTMTemplateOpener.hpp/.cpp` | 模板打开器 |
+| `src/Mogan/TemplateCenter/template_manager.hpp` | 模板管理器声明 |
+| `src/Mogan/TemplateCenter/template_manager.cpp` | 模板管理器实现 |
+| `src/Mogan/TemplateCenter/template_types.hpp` | 模板数据类型 |
+| `src/Mogan/TemplateCenter/thumbnail_loader.hpp/.cpp` | 缩略图异步加载器 |
+| `TeXmacs/progs/startup-tab/startup-tab.scm` | Scheme 模块入口 |
+| `TeXmacs/progs/startup-tab/startup-tab-file.scm` | 文件操作 Scheme 绑定 |
+| `TeXmacs/misc/themes/liii.css` | 亮色主题样式 |
+| `TeXmacs/misc/themes/liii-night.css` | 暗色主题样式 |
+| `ai-docs/startup/startup.html` | HTML UI 原型 |
+| `ai-docs/startup/README.md` | 本文档 |
diff --git a/ai-docs/startup/startup.html b/ai-docs/startup/startup.html
new file mode 100644
index 0000000000..72330c49e4
--- /dev/null
+++ b/ai-docs/startup/startup.html
@@ -0,0 +1,477 @@
+
+
+
+
+
+ Liii STEM — Startup Tab Prototype
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Document Style
+
+
+
+
+
+
+
+
New document
+
+
+
+
+
+
+
+
+
+
+
+
+
Recent Documents
+
+
+ 国自然基金模板.tmu
+ Last opened: 2026-05-08 10:39
+
+
+ 考研数学笔记.tmu
+ Last opened: 2026-05-07 09:45
+
+
+ 论文参考文献格式.tmu
+ Last opened: 2026-05-07 09:45
+
+
+ 高等数学期末复习.tmu
+ Last opened: 2026-05-06 15:20
+
+
+
+
+
+
+
+
+
+ 国自然模版
Loading templates...
+
+
+
+
+
+
+
+
diff --git a/devel/2046.md b/devel/2046.md
new file mode 100644
index 0000000000..3f966a7f1f
--- /dev/null
+++ b/devel/2046.md
@@ -0,0 +1,264 @@
+# [2046] startup tab QML 重构
+
+## 1 相关文档
+- [dddd.md](dddd.md) - 任务文档模板
+- [2047.md](2047.md) - QML 代码格式化
+
+## 2 任务相关的代码文件
+
+### 新增
+- `src/Plugins/Qt/StartupBridge.cpp` — C++↔QML 桥接实现
+- `src/Plugins/Qt/StartupBridge.hpp` — C++↔QML 桥接头文件
+- `src/Plugins/Qt/qml/startup/StartupTab.qml` — 启动页主容器
+- `src/Plugins/Qt/qml/startup/StartupHomePage.qml` — Home 页面
+- `src/Plugins/Qt/qml/startup/StartupTemplatePage.qml` — 模板分类页
+- `src/Plugins/Qt/qml/startup/atoms/StartupTheme.qml` — 全局主题单例
+- `src/Plugins/Qt/qml/startup/atoms/StyleCard.qml` — 样式卡片原子
+- `src/Plugins/Qt/qml/startup/atoms/TemplateCard.qml` — 模板卡片原子
+- `src/Plugins/Qt/qml/startup/qmldir`
+- `src/Plugins/Qt/qml/startup/atoms/qmldir`
+- `ai-docs/startup/startup.html` — HTML 原型
+- `ai-docs/startup/README.md`
+- `tests/Mogan/Startup/startup_bridge_test.cpp` — StartupBridge 单元测试
+
+### 修改
+- `src/Plugins/Qt/QTMStartupTabWidget.cpp` — 简化为 QQuickWidget 容器
+- `src/Plugins/Qt/QTMStartupTabWidget.hpp` — 移除旧 QWidget 页面相关声明
+- `src/Plugins/Qt/moganqml.qrc` — 注册新 QML 文件
+- `TeXmacs/misc/themes/liii.css` — 删除 startup-tab CSS 规则
+- `TeXmacs/misc/themes/liii-night.css` — 删除 startup-tab CSS 规则
+- `TeXmacs/progs/startup-tab/startup-tab-file.scm` — 默认最近文档数 10→50
+- `src/Plugins/Qt/qml/ConfirmRestart.qml` — 格式调整
+- `src/Plugins/Qt/qml/ParagraphFormat.qml` — 格式调整
+- `src/Plugins/Qt/qml/Preferences.qml` — 格式调整
+- `src/Plugins/Qt/qml/Statistics.qml` — 格式调整
+
+### 删除
+- `src/Plugins/Qt/QTMHomePage.cpp` / `.hpp` — 旧 Home 页面(QWidget)
+- `src/Plugins/Qt/QTMTemplatePage.cpp` / `.hpp` — 旧模板页(QWidget)
+- `tests/Mogan/Startup/startup_tab_widget_test.cpp` — 引用已删除类的过时测试
+
+## 3 如何测试
+
+### 3.1 确定性测试(单元测试)
+```bash
+# StartupBridge 数据逻辑测试
+xmake b startup_bridge_test && xmake r startup_bridge_test
+
+# 模板工具函数测试(确保未受本次改动影响)
+xmake b template_utils_test && xmake r template_utils_test
+```
+
+### 3.2 非确定性测试(文档验证)
+```bash
+# 格式化变更文件
+gf fmt --changed-since=main
+
+# 浏览 devel/2046.md
+```
+
+## 4 如何提交
+
+提交前执行以下最少步骤:
+
+```bash
+# 1. 格式化变更文件
+gf fmt --changed-since=main
+
+# 2. 运行启动页相关单元测试
+xmake b startup_bridge_test && xmake r startup_bridge_test
+xmake b template_utils_test && xmake r template_utils_test
+
+# 3. 构建主项目验证编译
+xmake b stem
+
+# 4. 提交
+git add devel/2046.md
+git commit -m "[2046] 更新任务文档"
+
+git add src/ tests/
+git commit -m "[2046] startup tab QML 重构"
+```
+
+## 5 What
+
+将启动页(startup tab)从纯 C++ QWidget 实现重构为 QML + C++ Bridge 架构。
+
+1. `QTMStartupTabWidget` 变为薄容器,内嵌 `QQuickWidget` 加载 `StartupTab.qml`
+2. 新增 `StartupBridge` 作为 C++↔QML 数据/动作桥接,通过 `Q_PROPERTY` 暴露数据模型、`Q_INVOKABLE` 暴露动作
+3. 删除旧 C++ 页面类 `QTMHomePage`、`QTMTemplatePage`
+4. 清理 `liii.css` / `liii-night.css` 中 startup-tab 相关 CSS 规则(样式已迁移到 QML `StartupTheme.qml`)
+5. 新增 `startup_bridge_test.cpp` 单元测试,覆盖构造、recentDocs CRUD、JSON 持久化、selectCategory、tr
+6. 修复 `saveRecentDocs` 时间戳丢失:`makeRecentEntry` 新增 `timestamp` 字段,保存时使用条目原始时间戳
+7. 修复 `selectCategory` 异步刷新时序:移除末尾的同步 `refreshCategoryTemplates()` 调用,由 `onTemplatesLoaded` 槽统一刷新
+
+## 6 Why
+
+1. QML 声明式 UI 比 C++ QWidget 手动布局更易维护和调样式
+2. 分离数据/动作(C++)与渲染(QML),职责清晰
+3. 借 HTML 原型 `ai-docs/startup/startup.html` 统一视觉规范
+
+## 7 How
+
+### C++ 侧
+- `StartupBridge`:通过 `Q_PROPERTY` 暴露数据模型(`recentDocs`、`styleCards`、`categories`、`categoryTemplates`、`categoryLoading`),通过 `Q_INVOKABLE` 暴露动作
+- `recentPathsFromScheme`:从 Scheme 获取最近文档路径
+- `readRecentMeta` / `saveRecentDocs`:读写 `recent-files.json`,写入使用 temp+rename 保证原子性,条目保留原始时间戳
+- `loadRecentDocs`:合并 Scheme 路径与缓存元数据,过滤不存在的文件
+- `rebuildStyleCards` / `refreshCategoryTemplates`:从 `TemplateManager` 同步数据
+- 非关键 `eval_scheme` 调用使用 `QTimer::singleShot` 延迟到下一事件循环,避免阻塞 UI
+- `QTMStartupTabWidget::refreshTheme()`:支持运行时主题切换
+- 移除 `Entry` 枚举、`set_current_entry`、`entry_changed` 信号:页面切换由 QML `StackLayout.currentIndex` 独立管理
+
+### QML 侧
+- `StartupTab.qml`:侧边栏 + StackLayout(Home / Template 页面切换)
+- `StartupHomePage.qml`:Document Style 样式卡片 + Recent Documents 列表
+- `StartupTemplatePage.qml`:分类标题 + 响应式模板卡片网格(Flickable 内部滚动)
+- `StartupTheme.qml`:全局主题单例(pragma Singleton),颜色/尺寸/字号对齐 HTML 原型,支持 dark 模式
+- `StyleCard.qml`:图标模式(新建/打开)/ 缩略图模式(推荐模板),通过 Loader 实现双模式切换
+- `TemplateCard.qml`:模板缩略卡片,鼠标区域覆盖整卡包括名称和版本信息,支持右键菜单
+
+## 8 Code Review 修复(2026-07-27)
+
+### 8.1 Bug 修复
+
+| 问题 | 文件 | 修复 |
+|------|------|------|
+| `selectCategory` 异步时序:sync refresh + 立即 loading=false 导致旧数据闪烁 | `StartupBridge.cpp` | 已初始化走异步路径(`onTemplatesLoaded` 统一刷新),未初始化走同步路径 |
+| 时间格式 `hh`(12h)歧义 | `StartupBridge.cpp` | `hh` → `HH`(24h)|
+| `saveRecentDocs` 原子写入失败静默 | `StartupBridge.cpp` | `remove`/`rename` 失败输出 `qWarning` |
+| 已缓存分类回切不显示 | `StartupBridge.cpp` + `template_manager.cpp` | `refreshTemplatesByCategory` 对已缓存分类是 no-op 不触发信号 → 追加同步 refresh + 按缓存命中判断 loading |
+| `file://` URL 持久化后清理缓存导致 `Cannot open` | `template_manager.cpp` + `StartupBridge.cpp` | URL 转换移到 bridge 层(`queryUrl`),`thumbnailUrl` 不修改,JSON 持久化保持原始远程 URL |
+
+### 8.2 QML 优化
+
+| 问题 | 文件 | 修复 |
+|------|------|------|
+| `ListView` margins 混淆 | `StartupHomePage.qml` | 拆为独立 `leftMargin`/`rightMargin`/`topMargin`/`bottomMargin` |
+| 回切分类时缩略图重建 | `StartupBridge.cpp` | `categoryTemplatesCache_` 按分类缓存 `QVariantList`,命中直接复用 |
+| 大量模板卡顿 | `StartupTemplatePage.qml` | `Grid`+`Repeater` → `GridView` 虚拟滚动 + `populate` 淡入过渡 |
+| 缩略图模糊 | `TemplateCard.qml` `StyleCard.qml` | 移除错误的 `sourceSize`(dpScale 重复乘),让 Qt 按原图分辨率解码 |
+
+### 8.3 架构优化 — `ThumbnailLoader`
+
+- 提取到 `src/Mogan/Cache/thumbnail_loader.cpp/hpp`,独立可复用
+- `getUrl(remoteUrl)` — 触发异步下载(`mergeMetadata` 调用,一次性后台预取)
+- `queryUrl(remoteUrl)` — 只查缓存不下载(`refreshCategoryTemplates` / `rebuildStyleCards` 调用,切换分类时无 I/O)
+- `ready(remoteUrl)` 信号 → `TemplateManager` emit `thumbnailCached` → `StartupBridge` 刷新
+- `cacheDir()` 用 `ImageCacheUtils::getEnvQString` + `QStandardPaths` fallback
+
+### 8.4 测试
+
+- 新增 `test_select_category_no_loading_when_uninitialized`(`startup_bridge_test.cpp`)
+
+## 9 macOS 启动崩溃修复(2026-07-27)
+
+启动页 QML 重构后,macOS 上 `xmake r stem` 直接崩溃(`execv failed(-1)`)。
+lldb 定位到三处独立崩溃,逐一修复:
+
+### 9.1 QQuickWidget 嵌入 QWK 代理主窗口 → EXC_BAD_ACCESS
+
+崩溃栈:`QQuickWidget` 构造 → `QWidget::setParent` → `QWidget::destroy` →
+QWindowKit 的 winId-change eventFilter → `CocoaWindowContext::winIdChanged` →
+`releaseWindowProxy` → `~NSWindowProxy` → `objc_msgSend` 解引用已释放的 NSView。
+
+`QQuickWidget` 自带原生渲染窗口(`QWindow`),嵌入 `QWK::WidgetWindowAgent`
+代理的主窗口后,reparent 时触发 winId 变动,QWindowKit 的 Cocoa 实现用已
+释放的 `NSWindowProxy` 派发消息而崩。QML 弹窗(`QTMQmlDialog`)不崩,因为
+它们是独立顶层窗口、不在代理窗口树下。仅 macOS 复现(`NSWindowProxy` 只在
+`cocoawindowcontext.mm`)。
+
+修复(`QTMStartupTabWidget.cpp`):构造时先对本容器设
+`WA_DontCreateNativeAncestors` + `WA_NativeWindow`,让 QWK 观察到稳定的原生
+窗口层级,reparent 时不再进入 `releaseWindowProxy` 路径;`QQuickWidget` 也
+设 `WA_DontCreateNativeAncestors` 并延迟到无父状态构造后再 reparent。
+
+### 9.2 启动页 QML 依赖未打包的 QtQuick.Controls / QtQuick.Layouts
+
+打包的 Qt 只含 `QtQuick`/`QtQml` 库框架,**不含** `QtQuickControls` /
+`QtQuickLayouts` 两个可选 QML 插件模块。项目其它对话框都只用裸 `import QtQuick`
+以规避此限制,启动页却用了 `RowLayout`/`ColumnLayout`/`Layout.*`/`StackLayout`
+及右键 `Menu`。
+
+修复:`StartupTab.qml` / `StartupHomePage.qml` 重写为裸 QtQuick——
+`Layout` 系列改 anchors 定位、`StackLayout` 改 `visible` 切换、右键菜单改为
+直接调用 `removeRecentDoc`(避免引入 `QtQuick.Controls`)。`StyleCard.qml` /
+`TemplateCard.qml` / `StartupTemplatePage.qml` 本就只用裸 QtQuick,无需改动。
+
+### 9.3 `ThumbnailLoader::loadMeta` 遍历临时 QJsonObject → use-after-free
+
+崩溃栈:`loadMeta` → `it.value().toObject` → `QJsonValueConstRef::concrete`
+解引用地址 `0x7470616320745125`(被当指针的字符串堆数据)。
+
+`for (auto it = doc.object().begin(); ...)` 中 `doc.object()` 返回临时对象,
+完整表达式结束时析构,迭代器随即悬空。
+
+修复(`thumbnail_loader.cpp`):把 `QJsonObject` 先存到局部变量 `root` 再遍历,
+延长其生命周期覆盖整个循环。
+
+### 9.4 验证
+
+`xmake r stem` 在 macOS 上正常启动,启动页渲染成功,插件正常加载。
+lldb 复跑无 `EXC_BAD_ACCESS`。
+
+## 10 `startup_bridge_test::test_recent_docs_persistence` SIGSEGV 修复(2026-07-28)
+
+### 10.1 现象
+
+CI(macOS arm64 releasedbg)跑 `startup_bridge_test`:
+
+```
+PASS : TestStartupBridge::test_clear_all_recent_docs()
+Received signal 11 (SIGSEGV), code 2, for address 0x0000000000000018
+ test_recent_docs_persistence function time: 1ms
+```
+
+前 5 个 recent-docs 用例全 PASS,崩在第 6 个 `test_recent_docs_persistence`。
+错误地址 `0x18` 是「空指针 + 成员偏移」的典型特征。
+
+### 10.2 根因
+
+测试原代码:
+
+```cpp
+void test_recent_docs_persistence () {
+ {
+ StartupBridge bridge1;
+ bridge1.addRecentDoc ("/tmp/persist-test.tmu");
+ QCoreApplication::processEvents (); // ← 崩在这
+ }
+ // ... 断言 recent-files.json 内容 ...
+}
+```
+
+`StartupBridge::addRecentDoc` 除了同步落盘 `recent-files.json`,还会:
+
+```cpp
+QTimer::singleShot (0, this, [normPath] {
+ eval_scheme ("(startup-tab-add-recent-doc " * ... * ")");
+});
+```
+
+测试只调了 `init_lolly()`,没调 `start_scheme()`——s7 解释器 `tm_s7` 为 `NULL`。
+`QCoreApplication::processEvents()` 触发上面的 `singleShot(0)` lambda →
+`eval_scheme` → `s7_eval_c_string_with_environment(NULL, ...)` → 解引用
+`tm_s7` 的成员(偏移 `0x18`)→ SIGSEGV。
+
+前 5 个用例没崩,是因为它们都没 `processEvents`:QtTest 不跑独立事件循环,
+`singleShot(0, this, ...)` 的 receiver `bridge` 析构时 Qt 自动断开定时器,
+lambda 永远不执行。
+
+### 10.3 Why 测试要调 `processEvents`?— 不需要
+
+原注释「处理延迟执行的 Scheme 调用」是误解:JSON 文件由 `saveRecentDocs()`
+**同步**写入(无 `singleShot`),延迟的 `eval_scheme` 只更新 Scheme 侧的内存
+状态,与 JSON 落盘无关。所以 `processEvents` 多余且有害。
+
+### 10.4 修复
+
+`tests/Mogan/Startup/startup_bridge_test.cpp`:删除 `processEvents()` 调用,
+保留 `bridge1` 的局部作用域块(限制 bridge 生命周期,方便后续断言读文件)。
+
+### 10.5 验证
+
+macOS CI 重跑 `xmake r startup_bridge_test`,9 个用例全部 PASS,无 SIGSEGV。
diff --git a/src/Mogan/Cache/thumbnail_loader.cpp b/src/Mogan/Cache/thumbnail_loader.cpp
index 8a1d50e47e..9256b4547b 100644
--- a/src/Mogan/Cache/thumbnail_loader.cpp
+++ b/src/Mogan/Cache/thumbnail_loader.cpp
@@ -1,158 +1,254 @@
/******************************************************************************
* MODULE : thumbnail_loader.cpp
- * DESCRIPTION: Shared thumbnail loading with queue, cache, and HTTP validation
+ * DESCRIPTION: 缩略图下载 + 本地缓存 + ETag 条件请求校验
* COPYRIGHT : (C) 2026 Yuki Lu
+ *******************************************************************************
+ * This software falls under the GNU general public license version 3 or later.
+ * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
+ * in the root directory or .
******************************************************************************/
#include "thumbnail_loader.hpp"
+#include "image_cache_base.hpp"
#include "qt_utilities.hpp"
-#include "thumbnail_cache.hpp"
-
-#include
-#include
-#include
+#include
+#include
+#include
+#include
+#include
#include
#include
#include
-#include
+#include
+
+#include "sys_utils.hpp"
+
+static QString
+localPathForUrl (const QString& cacheDir, const QString& remoteUrl) {
+ QByteArray hash=
+ QCryptographicHash::hash (remoteUrl.toUtf8 (), QCryptographicHash::Md5);
+ return QDir (cacheDir).filePath (hash.toHex () + ".png");
+}
ThumbnailLoader::ThumbnailLoader (QObject* parent) : QObject (parent) {
- networkManager_= new QNetworkAccessManager (this);
+ nam_= new QNetworkAccessManager (this);
+ loadMeta ();
+ connect (nam_, &QNetworkAccessManager::finished, this,
+ &ThumbnailLoader::onDownloadFinished);
}
ThumbnailLoader::~ThumbnailLoader ()= default;
-ThumbnailLoader*
-ThumbnailLoader::instance () {
- static ThumbnailLoader inst;
- return &inst;
+// =========================================================================
+// 缓存目录
+// =========================================================================
+
+QString
+ThumbnailLoader::cacheDir () const {
+ QString home= ImageCacheUtils::getEnvQString ("TEXMACS_HOME_PATH");
+ if (home.isEmpty ())
+ home= QStandardPaths::writableLocation (QStandardPaths::AppDataLocation);
+ QString dir= QDir (home).filePath ("system/cache/thumbnails");
+ QDir ().mkpath (dir);
+ return dir;
+}
+
+// =========================================================================
+// ETag 元数据持久化
+// =========================================================================
+
+QString
+ThumbnailLoader::metaFilePath () const {
+ return QDir (cacheDir ()).filePath ("thumbnails_meta.json");
}
void
-ThumbnailLoader::load (QLabel* label, const QString& url,
- const QSize& targetSize) {
- if (!label || url.isEmpty ()) return;
-
- auto cached= ThumbnailCache::instance ()->getEntry (url, targetSize);
-
- if (cached.isValid ()) {
- // 缓存中有缩略图,先立即显示,避免 UI 等待
- QPixmap px= cached.pixmap;
- px.setDevicePixelRatio (label->devicePixelRatioF ());
- label->setPixmap (px);
-
- // 如果该 URL 已在本次会话中验证过 freshness,直接复用缓存
- if (validatedUrls_.contains (url)) {
- if (DEBUG_IO)
- debug_io << "[ThumbnailLoader] Cache hit: " << qPrintable (url) << "\n";
- return;
- }
+ThumbnailLoader::loadMeta () {
+ meta_.clear ();
+ QFile file (metaFilePath ());
+ if (!file.open (QIODevice::ReadOnly)) return;
+
+ QJsonDocument doc= QJsonDocument::fromJson (file.readAll ());
+ file.close ();
+ if (!doc.isObject ()) return;
+
+ // 必须先把 QJsonObject 存到局部变量再遍历:doc.object() 返回临时对象,
+ // 若直接在 for 里取 begin()/end(),临时对象在该完整表达式结束时析构,
+ // 迭代器随即悬空 → it.value().toObject() 触发 use-after-free 崩溃。
+ QJsonObject root= doc.object ();
+ for (auto it= root.begin (); it != root.end (); ++it) {
+ QJsonObject o= it.value ().toObject ();
+ ThumbnailMeta m;
+ m.etag = o["etag"].toString ();
+ m.lastModified= o["lastModified"].toString ();
+ if (!m.etag.isEmpty () || !m.lastModified.isEmpty ())
+ meta_.insert (it.key (), m);
+ }
+}
- // 缓存存在但尚未验证 freshness,发送条件请求(If-None-Match)校验 ETag
- if (DEBUG_IO)
- debug_io << "[ThumbnailLoader] Cache validate: " << qPrintable (url)
- << "\n";
- queue_.enqueue ({label, url, cached.etag, targetSize});
- processQueue ();
- return;
+void
+ThumbnailLoader::saveMeta () {
+ QJsonObject root;
+ for (auto it= meta_.constBegin (); it != meta_.constEnd (); ++it) {
+ QJsonObject o;
+ if (!it->etag.isEmpty ()) o["etag"]= it->etag;
+ if (!it->lastModified.isEmpty ()) o["lastModified"]= it->lastModified;
+ if (!o.isEmpty ()) root[it.key ()]= o;
}
- // 缓存未命中,走网络下载
- if (DEBUG_IO)
- debug_io << "[ThumbnailLoader] Download: " << qPrintable (url) << "\n";
- queue_.enqueue ({label, url, QString (), targetSize});
- processQueue ();
+ // 原子写入:先写临时文件再 rename
+ QString tmpPath= metaFilePath () + ".tmp";
+ QFile tmpFile (tmpPath);
+ if (tmpFile.open (QIODevice::WriteOnly | QIODevice::Truncate)) {
+ tmpFile.write (QJsonDocument (root).toJson ());
+ tmpFile.close ();
+ QString destPath= metaFilePath ();
+ QFile::remove (destPath);
+ if (!tmpFile.rename (destPath))
+ qWarning () << "[ThumbnailLoader] Failed to rename meta temp file";
+ }
+ else {
+ qWarning () << "[ThumbnailLoader] Failed to write meta temp file";
+ }
}
-void
-ThumbnailLoader::processQueue () {
- while (!queue_.isEmpty () && activeRequests_ < MAX_CONCURRENT) {
- ThumbnailLoadRequest req= queue_.dequeue ();
+// =========================================================================
+// 公开 API
+// =========================================================================
- if (req.label.isNull ()) {
- continue;
- }
+QString
+ThumbnailLoader::getUrl (const QString& remoteUrl) {
+ return getUrlImpl (remoteUrl, true, true);
+}
- activeRequests_++;
+QString
+ThumbnailLoader::queryUrl (const QString& remoteUrl) {
+ return getUrlImpl (remoteUrl, false, false);
+}
- QNetworkRequest request (req.url);
- if (!req.cachedEtag.isEmpty ()) {
- request.setRawHeader ("If-None-Match", req.cachedEtag.toUtf8 ());
- }
- QNetworkReply* reply= networkManager_->get (request);
+// =========================================================================
+// 核心逻辑
+// =========================================================================
- connect (reply, &QNetworkReply::finished, this, [this, req, reply] () {
- activeRequests_--;
+QString
+ThumbnailLoader::getUrlImpl (const QString& remoteUrl, bool triggerDownload,
+ bool validateCached) {
+ if (remoteUrl.isEmpty ()) return remoteUrl;
- if (req.label.isNull ()) {
- reply->deleteLater ();
- validatedUrls_.insert (req.url);
- processQueue ();
- return;
- }
+ // 已是本地资源 / qrc 资源,不处理
+ if (remoteUrl.startsWith ("qrc:/") || remoteUrl.startsWith ("/") ||
+ remoteUrl.startsWith (":"))
+ return remoteUrl;
- int httpStatus=
- reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt ();
- if (httpStatus == 304) {
- // 服务器返回 304 Not Modified,缓存仍然新鲜,标记该 URL 已验证
- if (DEBUG_IO)
- debug_io << "[ThumbnailLoader] Cache fresh: " << qPrintable (req.url)
- << "\n";
- validatedUrls_.insert (req.url);
- reply->deleteLater ();
- processQueue ();
- return;
- }
+ // file:// URL:校验文件是否存在
+ if (remoteUrl.startsWith ("file://")) {
+ if (QFile::exists (remoteUrl.mid (7))) return remoteUrl;
+ return QString ();
+ }
+
+ // 检查 urlCache_ 中的 file:// 映射,校验文件是否仍存在
+ auto it= urlCache_.constFind (remoteUrl);
+ if (it != urlCache_.constEnd ()) {
+ // file:// URL 提取本地路径校验文件存在(用户可能手动删了缓存)
+ if (it->startsWith ("file://") && QFile::exists (it->mid (7))) return *it;
+ if (!it->startsWith ("file://")) return *it; // qrc:/ 等非 file:// 直接返回
+ // file:// 但文件已不存在:清除缓存,继续走下载/查询逻辑
+ urlCache_.erase (it);
+ }
- if (reply->error () == QNetworkReply::NoError) {
- QByteArray data= reply->readAll ();
- QImage image;
- if (image.loadFromData (data)) {
- qreal dpr = req.label->devicePixelRatioF ();
- int scaledW= qRound (req.targetSize.width () * dpr);
- int scaledH= qRound (req.targetSize.height () * dpr);
-
- QImage scaled=
- image.scaled (scaledW, scaledH, Qt::KeepAspectRatioByExpanding,
- Qt::SmoothTransformation);
- if (scaled.width () > scaledW || scaled.height () > scaledH) {
- int x = (scaled.width () - scaledW) / 2;
- int y = (scaled.height () - scaledH) / 2;
- scaled= scaled.copy (x, y, scaledW, scaledH);
- }
-
- QPixmap pixmap= QPixmap::fromImage (scaled);
- pixmap.setDevicePixelRatio (dpr);
-
- req.label->setPixmap (pixmap);
-
- QString etag= QString::fromUtf8 (reply->rawHeader ("ETag"));
- QDateTime lastModified;
- QString lmStr= QString::fromUtf8 (reply->rawHeader ("Last-Modified"));
- if (!lmStr.isEmpty ()) {
- lastModified= QDateTime::fromString (lmStr, Qt::RFC2822Date);
- if (!lastModified.isValid ()) {
- lastModified= QLocale::c ().toDateTime (
- lmStr, "ddd, dd MMM yyyy hh:mm:ss 'GMT'");
- }
- if (lastModified.isValid ()) {
- lastModified.setTimeZone (QTimeZone::utc ());
- }
- }
-
- ThumbnailCache::instance ()->put (req.url, req.targetSize, pixmap,
- etag, lastModified);
- }
+ // 本地文件已存在(从未缓存过,或缓存因文件丢失被清除后重新检查)
+ QString localPath= localPathForUrl (cacheDir (), remoteUrl);
+ if (QFile::exists (localPath)) {
+ QString fileUrl= "file://" + localPath;
+ urlCache_.insert (remoteUrl, fileUrl);
+
+ // 后台 ETag 条件请求校验缓存是否过期
+ if (validateCached && !validatedUrls_.contains (remoteUrl) &&
+ !inFlight_.contains (remoteUrl)) {
+ ThumbnailMeta meta= meta_.value (remoteUrl);
+ if (!meta.etag.isEmpty () || !meta.lastModified.isEmpty ()) {
+ inFlight_.insert (remoteUrl);
+ QNetworkRequest req (remoteUrl);
+ req.setAttribute (QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setRawHeader ("Cache-Control", "no-cache");
+ if (!meta.etag.isEmpty ())
+ req.setRawHeader ("If-None-Match", meta.etag.toUtf8 ());
+ if (!meta.lastModified.isEmpty ())
+ req.setRawHeader ("If-Modified-Since", meta.lastModified.toUtf8 ());
+ nam_->get (req);
}
- if (!req.label.isNull () && req.label->pixmap ().isNull ()) {
- req.label->setText (qt_translate ("Preview"));
+ else {
+ // 无 ETag 元数据(旧版下载或元数据丢失):标记为已验证,
+ // 不再发无条件请求浪费带宽
+ validatedUrls_.insert (remoteUrl);
}
+ }
- validatedUrls_.insert (req.url);
- reply->deleteLater ();
- processQueue ();
- });
+ return fileUrl;
}
+
+ // 不触发下载 → 返回原始远程 URL(QML Image 异步加载)
+ if (!triggerDownload) return remoteUrl;
+
+ // 触发全量下载
+ if (inFlight_.contains (remoteUrl)) return remoteUrl;
+ inFlight_.insert (remoteUrl);
+ QNetworkRequest req (remoteUrl);
+ req.setAttribute (QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+ nam_->get (req);
+ return remoteUrl;
+}
+
+// =========================================================================
+// 网络响应处理
+// =========================================================================
+
+void
+ThumbnailLoader::onDownloadFinished (QNetworkReply* reply) {
+ reply->deleteLater ();
+
+ QString remoteUrl= reply->request ().url ().toString ();
+ inFlight_.remove (remoteUrl);
+
+ if (reply->error () != QNetworkReply::NoError) return;
+
+ int httpStatus=
+ reply->attribute (QNetworkRequest::HttpStatusCodeAttribute).toInt ();
+
+ // ---- 304 Not Modified:缓存仍然新鲜 ----
+ if (httpStatus == 304) {
+ validatedUrls_.insert (remoteUrl);
+ saveMeta ();
+ return;
+ }
+
+ // ---- HTTP 错误:丢弃 ----
+ if (httpStatus < 200 || httpStatus >= 300) return;
+
+ // ---- 200 OK:保存文件 + 更新 ETag 元数据 ----
+ QString localPath= localPathForUrl (cacheDir (), remoteUrl);
+
+ QFile file (localPath);
+ if (!file.open (QIODevice::WriteOnly)) return;
+ file.write (reply->readAll ());
+ file.close ();
+
+ // 提取 ETag / Last-Modified
+ ThumbnailMeta meta;
+ meta.etag = QString::fromUtf8 (reply->rawHeader ("ETag"));
+ meta.lastModified= QString::fromUtf8 (reply->rawHeader ("Last-Modified"));
+ if (!meta.etag.isEmpty () || !meta.lastModified.isEmpty ())
+ meta_.insert (remoteUrl, meta);
+ else meta_.remove (remoteUrl);
+
+ validatedUrls_.insert (remoteUrl);
+ saveMeta ();
+
+ QString fileUrl= "file://" + localPath;
+ urlCache_.insert (remoteUrl, fileUrl);
+ emit ready (remoteUrl);
}
diff --git a/src/Mogan/Cache/thumbnail_loader.hpp b/src/Mogan/Cache/thumbnail_loader.hpp
index 875f18e9e0..a08b25df92 100644
--- a/src/Mogan/Cache/thumbnail_loader.hpp
+++ b/src/Mogan/Cache/thumbnail_loader.hpp
@@ -1,70 +1,91 @@
/******************************************************************************
* MODULE : thumbnail_loader.hpp
- * DESCRIPTION: Shared thumbnail loading with queue, cache, and HTTP validation
+ * DESCRIPTION: 缩略图下载 + 本地缓存 + ETag 条件请求校验
* COPYRIGHT : (C) 2026 Yuki Lu
+ *******************************************************************************
+ * This software falls under the GNU general public license version 3 or later.
+ * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
+ * in the root directory or .
******************************************************************************/
#ifndef THUMBNAIL_LOADER_HPP
#define THUMBNAIL_LOADER_HPP
+#include
#include
-#include
-#include
#include
-#include
+#include
-class QLabel;
class QNetworkAccessManager;
class QNetworkReply;
-/**
- * @brief Pending thumbnail load request
- */
-struct ThumbnailLoadRequest {
- QPointer label;
- QString url;
- QString cachedEtag;
- QSize targetSize;
+/** 单个缩略图的持久化校验元数据。 */
+struct ThumbnailMeta {
+ QString etag;
+ QString lastModified;
};
/**
- * @brief Shared thumbnail loader (singleton)
+ * @brief 缩略图下载器:将远程 URL 下载到本地,返回 file:// 路径。
+ *
+ * 用法:
+ * QString url= loader->getUrl (remoteUrl);
+ * // 本地已有 → 立即返回 "file:///.../xxx.png",后台发 If-None-Match 校验
+ * // 首次访问 → 触发异步下载,返回原 remoteUrl
*
- * Manages a global download queue with concurrency control,
- * ETag-based conditional HTTP requests, and ThumbnailCache integration.
+ * 下载完成后 emit ready (remoteUrl),消费者据此刷新对应 UI。
+ * 校验(304)不会触发 ready,仅静默更新元数据。
+ *
+ * 缓存策略:
+ * - 缩略图文件持久化到 system/cache/thumbnails/,以 URL MD5 命名
+ * - ETag + Last-Modified 持久化到
+ * system/cache/thumbnails/thumbnails_meta.json
+ * - 每会话每个 URL 最多发一次条件请求(304 响应后本会话不再请求)
*/
class ThumbnailLoader : public QObject {
Q_OBJECT
+ Q_DISABLE_COPY (ThumbnailLoader)
public:
explicit ThumbnailLoader (QObject* parent= nullptr);
~ThumbnailLoader ();
- static ThumbnailLoader* instance ();
-
- /**
- * @brief Load a thumbnail into a QLabel
- *
- * If the thumbnail is already cached, sets the pixmap immediately.
- * If the cached entry needs validation (ETag-based conditional request),
- * enqueues a background validation. If not cached at all, downloads it.
- *
- * @param label Target QLabel widget
- * @param url Image URL
- * @param targetSize Desired display size (used for cache key and scaling)
- */
- void load (QLabel* label, const QString& url, const QSize& targetSize);
+ /// 获取缩略图 URL:已缓存返回 file://,否则触发异步下载。
+ /// 已缓存且持有 ETag 时,后台发 If-None-Match 校验(不阻塞返回)。
+ QString getUrl (const QString& remoteUrl);
+ /// 只查缓存不触发下载/校验:已缓存返回 file://,否则直接返回原 URL。
+ QString queryUrl (const QString& remoteUrl);
-private:
- void processQueue ();
+signals:
+ /// remoteUrl 对应的本地 file:// 已更新(下载完成或服务端返回新版本)。
+ void ready (const QString& remoteUrl);
+
+private slots:
+ void onDownloadFinished (QNetworkReply* reply);
private:
- QNetworkAccessManager* networkManager_= nullptr;
- QQueue queue_;
- int activeRequests_= 0;
- static constexpr int MAX_CONCURRENT = 6;
- QSet validatedUrls_;
+ QString cacheDir () const;
+ QString metaFilePath () const;
+ void loadMeta ();
+ void saveMeta ();
+ QString getUrlImpl (const QString& remoteUrl, bool triggerDownload,
+ bool validateCached);
+
+ QNetworkAccessManager* nam_;
+
+ /// remote URL → file:// 已缓存路径(仅在下载完成后写入)。
+ QHash urlCache_;
+
+ /// 正在进行的网络请求的 remote URL(避免重复请求)。
+ QSet inFlight_;
+
+ /// 本会话已校验通过的 URL(304 或新下载后加入,避免同会话重复发条件请求)。
+ QSet validatedUrls_;
+
+ /// remote URL → ETag + Last-Modified(从 JSON
+ /// 加载,下载/校验成功后更新并持久化)。
+ QHash meta_;
};
-#endif // THUMBNAIL_LOADER_HPP
+#endif
diff --git a/src/Mogan/TemplateCenter/template_manager.cpp b/src/Mogan/TemplateCenter/template_manager.cpp
index e04ccabdc3..88cf6114d6 100644
--- a/src/Mogan/TemplateCenter/template_manager.cpp
+++ b/src/Mogan/TemplateCenter/template_manager.cpp
@@ -12,10 +12,10 @@
#include "template_manager.hpp"
#include "template_api.hpp"
#include "template_cache.hpp"
+#include "thumbnail_loader.hpp"
#include "image_cache_base.hpp"
#include "qt_utilities.hpp"
-#include
#include
#include
#include
@@ -70,6 +70,15 @@ TemplateManager::TemplateManager (QObject* parent)
&TemplateManager::onRemoteRecommendTemplatesLoaded);
connect (api_, &TemplateAPI::recommendTemplatesLoadFailed, this,
&TemplateManager::onRemoteRecommendTemplatesFailed);
+
+ // 缩略图下载器
+ thumbnailLoader_= new ThumbnailLoader (this);
+ connect (thumbnailLoader_, &ThumbnailLoader::ready, this,
+ [this] (const QString&) {
+ // 不修改 templates_ 中的 thumbnailUrl(保持原始远程 URL),
+ // 仅通知消费者刷新,由 bridge 层通过 getUrl() 取本地路径。
+ emit thumbnailCached (QString ());
+ });
}
TemplateManager::~TemplateManager () { g_instance= nullptr; }
@@ -788,6 +797,12 @@ TemplateManager::mergeMetadata (
<< " found in cache\n";
}
}
+
+ // 触发缩略图异步下载(不修改 thumbnailUrl,URL 转换在 bridge 层进行)
+ for (auto it= templates_.begin (); it != templates_.end (); ++it) {
+ TemplateMetadataPtr tmpl= it.value ();
+ if (tmpl) thumbnailLoader_->getUrl (tmpl->thumbnailUrl);
+ }
}
QString
diff --git a/src/Mogan/TemplateCenter/template_manager.hpp b/src/Mogan/TemplateCenter/template_manager.hpp
index d5c2608561..33f3a05731 100644
--- a/src/Mogan/TemplateCenter/template_manager.hpp
+++ b/src/Mogan/TemplateCenter/template_manager.hpp
@@ -25,6 +25,7 @@ class string;
// Forward declarations
class TemplateCache;
class TemplateAPI;
+class ThumbnailLoader;
/**
* @brief Template manager - main entry point for template operations
@@ -99,6 +100,8 @@ class TemplateManager : public QObject {
QString downloadTemplateSync (const QString& templateId, int timeoutMs= 30000,
QString* errorMessage= nullptr);
+ ThumbnailLoader* thumbnailLoader () const { return thumbnailLoader_; }
+
// Signals for UI updates
void onNetworkStateChanged (bool isOnline);
@@ -125,6 +128,9 @@ class TemplateManager : public QObject {
// Update notifications
void updateAvailable (int newTemplatesCount, int updatedTemplatesCount);
+ // 缩略图下载完成(通知 UI 刷新)
+ void thumbnailCached (const QString& templateId);
+
private slots:
void onRemoteCategoriesLoaded (const QList& categories);
void onRemoteCategoriesFailed (const QString& error);
@@ -165,8 +171,9 @@ private slots:
QList recommendTemplateIds_;
// Components
- TemplateCache* cache_;
- TemplateAPI* api_;
+ TemplateCache* cache_;
+ TemplateAPI* api_;
+ ThumbnailLoader* thumbnailLoader_;
// State
bool isOnline_;
diff --git a/src/Plugins/Qt/QTMHomePage.cpp b/src/Plugins/Qt/QTMHomePage.cpp
deleted file mode 100644
index 5a0b7068bf..0000000000
--- a/src/Plugins/Qt/QTMHomePage.cpp
+++ /dev/null
@@ -1,841 +0,0 @@
-
-/******************************************************************************
- * MODULE : QTMHomePage.cpp
- * DESCRIPTION: Home page implementation for startup tab
- * COPYRIGHT : (C) 2026 Yuki Lu
- *******************************************************************************
- * This software falls under the GNU general public license version 3 or later.
- * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
- * in the root directory or .
- ******************************************************************************/
-
-#include "QTMHomePage.hpp"
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include "QTMTemplateOpener.hpp"
-#include "qt_dpi_utils.hpp"
-#include "qt_floating_toast.hpp"
-#include "qt_utilities.hpp"
-#include "s7_tm.hpp"
-#include "sys_utils.hpp"
-#include "template_manager.hpp"
-#include "thumbnail_loader.hpp"
-
-// 最多显示的最近文档数量
-static const int MAX_RECENT_DOCS = 50;
-static const int MAX_GLOBAL_RECENT_DOCS= 100;
-
-namespace {
-constexpr int kMainMargin = 32; // 主内容区外边距
-constexpr int kMainSpacing = 24; // 主纵向布局间距
-constexpr int kStyleCardWidth = 160; // 样式卡片宽度
-constexpr int kStyleCardHeight = 256; // 样式卡片高度
-constexpr int kStyleIconSize = 96; // 样式卡片图标尺寸
-constexpr int kStyleIconImageSize = 50; // 样式卡片图标图片尺寸
-constexpr int kStyleCardTopPadding = 12; // 样式卡片顶部内边距
-constexpr int kStyleCardMargin = 8; // 样式卡片内边距
-constexpr int kStyleCardSpacing = 4; // 样式卡片内部控件间距
-constexpr int kStyleCardsSpacing = 16; // 样式卡片横向间距
-constexpr int kStyleCardRadius = 8; // 样式卡片圆角
-constexpr int kSectionTitleFontPx = 16; // 分区标题字号
-constexpr int kStyleIconFontPx = 48; // 样式图标字母字号
-constexpr int kStyleNameFontPx = 14; // 样式名称字号
-constexpr int kRecentListRadius = 8; // Recent 列表圆角
-constexpr int kRecentItemHeight = 40; // Recent 列表项高度
-constexpr int kRecentItemRadius = 4; // Recent 列表项圆角
-constexpr int kRecentItemMarginX = 4; // Recent 列表项横向边距
-constexpr int kRecentItemMarginY = 2; // Recent 列表项纵向边距
-constexpr int kRecentItemPaddingX = 8; // Recent 列表项横向内边距
-constexpr int kRecentItemPaddingY = 6; // Recent 列表项纵向内边距
-constexpr int kRecentItemSpacing = 3; // Recent 名称与时间标签间距
-constexpr int kRecentNameFontPx = 15; // Recent 文件名字号
-constexpr int kRecentTimeFontPx = 11; // Recent 时间字号
-constexpr int kStyleTitleHeight = 29; // 模板卡片标题高度
-constexpr int kStyleCardInnerPadding= 4; // 缩略图卡片内边距
-constexpr int kTemplateTitleFontPx = 10; // 模板卡片标题字号
-} // namespace
-
-/******************************************************************************
- * StyleCard 实现
- ******************************************************************************/
-
-StyleCard::StyleCard (const DocStyle& style, QWidget* parent)
- : QWidget (parent), styleId_ (style.id), templateId_ (style.templateId),
- isTemplate_ (!style.templateId.isEmpty ()) {
- setFixedSize (DpiUtils::scaled (kStyleCardWidth),
- DpiUtils::scaled (kStyleCardHeight));
- setCursor (Qt::PointingHandCursor);
- setFocusPolicy (Qt::NoFocus);
- setToolTip (style.description);
- setObjectName ("style-card");
-
- if (isTemplate_) {
- setupThumbnailMode (style);
- }
- else {
- setupIconMode (style);
- }
-}
-
-StyleCard::~StyleCard ()= default;
-
-void
-StyleCard::setupIconMode (const DocStyle& style) {
- int iconSize = DpiUtils::scaled (kStyleIconSize);
- int imageSize= DpiUtils::scaled (kStyleIconImageSize);
-
- QVBoxLayout* layout= new QVBoxLayout (this);
- layout->setContentsMargins (DpiUtils::scaled (kStyleCardMargin),
- DpiUtils::scaled (kStyleCardTopPadding),
- DpiUtils::scaled (kStyleCardMargin),
- DpiUtils::scaled (kStyleCardMargin));
- layout->setSpacing (DpiUtils::scaled (kStyleCardSpacing));
- layout->addStretch ();
-
- iconLabel_= new QLabel (this);
- iconLabel_->setAlignment (Qt::AlignCenter);
-
- if (style.id == "new") {
- iconLabel_->setObjectName ("style-card-icon-new");
- iconLabel_->setFixedSize (imageSize, imageSize);
- }
- else if (style.id == "open") {
- iconLabel_->setObjectName ("style-card-icon-open");
- iconLabel_->setFixedSize (imageSize, imageSize);
- }
- else {
- iconLabel_->setObjectName ("style-card-icon");
- iconLabel_->setFixedSize (iconSize, iconSize);
- iconLabel_->setText (style.id.left (1).toUpper ());
- QFont iconFont=
- DpiUtils::scaledFont (iconLabel_->font (), kStyleIconFontPx);
- iconFont.setBold (true);
- iconLabel_->setFont (iconFont);
- }
- layout->addWidget (iconLabel_, 0, Qt::AlignCenter);
- layout->addSpacing (DpiUtils::scaled (8));
-
- nameLabel_= new QLabel (style.name, this);
- nameLabel_->setAlignment (Qt::AlignCenter);
- nameLabel_->setObjectName ("style-card-name");
- DpiUtils::applyScaledFont (nameLabel_, kStyleNameFontPx);
- layout->addWidget (nameLabel_, 0, Qt::AlignCenter);
-
- layout->addStretch ();
-}
-
-void
-StyleCard::setupThumbnailMode (const DocStyle& style) {
- int cardW = DpiUtils::scaled (kStyleCardWidth);
- int cardH = DpiUtils::scaled (kStyleCardHeight);
- int titleH = DpiUtils::scaled (kStyleTitleHeight);
- int radiusPx= DpiUtils::scaled (kStyleCardRadius);
- int pad = DpiUtils::scaled (kStyleCardInnerPadding);
-
- QFrame* cardFrame= new QFrame (this);
- cardFrame->setObjectName ("style-card-frame");
- cardFrame->setFixedSize (cardW, cardH);
- cardFrame->setCursor (Qt::PointingHandCursor);
- cardFrame->setFrameShape (QFrame::NoFrame);
- cardFrame->setStyleSheet (QString ("QFrame#style-card-frame {"
- " border-radius: %1px;"
- "}")
- .arg (radiusPx));
-
- int thumbW= cardW - pad * 2;
- int thumbH= cardH - pad * 2 - titleH;
-
- thumbnailLabel_= new QLabel (cardFrame);
- thumbnailLabel_->setGeometry (pad, pad, thumbW, thumbH);
- thumbnailLabel_->setAlignment (Qt::AlignCenter);
- thumbnailLabel_->setObjectName ("style-card-thumbnail");
- thumbnailLabel_->setText (qt_translate ("Loading..."));
-
- titleLabel_= new QLabel (style.name, cardFrame);
- titleLabel_->setGeometry (pad, pad + thumbH, thumbW, titleH);
- titleLabel_->setAlignment (Qt::AlignCenter);
- titleLabel_->setObjectName ("style-card-title");
- titleLabel_->setStyleSheet (QString ("QLabel#style-card-title {"
- " border-bottom-left-radius: %1px;"
- " border-bottom-right-radius: %1px;"
- "}")
- .arg (radiusPx));
- DpiUtils::applyScaledFont (titleLabel_, kTemplateTitleFontPx);
-}
-
-void
-StyleCard::loadThumbnail (const QString& url) {
- if (url.isEmpty () || !thumbnailLabel_) return;
- ThumbnailLoader::instance ()->load (thumbnailLabel_, url,
- thumbnailLabel_->size ());
-}
-
-void
-StyleCard::mousePressEvent (QMouseEvent* event) {
- if (event->button () == Qt::LeftButton) {
- emit clicked ();
- }
- QWidget::mousePressEvent (event);
-}
-
-void
-StyleCard::paintEvent (QPaintEvent* event) {
- QPainter painter (this);
-
- QStyleOption opt;
- opt.initFrom (this);
-
- style ()->drawPrimitive (QStyle::PE_Widget, &opt, &painter, this);
-}
-
-/******************************************************************************
- * QTMHomePage 实现
- ******************************************************************************/
-
-QTMHomePage::QTMHomePage (QWidget* parent) : QWidget (parent) {
- eval_scheme ("(use-modules (startup-tab startup-tab-file))");
-
- styles_= {{"new", qt_translate ("New document"),
- qt_translate ("Create a new blank document"), ""},
- {"open", qt_translate ("Open document"),
- qt_translate ("Open an existing document"), ""}};
-
- setupUI ();
- loadRecentDocs ();
-
- TemplateManager* mgr= TemplateManager::instance ();
- if (mgr) {
- connect (mgr, &TemplateManager::templatesLoaded, this,
- &QTMHomePage::refreshTemplateThumbnails, Qt::UniqueConnection);
- connect (mgr, &TemplateManager::recommendTemplatesLoaded, this,
- &QTMHomePage::onRecommendTemplatesLoaded, Qt::UniqueConnection);
- connect (mgr, &TemplateManager::recommendTemplatesLoadFailed, this,
- &QTMHomePage::onRecommendTemplatesLoadFailed,
- Qt::UniqueConnection);
- if (mgr->isInitialized () && !mgr->templates ().isEmpty ()) {
- refreshTemplateThumbnails ();
- refreshTemplateCards ();
- }
- else if (!mgr->isInitialized ()) {
- // mgr 作 receiver:QObject 析构时 Qt 自动断开定时器,避免悬挂
- QTimer::singleShot (0, mgr, [mgr] () { mgr->initialize (); });
- }
- }
-}
-
-QTMHomePage::~QTMHomePage ()= default;
-
-void
-QTMHomePage::showEvent (QShowEvent* event) {
- QWidget::showEvent (event);
- refreshRecentDocs ();
- QTimer::singleShot (0, this, &QTMHomePage::rearrangeStyleCards);
-}
-
-void
-QTMHomePage::resizeEvent (QResizeEvent* event) {
- QWidget::resizeEvent (event);
- // 只在宽度变化时重排,避免不必要的计算
- if (event->oldSize ().width () != event->size ().width ()) {
- rearrangeStyleCards ();
- }
-}
-
-void
-QTMHomePage::setupUI () {
- QVBoxLayout* mainLayout= new QVBoxLayout (this);
- mainLayout->setContentsMargins (
- DpiUtils::scaled (kMainMargin), DpiUtils::scaled (kMainMargin),
- DpiUtils::scaled (kMainMargin), DpiUtils::scaled (kMainMargin));
- mainLayout->setSpacing (DpiUtils::scaled (kMainSpacing));
-
- // 1. 样式选择区
- setupStyleCards (mainLayout);
-
- // 分隔线
- QFrame* line= new QFrame (this);
- line->setFrameShape (QFrame::HLine);
- line->setObjectName ("startup-tab-separator");
- mainLayout->addWidget (line);
-
- // 2. 最近文档区
- setupRecentDocs (mainLayout);
-}
-
-void
-QTMHomePage::rearrangeStyleCards () {
- if (!cardsLayout_ || styleCards_.isEmpty ()) return;
-
- // 清除当前布局中的卡片
- for (auto* card : styleCards_) {
- cardsLayout_->removeWidget (card);
- }
-
- // 计算每行可以容纳的卡片数
- int cardWidth= DpiUtils::scaled (kStyleCardWidth);
- int spacing = DpiUtils::scaled (kStyleCardsSpacing);
- int availableWidth=
- cardsContainer_->width () - DpiUtils::scaled (kMainMargin * 2);
- int cardsPerRow= qMax (1, (availableWidth + spacing) / (cardWidth + spacing));
-
- // 重新排列卡片
- for (int i= 0; i < styleCards_.size (); ++i) {
- int row= i / cardsPerRow;
- int col= i % cardsPerRow;
- cardsLayout_->addWidget (styleCards_[i], row, col,
- Qt::AlignLeft | Qt::AlignTop);
- }
-}
-
-void
-QTMHomePage::setupStyleCards (QVBoxLayout* layout) {
- // 标题
- QLabel* title= new QLabel (qt_translate ("Document Style"), this);
- title->setObjectName ("startup-tab-section-title");
- DpiUtils::applyScaledFont (title, kSectionTitleFontPx);
- layout->addWidget (title);
-
- // 样式卡片容器(响应式网格布局)
- cardsContainer_= new QWidget (this);
- cardsContainer_->setObjectName ("startup-tab-file-cards");
- cardsLayout_= new QGridLayout (cardsContainer_);
- cardsLayout_->setContentsMargins (0, 0, 0, 0);
- cardsLayout_->setSpacing (DpiUtils::scaled (kStyleCardsSpacing));
- cardsLayout_->setAlignment (Qt::AlignLeft | Qt::AlignTop);
-
- for (const auto& style : styles_) {
- StyleCard* card= new StyleCard (style, cardsContainer_);
- styleCards_.append (card);
-
- connect (card, &StyleCard::clicked, this,
- [this, card] () { createDocumentWithStyle (card->styleId ()); });
-
- if (card->isTemplate ()) {
- TemplateManager* mgr= TemplateManager::instance ();
- if (mgr && mgr->isInitialized ()) {
- if (auto meta= mgr->templateById (card->templateId ())) {
- if (!meta->thumbnailUrl.isEmpty ()) {
- card->loadThumbnail (meta->thumbnailUrl);
- }
- }
- }
- }
- }
-
- layout->addWidget (cardsContainer_);
-}
-
-void
-QTMHomePage::setupRecentDocs (QVBoxLayout* layout) {
- // 标题
- QLabel* title= new QLabel (qt_translate ("Recent Documents"), this);
- title->setObjectName ("startup-tab-section-title");
- DpiUtils::applyScaledFont (title, kSectionTitleFontPx);
- layout->addWidget (title);
-
- // 最近文档列表
- recentList_= new QListWidget (this);
- recentList_->setObjectName ("recent-docs-list");
- recentList_->setStyleSheet (
- QString ("QListWidget#recent-docs-list { border-radius: %1px; }\n"
- "QListWidget#recent-docs-list::item {\n"
- " min-height: %2px;\n"
- " border-radius: %3px;\n"
- " margin: %4px %5px;\n"
- "}")
- .arg (DpiUtils::scaled (kRecentListRadius))
- .arg (DpiUtils::scaled (kRecentItemHeight))
- .arg (DpiUtils::scaled (kRecentItemRadius))
- .arg (DpiUtils::scaled (kRecentItemMarginY))
- .arg (DpiUtils::scaled (kRecentItemMarginX)));
- recentList_->setFocusPolicy (Qt::NoFocus);
- recentList_->setContextMenuPolicy (Qt::CustomContextMenu);
- recentList_->setSizePolicy (QSizePolicy::Expanding, QSizePolicy::Expanding);
-
- connect (recentList_, &QListWidget::itemClicked, this,
- [this] (QListWidgetItem* item) {
- if (item) onRecentDocClicked (item);
- });
- connect (recentList_, &QListWidget::customContextMenuRequested, this,
- &QTMHomePage::onRecentDocContextMenu);
-
- layout->addWidget (recentList_);
-}
-
-/******************************************************************************
- * 最近文档管理
- ******************************************************************************/
-
-static QString
-getRecentDocsFilePath () {
- string homePath = get_env ("TEXMACS_HOME_PATH");
- QString configDir= to_qstring (homePath * "/system");
- QDir ().mkpath (configDir);
- return QDir (configDir).filePath ("recent-files.json");
-}
-
-static QStringList
-getRecentDocPathsFromScheme () {
- QStringList paths;
- tmscm result= eval_scheme ("(startup-tab-get-recent-docs)");
- if (!tmscm_is_list (result)) return paths;
-
- for (tmscm cur= result; !tmscm_is_null (cur); cur= tmscm_cdr (cur)) {
- tmscm item= tmscm_car (cur);
- if (!tmscm_is_string (item)) continue;
- paths.append (QString::fromUtf8 (as_charp (tmscm_to_string (item))));
- }
- return paths;
-}
-
-static QDateTime
-readRecentOpenedAt (const QJsonObject& docObj, bool hasFilesField) {
- if (hasFilesField) {
- qint64 lastOpen= static_cast (docObj["last_open"].toDouble ());
- QDateTime openedAt= QDateTime::fromSecsSinceEpoch (lastOpen);
- return openedAt.isValid () ? openedAt : QDateTime::currentDateTime ();
- }
-
- QDateTime openedAt=
- QDateTime::fromString (docObj["opened_at"].toString (), Qt::ISODate);
- return openedAt.isValid () ? openedAt : QDateTime::currentDateTime ();
-}
-
-static void
-populateRecentDocsFromPaths (QList& recentDocs,
- const QStringList& recentPaths) {
- for (const QString& path : recentPaths) {
- if (path.isEmpty ()) continue;
- RecentDoc recentDoc;
- recentDoc.filePath= path;
- recentDoc.fileName= QFileInfo (path).fileName ();
- recentDoc.openedAt= QDateTime::currentDateTime ();
- recentDocs.append (recentDoc);
- if (recentDocs.size () >= MAX_RECENT_DOCS) break;
- }
-}
-
-void
-QTMHomePage::loadRecentDocs () {
- recentDocs_.clear ();
- recentList_->clear ();
-
- QStringList recentPaths= getRecentDocPathsFromScheme ();
- for (QString& path : recentPaths) {
- path= QDir::fromNativeSeparators (path);
- }
- recentPaths.removeDuplicates ();
-
- QStringList existingPaths;
- for (const QString& path : recentPaths) {
- if (QFile::exists (path)) {
- existingPaths.append (path);
- }
- }
- recentPaths= existingPaths;
-
- QString filePath= getRecentDocsFilePath ();
- QFile file (filePath);
- if (!file.open (QIODevice::ReadOnly)) {
- populateRecentDocsFromPaths (recentDocs_, recentPaths);
- renderRecentDocs ();
- return;
- }
-
- QByteArray data= file.readAll ();
- QJsonDocument doc = QJsonDocument::fromJson (data);
- if (!doc.isObject ()) {
- populateRecentDocsFromPaths (recentDocs_, recentPaths);
- renderRecentDocs ();
- return;
- }
-
- QJsonObject obj = doc.object ();
- const bool hasFilesField= obj.contains ("files");
- const QJsonArray recentArray = hasFilesField
- ? obj["files"].toArray ()
- : obj["recent_documents"].toArray ();
- QHash recentByPath;
- for (const auto& val : recentArray) {
- const QJsonObject docObj= val.toObject ();
- const QString path= QDir::fromNativeSeparators (docObj["path"].toString ());
- if (!path.isEmpty ()) recentByPath.insert (path, docObj);
- }
-
- for (const QString& path : recentPaths) {
- if (path.isEmpty ()) continue;
-
- RecentDoc recentDoc;
- recentDoc.filePath= path;
- if (recentByPath.contains (path)) {
- const QJsonObject& docObj= recentByPath[path];
- recentDoc.fileName = docObj["name"].toString ();
- recentDoc.openedAt = readRecentOpenedAt (docObj, hasFilesField);
- }
- else {
- recentDoc.openedAt= QDateTime::currentDateTime ();
- }
- if (recentDoc.fileName.isEmpty ()) {
- recentDoc.fileName= QFileInfo (path).fileName ();
- }
-
- recentDocs_.append (recentDoc);
- if (recentDocs_.size () >= MAX_RECENT_DOCS) break;
- }
-
- renderRecentDocs ();
-}
-
-void
-QTMHomePage::saveRecentDocs () {
- QFile file (getRecentDocsFilePath ());
- QJsonObject root;
- if (file.open (QIODevice::ReadOnly)) {
- QJsonDocument existingDoc= QJsonDocument::fromJson (file.readAll ());
- if (existingDoc.isObject ()) {
- root= existingDoc.object ();
- }
- file.close ();
- }
- if (root.isEmpty ()) {
- root["meta"] = QJsonObject{{"version", 1}, {"total", 0}};
- root["files"]= QJsonArray ();
- }
-
- QJsonObject meta = root["meta"].toObject ();
- QJsonArray files= root["files"].toArray ();
-
- QHash existingByPath;
- for (const auto& val : files) {
- QJsonObject obj = val.toObject ();
- QString path= QDir::fromNativeSeparators (obj["path"].toString ());
- if (!path.isEmpty ()) existingByPath.insert (path, obj);
- }
-
- QJsonArray updatedFiles;
- for (const auto& doc : recentDocs_) {
- QJsonObject obj = existingByPath.value (doc.filePath);
- obj["path"] = doc.filePath;
- obj["name"] = doc.fileName;
- obj["last_open"] = static_cast (doc.openedAt.toSecsSinceEpoch ());
- obj["show"] = true;
- obj["open_count"]= qMax (1, obj["open_count"].toInt () + 1);
- updatedFiles.append (obj);
- existingByPath.remove (doc.filePath);
- }
-
- for (auto it= existingByPath.constBegin (); it != existingByPath.constEnd ();
- ++it) {
- QJsonObject obj= it.value ();
- obj["show"] = false;
- updatedFiles.append (obj);
- }
-
- while (updatedFiles.size () > MAX_GLOBAL_RECENT_DOCS) {
- updatedFiles.removeLast ();
- }
-
- meta["version"]= meta.value ("version").toInt (1);
- meta["total"] = updatedFiles.size ();
- root["meta"] = meta;
- root["files"] = updatedFiles;
-
- if (!file.open (QIODevice::WriteOnly | QIODevice::Truncate)) return;
- QByteArray jsonData= QJsonDocument (root).toJson ();
- if (file.write (jsonData) != jsonData.size ()) {
- qWarning () << "Failed to write recent docs file";
- }
- file.close ();
-}
-
-void
-QTMHomePage::refreshRecentDocs () {
- loadRecentDocs ();
-}
-
-void
-QTMHomePage::renderRecentDocs () {
- while (recentList_->count () > 0) {
- QListWidgetItem* item = recentList_->takeItem (0);
- QWidget* widget= recentList_->itemWidget (item);
- if (widget) {
- recentList_->removeItemWidget (item);
- delete widget;
- }
- delete item;
- }
-
- for (const auto& doc : recentDocs_) {
- auto* item= new QListWidgetItem ();
- item->setData (Qt::UserRole, doc.filePath);
- item->setToolTip (doc.filePath);
- item->setSizeHint (QSize (0, DpiUtils::scaled (kRecentItemHeight)));
- recentList_->addItem (item);
-
- auto* rowWidget= new QWidget (recentList_);
- rowWidget->setObjectName ("startup-tab-recent-item");
- rowWidget->setAttribute (Qt::WA_TransparentForMouseEvents);
- auto* rowLayout= new QHBoxLayout (rowWidget);
- rowLayout->setContentsMargins (DpiUtils::scaled (kRecentItemPaddingX),
- DpiUtils::scaled (kRecentItemPaddingY),
- DpiUtils::scaled (kRecentItemPaddingX),
- DpiUtils::scaled (kRecentItemPaddingY));
- rowLayout->setSpacing (DpiUtils::scaled (kRecentItemSpacing));
-
- auto* nameLabel= new QLabel (doc.fileName, rowWidget);
- nameLabel->setObjectName ("startup-tab-recent-name");
- QFont nameFont=
- DpiUtils::scaledFont (nameLabel->font (), kRecentNameFontPx);
- nameFont.setBold (true);
- nameLabel->setFont (nameFont);
-
- auto* timeLabel= new QLabel (qt_translate ("Last opened") + ": " +
- doc.openedAt.toString ("yyyy-MM-dd hh:mm"),
- rowWidget);
- timeLabel->setObjectName ("startup-tab-recent-time");
- DpiUtils::applyScaledFont (timeLabel, kRecentTimeFontPx);
- timeLabel->setAlignment (Qt::AlignRight | Qt::AlignVCenter);
-
- rowLayout->addWidget (nameLabel, 1, Qt::AlignLeft | Qt::AlignVCenter);
- rowLayout->addWidget (timeLabel, 0, Qt::AlignRight | Qt::AlignVCenter);
- recentList_->setItemWidget (item, rowWidget);
- }
-
- if (recentDocs_.isEmpty ()) {
- auto* item= new QListWidgetItem (qt_translate ("No recent documents"));
- item->setFlags (item->flags () & ~Qt::ItemIsEnabled);
- recentList_->addItem (item);
- }
-}
-
-void
-QTMHomePage::addRecentDoc (const QString& path) {
- QString normPath= QDir::fromNativeSeparators (path);
- QString fileName= QFileInfo (normPath).fileName ();
-
- // 检查是否已存在
- for (auto it= recentDocs_.begin (); it != recentDocs_.end (); ++it) {
- if (it->filePath == normPath) {
- it->openedAt= QDateTime::currentDateTime ();
- // 移到最前面
- RecentDoc doc= *it;
- recentDocs_.erase (it);
- recentDocs_.prepend (doc);
- saveRecentDocs ();
- eval_scheme ("(startup-tab-add-recent-doc " *
- qt_scheme_quote_utf8 (normPath) * ")");
- refreshRecentDocs ();
- return;
- }
- }
-
- // 添加新条目
- RecentDoc doc;
- doc.filePath= normPath;
- doc.fileName= fileName;
- doc.openedAt= QDateTime::currentDateTime ();
- recentDocs_.prepend (doc);
-
- // 限制数量
- while (recentDocs_.size () > MAX_RECENT_DOCS) {
- recentDocs_.removeLast ();
- }
-
- saveRecentDocs ();
- eval_scheme ("(startup-tab-add-recent-doc " *
- qt_scheme_quote_utf8 (normPath) * ")");
- refreshRecentDocs ();
-}
-
-void
-QTMHomePage::removeRecentDoc (const QString& path) {
- QString normPath= QDir::fromNativeSeparators (path);
- for (auto it= recentDocs_.begin (); it != recentDocs_.end (); ++it) {
- if (it->filePath == normPath) {
- recentDocs_.erase (it);
- break;
- }
- }
- saveRecentDocs ();
-
- eval_scheme ("(startup-tab-clear-recent-doc " *
- qt_scheme_quote_utf8 (normPath) * ")");
- refreshRecentDocs ();
-}
-
-void
-QTMHomePage::clearAllRecentDocs () {
- eval_scheme ("(startup-tab-clear-all-recent)");
- recentDocs_.clear ();
- saveRecentDocs ();
- refreshRecentDocs ();
-}
-
-/******************************************************************************
- * 事件处理
- ******************************************************************************/
-
-void
-QTMHomePage::onRecentDocClicked (QListWidgetItem* item) {
- if (!item) return;
-
- QString path= item->data (Qt::UserRole).toString ();
- if (path.isEmpty ()) return;
-
- if (!QFile::exists (path)) {
- QtFloatingToast::showToast (
- recentList_, qt_translate ("File not found, removed from recent list"),
- 3000, QtFloatingToast::Error);
- removeRecentDoc (path);
- return;
- }
-
- addRecentDoc (path);
-
- eval_scheme ("(load-document " * qt_scheme_quote_utf8 (path) * ")");
-}
-
-void
-QTMHomePage::onRecentDocContextMenu (const QPoint& pos) {
- QListWidgetItem* item= recentList_->itemAt (pos);
- if (!item) return;
-
- QString path= item->data (Qt::UserRole).toString ();
- if (path.isEmpty ()) return;
-
- QMenu menu (this);
- QAction* removeAction= menu.addAction (qt_translate ("Remove from list"));
- QAction* clearAction = menu.addAction (qt_translate ("Clear list"));
-
- QAction* selected= menu.exec (recentList_->mapToGlobal (pos));
- if (selected == removeAction) {
- removeRecentDoc (path);
- }
- else if (selected == clearAction) {
- clearAllRecentDocs ();
- }
-}
-
-void
-QTMHomePage::createDocumentWithStyle (const QString& styleId) {
- if (styleId == "new") {
- eval_scheme ("(new-document-with-style \"generic\")");
- return;
- }
-
- if (styleId == "open") {
- if (!recentDocs_.isEmpty ()) {
- QString dir= QFileInfo (recentDocs_.first ().filePath).absolutePath ();
- eval_scheme (
- "(choose-file load-buffer \"Load file\" \"action_open\" \"\" "
- "(system->url " *
- qt_scheme_quote_utf8 (dir) * "))");
- }
- else {
- eval_scheme ("(open-document)");
- }
- return;
- }
-
- for (const auto& style : styles_) {
- if (style.id == styleId) {
- QTMTemplateOpener opener (this);
- opener.openTemplate (style.templateId);
- return;
- }
- }
-
- qWarning () << "Invalid style ID:" << styleId;
-}
-
-void
-QTMHomePage::refreshTemplateThumbnails () {
- TemplateManager* mgr= TemplateManager::instance ();
- if (!mgr || !mgr->isInitialized ()) return;
-
- for (StyleCard* card : styleCards_) {
- if (!card->isTemplate ()) continue;
- auto meta= mgr->templateById (card->templateId ());
- if (meta && !meta->thumbnailUrl.isEmpty ()) {
- card->loadThumbnail (meta->thumbnailUrl);
- }
- }
-}
-
-void
-QTMHomePage::refreshTemplateCards () {
- TemplateManager* mgr= TemplateManager::instance ();
- if (!mgr || !mgr->isInitialized ()) return;
-
- const int fixedCardCount= 2;
-
- while (styleCards_.size () > fixedCardCount) {
- StyleCard* card= styleCards_.takeLast ();
- cardsLayout_->removeWidget (card);
- card->deleteLater ();
- }
-
- while (styles_.size () > fixedCardCount) {
- styles_.removeLast ();
- }
-
- for (const auto& tmpl : mgr->recommendTemplates ()) {
- if (!tmpl) continue;
- DocStyle style;
- style.id = tmpl->id;
- style.name = tmpl->name;
- style.description= tmpl->description;
- style.templateId = tmpl->id;
- styles_.append (style);
- }
-
- for (int i= fixedCardCount; i < styles_.size (); ++i) {
- StyleCard* card= new StyleCard (styles_[i], cardsContainer_);
- styleCards_.append (card);
-
- connect (card, &StyleCard::clicked, this,
- [this, card] () { createDocumentWithStyle (card->styleId ()); });
-
- if (card->isTemplate ()) {
- if (auto meta= mgr->templateById (card->templateId ())) {
- if (!meta->thumbnailUrl.isEmpty ()) {
- card->loadThumbnail (meta->thumbnailUrl);
- }
- }
- }
- }
-
- rearrangeStyleCards ();
-}
-
-void
-QTMHomePage::onRecommendTemplatesLoaded () {
- refreshTemplateCards ();
-}
-
-void
-QTMHomePage::onRecommendTemplatesLoadFailed (const QString& error) {
- qWarning () << "[HomePage] Failed to load recommend templates:" << error;
-}
diff --git a/src/Plugins/Qt/QTMHomePage.hpp b/src/Plugins/Qt/QTMHomePage.hpp
deleted file mode 100644
index e9d88bd478..0000000000
--- a/src/Plugins/Qt/QTMHomePage.hpp
+++ /dev/null
@@ -1,141 +0,0 @@
-
-/******************************************************************************
- * MODULE : QTMHomePage.hpp
- * DESCRIPTION: Home page for startup tab with style cards and recent documents
- * COPYRIGHT : (C) 2026 Yuki Lu
- *******************************************************************************
- * This software falls under the GNU general public license version 3 or later.
- * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
- * in the root directory or .
- ******************************************************************************/
-
-#ifndef QTMHOMEPAGE_HPP
-#define QTMHOMEPAGE_HPP
-
-#include
-#include
-#include
-#include
-
-class QVBoxLayout;
-class QHBoxLayout;
-class QGridLayout;
-class QPushButton;
-class QLabel;
-class QListWidget;
-class QListWidgetItem;
-class QButtonGroup;
-class QShowEvent;
-class QResizeEvent;
-
-/**
- * @brief 文档样式信息(支持空白文档和快捷模板)
- */
-struct DocStyle {
- QString id; // 样式ID
- QString name; // 显示名称
- QString description; // 描述
- QString templateId; // 对应TemplateManager中的模板ID(空表示空白文档)
-};
-
-/**
- * @brief 最近文档条目
- */
-struct RecentDoc {
- QString fileName; // 文件名
- QString filePath; // 完整路径
- QDateTime openedAt; // 最后打开时间
-};
-
-/**
- * @brief 样式卡片部件(支持图标模式和缩略图模板模式)
- */
-class StyleCard : public QWidget {
- Q_OBJECT
-
-public:
- explicit StyleCard (const DocStyle& style, QWidget* parent= nullptr);
- ~StyleCard ();
-
- QString styleId () const { return styleId_; }
- QString templateId () const { return templateId_; }
- bool isTemplate () const { return isTemplate_; }
-
-signals:
- void clicked ();
-
-public:
- void loadThumbnail (const QString& url);
-
-protected:
- void mousePressEvent (QMouseEvent* event) override;
- void paintEvent (QPaintEvent* event) override;
-
-private:
- void setupIconMode (const DocStyle& style);
- void setupThumbnailMode (const DocStyle& style);
-
- QString styleId_;
- QString templateId_;
- bool isTemplate_= false;
-
- // Icon mode
- QLabel* iconLabel_= nullptr;
- QLabel* nameLabel_= nullptr;
-
- // Thumbnail mode
- QLabel* thumbnailLabel_= nullptr;
- QLabel* titleLabel_ = nullptr;
-};
-
-/**
- * @brief 主页 - 包含样式选择和最近文档
- */
-class QTMHomePage : public QWidget {
- Q_OBJECT
-
-public:
- explicit QTMHomePage (QWidget* parent= nullptr);
- ~QTMHomePage ();
-
- void refreshRecentDocs ();
- void addRecentDoc (const QString& path);
-
-protected:
- void showEvent (QShowEvent* event) override;
- void resizeEvent (QResizeEvent* event) override;
-
-private:
- void onRecentDocClicked (QListWidgetItem* item);
- void onRecentDocContextMenu (const QPoint& pos);
-
-private slots:
- void onRecommendTemplatesLoaded ();
- void onRecommendTemplatesLoadFailed (const QString& error);
-
-private:
- void setupUI ();
- void setupStyleCards (QVBoxLayout* layout);
- void setupRecentDocs (QVBoxLayout* layout);
- void renderRecentDocs ();
- void loadRecentDocs ();
- void saveRecentDocs ();
- void removeRecentDoc (const QString& path);
- void clearAllRecentDocs ();
- void createDocumentWithStyle (const QString& styleId);
- void refreshTemplateThumbnails ();
- void refreshTemplateCards ();
-
- // 样式卡片相关
- QList styles_;
- QList styleCards_;
- QWidget* cardsContainer_= nullptr;
- QGridLayout* cardsLayout_ = nullptr;
- void rearrangeStyleCards ();
-
- // 最近文档相关
- QList recentDocs_;
- QListWidget* recentList_= nullptr;
-};
-
-#endif // QTMHOMEPAGE_HPP
diff --git a/src/Plugins/Qt/QTMStartupTabWidget.cpp b/src/Plugins/Qt/QTMStartupTabWidget.cpp
index b7cb36ede2..125abcdc3a 100644
--- a/src/Plugins/Qt/QTMStartupTabWidget.cpp
+++ b/src/Plugins/Qt/QTMStartupTabWidget.cpp
@@ -1,7 +1,7 @@
/******************************************************************************
* MODULE : QTMStartupTabWidget.cpp
- * DESCRIPTION: Startup tab widget with left sidebar for Mogan STEM
+ * DESCRIPTION: Startup tab container implementation
* COPYRIGHT : (C) 2026 Yuki Lu
*******************************************************************************
* This software falls under the GNU general public license version 3 or later.
@@ -10,361 +10,105 @@
******************************************************************************/
#include "QTMStartupTabWidget.hpp"
-#include "QTMHomePage.hpp"
-#include "QTMTemplatePage.hpp"
+#include "StartupBridge.hpp"
+
+#include "analyze.hpp"
+#include "gui.hpp"
#include "qt_dpi_utils.hpp"
#include "qt_utilities.hpp"
-#include "template_manager.hpp"
+#include "s7_tm.hpp"
-#include
#include
#include
-#include
-#include
-#include
-#include
-
-#include "s7_tm.hpp"
+#include
+#include
+#include
namespace {
-constexpr int kMinWidth = 600; // 启动页最小宽度
-constexpr int kMinHeight = 400; // 启动页最小高度
-constexpr int kSidebarMinWidth = 150; // 左侧导航栏最小宽度
-constexpr int kSidebarMarginX = 8; // 左侧导航栏水平内边距
-constexpr int kSidebarMarginY = 16; // 左侧导航栏垂直内边距
-constexpr int kSidebarSpacing = 4; // 左侧导航栏控件间距
-constexpr int kQuitTopSpacing = 24; // Quit 按钮上方额外间距
-constexpr int kNavTitlePadding = 4; // Navigation 标题内边距
-constexpr int kNavTitleFontPx = 11; // Navigation 标题字号
-constexpr int kNavButtonPadY = 8; // 导航按钮纵向内边距
-constexpr int kNavButtonPadX = 12; // 导航按钮横向内边距
-constexpr int kNavButtonFontPx = 13; // 导航按钮字号
-constexpr int kQuitBorderWidth = 1; // Quit 按钮边框宽度
-constexpr int kQuitBorderRadius= 4; // Quit 按钮圆角
-constexpr int kQuitPadY = 8; // Quit 按钮纵向内边距
-constexpr int kQuitPadX = 12; // Quit 按钮横向内边距
-constexpr int kQuitButtonFontPx= 13; // Quit 按钮字号
+constexpr int kMinWidth = 600;
+constexpr int kMinHeight= 400;
} // namespace
-/**
- * @brief 构造函数 - 初始化启动标签页界面
- *
- * 布局结构:
- * +------------------+----------------------------------------+
- * | 左侧导航栏 | 右侧内容区 |
- * | (150px固定宽度) | (自适应剩余宽度) |
- * +------------------+----------------------------------------+
- */
-QTMStartupTabWidget::QTMStartupTabWidget (QWidget* parent)
- : QWidget (parent), currentEntry_ (Entry::Home), navHomeBtn_ (nullptr),
- navQuitBtn_ (nullptr), categoryLayout_ (nullptr),
- navButtonGroup_ (nullptr), homePage_ (nullptr), templatePage_ (nullptr),
- templateManager_ (nullptr) {
+QTMStartupTabWidget::QTMStartupTabWidget (QWidget* parent) : QWidget (parent) {
setMinimumSize (DpiUtils::scaled (kMinWidth), DpiUtils::scaled (kMinHeight));
setFocusPolicy (Qt::StrongFocus);
- // 主布局:水平排列,左侧导航栏 + 右侧内容区
- QHBoxLayout* mainLayout= new QHBoxLayout (this);
- mainLayout->setContentsMargins (0, 0, 0, 0);
- mainLayout->setSpacing (0);
-
- // 左侧导航栏
- QWidget* sidebar= new QWidget (this);
- sidebar->setObjectName ("startup-tab-sidebar"); // 样式在主题CSS中定义
- sidebar->setMinimumWidth (DpiUtils::scaled (kSidebarMinWidth));
- sidebar->setSizePolicy (QSizePolicy::Minimum, QSizePolicy::Preferred);
-
- QVBoxLayout* sidebarLayout= new QVBoxLayout (sidebar);
- sidebarLayout->setContentsMargins (
- DpiUtils::scaled (kSidebarMarginX), DpiUtils::scaled (kSidebarMarginY),
- DpiUtils::scaled (kSidebarMarginX), DpiUtils::scaled (kSidebarMarginY));
- sidebarLayout->setSpacing (DpiUtils::scaled (kSidebarSpacing));
-
- setup_left_sidebar (sidebarLayout);
- sidebar->adjustSize ();
- const int contentWidth= sidebar->sizeHint ().width ();
- sidebar->setFixedWidth (
- qMax (DpiUtils::scaled (kSidebarMinWidth), contentWidth));
- mainLayout->addWidget (sidebar);
-
- // 右侧内容区(使用堆叠控件切换不同页面)
- QStackedWidget* stackedWidget= new QStackedWidget (this);
- stackedWidget->setObjectName ("startup-tab-content"); // 样式在主题CSS中定义
- setup_right_content (stackedWidget);
- mainLayout->addWidget (stackedWidget, 1);
-}
-
-QTMStartupTabWidget::Entry
-QTMStartupTabWidget::current_entry () const {
- return currentEntry_;
-}
-
-/**
- * @brief 切换当前入口(页面)
- * @param entry 目标入口(Home/Template)
- */
-void
-QTMStartupTabWidget::set_current_entry (Entry entry) {
- if (currentEntry_ != entry) {
- currentEntry_= entry;
- emit entry_changed (entry); // 通知右侧内容区切换页面
- }
- set_active_nav_button (entry); // 更新导航按钮选中状态(无论是否变化都更新)
- refresh_recent_docs_on_file_entry (entry);
-}
-
-void
-QTMStartupTabWidget::refresh_recent_docs_on_file_entry (Entry entry) {
- if (entry == Entry::Home && homePage_) {
- homePage_->refreshRecentDocs ();
- }
-}
-
-/**
- * @brief 设置左侧导航栏
- * @param sidebarLayout 导航栏的垂直布局
- *
- * 导航栏结构:
- * - Navigation 标题
- * - Home/Template/ 导航按钮(互斥选中)
- * - 弹性空间(弹簧)
- * - Quit 退出按钮
- */
-void
-QTMStartupTabWidget::setup_left_sidebar (QVBoxLayout* sidebarLayout) {
- // Navigation 分组标题
- QLabel* navTitle= new QLabel (qt_translate ("Navigation"), this);
- navTitle->setObjectName ("startup-tab-nav-title");
- DpiUtils::applyScaledFont (navTitle, kNavTitleFontPx);
- navTitle->setContentsMargins (
- DpiUtils::scaled (kNavTitlePadding), DpiUtils::scaled (kNavTitlePadding),
- DpiUtils::scaled (kNavTitlePadding), DpiUtils::scaled (kNavTitlePadding));
- sidebarLayout->addWidget (navTitle);
-
- // 创建互斥按钮组
- navButtonGroup_= new QButtonGroup (this);
- navButtonGroup_->setExclusive (true);
-
- navHomeBtn_= create_nav_button (qt_translate ("Home"));
- navButtonGroup_->addButton (navHomeBtn_, static_cast (Entry::Home));
- sidebarLayout->addWidget (navHomeBtn_);
-
- // 导航按钮点击事件:切换到对应页面
- connect (navHomeBtn_, &QPushButton::clicked, this,
- [this] () { set_current_entry (Entry::Home); });
-
- // Category buttons container (populated when categories load)
- QWidget* categoryContainer= new QWidget (this);
- categoryContainer->setObjectName ("startup-tab-category-container");
- categoryLayout_= new QVBoxLayout (categoryContainer);
- categoryLayout_->setContentsMargins (0, 0, 0, 0);
- categoryLayout_->setSpacing (DpiUtils::scaled (kSidebarSpacing));
- sidebarLayout->addWidget (categoryContainer);
-
- sidebarLayout->addStretch ();
- sidebarLayout->addSpacing (DpiUtils::scaled (kQuitTopSpacing));
+ // 本容器嵌入在 QWK::WidgetWindowAgent 代理的主窗口下。QQuickWidget 会创建
+ // 原生渲染窗口(QWindow),若在 QWK 代理窗口下发生 winId 变动,QWindowKit
+ // 的 Cocoa winIdChanged 会用已释放的 NSWindowProxy 做消息派发而崩溃
+ // (EXC_BAD_ACCESS @ objc_msgSend,见 ~NSWindowProxy)。先让本容器成为稳定的
+ // 原生窗口并禁止为祖先创建原生窗口,QQuickWidget 重设父级时就不会触发代理的
+ // releaseWindowProxy 路径。
+ setAttribute (Qt::WA_DontCreateNativeAncestors);
+ setAttribute (Qt::WA_NativeWindow);
- // Quit 退出按钮
- navQuitBtn_= new QPushButton (qt_translate ("Quit"), this);
- navQuitBtn_->setObjectName ("startup-tab-quit-btn");
- navQuitBtn_->setFocusPolicy (Qt::NoFocus);
- navQuitBtn_->setCursor (Qt::PointingHandCursor);
- DpiUtils::applyScaledFont (navQuitBtn_, kQuitButtonFontPx);
- navQuitBtn_->setStyleSheet (
- QString ("border-width: %1px; border-radius: %2px; padding: %3px %4px;")
- .arg (DpiUtils::scaled (kQuitBorderWidth))
- .arg (DpiUtils::scaled (kQuitBorderRadius))
- .arg (DpiUtils::scaled (kQuitPadY))
- .arg (DpiUtils::scaled (kQuitPadX)));
- connect (navQuitBtn_, &QPushButton::clicked, this,
- &QTMStartupTabWidget::on_app_quit);
- sidebarLayout->addWidget (navQuitBtn_);
+ auto* layout= new QHBoxLayout (this);
+ layout->setContentsMargins (0, 0, 0, 0);
- // 默认选中 Home
- navHomeBtn_->setChecked (true);
+ // ---- QQuickWidget ----
+ Q_INIT_RESOURCE (moganqml);
- // Connect to TemplateManager for dynamic categories
- templateManager_= TemplateManager::instance ();
- connect (templateManager_, &TemplateManager::categoriesLoaded, this,
- &QTMStartupTabWidget::onCategoriesLoaded, Qt::UniqueConnection);
+ quickWidget_= new QQuickWidget ();
+ quickWidget_->setAttribute (Qt::WA_DontCreateNativeAncestors);
+ quickWidget_->setResizeMode (QQuickWidget::SizeRootObjectToView);
+ quickWidget_->setSizePolicy (QSizePolicy::Expanding, QSizePolicy::Expanding);
- // If categories already loaded, set up immediately
- if (templateManager_->isInitialized () &&
- !templateManager_->categories ().isEmpty ()) {
- setupCategoryNavButtons ();
- }
-}
+ // ---- Context properties(与 QTMQmlDialog 共用 dpScale / isDark) ----
+ auto* ctx= quickWidget_->rootContext ();
-/**
- * @brief 创建导航按钮(辅助函数)
- * @param text 按钮文字
- * @return 配置好的 QPushButton
- */
-void
-QTMStartupTabWidget::setupCategoryNavButtons () {
- if (!templateManager_ || !categoryLayout_) return;
+ ctx->setContextProperty ("dpScale", DpiUtils::scaleFactor ());
+ refreshTheme ();
- clearCategoryNavButtons ();
+ bridge_= new StartupBridge (this);
+ ctx->setContextProperty ("startupBridge", bridge_);
- auto categories= templateManager_->categories ();
- for (const auto& cat : categories) {
- QPushButton* btn= create_nav_button (cat.name);
- btn->setProperty ("categoryId", cat.id);
- btn->setProperty ("name", cat.name);
- navButtonGroup_->addButton (btn);
- categoryLayout_->addWidget (btn);
- navCategoryBtns_.append (btn);
+ // ---- 加载 QML ----
+ quickWidget_->setSource (QUrl ("qrc:/qml/startup/StartupTab.qml"));
- connect (btn, &QPushButton::clicked, this,
- &QTMStartupTabWidget::onCategoryClicked);
+#ifdef LIII_DEBUG
+ if (quickWidget_->status () != QQuickWidget::Ready) {
+ debug_std << "QTMStartupTabWidget: QML load failed, status="
+ << (int) quickWidget_->status () << LF;
+ for (const auto& e : quickWidget_->errors ())
+ debug_std << " QML error: " << from_qstring (e.toString ()) << LF;
}
-}
+#endif
-void
-QTMStartupTabWidget::clearCategoryNavButtons () {
- for (QPushButton* btn : navCategoryBtns_) {
- if (btn) {
- navButtonGroup_->removeButton (btn);
- btn->deleteLater ();
- }
- }
- navCategoryBtns_.clear ();
+ layout->addWidget (quickWidget_);
+ bridge_->initialize ();
}
-void
-QTMStartupTabWidget::onCategoryClicked () {
- QPushButton* btn= qobject_cast (sender ());
- if (!btn) return;
-
- QString categoryId= btn->property ("categoryId").toString ();
- if (categoryId.isEmpty ()) return;
-
- currentCategory_= categoryId;
- if (templatePage_) {
- QString name= btn->property ("name").toString ();
- templatePage_->setCategory (categoryId, name);
- }
-
- if (templateManager_) {
- templateManager_->refreshTemplatesByCategory (categoryId);
- }
-
- set_current_entry (Entry::Template);
-}
+QTMStartupTabWidget::~QTMStartupTabWidget ()= default;
void
-QTMStartupTabWidget::onCategoriesLoaded () {
- setupCategoryNavButtons ();
-}
-
-QPushButton*
-QTMStartupTabWidget::create_nav_button (const QString& text) {
- QPushButton* btn= new QPushButton (text, this);
- btn->setObjectName ("startup-tab-nav-btn"); // 样式在主题CSS中定义
- btn->setFocusPolicy (Qt::NoFocus);
- btn->setCheckable (true); // 支持选中状态
- btn->setCursor (Qt::PointingHandCursor);
- DpiUtils::applyScaledFont (btn, kNavButtonFontPx);
- btn->setStyleSheet (QString ("padding: %1px %2px;")
- .arg (DpiUtils::scaled (kNavButtonPadY))
- .arg (DpiUtils::scaled (kNavButtonPadX)));
- return btn;
+QTMStartupTabWidget::refreshRecentDocs () {
+ if (bridge_) bridge_->refreshRecentDocs ();
}
-/**
- * @brief 设置右侧内容区
- * @param stackedWidget 堆叠控件,用于页面切换
- */
void
-QTMStartupTabWidget::setup_right_content (QStackedWidget* stackedWidget) {
- // 添加2个页面到堆叠控件
- stackedWidget->addWidget (create_home_page ()); // index 0 - Home
- stackedWidget->addWidget (create_template_page ()); // index 1 - Template
-
- // 入口切换时,同步切换堆叠控件的当前页面
- connect (this, &QTMStartupTabWidget::entry_changed, stackedWidget,
- [stackedWidget] (QTMStartupTabWidget::Entry entry) {
- int index;
- switch (entry) {
- case QTMStartupTabWidget::Entry::Home:
- index= 0;
- break;
- case QTMStartupTabWidget::Entry::Template:
- index= 1;
- break;
- default:
- index= 0;
- break;
- }
- stackedWidget->setCurrentIndex (index);
- });
+QTMStartupTabWidget::addRecentDoc (const QString& path) {
+ if (bridge_) bridge_->addRecentDoc (path);
}
-/**
- * @brief 创建 Home 页面
- * @return Home 页面控件
- *
- * 使用 QTMHomePage 实现,包含:
- * - 文档样式选择卡片(新建、打开、模板)
- * - 最近文档列表
- */
-QWidget*
-QTMStartupTabWidget::create_home_page () {
- homePage_= new QTMHomePage (this);
- return homePage_;
-}
-
-/**
- * @brief 创建 Template 页面
- */
-QWidget*
-QTMStartupTabWidget::create_template_page () {
- templatePage_= new QTMTemplatePage (this);
- templatePage_->initialize ();
- return templatePage_;
-}
-
-/**
- * @brief 更新导航按钮的选中状态
- * @param entry 当前选中的入口
- *
- * 使用 QButtonGroup 的互斥特性,自动取消其他按钮的选中状态
- */
void
-QTMStartupTabWidget::set_active_nav_button (Entry entry) {
- QAbstractButton* btn= navButtonGroup_->button (static_cast (entry));
- if (btn) {
- btn->setChecked (true);
- return;
- }
-
- // For Template entry, activate the matching category button
- if (entry == Entry::Template) {
- for (QPushButton* catBtn : navCategoryBtns_) {
- if (catBtn &&
- catBtn->property ("categoryId").toString () == currentCategory_) {
- catBtn->setChecked (true);
- return;
- }
- }
- }
+QTMStartupTabWidget::refreshTheme () {
+ auto* ctx= quickWidget_->rootContext ();
+ bool isDark=
+ occurs ("dark", tm_style_sheet) || occurs ("liii-night", tm_style_sheet);
+ ctx->setContextProperty ("isDark", isDark);
}
-/**
- * @brief 退出程序
- * 调用 Scheme 函数 (quit-TeXmacs)
- */
void
-QTMStartupTabWidget::on_app_quit () {
- eval_scheme ("(quit-TeXmacs)");
+QTMStartupTabWidget::showEvent (QShowEvent* event) {
+ QWidget::showEvent (event);
+ refreshRecentDocs ();
}
void
QTMStartupTabWidget::keyPressEvent (QKeyEvent* event) {
string key= from_key_press_event (event);
if (is_empty (key)) return QWidget::keyPressEvent (event);
-
eval_scheme ("(key-press " * qt_scheme_quote (to_qstring (key)) * ")");
event->accept ();
}
@@ -373,7 +117,6 @@ void
QTMStartupTabWidget::keyReleaseEvent (QKeyEvent* event) {
string key= from_key_release_event (event);
if (is_empty (key)) return QWidget::keyReleaseEvent (event);
-
eval_scheme ("(key-press " * qt_scheme_quote (to_qstring (key)) * ")");
event->accept ();
}
diff --git a/src/Plugins/Qt/QTMStartupTabWidget.hpp b/src/Plugins/Qt/QTMStartupTabWidget.hpp
index b43597fad8..796d10adef 100644
--- a/src/Plugins/Qt/QTMStartupTabWidget.hpp
+++ b/src/Plugins/Qt/QTMStartupTabWidget.hpp
@@ -1,7 +1,8 @@
/******************************************************************************
* MODULE : QTMStartupTabWidget.hpp
- * DESCRIPTION: Startup tab widget with left sidebar navigation for Mogan STEM
+ * DESCRIPTION: Startup tab container — hosts QQuickWidget loading
+ * StartupTab.qml
* COPYRIGHT : (C) 2026 Yuki Lu
*******************************************************************************
* This software falls under the GNU general public license version 3 or later.
@@ -12,77 +13,38 @@
#ifndef QTMSTARTUPTABWIDGET_HPP
#define QTMSTARTUPTABWIDGET_HPP
-#include
#include
class QKeyEvent;
-class QLabel;
-class QShowEvent;
-class QVBoxLayout;
-class QPushButton;
-class QStackedWidget;
-class QButtonGroup;
-class QTMHomePage;
-class QTMTemplatePage;
-class TemplateManager;
-
+class QQuickWidget;
+class StartupBridge;
+
+/**
+ * @brief 启动页容器,内嵌 QQuickWidget 渲染 StartupTab.qml。
+ *
+ * 原实现使用 QTMHomePage + QTMTemplatePage 两个 widget 页面 + QPushButton
+ * 导航栏,现已替换为 QML:侧边栏、首页、模板页全部在 QML 内渲染,
+ * C++ 只通过 StartupBridge 提供数据与动作。
+ */
class QTMStartupTabWidget : public QWidget {
Q_OBJECT
-public:
- enum class Entry { Home, Template };
-
public:
explicit QTMStartupTabWidget (QWidget* parent= nullptr);
+ ~QTMStartupTabWidget ();
- Entry current_entry () const;
- void set_current_entry (Entry entry);
-
-signals:
- void entry_changed (Entry entry);
-
-private slots:
- // Application operation
- void on_app_quit ();
- void onCategoryClicked ();
- void onCategoriesLoaded ();
+ void refreshRecentDocs ();
+ void addRecentDoc (const QString& path);
+ void refreshTheme ();
protected:
void keyPressEvent (QKeyEvent* event) override;
void keyReleaseEvent (QKeyEvent* event) override;
+ void showEvent (QShowEvent* event) override;
private:
- // 界面构建辅助函数
- void setup_left_sidebar (QVBoxLayout* sidebarLayout);
- void setup_right_content (QStackedWidget* stackedWidget);
- void setupCategoryNavButtons ();
- void clearCategoryNavButtons ();
- QPushButton* create_nav_button (const QString& text);
-
- // 页面创建函数
- QWidget* create_home_page ();
- QWidget* create_template_page ();
-
- // 导航按钮状态管理
- void set_active_nav_button (Entry entry);
- void refresh_recent_docs_on_file_entry (Entry entry);
-
-private:
- Entry currentEntry_;
- QString currentCategory_;
-
- // Navigation buttons
- QPushButton* navHomeBtn_;
- QPushButton* navQuitBtn_;
- QList navCategoryBtns_;
- QVBoxLayout* categoryLayout_;
-
- // 互斥按钮组
- QButtonGroup* navButtonGroup_;
-
- QTMHomePage* homePage_;
- QTMTemplatePage* templatePage_;
- TemplateManager* templateManager_;
+ QQuickWidget* quickWidget_= nullptr;
+ StartupBridge* bridge_ = nullptr;
};
#endif
diff --git a/src/Plugins/Qt/QTMTemplatePage.cpp b/src/Plugins/Qt/QTMTemplatePage.cpp
deleted file mode 100644
index 98938852a6..0000000000
--- a/src/Plugins/Qt/QTMTemplatePage.cpp
+++ /dev/null
@@ -1,512 +0,0 @@
-
-/******************************************************************************
- * MODULE : QTMTemplatePage.cpp
- * DESCRIPTION: Template page implementation for startup tab
- * COPYRIGHT : (C) 2026 Yuki Lu
- *******************************************************************************
- * This software falls under the GNU general public license version 3 or later.
- * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
- * in the root directory or .
- ******************************************************************************/
-
-#include "QTMTemplatePage.hpp"
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include "QTMTemplateOpener.hpp"
-#include "qt_dpi_utils.hpp"
-#include "qt_floating_toast.hpp"
-#include "qt_pdf_preview_widget.hpp"
-#include "qt_utilities.hpp"
-#include "template_manager.hpp"
-#include "thumbnail_loader.hpp"
-
-namespace {
-// 预览图片尺寸(增大预览区域)
-constexpr int PREVIEW_IMAGE_WIDTH= 600;
-
-// 缩略图尺寸(使用2x尺寸以便在高分屏上显示清晰)
-constexpr int THUMBNAIL_WIDTH = 160;
-constexpr int THUMBNAIL_HEIGHT= 227;
-
-constexpr int kPageMargin = 16; // 页面边距(减小边白)
-constexpr int kPageSpacing = 24; // 页面主布局间距
-constexpr int kGridSpacing = 16; // 模板网格间距
-constexpr int kCardWidth = 176; // 模板卡片宽度
-constexpr int kCardHeight = 243; // 模板卡片高度(仅缩略图区域)
-constexpr int kCardMargin = 8; // 卡片内边距
-constexpr int kCardSpacing = 5; // 卡片内部间距
-constexpr int kNameLabelMaxWidth = 176; // 模板名称最大宽度
-constexpr int kNameLabelMaxHeight = 40; // 模板名称最大高度
-constexpr int kPreviewDialogMinW = 700; // 预览弹窗最小宽度
-constexpr int kPreviewDialogMinH = 800; // 预览弹窗最小高度
-constexpr int kPreviewLayoutSpacing= 16; // 预览弹窗布局间距
-constexpr int kPreviewLayoutMargin = 24; // 预览弹窗布局边距
-constexpr int kSectionTitleFontPx = 16; // 分区标题字号
-constexpr int kLoadingFontPx = 14; // Loading 文案字号
-constexpr int kTemplateNameFontPx = 11; // 模板名称字号
-constexpr int kPreviewTitleFontPx = 18; // 预览标题字号
-constexpr int kPreviewDescFontPx = 14; // 预览描述字号
-constexpr int kUseButtonFontPx = 13; // Use Template 按钮字号
-constexpr int kInfoFontPx = 10; // 模板信息字号
-constexpr int kUseButtonRadiusPx = 4; // Use Template 按钮圆角
-constexpr int kUseButtonPadYPx = 8; // Use Template 按钮纵向内边距
-constexpr int kUseButtonPadXPx = 24; // Use Template 按钮横向内边距
-constexpr int kGridMarginYPx = 5; // 网格布局上下边距
-constexpr int kGridMarginXPx = 10; // 网格布局左右边距
-constexpr int kCardRadiusPx = 8; // 模板卡片圆角
-
-} // namespace
-
-QTMTemplatePage::QTMTemplatePage (QWidget* parent)
- : QWidget (parent), titleLabel_ (nullptr), scrollArea_ (nullptr),
- gridWidget_ (nullptr), gridLayout_ (nullptr), templateManager_ (nullptr),
- resizeDebounceTimer_ (nullptr) {
-
- resizeDebounceTimer_= new QTimer (this);
- resizeDebounceTimer_->setSingleShot (true);
- resizeDebounceTimer_->setInterval (200);
- connect (resizeDebounceTimer_, &QTimer::timeout, this, [this] () {
- if (templateManager_ && templateManager_->isInitialized ()) {
- int newColumnCount= calculateColumnCount ();
- if (newColumnCount != currentColumnCount_) {
- refreshTemplateGrid ();
- }
- }
- });
-
- setupUI ();
-}
-
-QTMTemplatePage::~QTMTemplatePage () {}
-
-void
-QTMTemplatePage::initialize () {
- templateManager_= TemplateManager::instance ();
-
- connect (templateManager_, &TemplateManager::templatesLoaded, this,
- &QTMTemplatePage::onTemplatesLoaded, Qt::UniqueConnection);
-
- // Check if already initialized with data
- if (templateManager_->isInitialized () &&
- !templateManager_->templates ().isEmpty ()) {
- // Already have data, refresh immediately
- onTemplatesLoaded ();
- }
- else if (!templateManager_->isInitialized ()) {
- // Initialize asynchronously
- QTimer::singleShot (0, this,
- [this] () { templateManager_->initialize (); });
- }
-}
-
-void
-QTMTemplatePage::setCategory (const QString& categoryId,
- const QString& displayName) {
- if (currentCategory_ != categoryId) {
- currentCategory_ = categoryId;
- gridNeedsRefresh_= true;
- if (isVisible ()) {
- refreshTemplateGrid ();
- }
- }
- if (titleLabel_ && !displayName.isEmpty ()) {
- titleLabel_->setText (displayName);
- }
-}
-
-void
-QTMTemplatePage::refreshGrid () {
- gridNeedsRefresh_= true;
- refreshTemplateGrid ();
-}
-
-void
-QTMTemplatePage::setupUI () {
- QVBoxLayout* layout= new QVBoxLayout (this);
- layout->setContentsMargins (
- DpiUtils::scaled (kPageMargin), DpiUtils::scaled (kPageMargin),
- DpiUtils::scaled (kPageMargin), DpiUtils::scaled (kPageMargin));
- layout->setSpacing (DpiUtils::scaled (kPageSpacing));
-
- titleLabel_= new QLabel (qt_translate ("Template Center"), this);
- titleLabel_->setObjectName ("startup-tab-section-title");
- DpiUtils::applyScaledFont (titleLabel_, kSectionTitleFontPx);
- layout->addWidget (titleLabel_);
-
- // Scroll area for templates
- scrollArea_= new QScrollArea (this);
- scrollArea_->setWidgetResizable (true);
- scrollArea_->setFrameShape (QFrame::NoFrame);
- scrollArea_->setHorizontalScrollBarPolicy (Qt::ScrollBarAlwaysOff);
-
- gridWidget_= new QWidget (scrollArea_);
- gridWidget_->setObjectName ("startup-tab-grid");
- gridLayout_= new QGridLayout (gridWidget_);
- gridLayout_->setSpacing (DpiUtils::scaled (kGridSpacing));
- gridLayout_->setContentsMargins (
- DpiUtils::scaled (kGridMarginXPx), DpiUtils::scaled (kGridMarginYPx),
- DpiUtils::scaled (kGridMarginXPx), DpiUtils::scaled (kGridMarginYPx));
-
- scrollArea_->setWidget (gridWidget_);
- layout->addWidget (scrollArea_, 1);
-
- // Loading label
- QLabel* loadingLabel=
- new QLabel (qt_translate ("Loading templates..."), gridWidget_);
- loadingLabel->setObjectName ("startup-tab-loading");
- loadingLabel->setAlignment (Qt::AlignCenter);
- DpiUtils::applyScaledFont (loadingLabel, kLoadingFontPx);
- gridLayout_->addWidget (loadingLabel, 0, 0, 1, 1);
-}
-
-int
-QTMTemplatePage::calculateColumnCount () const {
- if (!scrollArea_) return 4;
-
- int availableWidth= scrollArea_->viewport ()->width ();
- int cardWidth = DpiUtils::scaled (kCardWidth);
- int spacing = DpiUtils::scaled (kGridSpacing);
- int cardSpace = cardWidth + spacing;
-
- // Viewport not yet properly laid out (default QWidget size is small),
- // return a sensible default instead of 1 column
- if (availableWidth < cardSpace && availableWidth < cardWidth * 2) return 4;
-
- int columns= (availableWidth + spacing) / cardSpace;
- return qBound (1, columns, 9);
-}
-
-void
-QTMTemplatePage::refreshTemplateGrid () {
- QLayoutItem* item;
- while ((item= gridLayout_->takeAt (0)) != nullptr) {
- if (item->widget ()) {
- delete item->widget ();
- }
- delete item;
- }
-
- // Calculate columns first so placeholder labels span the right width
- currentColumnCount_= calculateColumnCount ();
-
- if (!templateManager_ || !templateManager_->isInitialized ()) {
- QLabel* label= new QLabel (qt_translate ("Initializing..."), gridWidget_);
- label->setAlignment (Qt::AlignCenter);
- gridLayout_->addWidget (label, 0, 0, 1, currentColumnCount_);
- gridNeedsRefresh_= false;
- return;
- }
-
- // Get templates by category or all templates
- QList templates;
- if (currentCategory_.isEmpty ()) {
- templates= templateManager_->templates ();
- }
- else {
- templates= templateManager_->templatesByCategory (currentCategory_);
- }
-
- if (templates.isEmpty ()) {
- QLabel* label=
- new QLabel (qt_translate ("No templates available."), gridWidget_);
- label->setAlignment (Qt::AlignCenter);
- gridLayout_->addWidget (label, 0, 0, 1, currentColumnCount_);
- gridNeedsRefresh_= false;
- return;
- }
-
- // Add template cards
- int row= 0, col= 0;
- for (const auto& tmpl : templates) {
- QWidget* card= createTemplateCard (tmpl);
- gridLayout_->addWidget (card, row, col);
-
- col++;
- if (col >= currentColumnCount_) {
- col= 0;
- row++;
- }
- }
-
- gridLayout_->setRowStretch (row + 1, 1);
- gridNeedsRefresh_= false;
-}
-
-QWidget*
-QTMTemplatePage::createTemplateCard (const TemplateMetadataPtr& tmpl) {
- // 外层容器
- QWidget* item = new QWidget (gridWidget_);
- QVBoxLayout* itemLayout= new QVBoxLayout (item);
- itemLayout->setContentsMargins (0, 0, 0, 0);
- itemLayout->setSpacing (DpiUtils::scaled (kCardSpacing));
- item->setObjectName ("startup-tab-template-item");
- item->setToolTip (tmpl->description);
-
- // 缩略图卡片
- QFrame* card = new QFrame (item);
- QVBoxLayout* layout= new QVBoxLayout (card);
- layout->setContentsMargins (
- DpiUtils::scaled (kCardMargin), DpiUtils::scaled (kCardMargin),
- DpiUtils::scaled (kCardMargin), DpiUtils::scaled (kCardMargin));
- layout->setSpacing (0);
- card->setObjectName ("startup-tab-template-card");
- card->setFixedSize (DpiUtils::scaled (kCardWidth),
- DpiUtils::scaled (kCardHeight));
- card->setProperty ("templateId", tmpl->id);
- card->setCursor (Qt::PointingHandCursor);
- card->setFrameShape (QFrame::StyledPanel);
- card->setStyleSheet (QString ("QFrame#startup-tab-template-card {"
- " border-radius: %1px;"
- "}")
- .arg (DpiUtils::scaled (kCardRadiusPx)));
-
- // Thumbnail image
- QLabel* thumbnailLabel= new QLabel (card);
- thumbnailLabel->setObjectName ("startup-tab-template-thumbnail");
- thumbnailLabel->setFixedSize (DpiUtils::scaled (THUMBNAIL_WIDTH),
- DpiUtils::scaled (THUMBNAIL_HEIGHT));
- thumbnailLabel->setAlignment (Qt::AlignCenter);
- thumbnailLabel->setText (qt_translate ("Loading..."));
- layout->addWidget (thumbnailLabel, 0, Qt::AlignHCenter);
-
- // Load thumbnail from URL
- if (!tmpl->thumbnailUrl.isEmpty ()) {
- QSize targetSize (DpiUtils::scaled (THUMBNAIL_WIDTH),
- DpiUtils::scaled (THUMBNAIL_HEIGHT));
- ThumbnailLoader::instance ()->load (thumbnailLabel, tmpl->thumbnailUrl,
- targetSize);
- }
- else {
- thumbnailLabel->setText (qt_translate ("No Preview"));
- }
-
- itemLayout->addWidget (card, 0, Qt::AlignHCenter);
-
- // Template name
- QLabel* nameLabel= new QLabel (tmpl->name, item);
- nameLabel->setObjectName ("startup-tab-template-name");
- nameLabel->setAlignment (Qt::AlignCenter);
- nameLabel->setWordWrap (true);
- nameLabel->setFixedWidth (DpiUtils::scaled (kNameLabelMaxWidth));
- DpiUtils::applyScaledFont (nameLabel, kTemplateNameFontPx);
- // 手动计算换行后的实际高度,避免 QLabel sizeHint 不准确导致截断
- {
- QFontMetrics fm (nameLabel->font ());
- QRect textRect= fm.boundingRect (
- QRect (0, 0, DpiUtils::scaled (kNameLabelMaxWidth), INT_MAX),
- Qt::AlignCenter | Qt::TextWordWrap, tmpl->name);
- int neededHeight= textRect.height () + 4;
- nameLabel->setFixedHeight (
- qMin (neededHeight, DpiUtils::scaled (kNameLabelMaxHeight)));
- }
- nameLabel->setSizePolicy (QSizePolicy::Fixed, QSizePolicy::Fixed);
- itemLayout->addWidget (nameLabel, 0, Qt::AlignHCenter);
-
- // Author and version
- QLabel* infoLabel=
- new QLabel (QString ("%1 · v%2").arg (tmpl->author, tmpl->version), item);
- infoLabel->setObjectName ("startup-tab-template-info");
- infoLabel->setAlignment (Qt::AlignCenter);
- DpiUtils::applyScaledFont (infoLabel, kInfoFontPx);
- itemLayout->addWidget (infoLabel);
-
- itemLayout->addStretch ();
-
- // Install event filter on card only
- card->installEventFilter (this);
-
- return item;
-}
-
-bool
-QTMTemplatePage::eventFilter (QObject* watched, QEvent* event) {
- if (event->type () == QEvent::MouseButtonRelease) {
- QWidget* card= qobject_cast (watched);
- if (card && card->objectName () == "startup-tab-template-card") {
- QString templateId= card->property ("templateId").toString ();
- if (!templateId.isEmpty ()) {
- showTemplatePreview (templateId);
- return true;
- }
- }
- }
- return QWidget::eventFilter (watched, event);
-}
-
-void
-QTMTemplatePage::showTemplatePreview (const QString& templateId) {
- if (!templateManager_) return;
-
- TemplateMetadataPtr tmpl= templateManager_->templateById (templateId);
- if (!tmpl) return;
-
- // Create preview dialog
- QDialog* dialog= new QDialog (this);
- dialog->setWindowTitle (
- qt_translate ("Template Preview - %1").arg (tmpl->name));
-
- // 根据屏幕可用区域限制对话框尺寸,防止高分屏下溢出
- QScreen* screen= this->screen ();
- if (!screen) screen= QGuiApplication::primaryScreen ();
- QRect availGeo= screen ? screen->availableGeometry () : QRect ();
- int maxDlgH = availGeo.height () > 0 ? qRound (availGeo.height () * 0.9)
- : DpiUtils::scaled (kPreviewDialogMinH);
-
- // 预览区尺寸由对话框高度上限决定(1:1 正方形)
- int basePreviewSize= DpiUtils::scaled (PREVIEW_IMAGE_WIDTH);
- int maxPreviewSize = qRound (maxDlgH * 0.7);
- int previewSize = qMin (basePreviewSize, maxPreviewSize);
-
- // 对话框最大宽度收紧:仅比预览框宽一点(边距 + 少量余量)
- int marginW = DpiUtils::scaled (kPreviewLayoutMargin) * 2;
- int spacingW= DpiUtils::scaled (kPreviewLayoutSpacing);
- int maxDlgW = previewSize + marginW + spacingW;
- if (availGeo.width () > 0) {
- maxDlgW= qMin (maxDlgW, qRound (availGeo.width () * 0.9));
- }
-
- int minW= qMin (DpiUtils::scaled (kPreviewDialogMinW), maxDlgW);
- int minH= qMin (DpiUtils::scaled (kPreviewDialogMinH), maxDlgH);
- dialog->setMinimumSize (minW, minH);
- dialog->setMaximumSize (maxDlgW, maxDlgH);
- dialog->resize (minW, minH);
-
- QVBoxLayout* layout= new QVBoxLayout (dialog);
- layout->setSpacing (DpiUtils::scaled (kPreviewLayoutSpacing));
- layout->setContentsMargins (DpiUtils::scaled (kPreviewLayoutMargin),
- DpiUtils::scaled (kPreviewLayoutMargin),
- DpiUtils::scaled (kPreviewLayoutMargin),
- DpiUtils::scaled (kPreviewLayoutMargin));
-
- // Title
- QLabel* titleLabel= new QLabel (tmpl->name, dialog);
- titleLabel->setObjectName ("template-preview-title");
- QFont titleFont=
- DpiUtils::scaledFont (titleLabel->font (), kPreviewTitleFontPx);
- titleFont.setBold (true);
- titleLabel->setFont (titleFont);
- layout->addWidget (titleLabel);
-
- // Description
- QLabel* descLabel= new QLabel (tmpl->description, dialog);
- descLabel->setObjectName ("template-preview-desc");
- descLabel->setWordWrap (true);
- DpiUtils::applyScaledFont (descLabel, kPreviewDescFontPx);
- layout->addWidget (descLabel);
-
- // Info row
- QHBoxLayout* infoLayout= new QHBoxLayout ();
- infoLayout->addWidget (
- new QLabel (qt_translate ("Author: %1").arg (tmpl->author)));
- infoLayout->addWidget (
- new QLabel (qt_translate ("Version: %1").arg (tmpl->version)));
- infoLayout->addStretch ();
- layout->addLayout (infoLayout);
-
- // Preview area using reusable PDF preview widget
- QTPdfPreviewWidget* previewWidget= new QTPdfPreviewWidget (dialog);
- previewWidget->setFixedSize (previewSize, previewSize);
-
- // Load PDF preview
- if (!tmpl->previewUrl.isEmpty ()) {
- previewWidget->loadFromUrl (tmpl->previewUrl);
- }
- else {
- previewWidget->clearPreview (qt_translate ("No Preview"));
- }
- layout->addWidget (previewWidget, 0, Qt::AlignCenter);
-
- // Buttons
- QHBoxLayout* btnLayout= new QHBoxLayout ();
- btnLayout->addStretch ();
-
- QPushButton* cancelBtn= new QPushButton (qt_translate ("Cancel"), dialog);
- cancelBtn->setObjectName ("template-cancel-btn");
- DpiUtils::applyScaledFont (cancelBtn, kUseButtonFontPx);
- cancelBtn->setCursor (Qt::PointingHandCursor);
- cancelBtn->setStyleSheet (QString ("QPushButton#template-cancel-btn {"
- " padding: %1px %2px;"
- " border-radius: %3px;"
- "}")
- .arg (DpiUtils::scaled (kUseButtonPadYPx))
- .arg (DpiUtils::scaled (kUseButtonPadXPx))
- .arg (DpiUtils::scaled (kUseButtonRadiusPx)));
- connect (cancelBtn, &QPushButton::clicked, dialog, &QDialog::reject);
- btnLayout->addWidget (cancelBtn);
-
- QPushButton* useBtn= new QPushButton (qt_translate ("Use Template"), dialog);
- useBtn->setObjectName ("template-use-btn");
- DpiUtils::applyScaledFont (useBtn, kUseButtonFontPx);
- useBtn->setCursor (Qt::PointingHandCursor);
- useBtn->setStyleSheet (QString ("QPushButton#template-use-btn {"
- " padding: %1px %2px;"
- " border-radius: %3px;"
- "}")
- .arg (DpiUtils::scaled (kUseButtonPadYPx))
- .arg (DpiUtils::scaled (kUseButtonPadXPx))
- .arg (DpiUtils::scaled (kUseButtonRadiusPx)));
- useBtn->setDefault (true);
- connect (useBtn, &QPushButton::clicked, [this, dialog, templateId] () {
- dialog->accept ();
- QTMTemplateOpener opener (this);
- opener.openTemplate (templateId);
- });
- btnLayout->addWidget (useBtn);
-
- layout->addLayout (btnLayout);
-
- dialog->exec ();
- delete dialog;
-}
-
-void
-QTMTemplatePage::onTemplatesLoaded () {
- gridNeedsRefresh_= true;
- refreshTemplateGrid ();
-}
-
-void
-QTMTemplatePage::showEvent (QShowEvent* event) {
- QWidget::showEvent (event);
-
- // Refresh grid when page becomes visible. If onTemplatesLoaded already
- // refreshed while the widget had no proper size, recalculate now that
- // the viewport has its final width to avoid showing the wrong column count.
- if (gridNeedsRefresh_) {
- refreshTemplateGrid ();
- }
- else if (templateManager_ && templateManager_->isInitialized () &&
- !templateManager_->templates ().isEmpty ()) {
- int newColumnCount= calculateColumnCount ();
- if (newColumnCount != currentColumnCount_) {
- refreshTemplateGrid ();
- }
- }
-}
-
-void
-QTMTemplatePage::resizeEvent (QResizeEvent* event) {
- QWidget::resizeEvent (event);
-
- // Debounce resize to avoid frequent grid rebuilds during window dragging
- if (resizeDebounceTimer_) {
- resizeDebounceTimer_->start ();
- }
-}
diff --git a/src/Plugins/Qt/QTMTemplatePage.hpp b/src/Plugins/Qt/QTMTemplatePage.hpp
deleted file mode 100644
index 1bf758e924..0000000000
--- a/src/Plugins/Qt/QTMTemplatePage.hpp
+++ /dev/null
@@ -1,84 +0,0 @@
-
-/******************************************************************************
- * MODULE : QTMTemplatePage.hpp
- * DESCRIPTION: Template page implementation for startup tab
- * COPYRIGHT : (C) 2026 Yuki Lu
- *******************************************************************************
- * This software falls under the GNU general public license version 3 or later.
- * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
- * in the root directory or .
- ******************************************************************************/
-
-#ifndef QTMTEMPLATEPAGE_HPP
-#define QTMTEMPLATEPAGE_HPP
-
-#include
-#include
-
-class QGridLayout;
-class QLabel;
-class QResizeEvent;
-class QScrollArea;
-class QTimer;
-class TemplateManager;
-struct TemplateMetadata;
-using TemplateMetadataPtr= QSharedPointer;
-
-/**
- * @brief Template page widget for startup tab
- *
- * Displays a grid of template cards for the selected category.
- * Handles template download and opening.
- */
-class QTMTemplatePage : public QWidget {
- Q_OBJECT
-
-public:
- explicit QTMTemplatePage (QWidget* parent= nullptr);
- ~QTMTemplatePage ();
-
- // 初始化:连接 TemplateManager 信号
- void initialize ();
-
- // 设置当前显示的分类
- void setCategory (const QString& categoryId,
- const QString& displayName= QString ());
- QString currentCategory () const { return currentCategory_; }
- void refreshGrid ();
-
-protected:
- bool eventFilter (QObject* watched, QEvent* event) override;
- void showEvent (QShowEvent* event) override;
- void resizeEvent (QResizeEvent* event) override;
-
-private slots:
- void onTemplatesLoaded ();
-
-private:
- void setupUI ();
- QWidget* createTemplateCard (const TemplateMetadataPtr& tmpl);
- void refreshTemplateGrid ();
- int calculateColumnCount () const;
- void showTemplatePreview (const QString& templateId);
-
- // UI 组件
- QLabel* titleLabel_;
- QScrollArea* scrollArea_;
- QWidget* gridWidget_;
- QGridLayout* gridLayout_;
-
- // 数据
- TemplateManager* templateManager_;
- QString currentCategory_;
-
- // 响应式网格:当前列数
- int currentColumnCount_= 4;
-
- // 避免 onTemplatesLoaded 和 showEvent 重复刷新
- bool gridNeedsRefresh_= true;
-
- // resize 防抖定时器,避免拖拽窗口时频繁重建网格
- QTimer* resizeDebounceTimer_;
-};
-
-#endif // QTMTEMPLATEPAGE_HPP
diff --git a/src/Plugins/Qt/StartupBridge.cpp b/src/Plugins/Qt/StartupBridge.cpp
new file mode 100644
index 0000000000..3564232b52
--- /dev/null
+++ b/src/Plugins/Qt/StartupBridge.cpp
@@ -0,0 +1,588 @@
+
+/******************************************************************************
+ * MODULE : StartupBridge.cpp
+ * DESCRIPTION: C++↔QML bridge for startup tab — implementation
+ * COPYRIGHT : (C) 2026 Yuki Lu
+ *******************************************************************************
+ * This software falls under the GNU general public license version 3 or later.
+ * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
+ * in the root directory or .
+ ******************************************************************************/
+
+#include "StartupBridge.hpp"
+
+#include "QTMTemplateOpener.hpp"
+#include "qt_dpi_utils.hpp"
+#include "qt_floating_toast.hpp"
+#include "qt_pdf_preview_widget.hpp"
+#include "qt_utilities.hpp"
+#include "s7_tm.hpp"
+#include "sys_utils.hpp"
+#include "template_manager.hpp"
+#include "thumbnail_loader.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+QString
+recentDocsFilePath () {
+ string homePath = get_env ("TEXMACS_HOME_PATH");
+ string configDir= homePath * "/system";
+ QDir ().mkpath (to_qstring (configDir));
+ return QDir (to_qstring (configDir)).filePath ("recent-files.json");
+}
+
+QStringList
+recentPathsFromScheme () {
+ QStringList paths;
+ tmscm result= eval_scheme ("(startup-tab-get-recent-docs)");
+ if (!tmscm_is_list (result)) return paths;
+ for (tmscm cur= result; !tmscm_is_null (cur); cur= tmscm_cdr (cur)) {
+ tmscm item= tmscm_car (cur);
+ if (tmscm_is_string (item))
+ paths << QString::fromUtf8 (as_charp (tmscm_to_string (item)));
+ }
+ return paths;
+}
+
+/** 读取 recent-files.json 缓存:path → {name, openedAt}。 */
+QHash>
+readRecentMeta () {
+ QHash> meta;
+ QFile file (recentDocsFilePath ());
+ if (!file.open (QIODevice::ReadOnly)) return meta;
+
+ QJsonDocument doc= QJsonDocument::fromJson (file.readAll ());
+ file.close ();
+ if (!doc.isObject ()) return meta;
+
+ QJsonObject obj = doc.object ();
+ QJsonArray files= obj["files"].toArray ();
+ for (const auto& val : files) {
+ QJsonObject o= val.toObject ();
+ QString p= QDir::fromNativeSeparators (o["path"].toString ());
+ if (p.isEmpty ()) continue;
+ QString name= o["name"].toString ();
+ qint64 ts = static_cast (
+ o.contains ("last_open") ? o["last_open"].toDouble () : 0);
+ QDateTime dt= ts > 0 ? QDateTime::fromSecsSinceEpoch (ts)
+ : QDateTime::currentDateTime ();
+ meta.insert (p, {name, dt.toString ("yyyy-MM-dd HH:mm")});
+ }
+ return meta;
+}
+
+QVariantMap
+makeRecentEntry (const QString& fileName, const QString& filePath,
+ const QString& openedAt) {
+ QDateTime dt= QDateTime::fromString (openedAt, "yyyy-MM-dd HH:mm");
+ double ts= dt.isValid ()
+ ? static_cast (dt.toSecsSinceEpoch ())
+ : static_cast (
+ QDateTime::currentDateTime ().toSecsSinceEpoch ());
+ return {{"fileName", fileName},
+ {"filePath", filePath},
+ {"openedAt", openedAt},
+ {"timestamp", ts}};
+}
+
+QVariantMap
+makeTemplateEntry (const QString& id, const QString& name,
+ const QString& author, const QString& version,
+ const QString& thumbnailUrl) {
+ return {{"id", id},
+ {"name", name},
+ {"author", author},
+ {"version", version},
+ {"thumbnailUrl", thumbnailUrl}};
+}
+
+} // namespace
+
+StartupBridge::StartupBridge (QObject* parent) : QObject (parent) {}
+StartupBridge::~StartupBridge ()= default;
+
+void
+StartupBridge::initialize () {
+ eval_scheme ("(use-modules (startup-tab startup-tab-file))");
+ rebuildStyleCards ();
+ loadRecentDocs ();
+
+ templateManager_= TemplateManager::instance ();
+ connect (templateManager_, &TemplateManager::categoriesLoaded, this,
+ &StartupBridge::onCategoriesLoaded, Qt::UniqueConnection);
+ connect (templateManager_, &TemplateManager::recommendTemplatesLoaded, this,
+ &StartupBridge::onRecommendTemplatesLoaded, Qt::UniqueConnection);
+ connect (templateManager_, &TemplateManager::templatesLoaded, this,
+ &StartupBridge::onTemplatesLoaded, Qt::UniqueConnection);
+ connect (templateManager_, &TemplateManager::recommendTemplatesLoadFailed,
+ this, &StartupBridge::onRecommendTemplatesLoadFailed,
+ Qt::UniqueConnection);
+ connect (templateManager_, &TemplateManager::templatesLoadFailed, this,
+ &StartupBridge::onTemplatesLoadFailed, Qt::UniqueConnection);
+ connect (templateManager_, &TemplateManager::thumbnailCached, this,
+ [this] (const QString&) {
+ // 缩略图下载完成 → file:// URL 已可用,刷新分类模板和首页推荐卡片
+ categoryTemplatesCache_.remove (activeCategoryId_);
+ refreshCategoryTemplates ();
+ rebuildStyleCards ();
+ });
+
+ if (templateManager_->isInitialized ()) {
+ if (!templateManager_->categories ().isEmpty ()) onCategoriesLoaded ();
+ if (!templateManager_->templates ().isEmpty ()) onTemplatesLoaded ();
+ }
+ else {
+ QTimer::singleShot (0, this, [this] { templateManager_->initialize (); });
+ }
+}
+
+// =========================================================================
+// Style cards — 首页推荐模板
+// =========================================================================
+
+void
+StartupBridge::rebuildStyleCards () {
+ // build to a temp list to skip emit if nothing changed
+ QVariantList newCards;
+ auto* mgr= TemplateManager::instance ();
+ if (!mgr || !mgr->isInitialized ()) return;
+ auto* loader= mgr->thumbnailLoader ();
+ for (const auto& t : mgr->recommendTemplates ()) {
+ if (!t) continue;
+ QVariantMap m;
+ m["kind"] = "thumbnail";
+ m["id"] = t->id;
+ m["name"] = t->name;
+ m["titleText"]= t->name;
+ QString url= loader ? loader->queryUrl (t->thumbnailUrl) : t->thumbnailUrl;
+ if (!url.isEmpty ()) m["thumbSrc"]= url;
+ newCards << m;
+ }
+ if (newCards != styleCards_) {
+ styleCards_= newCards;
+ emit styleCardsChanged ();
+ }
+}
+
+void
+StartupBridge::onRecommendTemplatesLoaded () {
+ rebuildStyleCards ();
+}
+
+void
+StartupBridge::onRecommendTemplatesLoadFailed (const QString& error) {
+ qWarning () << "[StartupBridge] Failed to load recommend templates:" << error;
+}
+
+void
+StartupBridge::onTemplatesLoadFailed (const QString& error) {
+ qWarning () << "[StartupBridge] Failed to load templates:" << error;
+}
+
+// =========================================================================
+// Recent documents
+// =========================================================================
+
+void
+StartupBridge::loadRecentDocs () {
+ recentDocs_.clear ();
+
+ QStringList paths= recentPathsFromScheme ();
+ for (auto& p : paths)
+ p= QDir::fromNativeSeparators (p);
+ paths.removeDuplicates ();
+
+ // 只保留仍存在的文件
+ paths.erase (
+ std::remove_if (paths.begin (), paths.end (),
+ [] (const QString& p) { return !QFile::exists (p); }),
+ paths.end ());
+
+ auto meta= readRecentMeta ();
+ for (const auto& path : paths) {
+ if (recentDocs_.size () >= kMaxRecentDocs) break;
+ QString name= QFileInfo (path).fileName ();
+ QString time;
+ auto it= meta.constFind (path);
+ if (it != meta.constEnd ()) {
+ if (!it->first.isEmpty ()) name= it->first;
+ time= it->second;
+ }
+ else {
+ time= QDateTime::currentDateTime ().toString ("yyyy-MM-dd HH:mm");
+ }
+ recentDocs_ << makeRecentEntry (name, path, time);
+ }
+ emit recentDocsChanged ();
+}
+
+void
+StartupBridge::saveRecentDocs () {
+ QJsonObject root;
+ QFile file (recentDocsFilePath ());
+ if (file.open (QIODevice::ReadOnly)) {
+ QJsonDocument d= QJsonDocument::fromJson (file.readAll ());
+ if (d.isObject ()) root= d.object ();
+ file.close ();
+ }
+ if (!root.contains ("files")) root["files"]= QJsonArray ();
+
+ double now=
+ static_cast (QDateTime::currentDateTime ().toSecsSinceEpoch ());
+ QJsonArray arr;
+ for (const auto& v : recentDocs_) {
+ auto m = v.toMap ();
+ double ts= m.contains ("timestamp") ? m["timestamp"].toDouble () : now;
+ arr << QJsonObject{{"path", m["filePath"].toString ()},
+ {"name", m["fileName"].toString ()},
+ {"last_open", ts},
+ {"show", true}};
+ }
+ root["files"]= arr;
+
+ // Write to temp file then rename for atomicity
+ QString tmpPath= recentDocsFilePath () + ".tmp";
+ QFile tmpFile (tmpPath);
+ if (tmpFile.open (QIODevice::WriteOnly | QIODevice::Truncate)) {
+ tmpFile.write (QJsonDocument (root).toJson ());
+ tmpFile.close ();
+ QString destPath= recentDocsFilePath ();
+ // 跨文件系统 rename 可能失败,先用 remove 清理目标再 rename
+ if (!QFile::remove (destPath) && QFile::exists (destPath))
+ qWarning () << "[StartupBridge] Failed to remove old recent-files.json";
+ if (!tmpFile.rename (destPath))
+ qWarning () << "[StartupBridge] Failed to rename temp file to"
+ << destPath;
+ }
+ else {
+ qWarning () << "[StartupBridge] Failed to open temp file for writing:"
+ << tmpPath;
+ }
+}
+
+void
+StartupBridge::refreshRecentDocs () {
+ loadRecentDocs ();
+}
+
+void
+StartupBridge::scheduleRecentDocsRefresh () {
+ QTimer::singleShot (1500, this, [this] { loadRecentDocs (); });
+}
+
+void
+StartupBridge::addRecentDoc (const QString& path) {
+ QString normPath= QDir::fromNativeSeparators (path);
+
+ for (int i= 0; i < recentDocs_.size (); ++i) {
+ if (recentDocs_[i].toMap ()["filePath"].toString () == normPath) {
+ recentDocs_.removeAt (i); // 去重:删旧,后面 push_front
+ break;
+ }
+ }
+
+ recentDocs_.push_front (makeRecentEntry (
+ QFileInfo (normPath).fileName (), normPath,
+ QDateTime::currentDateTime ().toString ("yyyy-MM-dd HH:mm")));
+ while (recentDocs_.size () > kMaxRecentDocs)
+ recentDocs_.removeLast ();
+
+ saveRecentDocs ();
+ QTimer::singleShot (0, this, [normPath] {
+ eval_scheme ("(startup-tab-add-recent-doc " *
+ qt_scheme_quote_utf8 (normPath) * ")");
+ });
+ emit recentDocsChanged ();
+}
+
+// =========================================================================
+// Categories — 侧边栏模板分类
+// =========================================================================
+
+void
+StartupBridge::onCategoriesLoaded () {
+ if (!templateManager_) return;
+ categories_.clear ();
+ for (const auto& c : templateManager_->categories ())
+ categories_ << QVariantMap{{"id", c.id}, {"name", c.name}};
+ emit categoriesChanged ();
+}
+
+// =========================================================================
+// Template page
+// =========================================================================
+
+void
+StartupBridge::refreshCategoryTemplates () {
+ if (!templateManager_ || !templateManager_->isInitialized ()) return;
+
+ // 命中缓存:直接复用,避免不必要的 clear+重建导致 QML Image 重新加载
+ auto cacheIt= categoryTemplatesCache_.constFind (activeCategoryId_);
+ if (cacheIt != categoryTemplatesCache_.constEnd ()) {
+ if (categoryTemplates_ != *cacheIt) {
+ categoryTemplates_= *cacheIt;
+ emit categoryTemplatesChanged ();
+ }
+ return;
+ }
+
+ // 缓存未命中:从 TemplateManager 构建并缓存
+ categoryTemplates_.clear ();
+ auto templates=
+ activeCategoryId_.isEmpty ()
+ ? templateManager_->templates ()
+ : templateManager_->templatesByCategory (activeCategoryId_);
+ auto* loader= templateManager_->thumbnailLoader ();
+ for (const auto& t : templates) {
+ if (!t) continue;
+ QString url= loader ? loader->queryUrl (t->thumbnailUrl) : t->thumbnailUrl;
+ categoryTemplates_ << makeTemplateEntry (t->id, t->name, t->author,
+ t->version, url);
+ }
+ categoryTemplatesCache_.insert (activeCategoryId_, categoryTemplates_);
+ emit categoryTemplatesChanged ();
+}
+
+void
+StartupBridge::onTemplatesLoaded () {
+ categoryLoading_= false;
+ emit categoryLoadingChanged ();
+ // 异步新数据到达,清除缓存强制重新构建
+ categoryTemplatesCache_.remove (activeCategoryId_);
+ refreshCategoryTemplates ();
+}
+
+// =========================================================================
+// QML 工具方法
+// =========================================================================
+
+QString
+StartupBridge::tr (const QString& text) const {
+ return qt_translate (from_qstring (text));
+}
+
+// =========================================================================
+// QML actions
+// =========================================================================
+
+void
+StartupBridge::newDocument () {
+ eval_scheme ("(new-document-with-style \"generic\")");
+ scheduleRecentDocsRefresh ();
+}
+
+void
+StartupBridge::openDocument () {
+ if (!recentDocs_.isEmpty ()) {
+ QString dir=
+ QFileInfo (recentDocs_.first ().toMap ()["filePath"].toString ())
+ .absolutePath ();
+ eval_scheme ("(choose-file load-buffer \"Load file\" \"action_open\" \"\" "
+ "(system->url " *
+ qt_scheme_quote_utf8 (dir) * "))");
+ }
+ else {
+ eval_scheme ("(open-document)");
+ }
+ scheduleRecentDocsRefresh ();
+}
+
+void
+StartupBridge::openRecentDoc (const QString& path) {
+ if (path.isEmpty ()) return;
+ if (!QFile::exists (path)) {
+ removeRecentDoc (path);
+ QWidget* pw= qobject_cast (parent ());
+ QtFloatingToast::showToast (
+ pw, qt_translate ("File not found, removed from recent list"), 3000,
+ QtFloatingToast::Error);
+ return;
+ }
+ addRecentDoc (path);
+ eval_scheme ("(load-document " * qt_scheme_quote_utf8 (path) * ")");
+}
+
+void
+StartupBridge::removeRecentDoc (const QString& path) {
+ QString normPath= QDir::fromNativeSeparators (path);
+ for (int i= 0; i < recentDocs_.size (); ++i) {
+ if (recentDocs_[i].toMap ()["filePath"].toString () == normPath) {
+ recentDocs_.removeAt (i);
+ break;
+ }
+ }
+ saveRecentDocs ();
+ QTimer::singleShot (0, this, [normPath] {
+ eval_scheme ("(startup-tab-clear-recent-doc " *
+ qt_scheme_quote_utf8 (normPath) * ")");
+ });
+ emit recentDocsChanged ();
+}
+
+void
+StartupBridge::clearAllRecentDocs () {
+ QTimer::singleShot (0, this,
+ [] { eval_scheme ("(startup-tab-clear-all-recent)"); });
+ recentDocs_.clear ();
+ saveRecentDocs ();
+ emit recentDocsChanged ();
+}
+
+void
+StartupBridge::selectCategory (const QString& categoryId) {
+ if (activeCategoryId_ == categoryId) return;
+ activeCategoryId_= categoryId;
+ activeCategoryName_.clear ();
+ for (const auto& v : categories_) {
+ auto m= v.toMap ();
+ if (m["id"].toString () == categoryId) {
+ activeCategoryName_= m["name"].toString ();
+ break;
+ }
+ }
+ emit activeCategoryChanged ();
+ if (templateManager_ && templateManager_->isInitialized ()) {
+ categoryLoading_= true;
+ emit categoryLoadingChanged ();
+ templateManager_->refreshTemplatesByCategory (categoryId);
+ // 同步刷新:处理已缓存分类(refreshTemplatesByCategory 对已缓存分类
+ // 是 no-op,不会触发 onTemplatesLoaded),同时为未缓存分类展示已有数据
+ refreshCategoryTemplates ();
+ // 若数据已就绪(缓存命中),同步结束 loading;否则等待 onTemplatesLoaded
+ if (!categoryTemplates_.isEmpty ()) {
+ categoryLoading_= false;
+ emit categoryLoadingChanged ();
+ }
+ }
+ else {
+ // 未初始化时无异步回调,直接同步刷新
+ refreshCategoryTemplates ();
+ }
+}
+
+void
+StartupBridge::openTemplate (const QString& id) {
+ QTMTemplateOpener opener;
+ opener.openTemplate (id);
+ scheduleRecentDocsRefresh ();
+}
+
+void
+StartupBridge::previewTemplate (const QString& id) {
+ if (!templateManager_) return;
+
+ TemplateMetadataPtr tmpl= templateManager_->templateById (id);
+ if (!tmpl) return;
+
+ QWidget* parentWidget= qobject_cast (parent ());
+
+ // ---- 预览对话框 ----
+ // TODO: PDF 预览框后续改为 QML 实现
+ QDialog* dialog= new QDialog (parentWidget);
+ dialog->setWindowTitle (
+ qt_translate ("Template Preview - %1").arg (tmpl->name));
+ dialog->setAttribute (Qt::WA_DeleteOnClose);
+
+ // 根据屏幕可用区域限制尺寸
+ QScreen* screen= dialog->screen ();
+ if (!screen) screen= QGuiApplication::primaryScreen ();
+ QRect availGeo= screen ? screen->availableGeometry () : QRect ();
+
+ constexpr int kPreviewImageSize= 600;
+ constexpr int kLayoutMargin = 24;
+ constexpr int kLayoutSpacing = 16;
+ constexpr int kTitleFontPx = 18;
+ constexpr int kDescFontPx = 14;
+ constexpr int kBtnFontPx = 13;
+
+ int previewSize= DpiUtils::scaled (kPreviewImageSize);
+ int maxDlgH = availGeo.height () > 0 ? qRound (availGeo.height () * 0.9)
+ : DpiUtils::scaled (800);
+ previewSize = qMin (previewSize, qRound (maxDlgH * 0.7));
+
+ QVBoxLayout* layout= new QVBoxLayout (dialog);
+ layout->setSpacing (DpiUtils::scaled (kLayoutSpacing));
+ layout->setContentsMargins (
+ DpiUtils::scaled (kLayoutMargin), DpiUtils::scaled (kLayoutMargin),
+ DpiUtils::scaled (kLayoutMargin), DpiUtils::scaled (kLayoutMargin));
+
+ // 标题
+ QLabel* titleLabel= new QLabel (tmpl->name, dialog);
+ QFont titleFont = DpiUtils::scaledFont (titleLabel->font (), kTitleFontPx);
+ titleFont.setBold (true);
+ titleLabel->setFont (titleFont);
+ layout->addWidget (titleLabel);
+
+ // 描述
+ if (!tmpl->description.isEmpty ()) {
+ QLabel* descLabel= new QLabel (tmpl->description, dialog);
+ descLabel->setWordWrap (true);
+ DpiUtils::applyScaledFont (descLabel, kDescFontPx);
+ layout->addWidget (descLabel);
+ }
+
+ // 信息行
+ QHBoxLayout* infoLayout= new QHBoxLayout ();
+ if (!tmpl->author.isEmpty ())
+ infoLayout->addWidget (
+ new QLabel (qt_translate ("Author: %1").arg (tmpl->author)));
+ if (!tmpl->version.isEmpty ())
+ infoLayout->addWidget (
+ new QLabel (qt_translate ("Version: %1").arg (tmpl->version)));
+ infoLayout->addStretch ();
+ layout->addLayout (infoLayout);
+
+ // PDF 预览
+ QTPdfPreviewWidget* previewWidget= new QTPdfPreviewWidget (dialog);
+ previewWidget->setFixedSize (previewSize, previewSize);
+ if (!tmpl->previewUrl.isEmpty ())
+ previewWidget->loadFromUrl (tmpl->previewUrl);
+ else previewWidget->clearPreview (qt_translate ("No Preview"));
+ layout->addWidget (previewWidget, 0, Qt::AlignCenter);
+
+ // 按钮
+ QHBoxLayout* btnLayout= new QHBoxLayout ();
+ btnLayout->addStretch ();
+
+ QPushButton* cancelBtn= new QPushButton (qt_translate ("Cancel"), dialog);
+ cancelBtn->setObjectName ("template-cancel-btn");
+ DpiUtils::applyScaledFont (cancelBtn, kBtnFontPx);
+ cancelBtn->setCursor (Qt::PointingHandCursor);
+ connect (cancelBtn, &QPushButton::clicked, dialog, &QDialog::reject);
+ btnLayout->addWidget (cancelBtn);
+
+ QPushButton* useBtn= new QPushButton (qt_translate ("Use Template"), dialog);
+ useBtn->setObjectName ("template-use-btn");
+ DpiUtils::applyScaledFont (useBtn, kBtnFontPx);
+ useBtn->setCursor (Qt::PointingHandCursor);
+ useBtn->setDefault (true);
+ connect (useBtn, &QPushButton::clicked, [dialog, id] () {
+ dialog->accept ();
+ QTMTemplateOpener opener;
+ opener.openTemplate (id);
+ });
+ btnLayout->addWidget (useBtn);
+ layout->addLayout (btnLayout);
+
+ dialog->show ();
+}
+
+void
+StartupBridge::quit () {
+ eval_scheme ("(quit-TeXmacs)");
+}
diff --git a/src/Plugins/Qt/StartupBridge.hpp b/src/Plugins/Qt/StartupBridge.hpp
new file mode 100644
index 0000000000..9774dee40f
--- /dev/null
+++ b/src/Plugins/Qt/StartupBridge.hpp
@@ -0,0 +1,118 @@
+
+/******************************************************************************
+ * MODULE : StartupBridge.hpp
+ * DESCRIPTION: C++↔QML bridge for startup tab
+ * COPYRIGHT : (C) 2026 Yuki Lu
+ *******************************************************************************
+ * This software falls under the GNU general public license version 3 or later.
+ * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
+ * in the root directory or .
+ ******************************************************************************/
+
+#ifndef STARTUP_BRIDGE_HPP
+#define STARTUP_BRIDGE_HPP
+
+#include
+#include
+
+class TemplateManager;
+
+/**
+ * @brief C++↔QML 桥接,为 StartupTab.qml 提供数据模型与动作处理。
+ *
+ * 通过 Q_PROPERTY 暴露最近文档、模板分类、推荐模板等列表,
+ * 通过 Q_INVOKABLE 提供新建/打开文档、切换分类、退出等动作。
+ *
+ * QVariantList 条目均为 QVariantMap,key 约定:
+ * recentDocs: {fileName, filePath, openedAt}
+ * categories: {id, name}
+ * styleCards: {kind, id, name, titleText?, iconSrc?, thumbSrc?}
+ * templates: {id, name, author, version, thumbnailUrl}
+ */
+class StartupBridge : public QObject {
+ Q_OBJECT
+ Q_DISABLE_COPY (StartupBridge)
+
+ Q_PROPERTY (
+ QVariantList styleCards READ styleCards NOTIFY styleCardsChanged FINAL)
+ Q_PROPERTY (
+ QVariantList recentDocs READ recentDocs NOTIFY recentDocsChanged FINAL)
+ Q_PROPERTY (
+ QVariantList categories READ categories NOTIFY categoriesChanged FINAL)
+ Q_PROPERTY (QString activeCategoryId READ activeCategoryId NOTIFY
+ activeCategoryChanged FINAL)
+ Q_PROPERTY (QString activeCategoryName READ activeCategoryName NOTIFY
+ activeCategoryChanged FINAL)
+ Q_PROPERTY (QVariantList categoryTemplates READ categoryTemplates NOTIFY
+ categoryTemplatesChanged FINAL)
+ Q_PROPERTY (bool categoryLoading READ categoryLoading NOTIFY
+ categoryLoadingChanged FINAL)
+
+public:
+ explicit StartupBridge (QObject* parent= nullptr);
+ ~StartupBridge ();
+
+ void initialize ();
+
+ QVariantList styleCards () const { return styleCards_; }
+ QVariantList recentDocs () const { return recentDocs_; }
+ QVariantList categories () const { return categories_; }
+ QString activeCategoryId () const { return activeCategoryId_; }
+ QString activeCategoryName () const { return activeCategoryName_; }
+ QVariantList categoryTemplates () const { return categoryTemplates_; }
+ bool categoryLoading () const { return categoryLoading_; }
+
+ // ---- QML 工具方法 ----
+ Q_INVOKABLE QString tr (const QString& text) const;
+
+ // ---- QML 可调用动作 ----
+ Q_INVOKABLE void newDocument ();
+ Q_INVOKABLE void openDocument ();
+ Q_INVOKABLE void openRecentDoc (const QString& path);
+ Q_INVOKABLE void removeRecentDoc (const QString& path);
+ Q_INVOKABLE void clearAllRecentDocs ();
+ Q_INVOKABLE void selectCategory (const QString& categoryId);
+ Q_INVOKABLE void openTemplate (const QString& templateId);
+ Q_INVOKABLE void previewTemplate (const QString& templateId);
+ Q_INVOKABLE void quit ();
+
+ void addRecentDoc (const QString& path);
+ void refreshRecentDocs ();
+
+signals:
+ void styleCardsChanged ();
+ void recentDocsChanged ();
+ void categoriesChanged ();
+ void activeCategoryChanged ();
+ void categoryTemplatesChanged ();
+ void categoryLoadingChanged ();
+
+private slots:
+ void onCategoriesLoaded ();
+ void onRecommendTemplatesLoaded ();
+ void onTemplatesLoaded ();
+ void onRecommendTemplatesLoadFailed (const QString& error);
+ void onTemplatesLoadFailed (const QString& error);
+
+private:
+ void loadRecentDocs ();
+ void saveRecentDocs ();
+ void rebuildStyleCards ();
+ void refreshCategoryTemplates ();
+ void scheduleRecentDocsRefresh ();
+
+ TemplateManager* templateManager_= nullptr;
+
+ QVariantList styleCards_;
+ QVariantList recentDocs_;
+ QVariantList categories_;
+ QString activeCategoryId_;
+ QString activeCategoryName_;
+ QVariantList categoryTemplates_;
+ bool categoryLoading_= false;
+ QHash categoryTemplatesCache_;
+
+ static const int kMaxRecentDocs= 50;
+};
+
+#endif
diff --git a/src/Plugins/Qt/moganqml.qrc b/src/Plugins/Qt/moganqml.qrc
index 6955c83c19..264a6031d7 100644
--- a/src/Plugins/Qt/moganqml.qrc
+++ b/src/Plugins/Qt/moganqml.qrc
@@ -1,25 +1,59 @@
+
qml/qmldir
qml/atoms/qmldir
+ qml/startup/qmldir
+ qml/startup/atoms/qmldir
+
+
+
qml/atoms/Theme.qml
+ qml/startup/atoms/StartupTheme.qml
qml/atoms/DialogShell.qml
+
+
qml/atoms/DialogButtons.qml
+ qml/atoms/MiniButton.qml
+
+
+ qml/startup/atoms/StyleCard.qml
+ qml/startup/atoms/TemplateCard.qml
+
+
qml/atoms/EnumCombo.qml
qml/atoms/EnumComboList.qml
+ qml/atoms/Toggle.qml
+ qml/atoms/GroupHeader.qml
+
+
qml/atoms/SelectableList.qml
qml/atoms/PreviewPane.qml
+
+
qml/atoms/TabBar.qml
qml/atoms/TabPanel.qml
- qml/atoms/MiniButton.qml
- qml/atoms/Toggle.qml
- qml/atoms/GroupHeader.qml
- qml/FontSelector.qml
+
+
qml/ConfirmClose.qml
qml/ConfirmRestart.qml
qml/FormDialog.qml
+ qml/FontSelector.qml
qml/ParagraphFormat.qml
qml/Statistics.qml
qml/Preferences.qml
+
+
+ qml/startup/StartupTab.qml
+ qml/startup/StartupHomePage.qml
+ qml/startup/StartupTemplatePage.qml
diff --git a/src/Plugins/Qt/qml/startup/StartupHomePage.qml b/src/Plugins/Qt/qml/startup/StartupHomePage.qml
new file mode 100644
index 0000000000..a82f472cc4
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/StartupHomePage.qml
@@ -0,0 +1,212 @@
+// StartupHomePage.qml — 启动页 Home 页面。
+// 对应 C++ QTMHomePage:Document Style 样式卡片 + 最近文档列表。
+//
+// 页面整体不滚动,仅 Recent Documents 内部滚动。
+
+import QtQuick
+import "atoms"
+
+Item {
+ id: page
+
+ // 数据模型(来自 bridge,fallback 为空)
+ property var styleCards: typeof startupBridge !== "undefined" && startupBridge.styleCards ? startupBridge.styleCards : []
+ property var recentDocs: typeof startupBridge !== "undefined" && startupBridge.recentDocs ? startupBridge.recentDocs : []
+
+ // 内容区水平内边距统一应用到一个覆盖层,竖直方向分区用锚点堆叠。
+ property real padH: StartupTheme.contentPadH
+
+ // ---- Document Style 区块(顶部固定) ----
+ Item {
+ id: topSection
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: parent.top
+ leftMargin: page.padH
+ rightMargin: page.padH
+ topMargin: StartupTheme.contentPadTop
+ }
+ height: childrenRect.height
+
+ Text {
+ id: sectionTitle
+ anchors {
+ left: parent.left
+ top: parent.top
+ }
+ text: StartupTheme.tr("Document Style")
+ color: StartupTheme.sectionTitleFg
+ font.pixelSize: StartupTheme.fontSectionTitle
+ font.weight: Font.DemiBold
+ }
+
+ // 样式卡片行(Flow 布局,自动换行)
+ Flow {
+ id: cardsFlow
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: sectionTitle.bottom
+ topMargin: 16 * StartupTheme.scaleFactor
+ }
+ spacing: StartupTheme.gapCards
+
+ // 新建文档(图标模式,固定)
+ StyleCard {
+ kind: "icon"
+ iconSrc: "qrc:/startup-tab/new-file.svg"
+ cardName: StartupTheme.tr("New document")
+ onClicked: {
+ if (typeof startupBridge !== "undefined") startupBridge.newDocument()
+ }
+ }
+
+ // 打开文档(图标模式,固定)
+ StyleCard {
+ kind: "icon"
+ iconSrc: "qrc:/startup-tab/open-file.svg"
+ cardName: StartupTheme.tr("Open document")
+ onClicked: {
+ if (typeof startupBridge !== "undefined") startupBridge.openDocument()
+ }
+ }
+
+ // 推荐模板卡片(缩略图模式,动态)
+ Repeater {
+ model: page.styleCards
+ delegate: StyleCard {
+ kind: "thumbnail"
+ titleText: modelData.titleText || modelData.name
+ thumbSrc: modelData.thumbSrc || ""
+ onClicked: {
+ if (typeof startupBridge !== "undefined") startupBridge.openTemplate(modelData.id)
+ }
+ }
+ }
+ }
+ }
+
+ // ---- 分隔线 ----
+ Rectangle {
+ id: separator
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: topSection.bottom
+ topMargin: StartupTheme.sectionGap
+ leftMargin: page.padH
+ rightMargin: page.padH
+ }
+ height: 1
+ color: StartupTheme.separatorColor
+ }
+
+ // ---- Recent Documents 标题 ----
+ Text {
+ id: recentTitle
+ anchors {
+ left: parent.left
+ top: separator.bottom
+ topMargin: StartupTheme.sectionGap
+ leftMargin: page.padH
+ }
+ text: StartupTheme.tr("Recent Documents")
+ color: StartupTheme.sectionTitleFg
+ font.pixelSize: StartupTheme.fontSectionTitle
+ font.weight: Font.DemiBold
+ }
+
+ // ---- 最近文档列表(占满剩余高度,内部滚动) ----
+ Rectangle {
+ id: recentContainer
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: recentTitle.bottom
+ topMargin: 16 * StartupTheme.scaleFactor
+ bottom: parent.bottom
+ bottomMargin: StartupTheme.contentPadBottom
+ leftMargin: page.padH
+ rightMargin: page.padH
+ }
+ radius: StartupTheme.recentRadius
+ color: StartupTheme.recentBg
+ border.width: 1
+ border.color: StartupTheme.recentBorder
+
+ ListView {
+ id: recentList
+ anchors.fill: parent
+ anchors.leftMargin: StartupTheme.recentItemMarginH
+ anchors.rightMargin: StartupTheme.recentItemMarginH
+ anchors.topMargin: StartupTheme.recentItemMarginV
+ anchors.bottomMargin: StartupTheme.recentItemMarginV
+ spacing: 0
+ clip: true
+ model: page.recentDocs
+ interactive: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ delegate: Rectangle {
+ width: recentList.width - 2 * StartupTheme.recentItemMarginH
+ height: StartupTheme.recentItemH
+ anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
+ radius: StartupTheme.recentItemRadius
+ color: itemMouse.containsMouse ? StartupTheme.recentHoverBg : "transparent"
+
+ Text {
+ id: fileNameText
+ anchors {
+ left: parent.left
+ leftMargin: StartupTheme.recentItemPadH
+ verticalCenter: parent.verticalCenter
+ }
+ width: parent.width - timeText.width - 2 * StartupTheme.recentItemPadH
+ text: modelData.fileName
+ color: StartupTheme.recentNameFg
+ font.pixelSize: StartupTheme.fontRecentName
+ font.weight: Font.Bold
+ elide: Text.ElideRight
+ }
+ Text {
+ id: timeText
+ anchors {
+ right: parent.right
+ rightMargin: StartupTheme.recentItemPadH
+ verticalCenter: parent.verticalCenter
+ }
+ text: StartupTheme.tr("Last opened") + ": " + (modelData.openedAt || "")
+ color: StartupTheme.recentTimeFg
+ font.pixelSize: StartupTheme.fontRecentTime
+ }
+
+ MouseArea {
+ id: itemMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ cursorShape: Qt.PointingHandCursor
+ onClicked: function(mouse) {
+ if (mouse.button === Qt.RightButton) {
+ // 右键:从列表移除该项(原 Controls 菜单已移除,避免引入 QtQuick.Controls)
+ if (typeof startupBridge !== "undefined")
+ startupBridge.removeRecentDoc(modelData.filePath)
+ } else {
+ if (typeof startupBridge !== "undefined") startupBridge.openRecentDoc(modelData.filePath)
+ }
+ }
+ }
+ }
+
+ // 无文档占位提示
+ Text {
+ anchors.centerIn: parent
+ text: StartupTheme.tr("No recent documents")
+ color: StartupTheme.recentEmptyFg
+ font.pixelSize: StartupTheme.fontRecentEmpty
+ visible: page.recentDocs.length === 0
+ }
+ }
+ }
+}
diff --git a/src/Plugins/Qt/qml/startup/StartupTab.qml b/src/Plugins/Qt/qml/startup/StartupTab.qml
new file mode 100644
index 0000000000..0a98e32b76
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/StartupTab.qml
@@ -0,0 +1,180 @@
+// StartupTab.qml — 启动页主容器。
+// 左侧导航栏 + 右侧内容区。
+
+import QtQuick
+import "atoms"
+
+Rectangle {
+ id: root
+ color: StartupTheme.contentBg
+ focus: true
+
+ property var categories: typeof startupBridge !== "undefined" && startupBridge.categories ? startupBridge.categories : []
+ property int activePage: 0 // 0=Home, 1=Template
+
+ // ================================================================
+ // 左侧导航栏 (对应 QWidget#startup-tab-sidebar)
+ // ================================================================
+ Rectangle {
+ id: sidebar
+ anchors {
+ left: parent.left
+ top: parent.top
+ bottom: parent.bottom
+ }
+ width: StartupTheme.sidebarWidth
+ color: StartupTheme.sidebarBg
+
+ // 导航按钮统一尺寸
+ property real btnWidth: sidebar.width - 16 * StartupTheme.scaleFactor
+ property real btnHeight: StartupTheme.fontNavBtn + 2 * StartupTheme.navBtnPadV
+
+ // Navigation 标题
+ Text {
+ id: navTitle
+ anchors {
+ top: parent.top
+ left: parent.left
+ topMargin: 16 * StartupTheme.scaleFactor
+ leftMargin: 20 * StartupTheme.scaleFactor
+ }
+ text: StartupTheme.tr("Navigation")
+ color: StartupTheme.navTitleFg
+ font.pixelSize: StartupTheme.fontNavTitle
+ font.weight: Font.Bold
+ }
+
+ // Home 按钮
+ Rectangle {
+ id: homeBtn
+ anchors {
+ top: navTitle.bottom
+ topMargin: 8 * StartupTheme.scaleFactor + 2 * StartupTheme.scaleFactor
+ horizontalCenter: parent.horizontalCenter
+ }
+ width: sidebar.btnWidth
+ height: sidebar.btnHeight
+ radius: StartupTheme.navBtnRadius
+ color: root.activePage === 0 ? StartupTheme.navBtnActiveBg :
+ (homeMouse.containsMouse ? StartupTheme.navBtnHoverBg : "transparent")
+
+ Text {
+ anchors.centerIn: parent
+ text: StartupTheme.tr("Home")
+ color: StartupTheme.navBtnFg
+ font.pixelSize: StartupTheme.fontNavBtn
+ }
+ MouseArea {
+ id: homeMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.activePage = 0
+ }
+ }
+
+ // 分类按钮(动态)—— 用 Column 垂直堆叠,挂在 Home 按钮下方
+ Column {
+ id: categoryColumn
+ anchors {
+ top: homeBtn.bottom
+ left: parent.left
+ right: parent.right
+ topMargin: 2 * StartupTheme.scaleFactor
+ }
+ spacing: 2 * StartupTheme.scaleFactor
+
+ Repeater {
+ model: root.categories
+ delegate: Rectangle {
+ width: sidebar.btnWidth
+ height: sidebar.btnHeight
+ anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
+ radius: StartupTheme.navBtnRadius
+ color: {
+ var isActive = root.activePage === 1 &&
+ typeof startupBridge !== "undefined" &&
+ startupBridge.activeCategoryId === modelData.id
+ if (isActive) return StartupTheme.navBtnActiveBg
+ if (catMouse.containsMouse) return StartupTheme.navBtnHoverBg
+ return "transparent"
+ }
+
+ Text {
+ anchors.centerIn: parent
+ width: parent.width - 2 * StartupTheme.navBtnPadH
+ text: modelData.name
+ color: StartupTheme.navBtnFg
+ font.pixelSize: StartupTheme.fontNavBtn
+ elide: Text.ElideMiddle
+ horizontalAlignment: Text.AlignHCenter
+ }
+ MouseArea {
+ id: catMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.activePage = 1
+ if (typeof startupBridge !== "undefined") startupBridge.selectCategory(modelData.id)
+ }
+ }
+ }
+ }
+ }
+
+ // Quit 按钮(钉在侧边栏底部)
+ Rectangle {
+ id: quitBtn
+ anchors {
+ bottom: parent.bottom
+ bottomMargin: 16 * StartupTheme.scaleFactor
+ horizontalCenter: parent.horizontalCenter
+ }
+ width: sidebar.btnWidth
+ height: sidebar.btnHeight
+ radius: StartupTheme.quitBtnRadius
+ color: quitMouse.containsMouse ? StartupTheme.quitBtnHoverBg : "transparent"
+ border.width: 1
+ border.color: StartupTheme.quitBtnBorder
+
+ Text {
+ anchors.centerIn: parent
+ text: StartupTheme.tr("Quit")
+ color: StartupTheme.quitBtnFg
+ font.pixelSize: StartupTheme.fontNavBtn
+ }
+ MouseArea {
+ id: quitMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (typeof startupBridge !== "undefined") startupBridge.quit()
+ }
+ }
+ }
+ }
+
+ // ================================================================
+ // 右侧内容区 —— 用 visible 切换,替代 StackLayout
+ // ================================================================
+ Item {
+ id: contentArea
+ anchors {
+ left: sidebar.right
+ top: parent.top
+ right: parent.right
+ bottom: parent.bottom
+ }
+
+ StartupHomePage {
+ anchors.fill: parent
+ visible: root.activePage === 0
+ }
+ StartupTemplatePage {
+ anchors.fill: parent
+ visible: root.activePage === 1
+ }
+ }
+}
diff --git a/src/Plugins/Qt/qml/startup/StartupTemplatePage.qml b/src/Plugins/Qt/qml/startup/StartupTemplatePage.qml
new file mode 100644
index 0000000000..04a3ee8bdc
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/StartupTemplatePage.qml
@@ -0,0 +1,105 @@
+// StartupTemplatePage.qml — 启动页模板分类页。
+// GridView 按需创建 delegate(虚拟滚动),避免大量模板时一次性创建全部卡片。
+
+import QtQuick
+import "atoms"
+
+Item {
+ id: page
+
+ // 数据来源:startupBridge context property(C++ StartupBridge 注入)。
+ property bool loading: typeof startupBridge !== "undefined" && startupBridge.categoryLoading ? startupBridge.categoryLoading : false
+ property var templates: typeof startupBridge !== "undefined" && startupBridge.categoryTemplates ? startupBridge.categoryTemplates : []
+ property string categoryName: typeof startupBridge !== "undefined" && startupBridge.activeCategoryName ? startupBridge.activeCategoryName : ""
+
+ // 顶部标题 + 状态提示
+ Column {
+ id: header
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: parent.top
+ }
+ anchors.leftMargin: StartupTheme.contentPadH
+ anchors.rightMargin: StartupTheme.contentPadH
+ anchors.topMargin: StartupTheme.contentPadTop
+ spacing: 0
+
+ Text {
+ id: titleLabel
+ width: parent.width
+ text: page.categoryName || StartupTheme.tr("Template Center")
+ color: StartupTheme.sectionTitleFg
+ font.pixelSize: StartupTheme.fontSectionTitle
+ font.weight: Font.DemiBold
+ }
+
+ Item {
+ width: 1
+ height: 16 * StartupTheme.scaleFactor
+ }
+
+ // Loading 状态
+ Text {
+ width: parent.width
+ text: StartupTheme.tr("Loading templates...")
+ color: "#888"
+ font.pixelSize: StartupTheme.fontTemplateLoading
+ visible: page.loading && page.templates.length === 0
+ }
+
+ // Empty 状态
+ Text {
+ width: parent.width
+ text: StartupTheme.tr("No templates in this category")
+ color: "#888"
+ font.pixelSize: StartupTheme.fontTemplateLoading
+ visible: !page.loading && page.templates.length === 0
+ }
+ }
+
+ // 模板网格 — GridView 只创建可见区域的 delegate
+ GridView {
+ id: templateGrid
+ anchors {
+ left: parent.left
+ right: parent.right
+ top: header.bottom
+ bottom: parent.bottom
+ leftMargin: StartupTheme.contentPadH
+ rightMargin: StartupTheme.contentPadH
+ topMargin: 0
+ bottomMargin: StartupTheme.contentPadBottom
+ }
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ model: page.templates
+
+ // 卡片尺寸 + 间距
+ cellWidth: StartupTheme.tplCardW + 16 * StartupTheme.scaleFactor
+ cellHeight: StartupTheme.tplCardH + 60 * StartupTheme.scaleFactor
+
+ delegate: TemplateCard {
+ templateId: modelData.id
+ name: modelData.name
+ author: modelData.author
+ version: modelData.version
+ thumbnailUrl: modelData.thumbnailUrl || ""
+ onClicked: function (tid) {
+ if (typeof startupBridge !== "undefined")
+ startupBridge.previewTemplate(tid);
+ }
+ }
+
+ // 新 delegate 淡入,避免切换分类时完全空白
+ populate: Transition {
+ NumberAnimation {
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: 120
+ }
+ }
+ }
+}
diff --git a/src/Plugins/Qt/qml/startup/atoms/StartupTheme.qml b/src/Plugins/Qt/qml/startup/atoms/StartupTheme.qml
new file mode 100644
index 0000000000..c100736c03
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/atoms/StartupTheme.qml
@@ -0,0 +1,116 @@
+// StartupTheme.qml — Startup tab 全局主题单例(pragma Singleton)。
+// 与 dialog Theme.qml 互补:dialog 弹窗走 Theme,startup tab 走 StartupTheme。
+// scaleFactor/dark 取自 C++ context property dpScale/isDark;缺省回退 1.0/false。
+//
+// 用法:import "atoms" 后 StartupTheme.sidebarBg / StartupTheme.scaleFactor 等。
+
+pragma Singleton
+import QtQuick
+
+QtObject {
+ property real scaleFactor: typeof dpScale !== "undefined" ? dpScale : 1.0
+ property bool dark: typeof isDark !== "undefined" ? isDark : false
+
+ // 翻译函数,走 C++ qt_translate
+ function tr(s) { return typeof startupBridge !== "undefined" ? startupBridge.tr(s) : s }
+
+ // ---- 侧边栏 (liii.css #startup-tab-sidebar: #215a6a) ----
+ readonly property color sidebarBg: "#215a6a"
+ readonly property color navTitleFg: Qt.rgba(1, 1, 1, 0.6)
+ readonly property color navBtnFg: "#ffffff"
+ readonly property color navBtnHoverBg: Qt.rgba(1, 1, 1, 0.15)
+ readonly property color navBtnActiveBg: "#2791ad"
+ readonly property color quitBtnFg: "#ffffff"
+ readonly property color quitBtnBorder: Qt.rgba(1, 1, 1, 0.5)
+ readonly property color quitBtnHoverBg: Qt.rgba(1, 1, 1, 0.2)
+
+ // ---- 内容区 (liii.css #startup-tab-content: #f3f3f3) ----
+ readonly property color contentBg: dark ? "#2b2b2b" : "#f3f3f3"
+ readonly property color sectionTitleFg: dark ? "#4db6ac" : "#215a6a"
+
+ // ---- StyleCard (liii.css QWidget#style-card) ----
+ readonly property color cardBg: dark ? "#3d3d3d" : "#ffffff"
+ readonly property color cardBorder: dark ? "#4e4e4e" : "#cfcfcf"
+ readonly property color cardHoverBorder: "#2791ad"
+ readonly property color cardIconFg: dark ? "#4db6ac" : "#215a6a"
+ readonly property color cardNameFg: dark ? "#e0e0e0" : "#2b3b45"
+ readonly property color cardTitleBg: dark ? "#3d3d3d" : "#ffffff"
+ readonly property color cardTitleFg: dark ? "#e0e0e0" : "#2b3b45"
+ readonly property color cardTitleBorderTop: dark ? "#4e4e4e" : "#f0f0f0"
+ readonly property color thumbnailBg: dark ? "#333333" : "#f5f5f5"
+
+ // ---- 分隔线 (liii.css QFrame#startup-tab-separator: #d8dde2) ----
+ readonly property color separatorColor: dark ? "#3e3e3e" : "#d8dde2"
+
+ // ---- 最近文档 (liii.css QListWidget#recent-docs-list) ----
+ readonly property color recentBg: dark ? "#3d3d3d" : "#ffffff"
+ readonly property color recentBorder: dark ? "#4e4e4e" : "#e0e0e0"
+ readonly property color recentNameFg: dark ? "#eaeaea" : "#1f2933"
+ readonly property color recentTimeFg: "#888888"
+ readonly property color recentHoverBg: dark ? "#4e4e4e" : "#f0f0f0"
+ readonly property color recentEmptyFg: "#999999"
+
+ // ---- 模板卡片 (liii.css QFrame#startup-tab-template-card) ----
+ readonly property color templateCardBg: dark ? "#3d3d3d" : "#ffffff"
+ readonly property color templateCardBorder: dark ? "#4e4e4e" : "#cfcfcf"
+ readonly property color templateNameFg: dark ? "#e0e0e0" : "#333333"
+ readonly property color templateInfoFg: "#888888"
+
+ // ---- 尺寸 (96 DPI 逻辑值, 乘 scaleFactor) ----
+ // 侧边栏
+ readonly property real sidebarWidth: 160 * scaleFactor
+ readonly property real navBtnPadV: 8 * scaleFactor
+ readonly property real navBtnPadH: 12 * scaleFactor
+ readonly property real navBtnRadius: 4 * scaleFactor
+ readonly property real quitBtnRadius: 4 * scaleFactor
+
+ // 内容区内边距 (HTML: padding 76px 48px 40px, 其中 76px 含标题栏 44px,
+ // startup tab 不含标题栏故上边距用 32px)
+ readonly property real contentPadTop: 32 * scaleFactor
+ readonly property real contentPadH: 48 * scaleFactor
+ readonly property real contentPadBottom: 40 * scaleFactor
+
+ // StyleCard
+ readonly property real cardW: 160 * scaleFactor
+ readonly property real cardH: 228 * scaleFactor
+ readonly property real cardRadius: 8 * scaleFactor
+ readonly property real cardIconSize: 50 * scaleFactor
+ readonly property real cardFrameW: 152 * scaleFactor
+ readonly property real cardFrameH: 219 * scaleFactor
+ readonly property real cardFramePad: 4 * scaleFactor
+ readonly property real cardTitleH: 29 * scaleFactor
+ readonly property real cardFrameRadius: 6 * scaleFactor
+
+ // TemplateCard (HTML: 176×243 缩略图区 + 名称 + 信息)
+ readonly property real tplCardW: 176 * scaleFactor
+ readonly property real tplCardH: 243 * scaleFactor
+ readonly property real tplThumbW: 160 * scaleFactor
+ readonly property real tplThumbH: 227 * scaleFactor
+ readonly property real tplThumbRadius: 2 * scaleFactor
+
+ // 最近文档 (HTML: item min-height 40px, radius 4px, margin 2px 4px, padding 6px 14px)
+ readonly property real recentItemH: 40 * scaleFactor
+ readonly property real recentItemPadV: 6 * scaleFactor
+ readonly property real recentItemPadH: 14 * scaleFactor
+ readonly property real recentItemMarginV: 2 * scaleFactor
+ readonly property real recentItemMarginH: 4 * scaleFactor
+ readonly property real recentRadius: 8 * scaleFactor
+ readonly property real recentItemRadius: 4 * scaleFactor
+
+ // ---- 字号 ----
+ readonly property real fontSectionTitle: 16 * scaleFactor
+ readonly property real fontNavTitle: 13 * scaleFactor
+ readonly property real fontNavBtn: 15 * scaleFactor
+ readonly property real fontCardName: 14 * scaleFactor
+ readonly property real fontCardTitle: 10 * scaleFactor
+ readonly property real fontRecentName: 15 * scaleFactor
+ readonly property real fontRecentTime: 11 * scaleFactor
+ readonly property real fontRecentEmpty: 13 * scaleFactor
+ readonly property real fontTemplateName: 11 * scaleFactor
+ readonly property real fontTemplateInfo: 10 * scaleFactor
+ readonly property real fontTemplateLoading: 14 * scaleFactor
+
+ // ---- 间距 ----
+ readonly property real gapCards: 16 * scaleFactor
+ readonly property real sectionGap: 20 * scaleFactor
+}
diff --git a/src/Plugins/Qt/qml/startup/atoms/StyleCard.qml b/src/Plugins/Qt/qml/startup/atoms/StyleCard.qml
new file mode 100644
index 0000000000..cbfd9c10b3
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/atoms/StyleCard.qml
@@ -0,0 +1,136 @@
+// StyleCard.qml — 启动页文档样式卡片原子(图标模式 / 缩略图模式)。
+// 对应 C++ StyleCard::setupIconMode / setupThumbnailMode。
+//
+// API:
+// kind : string — "icon"(新建/打开)或 "thumbnail"(推荐模板)。
+// iconSrc : string — 图标模式下 SVG 路径(qrc 或内联 data URI),仅 kind=="icon"。
+// cardName : string — 底部名称文案(仅图标模式)。
+// titleText : string — 缩略图内标题栏文案,仅 kind=="thumbnail"。
+// thumbSrc : string — 缩略图图片 URL,仅 kind=="thumbnail"。
+// clicked() — 点击信号。
+
+import QtQuick
+
+Rectangle {
+ id: card
+ width: StartupTheme.cardW
+ height: StartupTheme.cardH
+ radius: StartupTheme.cardRadius
+ color: StartupTheme.cardBg
+ border.width: 1
+ border.color: mouseArea.containsMouse ? StartupTheme.cardHoverBorder : StartupTheme.cardBorder
+
+ property string kind: "icon" // "icon" | "thumbnail"
+ property string iconSrc: "" // 图标模式 SVG 路径
+ property string cardName: "" // 图标模式底部名称
+ property string titleText: "" // 缩略图模式标题栏文案
+ property string thumbSrc: "" // 缩略图模式图片 URL
+
+ signal clicked()
+
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: card.clicked()
+ }
+
+ // ---- 图标模式 (HTML: .style-card .card-icon + .card-name) ----
+ Loader {
+ anchors.fill: parent
+ active: card.kind === "icon"
+ sourceComponent: Item {
+ anchors.fill: parent
+
+ // 图标区域 (HTML: flex 1, centered, padding-top 24px)
+ Image {
+ id: iconImg
+ source: card.iconSrc
+ width: StartupTheme.cardIconSize
+ height: StartupTheme.cardIconSize
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: -12
+ fillMode: Image.PreserveAspectFit
+ }
+
+ // 底部名称 (HTML: padding 0 12px 24px, font 14px, color #2b3b45)
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 24 * StartupTheme.scaleFactor
+ text: card.cardName
+ color: StartupTheme.cardNameFg
+ font.pixelSize: StartupTheme.fontCardName
+ }
+ }
+ }
+
+ // ---- 缩略图模式 (HTML: .style-card.thumbnail .card-frame) ----
+ Loader {
+ anchors.fill: parent
+ active: card.kind === "thumbnail"
+ sourceComponent: Item {
+ anchors.fill: parent
+
+ // 内框 QFrame#style-card-frame: 152×219, margin 4, radius 6
+ Rectangle {
+ id: frame
+ x: StartupTheme.cardFramePad
+ y: StartupTheme.cardFramePad
+ width: StartupTheme.cardFrameW
+ height: StartupTheme.cardFrameH
+ radius: StartupTheme.cardFrameRadius
+ color: StartupTheme.cardBg
+ border.width: 1
+ border.color: mouseArea.containsMouse ? StartupTheme.cardHoverBorder : StartupTheme.cardBorder
+
+ // 缩略图区域 (HTML: .thumb-img, bg #f5f5f5)
+ Rectangle {
+ id: thumbArea
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: parent.height - StartupTheme.cardTitleH
+ color: StartupTheme.thumbnailBg
+
+ Image {
+ anchors.fill: parent
+ source: card.thumbSrc
+ fillMode: Image.PreserveAspectFit
+ visible: card.thumbSrc !== ""
+ cache: true
+ }
+ Text {
+ anchors.centerIn: parent
+ text: StartupTheme.tr("Thumbnail")
+ color: "#aaa"
+ font.pixelSize: 12 * StartupTheme.scaleFactor
+ visible: card.thumbSrc === ""
+ }
+ }
+
+ // 标题栏 (HTML: .thumb-title, height 29px, border-top 1px #f0f0f0)
+ Rectangle {
+ anchors { top: thumbArea.bottom; left: parent.left; right: parent.right }
+ height: StartupTheme.cardTitleH
+ color: StartupTheme.cardTitleBg
+
+ // HTML: border-top: 1px solid #f0f0f0
+ Rectangle {
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ height: 1
+ color: StartupTheme.cardTitleBorderTop
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: card.titleText
+ color: StartupTheme.cardTitleFg
+ font.pixelSize: StartupTheme.fontCardTitle
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Plugins/Qt/qml/startup/atoms/TemplateCard.qml b/src/Plugins/Qt/qml/startup/atoms/TemplateCard.qml
new file mode 100644
index 0000000000..87a150d5ee
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/atoms/TemplateCard.qml
@@ -0,0 +1,107 @@
+// TemplateCard.qml — 模板分类页卡片原子。
+// 对应 C++ QTMTemplatePage::createTemplateCard。
+// 176×243 缩略图区 + 名称 + 作者·版本信息。
+//
+// API:
+// templateId : string — 模板 ID(clicked 信号携带)。
+// name : string — 模板名称(11px, 最多两行)。
+// author : string — 作者名。
+// version : string — 版本号。
+// thumbnailUrl : string — 缩略图 URL。
+// clicked(string templateId) — 点击信号。
+
+import QtQuick
+
+Rectangle {
+ id: root
+ width: cardFrame.width
+ height: cardFrame.height + nameLabel.height + infoLabel.height + 8 * StartupTheme.scaleFactor
+ color: "transparent"
+
+ property string templateId: ""
+ property string name: ""
+ property string author: ""
+ property string version: ""
+ property string thumbnailUrl: ""
+
+ signal clicked(string templateId)
+
+ // 缩略图卡片 QFrame#startup-tab-template-card: 176×243
+ Rectangle {
+ id: cardFrame
+ width: StartupTheme.tplCardW
+ height: StartupTheme.tplCardH
+ radius: StartupTheme.cardRadius
+ color: StartupTheme.templateCardBg
+ border.width: 1
+ border.color: rootMouse.containsMouse ? StartupTheme.cardHoverBorder : StartupTheme.templateCardBorder
+
+ Behavior on border.color { ColorAnimation { duration: 150 } }
+
+ // 缩略图内框 (HTML: .tpl-thumb-inner, 160×227, bg #f5f5f5, radius 2px)
+ Rectangle {
+ id: thumbInner
+ anchors.centerIn: parent
+ width: StartupTheme.tplThumbW
+ height: StartupTheme.tplThumbH
+ radius: StartupTheme.tplThumbRadius
+ color: StartupTheme.thumbnailBg
+
+ Image {
+ anchors.fill: parent
+ source: root.thumbnailUrl
+ fillMode: Image.PreserveAspectFit
+ visible: root.thumbnailUrl !== ""
+ cache: true
+ }
+ Text {
+ anchors.centerIn: parent
+ text: root.name
+ color: "#aaa"
+ font.pixelSize: 12 * StartupTheme.scaleFactor
+ visible: root.thumbnailUrl === ""
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+
+ // 模板名称 (HTML: .tpl-name, width 176px, margin-top 5px, font 11px, color #333)
+ Text {
+ id: nameLabel
+ anchors { top: cardFrame.bottom; topMargin: 5 * StartupTheme.scaleFactor; horizontalCenter: parent.horizontalCenter }
+ width: StartupTheme.tplCardW
+ text: root.name
+ color: StartupTheme.templateNameFg
+ font.pixelSize: StartupTheme.fontTemplateName
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ }
+
+ // 作者·版本 (HTML: .tpl-info, margin-top 1px, font 10px, color #888)
+ Text {
+ id: infoLabel
+ anchors { top: nameLabel.bottom; topMargin: 1 * StartupTheme.scaleFactor; horizontalCenter: parent.horizontalCenter }
+ text: {
+ var parts = []
+ if (root.author) parts.push(root.author)
+ if (root.version) parts.push("v" + root.version)
+ return parts.length > 0 ? parts.join(" · ") : ""
+ }
+ color: StartupTheme.templateInfoFg
+ font.pixelSize: StartupTheme.fontTemplateInfo
+ visible: text !== ""
+ }
+
+ // 点击区域覆盖整个组件(包括名称和版本信息)
+ MouseArea {
+ id: rootMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.clicked(root.templateId)
+ }
+}
diff --git a/src/Plugins/Qt/qml/startup/atoms/qmldir b/src/Plugins/Qt/qml/startup/atoms/qmldir
new file mode 100644
index 0000000000..667a2feec8
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/atoms/qmldir
@@ -0,0 +1,3 @@
+singleton StartupTheme 1.0 StartupTheme.qml
+StyleCard 1.0 StyleCard.qml
+TemplateCard 1.0 TemplateCard.qml
diff --git a/src/Plugins/Qt/qml/startup/qmldir b/src/Plugins/Qt/qml/startup/qmldir
new file mode 100644
index 0000000000..5ef8961365
--- /dev/null
+++ b/src/Plugins/Qt/qml/startup/qmldir
@@ -0,0 +1,3 @@
+StartupTab 1.0 StartupTab.qml
+StartupHomePage 1.0 StartupHomePage.qml
+StartupTemplatePage 1.0 StartupTemplatePage.qml
diff --git a/tests/Mogan/Startup/startup_bridge_test.cpp b/tests/Mogan/Startup/startup_bridge_test.cpp
new file mode 100644
index 0000000000..b52b7c6372
--- /dev/null
+++ b/tests/Mogan/Startup/startup_bridge_test.cpp
@@ -0,0 +1,197 @@
+
+/******************************************************************************
+ * MODULE : startup_bridge_test.cpp
+ * DESCRIPTION: Unit tests for StartupBridge data logic
+ * COPYRIGHT : (C) 2026 Yuki Lu
+ ******************************************************************************/
+
+#include "StartupBridge.hpp"
+#include "base.hpp"
+#include "template_manager.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class TestStartupBridge : public QObject {
+ Q_OBJECT
+
+private:
+ QTemporaryDir tempHome_;
+
+private slots:
+ void initTestCase () {
+ QStandardPaths::setTestModeEnabled (true);
+ init_lolly ();
+
+ // 将 TEXMACS_HOME_PATH 指向临时目录,隔离 recent-files.json 读写
+ QVERIFY (tempHome_.isValid ());
+ qputenv ("TEXMACS_HOME_PATH", tempHome_.path ().toUtf8 ());
+
+ // 抑制 TemplateManager 初始化时因 Scheme 解释器未就绪产生的预期警告
+ QtMessageHandler oldHandler= qInstallMessageHandler (
+ [] (QtMsgType, const QMessageLogContext&, const QString&) {});
+ TemplateManager::instance ()->setOffline (true);
+ TemplateManager::instance ()->initialize ();
+ qInstallMessageHandler (oldHandler);
+ }
+
+ void init () {
+ // 每次测试前清理 recent-files.json
+ QDir systemDir (tempHome_.path () + "/system");
+ if (systemDir.exists ()) systemDir.removeRecursively ();
+ }
+
+ // ---- 构造与初始化 ----
+
+ void test_construct_and_initialize () {
+ StartupBridge bridge;
+ QVERIFY (bridge.recentDocs ().isEmpty ());
+ QVERIFY (bridge.styleCards ().isEmpty ());
+ QVERIFY (bridge.categories ().isEmpty ());
+ QVERIFY (bridge.activeCategoryId ().isEmpty ());
+ QVERIFY (bridge.activeCategoryName ().isEmpty ());
+ QVERIFY (bridge.categoryTemplates ().isEmpty ());
+ QCOMPARE (bridge.categoryLoading (), false);
+ }
+
+ // ---- Recent docs 管理 ----
+
+ void test_add_recent_doc () {
+ StartupBridge bridge;
+ bridge.addRecentDoc ("/tmp/test-doc-1.tmu");
+ QCOMPARE (bridge.recentDocs ().size (), 1);
+
+ auto m= bridge.recentDocs ().first ().toMap ();
+ QCOMPARE (m["filePath"].toString (), QString ("/tmp/test-doc-1.tmu"));
+ QCOMPARE (m["fileName"].toString (), QString ("test-doc-1.tmu"));
+ QVERIFY (!m["openedAt"].toString ().isEmpty ());
+ QVERIFY (m.contains ("timestamp"));
+ }
+
+ void test_add_recent_doc_dedup () {
+ StartupBridge bridge;
+ bridge.addRecentDoc ("/tmp/doc-a.tmu");
+ bridge.addRecentDoc ("/tmp/doc-b.tmu");
+ bridge.addRecentDoc ("/tmp/doc-a.tmu"); // 重复:应移到最前
+ QCOMPARE (bridge.recentDocs ().size (), 2);
+ QCOMPARE (bridge.recentDocs ().first ().toMap ()["filePath"].toString (),
+ QString ("/tmp/doc-a.tmu"));
+ }
+
+ void test_remove_recent_doc () {
+ StartupBridge bridge;
+ bridge.addRecentDoc ("/tmp/doc-x.tmu");
+ bridge.addRecentDoc ("/tmp/doc-y.tmu");
+ bridge.removeRecentDoc ("/tmp/doc-x.tmu");
+ QCOMPARE (bridge.recentDocs ().size (), 1);
+ QCOMPARE (bridge.recentDocs ().first ().toMap ()["filePath"].toString (),
+ QString ("/tmp/doc-y.tmu"));
+ }
+
+ void test_clear_all_recent_docs () {
+ StartupBridge bridge;
+ bridge.addRecentDoc ("/tmp/a.tmu");
+ bridge.addRecentDoc ("/tmp/b.tmu");
+ bridge.clearAllRecentDocs ();
+ QVERIFY (bridge.recentDocs ().isEmpty ());
+ }
+
+ // ---- 最近文档 JSON 持久化 ----
+
+ void test_recent_docs_persistence () {
+ // 写入:addRecentDoc 同步调用 saveRecentDocs() 落盘 recent-files.json
+ // 注意:不能调用 QCoreApplication::processEvents()——addRecentDoc 还会通过
+ // QTimer::singleShot(0, ...) 延迟调用 eval_scheme,而本测试未初始化 s7
+ // 解释器 (tm_s7 为空),触发会 SIGSEGV。saveRecentDocs
+ // 是同步的,无需事件循环。
+ {
+ StartupBridge bridge1;
+ bridge1.addRecentDoc ("/tmp/persist-test.tmu");
+ }
+
+ // 验证 JSON 文件内容
+ QString jsonPath=
+ QDir (tempHome_.path () + "/system").filePath ("recent-files.json");
+ QVERIFY (QFile::exists (jsonPath));
+
+ QFile file (jsonPath);
+ QVERIFY (file.open (QIODevice::ReadOnly));
+ QJsonDocument doc= QJsonDocument::fromJson (file.readAll ());
+ file.close ();
+
+ QVERIFY (doc.isObject ());
+ QJsonArray files= doc.object ()["files"].toArray ();
+ QCOMPARE (files.size (), 1);
+ QCOMPARE (files[0].toObject ()["path"].toString (),
+ QString ("/tmp/persist-test.tmu"));
+ QVERIFY (files[0].toObject ()["last_open"].toDouble () > 0);
+ }
+
+ // ---- 分类操作 ----
+
+ void test_select_category_idempotent () {
+ StartupBridge bridge;
+
+ QSignalSpy spy (&bridge, &StartupBridge::activeCategoryChanged);
+
+ // 首次选择(未初始化 templateManager_,走同步路径:直接刷新,
+ // 不进入 loading 状态,不 emit categoryLoadingChanged)
+ bridge.selectCategory ("cat-a");
+ QCOMPARE (bridge.activeCategoryId (), QString ("cat-a"));
+ QCOMPARE (bridge.categoryLoading (), false);
+ QCOMPARE (spy.count (), 1);
+
+ // 重复选择同一分类不应 emit 信号
+ bridge.selectCategory ("cat-a");
+ QCOMPARE (spy.count (), 1);
+
+ // 切换分类应再次 emit
+ bridge.selectCategory ("cat-b");
+ QCOMPARE (bridge.activeCategoryId (), QString ("cat-b"));
+ QCOMPARE (spy.count (), 2);
+ }
+
+ void test_select_category_no_loading_when_uninitialized () {
+ StartupBridge bridge;
+
+ QSignalSpy loadingSpy (&bridge, &StartupBridge::categoryLoadingChanged);
+
+ // 未初始化时 selectCategory 走同步路径,不应 emit categoryLoadingChanged
+ bridge.selectCategory ("cat-a");
+ QCOMPARE (bridge.categoryLoading (), false);
+ QCOMPARE (loadingSpy.count (), 0);
+
+ bridge.selectCategory ("cat-b");
+ QCOMPARE (bridge.categoryLoading (), false);
+ QCOMPARE (loadingSpy.count (), 0);
+ }
+
+ // ---- 属性访问 ----
+
+ void test_style_cards_initially_empty () {
+ StartupBridge bridge;
+ QVERIFY (bridge.styleCards ().isEmpty ());
+ }
+
+ void test_category_templates_initially_empty () {
+ StartupBridge bridge;
+ QVERIFY (bridge.categoryTemplates ().isEmpty ());
+ }
+
+ // ---- tr 工具方法 ----
+
+ void test_tr_returns_non_empty_for_valid_key () {
+ StartupBridge bridge;
+ QString result= bridge.tr ("File");
+ QVERIFY (!result.isEmpty ());
+ }
+};
+
+QTEST_MAIN (TestStartupBridge)
+#include "startup_bridge_test.moc"
diff --git a/tests/Mogan/Startup/startup_tab_widget_test.cpp b/tests/Mogan/Startup/startup_tab_widget_test.cpp
deleted file mode 100644
index c9e0b28b69..0000000000
--- a/tests/Mogan/Startup/startup_tab_widget_test.cpp
+++ /dev/null
@@ -1,126 +0,0 @@
-
-/******************************************************************************
- * MODULE : startup_tab_widget_test.cpp
- * DESCRIPTION: Integration tests for QTMTemplatePage signal chain
- * COPYRIGHT : (C) 2026 Yuki Lu
- ******************************************************************************/
-
-#include "QTMTemplatePage.hpp"
-#include "base.hpp"
-#include "template_manager.hpp"
-#include
-#include
-#include
-#include
-#include
-
-class TestStartupTabWidget : public QObject {
- Q_OBJECT
-
-private slots:
- void initTestCase () {
- QStandardPaths::setTestModeEnabled (true);
- init_lolly ();
-
- // 预初始化 TemplateManager singleton,抑制 Scheme 解释器未就绪的预期警告
- QtMessageHandler oldHandler= qInstallMessageHandler (
- [] (QtMsgType, const QMessageLogContext&, const QString&) {});
- // 进入离线模式:initialize() 不会发起真实网络请求,避免
- // QNetworkAccessManager 后台线程在进程退出阶段与全局对象析构竞争
- TemplateManager::instance ()->setOffline (true);
- TemplateManager::instance ()->initialize ();
- qInstallMessageHandler (oldHandler);
- }
-
- // --- 构造与初始化 ---
-
- // QTMTemplatePage 应能正常构造和初始化
- void test_template_page_construct_and_initialize () {
- QTMTemplatePage page;
- // setupUI 在构造函数中调用,grid widget 应立即存在
- QWidget* grid= page.findChild ("startup-tab-grid");
- QVERIFY (grid != nullptr);
-
- page.initialize ();
- // initialize 连接信号后不应改变基本结构
- QVERIFY (page.findChild ("startup-tab-grid") != nullptr);
- }
-
- // --- 信号响应 ---
-
- // 手动发射 TemplateManager::templatesLoaded 后,网格应被刷新
- void test_templates_loaded_signal_refreshes_grid () {
- QTMTemplatePage page;
- page.initialize ();
-
- TemplateManager* mgr= TemplateManager::instance ();
- QVERIFY (mgr != nullptr);
- QVERIFY (mgr->isInitialized ());
-
- // 手动发射信号,触发 onTemplatesLoaded
- emit mgr->templatesLoaded ();
-
- // 处理事件以便槽函数执行
- QCoreApplication::processEvents ();
-
- // 由于无模板数据,网格应显示 "No templates available."
- QWidget* grid= page.findChild ("startup-tab-grid");
- QVERIFY (grid != nullptr);
-
- QList labels = grid->findChildren ();
- bool foundNoTemplates= false;
- for (QLabel* label : labels) {
- if (label->text ().contains ("No templates available")) {
- foundNoTemplates= true;
- break;
- }
- }
- QVERIFY2 (foundNoTemplates,
- "Grid should display 'No templates available.' after "
- "templatesLoaded signal with empty template list");
- }
-
- // --- 分类操作 ---
-
- // setCategory 不应导致崩溃
- void test_set_category_does_not_crash () {
- QTMTemplatePage page;
- page.initialize ();
-
- page.setCategory ("test-category");
- QCoreApplication::processEvents ();
-
- QVERIFY (page.currentCategory () == "test-category");
- }
-
- void test_refresh_grid_does_not_crash () {
- QTMTemplatePage page;
- page.initialize ();
- page.setCategory ("cat1");
- page.refreshGrid ();
- QCoreApplication::processEvents ();
- QVERIFY (true);
- }
-
- void test_set_category_with_display_name () {
- QTMTemplatePage page;
- page.initialize ();
- page.setCategory ("thesis", "Thesis");
- QCoreApplication::processEvents ();
-
- QVERIFY (page.currentCategory () == "thesis");
- }
-
- // --- 事件处理 ---
-
- void test_resize_event_does_not_crash () {
- QTMTemplatePage page;
- page.initialize ();
- page.resize (800, 600);
- page.resize (400, 300);
- QVERIFY (true);
- }
-};
-
-QTEST_MAIN (TestStartupTabWidget)
-#include "startup_tab_widget_test.moc"