[发布] tp.el: Emacs 文本属性库

为什么你需要 tp.el?

如果你曾在 Emacs 开发中处理过文本属性(text properties),或许体会过原生 API 的种种痛点:

痛点 描述
API 不一致 put-text-property、propertize、get-text-property… 字符串和 buffer 用法完全不同
无法操作嵌套 想修改 face 里的 :underline 子属性?不好意思,你得整个 face 重写
无法叠加属性 同一区域的不同属性集会相互覆盖,无法分层管理
批量操作困难 想给所有匹配文本加属性?准备好写循环吧

tp.el 不只是原生 API 的简单封装,而是在原生 API 基础上做了大量增强,旨在系统性地解决上述痛点。

五大核心功能详解

:bullseye: 核心功能一:统一 API,一套范式掌握所有场景

原生 Emacs 的 API 让人崩溃——put-text-property 用于 buffer,propertize 用于字符串,参数顺序还不一样。

tp.el 统一了这一切:

;; 三种调用方式,同一个函数!

;; 方式 1:当前 buffer
(tp-set 1 10 '(face bold))

;; 方式 2:指定 buffer 或字符串
(tp-set 1 10 '(face bold) my-buffer)
(tp-set 0 5 '(face bold) my-string)

;; 方式 3:整个字符串(扁平属性)
(tp-set "Hello" 'face 'bold 'help-echo "提示信息")

所有核心函数都支持这三种模式:tp-set、tp-get、tp-remove、tp-add…

再也不用记忆不同函数的不同用法!

:bullseye: 核心功能二:三种语义操作,精准控制属性行为

原生 API 只有简单的设置和获取。tp.el 提供了三种清晰的操作语义:

函数 语义 行为 使用场景
tp-reset 完全替换 清除所有现有属性,只保留新设置的 需要"干净"的状态时
tp-set 部分替换 只替换指定的属性,保留其他属性 日常修改属性
tp-add 深度合并 智能合并嵌套属性,而非简单覆盖 增量添加样式
;; 深度合并示例 —— 这是原生 API 绝对做不到的!

;; 先设置前景色
(tp-set 1 10 '(face (:foreground "red")))

;; 再添加背景色 —— 使用 tp-add 深度合并
(tp-add 1 10 '(face (:background "blue")))

;; ✅ 结果:face = (:foreground "red" :background "blue")
;; ❌ 原生 API:face = (:background "blue")  ← 前景色丢失!

;; 更复杂的 underline 的例子
;; 先设置完整的 underline 样式
(tp-set 1 10 '(face (:underline (:color "green" :style wave :position t))))

;; 使用 tp-add 只修改颜色
(tp-add 1 10 '(face (:underline (:color "red"))))

;; ✅ 结果:
(tp-at 1 'face)
;; => (:underline (:color "red" :style wave :position t))
;; : style 和 : position 完好保留!

Face 智能合并:符号类型的 face 自动添加到 face 列表前面,plist 类型的 face 进行深度合并:

(let ((str (tp-set "Hello" 'face 'bold)))
  (tp-add str 'face 'shadow)
  (tp-at 0 'face str))
;; => (shadow bold)  ← 两个 face 都保留!

:bullseye: 核心功能三:路径式子属性操作

这是原生 Emacs API 完全不支持的,像操作 JSON/嵌套数据结构一样操作文本属性:

读取深层嵌套属性

;; 假设 face 结构为:
;; (:underline (:color "green" :style wave) :box (:color "blue"))

;; 路径式访问 —— 简洁直观
(tp-at 5 '(face :underline :style))   ; => wave
(tp-at 5 '(face :box :color))         ; => "blue"

;; 获取多个嵌套键
(tp-get str 'face :underline '(:color :style))
;; => ((: color "green" :style wave))

精确删除子属性

;; 只删除 :underline 下的 :style,保留 :color
(tp-remove 1 10 '(face :underline :style))

;; 删除多个嵌套键
(tp-remove 1 10 '(face :underline (:style :position)))
;; :color 保留,:style 和 :position 被删除

精准修改子属性

;; 只修改下划线颜色,其他属性不变
(tp-add 1 10 '(face (:underline (:color "red"))))

:bullseye: 核心功能四:创新属性层系统(Property Layer System)

这是 tp.el 最具创新性的功能!原生 Emacs 完全不支持。

什么是属性层?

在传统 Emacs 中,同一文本区域只能有一组属性。当你设置新属性时,旧属性会被覆盖——这在很多场景下非常不便。

属性层系统(Property Layer System) 借鉴了图形软件的图层概念,允许你在同一文本区域堆叠多组属性,随时切换显示、调整顺序、合并图层 …

定义可复用的属性层

;; 定义单个属性层
(tp-define-layer highlight '(face (:background "yellow")))
(tp-define-layer error '(face (:foreground "red" :weight bold)))
(tp-define-layer link '(face (:underline t) mouse-face highlight))

;; 定义属性层组(多个层的组合)
(tp-define-layer my-group
  highlight                                      ; 引用已存在的属性层
  (face (:background "red") line-prefix ">>")    ; 匿名属性层
  (face (:background "green" :weight bold)))     ; 另一个匿名属性层

丰富的层操作

;; 📥放置层
(tp-push-layer 1 10 'highlight)   ; 推入栈顶(可见)
(tp-push-layer 1 10 'error)       ; error 现在在最上面
(tp-put-layer 1 10 'link 1)       ; 放到指定索引位置

;; 🔄 切换显示
(tp-rotate-layer 1 10)            ; 旋转:栈顶移到底部
(tp-pin-layer 1 10 'highlight)    ; 将指定层固定到顶部

;; 🔀 调整顺序
(tp-raise-layer 1 10 'highlight 1)  ; 向上移动 1 位
(tp-switch-layer 1 10 'error 'link) ; 交换两层位置

;; 🗑️ 删除层
(tp-pop-layer 1 10)               ; 弹出顶层
(tp-delete-layer 1 10 'error)     ; 删除指定层

;; 🧩 合并层
(tp-merge-layers 1 10 '(error highlight) 'merged) ; 合并为新层
(tp-flatten-layers 1 10)          ; 将所有层扁平化为一层

查询层信息

(tp-layer-list 1 10)       ; => (error highlight link) 列出所有层
(tp-layer-count 1 10)      ; => 3 层数量
(tp-layer-exists-p 1 10 'error) ; => t 是否存在
(tp-layer-top 1 10)        ; => error 获取顶层名称

:bullseye: 核心功能五:模式匹配批量操作

告别繁琐的手动循环!一行代码批量添加属性:

字符串匹配

;; 高亮所有 TODO
(tp-match-set "TODO" '(face warning))
;; => ((1 . 5) (17 . 21))  返回所有匹配位置

;; 多模式匹配
(tp-match-set '("TODO" "FIXME" "BUG" "HACK") '(face error))

;; 三种语义都支持
(tp-match-reset "TODO" '(face warning))  ; 完全替换
(tp-match-set "TODO" '(face warning))    ; 部分替换
(tp-match-add "TODO" '(face (:underline t))) ; 深度合并

正则表达式匹配

;; 高亮所有数字
(tp-regexp-set "[0-9]+" '(face font-lock-number-face))

;; 高亮所有 URL
(tp-regexp-set "https?://[^ \t\n]+" '(face link mouse-face highlight))

;; 多正则匹配
(tp-regexp-set '("[0-9]+" "[A-Z]+") '(face bold))

增强搜索与导航

tp.el 还提供了强大的搜索和遍历功能:

;; 搜索所有匹配区间
(tp-search my-string 'marker)
;; => ((0 5 t) (12 17 t))  返回所有 (起始 结束 值) 列表

;; 向前/向后搜索 N 次
(tp-forward 'marker nil nil 3)   ; 向前搜索第 3 个匹配
(tp-backward 'type 'heading)     ; 向后搜索 type=heading

;; 搜索并执行函数
(tp-forward-do #'upcase 'marker) ; 找到匹配并大写转换

;; 批量转换
(tp-search-map #'upcase my-string 'marker) ; 所有匹配都大写

实用工具函数

;; 获取区域内的所有属性区间
(tp-intervals 1 100)
;; => ((1 10 (face bold)) (10 20 (face italic)) ...)

;; 对每个区间执行函数
(tp-intervals-map (lambda (start end props) .. .) 1 100)

;; 获取区域内存在的所有属性名
(tp-plist 1 100)
;; => (face help-echo mouse-face)

;; 检查对象是否有属性
(tp-empty-p my-string) ; => t 或 nil
19 个赞

overlay 有一个 ov.el 对 emacs 的 overlay 做了很好的封装和功能拓展,但是文本属性一直缺乏相关的库。我在实际使用文本属性时,需要补充大量的代码来对原始的api进行拓展,于是想效仿 ov.el 对文本属性也做个封装,于是有了 tp.el。

tp.el 除了对原始功能的封装和拓展,还提出了一个“文本属性层”的概念,可玩性很强,欢迎大家探索!

3 个赞

这篇文档如果没有那么多 AI 自以为是添加的 emoji 会好看很多,那个太干扰了。

整体看下来,十分清晰,很实用。

哈哈确实,花里胡哨的,github 的 readme 要更清晰,每个api都有例子。

很厉害。其实我一直想吐槽,lisp 明明本义是 “列表操作语言”,但是操作嵌套数据结构总是感觉非常麻烦。别的语言直接就 a.b.c.d = xxx; del a.b.c.d; 就好了,elisp 不自己写语法糖似乎就只能 (setf (plist-get (plist-get (plist-get xxx)) 以及 (map-delete (plist-get (plist-get xxx)) :a 这样写?

写 fennel 时最开心的就是有 a.b 这种语法糖~

test5

老哥,是不是要这种花里胡哨啊。甚至可以做动画的感觉。

test6

确实可以。

这整个dashboard,应该还是蛮炫酷的。果然之前跟我讲的更牛逼的东西搞出来了呢!!!

6 个赞

坐等你们搞出吊炸天的东西,等着抄作业。

:herb: 有点酷,你这个月亮也是用 tp.el 做的?用的什么 unicode 特殊符号?

月亮那个不是,另外一个是。想用你这东西搞点炫酷的东西来的,日常用ai做做测试,如果有好玩成熟的东西再发出来。 :joy:

哈哈,我就说,除非有月亮这种特殊的符号可以用来设置前景色,不然 emacs 里面应该搞不定。图一那种todo换个背景或颜色是轻轻松松就能实现的。

不过我想到如果能有不同情况的月亮的 emoj,设置多层的 display 属性应该就可以实现。

放两张效果图。

月亮切换:

tp-layer-moon

滚动条:

tp-layer-scroll

3 个赞

静态文本都变动画了 :+1: 不知道性能怎么样?

性能没问题,增量做文本属性的更新,文本属性的开销本来就很小。但是,这样的效果只能在一个单独的只读的buffer中展示,因为 emacs 的UI是一个单线程,不可能一边编辑文本,一边展示动画。

1 个赞

后续开发的想法的,具体能否实现还需要研究:

  1. 文本属性值的设置支持变量,让单层文本属性也具备灵活改变的可能。
  2. 文本属性层支持设置“条件函数”,当条件满足时触发自动切换到该层。

定时器貌似可以实现时间分片,在执行完回调函数之后,立即切回主线程,这样如果定时执行的回调开销很小,应该就可以在buffer中一边播放动画,一边流畅编辑了。待会去试一下。

1 个赞

可以的!得益于 tp.el 文本属性层切换的开销很小,这样在 emacs buffer 中播放动画就解决了,后续会封装为一个更好用的可以自己制作动画的函数。

tp-test-moon

5 个赞

超帅,Emacs 之光!

test7 其实还可以更离谱,这个是没用tp.el的版本,但是我没想好做啥东西。。 :joy:

哈哈,这种也是轻松实现,只需要加上 face 的高度属性即可。哈哈,怎么都是些花里胡哨的 :joy:,可以用来做一些更有意义的东西出来。

tp-test-moon-lager

附上代码:

(tp-define-layer-group tp-test-moons
  (display "🌑" face (:height 1.0))
  (display "🌘" face (:height 1.5))
  (display "🌗" face (:height 2.0))
  (display "🌖" face (:height 2.5))
  (display "🌕" face (:height 3.0))
  (display "🌔" face (:height 2.5))
  (display "🌓" face (:height 2.0))
  (display "🌒" face (:height 1.5)))

(defun tp-test-moons-init ()
  (let ((buffer (get-buffer-create "*tp-test-moon*")))
    (with-current-buffer buffer
      (erase-buffer)
      (let ((str " ") strs)
        (tp-push-layer str 'tp-test-moons)
        (tp-add-to-all-layers str '(test-moon t))
        (dotimes (_ 6)
          (push (copy-sequence str) strs))
        (insert "\n" (string-join strs  " -> ") "\n")))
    (pop-to-buffer buffer)))

(defun tp-test-moons-switch ()
  (with-current-buffer "*tp-test-moon*"
    (save-excursion
      (tp-search-map (lambda (str start end idx)
                       (tp-rotate-layer start end))
                     'test-moon))))

(tp-test-moons-init)
(defvar tp-test-moons-timer
  (run-with-timer 0 0.12 'tp-test-moons-switch))
(cancel-timer tp-test-moons-timer)
1 个赞