diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-05 00:31:56 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-05 00:31:56 -0500 |
| commit | 387c8a9ff9828d1b024987d136d216ef12c5103f (patch) | |
| tree | 130257f97e7481f6224cc959878323d94c90a9f7 | |
| parent | 4ca24db97d00bdf8ea1fff49f2c6545ff9d10514 (diff) | |
| download | pearl-387c8a9ff9828d1b024987d136d216ef12c5103f.tar.gz pearl-387c8a9ff9828d1b024987d136d216ef12c5103f.zip | |
feat(render): overlay content glyphs on issue and comment headings
A ticket glyph now sits on every issue heading and a speech balloon on every comment heading. The glyph rides a display overlay on the space after the leading stars, so it lands ahead of the TODO keyword where a literal text prefix can't go (Org wants the keyword first). The choice is keyed on the heading's own drawer, LINEAR-ID for issues and LINEAR-COMMENT-ID for comments, so it's per-heading-type rather than per outline level.
Display-only: the buffer text is untouched, so parse, sync, and merge are unaffected. It reuses the comment-highlight overlay pattern and rides the same redecoration trigger. It's applied synchronously because the glyph is drawer-driven and shouldn't wait on the viewer lookup. Behind pearl-show-glyphs (default on). Overlays go stale after a manual heading edit until the next refresh.
| -rw-r--r-- | README.org | 3 | ||||
| -rw-r--r-- | pearl.el | 77 | ||||
| -rw-r--r-- | tests/test-pearl-glyphs.el | 126 |
3 files changed, 200 insertions, 6 deletions
@@ -376,6 +376,8 @@ 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=. + *** Fidelity to Linear :PROPERTIES: :CUSTOM_ID: fidelity-to-linear @@ -407,6 +409,7 @@ 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-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 | @@ -287,6 +287,18 @@ Set to nil to omit the tag." :type 'boolean :group 'pearl) +(defcustom pearl-show-glyphs t + "When non-nil, overlay a content glyph on the leading stars of Linear headings. +An issue heading (one carrying a `LINEAR-ID') shows a ticket glyph; a comment +heading (`LINEAR-COMMENT-ID') shows a speech glyph. The glyph rides a `display' +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." + :type 'boolean + :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 @@ -7127,6 +7139,50 @@ 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) + (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." + (with-current-buffer (or buffer (current-buffer)) + (save-excursion + (remove-overlays (point-min) (point-max) 'pearl-glyph t) + (when pearl-show-glyphs + (goto-char (point-min)) + ;; Match the leading-star run; point lands on the char after it. A real + ;; heading has a space there -- the `char-after' guard skips body lines + ;; that merely begin with asterisks (e.g. inline `**bold**'). + (while (re-search-forward "^\\*+" nil t) + (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)) + (let ((ov (make-overlay stars-end (1+ stars-end)))) + (overlay-put ov 'pearl-glyph t) + (overlay-put ov 'evaporate t) + (overlay-put ov 'display (concat " " glyph " ")))))))))) + (defun pearl--apply-comment-highlights (viewer-id) "Color every comment heading in the buffer by editability for VIEWER-ID. The viewer's own comments get `pearl-editable-comment' on the heading line; all @@ -7160,11 +7216,18 @@ highlights first." ;;;###autoload (defun pearl-highlight-comments () - "Color comment headings in the current buffer by who can edit them. -The viewer's own comments render green (editable); others render greyed. Runs -after a fetch/refresh, and is safe to invoke by hand." + "Redecorate the current buffer's headings: content glyphs and comment colors. +First lays the heading glyphs (see `pearl--apply-heading-glyphs'), then colors +comment headings by who can edit them -- the viewer's own comments render green +(editable), others render greyed. Runs after a fetch/refresh, and is safe to +invoke by hand." (interactive) (let ((buffer (current-buffer))) + ;; Heading glyphs are drawer-driven, not viewer-driven, so apply them + ;; synchronously here -- they ride the same redecoration trigger as comment + ;; coloring but must show even when the viewer can't be resolved (offline, + ;; missing key). + (pearl--apply-heading-glyphs buffer) ;; Best-effort: highlighting is a display nicety and must never abort the ;; operation that triggered it (e.g. a missing API key errors in the ;; request layer), so a failure to resolve the viewer just skips coloring. @@ -7488,12 +7551,14 @@ reachable from the keyboard. Turns on automatically in any buffer Pearl rendered (one carrying a `#+LINEAR-SOURCE' header); see `pearl--maybe-enable-mode' on `org-mode-hook'. On enable it also collapses the `#+' keyword preamble (see -`pearl--hide-preamble'), so opening a Linear file hides it the same way a fresh -fetch does." +`pearl--hide-preamble') and overlays the heading content glyphs (see +`pearl--apply-heading-glyphs'), so opening a Linear file decorates it the same +way a fresh fetch does." :lighter (:eval (pearl--mode-line-lighter)) :keymap pearl-mode-map (when pearl-mode - (pearl--hide-preamble))) + (pearl--hide-preamble) + (pearl--apply-heading-glyphs))) (defun pearl--buffer-is-pearl-p () "Non-nil when the current buffer is a Pearl-rendered Linear file. diff --git a/tests/test-pearl-glyphs.el b/tests/test-pearl-glyphs.el new file mode 100644 index 0000000..78a4c2d --- /dev/null +++ b/tests/test-pearl-glyphs.el @@ -0,0 +1,126 @@ +;;; test-pearl-glyphs.el --- Tests for heading content glyphs -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for the heading-glyph overlay: a ticket glyph on issue headings +;; (LINEAR-ID) and a speech glyph on comment headings (LINEAR-COMMENT-ID), +;; laid as a `display' overlay on the space after the leading stars so the glyph +;; sits ahead of the TODO keyword without changing buffer text. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) +(require 'cl-lib) + +(defmacro test-pearl-glyphs--in-org (content &rest body) + "Run BODY in an org-mode temp buffer holding CONTENT." + (declare (indent 1)) + `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE")))) + (with-temp-buffer + (insert ,content) + (org-mode) + (goto-char (point-min)) + ,@body))) + +(defun test-pearl-glyphs--overlays () + "Return the list of pearl-glyph overlays in the current buffer." + (cl-remove-if-not (lambda (o) (overlay-get o 'pearl-glyph)) + (overlays-in (point-min) (point-max)))) + +;;; --heading-glyph (pure dispatch) + +(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))) + +(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))) + +(ert-deftest test-pearl-heading-glyph-none () + "A heading with neither id gets no glyph." + (should (null (pearl--heading-glyph nil nil)))) + +;;; --apply-heading-glyphs (overlay placement) + +(ert-deftest test-pearl-apply-glyphs-issue-heading () + "An issue heading gets a ticket-glyph display overlay on the post-stars space." + (test-pearl-glyphs--in-org + "* TODO SE-1: title\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n" + (let ((pearl-show-glyphs t)) + (pearl--apply-heading-glyphs) + (let ((ovs (test-pearl-glyphs--overlays))) + (should (= (length ovs) 1)) + (should (string-match-p (regexp-quote pearl--ticket-glyph) + (overlay-get (car ovs) 'display))))))) + +(ert-deftest test-pearl-apply-glyphs-comment-heading () + "A comment heading gets the speech-glyph display overlay." + (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--apply-heading-glyphs) + (let ((ovs (test-pearl-glyphs--overlays))) + (should (= (length ovs) 1)) + (should (string-match-p (regexp-quote pearl--comment-glyph) + (overlay-get (car ovs) 'display))))))) + +(ert-deftest test-pearl-apply-glyphs-plain-heading-none () + "A heading with no Linear drawer gets no glyph overlay." + (test-pearl-glyphs--in-org + "* TODO just an org heading\n* Pearl Help\n" + (let ((pearl-show-glyphs t)) + (pearl--apply-heading-glyphs) + (should (null (test-pearl-glyphs--overlays)))))) + +(ert-deftest test-pearl-apply-glyphs-idempotent () + "Re-applying does not stack duplicate glyph overlays." + (test-pearl-glyphs--in-org + "* TODO SE-1: title\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n" + (let ((pearl-show-glyphs t)) + (pearl--apply-heading-glyphs) + (pearl--apply-heading-glyphs) + (should (= (length (test-pearl-glyphs--overlays)) 1))))) + +(ert-deftest test-pearl-apply-glyphs-off-clears () + "With `pearl-show-glyphs' nil, applying lays no overlay and clears any prior." + (test-pearl-glyphs--in-org + "* TODO SE-1: title\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n" + (let ((pearl-show-glyphs t)) + (pearl--apply-heading-glyphs)) + (let ((pearl-show-glyphs nil)) + (pearl--apply-heading-glyphs) + (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." + (test-pearl-glyphs--in-org + (concat "* TODO SE-1: title\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n" + "** 💬 Comments\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)))))) + +(provide 'test-pearl-glyphs) +;;; test-pearl-glyphs.el ends here |
