§¿Qué es tabulated-list-mode?

Según (describe-symbol 'tabulated-list-mode):

Modo mayor genérico para navegar una lista de elementos. Este modo usualmente no es usado directamente; en lugar de ello, otros modos mayores (major modes) son derivados de él, usando define-derived-mode.

En este modo mayor, el buffer es dividido en varias columnas, las cuales están etiquetadas usando la línea de cabezera. Cada línea no-vacía pertecence a una «entrada», y las entradas pueden ser ordenadas de acuerdo con sus valores.

§¿Para qué me puede servir?

Bueno, cada quien tiene que decidir la respuesta a esta pregunta, pero de manera genérica, podríamos decir que te sirve para presentar información en una tabla cuyas filas pueden ser ordenadas en pantalla según el contenido de las columnas. Algo similar a lo que vemos en Microsoft Excell™ o Libre Office Calc (y similares), cuando hacemos un auto filtro.

En esta ocasión haremos un pequeño proyecto para crear una libreta de direcciones con tabulated-list-mode y org-mode.

§Implementación

Primero necesitamos definir el modo que crearemos, en nuestro caso, lo llamaremos org-pim-tabulated-list-mode (poco original, lo sé, pero a veces así es suficiente).

(define-derived-mode org-pim-tabulated-list-mode tabulated-list-mode
  "org-pim-tabulated"
  "Major mode to display in `tabulated-list-mode' the information of our PIM."
  (let ((columns '[
                   ("Apellidos" 15 t)
                   ("Nombres"   15 t)
                   ("F. Nac."   20 t)
                   ("L. Nac."   30 t)
                   ("F. Def."   20 t)
                   ("L. Def."   30 t)]))
    (setq-local tabulated-list-format columns
                tabulated-list-entries
                '((nil ["de la Cruz"
                        "Sor Juana Inés"
                        "[1648-11-12]"
                        "San Miguel Nepantla, Tepetlixpa"
                        "[1695-04-17]"
                        "Ciudad de México, Nueva España"])
                  (nil ["Mistral"
                        "Gabriela"
                        "[1898-04-07]"
                        "Vicuña, Chile"
                        "[1957-01-10]"
                        "Hempstead, Estados Unidos"])
                  (nil ["Reyes"
                        "Alfonso"
                        "[1889-05-17]"
                        "Monterrey, México"
                        "[1959-12-27]";27 de diciembre de 1959 (70 años)
                        "Ciudad de México, México"])
                  ))
    (tabulated-list-init-header)
    (tabulated-list-print)
    (setq-local buffer-read-only t)
    (hl-line-mode +1)))

(defun org-pim-tabulated ()
  "Displays my PIM information in `tabulated-list-mode'."
  (interactive)
  (with-current-buffer (get-buffer-create "*Org-PIM*")
    (switch-to-buffer (current-buffer))
    (org-pim-tabulated-list-mode)))
org-pim-tabulated

Pero no nos conviene que cada vez que queramos agregar información (entradas) en nuestro sistema, tengamos que modificar el código. Así que hay que pensar en una forma de guardar toda esta información fuera del código fuente.

Lo ideal (en muchos casos) sería una base de datos. Pero no me interesa tener que agregar la complejidad de administrar una DB, ni de tener mis datos en formato binario. La solución (quizá no muy obvia) es documentos en org-mode.

La idea es tener un repositorio con archivos (ficheros) individuales que contengan la información de una sola persona. Sé que quizá no sea lo más eficiente, pero por el momento es hasta donde llega mi habilidad con emacs-lisp y org-mode.

Para ello deberemos informar a la implementación de nuestro sistema cuál sería el directorio donde debería buscar dicha información. Y en lugar de realizar una consulta al usuario, podríamos símplemente definirlo en una varibale.

;; Definimos el grupo de customización
(defgroup org-pim nil
  "Store your PIM data in `org-mode'."
  :tag "Org PIM"
  :group 'org)

;; Definimos la variable de customización
(defcustom org-pim-directory (expand-file-name "org-pim" org-directory)
  "Directory to store individual PIM files."
  :type 'directory
  :group 'org-pim)
org-pim-directory

Decidí cambiar de una simple variable a una customización, ya que esta es una variable que es posible que el usuario deba estar cambiando o al menos customizando, y (aunque no me gusta utilizar la interfaz de customize) no puedo negar que es una herramienta muy util y que aumenta la accesibilidad.

Ahora tenemos que definir cómo vamos a almacenar la información en ese directorio y en los archivos individuales.

(defun org-pim--list-files ()
  "Lists the content of `org-pim-directory'."
  (when (and (file-exists-p org-pim-directory)
             (file-directory-p org-pim-directory))
    (directory-files org-pim-directory t "\\.org$")))
org-pim--list-files

Como ya vimos, los archivos serán nombrados según el nombre de la persona, iniciando con los apellidos, y terminando con los nombres, cambiando los espacios por guiones y la separación entre apellidos y nombres con doble guión. Eso es sólo por conveniencia; podría haber nombrado los archivos por la fecha y hora en que fueron creados, con un UUID, o cualquier otro método, pero para este ejemplo los nombres de las personas a las que el archivo hace referencia es suficiente.

También vimos que los datos de cada persona será guardada como una propiedad.

Ahora necesitamos un modo de extraer la información de cada archivo individual.

(defvar org-pim--open-buffers '()
  "List of buffers opened by `org-pim'.")

(defun org-pim--get-org-file-property (file property)
  "Returns the value of PROPERTY in FILE."
  (with-current-buffer (find-file-noselect file)
    (add-to-list 'org-pim--open-buffers (current-buffer))
    (goto-char (point-min))
    (car (org-property-values property))))

(defun org-pim--close-opened-buffers (&optional save-files)
  "Closes all the buffers listed in `org-pim--open-buffers'.

If SAVE-FILES is non-nil, it will write the content of the buffer
to its file."
  (dolist (buffer org-pim--open-buffers)
    (with-current-buffer buffer
      (when (and save-files
                 (buffer-modified-p))
        (write-file (buffer-file-name)))
      (kill-buffer (current-buffer)))))

(defun org-pim--generate-entry (file fields)
  "Generates a `tabulated-list-mode' entry from FILE with FIELDS' values."
  (with-current-buffer (find-file-noselect file)
    (add-to-list 'org-pim--open-buffers (current-buffer))
    (goto-char (point-min))
    (let ((data
           (mapcar (lambda (fld)
                     (or (car (org-property-values fld)) ""))
                   fields)))
      `(,file ,(seq--into-vector data)))))
org-pim--generate-entry

Con esto ya tenemos todo listo para crear nuestra org-pim-tabulated-list.

(define-derived-mode org-pim-tabulated-list-mode tabulated-list-mode
  "org-pim-tabulated"
  "Major mode to display in `tabulated-list-mode' the information of our PIM."
  (let ((columns '[("Apellidos" 15 t)
                   ("Nombres"   15 t)
                   ("F. Nac."   20 t)
                   ("L. Nac."   30 t)
                   ("F. Def."   20 t)
                   ("L. Def."   30 t)])
        (fields '("Apellidos"
                  "Nombres"
                  "FNac"
                  "LNac"
                  "FDef"
                  "LDef")))
    (setq-local tabulated-list-format columns
                tabulated-list-entries
                (mapcar (lambda (file)
                          (org-pim--generate-entry file fields))
                        (org-pim--list-files))))
  (tabulated-list-init-header)
  (tabulated-list-print)
  (setq-local buffer-read-only t)
  (hl-line-mode +1)
  (org-pim--close-opened-buffers)))
org-pim-tabulated-list-mode

La tabla ya está lista y ya podemos visualizar la información de manera comprensiva, pero aún no podemos hacer nada con esa información.

Vamos ahora a crear un comando para abrir (visitar) el archivo de la entrada en que esté el cursor (la entrada resaltada con hl-line-mode).

(defun org-pim-visit-entry-file ()
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (let ((file (tabulated-list-get-id)))
      (when (file-exists-p file)
        (find-file file)))))

(define-key org-pim-tabulated-list-mode-map (kbd "") #'org-pim-visit-entry-file)
org-pim-visit-entry-file

También podemos crear un comando para crear nuevos archivos de entradas.

(defun org-pim--generate-entry-file-name (last first)
  "Generates a filename based on the LAST and FIRST names."
  (expand-file-name
   (format "%s--%s.org"
           (replace-regexp-in-string " +" "-" last)
           (replace-regexp-in-string " +" "-" first))
   org-pim-directory))

(defun org-pim-new-entry ()
  (interactive)
  (let* ((last (read-string "Apellidos: "))
         (first (read-string "Nombres: "))
         (birthD (read-string "Fecha de nacimiento: "))
         (birthL (read-string "Lugar de nacimiento: "))
         (deathD (read-string "Fecha de defunción: "))
         (deathL (read-string "Lugar de defunción: "))
         (file (org-pim--generate-entry-file-name last first)))
    (unless (file-exists-p file)
      (with-current-buffer (find-file-noselect file)
        (org-set-property "Apellidos" last)
        (org-set-property "Nombres" first)
        (org-set-property "FNac" birthD)
        (org-set-property "FDef" deathD)
        (org-set-property "LNac" birthL)
        (org-set-property "LDef" deathL)
        (goto-char (point-max))
        (insert "\n\n")
        (insert "* Obra literaria\n")
        (insert ":PROPERTIES:"\n)
        (insert ":COLUMNS:   %35ITEM %10Publicado %Genero\n")
        (insert ":END:\n")
        (insert "\n* COMMENT Local Variables\n")
        (add-file-local-variable "eval" "(org-columns)")
        (write-file (buffer-file-name))
        (kill-buffer (current-buffer)))
      (org-pim-tabulated-refresh)
      )))

(define-key org-pim-tabulated-list-mode-map (kbd "+") #'org-pim-new-entry)

(defun org-pim-tabulated-refresh ()
  "Closes the buffer `*Org-PIM*' and regenerates it."
  (interactive)
  (when (and (equal major-mode 'org-pim-tabulated-list-mode)
             (string-equal "*Org-PIM*" (buffer-name)))
    (setq-local )
    (kill-buffer (current-buffer))
    (org-pim-tabulated)
    (message "Buffer *Org-PIM* refreshed!")))

(define-key org-pim-tabulated-list-mode-map (kbd "g") #'org-pim-tabulated-refresh)
org-pim-tabulated-refresh

Ahora, ya que nuestro proyecto ha estado tomando como referencia a figuras notables de la literatura latinoamericana, trataremos de agregar al fichero de cada persona su obra literaria (sólo la referencia, por supuesto).

Para ello podemos agregar una nota utilizando org-capture.

(defun org-pim-add-literary-work ()
  "Captures a literary work for the entry using `org-capture'."
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (let ((org-capture-templates
           `(("X" "Capture a literary work"
              entry (file+headline ,(tabulated-list-get-id)
                                   "Obra literaria")
              "* %^{Título}%^{Publicado}p%^{Genero}p\n%?"))))
      (org-capture t "X"))))

(defun org-pim-kill-buffer ()
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (kill-buffer (current-buffer))))

(defun org-pim-view-literary-work ()
  ""
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (let ((org-startup-folded 'content))
      (find-file (tabulated-list-get-id))
      (when (locate-library "visual-fill-column")
        (visual-fill-column-mode -1))
      (org-fold-show-set-visibility 'content)
      (goto-char (point-min))
      (search-forward-regexp "^\\* Obra literaria" nil :noerror)
      (org-narrow-to-subtree)
      (org-columns))))

(define-key org-pim-tabulated-list-mode-map (kbd "l") #'org-pim-view-literary-work)
(define-key org-pim-tabulated-list-mode-map (kbd "L") #'org-pim-add-literary-work)
(define-key org-pim-tabulated-list-mode-map (kbd "q") #'bury-buffer)
(define-key org-pim-tabulated-list-mode-map (kbd "Q") #'org-pim-kill-buffer)
org-pim-kill-buffer

De aquí en más, el límite es tu imaginación.

§Organicemos el código

§Variables

;;; Variables
;; Definimos el grupo de customización
(defgroup org-pim nil
  "Store your PIM data in `org-mode'."
  :tag "Org PIM"
  :group 'org)

;; Definimos la variable de customización
(defcustom org-pim-directory (expand-file-name "org-pim" org-directory)
  "Directory to store individual PIM files."
  :type 'directory
  :group 'org-pim)

;; Variable de trabajo, no hace falta customizarla.
(defvar org-pim--open-buffers '()
  "List of buffers opened by `org-pim'.")

§Funciones (no interactivas)

;;; Functions
(defun org-pim--list-files ()
  "Lists the content of `org-pim-directory'."
  (when (and (file-exists-p org-pim-directory)
             (file-directory-p org-pim-directory))
    (directory-files org-pim-directory t "\\.org$")))

(defun org-pim--close-opened-buffers (&optional save-files)
  "Closes all the buffers listed in `org-pim--open-buffers'.

If SAVE-FILES is non-nil, it will write the content of the buffer
to its file."
  (dolist (buffer org-pim--open-buffers)
    (with-current-buffer buffer
      (when (and save-files
                 (buffer-modified-p))
        (write-file (buffer-file-name)))
      (kill-buffer (current-buffer)))))

(defun org-pim--generate-entry (file fields)
  "Generates a `tabulated-list-mode' entry from FILE with FIELDS' values."
  (with-current-buffer (find-file-noselect file)
    (add-to-list 'org-pim--open-buffers (current-buffer))
    (goto-char (point-min))
    (let ((data
           (mapcar (lambda (fld)
                     (or (car (org-property-values fld)) ""))
                   fields)))
      `(,file ,(seq--into-vector data)))))

(defun org-pim--generate-entry-file-name (last first)
  "Generates a filename based on the LAST and FIRST names."
  (expand-file-name
   (format "%s--%s.org"
           (replace-regexp-in-string " +" "-" last)
           (replace-regexp-in-string " +" "-" first))
   org-pim-directory))

§Modos

;;; Modes
(define-derived-mode org-pim-tabulated-list-mode tabulated-list-mode
  "org-pim-tabulated"
  "Major mode to display in `tabulated-list-mode' the information of our PIM."
  (let ((columns '[("Apellidos" 15 t)
                   ("Nombres"   15 t)
                   ("F. Nac."   20 t)
                   ("L. Nac."   30 t)
                   ("F. Def."   20 t)
                   ("L. Def."   30 t)])
        (fields '("Apellidos"
                  "Nombres"
                  "FNac"
                  "LNac"
                  "FDef"
                  "LDef")))
    (setq-local tabulated-list-format columns
                tabulated-list-entries
                (mapcar (lambda (file)
                          (org-pim--generate-entry file fields))
                        (org-pim--list-files))))
  (tabulated-list-init-header)
  (tabulated-list-print)
  (setq-local buffer-read-only t)
  (hl-line-mode +1)
  (org-pim--close-opened-buffers)))

§Comandos (funciones interactivas)

;;; Commands
(defun org-pim-tabulated ()
  "Displays my PIM information in `tabulated-list-mode'."
  (interactive)
  (with-current-buffer (get-buffer-create "*Org-PIM*")
    (switch-to-buffer (current-buffer))
    (org-pim-tabulated-list-mode)))

(defun org-pim-visit-entry-file ()
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (let ((file (tabulated-list-get-id)))
      (when (file-exists-p file)
        (find-file file)))))

(defun org-pim-new-entry ()
  (interactive)
  (let* ((last (read-string "Apellidos: "))
         (first (read-string "Nombres: "))
         (birthD (read-string "Fecha de nacimiento: "))
         (birthL (read-string "Lugar de nacimiento: "))
         (deathD (read-string "Fecha de defunción: "))
         (deathL (read-string "Lugar de defunción: "))
         (file (org-pim--generate-entry-file-name last first)))
    (unless (file-exists-p file)
      (with-current-buffer (find-file-noselect file)
        (org-set-property "Apellidos" last)
        (org-set-property "Nombres" first)
        (org-set-property "FNac" birthD)
        (org-set-property "FDef" deathD)
        (org-set-property "LNac" birthL)
        (org-set-property "LDef" deathL)
        (goto-char (point-max))
        (insert "\n\n")
        (insert "* Obra literaria\n")
        (insert ":PROPERTIES:\n")
        (insert ":COLUMNS:   %35ITEM %10Publicado %Genero\n")
        (insert ":END:\n")
        (write-file (buffer-file-name))
        (kill-buffer (current-buffer)))
      (org-pim-tabulated-refresh)
      )))

(defun org-pim-tabulated-refresh ()
  "Closes the buffer `*Org-PIM*' and regenerates it."
  (interactive)
  (when (and (equal major-mode 'org-pim-tabulated-list-mode)
             (string-equal "*Org-PIM*" (buffer-name)))
    (setq-local )
    (kill-buffer (current-buffer))
    (org-pim-tabulated)
    (message "Buffer *Org-PIM* refreshed!")))

(defun org-pim-add-literary-work ()
  "Captures a literary work for the entry using `org-capture'."
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (let ((org-capture-templates
           `(("X" "Capture a literary work"
              entry (file+headline ,(tabulated-list-get-id)
                                   "Obra literaria")
              "* %^{Título}%^{Publicado}p%^{Genero}p\n%?"))))
      (message "%S" org-capture-templates)
      (org-capture t "X"))))

(defun org-pim-kill-buffer ()
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (kill-buffer (current-buffer))))

(defun org-pim-view-literary-work ()
  ""
  (interactive)
  (when (equal major-mode 'org-pim-tabulated-list-mode)
    (let ((org-startup-folded 'content))
      (find-file (tabulated-list-get-id))
      (when (locate-library "visual-fill-column")
        (visual-fill-column-mode -1))
      (org-fold-show-set-visibility 'content)
      (goto-char (point-min))
      (search-forward-regexp "^\\* Obra literaria" nil :noerror)
      (org-narrow-to-subtree)
      (org-columns))))

§Atajos de teclado (bindings)

;;; Bindings
(define-key org-pim-tabulated-list-mode-map (kbd "") #'org-pim-visit-entry-file)
(define-key org-pim-tabulated-list-mode-map (kbd "+") #'org-pim-new-entry)
(define-key org-pim-tabulated-list-mode-map (kbd "g") #'org-pim-tabulated-refresh)
(define-key org-pim-tabulated-list-mode-map (kbd "L") #'org-pim-add-literary-work)
(define-key org-pim-tabulated-list-mode-map (kbd "l") #'org-pim-view-literary-work)
(define-key org-pim-tabulated-list-mode-map (kbd "q") #'bury-buffer)
(define-key org-pim-tabulated-list-mode-map (kbd "Q") #'org-pim-kill-buffer)
org-pim-kill-buffer

§Observaciones

Este código lo he hecho básicamente para demostrar el potencial de `tabulated-list-mode’ y cómo podemos ligarlo a `org-mode’ y todo su potencial.

Sin duda hay muchas cosas que pueden mejorarse y realizarse de modo más eficiente, pero para el objetivo de este artículo con esto basta.

Ahora te toca a ti comenzar a pulir tus habilidades con emacs-lisp en Emacs.

¡Diviértete!