Implement reactive text properties feature with $-prefixed variables

Co-authored-by: Kinneyzhang <38454496+Kinneyzhang@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-12-20 04:53:35 +00:00
parent bb30bbe7b0
commit 1c7e37629b
2 changed files with 398 additions and 27 deletions

View File

@ -1844,5 +1844,182 @@ Returns list of (START END VALUE) intervals."
(should (stringp result)) (should (stringp result))
(should (eq result str))))) (should (eq result str)))))
;;; ============================================================
;;; Reactive Text Properties Tests
;;; ============================================================
(ert-deftest tp-test-reactive-symbol-p ()
"Test tp--reactive-symbol-p detects $-prefixed symbols."
(should (tp--reactive-symbol-p '$foo))
(should (tp--reactive-symbol-p '$my-color))
(should-not (tp--reactive-symbol-p 'foo))
(should-not (tp--reactive-symbol-p "string"))
(should-not (tp--reactive-symbol-p 42)))
(ert-deftest tp-test-reactive-var-symbol ()
"Test tp--reactive-var-symbol converts $foo to foo."
(should (eq (tp--reactive-var-symbol '$foo) 'foo))
(should (eq (tp--reactive-var-symbol '$my-color) 'my-color))
(should (null (tp--reactive-var-symbol 'foo)))
(should (null (tp--reactive-var-symbol "string"))))
(ert-deftest tp-test-collect-reactive-symbols ()
"Test tp--collect-reactive-symbols finds all $-prefixed symbols."
(should (equal (tp--collect-reactive-symbols '$foo) '($foo)))
(should (equal (tp--collect-reactive-symbols '(face (:foreground $color)))
'($color)))
(should (equal (tp--collect-reactive-symbols '(face (:foreground $color :background $bg)))
'($color $bg)))
(should (null (tp--collect-reactive-symbols '(face bold)))))
(ert-deftest tp-test-resolve-reactive-symbols ()
"Test tp--resolve-reactive-symbols replaces $foo with variable values."
;; Use defvar to create dynamically-bound variables
(defvar tp-test-my-color "red" "Test color variable.")
(defvar tp-test-my-bg "blue" "Test background variable.")
(unwind-protect
(progn
(should (equal (tp--resolve-reactive-symbols '$tp-test-my-color) "red"))
(should (equal (tp--resolve-reactive-symbols '(face (:foreground $tp-test-my-color)))
'(face (:foreground "red"))))
(should (equal (tp--resolve-reactive-symbols '(face (:foreground $tp-test-my-color :background $tp-test-my-bg)))
'(face (:foreground "red" :background "blue")))))
;; Cleanup
(makunbound 'tp-test-my-color)
(makunbound 'tp-test-my-bg)))
(ert-deftest tp-test-define-layer-with-reactive ()
"Test tp-define-layer with reactive variables."
(tp-test-with-temp-buffer
(defvar tp-test-var-color "red" "Test color variable.")
(unwind-protect
(progn
(tp-define-layer test-reactive-layer
(face (:foreground $tp-test-var-color)))
;; Check the layer is defined with resolved value
(let ((props (cdr (assoc 'test-reactive-layer tp-layer-alist))))
(should (equal (plist-get (plist-get props 'face) :foreground) "red")))
;; Check the template is stored
(should (assoc 'test-reactive-layer tp-layer-templates))
;; Check the dependency is registered
(should (assoc 'tp-test-var-color tp-reactive-deps)))
;; Cleanup
(makunbound 'tp-test-var-color))))
(ert-deftest tp-test-reactive-update-on-variable-change ()
"Test that changing a reactive variable updates the layer."
(tp-test-with-temp-buffer
(defvar tp-test-reactive-color nil "Test variable for reactive properties.")
(setq tp-test-reactive-color "red")
(unwind-protect
(progn
(tp-define-layer test-reactive-update
(face (:foreground $tp-test-reactive-color)))
;; Verify initial value
(let ((props (cdr (assoc 'test-reactive-update tp-layer-alist))))
(should (equal (plist-get (plist-get props 'face) :foreground) "red")))
;; Change the variable
(setq tp-test-reactive-color "blue")
;; Verify the layer definition is updated
(let ((props (cdr (assoc 'test-reactive-update tp-layer-alist))))
(should (equal (plist-get (plist-get props 'face) :foreground) "blue"))))
;; Cleanup
(makunbound 'tp-test-reactive-color))))
(ert-deftest tp-test-reactive-update-text-regions ()
"Test that changing a reactive variable updates applied text regions."
(tp-test-with-temp-buffer
(defvar tp-test-region-color nil "Test variable for reactive regions.")
(setq tp-test-region-color "red")
(unwind-protect
(progn
(tp-define-layer test-reactive-region
(face (:foreground $tp-test-region-color)))
(insert "Hello World")
;; Apply the layer to text
(tp-push-layer 1 6 'test-reactive-region)
;; Verify initial properties
(should (equal (plist-get (tp-at 1 'face) :foreground) "red"))
;; Change the variable
(setq tp-test-region-color "green")
;; Verify the text is updated
(should (equal (plist-get (tp-at 1 'face) :foreground) "green")))
;; Cleanup
(makunbound 'tp-test-region-color))))
(ert-deftest tp-test-reactive-reset ()
"Test tp-reactive-reset clears all reactive dependencies."
(tp-test-with-temp-buffer
(defvar tp-test-reset-color nil "Test variable for reactive reset.")
(setq tp-test-reset-color "red")
(unwind-protect
(progn
(tp-define-layer test-reactive-reset
(face (:foreground $tp-test-reset-color)))
(should tp-reactive-deps)
(should tp-layer-templates)
(tp-reactive-reset)
(should-not tp-reactive-deps)
(should-not tp-layer-templates))
;; Cleanup
(makunbound 'tp-test-reset-color))))
(ert-deftest tp-test-layer-reset-clears-reactive ()
"Test tp-layer-reset also clears reactive dependencies."
(tp-test-with-temp-buffer
(defvar tp-test-reset2-color nil "Test variable for layer reset.")
(setq tp-test-reset2-color "red")
(unwind-protect
(progn
(tp-define-layer test-reactive-reset2
(face (:foreground $tp-test-reset2-color)))
(should tp-reactive-deps)
(tp-layer-reset)
(should-not tp-reactive-deps)
(should-not tp-layer-templates))
;; Cleanup
(makunbound 'tp-test-reset2-color))))
(ert-deftest tp-test-define-layer-group-with-reactive ()
"Test tp-define-layer-group with reactive variables."
(tp-test-with-temp-buffer
(defvar tp-test-group-color nil "Test variable for layer group.")
(setq tp-test-group-color "red")
(unwind-protect
(progn
(tp-define-layer-group test-reactive-group
("first" :props (face (:foreground $tp-test-group-color)))
("second" :props (face (:foreground "blue"))))
;; Check the reactive layer is defined with resolved value
(let ((props (cdr (assoc 'test-reactive-group-first tp-layer-alist))))
(should (equal (plist-get (plist-get props 'face) :foreground) "red")))
;; Check the non-reactive layer is defined
(let ((props (cdr (assoc 'test-reactive-group-second tp-layer-alist))))
(should (equal (plist-get (plist-get props 'face) :foreground) "blue")))
;; Check the template is stored for reactive layer
(should (assoc 'test-reactive-group-first tp-layer-templates))
;; Non-reactive layer should not have template
(should-not (assoc 'test-reactive-group-second tp-layer-templates)))
;; Cleanup
(makunbound 'tp-test-group-color))))
(ert-deftest tp-test-undefine-layer-clears-reactive ()
"Test tp-undefine-layer clears reactive dependencies for that layer."
(tp-test-with-temp-buffer
(defvar tp-test-undef-color nil "Test variable for undefine.")
(setq tp-test-undef-color "red")
(unwind-protect
(progn
(tp-define-layer test-undef-reactive
(face (:foreground $tp-test-undef-color)))
(should (assoc 'test-undef-reactive tp-layer-templates))
(should (assoc 'tp-test-undef-color tp-reactive-deps))
(tp-undefine-layer 'test-undef-reactive)
(should-not (assoc 'test-undef-reactive tp-layer-templates))
;; Dependency should be cleaned up if no other layers use it
(should-not (cdr (assoc 'tp-test-undef-color tp-reactive-deps))))
;; Cleanup
(makunbound 'tp-test-undef-color))))
(provide 'tp-ert-tests) (provide 'tp-ert-tests)
;;; tp-ert-tests.el ends here ;;; tp-ert-tests.el ends here

226
tp.el
View File

@ -49,6 +49,152 @@ Stores individual layer definitions.")
"Alist where each element is (GROUP-NAME . (LAYER-NAME1 LAYER-NAME2 ...)). "Alist where each element is (GROUP-NAME . (LAYER-NAME1 LAYER-NAME2 ...)).
Stores layer group definitions, where each group contains multiple layer names.") Stores layer group definitions, where each group contains multiple layer names.")
;;; Reactive Text Properties Variables
(defvar tp-reactive-deps nil
"Alist mapping reactive variables to their dependent layers.
Each element is (VARIABLE-SYMBOL . ((LAYER-NAME . PROPERTY-SPEC) ...)).
PROPERTY-SPEC is the original property specification containing the variable.")
(defvar tp-layer-templates nil
"Alist mapping layer names to their template property specifications.
Each element is (LAYER-NAME . TEMPLATE-PLIST).
TEMPLATE-PLIST contains symbols starting with $ that need to be resolved.")
(defvar tp-reactive-enabled t
"Non-nil means reactive text property updates are enabled.")
;;; Reactive Text Properties Functions
(defun tp--reactive-symbol-p (sym)
"Return non-nil if SYM is a reactive variable symbol (starts with $)."
(and (symbolp sym)
(string-prefix-p "$" (symbol-name sym))))
(defun tp--reactive-var-symbol (sym)
"Convert a reactive symbol SYM (e.g., $foo) to its variable symbol (e.g., foo).
Returns nil if SYM is not a reactive symbol."
(when (tp--reactive-symbol-p sym)
(intern (substring (symbol-name sym) 1))))
(defun tp--collect-reactive-symbols (form)
"Recursively collect all reactive symbols ($-prefixed) from FORM.
Returns a list of reactive symbols found."
(cond
((tp--reactive-symbol-p form)
(list form))
((consp form)
(append (tp--collect-reactive-symbols (car form))
(tp--collect-reactive-symbols (cdr form))))
(t nil)))
(defun tp--resolve-reactive-symbols (form &optional override-alist)
"Recursively resolve all reactive symbols in FORM to their values.
Reactive symbols ($foo) are replaced with the value of the variable foo.
OVERRIDE-ALIST is an optional alist of (SYMBOL . VALUE) pairs that
override the current variable values (used during watcher callbacks)."
(cond
((tp--reactive-symbol-p form)
(let* ((var-sym (tp--reactive-var-symbol form))
(override (assoc var-sym override-alist)))
(if override
(cdr override)
(if (boundp var-sym)
(symbol-value var-sym)
nil))))
((consp form)
(cons (tp--resolve-reactive-symbols (car form) override-alist)
(tp--resolve-reactive-symbols (cdr form) override-alist)))
(t form)))
(defun tp--register-reactive-deps (layer-name reactive-symbols template-props)
"Register REACTIVE-SYMBOLS as dependencies for LAYER-NAME.
TEMPLATE-PROPS is the original property specification with reactive symbols."
;; Store the template for this layer
(if (assoc layer-name tp-layer-templates)
(setf (cdr (assoc layer-name tp-layer-templates)) template-props)
(push (cons layer-name template-props) tp-layer-templates))
;; Register each reactive symbol's dependency
(dolist (rsym reactive-symbols)
(let* ((var-sym (tp--reactive-var-symbol rsym))
(existing (assoc var-sym tp-reactive-deps)))
(if existing
;; Add this layer to existing dependencies if not already there
(unless (assoc layer-name (cdr existing))
(push (cons layer-name template-props) (cdr existing)))
;; Create new dependency entry and add watcher
(push (cons var-sym (list (cons layer-name template-props))) tp-reactive-deps)
;; Add variable watcher for this variable
(add-variable-watcher var-sym #'tp--reactive-variable-watcher)))))
(defun tp--unregister-reactive-deps (layer-name)
"Unregister all reactive dependencies for LAYER-NAME."
;; Remove from templates
(setq tp-layer-templates (assq-delete-all layer-name tp-layer-templates))
;; Remove from dependencies
(dolist (dep tp-reactive-deps)
(let ((var-sym (car dep)))
(setf (cdr dep) (assq-delete-all layer-name (cdr dep)))
;; If no more dependencies, remove the watcher
(when (null (cdr dep))
(remove-variable-watcher var-sym #'tp--reactive-variable-watcher))))
;; Clean up empty dependency entries
(setq tp-reactive-deps (cl-remove-if (lambda (dep) (null (cdr dep))) tp-reactive-deps)))
(defun tp--reactive-variable-watcher (symbol newval operation _where)
"Watcher function called when a reactive variable changes.
SYMBOL is the variable that changed.
NEWVAL is the new value being set.
OPERATION is the type of operation (set, let, unlet, makunbound, defvaralias).
Updates all layers that depend on this variable."
(when (and tp-reactive-enabled
(eq operation 'set)) ; Only react to set operations
(let ((deps (cdr (assoc symbol tp-reactive-deps)))
;; Create override alist with the new value
;; (watcher is called before the variable is actually updated)
(override-alist (list (cons symbol newval))))
(dolist (dep deps)
(let* ((layer-name (car dep))
(template (cdr (assoc layer-name tp-layer-templates))))
(when template
;; Resolve the template with the new value override
(let ((resolved-props (tp--resolve-reactive-symbols template override-alist)))
;; Update the layer definition using the helper function
(tp--set-layer-props layer-name resolved-props)
;; Update all text regions with this layer
(tp--update-layer-regions layer-name))))))))
(defun tp--update-layer-regions (layer-name)
"Update all text regions that have LAYER-NAME applied.
Re-applies the layer properties using tp--search-do and tp-add."
(let ((props (tp-layer-props layer-name)))
(when props
;; Update in all buffers
(dolist (buf (buffer-list))
(when (buffer-live-p buf)
(with-current-buffer buf
;; Use tp--search-do to find all regions with this layer
;; and apply updated properties directly to the buffer region
(tp--search-do
(lambda (match _obj)
(let ((m-start (car match))
(m-end (cadr match)))
;; Apply the new properties directly to the buffer region
(tp-add m-start m-end props)))
'tp-name layer-name)))))))
(defun tp-reactive-reset ()
"Reset all reactive text property watchers and dependencies."
(interactive)
;; Remove all watchers
(dolist (dep tp-reactive-deps)
(let ((var-sym (car dep)))
(remove-variable-watcher var-sym #'tp--reactive-variable-watcher)))
;; Clear registries
(setq tp-reactive-deps nil)
(setq tp-layer-templates nil))
;;; Core Property Functions ;;; Core Property Functions
@ -1524,12 +1670,23 @@ Format 2 - With :props keyword (for future extensibility):
(tp-define-layer layer-name (tp-define-layer layer-name
:props (display \"🌑\" face (:height 1.0))) :props (display \"🌑\" face (:height 1.0)))
Reactive Variables:
If any symbol in the property specification starts with $, it is
treated as a reactive variable. When that variable's value changes,
all text regions with this layer will be automatically updated.
Example:
(defvar my-color \"red\")
(tp-define-layer my-layer
(face (:foreground $my-color)))
;; Changing my-color will automatically update text with my-layer
If a layer with the same NAME already exists, it will be overwritten If a layer with the same NAME already exists, it will be overwritten
with the new definition. with the new definition.
The layer is stored in `tp-layer-alist'." The layer is stored in `tp-layer-alist'."
(declare (indent defun)) (declare (indent defun))
(let ((properties (let* ((properties
(cond (cond
;; Format 2: :props (plist) ;; Format 2: :props (plist)
((and (eq (car args) :props) ((and (eq (car args) :props)
@ -1539,12 +1696,19 @@ The layer is stored in `tp-layer-alist'."
((and (= (length args) 1) ((and (= (length args) 1)
(listp (car args))) (listp (car args)))
(car args)) (car args))
(t (error "Invalid tp-define-layer format for %s" name))))) (t (error "Invalid tp-define-layer format for %s" name))))
(reactive-syms (tp--collect-reactive-symbols properties)))
(if reactive-syms
;; Has reactive symbols - register dependencies and resolve at runtime
`(progn `(progn
(if (assoc ',name tp-layer-alist) (tp--register-reactive-deps ',name ',reactive-syms ',properties)
(setf (cdr (assoc ',name tp-layer-alist)) ',properties) (let ((resolved-props (tp--resolve-reactive-symbols ',properties)))
(push (cons ',name ',properties) tp-layer-alist)) (tp--set-layer-props ',name resolved-props))
(assoc ',name tp-layer-alist)))) (assoc ',name tp-layer-alist))
;; No reactive symbols - use static properties
`(progn
(tp--set-layer-props ',name ',properties)
(assoc ',name tp-layer-alist)))))
(defalias 'define-tp 'tp-define-layer) (defalias 'define-tp 'tp-define-layer)
@ -1622,6 +1786,11 @@ Format 3 - Named layers with :props keyword (named as NAME-suffix):
(\"残月\" :props (display \"🌘\" face (:height 1.5))) (\"残月\" :props (display \"🌘\" face (:height 1.5)))
(\"下弦月\" :props (display \"🌗\" face (:height 2.0)))) (\"下弦月\" :props (display \"🌗\" face (:height 2.0))))
Reactive Variables:
If any symbol in the property specification starts with $, it is
treated as a reactive variable. When that variable's value changes,
all text regions with that layer will be automatically updated.
You can also reference already-defined layers by their symbol name: You can also reference already-defined layers by their symbol name:
(tp-define-layer-group group-name (tp-define-layer-group group-name
existing-layer-1 existing-layer-1
@ -1643,12 +1812,19 @@ and the group itself is stored in `tp-layer-groups'."
(push parsed layer-names)) (push parsed layer-names))
;; New layer definition (cons cell of name . props) ;; New layer definition (cons cell of name . props)
((consp parsed) ((consp parsed)
(let ((layer-name (car parsed)) (let* ((layer-name (car parsed))
(props (cdr parsed))) (props (cdr parsed))
(push `(if (assoc ',layer-name tp-layer-alist) (reactive-syms (tp--collect-reactive-symbols props)))
(setf (cdr (assoc ',layer-name tp-layer-alist)) ',props) (if reactive-syms
(push (cons ',layer-name ',props) tp-layer-alist)) ;; Has reactive symbols - register dependencies and resolve at runtime
(push `(progn
(tp--register-reactive-deps ',layer-name ',reactive-syms ',props)
(let ((resolved-props (tp--resolve-reactive-symbols ',props)))
(tp--set-layer-props ',layer-name resolved-props)))
layer-defs) layer-defs)
;; No reactive symbols - use static properties
(push `(tp--set-layer-props ',layer-name ',props)
layer-defs))
(push layer-name layer-names) (push layer-name layer-names)
;; Only increment idx for anonymous (Format 1) elements ;; Only increment idx for anonymous (Format 1) elements
(when (eq (tp--layer-group-element-format element) 'format-1) (when (eq (tp--layer-group-element-format element) 'format-1)
@ -1657,13 +1833,27 @@ and the group itself is stored in `tp-layer-groups'."
(setq layer-defs (nreverse layer-defs)) (setq layer-defs (nreverse layer-defs))
`(progn `(progn
,@layer-defs ,@layer-defs
(if (assoc ',name tp-layer-groups) (tp--set-group-layers ',name ',layer-names)
(setf (cdr (assoc ',name tp-layer-groups)) ',layer-names)
(push (cons ',name ',layer-names) tp-layer-groups))
(assoc ',name tp-layer-groups)))) (assoc ',name tp-layer-groups))))
(defalias 'define-tp-group 'tp-define-layer-group) (defalias 'define-tp-group 'tp-define-layer-group)
(defun tp--set-layer-props (layer-name properties)
"Set PROPERTIES for layer LAYER-NAME in `tp-layer-alist'.
If the layer already exists, updates its properties; otherwise creates it.
This is an internal function used by layer definition macros and reactive updates."
(if (assoc layer-name tp-layer-alist)
(setf (cdr (assoc layer-name tp-layer-alist)) properties)
(push (cons layer-name properties) tp-layer-alist)))
(defun tp--set-group-layers (group-name layer-names)
"Set LAYER-NAMES for group GROUP-NAME in `tp-layer-groups'.
If the group already exists, updates its layer list; otherwise creates it.
This is an internal function used by group definition macros."
(if (assoc group-name tp-layer-groups)
(setf (cdr (assoc group-name tp-layer-groups)) layer-names)
(push (cons group-name layer-names) tp-layer-groups)))
(defun tp-layer-props (layer-name) (defun tp-layer-props (layer-name)
"Return properties for layer LAYER-NAME from `tp-layer-alist'. "Return properties for layer LAYER-NAME from `tp-layer-alist'.
Appends 'tp-name property to identify the layer." Appends 'tp-name property to identify the layer."
@ -1679,13 +1869,17 @@ Appends 'tp-name property to identify the layer."
(defun tp-layer-reset () (defun tp-layer-reset ()
"Reset all layer definitions. "Reset all layer definitions.
Clears both `tp-layer-alist' and `tp-layer-groups'." Clears both `tp-layer-alist' and `tp-layer-groups'.
Also resets all reactive text property watchers and dependencies."
(interactive) (interactive)
(tp-reactive-reset)
(setq tp-layer-alist nil) (setq tp-layer-alist nil)
(setq tp-layer-groups nil)) (setq tp-layer-groups nil))
(defun tp-undefine-layer (name) (defun tp-undefine-layer (name)
"Remove layer NAME from `tp-layer-alist'." "Remove layer NAME from `tp-layer-alist'.
Also unregisters any reactive dependencies for this layer."
(tp--unregister-reactive-deps name)
(setq tp-layer-alist (assq-delete-all name tp-layer-alist))) (setq tp-layer-alist (assq-delete-all name tp-layer-alist)))
(defun tp-undefine-group (name) (defun tp-undefine-group (name)