summaryrefslogtreecommitdiff
path: root/lisp/denote-menu.el
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-23 19:19:17 +0200
committercashmere <cashmere@autistici.org>2026-08-23 19:19:17 +0200
commit15def4cc1edbd5a80973fa9c5bba35d5eb32c340 (patch)
treea9172e36e74a6691738b7d5012289cec1767241d /lisp/denote-menu.el
parente8b0abed0711cefcd0c06b8e12e3a7a9224fb329 (diff)
downloademacs-15def4cc1edbd5a80973fa9c5bba35d5eb32c340.tar.gz
feat: refresh Emacs UI and lock Elpaca packages
Diffstat (limited to 'lisp/denote-menu.el')
-rw-r--r--lisp/denote-menu.el297
1 files changed, 195 insertions, 102 deletions
diff --git a/lisp/denote-menu.el b/lisp/denote-menu.el
index eb1c76c..7e1c8e0 100644
--- a/lisp/denote-menu.el
+++ b/lisp/denote-menu.el
@@ -8,8 +8,7 @@
;; A keyboard-first browser for Denote notes built on vui.el.
;; Lists notes from `denote-directory' (silo-aware) in a borderless table with
;; live filtering, column sorting, and one-key actions. Ships its own
-;; `denote-menu-mode' derived from `vui-mode' and registers normal-state
-;; keys with helix when it is available.
+;; `denote-menu-mode' derived from `vui-mode' with Evil normal-state keys.
;;; Code:
@@ -17,6 +16,9 @@
(require 'vui-components)
(require 'denote)
+(declare-function evil-define-key* "evil-core" (state keymap key def &rest bindings))
+(declare-function evil-set-initial-state "evil-core" (mode state))
+
(defgroup denote-menu nil
"A vui-based browser for Denote notes."
:group 'denote
@@ -34,10 +36,20 @@
"Width of the file extension column."
:type 'integer)
+(defcustom denote-menu-wide-breakpoint 78
+ "Minimum window width for the sticky table layout."
+ :type 'integer)
+
+(defcustom denote-menu-preview-width 0.38
+ "Fractional width of the optional note preview side window."
+ :type 'number)
+
(defvar-local denote-menu--dispatch nil
"Alist of actions and visible paths for the current buffer.
Set during render; consumed by the mode's interactive commands.")
+(defconst denote-menu--preview-buffer-name "*Denote Preview*")
+
(defun denote-menu--format-date (identifier)
"Format a Denote IDENTIFIER (20260315T120000) as \"2026-03-15 12:00\"."
(if (and identifier (string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" identifier))
@@ -94,32 +106,11 @@ Set during render; consumed by the mode's interactive commands.")
(string< (funcall key-fn a) (funcall key-fn b))))))
(if sort-desc (nreverse sorted) sorted)))
-(defun denote-menu--format-keywords (keywords width)
- "Format KEYWORDS as [a,b] fitting WIDTH, collapsing overflow to ,+N."
- (let ((parts '())
- (used 0)
- (idx 0)
- (total (length keywords))
- (done nil))
- (while (and (< idx total) (not done))
- (let* ((kw (nth idx keywords))
- (remaining (- total idx 1))
- (suffix (if (> remaining 0)
- (+ 2 (length (number-to-string remaining)))
- 0))
- (cost (+ (if parts 1 0) (length kw))))
- (if (<= (+ used cost suffix 2) width)
- (setq parts (cons kw parts)
- used (+ used cost)
- idx (1+ idx))
- (setq done t))))
- (let ((remaining (- total idx)))
- (cond
- ((> remaining 0)
- (if parts
- (format "[%s,+%d]" (string-join (nreverse parts) ",") remaining)
- (format "[%s...]" (substring (car keywords) 0 (max 1 (- width 5))))))
- (t (format "[%s]" (string-join (nreverse parts) ",")))))))
+(defun denote-menu--keywords-label (keywords)
+ "Return a compact display label for KEYWORDS."
+ (if keywords
+ (format "#%s" (string-join keywords " #"))
+ "untagged"))
(defun denote-menu--sort-indicator (sort-key sort-desc)
"Human-readable sort indicator for SORT-KEY and SORT-DESC."
@@ -131,6 +122,74 @@ Set during render; consumed by the mode's interactive commands.")
(format "%s %s" label (if sort-desc "↓" "↑"))
label))
+(defun denote-menu--window-width ()
+ "Return the live body width of the window displaying this menu."
+ (if-let* ((window (get-buffer-window (current-buffer) t)))
+ (window-body-width window)
+ (window-width)))
+
+(defun denote-menu--wide-table (visible sort-key sort-desc width)
+ "Render VISIBLE as a sticky table using responsive WIDTH columns."
+ (let* ((date-width (if (>= width 100) denote-menu-date-width 10))
+ (keyword-width (max 14 (min denote-menu-keywords-width (/ width 4))))
+ (title-width (max 18 (- width date-width keyword-width
+ denote-menu-ext-width 5))))
+ (vui-table
+ :sticky-header t
+ :columns (list
+ (list :header (denote-menu--column-header
+ "Date" 'date sort-key sort-desc)
+ :width date-width :truncate t)
+ (list :header "Type" :width denote-menu-ext-width
+ :truncate t)
+ (list :header (denote-menu--column-header
+ "Keywords" 'keywords sort-key sort-desc)
+ :width keyword-width :truncate t)
+ (list :header (denote-menu--column-header
+ "Title" 'title sort-key sort-desc)
+ :width title-width :grow t :truncate t))
+ :rows
+ (mapcar
+ (lambda (note)
+ (list
+ (vui-text (plist-get note :date) :face 'shadow)
+ (vui-text (if-let* ((ext (plist-get note :ext)))
+ (concat "." ext)
+ "—")
+ :face 'shadow)
+ (vui-text (denote-menu--keywords-label (plist-get note :keywords))
+ :face 'font-lock-comment-face)
+ (vui-button (plist-get note :title)
+ :key (plist-get note :path)
+ :no-decoration t :help-echo nil
+ :on-click (lambda ()
+ (find-file (plist-get note :path))))))
+ visible))))
+
+(defun denote-menu--compact-card (note width)
+ "Render NOTE as a two-line card fitting WIDTH."
+ (let ((path (plist-get note :path)))
+ (vui-vstack
+ :spacing 0
+ (vui-flex
+ :width 'window :justify :space-between
+ (vui-flex-item
+ :grow 1
+ (lambda (available)
+ (vui-button (plist-get note :title)
+ :key path :no-decoration t :help-echo nil
+ :max-width (max 8 available)
+ :face '(:inherit bold)
+ :on-click (lambda () (find-file path)))))
+ (vui-text (format "%s .%s"
+ (substring (plist-get note :date) 0 10)
+ (or (plist-get note :ext) ""))
+ :face 'shadow))
+ (vui-box
+ (vui-text (denote-menu--keywords-label (plist-get note :keywords))
+ :face 'font-lock-comment-face)
+ :width (max 12 (- width 2)) :padding-left 2))))
+
(vui-defcomponent denote-menu-app ()
:state ((query "")
(sort-key 'date)
@@ -158,96 +217,117 @@ Set during render; consumed by the mode's interactive commands.")
(_ 'date))))
:toggle-direction (vui-with-async-context
(vui-set-state :sort-desc (not sort-desc)))))
- (vui-vstack
+ (let ((width (denote-menu--window-width)))
+ (vui-vstack
:spacing 1
- (vui-hstack
- :spacing 2
- (vui-heading-2 "Denotes")
- (vui-text (format "▸ %s" silo) :face '(:inherit success :weight bold))
+ (vui-flex
+ :width 'window :justify :space-between
+ (vui-hstack
+ :spacing 2
+ (vui-heading-2 "Denotes")
+ (vui-text (format "▸ %s" silo)
+ :face '(:inherit success :weight bold)))
(vui-text (format "%d%s notes"
(length visible)
- (if filtering
- (format " of %d" (length notes))
- ""))
- :face 'shadow)
- (vui-button "new" :on-click (lambda ()
- (let ((file (call-interactively #'denote)))
- (when file (funcall (plist-get denote-menu--dispatch :refresh)))))
- :help-echo "Create a new note")
- (vui-button "export" :on-click #'denote-menu-export-to-dired
- :help-echo "Open the visible notes in Dired"))
- (vui-text "RET open · / filter · s/S sort · e export · n new · q quit"
- :face 'shadow)
- (vui-hstack
- :spacing 2
+ (if filtering (format " / %d" (length notes)) ""))
+ :face 'shadow))
+ (vui-flex
+ :width 'window :justify :space-between
(vui-hstack
:spacing 1
- (vui-text "filter:" :face 'shadow)
- (if filtering
- (vui-text query :face '(:inherit warning :weight bold))
- (vui-text "none (press / to filter)" :face 'shadow)))
- (vui-text (format "sort: %s" (denote-menu--sort-indicator sort-key sort-desc))
- :face 'shadow))
+ (vui-text "filter" :face 'shadow)
+ (vui-text (if filtering query "all notes")
+ :face (if filtering 'warning 'shadow)))
+ (vui-hstack
+ :spacing 1
+ (vui-text (format "sort %s"
+ (denote-menu--sort-indicator sort-key sort-desc))
+ :face 'shadow)
+ (vui-button "new" :on-click #'denote-menu-new-note)
+ (vui-button "dired" :on-click #'denote-menu-export-to-dired)))
(if (null visible)
(vui-vstack
:spacing 1
(vui-box (vui-text "∅" :face '(:inherit shadow :height 2.0))
- :width fill-column :align :center)
+ :width width :align :center)
(vui-box (vui-text (if filtering
(format "No notes matching \"%s\"" query)
"No notes here yet")
:face 'shadow)
- :width fill-column :align :center)
+ :width width :align :center)
(vui-box (vui-button "Create the first one"
:on-click (lambda () (call-interactively #'denote)))
- :width fill-column :align :center))
- (let* ((win (or (get-buffer-window (current-buffer)) (selected-window)))
- (title-width (max 10 (- (window-width win)
- denote-menu-date-width
- denote-menu-ext-width
- denote-menu-keywords-width
- 4))))
- (vui-table
- :columns (list (list :header (denote-menu--column-header "Date" 'date sort-key sort-desc)
- :width denote-menu-date-width)
- (list :header ""
- :width denote-menu-ext-width)
- (list :header (denote-menu--column-header "Keywords" 'keywords sort-key sort-desc)
- :width denote-menu-keywords-width :truncate t)
- (list :header (denote-menu--column-header "Title" 'title sort-key sort-desc)
- :width title-width :truncate t))
- :rows (mapcar
- (lambda (note)
- (list (vui-text (plist-get note :date) :face 'shadow)
- (vui-text (if-let* ((ext (plist-get note :ext)))
- (concat "." ext)
- "")
- :face 'shadow)
- (if (plist-get note :keywords)
- (vui-text (denote-menu--format-keywords
- (plist-get note :keywords)
- denote-menu-keywords-width)
- :face 'font-lock-comment-face)
- (vui-text "·" :face 'shadow))
- (vui-button (plist-get note :title)
- :key (plist-get note :path)
- :no-decoration t
- :help-echo nil
- :on-click (lambda () (find-file (plist-get note :path))))))
- visible)))))))
+ :width width :align :center))
+ (if (>= width denote-menu-wide-breakpoint)
+ (denote-menu--wide-table visible sort-key sort-desc width)
+ (vui-list visible
+ (lambda (note) (denote-menu--compact-card note width))
+ (lambda (note) (plist-get note :path))
+ :spacing 1)))
+ (vui-flex
+ :width 'window :justify :space-between
+ (vui-text "j/k move · RET/l open · / filter · p preview"
+ :face 'shadow)
+ (vui-text "s/S sort · n new · g refresh · ? help · q close"
+ :face 'shadow))))))
(defun denote-menu--action (name)
"Return the buffer's dispatch action NAME, or signal an error."
(or (plist-get denote-menu--dispatch name)
(user-error "Denote-explorer is not ready in this buffer")))
+(defun denote-menu--path-on-line ()
+ "Return the note path keyed on the current visual row."
+ (save-excursion
+ (beginning-of-line)
+ (let ((end (line-end-position)) path)
+ (while (and (not path) (< (point) end))
+ (setq path (vui-key-at))
+ (forward-char 1))
+ path)))
+
(defun denote-menu-open-at-point ()
"Open the note at point."
(interactive)
- (if-let* ((path (vui-key-at)))
+ (if-let* ((path (or (vui-key-at) (denote-menu--path-on-line))))
(find-file path)
(user-error "No note at point")))
+(defun denote-menu-preview-at-point ()
+ "Show the note at point in an opt-in right side window."
+ (interactive)
+ (if-let* ((path (or (vui-key-at) (denote-menu--path-on-line))))
+ (let ((buffer (get-buffer-create denote-menu--preview-buffer-name)))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert-file-contents path)
+ (org-mode)
+ (setq-local buffer-file-name path)
+ (setq-local mode-line-format nil)
+ (goto-char (point-min))
+ (view-mode 1)))
+ (display-buffer-in-side-window
+ buffer `((side . right)
+ (slot . 1)
+ (window-width . ,denote-menu-preview-width))))
+ (user-error "No note at point")))
+
+(defun denote-menu-help ()
+ "Show the Denote Menu key summary."
+ (interactive)
+ (message "Denote Menu: j/k move, TAB/S-TAB widgets, RET/l open, / filter, p preview, n new, s/S sort, e Dired, g refresh, h/q close"))
+
+(defun denote-menu-quit ()
+ "Close the menu and its optional preview."
+ (interactive)
+ (when-let* ((preview (get-buffer denote-menu--preview-buffer-name))
+ (window (get-buffer-window preview t)))
+ (delete-window window))
+ (when-let* ((preview (get-buffer denote-menu--preview-buffer-name)))
+ (kill-buffer preview))
+ (vui-quit))
+
(defun denote-menu-filter ()
"Prompt for a filter query."
(interactive)
@@ -299,14 +379,25 @@ for `denote-directory' and silently drop the file-list filter."
(defvar denote-menu-mode-map
(let ((map (make-sparse-keymap)))
+ (set-keymap-parent map vui-mode-map)
(define-key map (kbd "RET") #'denote-menu-open-at-point)
+ (define-key map (kbd "l") #'denote-menu-open-at-point)
+ (define-key map (kbd "j") #'next-line)
+ (define-key map (kbd "k") #'previous-line)
+ (define-key map (kbd "TAB") #'vui-forward)
+ (define-key map (kbd "<backtab>") #'vui-backward)
+ (define-key map (kbd "p") #'denote-menu-preview-at-point)
(define-key map (kbd "n") #'denote-menu-new-note)
(define-key map (kbd "e") #'denote-menu-export-to-dired)
(define-key map (kbd "r") #'denote-menu-refresh)
+ (define-key map (kbd "g") #'denote-menu-refresh)
(define-key map (kbd "/") #'denote-menu-filter)
(define-key map (kbd "c") #'denote-menu-clear-filter)
(define-key map (kbd "s") #'denote-menu-cycle-sort)
(define-key map (kbd "S") #'denote-menu-toggle-sort-direction)
+ (define-key map (kbd "?") #'denote-menu-help)
+ (define-key map (kbd "h") #'denote-menu-quit)
+ (define-key map (kbd "q") #'denote-menu-quit)
map)
"Keymap for `denote-menu-mode'.")
@@ -319,18 +410,20 @@ across re-renders.
:group 'denote-menu
(hl-line-mode 1))
+(with-eval-after-load 'evil
+ (evil-set-initial-state 'denote-menu-mode 'normal)
+ (evil-define-key* '(normal motion) denote-menu-mode-map
+ (kbd "j") #'next-line
+ (kbd "k") #'previous-line
+ (kbd "RET") #'denote-menu-open-at-point
+ (kbd "l") #'denote-menu-open-at-point
+ (kbd "h") #'denote-menu-quit
+ (kbd "q") #'denote-menu-quit
+ (kbd "g") #'denote-menu-refresh
+ (kbd "/") #'denote-menu-filter
+ (kbd "p") #'denote-menu-preview-at-point
+ (kbd "?") #'denote-menu-help))
-(with-eval-after-load 'helix
- (helix-define-key 'normal "RET" #'denote-menu-open-at-point 'denote-menu-mode)
- (helix-define-key 'normal "o" #'denote-menu-open-at-point 'denote-menu-mode)
- (helix-define-key 'normal "/" #'denote-menu-filter 'denote-menu-mode)
- (helix-define-key 'normal "c" #'denote-menu-clear-filter 'denote-menu-mode)
- (helix-define-key 'normal "s" #'denote-menu-cycle-sort 'denote-menu-mode)
- (helix-define-key 'normal "S" #'denote-menu-toggle-sort-direction 'denote-menu-mode)
- (helix-define-key 'normal "e" #'denote-menu-export-to-dired 'denote-menu-mode)
- (helix-define-key 'normal "n" #'denote-menu-new-note 'denote-menu-mode)
- (helix-define-key 'normal "r" #'denote-menu-refresh 'denote-menu-mode)
- (helix-define-key 'normal "q" #'vui-quit 'denote-menu-mode))
;;;###autoload
(defun denote-menu ()