branch: elpa/adoc-mode
commit 696af9920c52c44240ef2e74535e2844fe22c5c4
Author: Bozhidar Batsov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    Add heading navigation commands
    
    Model next/previous heading, forward/backward at the same level, and
    up-to-parent on markdown-mode and org-mode. The commands understand both
    one-line and two-line titles and skip headings hidden by folding.
    
    Promote/demote move from C-c C-p / C-c C-d to M-left / M-right (org-style)
    to free up C-c C-p for previous-heading. Enable outline-minor-mode by
    default and widen outline-regexp to cover level-5 (======) titles.
---
 CHANGELOG.md           |   2 +
 README.adoc            |   5 +-
 adoc-mode.el           | 195 ++++++++++++++++++++++++++++++++++++++++++++-----
 test/adoc-mode-test.el | 123 +++++++++++++++++++++++++++++++
 4 files changed, 306 insertions(+), 19 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index db86b5843b..cd1d2a02bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 ### New features
 
+- Add heading navigation commands modelled on `markdown-mode` and `org-mode`: 
`adoc-next-visible-heading` (`C-c C-n`), `adoc-previous-visible-heading` (`C-c 
C-p`), `adoc-forward-same-level` (`C-c C-f`), `adoc-backward-same-level` (`C-c 
C-b`), and `adoc-up-heading` (`C-c C-u`). They understand both one-line (`== 
Title`) and two-line (underlined) titles and skip headings hidden by folding. 
`outline-minor-mode` is now enabled by default so the folding commands are 
available out of the box.
 - New `adoc-title-scaling` defcustom (default `t`) and 
`adoc-title-scaling-values` list let users disable the variable-height title 
faces or pick their own scale factors. Set the boolean to nil for 
uniformly-sized headings, or customise the list to control the level-0..5 
heights. Mirrors `markdown-header-scaling`.
 - New `adoc-blockquote-face` for the body of `[quote]` and `[verse]` delimited 
blocks (inherits `font-lock-doc-face`); previously the body was left 
unfontified.
 - New `adoc-highlight-face` for `#text#` / `##text##` highlighted spans 
(inherits the standard `highlight` face); previously these reused 
`adoc-gen-face`.
@@ -14,6 +15,7 @@
 
 ### Changes
 
+- Title promotion and demotion move to `M-left` and `M-right` (org-style), 
freeing up `C-c C-p` and `C-c C-d` for the new heading-navigation commands. 
Previously `adoc-promote` lived on `C-c C-p` and `adoc-demote` on `C-c C-d`.
 - `adoc-gen-face`, `adoc-verbatim-face`, `adoc-secondary-text-face`, and 
`adoc-replacement-face` now inherit from `font-lock-*` faces instead of 
hardcoding literal colours. Themes that style the font-lock palette will now 
style AsciiDoc buffers consistently. Users who relied on the old defaults can 
restore them via `M-x customize-face`.
 - Simplify `adoc-meta-face` to `(:inherit shadow :slant normal :weight 
normal)` instead of overriding eleven attributes including `:family 
"Monospace"`. AsciiDoc markup characters now respect the user's font choices 
and theme `shadow` colour rather than being forced into a monospace family with 
hardcoded grays.
 - Drop the dated 3D button decoration (`:box (:style released-button)`) and 
hardcoded hex colours from `adoc-command-face` and 
`adoc-complex-replacement-face`; inherit from `font-lock-builtin-face` instead.
diff --git a/README.adoc b/README.adoc
index a3219cca09..3514377502 100644
--- a/README.adoc
+++ b/README.adoc
@@ -39,10 +39,11 @@ Here are some of the main features of `adoc-mode`:
 - font-lock support for Asciidoctor inline macros (`kbd:[]`, `btn:[]`, 
`menu:[]`, etc.)
 - inline image preview with right-click context menus and remote image support
 - ~50 tempo templates for inserting AsciiDoc markup (formatting, titles, 
blocks, lists, macros, etc.)
-- title management: promote / demote, toggle between one-line and two-line 
styles, adjust underline length
+- heading navigation modelled on `markdown-mode` / `org-mode`: next / previous 
heading (`C-c C-n` / `C-c C-p`), forward / backward at the same level (`C-c 
C-f` / `C-c C-b`), and up to the parent heading (`C-c C-u`)
+- title management: promote / demote (`M-left` / `M-right`), toggle between 
one-line and two-line styles, adjust underline length
 - navigate to anchors (`C-c C-a`) and follow URLs, `include::` macros, and 
xrefs at point (`C-c C-o` / `M-.`)
 - nested `imenu` index with hierarchical heading structure
-- support for `outline-mode` (one-line title style only)
+- `outline-minor-mode` enabled out of the box for folding (one-line title 
style only)
 - integration with `flyspell-mode` (skips non-prose regions), comment commands 
(`M-;`), and compilation mode
 
 === Demo
diff --git a/adoc-mode.el b/adoc-mode.el
index a74eb07755..e4036b9d26 100644
--- a/adoc-mode.el
+++ b/adoc-mode.el
@@ -3090,6 +3090,168 @@ and title's text are not preserved, afterwards its 
always one space."
         (forward-line -1))
       (move-to-column saved-col))))
 
+;;;; Heading navigation
+
+(defun adoc--re-all-titles ()
+  "Return a regexp matching any section title (one-line or two-line)."
+  (let* ((two-line-count (length adoc-two-line-title-del))
+         (core
+          (mapconcat
+           (lambda (level)
+             (if (< level two-line-count)
+                 (concat
+                  (adoc-re-one-line-title level)
+                  "\\|"
+                  (adoc-re-two-line-title (nth level adoc-two-line-title-del)))
+               (adoc-re-one-line-title level)))
+           (number-sequence 0 adoc-title-max-level)
+           "\\)\\|\\(?:")))
+    (concat "\\(?:" core "\\)")))
+
+(defun adoc--title-bounds ()
+  "Return (LEVEL START END) when point is on a section title, else nil.
+START and END delimit the whole title (both lines for a two-line
+title); point need not be on the first line."
+  (let ((descriptor (adoc-title-descriptor)))
+    (when descriptor
+      (list (nth 2 descriptor) (nth 4 descriptor) (nth 5 descriptor)))))
+
+(defun adoc--forward-heading ()
+  "Move point to the start of the next section title.
+Return the title's level, or nil if there is no following title.
+Point is left unchanged when nil is returned."
+  (let ((bounds (adoc--title-bounds))
+        (re (adoc--re-all-titles))
+        (orig (point))
+        level)
+    ;; If we're on a title, step past it so we don't match it again.
+    (when bounds (goto-char (nth 2 bounds)))
+    (while (and (not level) (re-search-forward re nil t))
+      (goto-char (match-beginning 0))
+      (let ((descriptor (adoc-title-descriptor)))
+        (if descriptor
+            (setq level (nth 2 descriptor))
+          ;; A regexp match the descriptor rejects; skip the line and retry.
+          (forward-line 1))))
+    (unless level (goto-char orig))
+    level))
+
+(defun adoc--backward-heading ()
+  "Move point to the start of the previous section title.
+Return the title's level, or nil if there is no preceding title.
+Point is left unchanged when nil is returned."
+  (let ((bounds (adoc--title-bounds))
+        (re (adoc--re-all-titles))
+        (orig (point))
+        level)
+    (when bounds (goto-char (nth 1 bounds)))
+    (while (and (not level) (re-search-backward re nil t))
+      (goto-char (match-beginning 0))
+      (let ((descriptor (adoc-title-descriptor)))
+        (when descriptor (setq level (nth 2 descriptor)))))
+    (unless level (goto-char orig))
+    level))
+
+(defun adoc--back-to-heading ()
+  "Move to the title heading the section point is in, return its level.
+Signal a `user-error' when point is before the first section title."
+  (let ((bounds (adoc--title-bounds)))
+    (cond
+     (bounds (goto-char (nth 1 bounds)) (car bounds))
+     (t (let ((level (adoc--backward-heading)))
+          (unless level (user-error "Before first section title"))
+          level)))))
+
+(defun adoc-next-visible-heading (arg)
+  "Move forward to the ARGth next visible section title.
+With a negative ARG, move backward (see
+`adoc-previous-visible-heading')."
+  (interactive "p")
+  (if (< arg 0)
+      (adoc-previous-visible-heading (- arg))
+    (let ((orig (point))
+          level)
+      (dotimes (_ arg)
+        (while (and (setq level (adoc--forward-heading))
+                    (invisible-p (point))))
+        (unless level
+          (goto-char orig)
+          (user-error "No following section title"))))))
+
+(defun adoc-previous-visible-heading (arg)
+  "Move backward to the ARGth previous visible section title.
+With a negative ARG, move forward (see
+`adoc-next-visible-heading')."
+  (interactive "p")
+  (if (< arg 0)
+      (adoc-next-visible-heading (- arg))
+    (let ((orig (point))
+          level)
+      (dotimes (_ arg)
+        (while (and (setq level (adoc--backward-heading))
+                    (invisible-p (point))))
+        (unless level
+          (goto-char orig)
+          (user-error "No preceding section title"))))))
+
+(defun adoc-forward-same-level (arg)
+  "Move forward to the ARGth next visible title at the same level.
+A title at a shallower (more important) level or the end of the
+buffer stops the search.  With a negative ARG, move backward."
+  (interactive "p")
+  (if (< arg 0)
+      (adoc-backward-same-level (- arg))
+    (let ((orig (point))
+          (level (adoc--back-to-heading)))
+      (dotimes (_ arg)
+        (let (target stop found)
+          (while (and (not target) (not stop)
+                      (setq found (adoc--forward-heading)))
+            (cond
+             ((< found level) (setq stop t))
+             ((and (= found level) (not (invisible-p (point))))
+              (setq target (point)))))
+          (unless target
+            (goto-char orig)
+            (user-error "No following same-level section title")))))))
+
+(defun adoc-backward-same-level (arg)
+  "Move backward to the ARGth previous visible title at the same level.
+A title at a shallower (more important) level or the start of the
+buffer stops the search.  With a negative ARG, move forward."
+  (interactive "p")
+  (if (< arg 0)
+      (adoc-forward-same-level (- arg))
+    (let ((orig (point))
+          (level (adoc--back-to-heading)))
+      (dotimes (_ arg)
+        (let (target stop found)
+          (while (and (not target) (not stop)
+                      (setq found (adoc--backward-heading)))
+            (cond
+             ((< found level) (setq stop t))
+             ((and (= found level) (not (invisible-p (point))))
+              (setq target (point)))))
+          (unless target
+            (goto-char orig)
+            (user-error "No preceding same-level section title")))))))
+
+(defun adoc-up-heading (arg)
+  "Move to the visible parent title, ARG levels up."
+  (interactive "p")
+  (let ((orig (point))
+        (level (adoc--back-to-heading)))
+    (dotimes (_ arg)
+      (let (target found)
+        (while (and (not target)
+                    (setq found (adoc--backward-heading)))
+          (when (and (< found level) (not (invisible-p (point))))
+            (setq target (point)
+                  level found)))
+        (unless target
+          (goto-char orig)
+          (user-error "No parent section title"))))))
+
 (defvar sgml-char-names)
 
 (defun adoc-make-unichar-alist ()
@@ -3173,20 +3335,7 @@ LOCAL-ATTRIBUTE-FACE-ALIST before it is looked up in
 
 (defun adoc-imenu-create-index ()
   (let* ((index-alist)
-         (two-line-count (length adoc-two-line-title-del))
-         (re-all-titles-core
-          (mapconcat
-           (lambda (level)
-             (if (< level two-line-count)
-                 (concat
-                  (adoc-re-one-line-title level)
-                  "\\|"
-                  (adoc-re-two-line-title (nth level adoc-two-line-title-del)))
-               (adoc-re-one-line-title level)))
-           (number-sequence 0 adoc-title-max-level)
-           "\\)\\|\\(?:"))
-         (re-all-titles
-          (concat "\\(?:" re-all-titles-core "\\)")))
+         (re-all-titles (adoc--re-all-titles)))
     (save-restriction
       (widen)
       (goto-char (point-min))
@@ -3283,14 +3432,25 @@ ITEMS is a list of (name pos . level)."
 
 (defvar adoc-mode-map
   (let ((map (make-sparse-keymap)))
-    (define-key map "\C-c\C-d" 'adoc-demote)
-    (define-key map "\C-c\C-p" 'adoc-promote)
+    (define-key map "\C-c\C-n" 'adoc-next-visible-heading)
+    (define-key map "\C-c\C-p" 'adoc-previous-visible-heading)
+    (define-key map "\C-c\C-f" 'adoc-forward-same-level)
+    (define-key map "\C-c\C-b" 'adoc-backward-same-level)
+    (define-key map "\C-c\C-u" 'adoc-up-heading)
+    (define-key map (kbd "M-<left>") 'adoc-promote)
+    (define-key map (kbd "M-<right>") 'adoc-demote)
     (define-key map "\C-c\C-t" 'adoc-toggle-title-type)
     (define-key map "\C-c\C-a" 'adoc-goto-ref-label)
     (define-key map "\C-c\C-o" 'adoc-follow-thing-at-point)
     (define-key map (kbd "M-.") 'adoc-follow-thing-at-point)
     (easy-menu-define adoc-mode-menu map "Menu for adoc mode"
       `("AsciiDoc"
+        ["Next heading" adoc-next-visible-heading]
+        ["Previous heading" adoc-previous-visible-heading]
+        ["Forward (same level)" adoc-forward-same-level]
+        ["Backward (same level)" adoc-backward-same-level]
+        ["Up to parent heading" adoc-up-heading]
+        "---"
         ["Promote" adoc-promote]
         ["Demote" adoc-demote]
         ["Toggle title type" adoc-toggle-title-type]
@@ -3498,11 +3658,12 @@ Turning on Adoc mode runs the normal hook 
`adoc-mode-hook'."
   (add-hook 'font-lock-extend-region-functions #'adoc-font-lock-extend-region 
nil t)
 
   ;; outline mode
-  (setq-local outline-regexp "=\\{1,5\\}[ \t]+[^ \t\n]")
+  (setq-local outline-regexp "=\\{1,6\\}[ \t]+[^ \t\n]")
   (setq-local outline-level (lambda ()
                               (save-excursion
                                 (skip-chars-forward "=")
                                 (current-column))))
+  (outline-minor-mode 1)
 
   ;; fill
   (add-hook 'fill-nobreak-predicate #'adoc-fill-nobreak-p nil t)
diff --git a/test/adoc-mode-test.el b/test/adoc-mode-test.el
index 9d9df92800..f2e923ebe9 100644
--- a/test/adoc-mode-test.el
+++ b/test/adoc-mode-test.el
@@ -890,6 +890,129 @@ on top of the `<mark>' default)."
   (adoctest-trans "lorem!\n========!" "lorem\n=====" '(adoc-adjust-title-del))
   (adoctest-trans "lorem!\n=====!" "lorem\n=====" '(adoc-adjust-title-del)))
 
+;;;; Heading navigation
+
+(defun adoctest-insert-nav-doc ()
+  "Insert a small multi-level document used by the navigation tests."
+  (insert "= Doc Title\n\n"
+          "intro\n\n"
+          "== Section A\n\n"
+          "text a\n\n"
+          "=== Sub A1\n\n"
+          "text\n\n"
+          "=== Sub A2\n\n"
+          "more\n\n"
+          "== Section B\n\n"
+          "end\n"))
+
+(defun adoctest-goto-heading (text)
+  "Move point to the beginning of the line containing TEXT."
+  (goto-char (point-min))
+  (search-forward text)
+  (beginning-of-line))
+
+(ert-deftest adoctest-test-next-visible-heading ()
+  (with-temp-buffer
+    (adoc-mode)
+    (adoctest-insert-nav-doc)
+    (goto-char (point-min))
+    (adoc-next-visible-heading 1)
+    (should (looking-at-p "== Section A$"))
+    (adoc-next-visible-heading 1)
+    (should (looking-at-p "=== Sub A1$"))
+    ;; a count argument moves several headings at once
+    (adoc-next-visible-heading 2)
+    (should (looking-at-p "== Section B$"))
+    ;; nothing past the last heading: point stays put, signals a user-error
+    (let ((pos (point)))
+      (should-error (adoc-next-visible-heading 1) :type 'user-error)
+      (should (= (point) pos)))))
+
+(ert-deftest adoctest-test-previous-visible-heading ()
+  (with-temp-buffer
+    (adoc-mode)
+    (adoctest-insert-nav-doc)
+    (adoctest-goto-heading "Section B")
+    (adoc-previous-visible-heading 1)
+    (should (looking-at-p "=== Sub A2$"))
+    (adoc-previous-visible-heading 2)
+    (should (looking-at-p "== Section A$"))
+    ;; from within a section body it climbs to that section's heading
+    (adoctest-goto-heading "text a")
+    (adoc-previous-visible-heading 1)
+    (should (looking-at-p "== Section A$"))
+    ;; a negative argument moves forward
+    (adoc-previous-visible-heading -1)
+    (should (looking-at-p "=== Sub A1$"))))
+
+(ert-deftest adoctest-test-forward-same-level ()
+  (with-temp-buffer
+    (adoc-mode)
+    (adoctest-insert-nav-doc)
+    ;; level-1 -> level-1, skipping the level-2 subsections in between
+    (adoctest-goto-heading "Section A")
+    (adoc-forward-same-level 1)
+    (should (looking-at-p "== Section B$"))
+    ;; level-2 -> level-2
+    (adoctest-goto-heading "Sub A1")
+    (adoc-forward-same-level 1)
+    (should (looking-at-p "=== Sub A2$"))
+    ;; a shallower heading stops the search; point is restored
+    (adoctest-goto-heading "Sub A2")
+    (let ((pos (point)))
+      (should-error (adoc-forward-same-level 1) :type 'user-error)
+      (should (= (point) pos)))))
+
+(ert-deftest adoctest-test-backward-same-level ()
+  (with-temp-buffer
+    (adoc-mode)
+    (adoctest-insert-nav-doc)
+    (adoctest-goto-heading "Section B")
+    (adoc-backward-same-level 1)
+    (should (looking-at-p "== Section A$"))
+    (adoctest-goto-heading "Sub A2")
+    (adoc-backward-same-level 1)
+    (should (looking-at-p "=== Sub A1$"))
+    ;; a shallower heading stops the search; point is restored
+    (adoctest-goto-heading "Sub A1")
+    (let ((pos (point)))
+      (should-error (adoc-backward-same-level 1) :type 'user-error)
+      (should (= (point) pos)))))
+
+(ert-deftest adoctest-test-up-heading ()
+  (with-temp-buffer
+    (adoc-mode)
+    (adoctest-insert-nav-doc)
+    (adoctest-goto-heading "Sub A1")
+    (adoc-up-heading 1)
+    (should (looking-at-p "== Section A$"))
+    ;; two levels up from a level-2 heading reaches the document title
+    (adoctest-goto-heading "Sub A2")
+    (adoc-up-heading 2)
+    (should (looking-at-p "= Doc Title$"))
+    ;; nothing above the document title: point is restored
+    (adoctest-goto-heading "Doc Title")
+    (let ((pos (point)))
+      (should-error (adoc-up-heading 1) :type 'user-error)
+      (should (= (point) pos)))))
+
+(ert-deftest adoctest-test-nav-two-line-titles ()
+  (with-temp-buffer
+    (adoc-mode)
+    (insert "Doc Title\n=========\n\n"
+            "intro\n\n"
+            "Section A\n---------\n\n"
+            "text\n\n"
+            "Section B\n---------\n\n"
+            "end\n")
+    (goto-char (point-min))
+    (adoc-next-visible-heading 1)
+    (should (looking-at-p "Section A$"))
+    (adoc-forward-same-level 1)
+    (should (looking-at-p "Section B$"))
+    (adoc-up-heading 1)
+    (should (looking-at-p "Doc Title$"))))
+
 (ert-deftest adoctest-test-xref-at-point-1 ()
   (unwind-protect
       (progn

Reply via email to