;;; 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: ;; ;; ;;; 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--default-handler-fn nil) (defvar-local osl--current-menu-column nil) (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--default-handler-fn (entry) "Try to execute form found in ENTRY if any." (let ((form (nth 2 entry))) (cond ((listp form) (eval form)) (t (if (commandp form) (call-interactively form) (eval form)))))) (with-eval-after-load (setq osl--default-handler-fn #'osl--default-handler-fn)) (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-name) (interactive) (let ((window (if buffer-name (get-buffer-window buffer-name) osl--buffer-window)) (kill-buffer (buffer-local-value 'osl--buffer (current-buffer)))) (when (window-live-p window) (select-window window) (quit-window kill-buffer 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 [left] #'osl--back) (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) "Place string LABEL between strings specified in DECORATORS strings. DECOARATOR is a cons containing two elements: left and right decorators." (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 &optional _length) (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 &optional _length) (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 :label) "*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) osl--default-handler-fn 'osl--default-handler-fn)))) ;; menu is a list of tables, display one table at a time (defun osl--draw () "Starts menu parsing and insertig." (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)) (dolist (menu menus) (if (symbolp menu) (setq menu (eval menu))) (osl--do-menu menu) (setq menus (cdr menus)) (when menus (osl--insert-separator marker) (osl--forward-menu))) (goto-char 1) (let ((sep (osl--make-separator nil (osl--longest-line))) ;; (osl--make-separator nil fill-column)) ) (while (search-forward marker nil t) (replace-match "") (osl--insert-separator sep))) (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) "Insert one menu at a time." (while menu (let ((entry (car menu))) (setq menu (if (> (length entry) 2) (osl--do-entry menu) (osl--do-group menu)))))) (defun osl--do-group (menu) "Do a menu with group nodes." (let ((group (car menu)) (transient (osl--arg :transient)) newmenu) (osl--do-entry menu) (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) (if transient (org-select-quit ""))))) (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) "Display a single entry in the buffer." (let* ((entry (car menu)) (key (car entry)) (line-length 0) (transient (osl--arg :transient))) (define-key org-select-mode-map (kbd key) (lambda () (interactive) (let ((label (nth 1 entry)) (handler (or (osl--arg :handler) osl--default-handler-fn)) (init (buffer-local-value 'osl--init osl--buffer)) msg) (and init handler (setq msg (funcall handler entry))) (if transient (org-select-quit "")) (message (or msg 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))) ;;; API (defun org-select (tables &rest args) "Select a member of an alist with multiple keys. TABLE is an alist which should contain entries where the car is a string. There should be two types of entries. 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. ARGS is a property list containing following members: :text a string placed over the selection in the buffer. :label a string used for the selections buffer name. :prompt a string used when prompting for a key. :always when `t', this menu is shown; even descended into submenus :transient when `t', the menu is dissmised after user perform an action :key-decorator a two-character string used to decorate command characters. When this string is specified, it will take precedence over the global variable `org-select-key-decorator-chars'. TABLES are additional menus in the same format as TABLE. 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'" (osl--setup-buffer tables 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)) (setq org-capture--old-window-config (current-window-configuration)) (org-select ;; tables '(org-capture-templates (("C" "Customize org-capture-templates" (customize-variable 'org-capture-templates)) ("q" "Abort" (org-select-quit "Abort")))) ;; description :transient t :handler #'org-capture--handle :label "*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--handle (entry) (org-select-quit "") (cond ((or (equal "C" (car entry)) (equal "q" (car entry))) (eval (nth 2 entry))) (t (let* ((orig-buf (current-buffer)) (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 orig-buf :original-file (or (buffer-file-name orig-buf) (and (featurep 'dired) (car (rassq orig-buf dired-buffers)))) :original-file-nondirectory (and (buffer-file-name orig-buf) (file-name-nondirectory (buffer-file-name orig-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*") (kill-buffer "*Capture*")) (org-select-quit "" "*Capture*") (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--exec (action &rest args) "Execute ACTION and exit org-agenda menu." (interactive) (org-select-quit "") (apply action args)) (defvar org-agenda--menu '((("a" "Agenda for current week or day" (org-agenda--exec 'org-agenda-list)) ("t" "List of all TODO entries" (org-agenda--exec 'org-todo-list)) ("m" "Match a TAGS/PROP/TODO query" (org-agenda--exec 'org-tags-view)) ("s" "Search for keywords" (org-agenda--exec 'org-search-view)) ("/" "Multi-occur" (call-interactively 'org-occur-in-agenda-files)) ("?" "Find :FLAGGED: entries" (org-agenda--exec 'org-tags-view nil "+FLAGGED")) ("*" "Toggle sticky agenda views" (call-interactively #'org-toggle-sticky-agenda))) (("<" "Buffer, subtree/region restriction" ignore) (">" "Remove restriction" ignore) ("e" "Export agenda views" org-store-agenda-views) ("T" "Entries with special TODO kwd" (org-agenda--exec 'org-call-with-arg 'org-todo-list (or org-agenda--arg '(4)))) ("M" "Like m, but only TODO entries" (org-agenda--exec 'org-call-with-arg 'org-tags-view (or org-agenda--arg '(4)))) ("S" "Like s, but only TODO entries" (org-agenda--exec 'org-call-with-arg 'org-search-view (or org-agenda--arg '(4)))) ("C" "Configure custom agenda commands" (org-agenda--exec 'customize-variable 'org-agenda-custom-commands)) ("#" "List stuck projects" (org-agenda--exec 'org-agenda-list-stuck-projects)) ("!" "Configure stuck projects" (org-agenda--exec 'customize-variable 'org-stuck-projects))))) (defun org-agenda-test (&optional _arg _keys _restriction) (interactive "P") (let ((org-select-horizontal-separator " ")) (org-select org-agenda--menu :text "Press key for an agenda command: --------------------------------\n" :horizontal t) (org-agenda-fit-window-to-buffer))) (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