feat: verbatim protection — code blocks and mode hooks
Paragraphs stay verbatim (never justified, byte-identical through justify/auto-mode/unjustify) when they carry the ekp-verbatim text property, wear a face from ekp-region-skip-faces (point it at org-block / markdown-code-face and code blocks are exempt), or match the buffer-local ekp-region-skip-predicate — three layers of the same general mechanism, no mode-specific code. Inline code inside prose is the existing ekp-no-break atom (rigid, unbreakable, unhyphenated). New commands: ekp-verbatim-region / ekp-clear-verbatim-region. Core engine untouched: block protection lives entirely in the region layer, so skipped text never even reaches the tokenizer. Tests: 66 ERT green (property / face / predicate paths, exact roundtrips around justified prose). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
720b1cd0e4
commit
4d9a018fbf
@ -46,6 +46,21 @@ by the display engine due to rounding."
|
|||||||
"Idle seconds before edited paragraphs are re-justified."
|
"Idle seconds before edited paragraphs are re-justified."
|
||||||
:type 'number)
|
:type 'number)
|
||||||
|
|
||||||
|
(defcustom ekp-region-skip-faces nil
|
||||||
|
"Faces whose paragraphs are never justified (kept verbatim).
|
||||||
|
Point major-mode faces here — e.g. `org-block' and `org-code' for
|
||||||
|
Org, `markdown-code-face' for Markdown — and code blocks pass
|
||||||
|
through untouched. Checked against the `face' property of each
|
||||||
|
paragraph, symbol or list."
|
||||||
|
:type '(repeat face))
|
||||||
|
|
||||||
|
(defvar-local ekp-region-skip-predicate nil
|
||||||
|
"When non-nil, a function called with a paragraph string.
|
||||||
|
Return non-nil to keep that paragraph verbatim (no justification).
|
||||||
|
The general escape hatch for mode-specific block detection; prefer
|
||||||
|
the `ekp-verbatim' text property or `ekp-region-skip-faces' when
|
||||||
|
they suffice.")
|
||||||
|
|
||||||
(defvar ekp-region--inhibit nil
|
(defvar ekp-region--inhibit nil
|
||||||
"Non-nil while ekp-region is modifying the buffer itself.")
|
"Non-nil while ekp-region is modifying the buffer itself.")
|
||||||
|
|
||||||
@ -88,12 +103,39 @@ extend past the flush edge, so the layout width must leave room."
|
|||||||
(push (substring string start) parts)
|
(push (substring string start) parts)
|
||||||
(nreverse parts)))
|
(nreverse parts)))
|
||||||
|
|
||||||
|
(defun ekp-region--face-hit-p (string)
|
||||||
|
"Non-nil when STRING carries any face from `ekp-region-skip-faces'."
|
||||||
|
(let ((pos 0) (len (length string)) hit)
|
||||||
|
(while (and (not hit) (< pos len))
|
||||||
|
(let ((f (get-text-property pos 'face string)))
|
||||||
|
(when (if (listp f)
|
||||||
|
(seq-intersection f ekp-region-skip-faces)
|
||||||
|
(memq f ekp-region-skip-faces))
|
||||||
|
(setq hit t))
|
||||||
|
(setq pos (or (next-single-property-change pos 'face string len)
|
||||||
|
len))))
|
||||||
|
hit))
|
||||||
|
|
||||||
|
(defun ekp-region--skip-para-p (para)
|
||||||
|
"Non-nil when the paragraph string PARA must stay verbatim.
|
||||||
|
Code blocks and other protected text: marked with the `ekp-verbatim'
|
||||||
|
property, matching `ekp-region-skip-faces', or accepted by
|
||||||
|
`ekp-region-skip-predicate'."
|
||||||
|
(or (text-property-not-all 0 (length para) 'ekp-verbatim nil para)
|
||||||
|
(and ekp-region-skip-faces (ekp-region--face-hit-p para))
|
||||||
|
(and ekp-region-skip-predicate
|
||||||
|
(funcall ekp-region-skip-predicate para))))
|
||||||
|
|
||||||
(defun ekp-region--justify-string (text pixel)
|
(defun ekp-region--justify-string (text pixel)
|
||||||
"Return TEXT justified to PIXEL with exact-recovery markers.
|
"Return TEXT justified to PIXEL with exact-recovery markers.
|
||||||
Hard newlines are preserved one-to-one. Whitespace-only paragraphs
|
Hard newlines are preserved one-to-one. Whitespace-only paragraphs
|
||||||
(which the string API would empty out) survive as hidden text."
|
(which the string API would empty out) survive as hidden text;
|
||||||
|
verbatim paragraphs (see `ekp-region--skip-para-p') pass through
|
||||||
|
untouched."
|
||||||
(let* ((paras (split-string text "\n"))
|
(let* ((paras (split-string text "\n"))
|
||||||
(cores (cl-remove-if #'string-blank-p paras))
|
(skips (mapcar #'ekp-region--skip-para-p paras))
|
||||||
|
(cores (cl-loop for p in paras for s in skips
|
||||||
|
unless (or s (string-blank-p p)) collect p))
|
||||||
(out (and cores
|
(out (and cores
|
||||||
(ekp-region--split-hard
|
(ekp-region--split-hard
|
||||||
(ekp-pixel-justify (string-join cores "\n") pixel)))))
|
(ekp-pixel-justify (string-join cores "\n") pixel)))))
|
||||||
@ -101,11 +143,10 @@ Hard newlines are preserved one-to-one. Whitespace-only paragraphs
|
|||||||
(error "ekp-region: paragraph count mismatch (%d vs %d)"
|
(error "ekp-region: paragraph count mismatch (%d vs %d)"
|
||||||
(length out) (length cores)))
|
(length out) (length cores)))
|
||||||
(string-join
|
(string-join
|
||||||
(mapcar (lambda (p)
|
(cl-loop for p in paras for s in skips
|
||||||
(if (string-blank-p p)
|
collect (cond (s p)
|
||||||
(ekp--hide-string p)
|
((string-blank-p p) (ekp--hide-string p))
|
||||||
(pop out)))
|
(t (pop out))))
|
||||||
paras)
|
|
||||||
"\n")))
|
"\n")))
|
||||||
|
|
||||||
(defun ekp-region--pos-for-offset (string offset)
|
(defun ekp-region--pos-for-offset (string offset)
|
||||||
@ -216,6 +257,21 @@ numbers with units)."
|
|||||||
(interactive "r")
|
(interactive "r")
|
||||||
(remove-text-properties beg end '(ekp-no-break nil)))
|
(remove-text-properties beg end '(ekp-no-break nil)))
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun ekp-verbatim-region (beg end)
|
||||||
|
"Protect the region's paragraphs from justification (code blocks).
|
||||||
|
Whole paragraphs carrying the `ekp-verbatim' property pass through
|
||||||
|
`ekp-justify-region' and `ekp-auto-justify-mode' untouched. For an
|
||||||
|
unbreakable span inside prose, use `ekp-no-break-region' instead."
|
||||||
|
(interactive "r")
|
||||||
|
(add-text-properties beg end '(ekp-verbatim t)))
|
||||||
|
|
||||||
|
;;;###autoload
|
||||||
|
(defun ekp-clear-verbatim-region (beg end)
|
||||||
|
"Remove `ekp-verbatim' protection from the region."
|
||||||
|
(interactive "r")
|
||||||
|
(remove-text-properties beg end '(ekp-verbatim nil)))
|
||||||
|
|
||||||
;;;; Auto-justify minor mode
|
;;;; Auto-justify minor mode
|
||||||
|
|
||||||
(defun ekp-region--para-bounds (marker-pair)
|
(defun ekp-region--para-bounds (marker-pair)
|
||||||
|
|||||||
@ -166,6 +166,49 @@
|
|||||||
;; re-enable so with-mode's cleanup disable is a no-op state-wise
|
;; re-enable so with-mode's cleanup disable is a no-op state-wise
|
||||||
(ekp-auto-justify-mode 1))))
|
(ekp-auto-justify-mode 1))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-verbatim-paragraph-skipped ()
|
||||||
|
"A code-block paragraph stays byte-identical; prose around it justifies."
|
||||||
|
(let* ((code (propertize "(defun foo (x) (list 1 2))"
|
||||||
|
'ekp-verbatim t 'face 'font-lock-keyword-face))
|
||||||
|
(text (concat "prose before with words enough to wrap lines\n"
|
||||||
|
code
|
||||||
|
"\nprose after also long enough to wrap lines")))
|
||||||
|
(ekp-region-test--with-text text
|
||||||
|
(ekp-justify-region (point-min) (point-max) 20)
|
||||||
|
;; the code line is still there, character-exact, spacing intact
|
||||||
|
(goto-char (point-min))
|
||||||
|
(should (search-forward "(defun foo (x) (list 1 2))" nil t))
|
||||||
|
;; prose got justified (soft breaks appeared)
|
||||||
|
(should (text-property-not-all (point-min) (point-max)
|
||||||
|
'ekp-soft-break nil))
|
||||||
|
(ekp-unjustify-region (point-min) (point-max))
|
||||||
|
(should (equal-including-properties (buffer-string) text)))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-skip-faces ()
|
||||||
|
"Paragraphs wearing a skip face stay verbatim."
|
||||||
|
(let* ((ekp-region-skip-faces '(font-lock-comment-face))
|
||||||
|
(code (propertize ";; a comment line kept as-is"
|
||||||
|
'face 'font-lock-comment-face))
|
||||||
|
(text (concat "prose paragraph long enough to wrap\n" code)))
|
||||||
|
(ekp-region-test--with-text text
|
||||||
|
(ekp-justify-region (point-min) (point-max) 15)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(should (search-forward ";; a comment line kept as-is" nil t))
|
||||||
|
(ekp-unjustify-region (point-min) (point-max))
|
||||||
|
(should (equal-including-properties (buffer-string) text)))))
|
||||||
|
|
||||||
|
(ert-deftest ekp-region-test-skip-predicate ()
|
||||||
|
"The paragraph predicate is the general escape hatch."
|
||||||
|
(let ((text "keepme raw spacing\nnormal prose that wraps around"))
|
||||||
|
(ekp-region-test--with-text text
|
||||||
|
(setq-local ekp-region-skip-predicate
|
||||||
|
(lambda (p) (string-prefix-p "keepme" p)))
|
||||||
|
(ekp-justify-region (point-min) (point-max) 12)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(should (looking-at-p "keepme raw spacing$"))
|
||||||
|
(ekp-unjustify-region (point-min) (point-max))
|
||||||
|
(should (equal (buffer-string) text)))))
|
||||||
|
|
||||||
(ert-deftest ekp-region-test-indent-roundtrip ()
|
(ert-deftest ekp-region-test-indent-roundtrip ()
|
||||||
"First-line indent spacers vanish exactly on unjustify."
|
"First-line indent spacers vanish exactly on unjustify."
|
||||||
(let ((ekp-first-line-indent 6)
|
(let ((ekp-first-line-indent 6)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user