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
71 changes: 71 additions & 0 deletions devel/0986.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,74 @@
`tests/Plugins/Qt/qt_chat_controller_test.cpp`(新增 4 个
`composeAiInputBody` 用例)
- 删除:`TeXmacs/tests/1243.scm`、`TeXmacs/tests/python/1243.py`

### 追加修订(2026-09-15,基于 main;第二步已合并)

#### What

1. AI 操作栏水平位置由「与锚行左缘对齐」改为「居中于锚行」;
2. 出现位置按选择方向决定:从上往下选择显示在最后选中文字(选区底行)
的下一行(原行为),从下往上选择显示在最后选中文字(选区顶行)的
上一行;首选侧放不下时退到另一侧,最终裁剪到视口内;
3. cmd+j / ctrl+j(`std j`)改为:未选中文字时开关 AI 侧边栏(原语义),
有选中文字时直接触发操作栏的「对话」动作(引用选区进当前会话输入区,
不自动发送,与点击「对话」按钮一致)。

#### Why

1. 左缘对齐在长选区/窄末行下视觉偏置,居中更贴合悬浮工具栏惯例;
2. 操作栏应贴着「最后选中」的一端出现——从下往上选时最后选中的文字在
选区顶部,固定显示在选区下方会把栏甩到远离落笔处;
3. cmd+j 作为 AI 入口,选中文字时用户意图是「对选中内容发起对话」而非
开侧边栏,直接路由到操作栏「对话」可免除先找按钮一步。

#### How

- 方向判定 `edit_interface_rep::selection_made_upward`:选区按文档序规范
化存储(start ≤ end),方向信息只在光标上——拖拽与 shift+方向键选择
结束时光标 `tp` 停在「最后选中」的一端(mouse_drag 的 p2 与 tp 同源同
参,严格相等),故 `path_less_eq (tp, selection_get_start ())` 为真即
从下往上选择;选区为空时 start 为空路径,判定自然回落到向下。
- 方向与锚行在同一时刻确定并**随调用链一路传入 popup 缓存**:
`update_translate_popup` 算一次方向 → `get_selection_last_rect (upward)`
取锚行(向下取屏幕最下方、同行最右;向上取屏幕最上方、同行最左)→
`show_translate_popup (selr, upward, ...)` → `qt_simple_widget` 调
`popup->setUpward (upward)` 存入缓存。`getCachedPosition` 只读缓存字段,
不在定位时回查编辑器活态——避免选区变化后「位置按新方向、锚行是旧的」
的失配,也省掉每 tick 的 dynamic_cast 与重复计算。
- `QTMAiTranslatePopup::getCachedPosition`:x 为锚行水平中心减栏宽一半;
纵向以方向无关的 `first/backup` 两候选取首选侧,放不下退另一侧,最终
`clampToViewport` 收敛。坐标换算(逻辑→像素 + 留白补偿)与视口裁剪
提取为基类 `QTMBasePopup::selectionEdgePixels` / `clampToViewport`,
基类与派生定位共用(原先两处复制,会随维护漂移)。
- 操作栏动作统一入口 `edit_interface_rep::ai_action (action)`:第二步(已
合并进 main)在 `onActionTriggered` 槽内的取选区 +
`qt_chat_ai_send_selection` + dismiss 流程移入此方法(含「选区须在打开
侧边栏前捕获」约束),QML 按钮 `triggered(action)` 槽改为一行转调。
- 快捷键:cmd/ctrl+j 的真实派发点是 `QTMWidget::keyPressEvent` 里 [0247]
引入的 C++ 拦截(在 scheme 键位表之前 `return`)——拦截改为:有选区
(编辑器 `selection_active_any`)先走 `ai_action ("chat")`,无选区保持
原 dock 开关逻辑不变。曾同步新增 scheme 侧 `ai-chat-shortcut` +
`("std j")` 改绑 + glue `ai-action` + `editor.hpp` 虚函数,但 Qt 是
唯一编辑器前端、该键在 Qt 上被 C++ 拦截,四层均为不可达死代码,
简化审查后整链移除(`ai_action` 保留为纯 C++ 方法)。
`selection-active-any?` 对纯点击后的退化选区
(`simple_range (p, p)` 判空)为假,天然区分「未选中」。cmd+j 的对话
动作只看选区、不看操作栏可见性(同按钮白名单 translate/chat),
数学/代码/verbatim 等操作栏不弹出的上下文里也可用 cmd+j 把选区引用进
对话。

#### 涉及文件(2026-09-15 追加修订)

- 修改:`src/Plugins/Qt/QTMWidget.cpp`(cmd/ctrl+j 拦截增加选区分支,
快捷键真实派发点)、
`src/Edit/Interface/edit_interface.hpp`、
`src/Edit/Interface/edit_mouse.cpp`(`selection_made_upward`、
`ai_action` 承接第二步流程、方向随链下传,新增 `#ifdef QTTEXMACS`
引入 `qt_chat_controller.hpp`)、
`src/Plugins/Qt/qt_simple_widget.hpp/.cpp`(`show_translate_popup`
透传方向)、
`src/Plugins/Qt/QTMBasePopup.hpp/.cpp`(提取 `selectionEdgePixels` /
`clampToViewport` 共享助手)、
`src/Plugins/Qt/QTMAiTranslatePopup.hpp/.cpp`(槽改转调;定位居中 +
缓存方向 + 共享助手)
13 changes: 9 additions & 4 deletions src/Edit/Interface/edit_interface.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -287,15 +287,20 @@ class edit_interface_rep : virtual public editor_rep {
void update_text_popup ();
void invalidate_text_popup_cache (); // 重置工具栏缓存

bool should_show_translate_popup ();
rectangle get_selection_last_rect (); // 选区最末(屏幕最下方、同行最右)矩形
void show_translate_popup (rectangle selr, double magf, int scroll_x,
int scroll_y, int canvas_x, int canvas_y);
bool should_show_translate_popup ();
bool selection_made_upward (); // 从下往上选择(光标停在选区起点一侧)
// 「最后选中文字」所在行(按选择方向取顶行/底行)
rectangle get_selection_last_rect (bool upward);
void show_translate_popup (rectangle selr, bool upward, double magf,
int scroll_x, int scroll_y, int canvas_x,
int canvas_y);
void hide_translate_popup ();
bool is_point_in_translate_popup (SI x, SI y);
void update_translate_popup ();
void dismiss_translate_popup (); // 点击按钮后本次选区内不再弹出
void invalidate_translate_popup_cache ();
// 操作栏动作统一入口(按钮点击与 cmd/ctrl+j 共用)
void ai_action (string action);

void show_ghost_popup ();
void hide_ghost_popup ();
Expand Down
55 changes: 45 additions & 10 deletions src/Edit/Interface/edit_mouse.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#ifdef QTTEXMACS
#include "qapplication.h"
#include "qnamespace.h"
#include "qt_chat_controller.hpp"
#include "qt_simple_widget.hpp"
#endif
#include "scheme.hpp"
Expand Down Expand Up @@ -1467,11 +1468,20 @@ edit_interface_rep::should_show_translate_popup () {
#endif
}

bool
edit_interface_rep::selection_made_upward () {
// 选区按文档序规范化存储(start ≤ end),选择方向只能由光标位置判
// 断:拖拽与 shift+方向键选择结束时,光标 tp 停在「最后选中」的一
// 端——停在选区起点一侧即从下往上选择
return path_less_eq (tp, selection_get_start ());
}

rectangle
edit_interface_rep::get_selection_last_rect () {
edit_interface_rep::get_selection_last_rect (bool upward) {
// 优先用 apply_changes 维护的已绘制选区矩形(与屏幕所见一致,且避免在
// box 树重建的瞬态窗口期重走 find_check_selection);缓存为空才遍历。
// 取屏幕最下方、同行最右的矩形(即最后一个选中文字所在行)
// 取「最后一个选中文字」所在行:向下选择为屏幕最下方、同行最右;向上
// 选择为屏幕最上方、同行最左
rectangles rs= selection_rects;
if (is_nil (rs)) {
path p1, p2;
Expand All @@ -1482,19 +1492,26 @@ edit_interface_rep::get_selection_last_rect () {
rectangle last= rs->item;
for (; !is_nil (rs); rs= rs->next) {
rectangle r= rs->item;
// 屏幕下方对应逻辑 y 更小
if (r->y2 < last->y2 || (r->y2 == last->y2 && r->x2 > last->x2)) last= r;
if (upward) {
// 屏幕上方对应逻辑 y 更大
if (r->y2 > last->y2 || (r->y2 == last->y2 && r->x1 < last->x1)) last= r;
}
else {
// 屏幕下方对应逻辑 y 更小
if (r->y2 < last->y2 || (r->y2 == last->y2 && r->x2 > last->x2)) last= r;
}
}
return last;
}

void
edit_interface_rep::show_translate_popup (rectangle selr, double magf,
int scroll_x, int scroll_y,
int canvas_x, int canvas_y) {
edit_interface_rep::show_translate_popup (rectangle selr, bool upward,
double magf, int scroll_x,
int scroll_y, int canvas_x,
int canvas_y) {
#ifdef QTTEXMACS
if (qt_simple_widget_rep* qsw= dynamic_cast<qt_simple_widget_rep*> (this)) {
qsw->show_translate_popup (selr, magf, scroll_x, scroll_y, canvas_x,
qsw->show_translate_popup (selr, upward, magf, scroll_x, scroll_y, canvas_x,
canvas_y);
}
#endif
Expand Down Expand Up @@ -1532,20 +1549,38 @@ edit_interface_rep::invalidate_translate_popup_cache () {
translate_popup_dismissed = false;
}

void
edit_interface_rep::ai_action (string action) {
// AI 操作栏动作统一入口(translate/polish/chat):操作栏按钮点击与
// cmd/ctrl+j 快捷键共用此路径
#ifdef QTTEXMACS
// 选区须在打开侧边栏(焦点/视图切换)之前捕获;无选区时忽视操作。
// 翻译引用选区并自动发送,对话只填入输入区;润色后续接入,暂仅关闭操作栏
if ((action == "translate" || action == "chat") && selection_active_any ())
qt_chat_ai_send_selection (selection_get (), action);
#else
(void) action;
#endif
dismiss_translate_popup ();
}

void
edit_interface_rep::update_translate_popup () {
if (left_dragging || translate_popup_dismissed) {
hide_translate_popup ();
return;
}
if (should_show_translate_popup ()) {
rectangle selr= get_selection_last_rect ();
// 方向与锚行在同一时刻确定,随锚行一路传入 popup 缓存——定位时不再
// 回查编辑器活态,避免选区变化后位置与锚行失配
bool upward= selection_made_upward ();
rectangle selr = get_selection_last_rect (upward);
if (selr->x1 >= selr->x2 || selr->y1 >= selr->y2) {
hide_translate_popup ();
return;
}
// 选区移出视口由 popup 侧的 selectionInView 判定并隐藏
show_translate_popup (selr, magf, get_scroll_x (), get_scroll_y (),
show_translate_popup (selr, upward, magf, get_scroll_x (), get_scroll_y (),
get_canvas_x (), get_canvas_y ());
}
else {
Expand Down
56 changes: 19 additions & 37 deletions src/Plugins/Qt/QTMAiTranslatePopup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ QTMAiTranslatePopup::QTMAiTranslatePopup (QWidget* parent,
layout->setContentsMargins (0, 0, 0, 0);
layout->addWidget (quick);

// 翻译/对话经根信号回传后引用选区到 AI 侧边栏(翻译自动发送),润色后续接入
// 动作经根信号回传后由 edit_interface_rep::ai_action 统一处理(引用选区
// 到 AI 侧边栏,翻译自动发送),润色后续接入
if (QQuickItem* root= quick->rootObject ()) {
QObject::connect (root, SIGNAL (triggered (QString)), this,
SLOT (onActionTriggered (QString)));
Expand All @@ -81,12 +82,9 @@ QTMAiTranslatePopup::QTMAiTranslatePopup (QWidget* parent,
void
QTMAiTranslatePopup::onActionTriggered (const QString& action) {
if (edit_interface_rep* ed= dynamic_cast<edit_interface_rep*> (this->owner)) {
// 选区须在打开侧边栏(焦点/视图切换)之前捕获;无选区时忽视操作。
// 翻译引用选区并自动发送,对话只填入输入区;润色后续接入,暂仅关闭操作栏
string act= from_qstring_utf8 (action);
if ((act == "translate" || act == "chat") && ed->selection_active_any ())
qt_chat_ai_send_selection (ed->selection_get (), act);
ed->dismiss_translate_popup ();
// 动作统一入口(引用选区到 AI 侧边栏的流程与快捷键共用),见
// edit_interface_rep::ai_action
ed->ai_action (from_qstring_utf8 (action));
}
}

Expand Down Expand Up @@ -156,41 +154,25 @@ QTMAiTranslatePopup::autoSize () {
void
QTMAiTranslatePopup::getCachedPosition (qt_renderer_rep* ren, int& x, int& y) {
(void) ren;
rectangle selr = cached_rect;
double inv_unit= 1.0 / 256.0;
// 选区矩形不变式 y1 < y2:y2 为上缘、y1 为下缘(逻辑坐标 y 向上)
double sel_top_logic = selr->y2;
double sel_bottom_logic= selr->y1;

double left_px=
((selr->x1 - cached_scroll_x) * cached_magf + cached_canvas_x) * inv_unit;
double top_px= -(sel_top_logic - cached_scroll_y) * cached_magf * inv_unit;
double bottom_px=
-(sel_bottom_logic - cached_scroll_y) * cached_magf * inv_unit;

// 视口大于画布表面时顶部存在居中留白,需补偿(同基类算法)
double blank_top= blank_top_offset ();
top_px+= blank_top;
bottom_px+= blank_top;
double center_px, top_px, bottom_px;
selectionEdgePixels (center_px, top_px, bottom_px);

const int gap= 4;
// 始终显示在选中文字的下一行:与最末选区行左缘对齐、位于其下方
x= int (std::round (left_px));
y= int (std::round (bottom_px + gap));

// 水平居中于「最后选中文字」所在行
x= int (std::round (center_px - cached_width * 0.5));

// 向下选择显示在锚行下方、向上选择显示在锚行上方;首选侧放不下退到
// 另一侧(两侧均放不下时由 clampToViewport 收敛到视口内,结果一致)
int first = cached_upward ? int (std::round (top_px - cached_height - gap))
: int (std::round (bottom_px + gap));
int backup= cached_upward ? int (std::round (bottom_px + gap))
: int (std::round (top_px - cached_height - gap));
y = first;
if (owner && owner->scrollarea () && owner->scrollarea ()->viewport ()) {
int vp_w= owner->scrollarea ()->viewport ()->width ();
int vp_h= owner->scrollarea ()->viewport ()->height ();

// 下方放不下时退到选区上方,最终裁剪到视口内
if (y + cached_height > vp_h) {
y= int (std::round (top_px - cached_height - gap));
}
if (x < 0) x= 0;
if (x + cached_width > vp_w) x= vp_w - cached_width;
if (y < 0) y= 0;
if (y + cached_height > vp_h) y= vp_h - cached_height;
if (y < 0 || y + cached_height > vp_h) y= backup;
}
clampToViewport (x, y);
}

void
Expand Down
9 changes: 8 additions & 1 deletion src/Plugins/Qt/QTMAiTranslatePopup.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ class QTMAiTranslatePopup : public QTMBasePopup {
int canvas_y) override;
void autoSize () override;

// 选择方向(由下往上为 true):与锚行同时由编辑器侧算出,须在 showPopup
// 前调用——定位只读缓存,不回查编辑器活态
void setUpward (bool upward) { cached_upward= upward; }

protected:
// 定位到选区最末行的下一行(左缘对齐),下方放不下时退到选区上方
// 水平居中于「最后选中文字」所在行,纵向按选择方向取该行下方/上方,
// 首选侧放不下时退到另一侧
void getCachedPosition (qt_renderer_rep* ren, int& x, int& y) override;

// qApp 级截获无按键 move,持续同步 hover(见 cpp,悬浮可靠性关键)
Expand All @@ -47,6 +52,8 @@ private slots:
void syncHover ();

QQuickWidget* quick;
// 上次显示时的选择方向:定位输出仅为缓存的函数
bool cached_upward= false;
// 上次同步时光标是否在栏内:决定「进入/栏内移动/首次离开」三态是否需要
// 同步,栏外远处的 move 不再触发 Quick 场景命中测试
bool hover_inside= false;
Expand Down
34 changes: 24 additions & 10 deletions src/Plugins/Qt/QTMBasePopup.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,41 @@ QTMBasePopup::cachePosition (rectangle selr, double magf, int scroll_x,
}

void
QTMBasePopup::getCachedPosition (qt_renderer_rep* ren, int& x, int& y) {
QTMBasePopup::selectionEdgePixels (double& cx_px, double& top_px,
double& bottom_px) const {
rectangle selr = cached_rect;
double inv_unit = 1.0 / 256.0;
double cx_logic = (selr->x1 + selr->x2) * 0.5;
double sel_top_logic = (selr->y1 > selr->y2) ? selr->y1 : selr->y2;
double sel_bottom_logic= (selr->y1 > selr->y2) ? selr->y2 : selr->y1;

// 使用公式计算QT坐标
double cx_px=
cx_px=
((cx_logic - cached_scroll_x) * cached_magf + cached_canvas_x) * inv_unit;
double top_px= -(sel_top_logic - cached_scroll_y) * cached_magf * inv_unit;
double bottom_px=
-(sel_bottom_logic - cached_scroll_y) * cached_magf * inv_unit;
top_px = -(sel_top_logic - cached_scroll_y) * cached_magf * inv_unit;
bottom_px= -(sel_bottom_logic - cached_scroll_y) * cached_magf * inv_unit;

// 修正:视口 > 表面:存在空白顶部
double blank_top= blank_top_offset ();
top_px+= blank_top;
bottom_px+= blank_top;
}

void
QTMBasePopup::clampToViewport (int& x, int& y) const {
if (owner && owner->scrollarea () && owner->scrollarea ()->viewport ()) {
int vp_w= owner->scrollarea ()->viewport ()->width ();
int vp_h= owner->scrollarea ()->viewport ()->height ();
if (x < 0) x= 0;
if (x + cached_width > vp_w) x= vp_w - cached_width;
if (y < 0) y= 0;
if (y + cached_height > vp_h) y= vp_h - cached_height;
}
}

void
QTMBasePopup::getCachedPosition (qt_renderer_rep* ren, int& x, int& y) {
double cx_px, top_px, bottom_px;
selectionEdgePixels (cx_px, top_px, bottom_px);

const int above_y=
int (std::round (top_px - cached_height - 10)); // 在选区顶部上方显示
Expand All @@ -118,10 +135,7 @@ QTMBasePopup::getCachedPosition (qt_renderer_rep* ren, int& x, int& y) {
y= std::max (0, (vp_h - cached_height) / 2);
}

if (x < 0) x= 0;
if (x + cached_width > vp_w) x= vp_w - cached_width;
if (y < 0) y= 0;
if (y + cached_height > vp_h) y= vp_h - cached_height;
clampToViewport (x, y);
}
else {
if (y < 0) y= below_y;
Expand Down
8 changes: 8 additions & 0 deletions src/Plugins/Qt/QTMBasePopup.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ class QTMBasePopup : public QWidget {
// 计算显示位置(虚函数,子类可重写不同位置算法)
virtual void getCachedPosition (qt_renderer_rep* ren, int& x, int& y);

// 选区矩形中心/上下缘的逻辑→像素换算(含滚动/画布偏移与顶部留白补偿),
// 各子类定位算法共用
void selectionEdgePixels (double& cx_px, double& top_px,
double& bottom_px) const;

// 把位置裁剪到视口内,各子类定位算法共用
void clampToViewport (int& x, int& y) const;

// 检查选区是否在视口内
virtual bool selectionInView () const;

Expand Down
Loading
Loading