;;; org-select.el --- Build custom menus from declarative templates -*- lexical-binding: t; -*- ;; Copyright (C) 2022 Arthur Miller ;; Author: Arthur Miller ;; Keywords: tools ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; Org-select is a selection framework meant to be simple and easy to use. Its ;; job is to display a list of choices for a user to pick from and to hand in ;; the selection to the client application. Org-select can be used to allow ;; either a single selection at a time, or for repeated selections from a ;; menu-like text-buffer. ;; One of goals with this framework is to be easy to setup for the client ;; code. For that reason, org-select uses simple template language modeled after ;; org-capture templates. ;;; Code: (require 'org-macs) ;;; User vars (defgroup org-select nil "Create menus from declarative templates." :prefix "org-select-" :prefix "osl--" :tag "Org Select" :group 'org) (defcustom org-select-back-key [f10] "Used to render string for the horizontal separator." :type 'character :group 'org-select) (defcustom org-select-horizontal-separator "|" "Used to render string for the horizontal separator." :type 'string :group 'org-select) (defcustom org-select-vertical-separator "-" "Used to render string for the vetical separator." :type 'string :group 'org-select) (defcustom org-select-key-decorator-chars "" "Characters used to decorate shortcut keys. This string should contain only two characters, the first one for the left decorator and the second one for the right decorator. Example: string \"[]\" will render key \"C\" as \"[C]\"." :type 'string :group 'org-select) (defcustom org-select-label-decorators (cons "..." "...") "Used to render string for the vetical separator." :type 'cons :group 'org-select) ;;; Implementation (defvar-local osl--init nil) (defvar-local osl--args nil) (defvar-local osl--buffer nil) (defvar-local osl--menu-begin nil) (defvar-local osl--buffer-menu nil) (defvar-local osl--longest-label 0) (defvar-local osl--buffer-window nil) (defvar-local org-select-mode-map nil) (defvar-local osl--horizontal-layout nil) (defvar-local osl--current-menu-column nil) (defvar-local osl--handler-fn nil "The handler invoked when per-menu handler is not specified. The default one is org-select-run-once.") (define-minor-mode org-select-mode "" :interactive nil :global nil) ;;;; Help-functions (defun osl--arg (key) (plist-get osl--args key)) (defun osl--init () (buffer-local-value 'osl--init (current-buffer))) (defun osl--prop (property list) "Return value of PROPERTY from irregular plist LIST." (cadr (member property list))) (defun osl--ignore-key () (interactive) (message "Invalid key %S" ;; I am not happy but it works somewhat (edmacro-format-keys (vector last-input-event)))) (defun org-select-quit (&optional abort-message buffer-or-name) "Callback to quit an org-select buffer. If given, and optional ABORT-MESSAGE will be printed instead of the default one. BUFFER-NAME can be used to quit org-select mode from a non org-select buffer." (interactive) (let ((window (if buffer-or-name (get-buffer-window buffer-or-name) osl--buffer-window))) (when (window-live-p window) (select-window window) (quit-window (buffer-live-p buffer-or-name) window)) (message (or abort-message "Org Select Quit")))) (defun osl--make-mode-map () (let ((map (make-sparse-keymap))) (define-key map [?q] #'org-select-quit) (define-key map [?\C-g] #'org-select-abort) (define-key map [?\C-p] #'osl--back) (define-key map [remap newline] #'osl--ignore-key) (define-key map [remap self-insert-command] #'osl--ignore-key) (setq org-select-mode-map map) (use-local-map org-select-mode-map))) (defun org-select-abort () (interactive) (org-select-quit "Aborted")) (defun osl--back () (interactive) (when (bound-and-true-p org-select-mode) (osl--make-mode-map) (osl--draw))) (defun osl--longest-line () "Return the length of the longest line in current buffer." (let ((n 1) (L 0) (e 0) (E (point-max)) l) (while (< e E) (setq e (line-end-position n) l (- e (line-beginning-position n)) n (1+ n)) (if (> l L) (setq L l))) L)) (defun osl--decorate-key (key) "Place string KEY between characters specified in DECORATOR string." (let ((kd (if (> (length org-select-key-decorator-chars) 0) org-select-key-decorator-chars (osl--arg :key-decorator)))) (if (= (length kd) 2) (concat (substring kd 0 1) key (substring kd 1)) key))) (defun osl--decorate-label (entry) (let ((left (car org-select-label-decorators)) (right (cdr org-select-label-decorators))) (if (= (length entry) 2) (concat left (cadr entry) right) (cadr entry)))) (defun osl--make-separator (&optional marker length) (let ((len (or length (osl--longest-line))) (sep (if (osl--arg :horizontal) org-select-horizontal-separator org-select-vertical-separator))) (if marker (concat "sep" sep) (make-string len (string-to-char sep))))) (defun osl--insert-horizontal-separator (sep) (goto-char 1) (let ((lol (osl--longest-line)) (sep (or org-select-horizontal-separator sep))) (while (not (eobp)) (let* ((eol (line-end-position)) (bol (line-beginning-position)) (fill (- (+ bol lol) eol))) (goto-char eol) (if (> fill 0) (while (> fill 0) (insert " ") (setq fill (1- fill))) (while (> 0 fill) (delete-char 1) (setq fill (1+ fill)))) (insert " " sep " ")) (forward-line)) (setq osl--current-menu-column (+ lol (length sep) 2)))) (defun osl--insert-separator (sep) (if (osl--arg :horizontal) (osl--insert-horizontal-separator sep) (insert sep))) (defun osl--insert (&rest strings) (cond ((and (osl--arg :horizontal) (> osl--current-menu-column 0)) (goto-char (+ (line-beginning-position) osl--current-menu-column)) (apply #'insert strings) (if (char-after) (forward-line) (insert "\n"))) (t (apply #'insert strings) (insert "\n")))) (defun osl--forward-menu () (cond ((osl--arg :horizontal) (goto-char (point-min)) (goto-char (line-end-position)) (setq osl--current-menu-column (- (point) (line-beginning-position)))) (t (insert "\n")))) ;;;; Menu drawing (defun osl--setup-buffer (tables args) "Setup buffer local variables needed for an org-select buffer." (let* ((buffer (or (plist-get args :buffer-name) "*Org-select: ")) (window (get-buffer-window buffer))) (if window (select-window window) (org-switch-to-buffer-other-window buffer)) (with-current-buffer (get-buffer buffer) (special-mode) (setq cursor-type nil) (org-select-mode) (osl--make-mode-map) (setq osl--args args osl--buffer-menu tables osl--current-menu-column 0 osl--buffer (current-buffer) osl--buffer-window (get-buffer-window))))) (defun osl--draw () "Starts menu parsing." (with-silent-modifications (erase-buffer) (setq osl--init nil) (let ((marker (osl--make-separator 'marker)) (text (osl--arg :text)) (menus (buffer-local-value 'osl--buffer-menu (current-buffer)))) (setq osl--menu-begin (point)) ;; given a list of menus, display one menu at a time (dolist (menu menus) (cond ((symbolp menu) (setq menu (eval menu))) ((symbolp (car menu)) (setq menu (eval (car menu))))) (let ((handler (osl--prop :org-select-handler menu))) (when handler (setq menu (delete :org-select-handler (delete handler menu)))) (osl--do-menu menu (or handler #'org-select-run-once))) (setq menus (cdr menus)) (when menus (osl--insert-separator marker) (osl--forward-menu))) ;; redraw markers with real separator strings (goto-char 1) (let ((sep (osl--make-separator nil (osl--longest-line)))) (while (search-forward marker nil t) (replace-match "") (osl--insert-separator sep))) ;; insert info text if any (when text (goto-char 1) (insert "\n" text "\n")) (org-fit-window-to-buffer) (setq osl--init t) (goto-char 1)))) ; unnecessary but prettier if beacon-mode is active ;; iterate through menu and render a single entry or a group of entries on each ;; iteration (defun osl--do-menu (menu handler) "Insert one menu at a time." (while menu (let ((entry (car menu))) (setq menu (if (> (length entry) 2) (osl--do-entry menu handler) (osl--do-group menu handler)))))) (defun osl--do-group (menu handler) "Do a menu with group nodes." (let ((group (car menu)) newmenu) (osl--do-entry menu handler) (while (> (length (cadr menu)) 2) (let (entry newentry key) (setq menu (cdr menu) entry (car menu)) (setq key (substring (car entry) 1)) (push key newentry) (dolist (elt (cdr entry)) (push elt newentry)) (push (nreverse newentry) newmenu))) (setq newmenu (nreverse newmenu)) (define-key org-select-mode-map (kbd (car group)) (lambda () (interactive) (with-silent-modifications (erase-buffer) (setq osl--current-menu-column 0) (osl--do-menu newmenu handler)))) (cdr menu))) ;; return next group in chain ;; we send in the entire menu so we can return next piece in chain, ;; but *the* entry we work with is just the very first one (car menu) (defun osl--do-entry (menu handler) "Display a single entry in the buffer." (let* ((entry (car menu)) (key (car entry)) (line-length 0) (handler (or (osl--prop :org-select-handler entry) handler))) (define-key org-select-mode-map (kbd key) (lambda () (interactive) (let ((label (nth 1 entry)) (init (buffer-local-value 'osl--init osl--buffer))) (and init handler (message (or (funcall handler entry (current-buffer)) label)))))) (osl--insert (osl--decorate-key key) " " (osl--decorate-label entry)) (setq line-length (- (line-end-position) (line-beginning-position))) (if (> line-length osl--longest-label) (setq osl--longest-label line-length)) (cdr menu))) (defun org-select-run (entry &optional _org-select-buffer) "Try to execute form found in ENTRY if any leave ORG-SELECT-BUFFER live. This handler provides an easy way to use the framework for the simple use-cases for multiple choices. It relies on the user to press built-in choice `q' or `C-g' to exit the menu." (let* ((form (nth 2 entry)) (message (cond ((commandp form) (call-interactively form)) ((functionp form) (apply form (cddr entry))) (t (eval form))))) (if (stringp message) message))) (defun org-select-run-once (entry &optional org-select-buffer) "Try to execute form found in ENTRY if any and kill ORG-SELECT-BUFFER. Provides an easy way to use the framework for the simple use-cases for multiple choices. It relies on the user to press built-in choice `q' or `C-g' to exit the menu." (if org-select-buffer (org-select-quit "")) (let* ((form (nth 2 entry)) (message (cond ((commandp form) (call-interactively form)) ((functionp form) (apply form (cddr entry))) (t (eval form))))) (if (stringp message) message))) ;;; API (defun org-select (menus &rest args) "Select a member of an alist with multiple keys. MENUS is a list of menus which themselves are lists containing entries in one of following two formats: 1. prefix descriptions like (\"a\" \"Description\") This indicates that `a' is a prefix key for multi-letter selection, and that there are entries following with keys like \"ab\", \"ax\"... 2. Select-able members must have more than two elements, with the first being the string of keys that lead to selecting it, and the second a short description string of the item. The command will then make a temporary buffer listing all entries that can be selected with a single key, and all the single key prefixes. When you press the key for a single-letter entry, it is selected. When you press a prefix key, the commands (and maybe further prefixes) under this key will be shown and offered for selection. Each menu can be followed by some properties in form of a keu-value pair. The entire menu or entry does not need to be a regular plist. Following keys are recognized: :org-select-pin Pin this menu in org-select buffer. If group nodes are used, when this option is `t', keep this menu visible even when descending into a submenu. ;; FIXME Not implemented yet. :org-select-handler Use this function to handle this particular menu or entry. When none is specified, org-select uses `org-select-run-once' to hande the menu. Entry handler takes precedence over menu handler. If there are more than one menus, they will be separated by a separator line rendered with character as specified in `org-select-horizontal-separator'. ARGS is a org-select buffer or entry property list containing following members: :text a string placed over selections in the buffer. :buffer-name a string used for the selections buffer name. :key-decorator a two-character string used to decorate command characters. A menu can specify this string, but the precedence will be given the global variable `org-select-key-decorator-chars'. This to ensure that users can customize the appearance of the menus. Properties in ARGS list are global for the entire org-select buffer." (osl--setup-buffer menus args) (osl--draw)) ;;; Demo ;;;; org-capture (require 'org) (require 'org-capture) (defvar org-capture--current-goto nil) (defvar org-capture--current-keys nil) (defvar org-capture--old-window-config nil) (defun org-capture-test (&optional goto keys) "Simple illustration to recreate org-capture menu (visually only)." (interactive "P") (let ((org-select-vertical-separator "-") (org-capture-templates (or (org-contextualize-keys (org-capture-upgrade-templates org-capture-templates) org-capture-templates-contexts) '(("t" "Task" entry (file+headline "" "Tasks") "* TODO %?\n %u\n %a"))))) (if keys (or (assoc keys org-capture-templates) (error "No capture template referred to by \"%s\" keys" keys))) (cond ((equal goto '(4)) (org-capture-goto-target keys)) ((equal goto '(16)) (org-capture-goto-last-stored)) (t (if goto (setq org-capture--current-goto goto)) (push :org-select-handler org-capture-templates) (push #'org-capture--handler org-capture-templates) (org-select ;; tables `(,(nreverse org-capture-templates) (("C" "Customize org-capture-templates" (customize-variable 'org-capture-templates)) ("q" "Abort" (org-select-quit "Abort")))) ;; description :buffer-name "*Capture*" :key-decorator "[]" :text "Select a capture template\n=========================")))) (message "Org Capture")) ;;(define-key global-map (kbd "C-v c") #'org-capture-test) (defun org-capture--handler (entry org-select-buf) (org-select-quit "" org-select-buf) (let* ((capture-buf (generate-new-buffer "*Capture*")) (annotation (if (and (boundp 'org-capture-link-is-already-stored) org-capture-link-is-already-stored) (plist-get org-store-link-plist :annotation) (ignore-errors (org-store-link nil)))) (entry (or org-capture-entry entry)) (goto org-capture--current-goto) (inhibit-read-only t) initial) (setq initial (or org-capture-initial (and (org-region-active-p) (buffer-substring (point) (mark))))) (when (stringp initial) (remove-text-properties 0 (length initial) '(read-only t) initial)) (when (stringp annotation) (remove-text-properties 0 (length annotation) '(read-only t) annotation)) (org-capture-set-plist entry) (org-capture-get-template) (org-capture-put :original-buffer capture-buf :original-file (or (buffer-file-name capture-buf) (and (featurep 'dired) (car (rassq capture-buf dired-buffers)))) :original-file-nondirectory (and (buffer-file-name capture-buf) (file-name-nondirectory (buffer-file-name capture-buf))) :annotation annotation :initial initial :return-to-wconf (current-window-configuration) :default-time (or org-overriding-default-time (org-current-time))) (org-capture-set-target-location (and (equal goto 0) 'here)) (condition-case error (org-capture-put :template (org-capture-fill-template)) ((error quit) (if (get-buffer capture-buf) (kill-buffer capture-buf)) (error "Capture abort: %s" (error-message-string error)))) (setq org-capture-clock-keep (org-capture-get :clock-keep)) (condition-case error (org-capture-place-template (eq (car (org-capture-get :target)) 'function)) ((error quit) (when (and (buffer-base-buffer (current-buffer)) (string-prefix-p "CAPTURE-" (buffer-name))) (kill-buffer (current-buffer))) (set-window-configuration (org-capture-get :return-to-wconf)) (error "Capture template `%s': %s" (org-capture-get :key) (error-message-string error)))) (when (and (derived-mode-p 'org-mode) (org-capture-get :clock-in)) (condition-case nil (progn (when (org-clock-is-active) (org-capture-put :interrupted-clock (copy-marker org-clock-marker))) (org-clock-in) (setq-local org-capture-clock-was-started t)) (error "Could not start the clock in this capture buffer"))) (when (org-capture-get :immediate-finish) (org-capture-finalize)))) ;;;; Org Agenda (require 'org-agenda) (defvar org-agenda--arg nil) (defvar org-agenda--keys nil) (defvar org-agenda--restriction nil) (defun org-agenda-test (&optional _arg _keys _restriction) (interactive "P") (let ((org-select-horizontal-separator " ")) (org-select '((("a" "Agenda for current week or day" org-agenda-list) ("t" "List of all TODO entries" org-todo-list) ("m" "Match a TAGS/PROP/TODO query" org-tags-view) ("s" "Search for keywords" org-search-view) ("/" "Multi-occur" (call-interactively 'org-occur-in-agenda-files) :org-select-inhibit-transient t) ("?" "Find :FLAGGED: entries" (org-tags-view nil "+FLAGGED")) ("*" "Toggle sticky agenda views" org-toggle-sticky-agenda :org-select-handler org-select-run)) (("<" "Buffer, subtree/region restriction" ignore) (">" "Remove restriction" ignore) ("e" "Export agenda views" org-store-agenda-views) ("T" "Entries with special TODO kwd" (org-call-with-arg 'org-todo-list (or org-agenda--arg '(4)))) ("M" "Like m, but only TODO entries" (org-call-with-arg 'org-tags-view (or org-agenda--arg '(4)))) ("S" "Like s, but only TODO entries" (org-call-with-arg 'org-search-view (or org-agenda--arg '(4)))) ("C" "Configure custom agenda commands" (customize-variable 'org-agenda-custom-commands)) ("#" "List stuck projects" (org-agenda--exec 'org-agenda-list-stuck-projects)) ("!" "Configure stuck projects" (customize-variable 'org-stuck-projects)))) :text "Press key for an agenda command: --------------------------------\n" :horizontal t :buffer-name "*Agenda Commands*"))) ;;;; Various tests (defun test1 () "Stays after a choice is made." (interactive) (let ((org-select-horizontal-separator "│")) (org-select ;; table '((("1" "One" (message "One!")) ("2" "Two" (message "Two!!")) ("3" "Three" (message "Three!!!"))) (("C-4" "Four" (message "Four!!!!")) ("C-5" "Five" (message "Five!!!!!")) ("C-6" "six" (message "Six!"))) (("M-7" "Seven" (message "Seven!")) ("M-8" "Eight" (message "Eight!")) ("M-9" "Nine" (message "Nine!")))) ;; description :horizontal t :key-decorator "<>"))) (defun test2 () "Dissapears after a choice is made." (interactive) (let ((org-select-horizontal-separator "│")) (org-select ;; menus '((("h" "Hello, World!" (message "Hello, World!")) ("b" "Bar" (message "Hello, Bar!"))) (("f" "Find File" find-file) ("o" "Open File" (flet ((next-read-file-uses-dialog-p () t)) (call-interactively #'find-file))))) ;; description :key-decorator "\"\"" :transient t) ;; Hints (setq header-line-format (if (not (pos-visible-in-window-p (point-max))) "Use C-v, M-v, C-n or C-p to navigate. C-g, q to quit." "Use C-p/Left to go back, C-g, q to quit.")))) (defun test3 () "Illustrate nested menus, unicode separator and alternative decorator." (interactive) (let ((org-select-vertical-separator "─")) (org-select ;; tables '((("g" "Greetings") ("gh" "Hello, World!" (message "Hello, World!")) ("gb" "Bar" (message "Hello, Bar!"))) (("f" "Functions") ("ff" "Find File" find-file) ("fo" "Open File" (flet ((next-read-file-uses-dialog-p () t)) (call-interactively #'find-file))))))) ;; Hints (setq header-line-format (if (not (pos-visible-in-window-p (point-max))) "Use C-v, M-v, C-n or C-p to navigate. C-g, q to quit." "Use C-p/Left to go back, C-g, q to quit."))) (provide 'org-select) ;;; org-select.el ends here