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 diff --git a/occult.el b/occult.el index 7791092..518bc53 100644 --- a/occult.el +++ b/occult.el @@ -156,13 +156,35 @@ 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 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) + (skip-chars-forward " \t\n\r\f\v" end) + (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))) @@ -171,26 +193,51 @@ characters from BEG, whichever comes first." (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* ((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))) - ;; Parent overlay - spans the whole fold, provides keymap and ID + (head (make-overlay beg head-split nil t nil)) + (body (make-overlay body-split end nil t nil))) + ;; 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 '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 'evaporate nil) (overlay-put parent 'modification-hooks (list #'occult--modification-hook)) - ;; Body overlay - hides everything after 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 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) @@ -207,6 +254,9 @@ Returns the parent overlay." (when-let ((body (overlay-get ov 'occult-body))) (when (overlay-buffer body) (delete-overlay body))) + (when-let ((head (overlay-get ov 'occult-head))) + (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..7f48730 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 whitespace + +(describe "occult--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))) + + (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 "gives 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" @@ -71,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" @@ -106,7 +128,49 @@ (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 "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 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" + (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 "📎")))) + + (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 @@ -648,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: