El Gantt – A Gantt chart/calendar for Orgmode
El Gantt creates a Gantt calendar from your orgmode files. It provides a flexible customization system with the goal of being adaptable to ?multiple purposes. You can move dates, scroll forward and backward, jump to the underlying org file, and customize the display.
This package is under ongoing development. Ideas are welcome. Feature requests and bug reports are welcome. Help is welcome.
Install the dependencies:
Clone this repository into your
lisp directory (if you are using Emacs 27, you made need to swap
cd ~/.emacs.d/lisp git clone httpqs://github.com/legalnonsense/elgantt.git
(add-to-list 'load-path (concat user-emacs-directory "lisp/elgantt/")) ;; Or wherever it is located (require 'elgantt)
(elgantt-open). By default, Elgantt will use your org-agenda files.
Before running this on your org-agenda files, you may want to experiment with the test file.
Using the test file
elgantt-agenda-files to the location wherever you installed
elgantt. If you cloned to the
lisp subdirectory, then:
(setq elgantt-agenda-files (concat user-emacs-directory "lisp/elgantt/test.org"))
All variables can get set with
setq and there is no need to use the customization interface, or
use-package’s :custom keyword.
Setting the header type
The headers of the calendar are defined by
elgantt-header-type. There are four default values and an option to define a custom function which will be run at the first point of each org heading:
|category||Group entries by their CATEGORY property, or the filename if no CATEGORY property is set.|
|hashtag||Group entries by tags which are prefixed by a hashtag.|
|outline||Use the outline structure|
|root||Group by the root heading|
|function||Run the given function at point, grouping entries by the return value of the function|
Setting the timestamps to display
Set the variable
elgantt-timestamps-to-display to control what types of timestamps are displayed. This variable is a list which can contain any of:
The order of the list matters determined precedence. Only the first type of entry found in a heading will be displayed with a character. I generally use
(setq elgantt-timestamps-to-display '(deadline timestamp scheduled timestamp-range))
|elgantt-startup-folded||If non-nil, display all entries, grouped by header-type, on a single line; otherwise, entries are grouped under a header with one entry per line|
|elgantt-show-header-depth||If elgantt-header-type is set to ‘outline, then show the outline depth by inserting elgantt-level-prefix-char. If elgantt-header-type is not ‘outline, then this has no effect|
|elgantt-level-prefix-char||The character used to prefix nested entries.|
|elgantt-even-numbered-line-change||This controls how much the percent the even numbered lines are offset from the background color; set to 0 if you don’t want any distinction|
|elgantt-scroll-to-current-month-at-startup||Scroll to the current month at startup, or keep the calendar at the first timestamp|
|elgantt-insert-blank-line-between-top-level-header||Just what it says.|
|elgantt-draw-overarching-headers||Draw a line bracketing the start and end dates for the children of and top-level headers, assuming there is no date already associated with the header.|
|elgantt-header-column-offset||The width of the header column.|
|elgantt-header-line-format||This is currently a mess and needs to be fixed. I will write something that makes it easy to customize what data is shown in the header. Until then, refer to the documentation for the header-line and good luck.|
|elgantt-exclusions||This is a list of strings. Do not display any headers that appear in this list.|
|elgantt-insert-header-even-if-no-timestamp||Insert the header even if there is no timestamp associated with it.|
|elgantt-hide-number-line||Hides the number line that appears at the top of the calendar|
Other custom variables
|elgantt-start-date||(concat (format-time-string “%Y-%m”) “-01”) (i.e., the current month)|
elgantt-start-date is probably the most important one here. This sets the cut-off date for when to ignore old entries.
|f||Move forward to next entry on the line|
|n||Move backward to previous entry|
|n||Move to the closest entry on the next line|
|p||Move to the closest entry on the previous line|
|F||Scroll forward by one month|
|B||Scroll backward by one month|
|M-f||Shift date at point forward one day|
|M-b||Shift date at point backward one day|
|c||Move calendar to current date|
|space||Navigate to org heading at point|
|Return||Show agenda for date at point|
Note about cells with multiple entries: If a calendar cell has multiple entries, a special character will be displayed (“☰” by default). If you try to perform a function on one of these cells (e.g., navigating to the org file, shifting a date, etc.), you will be prompted to select the entry you want to perform the operation on.
These exampes all use the
(setq elgantt-agenda-files "~/.emacs.d/lisp/elgantt/test.org") (or wherever your elgantt direcctory is located).
A note about colorizing the outline
The examples that follow draw a gradient between the scheduled time of an entry and the deadline of the entry. (The scheduled date is not actually shown in the calendar.) This is not included in the package and you need to use a custom macro (shown below) to do it. I took this idea from the org-gantt package. It is not included by default because it only works if you use deadlines and scheduling in a particular way. I do not use colorize my calendars this way, but it makes for a good demonstration. The code necessary to do this, and an alternative way to use colors, are discussed below when explaining the
elgantt-create-display-rule macro. If you want these colors to appear, evaluate this code and reload (i.e.,
C-r) the calendar:
(setq elgantt-user-set-color-priority-counter 0) (elgantt-create-display-rule draw-scheduled-to-deadline :parser ((elgantt-color . ((when-let ((colors (org-entry-get (point) "ELGANTT-COLOR"))) (s-split " " colors))))) :args (elgantt-scheduled elgantt-color elgantt-org-id) :body ((when elgantt-scheduled (let ((point1 (point)) (point2 (save-excursion (elgantt--goto-date elgantt-scheduled) (point))) (color1 (or (car elgantt-color) "black")) (color2 (or (cadr elgantt-color) "red"))) (when (/= point1 point2) (elgantt--draw-gradient color1 color2 (if (< point1 point2) point1 point2) ;; Since cells are not necessarily linked in (if (< point1 point2) point2 point1) ;; chronological order, make sure they are sorted nil `(priority ,(setq elgantt-user-set-color-priority-counter (1- elgantt-user-set-color-priority-counter)) ;; Decrease the priority so that earlier entries take ;; precedence over later ones (note: it doesn’t matter if the number is negative) :elgantt-user-overlay ,elgantt-org-id)))))))
Use outline structure, unfolded, with space between headers, and overarching header lines
(setq elgantt-header-type 'outline elgantt-insert-blank-line-between-top-level-header t elgantt-startup-folded nil elgantt-show-header-depth t elgantt-draw-overarching-headers t)
Same as above, but folded
(setq elgantt-header-type 'outline elgantt-insert-blank-line-between-top-level-header nil elgantt-startup-folded t elgantt-show-header-depth t elgantt-draw-overarching-headers)
Use hashtags, folded, with no spaces
(setq elgantt-header-type 'hashtag elgantt-insert-blank-line-between-top-level-header nil elgantt-startup-folded t)
What does it look like unfolded?
A custom header
Here’s a silly example that will group headers by the first letter ofo the headline
(setq elgantt-header-type (lambda () (substring (org-entry-get (point) "ITEM") 0 1))) ;; You’ll also want to set `elgantt-insert-header-even-if-no-timestamp' to nil, otherwise you’ll see single letter headers that are assocated with headlines without dates
Macro/configuration examples and explanations
Elgantt aims to provide a flexible way to customize calendar displays. Whether it hits its target is not my concern.
This macro is used to customize the display of the calendar. It defines functions that are run at each cell after the calendar is generated. If a cell contains multiple entries, it will be run for each entry in the cell.
Accessing and adding properties
Before proceeding, here is a list of the properties that are included for each entry in the calendar:
The following properties are included in each cell by default:
|:elgantt-headline||Text of the org headline (no text properties)|
|:elgantt-deadline||Deadline as a string YYYY-MM-DD, or nil|
|:elgantt-scheduled||Scheduled timestamp, or nil|
|:elgantt-timestamp||First active timestamp (date only) or nil|
|:elgantt-timestamp-ia||First inactive timestamp (date only) or nil|
|:elgantt-timestamp-range||Active timestamp range, as a list of two strings ‘(“YYYY-MM-DD” “YYYY-MM-DD”) or nil|
|:elgantt-timestamp-range-ia||Same, but inactive timestamp range|
|:elgantt-category||Category property of the heading, or the filename if no category property is supplied|
|:elgantt-todo||TODO type, no properties, or nil|
|:elgantt-marker||Marker pointing to the location of the heading in the org buffer|
|:elgantt-file||Filename of the underlying org file|
|:elgantt-org-buffer||Buffer for the underlying org heading|
|:elgantt-alltags||A list of all tags, including inherited tags, associated with the heading|
|:elgantt-header||Header used for insertion into the calendar buffer. Depends on the value of
|:elgantt-date||Date used for insertion into the calendar. Uses the first date found in
|:elgantt-hashtag||Any hashtag (inherited) associated with the headline|
All properties returned by
(org-entry-properties) are also included in an entry’s property list.
Here are some basic examples of how to use the display customization macro.
Changing the color of certain cells
Suppose we want to change the background color of any cell with a “TODO” state to red:
(elgantt-create-display-rule turn-todo-red :args (elgantt-todo) ;; Any argument in this list is available in the body :body ((when (string= "TODO" elgantt-todo) ;; `elgantt--create-overlay' is generally the easiest way to create an overlay ;; since `ov' is not a dependency. (elgantt--create-overlay (point) (1+ (point)) '(face (:background "red"))))))
Some caveats: If there is already an overlay on the cell, you have to manage the overlay priorities for them to display properly. The manual is serious when it warns “you should not make assumptions about which overlay will prevail” when two overlays share the same priority (or do not have a priority).
For example, here we will choose an arbitrarily large priority to make sure this overlay is displayed over any others:
(elgantt-create-display-rule turn-todo-red :args (elgantt-todo) ;; Any argument listed here is available in the body :body ((when (string= "TODO" elgantt-todo) ;; `elgantt--create-overlay' is generally the easiest way to create an overlay (elgantt--create-overlay (point) (1+ (point)) '(face (:background "red") priority 99999)))))
If you want to make a dynamic display (i.e., one that updates every time you move), the
post-command-hook keyword will add the function as a post-command-hook and run it each time the cursor moves. For example, suppose you want to make each cell red that matches the TODO state of the cell at point. We’ll use the the macro
elgantt--iterate-over-cells to run the expression for each cell.
If you want to use this kind of display, then you’ll probably want to give the overlay a unique ID, and clear those overlay each time the cursor moves.
(elgantt-create-display-rule turn-matching-todos-red :args (elgantt-todo) :post-command-hook t ;; This will recalculate every time the point moves :body ((remove-overlays (point-min) (point-max) :turn-it-red t) ;; Since this will run each time the cursor moves, we need to clear ;; the previous overlays first (when elgantt-todo ;; make sure there is a todo state (elgantt--iterate-over-cells (when (member elgantt-todo (elgantt-get-prop-at-point :elgantt-todo)) (elgantt--create-overlay (point) (1+ (point)) '(face (:background "red") priority 9999 ;; arbitrary identifier ;; so we know what overlays to clear :turn-it-red t)))))))
Using the test.org file (where only a few of the headlines have TODO states), you’ll see this will turn the background of any entry that also has a TODO state when the point is on a cell with the same state:
If, during your experimentation, you want to disable a display rule, add
:disable t and it will be removed from the function stack (or the post-command hook, if appropriate). In the alternative, call
elgantt--clear-all-customizations which will delete any functions created by the customization macros.
Adding new properties from org files
Suppose you want to change the color of a cell based on a property that is not present by default. For example, you want to change the color if the cell has a certain priority, but that property is not included by default. In that case, use the
:parser keyword to add a property. The expression is run at the first point of each org heading, and will be automatically added to the parsing function. The syntax is:
:parser ((property-name1 . ((expression))) (property-name2 . ((expression))))
So, to add the property to get the priority of an org heading:
(elgantt-create-display-rule priority-display :parser ((elgantt-priority . ((org-entry-get (point) "PRIORITY")))) :body (())) ;; insert code here, which can use elgantt-priority variable
You must reload the calendar after evaluating the macro so the calendar can repopulate and
:elgantt-priority and its value will be added to each entry’s text properties.
Other ways to colorize time blocks
Here is how I colorize blocks of time. It depends on two org properties:
ELGANTT-COLOR is an org property that contains two color names, which will represent the start and end of a gradient.
ELGANTT-LINKED-TO contains the ID of an org heading. This is different than the colorizing macro used for other examples, which colors a block starting with the scheduled date and ending with a deadline.
(setq elgantt-user-set-color-priority-counter 0) ;; There must be a counter to ensure that overlapping overlays are handled properly (elgantt-create-display-rule user-set-color :parser ((elgantt-color . ((when-let ((colors (org-entry-get (point) "ELGANTT-COLOR"))) (s-split " " colors)))) (elgantt-linked-to . ((org-entry-get (point) "ELGANTT-LINKED-TO")))) :args (elgantt-org-id) :body ((when elgantt-linked-to (save-excursion (when-let ((point1 (point)) (point2 (let (date) ;; Cells can be linked even if they are not ;; in the same header in the calendar. Therefore, ;; we have to get the date of the linked cell, and then ;; move to that date in the current header (save-excursion (elgantt--goto-id elgantt-linked-to) (setq date (elgantt-get-date-at-point))) (elgantt--goto-date date) (point))) (color1 (car elgantt-color)) (color2 (cadr elgantt-color))) (when (/= point1 point2) (elgantt--draw-gradient color1 color2 (if (< point1 point2) point1 point2) ;; Since cells are not necessarily linked in (if (< point1 point2) point2 point1) ;; chronological order, make sure they are sorted nil `(priority ,(setq elgantt-user-set-color-priority-counter (1- elgantt-user-set-color-priority-counter)) ;; Decrease the priority so that earlier entries take ;; precedence over later ones :elgantt-user-overlay ,elgantt-org-id))))))))
Linking cells with
Some samples here use the following macro to draw a line through cells which share the same hashtag. This code also adds a shortcut to move to the next matching hashtag:
(elgantt-create-display-rule show-hashtag-links :args (elgantt-hashtag) :post-command-hook t ;; update each time the point is moved :body ((elgantt--clear-juxtapositions nil nil 'hashtag-link) ;; Need to clear the last display (when elgantt-hashtag ;; only do it if there is a hashtag property at the cell (elgantt--connect-cells :elgantt-alltags elgantt-hashtag 'hashtag-link '(:foreground "red"))))) (elgantt-create-action follow-hashtag-link-forward :args (elgantt-alltags) :binding "C-M-f" :body ((when-let* ((hashtag (--first (s-starts-with-p "#" it) elgantt-alltags)) (point (car (elgantt--next-match :elgantt-alltags hashtag)))) (goto-char point)))) (elgantt-create-action follow-hashtag-link-backward :args (elgantt-alltags) :binding "C-M-b" :body ((when-let* ((hashtag (--first (s-starts-with-p "#" it) elgantt-alltags)) (point (car (elgantt--previous-match :elgantt-alltags hashtag)))) (goto-char point))))
The following functions are included to aid customizing the display. See docstrings for more information.
Drawing the display
Create overlays with
Draw a gradient with
Draw a progress bar with
Here is an example of how to use
elgantt--draw-progress-bar Suppose you have the following org file:
* TODO read The Illuminatus! Trilogy SCHEDULED: <2020-06-02 Tue> DEADLINE: <2020-07-21 Tue> :PROPERTIES: :TOTAL_PAGES: 667 :PAGES_READ: 555 :ID: 99a97ef7-b555-4f98-bdd3-7e44510ac7a4 :END:
The following code:
(elgantt-create-display-rule pages-read-progress :parser ((total-pages . ((string-to-number (org-entry-get (point) "TOTAL_PAGES")))) (pages-read . ((string-to-number (org-entry-get (point) "PAGES_READ"))))) :args (elgantt-deadline elgantt-scheduled) :body ((when (and elgantt-deadline elgantt-scheduled total-pages pages-read) (let* ((start (progn (elgantt--goto-date elgantt-scheduled) (point))) (end (progn (elgantt--goto-date elgantt-deadline) (point))) (percent (/ (float pages-read) (float total-pages)))) (elgantt--draw-progress-bar "red" "blue" start end percent)))))
Will automatically display a progress bar starting at the scheduled date, to the deadline date, displaying a progress bar that represents the percent of pages read: Note: the above code will generate an error if it is run on an org file that does not have the “TOTAL_PAGES” and “PAGES_READ” properties, because
org-entry-get will return nil, which will cause
string-to-number to fail. Instead, you should do something like:
:parser ((total-pages . ((--when-let (org-entry-get (point) "TOTAL_PAGES") (string-to-number it)))) (pages-read . ((--when-let (org-entry-get (point) "PAGES_READ") (string-to-number it)))))
Or some other solution if you don’t like
Draw a line from one cell to another with
elgantt--draw-line. See also
Juxtapose text on top of a cell with
elgantt--insert-juxtaposition and clear them with
Change the character of a cell (while preserving text properties) with
Navigating the buffer
Move to a cell by org-id with
Move to a date on the current line with
Iterate over all entries with
Selecting from multiple entries
Some cells will have multiple entries. To prompt the user to pick which one should be used:
Getting calendar data
To get the date at point:
To get the properties of a cell:
This will always return a list, and if there are multiple entries in the cell at point it will list all values. Without any arguments, it will return all properties.
Editing the underlying org file
Use the macro
elgantt-with-point-at-orig-entry to execute code at the underlying org heading.
You can’t reload a single cell because doing so invites catastrophe. But you can update all cells for the date at point:
The display (i.e., overlays) of a single cell can be redrawn with
elgantt--update-display-this-cell or all cells with
If all else fails, reload everything with
A note about org-ql: Org-ql creates a cache of its results and uses that cache until the underlying org file is changed. If you change something about the way the calendar is displayed, odds are that there will be a problem with using the org-ql cache. For this reason, all reloading invalidates the org-ql cache by calling
elgantt--reset-org-ql-cache which simply sets
org-ql-cache to its initial value. This seems to solve reloading problems.
Creating custom views
You can create custom views of the gantt chart/calendar by defining a function like this. Don’t try to let-bind the variables and then call
elgantt-open open inside the closure; things will break. You can use
setq and do not need to use the customize interface.
(defun elgantt-outline-folded () (interactive) (setq elgantt-start-date nil elgantt-scroll-to-current-month-at-startup nil elgantt-agenda-files "~/.emacs.d/lisp/elgantt/test.org" elgantt-startup-folded nil elgantt-insert-header-even-if-no-timestamp t elgantt-header-type 'outline elgantt-show-header-depth t elgantt-header-column-offset 30 elgantt-even-numbered-line-change 5) (elgantt-open))
If you want to use custom display macros, then you should call
(elgantt--clear-all-customizations) and then include your custom macros inside the function.
Faces and themes
Elgantt should adjust its colors to work with your theme, regardless of whether it is dark or light.
Iteracting with the calendar
There are two ways to interact with the calender: the
elgantt-create-action macro and the separate module,
This macro works the same way as
elgantt-create-display-rule except that has keywords for binding commands. I don’t use this macro for anything, but you could use it to perform actions on the org-file from the calendar (e.g., marking a TODO as DONE).
To use this, you must
This module experimental. The code is not cleaned up. It was written in a frenzy of wondering whether I could without considering whether I should. If this inspires ideas for others to use it, I will return to it. Otherwise, unless I have a need, I plan to abandon it.
Here is an example I use to set the
:ELGANTT-COLOR property used in the example above. It is designed to allow the user to select cells and perform actions on them in a certain sequence. Here, it allows the user to make two selections, and when return is pressed, it will prompted the user to enter two colors, and then set the properties of the relevant org heading.
While this example works, the code in
elgantt-interaction is generally untested. I do not know whether I will develop it further absent a need to do so. The framework, in theory, provides a robust way to create ways to interact with the calendar and perform actions on multiple org entries.
To invoke the interface, press
a to be prompted to select which interface you’d like to execute. After that, a counter should appear which shows the number of cells selected. The message displayed is defined by the
:selection-messages keyword. Once the cells are selected (by pressing
space), the user presses
Return to execute the command. The execution functions will be run in the order listed in
:execution-functions. The first number refers to cells in the order in which they were selected. The variable
return-val is the return value of the previous function.
So, here, the user selects two cells and presses return. Then, the program moves to the second selected cell, and runs
org-id-get-create, and returns the value. The section function moves to the first cell that the user selected, and adds the ID of the second selection (i.e.,
return-val), and then prompts the user for two colors and sets the properties of that heading appropriatly.
In addition to being able to use numbers to refer to cells by the order in which they were selected, you can use
last to refer to the cells and perform operations on them.
(require 'elgantt-interaction) (elgantt--selection-rule :name colorize :selection-number 2 :selection-messages ((1 . "Select first cell") (2 . "Select second cell")) :execution-functions ((2 . ((elgantt-with-point-at-orig-entry nil (org-id-get-create)))) (1 . ((elgantt-with-point-at-orig-entry nil (org-set-property "ELGANTT-LINKED-TO" return-val) (org-set-property "ELGANTT-COLOR" (concat (s-trim (read-color "Select start color:")) " " (s-trim (read-color "Select end color:"))))))))) ;; You’ll also need to use this to colorize (setq elgantt-user-set-color-priority-counter 0) ;; There must be a counter to ensure that overlapping overlays are handled properly (elgantt-create-display-rule user-set-color :parser ((elgantt-color . ((when-let ((colors (org-entry-get (point) "ELGANTT-COLOR"))) (s-split " " colors)))) (elgantt-linked-to . ((org-entry-get (point) "ELGANTT-LINKED-TO")))) :args (elgantt-org-id) :body ((when elgantt-linked-to (save-excursion (when-let ((point1 (point)) (point2 (let (date) ;; Cells can be linked even if they are not ;; in the same header in the calendar. Therefore, ;; we have to get the date of the linked cell, and then ;; move to that date in the current header (save-excursion (elgantt--goto-id elgantt-linked-to) (setq date (elgantt-get-date-at-point))) (elgantt--goto-date date) (point))) (color1 (car elgantt-color)) (color2 (cadr elgantt-color))) (when (/= point1 point2) (elgantt--draw-gradient color1 color2 (if (< point1 point2) point1 point2) ;; Since cells are not necessarily linked in (if (< point1 point2) point2 point1) ;; chronological order, make sure they are sorted nil `(priority ,(setq elgantt-user-set-color-priority-counter (1- elgantt-user-set-color-priority-counter)) ;; Decrease the priority so that earlier entries take ;; precedence over later ones :elgantt-user-overlay ,elgantt-org-id))))))))
Here is a second example I played with previously, which provided a more advanced way to link cells/headings together. You can see the use of
return-val being passed from one execution function to the next. This is included only for the purposes of illustrating how to use the macro.
(elgantt--selection-rule :name set-anchor :parser ((:elgantt-dependents . ((when-let ((dependents (cdar (org-entry-properties (point) "ELGANTT-DEPENDENTS")))) (s-split " " dependents))))) :execution-functions ((2 . ((elgantt-with-point-at-orig-entry nil (org-id-get-create)))) (1 . ((elgantt-with-point-at-orig-entry nil (let ((current-heading-id (org-id-get-create))) (org-set-property "ELGANTT-DEPENDENTS" (format "%s" (substring (if (member return-val elgantt-dependents) elgantt-dependents (push return-val elgantt-dependents)) 1 -1))))))) (2 . ((elgantt-with-point-at-orig-entry nil (org-set-property "ELGANTT-ANCHOR" return-val))))) :selection-messages ((1 . "Select the anchor.") (rest . "Select the dependents.")) :selection-number 0)
This was previously accompanied by code that allowed the user to move the date of dependent cells by moving the anchor cell, and which highlighted all dependent cells when the point was on an anchor. I abandoned this for various reasons. If there is interest in this level of interface I can clean it up and get it working.
I’ll save you the trouble:
This is a hobby and a continued exercise in learning elisp and programming, and I realized a lot of things along the way. Mostly, I realized that programming is not as much fun as I thought it was, and takes way more time than it should. I don’t have the patience to clean up the code like I should. There are byte-compile warnings. I do not care.
I originally wrote that I hoped publishing this would get it out of my life, but it seems there is interest so I will push this as far as my time and ability will allow. If you can help, please help.
Can you fold and unfold without reloading?
Not without significant changes to the code, or breaking other existing features. You’ll just have to change the value of
elgantt-startup-folded and reload.
Why so many gradients?
They are pretty. You can also customize where the midpoint of the gradient appears so it reflects remaining time. If you don’t like gradients, then just use the same start and end color.