legalnonsense / elgantt

A Gantt Chart (Calendar) for Org Mode
GNU General Public License v3.0
393 stars 16 forks source link

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. Feature requests and bug reports are welcome. Help is welcome.

[[file:screenshots/output-2020-07-20-14:25:27.gif]] ** Installation Install the dependencies:

Before running this on your org-agenda files, you may want to experiment with the test file. *** Using the test file Set =elgantt-agenda-files= to the location wherever you installed =elgantt=. If you cloned to the =lisp= subdirectory, then:

+begin_src emacs-lisp :results silent

(setq elgantt-agenda-files (concat user-emacs-directory "lisp/elgantt/test.org"))

+end_src

And run =(elgantt-open)=. You’ll see this rather bland looking chart: [[file:screenshots/Screenshot_2020-07-20_20-20-20.png]] * Customization 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: | Value | Behavior | |----------+-------------------------------------------------------------------------------------------| | 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:

[[file:screenshots/unfolded-outline-with-space-betwee-headers.png]] *** Same as above, but folded

+begin_src emacs-lisp :results silent

(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)

+end_src

[[file:screenshots/folded-outline.png]] Note: When two colored gradients overlap, the average of the two gradients will be used for the display. This way, you can still see both spans of time. (Though the result is not always pretty.) *** Use hashtags, folded, with no spaces

+begin_src emacs-lisp :results silent

(setq elgantt-header-type 'hashtag elgantt-insert-blank-line-between-top-level-header nil elgantt-startup-folded t)

+end_src

[[file:screenshots/folded-hashtag-no-space.png]]

What does it look like unfolded?

[[file:screenshots/Screenshot_2020-07-20_20-39-11.png]] *** A custom header Here’s a silly example that will group headers by the first letter ofo the headline

+begin_src emacs-lisp :results silent

(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

+end_src

[[file:screenshots/Screenshot_2020-07-20_20-48-32.png]] ** Header line format The variable =elgantt-custom-header-line= controls the format of the header line. It can use any of the properties that are in a cell. You can reference these properties with the =:prop= keyword, with or without the =:elgantt-= prefix. (For example, you can access the headline of a cell’s entry with =:elgantt-headline= or =headline=. There is also a unique property =date-at-point= which will display the date at point and that is not dependent on the properties stored in the given cell. If there are multiple entries in the cell, then the data will be separated with a pipe (i.e, =|=). You can align text in the headerline to the left, center, or right side of the header. If there is an overlap, the latter properties will take precedence over the former. If the property doesn’t return a string, it will be formatted into a string with =(format "%s")=.

The =:prop= keyword can also be a function that is run at the cell at point.

The header line is disabled by default while I finish sorting out the variable and function that handles it. You can enable it with =(setq header-line-format '(:eval (elgantt-header-line-function)))=. This will use the default value for =elgantt-custom-header-line=, which is:

+begin_src emacs-lisp :results silent

(setq elgantt-custom-header-line '((:left ((:prop date-at-point :padding 25) (:prop headline :padding 25)))))

+end_src

Here is another example:

+begin_src emacs-lisp :results silent

(setq elgantt-custom-header-line '((:left ((:prop date-at-point ;; you could also use, for example, 'elgantt-get-date-at-point ;; or (lambda () (elgantt-get-date-at-point)) :padding 25) (:prop todo :padding 30))) (:center ((:prop headline))) (:right ((:prop hashtag :padding 40 :text-props (face (:background "red")))))))

+end_src

The header line is work-in-progress and this was my first attempt at a solution. Here is a list of all the properties: | Keyword | Description | |------------------------+----------------------------------------------------------------------------------------------------------------------------------------------| | :prop | Any property stored in a cell that is retrievable with =elgantt-get-prop-at-point=, or a function that is run at the cell at point | | :padding | Integer which indicates the padding before the next entry (defaults to no padding) | | :after-pad | If the length of the string exceeds the value of :padding, still separate this entry from the following by this number of padding characters | | :padding-char | The character used for padding. Can be any single character. Defaults to a space | | :text-props | Any text properties associated with the text. For example, you can set a custom face with =(face '(:background "red"))= | 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. ** The =elgantt-create-display-rule= macro 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: | Property | Value | |-----------------------------+-------------------------------------------------------------------------------------------------------| | :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-header-type= | | :elgantt-date | Date used for insertion into the calendar. Uses the first date found in =elgantt-timestamps-to-display= | | :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:

+begin_src emacs-lisp :results silent

(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 ;; sinceov' is not a dependency. (elgantt--create-overlay (point) (1+ (point)) '(face (:background "red"))))))

+end_src

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:

+begin_src emacs-lisp :results silent

(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)))))

+end_src

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.

+begin_src emacs-lisp :results silent

(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)))))))

+end_src

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: [[file:screenshots/output-2020-07-21-12:39:52.gif]]

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:

+begin_src emacs-lisp :results silent

:parser ((property-name1 . ((expression))) (property-name2 . ((expression))))

+end_src

So, to add the property to get the priority of an org heading:

+begin_src emacs-lisp :results silent

(elgantt-create-display-rule priority-display
  :parser ((elgantt-priority . ((org-entry-get (point) "PRIORITY"))))
  :body (())) ;; insert code here, which can use elgantt-priority variable

+end_src

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. * Examples ** Other ways to colorize time blocks Here is how I colorize blocks of time. It depends on two org properties: =ELGANTT-COLOR= and =ELGANTT-LINKED-TO=. =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.

+begin_src emacs-lisp :results silent

(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))))))))

+end_src

**** Linking cells with =elgantt--connect-cells= 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:

+begin_src emacs-lisp :results silent

(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))))

+end_src

[[file:screenshots/output-2020-07-20-14:14:55.gif]] * Helper functions The following functions are included to aid customizing the display. See docstrings for more information. * Drawing the display Create overlays with =elgantt--create-overlay=. Draw a gradient with =elgantt--draw-gradient.= Draw a progress bar with =elgantt--draw-progress-bar.= Here is an example of how to use =elgantt--draw-progress-bar= Suppose you have the following org file:

+begin_src org

The display (i.e., overlays) of a single cell can be redrawn with =elgantt--update-display-this-cell= or all cells with =elgantt--update-display-all-cells=.

If all else fails, reload everything with =elgantt-open=.

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.

+begin_src emacs-lisp :results silent

(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))

+end_src

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. Interacting with the calendar There are two ways to interact with the calender: the =elgantt-create-action= macro and the separate module, =elgantt-interaction=. **** =elgantt-create-action= 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).

**** =elgantt-interaction= To use this, you must =(require 'elgantt-interaction)=.

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-LINKED-TO= and =: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 =all=, =rest=, =all-but-last=, and =last= to refer to the cells and perform operations on them.

+begin_src emacs-lisp :results silent

(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))))))))

+end_src

[[file:screenshots/output-2020-07-21-12:27:23.gif]]

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.

+begin_src emacs-lisp :results silent

(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)

+end_src

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. ** FAQ *** Your code... I’ll save you the trouble:

[[file:screenshots/code_quality.png]]

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. ** Change log *** [2020-07-31 Fri] Added =elgantt-custom-header-line= and =elgantt--header-line-formatter=