ekp/ekp-region.el
Kinneyzhang ede518f10f fix: editor-state integrity — saving, stickiness, modified flag, undo
Justification is a re-layout of the same logical text; it now behaves
that way toward the rest of the editor:

- saving a justified buffer writes the LOGICAL text: before-save-hook
  unjustifies (spans remembered as markers), after-save-hook restores
  the justified view; the deterministic pair stays off undo history.
  Previously soft newlines and break hyphens were silently persisted
  to disk and the original whitespace was unrecoverable after reopen.
- renderer marker properties (ekp-glue/soft-break/soft-hyphen/hidden/
  justified) are registered in text-property-default-nonsticky: text
  typed after a glue no longer inherits the marker and is no longer
  deleted as a synthesized space by the next re-flow.
- pure re-layout preserves buffer-modified-p when it was nil: no more
  lock files, auto-saves and "buffer modified" prompts from merely
  enabling ekp-auto-justify-mode.
- changes applied by undo are not re-dirtied (no timer war against
  the user's undo sequence).
- switching major modes tears justified state down cleanly
  (change-major-mode-hook), widening first so narrowing never leaves
  justified orphans.

5 new ERT tests (72 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 01:00:27 +08:00

630 lines
27 KiB
EmacsLisp

;;; ekp-region.el --- Buffer-level justification for ekp -*- lexical-binding: t; -*-
;; Copyright (C) 2024-2026 Kinney Zhang
;; Author: Kinney Zhang <kinneyzhang666@gmail.com>
;; Keywords: wp, convenience
;; This file is NOT part of GNU Emacs.
;; 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.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Interactive layer over the ekp string API.
;;
;; - `ekp-justify-region' / `ekp-unjustify-region': justify buffer text
;; in place. Unjustification is a pure structural transform driven by
;; the text properties the renderer leaves behind (`ekp-glue',
;; `ekp-soft-break', `ekp-soft-hyphen', `ekp-hidden'), so the
;; original text — including whitespace runs stripped at line breaks —
;; is recovered exactly, even after the justified text was edited.
;;
;; - `ekp-auto-justify-mode': keeps the whole buffer justified to the
;; window width. Re-flows (debounced) when the window width changes,
;; and incrementally re-justifies only the edited paragraphs after
;; edits, so large buffers stay responsive (unchanged paragraphs hit
;; the ekp paragraph cache).
;;; Code:
(require 'ekp)
(require 'cl-lib)
(defvar ekp-auto-justify-mode)
(defgroup ekp-region nil
"Buffer-level justification built on ekp."
:group 'text
:prefix "ekp-")
(defcustom ekp-region-margin-pixel 2
"Pixels subtracted from the window body width when justifying.
A small safety margin that keeps justified lines from being wrapped
by the display engine due to rounding."
:type 'natnum)
(defcustom ekp-auto-justify-resize-delay 0.15
"Seconds to debounce window-resize re-flows in `ekp-auto-justify-mode'."
:type 'number)
(defcustom ekp-auto-justify-edit-delay 0.3
"Idle seconds before edited paragraphs are re-justified."
:type 'number)
(defcustom ekp-auto-justify-lazy-threshold 20000
"Buffer size (characters) beyond which re-flows go visible-first.
Below it a window-width change re-justifies the whole buffer at
once; above it the visible portion is done synchronously and the
rest follows in idle background chunks."
:type 'natnum)
(defcustom ekp-auto-justify-chunk-size 10
"Paragraphs re-justified per background tick in lazy re-flows."
:type 'natnum)
(defconst ekp-region-org-skip-faces
'(org-block org-block-begin-line org-block-end-line org-code
org-verbatim org-table org-meta-line)
"Reasonable `ekp-region-skip-faces' preset for Org buffers.")
(defconst ekp-region-markdown-skip-faces
'(markdown-code-face markdown-inline-code-face markdown-pre-face
markdown-table-face)
"Reasonable `ekp-region-skip-faces' preset for Markdown buffers.")
(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
"Non-nil while ekp-region is modifying the buffer itself.")
(defvar-local ekp-region--save-state nil
"Spans unjustified for saving: list of (BEG-MARKER END-MARKER WIDTH).
Set by `ekp-region--before-save', consumed by `ekp-region--after-save'.")
(defmacro ekp-region--preserving-modified (&rest body)
"Run BODY, keeping the buffer unmodified if it was unmodified.
Justification is a reversible re-layout of the same logical text, so
it must not flip `buffer-modified-p' on its own — that would create
lock files, trigger auto-saves and \"buffer modified\" prompts for
buffers the user never edited."
(declare (indent 0) (debug t))
`(let ((ekp-region--modified-was (buffer-modified-p)))
(prog1 (progn ,@body)
(unless ekp-region--modified-was
(restore-buffer-modified-p nil)))))
(defvar-local ekp-region--auto-width nil
"Pixel width the buffer is currently auto-justified to.")
(defvar-local ekp-region--resize-timer nil)
(defvar-local ekp-region--edit-timer nil)
(defvar-local ekp-region--dirty nil
"Pending edited regions, as a list of (BEG-MARKER . END-MARKER).")
(defvar-local ekp-region--pending nil
"Lazy re-flow state: (WIDTH . CHUNKS), CHUNKS = ((BEG-M . END-M)...).")
(defvar-local ekp-region--chunk-timer nil)
;;;; Width
(defun ekp-region-protrusion-reserve ()
"Pixels reserved at the right margin for hanging punctuation.
Non-zero only while `ekp-protrusion' is enabled: protruding glyphs
extend past the flush edge, so the layout width must leave room."
(if ekp-protrusion
(max 2 (ceiling (* (alist-get 'cjk-close ekp-protrusion-ratios 0.5)
(string-pixel-width ""))))
0))
(defun ekp-region--window-pixel (&optional window)
"Usable text width in pixels of WINDOW (default: selected window)."
(max 1 (- (window-body-width window t)
ekp-region-margin-pixel
(ekp-region-protrusion-reserve))))
;;;; Pure string transforms
(defun ekp-region--split-hard (string)
"Split justified STRING on hard newlines (those without `ekp-soft-break')."
(let ((parts nil) (start 0) (i 0) (len (length string)))
(while (< i len)
(when (and (eq (aref string i) ?\n)
(not (get-text-property i 'ekp-soft-break string)))
(push (substring string start i) parts)
(setq start (1+ i)))
(setq i (1+ i)))
(push (substring string start) 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)
"Return TEXT justified to PIXEL with exact-recovery markers.
Hard newlines are preserved one-to-one. Whitespace-only paragraphs
(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"))
(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
(ekp-region--split-hard
(ekp-pixel-justify (string-join cores "\n") pixel)))))
(unless (= (length out) (length cores))
(error "ekp-region: paragraph count mismatch (%d vs %d)"
(length out) (length cores)))
(string-join
(cl-loop for p in paras for s in skips
collect (cond (s p)
((string-blank-p p) (ekp--hide-string p))
(t (pop out))))
"\n")))
(defun ekp-region--pos-for-offset (string offset)
"Physical position in justified STRING for logical OFFSET.
Glue characters count for the length of the original text they
replaced (their `ekp-glue' value), soft breaks for their payload,
soft hyphens for nothing; everything else (including hidden text)
is one logical character."
(let ((i 0) (len (length string)))
(while (and (< i len) (> offset 0))
(let ((glue (get-text-property i 'ekp-glue string)))
(cond
(glue
(setq offset (- offset (length glue))))
((get-text-property i 'ekp-soft-hyphen string))
((and (eq (aref string i) ?\n)
(get-text-property i 'ekp-soft-break string))
(setq offset (- offset (length (get-text-property
i 'ekp-soft-break string)))))
(t (setq offset (1- offset)))))
(setq i (1+ i)))
i))
;;;; Commands
;;;###autoload
(defun ekp-justify-region (beg end &optional pixel)
"Justify the text between BEG and END to PIXEL width.
PIXEL defaults to the window text width (see `ekp-region-margin-pixel');
interactively, a numeric prefix argument supplies it explicitly.
Already-justified text is unjustified first, so the command is
idempotent and can re-flow to a new width."
(interactive
(list (region-beginning) (region-end)
(and current-prefix-arg (prefix-numeric-value current-prefix-arg))))
(setq pixel (or pixel (ekp-region--window-pixel)))
(let ((beg (copy-marker (min beg end)))
(end (copy-marker (max beg end) t))
(ekp-region--inhibit t)
(inhibit-read-only t))
(unwind-protect
(ekp-region--preserving-modified
(atomic-change-group
;; Re-flow support: strip previous justification first.
(when (text-property-not-all beg end 'ekp-justified nil)
(ekp-unjustify-region beg end))
(let* ((text (buffer-substring beg end))
(justified (ekp-region--justify-string text pixel))
(point-offset (and (>= (point) beg) (< (point) end)
(- (point) beg))))
(unless (equal-including-properties text justified)
(goto-char beg)
(delete-region beg end)
(insert justified)
(when point-offset
(goto-char (+ beg (ekp-region--pos-for-offset
justified point-offset)))))
(add-text-properties beg end (list 'ekp-justified pixel))))
;; Saving a justified buffer must write the logical text.
(add-hook 'before-save-hook #'ekp-region--before-save nil t)
(add-hook 'after-save-hook #'ekp-region--after-save nil t))
(set-marker beg nil)
(set-marker end nil))))
;;;###autoload
(defun ekp-unjustify-region (beg end)
"Restore the logical text between BEG and END.
Removes synthesized glue and soft hyphens, replaces soft line breaks
with the whitespace they swallowed, and re-exposes hidden paragraph
tails. Text the user typed into the justified region is preserved."
(interactive "r")
(let ((end-m (copy-marker (max beg end) t))
(ekp-region--inhibit t)
(inhibit-read-only t))
(unwind-protect
(ekp-region--preserving-modified
(save-excursion
(goto-char (min beg end))
(while (< (point) end-m)
(let* ((pos (point))
(glue (get-text-property pos 'ekp-glue)))
(cond
(glue
(delete-region pos (1+ pos))
(when (stringp glue) (insert glue)))
((get-text-property pos 'ekp-soft-hyphen)
(delete-region pos (1+ pos)))
((and (eq (char-after pos) ?\n)
(get-text-property pos 'ekp-soft-break))
(let ((payload (get-text-property pos 'ekp-soft-break)))
(delete-region pos (1+ pos))
(insert payload)))
((get-text-property pos 'ekp-hidden)
(remove-text-properties pos (1+ pos)
'(ekp-hidden nil display nil))
(forward-char 1))
;; Plain text: our markers are sparse, so hop straight
;; to the next property boundary instead of stepping
;; char by char.
(t (goto-char (min (marker-position end-m)
(next-property-change pos nil
(marker-position
end-m))))))))
(remove-text-properties (min beg end) end-m '(ekp-justified nil))))
(set-marker end-m nil))))
;;;; Saving: the file always receives the logical text
(defun ekp-region--justified-spans ()
"Return justified spans of the buffer as a list of (BEG END WIDTH).
BEG/END are positions; WIDTH is the span's `ekp-justified' value."
(let ((pos (point-min)) spans)
(while (< pos (point-max))
(let ((w (get-text-property pos 'ekp-justified))
(next (next-single-property-change pos 'ekp-justified
nil (point-max))))
(when w (push (list pos next w) spans))
(setq pos next)))
(nreverse spans)))
(defun ekp-region--before-save ()
"Restore the logical text before the buffer is written to disk.
Saving a justified buffer must never persist soft line breaks, glue
spaces or break hyphens: they are layout, not content. The spans are
remembered (as markers) and re-justified by `ekp-region--after-save',
so the user never sees the buffer un-justified."
(let ((spans (ekp-region--justified-spans)))
(when spans
;; The unjustify+rejustify pair is deterministic and cancels out
;; exactly, so keep it off the undo history.
(let ((buffer-undo-list t))
;; Marker-ize every span before the first unjustification
;; shifts the positions of the spans after it.
(setq ekp-region--save-state
(mapcar (pcase-lambda (`(,beg ,end ,width))
(list (copy-marker beg) (copy-marker end t) width))
spans))
(pcase-dolist (`(,beg ,end ,_width) ekp-region--save-state)
(ekp-unjustify-region beg end))))))
(defun ekp-region--after-save ()
"Re-justify the spans un-done by `ekp-region--before-save'."
(when ekp-region--save-state
(let ((buffer-undo-list t))
(pcase-dolist (`(,beg ,end ,width) ekp-region--save-state)
(when (and (marker-position beg) (marker-position end))
(ekp-justify-region beg end width))
(set-marker beg nil)
(set-marker end nil)))
(setq ekp-region--save-state nil)
;; The file on disk holds exactly this buffer's logical text.
(set-buffer-modified-p nil)))
;;;###autoload
(defun ekp-no-break-region (beg end)
"Mark the region as an unbreakable typesetting atom.
Justification treats it as one rigid unit: no line break inside, no
hyphenation, spacing stays literal (inline code, product names,
numbers with units)."
(interactive "r")
(add-text-properties beg end '(ekp-no-break t)))
;;;###autoload
(defun ekp-allow-break-region (beg end)
"Remove `ekp-no-break' marking from the region."
(interactive "r")
(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
(defun ekp-region--para-bounds (marker-pair)
"Hard-paragraph bounds containing MARKER-PAIR, as (BEG . END)."
(let ((b (let ((x (car marker-pair)))
(if (markerp x) (marker-position x) x)))
(e (let ((x (cdr marker-pair)))
(if (markerp x) (marker-position x) x))))
(save-excursion
(goto-char (max (point-min) (min b (point-max))))
(while (and (> (point) (point-min))
(let ((prev (1- (point))))
(not (and (eq (char-after prev) ?\n)
(not (get-text-property prev 'ekp-soft-break))))))
(forward-char -1))
(setq b (point))
(goto-char (max (point-min) (min e (point-max))))
(while (and (< (point) (point-max))
(not (and (eq (char-after) ?\n)
(not (get-text-property (point) 'ekp-soft-break)))))
(forward-char 1))
(cons b (point)))))
(defun ekp-region--merge-regions (regions)
"Merge overlapping or adjacent (BEG . END) REGIONS."
(let ((sorted (sort regions (lambda (a b) (< (car a) (car b)))))
merged)
(dolist (r sorted)
(if (and merged (<= (car r) (cdr (car merged))))
(setcdr (car merged) (max (cdr (car merged)) (cdr r)))
(push (cons (car r) (cdr r)) merged)))
(nreverse merged)))
(defun ekp-region--after-change (beg end _len)
"Record the edit between BEG and END for incremental re-justification.
Changes applied by undo are not re-flowed: re-justifying behind the
user's back would fight the undo sequence (and immediately dirty what
undo just restored). The next real edit or resize re-flows normally."
(when (and ekp-auto-justify-mode (not ekp-region--inhibit)
(not undo-in-progress))
(push (cons (copy-marker beg) (copy-marker end)) ekp-region--dirty)
(when (timerp ekp-region--edit-timer)
(cancel-timer ekp-region--edit-timer))
(setq ekp-region--edit-timer
(run-with-idle-timer ekp-auto-justify-edit-delay nil
#'ekp-region--flush-dirty (current-buffer)))))
(defun ekp-region--flush-dirty (buffer)
"Re-justify the paragraphs of BUFFER touched by recent edits."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when (and ekp-auto-justify-mode ekp-region--dirty ekp-region--auto-width)
(let* ((pairs (prog1 ekp-region--dirty (setq ekp-region--dirty nil)))
;; Convert all bounds to markers before the first
;; re-justification shifts later positions.
(regions (mapcar (lambda (r)
(cons (copy-marker (car r))
(copy-marker (cdr r) t)))
(ekp-region--merge-regions
(mapcar #'ekp-region--para-bounds pairs)))))
(dolist (r regions)
(ekp-justify-region (car r) (cdr r) ekp-region--auto-width)
(set-marker (car r) nil)
(set-marker (cdr r) nil))
(dolist (p pairs)
(set-marker (car p) nil)
(set-marker (cdr p) nil)))))))
(defun ekp-region--on-resize (window-or-frame)
"Debounced re-flow after WINDOW-OR-FRAME changed size.
Buffer-local members of `window-size-change-functions' receive the
window showing the buffer — and are not guaranteed to run with that
buffer current — so resolve both explicitly."
(let ((win (cond ((windowp window-or-frame) window-or-frame)
((framep window-or-frame)
(get-buffer-window (current-buffer) window-or-frame))
(t (get-buffer-window (current-buffer))))))
(when (window-live-p win)
(with-current-buffer (window-buffer win)
(when ekp-auto-justify-mode
(let ((w (ekp-region--window-pixel win)))
(when (and ekp-region--auto-width (/= w ekp-region--auto-width))
(when (timerp ekp-region--resize-timer)
(cancel-timer ekp-region--resize-timer))
(setq ekp-region--resize-timer
(run-with-timer ekp-auto-justify-resize-delay nil
#'ekp-region--reflow
(current-buffer) w)))))))))
(defun ekp-region--cancel-pending ()
"Drop any queued lazy re-flow chunks."
(when (timerp ekp-region--chunk-timer)
(cancel-timer ekp-region--chunk-timer))
(setq ekp-region--chunk-timer nil)
(dolist (c (cdr ekp-region--pending))
(set-marker (car c) nil)
(set-marker (cdr c) nil))
(setq ekp-region--pending nil))
(defun ekp-region--make-chunks (beg end)
"Split [BEG, END) into marker-pair chunks of whole hard paragraphs."
(let ((chunks nil))
(save-excursion
(goto-char beg)
(while (< (point) end)
(let ((cbeg (point)) (paras 0))
(while (and (< (point) end)
(< paras ekp-auto-justify-chunk-size))
(if (search-forward "\n" end 'move)
(unless (get-text-property (match-beginning 0)
'ekp-soft-break)
(setq paras (1+ paras)))
nil))
(when (> (point) cbeg)
;; BEG has insertion-type t: the previous chunk's re-insert
;; happens exactly at this boundary, and the marker must
;; end up after that text, not before it.
(push (cons (copy-marker cbeg t) (copy-marker (point) t))
chunks)))))
(nreverse chunks)))
(defun ekp-region--visible-span ()
"Visible portion of the current buffer, as (BEG . END)."
(let ((win (get-buffer-window (current-buffer))))
(if win
(cons (window-start win) (or (window-end win t) (point-max)))
(cons (point-min) (point-max)))))
(defun ekp-region--process-chunk (buffer)
"Re-justify the next queued chunk of BUFFER, then reschedule."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(setq ekp-region--chunk-timer nil)
(cond
((or (not ekp-auto-justify-mode) (null ekp-region--pending))
(ekp-region--cancel-pending))
;; a newer re-flow superseded this queue
((not (eql (car ekp-region--pending) ekp-region--auto-width))
(ekp-region--cancel-pending))
;; be polite: yield to pending input, try again shortly
((input-pending-p)
(setq ekp-region--chunk-timer
(run-with-timer 0.1 nil #'ekp-region--process-chunk buffer)))
(t
(let* ((width (car ekp-region--pending))
(chunk (pop (cdr ekp-region--pending))))
(when chunk
(ekp-justify-region (car chunk) (cdr chunk) width)
(set-marker (car chunk) nil)
(set-marker (cdr chunk) nil))
(if (cdr ekp-region--pending)
(setq ekp-region--chunk-timer
(run-with-timer 0.02 nil
#'ekp-region--process-chunk buffer))
(setq ekp-region--pending nil))))))))
(defun ekp-region--reflow (buffer width)
"Re-justify BUFFER to WIDTH — whole buffer, or visible-first when large."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when ekp-auto-justify-mode
(setq ekp-region--auto-width width)
(ekp-region--cancel-pending)
(if (< (- (point-max) (point-min))
ekp-auto-justify-lazy-threshold)
(ekp-justify-region (point-min) (point-max) width)
;; visible part now, the rest in background chunks
(pcase-let* ((`(,vbeg . ,vend) (ekp-region--visible-span))
(`(,pbeg . ,pend)
(ekp-region--para-bounds (cons vbeg vend))))
(ekp-justify-region pbeg pend width)
(let ((chunks (nconc
;; start at PEND so the hard newline there
;; gets its ekp-justified property too
(ekp-region--make-chunks pend (point-max))
(ekp-region--make-chunks (point-min) pbeg))))
(when chunks
(setq ekp-region--pending (cons width chunks))
(setq ekp-region--chunk-timer
(run-with-timer 0.02 nil
#'ekp-region--process-chunk
buffer))))))))))
;;;###autoload
(define-minor-mode ekp-auto-justify-mode
"Keep the buffer pixel-justified to the window width.
Re-flows when the window width changes and re-justifies edited
paragraphs incrementally. Designed for reading and previewing;
the buffer text is restored exactly when the mode is turned off."
:lighter " EKP"
(if ekp-auto-justify-mode
(progn
(setq ekp-region--auto-width
(ekp-region--window-pixel (get-buffer-window)))
(ekp-region--reflow (current-buffer) ekp-region--auto-width)
(add-hook 'window-size-change-functions #'ekp-region--on-resize nil t)
(add-hook 'after-change-functions #'ekp-region--after-change nil t)
(add-hook 'before-save-hook #'ekp-region--before-save nil t)
(add-hook 'after-save-hook #'ekp-region--after-save nil t)
;; Turning the major mode off/over kills local hooks silently;
;; the buffer must get its logical text back first.
(add-hook 'change-major-mode-hook #'ekp-region--teardown nil t))
(remove-hook 'window-size-change-functions #'ekp-region--on-resize t)
(remove-hook 'after-change-functions #'ekp-region--after-change t)
(remove-hook 'change-major-mode-hook #'ekp-region--teardown t)
(ekp-region--teardown)
;; Keep the save hooks only while justified text remains (the
;; teardown above removed all of it; a later ekp-justify-region
;; re-adds them).
(remove-hook 'before-save-hook #'ekp-region--before-save t)
(remove-hook 'after-save-hook #'ekp-region--after-save t)))
(defun ekp-region--teardown ()
"Cancel timers and restore the whole buffer's logical text.
Runs when `ekp-auto-justify-mode' is turned off and, via
`change-major-mode-hook', when a major-mode switch is about to
discard the mode silently."
(when (timerp ekp-region--resize-timer)
(cancel-timer ekp-region--resize-timer))
(when (timerp ekp-region--edit-timer)
(cancel-timer ekp-region--edit-timer))
(ekp-region--cancel-pending)
(dolist (p ekp-region--dirty)
(set-marker (car p) nil)
(set-marker (cdr p) nil))
(setq ekp-region--resize-timer nil
ekp-region--edit-timer nil
ekp-region--dirty nil
ekp-region--auto-width nil)
;; Narrowing must not leave justified orphans outside the visible
;; region.
(save-restriction
(widen)
(ekp-unjustify-region (point-min) (point-max))))
(provide 'ekp-region)
;;; ekp-region.el ends here