From 15def4cc1edbd5a80973fa9c5bba35d5eb32c340 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 23 Aug 2026 19:19:17 +0200 Subject: feat: refresh Emacs UI and lock Elpaca packages --- lisp/denote-menu.el | 297 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 195 insertions(+), 102 deletions(-) (limited to 'lisp/denote-menu.el') 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 "") #'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 () -- cgit v1.3.1