happyponyland.net / battle-plan.el

Battle-Planner screenshot

battle-plan.el is a personal to do/checklist manager for GNU Emacs. It is designed to be non-intrusive and not interfere with editing. Of course, it uses a plain text format that plays nicely with other tools, such as Git and grep.

Basic usage

Each item is a single line. There are three types of items. Lines are prefixed with - for pending items, + for completed items, and / for "won't do" (when you change your mind, but still wish to document your history). Items are color-coded, providing a quick and visually satisfying overview.

M-c toggles the current item between pending/completed, with automatic sorting. Completed items drop to the bottom, while pending items (perhaps you needed to re-open a bug?) pop to the top. Use empty lines to group items logically; toggling will only relocate an item within its local group.

There's also a shorthand for adding stand-out :: Headers, which has no programmatic meaning but helps arrange items in a meaningful way.

Tagging & cross-referencing

Items can have any number of #tags which can be used to track progress. Found a bug that needs to be fixed for version 1.3? Tag it #bug #1.3.

C-c ; generates a report listing all tags and how many pending/completed items there are for each. Selecting a line in the report with RET or mouse-2 will open an occur buffer listing all items with the selected tag. Selecting a line in the occur buffer (as per standard occur behaviour) will jump to that specific line in the to do-list. DEL (that is; backspace) in the occur buffer will return to the report buffer.

Installation

Installation is simple:

Once installed, battle-plan will automatically detect filenames containing the phrase TODO. It can also be manually activated with M-x battle-plan-mode.

I strongly recommend using move-lines.el (or something that provides the same functionality). I personally have S-up and S-down bound to move the current line or region up and down, which lets me quickly rearrange to do-items (transpose-lines is kinda dodgy). However, this is purely in the realm of text editing and outside the scope for battle-plan, so no such routines are included here.

I also recommend using volatile-highlights-mode, as it will provide visual feedback where items end up as they are toggled and relocated.

Code

;;
;;                     Happy Pony Land presents
;;
;;        ▛▀▖ ▞▀▖ ▀▛▘ ▀▛▘ ▌   ▛▀▘   ▛▀▖ ▌   ▞▀▖ ▛▖▐   ▐▀▀ ▐  
;;        ▛▀▖ ▛▀▌  ▌   ▌  ▌   ▛▘ ▝▀ ▛▀  ▌   ▛▀▌ ▌▝▟   ▐▀  ▐  
;;        ▀▀  ▘ ▘  ▘   ▘  ▀▀▘ ▀▀▘   ▘   ▀▀▘ ▘ ▘ ▘ ▝ ▝ ▝▀▀ ▝▀▀
;;
;;  battle-plan.el is a personal to do manager for GNU Emacs.
;;
;;  See http://www.happyponyland.net/battle-plan for more information.
;;
;;  Version: 2018-09-08
;;
;;  Author: ulf.astrom@gmail.com
;;
;;  License: By using, modifying or distributing this Software,
;;           you pledge your Soul to be consumed by the Dark One.
;;

;;;###autoload

(defvar battle-plan-mode-map nil "Keymap for battle-plan-mode.")
(setq battle-plan-mode-map (make-sparse-keymap))
(define-key battle-plan-mode-map [M-c] 'bplan-dwim)
(define-key battle-plan-mode-map (kbd "C-c ;") 'bplan-report)

;; Face definitions: the colors used for items, tags and headers.

(defface bplan-pending
  '((t :foreground "light goldenrod"))
  "Face for battle-plan pending tasks."
  :group 'battle-plan-mode)

(defface bplan-done
  '((t :foreground "spring green"))
  "Face for battle-plan completed tasks."
  :group 'battle-plan-mode)

(defface bplan-wont-do
  '((t :foreground "coral4"))
  "Face for battle-plan tasks that won't be done."
  :group 'battle-plan-mode)

(defface bplan-tag
  '((t :foreground "cyan3"
       :weight extra-bold))
  "Face for battle-plan #tags in to do tasks."
  :group 'battle-plan-mode)

(defface bplan-header
  '((t :foreground "light salmon"
       :underline t
       :weight extra-bold
       ))
  "Face for battle-plan headers."
  :group 'battle-plan-mode)

(defvar bplan-pending 'bplan-pending "")
(defvar bplan-done 'bplan-done "")
(defvar bplan-wont-do 'bplan-wont-do "")
(defvar bplan-tag 'bplan-tag "")
(defvar bplan-header 'bplan-header "")



;; Syntax highlighting: used by font lock to set the faces above.

(setq battle-plan-format
      '(("^- .*$"  . bplan-pending)
        ("^\+ .*$" . bplan-done)
        ("^\/ .*$" . bplan-wont-do)
        ("^:: .*$" . bplan-header)
        ("#[[:graph:]]*" . (0 'bplan-tag t))
        )
      )


(define-derived-mode battle-plan-mode nil "Battle-Planner"
  "Major mode for managing personal to do-lists."

  (setq bplan-report-buf-name "*battle-plan-report*"
        bplan-occur-buf-name "*battle-plan-occur*"
        font-lock-keywords-only t
        font-lock-defaults '(battle-plan-format))
  )


(defun bplan-rewind-markers ()
  "Determine where -+/ lines should end up relative to the current
line. Returns a list with three markers."
  (beginning-of-line)

  ;; At this point we don't know what the caller wants to use the
  ;; markers for, but to determine proper placement we need to
  ;; calculate all of them anyway, so we'll just return all three.
  
  ;; Default: start of current line
  (setq bplan-pending-insert (point-marker)
        bplan-done-insert    (point-marker)
        bplan-wont-insert    (point-marker))

  ;; Move forward so we can match prefixes backwards on this line.
  (forward-char 2)

  ;; Decide where to insert a - line (ideally at the start of the
  ;; current - line block). This can match several different patterns:
  ;;
  ;; (note: "whitespace" here refers to space and tab)
  ;;
  ;; - an empty line (w/ or w/o whitespace) followed by "/ ", "+ " or "- "
  ;; - a line with a header (:: + any text) and a newline
  ;; - two empty lines (to break free from a non-to do block)
  ;; - an empty line at the start of buffer, followed by /, + or -
  ;; - a header at the start of buffer
  ;; - the start of buffer
  ;;
  ;; The value returned by cond is how many lines we must skip forward
  ;; to get to the proper position, e.g. if we match to the start of
  ;; buffer, there is only one newline between point and desired
  ;; insertion position. If we match _only_ start of buffer, we don't
  ;; need to move at all.
  ;;
  ;; We match "/ " and "+ " before "- " since if these are preceeded
  ;; by empty lines, they are considered the start of the group, even
  ;; if there are "- " blocks further up in the buffer.
  (forward-line
   (cond ((search-backward-regexp "\n[ \t]*\n/ "    nil t) 2)
         ((search-backward-regexp "\n[ \t]*\n\\+ "  nil t) 2)
         ((search-backward-regexp "\n[ \t]*\n- "    nil t) 2)
         ((search-backward-regexp "\n:: .*\n"       nil t) 2)
         ((search-backward-regexp "\n[ \t]*\n"      nil t) 1)
         ((search-backward-regexp "\\`[ \t]*\n/ "   nil t) 1)
         ((search-backward-regexp "\\`[ \t]*\n\\+ " nil t) 1)
         ((search-backward-regexp "\\`[ \t]*\n- "   nil t) 1)
         ((search-backward-regexp "\\`:: .*\n"      nil t) 1)
         ((search-backward-regexp "\\`"             nil t) 0)
         ))

  ;; This is where we want to insert - lines
  (setq bplan-pending-insert (point-marker))

  ;; Start searching forward for the first line that doesn't start
  ;; with "- ", or the end of buffer.
  (if (or (re-search-forward "\n[^-]" nil t)
          (re-search-forward "\\'" nil t))
      (progn
        ;; If we matched the end of buffer, make sure it ends with a
        ;; newline. Otherwise, back up a char.
        (if (eq (point) (point-max))
            (unless (eq (point) (line-beginning-position))
              (insert "\n"))
          (forward-char -1))

        ;; This is where we want to insert + lines. It is ALSO where
        ;; we want to insert / lines, UNLESS we find a better place
        ;; for them below.
        (setq bplan-done-insert (point-marker)
              bplan-wont-insert (point-marker))

        ;; Look for a line that doesn't start with +
        (if (re-search-forward "\n[^\\+]" nil t)
            (progn (forward-char -1)
                   (setq bplan-wont-insert (point-marker))))
        )

    ;; else; couldn't find any - lines
    ((setq bplan-done-insert (point-marker)
           bplan-wont-insert (point-marker)))
    )
  
  (list bplan-pending-insert bplan-done-insert bplan-wont-insert)
  )


(defun bplan-modify-item (prefix)
  "Change the -+/ prefix (any kind) of the current line to PREFIX."

  ;; Get positions to insert -+/ lines.
  ;; bplan-rewind-markers moves point, so save start-pos for later.
  (setq start-pos (point-marker)
        markers (bplan-rewind-markers)
        pending-insert (nth 0 markers)
        done-insert    (nth 1 markers)
        wont-insert    (nth 2 markers))

  ;; Go to the start of the current line, remove any existing prefix,
  ;; add the desired prefix and a space.
  (goto-char start-pos)
  (beginning-of-line)
  (if (bplan-is-todo-item) (delete-char 2))
  (insert prefix)
  (insert " ")

  ;; Sort line
  (beginning-of-line)
  (kill-line)

  ;; Kill the remaining newline, but not if we're at the end of buffer
  (cond ((eq (point) (point-min)) (delete-char 1))
        ((not (eq (point) (point-max))) (delete-char 1))
        )
  
  ;; Go to the insert point for this prefix
  (cond ((eq prefix ?\-) (goto-char pending-insert))
        ((eq prefix ?\+) (goto-char done-insert))
        ((eq prefix ?\/) (goto-char wont-insert))
        )

  (yank)
  (insert "\n")
  )


(defun bplan-is-todo-item ()
  "Returns t if point is at a -+/ prefix."
  (and (eq ?\s (char-after (+ (point) 1)))
       (or (bplan-following ?\+)
           (bplan-following ?\-)
           (bplan-following ?\/)))
  )


(defun bplan-following (ch)
  "Returns t if the character following point equals CH."
  (eq (following-char) ch)
  )


(defun bplan-mark-done ()
  "Marks the current line/item as done."
  (interactive)
  (bplan-modify-todo-line ?\+)
  )


(defun bplan-mark-pending ()
  "Marks the current line/item as pending."
  (interactive)
  (bplan-modify-pending-line ?\-)
  )


(defun bplan-dwim ()
  "Toggles the current line/item prefix (- to +, + to -, / to -)."
  (interactive)
  (save-excursion
    (beginning-of-line)
    (if (bplan-is-todo-item)
        (cond ((bplan-following ?\-) (bplan-modify-item ?\+))
              ((bplan-following ?\+) (bplan-modify-item ?\-))
              ((bplan-following ?\/) (bplan-modify-item ?\-))
              )
      )
    )
  )


(defun bplan-incr-todo-counter (status alist)
  "Increments the key STATUS in association list ALIST and returns ALIST."
  (incf (alist-get status alist))
  alist
  )


(defun bplan-report ()
  "Generates a report of all #tags present in the current buffer."
  (interactive)

  (let ((lines (split-string (buffer-string) "\n" t))
        (tags (make-hash-table :test 'equal)))
    (while lines
      (setq line (substring-no-properties (car lines)))
      (if (setq status (cond ( (eq (string-to-char line) ?\+) 'done )
                             ( (eq (string-to-char line) ?\-) 'pending )
                             ( (eq (string-to-char line) ?\/) 'wont )
                             ( t nil )))
          (progn
            (setq start-pos 0)
            (while (setq pos (string-match "#[[:alnum:]]+" line start-pos))
              (let ((tag (match-string 0 line)))
                (puthash tag (bplan-incr-todo-counter status (gethash tag tags
                                                                      (list 'list
                                                                            (cons 'done 0)
                                                                            (cons 'pending 0)
                                                                            (cons 'wont 0))))
                         tags)
                )
              (setq start-pos (+ 1 pos))
              )
            )
        )
      (setq lines (cdr lines))
      )

    (setq generated-from (current-buffer)) ;; Keep track of where we came from
    
    (with-current-buffer-window
     bplan-report-buf-name nil nil
     (make-local-variable 'tab-stop-list) ;; Set up the tabs to align against
     (setq tab-stop-list '(15 30 50 100))
     (maphash 'bplan-list-todo-tag tags)

     ;; Make a keymap to open an occur buffer; apply it to all lines
     (let ((map (make-sparse-keymap))
           (begin (point-min))
           (end (point-max)))
       
       (define-key map (kbd "RET") 'bplan-occur)
       (define-key map  [mouse-2]  'bplan-occur)
       
       (put-text-property begin end 'keymap map)
       (add-text-properties begin end
                            '(mouse-face highlight
                                         help-echo
                                         "mouse-2: find occurences of this tag"))
       )
     )
    ) 
  )


(defun bplan-switch-to-report ()
  "Switches the current window (back) to the battle-plan report.
This is intended to be mapped in the occur buffer."
  (interactive)
  (switch-to-buffer bplan-report-buf-name)
  )


(defun bplan-occur ()
  "In the report buffer, opens an occur buffer for the currently selected tag."
  (interactive)
  (goto-char (line-beginning-position))
  (if (re-search-forward "#[[:alnum:]]+" (line-end-position) t)
      (let ((tag (match-string 0 nil)))
        (goto-char (line-beginning-position))
        (switch-to-buffer bplan-occur-buf-name)
        (occur-1 tag 0 (list generated-from) bplan-occur-buf-name)
        ;; Bind *backspace* to return to report
        (local-set-key (kbd "DEL") 'bplan-switch-to-report)
        )
    )
  )


(defun bplan-list-todo-tag (key value)
  "Format and display a line for #tag KEY, with done/pending/wont
count retrieved from VALUE."
  (let ((done    (alist-get 'done    value))
        (pending (alist-get 'pending value))
        (wont    (alist-get 'wont    value)))
    (bplan-insert-face-tab key bplan-tag t)
    (bplan-insert-face-tab (format "%3d" done) bplan-done nil)
    (bplan-insert-face-tab " / " nil nil)
    (bplan-insert-face-tab (number-to-string pending) bplan-pending t)
    (bplan-insert-face-tab (format "(total: %3s)  " (number-to-string (+ done pending))) nil nil)
    (bplan-insert-face-tab (format "(won't do: %s)" (number-to-string wont)) bplan-wont-do nil)

    (let ((begin (line-beginning-position))
          (end (line-end-position)))
      (add-face-text-property begin end '(:underline "gray20")))
    
    (insert "\n"))
  )


(defun bplan-insert-face-tab (text face tab)
  "Inserts TEXT in the current buffer and apply FACE to it. If TAG is t, skip to the next tabstop."
  (let ((begin (point-marker)))
    (insert text)
    (let ((end (point-marker)))
      (if tab (tab-to-tab-stop))
      (add-face-text-property begin end face)
      )
    )
  )


;; Associate buffers called TODO with battle-plan
(add-to-list 'auto-mode-alist '("TODO" . battle-plan-mode))

(provide 'battle-plan)

Note: This was my first substantial elisp project. The code might not be optimal.

Frequently Asked Question(s)

BUT "HPL" WHAT ABOUT ORG-MODE???
org-mode is too complicated and I don't like it.