From d75070824af61569f809acdae8d622e2e90553a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 09:12:27 +0000 Subject: [PATCH] Implement tp-text reactive text feature and optimize non-reactive property handling - Modified tp--resolve-props to not add tp-name for non-reactive plists - Added tp-text property support for reactive text content - Created tp--handle-tp-text-property helper for code reuse - Added tp--update-reactive-text and tp--replace-reactive-text-in-buffer - Updated tp-set, tp-reset, tp-add to handle tp-text property - Added comprehensive tests for tp-text functionality - Updated documentation with reactive text examples Co-authored-by: Kinneyzhang <38454496+Kinneyzhang@users.noreply.github.com> --- docs/reactive-text-properties.md | 115 ++++++++++++++++++ tp-tests.el | 129 +++++++++++++++++++- tp.el | 196 +++++++++++++++++++++++++++++-- 3 files changed, 425 insertions(+), 15 deletions(-) diff --git a/docs/reactive-text-properties.md b/docs/reactive-text-properties.md index 6af20e0..8740f5d 100644 --- a/docs/reactive-text-properties.md +++ b/docs/reactive-text-properties.md @@ -341,6 +341,120 @@ tp.el 的响应式系统借鉴了 Vue 的 API,提供了三个强大的关键 匿名响应式层适用于简单的场景,当你不需要在多个地方复用同一个层定义时。 +## 响应式文本 (tp-text) + +除了响应式文本**属性**,tp.el 还支持响应式**文本内容**本身。通过特殊的 `tp-text` 属性,你可以让文本内容也变成响应式的——当绑定的变量改变时,文本内容会自动更新。 + +### 基本用法 + +`tp-text` 属性有两种使用方式: + +#### 1. 初始化当前文本 + +当 `tp-text` 的值为 `nil` 时,它会被自动设置为当前区域的文本内容: + +```lisp +(tp-pop-to-buffer "*tp-test*" + (insert "Hello World") + ;; tp-text 为 nil 时,自动初始化为当前文本 "Hello" + (tp-set 1 6 '(face bold tp-text nil)) + ;; 现在 tp-text 的值是 "Hello" + (message "tp-text = %s" (tp-at 1 'tp-text))) +;; => "Hello" +``` + +#### 2. 替换文本内容 + +当 `tp-text` 的值为字符串时,它会替换区域内的文本,同时保留其他文本属性: + +```lisp +(tp-pop-to-buffer "*tp-test*" + (insert "Hello World") + ;; tp-text 为字符串时,替换文本内容 + (tp-set 1 6 '(face bold tp-text "Hi")) + ;; 文本变为 "Hi World",且 "Hi" 仍然有 bold 样式 + (message "buffer = %s" (buffer-string))) +;; => "Hi World" +``` + +### 响应式文本层 + +`tp-text` 的真正威力在于与响应式变量结合使用: + +```lisp +;; 定义响应式变量 +(defvar my-dynamic-text "Loading...") + +;; 定义包含 tp-text 的响应式层 +(tp-define-layer 'dynamic-content + :props '(face (:foreground "blue") tp-text $my-dynamic-text)) + +;; 应用到文本 +(tp-pop-to-buffer "*tp-test*" + (insert "placeholder") + (tp-set 1 12 'dynamic-content) + ;; 文本现在显示 "Loading..." + (message "初始文本: %s" (buffer-string)) + ;; => "Loading... " + + ;; 改变变量 + (setq my-dynamic-text "数据加载完成!") + ;; 文本自动更新! + (message "更新后: %s" (buffer-string))) +;; => "数据加载完成! " +``` + +### 使用 :compute 生成动态文本 + +`tp-text` 可以与 `:compute` 结合,创建由其他变量派生的动态文本: + +```lisp +(tp-define-layer 'greeting-layer + :props '(face (:foreground "green") tp-text $full-greeting) + :data '((user-name . "访客") + (greeting-prefix . "欢迎")) + :compute '((full-greeting + (lambda () + (format "%s, %s!" greeting-prefix user-name))))) + +;; 应用到文本 +(tp-pop-to-buffer "*tp-test*" + (insert "placeholder") + (tp-set 1 12 'greeting-layer) + ;; 显示 "欢迎, 访客!" + (message "初始: %s" (buffer-string)) + + ;; 改变用户名 + (setq user-name "张三") + ;; 文本自动更新为 "欢迎, 张三!" + (message "更新后: %s" (buffer-string))) +``` + +### 匿名响应式文本 + +你也可以直接在属性列表中使用响应式 `tp-text`,无需定义层: + +```lisp +(defvar inline-text "原始内容") + +(tp-pop-to-buffer "*tp-test*" + (insert "placeholder") + ;; 直接使用响应式 tp-text + (tp-set 1 12 '(face bold tp-text $inline-text)) + ;; 显示 "原始内容" + + ;; 改变变量 + (setq inline-text "新内容") + ;; 文本自动更新为 "新内容" + ) +``` + +### 注意事项 + +1. **tp-text 只影响缓冲区文本**:对于字符串对象,由于 Emacs 字符串长度固定,`tp-text` 不会替换字符串内容。 +2. **保留现有属性**:使用 `tp-set` 或 `tp-add` 设置 `tp-text` 时,现有的文本属性会被保留。 +3. **非响应式属性不添加 tp-name**:如果文本属性中没有响应式变量(`$` 前缀),则不会添加 `tp-name` 等响应式专用属性,保持原生文本属性行为。 + ## 总结 tp.el 的响应式文本属性功能为 Emacs 开发带来了现代化的响应式编程体验。通过使用 `$` 前缀的响应式变量、`:data` 定义状态、`:compute` 计算派生值、`:watch` 监听变化,你可以构建出更加动态、易于维护的文本属性系统。 @@ -352,3 +466,4 @@ tp.el 的响应式文本属性功能为 Emacs 开发带来了现代化的响应 4. **:compute**:定义由其他变量派生的计算属性 5. **:watch**:监听变量变化并执行副作用 6. **自动更新**:改变变量值,所有相关文本自动更新 +7. **响应式文本 (tp-text)**:让文本内容本身也能响应式更新 diff --git a/tp-tests.el b/tp-tests.el index 7e5ac3f..f05ad13 100644 --- a/tp-tests.el +++ b/tp-tests.el @@ -2248,15 +2248,17 @@ incorrectly generate an anonymous tp-name instead of using the layer name." ;;; Anonymous Layer and Reactive Text Property Tests ;;; ============================================================ -(ert-deftest tp-test-set-anonymous-layer-gets-tp-name () - "Test that tp-set with anonymous plist gets a tp-name." +(ert-deftest tp-test-set-anonymous-layer-no-tp-name-for-non-reactive () + "Test that tp-set with non-reactive plist does NOT get tp-name. +Per requirement 1: non-reactive properties should not have tp-name added, +preserving the native text property behavior." (tp-test-with-temp-buffer (insert "Hello World") (tp-set 1 6 '(face bold)) - ;; Anonymous layer should have a generated tp-name - (should (tp-at 1 'tp-name)) - ;; The tp-name should be a symbol starting with tp-anon- - (should (string-prefix-p "tp-anon-" (symbol-name (tp-at 1 'tp-name)))))) + ;; Non-reactive anonymous layer should NOT have tp-name + (should-not (tp-at 1 'tp-name)) + ;; But the face property should still be set + (should (eq (tp-at 1 'face) 'bold)))) (ert-deftest tp-test-set-anonymous-reactive-layer () "Test that tp-set with anonymous reactive plist works." @@ -2943,5 +2945,120 @@ incorrectly generate an anonymous tp-name instead of using the layer name." ;; Cleanup (ignore-errors (makunbound 'tp-test-applied-color))))) +;;; ============================================================ +;;; Reactive Text (tp-text) Tests +;;; ============================================================ + +(ert-deftest tp-test-tp-text-nil-initializes-to-current-text () + "Test that tp-text with nil value is initialized to current text." + (tp-test-with-temp-buffer + (insert "Hello World") + (tp-set 1 6 '(face bold tp-text nil)) + ;; tp-text should be set to the current text + (should (equal (tp-at 1 'tp-text) "Hello")) + ;; face should still be bold + (should (eq (tp-at 1 'face) 'bold)))) + +(ert-deftest tp-test-tp-text-string-replaces-text () + "Test that tp-text with string value replaces the text in the region." + (tp-test-with-temp-buffer + (insert "Hello World") + (tp-set 1 6 '(face bold tp-text "Hi")) + ;; Text should be replaced + (should (equal (buffer-substring-no-properties 1 3) "Hi")) + ;; face should still be applied + (should (eq (tp-at 1 'face) 'bold)) + ;; tp-text property should be set + (should (equal (tp-at 1 'tp-text) "Hi")))) + +(ert-deftest tp-test-tp-text-preserves-other-properties () + "Test that tp-text replacement preserves existing properties." + (tp-test-with-temp-buffer + (insert "Hello World") + ;; First set some properties + (tp-set 1 6 '(help-echo "greeting")) + ;; Then set tp-text with face + (tp-set 1 6 '(face bold tp-text "Hi")) + ;; Text should be replaced + (should (equal (buffer-substring-no-properties 1 3) "Hi")) + ;; Both face and help-echo should be preserved + (should (eq (tp-at 1 'face) 'bold)) + (should (equal (tp-at 1 'help-echo) "greeting")))) + +(ert-deftest tp-test-tp-reset-with-tp-text () + "Test that tp-reset with tp-text works correctly." + (tp-test-with-temp-buffer + (insert "Hello World") + (tp-reset 1 6 '(face italic tp-text "Bye")) + ;; Text should be replaced + (should (equal (buffer-substring-no-properties 1 4) "Bye")) + ;; Properties should be set + (should (eq (tp-at 1 'face) 'italic)) + (should (equal (tp-at 1 'tp-text) "Bye")))) + +(ert-deftest tp-test-tp-add-with-tp-text () + "Test that tp-add with tp-text works correctly." + (tp-test-with-temp-buffer + (insert "Hello World") + (tp-set 1 6 '(help-echo "existing")) + (tp-add 1 6 '(face bold tp-text "Hi")) + ;; Text should be replaced + (should (equal (buffer-substring-no-properties 1 3) "Hi")) + ;; Both properties should be present + (should (eq (tp-at 1 'face) 'bold)) + (should (equal (tp-at 1 'help-echo) "existing")))) + +(ert-deftest tp-test-tp-text-reactive-layer () + "Test tp-text with reactive variable." + (tp-test-with-temp-buffer + (defvar tp-test-reactive-text nil "Test variable for reactive text.") + (setq tp-test-reactive-text "Initial") + (unwind-protect + (progn + (tp-define-layer 'test-reactive-text-layer + :props '(face bold tp-text $tp-test-reactive-text)) + ;; Apply layer to text + (insert "Hello World") + (tp-set 1 6 'test-reactive-text-layer) + ;; Initial text should be replaced + (should (equal (buffer-substring-no-properties 1 8) "Initial")) + ;; tp-text should be set + (should (equal (tp-at 1 'tp-text) "Initial")) + ;; Change the reactive variable + (setq tp-test-reactive-text "Changed") + ;; Text should be updated + (should (equal (buffer-substring-no-properties 1 8) "Changed")) + ;; face should still be applied + (should (eq (tp-at 1 'face) 'bold))) + ;; Cleanup + (makunbound 'tp-test-reactive-text)))) + +(ert-deftest tp-test-tp-text-reactive-computed () + "Test tp-text with computed reactive variable." + (tp-test-with-temp-buffer + (unwind-protect + (progn + (setq tp-test-name-part1 "Hello") + (setq tp-test-name-part2 "World") + (tp-define-layer 'test-computed-text-layer + :props '(face bold tp-text $tp-test-full-text) + :data '(tp-test-name-part1 tp-test-name-part2) + :compute '((tp-test-full-text + (lambda () + (concat tp-test-name-part1 " " tp-test-name-part2))))) + ;; Apply layer to text + (insert "placeholder") + (tp-set 1 12 'test-computed-text-layer) + ;; Text should be replaced with computed value + (should (equal (buffer-substring-no-properties 1 12) "Hello World")) + ;; Change a data variable + (setq tp-test-name-part1 "Goodbye") + ;; Text should be updated with new computed value + (should (equal (buffer-substring-no-properties 1 14) "Goodbye World"))) + ;; Cleanup + (ignore-errors (makunbound 'tp-test-name-part1)) + (ignore-errors (makunbound 'tp-test-name-part2)) + (ignore-errors (makunbound 'tp-test-full-text))))) + (provide 'tp-ert-tests) ;;; tp-ert-tests.el ends here diff --git a/tp.el b/tp.el index 0535f80..41b4f2b 100644 --- a/tp.el +++ b/tp.el @@ -231,7 +231,9 @@ Only 'set' operations trigger updates because: (dolist (dep deps) (let* ((layer-name (car dep)) ;; Get the reactive props stored directly in the dependency - (reactive-props (cdr dep))) + (reactive-props (cdr dep)) + ;; Check if tp-text is affected by this variable + (tp-text-affected (plist-member reactive-props 'tp-text))) ;; Call user-defined watch callbacks for this layer (tp--invoke-layer-watchers layer-name symbol newval oldval) ;; Update computed properties for this layer @@ -250,9 +252,11 @@ Only 'set' operations trigger updates because: (plist-put current-props key val))) (tp--set-layer-props layer-name current-props)))))) ;; Update text regions with this layer - ;; If WHERE is a buffer (setq-local), only update that buffer - ;; If WHERE is nil (setq), update all buffers that have the text property - (tp--update-layer-regions layer-name where)))))) + ;; If tp-text is affected, use tp--update-reactive-text for text replacement + ;; Otherwise use tp--update-layer-regions for property-only updates + (if tp-text-affected + (tp--update-reactive-text layer-name where) + (tp--update-layer-regions layer-name where))))))) (defun tp--invoke-layer-watchers (layer-name symbol newval oldval) "Invoke all registered watcher callbacks for LAYER-NAME watching SYMBOL. @@ -463,6 +467,118 @@ WHERE specifies which buffers to update: (setq tp-layer-data nil)) +;;; Reactive Text (tp-text) Functions + +(defun tp--update-reactive-text (layer-name &optional where) + "Update text regions that have tp-text property with LAYER-NAME applied. +This is called when a reactive variable bound to tp-text changes. + +WHERE specifies which buffers to update: + - If WHERE is a buffer, only update that buffer (setq-local case). + - If WHERE is nil, update all buffers that have the text property (setq case)." + (let ((props (tp-layer-props layer-name))) + (when props + (let ((new-text (plist-get props 'tp-text))) + (when (and new-text (stringp new-text)) + (if (and where (bufferp where) (buffer-live-p where)) + ;; setq-local case: only update the specific buffer + (tp-with-current-buffer where + (save-excursion + (tp--replace-reactive-text-in-buffer layer-name new-text props))) + ;; setq case: update all buffers that have the text property + (dolist (buf (buffer-list)) + (when (buffer-live-p buf) + (tp-with-current-buffer buf + (save-excursion + (tp--replace-reactive-text-in-buffer layer-name new-text props))))))))))) + +(defun tp--replace-reactive-text-in-buffer (layer-name new-text props) + "Replace text in current buffer for reactive text with LAYER-NAME. +NEW-TEXT is the new text to replace with. +PROPS are the properties to apply to the new text." + (goto-char (point-min)) + (let ((match t)) + (while match + (setq match (text-property-search-forward 'tp-name layer-name t)) + (when match + (let* ((m-start (prop-match-beginning match)) + (m-end (prop-match-end match)) + (old-text (buffer-substring-no-properties m-start m-end))) + ;; Only replace if text is different + (unless (equal old-text new-text) + ;; Delete old text and insert new + (delete-region m-start m-end) + (goto-char m-start) + (insert new-text) + ;; Apply the layer properties (including tp-text and tp-name) to new text + (let ((new-end (+ m-start (length new-text)))) + (set-text-properties m-start new-end props)))))))) + +(defun tp--handle-tp-text-property (start end props object) + "Handle tp-text property in PROPS for region from START to END in OBJECT. +If tp-text is nil, initialize it to the current text in the region. +If tp-text is a string different from current text, replace the text. +Returns (PROPS NEW-END) where PROPS is the updated props and NEW-END is +the new end position after any text replacement." + (if (not (plist-member props 'tp-text)) + ;; tp-text not in props - return unchanged + (list props end) + (let ((tp-text-val (plist-get props 'tp-text))) + (cond + ;; tp-text is nil - initialize it to the current text + ((null tp-text-val) + (let ((current-text (if (stringp object) + (substring object start end) + (if object + (with-current-buffer object + (buffer-substring-no-properties start end)) + (buffer-substring-no-properties start end))))) + (list (plist-put props 'tp-text current-text) end))) + ;; tp-text has a string value - replace the text in the region + ((stringp tp-text-val) + (if (stringp object) + ;; For strings, we can't change length, so just return as-is + (list props end) + ;; For buffers: replace text and adjust end position + (let ((old-text (if object + (with-current-buffer object + (buffer-substring-no-properties start end)) + (buffer-substring-no-properties start end)))) + (if (equal old-text tp-text-val) + ;; Same text, no replacement needed + (list props end) + ;; Need to replace text + (let ((existing-props (if object + (with-current-buffer object + (text-properties-at start)) + (text-properties-at start)))) + (save-excursion + (if object + (with-current-buffer object + (let ((inhibit-read-only t)) + (delete-region start end) + (goto-char start) + (insert tp-text-val))) + (let ((inhibit-read-only t)) + (delete-region start end) + (goto-char start) + (insert tp-text-val)))) + (let ((new-end (+ start (length tp-text-val)))) + ;; Re-apply existing properties to new text region + (when existing-props + (let ((i 0) + (len (length existing-props))) + (while (< i len) + (put-text-property start new-end + (nth i existing-props) + (nth (1+ i) existing-props) + object) + (setq i (+ i 2))))) + (list props new-end))))))) + ;; Other types - return unchanged + (t (list props end)))))) + + ;;; Core Property Functions (defun tp--parse-args (start-or-string end-or-prop props-or-val rest) @@ -538,10 +654,21 @@ This function supports four calling conventions: PROPS can also be a symbol representing a layer or group name defined by `define-tp' or `define-tp-group', which will be resolved to its properties. +Special property `tp-text': + If PROPS contains `tp-text' with a nil value, it will be initialized + to the current text in the region, making the text reactive. + If `tp-text' has a string value, the text in the region will be replaced + with this value while preserving the text properties. + This replaces only the properties specified, preserving other properties. Return the modified object (string) or region (START . END) for buffer." (pcase-let ((`(,object ,start ,finish ,props) (tp--parse-args start-or-string end-or-prop props-or-val rest))) + ;; Handle tp-text property specially using helper function + (pcase-let ((`(,new-props ,new-finish) + (tp--handle-tp-text-property start finish props object))) + (setq props new-props) + (setq finish new-finish)) ;; Apply properties individually (preserves other properties) (let ((len (length props)) (i 0)) @@ -579,9 +706,51 @@ PROPS can also be a symbol representing a layer or group name defined by `define-tp' or `define-tp-group', which will be resolved to its properties. Unlike `tp-set', this completely replaces all existing properties. + +Special property `tp-text': + If PROPS contains `tp-text' with a nil value, it will be initialized + to the current text in the region, making the text reactive. + If `tp-text' has a string value, the text in the region will be replaced + with this value while preserving the text properties. + Return the modified object (string) or region (START . END) for buffer." (pcase-let ((`(,object ,start ,finish ,props) (tp--parse-args start-or-string end-or-prop props-or-val rest))) + ;; Handle tp-text property specially using helper function + ;; Note: for tp-reset we don't preserve existing props on text replacement + ;; since tp-reset is meant to completely replace all properties + (when (plist-member props 'tp-text) + (let ((tp-text-val (plist-get props 'tp-text))) + (cond + ;; tp-text is nil - initialize it to the current text + ((null tp-text-val) + (let ((current-text (if (stringp object) + (substring object start finish) + (if object + (with-current-buffer object + (buffer-substring-no-properties start finish)) + (buffer-substring-no-properties start finish))))) + (setq props (plist-put props 'tp-text current-text)))) + ;; tp-text has a string value - replace the text in the region + ((stringp tp-text-val) + (unless (stringp object) + (let ((old-text (if object + (with-current-buffer object + (buffer-substring-no-properties start finish)) + (buffer-substring-no-properties start finish)))) + (unless (equal old-text tp-text-val) + (save-excursion + (if object + (with-current-buffer object + (let ((inhibit-read-only t)) + (delete-region start finish) + (goto-char start) + (insert tp-text-val))) + (let ((inhibit-read-only t)) + (delete-region start finish) + (goto-char start) + (insert tp-text-val)))) + (setq finish (+ start (length tp-text-val)))))))))) ;; Completely replace all properties (set-text-properties start finish props object) (if (stringp object) @@ -692,9 +861,20 @@ the existing face list rather than replacing. For example: (tp-add str \\='face \\='shadow) with existing face \\='bold results in face value \\='(shadow bold). +Special property `tp-text': + If PROPS contains `tp-text' with a nil value, it will be initialized + to the current text in the region, making the text reactive. + If `tp-text' has a string value, the text in the region will be replaced + with this value while preserving the text properties. + Return the modified object (string) or region (START . END) for buffer." (pcase-let ((`(,object ,start ,finish ,props) (tp--parse-args start-or-string end-or-prop props-or-val rest))) + ;; Handle tp-text property specially using helper function + (pcase-let ((`(,new-props ,new-finish) + (tp--handle-tp-text-property start finish props object))) + (setq props new-props) + (setq finish new-finish)) ;; Process each property with deep merging (let ((pos start)) (while (< pos finish) @@ -2422,11 +2602,9 @@ For group names, includes `tp-layers' property with the full layer stack." (tp--register-reactive-deps layer-name reactive-syms props) ;; Return resolved props with tp-name (append resolved-props (list 'tp-name layer-name))) - ;; No reactive symbols - just add tp-name if not present - (if existing-tp-name - props - (let ((layer-name (tp--generate-anonymous-layer-name))) - (append props (list 'tp-name layer-name))))))))) + ;; No reactive symbols - return props as-is (no tp-name needed) + ;; This preserves the native text property behavior for non-reactive plists + props))))) ;; Symbol - check if it's a layer or group name ((symbolp props) (cond