aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 07:38:46 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 07:38:46 -0500
commitadee44df124e8983084737c50e9c767edd70aea8 (patch)
treea16536b472f309704bafe4edc389d343d92556d7
parentecfc66294e57788df715c98297ef425fd9dbd9ef (diff)
downloadpearl-adee44df124e8983084737c50e9c767edd70aea8.tar.gz
pearl-adee44df124e8983084737c50e9c767edd70aea8.zip
feat(render): make heading glyphs configurable
The Comments container heading rendered bare. It carries no Linear id, so the id-keyed overlay never glyphed it, while each comment already showed a balloon. I gave the container its own glyph and split them so the thread and its items read as a hierarchy: a filled balloon (๐Ÿ’ฌ) on the Comments header, an outline bubble (๐Ÿ—จ๏ธ) on each comment, the ticket (๐ŸŽซ) unchanged on issues. I promoted the two glyph constants to defcustoms and added pearl-comments-header-glyph, so any glyph can be changed or dropped to empty while pearl-show-glyphs stays the master switch. The header glyph rides the same display overlay as the others, so the buffer text stays a bare Comments and sync and merge are untouched. The Comments header is now found by one glyph-agnostic regexp, replacing two copies of the old ๐Ÿ’ฌ-only matcher.
-rw-r--r--README.org7
-rw-r--r--pearl.el93
-rw-r--r--tests/test-pearl-glyphs.el67
3 files changed, 123 insertions, 44 deletions
diff --git a/README.org b/README.org
index 8f55c74..d9bd19b 100644
--- a/README.org
+++ b/README.org
@@ -383,7 +383,7 @@ A fetched Pearl file is intentionally readable. The header records the source, r
The =LINEAR-*= properties store both ids and display names so common commands do not need a network lookup just to render. The hash properties are provenance for conflict-aware sync.
-Pearl overlays a glyph on each heading's leading stars: a ticket on an issue, a speech balloon on a comment. The glyph is display-only and keyed on the heading's drawer, so it never enters the buffer text or interferes with sync. Turn it off with =pearl-show-glyphs=.
+Pearl overlays a glyph on each heading's leading stars: a ticket (๐ŸŽซ) on an issue, a left speech bubble (๐Ÿ—จ๏ธ) on each comment, and a speech balloon (๐Ÿ’ฌ) on the Comments container heading. The glyphs are display-only โ€” they never enter the buffer text or interfere with sync. Turn them all off with =pearl-show-glyphs=, or change any one with =pearl-ticket-glyph=, =pearl-comment-glyph=, and =pearl-comments-header-glyph= (set a glyph to the empty string to drop just that one).
*** Fidelity to Linear
:PROPERTIES:
@@ -416,7 +416,10 @@ Most users only need an API key and an output path. The rest are knobs for teams
| =pearl-request-timeout= | Synchronous request timeout in seconds |
| =pearl-fold-after-update= | Re-fold the active page after fetch/refresh |
| =pearl-title-case-headings= | Opt-in smart title case for issue titles (off) |
-| =pearl-show-glyphs= | Glyph on issue/comment headings (on) |
+| =pearl-show-glyphs= | Master switch for heading glyphs (on) |
+| =pearl-ticket-glyph= | Glyph on issue headings (๐ŸŽซ) |
+| =pearl-comment-glyph= | Glyph on each comment heading (๐Ÿ—จ๏ธ) |
+| =pearl-comments-header-glyph= | Glyph on the Comments container heading (๐Ÿ’ฌ) |
| =pearl-surface-buffer= | Show the active buffer after a command updates it |
| =pearl-surface-select-window= | Move focus to the surfaced buffer |
| =pearl-compose-window-side= | Side the compose/conflict buffer opens from |
diff --git a/pearl.el b/pearl.el
index 880e7ed..d2941aa 100644
--- a/pearl.el
+++ b/pearl.el
@@ -295,10 +295,35 @@ overlay on the space after the stars, so it sits ahead of the TODO keyword --
where a literal text prefix can't go, since Org needs the keyword first.
Display-only: the buffer text is untouched, so parsing, sync, and merge keep
working. The overlay goes stale after a manual heading edit until the next
-fetch/refresh. Set to nil to omit the glyphs."
+fetch/refresh. Set to nil to omit the glyphs. The glyph strings themselves
+are `pearl-ticket-glyph', `pearl-comment-glyph', and
+`pearl-comments-header-glyph'."
:type 'boolean
:group 'pearl)
+(defcustom pearl-ticket-glyph "๐ŸŽซ"
+ "Glyph overlaid on issue headings (those carrying a `LINEAR-ID').
+Shown only when `pearl-show-glyphs' is non-nil. Set to the empty string to
+omit the issue glyph while keeping the others."
+ :type 'string
+ :group 'pearl)
+
+(defcustom pearl-comment-glyph "๐Ÿ—จ๏ธ"
+ "Glyph overlaid on each comment heading (those carrying a `LINEAR-COMMENT-ID').
+Shown only when `pearl-show-glyphs' is non-nil. Set to the empty string to
+omit the per-comment glyph."
+ :type 'string
+ :group 'pearl)
+
+(defcustom pearl-comments-header-glyph "๐Ÿ’ฌ"
+ "Glyph overlaid on the `Comments' container heading of an issue's thread.
+The container carries no Linear id, so it is matched by heading text rather than
+a drawer id. Like the other glyphs it rides a `display' overlay -- the buffer
+text stays a bare `Comments', so parsing, sync, and merge are untouched. Shown
+only when `pearl-show-glyphs' is non-nil. Set to the empty string to omit it."
+ :type 'string
+ :group 'pearl)
+
(defcustom pearl-assignee-tag-short nil
"When non-nil, shorten the assignee @-tag to the first name only.
The default slugs Linear's full @-mention handle (e.g. `first.last' becomes
@@ -2784,6 +2809,15 @@ newest, like an email thread, and a new comment appends at the bottom."
(const :tag "Oldest first (chronological)" oldest-first))
:group 'pearl)
+(defconst pearl--comments-heading-regexp
+ "^\\*+ \\(?:[^ \t\n]+ \\)?Comments\\(?:[ \t].*\\)?$"
+ "Regexp matching an issue's `Comments' container heading.
+Glyph-agnostic: the optional leading token tolerates any header glyph a buffer
+was rendered with -- the current overlay form leaves the text a bare
+`Comments', but older buffers carry a literal `๐Ÿ’ฌ ' prefix (the 2026-05 layout)
+or a trailing `Comments ๐Ÿ’ฌ N/N' (the pre-2026-05 layout). The optional tail
+covers the ` N/M[+]' count marker.")
+
(defun pearl--format-comments (comments &optional count-info level)
"Format COMMENTS (a list of normalized comment plists) as a Comments subtree.
LEVEL is the parent issue's heading depth (default 2): the `Comments' heading
@@ -3586,12 +3620,7 @@ subtree at the end of the issue when it does not exist yet."
(let* ((issue-end (save-excursion (org-end-of-subtree t t) (point)))
(comments-pos
(save-excursion
- ;; Match the new no-emoji form, the prior `๐Ÿ’ฌ ' leading-glyph
- ;; layout (rendered between 2026-05-26 and the de-emoji
- ;; revert), and the pre-2026-05 trailing-glyph layout
- ;; (`Comments ๐Ÿ’ฌ N/N'). Buffers rendered in any of the three
- ;; states locate their Comments heading.
- (when (re-search-forward "^\\*+ \\(?:๐Ÿ’ฌ \\)?Comments\\(?:[ \t].*\\)?$" issue-end t)
+ (when (re-search-forward pearl--comments-heading-regexp issue-end t)
(match-beginning 0)))))
(if comments-pos
(progn
@@ -3675,8 +3704,7 @@ survive."
(issue-end (save-excursion (org-end-of-subtree t t) (point)))
(comments-pos
(save-excursion
- (when (re-search-forward
- "^\\*+ \\(?:๐Ÿ’ฌ \\)?Comments\\(?:[ \t].*\\)?$" issue-end t)
+ (when (re-search-forward pearl--comments-heading-regexp issue-end t)
(match-beginning 0))))
(n (length comments))
(count-info (and (> n 0) (list :shown n :total n :overflow nil)))
@@ -7537,31 +7565,29 @@ unlike `issueDelete' this is not a recoverable soft delete."
(list :success (eq t (cdr (assoc 'success payload)))))))
(lambda (_error _response _data) (funcall callback '(:success nil))))))
-(defconst pearl--ticket-glyph "๐ŸŽซ"
- "Glyph overlaid on issue headings (those carrying a `LINEAR-ID').")
-
-(defconst pearl--comment-glyph "๐Ÿ’ฌ"
- "Glyph overlaid on comment headings (those carrying a `LINEAR-COMMENT-ID').")
-
-(defun pearl--heading-glyph (comment-id issue-id)
- "Return the content glyph for a heading, or nil when it carries no Linear id.
-COMMENT-ID wins over ISSUE-ID so a comment heading shows the speech glyph; the
-two ids are disjoint in practice, but the order makes the precedence explicit."
- (cond (comment-id pearl--comment-glyph)
- (issue-id pearl--ticket-glyph)
+(defun pearl--heading-glyph (comment-id issue-id &optional header-p)
+ "Return the content glyph for a heading, or nil when it carries none.
+COMMENT-ID selects `pearl-comment-glyph' and outranks ISSUE-ID, which selects
+`pearl-ticket-glyph'. With no id, HEADER-P (the `Comments' container heading)
+selects `pearl-comments-header-glyph'. The id checks come first so a comment or
+issue heading never falls through to the header glyph."
+ (cond (comment-id pearl-comment-glyph)
+ (issue-id pearl-ticket-glyph)
+ (header-p pearl-comments-header-glyph)
(t nil)))
(defun pearl--apply-heading-glyphs (&optional buffer)
"Overlay a content glyph on the leading stars of Linear headings in BUFFER.
-An issue heading (`LINEAR-ID') gets `pearl--ticket-glyph'; a comment heading
-(`LINEAR-COMMENT-ID') gets `pearl--comment-glyph', chosen per heading from its
-drawer rather than per outline level. The glyph rides a `display' overlay on
-the space after the leading stars, so it renders ahead of the TODO keyword and
-leaves the stars (and any `org-superstar' / `org-modern' bullet composed from
-them) untouched. Buffer text is unchanged, so parsing and the merge rewrites
-keep working. Idempotent: clears prior glyph overlays first; with
-`pearl-show-glyphs' nil it just clears. Overlays go stale after a manual
-heading edit until the next fetch/refresh re-applies them."
+An issue heading (`LINEAR-ID') gets `pearl-ticket-glyph'; a comment heading
+(`LINEAR-COMMENT-ID') gets `pearl-comment-glyph'; the `Comments' container
+heading (no id, matched by `pearl--comments-heading-regexp') gets
+`pearl-comments-header-glyph'. The glyph rides a `display' overlay on the space
+after the leading stars, so it renders ahead of the TODO keyword and leaves the
+stars (and any `org-superstar' / `org-modern' bullet composed from them)
+untouched. Buffer text is unchanged, so parsing and the merge rewrites keep
+working. A glyph set to the empty string is skipped. Idempotent: clears prior
+glyph overlays first; with `pearl-show-glyphs' nil it just clears. Overlays go
+stale after a manual heading edit until the next fetch/refresh re-applies them."
(with-current-buffer (or buffer (current-buffer))
(save-excursion
(remove-overlays (point-min) (point-max) 'pearl-glyph t)
@@ -7574,8 +7600,11 @@ heading edit until the next fetch/refresh re-applies them."
(let ((stars-end (point))
(glyph (pearl--heading-glyph
(org-entry-get nil "LINEAR-COMMENT-ID")
- (org-entry-get nil "LINEAR-ID"))))
- (when (and glyph (eq (char-after) ?\s))
+ (org-entry-get nil "LINEAR-ID")
+ (save-excursion
+ (beginning-of-line)
+ (looking-at-p pearl--comments-heading-regexp)))))
+ (when (and glyph (not (string-empty-p glyph)) (eq (char-after) ?\s))
(let ((ov (make-overlay stars-end (1+ stars-end))))
(overlay-put ov 'pearl-glyph t)
(overlay-put ov 'evaporate t)
diff --git a/tests/test-pearl-glyphs.el b/tests/test-pearl-glyphs.el
index 78a4c2d..c2e9e7f 100644
--- a/tests/test-pearl-glyphs.el
+++ b/tests/test-pearl-glyphs.el
@@ -48,14 +48,23 @@
(ert-deftest test-pearl-heading-glyph-comment-wins ()
"A comment id selects the comment glyph even when an issue id is also present."
- (should (equal (pearl--heading-glyph "c1" "i1") pearl--comment-glyph)))
+ (should (equal (pearl--heading-glyph "c1" "i1") pearl-comment-glyph)))
(ert-deftest test-pearl-heading-glyph-issue ()
"An issue id with no comment id selects the ticket glyph."
- (should (equal (pearl--heading-glyph nil "i1") pearl--ticket-glyph)))
+ (should (equal (pearl--heading-glyph nil "i1") pearl-ticket-glyph)))
+
+(ert-deftest test-pearl-heading-glyph-header ()
+ "No id but the header flag selects the Comments-header glyph."
+ (should (equal (pearl--heading-glyph nil nil t) pearl-comments-header-glyph)))
+
+(ert-deftest test-pearl-heading-glyph-id-wins-over-header ()
+ "A Linear id outranks the header flag."
+ (should (equal (pearl--heading-glyph "c1" nil t) pearl-comment-glyph))
+ (should (equal (pearl--heading-glyph nil "i1" t) pearl-ticket-glyph)))
(ert-deftest test-pearl-heading-glyph-none ()
- "A heading with neither id gets no glyph."
+ "A heading with no id and no header flag gets no glyph."
(should (null (pearl--heading-glyph nil nil))))
;;; --apply-heading-glyphs (overlay placement)
@@ -68,7 +77,7 @@
(pearl--apply-heading-glyphs)
(let ((ovs (test-pearl-glyphs--overlays)))
(should (= (length ovs) 1))
- (should (string-match-p (regexp-quote pearl--ticket-glyph)
+ (should (string-match-p (regexp-quote pearl-ticket-glyph)
(overlay-get (car ovs) 'display)))))))
(ert-deftest test-pearl-apply-glyphs-comment-heading ()
@@ -79,9 +88,30 @@
(pearl--apply-heading-glyphs)
(let ((ovs (test-pearl-glyphs--overlays)))
(should (= (length ovs) 1))
- (should (string-match-p (regexp-quote pearl--comment-glyph)
+ (should (string-match-p (regexp-quote pearl-comment-glyph)
(overlay-get (car ovs) 'display)))))))
+(ert-deftest test-pearl-apply-glyphs-comments-header ()
+ "The Comments container heading (no Linear id) gets the header-glyph overlay."
+ (test-pearl-glyphs--in-org
+ "*** Comments 2/2\n"
+ (let ((pearl-show-glyphs t)
+ (pearl-comments-header-glyph "๐Ÿ’ฌ"))
+ (pearl--apply-heading-glyphs)
+ (let ((ovs (test-pearl-glyphs--overlays)))
+ (should (= (length ovs) 1))
+ (should (string-match-p (regexp-quote "๐Ÿ’ฌ")
+ (overlay-get (car ovs) 'display)))))))
+
+(ert-deftest test-pearl-apply-glyphs-empty-comment-glyph-none ()
+ "An empty `pearl-comment-glyph' lays no overlay on a comment heading."
+ (test-pearl-glyphs--in-org
+ "**** Craig โ€” 2026-05-23\n:PROPERTIES:\n:LINEAR-COMMENT-ID: c1\n:END:\n"
+ (let ((pearl-show-glyphs t)
+ (pearl-comment-glyph ""))
+ (pearl--apply-heading-glyphs)
+ (should (null (test-pearl-glyphs--overlays))))))
+
(ert-deftest test-pearl-apply-glyphs-plain-heading-none ()
"A heading with no Linear drawer gets no glyph overlay."
(test-pearl-glyphs--in-org
@@ -110,17 +140,34 @@
(should (null (test-pearl-glyphs--overlays))))))
(ert-deftest test-pearl-apply-glyphs-mixed-buffer ()
- "Issue and comment headings each get their own glyph; the count matches."
+ "Issue, Comments container, and comment headings each get their own glyph."
(test-pearl-glyphs--in-org
(concat "* TODO SE-1: title\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n"
- "** ๐Ÿ’ฌ Comments\n"
+ "** Comments 1/1\n"
"*** Craig โ€” 2026-05-23\n:PROPERTIES:\n:LINEAR-COMMENT-ID: c1\n:END:\n")
(let ((pearl-show-glyphs t))
(pearl--apply-heading-glyphs)
(let ((ovs (test-pearl-glyphs--overlays)))
- ;; Two glyphed headings: the issue and the one real comment. The
- ;; "Comments" container carries no Linear id, so it gets nothing.
- (should (= (length ovs) 2))))))
+ ;; Three glyphed headings: the issue (ticket), the Comments container
+ ;; (header glyph, matched by regex since it carries no id), and the one
+ ;; real comment (speech).
+ (should (= (length ovs) 3))))))
+
+;;; --comments-heading-regexp (glyph-agnostic Comments-header matcher)
+
+(ert-deftest test-pearl-comments-heading-regexp-tolerates-any-glyph ()
+ "The Comments-heading regexp matches bare, glyphed, counted, and legacy forms."
+ (dolist (h '("*** Comments"
+ "*** Comments 2/2"
+ "*** Comments 2/2+"
+ "*** ๐Ÿ’ฌ Comments"
+ "*** ๐Ÿ’ฌ Comments 2/2"
+ "*** ๐Ÿ—จ๏ธ Comments 1/1"
+ "**** ๐Ÿงต Comments"
+ "*** Comments ๐Ÿ’ฌ 1/1")) ; pre-2026-05 trailing-glyph layout
+ (should (string-match-p pearl--comments-heading-regexp h)))
+ ;; Body prose that merely mentions Comments must not match.
+ (should-not (string-match-p pearl--comments-heading-regexp "Comments are nice")))
(provide 'test-pearl-glyphs)
;;; test-pearl-glyphs.el ends here