DEV Community

Cover image for Creating an Emacs major mode - because why not?
The Struggling Dev
The Struggling Dev

Posted on • Edited on

Creating an Emacs major mode - because why not?

Introduction

So, a while ago I've gone down the rabbit hole - and I don't intend to come up again.

For multiple reasons, one of them just being curiosity, I started using Emacs. And before anyone wants to start waging the holy war of editors1, I'll put myself out there and pronounce that the one and only correct answer is: Emacs with EVIL (GitHub) mode.

I'm currently working on a time tracking tool with an analysis component. Time is tracked in a simple text file and supports tagging. A sample file looks like this:

2024|08|24
8.00 fixed some bugs in time tracker #timetracker
9.00 requirements engineering for TT024 #timetracker #re #tt024
10.00 impl. TT024 #timetracker #tt024
12.00
Enter fullscreen mode Exit fullscreen mode

And in emacs
Image description

And while the format is simple enough and Emacs supplies basic auto-completion, there are a few things that would make life even easier.

Inserting the current date and time

It would be nice if I could just press some key combination to insert the current date, and another one to insert the current time. So, I set out to figure out how to do this and started reading up in the Emacs manual. I knew that Emacs has the concept of major and minor modes because of org mode and programming language specific modes. After reading a bit I decided to implement a major mode for my time tracking project. Not sure whether a simple minor mode would've been enough for the shortcut feature, but I'm quite sure I'll need a major mode for the next feature 😉.

Creating a major mode is surprisingly easy. Just create an .el file and use define-derived-mode to define a major mode that derives from an existing mode. My mode is named time-tracking-mode and derives from the basic text-mode. "ttr" is the name that is displayed in Emacs when the major mode is active, and the last string is the documentation string.

;; time-tracking-mode.el
(define-derived-mode time-tracking-mode
  text-mode
  "ttr"
  "Major mode for time tracking.")
Enter fullscreen mode Exit fullscreen mode

Next, we can load the mode by evaluating the buffer containing time-tracking-mode.el by M-x: eval-buffer, or SPC m b e (Doom Emacs). Alternatively we can run M-x: load-file and provide the path to the file. After this, we can now select the major mode for our time tracking text file by opening the corresponding file and then M-x: time-tracking-mode. It doesn't do much yet, except showing ttr in the lower right corner - small wins.

Image description

Image description

With our major mode running smoothly, we can now add the shortcuts. For this we define the following variable above the definition of our mode definition.

(defvar-keymap time-tracking-mode-map
  :parent text-mode-map
  :doc "Keymap for `time-tracking-mode'."
  "C-c C-d" #'insert-current-date
  "C-c C-t" #'insert-current-time)
Enter fullscreen mode Exit fullscreen mode

The name of the variable matters, it has to have the form *variant*-map. The variant is the first argument to the call to define-derived-mode, in our case time-tracking-mode. We inherit from text-mode-map by specifying it as the :parent. :doc is simply the documentation string. The next two lines define the actual shortcuts and the corresponding function to call. Control+C Control+d inserts the current date, and Control+C Control+t inserts the current time. The methods look as follows:

(defun insert-current-date ()
  (interactive)
  (insert (format-time-string "%Y|%m|%d")))

(defun insert-current-time ()
  (interactive)
  (insert (format-time-string "%H.%M")))
Enter fullscreen mode Exit fullscreen mode

Important: Variable definitions like defvar-keymap are only executed the first time. Therefore we have to either close and reopen Emacs or create another major mode 😉 to test changes to variables.

Let's try if it works.

Image description

Great, on to the next feature.

"Syntax" highlighting

I want the tags to be drawn in a different color. The corresponding feature is called "font-locking" - took me some time to figure this out 😉.

We can define simple regex rules to color text.

(defvar time-tracking-font-lock-keywords
      '(("[0-9]\{1,2\}\.[0-9]\{2\}" . 'font-lock-function-name-face)
        ("[0-9]\{4\}|[0-9]\{2\}|[0-9]\{2\}" . 'font-lock-function-name-face)
        ;; #[^#\n]+\\b doesn't work, neither does #[^#\\n]+\\b as both are evaluated to #[^#n]+\\b. We need an actual line break in the pattern.
        ("#[^#
]+\\b" . 'font-lock-constant-face))
  "Keyword highlighting specification for `time-tracking-mode'."
)
Enter fullscreen mode Exit fullscreen mode

And then we set the variable in our major mode:

(define-derived-mode time-tracking-mode
  text-mode
  "ttr"
  (setq-local font-lock-defaults '(time-tracking-font-lock-keywords)) ;; <- this line is new
  "Major mode for time tracking.")
Enter fullscreen mode Exit fullscreen mode

"Easy" as this:
Image description

Entire major mode

Here's the entire major mode in all its glory.

(defvar-keymap time-tracking-mode-map
  :parent text-mode-map
  :doc "Keymap fr `time-tracking-mode'."
  "C-c C-d" #'insert-current-date
  "C-c C-t" #'insert-current-time)

(defvar time-tracking-font-lock-keywords
      '(("[0-9]\{1,2\}\.[0-9]\{2\}" . 'font-lock-function-name-face)
        ("[0-9]\{4\}|[0-9]\{2\}|[0-9]\{2\}" . 'font-lock-function-name-face)
        ;; whatever you do, do NOT remove the line break within the regex pattern. Elisp interprets \n as just an n and the line break has to inserted with C-q C-j - actuall, a simple [RET] linebreak works as well.
        ("#[^#
]+\\b" . 'font-lock-constant-face))
  "Keyword highlighting specification for `time-tracking-mode'.")

(define-derived-mode time-tracking-mode
  text-mode
  "ttr"
  (setq-local font-lock-defaults '(time-tracking-font-lock-keywords))
  "Major mode for time tracking.")

(defun insert-current-date ()
  (interactive)
  (insert (format-time-string "%Y|%m|%d")))

(defun insert-current-time ()
  (interactive)
  (insert (format-time-string "%H.%M")))
Enter fullscreen mode Exit fullscreen mode

Improvements for another time

Loading and choosing the major mode every time is a bit of a hassle. There are ways to load it automatically when Emacs starts and associate the mode with a file extension so it gets activated every time a file with the extension is opened in a buffer.

Closing and opening Emacs every time changes are made to a variable is annoying, maybe there's a way to unload the major mode?

The Struggles

One of the biggest struggles was to figure out what was necessary and how to implement it. The Emacs manual is - not very beginner friendly? Regular expressions in Emacs Lisp almost drove me mad. It took me a long while to figure out a way to efficiently test a regular expression. As you might have noticed in the corresponding code, I had an issue with line breaks in the expression. The expression worked in regex101 but ignored to ignore line breaks in Emacs. This lead to the next line after a tag being matched as well. My first reaction was to just write an Emacs Lisp function to test arbitrary regular expressions. Just create a small function that returns all matches for a regular expression on a given input text, right? Something like this ought to do:

(defun get-matches (regext text)
  (let ((matches nil))
      (while (re-search-forward regex text)
        (push (match-string 0) matches)))
Enter fullscreen mode Exit fullscreen mode

The problem is the second parameter of re-search-forward is actually not the text and the function uses the current buffer as the target text to match the expression. There might be a way to make this work, but not today. So, I took to the internet and found that there's a built in tool called re-builder to test regular expressions. M-x re-builder it was, wrote my regular expression #(^#\n)+ and confusion ensued, it did what I wanted it to do: tags highlighted, stopping at line breaks. WTF! But, hold on. Re-builder has multiple modes which can be changed via C-c TAB. The default mode is read, when I switched to string the regular expression changed from

Image description

to
Image description

and this marked the end of my Odyssey. Replacing `\n' with an actual line break was all it took.

In retrospect, I didn't take enough time to really "immerse" myself in the Emacs documentation. The initial win of creating a major mode that did something - even if it just was being selectable and showing "ttr" in the lower right corner - made me want to rush the rest. Especially for the regex stuff I should've done more research before just blindly doing what I'd do in other languages.

Thanks for reading, and keep on struggling.


  1. Although, some argue Emacs is so much more than simply an editor, which is fair enough. ↩

Top comments (0)