From b263ce29501c1dd7ac554ec3aa82252d630db7ca Mon Sep 17 00:00:00 2001 From: ImFstAsFckBoi Date: Sat, 4 Apr 2026 14:38:21 +0200 Subject: [PATCH 1/6] Add fix for leading whitespace and associated tests --- occult.el | 34 ++++++++++++++++++++++++++------ test/occult-tests.el | 46 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/occult.el b/occult.el index 7791092..6f3c59a 100644 --- a/occult.el +++ b/occult.el @@ -163,6 +163,14 @@ characters from BEG, whichever comes first." (goto-char beg) (min (line-end-position) end (+ beg occult-summary-max-length)))) +(defun occult--leading-whitespace (beg end) + (save-excursion + (goto-char beg) + (while (and (looking-at-p "[ \t\n\r]") + (< (point) end)) + (forward-char 1)) + (point))) + (defun occult--content-hash (beg end) "Compute a SHA-256 hash of buffer text between BEG and END." (secure-hash 'sha256 (buffer-substring-no-properties beg end))) @@ -174,22 +182,33 @@ characters from BEG, whichever comes first." The first line (up to `occult-summary-max-length' chars) stays visible and navigable. The remainder is hidden via a body overlay. Returns the parent overlay." - (let* ((split (occult--visible-end beg end)) - (body-text (buffer-substring-no-properties split end)) + (let* ((head-split (occult--leading-whitespace beg end)) + (body-split (occult--visible-end head-split end)) + (body-text (buffer-substring-no-properties body-split end)) (indicator (propertize occult-indicator 'face 'occult-indicator)) (ellipsis (concat (propertize occult-ellipsis 'face 'occult-summary) (if (string-match-p "\n" body-text) "\n" ""))) (parent (make-overlay beg end nil t nil)) - (body (make-overlay split end nil t nil))) + (head (make-overlay beg head-split nil t nil)) + (body (make-overlay body-split end nil t nil))) + ;; Put indicator on the front most overlay, head will disapear if its empty + (overlay-put (if (= beg head-split) parent head) + 'before-string + indicator) + ;; Parent overlay - spans the whole fold, provides keymap and ID (overlay-put parent 'occult t) (overlay-put parent 'occult-body body) + (overlay-put parent 'occult-head head) (overlay-put parent 'face 'occult-summary) - (overlay-put parent 'before-string indicator) (overlay-put parent 'keymap occult-overlay-map) (overlay-put parent 'help-echo "Press TAB to expand") (overlay-put parent 'evaporate t) (overlay-put parent 'modification-hooks (list #'occult--modification-hook)) + ;; Head overlay - hides leading whitespace and newlines before the visible portion + (overlay-put head 'occult-parent parent) + (overlay-put head 'invisible 'occult) + (overlay-put head 'evaporate t) ;; Body overlay - hides everything after the visible portion (overlay-put body 'occult-parent parent) (overlay-put body 'invisible 'occult) @@ -204,9 +223,12 @@ Returns the parent overlay." (defun occult--delete-fold (ov) "Delete fold OV and its associated body overlay." (when (and ov (overlay-buffer ov)) - (when-let ((body (overlay-get ov 'occult-body))) + (when-let ((body (overlay-get ov 'occult-body)) + (head (overlay-get ov 'occult-head))) (when (overlay-buffer body) - (delete-overlay body))) + (delete-overlay body)) + (when (overlay-buffer head) + (delete-overlay head))) (delete-overlay ov))) (defun occult--remove-overlay (ov) diff --git a/test/occult-tests.el b/test/occult-tests.el index 199b52b..2f6801d 100644 --- a/test/occult-tests.el +++ b/test/occult-tests.el @@ -28,6 +28,10 @@ "Get the body overlay associated with PARENT." (overlay-get parent 'occult-body)) +(defun occult-test--head-overlay (parent) + "Get the head overlay associated with PARENT." + (overlay-get parent 'occult-head)) + ;;; Visible end calculation (describe "occult--visible-end" @@ -44,6 +48,22 @@ (occult-test-with-buffer "Hi\n" (expect (occult--visible-end 1 3) :to-equal 3)))) + +;;; Trim leading whitepace + +(describe "occult--leading-whitespace" + (it "does nothing for not leading whitespace" + (occult-test-with-buffer "line with 17 char" + (expect (occult--leading-whitespace 1 18) :to-be 1))) + + (it "gives first char that is not whitespace" + (occult-test-with-buffer " \n\r\tline with 21 char" + (expect (occult--leading-whitespace 1 22) :to-be 5))) + + (it "gived END when there is only whitespace" + (occult-test-with-buffer " \n\r\t" + (expect (occult--leading-whitespace 1 5) :to-be 5)))) + ;;; Overlay creation - two-overlay structure (describe "occult-hide-region" @@ -106,7 +126,31 @@ (occult-test-with-buffer "Read only content\n" (setq buffer-read-only t) (occult-hide-region 1 19) - (expect (occult-test--fold-count) :to-equal 1)))) + (expect (occult-test--fold-count) :to-equal 1))) + + (it "creates a head overlay for leading whitespace" + (occult-test-with-buffer " \t\r\nLine 1\nLine 2\nLine 3\n" + (occult-hide-region 1 26) + (let* ((parent (occult--overlay-at-point)) + (head (occult-test--head-overlay parent))) + (expect head :to-be-truthy) + (expect (overlay-end head) :to-be 5) + (expect (overlay-get head 'invisible) :to-equal 'occult)))) + + (it "evaporates the head overlay with not leading whitepace" + (occult-test-with-buffer "Line 1\nLine 2\nLine 3\n" + (occult-hide-region 1 22) + (let* ((parent (occult--overlay-at-point)) + (head (occult-test--head-overlay parent))) + (expect (overlay-buffer head) :not :to-be-truthy)))) + + (it "shows indicator in head before-string with leading whitespace" + (occult-test-with-buffer " \t\r\nLine 1\nLine 2\nLine 3\n" + (occult-hide-region 1 26) + (let* ((parent (occult--overlay-at-point)) + (head (occult-test--head-overlay parent))) + (expect (overlay-get parent 'before-string) :not :to-be-truthy) + (expect (overlay-get head 'before-string) :to-match "📎"))))) ;;; Toggle From ae27996aa11006d8993c0a8f2011cad137f458d6 Mon Sep 17 00:00:00 2001 From: ImFstAsFckBoi Date: Mon, 6 Apr 2026 11:39:56 +0200 Subject: [PATCH 2/6] Fix small issues --- occult.el | 10 ++++++---- test/occult-tests.el | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/occult.el b/occult.el index 6f3c59a..5bea096 100644 --- a/occult.el +++ b/occult.el @@ -164,6 +164,8 @@ characters from BEG, whichever comes first." (min (line-end-position) end (+ beg occult-summary-max-length)))) (defun occult--leading-whitespace (beg end) + "Return the position where non-whitespace characters begins between BEG..END. +If the region contains only whitespace symbols, it returns END." (save-excursion (goto-char beg) (while (and (looking-at-p "[ \t\n\r]") @@ -191,7 +193,7 @@ Returns the parent overlay." (parent (make-overlay beg end nil t nil)) (head (make-overlay beg head-split nil t nil)) (body (make-overlay body-split end nil t nil))) - ;; Put indicator on the front most overlay, head will disapear if its empty + ;; Put indicator on the front most overlay, head will disappear if its empty (overlay-put (if (= beg head-split) parent head) 'before-string indicator) @@ -223,10 +225,10 @@ Returns the parent overlay." (defun occult--delete-fold (ov) "Delete fold OV and its associated body overlay." (when (and ov (overlay-buffer ov)) - (when-let ((body (overlay-get ov 'occult-body)) - (head (overlay-get ov 'occult-head))) + (when-let ((body (overlay-get ov 'occult-body))) (when (overlay-buffer body) - (delete-overlay body)) + (delete-overlay body))) + (when-let ((head (overlay-get ov 'occult-head))) (when (overlay-buffer head) (delete-overlay head))) (delete-overlay ov))) diff --git a/test/occult-tests.el b/test/occult-tests.el index 2f6801d..288c1ab 100644 --- a/test/occult-tests.el +++ b/test/occult-tests.el @@ -49,10 +49,10 @@ (expect (occult--visible-end 1 3) :to-equal 3)))) -;;; Trim leading whitepace +;;; Trim leading whitespace (describe "occult--leading-whitespace" - (it "does nothing for not leading whitespace" + (it "does nothing for no leading whitespace" (occult-test-with-buffer "line with 17 char" (expect (occult--leading-whitespace 1 18) :to-be 1))) @@ -60,7 +60,7 @@ (occult-test-with-buffer " \n\r\tline with 21 char" (expect (occult--leading-whitespace 1 22) :to-be 5))) - (it "gived END when there is only whitespace" + (it "gives END when there is only whitespace" (occult-test-with-buffer " \n\r\t" (expect (occult--leading-whitespace 1 5) :to-be 5)))) From 2bbaefb0566932c18d13031d0d9eb69a709c734b Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 11 Apr 2026 16:09:45 -0500 Subject: [PATCH 3/6] Apply always-head invariant for indicator placement The previous iteration dispatched the indicator `before-string` between parent and head depending on whether the region had leading whitespace. The head overlay was `evaporate t` and relied on Emacs dropping it when zero-length. That broke the uniform "indicator lives on parent" invariant and triggered display quirks where parent's `before-string` could be shadowed by head's `invisible` property when both overlays started at the same position. This change makes the head overlay the single, uniform home of the indicator. Head is always created (even zero-length) and does not evaporate, so the indicator has a stable host regardless of leading whitespace. Parent is also made non-evaporating so that an edit cannot drop the parent before the modification-hook runs cleanup on head and body. Tests covering the parent-before-string and head-evaporates cases are rewritten to match the new invariant. --- occult.el | 38 ++++++++++++++++++++++++++------------ test/occult-tests.el | 16 +++++++++++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/occult.el b/occult.el index 5bea096..9ac1eb1 100644 --- a/occult.el +++ b/occult.el @@ -181,8 +181,19 @@ If the region contains only whitespace symbols, it returns END." (defun occult--create-overlay (beg end) "Create an occult fold spanning BEG to END. -The first line (up to `occult-summary-max-length' chars) stays visible -and navigable. The remainder is hidden via a body overlay. +The fold is backed by three overlays: a head overlay over any +leading whitespace that carries the indicator `before-string', a +body overlay over the hidden tail that carries the ellipsis +`before-string', and a parent overlay spanning the whole region +that owns the face, keymap, and modification-hook. + +The head overlay is always created, even when there is no leading +whitespace to hide; in that case it is a zero-length overlay at +BEG. Keeping the indicator on a single overlay (head) gives a +uniform rendering rule and avoids the ordering ambiguity that +arises when two overlays starting at the same position both +carry a `before-string'. + Returns the parent overlay." (let* ((head-split (occult--leading-whitespace beg end)) (body-split (occult--visible-end head-split end)) @@ -193,25 +204,28 @@ Returns the parent overlay." (parent (make-overlay beg end nil t nil)) (head (make-overlay beg head-split nil t nil)) (body (make-overlay body-split end nil t nil))) - ;; Put indicator on the front most overlay, head will disappear if its empty - (overlay-put (if (= beg head-split) parent head) - 'before-string - indicator) - - ;; Parent overlay - spans the whole fold, provides keymap and ID + ;; Parent overlay - spans the whole fold and owns the face, keymap, + ;; and modification-hook. Non-evaporating so that an edit which + ;; collapses the region does not drop the parent before the + ;; modification-hook has a chance to clean up head and body. (overlay-put parent 'occult t) (overlay-put parent 'occult-body body) (overlay-put parent 'occult-head head) (overlay-put parent 'face 'occult-summary) (overlay-put parent 'keymap occult-overlay-map) (overlay-put parent 'help-echo "Press TAB to expand") - (overlay-put parent 'evaporate t) + (overlay-put parent 'evaporate nil) (overlay-put parent 'modification-hooks (list #'occult--modification-hook)) - ;; Head overlay - hides leading whitespace and newlines before the visible portion + ;; Head overlay - always present (zero-length when no leading + ;; whitespace) so that the indicator has a single, uniform home. + ;; `invisible 'occult' hides any leading whitespace it covers; + ;; on a zero-length head it is a no-op but harmless. (overlay-put head 'occult-parent parent) + (overlay-put head 'before-string indicator) (overlay-put head 'invisible 'occult) - (overlay-put head 'evaporate t) - ;; Body overlay - hides everything after the visible portion + (overlay-put head 'evaporate nil) + ;; Body overlay - hides everything after the visible portion and + ;; is the primary surface for isearch reveal. (overlay-put body 'occult-parent parent) (overlay-put body 'invisible 'occult) (overlay-put body 'before-string ellipsis) diff --git a/test/occult-tests.el b/test/occult-tests.el index 288c1ab..4311a0e 100644 --- a/test/occult-tests.el +++ b/test/occult-tests.el @@ -91,11 +91,13 @@ ;; Body starts after first line, not at fold start (expect (overlay-start body) :to-be-greater-than 1)))) - (it "shows indicator in parent before-string" + (it "shows indicator in head before-string" (occult-test-with-buffer "Hello\nWorld\n" (occult-hide-region 1 13) - (let ((parent (occult--overlay-at-point))) - (expect (overlay-get parent 'before-string) :to-match "📎")))) + (let* ((parent (occult--overlay-at-point)) + (head (occult-test--head-overlay parent))) + (expect (overlay-get head 'before-string) :to-match "📎") + (expect (overlay-get parent 'before-string) :not :to-be-truthy)))) (it "shows ellipsis in body before-string" (occult-test-with-buffer "Hello\nWorld\n" @@ -137,12 +139,16 @@ (expect (overlay-end head) :to-be 5) (expect (overlay-get head 'invisible) :to-equal 'occult)))) - (it "evaporates the head overlay with not leading whitepace" + (it "creates a zero-length head overlay when there is no leading whitespace" (occult-test-with-buffer "Line 1\nLine 2\nLine 3\n" (occult-hide-region 1 22) (let* ((parent (occult--overlay-at-point)) (head (occult-test--head-overlay parent))) - (expect (overlay-buffer head) :not :to-be-truthy)))) + (expect head :to-be-truthy) + (expect (overlay-buffer head) :to-be-truthy) + (expect (overlay-start head) :to-equal 1) + (expect (overlay-end head) :to-equal 1) + (expect (overlay-get head 'before-string) :to-match "📎")))) (it "shows indicator in head before-string with leading whitespace" (occult-test-with-buffer " \t\r\nLine 1\nLine 2\nLine 3\n" From fb7bb3c0908193a801b262fff98ec0fb17e1373b Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 11 Apr 2026 16:10:39 -0500 Subject: [PATCH 4/6] Refactor leading-whitespace helper and clarify visible-end docstring Replace the character-by-character `looking-at-p' + `forward-char' loop in `occult--leading-whitespace' with a single `skip-chars-forward' call bounded by END. This is the idiomatic form, runs in C, and handles a slightly broader ASCII whitespace set (form feed and vertical tab in addition to the previous space/tab/LF/CR). Update `occult--visible-end' docstring to state explicitly that BEG is the start of the fold's visible content (first non-whitespace position), not the fold's outer start, and that `occult-summary-max-length' is measured from there. This documents the semantic introduced by the leading-whitespace fix: the summary budget is now spent on real content and leading blank lines do not consume any of it. --- occult.el | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/occult.el b/occult.el index 9ac1eb1..518bc53 100644 --- a/occult.el +++ b/occult.el @@ -156,21 +156,33 @@ List of (BEG END CONTENT-HASH) tuples.") (overlays-at (point)))) (defun occult--visible-end (beg end) - "Return the position where visible text ends for a fold at BEG..END. -Capped at the end of the first line or `occult-summary-max-length' -characters from BEG, whichever comes first." + "Return the position where the visible summary line ends. +BEG is the start of the visible content of the fold - i.e. the +first non-whitespace position, as returned by +`occult--leading-whitespace', not necessarily the fold's outer +start. END is the fold's outer end. + +The result is capped at the end of the line containing BEG, the +fold END, or BEG plus `occult-summary-max-length' characters, +whichever comes first. This means `occult-summary-max-length' +is measured from the first non-whitespace character of the fold, +so leading blank lines do not consume any of the budget." (save-excursion (goto-char beg) (min (line-end-position) end (+ beg occult-summary-max-length)))) (defun occult--leading-whitespace (beg end) - "Return the position where non-whitespace characters begins between BEG..END. -If the region contains only whitespace symbols, it returns END." + "Return the first non-whitespace position in the range BEG..END. +Scans ASCII whitespace (space, tab, newline, carriage return, +form feed, vertical tab) forward from BEG, stopping at END at +the latest. Returns END if the range is entirely whitespace. + +Used by `occult--create-overlay' to skip leading blank lines +when computing where the visible summary begins, so the summary +is not wasted on empty leading whitespace." (save-excursion (goto-char beg) - (while (and (looking-at-p "[ \t\n\r]") - (< (point) end)) - (forward-char 1)) + (skip-chars-forward " \t\n\r\f\v" end) (point))) (defun occult--content-hash (beg end) From 247dd43ae5b8cd6b8abd314876f6c8438a376248 Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 11 Apr 2026 16:27:42 -0500 Subject: [PATCH 5/6] Add integration tests for leading-whitespace edge cases Four new tests lock in behavior that was previously only indirectly covered: - `occult-summary-max-length' is measured from the first non-whitespace position, not from the fold start. The test uses cap=3 on a region with two leading newlines and asserts body-split lands at head-split+cap, not beg+cap. - `occult-edit-region' cleans up the head overlay inside the indirect edit buffer alongside parent and body, so the fold content is fully visible for editing. - The base buffer's head overlay survives an edit session opening and still carries the indicator `before-string'. - `revert-buffer' persistence round-trips leading-whitespace folds: after save + delete + restore, the head overlay is re-created with the correct extent and indicator. --- test/occult-tests.el | 54 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/test/occult-tests.el b/test/occult-tests.el index 4311a0e..7f48730 100644 --- a/test/occult-tests.el +++ b/test/occult-tests.el @@ -156,7 +156,21 @@ (let* ((parent (occult--overlay-at-point)) (head (occult-test--head-overlay parent))) (expect (overlay-get parent 'before-string) :not :to-be-truthy) - (expect (overlay-get head 'before-string) :to-match "📎"))))) + (expect (overlay-get head 'before-string) :to-match "📎")))) + + (it "measures summary-max-length from first non-whitespace position" + ;; With two leading newlines and cap = 3, the visible content + ;; starts at pos 3 and body-split lands at pos 3+3 = 6. Before + ;; the leading-whitespace fix the cap was measured from beg (pos + ;; 1) and body-split would have been at pos 4. + (let ((occult-summary-max-length 3)) + (occult-test-with-buffer "\n\nHello World\n" + (occult-hide-region 1 15) + (let* ((parent (occult--overlay-at-point)) + (body (occult-test--body-overlay parent)) + (head (occult-test--head-overlay parent))) + (expect (overlay-end head) :to-equal 3) + (expect (overlay-start body) :to-equal 6)))))) ;;; Toggle @@ -698,6 +712,44 @@ The edit buffer and base buffer are cleaned up at the end." (expect (window-buffer) :to-equal base))) (when (buffer-live-p base) (kill-buffer base)))))) +(describe "occult with leading whitespace" + (it "cleans up head overlay inside the indirect edit buffer" + (occult-test-with-edit-session "\n\nLine 1\nLine 2\nLine 3\n" 1 24 + (let ((occult-ovs + (cl-remove-if-not + (lambda (ov) + (or (overlay-get ov 'occult) + (overlay-get ov 'occult-parent))) + (overlays-in (point-min) (point-max))))) + (expect (length occult-ovs) :to-equal 0)))) + + (it "keeps head overlay alive in the base buffer during edit session" + (occult-test-with-edit-session "\n\nLine 1\nLine 2\nLine 3\n" 1 24 + (with-current-buffer (buffer-base-buffer) + (let* ((parent (cl-find-if (lambda (ov) (overlay-get ov 'occult)) + (overlays-in (point-min) (point-max)))) + (head (overlay-get parent 'occult-head))) + (expect head :to-be-truthy) + (expect (overlay-buffer head) :to-be-truthy) + (expect (overlay-end head) :to-equal 3) + (expect (overlay-get head 'before-string) :to-match "📎"))))) + + (it "restores head overlay after revert-buffer round-trip" + (occult-test-with-buffer "\n\nHello world\n" + (occult-hide-region 1 15) + (occult--save-overlays) + (dolist (ov (overlays-in (point-min) (point-max))) + (delete-overlay ov)) + (expect (occult-test--fold-count) :to-equal 0) + (occult--restore-overlays) + (expect (occult-test--fold-count) :to-equal 1) + (let* ((parent (cl-find-if (lambda (ov) (overlay-get ov 'occult)) + (overlays-in (point-min) (point-max)))) + (head (overlay-get parent 'occult-head))) + (expect head :to-be-truthy) + (expect (overlay-end head) :to-equal 3) + (expect (overlay-get head 'before-string) :to-match "📎"))))) + (provide 'occult-tests) ;; Local Variables: From 39337a31b8fbf511152cd8fb7d324696ed78c80a Mon Sep 17 00:00:00 2001 From: Ag Ibragimov Date: Sat, 11 Apr 2026 16:48:21 -0500 Subject: [PATCH 6/6] Document leading-whitespace handling in SPEC and changelog SPEC.md: - Core Mechanism now describes three overlays (parent, head, body) rather than two, and notes that parent and head are non-evaporating. - Summary Line Format states that `occult-summary-max-length' is measured from `head-split' (first non-whitespace position), not from the region start. - Overlay Properties gains a Head overlay table, removes `before-string' from the Parent table, and flips Parent `evaporate' to nil. changelog.org: - New 1.2.0 entry covering the leading-whitespace fix, the uniform indicator placement, and the max-length semantic clarification. Credits @ImFstAsFckBoi for the initial investigation and prototype in PR #2. --- SPEC.md | 87 ++++++++++++++++++++++++++++++++++++++------------- changelog.org | 11 +++++++ 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4e9788b..4405220 100644 --- a/SPEC.md +++ b/SPEC.md @@ -17,18 +17,31 @@ to visually collapse any selected region, with the guarantee that: ## Core Mechanism -Each fold is backed by a pair of overlays: a parent covering the entire region -and a body covering the hidden suffix. The first line of the region remains -live, navigable buffer text. - -- Parent overlay spans `[beg, end)` and carries the indicator glyph as - `before-string`, an interaction keymap, a face, and `modification-hooks`. -- Body overlay spans `[split, end)` where `split` is the first-line break (or - `beg + occult-summary-max-length`, whichever comes first). It carries - `invisible 'occult` and prepends the ellipsis via `before-string`. -- The two overlays are linked via `occult-body` / `occult-parent` properties. +Each fold is backed by three overlays: a parent covering the entire region, +a head covering any leading whitespace, and a body covering the hidden +suffix. The first non-whitespace line of the region remains live, navigable +buffer text. + +- Parent overlay spans `[beg, end)` and carries an interaction keymap, a + face, and `modification-hooks`. It is non-evaporating so that an edit + cannot drop the parent before the modification-hook gets a chance to + clean up head and body. +- Head overlay spans `[beg, head-split)` where `head-split` is the first + non-whitespace position in the region. It carries the indicator glyph as + `before-string` and `invisible 'occult`. The head is always created, + even as a zero-length overlay at `beg` when there is no leading + whitespace; this gives the indicator a single, uniform host regardless + of input shape. +- Body overlay spans `[body-split, end)` where `body-split` is the first + line break after `head-split`, the fold end, or + `head-split + occult-summary-max-length` characters from `head-split`, + whichever comes first. It carries `invisible 'occult` and prepends the + ellipsis via `before-string`. +- The three overlays are linked: parent references body and head via + `occult-body` / `occult-head`; head and body reference parent via + `occult-parent`. - `buffer-invisibility-spec` includes `'occult` whenever the internal mode is - active, so the body text is hidden from display. + active, so the head and body text is hidden from display. - `buffer-string` / `buffer-substring-no-properties` return the full original text regardless of overlay state - this is what LLM packages, org-export, and copy/kill use. @@ -154,23 +167,45 @@ line. ## Overlay Properties -Folds use two overlays. +Folds use three overlays: parent, head, and body. ### Parent overlay +Spans `[beg, end)`. Owns the face, keymap, and modification-hook. + | Property | Value | |----------------------|--------------------------------------------------------| | `occult` | `t` (marker for finding our overlays) | | `occult-body` | Reference to the body overlay | +| `occult-head` | Reference to the head overlay | | `face` | `occult-summary` | -| `before-string` | Indicator string | | `keymap` | TAB/mouse-1 toggle the fold; `e` opens it for editing | | `help-echo` | "Press TAB to expand" | -| `evaporate` | `t` | +| `evaporate` | `nil` | | `modification-hooks` | Remove the fold if underlying text is edited | +Parent no longer carries `before-string`; the indicator lives on the head +overlay so that its placement is uniform regardless of leading whitespace. +Parent is non-evaporating so an edit cannot drop it before the +modification-hook runs and cleans up head and body. + +### Head overlay + +Spans `[beg, head-split)`. Always created, even as a zero-length overlay +at `beg` when there is no leading whitespace, so that the indicator has a +single, uniform host. + +| Property | Value | +|-------------------|--------------------------------------| +| `occult-parent` | Back-reference to parent overlay | +| `invisible` | `'occult` | +| `before-string` | Indicator string | +| `evaporate` | `nil` | + ### Body overlay +Spans `[body-split, end)`. Hides the tail of the fold. + | Property | Value | |--------------------------------------|------------------------------------| | `occult-parent` | Back-reference to parent overlay | @@ -183,19 +218,29 @@ Folds use two overlays. ## Summary Line Format ``` -📎 First line of the region... +📎 First non-whitespace line of the region... ``` -The visible portion of a folded region is live buffer text from `beg` up to -`split`, where `split = min(line-end, end, beg + occult-summary-max-length)`. -The body overlay takes over from `split` and prepends `occult-ellipsis` via -its `before-string`. +The visible portion of a folded region is live buffer text between +`head-split` and `body-split`: + +- `head-split` = first non-whitespace position in the region (leading + blank lines and other ASCII whitespace are hidden by the head overlay) +- `body-split = min(line-end-from-head-split, end, head-split + occult-summary-max-length)` + +The head overlay hides `[beg, head-split)` and prepends the indicator via +its `before-string`. The body overlay hides `[body-split, end)` and +prepends `occult-ellipsis` via its `before-string`. + +`occult-summary-max-length` is measured from `head-split`, not from the +region start, so leading whitespace does not consume any of the summary +budget. - Indicator: customizable via `occult-indicator`, default `"📎 "` - Ellipsis: customizable via `occult-ellipsis`, default `"..."` - Max length: customizable via `occult-summary-max-length`, default `80` -- The first line is not synthesized or copied - it is the actual underlying - buffer text, navigable and selectable. +- The visible summary is not synthesized or copied - it is the actual + underlying buffer text, navigable and selectable. ## Faces diff --git a/changelog.org b/changelog.org index 7cb3f22..af67075 100644 --- a/changelog.org +++ b/changelog.org @@ -3,6 +3,17 @@ Changes to the project, following Keep a Changelog conventions. +* [1.2.0] - 2026-04-11 + +** Fixed +- Folding a region that begins with blank lines no longer collapses the summary to just the ellipsis. A new head overlay hides the leading whitespace so the summary shows the first non-whitespace line. + +** Changed +- ~occult-summary-max-length~ is now measured from the first non-whitespace position of the region rather than the region start. Leading blank lines no longer consume any of the summary budget. +- Fold overlay structure is now three overlays: parent, head, and body. The indicator ~before-string~ lives on the head overlay so its placement is uniform regardless of leading whitespace. Parent and head are non-evaporating so a region edit cannot drop them before the modification-hook cleans up. + +Investigated and initially prototyped by @ImFstAsFckBoi in PR #2. + * [1.1.0] - 2026-04-05 ** Added