tp/tp-stack.el
Kinneyzhang 972b6d4e4c Complete text-property facade and managed lifecycle
Add canonical query semantics, managed metadata and transactions, overlay-aware lookup, reproducible benchmarks, and synchronized API documentation.
2026-07-28 22:42:55 +08:00

1555 lines
63 KiB
EmacsLisp

;;; tp-stack.el --- Layer stack operations for tp -*- lexical-binding: t -*-
;; Copyright (C) 2024-2026 Geekinney
;; Author: Geekinney (kinneyzhang666@gmail.com)
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 3 of
;; the License, or (at your option) any later version.
;;; Commentary:
;; Photoshop-style layer stack operations on text regions: put/push/
;; delete/pop/move/raise/lower/rotate/pin/switch/hide/show/merge/
;; flatten, stack queries, and bulk layer property manipulation.
;;; Code:
(require 'cl-lib)
(require 'dash)
(require 'tp-core)
(require 'tp-reactive)
(require 'tp-layer)
;;; Shared argument parsing and region iteration
(defun tp--parse-layer-args (start-or-string rest n)
"Normalize a layer operation's positional arguments.
START-OR-STRING is the caller's first positional argument and REST the
list of its remaining positional arguments, in order. N is the number
of operation-specific arguments the caller takes (for example 2 for
`tp-put-layer's LAYER and IDX).
Two calling conventions are supported:
- (STRING ARG1 ... ARGN): operate on the whole STRING.
- (START END ARG1 ... ARGN OBJECT): operate on a region of OBJECT,
where nil means the current buffer.
Returns the list (START END OBJECT ARG1 ... ARGN) with START/END in
OBJECT's native coordinates (0-based for strings, 1-based for
buffers)."
(cond
((stringp start-or-string)
(let ((range (tp--native-range-from-object
start-or-string 0 (length start-or-string))))
(append (list (tp--native-range-start range)
(tp--native-range-end range)
(tp--native-range-object range))
(seq-take rest n))))
((numberp start-or-string)
(let* ((operation-args (seq-take (cdr rest) n))
(object (nth (1+ n) rest))
(range (tp--native-range-from-object
object start-or-string (car rest))))
(append (list (tp--native-range-start range)
(tp--native-range-end range)
(tp--native-range-object range))
operation-args)))
(t (error "Invalid layer arguments: %S" (cons start-or-string rest)))))
(defun tp--plist-remove (plist key)
"Return a copy of PLIST without KEY and its value.
Comparison uses `eq'. PLIST itself is not modified."
(cl-loop for (k v) on plist by #'cddr
unless (eq k key) append (list k v)))
(define-error 'tp-layer-transaction-error "tp layer transaction failed")
(defvar tp--managed-operation-counter 0
"Monotonic counter for managed lifecycle operation ids.")
(defun tp--managed-next-operation-id ()
"Return a fresh managed lifecycle operation id."
(setq tp--managed-operation-counter (1+ tp--managed-operation-counter))
(intern (format "tp-managed-op-%d" tp--managed-operation-counter)))
(defun tp--plist-remove-keys (plist keys)
"Return a copy of PLIST without any key in KEYS."
(cl-loop for (k v) on plist by #'cddr
unless (memq k keys) append (list k v)))
(defun tp--managed-render-props (layer)
"Return LAYER without lifecycle-only storage properties."
(tp--entry-render-projection layer))
(defun tp--managed-public-layer-props (layer)
"Return LAYER's public stack query properties."
(tp--plist-remove-keys layer '(tp-name tp-meta)))
(defun tp--managed-spec-arglist (name)
"Return NAME's parameter arglist when available."
(cond
((and (fboundp 'tp-layer-parameterized-p)
(tp-layer-parameterized-p name))
(tp-layer-arglist name))
((and (fboundp 'tp-group-parameterized-p)
(tp-group-parameterized-p name))
(tp--group-arglist name))))
(defun tp--managed-meta-for-spec (spec layer)
"Build managed metadata for SPEC mounted as LAYER."
(let* ((name (plist-get layer 'tp-name))
(spec-name (if (consp spec) (car spec) spec))
(group-p (and (symbolp spec-name)
(assoc spec-name tp-layer-groups)))
(parameterized-p (and (symbolp spec-name)
(tp--managed-spec-arglist spec-name)))
(args (when (and (consp spec) (or group-p parameterized-p))
(copy-tree (cdr spec))))
(origin (cond
(group-p 'group)
(args 'parameterized)
((assoc name tp-layer-alist) 'defined)
(name 'inline)
(t 'anonymous))))
(tp--managed-entry-meta
name origin spec args (tp--managed-spec-arglist spec-name))))
(defun tp--managed-add-meta-to-layers (spec layers)
"Return LAYERS with managed metadata derived from SPEC."
(mapcar (lambda (layer)
(if (plist-member layer 'tp-meta)
layer
(append layer
(list 'tp-meta
(tp--managed-meta-for-spec spec layer)))))
layers))
(defun tp--stack-map-region (start end object function)
"Call FUNCTION over each property run of [START, END) in OBJECT.
OBJECT is a string, a buffer, or nil for the current buffer.
FUNCTION receives (ABS-START ABS-END STACK): the run's bounds, clipped
to [START, END) and expressed in OBJECT's native coordinates (0-based
for strings, 1-based for buffers), and the run's layer stack as a list
of layer plists, top layer first (empty for bare text). Hidden layers
\(see `tp-hide-layer') are included at their stack position.
Returns the list of FUNCTION's non-nil results, in order.
Unlike `tp-intervals-map', runs never extend beyond the requested
region, positions are absolute for strings as well as buffers, and
bare text is visited (with an empty STACK) so layers can be applied to
previously property-less text."
(delq nil
(tp--map-intervals
object start end
(lambda (i-start i-end props)
(funcall function i-start i-end
(tp--stack-props-to-list props))))))
(defun tp--stack-register-layers (stack object)
"Register OBJECT in the reactive buffer registry for every layer in STACK.
STACK is a list of layer plists as stored by the stack operations.
When OBJECT is a buffer or nil (the current buffer), every plist
carrying a `tp-name' - buried and hidden layers included - registers
that buffer via `tp-reactive--register-layer-buffer', so reactive
updates and the anonymous-layer GC keep seeing buffers whose layers
were written by stack mutators rather than by `tp-set'. String
OBJECTs are not registered; see `tp-reactive-layer-buffers' for that
gap. Registration is idempotent, so calling this once per rewritten
run is cheap."
(when (or (null object) (bufferp object))
(let ((buf (or object (current-buffer))))
(dolist (layer stack)
(when-let ((name (plist-get layer 'tp-name)))
(tp-reactive--register-layer-buffer name buf))))))
;;; Queries
(defun tp-region-layer-props (start end layer-name &optional object)
"Return layer properties for LAYER-NAME in region from START to END.
OBJECT defaults to current buffer.
Returns a list of (START END PROPERTIES) for matching intervals, with
positions in OBJECT's native coordinates (0-based for strings, 1-based
for buffers) and clipped to the requested region."
(tp--stack-map-region
start end object
(lambda (abs-start abs-end stack)
(when-let ((props (seq-find
(lambda (props)
(equal layer-name
(plist-get props 'tp-name)))
stack)))
(list abs-start abs-end
(tp--plist-remove props 'tp-meta))))))
(defun tp-layer-list (start end &optional object)
"Return list of all layer names in region from START to END."
(let ((layers nil))
(tp--stack-map-region
start end object
(lambda (_abs-start _abs-end stack)
(dolist (layer stack)
(when-let ((name (plist-get layer 'tp-name)))
(cl-pushnew name layers :test #'equal)))))
(nreverse layers)))
(defun tp-layer-count (start end &optional object)
"Return number of layers in region from START to END.
OBJECT defaults to current buffer."
(let ((max-count 0))
(tp--stack-map-region
start end object
(lambda (_abs-start _abs-end stack)
(setq max-count (max max-count (length stack)))))
max-count))
(defun tp-layer-exists-p (start end name &optional object)
"Return t if layer NAME exists in region from START to END.
OBJECT defaults to current buffer."
(not (null (tp-region-layer-props start end name object))))
(defun tp-layer-top (start end &optional object)
"Return the name of the topmost named layer in START..END of OBJECT.
Scans the region's property runs in order and returns the `tp-name'
of the first top layer that has one, so bare or unnamed runs (for
example before a layer that starts mid-region) do not hide layers
later in the region. Returns nil when no run in the region has a
named top layer. OBJECT defaults to current buffer.
The topmost layer is reported in stack order even when it is hidden
\(see `tp-hide-layer'); use `tp-layer-stack-at' to distinguish hidden
layers from visible ones."
(car (tp--stack-map-region
start end object
(lambda (_abs-start _abs-end stack)
(plist-get (car stack) 'tp-name)))))
(defun tp-layer-stack-at (pos &optional object)
"Return the full ordered layer stack at POS in OBJECT.
The result is a list with one element per layer, topmost layer first
and bottommost last, where each element is a cons (NAME . PROPS):
- NAME is the layer's `tp-name' symbol, or nil for an unnamed layer.
- PROPS is the layer's property plist without its `tp-name' entry.
A hidden layer (see `tp-hide-layer') is distinguishable by the
entry `tp-hidden' with value t in PROPS; visible layers never
carry a `tp-hidden' entry.
Hidden layers are included at their stack position. Returns nil for
bare text. POS is in OBJECT's native coordinates (0-based for
strings, 1-based for buffers). OBJECT is a string, a buffer, or nil
for the current buffer."
(mapcar (lambda (layer)
(cons (plist-get layer 'tp-name)
(tp--managed-public-layer-props layer)))
(tp--stack-props-to-list (text-properties-at pos object))))
;;; Managed lifecycle APIs
(defun tp--managed-layer-names-in-stack (stack)
"Return named managed layers from STACK in stack order."
(let (names)
(dolist (layer stack)
(when-let ((name (plist-get layer 'tp-name)))
(cl-pushnew name names :test #'equal)))
(nreverse names)))
(defun tp--managed-legacy-meta (layer)
"Return safe legacy metadata for LAYER."
(plist-put
(tp--managed-meta-for-spec (plist-get layer 'tp-name) layer)
:legacy-no-args t))
(defun tp--managed-normalize-stack (stack)
"Return STACK with safe legacy managed entries migrated."
(mapcar (lambda (layer)
(if (or (not (plist-get layer 'tp-name))
(plist-member layer 'tp-meta))
layer
(append layer (list 'tp-meta
(tp--managed-legacy-meta layer)))))
stack))
;;;###autoload
(defun tp-attach-managed-layers (start end &optional object)
"Attach managed layer identity in START..END of OBJECT.
The scan is limited to the requested range. Return discovered layer
names in range order."
(let ((found nil))
(tp--stack-map-region
start end object
(lambda (abs-start abs-end stack)
(let ((new-stack (tp--managed-normalize-stack stack)))
(unless (equal new-stack stack)
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
object))
(dolist (name (tp--managed-layer-names-in-stack new-stack))
(cl-pushnew name found :test #'equal)))))
(dolist (name (nreverse found))
(when (or (null object) (bufferp object))
(tp-reactive--register-layer-buffer
name (or object (current-buffer)))))
(nreverse found)))
(defun tp--managed-detached-props (stack keep-rendered)
"Return raw properties for detaching STACK.
When KEEP-RENDERED is non-nil, preserve the current visible
projection without managed storage properties."
(when keep-rendered
(tp--plist-remove
(tp--managed-render-props
(seq-find (lambda (layer)
(not (tp--stack-hidden-p layer)))
stack))
'tp-name)))
;;;###autoload
(defun tp-detach-managed-layers (start end &optional object keep-rendered)
"Detach managed layer storage in START..END of OBJECT.
When KEEP-RENDERED is non-nil, preserve currently visible text
properties after removing lifecycle storage. Return detached layer
names in range order."
(let ((found nil))
(tp--stack-map-region
start end object
(lambda (abs-start abs-end stack)
(when stack
(dolist (name (tp--managed-layer-names-in-stack stack))
(cl-pushnew name found :test #'equal))
(set-text-properties abs-start abs-end
(tp--managed-detached-props
stack keep-rendered)
object))))
(let ((result (nreverse found)))
(when (or (null object) (bufferp object))
(let* ((buffer (or object (current-buffer)))
(remaining (tp-reactive--buffer-layer-names buffer)))
(dolist (name result)
(unless (member name remaining)
(tp-reactive--unregister-layer-buffer name buffer)))))
result)))
(defun tp--managed-layer-entry (start end layer)
"Return a diagnostics entry for LAYER covering START..END."
(let ((meta (plist-get layer 'tp-meta)))
(list :range (cons start end)
:name (plist-get layer 'tp-name)
:entry-id (plist-get meta :entry-id)
:origin (plist-get meta :origin)
:spec (copy-tree (plist-get meta :spec))
:args (copy-tree (plist-get meta :args))
:arglist (copy-tree (plist-get meta :arglist))
:definition-version (plist-get meta :definition-version)
:entry-version (plist-get meta :entry-version)
:hidden (tp--stack-hidden-p layer)
:palette-deps (copy-tree (plist-get meta :palette-deps))
:palette-generation (plist-get meta :palette-generation)
:legacy-no-args (plist-get meta :legacy-no-args))))
(defun tp--managed-buffer-diagnostic-data (buffer layer-filter)
"Collect managed diagnostics in BUFFER for LAYER-FILTER or all layers."
(let ((layers nil)
(entries nil)
(errors nil))
(when (buffer-live-p buffer)
(tp--map-intervals
buffer nil nil
(lambda (start end props)
(condition-case err
(dolist (layer (tp--stack-props-to-list props))
(let ((name (plist-get layer 'tp-name)))
(when (and name
(or (null layer-filter)
(equal name layer-filter)))
(cl-pushnew name layers :test #'equal)
(push (tp--managed-layer-entry start end layer)
entries))))
(error
(push (list :range (cons start end)
:condition err)
errors))))))
(list :buffer buffer
:layers (nreverse layers)
:entries (nreverse entries)
:observer-errors (copy-tree tp-reactive-observer-errors)
:errors (nreverse errors))))
;;;###autoload
(defun tp-managed-layer-diagnostics (layer-name)
"Return read-only managed diagnostics for LAYER-NAME."
(let ((entries nil)
(buffers nil)
(errors nil)
(registered (tp-reactive-layer-buffers layer-name)))
(dolist (buffer (if (eq registered 'unknown)
(buffer-list)
registered))
(when (buffer-live-p buffer)
(let ((diag (tp--managed-buffer-diagnostic-data
buffer layer-name)))
(when (plist-get diag :entries)
(push buffer buffers)
(setq entries (append entries
(plist-get diag :entries))))
(setq errors (append errors (plist-get diag :errors))))))
(list :layer layer-name
:definition-version (tp--layer-definition-version layer-name)
:entries entries
:args (mapcar (lambda (entry)
(plist-get entry :args))
entries)
:registry registered
:buffers (nreverse buffers)
:observer-errors
(cl-remove-if-not
(lambda (entry) (equal (plist-get entry :layer) layer-name))
(copy-tree tp-reactive-observer-errors))
:errors errors)))
;;;###autoload
(defun tp-managed-buffer-diagnostics (&optional buffer)
"Return read-only managed diagnostics for BUFFER."
(tp--managed-buffer-diagnostic-data
(or buffer (current-buffer)) nil))
(defun tp--managed-theme-diagnostics ()
"Return read-only managed theme diagnostics."
(list :generation (if (boundp 'tp-theme-generation)
tp-theme-generation 0)
:last-hook-source (and (boundp 'tp-theme-last-hook-source)
tp-theme-last-hook-source)
:refresh-mode (if (boundp 'tp-theme-last-refresh-mode)
tp-theme-last-refresh-mode :conservative)
:refreshed-ranges
(and (boundp 'tp-theme-last-refreshed-ranges)
tp-theme-last-refreshed-ranges)
:errors (and (boundp 'tp-theme-last-refresh-errors)
tp-theme-last-refresh-errors)))
;;;###autoload
(defun tp-managed-diagnostics ()
"Return read-only global managed lifecycle diagnostics."
(let ((layers nil)
(buffers nil)
(entries nil)
(errors nil))
(dolist (buffer (buffer-list))
(when (buffer-live-p buffer)
(let ((diag (tp--managed-buffer-diagnostic-data buffer nil)))
(when (plist-get diag :entries)
(push buffer buffers)
(setq layers (append layers (plist-get diag :layers)))
(setq entries (append entries
(plist-get diag :entries))))
(setq errors (append errors (plist-get diag :errors))))))
(list :layers (delete-dups layers)
:buffers (nreverse buffers)
:entries entries
:args (mapcar (lambda (entry)
(plist-get entry :args))
entries)
:registry (copy-hash-table tp--layer-buffers)
:observer-errors (copy-tree tp-reactive-observer-errors)
:errors errors
:theme (tp--managed-theme-diagnostics))))
(defun tp--transaction-snapshot (start end object)
"Snapshot exact text and properties in START..END of OBJECT."
(if (stringp object)
(substring object start end)
(let ((buf (or object (current-buffer))))
(with-current-buffer buf
(buffer-substring start end)))))
(defun tp--transaction-restore (start end object snapshot)
"Restore SNAPSHOT over START..END of OBJECT."
(if (stringp object)
(progn
(unless (= (- end start) (length snapshot))
(error "Cannot restore a resized string transaction"))
(store-substring object start (substring-no-properties snapshot))
(set-text-properties start end nil object)
(tp--map-intervals
snapshot 0 (length snapshot)
(lambda (from to props)
(set-text-properties (+ start from) (+ start to) props object))))
(let ((buf (or object (current-buffer))))
(with-current-buffer buf
(let ((inhibit-read-only t))
(delete-region start end)
(goto-char start)
(insert snapshot))))))
(defun tp--transaction-runs (start end object)
"Return exact property intervals for START..END of OBJECT."
(tp-intervals start end object t))
(defun tp--transaction-changed-ranges (before after)
"Return ranges whose text/property intervals differ between BEFORE and AFTER."
(let (ranges)
(cl-loop for b in before
for a in after
unless (equal b a)
do (push (cons (nth 0 (or a b))
(nth 1 (or a b)))
ranges))
(when (/= (length before) (length after))
(dolist (entry (nthcdr (min (length before) (length after))
(append before after)))
(push (cons (nth 0 entry) (nth 1 entry)) ranges)))
(nreverse ranges)))
(defun tp--transaction-live-bounds (from to markers)
"Return the live transaction bounds for FROM, TO, and MARKERS."
(if markers
(cons (marker-position (car markers))
(marker-position (cdr markers)))
(cons from to)))
(defun tp--transaction-result
(status operation-id stage start end object result condition rollback changed)
"Build a structured transaction result plist."
(list :status status
:ok (eq status 'ok)
:result result
:operation-id operation-id
:stage stage
:object object
:range (cons start end)
:current-run (car (tp--transaction-runs start end object))
:layer nil
:entry-id nil
:expected-version nil
:actual-version nil
:expected-properties nil
:actual-properties nil
:original-condition condition
:rollback-applied rollback
:changed-ranges changed))
;;;###autoload
(defun tp-layer-transaction (start end object function &optional noerror)
"Run FUNCTION as a managed layer transaction over START..END of OBJECT.
On success, return a structured plist whose `:result' is FUNCTION's
value. On failure, restore the exact pre-transaction text/property
state. Signal `tp-layer-transaction-error' unless NOERROR is non-nil,
in which case return the structured failure plist."
(let* ((range (tp--native-range-from-object object start end))
(obj (tp--native-range-object range))
(from (tp--native-range-start range))
(to (tp--native-range-end range))
(operation-id (tp--managed-next-operation-id))
(snapshot (tp--transaction-snapshot from to obj))
(before (tp--transaction-runs from to obj))
(markers (unless (stringp obj)
(cons (copy-marker from nil)
(copy-marker to nil)))))
(unwind-protect
(condition-case err
(let* ((result (funcall function))
(live (tp--transaction-live-bounds from to markers)))
(tp--transaction-result
'ok operation-id 'commit from to obj result nil nil
(tp--transaction-changed-ranges
before (tp--transaction-runs
(car live) (cdr live) obj))))
(error
(let* ((live (tp--transaction-live-bounds from to markers))
(rollback-applied nil)
rollback-condition)
(condition-case rollback-err
(progn
(tp--transaction-restore
(car live) (cdr live) obj snapshot)
(setq rollback-applied t))
(error (setq rollback-condition rollback-err)))
(let ((result (tp--transaction-result
'error operation-id 'body from to obj nil err
rollback-applied
(tp--transaction-changed-ranges
before (tp--transaction-runs from to obj)))))
(when rollback-condition
(setq result
(plist-put result :rollback-condition
rollback-condition)))
(if noerror
result
(signal 'tp-layer-transaction-error (list result)))))))
(when markers
(set-marker (car markers) nil)
(set-marker (cdr markers) nil)))))
;;; Layer spec normalization for tp-put-layer
(defun tp--put-layer-specs (layer-spec)
"Normalize LAYER-SPEC into a list of layer plists for `tp-put-layer'.
LAYER-SPEC can be:
- a layer name or group name (symbol);
- (LAYER-NAME ARG) or (GROUP-NAME ARG) for parameterized layers/groups;
- an inline plist, e.g. (face bold) or (:foreground \"red\");
- (NAME PROP VAL ...) for a named inline layer;
- a list of any of the above.
An inline plist is recognized by its even length together with a head
that is a keyword or an ordinary property symbol (one that is not a
defined layer or group name); a named inline layer has odd length
\(NAME plus prop/value pairs)."
(cond
;; Group name symbol.
((and (symbolp layer-spec)
(assoc layer-spec tp-layer-groups))
(if (tp-group-parameterized-p layer-spec)
(error "Parameterized group %S requires an argument, use '(%S ARG)"
layer-spec layer-spec)
(tp-group-props layer-spec t))) ; include tp-name for layer stack
;; Any other symbol: a single layer name.
((symbolp layer-spec)
(list (tp--normalize-layer-spec layer-spec)))
;; (GROUP-NAME ARG1 ... ARGN) or (GROUP-NAME (ARG1 ... ARGN)):
;; multi-argument parameterized group (arity >= 2). Checked before
;; the single-arg forms so the wrapped variant is not mistaken for
;; one list-valued argument.
((and (consp layer-spec)
(symbolp (car layer-spec))
(proper-list-p layer-spec)
(let ((arity (length (tp--group-arglist (car layer-spec)))))
(and (>= arity 2)
(or (= (length (cdr layer-spec)) arity)
(and (= (length (cdr layer-spec)) 1)
(proper-list-p (cadr layer-spec))
(= (length (cadr layer-spec)) arity))))))
(let* ((arity (length (tp--group-arglist (car layer-spec))))
(args (if (= (length (cdr layer-spec)) arity)
(cdr layer-spec)
(cadr layer-spec))))
(tp--group-props-with-args (car layer-spec) args t)))
;; (LAYER-NAME ARG1 ... ARGN) or (LAYER-NAME (ARG1 ... ARGN)):
;; multi-argument parameterized layer (arity >= 2).
((and (consp layer-spec)
(symbolp (car layer-spec))
(proper-list-p layer-spec)
(let ((arity (length (tp-layer-arglist (car layer-spec)))))
(and (>= arity 2)
(or (= (length (cdr layer-spec)) arity)
(and (= (length (cdr layer-spec)) 1)
(proper-list-p (cadr layer-spec))
(= (length (cadr layer-spec)) arity))))))
(let* ((arity (length (tp-layer-arglist (car layer-spec))))
(args (if (= (length (cdr layer-spec)) arity)
(cdr layer-spec)
(cadr layer-spec))))
(list (tp--normalize-layer-spec (cons (car layer-spec) args)))))
;; (GROUP-NAME ARG): parameterized group.
((and (consp layer-spec)
(symbolp (car layer-spec))
(= (safe-length layer-spec) 2)
(tp-group-parameterized-p (car layer-spec)))
(tp-group-props-with-arg (car layer-spec) (cadr layer-spec) t))
;; (LAYER-NAME ARG): parameterized layer.
((and (consp layer-spec)
(symbolp (car layer-spec))
(= (safe-length layer-spec) 2)
(tp-layer-parameterized-p (car layer-spec)))
(list (tp--normalize-layer-spec layer-spec)))
;; Keyword-headed plist: a single inline layer.
((and (consp layer-spec) (keywordp (car layer-spec)))
(list (tp--normalize-layer-spec layer-spec)))
;; Even-length plist headed by an ordinary (non-layer) property
;; symbol, e.g. (face bold): a single inline layer.
((and (consp layer-spec)
(car layer-spec)
(symbolp (car layer-spec))
(not (tp--is-layer-name-p (car layer-spec)))
(proper-list-p layer-spec)
(cl-evenp (length layer-spec)))
(list layer-spec))
;; List whose every element is itself a spec (a layer/group name or
;; a list): multiple layers.
((and (consp layer-spec)
(proper-list-p layer-spec)
(cl-every (lambda (el)
(or (consp el) (tp--is-layer-name-p el)))
layer-spec))
(apply #'append (mapcar #'tp--put-layer-specs layer-spec)))
;; Anything else, including (NAME PROP VAL ...) named inline
;; layers; tp--normalize-layer-spec signals on invalid specs.
(t
(list (tp--normalize-layer-spec layer-spec)))))
;;; Mutators
(defun tp-put-layer (start-or-string &optional end-or-layer layer-or-idx idx-or-object object noerror)
"Set layer(s) at a specific index position.
Calling conventions:
1. Buffer/string region:
(tp-put-layer START END LAYER IDX OBJECT NOERROR)
2. Entire string:
(tp-put-layer STRING LAYER IDX NOERROR)
LAYER can be:
- A symbol (layer name from `tp-layer-alist' or `tp-layer-groups')
- A list (LAYER-NAME ARG) or (GROUP-NAME ARG) for parameterized
layers or groups
- A plist (inline layer definition), e.g. (face bold)
- A list (NAME &rest PLIST) for named inline layer
- A list of the above for multiple layers
IDX specifies where to insert:
- 0 means top (visible layer)
- -1 means bottom
- Other values insert at that position
OBJECT defaults to current buffer for region form. Only text inside
\[START, END) is modified.
A LAYER naming an undefined layer or group normally signals an
error. If NOERROR is non-nil, return nil instead of signaling when
LAYER cannot be resolved; nothing is modified in that case.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own. The returned string is
that same mutated object.
Returns OBJECT when one was given (in particular the string in
string forms), otherwise the cons (START . END)."
(pcase-let ((`(,start ,end ,obj ,layer-spec ,idx)
(tp--parse-layer-args
start-or-string
(list end-or-layer layer-or-idx idx-or-object object) 2)))
(setq idx (or idx 0))
(let* ((noerr (if (stringp start-or-string) idx-or-object noerror))
(layers-to-add
(if noerr
(condition-case nil
(tp--put-layer-specs layer-spec)
(tp-unresolved-layer 'tp--unresolved))
(tp--put-layer-specs layer-spec))))
(unless (eq layers-to-add 'tp--unresolved)
(setq layers-to-add
(tp--managed-add-meta-to-layers layer-spec layers-to-add))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(let* ((actual-idx (if (< idx 0)
(max 0 (+ (length stack) 1 idx))
(min idx (length stack))))
(new-stack (append (seq-take stack actual-idx)
layers-to-add
(seq-drop stack actual-idx))))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj))))
(or obj (cons start end))))))
(defun tp-push-layer (start-or-string &optional end-or-layer layer-or-object object noerror)
"Push layer(s) to the top of the layer stack.
This is equivalent to (tp-put-layer ... LAYER 0 ...).
Calling conventions:
1. Buffer/string region:
(tp-push-layer START END LAYER OBJECT NOERROR)
2. Entire string:
(tp-push-layer STRING LAYER NOERROR)
A LAYER naming an undefined layer or group normally signals an
error. If NOERROR is non-nil, return nil instead of signaling when
LAYER cannot be resolved; nothing is modified in that case.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own. The returned string is
that same mutated object.
Returns what `tp-put-layer' returns: OBJECT when one was given (in
particular the string in string forms), otherwise (START . END)."
(pcase-let ((`(,start ,end ,obj ,layer)
(tp--parse-layer-args
start-or-string
(list end-or-layer layer-or-object object) 1)))
(let ((noerr (if (stringp start-or-string) layer-or-object noerror)))
(tp-put-layer start end layer 0 obj noerr))))
(defun tp-delete-layer (start-or-string &optional end-or-idx idx-or-object object)
"Delete layer by name or index.
Calling conventions:
1. Buffer/string region:
(tp-delete-layer START END LAYER-NAME/IDX OBJECT)
2. Entire string:
(tp-delete-layer STRING LAYER-NAME/IDX)
LAYER-NAME/IDX can be:
- A symbol (layer name)
- An integer (layer index, 0=top, -1=bottom)
Only text inside [START, END) is modified.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. A LAYER-NAME/IDX
matching no layer never signals: unmatched runs are silently left
alone and a return value of 0 means nothing matched at all."
(pcase-let ((`(,start ,end ,obj ,layer-id)
(tp--parse-layer-args
start-or-string
(list end-or-idx idx-or-object object) 1)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when-let ((found (tp--get-layer-by-idx-or-name stack layer-id)))
(let ((new-stack (-remove-at (car found) stack)))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj))
(setq count (1+ count)))))
count)))
(defun tp-pop-layer (start-or-string &optional end-or-object object)
"Pop the top layer from the layer stack.
This is equivalent to (tp-delete-layer ... 0 ...).
Calling conventions:
1. Buffer/string region:
(tp-pop-layer START END OBJECT)
2. Entire string:
(tp-pop-layer STRING)
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified; 0 means no run in the
region had a layer to pop."
(pcase-let ((`(,start ,end ,obj)
(tp--parse-layer-args
start-or-string (list end-or-object object) 0)))
(tp-delete-layer start end 0 obj)))
(defun tp--move-layer-in-stack (stack from-id to-idx)
"Move layer at FROM-ID to TO-IDX position in STACK.
FROM-ID can be an integer index or a layer name symbol.
TO-IDX must be an integer index.
Both indices refer to positions before the move and can be negative
\(counting from end).
TO-IDX is clamped to valid range (0 to stack length - 1) if out of bounds.
Returns the new stack, or nil if FROM-ID is invalid."
(let* ((len (length stack))
;; Resolve from-id to actual index
(found (tp--get-layer-by-idx-or-name stack from-id))
(actual-from (when found (car found)))
;; Normalize to-idx
(actual-to (if (< to-idx 0)
(+ len to-idx)
to-idx)))
;; Only proceed if from-id is valid
(when actual-from
(let* ((layer-props (cdr found))
(stack-without (-remove-at actual-from stack))
;; Clamp to-idx to valid range for insertion
(clamped-to (max 0 (min actual-to (length stack-without)))))
(append (seq-take stack-without clamped-to)
(list layer-props)
(seq-drop stack-without clamped-to))))))
(defun tp--raise-layer-in-stack (stack from-id n)
"Raise layer at FROM-ID by N positions in STACK.
FROM-ID can be an integer index or a layer name symbol.
Positive N moves the layer up (toward top/visible).
Negative N moves the layer down (toward bottom).
The resulting position is clamped to valid range (0 to stack length - 1).
Returns the new stack, or nil if FROM-ID is invalid."
(let* ((found (tp--get-layer-by-idx-or-name stack from-id))
(actual-from (when found (car found))))
(when actual-from
(let* ((len (length stack))
;; Calculate new position: subtracting N because lower index = higher in stack
(new-idx (max 0 (min (1- len) (- actual-from n)))))
(tp--move-layer-in-stack stack actual-from new-idx)))))
(defun tp--switch-layers-in-stack (stack id1 id2)
"Swap layers at ID1 and ID2 positions in STACK.
ID1 and ID2 can be integer indices or layer name symbols.
Returns the new stack, or nil if either ID is invalid."
(let* ((found1 (tp--get-layer-by-idx-or-name stack id1))
(found2 (tp--get-layer-by-idx-or-name stack id2)))
(when (and found1 found2)
(let* ((idx1 (car found1))
(idx2 (car found2))
(props1 (cdr found1))
(props2 (cdr found2))
(new-stack (copy-sequence stack)))
(setf (nth idx1 new-stack) props2)
(setf (nth idx2 new-stack) props1)
new-stack))))
(defun tp-move-layer (start-or-string &optional end-or-from from-or-to to-or-object object)
"Move a layer from one position to another in the layer stack.
Calling conventions:
1. Buffer/string region:
(tp-move-layer START END FROM-ID TO-IDX OBJECT)
2. Entire string:
(tp-move-layer STRING FROM-ID TO-IDX)
FROM-ID identifies the layer to move:
- An integer index (0 = top, 1 = second from top, -1 = bottom, etc.)
- A layer name symbol
TO-IDX is the target position (integer index):
- 0 means top (visible)
- Positive integers count from top
- -1 means bottom
- Negative integers count from bottom
Both indices refer to positions before the move.
The layer at FROM-ID is removed and inserted at TO-IDX position.
OBJECT defaults to current buffer for region form.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. A FROM-ID matching no
layer never signals: unmatched runs are silently left alone and a
return value of 0 means nothing matched at all."
(pcase-let ((`(,start ,end ,obj ,from-id ,to-idx)
(tp--parse-layer-args
start-or-string
(list end-or-from from-or-to to-or-object object) 2)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when-let ((new-stack (tp--move-layer-in-stack stack from-id to-idx)))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq count (1+ count)))))
count)))
(defun tp-raise-layer (start-or-string &optional end-or-idx idx-or-n n-or-object object)
"Raise a layer by N positions in the stack.
Calling conventions:
1. Buffer/string region:
(tp-raise-layer START END IDX/LAYER-NAME N OBJECT)
2. Entire string:
(tp-raise-layer STRING IDX/LAYER-NAME N)
Positive N moves the layer up (toward top/visible).
Negative N moves the layer down (toward bottom).
N defaults to 1. The resulting position is clamped to the stack.
Uses `tp--raise-layer-in-stack' internally, which is built on
`tp--move-layer-in-stack'.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. An IDX/LAYER-NAME
matching no layer never signals: unmatched runs are silently left
alone and a return value of 0 means nothing matched at all."
(pcase-let ((`(,start ,end ,obj ,layer-id ,n)
(tp--parse-layer-args
start-or-string
(list end-or-idx idx-or-n n-or-object object) 2)))
(setq n (or n 1))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when-let ((new-stack (tp--raise-layer-in-stack stack layer-id n)))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq count (1+ count)))))
count)))
(defun tp-lower-layer (start-or-string &optional end-or-idx idx-or-n n-or-object object)
"Lower a layer by N positions in the stack.
This is the mirror image of `tp-raise-layer': lowering by N is
raising by -N.
Calling conventions:
1. Buffer/string region:
(tp-lower-layer START END IDX/LAYER-NAME N OBJECT)
2. Entire string:
(tp-lower-layer STRING IDX/LAYER-NAME N)
IDX/LAYER-NAME identifies the layer: a layer name symbol or an
integer index (0 = top, negative indices count from the bottom, so
-1 = bottom).
Positive N moves the layer down (toward bottom).
Negative N moves the layer up (toward top/visible).
N defaults to 1. The resulting position is clamped to the stack.
OBJECT defaults to current buffer for region form.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. An IDX/LAYER-NAME
matching no layer never signals: unmatched runs are silently left
alone and a return value of 0 means nothing matched at all."
(pcase-let ((`(,start ,end ,obj ,layer-id ,n)
(tp--parse-layer-args
start-or-string
(list end-or-idx idx-or-n n-or-object object) 2)))
(setq n (or n 1))
(tp-raise-layer start end layer-id (- n) obj)))
(defun tp-rotate-layer (start-or-string &optional end-or-direction
direction-object-or-count
count-or-direction object-or-count)
"Rotate layers, by default moving the top layer to the bottom.
Calling conventions:
1. Buffer/string region (canonical order, OBJECT last like the rest
of the stack family):
(tp-rotate-layer START END DIRECTION &optional COUNT OBJECT)
2. Entire string:
(tp-rotate-layer STRING DIRECTION COUNT)
3. Buffer/string region (legacy 0.3.0 order, kept working forever):
(tp-rotate-layer START END OBJECT DIRECTION COUNT)
The two region orders are told apart by the third argument: the
symbols `up' and `down' are never valid OBJECTs, so a third argument
of `up'/`down' unambiguously selects the canonical order, e.g.
\(tp-rotate-layer 1 5 \\='up) - no nil OBJECT placeholder needed.
Any other third argument (a buffer, a string, or nil for the current
buffer) selects the legacy order.
DIRECTION is `down' or nil to move the top layer to the bottom (the
historical behavior), or `up' to move the bottom layer to the top;
any other value signals an error. COUNT is the number of rotation
steps and defaults to 1; a COUNT below 1 rotates nothing. Layers
keep their relative order; hidden layers rotate with the rest of the
stack.
OBJECT defaults to current buffer for region forms.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified; 0 means no run in the
region had layers to rotate (or COUNT was below 1)."
(let (start end obj dir cnt)
(cond
;; Entire string form: (STRING DIRECTION COUNT).
((stringp start-or-string)
(setq start 0
end (length start-or-string)
obj start-or-string
dir end-or-direction
cnt direction-object-or-count))
((numberp start-or-string)
(setq start start-or-string
end end-or-direction)
(if (memq direction-object-or-count '(up down))
;; Canonical region order: (START END DIRECTION COUNT OBJECT).
(setq dir direction-object-or-count
cnt count-or-direction
obj object-or-count)
;; Legacy region order: (START END OBJECT DIRECTION COUNT).
(setq obj direction-object-or-count
dir count-or-direction
cnt object-or-count)))
(t (error "Invalid layer arguments: %S"
(cons start-or-string
(list end-or-direction direction-object-or-count)))))
(let ((applied 0))
(setq dir (or dir 'down)
cnt (or cnt 1))
(unless (memq dir '(up down))
(error "Invalid rotate direction: %S" dir))
(when (>= cnt 1)
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when stack
(let* ((len (length stack))
(k (mod (if (eq dir 'up) (- cnt) cnt) len))
(new-stack (append (seq-drop stack k)
(seq-take stack k))))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq applied (1+ applied)))))))
applied)))
(defun tp-pin-layer (start-or-string &optional end-or-idx idx-or-object object)
"Move layer IDX/LAYER-NAME to the top of the stack (one-shot).
Despite the name, nothing stays pinned: this is a single move to
index 0, exactly (tp-move-layer ... IDX/LAYER-NAME 0 ...), and
nothing prevents a later `tp-push-layer' or `tp-put-layer' from
covering the moved layer again.
Calling conventions:
1. Buffer/string region:
(tp-pin-layer START END IDX/LAYER-NAME OBJECT)
2. Entire string:
(tp-pin-layer STRING IDX/LAYER-NAME)
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. An IDX/LAYER-NAME
matching no layer never signals: unmatched runs are silently left
alone and a return value of 0 means nothing matched at all."
(pcase-let ((`(,start ,end ,obj ,layer-id)
(tp--parse-layer-args
start-or-string
(list end-or-idx idx-or-object object) 1)))
(tp-move-layer start end layer-id 0 obj)))
(defun tp-switch-layer (start-or-string &optional end-or-id1 id1-or-id2 id2-or-object object)
"Switch between two layers by name or index.
Calling conventions:
1. Buffer/string region:
(tp-switch-layer START END IDX1/NAME1 IDX2/NAME2 OBJECT)
2. Entire string:
(tp-switch-layer STRING IDX1/NAME1 IDX2/NAME2)
Uses `tp--switch-layers-in-stack' internally.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. When either layer is
missing from a run's stack nothing signals: such runs are silently
left alone and a return value of 0 means nothing matched at all."
(pcase-let ((`(,start ,end ,obj ,id1 ,id2)
(tp--parse-layer-args
start-or-string
(list end-or-id1 id1-or-id2 id2-or-object object) 2)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when-let ((new-stack (tp--switch-layers-in-stack stack id1 id2)))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq count (1+ count)))))
count)))
(defun tp-hide-layer (start-or-string &optional end-or-name name-or-object object)
"Hide layer NAME in region from START to END without removing it.
Calling conventions:
1. Buffer/string region:
(tp-hide-layer START END NAME OBJECT)
2. Entire string:
(tp-hide-layer STRING NAME)
NAME identifies the layer: a layer name symbol or an integer index
into the full stack, hidden layers included (0 = top, -1 = bottom).
A hidden layer stays in the stack -- it still counts for
`tp-layer-count', appears in `tp-layer-list' and `tp-layer-stack-at'
and can be moved, raised or lowered -- but it no longer renders: the
text shows the properties of the topmost non-hidden layer instead.
Hiding the currently visible top layer therefore reveals the next
visible layer below it. When every layer of a run is hidden the text
keeps only the `tp-layers' bookkeeping property (so not even
`tp-name' renders) while all layers stay queryable. Use
`tp-show-layer' to make a hidden layer render again.
Hiddenness is stored as a `tp-hidden' flag entry inside the layer's
plist in the `tp-layers' stack storage, so `tp-hidden' is a reserved
property name inside layers, like `tp-name'.
OBJECT defaults to current buffer for region form.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. A NAME matching no
layer never signals; runs whose match is already hidden are left
alone as well, so a return value of 0 means nothing changed."
(pcase-let ((`(,start ,end ,obj ,name)
(tp--parse-layer-args
start-or-string
(list end-or-name name-or-object object) 1)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when-let ((found (tp--get-layer-by-idx-or-name stack name)))
(unless (tp--stack-hidden-p (cdr found))
(let ((new-stack (-replace-at (car found)
(append (list 'tp-hidden t)
(cdr found))
stack)))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq count (1+ count)))))))
count)))
(defun tp-show-layer (start-or-string &optional end-or-name name-or-object object)
"Show layer NAME in region from START to END, undoing `tp-hide-layer'.
Calling conventions:
1. Buffer/string region:
(tp-show-layer START END NAME OBJECT)
2. Entire string:
(tp-show-layer STRING NAME)
NAME identifies the layer: a layer name symbol or an integer index
into the full stack, hidden layers included (0 = top, -1 = bottom).
The layer's `tp-hidden' flag is removed. When the shown layer sits
above the currently visible top layer it becomes the rendered layer
again, restoring its properties onto the text.
OBJECT defaults to current buffer for region form.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified. A NAME matching no
layer never signals; runs whose match is not hidden are left alone
as well, so a return value of 0 means nothing changed."
(pcase-let ((`(,start ,end ,obj ,name)
(tp--parse-layer-args
start-or-string
(list end-or-name name-or-object object) 1)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when-let ((found (tp--get-layer-by-idx-or-name stack name)))
(when (tp--stack-hidden-p (cdr found))
(let ((new-stack (-replace-at (car found)
(tp--plist-remove (cdr found)
'tp-hidden)
stack)))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq count (1+ count)))))))
count)))
(defun tp--merge-layer-props (layers initial)
"Merge the plists of LAYERS into the INITIAL plist and return it.
LAYERS is a list of (INDEX . PROPS) conses as returned by
`tp--get-layer-by-idx-or-name'. Earlier layers take precedence: a key
already present in the accumulator is never overwritten, and presence
is tested with `plist-member' so an explicit nil value in a higher
layer shadows lower layers' values. `tp-name' keys of the merged
layers are dropped (INITIAL may seed its own), as are `tp-hidden'
bookkeeping flags (see `tp-hide-layer')."
(cl-reduce (lambda (acc layer)
(cl-loop for (key val) on (cdr layer) by #'cddr
unless (memq key '(tp-name tp-hidden))
do (unless (plist-member acc key)
(setq acc (plist-put acc key val))))
acc)
layers
:initial-value initial))
(defun tp-merge-layers (start-or-string &optional end-or-name name-or-ids ids-or-object object)
"Merge specified layers into a new layer.
Calling conventions:
1. Buffer/string region:
(tp-merge-layers START END NEW-LAYER-NAME
\\='(IDX1 LAYER-NAME1 IDX2 ...) OBJECT)
2. Entire string:
(tp-merge-layers STRING NEW-LAYER-NAME \\='(IDX1 LAYER-NAME1 IDX2 ...))
Earlier layers in the list take precedence; a property explicitly set
to nil in a higher-precedence layer stays nil in the merged layer.
Hidden matched layers (see `tp-hide-layer') are merged away with the
rest but contribute NO properties to the merged layer, so a merge can
never render what was hidden. When EVERY matched layer of a run is
hidden, the merged layer keeps their merged properties but carries
the `tp-hidden' flag itself: the data is preserved without un-hiding
anything, and `tp-show-layer' on the merged layer renders it.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified, counting like
`tp-delete-layer': a run counts when at least one listed layer
matched and the merge rewrote it, and 0 means nothing matched at
all."
(pcase-let ((`(,start ,end ,obj ,new-name ,layer-ids)
(tp--parse-layer-args
start-or-string
(list end-or-name name-or-ids ids-or-object object) 2)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(let* ((layers-to-merge
(cl-loop for id in layer-ids
for found = (tp--get-layer-by-idx-or-name stack id)
when found collect found))
;; Sort by index (descending) to remove from end first
(sorted-layers (sort (copy-sequence layers-to-merge)
(lambda (a b) (> (car a) (car b))))))
(when layers-to-merge
;; Merge properties (earlier in list takes precedence).
;; Hidden layers contribute no props unless ALL matched
;; layers are hidden, in which case the merged layer
;; keeps their props but stays hidden itself.
(let* ((visible (seq-remove (lambda (found)
(tp--stack-hidden-p (cdr found)))
layers-to-merge))
(merged-props
(if visible
(tp--merge-layer-props
visible (list 'tp-name new-name))
(tp--merge-layer-props
layers-to-merge
(list 'tp-name new-name 'tp-hidden t))))
(new-stack stack))
;; Remove old layers from stack
(dolist (idx (mapcar #'car sorted-layers))
(setq new-stack (-remove-at idx new-stack)))
;; Add merged layer at top
(setq new-stack (cons merged-props new-stack))
(set-text-properties abs-start abs-end
(tp--stack-build-props new-stack)
obj)
(tp--stack-register-layers new-stack obj)
(setq count (1+ count)))))))
count)))
(defun tp-flatten-layers (start-or-string &optional end-or-name name-or-object object)
"Flatten all layers into a single layer.
Calling conventions:
1. Buffer/string region:
(tp-flatten-layers START END NAME OBJECT)
2. Entire string:
(tp-flatten-layers STRING NAME)
NAME can be nil for an unnamed layer. Higher layers take precedence;
a property explicitly set to nil in a higher layer stays nil in the
flattened result.
Hidden layers (see `tp-hide-layer') are DISCARDED, mirroring
image-editor flatten semantics: only the visible layers' properties
merge into the flattened result, so flattening can never render what
was hidden. When EVERY layer of a run is hidden, the run's
properties are cleared entirely (bare text), consistent with the
all-hidden rendering of `tp-hide-layer'.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own.
Returns the number of property runs modified, counting like
`tp-delete-layer': every run that had layers to flatten counts, and
0 means no run in the region had any layers."
(pcase-let ((`(,start ,end ,obj ,name)
(tp--parse-layer-args
start-or-string
(list end-or-name name-or-object object) 1)))
(let ((count 0))
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(when stack
;; Hidden layers are discarded; an all-hidden run flattens
;; to bare text.
(let* ((visible (seq-remove #'tp--stack-hidden-p stack))
(merged-props
(when visible
(tp--merge-layer-props
(cl-loop for layer in visible
for i from 0
collect (cons i layer))
(when name (list 'tp-name name))))))
(set-text-properties abs-start abs-end merged-props obj)
(when merged-props
(tp--stack-register-layers (list merged-props) obj))
(setq count (1+ count))))))
count)))
(defun tp-add-to-layers (idx-or-layer-name-list start-or-string &optional end-or-plist plist-or-object &rest rest)
"Add/merge properties to specified layers.
IDX-OR-LAYER-NAME-LIST is a list of layer indices (integers) or
layer names (symbols) specifying which layers to add properties to.
For indices: 0 means top layer, -1 means bottom layer.
For region form, PLIST is a property list to merge into the specified layers.
For string form, PROP VAL ... are property-value pairs to merge.
Properties are deeply merged (nested plists are merged, not replaced).
OBJECT defaults to current buffer for region form.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own. The returned string is
that same mutated object.
Returns the modified object (string) or nil for buffer operations."
(let (start end plist obj layer-ids)
(setq layer-ids idx-or-layer-name-list)
(cond
;; Entire string form: (tp-add-to-layers ids string prop val ...)
((stringp start-or-string)
(setq obj start-or-string
start 0
end (length start-or-string))
;; Construct plist from end-or-plist, plist-or-object, and rest
;; Always include plist-or-object even if nil, to handle (... 'prop nil)
(when end-or-plist
(setq plist (cons end-or-plist (cons plist-or-object rest)))))
;; Region form: (tp-add-to-layers ids start end plist object)
((numberp start-or-string)
(setq start start-or-string
end end-or-plist
plist plist-or-object
obj (car rest)))
(t (error "Invalid layer arguments: %S"
(cons start-or-string (list end-or-plist plist-or-object)))))
;; Handle plist wrapped in a list (from region form)
(when (and (listp plist)
(not (keywordp (car-safe plist)))
(listp (car-safe plist)))
(setq plist (car plist)))
;; Process each interval
(tp--stack-map-region
start end obj
(lambda (abs-start abs-end stack)
(let ((modified-stack
(cl-loop for layer in stack
for i from 0
collect
(if (cl-some
(lambda (id)
(let ((found (tp--get-layer-by-idx-or-name
stack id)))
(and found (= (car found) i))))
layer-ids)
;; Merge plist into this layer
(tp--deep-merge-plist layer plist)
;; Keep layer unchanged
layer))))
(when stack
(set-text-properties abs-start abs-end
(tp--stack-build-props modified-stack)
obj)
(tp--stack-register-layers modified-stack obj)))))
(if (stringp obj) obj nil)))
(defun tp-add-to-all-layers (start-or-string &optional end-or-plist plist-or-object &rest rest)
"Add/merge properties to all layers.
This function supports two calling conventions:
1. Buffer/string region:
(tp-add-to-all-layers START END PLIST OBJECT)
2. Entire string:
(tp-add-to-all-layers STRING PROP VAL ...)
For region form, PLIST is a property list to merge into all layers.
For string form, PROP VAL ... are property-value pairs to merge.
Properties are deeply merged (nested plists are merged, not replaced).
OBJECT defaults to current buffer for region form.
This function uses `tp-add-to-layers' internally, collecting all
layer indices and passing them to add the plist to every layer.
Unlike `tp-set', the string form modifies STRING destructively (in
place) rather than returning a propertized copy: never pass a string
literal or a shared string you do not own. The returned string is
that same mutated object.
Returns the modified object (string) or nil for buffer operations."
(let (start end plist obj)
(cond
;; Entire string form: (tp-add-to-all-layers string prop val ...)
((stringp start-or-string)
(setq obj start-or-string
start 0
end (length start-or-string))
;; Construct plist from end-or-plist, plist-or-object, and rest
;; Always include plist-or-object even if nil, to handle (... 'prop nil)
(when end-or-plist
(setq plist (cons end-or-plist (cons plist-or-object rest)))))
;; Region form: (tp-add-to-all-layers start end plist object)
((numberp start-or-string)
(setq start start-or-string
end end-or-plist
plist plist-or-object
obj (car rest)))
(t (error "Invalid layer arguments: %S"
(cons start-or-string (list end-or-plist plist-or-object)))))
;; Handle plist wrapped in a list (from region form)
(when (and (listp plist)
(not (keywordp (car-safe plist)))
(listp (car-safe plist)))
(setq plist (car plist)))
;; Get the maximum layer count in the region to build a list of all indices
(let ((max-count (tp-layer-count start end obj)))
(when (> max-count 0)
(let ((all-indices (cl-loop for i from 0 below max-count collect i)))
(tp-add-to-layers all-indices start end plist obj))))
(if (stringp obj) obj nil)))
(provide 'tp-stack)
;;; tp-stack.el ends here