Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions devel/0137.md
Original file line number Diff line number Diff line change
@@ -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` 节点的子节点数)来调整高度,而非实际的视觉行数。因此:
- 按回车键创建新段落时,输入框能正确变大。
- 粘贴不带换行符的长文本时,由于段落数不变,输入框**不会**随内容自动增高。

后续如需支持粘贴长文本时的自适应,需要改用能够计算实际排版行数的方式(如查询编辑器内部排版后的行高)。
110 changes: 88 additions & 22 deletions src/Plugins/Qt/qt_chat_tab_widget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
#include <QPushButton>
#include <QScrollBar>
#include <QSpacerItem>
#include <QSplitter>
#include <QStackedWidget>
#include <QTimer>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout>
Expand Down Expand Up @@ -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;
/// 左侧边栏收起后的窄条宽度(像素)。
Expand Down Expand Up @@ -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;
/// 发送按钮水平内边距。
Expand Down Expand Up @@ -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 单个会话面板的内部数据。
*/
Expand Down Expand Up @@ -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; "
Expand All @@ -483,18 +494,25 @@ 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);
if (QTMWidget* editor= inputQWidget->findChild<QTMWidget*> ()) {
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; "
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 按键事件。
Expand Down
21 changes: 21 additions & 0 deletions src/Plugins/Qt/qt_chat_tab_widget.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class QPushButton;
class QSpacerItem;
class QStackedWidget;
class QString;
class QTimer;
class QToolBar;
class QVBoxLayout;
class QEvent;
Expand Down Expand Up @@ -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。
Expand Down
75 changes: 75 additions & 0 deletions tests/Plugins/Qt/qt_chat_tab_widget_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <QtTest/QtTest>

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"
Loading