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 77b467f4bf..b5249f88dc 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,9 @@ ("next screen" "前一屏") ("next similar" "相似的(后一个)") ("next" "后一个") +("math mode (Ctrl+Tab)" "数学模式 (Ctrl+Tab)") +("math mode (Option+Tab)" "数学模式 (Option+Tab)") +("Next (Enter)" "下一个 (Enter)") ("no changes need to be saved" "没有任何更改需要保存") ("no dictionary for" "") ("no first indentation" "") @@ -1608,6 +1612,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 +1876,8 @@ ("previous screen" "后一屏") ("previous similar" "相似的(前一个)") ("previous" "前一个") +("Previous (Ctrl+Enter)" "上一个 (Ctrl+Enter)") +("Previous (Cmd+Enter)" "上一个 (Cmd+Enter)") ("primary" "") ("prime" "") ("print all to file" "全部打印为文件") @@ -2396,6 +2404,8 @@ ("Text for note" "用于笔记的文本") ("text height correction" "文本高度修正") ("text input" "文本输入") +("text mode (Ctrl+Tab)" "文本模式 (Ctrl+Tab)") +("text mode (Option+Tab)" "文本模式 (Option+Tab)") ("text mode" "文本模式") ("text width" "") ("text" "文本") diff --git a/TeXmacs/progs/generic/search-widgets.scm b/TeXmacs/progs/generic/search-widgets.scm index 0c281c7a28..2967ed6756 100644 --- a/TeXmacs/progs/generic/search-widgets.scm +++ b/TeXmacs/progs/generic/search-widgets.scm @@ -111,6 +111,20 @@ (when isreplace? (set! isreplace? #f) ) ;when + ;; 更新浮动搜索栏的匹配计数 + (when floating-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) + ) ;when + ) ;let* + ) ;if + ) ;when (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 +172,30 @@ ;; ---- ;; 此函数用于管理搜索辅助缓冲区的生命周期,确保每个主文档视图有唯一的搜索缓冲区。 (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 + ;; 悬浮搜索激活时直接返回保存的 aux buffer + (if (and floating-search-active? + floating-search-aux + (buffer-exists? floating-search-aux) + (or (== (current-buffer) floating-search-aux) + (== (current-buffer) floating-search-target) + ) ;or + ) ;and + floating-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 +353,48 @@ ) ;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 + ;; 悬浮搜索激活时直接返回保存的 target buffer + (if (and floating-search-active? + floating-search-target + (buffer-exists? floating-search-target) + (or (== (current-buffer) floating-search-aux) + (== (current-buffer) floating-search-target) + ) ;or + ) ;and + floating-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 floating-search-active? + (or (== (current-buffer) floating-search-aux) + (== (current-buffer) floating-search-target) + ) ;or + ) ;and + (== (current-buffer) floating-search-aux) + (== (current-buffer) (search-buffer)) + ) ;if +) ;tm-define (tm-define (inside-replace-buffer?) (== (current-buffer) (replace-buffer))) @@ -417,17 +460,38 @@ ) ;define (define (accept-search-result? p) - (or (== (get-init "mode") "src") + (if (and floating-search-active? (== floating-search-mode "math")) + (search-path-inside-math? p) + (if floating-search-active? + #t + ;; text mode: tree-perform-search 中 access=0 已限制只搜文本节点,无需额外 filter + (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 + ) ;if + ) ;if +) ;define + +(define (search-path-inside-math? p) + (with-buffer (master-buffer) (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)) + (env (and rel (tree-descendant-env* buf rel initial))) ) ; - ;; (display* p " ~> " new-env "\n") - (check-same? (tm-children new-env) (tm-children old-env)) + (and env + (with env-attrs (tm-children env) (check-same-sub? env-attrs "mode" "math")) + ) ;and ) ;let* - ) ;or + ) ;with-buffer ) ;define (define (filter-search-results sels) @@ -450,8 +514,13 @@ (define (tree-perform-search t what p limit) (let* ((source-mode 2) + (math-mode 1) (old-mode (get-access-mode)) - (new-mode (if (== (get-init "mode") "src") source-mode old-mode)) + (new-mode (cond ((== (get-init "mode") "src") source-mode) + ((and floating-search-active? (== floating-search-mode "math")) math-mode) + (else old-mode) + ) ;cond + ) ;new-mode ) ; (set-access-mode new-mode) (let* ((cp (cDr (cursor-path))) @@ -466,7 +535,10 @@ (define (go-to* p) (go-to p) - (when (and (not (cursor-accessible?)) (not (in-source?))) + (when (and (not (cursor-accessible?)) + (not (in-source?)) + (not floating-search-active?) + ) ;and (cursor-show-hidden) (delayed (:pause 50) (set! search-serial (+ search-serial 1)) @@ -637,7 +709,9 @@ (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) + ) ;when ) ;tm-define ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1112,6 +1186,111 @@ ) ;when ) ;tm-define +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Chat tab search (floating search bar) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(define floating-search-target #f) + +(define floating-search-aux #f) + +(define floating-search-active? #f) + +(define floating-search-mode "text") + +(define floating-search-last-content "") + +(tm-define (floating-search-toggle-mode) + (set! floating-search-mode (if (== floating-search-mode "text") "math" "text")) + ;; 更新 filter + (when floating-search-target + (with-buffer floating-search-target (set-search-filter)) + ) ;when + ;; 重建 widget 以切换数学/文本输入环境,保留已输入文本 + (when floating-search-aux + (let ((saved-body (buffer-get-body floating-search-aux))) + (qt-floating-search-init (url->string floating-search-aux) floating-search-mode) + (when (not (tree-empty? saved-body)) + (buffer-set-body floating-search-aux saved-body) + ) ;when + ;; 重建 widget 后同步暗色样式 + (sync-buffer-dark-style-with-gui-theme floating-search-aux) + ) ;let + ) ;when + ;; 在 floating-search-aux 上下文中搜索,确保 guards 通过 + (when floating-search-aux + (with-buffer floating-search-aux (perform-search*)) + ) ;when +) ;tm-define + +(define (floating-search-init target-buf) + (set! floating-search-target target-buf) + (set! floating-search-last-content "") + (let ((aux (search-buffer))) + (set! floating-search-aux aux) + (set! floating-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) + ) ;with-buffer + (set! search-filter-out? #f) + ;; 设置搜索缓冲区的初始 init env(与侧边栏 texmacs-input 行为一致) + (with-buffer aux (init-env "mode" floating-search-mode)) + (qt-floating-search-set-callbacks "(floating-search-next #t)" + "(floating-search-next #f)" + "(floating-search-close)" + ) ;qt-floating-search-set-callbacks + (qt-floating-search-init (url->string aux) floating-search-mode) + ;; 同步暗色样式到搜索缓冲区 + (sync-buffer-dark-style-with-gui-theme aux) + (qt-floating-search "true") + ) ;let +) ;define + +(tm-define (floating-search-next forward?) + (when (and floating-search-target floating-search-aux) + (with-buffer floating-search-target (search-rotate-match forward?)) + ) ;when +) ;tm-define + +(tm-define (floating-search-on-input) + (when (and floating-search-active? floating-search-aux) + (let ((current (tree->string (buffer-get-body floating-search-aux)))) + (when (not (== current floating-search-last-content)) + (set! floating-search-last-content current) + (with-buffer floating-search-aux (perform-search*)) + ) ;when + ) ;let + ) ;when +) ;tm-define + +(tm-define (floating-search-close) + (when floating-search-target + (search-show-all) + (set! search-serial (+ search-serial 1)) + (with-buffer floating-search-target (cancel-alt-selection "alternate")) + (set-search-window-state #f #f) + (let* ((msg-url (url->system floating-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-")) + ) ;string-append + "" + ) ;if + ) ;in-url + ) ; + (when (not (== in-url "")) + (buffer-focus (string->url in-url) #t) + ) ;when + ) ;let* + (set! floating-search-active? #f) + (set! floating-search-target #f) + (set! floating-search-aux #f) + ) ;when +) ;tm-define + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Search and replace widget ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1393,6 +1572,9 @@ ) ;tm-define (tm-define (toolbar-search-end) + (when floating-search-active? + (floating-search-close) + ) ;when (cancel-alt-selection "alternate") (search-show-all) (set! search-filter-out? #f) @@ -1626,19 +1808,73 @@ (define-preferences ("toolbar search" "on" noop) ("toolbar replace" "on" noop)) +(define (chat-message-buffer? buf) + (string-starts? (url->system buf) "tmfs://chat-message-") +) ;define + +(define (chat-input-buffer? buf) + (string-starts? (url->system buf) "tmfs://chat-input-") +) ;define + +(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) + ) ;cond + ) ;with +) ;define + +(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)))) + ) ;with + ) ;and +) ;define + (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) + ) ;if + ) ;msg-url + (msg-u (and msg-url (not (== msg-url "")) (string->url msg-url))) + ) ; + (if (and msg-u (chat-message-buffer-has-content? msg-u)) + (floating-search-init msg-u) + (noop) + ) ;if + ) ;let* + ) ; + ((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) + ) ;else ) ;cond - ) ;set! - (set-boolean-preference "search-and-replace" #f) - (open-search) - ) ;unless + ) ;with + ) ;with ) ;tm-define (tm-define (interactive-replace) diff --git a/TeXmacs/progs/texmacs/texmacs/tm-files.scm b/TeXmacs/progs/texmacs/texmacs/tm-files.scm index c885d1e4d1..5c215ddc00 100644 --- a/TeXmacs/progs/texmacs/texmacs/tm-files.scm +++ b/TeXmacs/progs/texmacs/texmacs/tm-files.scm @@ -118,7 +118,7 @@ (with t (tree->stree (get-style-tree)) (and (pair? t) (== (car t) 'tuple) (null? (cdr t))))) -(define (sync-buffer-dark-style-with-gui-theme . opt-buf) +(tm-define (sync-buffer-dark-style-with-gui-theme . opt-buf) (with buf (if (null? opt-buf) (current-buffer) (car opt-buf)) (with-buffer buf (if (== (get-preference "gui theme") "liii-night") diff --git a/TeXmacs/tests/1042.scm b/TeXmacs/tests/1042.scm new file mode 100644 index 0000000000..63b16eb94a --- /dev/null +++ b/TeXmacs/tests/1042.scm @@ -0,0 +1,99 @@ +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; MODULE : 1042.scm +;; DESCRIPTION : Unit tests for floating search on-input dedup logic +;; COPYRIGHT : (C) 2026 Yuki Lu +;; +;; 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 . +;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(import (liii check)) + +(load "./TeXmacs/progs/generic/search-widgets.scm") + +(check-set-mode! 'report-failed) + +(define (test-floating-search-on-input-dedup) + (display "Testing floating-search-on-input dedup logic...\n") + + ;; 创建临时 buffer,使用原子字符串作为 body(tree->string 才能返回值) + (let* ((buf (buffer-new)) (initial-content "hello")) + + ;; 设置初始内容 + (buffer-set-body buf initial-content) + + ;; 初始化 floating search 状态 + (set! floating-search-active? #t) + (set! floating-search-aux buf) + ;; 同步 last-content 为当前 buffer 内容 + (set! floating-search-last-content (tree->string (buffer-get-body buf))) + + ;; Test 1: 内容未变 → floating-search-last-content 保持不变 + (let ((before floating-search-last-content)) + (floating-search-on-input) + (check floating-search-last-content => before) + ) ;let + + ;; Test 2: 内容改变 → floating-search-last-content 应更新为新内容 + (buffer-set-body buf "hello world") + (floating-search-on-input) + (check floating-search-last-content => "hello world") + + ;; Test 3: 再次用相同内容调用 → 不变 + (let ((before floating-search-last-content)) + (floating-search-on-input) + (check floating-search-last-content => before) + ) ;let + + ;; Test 4: 清空内容 → last-content 应更新为空串 + (buffer-set-body buf "") + (floating-search-on-input) + (check floating-search-last-content => "") + + ;; Test 5: floating-search-active? 为 #f 时 → last-content 不变 + (set! floating-search-active? #f) + (buffer-set-body buf "should be ignored") + (let ((before floating-search-last-content)) + (floating-search-on-input) + (check floating-search-last-content => before) + ) ;let + + ;; 恢复 + (set! floating-search-active? #t) + + (display "floating-search-on-input dedup tests passed!\n") + ) ;let* +) ;define + +(define (test-floating-search-toggle-mode) + (display "Testing floating-search-toggle-mode...\n") + + ;; 记录原始 mode + (let ((orig-mode floating-search-mode)) + ;; 确保从已知状态开始 + (set! floating-search-mode "text") + (check floating-search-mode => "text") + + ;; 切换到 math + (set! floating-search-mode "math") + (check floating-search-mode => "math") + + ;; 切回 text + (set! floating-search-mode "text") + (check floating-search-mode => "text") + + ;; 恢复 + (set! floating-search-mode orig-mode) + (display "floating-search-toggle-mode tests passed!\n") + ) ;let +) ;define + +(tm-define (test_1042) + (display "Running test_1042...\n") + (test-floating-search-on-input-dedup) + (test-floating-search-toggle-mode) + (check-report) +) ;tm-define diff --git a/devel/1042.md b/devel/1042.md new file mode 100644 index 0000000000..f543fd3e12 --- /dev/null +++ b/devel/1042.md @@ -0,0 +1,190 @@ +# [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 确定性测试(单元测试) + +**C++ 测试** (`tests/Plugins/Qt/qt_floating_search_bar_test.cpp`): +- 构造与布局完整性(objectName、子 widget 存在性) +- `setMatchInfo`:默认、有匹配、零匹配三种状态 +- `setModeIcon`:默认 text / 切换 math / 切回 text 三种状态 +- 运行:`xmake b qt_floating_search_bar_test && xmake r qt_floating_search_bar_test` + +**Scheme 测试** (`TeXmacs/tests/1042.scm`): +- `floating-search-on-input` 去重逻辑: + - 内容不变 → `floating-search-last-content` 不变 + - 内容改变 → `floating-search-last-content` 更新为新值 + - `floating-search-active?` 为 `#f` → 不做任何操作 +- `floating-search-toggle-mode` 模式切换 +- 运行:`./bin/test_only 1042` 或 `xmake r 1042` + +### 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 缩放、系统字体、暗色主题跟随 +7. **实时搜索**:输入框内每输入/删除字符即触发 `floating-search-on-input` 自动重新搜索,无需回车 +8. **缓存 `inputScrollArea_` 指针**:避免每次按键调用 `findChild`,减少事件过滤器中的开销 + +## 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 端新增 `floating-search-init`、`floating-search-next`、`floating-search-close`、`floating-search-toggle-mode` 函数 +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 端在 `floating-search-init` 中检测 `liii-night` 主题,自动给搜索缓冲区添加 `"dark"` 样式包 +9. text 模式:`accept-search-result?` 直接返回 `#t`,依赖 `tree-perform-search` 中 `access=0` 引擎层过滤文本节点;math 模式:独立 `search-path-inside-math?` 检查路径是否在数学环境内 +10. `connectSignals` 中 `closeRequested` 始终连接 `hide()`,不依赖 `close_cmd_` 是否为空;callback 非空时才额外调 `eval_scheme` +11. `QTMFloatingSearchBar` 用 `mathMode_` 成员变量追踪当前模式,`setModeIcon` 同步写入,`toggleMode` 直接读取,避免依赖 `objectName` 推断导致 C++/Scheme 端状态漂移 +12. 暗色主题 CSS 选择器 `QWidget#floating_search_bar` 与亮色主题统一,去掉多余的 `#centralWidget` 祖先约束 +13. `floating-search-toggle-mode` 切换 text/math 模式时保存并恢复 aux buffer body,保留用户已输入的搜索文本 +14. **实时搜索**:`QTMFloatingSearchBar::eventFilter` 在输入区(QAbstractScrollArea)收到非修饰键/非导航键按键时,通过 `QMetaObject::invokeMethod` + `Qt::QueuedConnection` 异步调用 `(floating-search-on-input)`,Scheme 端比较 `tree->string (buffer-get-body aux)` 与 `floating-search-last-content`,仅内容真变化时才执行 `perform-search*`,避免重复搜索 +15. **缓存 scroll area**:`inputScrollArea_` 成员在 `setSearchInput` 的延迟 lambda 中赋值、replace 旧输入时清除,`eventFilter` 和 `activate` 复用缓存指针,避免每次事件都 `findChild` +16. **导航键过滤**:方向键、Home/End/PageUp/PageDown 不触发实时搜索,避免无内容变化的输入事件产生多余 Scheme 调用 + +--- + +## 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..83bc13aee3 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_; } @@ -450,6 +457,9 @@ void ChatController::activateSession (const string& sessionId) { if (!view_) return; + // 切换 session 时隐藏悬浮搜索栏 + qt_floating_search_bar_show (view_->contentWidget (), false); + ChatConversationPanel* panel= getOrCreatePanel (sessionId); if (!panel) return; @@ -705,3 +715,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 4a5e5ab74b..1d34b4f7a9 100644 --- a/src/Plugins/Qt/qt_chat_tab_widget.hpp +++ b/src/Plugins/Qt/qt_chat_tab_widget.hpp @@ -355,6 +355,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..5eaf772006 --- /dev/null +++ b/src/Plugins/Qt/qt_floating_search_bar.cpp @@ -0,0 +1,501 @@ + +/****************************************************************************** + * MODULE : qt_floating_search_bar.cpp + * DESCRIPTION: A VSCode-style floating search bar widget + * COPYRIGHT : (C) 2026 Yuki Lu + ****************************************************************************** + * 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)); +#ifdef Q_OS_MAC + modeBtn_->setToolTip (qt_translate ("text mode (Option+Tab)")); +#else + modeBtn_->setToolTip (qt_translate ("text mode (Ctrl+Tab)")); +#endif + 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, + &QTMFloatingSearchBar::toggleMode); + + if (parent) parent->installEventFilter (this); + + hide (); +} + +QTMFloatingSearchBar::~QTMFloatingSearchBar () { + if (parent ()) parent ()->removeEventFilter (this); +} + +void +QTMFloatingSearchBar::setSearchInput (QWidget* input) { + if (inputQW_) { + if (inputScrollArea_) { + inputScrollArea_->removeEventFilter (this); + inputScrollArea_= nullptr; + } + rowLayout_->removeWidget (inputQW_); + inputQW_->deleteLater (); + } + inputQW_= input; + if (input) { + input->setObjectName ("floating-search-input"); + rowLayout_->insertWidget (0, input, 1); + // texmacs_input_widget 内部的 QAbstractScrollArea 可能延迟创建(show 时), + // 所有 scroll area 设置统一延迟到事件循环处理 + QMetaObject::invokeMethod ( + this, + [this] { + if (inputQW_) { + QAbstractScrollArea* sa= + inputQW_->findChild (); + if (sa) { + sa->viewport ()->setBackgroundRole (QPalette::Base); + sa->installEventFilter (this); + sa->setFocus (); + inputScrollArea_= sa; + } + else { + inputScrollArea_= nullptr; + inputQW_->setFocus (); + } + } + }, + Qt::QueuedConnection); + } +} + +void +QTMFloatingSearchBar::activate () { + show (); + raise (); + if (inputQW_) { + QAbstractScrollArea* sa= inputScrollArea_; + if (!sa) 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_); }); + } + // close: 始终 hide,callback 非空时才调 eval_scheme + connect (this, &QTMFloatingSearchBar::closeRequested, this, [this] () { + if (!is_empty (close_cmd_)) eval_scheme (close_cmd_); + hide (); + }); +} + +void +QTMFloatingSearchBar::setModeIcon (bool mathMode) { + if (!modeBtn_) return; + mathMode_= mathMode; + modeBtn_->setObjectName (mathMode ? "floating-search-mode-math" + : "floating-search-mode-text"); + modeBtn_->style ()->unpolish (modeBtn_); + modeBtn_->style ()->polish (modeBtn_); +#ifdef Q_OS_MAC + modeBtn_->setToolTip (mathMode ? qt_translate ("math mode (Option+Tab)") + : qt_translate ("text mode (Option+Tab)")); +#else + modeBtn_->setToolTip (mathMode ? qt_translate ("math mode (Ctrl+Tab)") + : qt_translate ("text mode (Ctrl+Tab)")); +#endif +} + +void +QTMFloatingSearchBar::toggleMode () { + bool isMath= !mathMode_; + setModeIcon (isMath); + eval_scheme ("(floating-search-toggle-mode)"); +} + +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); + QAbstractScrollArea* sa= inputScrollArea_; + + if (ke->key () == Qt::Key_Escape) { + if (sa && watched == sa) { + emit closeRequested (); + return true; + } + } +#ifdef Q_OS_MAC + if (ke->key () == Qt::Key_Tab && (ke->modifiers () & Qt::AltModifier)) { +#else + if (ke->key () == Qt::Key_Tab && (ke->modifiers () & Qt::ControlModifier)) { +#endif + toggleMode (); + return true; + } + + // 输入区内非修饰键/非导航键按键:触发实时搜索更新 + if (sa && watched == sa) { + switch (ke->key ()) { + case Qt::Key_Shift: + case Qt::Key_Control: + case Qt::Key_Meta: + case Qt::Key_Alt: + case Qt::Key_Left: + case Qt::Key_Right: + case Qt::Key_Up: + case Qt::Key_Down: + case Qt::Key_Home: + case Qt::Key_End: + case Qt::Key_PageUp: + case Qt::Key_PageDown: + break; + default: + QMetaObject::invokeMethod ( + this, [this] () { eval_scheme ("(floating-search-on-input)"); }, + Qt::QueuedConnection); + break; + } + } + } + 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, + const string& mode) { + 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; + if (mode == "math") { + doc= tree (WITH, "font", "sys-chinese", "zoom-factor", + as_string (searchZoom), "mode", "math", tree (DOCUMENT, "")); + } + else { + doc= tree (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, string mode) { + QWidget* parent= get_provider_parent (); + if (!parent) return; + qt_floating_search_bar_init (parent, aux_url_str, mode); +} + +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..65fcf8af74 --- /dev/null +++ b/src/Plugins/Qt/qt_floating_search_bar.hpp @@ -0,0 +1,132 @@ + +/****************************************************************************** + * MODULE : qt_floating_search_bar.hpp + * DESCRIPTION: A VSCode-style floating search bar widget for TeXmacs + * COPYRIGHT : (C) 2026 Yuki Lu + ****************************************************************************** + * 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 + +#include "string.hpp" + +#include + +class QAbstractScrollArea; + +/** + * 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); + void toggleMode (); + +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; + QAbstractScrollArea* inputScrollArea_= nullptr; + QLabel* infoLbl_ = nullptr; + QToolButton* modeBtn_ = nullptr; + + string next_cmd_; + string prev_cmd_; + string close_cmd_; + bool mathMode_ = false; + 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 输入框初始化。\a mode 为 "text" 或 "math",决定输入框的数学环境。 +/// 失败时返回 false。 +bool qt_floating_search_bar_init (QWidget* parent, const string& aux_url_str, + const string& mode); + +/// 更新 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 和 mode ("text"/"math"), +/// 创建 texmacs-input 并嵌入浮动搜索栏。 +void qt_floating_search_init (string aux_url_str, string mode); + +/// 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..5624668ee5 100644 --- a/src/Scheme/L5/glue_widget.lua +++ b/src/Scheme/L5/glue_widget.lua @@ -526,6 +526,49 @@ 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", + "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" diff --git a/tests/Plugins/Qt/qt_floating_search_bar_test.cpp b/tests/Plugins/Qt/qt_floating_search_bar_test.cpp new file mode 100644 index 0000000000..e8604e36af --- /dev/null +++ b/tests/Plugins/Qt/qt_floating_search_bar_test.cpp @@ -0,0 +1,122 @@ + +/****************************************************************************** + * MODULE : qt_floating_search_bar_test.cpp + * DESCRIPTION: Tests for QTMFloatingSearchBar widget + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * 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/qt_floating_search_bar.hpp" +#include "base.hpp" +#include +#include +#include + +class TestFloatingSearchBar : public QObject { + Q_OBJECT + +private slots: + void init () { init_lolly (); } + + // === 构造 === + void test_constructor (); + + // === setMatchInfo === + void test_matchInfo_default_no_matches (); + void test_matchInfo_with_matches (); + void test_matchInfo_zero_matches (); + + // === setModeIcon === + void test_modeIcon_default_text_mode (); + void test_modeIcon_switch_to_math (); + void test_modeIcon_switch_back_to_text (); +}; + +/****************************************************************************** + * 构造 + ******************************************************************************/ + +void +TestFloatingSearchBar::test_constructor () { + QTMFloatingSearchBar bar; + QCOMPARE (bar.objectName (), QString ("floating_search_bar")); + QVERIFY (bar.findChild ("floating-search-info") != nullptr); + QVERIFY (bar.findChild ("floating-search-mode-text") != + nullptr); + QVERIFY (bar.findChild ("floating-search-prev") != nullptr); + QVERIFY (bar.findChild ("floating-search-next") != nullptr); + QVERIFY (bar.findChild ("floating-search-close") != nullptr); +} + +/****************************************************************************** + * setMatchInfo + ******************************************************************************/ + +void +TestFloatingSearchBar::test_matchInfo_default_no_matches () { + QTMFloatingSearchBar bar; + auto* info= bar.findChild ("floating-search-info"); + QVERIFY (info != nullptr); + // 构造后默认应显示 "No matches"(英文环境下) + QVERIFY (info->text ().contains ("No matches")); +} + +void +TestFloatingSearchBar::test_matchInfo_with_matches () { + QTMFloatingSearchBar bar; + bar.setMatchInfo (3, 10); + auto* info= bar.findChild ("floating-search-info"); + QVERIFY (info != nullptr); + QVERIFY (info->text ().contains ("3")); + QVERIFY (info->text ().contains ("10")); +} + +void +TestFloatingSearchBar::test_matchInfo_zero_matches () { + QTMFloatingSearchBar bar; + // 先设置为有匹配 + bar.setMatchInfo (1, 5); + // 再清零 + bar.setMatchInfo (0, 0); + auto* info= bar.findChild ("floating-search-info"); + QVERIFY (info != nullptr); + QVERIFY (info->text ().contains ("No matches")); +} + +/****************************************************************************** + * setModeIcon + ******************************************************************************/ + +void +TestFloatingSearchBar::test_modeIcon_default_text_mode () { + QTMFloatingSearchBar bar; + auto* modeBtn= bar.findChild (); + QVERIFY (modeBtn != nullptr); + // 默认是 text mode,objectName 应为 floating-search-mode-text + QCOMPARE (modeBtn->objectName (), QString ("floating-search-mode-text")); +} + +void +TestFloatingSearchBar::test_modeIcon_switch_to_math () { + QTMFloatingSearchBar bar; + bar.setModeIcon (true); // math mode + auto* modeBtn= bar.findChild (); + QVERIFY (modeBtn != nullptr); + QCOMPARE (modeBtn->objectName (), QString ("floating-search-mode-math")); +} + +void +TestFloatingSearchBar::test_modeIcon_switch_back_to_text () { + QTMFloatingSearchBar bar; + bar.setModeIcon (true); // math + bar.setModeIcon (false); // back to text + auto* modeBtn= bar.findChild (); + QVERIFY (modeBtn != nullptr); + QCOMPARE (modeBtn->objectName (), QString ("floating-search-mode-text")); +} + +QTEST_MAIN (TestFloatingSearchBar) +#include "qt_floating_search_bar_test.moc"