Fix confirmed bugs in core ops and builtins/palette modules

ops/core (B1-B8): tp-remove no longer drops 3rd+ properties; string
removal is per-interval via tp--map-intervals instead of smearing
position-0 props; tp-clear defaults bounds from OBJECT; (tp-get STR N N)
works like the buffer region form; no bogus (:key nil) from trailing
bare keywords; region form signals immediately on flat PROP/VAL args;
face-family prepend semantics extended to font-lock-face/mouse-face.

builtins/palette (B45-B51): Emacs 28.1 compat for plistp/subr-x;
display-buffer macros use a minor-mode keymap instead of mutating the
major-mode map; tp-link resolves palette colors lazily (theme-correct);
tp-palette-alist is the single source of truth (stale defvars dropped);
tp-headline handles integer heights; tp-space matches its documented
pixel spec; tp-parse-color accepts one-sided cons colors.

Test infra: fixture gains unwind-protect teardown via tp-layer-reset
(incl. transforms); file header/provide renamed to tp-tests; suite is
order-independent (verified with shuffled runs). Adds Makefile with
test/compile/clean targets.

334 tests green (280 legacy + 54 new regression tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-26 18:31:59 +08:00
parent 5e5017a726
commit 49cb8d8062
9 changed files with 840 additions and 198 deletions

3
.gitignore vendored
View File

@ -14,3 +14,6 @@ dash.el
# Syncthing conflict files
*.sync-conflict-*
.syncthing.*
# Claude worktrees
.claude/

29
Makefile Normal file
View File

@ -0,0 +1,29 @@
# Makefile for the tp library.
#
# Usage:
# make test # run all ERT test suites
# make compile # byte-compile all modules
# make clean # remove compiled files
#
# If dash.el is not on the default load-path, point LOAD_EXTRA at it:
# make test LOAD_EXTRA="-L ~/.emacs.d/elpa/dash-20240510.1327"
EMACS ?= emacs
LOAD_EXTRA ?=
LOADPATH = -L . $(LOAD_EXTRA)
SRC = tp-core.el tp-reactive.el tp-layer.el tp-ops.el tp-search.el \
tp-render.el tp-stack.el tp-palette.el tp-builtins.el tp.el
TESTS = $(wildcard *-tests.el)
.PHONY: test compile clean
test:
$(EMACS) -Q --batch $(LOADPATH) -l tp.el $(patsubst %,-l %,$(TESTS)) \
-f ert-run-tests-batch-and-exit
compile: clean
$(EMACS) -Q --batch $(LOADPATH) -f batch-byte-compile $(SRC)
clean:
rm -f *.elc

238
tp-builtins-tests.el Normal file
View File

@ -0,0 +1,238 @@
;;; tp-builtins-tests.el --- ERT tests for tp-builtins.el and tp-palette.el -*- lexical-binding: t -*-
;;; Commentary:
;; Regression tests for the built-in layers, the display buffer
;; macros, and the palette module (tp-builtins.el / tp-palette.el).
;;
;; Run with:
;; emacs --batch -L . -l tp.el -l tp-builtins-tests.el \
;; -f ert-run-tests-batch-and-exit
;;; Code:
(require 'ert)
(require 'tp)
;;; Test helpers
(defmacro tp-builtins-test--with-builtins (&rest body)
"Run BODY ensuring the shipped built-in layers are registered.
Other test files reset `tp-layer-alist' globally; reloading
tp-builtins restores the shipped layer definitions."
(declare (indent defun))
`(progn
(unless (assoc 'tp-link tp-layer-alist)
(load "tp-builtins" nil t))
,@body))
(defmacro tp-builtins-test--with-background-mode (mode &rest body)
"Run BODY with the frame background-mode set to MODE, restoring it after."
(declare (indent 1))
(let ((old (gensym "old-mode-")))
`(let ((,old (frame-parameter nil 'background-mode)))
(unwind-protect
(progn (set-frame-parameter nil 'background-mode ,mode)
,@body)
(set-frame-parameter nil 'background-mode ,old)))))
;;; B45: plistp / subr-x compatibility with Emacs 28.1
(ert-deftest tp-builtins-test-plistp-compat ()
"The `tp-palette--plistp' compat helper mirrors `plistp' semantics."
(should (tp-palette--plistp nil))
(should (tp-palette--plistp '(:a 1)))
(should (tp-palette--plistp '(:a 1 :b 2)))
(should-not (tp-palette--plistp '(:a 1 :b)))
(should-not (tp-palette--plistp "string"))
(should-not (tp-palette--plistp '(:a . 1))))
(ert-deftest tp-builtins-test-palette-pure-suffix-stripping ()
"`tp-palette-pure' strips variant suffixes (needs subr-x loaded)."
(should (eq (tp-palette-pure 'info) 'info))
(should (eq (tp-palette-pure 'info-fg) 'info))
(should (eq (tp-palette-pure 'info-bg) 'info))
(should (eq (tp-palette-pure 'info-fbg) 'info))
(should (eq (tp-palette-pure 'heading-1-border) 'heading-1))
(should-error (tp-palette-pure 'no-such-palette)))
;;; B46: display buffer macros must not mutate shared keymaps
(ert-deftest tp-builtins-test-display-buffer-no-shared-keymap-pollution ()
"Using the display macros must leave the major-mode keymap untouched."
(unwind-protect
(progn
(tp-switch-to-buffer "*tp-builtins-test-display*"
(text-mode)
(insert "hello"))
;; The shared text-mode keymap must NOT have gained a q binding.
(should-not (lookup-key text-mode-map "q"))
(with-current-buffer "*tp-builtins-test-display*"
;; q still quits, via the buffer-local minor mode.
(should (eq (key-binding "q") #'quit-window))
(should tp-display-buffer-mode)
(should buffer-read-only)
(should (equal (buffer-string) "hello"))))
(when (get-buffer "*tp-builtins-test-display*")
(kill-buffer "*tp-builtins-test-display*"))))
(ert-deftest tp-builtins-test-display-buffer-hygienic-binding ()
"BODY must see the user's own `buffer' variable, not a macro capture."
(unwind-protect
(let ((buffer "user-value"))
(tp-switch-to-buffer "*tp-builtins-test-hygiene*"
(insert buffer))
(with-current-buffer "*tp-builtins-test-hygiene*"
(should (equal (buffer-string) "user-value"))))
(when (get-buffer "*tp-builtins-test-hygiene*")
(kill-buffer "*tp-builtins-test-hygiene*"))))
(ert-deftest tp-builtins-test-pop-to-buffer-expansion-hygiene ()
"`tp-pop-to-buffer' expands to a gensym binding, never literal `buffer'."
(let* ((expansion (macroexpand-1 '(tp-pop-to-buffer "b" (ignore))))
(binding-var (caar (nth 1 expansion))))
(should (eq (car expansion) 'let))
(should (symbolp binding-var))
(should-not (eq binding-var 'buffer))))
(ert-deftest tp-builtins-test-display-buffer-reusable ()
"A second invocation erases and refills the (read-only) buffer."
(unwind-protect
(progn
(tp-switch-to-buffer "*tp-builtins-test-reuse*"
(insert "first"))
(tp-switch-to-buffer "*tp-builtins-test-reuse*"
(insert "second"))
(with-current-buffer "*tp-builtins-test-reuse*"
(should (equal (buffer-string) "second"))
(should buffer-read-only)))
(when (get-buffer "*tp-builtins-test-reuse*")
(kill-buffer "*tp-builtins-test-reuse*"))))
(ert-deftest tp-builtins-test-palette-show-smoke ()
"`tp-palette-show' renders the gallery without error."
(tp-builtins-test--with-builtins
(unwind-protect
(progn
(tp-palette-show)
(with-current-buffer "*tp-palette-gallery*"
(should (> (buffer-size) 0))
(should buffer-read-only)))
(when (get-buffer "*tp-palette-gallery*")
(kill-buffer "*tp-palette-gallery*")))))
;;; B47: tp-link resolves its palette color lazily
(ert-deftest tp-builtins-test-link-no-frozen-color ()
"The registered tp-link layer must not contain a baked-in hex color."
(tp-builtins-test--with-builtins
(let ((entry (assoc 'tp-link tp-layer-alist)))
(should entry)
(should-not (string-match-p "#[0-9a-fA-F]" (format "%S" entry))))))
(ert-deftest tp-builtins-test-link-lazy-theme-resolution ()
"tp-link resolves the info color at application time per theme."
(tp-builtins-test--with-builtins
(tp-builtins-test--with-background-mode 'light
(let ((face (get-text-property 0 'face (tp-set "x" 'tp-link t))))
(should (equal (plist-get face :foreground) "#0969da"))
(should (plist-get face :underline))))
(tp-builtins-test--with-background-mode 'dark
(let* ((s (tp-set "x" 'tp-link t))
(face (get-text-property 0 'face s)))
(should (equal (plist-get face :foreground) "#58a6ff"))
(should (plist-get face :underline))
(should (eq (get-text-property 0 'mouse-face s) 'highlight))
(should (eq (get-text-property 0 'pointer s) 'hand))))))
;;; B48: palette redefinition must not go stale
(ert-deftest tp-builtins-test-palette-redefinition-updates-colors ()
"Redefining a palette updates what the color lookups return."
(unwind-protect
(tp-builtins-test--with-background-mode 'light
(define-tp-palette tp-builtins-test-pal
:fg ("#111111" . "#aaaaaa") :bg ("#222222" . "#bbbbbb"))
(should (tp-palette-p 'tp-builtins-test-pal))
(should (equal (tp-palette-fg-color 'tp-builtins-test-pal) "#111111"))
(define-tp-palette tp-builtins-test-pal
:fg ("#333333" . "#cccccc") :bg ("#444444" . "#dddddd"))
(should (equal (tp-palette-fg-color 'tp-builtins-test-pal) "#333333"))
(should (equal (tp-palette-bg-color 'tp-builtins-test-pal) "#444444")))
(setq tp-palette-alist
(assq-delete-all 'tp-builtins-test-pal tp-palette-alist))))
;;; B49: tp-headline accepts integer heights, never emits :height nil
(ert-deftest tp-builtins-test-headline-integer-height ()
"An integer height (absolute, 1/10 pt units) produces a valid face."
(tp-builtins-test--with-builtins
(let ((face (get-text-property 0 'face (tp-set "h" 'tp-headline 120))))
(should (equal (plist-get face :height) 120))
(should (eq (plist-get face :weight) 'bold)))))
(ert-deftest tp-builtins-test-headline-float-height ()
"A float height (scaling factor) keeps its documented behavior."
(tp-builtins-test--with-builtins
(let ((face (get-text-property 0 'face (tp-set "h" 'tp-headline 1.5))))
(should (equal (plist-get face :height) 1.5))
(should (eq (plist-get face :weight) 'bold)))))
(ert-deftest tp-builtins-test-headline-plist-height ()
"A (:height H :bold B) plist is honored."
(tp-builtins-test--with-builtins
(let ((face (get-text-property 0 'face
(tp-set "h" 'tp-headline
'(:height 1.2 :bold nil)))))
(should (equal (plist-get face :height) 1.2))
(should-not (plist-get face :weight)))))
(ert-deftest tp-builtins-test-headline-never-emits-nil-height ()
"A plist without :height must not produce (:height nil)."
(tp-builtins-test--with-builtins
(let ((face (get-text-property 0 'face
(tp-set "h" 'tp-headline '(:bold t)))))
(should-not (plist-member face :height))
(should (eq (plist-get face :weight) 'bold)))))
(ert-deftest tp-builtins-test-headline-invalid-spec-errors ()
"Unsupported tp-headline specs signal an error instead of a no-op."
(tp-builtins-test--with-builtins
(should-error (tp-set "h" 'tp-headline "big"))))
;;; B50: tp-space uses the documented pixel spec
(ert-deftest tp-builtins-test-space-pixel-spec ()
"The shipped tp-space emits (space :width (PIXEL)) as documented."
(tp-builtins-test--with-builtins
;; Other test files redefine tp-space; make sure we exercise the
;; shipped definition.
(load "tp-builtins" nil t)
(should (equal (get-text-property 0 'display (tp-set "emacs" 'tp-space 2))
'(space :width (2))))))
;;; B51: tp-parse-color accepts one-sided cons colors
(ert-deftest tp-builtins-test-parse-color-one-sided-cons ()
"A cons with a nil side means no color for that mode."
(tp-builtins-test--with-background-mode 'light
(should (equal (tp-parse-color '("red" . nil)) "red"))
(should-not (tp-parse-color '(nil . "green"))))
(tp-builtins-test--with-background-mode 'dark
(should-not (tp-parse-color '("red" . nil)))
(should (equal (tp-parse-color '(nil . "green")) "green"))))
(ert-deftest tp-builtins-test-parse-color-existing-forms ()
"Strings, two-sided conses and plists keep their behavior."
(tp-builtins-test--with-background-mode 'light
(should (equal (tp-parse-color "red") "red"))
(should (equal (tp-parse-color '("red" . "green")) "red"))
(should (equal (tp-parse-color '(:light "red" :dark "green")) "red")))
(tp-builtins-test--with-background-mode 'dark
(should (equal (tp-parse-color '("red" . "green")) "green"))
(should (equal (tp-parse-color '(:light "red" :dark "green")) "green")))
(should-not (tp-parse-color nil))
(should-error (tp-parse-color 42)))
(provide 'tp-builtins-tests)
;;; tp-builtins-tests.el ends here

View File

@ -23,31 +23,51 @@
(require 'tp-ops)
(require 'tp-palette)
(defvar tp-display-buffer-mode-map
(let ((map (make-sparse-keymap)))
(define-key map "q" #'quit-window)
map)
"Keymap for `tp-display-buffer-mode'.")
(define-minor-mode tp-display-buffer-mode
"Minor mode enabled in tp read-only display buffers.
It binds \\`q' to `quit-window' in its own buffer-local minor-mode
keymap, leaving the major-mode keymap (which is shared by every
buffer of that major mode) untouched."
:lighter nil
:keymap tp-display-buffer-mode-map)
(eval-and-compile
(defun tp--display-buffer-form (buffer-or-name body display-fn)
"Build the shared expansion of the display-buffer macros.
BUFFER-OR-NAME and BODY are the macro arguments; DISPLAY-FN is
the symbol of the function used to display the populated buffer."
(let ((buffer (gensym "tp-buffer-")))
`(let ((,buffer (get-buffer-create ,buffer-or-name)))
(tp-with-current-buffer ,buffer
(erase-buffer)
,@body
(tp-display-buffer-mode 1)
(read-only-mode 1))
(,display-fn ,buffer)))))
(defmacro tp-pop-to-buffer (buffer-or-name &rest body)
"Show BUFFER-OR-NAME with `pop-to-buffer' after filling it by BODY.
The buffer is created if needed and erased, then BODY runs inside
it with `inhibit-read-only' non-nil. The buffer is finally made
read-only with `tp-display-buffer-mode' enabled, so \\`q' quits
its window."
(declare (indent defun))
`(let ((buffer (get-buffer-create ,buffer-or-name)))
(tp-with-current-buffer buffer
(erase-buffer)
,@body
(local-set-key "q" (lambda ()
(interactive)
(local-unset-key "q")
(quit-window)))
(read-only-mode 1))
(pop-to-buffer buffer)))
(tp--display-buffer-form buffer-or-name body 'pop-to-buffer))
(defmacro tp-switch-to-buffer (buffer-or-name &rest body)
"Show BUFFER-OR-NAME with `switch-to-buffer' after filling it by BODY.
The buffer is created if needed and erased, then BODY runs inside
it with `inhibit-read-only' non-nil. The buffer is finally made
read-only with `tp-display-buffer-mode' enabled, so \\`q' quits
its window."
(declare (indent defun))
`(let ((buffer (get-buffer-create ,buffer-or-name)))
(tp-with-current-buffer buffer
(erase-buffer)
,@body
(local-set-key "q" (lambda ()
(interactive)
(local-unset-key "q")
(quit-window)))
(read-only-mode 1))
(switch-to-buffer buffer)))
(tp--display-buffer-form buffer-or-name body 'switch-to-buffer))
(define-tp tp-palette (palette)
(let* ((pure-palette (tp-palette-pure palette))
@ -124,24 +144,34 @@
`(face (:strike-through ,color)))
(define-tp tp-link ()
(let ((color (tp-palette-fg-color 'info)))
`( tp-underline ,color
tp-palette info-fg
mouse-face highlight
pointer hand)))
;; No color is resolved here: the body of a zero-arg layer is
;; evaluated once, when this file is loaded, so any color computed
;; here would be frozen forever (wrong after a theme switch, or in a
;; daemon session started before any frame exists). Instead the
;; nested parameterized layer `tp-palette' resolves the info
;; foreground lazily at application time, and `:underline t'
;; underlines with that same foreground color.
'( face (:underline t)
tp-palette info-fg
mouse-face highlight
pointer hand))
(define-tp tp-space (width)
`(display (space :width ,width)))
(define-tp tp-space (pixel)
`(display (space :width (,pixel))))
(define-tp tp-headline (props)
;; PROPS is either a number - a float scaling factor or an integer
;; absolute height in units of 1/10 pt, both valid face :height
;; values - implying bold, or a (:height H :bold B) plist.
(let (height boldp)
(cond ((floatp props)
(cond ((numberp props)
(setq height props boldp t))
((plistp props)
((tp-palette--plistp props)
(setq height (plist-get props :height)
boldp (plist-get props :bold))))
`(face (:height ,height
,@(when boldp '(:weight bold))))))
boldp (plist-get props :bold)))
(t (error "Invalid tp-headline spec: %S" props)))
`(face (,@(when height (list :height height))
,@(when boldp '(:weight bold))))))
(define-tp tp-action (sexp)
;; SEXP is a function or plist

View File

@ -78,6 +78,11 @@ being created.")
NAME should be a symbol."
(memq name tp--builtin-text-properties))
(defconst tp-face-properties '(face font-lock-face mouse-face)
"Text properties whose values follow face merging semantics.
These properties hold face specs (symbols, plists or lists thereof)
and are merged with face-aware logic instead of plain replacement.")
(defun tp-debug-log (format-string &rest args)
"Log a debug message if `tp-debug-mode' is enabled.
FORMAT-STRING and ARGS are passed to `format'."
@ -218,12 +223,16 @@ and PLIST is the merged plist of all face attributes."
(setq i (1+ i)))
;; Inline keyword - consume key and value
((keywordp elem)
(let ((key elem)
(val (nth (1+ i) face-list)))
(setq plist (if plist
(plist-put plist key val)
(list key val)))
(setq i (+ i 2))))
(if (< (1+ i) len)
(let ((key elem)
(val (nth (1+ i) face-list)))
(setq plist (if plist
(plist-put plist key val)
(list key val)))
(setq i (+ i 2)))
;; Trailing bare keyword with no value: malformed input.
;; Ignore it rather than inventing a bogus (KEY nil) pair.
(setq i (1+ i))))
;; Face symbol
((symbolp elem)
(push elem symbols)
@ -330,7 +339,7 @@ For simplicity, only considers properties at position 0 of STR."
(cond
;; Face properties need special merging
;; Pass embedded val as face1 (base), existing as face2 (override)
((memq key '(face font-lock-face mouse-face))
((memq key tp-face-properties)
(tp--merge-face-values val existing))
;; Other properties - props value takes precedence
(t existing))))
@ -343,7 +352,21 @@ For simplicity, only considers properties at position 0 of STR."
FACE1 is the earlier value, FACE2 is the later value.
For face plists (like (:foreground \"red\")), merge with later overriding.
For symbol faces, create a list with FACE2 taking precedence.
Returns the merged face value."
Returns the merged face value.
Role: this is the merge engine for face values that arrive together in
a SINGLE call's property spec - `tp--merge-duplicate-keys' reduces
repeated face/font-lock-face/mouse-face keys through it, and
`tp--merge-string-props-into-plist' uses it to fold a string's embedded
face into caller props. Argument order is (EARLIER LATER); LATER wins.
Note: `tp--prepend-face' is a sibling engine used by `tp-add' to merge
an INCOMING face value into one already present on the text. Its
argument order is swapped ((NEW EXISTING)), and the two engines have
drifted for mixed lists: `tp--prepend-face' parses a mixed
symbol/plist list and merges plist components, whereas this function
conses a plist override onto a non-plist list without parsing. Do not
substitute one for the other without checking those cases."
(cond
;; No earlier face - just use later face
((null face1) face2)
@ -460,7 +483,7 @@ Example:
(let ((merged-val
(cond
;; Face properties - use special face merging
((memq key '(face font-lock-face mouse-face))
((memq key tp-face-properties)
(cl-reduce #'tp--merge-face-values values))
;; Other properties - later overrides earlier
(t (car (last values))))))
@ -595,7 +618,20 @@ Examples:
If NEW-FACE is a plist (like (:foreground \"red\")), deeply merge it.
If NEW-FACE is a symbol or list of faces, prepend it to create a face list.
For mixed lists containing both symbols and plists, plists are merged correctly.
Duplicate faces are not added."
Duplicate faces are not added.
Role: this is the merge engine `tp-add' uses to fold an INCOMING face
value into the face value already present on the text, for every
property in `tp-face-properties'. Argument order is (NEW EXISTING);
NEW wins.
Note: `tp--merge-face-values' is a sibling engine (argument order
swapped: (EARLIER LATER)) used when duplicate face keys appear within
a single call's property spec. The two have drifted for mixed
symbol/plist lists - this function parses such lists and merges their
plist components, `tp--merge-face-values' conses a plist override onto
a non-plist list without parsing. Do not substitute one for the other
without checking those cases."
(cond
;; No existing face - just use new face
((null existing-face) new-face)
@ -665,11 +701,6 @@ Duplicate faces are not added."
(t new-face)))
(t new-face)))
(defconst tp-face-properties '(face font-lock-face mouse-face)
"Text properties whose values follow face merging semantics.
These properties hold face specs (symbols, plists or lists thereof)
and are merged with face-aware logic instead of plain replacement.")
(defun tp--map-intervals (object start end function &optional property)
"Iterate property intervals of OBJECT between START and END, clipped.

254
tp-ops-tests.el Normal file
View File

@ -0,0 +1,254 @@
;;; tp-ops-tests.el --- ERT regression tests for tp-ops.el -*- lexical-binding: t -*-
;;; Commentary:
;; Regression tests for confirmed bugs fixed in the ops-core module
;; (tp-ops.el, with supporting fixes in tp-core.el). Each section is
;; tagged with the canonical bug id it guards against.
;;; Code:
(require 'ert)
(require 'tp)
;;; B1: tp-remove string form must not drop the 3rd+ properties
(ert-deftest tp-ops-test-remove-string-three-props ()
"Removing three properties from a string removes all three."
(let* ((str (propertize "hi" 'face 'bold 'help-echo "x" 'mouse-face 'highlight))
(result (tp-remove str 'face 'help-echo 'mouse-face)))
(should (null (get-text-property 0 'face result)))
(should (null (get-text-property 0 'help-echo result)))
(should (null (get-text-property 0 'mouse-face result)))))
(ert-deftest tp-ops-test-remove-string-four-props ()
"Removing four properties removes all four; nothing rides along."
(let* ((str (propertize "hi" 'face 'bold 'help-echo "x"
'mouse-face 'highlight 'keymap 'km))
(result (tp-remove str 'face 'help-echo 'mouse-face 'keymap)))
(should (null (text-properties-at 0 result)))))
(ert-deftest tp-ops-test-remove-string-third-prop-kept-elsewhere ()
"Properties not listed stay when 3+ properties are removed."
(let* ((str (propertize "hi" 'face 'bold 'help-echo "x"
'mouse-face 'highlight 'keymap 'km))
(result (tp-remove str 'face 'help-echo 'mouse-face)))
(should (eq (get-text-property 0 'keymap result) 'km))))
;;; B2: string-form removal operates per interval
(ert-deftest tp-ops-test-remove-string-prop-preserves-other-intervals ()
"Removing a property keeps each interval's own remaining props."
(let* ((s (concat (propertize "ab" 'face 'bold)
(propertize "cd" 'face 'italic 'help-echo "x")))
(result (tp-remove s 'help-echo)))
(should (eq (get-text-property 0 'face result) 'bold))
(should (eq (get-text-property 2 'face result) 'italic))
(should (null (get-text-property 2 'help-echo result)))))
(ert-deftest tp-ops-test-remove-string-sub-key-per-interval ()
"Sub-key removal does not smear one interval's face over another."
(let* ((s (concat (propertize "ab" 'face '(:weight bold :underline t))
(propertize "cd" 'face 'italic)))
(result (tp-remove s 'face :underline)))
(let ((face0 (get-text-property 0 'face result)))
(should (eq (plist-get face0 :weight) 'bold))
(should (null (plist-get face0 :underline))))
(should (eq (get-text-property 2 'face result) 'italic))))
(ert-deftest tp-ops-test-remove-string-nested-sub-key-per-interval ()
"Nested sub-key removal keeps other intervals' face values intact."
(let* ((s (concat (propertize "ab" 'face '(:underline (:style wave :position t)))
(propertize "cd" 'face 'italic)))
(result (tp-remove s 'face :underline '(:style))))
(let* ((face0 (get-text-property 0 'face result))
(underline (plist-get face0 :underline)))
(should (plist-get underline :position))
(should (null (plist-get underline :style))))
(should (eq (get-text-property 2 'face result) 'italic))))
(ert-deftest tp-ops-test-remove-string-prop-interval-boundaries-kept ()
"Interval boundaries survive removal of an unrelated property."
(let* ((s (concat (propertize "ab" 'face 'bold)
"cd"
(propertize "ef" 'face 'underline 'help-echo "z")))
(result (tp-remove s 'help-echo)))
(should (eq (get-text-property 0 'face result) 'bold))
(should (null (get-text-property 2 'face result)))
(should (eq (get-text-property 4 'face result) 'underline))
(should (null (get-text-property 4 'help-echo result)))))
;;; B3: tp-clear defaults bounds from OBJECT
(ert-deftest tp-ops-test-clear-string-defaults ()
"tp-clear with a string OBJECT clears the whole string by default."
(with-temp-buffer ; empty buffer: old code no-oped
(let ((s (propertize "hey" 'face 'bold 'help-echo "x")))
(tp-clear nil nil s)
(should (null (text-properties-at 0 s)))
(should (tp-empty-p s)))))
(ert-deftest tp-ops-test-clear-string-defaults-in-longer-buffer ()
"tp-clear on a short string works even when current buffer is longer."
(with-temp-buffer
(insert (make-string 100 ?x))
(let ((s (propertize "ab" 'face 'bold)))
(tp-clear nil nil s) ; old code: args-out-of-range
(should (tp-empty-p s)))))
(ert-deftest tp-ops-test-clear-buffer-defaults-still-work ()
"tp-clear with no args still clears the whole current buffer."
(with-temp-buffer
(insert "Hello")
(put-text-property 1 6 'face 'bold)
(tp-clear)
(should (null (text-properties-at 1)))))
(ert-deftest tp-ops-test-clear-buffer-object-defaults ()
"tp-clear defaults bounds from a buffer OBJECT, not the current buffer."
(let ((buf (generate-new-buffer " tp-ops-test-clear")))
(unwind-protect
(progn
(with-current-buffer buf
(insert "Hello")
(put-text-property 1 6 'face 'bold))
(with-temp-buffer ; empty current buffer
(tp-clear nil nil buf))
(with-current-buffer buf
(should (null (text-properties-at 1)))))
(kill-buffer buf))))
;;; B4: (tp-get STRING START END ...) range form
(ert-deftest tp-ops-test-get-string-numeric-range ()
"String object with a numeric range returns the intervals in range."
(let ((str (propertize "hey" 'face 'bold)))
(should (equal (tp-get str 0 2) '((0 2 (face bold)))))))
(ert-deftest tp-ops-test-get-string-numeric-range-with-property ()
"String range form accepts a property like the buffer region form."
(let ((str (concat (propertize "ab" 'face 'bold)
(propertize "cd" 'face 'italic))))
(should (equal (tp-get str 0 4 'face)
'((0 2 bold) (2 4 italic))))
(should (equal (tp-get str 2 4 'face) '((2 4 italic))))))
(ert-deftest tp-ops-test-get-string-numeric-range-with-sub-path ()
"String range form supports nested sub-paths."
(let ((str (propertize "hey" 'face '(:foreground "red" :weight bold))))
(should (equal (tp-get str 0 3 'face :foreground)
'((0 3 "red"))))))
(ert-deftest tp-ops-test-get-string-numeric-start-without-end-errors ()
"A numeric START without a numeric END signals a clear error."
(should-error (tp-get (propertize "hey" 'face 'bold) 0)))
;;; B5: tp--parse-face-list trailing bare keyword
(ert-deftest tp-ops-test-parse-face-list-trailing-keyword ()
"A trailing bare keyword does not produce a bogus (KEY nil) pair."
(should (equal (tp--parse-face-list '(bold :foreground))
'((bold)))))
(ert-deftest tp-ops-test-parse-face-list-inline-keyword-still-works ()
"Inline keyword-value pairs are still consumed normally."
(should (equal (tp--parse-face-list '(bold :foreground "green"))
'((bold) :foreground "green"))))
;;; B6: region form with flat prop/val signals immediately
(ert-deftest tp-ops-test-set-region-flat-args-error ()
"Region form with flat PROP/VAL args signals an immediate error."
(with-temp-buffer
(insert "hello")
(should-error (tp-set 1 4 'face 'bold))
;; Nothing was applied
(should (null (get-text-property 1 'face)))))
(ert-deftest tp-ops-test-add-and-reset-region-flat-args-error ()
"tp-add and tp-reset region forms reject flat PROP/VAL args too."
(with-temp-buffer
(insert "hello")
(should-error (tp-add 1 4 'face 'bold))
(should-error (tp-reset 1 4 'face 'bold))))
(ert-deftest tp-ops-test-set-region-with-object-still-works ()
"Region form with a plist and trailing OBJECT is unaffected."
(let ((s (copy-sequence "hello")))
(tp-set 0 3 '(face bold) s)
(should (eq (get-text-property 0 'face s) 'bold)))
(with-temp-buffer
(insert "hello")
(tp-set 1 4 '(face bold))
(should (eq (get-text-property 1 'face) 'bold))))
;;; B7: tp-add face-family prepend semantics for all tp-face-properties
(ert-deftest tp-ops-test-add-font-lock-face-prepends-string ()
"tp-add prepends font-lock-face like face (string form)."
(let* ((s (propertize "hey" 'font-lock-face 'bold))
(result (tp-add s 'font-lock-face 'italic)))
(should (equal (get-text-property 0 'font-lock-face result)
'(italic bold)))))
(ert-deftest tp-ops-test-add-mouse-face-prepends-string ()
"tp-add prepends mouse-face like face (string form)."
(let* ((s (propertize "hey" 'mouse-face 'highlight))
(result (tp-add s 'mouse-face 'region)))
(should (equal (get-text-property 0 'mouse-face result)
'(region highlight)))))
(ert-deftest tp-ops-test-add-font-lock-face-prepends-buffer ()
"tp-add prepends font-lock-face like face (buffer region form)."
(with-temp-buffer
(insert "hey")
(put-text-property 1 4 'font-lock-face 'bold)
(tp-add 1 4 '(font-lock-face italic))
(should (equal (get-text-property 1 'font-lock-face) '(italic bold)))))
(ert-deftest tp-ops-test-add-font-lock-face-prepends-string-region ()
"tp-add prepends font-lock-face in the string region form."
(let ((s (propertize "hey" 'font-lock-face 'bold)))
(tp-add 0 3 '(font-lock-face italic) s)
(should (equal (get-text-property 0 'font-lock-face s) '(italic bold)))))
(ert-deftest tp-ops-test-add-font-lock-face-plist-merge ()
"tp-add deep-merges font-lock-face plists like face plists."
(let* ((s (propertize "hey" 'font-lock-face '(:foreground "red")))
(result (tp-add s 'font-lock-face '(:background "blue"))))
(let ((flf (get-text-property 0 'font-lock-face result)))
(should (equal (plist-get flf :foreground) "red"))
(should (equal (plist-get flf :background) "blue")))))
(ert-deftest tp-ops-test-add-non-face-property-still-replaces ()
"Non-face properties keep plain replacement semantics in tp-add."
(let* ((s (propertize "hey" 'help-echo "old"))
(result (tp-add s 'help-echo "new")))
(should (equal (get-text-property 0 'help-echo result) "new"))))
;;; B8: tp-intervals clips to the requested range
(ert-deftest tp-ops-test-intervals-clipped-buffer ()
"Buffer intervals are clipped: offsets stay within [0, END-START)."
(with-temp-buffer
(insert "abcdef")
(put-text-property 1 5 'face 'bold)
(let ((intervals (tp-intervals 3 6)))
(dolist (iv intervals)
(should (>= (nth 0 iv) 0))
(should (<= (nth 1 iv) 3))
(should (< (nth 0 iv) (nth 1 iv))))
(should (equal intervals '((0 2 (face bold)) (2 3 nil)))))))
(ert-deftest tp-ops-test-intervals-clipped-string ()
"String intervals are clipped to [START, END)."
(let ((s (copy-sequence "abcdef")))
(put-text-property 0 6 'face 'bold s)
(let ((intervals (tp-intervals 2 4 s)))
(dolist (iv intervals)
(should (>= (nth 0 iv) 2))
(should (<= (nth 1 iv) 4)))
(should (equal intervals '((2 4 (face bold))))))))
(provide 'tp-ops-tests)
;;; tp-ops-tests.el ends here

272
tp-ops.el
View File

@ -84,9 +84,18 @@ Supports multiple calling conventions:
finish end-or-prop
props props-or-val)
;; Check if 4th arg (first of rest) is a buffer or string
(when (and rest (or (bufferp (car rest))
(stringp (car rest))))
(setq object (car rest))))
(let ((extra rest))
(when (and extra (or (bufferp (car extra))
(stringp (car extra))))
(setq object (car extra)
extra (cdr extra)))
;; Anything left over is not a valid region-form argument.
;; In particular, flat PROP/VAL pairs like (tp-set 1 4 'face 'bold)
;; are only supported in the whole-string form; region form takes
;; a plist. Signal immediately instead of silently discarding.
(when extra
(error "Region form takes a properties plist: (tp-set START END '(PROP VAL ...) &optional OBJECT); flat PROP/VAL arguments like %S are only supported in the whole-string form"
(car extra)))))
(t (error "Invalid first argument: %S" start-or-string)))
;; Unwrap double-wrapped properties
(when (and (listp props) (listp (car-safe props)))
@ -131,7 +140,8 @@ Returns a new propertized string."
(while (< pos end)
(let* ((current-val (get-text-property pos key result))
(new-val (cond
((eq key 'face) (tp--prepend-face val current-val))
((memq key tp-face-properties)
(tp--prepend-face val current-val))
((and (listp val) (keywordp (car-safe val))
(listp current-val) (keywordp (car-safe current-val)))
(tp--deep-merge-plist current-val val))
@ -245,7 +255,9 @@ Returns: For buffers, (START . END) cons. For strings, the result string."
(defun tp-add (start-or-string &optional end-or-prop props-or-val &rest rest)
"Add or update text properties with deep merging.
Unlike `tp-set', deeply merges nested properties.
For `face' property, symbol faces are prepended to existing face list.
For face-family properties (see `tp-face-properties': face,
font-lock-face, mouse-face), symbol faces are prepended to the
existing face list and face plists are deep-merged.
For tp-text, embedded text properties are merged with props.
**String Modification Behavior:**
@ -290,7 +302,8 @@ Returns: For buffers, (START . END) cons. For strings, the result string."
for (key val) on props by #'cddr
do (let* ((current-val (plist-get current-props key))
(new-val (cond
((eq key 'face) (tp--prepend-face val current-val))
((memq key tp-face-properties)
(tp--prepend-face val current-val))
((and (listp val) (keywordp (car-safe val))
(listp current-val) (keywordp (car-safe current-val)))
(tp--deep-merge-plist current-val val))
@ -308,7 +321,8 @@ Returns: For buffers, (START . END) cons. For strings, the result string."
for (key val) on props by #'cddr
do (let* ((current-val (plist-get current-props key))
(new-val (cond
((eq key 'face) (tp--prepend-face val current-val))
((memq key tp-face-properties)
(tp--prepend-face val current-val))
((and (listp val) (keywordp (car-safe val))
(listp current-val) (keywordp (car-safe current-val)))
(tp--deep-merge-plist current-val val))
@ -321,7 +335,13 @@ Returns: For buffers, (START . END) cons. For strings, the result string."
"Get text property value(s) with support for nested sub-properties.
Returns list of (START END VALUE) intervals.
Use `tp-at' for single position queries.
OBJECT defaults to current buffer."
OBJECT defaults to current buffer.
Calling conventions:
(tp-get STRING [PROPERTY [SUB-KEYS...]]) - entire string
(tp-get STRING START END [PROPERTY ...]) - range within STRING
(tp-get START END [PROPERTY ...] [OBJECT]) - region form
String positions are 0-based; buffer positions are 1-based."
(cond
;; (tp-get STRING ...) - entire string
;; Returns list of (START END VALUE) intervals for all property values
@ -342,6 +362,18 @@ OBJECT defaults to current buffer."
(push (list pos next-pos current-props) intervals))
(setq pos next-pos)))
(nreverse intervals)))
;; (tp-get str START END [PROPERTY [SUB-KEYS...]]) - range within
;; the string, consistent with the buffer region form. Positions
;; are 0-based as everywhere else for strings.
((numberp end-or-property)
(let ((range-start end-or-property)
(range-end (car args)))
(unless (numberp range-end)
(error "tp-get: string range form requires a numeric END after START, got %S"
range-end))
;; Delegate to the region form with the string as OBJECT.
(apply #'tp-get range-start range-end
(append (cdr args) (list str)))))
;; (tp-get str '(face :foreground)) - property path as list
((listp end-or-property)
(setq property (car end-or-property))
@ -668,8 +700,12 @@ Returns: For buffers, nil. For entire string forms, a new string."
(tp--remove-sub-from-string str start end end-or-prop prop-or-sub))
;; (tp-remove str 'face 'help-echo ...) - multiple properties
((symbolp end-or-prop)
(let ((props-to-remove (cl-remove-if-not #'symbolp
(list end-or-prop prop-or-sub rest))))
;; Splice REST so the 3rd and later properties are kept, and
;; drop nils (nil is a symbol and would otherwise ride along
;; when PROP-OR-SUB is not given).
(let ((props-to-remove (cl-remove-if-not
(lambda (p) (and p (symbolp p)))
(cons end-or-prop (cons prop-or-sub rest)))))
(tp--remove-props-from-string str start end props-to-remove)))
;; (tp-remove str '(face :underline)) - nested property spec
((listp end-or-prop)
@ -691,91 +727,82 @@ PROPS-TO-REMOVE can include layer names, which will be expanded to include
all properties that the layer adds.
For face properties from layers, subtracts the layer's face contribution
instead of removing the entire face property.
Operates per property interval, so every interval keeps its own
remaining properties (intervals are never overwritten with properties
sampled at START).
Returns a new string (original is not modified)."
(let* ((len (length str))
(start (max 0 start))
(end (min end len))
(before (when (> start 0)
(substring str 0 start)))
(middle-text (substring-no-properties str start end))
(after (when (< end len)
(substring str end len)))
(existing-props (text-properties-at start str))
;; Remaining face after layer subtractions
(remaining-face nil)
;; Track if face was modified by layer subtraction
(face-was-modified nil)
;; Collect all properties to remove entirely (non-face or non-layer)
(props-to-remove-entirely nil))
;; Process each property to remove
(dolist (prop props-to-remove)
(if (tp--is-layer-name-p prop)
;; Layer name - get its face contribution and subtract from face
(let* ((layer-prop-value (plist-get existing-props prop))
(layer-face (tp--get-layer-face-contribution prop layer-prop-value)))
;; Subtract layer's face from the current face
(when layer-face
(let ((current-face (or remaining-face (plist-get existing-props 'face))))
(setq remaining-face
(tp--subtract-face-from-face-value current-face layer-face))
;; Mark that we processed the face (even if result is nil)
(setq face-was-modified t)))
;; Add the layer property itself to remove list
(push prop props-to-remove-entirely)
;; Also add tp-name if it matches
(when (eq (plist-get existing-props 'tp-name) prop)
(push 'tp-name props-to-remove-entirely)))
;; Non-layer property - remove entirely
(push prop props-to-remove-entirely)))
;; Build final properties
(let* ((final-props
(let ((result nil))
(cl-loop for (key val) on existing-props by #'cddr
do (cond
;; Face property with layer subtraction
((and (eq key 'face) face-was-modified)
(when remaining-face
(setq result (plist-put result key remaining-face))))
;; Property to remove entirely
((memq key props-to-remove-entirely)
nil) ; skip
;; Keep other properties
(t (setq result (plist-put result key val)))))
result))
(middle-propertized (if final-props
(apply #'propertize middle-text final-props)
middle-text)))
(concat before middle-propertized after))))
(let ((result (copy-sequence str)))
(tp--map-intervals
str start end
(lambda (istart iend existing-props)
(let (;; Remaining face after layer subtractions (this interval)
(remaining-face nil)
;; Track if face was modified by layer subtraction
(face-was-modified nil)
;; Collect all properties to remove entirely (non-face or non-layer)
(props-to-remove-entirely nil))
;; Process each property to remove against this interval's props
(dolist (prop props-to-remove)
(if (tp--is-layer-name-p prop)
;; Layer name - get its face contribution and subtract from face
(let* ((layer-prop-value (plist-get existing-props prop))
(layer-face (tp--get-layer-face-contribution prop layer-prop-value)))
;; Subtract layer's face from the current face
(when layer-face
(let ((current-face (or remaining-face
(plist-get existing-props 'face))))
(setq remaining-face
(tp--subtract-face-from-face-value current-face layer-face))
;; Mark that we processed the face (even if result is nil)
(setq face-was-modified t)))
;; Add the layer property itself to remove list
(push prop props-to-remove-entirely)
;; Also add tp-name if it matches
(when (eq (plist-get existing-props 'tp-name) prop)
(push 'tp-name props-to-remove-entirely)))
;; Non-layer property - remove entirely
(push prop props-to-remove-entirely)))
;; Build this interval's final properties
(let ((final-props
(let ((res nil))
(cl-loop for (key val) on existing-props by #'cddr
do (cond
;; Face property with layer subtraction
((and (eq key 'face) face-was-modified)
(when remaining-face
(setq res (plist-put res key remaining-face))))
;; Property to remove entirely
((memq key props-to-remove-entirely)
nil) ; skip
;; Keep other properties
(t (setq res (plist-put res key val)))))
res)))
(set-text-properties istart iend final-props result)))))
result))
(defun tp--remove-sub-from-string (str start end property sub-key)
"Create a new string from STR with SUB-KEY removed from PROPERTY.
Returns a new string (original is not modified).
Handles complex face values that contain a mix of symbols and plists."
(let* ((len (length str))
(start (max 0 start))
(end (min end len))
(before (when (> start 0)
(substring str 0 start)))
(middle-text (substring-no-properties str start end))
(after (when (< end len)
(substring str end len)))
;; Get existing properties and modify the property
(existing-props (text-properties-at start str))
(prop-value (plist-get existing-props property))
;; Use the new helper to handle complex face values
(new-value (when prop-value
(tp--remove-sub-from-face-value prop-value sub-key)))
(final-props (let ((result nil))
(cl-loop for (key val) on existing-props by #'cddr
do (setq result (plist-put result key
(if (eq key property)
new-value
val))))
result))
(middle-propertized (if final-props
(apply #'propertize middle-text final-props)
middle-text)))
(concat before middle-propertized after)))
Handles complex face values that contain a mix of symbols and plists.
Operates per property interval, so every interval keeps its own
remaining properties."
(let ((result (copy-sequence str)))
(tp--map-intervals
str start end
(lambda (istart iend existing-props)
(let* ((prop-value (plist-get existing-props property))
;; Use the helper to handle complex face values
(new-value (when prop-value
(tp--remove-sub-from-face-value prop-value sub-key)))
(final-props (let ((res nil))
(cl-loop for (key val) on existing-props by #'cddr
do (setq res (plist-put res key
(if (eq key property)
new-value
val))))
res)))
(set-text-properties istart iend final-props result))))
result))
(defun tp--remove-property-from-string (str start end property-spec)
"Create a new string from STR with PROPERTY-SPEC removed from START to END.
@ -789,32 +816,30 @@ Returns a new string (original is not modified)."
(sub-key (cadr property-spec))
(nested-keys (caddr property-spec)))
(cond
;; Nested sub-property removal
;; Nested sub-property removal - per interval so every interval
;; keeps its own remaining properties
((and sub-key nested-keys)
;; For complex nested removal, we need to handle this specially
(let* ((len (length str))
(start (max 0 start))
(end (min end len))
(before (when (> start 0)
(substring str 0 start)))
(middle-text (substring-no-properties str start end))
(after (when (< end len)
(substring str end len)))
(existing-props (text-properties-at start str))
(prop-value (plist-get existing-props property))
(new-value (when (and prop-value (listp prop-value))
(tp--remove-nested-sub-keys prop-value sub-key nested-keys)))
(final-props (let ((result nil))
(cl-loop for (key val) on existing-props by #'cddr
do (setq result (plist-put result key
(if (eq key property)
new-value
val))))
result))
(middle-propertized (if final-props
(apply #'propertize middle-text final-props)
middle-text)))
(concat before middle-propertized after)))
(let ((result (copy-sequence str)))
(tp--map-intervals
str start end
(lambda (istart iend existing-props)
(let* ((prop-value (plist-get existing-props property))
(new-value (if (and prop-value (listp prop-value))
(tp--remove-nested-sub-keys
prop-value sub-key nested-keys)
;; Not a plist-shaped value (e.g. a bare
;; face symbol) - the nested spec does
;; not apply; keep the value unchanged.
prop-value))
(final-props (let ((res nil))
(cl-loop for (key val) on existing-props by #'cddr
do (setq res (plist-put res key
(if (eq key property)
new-value
val))))
res)))
(set-text-properties istart iend final-props result))))
result))
;; Simple sub-property removal
(sub-key
(tp--remove-sub-from-string str start end property sub-key))
@ -853,10 +878,21 @@ Returns a new plist (does not modify the original)."
;;;###autoload
(defun tp-clear (&optional start end object)
"Clear all text properties from START to END in OBJECT.
If START and END are not provided, clear the entire buffer."
OBJECT is a string or buffer; nil means the current buffer.
If START and END are not provided, they default to the whole of
OBJECT: 0/(length OBJECT) for strings, `point-min'/`point-max' of
OBJECT for buffers (the current buffer when OBJECT is nil)."
(interactive)
(let ((beg (or start (point-min)))
(finish (or end (point-max))))
(let ((beg (or start
(cond ((stringp object) 0)
((bufferp object)
(with-current-buffer object (point-min)))
(t (point-min)))))
(finish (or end
(cond ((stringp object) (length object))
((bufferp object)
(with-current-buffer object (point-max)))
(t (point-max))))))
(set-text-properties beg finish nil object)))
(provide 'tp-ops)

View File

@ -17,15 +17,30 @@
;;; Code:
(defvar tp-palette-alist nil)
(require 'subr-x) ; string-trim-right
(defalias 'tp-palette--plistp
(if (fboundp 'plistp)
#'plistp
(lambda (object)
(let ((len (proper-list-p object)))
(and len (zerop (% len 2)) t))))
"Return non-nil if OBJECT is a property list.
Compatibility shim: `plistp' was only added in Emacs 29.1, while
the library supports Emacs 28.1.")
(defvar tp-palette-alist nil
"Alist of (NAME . PLIST) palette definitions.
This is the single source of truth for palette lookups.")
(defmacro define-tp-palette (name &rest plist)
"Register a color palette named NAME, defined by PLIST.
PLIST maps the keys :fg, :bg and :border to colors in any format
accepted by `tp-parse-color' (usually a (LIGHT . DARK) cons).
The palette is stored in `tp-palette-alist'; re-evaluating a
definition updates the stored palette in place."
(declare (indent defun))
(let ((var (intern (concat "tp-palette-" (symbol-name name)))))
`(progn
(setf (alist-get ',name tp-palette-alist)
'(,@plist))
(defvar ,var '(,@plist)))))
`(setf (alist-get ',name tp-palette-alist) '(,@plist)))
(define-tp-palette button-primary
:fg ("#ffffff" . "#ffffff") :bg ("#007bff" . "#007bff"))
@ -236,19 +251,25 @@
(eq (frame-parameter nil 'background-mode) 'light))
(defun tp-parse-color (color)
"e.g.1 (tp-parse-color \"red\")
e.g.2 (tp-parse-color '(\"red\" . \"green\"))
e.g.3 (tp-parse-color '(:light \"red\" :dark \"green\"))"
"Resolve COLOR to a color string for the current theme.
COLOR may be:
- a color string, returned as is: \"red\"
- a (LIGHT . DARK) cons: (\"red\" . \"green\"); either side may be
nil, meaning no color for that mode
- a (:light LIGHT :dark DARK) plist: (:light \"red\" :dark \"green\")
Return nil when COLOR is nil, or when the side selected by the
current theme is nil. When the theme cannot be determined, fall
back to the light color."
(cond ((stringp color) color)
((and (consp color)
(stringp (car color))
(stringp (cdr color)))
(or (stringp (car color)) (null (car color)))
(or (stringp (cdr color)) (null (cdr color))))
(cond
((tp-theme-light-p) (car color))
((tp-theme-dark-p) (cdr color))
;; Default to light color when background-mode is unknown
(t (car color))))
((and (plistp color)
((and (tp-palette--plistp color)
(or (plist-member color :light)
(plist-member color :dark)))
(cond
@ -260,16 +281,13 @@ e.g.3 (tp-parse-color '(:light \"red\" :dark \"green\"))"
(t (error "Invalid format of color %S" color))))
(defun tp-palette--get-color (symbol key)
"Get color value for KEY from palette SYMBOL.
SYMBOL should be a symbol bound to a palette plist.
KEY should be one of :fg, :bg, or :border.
Returns nil if SYMBOL is unbound or doesn't contain KEY."
(setq symbol (intern (concat "tp-palette-"
(symbol-name symbol))))
(when (and (symbolp symbol) (boundp symbol))
(let ((plist (symbol-value symbol)))
(when (plistp plist)
(tp-parse-color (plist-get plist key))))))
"Get color value for KEY from the palette named SYMBOL.
SYMBOL is looked up in `tp-palette-alist'. KEY should be one of
:fg, :bg, or :border. Return nil if SYMBOL names no registered
palette or its definition doesn't contain KEY."
(let ((plist (alist-get symbol tp-palette-alist)))
(when (tp-palette--plistp plist)
(tp-parse-color (plist-get plist key)))))
(defun tp-palette-fg-color (symbol)
"Get the foreground color from palette SYMBOL.

View File

@ -1,11 +1,11 @@
;;; tp-ert-tests.el --- ERT tests for tp.el -*- lexical-binding: t -*-
;;; tp-tests.el --- ERT tests for tp.el -*- lexical-binding: t -*-
;; Copyright (C) 2024
;; Copyright (C) 2024-2026
;;; Commentary:
;; Comprehensive test suite for tp.el using ERT (Emacs Lisp Regression Testing).
;; Run with: emacs --batch -l tp.el -l tp-ert-tests.el -f ert-run-tests-batch-and-exit
;; Run with: emacs --batch -L . -l tp.el -l tp-tests.el -f ert-run-tests-batch-and-exit
;;; Code:
@ -23,13 +23,16 @@
;;; ============================================================
(defmacro tp-test-with-temp-buffer (&rest body)
"Execute BODY in a temporary buffer with tp.el loaded."
"Execute BODY in a temporary buffer with a clean tp state.
All layer registries, transforms and reactive watchers are cleared
before BODY runs and again afterwards (teardown), so state cannot
leak between tests regardless of how BODY exits."
(declare (indent 0))
`(with-temp-buffer
(setq tp-layer-alist nil)
(setq tp-layer-groups nil)
(tp-reactive-reset)
,@body))
`(unwind-protect
(with-temp-buffer
(tp-layer-reset)
,@body)
(tp-layer-reset)))
;;; ============================================================
;;; Basic Text Property Functions Tests
@ -4109,5 +4112,5 @@ can be tracked and removed."
;; tp-delete property should be removed
(should (null (get-text-property 0 'tp-delete result)))))
(provide 'tp-ert-tests)
;;; tp-ert-tests.el ends here
(provide 'tp-tests)
;;; tp-tests.el ends here