Fun with Rofi and Guile - A minimal habit tracker

In my personal project to learn LISP, I decided to try GNU Guile. It is a Scheme implementation and it is the official extension language of the GNU project. To learn, my first step is to actually use it, and my first minimal toy project, will be a Habit tracker. There are plenty habit trackers around, to make another one, yet this is just mine, done for my personal joy of implementing it in a language I’m trying to learn.

A habit tracker is composed of 2 parts. The first is the logger, how is the data going to be acquired and stored? This post will have that focus. The second part is the data analysis and visualization. That is where you see your progress, how you keep yourself accountable and motivated. Those parts require a lot more work and will come on later posts.

The logs

I don’t want a database, I want something extremely simple and future proof. That is plain text files. Keeping my habits on a csv file is enough. I can create and store the information as such directly on the command line like this:

echo $(date +%s),1 >> ~/habits/myhabit.csv

It is a two column csv file. The first entry is the timestamp, the second is the amount of the habit done, in this case 1. This logger command line can be extended to take a habit name and the amount by this function:

habitlog() {
echo $(date +%s),${2:-1} >> "$HOME/habits/${1:-myhabit}.csv"

This is already all I really need. This function can be on my .bashrc and I can call it anytime. habitlog without arguments adds the timestamp and a count of 1 to the file ~/habits/myhabit.csv. The next option is to keep a new file for each habit I want to track. Thus, habitlog other_habit will create ~/habit/other_habit.csv and again add the timestamp and the one count. Finally, I could use habitlog steps 2000, and it will track my on ~/habits/steps.csv that I had walked 2000 steps.

The limitation of the bash function is that I need to have a terminal open to enter the data, that is not always the case for myself. The second limitation is that I don’t get simple auto completion for all the habits I’m tracking. The auto completion can be fixed by also bash scripting that, but I don’t want to and prefer using Rofi.

Rofi to improve the User interface

Rofi as a dmenu replacement can be used as an input user interface. I could script this user interface in bash, and that is a common practice for rofi. Yet, because my entire goal is to practice Guile and have fun learning it, I’ll do this in Guile. This despite the convenience of bash, which is of course, simple and I already have half the work done. Guile is a new dependency, bash is on every Linux machine.

To make the source file a script file, start with this header. It will call guile as the interpreter, -e main-rofi is the function to call after loading the file and -s is to interpret this file as a script.

#!/usr/bin/guile \
-e main-rofi -s

Modules are imported in this very simple structure. It feels completely foreign to import modules. I’m so used to know where things are in Python that importing modules on other language make me feel clueless.

(use-modules (ice-9 rdelim)
             (ice-9 popen)
             (ice-9 ftw)
             (ice-9 format)
             (ice-9 and-let-star))

The next utility function I copied from a friendly blog on Emacs. It takes a string that represents a path and expands it to an absolute path. The main goal is to deal with the common convention ~/ represents the user home directory.

(define (expand-file f)
  (cond ((char=? (string-ref f 0) #\/) f)
        ((string=? (substring f 0 2) "~/")
         (let ((prefix (passwd:dir (getpwuid (geteuid)))))
           (string-append prefix (substring f 1 (string-length f)))))
        ((char=? (string-ref f 0) #\~)
         (let* ((user-end (string-index f #\/))
                (user (substring f 1 user-end))
                (prefix (passwd:dir (getpwnam user))))
           (string-append prefix (substring f user-end (string-length f)))))
        (else (string-append (getcwd) "/" f))))

This function receives the path to the directory where the csv logs are stored and returns a list of only the csv files.

(define (get-habit-files dir)
  (scandir dir (lambda (file) (string-suffix? ".csv" file))))

Here I process the list of files by removing the csv extension and then join the file names with a new line \n. This prepares a string of text that can be provided to rofi.

(define (prepare-options files)
  (string-join (map (lambda (name) (basename name ".csv")) files) "\n"))

To call another process, I imitate, what I would do in Bash. I prepare under cmd the exact bash command I would use to provide the options to rofi and call it through a pipe |. What happens next is that using open-input-pip cmd, I execute the command. The name input, means that guile will capture the output of the command, thus it will take that as input. In such way I can call read-line and read one line of the output.

What happens on the rofi side is that using the provided options, rofi will present me a the menu with them. I can use my keyboard to narrow the options and finally pick one. The option I pick is the return value of rofi. That is why I only need to capture one line. I close the port, as rofi should have also terminated by then. The last line tests that the output I received is not the end of file object. That would happen in the event I cancelled rofi and did not pick an option. If that is the case this function returns #f, that is false.

(define (rofi-capture-option options)
  (let* ((cmd (format #f "echo -e ~s | rofi -dmenu" options))
         (port (open-input-pipe cmd))
         (option (read-line port)))
    (close-pipe port)
    (if (eof-object? option) #f option)))

This next function is the same as before, I call a command and pick the single line output. It is a bad example, as I’m repeating code.

(define (rofi-capture-habit-quantity habit)
  (let* ((cmd (format #f "echo 1 | rofi -dmenu -p 'Add to ~a'" habit))
         (port (open-input-pipe cmd))
         (quantity (read-line port)))
    (close-pipe port)
    (if (eof-object? quantity) #f quantity)))

This is the main function. It calls rofi twice, once to pick the habit and then to request the quantity of it. and-let* is a beautiful macro, I wonder why I don’t have it on other languages. It binds each variable to its value, and tests that the value is true. That is the reason why the previous functions returned false, if the command failed. Here I control for true values, and only continue executing the program, if I keep getting valid values. Once I have the info I want to save, I open the log file where I want to store in append mode and write into it a new entry with the current time timestamp, and the quantity of the habit.

(define habits-dir (expand-file "~/habits/"))

(define (main-rofi args)
  (and-let* ((habit (rofi-capture-option (prepare-options (get-habit-files habits-dir))))
             (quantity (rofi-capture-habit-quantity habit))
             (file-out (open-file (string-append habits-dir habit ".csv") "a")))
    (format file-out "~d,~a\n" (current-time) quantity)
    (close-port file-out)))


This was a fun experience and forced myself to work with a new language. Mission accomplished. This habit logger, is just too simple and using Guile is an over-kill compared to bash. The complexity of the script is not high enough to use anything other than bash. As the complexity of the program goes up, bash is a pain to read and maintain and other languages become more attractive.

Python has been my go-to language for many years now. Compared to python Guile is for now not much different. In both languages I could have written this script in the same amount of lines. Maybe because Guile is not whitespace relevant I could squeeze some lines of code into one and make it shorter, especially using the and-let* macro, which I find just beautiful. Something like that just came into Python 3.8 with the walrus operator :=, yet it is not as powerful compared to and-let*.

Python has the huge advantage of being popular, and that leads to a massive amount of packages that can get you started and even done when solving your problems. Guile on the other hand doesn’t seem to have that much available. I’ll see what I find when building the data analysis and visualization in the next weeks.

Dr. Óscar Nájera
Dr. Óscar Nájera
Hacker & Recovering Physicists

I try to get things done by removing some code

comments powered by Disqus