diff --git a/TeXmacs/misc/images/floating-search/down-white.svg b/TeXmacs/misc/images/floating-search/down-white.svg
new file mode 100644
index 0000000000..a8bc6f93eb
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/down-white.svg
@@ -0,0 +1,12 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/down.svg b/TeXmacs/misc/images/floating-search/down.svg
new file mode 100644
index 0000000000..c6115207a6
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/down.svg
@@ -0,0 +1,12 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/math-mode-white.svg b/TeXmacs/misc/images/floating-search/math-mode-white.svg
new file mode 100644
index 0000000000..c88232ab2b
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/math-mode-white.svg
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/math-mode.svg b/TeXmacs/misc/images/floating-search/math-mode.svg
new file mode 100644
index 0000000000..d78999d32e
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/math-mode.svg
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/text-mode-white.svg b/TeXmacs/misc/images/floating-search/text-mode-white.svg
new file mode 100644
index 0000000000..476050b613
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/text-mode-white.svg
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/text-mode.svg b/TeXmacs/misc/images/floating-search/text-mode.svg
new file mode 100644
index 0000000000..7ff9c9e988
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/text-mode.svg
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/up-white.svg b/TeXmacs/misc/images/floating-search/up-white.svg
new file mode 100644
index 0000000000..74ec95b068
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/up-white.svg
@@ -0,0 +1,12 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/floating-search/up.svg b/TeXmacs/misc/images/floating-search/up.svg
new file mode 100644
index 0000000000..95ef9e9fc9
--- /dev/null
+++ b/TeXmacs/misc/images/floating-search/up.svg
@@ -0,0 +1,12 @@
+
+
+
\ No newline at end of file
diff --git a/TeXmacs/misc/images/images.qrc b/TeXmacs/misc/images/images.qrc
index 1669da501a..c08353c8d1 100644
--- a/TeXmacs/misc/images/images.qrc
+++ b/TeXmacs/misc/images/images.qrc
@@ -25,6 +25,11 @@
llm-chat/thinking.svg
llm-chat/thinking-white.svg
+ floating-search/down.svg
+ floating-search/up.svg
+ floating-search/text-mode.svg
+ floating-search/math-mode.svg
+
ocr-button/left-align-white.svg
ocr-button/middle-align-white.svg
@@ -43,6 +48,11 @@
pdf-reader/open-white.svg
pdf-reader/screenshot-white.svg
+ floating-search/down-white.svg
+ floating-search/up-white.svg
+ floating-search/text-mode-white.svg
+ floating-search/math-mode-white.svg
+
tutorial/ocr-tutorial.gif
tutorial/magic-paste-tutorial.gif
diff --git a/TeXmacs/misc/themes/liii-night.css b/TeXmacs/misc/themes/liii-night.css
index b1e75cbd4a..e2bbc1f0cf 100644
--- a/TeXmacs/misc/themes/liii-night.css
+++ b/TeXmacs/misc/themes/liii-night.css
@@ -1491,3 +1491,58 @@ QPushButton#chat-tab-conversation-btn:checked {
QWidget#centralWidget QPushButton#chat-tab-conversation-btn:checked {
background-color: #1a3a5a;
}
+
+/****************************************************************************
+* 悬浮搜索栏样式
+****************************************************************************/
+
+QWidget#centralWidget QWidget#floating_search_bar {
+ background: #2d2d2d;
+ border: none;
+}
+
+QWidget#floating_search_bar QToolButton {
+ border: none;
+ background: transparent;
+}
+
+QWidget#floating_search_bar QToolButton:hover {
+ background: rgba(255, 255, 255, 30);
+}
+
+QWidget#floating_search_bar QToolButton:pressed {
+ background: rgba(255, 255, 255, 50);
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-prev {
+ qproperty-icon: url(":/floating-search/up-white.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-next {
+ qproperty-icon: url(":/floating-search/down-white.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-close {
+ qproperty-icon: url(":/tabpage/close-white.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-mode-text {
+ qproperty-icon: url(":/floating-search/text-mode-white.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-mode-math {
+ qproperty-icon: url(":/floating-search/math-mode-white.svg");
+}
+
+QWidget#floating_search_bar QWidget#floating-search-input {
+ border: 1px solid #555555;
+}
+
+QWidget#floating_search_bar QWidget#floating-search-input:focus {
+ border: 1px solid #215a6a;
+}
+
+QWidget#floating_search_bar QLabel#floating-search-info {
+ background: transparent;
+ color: #ffffff;
+}
diff --git a/TeXmacs/misc/themes/liii.css b/TeXmacs/misc/themes/liii.css
index 0a9fd19a7d..26a3b513f4 100644
--- a/TeXmacs/misc/themes/liii.css
+++ b/TeXmacs/misc/themes/liii.css
@@ -1410,3 +1410,57 @@ QPushButton#chat-tab-conversation-btn:checked {
font-weight: 600;
background-color: #e8eefc;
}
+
+/****************************************************************************
+* 悬浮搜索栏样式
+****************************************************************************/
+QWidget#floating_search_bar {
+ background: #f3f3f3;
+ border: none;
+}
+
+QWidget#floating_search_bar QToolButton {
+ border: none;
+ background: transparent;
+}
+
+QWidget#floating_search_bar QToolButton:hover {
+ background: rgba(0, 0, 0, 30);
+}
+
+QWidget#floating_search_bar QToolButton:pressed {
+ background: rgba(0, 0, 0, 50);
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-prev {
+ qproperty-icon: url(":/floating-search/up.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-next {
+ qproperty-icon: url(":/floating-search/down.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-close {
+ qproperty-icon: url(":/tabpage/close.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-mode-text {
+ qproperty-icon: url(":/floating-search/text-mode.svg");
+}
+
+QWidget#floating_search_bar QToolButton#floating-search-mode-math {
+ qproperty-icon: url(":/floating-search/math-mode.svg");
+}
+
+QWidget#floating_search_bar QWidget#floating-search-input {
+ border: 1px solid #d0d0d0;
+}
+
+QWidget#floating_search_bar QWidget#floating-search-input:focus {
+ border: 1px solid #215a6a;
+}
+
+QWidget#floating_search_bar QLabel#floating-search-info {
+ background: transparent;
+ color: #2c2c2c;
+}
diff --git a/TeXmacs/plugins/lang/dic/en_US/zh_CN.scm b/TeXmacs/plugins/lang/dic/en_US/zh_CN.scm
index c18a0c313c..b8691fcec1 100644
--- a/TeXmacs/plugins/lang/dic/en_US/zh_CN.scm
+++ b/TeXmacs/plugins/lang/dic/en_US/zh_CN.scm
@@ -465,6 +465,7 @@
("close replace tool (Esc)" "关闭替换窗 (Esc)")
("close window" "关闭窗口")
("close" "关闭")
+("Close (Esc)" "关闭 (Esc)")
("closed bezier" "闭合贝塞尔曲线")
("closed curve" "闭合曲线")
("closed smooth" "闭合平滑曲线")
@@ -1600,6 +1601,7 @@
("next screen" "前一屏")
("next similar" "相似的(后一个)")
("next" "后一个")
+("Next (Enter)" "下一个 (Enter)")
("no changes need to be saved" "没有任何更改需要保存")
("no dictionary for" "")
("no first indentation" "")
@@ -1608,6 +1610,8 @@
("no line break" "禁止换行")
("no matches found for" "")
("no matches found" "")
+("No matches" "无匹配")
+("%1 of %2" "%1 / %2")
("no more matches for" "")
("no more redo information available" "")
("no more undo information available" "")
@@ -1870,6 +1874,8 @@
("previous screen" "后一屏")
("previous similar" "相似的(前一个)")
("previous" "前一个")
+("Previous (Ctrl+Enter)" "上一个 (Ctrl+Enter)")
+("Previous (Cmd+Enter)" "上一个 (Cmd+Enter)")
("primary" "")
("prime" "")
("print all to file" "全部打印为文件")
diff --git a/TeXmacs/progs/generic/search-widgets.scm b/TeXmacs/progs/generic/search-widgets.scm
index 0c281c7a28..2a4047aadf 100644
--- a/TeXmacs/progs/generic/search-widgets.scm
+++ b/TeXmacs/progs/generic/search-widgets.scm
@@ -111,6 +111,15 @@
(when isreplace?
(set! isreplace? #f)
) ;when
+ ;; 更新浮动搜索栏的匹配计数
+ (when chat-tab-search-target
+ (if (== index-str "")
+ (qt-floating-search-set-match-info 0 0)
+ (let* ((parts (string-split index-str #\/))
+ (cur (string->number (car parts)))
+ (tot (string->number (cadr parts))))
+ (when (and cur tot)
+ (qt-floating-search-set-match-info cur tot)))))
(if (== index-str "")
(set-auxiliary-widget-title (translate search-replace-text))
(set-auxiliary-widget-title (string-append (translate search-replace-text) " (" index-str ")")
@@ -158,20 +167,26 @@
;; ----
;; 此函数用于管理搜索辅助缓冲区的生命周期,确保每个主文档视图有唯一的搜索缓冲区。
(tm-define (search-buffer)
- (with u
- (current-buffer)
- (if (and (url-rooted-tmfs? u)
- (== (url-head (url-head u)) (string->url "tmfs://aux/search"))
- ) ;and
- u
- (string->url (string-append "tmfs://aux/search/"
- (md5 (url->string (current-view-url)))
- "/"
- (url->string (url-tail (current-window)))
- ) ;string-append
- ) ;string->url
- ) ;if
- ) ;with
+ ;; chat tab 搜索激活且当前在 chat tab 上下文中时,返回保存的 aux buffer
+ (if (and chat-tab-search-active? chat-tab-search-aux
+ (or (== (current-buffer) chat-tab-search-aux)
+ (== (current-buffer) chat-tab-search-target)))
+ chat-tab-search-aux
+ (with u
+ (current-buffer)
+ (if (and (url-rooted-tmfs? u)
+ (== (url-head (url-head u)) (string->url "tmfs://aux/search"))
+ ) ;and
+ u
+ (string->url (string-append "tmfs://aux/search/"
+ (md5 (url->string (current-view-url)))
+ "/"
+ (url->string (url-tail (current-window)))
+ ) ;string-append
+ ) ;string->url
+ ) ;if
+ ) ;with
+ ) ;if
) ;tm-define
;; replace-buffer
@@ -329,29 +344,40 @@
) ;tm-define
(tm-define (master-buffer)
- (and (buffer-exists? (search-buffer))
- (with mas
- (buffer-get-master (search-buffer))
- (cond ((nnull? (buffer->windows mas)) mas)
- ((in? search-window (window-list))
- (buffer-set-master (search-buffer) (window->buffer search-window))
- (with-buffer (buffer-get-master (search-buffer))
- (set-search-reference (cursor-path))
- (set-search-filter)
- ) ;with-buffer
- (master-buffer)
- ) ;
- ((nnull? (window-list))
- (set! search-window (car (window-list)))
- (master-buffer)
- ) ;
- (else #f)
- ) ;cond
- ) ;with
- ) ;and
+ ;; chat tab 搜索激活且当前在 chat tab 上下文中时,返回保存的 target buffer
+ (if (and chat-tab-search-active? chat-tab-search-target
+ (or (== (current-buffer) chat-tab-search-aux)
+ (== (current-buffer) chat-tab-search-target)))
+ chat-tab-search-target
+ (and (buffer-exists? (search-buffer))
+ (with mas
+ (buffer-get-master (search-buffer))
+ (cond ((nnull? (buffer->windows mas)) mas)
+ ((in? search-window (window-list))
+ (buffer-set-master (search-buffer) (window->buffer search-window))
+ (with-buffer (buffer-get-master (search-buffer))
+ (set-search-reference (cursor-path))
+ (set-search-filter)
+ ) ;with-buffer
+ (master-buffer)
+ ) ;
+ ((nnull? (window-list))
+ (set! search-window (car (window-list)))
+ (master-buffer)
+ ) ;
+ (else #f)
+ ) ;cond
+ ) ;with
+ ) ;and
+ ) ;if
) ;tm-define
-(tm-define (inside-search-buffer?) (== (current-buffer) (search-buffer)))
+(tm-define (inside-search-buffer?)
+ (if (and chat-tab-search-active?
+ (or (== (current-buffer) chat-tab-search-aux)
+ (== (current-buffer) chat-tab-search-target)))
+ (== (current-buffer) chat-tab-search-aux)
+ (== (current-buffer) (search-buffer))))
(tm-define (inside-replace-buffer?) (== (current-buffer) (replace-buffer)))
@@ -417,16 +443,23 @@
) ;define
(define (accept-search-result? p)
- (or (== (get-init "mode") "src")
- (let* ((buf (buffer-tree))
- (rel (path-strip (cDr p) (tree->path buf)))
- (initial (cons 'attr (get-main-attrs get-init)))
- (old-env (get-search-filter))
- (new-env (tree-descendant-env* buf rel initial))
- ) ;
- ;; (display* p " ~> " new-env "\n")
- (check-same? (tm-children new-env) (tm-children old-env))
- ) ;let*
+ (or (and chat-tab-search-active?
+ (== chat-tab-search-mode "math")
+ (search-path-inside-math? p))
+ (== (get-init "mode") "src")
+ (catch #t
+ (lambda ()
+ (let* ((buf (buffer-tree))
+ (rel (path-strip (cDr p) (tree->path buf))))
+ (if (not rel) #t
+ (let* ((initial (cons 'attr (get-main-attrs get-init)))
+ (old-env (get-search-filter))
+ (new-env (tree-descendant-env* buf rel initial)))
+ (if (not new-env) #t
+ (check-same? (tm-children new-env) (tm-children old-env)))))))
+ (lambda (key msg . rest)
+ (display* "Warning: accept-search-result? error: " msg "\n")
+ (if chat-tab-search-active? #t #f)))
) ;or
) ;define
@@ -451,8 +484,11 @@
(define (tree-perform-search t what p limit)
(let* ((source-mode 2)
(old-mode (get-access-mode))
- (new-mode (if (== (get-init "mode") "src") source-mode old-mode))
- ) ;
+ (new-mode (if (or (== (get-init "mode") "src")
+ (and chat-tab-search-active?
+ (== chat-tab-search-mode "math")))
+ source-mode old-mode))
+ ) ;
(set-access-mode new-mode)
(let* ((cp (cDr (cursor-path)))
(pos (if (list-starts? cp p) (list-tail cp (length p)) (list)))
@@ -637,7 +673,7 @@
(set! search-serial (+ search-serial 1))
(with-buffer (master-buffer) (cancel-alt-selection "alternate"))
(set-search-window-state #f #f)
- (buffer-focus u #t)
+ (when u (buffer-focus u #t))
) ;tm-define
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -1112,6 +1148,80 @@
) ;when
) ;tm-define
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Chat tab search (floating search bar)
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(define chat-tab-search-target #f)
+(define chat-tab-search-aux #f)
+(define chat-tab-search-active? #f)
+(define chat-tab-search-mode "text")
+
+(define (chat-tab-search-toggle-mode)
+ (set! chat-tab-search-mode
+ (if (== chat-tab-search-mode "text") "math" "text"))
+ (with-buffer (master-buffer)
+ (if (== chat-tab-search-mode "math")
+ (begin (set-access-mode 2) (init-env "mode" "math"))
+ (begin (set-access-mode 0) (init-default "mode"))))
+ (perform-search*))
+
+(define (search-path-inside-math? p)
+ (with-buffer (master-buffer)
+ (let* ((buf (buffer-tree))
+ (dr (cDr p))
+ (len (length dr)))
+ (let loop ((i (- len 1)))
+ (if (< i 0) #f
+ (let ((node (subtree buf (sublist dr 0 (- i 1)))))
+ (if (tm-func? node 'math) #t
+ (loop (- i 1)))))))))
+
+(define (chat-tab-search-init target-buf)
+ (set! chat-tab-search-target target-buf)
+ (let ((aux (search-buffer)))
+ (set! chat-tab-search-aux aux)
+ (set! chat-tab-search-active? #t)
+ (buffer-set-master aux target-buf)
+ (set-search-window-state #t #t)
+ (with-buffer target-buf
+ (set-search-reference (cursor-path)))
+ (set-search-filter)
+ (set! search-filter-out? #f)
+ (qt-floating-search-set-callbacks
+ "(chat-tab-search-next #t)" "(chat-tab-search-next #f)" "(chat-tab-search-close)")
+ (qt-floating-search-init (url->string aux))
+ ;; 同步暗色样式到搜索缓冲区
+ (when (== (get-preference "gui theme") "liii-night")
+ (with-buffer aux
+ (when (not (has-style-package? "dark"))
+ (add-style-package "dark"))))
+ (qt-floating-search "true")))
+
+(tm-define (chat-tab-search-next forward?)
+ (when (and chat-tab-search-target chat-tab-search-aux)
+ (with-buffer chat-tab-search-target
+ (search-rotate-match forward?))))
+
+(tm-define (chat-tab-search-close)
+ (when chat-tab-search-target
+ (search-show-all)
+ (set! search-serial (+ search-serial 1))
+ (with-buffer chat-tab-search-target
+ (cancel-alt-selection "alternate"))
+ (set-search-window-state #f #f)
+ (let* ((msg-url (url->system chat-tab-search-target))
+ (in-url (if (string-starts? msg-url "tmfs://chat-message-")
+ (string-append "tmfs://chat-input-"
+ (substring msg-url
+ (string-length "tmfs://chat-message-")))
+ "")))
+ (when (not (== in-url ""))
+ (buffer-focus (string->url in-url) #t)))
+ (set! chat-tab-search-active? #f)
+ (set! chat-tab-search-target #f)
+ (set! chat-tab-search-aux #f)))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Search and replace widget
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -1393,6 +1503,7 @@
) ;tm-define
(tm-define (toolbar-search-end)
+ (when chat-tab-search-active? (chat-tab-search-close))
(cancel-alt-selection "alternate")
(search-show-all)
(set! search-filter-out? #f)
@@ -1626,19 +1737,57 @@
(define-preferences ("toolbar search" "on" noop) ("toolbar replace" "on" noop))
+(define (chat-message-buffer? buf)
+ (string-starts? (url->system buf) "tmfs://chat-message-"))
+
+(define (chat-input-buffer? buf)
+ (string-starts? (url->system buf) "tmfs://chat-input-"))
+
+(define (chat-buffer-session-id buf)
+ (with s (url->system buf)
+ (cond ((chat-message-buffer? buf)
+ (substring s (string-length "tmfs://chat-message-")))
+ ((chat-input-buffer? buf)
+ (substring s (string-length "tmfs://chat-input-")))
+ (else #f))))
+
+(define (chat-message-buffer-has-content? msg-buf)
+ (and (buffer-exists? msg-buf)
+ (with body (buffer-get-body msg-buf)
+ (not (and (tm-func? body 'document 1)
+ (tree-empty? (tm-ref body 0)))))))
+
(tm-define (interactive-search)
(:interactive #t)
- (unless (string-starts? (url->system (current-buffer)) "tmfs:")
- (set! search-replace-text
- (cond ((in-math?) "Only search in math mode")
- ((in-prog?) "Only search in Program mode")
- ((in-graphics?) "Graphics mode cannot search")
- (else "Only search in text mode")
+ (with buf (current-buffer)
+ (with sid (chat-buffer-session-id buf)
+ (cond
+ ((string-starts? (url->system buf) "tmfs://chat-")
+ ;; chat tab 任何缓冲区:通过 sid 或胶水函数找到消息缓冲区
+ (let* ((msg-url (if sid
+ (string-append "tmfs://chat-message-" sid)
+ (qt-chat-tab-active-message-buffer-url)))
+ (msg-u (and msg-url (not (== msg-url ""))
+ (string->url msg-url))))
+ (if (and msg-u (chat-message-buffer-has-content? msg-u))
+ (chat-tab-search-init msg-u)
+ (noop))))
+ ((string-starts? (url->system buf) "tmfs:")
+ ;; 其他 tmfs:// 缓冲区保持禁用搜索
+ (noop))
+ (else
+ (set! search-replace-text
+ (cond ((in-math?) "Only search in math mode")
+ ((in-prog?) "Only search in Program mode")
+ ((in-graphics?) "Graphics mode cannot search")
+ (else "Only search in text mode")
+ ) ;cond
+ ) ;set!
+ (set-boolean-preference "search-and-replace" #f)
+ (open-search))
) ;cond
- ) ;set!
- (set-boolean-preference "search-and-replace" #f)
- (open-search)
- ) ;unless
+ ) ;with
+ ) ;with
) ;tm-define
(tm-define (interactive-replace)
diff --git a/devel/1042.md b/devel/1042.md
new file mode 100644
index 0000000000..9fa87c1dd1
--- /dev/null
+++ b/devel/1042.md
@@ -0,0 +1,167 @@
+# [1042] Chat Tab 搜索功能
+
+## 1 相关文档
+- [dddd.md](dddd.md) - 任务文档模板
+- [0228.md](0228.md) - 禁用 Chat Tab 搜索(本任务修复并替代)
+
+## 2 任务相关的代码文件
+- `TeXmacs/progs/generic/search-widgets.scm` - 搜索/替换入口函数
+- `src/Plugins/Qt/qt_floating_search_bar.hpp` - 悬浮搜索栏通用组件
+- `src/Plugins/Qt/qt_floating_search_bar.cpp` - 悬浮搜索栏实现与管理器
+- `src/Plugins/Qt/qt_chat_controller.cpp` - Chat controller(注册 parent provider)
+- `src/Plugins/Qt/qt_chat_tab_widget.hpp` - Chat tab widget 头文件
+- `src/Scheme/L5/glue_widget.lua` - Scheme 胶水函数声明
+
+## 3 如何测试
+
+### 3.1 确定性测试(单元测试)
+无单元测试,需手动验证。
+
+### 3.2 非确定性测试(文档验证)
+```
+1. 打开 Chat Tab,在 message buffer 中按 Cmd+F → 右上角出现悬浮搜索框
+2. 输入文本 → 高亮匹配项,显示匹配计数
+3. 点击上一个/下一个按钮 → 跳转到对应匹配
+4. 按 Esc 或点击关闭 → 搜索框消失,高亮清除
+5. 在 Chat Tab 的 input buffer 中按 Cmd+F → 自动定位到 message buffer 搜索
+6. 在 Chat Tab 中按 Cmd+H(替换) → 无反应
+7. 普通文档 Tab 中按 Cmd+F → 侧边栏搜索正常工作
+```
+
+## 4 如何提交
+
+提交前执行以下最少步骤:
+
+```bash
+xmake b stem
+gf fmt --changed-since=main
+```
+
+## 5 What
+Chat Tab 的 message buffer 是嵌入式 TeXmacs widget,没有标准 `tm_window`,无法使用主窗口的 auxiliary-widget 侧边栏搜索机制。需要为 Chat Tab 实现独立的搜索功能。
+
+1. 实现可复用的悬浮搜索栏 Qt 组件 `QTMFloatingSearchBar`,支持任意 QWidget parent
+2. 搜索栏包含:输入框、上一个/下一个按钮、关闭按钮、匹配计数
+3. Scheme 端新增 chat-tab 专用搜索初始化和导航函数
+4. 修改 `interactive-search` 在嵌入式 chat buffer 时使用新的搜索 UI
+5. 修改 `interactive-replace` 在 chat tab 中禁用替换
+6. 搜索框 DPI 缩放、系统字体、暗色主题跟随
+
+## 6 Why
+commit [0228] 通过 blanket `tmfs:` 检查禁用了所有 tmfs:// 缓冲区的搜索来避免 crash。用户需要在 Chat Tab 的消息输出框中搜索对话内容。Chat Tab 的嵌入式 buffer 不支持 auxiliary-widget 机制,需要独立的搜索 UI。
+
+## 7 How
+1. 实现通用悬浮搜索栏 Qt 组件 `QTMFloatingSearchBar`,包含输入框、导航按钮、关闭按钮、匹配计数标签
+2. 通过 `QHash` 按 parent widget 管理实例,parent 销毁时自动清理
+3. `QTMFloatingSearchBar` 自身安装 eventFilter 处理 parent resize,自动重新定位到右上角
+4. Scheme 端新增 `chat-tab-search-init`、`chat-tab-perform-search`、`chat-tab-search-next`、`chat-tab-search-close` 函数
+5. C++ 按钮回调通过 `eval_scheme` 调用 Scheme 搜索函数,回调命令由 `qt-floating-search-set-callbacks` 注入,不再硬编码
+6. `interactive-search` 检测到嵌入式 chat buffer 时调 C++ glue 显示搜索栏并初始化搜索
+7. 搜索框的 `texmacs_input_widget` 参照 chat 组件,应用 `DpiUtils::scaled` 缩放、`"font" "sys-chinese"` 字体、viewport 背景色 `QPalette::Base`
+8. Scheme 端在 `chat-tab-search-init` 中检测 `liii-night` 主题,自动给搜索缓冲区添加 `"dark"` 样式包
+
+---
+
+## 8 组件复用方法(供后续开发者参考)
+
+`QTMFloatingSearchBar` 已设计为**通用组件**,不依赖 ChatController,可 attach 到任意 `QWidget`。
+
+### 8.1 复用架构
+
+```
+┌─────────────────────────────────────┐
+│ Scheme 侧 (search-widgets.scm) │
+│ ── (qt-floating-search-set-callbacks ...)
+│ ── (qt-floating-search-init aux-url)
+│ ── (qt-floating-search "true")
+└─────────────┬───────────────────────┘
+ │ glue_widget.lua
+┌─────────────▼───────────────────────┐
+│ C++ 兼容层 (qt_floating_search_bar.cpp)
+│ ── 通过注册的 parent provider 获取默认 parent
+└─────────────┬───────────────────────┘
+ │ 底层通用 API
+┌─────────────▼───────────────────────┐
+│ QTMFloatingSearchBar 管理器 │
+│ ── QHash 管理生命周期 │
+└─────────────────────────────────────┘
+```
+
+### 8.2 在新页面中使用(以 XXX Tab 为例)
+
+#### 步骤 1:C++ 侧注册 parent provider
+
+在负责该页面的 Controller 初始化时(如 `createView` 中),注册一个返回 content widget 的 provider:
+
+```cpp
+#include "qt_floating_search_bar.hpp"
+
+void
+XXXController::createView (QWidget* parent) {
+ view_ = new XXXTabWidget (...);
+ // ... 其他初始化 ...
+
+ // 注册浮动搜索栏的 parent provider
+ qt_floating_search_set_parent_provider ([this] () -> QWidget* {
+ if (!view_) return nullptr;
+ return view_->contentWidget (); // 或任意目标 QWidget
+ });
+}
+```
+
+> **注意**:同一时间只能有一个活跃的 provider。如果多个页面都想使用浮动搜索栏,需要确保切换页面时重新注册 provider,或直接调用底层通用 API(见 8.3)。
+
+#### 步骤 2:Scheme 侧调用胶水函数
+
+```scheme
+;; 设置按钮对应的 Scheme 回调命令
+(qt-floating-search-set-callbacks
+ "(my-page-search-next #t)"
+ "(my-page-search-next #f)"
+ "(my-page-search-close)")
+
+;; 初始化:创建 texmacs_input_widget 并嵌入搜索栏
+(qt-floating-search-init (url->string aux-buffer-url))
+
+;; 显示搜索栏
+(qt-floating-search "true")
+
+;; 隐藏搜索栏
+(qt-floating-search "false")
+
+;; 更新匹配计数(通常在搜索回调中调用)
+(qt-floating-search-set-match-info 3 10)
+```
+
+#### 步骤 3:实现对应的 Scheme 搜索函数
+
+参考 `search-widgets.scm` 中的 `chat-tab-search-init` / `chat-tab-search-next` / `chat-tab-search-close` 实现即可。核心逻辑:
+- `init`:绑定 search-buffer 到目标 buffer,设置搜索起点
+- `next`:调用 `search-next-match` 在目标 buffer 中跳转
+- `close`:清除高亮、重置搜索状态、隐藏搜索栏
+
+### 8.3 直接使用底层 C++ API(不走 Scheme 胶水层)
+
+如果场景更适合纯 C++ 控制(如非 TeXmacs buffer 的纯 Qt 页面),可以直接调用:
+
+```cpp
+#include "qt_floating_search_bar.hpp"
+
+// 在某个 QWidget* content 上创建/显示搜索栏
+qt_floating_search_bar_show (content, true);
+
+// 初始化输入框(需要 aux_url 作为 texmacs_input_widget 的绑定)
+bool ok = qt_floating_search_bar_init (content, "tmfs://aux/search/...");
+
+// 设置匹配计数
+qt_floating_search_bar_set_match_info (content, 3, 10);
+
+// 设置 Scheme 回调(可选,如果不需要 Scheme 交互可跳过)
+qt_floating_search_bar_set_callbacks (
+ content, "(my-next)", "(my-prev)", "(my-close)");
+
+// 手动销毁(parent 销毁时会自动清理,通常不需要手动调用)
+qt_floating_search_bar_destroy (content);
+```
+
+底层 API 的 parent 参数可以是任意 `QWidget*`,组件会自动安装 eventFilter 处理 resize 并定位到 parent 的右上角。
diff --git a/src/Plugins/Qt/qt_chat_controller.cpp b/src/Plugins/Qt/qt_chat_controller.cpp
index 9cc701de62..9652d2ab34 100644
--- a/src/Plugins/Qt/qt_chat_controller.cpp
+++ b/src/Plugins/Qt/qt_chat_controller.cpp
@@ -11,6 +11,7 @@
#include "qt_chat_controller.hpp"
#include "qt_chat_tab_widget.hpp"
+#include "qt_floating_search_bar.hpp"
#include "new_buffer.hpp"
#include "s7_tm.hpp"
@@ -139,6 +140,12 @@ ChatController::createView (QWidget* parent, qt_tm_widget_rep* tm) {
}
}
+ // 6. 注册浮动搜索栏的 parent provider
+ qt_floating_search_set_parent_provider ([this] () -> QWidget* {
+ if (!view_) return nullptr;
+ return view_->contentWidget ();
+ });
+
return view_;
}
@@ -705,3 +712,19 @@ qt_chat_tab_restore_session (string sessionId, string title, string model,
get_chat_controller ()->restoreSessionMeta (
sessionId, title, model, isArchived, createdAt, expandCount, isThinking);
}
+
+string
+ChatController::activeSessionMessageBufferUrl () const {
+ if (!view_) return "";
+ ChatSidebar* sidebar= view_->sidebar ();
+ if (!sidebar) return "";
+ string activeId= sidebar->activeSessionId ();
+ if (is_empty (activeId)) return "";
+ url msgBufUrl= ChatSessionManager::messageBufferUrl (activeId);
+ return as_string (msgBufUrl);
+}
+
+string
+qt_chat_tab_active_message_buffer_url () {
+ return get_chat_controller ()->activeSessionMessageBufferUrl ();
+}
diff --git a/src/Plugins/Qt/qt_chat_controller.hpp b/src/Plugins/Qt/qt_chat_controller.hpp
index 06d51c6482..651263f7eb 100644
--- a/src/Plugins/Qt/qt_chat_controller.hpp
+++ b/src/Plugins/Qt/qt_chat_controller.hpp
@@ -133,6 +133,8 @@ class ChatController : public QObject {
*/
void destroyView ();
+ string activeSessionMessageBufferUrl () const;
+
private:
QTChatTabWidget* view_= nullptr; ///< View 指针,由 createView 创建
ChatSessionManager sessionManager_; ///< 会话管理器
@@ -234,4 +236,6 @@ void qt_chat_tab_restore_session (string sessionId, string title, string model,
string archived, string createdAt,
int defaultExpandCount, string thinking);
+string qt_chat_tab_active_message_buffer_url ();
+
#endif // QT_CHAT_CONTROLLER_HPP
diff --git a/src/Plugins/Qt/qt_chat_tab_widget.hpp b/src/Plugins/Qt/qt_chat_tab_widget.hpp
index 41aee1cc66..81791f6645 100644
--- a/src/Plugins/Qt/qt_chat_tab_widget.hpp
+++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp
@@ -351,6 +351,9 @@ class QTChatTabWidget : public QWidget {
void setSidebarVisible (bool visible);
void setCloseSidebarButtonVisible (bool visible);
+ // ---- 供外部组件访问 ----
+ QWidget* contentWidget () const { return contentWidget_; }
+
signals:
void cancelRequested (const string& sessionId);
void newChatRequested ();
diff --git a/src/Plugins/Qt/qt_floating_search_bar.cpp b/src/Plugins/Qt/qt_floating_search_bar.cpp
new file mode 100644
index 0000000000..df281c077e
--- /dev/null
+++ b/src/Plugins/Qt/qt_floating_search_bar.cpp
@@ -0,0 +1,427 @@
+
+/******************************************************************************
+ * MODULE : qt_floating_search_bar.cpp
+ * DESCRIPTION: A VSCode-style floating search bar widget
+ * COPYRIGHT : (C) 2026 Mogan STEM
+ ******************************************************************************
+ * This software falls under the GNU general public license version 3 or later.
+ * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
+ * in the root directory or .
+ ******************************************************************************/
+
+#include "qt_floating_search_bar.hpp"
+#include "qt_dpi_utils.hpp"
+#include "qt_utilities.hpp"
+#include "qt_widget.hpp"
+
+#include "s7_tm.hpp"
+#include "tm_window.hpp"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace moebius;
+
+// ---- 尺寸常量(逻辑像素,经 DpiUtils 缩放) ----
+constexpr int kBarMinHeight= 64;
+constexpr int kBarWidth = 420;
+constexpr int kBarRadius = 4;
+constexpr int kBarMargin = 6;
+constexpr int kBarSpacing = 4;
+
+constexpr int kBtnSize = 24;
+constexpr int kBtnRadius= 12;
+
+constexpr int kInfoHeight= 24;
+
+constexpr int kShadowBlur = 8;
+constexpr int kShadowOffsetY= 1;
+constexpr int kShadowAlpha = 30;
+
+constexpr int kPosRightPad = 8;
+constexpr int kPosTopPad = 4;
+constexpr int kInnerSpacing= 4;
+
+/******************************************************************************
+ * QTMFloatingSearchBar 实现
+ ******************************************************************************/
+
+QTMFloatingSearchBar::QTMFloatingSearchBar (QWidget* parent)
+ : QWidget (parent) {
+ setObjectName ("floating_search_bar");
+ setWindowFlags (Qt::Widget);
+ setAttribute (Qt::WA_StyledBackground);
+ setMinimumHeight (DpiUtils::scaled (kBarMinHeight));
+
+ // border-radius 由代码动态计算,支持 DPI 缩放
+ setStyleSheet (QString ("#floating_search_bar {"
+ " border-radius: %1px;"
+ "}")
+ .arg (DpiUtils::scaled (kBarRadius)));
+
+ auto* shadow= new QGraphicsDropShadowEffect (this);
+ shadow->setBlurRadius (DpiUtils::scaled (kShadowBlur));
+ shadow->setOffset (0, DpiUtils::scaled (kShadowOffsetY));
+ shadow->setColor (QColor (0, 0, 0, kShadowAlpha));
+ setGraphicsEffect (shadow);
+
+ // 外层水平布局:左边 [输入区],右边 [按钮 + 匹配信息]
+ auto* mainLayout= new QHBoxLayout (this);
+ mainLayout->setContentsMargins (
+ DpiUtils::scaled (kBarMargin), DpiUtils::scaled (kBarMargin),
+ DpiUtils::scaled (kBarMargin), DpiUtils::scaled (kBarMargin));
+ mainLayout->setSpacing (DpiUtils::scaled (kBarSpacing));
+
+ // 左侧:输入区(由 setSearchInput 动态插入,占满左侧)
+ rowLayout_= new QHBoxLayout ();
+ rowLayout_->setSpacing (0);
+
+ // 右侧:垂直布局 [按钮行] + [匹配信息]
+ auto* rightLayout= new QVBoxLayout ();
+ rightLayout->setSpacing (DpiUtils::scaled (kInnerSpacing));
+
+ // 右侧上层:按钮行
+ auto* btnRow= new QHBoxLayout ();
+ btnRow->setSpacing (DpiUtils::scaled (kInnerSpacing));
+
+ const QString btnRadiusStyle=
+ QString (
+ "QToolButton { border-radius: %1px; padding: 0px; margin: 0px; }")
+ .arg (DpiUtils::scaled (kBtnRadius));
+
+ modeBtn_= new QToolButton (this);
+ modeBtn_->setObjectName ("floating-search-mode-text");
+ modeBtn_->setFixedSize (DpiUtils::scaled (kBtnSize),
+ DpiUtils::scaled (kBtnSize));
+ modeBtn_->setToolTip (qt_translate ("Toggle search mode (text/math)"));
+ modeBtn_->setStyleSheet (btnRadiusStyle);
+ btnRow->addWidget (modeBtn_);
+
+ auto* prevBtn= new QToolButton (this);
+ prevBtn->setObjectName ("floating-search-prev");
+ prevBtn->setFixedSize (DpiUtils::scaled (kBtnSize),
+ DpiUtils::scaled (kBtnSize));
+#ifdef Q_OS_MAC
+ prevBtn->setToolTip (qt_translate ("Previous (Cmd+Enter)"));
+#else
+ prevBtn->setToolTip (qt_translate ("Previous (Ctrl+Enter)"));
+#endif
+ prevBtn->setStyleSheet (btnRadiusStyle);
+ btnRow->addWidget (prevBtn);
+
+ auto* nextBtn= new QToolButton (this);
+ nextBtn->setObjectName ("floating-search-next");
+ nextBtn->setFixedSize (DpiUtils::scaled (kBtnSize),
+ DpiUtils::scaled (kBtnSize));
+ nextBtn->setToolTip (qt_translate ("Next (Enter)"));
+ nextBtn->setStyleSheet (btnRadiusStyle);
+ btnRow->addWidget (nextBtn);
+
+ auto* closeBtn= new QToolButton (this);
+ closeBtn->setObjectName ("floating-search-close");
+ closeBtn->setFixedSize (DpiUtils::scaled (kBtnSize),
+ DpiUtils::scaled (kBtnSize));
+ closeBtn->setToolTip (qt_translate ("Close (Esc)"));
+ closeBtn->setStyleSheet (btnRadiusStyle);
+ btnRow->addWidget (closeBtn);
+
+ rightLayout->addLayout (btnRow);
+
+ // 右侧下层:匹配信息
+ infoLbl_= new QLabel (this);
+ infoLbl_->setObjectName ("floating-search-info");
+ infoLbl_->setFixedHeight (DpiUtils::scaled (kInfoHeight));
+ infoLbl_->setAlignment (Qt::AlignCenter);
+ infoLbl_->setText (qt_translate ("No matches"));
+ rightLayout->addWidget (infoLbl_);
+
+ // 组装:左输入(stretch=1) + 右面板
+ mainLayout->addLayout (rowLayout_, 1);
+ mainLayout->addLayout (rightLayout);
+
+ connect (nextBtn, &QToolButton::clicked, this,
+ &QTMFloatingSearchBar::findNextRequested);
+ connect (prevBtn, &QToolButton::clicked, this,
+ &QTMFloatingSearchBar::findPreviousRequested);
+ connect (closeBtn, &QToolButton::clicked, this,
+ &QTMFloatingSearchBar::closeRequested);
+ connect (modeBtn_, &QToolButton::clicked, this, [this] () {
+ bool isMath= (modeBtn_->objectName () ==
+ QStringLiteral ("floating-search-mode-text"));
+ setModeIcon (isMath);
+ eval_scheme ("(chat-tab-search-toggle-mode)");
+ });
+
+ if (parent) parent->installEventFilter (this);
+
+ hide ();
+}
+
+QTMFloatingSearchBar::~QTMFloatingSearchBar () {
+ if (parent ()) parent ()->removeEventFilter (this);
+}
+
+void
+QTMFloatingSearchBar::setSearchInput (QWidget* input) {
+ if (inputQW_) {
+ QAbstractScrollArea* oldArea= inputQW_->findChild ();
+ if (oldArea) oldArea->removeEventFilter (this);
+ rowLayout_->removeWidget (inputQW_);
+ inputQW_->deleteLater ();
+ }
+ inputQW_= input;
+ if (input) {
+ input->setObjectName ("floating-search-input");
+ QAbstractScrollArea* scrollArea= input->findChild ();
+ if (scrollArea) {
+ scrollArea->viewport ()->setBackgroundRole (QPalette::Base);
+ scrollArea->installEventFilter (this);
+ }
+ rowLayout_->insertWidget (0, input, 1);
+ }
+}
+
+void
+QTMFloatingSearchBar::activate () {
+ show ();
+ raise ();
+ if (inputQW_) {
+ QAbstractScrollArea* sa= inputQW_->findChild ();
+ if (sa) sa->setFocus ();
+ else inputQW_->setFocus ();
+ }
+}
+
+void
+QTMFloatingSearchBar::setMatchInfo (int current, int total) {
+ if (total == 0) infoLbl_->setText (qt_translate ("No matches"));
+ else infoLbl_->setText (qt_translate ("%1 of %2").arg (current).arg (total));
+}
+
+void
+QTMFloatingSearchBar::setSchemeCallbacks (const string& next_cmd,
+ const string& prev_cmd,
+ const string& close_cmd) {
+ next_cmd_ = next_cmd;
+ prev_cmd_ = prev_cmd;
+ close_cmd_= close_cmd;
+ if (!callbacksConnected_) {
+ connectSignals ();
+ callbacksConnected_= true;
+ }
+}
+
+void
+QTMFloatingSearchBar::connectSignals () {
+ if (!is_empty (next_cmd_)) {
+ connect (this, &QTMFloatingSearchBar::findNextRequested, this,
+ [this] () { eval_scheme (next_cmd_); });
+ }
+ if (!is_empty (prev_cmd_)) {
+ connect (this, &QTMFloatingSearchBar::findPreviousRequested, this,
+ [this] () { eval_scheme (prev_cmd_); });
+ }
+ if (!is_empty (close_cmd_)) {
+ connect (this, &QTMFloatingSearchBar::closeRequested, this, [this] () {
+ eval_scheme (close_cmd_);
+ hide ();
+ });
+ }
+}
+
+void
+QTMFloatingSearchBar::setModeIcon (bool mathMode) {
+ if (!modeBtn_) return;
+ modeBtn_->setObjectName (mathMode ? "floating-search-mode-math"
+ : "floating-search-mode-text");
+ modeBtn_->style ()->unpolish (modeBtn_);
+ modeBtn_->style ()->polish (modeBtn_);
+}
+
+bool
+QTMFloatingSearchBar::eventFilter (QObject* watched, QEvent* event) {
+ if (event->type () == QEvent::Resize && watched == parent () &&
+ isVisible ()) {
+ reposition ();
+ }
+ if (event->type () == QEvent::KeyPress && isVisible () && inputQW_) {
+ auto* ke= static_cast (event);
+ if (ke->key () == Qt::Key_Escape) {
+ QAbstractScrollArea* sa= inputQW_->findChild ();
+ if (sa && watched == sa) {
+ emit closeRequested ();
+ return true;
+ }
+ }
+ }
+ return QWidget::eventFilter (watched, event);
+}
+
+void
+QTMFloatingSearchBar::showEvent (QShowEvent* event) {
+ QWidget::showEvent (event);
+ reposition ();
+}
+
+void
+QTMFloatingSearchBar::reposition () {
+ QWidget* p= qobject_cast (parent ());
+ if (!p) return;
+ int x= p->width () - width () - DpiUtils::scaled (kPosRightPad);
+ int y= DpiUtils::scaled (kPosTopPad);
+ move (x, y);
+}
+
+/******************************************************************************
+ * 搜索栏管理器
+ ******************************************************************************/
+
+static QHash&
+searchBars () {
+ static QHash bars;
+ return bars;
+}
+
+static QTMFloatingSearchBar*
+get_or_create_bar (QWidget* parent) {
+ if (!parent) return nullptr;
+ auto& bars= searchBars ();
+ auto it = bars.find (parent);
+ if (it != bars.end () && *it) return *it;
+
+ auto* bar = new QTMFloatingSearchBar (parent);
+ bars[parent]= bar;
+
+ QWidget* pw= parent;
+ QObject::connect (parent, &QObject::destroyed,
+ [pw] () { searchBars ().remove (pw); });
+
+ bar->setFixedWidth (DpiUtils::scaled (kBarWidth));
+ return bar;
+}
+
+void
+qt_floating_search_bar_show (QWidget* parent, bool show) {
+ if (!parent) return;
+ auto* bar= get_or_create_bar (parent);
+ if (!bar) return;
+ if (show) bar->show ();
+ else bar->hide ();
+}
+
+bool
+qt_floating_search_bar_init (QWidget* parent, const string& aux_url_str) {
+ if (!parent) return false;
+ auto* bar= get_or_create_bar (parent);
+
+ url aux_url = url_system (aux_url_str);
+ qreal searchZoom= DpiUtils::scaled (100) / 100.0;
+ tree doc (WITH, "font", "sys-chinese", "zoom-factor", as_string (searchZoom),
+ tree (DOCUMENT, ""));
+ tree sty= compound ("style", tree (TUPLE, "generic"));
+ widget tw= texmacs_input_widget (doc, sty, aux_url);
+ set_zoom_factor (tw, searchZoom);
+ if (is_nil (tw)) {
+ bar->hide ();
+ return false;
+ }
+ QWidget* inputW= concrete (tw)->as_qwidget ();
+ if (!inputW) {
+ bar->hide ();
+ return false;
+ }
+ bar->setSearchInput (inputW);
+ return true;
+}
+
+void
+qt_floating_search_bar_set_match_info (QWidget* parent, int current,
+ int total) {
+ if (!parent) return;
+ auto* bar= searchBars ().value (parent);
+ if (bar) bar->setMatchInfo (current, total);
+}
+
+void
+qt_floating_search_bar_set_callbacks (QWidget* parent, const string& next_cmd,
+ const string& prev_cmd,
+ const string& close_cmd) {
+ if (!parent) return;
+ auto* bar= get_or_create_bar (parent);
+ bar->setSchemeCallbacks (next_cmd, prev_cmd, close_cmd);
+}
+
+void
+qt_floating_search_bar_destroy (QWidget* parent) {
+ if (!parent) return;
+ auto& bars= searchBars ();
+ auto it = bars.find (parent);
+ if (it != bars.end ()) {
+ (*it)->setParent (nullptr);
+ delete *it;
+ bars.erase (it);
+ }
+}
+
+/******************************************************************************
+ * 兼容层胶水函数(通过 provider 代理)
+ ******************************************************************************/
+
+static qt_floating_search_parent_provider g_parent_provider;
+
+void
+qt_floating_search_set_parent_provider (
+ qt_floating_search_parent_provider provider) {
+ g_parent_provider= provider;
+}
+
+static QWidget*
+get_provider_parent () {
+ return g_parent_provider ? g_parent_provider () : nullptr;
+}
+
+void
+qt_floating_search (string flag) {
+ QWidget* parent= get_provider_parent ();
+ if (!parent) return;
+ bool show= (flag == "true" || flag == "#t");
+ if (show) {
+ auto* bar= get_or_create_bar (parent);
+ if (bar) bar->activate ();
+ }
+ else {
+ qt_floating_search_bar_show (parent, false);
+ }
+}
+
+void
+qt_floating_search_init (string aux_url_str) {
+ QWidget* parent= get_provider_parent ();
+ if (!parent) return;
+ qt_floating_search_bar_init (parent, aux_url_str);
+}
+
+void
+qt_floating_search_set_match_info (int current, int total) {
+ QWidget* parent= get_provider_parent ();
+ if (!parent) return;
+ qt_floating_search_bar_set_match_info (parent, current, total);
+}
+
+void
+qt_floating_search_set_callbacks (string next_cmd, string prev_cmd,
+ string close_cmd) {
+ QWidget* parent= get_provider_parent ();
+ if (!parent) return;
+ qt_floating_search_bar_set_callbacks (parent, next_cmd, prev_cmd, close_cmd);
+}
diff --git a/src/Plugins/Qt/qt_floating_search_bar.hpp b/src/Plugins/Qt/qt_floating_search_bar.hpp
new file mode 100644
index 0000000000..024865274d
--- /dev/null
+++ b/src/Plugins/Qt/qt_floating_search_bar.hpp
@@ -0,0 +1,125 @@
+
+/******************************************************************************
+ * MODULE : qt_floating_search_bar.hpp
+ * DESCRIPTION: A VSCode-style floating search bar widget for TeXmacs
+ * COPYRIGHT : (C) 2026 Mogan STEM
+ ******************************************************************************
+ * This software falls under the GNU general public license version 3 or later.
+ * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE
+ * in the root directory or .
+ ******************************************************************************/
+
+#ifndef QT_FLOATING_SEARCH_BAR_HPP
+#define QT_FLOATING_SEARCH_BAR_HPP
+
+#include
+#include
+#include
+
+#include "string.hpp"
+
+#include
+
+/**
+ * VSCode 风格的悬浮搜索栏组件。
+ *
+ * 布局:
+ * 左侧:嵌入的输入框(如 texmacs_input_widget)
+ * 右侧:[上一个] [下一个] [关闭] 按钮 + 匹配计数
+ */
+class QTMFloatingSearchBar : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit QTMFloatingSearchBar (QWidget* parent= nullptr);
+ ~QTMFloatingSearchBar () override;
+
+ /// 设置嵌入的搜索输入框。旧的输入框(如有)会被移除并 deleteLater。
+ void setSearchInput (QWidget* input);
+ /// 显示搜索栏并聚焦输入框。
+ void activate ();
+ /// 设置匹配信息(current=0, total=0 时显示"无匹配")。
+ void setMatchInfo (int current, int total);
+
+ /// 配置按钮点击时求值的 Scheme 命令。
+ void setSchemeCallbacks (const string& next_cmd, const string& prev_cmd,
+ const string& close_cmd);
+ void setModeIcon (bool mathMode);
+
+signals:
+ void findNextRequested ();
+ void findPreviousRequested ();
+ void closeRequested ();
+ void modeToggled ();
+
+protected:
+ bool eventFilter (QObject* watched, QEvent* event) override;
+ void showEvent (QShowEvent* event) override;
+
+private:
+ void reposition ();
+ void connectSignals ();
+
+ QHBoxLayout* rowLayout_= nullptr;
+ QWidget* inputQW_ = nullptr;
+ QLabel* infoLbl_ = nullptr;
+ QToolButton* modeBtn_ = nullptr;
+
+ string next_cmd_;
+ string prev_cmd_;
+ string close_cmd_;
+ string mode_cmd_;
+ bool callbacksConnected_= false;
+};
+
+/******************************************************************************
+ * 通用管理 API(基于 parent widget,不依赖 ChatController)
+ ******************************************************************************/
+
+/// 显示或隐藏 attach 到 \a parent 的悬浮搜索栏。
+void qt_floating_search_bar_show (QWidget* parent, bool show);
+
+/// 为 \a parent 创建/attach 搜索栏,并用绑定到 \a aux_url_str 的
+/// texmacs 输入框初始化。失败时返回 false。
+bool qt_floating_search_bar_init (QWidget* parent, const string& aux_url_str);
+
+/// 更新 attach 到 \a parent 的搜索栏的匹配计数。
+void qt_floating_search_bar_set_match_info (QWidget* parent, int current,
+ int total);
+
+/// 为 attach 到 \a parent 的搜索栏设置 Scheme 回调。
+void qt_floating_search_bar_set_callbacks (QWidget* parent,
+ const string& next_cmd,
+ const string& prev_cmd,
+ const string& close_cmd);
+
+/// 销毁 attach 到 \a parent 的搜索栏。
+void qt_floating_search_bar_destroy (QWidget* parent);
+
+/******************************************************************************
+ * 兼容层胶水函数(保留向后兼容)。
+ * 通过注册的 parent provider 代理到上面的通用 API。
+ ******************************************************************************/
+
+using qt_floating_search_parent_provider= std::function;
+
+/// 注册一个返回默认 parent widget 的函数,供兼容层胶水函数使用。
+/// 通常在 chat controller 初始化时调用。
+void qt_floating_search_set_parent_provider (
+ qt_floating_search_parent_provider provider);
+
+/// Scheme 胶水函数:显示 ("true"/"#t") 或隐藏悬浮搜索栏。
+void qt_floating_search (string flag);
+
+/// Scheme 胶水函数:传入 search-buffer URL,创建 texmacs-input
+/// 并嵌入浮动搜索栏。
+void qt_floating_search_init (string aux_url_str);
+
+/// Scheme 胶水函数:更新浮动搜索栏的匹配计数显示。
+void qt_floating_search_set_match_info (int current, int total);
+
+/// Scheme 胶水函数:设置搜索栏按钮点击时求值的 Scheme 回调命令。
+void qt_floating_search_set_callbacks (string next_cmd, string prev_cmd,
+ string close_cmd);
+
+#endif // QT_FLOATING_SEARCH_BAR_HPP
diff --git a/src/Scheme/L5/glue_widget.lua b/src/Scheme/L5/glue_widget.lua
index 30cc2501e3..ba9da6918b 100644
--- a/src/Scheme/L5/glue_widget.lua
+++ b/src/Scheme/L5/glue_widget.lua
@@ -526,6 +526,48 @@ function main()
"int",
"string"
}
+ },
+ {
+ scm_name = "qt-chat-tab-active-message-buffer-url",
+ cpp_name = "qt_chat_tab_active_message_buffer_url",
+ ret_type = "string",
+ arg_list = {}
+ },
+
+ {
+ scm_name = "qt-floating-search",
+ cpp_name = "qt_floating_search",
+ ret_type = "void",
+ arg_list = {
+ "string"
+ }
+ },
+ {
+ scm_name = "qt-floating-search-init",
+ cpp_name = "qt_floating_search_init",
+ ret_type = "void",
+ arg_list = {
+ "string"
+ }
+ },
+ {
+ scm_name = "qt-floating-search-set-match-info",
+ cpp_name = "qt_floating_search_set_match_info",
+ ret_type = "void",
+ arg_list = {
+ "int",
+ "int"
+ }
+ },
+ {
+ scm_name = "qt-floating-search-set-callbacks",
+ cpp_name = "qt_floating_search_set_callbacks",
+ ret_type = "void",
+ arg_list = {
+ "string",
+ "string",
+ "string"
+ }
}
}
}
diff --git a/src/Scheme/L5/init_glue_l5.cpp b/src/Scheme/L5/init_glue_l5.cpp
index 4bd3a48a7c..aae47442c5 100644
--- a/src/Scheme/L5/init_glue_l5.cpp
+++ b/src/Scheme/L5/init_glue_l5.cpp
@@ -29,6 +29,7 @@
#include "preferences.hpp"
#include "promise.hpp"
#include "qt_chat_controller.hpp"
+#include "qt_floating_search_bar.hpp"
#include "tm_debug.hpp"
#include "tm_locale.hpp"
#include "tree_observer.hpp"