diff --git a/devel/0137.md b/devel/0137.md new file mode 100644 index 0000000000..8937989c02 --- /dev/null +++ b/devel/0137.md @@ -0,0 +1,68 @@ +# [0137] 调整 AI 对话输入框高度为默认三行并支持自适应延伸 + +## 1 相关文档 +- [dddd.md](dddd.md) - 任务文档模板 +- [0302.md](0302.md) - LLM 聊天页实现 + +## 2 任务相关的代码文件 +- `src/Plugins/Qt/qt_chat_tab_widget.hpp` +- `src/Plugins/Qt/qt_chat_tab_widget.cpp` + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) +本项目无对应单元测试。 + +### 3.2 非确定性测试(文档验证) +1. 启动 Mogan STEM,点击顶部工具栏的 AI 图标打开 Chat 标签页 +2. 观察输入框默认高度是否为三行(约三行文本的高度) +3. 在输入框中连续按回车键,观察输入框高度是否随内容增加而向上延伸 +4. 输入多行文本后,确认输入框没有出现不必要的滚动条,而是直接扩展高度 +5. 删除多行内容后,确认输入框高度能相应收缩 + +## 4 如何提交 + +提交前执行以下最少步骤: + +```bash +xmake b stem +``` + +## 5 What + +1. 将 AI 对话输入框的默认高度调整为三行文本的高度 +2. 输入框支持根据内容行数自适应向上延伸高度 +3. 当用户删除内容时,输入框高度相应收缩 + +## 6 Why + +当前 Chat Tab 的输入框高度固定,只能显示一行内容。当用户输入较长消息或多行内容时,体验不佳。将默认高度设为三行并支持自适应延伸,可以: +- 让用户更直观地感知这是一个多行输入框 +- 减少用户在输入长消息时的滚动操作 +- 更符合现代聊天产品的交互习惯 + +## 7 How + +### 7.1 默认高度设为三行 + +将 `kInputHeight` 常量替换为 `kInputLineHeight`(22 像素)、`kInputDefaultLines`(3 行)和 `kInputMaxLines`(10 行)。在创建输入框时,用 `setMinimumHeight()` 设置最小高度为三行,用 `setMaximumHeight()` 设置最大高度上限,避免无限增长。 + +### 7.2 自适应高度延伸 + +由于 `texmacs_input_widget` 的 `sizeHint()` 返回屏幕尺寸而非内容尺寸,无法直接依赖 Qt 的 size hint 机制。改为使用 `QTimer` 每 100ms 轮询一次输入 buffer 的文档树,通过 `count_input_lines()` 计算段落数: +- 空文档或仅含空字符串时返回 1 行 +- 否则返回 `DOCUMENT` 节点的子节点数 `N(body)` + +`adjust_input_height()` 根据段落数调整 `inputEditorWidget` 的 `minimumHeight`,使其在 3~10 行范围内自适应。 + +### 7.3 高度收缩 + +当用户删除内容导致段落数减少时,`adjust_input_height()` 同样会降低 `minimumHeight`。最小值始终不低于三行默认值。 + +## 8 已知限制 + +当前实现通过 `count_input_lines()` 统计文档的**段落数**(`DOCUMENT` 节点的子节点数)来调整高度,而非实际的视觉行数。因此: +- 按回车键创建新段落时,输入框能正确变大。 +- 粘贴不带换行符的长文本时,由于段落数不变,输入框**不会**随内容自动增高。 + +后续如需支持粘贴长文本时的自适应,需要改用能够计算实际排版行数的方式(如查询编辑器内部排版后的行高)。 diff --git a/src/Plugins/Qt/qt_chat_tab_widget.cpp b/src/Plugins/Qt/qt_chat_tab_widget.cpp index 868d8e09ef..d663b88668 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.cpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.cpp @@ -38,7 +38,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -96,18 +98,6 @@ disable_scrollbars_recursively (QWidget* root) { } } -/** - * @brief 判断文档主体是否实际为空。 - * @param body TeXmacs 文档树。 - * @return 若主体不含可见内容则返回 true。 - */ -bool -is_empty_document_body (tree body) { - if (!is_func (body, DOCUMENT)) return false; - if (N (body) == 0) return true; - return N (body) == 1 && is_atomic (body[0]) && body[0]->label == ""; -} - /// 左侧边栏最小宽度(像素)。 constexpr int kSidebarMinWidth= 200; /// 左侧边栏收起后的窄条宽度(像素)。 @@ -138,8 +128,12 @@ constexpr int kCollapsePadY= 4; constexpr int kCollapsePadX= 8; /// 欢迎标题字体大小(像素)。 constexpr int kWelcomeFontPx= 34; -/// 输入编辑器固定高度(像素)。 -constexpr int kInputHeight= 44; +/// 输入编辑器单行高度(像素)。 +constexpr int kInputLineHeight= 22; +/// 输入编辑器默认行数。 +constexpr int kInputDefaultLines= 3; +/// 输入编辑器最大行数。 +constexpr int kInputMaxLines= 10; /// 发送按钮垂直内边距。 constexpr int kSendButtonPadY= 6; /// 发送按钮水平内边距。 @@ -171,6 +165,18 @@ constexpr int kTransitionDurationMs= 220; } // namespace +/** + * @brief 判断文档主体是否实际为空。 + * @param body TeXmacs 文档树。 + * @return 若主体不含可见内容则返回 true。 + */ +bool +QTChatTabWidget::is_empty_document_body (tree body) { + if (!is_func (body, DOCUMENT)) return false; + if (N (body) == 0) return true; + return N (body) == 1 && is_atomic (body[0]) && body[0]->label == ""; +} + /** * @brief 单个会话面板的内部数据。 */ @@ -466,8 +472,13 @@ QTChatTabWidget::create_conversation (const QString& title) { panel->messageWidget= texmacs_input_widget ( tree (DOCUMENT, ""), make_chat_embedded_style (), msgBufUrl); + QSplitter* splitter= new QSplitter (Qt::Vertical, topPanel); + splitter->setObjectName ("chat-tab-splitter"); + splitter->setHandleWidth (DpiUtils::scaled (4)); + splitter->setChildrenCollapsible (false); + QWidget* messageQWidget= concrete (panel->messageWidget)->as_qwidget (); - panel->messageFrame = new QWidget (topPanel); + panel->messageFrame = new QWidget (splitter); panel->messageFrame->setObjectName ("chat-tab-message-frame"); panel->messageFrame->setStyleSheet ( QString ("border: %1px solid #d9d9d9; border-radius: %2px; " @@ -483,10 +494,17 @@ QTChatTabWidget::create_conversation (const QString& title) { messageQWidget->setMinimumHeight (DpiUtils::scaled (kMessageMinHeight)); messageFrameLayout->addWidget (messageQWidget); panel->messageFrame->hide (); - topLayout->addWidget (panel->messageFrame, 1); + splitter->addWidget (panel->messageFrame); + + QWidget* inputArea= new QWidget (splitter); + inputArea->setObjectName ("chat-tab-input-area"); + QVBoxLayout* inputAreaLayout= new QVBoxLayout (inputArea); + inputAreaLayout->setContentsMargins (0, 0, 0, 0); + inputAreaLayout->setSpacing (DpiUtils::scaled (kContentSpacing)); panel->inputWidget= texmacs_input_widget ( - tree (DOCUMENT, ""), make_chat_embedded_style (), inBufUrl); + tree (WITH, "par-par-sep", "0.05fn", tree (DOCUMENT, "")), + make_chat_embedded_style (), inBufUrl); QWidget* inputQWidget = concrete (panel->inputWidget)->as_qwidget (); panel->inputEditorWidget= inputQWidget; disable_scrollbars_recursively (inputQWidget); @@ -494,7 +512,7 @@ QTChatTabWidget::create_conversation (const QString& title) { editor->setProperty ("chat_panel", QVariant::fromValue ((void*) panel)); editor->installEventFilter (this); } - QWidget* inputFrame= new QWidget (topPanel); + QWidget* inputFrame= new QWidget (inputArea); inputFrame->setObjectName ("chat-tab-input-frame"); inputFrame->setStyleSheet ( QString ("border: %1px solid #d9d9d9; border-radius: %2px; " @@ -507,14 +525,22 @@ QTChatTabWidget::create_conversation (const QString& title) { DpiUtils::scaled (kInputFramePad), DpiUtils::scaled (kInputFramePad)); inputFrameLayout->setSpacing (0); inputQWidget->setParent (inputFrame); - inputQWidget->setFixedHeight (DpiUtils::scaled (kInputHeight)); + int defaultHeight= DpiUtils::scaled (kInputLineHeight * kInputDefaultLines); + inputQWidget->setMinimumHeight (defaultHeight); + inputQWidget->setMaximumHeight (defaultHeight); inputFrameLayout->addWidget (inputQWidget); - topLayout->addWidget (inputFrame, 0); + inputAreaLayout->addWidget (inputFrame, 0); + + QTimer* inputHeightTimer= new QTimer (inputFrame); + inputHeightTimer->setInterval (100); + connect (inputHeightTimer, &QTimer::timeout, this, + [this, panel] () { adjust_input_height (panel); }); + inputHeightTimer->start (); QHBoxLayout* btnLayout= new QHBoxLayout (); btnLayout->addStretch (); - panel->sendButton= new QPushButton ("Send", topPanel); + panel->sendButton= new QPushButton ("Send", inputArea); panel->sendButton->setObjectName ("chat-tab-send-btn"); panel->sendButton->setFocusPolicy (Qt::NoFocus); panel->sendButton->setCursor (Qt::PointingHandCursor); @@ -526,7 +552,13 @@ QTChatTabWidget::create_conversation (const QString& title) { connect (panel->sendButton, &QPushButton::clicked, this, [this, panel] () { handle_send (panel); }); btnLayout->addWidget (panel->sendButton); - topLayout->addLayout (btnLayout); + inputAreaLayout->addLayout (btnLayout); + + splitter->addWidget (inputArea); + splitter->setStretchFactor (0, 1); + splitter->setStretchFactor (1, 0); + + topLayout->addWidget (splitter, 1); contentLayout->addWidget (topPanel, 1, Qt::AlignHCenter | Qt::AlignTop); conversationStack_->addWidget (page); @@ -941,6 +973,40 @@ QTChatTabWidget::focus_input_editor (ChatConversationPanel* panel) { } } +/** + * @brief 计算输入文档的段落(行)数。 + * @param body TeXmacs 文档树。 + * @return 段落数量。 + */ +int +QTChatTabWidget::count_input_lines (tree body) { + if (!is_func (body, DOCUMENT)) return 1; + if (N (body) == 0) return 1; + if (N (body) == 1 && is_atomic (body[0]) && body[0]->label == "") return 1; + return N (body); +} + +/** + * @brief 根据输入内容自适应调整输入框高度。 + * @param panel 目标会话面板。 + */ +void +QTChatTabWidget::adjust_input_height (ChatConversationPanel* panel) { + if (!panel || !panel->inputEditorWidget) return; + + tree body = read_input_message (panel); + int lines = count_input_lines (body); + int targetLines= qMax (kInputDefaultLines, lines); + targetLines = qMin (targetLines, kInputMaxLines); + int targetHeight= DpiUtils::scaled (kInputLineHeight * targetLines); + + if (panel->inputEditorWidget->minimumHeight () != targetHeight || + panel->inputEditorWidget->maximumHeight () != targetHeight) { + panel->inputEditorWidget->setMinimumHeight (targetHeight); + panel->inputEditorWidget->setMaximumHeight (targetHeight); + } +} + /** * @brief 通过 \c eval_scheme 将按键按下事件转发到 Scheme 层。 * @param event Qt 按键事件。 diff --git a/src/Plugins/Qt/qt_chat_tab_widget.hpp b/src/Plugins/Qt/qt_chat_tab_widget.hpp index f12f570745..067515e5ea 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.hpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp @@ -26,6 +26,7 @@ class QPushButton; class QSpacerItem; class QStackedWidget; class QString; +class QTimer; class QToolBar; class QVBoxLayout; class QEvent; @@ -247,7 +248,27 @@ class QTChatTabWidget : public QWidget { */ void toggle_sidebar (); + /** + * @brief 根据输入内容自适应调整输入框高度。 + * @param panel 目标会话面板。 + */ + void adjust_input_height (ChatConversationPanel* panel); + public: + /** + * @brief 计算输入文档的段落(行)数。 + * @param body TeXmacs 文档树。 + * @return 段落数量。 + */ + static int count_input_lines (tree body); + + /** + * @brief 判断文档主体是否实际为空。 + * @param body TeXmacs 文档树。 + * @return 若主体不含可见内容则返回 true。 + */ + static bool is_empty_document_body (tree body); + /** * @brief 为 Chat Tab 安装主菜单栏内容。 * @param menuWidget 菜单 widget。 diff --git a/tests/Plugins/Qt/qt_chat_tab_widget_test.cpp b/tests/Plugins/Qt/qt_chat_tab_widget_test.cpp new file mode 100644 index 0000000000..4dd724db94 --- /dev/null +++ b/tests/Plugins/Qt/qt_chat_tab_widget_test.cpp @@ -0,0 +1,75 @@ + +/****************************************************************************** + * MODULE : qt_chat_tab_widget_test.cpp + * DESCRIPTION: Tests for QTChatTabWidget helper functions + * COPYRIGHT : (C) 2026 Mogan STEM + ******************************************************************************/ + +#include "Qt/qt_chat_tab_widget.hpp" +#include "base.hpp" +#include + +using namespace moebius; + +class TestChatTabWidget : public QObject { + Q_OBJECT + +private slots: + void init () { init_lolly (); } + + void test_count_input_lines_empty_document () { + tree empty_doc= tree (DOCUMENT, ""); + QCOMPARE (QTChatTabWidget::count_input_lines (empty_doc), 1); + } + + void test_count_input_lines_single_paragraph () { + tree doc= tree (DOCUMENT, "hello"); + QCOMPARE (QTChatTabWidget::count_input_lines (doc), 1); + } + + void test_count_input_lines_multiple_paragraphs () { + tree doc= tree (DOCUMENT, "para1", "para2", "para3"); + QCOMPARE (QTChatTabWidget::count_input_lines (doc), 3); + } + + void test_count_input_lines_not_document () { + tree not_doc= tree (WITH, "font", "roman", "hello"); + QCOMPARE (QTChatTabWidget::count_input_lines (not_doc), 1); + } + + void test_count_input_lines_empty_string_only () { + // DOCUMENT with only an empty string atom + tree doc= tree (DOCUMENT, ""); + QCOMPARE (QTChatTabWidget::count_input_lines (doc), 1); + } + + void test_is_empty_document_body_truly_empty () { + // tree(DOCUMENT) 在 TeXmacs 中实际创建的是带有一个空子节点的 DOCUMENT + // 空文档的标准表示是 tree(DOCUMENT, "") + tree empty_doc= tree (DOCUMENT, ""); + QVERIFY (QTChatTabWidget::is_empty_document_body (empty_doc)); + } + + void test_is_empty_document_body_with_empty_string () { + tree doc= tree (DOCUMENT, ""); + QVERIFY (QTChatTabWidget::is_empty_document_body (doc)); + } + + void test_is_empty_document_body_not_empty () { + tree doc= tree (DOCUMENT, "hello"); + QVERIFY (!QTChatTabWidget::is_empty_document_body (doc)); + } + + void test_is_empty_document_body_not_document () { + tree not_doc= tree (WITH, "font", "roman", "hello"); + QVERIFY (!QTChatTabWidget::is_empty_document_body (not_doc)); + } + + void test_is_empty_document_body_multiple_paragraphs () { + tree doc= tree (DOCUMENT, "para1", "para2"); + QVERIFY (!QTChatTabWidget::is_empty_document_body (doc)); + } +}; + +QTEST_MAIN (TestChatTabWidget) +#include "qt_chat_tab_widget_test.moc"