Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 66 additions & 21 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions changelog.org
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 62 additions & 12 deletions occult.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading