Does The World Really Need Another Todo List Tool?

The answer, of course, is no, but I built one anyway, and I like it and making it was fun and edifying and that alone is enough to justify its creation. I’m also finding it useful, not only as a program for making lists but as a part of a suite of programs I’ve been building for managing (my) personal information (another part of which is a calendar).

Anyway, I’m not the only one designing a new todo list. Construction is life, and if improvements can be made, the future will thank us for making them.

The Problem

I needed a todo list that fit in my bigass text file and whose contents I could extract with scripts to, for example, include in my morning calendar/reminder email, or move completed items from the main list to the day’s journal entry, etc.

Prior to this, for lists, I’ve been using org-mode. Before that, I used TaskPaper (and taskpaper-mode after switching to Linux). I have small gripes with both—they’re both pretty perfect.

With org-mode: it’s a huge program, so isn’t quite optimized for lists, so has some syntax that detracts from the experience. Other parts of its syntax are a bit unsightly (like its :tags:). And its nesting rules can sometimes be inconvenient—because it uses duplicated asterisks for section nesting, it’s not possible to resume a section if it contains a subsection. A section header consumes everything under it.

* Section 1
- [ ] This belongs to section 1.
** Section 2
- [ ] This belongs to section 2, which belongs to section 1.
How to resume section 1? This belongs to section 2.

This issue isn’t limited to org-mode—any information-hierarchy system that nests subsections with bullets, text size, etc, inherits it. Systems that nest with whitespace don’t.

* Section 1
  - This belongs to section 1.
  * Section 2
     - This belongs to section 2, which belongs to section 1.
  - This resumes section 1.

TaskPaper nests with whitespace. Its syntax doesn’t include checkboxes—instead, it uses tags (in the form of @tag) to indicate a task’s status. Though tags are a more general and flexible mechanism (especially when tags can contain values, like @priority(high)) checkboxes provide a nice bit of syntactic sugar.

Which do you prefer?:
  - Task 1 @status(done)
  - Task 1 @done
  - [X] Task 1

Parts Of Lists

It’s fun to start with first principles.

What is a list? A group of items.

Is that all? Maybe also a title, maybe also notes.

What is a todo list? A list of tasks.

What are the properties of a task? Assigner, assignee, priority, order, completion status, dates, subtasks, etc.

Do we want to represent all that here? No. This is for personal use.

So what information do we want to represent? Lists and todo lists, which include:

Also, it might be nice to be able to specify item order. Some lists represent a group, some represent a sequence, and though the difference might be known to the reader, being able to make the difference explicit is a feature, not a bug, and not bloat.

The Format

So I wanted a format that’s optimized for making lists in plain text and eminently human-workable.

Here’s a little grammar, written in Janet:

# This grammar recognizes three types of line:
# - an "item" contains a bullet
# - a "task" contains a checkbox and possibly a bullet
# - a "note" contains neither bullet nor checkbox
# All nodes must contain a description, and they can contain leading
# whitespace, which determines node nesting. Any node can be nested
# under any other at any depth.
# Bullets can be of a style used for unordered lists (one character,
# like `-` or `*`) or ordered lists (digits followed by a `.` or `)`).
# A "note" line with children can be considered a "header".
(def lines-grammar
  ~{:main (choice :task-line :item-line :note-line)
    # The `0` and `-1` indicate the start and end of the line,
    # like `^` and `$` in regular expressions.
    :item-line (sequence 0 :lead-space :bullet :description -1)
    :task-line (sequence 0 :lead-space :bullet? :checkbox :description -1)
    :note-line (sequence 0 :lead-space :description -1)
    # `set` means "in this set of characters".
    :lead-space (any (set " \t"))
    # `any` = 0 or more
    :bullet? (any :bullet)
    :bullet (sequence (choice (set "#*-")
                              (sequence (some (range "09"))
                                        (some (set ".)")))
                      # `some` = 1 or more
                      (some " "))
    :checkbox (sequence "[" :status "]"
                        (some " "))
    :status (set " -@X!?/")
    # `:s` = space, `:S` = not space
    :description (some (choice :s :S))})

That is:

  1. List items have bullets, which can be numeric.
  2. Tasks have checkboxes, which contain a status, and can have bullets.
  3. Notes/headers have neither checkboxes nor bullets.
  4. Nesting is determined by leading whitespace, and any line can be nested under any other.

(An aside: Janet feels like the Lisp I needed five years ago—like it’d be promising enough to someone writing Python or PHP but not quite competent/confident enough with Scheme or Common Lisp to lure them away. It could make a great gateway Lisp. I also wrote a parser in Fennel. Maybe I’ll write about that experience another time.)

So, like TaskPaper, this format nests with whitespace and knows of three types of line that can be nested under any other. Like org-mode, it uses checkboxes for tasks and supports both ordered and unordered lists. Like neither, bullets on tasks are optional and, like xit, there can be a variety of status indicators. Here’s an example:

A header
  - [X] Task 1
        A note
  - [/] Task 2
        - [X] A subtask
        - [@] Another subtask
  - [?] Task 3
        - A list item
        - Another list item
  - [ ] Task 4
    
Another header
  1. [!] A task
  2. [?] Another task
    
Another header
  [-] A task
  [#] Another task

Using It

So of course I wrote an emacs major mode (named, per tradition, tdtd-mode) to make this format usable. It’s a work in progress but is probably 90–95% there.

While working on the mode, I found myself wishing I could use the format in the file itself. I can’t be the only person in the world that adds todo lists in the files that need the work.

;; Todo:
;; [X] Move node/tree up and down among peers
;; [X] Promote/demote nodes/trees
;;     [X] Bug: when undoing, the point goes to the last line in the node
;;         Bizarrely, this was resolved by switching from `goto-line` to
;;         `forward-line`. Also bizarrely, if `forward-line` is given a
;;         negative argument, it goes backwards.
;; [X] Update parent task's status
;;     When a child task's status is non-blank, it'd be nice if the
;;     parent's is "/", and when every child's status is complete, if
;;     the parent's is also complete.

So it can also work as a minor mode, in which case it is called +tdtd-mode.

(define-minor-mode +tdtd-mode
  "Add `tdtd-mode` as a minor mode. Keybindings may be different from the major mode."
  :init-value nil
  :lighter " +tdtd"
  :keymap +tdtd/mode-map
  (when +tdtd-mode
    (when (string= major-mode "tdtd-mode")
      (message "Cannot use +tdtd within tdtd-mode.")
      (setq +tdtd-mode nil))))

Aside from the declaration, making a major mode work as a minor mode doesn’t involve much more than adding a different keymap, since the conventions of major and minor modes are different.

;; tdtd/mode-map :: keymap
(defvar tdtd/mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "<M-return>") #'tdtd/handle-add-like-line-below)
    (define-key map (kbd "<M-S-return>") #'tdtd/handle-add-like-line-above)
    (define-key map (kbd "<M-up>") #'tdtd/handle-move-node-up)
    ...
    
;; +tdtd/mode-map :: keymap
(defvar +tdtd/mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-c <return>") #'tdtd/handle-add-like-line-below)
    (define-key map (kbd "C-c <S-return>") #'tdtd/handle-add-like-line-above)
    (define-key map (kbd "C-c <up>") #'tdtd/handle-move-node-up)
    ...

And in this case a change in the handling of leading whitespace to include comment characters.

To use a mode in the file that defines the mode feels appropriately emacsy.