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>
This commit is contained in:
copilot-swe-agent[bot] 2025-12-28 09:12:27 +00:00
parent 31f08e83f3
commit d75070824a
3 changed files with 425 additions and 15 deletions

View File

@ -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)**:让文本内容本身也能响应式更新

View File

@ -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

196
tp.el
View File

@ -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