notmuch-hello.el (38483B)
1 ;;; notmuch-hello.el --- welcome to notmuch, a frontend -*- lexical-binding: t -*- 2 ;; 3 ;; Copyright © David Edmondson 4 ;; 5 ;; This file is part of Notmuch. 6 ;; 7 ;; Notmuch is free software: you can redistribute it and/or modify it 8 ;; under the terms of the GNU General Public License as published by 9 ;; the Free Software Foundation, either version 3 of the License, or 10 ;; (at your option) any later version. 11 ;; 12 ;; Notmuch is distributed in the hope that it will be useful, but 13 ;; WITHOUT ANY WARRANTY; without even the implied warranty of 14 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15 ;; General Public License for more details. 16 ;; 17 ;; You should have received a copy of the GNU General Public License 18 ;; along with Notmuch. If not, see <https://www.gnu.org/licenses/>. 19 ;; 20 ;; Authors: David Edmondson <dme@dme.org> 21 22 ;;; Code: 23 24 (require 'widget) 25 (require 'wid-edit) ; For `widget-forward'. 26 27 (require 'notmuch-lib) 28 (require 'notmuch-mua) 29 30 (declare-function notmuch-search "notmuch" 31 (&optional query oldest-first target-thread target-line 32 no-display)) 33 (declare-function notmuch-poll "notmuch-lib" ()) 34 (declare-function notmuch-tree "notmuch-tree" 35 (&optional query query-context target buffer-name 36 open-target unthreaded parent-buffer oldest-first)) 37 (declare-function notmuch-unthreaded "notmuch-tree" 38 (&optional query query-context target buffer-name 39 open-target)) 40 41 42 ;;; Options 43 44 (defun notmuch-saved-search-get (saved-search field) 45 "Get FIELD from SAVED-SEARCH. 46 47 If SAVED-SEARCH is a plist, this is just `plist-get', but for 48 backwards compatibility, this also deals with the two other 49 possible formats for SAVED-SEARCH: cons cells (NAME . QUERY) and 50 lists (NAME QUERY COUNT-QUERY)." 51 (cond 52 ((keywordp (car saved-search)) 53 (plist-get saved-search field)) 54 ;; It is not a plist so it is an old-style entry. 55 ((consp (cdr saved-search)) 56 (pcase-let ((`(,name ,query ,count-query) saved-search)) 57 (cl-case field 58 (:name name) 59 (:query query) 60 (:count-query count-query) 61 (t nil)))) 62 (t 63 (pcase-let ((`(,name . ,query) saved-search)) 64 (cl-case field 65 (:name name) 66 (:query query) 67 (t nil)))))) 68 69 (defun notmuch-hello-saved-search-to-plist (saved-search) 70 "Return a copy of SAVED-SEARCH in plist form. 71 72 If saved search is a plist then just return a copy. In other 73 cases, for backwards compatibility, convert to plist form and 74 return that." 75 (if (keywordp (car saved-search)) 76 (copy-sequence saved-search) 77 (let ((fields (list :name :query :count-query)) 78 plist-search) 79 (dolist (field fields plist-search) 80 (let ((string (notmuch-saved-search-get saved-search field))) 81 (when string 82 (setq plist-search (append plist-search (list field string))))))))) 83 84 (defun notmuch-hello--saved-searches-to-plist (symbol) 85 "Extract a saved-search variable into plist form. 86 87 The new style saved search is just a plist, but for backwards 88 compatibility we use this function to extract old style saved 89 searches so they still work in customize." 90 (let ((saved-searches (default-value symbol))) 91 (mapcar #'notmuch-hello-saved-search-to-plist saved-searches))) 92 93 (define-widget 'notmuch-saved-search-plist 'list 94 "A single saved search property list." 95 :tag "Saved Search" 96 :args '((list :inline t 97 :format "%v" 98 (group :format "%v" :inline t 99 (const :format " Name: " :name) 100 (string :format "%v")) 101 (group :format "%v" :inline t 102 (const :format " Query: " :query) 103 (string :format "%v"))) 104 (checklist :inline t 105 :format "%v" 106 (group :format "%v" :inline t 107 (const :format "Shortcut key: " :key) 108 (key-sequence :format "%v")) 109 (group :format "%v" :inline t 110 (const :format "Count-Query: " :count-query) 111 (string :format "%v")) 112 (group :format "%v" :inline t 113 (const :format "" :sort-order) 114 (choice :tag " Sort Order" 115 (const :tag "Default" nil) 116 (const :tag "Oldest-first" oldest-first) 117 (const :tag "Newest-first" newest-first))) 118 (group :format "%v" :inline t 119 (const :format "" :search-type) 120 (choice :tag " Search Type" 121 (const :tag "Search mode" nil) 122 (const :tag "Tree mode" tree) 123 (const :tag "Unthreaded mode" unthreaded)))))) 124 125 (defcustom notmuch-saved-searches 126 `((:name "inbox" :query "tag:inbox" :key ,(kbd "i")) 127 (:name "unread" :query "tag:unread" :key ,(kbd "u")) 128 (:name "flagged" :query "tag:flagged" :key ,(kbd "f")) 129 (:name "sent" :query "tag:sent" :key ,(kbd "t")) 130 (:name "drafts" :query "tag:draft" :key ,(kbd "d")) 131 (:name "all mail" :query "*" :key ,(kbd "a"))) 132 "A list of saved searches to display. 133 134 The saved search can be given in 3 forms. The preferred way is as 135 a plist. Supported properties are 136 137 :name Name of the search (required). 138 :query Search to run (required). 139 :key Optional shortcut key for `notmuch-jump-search'. 140 :count-query Optional extra query to generate the count 141 shown. If not present then the :query property 142 is used. 143 :sort-order Specify the sort order to be used for the search. 144 Possible values are `oldest-first', `newest-first' 145 or nil. Nil means use the default sort order. 146 :search-type Specify whether to run the search in search-mode, 147 tree mode or unthreaded mode. Set to `tree' to 148 specify tree mode, \\='unthreaded to specify 149 unthreaded mode, and set to nil (or anything 150 except tree and unthreaded) to specify search 151 mode. 152 153 Other accepted forms are a cons cell of the form (NAME . QUERY) 154 or a list of the form (NAME QUERY COUNT-QUERY)." 155 ;; The saved-search format is also used by the all-tags notmuch-hello 156 ;; section. This section generates its own saved-search list in one of 157 ;; the latter two forms. 158 :get 'notmuch-hello--saved-searches-to-plist 159 :type '(repeat notmuch-saved-search-plist) 160 :tag "List of Saved Searches" 161 :group 'notmuch-hello) 162 163 (defcustom notmuch-hello-recent-searches-max 10 164 "The number of recent searches to display." 165 :type 'integer 166 :group 'notmuch-hello) 167 168 (defcustom notmuch-show-empty-saved-searches nil 169 "Should saved searches with no messages be listed?" 170 :type 'boolean 171 :group 'notmuch-hello) 172 173 (defun notmuch-sort-saved-searches (saved-searches) 174 "Generate an alphabetically sorted saved searches list." 175 (sort (copy-sequence saved-searches) 176 (lambda (a b) 177 (string< (notmuch-saved-search-get a :name) 178 (notmuch-saved-search-get b :name))))) 179 180 (defcustom notmuch-saved-search-sort-function nil 181 "Function used to sort the saved searches for the notmuch-hello view. 182 183 This variable controls how saved searches should be sorted. No 184 sorting (nil) displays the saved searches in the order they are 185 stored in `notmuch-saved-searches'. Sort alphabetically sorts the 186 saved searches in alphabetical order. Custom sort function should 187 be a function or a lambda expression that takes the saved 188 searches list as a parameter, and returns a new saved searches 189 list to be used. For compatibility with the various saved-search 190 formats it should use notmuch-saved-search-get to access the 191 fields of the search." 192 :type '(choice (const :tag "No sorting" nil) 193 (const :tag "Sort alphabetically" notmuch-sort-saved-searches) 194 (function :tag "Custom sort function" 195 :value notmuch-sort-saved-searches)) 196 :group 'notmuch-hello) 197 198 (defvar notmuch-hello-indent 4 199 "How much to indent non-headers.") 200 201 (defimage notmuch-hello-logo ((:type svg :file "notmuch-logo.svg"))) 202 203 (defcustom notmuch-show-logo t 204 "Should the notmuch logo be shown?" 205 :type 'boolean 206 :group 'notmuch-hello) 207 208 (defcustom notmuch-show-all-tags-list nil 209 "Should all tags be shown in the notmuch-hello view?" 210 :type 'boolean 211 :group 'notmuch-hello) 212 213 (defcustom notmuch-hello-tag-list-make-query nil 214 "Function or string to generate queries for the all tags list. 215 216 This variable controls which query results are shown for each tag 217 in the \"all tags\" list. If nil, it will use all messages with 218 that tag. If this is set to a string, it is used as a filter for 219 messages having that tag (equivalent to \"tag:TAG and (THIS-VARIABLE)\"). 220 Finally this can be a function that will be called for each tag and 221 should return a filter for that tag, or nil to hide the tag." 222 :type '(choice (const :tag "All messages" nil) 223 (const :tag "Unread messages" "tag:unread") 224 (string :tag "Custom filter" 225 :value "tag:unread") 226 (function :tag "Custom filter function")) 227 :group 'notmuch-hello) 228 229 (defcustom notmuch-hello-hide-tags nil 230 "List of tags to be hidden in the \"all tags\"-section." 231 :type '(repeat string) 232 :group 'notmuch-hello) 233 234 (defface notmuch-hello-logo-background 235 '((((class color) 236 (background dark)) 237 (:background "#5f5f5f")) 238 (((class color) 239 (background light)) 240 (:background "white"))) 241 "Background colour for the notmuch logo." 242 :group 'notmuch-hello 243 :group 'notmuch-faces) 244 245 (defcustom notmuch-column-control t 246 "Controls the number of columns for saved searches/tags in notmuch view. 247 248 This variable has three potential sets of values: 249 250 - t: automatically calculate the number of columns possible based 251 on the tags to be shown and the window width, 252 - an integer: a lower bound on the number of characters that will 253 be used to display each column, 254 - a float: a fraction of the window width that is the lower bound 255 on the number of characters that should be used for each 256 column. 257 258 So: 259 - if you would like two columns of tags, set this to 0.5. 260 - if you would like a single column of tags, set this to 1.0. 261 - if you would like tags to be 30 characters wide, set this to 262 30. 263 - if you don't want to worry about all of this nonsense, leave 264 this set to `t'." 265 :type '(choice 266 (const :tag "Automatically calculated" t) 267 (integer :tag "Number of characters") 268 (float :tag "Fraction of window")) 269 :group 'notmuch-hello) 270 271 (defcustom notmuch-hello-thousands-separator " " 272 "The string used as a thousands separator. 273 274 Typically \",\" in the US and UK and \".\" or \" \" in Europe. 275 The latter is recommended in the SI/ISO 31-0 standard and by the 276 International Bureau of Weights and Measures." 277 :type 'string 278 :group 'notmuch-hello) 279 280 (defcustom notmuch-hello-mode-hook nil 281 "Functions called after entering `notmuch-hello-mode'." 282 :type 'hook 283 :group 'notmuch-hello 284 :group 'notmuch-hooks) 285 286 (defcustom notmuch-hello-refresh-hook nil 287 "Functions called after updating a `notmuch-hello' buffer." 288 :type 'hook 289 :group 'notmuch-hello 290 :group 'notmuch-hooks) 291 292 (defconst notmuch-hello-url "https://notmuchmail.org" 293 "The `notmuch' web site.") 294 295 (defvar notmuch-hello-custom-section-options 296 '((:filter (string :tag "Filter for each tag")) 297 (:filter-count (string :tag "Different filter to generate message counts")) 298 (:initially-hidden (const :tag "Hide this section on startup" t)) 299 (:show-empty-searches (const :tag "Show queries with no matching messages" t)) 300 (:hide-if-empty (const :tag "Hide this section if all queries are empty 301 \(and not shown by show-empty-searches)" t))) 302 "Various customization-options for notmuch-hello-tags/query-section.") 303 304 (define-widget 'notmuch-hello-tags-section 'lazy 305 "Customize-type for notmuch-hello tag-list sections." 306 :tag "Customized tag-list section (see docstring for details)" 307 :type 308 `(list :tag "" 309 (const :tag "" notmuch-hello-insert-tags-section) 310 (string :tag "Title for this section") 311 (plist 312 :inline t 313 :options 314 ,(append notmuch-hello-custom-section-options 315 '((:hide-tags (repeat :tag "Tags that will be hidden" 316 string))))))) 317 318 (define-widget 'notmuch-hello-query-section 'lazy 319 "Customize-type for custom saved-search-like sections" 320 :tag "Customized queries section (see docstring for details)" 321 :type 322 `(list :tag "" 323 (const :tag "" notmuch-hello-insert-searches) 324 (string :tag "Title for this section") 325 (repeat :tag "Queries" 326 (cons (string :tag "Name") (string :tag "Query"))) 327 (plist :inline t :options ,notmuch-hello-custom-section-options))) 328 329 (defcustom notmuch-hello-sections 330 (list #'notmuch-hello-insert-header 331 #'notmuch-hello-insert-saved-searches 332 #'notmuch-hello-insert-search 333 #'notmuch-hello-insert-recent-searches 334 #'notmuch-hello-insert-alltags 335 #'notmuch-hello-insert-footer) 336 "Sections for notmuch-hello. 337 338 The list contains functions which are used to construct sections in 339 notmuch-hello buffer. When notmuch-hello buffer is constructed, 340 these functions are run in the order they appear in this list. Each 341 function produces a section simply by adding content to the current 342 buffer. A section should not end with an empty line, because a 343 newline will be inserted after each section by `notmuch-hello'. 344 345 Each function should take no arguments. The return value is 346 ignored. 347 348 For convenience an element can also be a list of the form (FUNC ARG1 349 ARG2 .. ARGN) in which case FUNC will be applied to the rest of the 350 list. 351 352 A \"Customized tag-list section\" item in the customize-interface 353 displays a list of all tags, optionally hiding some of them. It 354 is also possible to filter the list of messages matching each tag 355 by an additional filter query. Similarly, the count of messages 356 displayed next to the buttons can be generated by applying a 357 different filter to the tag query. These filters are also 358 supported for \"Customized queries section\" items." 359 :group 'notmuch-hello 360 :type 361 '(repeat 362 (choice (function-item notmuch-hello-insert-header) 363 (function-item notmuch-hello-insert-saved-searches) 364 (function-item notmuch-hello-insert-search) 365 (function-item notmuch-hello-insert-recent-searches) 366 (function-item notmuch-hello-insert-alltags) 367 (function-item notmuch-hello-insert-footer) 368 (function-item notmuch-hello-insert-inbox) 369 notmuch-hello-tags-section 370 notmuch-hello-query-section 371 (function :tag "Custom section")))) 372 373 (defcustom notmuch-hello-auto-refresh t 374 "Automatically refresh when returning to the notmuch-hello buffer." 375 :group 'notmuch-hello 376 :type 'boolean) 377 378 ;;; Internal variables 379 380 (defvar notmuch-hello-hidden-sections nil 381 "List of sections titles whose contents are hidden.") 382 383 (defvar notmuch-hello-first-run t 384 "True if `notmuch-hello' is run for the first time, set to nil afterwards.") 385 386 ;;; Widgets for inserters 387 388 (define-widget 'notmuch-search-item 'item 389 "A recent search." 390 :format "%v\n" 391 :value-create 'notmuch-search-item-value-create) 392 393 (defun notmuch-search-item-value-create (widget) 394 (let ((value (widget-get widget :value))) 395 (widget-insert (make-string notmuch-hello-indent ?\s)) 396 (widget-create 'editable-field 397 :size (widget-get widget :size) 398 :parent widget 399 :action #'notmuch-hello-search 400 value) 401 (widget-insert " ") 402 (widget-create 'push-button 403 :parent widget 404 :notify #'notmuch-hello-add-saved-search 405 "save") 406 (widget-insert " ") 407 (widget-create 'push-button 408 :parent widget 409 :notify #'notmuch-hello-delete-search-from-history 410 "del"))) 411 412 (defun notmuch-search-item-field-width () 413 (max 8 ; Don't let the search boxes be less than 8 characters wide. 414 (- (window-width) 415 notmuch-hello-indent ; space at bol 416 notmuch-hello-indent ; space at eol 417 1 ; for the space before the [save] button 418 6 ; for the [save] button 419 1 ; for the space before the [del] button 420 5))) ; for the [del] button 421 422 ;;; Widget actions 423 424 (defun notmuch-hello-search (widget &rest _event) 425 (let ((search (widget-value widget))) 426 (when search 427 (setq search (string-trim search)) 428 (let ((history-delete-duplicates t)) 429 (add-to-history 'notmuch-search-history search))) 430 (notmuch-search search notmuch-search-oldest-first))) 431 432 (defun notmuch-hello-add-saved-search (widget &rest _event) 433 (let ((search (widget-value (widget-get widget :parent))) 434 (name (completing-read "Name for saved search: " 435 notmuch-saved-searches))) 436 ;; If an existing saved search with this name exists, remove it. 437 (setq notmuch-saved-searches 438 (cl-loop for elem in notmuch-saved-searches 439 unless (equal name (notmuch-saved-search-get elem :name)) 440 collect elem)) 441 ;; Add the new one. 442 (customize-save-variable 'notmuch-saved-searches 443 (add-to-list 'notmuch-saved-searches 444 (list :name name :query search) t)) 445 (message "Saved '%s' as '%s'." search name) 446 (notmuch-hello-update))) 447 448 (defun notmuch-hello-delete-search-from-history (widget &rest _event) 449 (when (y-or-n-p "Are you sure you want to delete this search? ") 450 (let ((search (widget-value (widget-get widget :parent)))) 451 (setq notmuch-search-history 452 (delete search notmuch-search-history))) 453 (notmuch-hello-update))) 454 455 ;;; Button utilities 456 457 ;; `notmuch-hello-query-counts', `notmuch-hello-nice-number' and 458 ;; `notmuch-hello-insert-buttons' are used outside this section. 459 ;; All other functions that are defined in this section are only 460 ;; used by these two functions. 461 462 (defun notmuch-hello-longest-label (searches-alist) 463 (or (cl-loop for elem in searches-alist 464 maximize (length (notmuch-saved-search-get elem :name))) 465 0)) 466 467 (defun notmuch-hello-reflect-generate-row (ncols nrows row list) 468 (let ((len (length list))) 469 (cl-loop for col from 0 to (- ncols 1) 470 collect (let ((offset (+ (* nrows col) row))) 471 (if (< offset len) 472 (nth offset list) 473 ;; Don't forget to insert an empty slot in the 474 ;; output matrix if there is no corresponding 475 ;; value in the input matrix. 476 nil))))) 477 478 (defun notmuch-hello-reflect (list ncols) 479 "Reflect a `ncols' wide matrix represented by `list' along the 480 diagonal." 481 ;; Not very lispy... 482 (let ((nrows (ceiling (length list) ncols))) 483 (cl-loop for row from 0 to (- nrows 1) 484 append (notmuch-hello-reflect-generate-row ncols nrows row list)))) 485 486 (defun notmuch-hello-widget-search (widget &rest _ignore) 487 (cl-case (widget-get widget :notmuch-search-type) 488 (tree 489 (let ((n (notmuch-search-format-buffer-name (widget-value widget) "tree" t))) 490 (notmuch-tree (widget-get widget :notmuch-search-terms) 491 nil nil n nil nil nil 492 (widget-get widget :notmuch-search-oldest-first)))) 493 (unthreaded 494 (let ((n (notmuch-search-format-buffer-name (widget-value widget) 495 "unthreaded" t))) 496 (notmuch-unthreaded (widget-get widget :notmuch-search-terms) nil nil n))) 497 (t 498 (notmuch-search (widget-get widget :notmuch-search-terms) 499 (widget-get widget :notmuch-search-oldest-first))))) 500 501 (defun notmuch-saved-search-count (search) 502 (car (notmuch--process-lines notmuch-command "count" search))) 503 504 (defun notmuch-hello-tags-per-line (widest) 505 "Determine how many tags to show per line and how wide they 506 should be. Returns a cons cell `(tags-per-line width)'." 507 (let ((tags-per-line 508 (cond 509 ((integerp notmuch-column-control) 510 (max 1 511 (/ (- (window-width) notmuch-hello-indent) 512 ;; Count is 9 wide (8 digits plus space), 1 for the space 513 ;; after the name. 514 (+ 9 1 (max notmuch-column-control widest))))) 515 ((floatp notmuch-column-control) 516 (let* ((available-width (- (window-width) notmuch-hello-indent)) 517 (proposed-width (max (* available-width notmuch-column-control) 518 widest))) 519 (floor available-width proposed-width))) 520 (t 521 (max 1 522 (/ (- (window-width) notmuch-hello-indent) 523 ;; Count is 9 wide (8 digits plus space), 1 for the space 524 ;; after the name. 525 (+ 9 1 widest))))))) 526 (cons tags-per-line (/ (max 1 527 (- (window-width) notmuch-hello-indent 528 ;; Count is 9 wide (8 digits plus 529 ;; space), 1 for the space after the 530 ;; name. 531 (* tags-per-line (+ 9 1)))) 532 tags-per-line)))) 533 534 (defun notmuch-hello-filtered-query (query filter) 535 "Constructs a query to search all messages matching QUERY and FILTER. 536 537 If FILTER is a string, it is directly used in the returned query. 538 539 If FILTER is a function, it is called with QUERY as a parameter and 540 the string it returns is used as the query. If nil is returned, 541 the entry is hidden. 542 543 Otherwise, FILTER is ignored." 544 (cond 545 ((functionp filter) (funcall filter query)) 546 ((stringp filter) 547 (concat "(" query ") and (" filter ")")) 548 (t query))) 549 550 (defun notmuch-hello-query-counts (query-list &rest options) 551 "Compute list of counts of matched messages from QUERY-LIST. 552 553 QUERY-LIST must be a list of saved-searches. Ideally each of 554 these is a plist but other options are available for backwards 555 compatibility: see `notmuch-saved-searches' for details. 556 557 The result is a list of plists each of which includes the 558 properties :name NAME, :query QUERY and :count COUNT, together 559 with any properties in the original saved-search. 560 561 The values :show-empty-searches, :filter and :filter-count from 562 options will be handled as specified for 563 `notmuch-hello-insert-searches'. :disable-includes can be used to 564 turn off the default exclude processing in `notmuch-count(1)'" 565 (with-temp-buffer 566 (dolist (elem query-list nil) 567 (let ((count-query (or (notmuch-saved-search-get elem :count-query) 568 (notmuch-saved-search-get elem :query)))) 569 (insert 570 (replace-regexp-in-string 571 "\n" " " 572 (notmuch-hello-filtered-query count-query 573 (or (plist-get options :filter-count) 574 (plist-get options :filter)))) 575 "\n"))) 576 (unless (= (notmuch--call-process-region (point-min) (point-max) notmuch-command 577 t t nil "count" 578 (if (plist-get options :disable-excludes) 579 "--exclude=false" 580 "--exclude=true") 581 "--batch") 0) 582 (notmuch-logged-error 583 "notmuch count --batch failed" 584 "Please check that the notmuch CLI is new enough to support `count 585 --batch'. In general we recommend running matching versions of 586 the CLI and emacs interface.")) 587 (goto-char (point-min)) 588 (cl-mapcan 589 (lambda (elem) 590 (let* ((elem-plist (notmuch-hello-saved-search-to-plist elem)) 591 (search-query (plist-get elem-plist :query)) 592 (filtered-query (notmuch-hello-filtered-query 593 search-query (plist-get options :filter))) 594 (message-count (prog1 (read (current-buffer)) 595 (forward-line 1)))) 596 (when (and filtered-query (or (plist-get options :show-empty-searches) 597 (> message-count 0))) 598 (setq elem-plist (plist-put elem-plist :query filtered-query)) 599 (list (plist-put elem-plist :count message-count))))) 600 query-list))) 601 602 (defun notmuch-hello-nice-number (n) 603 (let (result) 604 (while (> n 0) 605 (push (% n 1000) result) 606 (setq n (/ n 1000))) 607 (setq result (or result '(0))) 608 (apply #'concat 609 (number-to-string (car result)) 610 (mapcar (lambda (elem) 611 (format "%s%03d" notmuch-hello-thousands-separator elem)) 612 (cdr result))))) 613 614 (defun notmuch-hello-insert-buttons (searches) 615 "Insert buttons for SEARCHES. 616 617 SEARCHES must be a list of plists each of which should contain at 618 least the properties :name NAME :query QUERY and :count COUNT, 619 where QUERY is the query to start when the button for the 620 corresponding entry is activated, and COUNT should be the number 621 of messages matching the query. Such a plist can be computed 622 with `notmuch-hello-query-counts'." 623 (let* ((widest (notmuch-hello-longest-label searches)) 624 (tags-and-width (notmuch-hello-tags-per-line widest)) 625 (tags-per-line (car tags-and-width)) 626 (column-width (cdr tags-and-width)) 627 (column-indent 0) 628 (count 0) 629 (reordered-list (notmuch-hello-reflect searches tags-per-line)) 630 ;; Hack the display of the buttons used. 631 (widget-push-button-prefix "") 632 (widget-push-button-suffix "")) 633 ;; dme: It feels as though there should be a better way to 634 ;; implement this loop than using an incrementing counter. 635 (mapc (lambda (elem) 636 ;; (not elem) indicates an empty slot in the matrix. 637 (when elem 638 (when (> column-indent 0) 639 (widget-insert (make-string column-indent ? ))) 640 (let* ((name (plist-get elem :name)) 641 (query (plist-get elem :query)) 642 (oldest-first (cl-case (plist-get elem :sort-order) 643 (newest-first nil) 644 (oldest-first t) 645 (otherwise notmuch-search-oldest-first))) 646 (search-type (plist-get elem :search-type)) 647 (msg-count (plist-get elem :count))) 648 (widget-insert (format "%8s " 649 (notmuch-hello-nice-number msg-count))) 650 (widget-create 'push-button 651 :notify #'notmuch-hello-widget-search 652 :notmuch-search-terms query 653 :notmuch-search-oldest-first oldest-first 654 :notmuch-search-type search-type 655 name) 656 (setq column-indent 657 (1+ (max 0 (- column-width (length name))))))) 658 (cl-incf count) 659 (when (eq (% count tags-per-line) 0) 660 (setq column-indent 0) 661 (widget-insert "\n"))) 662 reordered-list) 663 ;; If the last line was not full (and hence did not include a 664 ;; carriage return), insert one now. 665 (unless (eq (% count tags-per-line) 0) 666 (widget-insert "\n")))) 667 668 ;;; Mode 669 670 (defun notmuch-hello-update () 671 "Update the notmuch-hello buffer." 672 ;; Lazy - rebuild everything. 673 (interactive) 674 (notmuch-hello t)) 675 676 (defun notmuch-hello-window-configuration-change () 677 "Hook function to update the hello buffer when it is switched to." 678 (let ((hello-buf (get-buffer "*notmuch-hello*")) 679 (do-refresh nil)) 680 ;; Consider all windows in the currently selected frame, since 681 ;; that's where the configuration change happened. This also 682 ;; refreshes our snapshot of all windows, so we have to do this 683 ;; even if we know we won't refresh (e.g., hello-buf is null). 684 (dolist (window (window-list)) 685 (let ((last-buf (window-parameter window 'notmuch-hello-last-buffer)) 686 (cur-buf (window-buffer window))) 687 (unless (eq last-buf cur-buf) 688 ;; This window changed or is new. Update recorded buffer 689 ;; for next time. 690 (set-window-parameter window 'notmuch-hello-last-buffer cur-buf) 691 (when (and (eq cur-buf hello-buf) last-buf) 692 ;; The user just switched to hello in this window (hello 693 ;; is currently visible, was not visible on the last 694 ;; configuration change, and this is not a new window) 695 (setq do-refresh t))))) 696 (when (and do-refresh notmuch-hello-auto-refresh) 697 ;; Refresh hello as soon as we get back to redisplay. On Emacs 698 ;; 24, we can't do it right here because something in this 699 ;; hook's call stack overrides hello's point placement. 700 ;; FIXME And on Emacs releases that we still support? 701 (run-at-time nil nil #'notmuch-hello t)) 702 (unless hello-buf 703 ;; Clean up hook 704 (remove-hook 'window-configuration-change-hook 705 #'notmuch-hello-window-configuration-change)))) 706 707 (defvar notmuch-hello-mode-map 708 ;; Inherit both widget-keymap and notmuch-common-keymap. We have 709 ;; to use make-sparse-keymap to force this to be a new keymap (so 710 ;; that when we modify map it does not modify widget-keymap). 711 (let ((map (make-composed-keymap (list (make-sparse-keymap) widget-keymap)))) 712 (set-keymap-parent map notmuch-common-keymap) 713 ;; Currently notmuch-hello-mode supports free text entry, but not 714 ;; tagging operations, so provide standard undo. 715 (define-key map [remap notmuch-tag-undo] #'undo) 716 map) 717 "Keymap for \"notmuch hello\" buffers.") 718 719 (define-derived-mode notmuch-hello-mode fundamental-mode "notmuch-hello" 720 "Major mode for convenient notmuch navigation. This is your entry 721 portal into notmuch. 722 723 Saved searches are \"bookmarks\" for arbitrary queries. Hit RET 724 or click on a saved search to view matching threads. Edit saved 725 searches with the `edit' button. Type `\\[notmuch-jump-search]' 726 in any Notmuch screen for quick access to saved searches that 727 have shortcut keys. 728 729 Type new searches in the search box and hit RET to view matching 730 threads. Hit RET in a recent search box to re-submit a previous 731 search. Edit it first if you like. Save a recent search to saved 732 searches with the `save' button. 733 734 Hit `\\[notmuch-search]' or `\\[notmuch-tree]' in any Notmuch 735 screen to search for messages and view matching threads or 736 messages, respectively. Recent searches are available in the 737 minibuffer history. 738 739 Expand the all tags view with the `show' button (and collapse 740 again with the `hide' button). Hit RET or click on a tag name to 741 view matching threads. 742 743 Hit `\\[notmuch-refresh-this-buffer]' to refresh the screen and 744 `\\[notmuch-bury-or-kill-this-buffer]' to quit. 745 746 The screen may be customized via `\\[customize]'. 747 748 Complete list of currently available key bindings: 749 750 \\{notmuch-hello-mode-map}" 751 (setq notmuch-buffer-refresh-function #'notmuch-hello-update)) 752 753 ;;; Inserters 754 755 (defun notmuch-hello-generate-tag-alist (&optional hide-tags) 756 "Return an alist from tags to queries to display in the all-tags section." 757 (cl-mapcan (lambda (tag) 758 (and (not (member tag hide-tags)) 759 (list (cons tag 760 (concat "tag:" 761 (notmuch-escape-boolean-term tag)))))) 762 (notmuch--process-lines notmuch-command "search" "--output=tags" "*"))) 763 764 (defun notmuch-hello-insert-header () 765 "Insert the default notmuch-hello header." 766 (when notmuch-show-logo 767 (let ((image notmuch-hello-logo)) 768 ;; The notmuch logo uses transparency. That can display poorly 769 ;; when inserting the image into an emacs buffer (black logo on 770 ;; a black background), so force the background colour of the 771 ;; image. We use a face to represent the colour so that 772 ;; `defface' can be used to declare the different possible 773 ;; colours, which depend on whether the frame has a light or 774 ;; dark background. 775 (setq image (cons 'image 776 (append (cdr image) 777 (list :background 778 (face-background 779 'notmuch-hello-logo-background))))) 780 (insert-image image)) 781 (widget-insert " ")) 782 783 (widget-insert "Welcome to ") 784 ;; Hack the display of the links used. 785 (let ((widget-link-prefix "") 786 (widget-link-suffix "")) 787 (widget-create 'link 788 :notify (lambda (&rest _ignore) 789 (browse-url notmuch-hello-url)) 790 :help-echo "Visit the notmuch website." 791 "notmuch") 792 (widget-insert ". ") 793 (widget-insert "You have ") 794 (widget-create 'link 795 :notify (lambda (&rest _ignore) 796 (notmuch-hello-update)) 797 :help-echo "Refresh" 798 (notmuch-hello-nice-number 799 (string-to-number 800 (car (notmuch--process-lines notmuch-command "count" "--exclude=false"))))) 801 (widget-insert " messages.\n"))) 802 803 (defun notmuch-hello-insert-saved-searches () 804 "Insert the saved-searches section." 805 (let ((searches (notmuch-hello-query-counts 806 (if notmuch-saved-search-sort-function 807 (funcall notmuch-saved-search-sort-function 808 notmuch-saved-searches) 809 notmuch-saved-searches) 810 :show-empty-searches notmuch-show-empty-saved-searches))) 811 (when searches 812 (widget-insert "Saved searches: ") 813 (widget-create 'push-button 814 :notify (lambda (&rest _ignore) 815 (customize-variable 'notmuch-saved-searches)) 816 "edit") 817 (widget-insert "\n\n") 818 (let ((start (point))) 819 (notmuch-hello-insert-buttons searches) 820 (indent-rigidly start (point) notmuch-hello-indent))))) 821 822 (defun notmuch-hello-insert-search () 823 "Insert a search widget." 824 (widget-insert "Search: ") 825 (widget-create 'editable-field 826 ;; Leave some space at the start and end of the 827 ;; search boxes. 828 :size (max 8 (- (window-width) notmuch-hello-indent 829 (length "Search: "))) 830 :action #'notmuch-hello-search) 831 ;; Add an invisible dot to make `widget-end-of-line' ignore 832 ;; trailing spaces in the search widget field. A dot is used 833 ;; instead of a space to make `show-trailing-whitespace' 834 ;; happy, i.e. avoid it marking the whole line as trailing 835 ;; spaces. 836 (widget-insert (propertize "." 'invisible t)) 837 (widget-insert "\n")) 838 839 (defun notmuch-hello-insert-recent-searches () 840 "Insert recent searches." 841 (when notmuch-search-history 842 (widget-insert "Recent searches: ") 843 (widget-create 844 'push-button 845 :notify (lambda (&rest _ignore) 846 (when (y-or-n-p "Are you sure you want to clear the searches? ") 847 (setq notmuch-search-history nil) 848 (notmuch-hello-update))) 849 "clear") 850 (widget-insert "\n\n") 851 (let ((width (notmuch-search-item-field-width))) 852 (dolist (search (seq-take notmuch-search-history 853 notmuch-hello-recent-searches-max)) 854 (widget-create 'notmuch-search-item :value search :size width))))) 855 856 (defun notmuch-hello-insert-searches (title query-list &rest options) 857 "Insert a section with TITLE showing a list of buttons made from 858 QUERY-LIST. 859 860 QUERY-LIST should ideally be a plist but for backwards 861 compatibility other forms are also accepted (see 862 `notmuch-saved-searches' for details). The plist should 863 contain keys :name and :query; if :count-query is also present 864 then it specifies an alternate query to be used to generate the 865 count for the associated search. 866 867 Supports the following entries in OPTIONS as a plist: 868 :initially-hidden - if non-nil, section will be hidden on startup 869 :show-empty-searches - show buttons with no matching messages 870 :hide-if-empty - hide if no buttons would be shown 871 (only makes sense without :show-empty-searches) 872 :filter - This can be a function that takes the search query as 873 its argument and returns a filter to be used in conjunction 874 with the query for that search or nil to hide the 875 element. This can also be a string that is used as a combined 876 with each query using \"and\". 877 :filter-count - Separate filter to generate the count displayed 878 each search. Accepts the same values as :filter. If :filter 879 and :filter-count are specified, this will be used instead of 880 :filter, not in conjunction with it." 881 882 (widget-insert title ": ") 883 (when (and notmuch-hello-first-run (plist-get options :initially-hidden)) 884 (add-to-list 'notmuch-hello-hidden-sections title)) 885 (let ((is-hidden (member title notmuch-hello-hidden-sections)) 886 (start (point))) 887 (if is-hidden 888 (widget-create 'push-button 889 :notify (lambda (&rest _ignore) 890 (setq notmuch-hello-hidden-sections 891 (delete title notmuch-hello-hidden-sections)) 892 (notmuch-hello-update)) 893 "show") 894 (widget-create 'push-button 895 :notify (lambda (&rest _ignore) 896 (add-to-list 'notmuch-hello-hidden-sections 897 title) 898 (notmuch-hello-update)) 899 "hide")) 900 (widget-insert "\n") 901 (unless is-hidden 902 (let ((searches (apply 'notmuch-hello-query-counts query-list options))) 903 (when (or (not (plist-get options :hide-if-empty)) 904 searches) 905 (widget-insert "\n") 906 (notmuch-hello-insert-buttons searches) 907 (indent-rigidly start (point) notmuch-hello-indent)))))) 908 909 (defun notmuch-hello-insert-tags-section (&optional title &rest options) 910 "Insert a section displaying all tags with message counts. 911 912 TITLE defaults to \"All tags\". 913 Allowed options are those accepted by `notmuch-hello-insert-searches' and the 914 following: 915 916 :hide-tags - List of tags that should be excluded." 917 (apply 'notmuch-hello-insert-searches 918 (or title "All tags") 919 (notmuch-hello-generate-tag-alist (plist-get options :hide-tags)) 920 options)) 921 922 (defun notmuch-hello-insert-inbox () 923 "Show an entry for each saved search and inboxed messages for each tag." 924 (notmuch-hello-insert-searches "What's in your inbox" 925 (append 926 notmuch-saved-searches 927 (notmuch-hello-generate-tag-alist)) 928 :filter "tag:inbox")) 929 930 (defun notmuch-hello-insert-alltags () 931 "Insert a section displaying all tags and associated message counts." 932 (notmuch-hello-insert-tags-section 933 nil 934 :initially-hidden (not notmuch-show-all-tags-list) 935 :hide-tags notmuch-hello-hide-tags 936 :filter notmuch-hello-tag-list-make-query 937 :disable-excludes t)) 938 939 (defun notmuch-hello-insert-footer () 940 "Insert the notmuch-hello footer." 941 (let ((start (point))) 942 (widget-insert "Hit `?' for context-sensitive help in any Notmuch screen.\n") 943 (widget-insert "Customize ") 944 (widget-create 'link 945 :notify (lambda (&rest _ignore) 946 (customize-group 'notmuch)) 947 :button-prefix "" :button-suffix "" 948 "Notmuch") 949 (widget-insert " or ") 950 (widget-create 'link 951 :notify (lambda (&rest _ignore) 952 (customize-variable 'notmuch-hello-sections)) 953 :button-prefix "" :button-suffix "" 954 "this page.") 955 (let ((fill-column (- (window-width) notmuch-hello-indent))) 956 (center-region start (point))))) 957 958 ;;; Hello! 959 960 ;;;###autoload 961 (defun notmuch-hello (&optional no-display) 962 "Run notmuch and display saved searches, known tags, etc." 963 (interactive) 964 (notmuch-assert-cli-sane) 965 ;; This may cause a window configuration change, so if the 966 ;; auto-refresh hook is already installed, avoid recursive refresh. 967 (let ((notmuch-hello-auto-refresh nil)) 968 (if no-display 969 (set-buffer "*notmuch-hello*") 970 (pop-to-buffer-same-window "*notmuch-hello*"))) 971 ;; Install auto-refresh hook 972 (when notmuch-hello-auto-refresh 973 (add-hook 'window-configuration-change-hook 974 #'notmuch-hello-window-configuration-change)) 975 (let ((target-line (line-number-at-pos)) 976 (target-column (current-column)) 977 (inhibit-read-only t)) 978 ;; Delete all editable widget fields. Editable widget fields are 979 ;; tracked in a buffer local variable `widget-field-list' (and 980 ;; others). If we do `erase-buffer' without properly deleting the 981 ;; widgets, some widget-related functions are confused later. 982 (mapc 'widget-delete widget-field-list) 983 (erase-buffer) 984 (unless (eq major-mode 'notmuch-hello-mode) 985 (notmuch-hello-mode)) 986 (let ((all (overlay-lists))) 987 ;; Delete all the overlays. 988 (mapc 'delete-overlay (car all)) 989 (mapc 'delete-overlay (cdr all))) 990 (mapc 991 (lambda (section) 992 (let ((point-before (point))) 993 (if (functionp section) 994 (funcall section) 995 (apply (car section) (cdr section))) 996 ;; don't insert a newline when the previous section didn't 997 ;; show anything. 998 (unless (eq (point) point-before) 999 (widget-insert "\n")))) 1000 notmuch-hello-sections) 1001 (widget-setup) 1002 ;; Move point back to where it was before refresh. Use line and 1003 ;; column instead of point directly to be insensitive to additions 1004 ;; and removals of text within earlier lines. 1005 (goto-char (point-min)) 1006 (forward-line (1- target-line)) 1007 (move-to-column target-column)) 1008 (run-hooks 'notmuch-hello-refresh-hook) 1009 (setq notmuch-hello-first-run nil)) 1010 1011 ;;; _ 1012 1013 (provide 'notmuch-hello) 1014 1015 ;;; notmuch-hello.el ends here