diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 07:38:46 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 07:38:46 -0500 |
| commit | adee44df124e8983084737c50e9c767edd70aea8 (patch) | |
| tree | a16536b472f309704bafe4edc389d343d92556d7 | |
| parent | ecfc66294e57788df715c98297ef425fd9dbd9ef (diff) | |
| download | pearl-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.org | 7 | ||||
| -rw-r--r-- | pearl.el | 93 | ||||
| -rw-r--r-- | tests/test-pearl-glyphs.el | 67 |
3 files changed, 123 insertions, 44 deletions
@@ -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 | @@ -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 |
