diff --git a/devel/0986.md b/devel/0986.md index 47d6be69a6..d8845ca6fc 100644 --- a/devel/0986.md +++ b/devel/0986.md @@ -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`(槽改转调;定位居中 + + 缓存方向 + 共享助手) diff --git a/src/Edit/Interface/edit_interface.hpp b/src/Edit/Interface/edit_interface.hpp index dfc10978b7..0aef4be61f 100644 --- a/src/Edit/Interface/edit_interface.hpp +++ b/src/Edit/Interface/edit_interface.hpp @@ -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 (); diff --git a/src/Edit/Interface/edit_mouse.cpp b/src/Edit/Interface/edit_mouse.cpp index 608986a17d..6f0d490af2 100644 --- a/src/Edit/Interface/edit_mouse.cpp +++ b/src/Edit/Interface/edit_mouse.cpp @@ -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" @@ -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; @@ -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 (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 @@ -1532,6 +1549,21 @@ 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) { @@ -1539,13 +1571,16 @@ edit_interface_rep::update_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 { diff --git a/src/Plugins/Qt/QTMAiTranslatePopup.cpp b/src/Plugins/Qt/QTMAiTranslatePopup.cpp index a44ac53332..9c00f13f7a 100644 --- a/src/Plugins/Qt/QTMAiTranslatePopup.cpp +++ b/src/Plugins/Qt/QTMAiTranslatePopup.cpp @@ -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))); @@ -81,12 +82,9 @@ QTMAiTranslatePopup::QTMAiTranslatePopup (QWidget* parent, void QTMAiTranslatePopup::onActionTriggered (const QString& action) { if (edit_interface_rep* ed= dynamic_cast (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)); } } @@ -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 diff --git a/src/Plugins/Qt/QTMAiTranslatePopup.hpp b/src/Plugins/Qt/QTMAiTranslatePopup.hpp index e6f414345b..c4b2f8e6df 100644 --- a/src/Plugins/Qt/QTMAiTranslatePopup.hpp +++ b/src/Plugins/Qt/QTMAiTranslatePopup.hpp @@ -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,悬浮可靠性关键) @@ -47,6 +52,8 @@ private slots: void syncHover (); QQuickWidget* quick; + // 上次显示时的选择方向:定位输出仅为缓存的函数 + bool cached_upward= false; // 上次同步时光标是否在栏内:决定「进入/栏内移动/首次离开」三态是否需要 // 同步,栏外远处的 move 不再触发 Quick 场景命中测试 bool hover_inside= false; diff --git a/src/Plugins/Qt/QTMBasePopup.cpp b/src/Plugins/Qt/QTMBasePopup.cpp index 05f944a5bd..be24ffc0bf 100644 --- a/src/Plugins/Qt/QTMBasePopup.cpp +++ b/src/Plugins/Qt/QTMBasePopup.cpp @@ -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)); // 在选区顶部上方显示 @@ -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; diff --git a/src/Plugins/Qt/QTMBasePopup.hpp b/src/Plugins/Qt/QTMBasePopup.hpp index b0360d05b8..38f25b7480 100644 --- a/src/Plugins/Qt/QTMBasePopup.hpp +++ b/src/Plugins/Qt/QTMBasePopup.hpp @@ -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; diff --git a/src/Plugins/Qt/QTMWidget.cpp b/src/Plugins/Qt/QTMWidget.cpp index 72255f6b93..767152838d 100644 --- a/src/Plugins/Qt/QTMWidget.cpp +++ b/src/Plugins/Qt/QTMWidget.cpp @@ -257,7 +257,8 @@ QTMWidget::keyPressEvent (QKeyEvent* event) { cout << "[QTMWidget::keyPressEvent] key=" << event->key () << " modifiers=" << event->modifiers () << "\n"; - // Ctrl/Cmd+J:切换 AI 聊天侧边栏(绕过 Scheme slot 链路) + // Ctrl/Cmd+J:有选区时直接触发操作栏「对话」(与按钮同路径,选区引用进 + // AI 侧边栏);无选区时切换 AI 聊天侧边栏(绕过 Scheme slot 链路) if (event->key () == Qt::Key_J && (event->modifiers () & (Qt::ControlModifier | Qt::MetaModifier))) { // 检查是否在 chat tab 模式(非 dock 的 QTChatTabWidget 内) @@ -273,6 +274,15 @@ QTMWidget::keyPressEvent (QKeyEvent* event) { p= p->parentWidget (); } if (!inChatTab) { + // 与操作栏「对话」按钮同路径;选区在 ai_action 内部先于侧边栏打开 + // 捕获 + if (edit_interface_rep* ed= + dynamic_cast (tm_widget ())) { + if (ed->selection_active_any ()) { + ed->ai_action ("chat"); + return; + } + } QMainWindow* mw= qobject_cast (window ()); if (mw) { QDockWidget* dock= mw->findChild ("chatSideDock"); diff --git a/src/Plugins/Qt/qt_simple_widget.cpp b/src/Plugins/Qt/qt_simple_widget.cpp index bf465623b7..379aa368cb 100644 --- a/src/Plugins/Qt/qt_simple_widget.cpp +++ b/src/Plugins/Qt/qt_simple_widget.cpp @@ -933,11 +933,13 @@ qt_simple_widget_rep::ensure_translate_popup () { } void -qt_simple_widget_rep::show_translate_popup (rectangle selr, double magf, - int scroll_x, int scroll_y, - int canvas_x, int canvas_y) { +qt_simple_widget_rep::show_translate_popup (rectangle selr, bool upward, + double magf, int scroll_x, + int scroll_y, int canvas_x, + int canvas_y) { ensure_translate_popup (); qt_renderer_rep* ren= the_qt_renderer (); + translatePopup->setUpward (upward); translatePopup->showPopup (ren, selr, magf, scroll_x, scroll_y, canvas_x, canvas_y); } diff --git a/src/Plugins/Qt/qt_simple_widget.hpp b/src/Plugins/Qt/qt_simple_widget.hpp index 0e988621e8..fc16885b34 100644 --- a/src/Plugins/Qt/qt_simple_widget.hpp +++ b/src/Plugins/Qt/qt_simple_widget.hpp @@ -145,8 +145,9 @@ class qt_simple_widget_rep : public qt_widget_rep { ////////////////////// AI translate popup support void ensure_translate_popup (); - void show_translate_popup (rectangle selr, double magf, int scroll_x, - int scroll_y, int canvas_x, int canvas_y); + 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 (); void scroll_translate_popup_by (SI x, SI y); bool is_point_in_translate_popup (SI x, SI y);