From d52731b38891cbf43c3970be2aa228aa56c6e985 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 19:38:03 +0800 Subject: [PATCH 1/4] =?UTF-8?q?[0153]=20=E5=88=86=E5=B1=8F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9A=E5=85=B1=E4=BA=AB=E8=8F=9C=E5=8D=95=E6=A0=8F?= =?UTF-8?q?/=E5=B7=A5=E5=85=B7=E6=A0=8F=E7=9A=84=E5=A4=9A=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=97=B6=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- devel/0153.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 devel/0153.md diff --git a/devel/0153.md b/devel/0153.md new file mode 100644 index 0000000000..70238a5464 --- /dev/null +++ b/devel/0153.md @@ -0,0 +1,174 @@ +# [0153] 分屏功能:共享菜单栏/工具栏的多文档同时编辑 + +## 1 相关文档 +- [dddd.md](dddd.md) - 任务文档模板 +- [new_view.cpp](../src/Texmacs/Data/new_view.cpp) - 视图与窗口管理 +- [qt_tm_widget.hpp](../src/Plugins/Qt/qt_tm_widget.hpp) - 主窗口 Qt 部件 +- [qt_tm_widget.cpp](../src/Plugins/Qt/qt_tm_widget.cpp) - 主窗口实现 +- [qt_ui_element.cpp](../src/Plugins/Qt/qt_ui_element.cpp) - UI 元素(含 QSplitter) +- [edit_interface.cpp](../src/Edit/Interface/edit_interface.cpp) - 编辑器 resume/suspend + +## 2 任务相关的代码文件 +- `src/Plugins/Qt/qt_tm_widget.hpp` +- `src/Plugins/Qt/qt_tm_widget.cpp` +- `src/Texmacs/Data/new_view.cpp` +- `src/Texmacs/Window/tm_frame.cpp` +- `src/Edit/Interface/edit_interface.cpp` +- `src/Plugins/Qt/qt_ui_element.cpp` + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) +```bash +# 构建主项目 +xmake b stem +# 运行 Qt 相关测试 +xmake b qt_test 2>/dev/null || true +``` + +### 3.2 非确定性测试(文档验证) +```bash +# 启动 Mogan,验证以下场景: +# 1. 菜单栏「视图」->「分屏」->「左右分屏」 +# 2. 左 pane 打开 tmu 文档,右 pane 打开另一份 tmu 文档 +# 3. 左 pane 打开 tmu,右 pane 打开 PDF(通过预览或拖拽) +# 4. 焦点切换时菜单栏和工具栏状态正确更新 +# 5. ESC 或菜单可退出分屏模式 +``` + +## 4 如何提交 + +提交前执行以下最少步骤: +```bash +xmake b stem +# 手动运行验证分屏功能正常 +``` + +## 5 What + +实现主窗口内的分屏(Split Screen)功能: + +1. 一个主窗口内可同时显示两个 pane(左/右),通过 `QSplitter` 分隔。 +2. 每个 pane 可独立承载编辑器视图(`main_widget`)、PDF 阅读器(`PDFReaderWidget`)或启动页。 +3. 菜单栏和工具栏保持在窗口级别共享,不随 pane 数量翻倍。 +4. 焦点切换到某个 pane 时,该 pane 对应的文档/视图成为「当前活动视图」,菜单和工具栏状态随之更新。 +5. 支持以下典型布局: + - 左编辑器 + 右编辑器 + - 左编辑器 + 右 PDF + - 左 PDF + 右编辑器 +6. 提供 Scheme 命令和菜单项来开启/关闭分屏、调整分屏比例。 + +## 6 Why + +用户需要同时参考或编辑两份文档,或一边编辑 tmu 一边查看 PDF 预览。当前每个窗口只能显示单一内容(编辑器/PDF/启动页三选一),必须开两个独立窗口,无法共享菜单栏和工具栏,体验割裂。分屏功能可提升多文档协作效率。 + +## 7 How + +### 7.1 架构分析 + +当前 `qt_tm_widget_rep` 的 central widget 是一个 `QVBoxLayout`,按顺序堆叠: +``` +centralWidget (QVBoxLayout) + ├── notificationContainer + ├── main_widget / pdfViewerWidget / startupContentWidget / chatContentWidget +``` + +`sync_startup_tab_mode()` / `sync_chat_tab_mode()` / `SLOT_FILE` 触发的逻辑通过 `hide_widget_from_layout` / `show_widget_in_layout` 在布局中切换显示哪个 widget。 + +菜单和工具栏(`menuToolBar`, `mainToolBar`, `modeToolBar`, `focusToolBar`, `userToolBar`)是 `qt_tm_widget_rep` 的成员,**已经是窗口级别**,天然共享。 + +全局 `the_view`(`src/Texmacs/Data/new_view.cpp:121`)跟踪唯一当前活动视图。`attach_view()` / `window_set_view()` 负责将视图绑定到窗口。`resume()` 触发菜单刷新,`suspend()` 清除焦点。 + +### 7.2 实现步骤 + +#### Step 1: 引入 `QSplitter` 和双 pane 结构 + +将 central widget 的单一内容区域改为可切换的两种模式: +- **单 pane 模式**:保持现有行为(向后兼容)。 +- **分屏模式**:central widget 内使用 `QSplitter`,左右各一个 `QWidget* pane`。 + +每个 pane 内部仍使用 `QVBoxLayout`,可独立容纳: +- 编辑器 `main_widget` +- PDF 阅读器 `pdfViewerWidget` +- 启动页 `startupContentWidget` +- 聊天页 `chatContentWidget` + +> 注意:不要为 pane 创建新的窗口类,而是将 pane 作为一个轻量的 `QWidget` 容器,由 `qt_tm_widget_rep` 统一管理其内容切换。 + +#### Step 2: 为每个 pane 独立管理内容状态 + +当前 `qt_tm_widget_rep` 使用全局标志(`startupTabMode`, `pdfTabMode`, `chatTabMode`)决定 central widget 显示什么。分屏模式下需要为**每个 pane**维护: +- 当前 pane 显示的内容类型(editor / pdf / startup / chat) +- 当前 pane 绑定的视图(`url`)或 PDF 路径 +- 焦点状态 + +在 `qt_tm_widget_rep` 中新增: +```cpp +struct PaneState { + enum Type { Editor, Pdf, Startup, Chat } type; + url view; // 编辑器视图 + QString pdfPath; // PDF 路径 +}; +PaneState leftPane; +PaneState rightPane; +QWidget* leftPaneWidget; // pane 容器 +QWidget* rightPaneWidget; // pane 容器 +bool splitMode; +QSplitter* splitter; +``` + +#### Step 3: 焦点切换与 `the_view` 更新 + +当前 `the_view` 是全局单例,菜单/工具栏更新通过 `resume()` -> `SERVER(menu_main(...))` 触发。 + +分屏时,用户点击左/右 pane 需要: +1. 接收焦点的 pane 将其绑定的视图设为 `the_view`(调用 `set_current_view()`)。 +2. 调用该视图的 `resume()` 刷新菜单和工具栏。 +3. 失去焦点的 pane 调用其旧视图的 `suspend()`。 + +实现方式:在 pane 容器上安装 `eventFilter`,监听 `QEvent::FocusIn` / `QEvent::MouseButtonPress`,激活时调用 `window_set_view()` 或等效逻辑。 + +#### Step 4: 改造 `sync_*_tab_mode()` 系列函数 + +将现有的 `sync_startup_tab_mode()` 等函数从操作全局 central widget 布局,改为操作**指定 pane** 的布局。 + +例如: +```cpp +void sync_pane_content(QWidget* pane, PaneState& state); +``` + +#### Step 5: 提供 Scheme 接口和菜单 + +在 Scheme 层暴露命令: +```scheme +(split-window-horizontally) +(unsplit-window) +(other-pane) +``` + +菜单栏「视图」下新增子菜单: +- 分屏 -> 左右分屏 +- 分屏 -> 取消分屏 +- 分屏 -> 切换焦点到另一 pane + +#### Step 6: 打开文档时的路由逻辑 + +当用户通过菜单或快捷键在新窗口/新标签打开文档时,需要判断: +- 分屏模式下,是否在当前非活动 pane 打开? +- 提供「在另一 pane 打开」选项。 + +初期可先保持简单:分屏后,新打开的文档替换当前活动 pane 的内容;用户手动切换焦点到另一 pane 后再打开文档。 + +### 7.3 关键修改点 + +1. **`qt_tm_widget_rep` 新增分屏相关成员和方法**(`qt_tm_widget.hpp` / `.cpp`) +2. **`sync_startup_tab_mode()` 等改造为 pane 级别操作**(`qt_tm_widget.cpp`) +3. **焦点事件处理,更新 `the_view`**(`qt_tm_widget.cpp`) +4. **Scheme 绑定新增分屏命令**(新增或修改 `src/Scheme/` 相关文件) +5. **菜单定义文件新增「分屏」子菜单**(`TeXmacs/progs/` 下的菜单定义) + +### 7.4 兼容性 + +- 默认关闭分屏模式,不影响现有单 pane 行为。 +- 退出分屏时恢复当前活动 pane 的内容到单 pane 模式。 +- `QSplitter` 的 handle 允许用户拖拽调整比例。 From 79204b5999d59d26748ebab75b3582237773b2d0 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 19:49:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?[0153]=20=E5=AE=9E=E7=8E=B0=E4=B8=BB?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=B7=A6=E5=8F=B3=E5=88=86=E5=B1=8F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 qt_tm_widget_rep 中引入 QSplitter 和双 pane 结构 - 支持 split_window_horizontally / unsplit_window / set_active_pane - 每个 pane 可独立承载编辑器、PDF、启动页或聊天内容 - 焦点切换时自动更新菜单栏和工具栏状态 - 提供 Scheme 命令和「视图」菜单子菜单控制分屏 - 新增 qt_split_screen_test 单元测试 Co-Authored-By: Claude Opus 4.7 --- TeXmacs/progs/texmacs/menus/view-menu.scm | 4 + src/Plugins/Qt/qt_tm_widget.cpp | 296 +++++++++++++++++++++- src/Plugins/Qt/qt_tm_widget.hpp | 27 ++ src/Scheme/L5/glue_widget.lua | 15 ++ src/Scheme/L5/init_glue_l5.cpp | 37 +++ src/Scheme/L5/init_glue_l5.hpp | 3 + tests/Plugins/Qt/qt_split_screen_test.cpp | 81 ++++++ 7 files changed, 452 insertions(+), 11 deletions(-) create mode 100644 tests/Plugins/Qt/qt_split_screen_test.cpp diff --git a/TeXmacs/progs/texmacs/menus/view-menu.scm b/TeXmacs/progs/texmacs/menus/view-menu.scm index 3670e6e1b5..8d9786dde8 100644 --- a/TeXmacs/progs/texmacs/menus/view-menu.scm +++ b/TeXmacs/progs/texmacs/menus/view-menu.scm @@ -138,6 +138,10 @@ ("200%" (change-zoom-factor 2.0)) --- ("Other" (interactive other-zoom-factor))) + (-> "Split" + ("Split horizontally" (split-window-horizontally)) + ("Unsplit" (unsplit-window)) + ("Other pane" (other-pane))) ("Snap to pages" (toggle-snap-to-pages)) --- diff --git a/src/Plugins/Qt/qt_tm_widget.cpp b/src/Plugins/Qt/qt_tm_widget.cpp index efc6906d23..ab4d2fd987 100644 --- a/src/Plugins/Qt/qt_tm_widget.cpp +++ b/src/Plugins/Qt/qt_tm_widget.cpp @@ -181,7 +181,9 @@ qt_tm_widget_rep::qt_tm_widget_rep (int mask, command _quit) m_memberType (""), m_currentScmNotificationItem (""), startupContentWidget (nullptr), startupTabMode (false), pdfViewerWidget (nullptr), pdfTabMode (false), currentPdfPath (""), - lastLoadedPdfPath (""), chatContentWidget (nullptr), chatTabMode (false) { + lastLoadedPdfPath (""), chatContentWidget (nullptr), chatTabMode (false), + splitMode (false), activePane (0), splitter (nullptr), + leftPaneWidget (nullptr), rightPaneWidget (nullptr) { type= texmacs_widget; main_widget= concrete (::glue_widget (true, true, 1, 1)); @@ -1037,6 +1039,76 @@ qt_tm_widget_rep::sync_chat_tab_mode () { } } +void +qt_tm_widget_rep::sync_pane_content (int pane) { + if (!splitMode) return; + QWidget* paneWidget= pane == 0 ? leftPaneWidget : rightPaneWidget; + if (!paneWidget) return; + QLayout* paneLayout= paneWidget->layout (); + if (!paneLayout) return; + + PaneContentState& state= pane == 0 ? leftPaneState : rightPaneState; + + QWidget* editorWidget= main_widget->qwid; + + if (state.startupTabMode) { + hide_widget_from_layout (editorWidget, paneLayout); + if (pdfViewerWidget) hide_widget_from_layout (pdfViewerWidget, paneLayout); + if (chatContentWidget) hide_widget_from_layout (chatContentWidget, paneLayout); + + if (!startupContentWidget) { + startupContentWidget= new QTMStartupTabWidget (centralwidget ()); + } + show_widget_in_layout (startupContentWidget, paneLayout); + startupContentWidget->setFocus (Qt::OtherFocusReason); + } + else if (state.pdfTabMode) { + hide_widget_from_layout (editorWidget, paneLayout); + if (startupContentWidget) + hide_widget_from_layout (startupContentWidget, paneLayout); + if (chatContentWidget) hide_widget_from_layout (chatContentWidget, paneLayout); + + if (!pdfViewerWidget) { + pdfViewerWidget= new PDFReaderWidget (centralwidget ()); + } + show_widget_in_layout (pdfViewerWidget, paneLayout); + pdfViewerWidget->setFocus (Qt::OtherFocusReason); + + if (!state.currentPdfPath.isEmpty () && + state.currentPdfPath != state.lastLoadedPdfPath) { + pdfViewerWidget->loadFromFile (state.currentPdfPath); + state.lastLoadedPdfPath= state.currentPdfPath; + } + } + else if (state.chatTabMode) { + hide_widget_from_layout (editorWidget, paneLayout); + if (startupContentWidget) + hide_widget_from_layout (startupContentWidget, paneLayout); + if (pdfViewerWidget) hide_widget_from_layout (pdfViewerWidget, paneLayout); + + if (!chatContentWidget) { + chatContentWidget= new QTChatTabWidget (centralwidget ()); + } + static_cast (chatContentWidget) + ->ensure_new_conversation (); + show_widget_in_layout (chatContentWidget, paneLayout); + chatContentWidget->setFocus (Qt::OtherFocusReason); + } + else { + if (startupContentWidget) + hide_widget_from_layout (startupContentWidget, paneLayout); + if (pdfViewerWidget) hide_widget_from_layout (pdfViewerWidget, paneLayout); + if (chatContentWidget) hide_widget_from_layout (chatContentWidget, paneLayout); + show_widget_in_layout (editorWidget, paneLayout); + + if (scrollarea ()) + scrollarea ()->surface ()->setSizePolicy (QSizePolicy::Fixed, + QSizePolicy::Fixed); + url currentView= get_current_view_safe (); + if (!is_none (currentView)) send_keyboard_focus (abstract (main_widget)); + } +} + void qt_tm_widget_rep::update_visibility () { #define XOR(exp1, exp2) (((!exp1) && (exp2)) || ((exp1) && (!exp2))) @@ -1351,14 +1423,27 @@ qt_tm_widget_rep::send (slot s, blackbox val) { string file= open_box (val); if (DEBUG_QT_WIDGETS) debug_widgets << "\tFile: " << file << LF; mainwindow ()->setWindowFilePath (utf8_to_qstring (file)); - startupTabMode= is_startup_tab_file (file); - pdfTabMode = is_pdf_tab_file (file); - if (pdfTabMode) { - currentPdfPath= utf8_to_qstring (file); + if (splitMode) { + PaneContentState& state= + activePane == 0 ? leftPaneState : rightPaneState; + state.startupTabMode= is_startup_tab_file (file); + state.pdfTabMode = is_pdf_tab_file (file); + if (state.pdfTabMode) { + state.currentPdfPath= utf8_to_qstring (file); + } + state.chatTabMode= is_chat_tab_file (file); + sync_pane_content (activePane); + } + else { + startupTabMode= is_startup_tab_file (file); + pdfTabMode = is_pdf_tab_file (file); + if (pdfTabMode) { + currentPdfPath= utf8_to_qstring (file); + } + chatTabMode= is_chat_tab_file (file); + sync_startup_tab_mode (); + sync_chat_tab_mode (); } - chatTabMode= is_chat_tab_file (file); - sync_startup_tab_mode (); - sync_chat_tab_mode (); } break; case SLOT_POSITION: { check_type (val, s); @@ -1560,9 +1645,29 @@ qt_tm_widget_rep::write (slot s, blackbox index, widget w) { QWidget* q= main_widget->qwid; QLayout* l= centralwidget ()->layout (); - if (q && l->indexOf (q) >= 0) { - l->removeWidget (q); - q->hide (); // 隐藏旧的 widget + if (q) { + if (splitMode) { + // In split mode, remove old widget from whichever pane it is in + QWidget* oldPane= activePane == 0 ? leftPaneWidget : rightPaneWidget; + QWidget* otherPane= activePane == 0 ? rightPaneWidget : leftPaneWidget; + if (oldPane && oldPane->layout () && oldPane->layout ()->indexOf (q) >= 0) { + oldPane->layout ()->removeWidget (q); + } + else if (otherPane && otherPane->layout () && + otherPane->layout ()->indexOf (q) >= 0) { + otherPane->layout ()->removeWidget (q); + } + else if (l->indexOf (q) >= 0) { + l->removeWidget (q); + } + q->hide (); + } + else { + if (l->indexOf (q) >= 0) { + l->removeWidget (q); + q->hide (); // 隐藏旧的 widget + } + } } q= concrete (w)->as_qwidget (); // force creation of the new QWidget @@ -2917,3 +3022,172 @@ qt_tm_widget_rep::isVersionNewer (const QString& remote, const QString& local) { } return false; // 版本相同 } + +/****************************************************************************** + * Split screen implementation + ******************************************************************************/ + +class PaneFocusTracker : public QObject { + qt_tm_widget_rep* widget; + int paneIndex; + +public: + PaneFocusTracker (qt_tm_widget_rep* w, int pane, QObject* parent= nullptr) + : QObject (parent), widget (w), paneIndex (pane) {} + +protected: + bool eventFilter (QObject* watched, QEvent* event) override { + (void) watched; + if (event->type () == QEvent::FocusIn || + event->type () == QEvent::MouseButtonPress) { + widget->set_active_pane (paneIndex); + } + return QObject::eventFilter (watched, event); + } +}; + +void +qt_tm_widget_rep::split_window_horizontally () { + if (splitMode) return; +#ifdef LIII_DEBUG + cout << "Split window horizontally\n"; +#endif + splitMode= true; + activePane= 0; + + // Initialize pane states from current global state + leftPaneState.startupTabMode = startupTabMode; + leftPaneState.pdfTabMode = pdfTabMode; + leftPaneState.chatTabMode = chatTabMode; + leftPaneState.currentPdfPath = currentPdfPath; + leftPaneState.lastLoadedPdfPath= lastLoadedPdfPath; + rightPaneState= PaneContentState (); + + QWidget* cw= centralwidget (); + QLayout* layout= cw->layout (); + if (!layout) return; + + // Create splitter and panes + splitter= new QSplitter (Qt::Horizontal, cw); + leftPaneWidget = new QWidget (splitter); + rightPaneWidget= new QWidget (splitter); + + QVBoxLayout* leftLayout = new QVBoxLayout (leftPaneWidget); + QVBoxLayout* rightLayout= new QVBoxLayout (rightPaneWidget); + leftLayout->setContentsMargins (0, 0, 0, 0); + leftLayout->setSpacing (0); + rightLayout->setContentsMargins (0, 0, 0, 0); + rightLayout->setSpacing (0); + + // Move current main_widget into left pane + QWidget* editorWidget= main_widget->qwid; + if (editorWidget) { + hide_widget_from_layout (editorWidget, layout); + leftLayout->addWidget (editorWidget); + } + + // Hide other widgets from central layout + hide_widget_from_layout (startupContentWidget, layout); + hide_widget_from_layout (pdfViewerWidget, layout); + hide_widget_from_layout (chatContentWidget, layout); + + // Install focus trackers on panes + leftPaneWidget->installEventFilter ( + new PaneFocusTracker (this, 0, leftPaneWidget)); + rightPaneWidget->installEventFilter ( + new PaneFocusTracker (this, 1, rightPaneWidget)); + + layout->addWidget (splitter); + splitter->show (); + leftPaneWidget->show (); + rightPaneWidget->show (); +} + +void +qt_tm_widget_rep::unsplit_window () { + if (!splitMode) return; +#ifdef LIII_DEBUG + cout << "Unsplit window\n"; +#endif + QWidget* cw = centralwidget (); + QLayout* layout= cw->layout (); + if (!layout) return; + + // Move main_widget back to central layout + QWidget* editorWidget= main_widget->qwid; + if (editorWidget) { + if (leftPaneWidget && leftPaneWidget->layout () && + leftPaneWidget->layout ()->indexOf (editorWidget) >= 0) { + leftPaneWidget->layout ()->removeWidget (editorWidget); + } + if (rightPaneWidget && rightPaneWidget->layout () && + rightPaneWidget->layout ()->indexOf (editorWidget) >= 0) { + rightPaneWidget->layout ()->removeWidget (editorWidget); + } + hide_widget_from_layout (editorWidget, layout); + show_widget_in_layout (editorWidget, layout); + } + + // Remove splitter from layout + if (splitter) { + hide_widget_from_layout (splitter, layout); + splitter->deleteLater (); + splitter= nullptr; + } + leftPaneWidget = nullptr; + rightPaneWidget= nullptr; + + splitMode= false; + activePane= 0; + + // Restore normal editor visibility for single pane mode + // (avoid calling sync_startup_tab_mode here, which requires scheme server) + QWidget* editorWidget2= main_widget->qwid; + if (editorWidget2) { + hide_widget_from_layout (editorWidget2, layout); + show_widget_in_layout (editorWidget2, layout); + } + hide_widget_from_layout (startupContentWidget, layout); + hide_widget_from_layout (pdfViewerWidget, layout); + hide_widget_from_layout (chatContentWidget, layout); + update_visibility (); +} + +void +qt_tm_widget_rep::set_active_pane (int pane) { + if (!splitMode) return; + if (pane != 0 && pane != 1) return; + if (activePane == pane) return; +#ifdef LIII_DEBUG + cout << "Set active pane " << pane << "\n"; +#endif + activePane= pane; + + // Sync global state flags to the active pane's state so that + // menu/toolbar updates use the correct context. + PaneContentState& state= activePane == 0 ? leftPaneState : rightPaneState; + startupTabMode = state.startupTabMode; + pdfTabMode = state.pdfTabMode; + chatTabMode = state.chatTabMode; + currentPdfPath = state.currentPdfPath; + lastLoadedPdfPath= state.lastLoadedPdfPath; + + // Refresh menu bar for the new active pane context + install_main_menu (); + + // If the active pane contains the main editor widget, ensure it has focus. + QWidget* editorWidget= main_widget->qwid; + if (editorWidget) { + QWidget* paneWidget= activePane == 0 ? leftPaneWidget : rightPaneWidget; + if (paneWidget && paneWidget->layout () && + paneWidget->layout ()->indexOf (editorWidget) >= 0) { + if (!editorWidget->hasFocus ()) { + editorWidget->setFocus (Qt::OtherFocusReason); + } + url currentView= get_current_view_safe (); + if (!is_none (currentView)) { + send_keyboard_focus (abstract (main_widget)); + } + } + } +} diff --git a/src/Plugins/Qt/qt_tm_widget.hpp b/src/Plugins/Qt/qt_tm_widget.hpp index 1d40a5807f..4c10fc91a2 100644 --- a/src/Plugins/Qt/qt_tm_widget.hpp +++ b/src/Plugins/Qt/qt_tm_widget.hpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #if defined(Q_OS_MAC) || defined(Q_OS_LINUX) || defined(Q_OS_WIN) @@ -183,6 +184,24 @@ class qt_tm_widget_rep : public qt_window_widget_rep { QString lastLoadedPdfPath; ///\< 上次加载的 PDF 路径。 bool chatTabMode; ///\< 聊天标签页视图是否激活。 + /* Split screen support */ + struct PaneContentState { + bool startupTabMode; + bool pdfTabMode; + bool chatTabMode; + QString currentPdfPath; + QString lastLoadedPdfPath; + PaneContentState () + : startupTabMode (false), pdfTabMode (false), chatTabMode (false) {} + }; + bool splitMode; ///\< 分屏模式是否激活。 + int activePane; ///\< 当前活动 pane(0=左,1=右)。 + QSplitter* splitter; ///\< 分屏分隔器。 + QWidget* leftPaneWidget; ///\< 左 pane 容器。 + QWidget* rightPaneWidget; ///\< 右 pane 容器。 + PaneContentState leftPaneState; + PaneContentState rightPaneState; + public: qt_tm_widget_rep (int mask, command _quit); ~qt_tm_widget_rep (); @@ -201,6 +220,13 @@ class qt_tm_widget_rep : public qt_window_widget_rep { void openRenewalPage (); void checkNetworkAvailable (); void sync_startup_tab_mode (); + + /* Split screen API */ + void split_window_horizontally (); + void unsplit_window (); + bool is_split_mode () const { return splitMode; } + int active_pane () const { return activePane; } + void set_active_pane (int pane); /** * @brief 同步聊天标签页控件的可见性。 * @@ -209,6 +235,7 @@ class qt_tm_widget_rep : public qt_window_widget_rep { * 否则隐藏聊天控件并恢复编辑器。 */ void sync_chat_tab_mode (); + void sync_pane_content (int pane); friend class QTMInteractiveInputHelper; diff --git a/src/Scheme/L5/glue_widget.lua b/src/Scheme/L5/glue_widget.lua index ede3c7182b..95ff92917c 100644 --- a/src/Scheme/L5/glue_widget.lua +++ b/src/Scheme/L5/glue_widget.lua @@ -528,6 +528,21 @@ function main() "string", "string" } + }, + { + scm_name = "split-window-horizontally", + cpp_name = "split_window_horizontally_cmd", + ret_type = "void" + }, + { + scm_name = "unsplit-window", + cpp_name = "unsplit_window_cmd", + ret_type = "void" + }, + { + scm_name = "other-pane", + cpp_name = "other_pane_cmd", + ret_type = "void" } } } diff --git a/src/Scheme/L5/init_glue_l5.cpp b/src/Scheme/L5/init_glue_l5.cpp index 6e36120b52..4c00fb6786 100644 --- a/src/Scheme/L5/init_glue_l5.cpp +++ b/src/Scheme/L5/init_glue_l5.cpp @@ -314,6 +314,43 @@ open_pricing_url () { } } +void +split_window_horizontally_cmd () { + if (!has_current_window ()) return; + tm_window win= concrete_window (); + if (win == NULL) return; + widget w = win->win; + qt_tm_widget_rep* tm_widget= dynamic_cast (w.rep); + if (tm_widget != NULL) { + tm_widget->split_window_horizontally (); + } +} + +void +unsplit_window_cmd () { + if (!has_current_window ()) return; + tm_window win= concrete_window (); + if (win == NULL) return; + widget w = win->win; + qt_tm_widget_rep* tm_widget= dynamic_cast (w.rep); + if (tm_widget != NULL) { + tm_widget->unsplit_window (); + } +} + +void +other_pane_cmd () { + if (!has_current_window ()) return; + tm_window win= concrete_window (); + if (win == NULL) return; + widget w = win->win; + qt_tm_widget_rep* tm_widget= dynamic_cast (w.rep); + if (tm_widget != NULL) { + int other= tm_widget->active_pane () == 0 ? 1 : 0; + tm_widget->set_active_pane (other); + } +} + void initialize_glue_l5 () { initialize_glue_font (); diff --git a/src/Scheme/L5/init_glue_l5.hpp b/src/Scheme/L5/init_glue_l5.hpp index 9b4a53a1b0..7764d03b49 100644 --- a/src/Scheme/L5/init_glue_l5.hpp +++ b/src/Scheme/L5/init_glue_l5.hpp @@ -14,5 +14,8 @@ void initialize_glue_l5 (); void open_pricing_url (); +void split_window_horizontally_cmd (); +void unsplit_window_cmd (); +void other_pane_cmd (); #endif diff --git a/tests/Plugins/Qt/qt_split_screen_test.cpp b/tests/Plugins/Qt/qt_split_screen_test.cpp new file mode 100644 index 0000000000..d8faa33e9d --- /dev/null +++ b/tests/Plugins/Qt/qt_split_screen_test.cpp @@ -0,0 +1,81 @@ +/****************************************************************************** + * MODULE : qt_split_screen_test.cpp + * DESCRIPTION: Tests for split screen functionality in qt_tm_widget_rep + * COPYRIGHT : (C) 2026 Da Shen + ******************************************************************************/ + +#include "Qt/qt_tm_widget.hpp" +#include "Qt/qt_utilities.hpp" +#include "base.hpp" +#include +#include + +static QtMessageHandler defaultMessageHandler= nullptr; + +static void +filterTestWarnings (QtMsgType type, const QMessageLogContext& context, + const QString& msg) { + if (type == QtWarningMsg) { + if (msg.contains ("cached device pixel ratio") || + msg.contains ("wayland.textinput")) { + return; + } + } + defaultMessageHandler (type, context, msg); +} + +class TestSplitScreen : public QObject { + Q_OBJECT + +private slots: + void initTestCase () { + defaultMessageHandler= qInstallMessageHandler (filterTestWarnings); + } + + void init () { init_lolly (); } + + void test_default_not_split () { + qt_tm_widget_rep* w= new qt_tm_widget_rep (0, command ()); + QVERIFY (!w->is_split_mode ()); + QCOMPARE (w->active_pane (), 0); + delete w; + } + + void test_split_and_unsplit () { + qt_tm_widget_rep* w= new qt_tm_widget_rep (0, command ()); + QVERIFY (!w->is_split_mode ()); + + w->split_window_horizontally (); + QVERIFY (w->is_split_mode ()); + + w->unsplit_window (); + QVERIFY (!w->is_split_mode ()); + delete w; + } + + void test_active_pane_switching () { + qt_tm_widget_rep* w= new qt_tm_widget_rep (0, command ()); + QCOMPARE (w->active_pane (), 0); + + w->split_window_horizontally (); + w->set_active_pane (1); + QCOMPARE (w->active_pane (), 1); + + w->set_active_pane (0); + QCOMPARE (w->active_pane (), 0); + delete w; + } + + void test_unsplit_restores_single_pane () { + qt_tm_widget_rep* w= new qt_tm_widget_rep (0, command ()); + w->split_window_horizontally (); + QVERIFY (w->is_split_mode ()); + w->unsplit_window (); + QVERIFY (!w->is_split_mode ()); + QCOMPARE (w->active_pane (), 0); + delete w; + } +}; + +QTEST_MAIN (TestSplitScreen) +#include "qt_split_screen_test.moc" From 63d472dfc41d0a05447c13d1d188a9b90758d689 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 19:51:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?[0153]=20=E5=88=86=E5=B1=8F=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E4=B8=8E=20SLOT=5FSCROLLABLE=20pane=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 取消注释 emacs 风格分屏快捷键 (C-c 3 分屏, C-c 0 取消, C-c o 切换) - SLOT_SCROLLABLE 在分屏模式下将新编辑器 widget 放入活动 pane Co-Authored-By: Claude Opus 4.7 --- TeXmacs/progs/generic/generic-kbd.scm | 4 +++- src/Plugins/Qt/qt_tm_widget.cpp | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/TeXmacs/progs/generic/generic-kbd.scm b/TeXmacs/progs/generic/generic-kbd.scm index 00e098bfe3..aaf4999aa0 100644 --- a/TeXmacs/progs/generic/generic-kbd.scm +++ b/TeXmacs/progs/generic/generic-kbd.scm @@ -347,7 +347,9 @@ ;; ("emacs:prefix 0" (delete-window)) ;; ("emacs:prefix 1" (delete-other-windows)) ;; ("emacs:prefix 2" (split-window-vertically)) - ;; ("emacs:prefix 3" (split-window-horizontally)) + ("emacs:prefix 3" (split-window-horizontally)) + ("emacs:prefix 0" (unsplit-window)) + ("emacs:prefix o" (other-pane)) ;; ("emacs:prefix d" (dired)) ;; ("emacs:prefix f" (set-fill-column)) ;; ("emacs:prefix i" (interactive insert-buffer)) diff --git a/src/Plugins/Qt/qt_tm_widget.cpp b/src/Plugins/Qt/qt_tm_widget.cpp index ab4d2fd987..387d1856f6 100644 --- a/src/Plugins/Qt/qt_tm_widget.cpp +++ b/src/Plugins/Qt/qt_tm_widget.cpp @@ -1674,6 +1674,17 @@ qt_tm_widget_rep::write (slot s, blackbox index, widget w) { // SLOT_SCROLLABLE 只更新 main_widget,不设置 startupTabMode // startupTabMode 的判定和界面更新由 SLOT_FILE 处理 main_widget= concrete (w); + + if (splitMode) { + // In split mode, add the new editor widget to the active pane + QWidget* paneWidget= + activePane == 0 ? leftPaneWidget : rightPaneWidget; + if (paneWidget && paneWidget->layout ()) { + QLayout* paneLayout= paneWidget->layout (); + hide_widget_from_layout (q, paneLayout); + show_widget_in_layout (q, paneLayout); + } + } } break; case SLOT_MAIN_MENU: From 141ff683e01e9c1df1e220cabc062ab35de77135 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Wed, 20 May 2026 20:06:07 +0800 Subject: [PATCH 4/4] =?UTF-8?q?[0153]=20=E6=94=AF=E6=8C=81=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A1=B5=E8=BF=9B=E5=85=A5=E4=B8=8D=E5=90=8C=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaneContentState 增加 editorWidget,记录每个 pane 的编辑器 widget - SLOT_SCROLLABLE 分屏模式下仅操作活动 pane,保留另一 pane 内容 - set_active_pane 切换时从 pane 状态恢复 main_widget 并刷新菜单 Co-Authored-By: Claude Opus 4.7 --- src/Plugins/Qt/qt_tm_widget.cpp | 45 ++++++++++++++++++++++++++------- src/Plugins/Qt/qt_tm_widget.hpp | 14 +++++----- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/Plugins/Qt/qt_tm_widget.cpp b/src/Plugins/Qt/qt_tm_widget.cpp index 387d1856f6..c3c44e5b05 100644 --- a/src/Plugins/Qt/qt_tm_widget.cpp +++ b/src/Plugins/Qt/qt_tm_widget.cpp @@ -1647,15 +1647,19 @@ qt_tm_widget_rep::write (slot s, blackbox index, widget w) { QLayout* l= centralwidget ()->layout (); if (q) { if (splitMode) { - // In split mode, remove old widget from whichever pane it is in - QWidget* oldPane= activePane == 0 ? leftPaneWidget : rightPaneWidget; - QWidget* otherPane= activePane == 0 ? rightPaneWidget : leftPaneWidget; - if (oldPane && oldPane->layout () && oldPane->layout ()->indexOf (q) >= 0) { - oldPane->layout ()->removeWidget (q); - } - else if (otherPane && otherPane->layout () && - otherPane->layout ()->indexOf (q) >= 0) { - otherPane->layout ()->removeWidget (q); + // Save current main_widget into the active pane state before + // replacing it, so that we can restore it when switching back. + PaneContentState& state= + activePane == 0 ? leftPaneState : rightPaneState; + state.editorWidget= main_widget; + + // Only remove from the active pane (or central layout), never + // from the other pane -- that pane keeps its own content. + QWidget* activePaneW= + activePane == 0 ? leftPaneWidget : rightPaneWidget; + if (activePaneW && activePaneW->layout () && + activePaneW->layout ()->indexOf (q) >= 0) { + activePaneW->layout ()->removeWidget (q); } else if (l->indexOf (q) >= 0) { l->removeWidget (q); @@ -3072,6 +3076,7 @@ qt_tm_widget_rep::split_window_horizontally () { leftPaneState.chatTabMode = chatTabMode; leftPaneState.currentPdfPath = currentPdfPath; leftPaneState.lastLoadedPdfPath= lastLoadedPdfPath; + leftPaneState.editorWidget = main_widget; rightPaneState= PaneContentState (); QWidget* cw= centralwidget (); @@ -3183,6 +3188,28 @@ qt_tm_widget_rep::set_active_pane (int pane) { currentPdfPath = state.currentPdfPath; lastLoadedPdfPath= state.lastLoadedPdfPath; + // If the target pane has its own editor widget, restore it as main_widget. + if (!is_nil (state.editorWidget)) { + QWidget* qw= state.editorWidget->qwid; + if (qw) { + // Move the restored widget into the active pane if needed. + QWidget* paneWidget= activePane == 0 ? leftPaneWidget : rightPaneWidget; + if (paneWidget && paneWidget->layout () && + paneWidget->layout ()->indexOf (qw) < 0) { + // Remove from other pane or central layout first. + QWidget* otherPane= activePane == 0 ? rightPaneWidget : leftPaneWidget; + if (otherPane && otherPane->layout () && + otherPane->layout ()->indexOf (qw) >= 0) { + otherPane->layout ()->removeWidget (qw); + } + QLayout* cl= centralwidget ()->layout (); + if (cl->indexOf (qw) >= 0) cl->removeWidget (qw); + show_widget_in_layout (qw, paneWidget->layout ()); + } + main_widget= state.editorWidget; + } + } + // Refresh menu bar for the new active pane context install_main_menu (); diff --git a/src/Plugins/Qt/qt_tm_widget.hpp b/src/Plugins/Qt/qt_tm_widget.hpp index 4c10fc91a2..9f12829da4 100644 --- a/src/Plugins/Qt/qt_tm_widget.hpp +++ b/src/Plugins/Qt/qt_tm_widget.hpp @@ -186,13 +186,15 @@ class qt_tm_widget_rep : public qt_window_widget_rep { /* Split screen support */ struct PaneContentState { - bool startupTabMode; - bool pdfTabMode; - bool chatTabMode; - QString currentPdfPath; - QString lastLoadedPdfPath; + bool startupTabMode; + bool pdfTabMode; + bool chatTabMode; + QString currentPdfPath; + QString lastLoadedPdfPath; + qt_widget editorWidget; // 该 pane 中显示的编辑器 widget PaneContentState () - : startupTabMode (false), pdfTabMode (false), chatTabMode (false) {} + : startupTabMode (false), pdfTabMode (false), chatTabMode (false), + editorWidget (NULL) {} }; bool splitMode; ///\< 分屏模式是否激活。 int activePane; ///\< 当前活动 pane(0=左,1=右)。