diff --git a/TeXmacs/progs/doc/docgrep.scm b/TeXmacs/progs/doc/docgrep.scm index 6c568b96ce..076e3186b8 100644 --- a/TeXmacs/progs/doc/docgrep.scm +++ b/TeXmacs/progs/doc/docgrep.scm @@ -247,3 +247,15 @@ (load-document (string-append "tmfs://grep/" query)) ) ;with ) ;tm-define + +(tm-define (recent-documents-for-qml) (map url->system (recent-file-list 25))) + +(tm-define (open-search-recent-documents) + (:interactive #t) + (with path + (cpp-search-recent-documents-dialog) + (when (!= path "") + (load-document (system->url path)) + ) ;when + ) ;with +) ;tm-define diff --git a/TeXmacs/progs/texmacs/menus/edit-menu.scm b/TeXmacs/progs/texmacs/menus/edit-menu.scm index dd7314b5da..1490ba42fe 100644 --- a/TeXmacs/progs/texmacs/menus/edit-menu.scm +++ b/TeXmacs/progs/texmacs/menus/edit-menu.scm @@ -99,7 +99,7 @@ ) ;-> ) ;if --- - ("Search recent documents" (interactive docgrep-in-recent)) + ("Search recent documents" (open-search-recent-documents)) --- (if (use-menus?) (-> "Preferences" (link preferences-menu))) (if (use-popups?) ("Preferences" (open-preferences))) diff --git a/ai-docs/qml/qml-dialog.html b/ai-docs/qml/qml-dialog.html index 23e93674d5..d62c619022 100644 --- a/ai-docs/qml/qml-dialog.html +++ b/ai-docs/qml/qml-dialog.html @@ -299,6 +299,76 @@ box-shadow: var(--shadow); } + .recent-search-shell { + width: min(620px, 100%); + background: #ffffff; + border: 1px solid #d6dfdf; + border-radius: var(--radius); + padding: 22px; + box-shadow: var(--shadow); + } + + .recent-search-shell h3 { + text-align: center; + font-size: 18px; + margin-bottom: 18px; + } + + .recent-search-input { + width: 100%; + height: 38px; + border: 1px solid #9fcac7; + border-radius: 10px; + padding: 0 12px; + background: #f9fcfc; + color: #25353a; + font: inherit; + } + + .recent-results { + list-style: none; + margin-top: 14px; + border: 1px solid #cad7d7; + border-radius: 10px; + overflow: hidden; + } + + .recent-result { + padding: 10px 12px; + border-bottom: 1px solid #e4eeee; + cursor: pointer; + } + + .recent-result:last-child { + border-bottom: 0; + } + + .recent-result.active { + background: #dff3f1; + color: #194f53; + } + + .recent-result strong, + .recent-result span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .recent-result span { + margin-top: 3px; + color: #60787e; + font-size: 12px; + } + + .recent-empty { + display: none; + padding: 18px; + color: var(--muted); + text-align: center; + } + .field { display: grid; grid-template-columns: 120px 1fr; @@ -994,6 +1064,7 @@

QML 组件模拟器

+ @@ -1596,6 +1667,36 @@

切换界面语言

+ @@ -1611,7 +1712,8 @@

切换界面语言

statistics: document.getElementById('panel-statistics'), version: document.getElementById('panel-version'), preferences: document.getElementById('panel-preferences'), - confirmRestart: document.getElementById('panel-confirm-restart') + confirmRestart: document.getElementById('panel-confirm-restart'), + recentSearch: document.getElementById('panel-recent-search') }; const titleMap = { @@ -1622,7 +1724,8 @@

切换界面语言

paragraph: 'ParagraphFormat.qml 模拟', statistics: 'Statistics.qml 模拟', preferences: 'Preferences.qml 模拟', - confirmRestart: 'ConfirmRestart.qml 模拟' + confirmRestart: 'ConfirmRestart.qml 模拟', + recentSearch: 'SearchRecentDocuments.qml 模拟' }; const codeMap = { @@ -1633,7 +1736,8 @@

切换界面语言

paragraph: 'EnumCombo x N + tabs(基础/高级) + make-multi-line-with 写回', statistics: 'statsModel: [{label, value}, ...] + closeBridge.choose(0) 关闭', preferences: 'prefTabs(5) + subTabs(6) + EnumCombo + Toggle + 纯 OK 提交', - confirmRestart: 'message + buttonLabels + closeBridge.choose(index)' + confirmRestart: 'message + buttonLabels + closeBridge.choose(index)', + recentSearch: 'recentDocuments + fuzzyScore() + recentSearchBridge.open(path)' }; const viewTitle = document.getElementById('view-title'); @@ -1668,6 +1772,46 @@

切换界面语言

}); }); + const recentSearchInput = document.getElementById('recent-search-input'); + const recentResults = document.querySelectorAll('.recent-result'); + const recentEmpty = document.getElementById('recent-empty'); + let recentSelected = null; + + function fuzzyMatch(text, query) { + let cursor = 0; + const haystack = text.toLowerCase(); + for (const character of query.toLowerCase()) { + cursor = haystack.indexOf(character, cursor); + if (cursor < 0) return false; + cursor += 1; + } + return true; + } + + recentSearchInput.addEventListener('input', () => { + const query = recentSearchInput.value.trim(); + let visibleCount = 0; + recentResults.forEach((row) => { + const matched = !query || fuzzyMatch(row.dataset.name, query) || fuzzyMatch(row.dataset.path, query); + row.style.display = matched ? '' : 'none'; + if (matched) visibleCount += 1; + }); + recentEmpty.style.display = visibleCount ? 'none' : 'block'; + recentSelected = null; + recentResults.forEach((row) => row.classList.remove('active')); + }); + + recentResults.forEach((row) => { + row.addEventListener('click', () => { + recentSelected = row; + recentResults.forEach((item) => item.classList.toggle('active', item === row)); + }); + }); + + document.getElementById('recent-open').addEventListener('click', () => { + if (recentSelected) log('SearchRecentDocuments: open("' + recentSelected.dataset.path + '")'); + }); + document.getElementById('form-submit').addEventListener('click', () => { const values = { language: document.getElementById('field-language').value, diff --git a/devel/2085.md b/devel/2085.md new file mode 100644 index 0000000000..60a9465607 --- /dev/null +++ b/devel/2085.md @@ -0,0 +1,79 @@ +# [2085] 编辑菜单中的最近文档搜索 + +## 相关文档 +- [Issue #4143](https://github.com/MoganLab/mogan/issues/4143) - 采用 QML 重构并修复最近文档搜索。 + +## 2026/08/04 对齐 QML bridge 约定 + +### What +- 最近文档 bridge 负责读取 Scheme 数据、Cork 到 UTF-8 转码、规范化去重和已翻译文案。 +- QML 仅维护模糊筛选与显式选择状态,支持上下键选择和 Enter 打开。 +- 按钮文案改由通用 `dialogButtons` context property 注入。 + +### Why +此前 bridge 只承载已在对话框入口解析的数据,导致编码与去重规则分散;设计稿仍默认选中首项,也与实际交互不一致。 + +### How +- Windows 下对规范化路径做大小写折叠后去重,保留最近记录中的首次出现。 +- 筛选后始终清空选择,只有点击或键盘移动后才可打开候选项。 +- 设计稿、QML 加载测试和开发文档同步验证 bridge/context 契约。 + +## 任务相关的代码文件 +- `TeXmacs/progs/doc/docgrep.scm` +- `TeXmacs/progs/texmacs/menus/edit-menu.scm` +- `src/Plugins/Qt/QTMQmlDialog.cpp` +- `src/Plugins/Qt/RecentDocumentsSearchBridge.hpp` +- `src/Plugins/Qt/RecentDocumentsSearchBridge.cpp` +- `src/Plugins/Qt/qml/SearchRecentDocuments.qml` +- `tests/Plugins/Qt/qml_dialog_test.cpp` +- `tests/Plugins/Qt/qml_load_test.cpp` +- `ai-docs/qml/qml-dialog.html` + +## 如何测试 +```powershell +xmake build stem +xmake install stem +& '.\build\packages\stem\data\bin\MoganSTEM.exe' +``` + +在“编辑 -> 搜索最近打开的文档”中输入文件名的一部分,确认结果按模糊匹配显示。选中条目并点击“打开”,确认文件被加载。 + +## 2026/08/04 Bridge 收敛与路径编码 + +### What +- 删除冗余的 `RecentDocumentsSearchDialog.cpp`,将对话框 glue 入口收回 + `QTMQmlDialog.cpp`。 +- 将最近文档 bridge 收敛为独立 `QObject`:它提供只读模型并完成确认回流;通用 + `QmlDialogBridge` 只处理取消、Esc 与窗口拖动。 +- 最近文档读取和返回均使用 UTF-8 系统路径,保留中文文件名。 + +### Why +专属业务动作不应继承通用 form bridge;同时 `system->url` 只接受 UTF-8 系统路径, +将路径转换为 Cork 会导致中文路径在回传后无法打开。 + +### How +- `url->system` 的 Scheme 返回值经 `QString::fromUtf8` 进入 QML;QML 确认后通过 + `from_qstring_utf8` 回传给 `system->url`。 +- QML 继续使用 `DialogShell`、`Theme` 和独立的 `recentSearchBridge`,不在筛选中 + 修改最近记录。 + +## 2026/08/03 采用 QML 重构最近文档搜索 + +### What +- 用 QML 对话框替换旧的交互式全文 grep 输入。 +- 显示最近文档的文件名和路径,并支持实时模糊筛选与显式选择。 +- 对最近文件路径去重;初始和筛选后均不默认选中,避免重复项同时高亮或误打开。 +- 补充 QML 加载与桥接测试。 +- 在 `ai-docs/qml/qml-dialog.html` 中补充该对话框的交互设计稿。 + +### Why +旧交互式输入在中文输入法确认候选字时会提前执行,且其全文搜索语义不符合按最近文件选择文档的预期。 + +### How +- Scheme 侧提供最近文件列表,并在确认选择后加载对应路径。 +- C++ 将最近文件模型注入 QML,保持 UTF-8 路径编码。 +- C++ 以规范化路径去重,保留最近记录中的第一次出现。 +- QML 按文件名优先、完整路径兜底执行子序列模糊匹配;只有显式选中并点击“打开”才加载选定文件。 +- 最近文档搜索的路径和模型逻辑独立于 QML 通用对话框底座,复用其内部模态引擎和 context 注入接口。 +- 专属 bridge 向 QML 暴露候选模型和已翻译文案,并在确认时回传所选路径;通用 + `QmlDialogBridge` 仅负责取消、Esc 和窗口关闭。 diff --git a/src/Plugins/Qt/QTMQmlDialog.cpp b/src/Plugins/Qt/QTMQmlDialog.cpp index fb9a195666..fcd777b583 100644 --- a/src/Plugins/Qt/QTMQmlDialog.cpp +++ b/src/Plugins/Qt/QTMQmlDialog.cpp @@ -13,6 +13,7 @@ #include "PreferencesBridge.hpp" #include "QTMQmlDialogBridge.hpp" #include "QTMQmlDialogInternal.hpp" +#include "RecentDocumentsSearchBridge.hpp" #include "VersionDialogBridge.hpp" #include "analyze.hpp" // occurs @@ -156,7 +157,7 @@ lock_fixed_size (QQuickWidget* qw, QVBoxLayout* vl, QDialog& d, int logic_w, * QML 加载失败返回 -1。调用方据此映射结果(确认型 → 按钮下标;form 型 * → Accepted/Rejected,表单值另行从 bridge->results() 取)。 */ -static int +int run_qml_dialog (const string& qml_url, const char* debug_tag, std::function inject_context, int logic_w, int logic_h) { @@ -580,8 +581,36 @@ cpp_version_dialog (string title, string message) { return choice == 1; } +string +cpp_search_recent_documents_dialog () { + string preset= get_env ("MOGAN_TEST_SEARCH_RECENT_DOCUMENTS"); + if (preset != "") return preset == "cancel" ? string ("") : preset; + + array buttons= {string ("Open"), string ("Cancel")}; + QmlDialogBridge* closeBridge = nullptr; + RecentDocumentsSearchBridge* searchBridge= nullptr; + run_qml_dialog ( + "qrc:/qml/SearchRecentDocuments.qml", "SearchRecentDocuments.qml", + [&] (QQuickWidget* qw, QDialog& host) { + closeBridge = inject_common_context (qw, host); + searchBridge= new RecentDocumentsSearchBridge (&host); + qw->rootContext ()->setContextProperty ("recentSearchBridge", + searchBridge); + qw->rootContext ()->setContextProperty ("dialogButtons", + translate_buttons (buttons)); + }, + 520, 450); + + QString path; + if (searchBridge) path= searchBridge->selectedPath (); + delete closeBridge; + delete searchBridge; + return from_qstring_utf8 (path); +} + /** - * @brief 首选项 QML 对话框的 glue 入口(声明/语义见 QTMQmlDialog.hpp)。 + * @brief 首选项 QML 对话框的 glue 入口(声明/语义见 + * QTMQmlDialog.hpp)。 * * @details 走 run_qml_dialog(exec 阻塞模态,同 * FormDialog——首选项是一次性提交, 无需 live diff --git a/src/Plugins/Qt/QTMQmlDialog.hpp b/src/Plugins/Qt/QTMQmlDialog.hpp index 24bf9273ab..f0d4ced372 100644 --- a/src/Plugins/Qt/QTMQmlDialog.hpp +++ b/src/Plugins/Qt/QTMQmlDialog.hpp @@ -214,4 +214,9 @@ bool cpp_version_dialog (string title, string message); */ tree cpp_preferences_dialog (); +/** + * @brief 打开最近文档搜索 QML 对话框。 + */ +string cpp_search_recent_documents_dialog (); + #endif // defined QTM_QML_DIALOG_H diff --git a/src/Plugins/Qt/QTMQmlDialogInternal.hpp b/src/Plugins/Qt/QTMQmlDialogInternal.hpp index 4c4fb5b16d..5cfaec75e9 100644 --- a/src/Plugins/Qt/QTMQmlDialogInternal.hpp +++ b/src/Plugins/Qt/QTMQmlDialogInternal.hpp @@ -10,10 +10,17 @@ #ifndef QTM_QML_DIALOG_INTERNAL_H #define QTM_QML_DIALOG_INTERNAL_H +#include "array.hpp" +#include "string.hpp" #include "tree.hpp" #include #include +#include + +class QDialog; +class QQuickWidget; +class QmlDialogBridge; // 字段节点下标协议(见 QTMQmlDialog.hpp @par 数据协议): // (