65fde75a07d3b3ad10179315941c29ab0a26bacb
[notmuch] / emacs / notmuch-hello.el
1 ;; notmuch-hello.el --- welcome to notmuch, a frontend
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 <http://www.gnu.org/licenses/>.
19 ;;
20 ;; Authors: David Edmondson <dme@dme.org>
21
22 (eval-when-compile (require 'cl))
23 (require 'widget)
24 (require 'wid-edit) ; For `widget-forward'.
25
26 (require 'notmuch-lib)
27 (require 'notmuch-mua)
28
29 (declare-function notmuch-search "notmuch" (query &optional oldest-first target-thread target-line continuation))
30 (declare-function notmuch-poll "notmuch" ())
31
32 (defvar notmuch-hello-search-bar-marker nil
33   "The position of the search bar within the notmuch-hello buffer.")
34
35 (defcustom notmuch-recent-searches-max 10
36   "The number of recent searches to store and display."
37   :type 'integer
38   :group 'notmuch)
39
40 (defcustom notmuch-show-empty-saved-searches nil
41   "Should saved searches with no messages be listed?"
42   :type 'boolean
43   :group 'notmuch)
44
45 (defvar notmuch-hello-indent 4
46   "How much to indent non-headers.")
47
48 (defcustom notmuch-show-logo t
49   "Should the notmuch logo be shown?"
50   :type 'boolean
51   :group 'notmuch)
52
53 (defcustom notmuch-show-all-tags-list nil
54   "Should all tags be shown in the notmuch-hello view?"
55   :type 'boolean
56   :group 'notmuch)
57
58 (defcustom notmuch-hello-tag-list-make-query nil
59   "Function or string to generate queries for the all tags list.
60
61 This variable controls which query results are shown for each tag
62 in the \"all tags\" list. If nil, it will use all messages with
63 that tag. If this is set to a string, it is used as a filter for
64 messages having that tag (equivalent to \"tag:TAG and (THIS-VARIABLE)\").
65 Finally this can be a function that will be called for each tag and
66 should return a filter for that tag, or nil to hide the tag."
67   :type '(choice (const :tag "All messages" nil)
68                  (const :tag "Unread messages" "tag:unread")
69                  (const :tag "Custom filter" string)
70                  (const :tag "Custom filter function" function))
71   :group 'notmuch)
72
73 (defcustom notmuch-hello-hide-tags nil
74   "List of tags to be hidden in the \"all tags\"-section."
75   :type '(repeat string)
76   :group 'notmuch)
77
78 (defface notmuch-hello-logo-background
79   '((((class color)
80       (background dark))
81      (:background "#5f5f5f"))
82     (((class color)
83       (background light))
84      (:background "white")))
85   "Background colour for the notmuch logo."
86   :group 'notmuch)
87
88 (defcustom notmuch-column-control t
89   "Controls the number of columns for saved searches/tags in notmuch view.
90
91 This variable has three potential sets of values:
92
93 - t: automatically calculate the number of columns possible based
94   on the tags to be shown and the window width,
95 - an integer: a lower bound on the number of characters that will
96   be used to display each column,
97 - a float: a fraction of the window width that is the lower bound
98   on the number of characters that should be used for each
99   column.
100
101 So:
102 - if you would like two columns of tags, set this to 0.5.
103 - if you would like a single column of tags, set this to 1.0.
104 - if you would like tags to be 30 characters wide, set this to
105   30.
106 - if you don't want to worry about all of this nonsense, leave
107   this set to `t'."
108   :group 'notmuch
109   :type '(choice
110           (const :tag "Automatically calculated" t)
111           (integer :tag "Number of characters")
112           (float :tag "Fraction of window")))
113
114 (defcustom notmuch-decimal-separator ","
115   "The string used as a decimal separator.
116
117 Typically \",\" in the US and UK and \".\" in Europe."
118   :group 'notmuch
119   :type 'string)
120
121 (defvar notmuch-hello-url "http://notmuchmail.org"
122   "The `notmuch' web site.")
123
124 (defvar notmuch-hello-recent-searches nil)
125
126 (defun notmuch-hello-remember-search (search)
127   (if (not (member search notmuch-hello-recent-searches))
128       (push search notmuch-hello-recent-searches))
129   (if (> (length notmuch-hello-recent-searches)
130          notmuch-recent-searches-max)
131       (setq notmuch-hello-recent-searches (butlast notmuch-hello-recent-searches))))
132
133 (defun notmuch-hello-nice-number (n)
134   (let (result)
135     (while (> n 0)
136       (push (% n 1000) result)
137       (setq n (/ n 1000)))
138     (setq result (or result '(0)))
139     (apply #'concat
140      (number-to-string (car result))
141      (mapcar (lambda (elem)
142               (format "%s%03d" notmuch-decimal-separator elem))
143              (cdr result)))))
144
145 (defun notmuch-hello-trim (search)
146   "Trim whitespace."
147   (if (string-match "^[[:space:]]*\\(.*[^[:space:]]\\)[[:space:]]*$" search)
148       (match-string 1 search)
149     search))
150
151 (defun notmuch-hello-search (search)
152   (let ((search (notmuch-hello-trim search)))
153     (notmuch-hello-remember-search search)
154     (notmuch-search search notmuch-search-oldest-first nil nil #'notmuch-hello-search-continuation)))
155
156 (defun notmuch-hello-add-saved-search (widget)
157   (interactive)
158   (let ((search (widget-value
159                  (symbol-value
160                   (widget-get widget :notmuch-saved-search-widget))))
161         (name (completing-read "Name for saved search: "
162                                notmuch-saved-searches)))
163     ;; If an existing saved search with this name exists, remove it.
164     (setq notmuch-saved-searches
165           (loop for elem in notmuch-saved-searches
166                 if (not (equal name
167                                (car elem)))
168                 collect elem))
169     ;; Add the new one.
170     (customize-save-variable 'notmuch-saved-searches
171                              (push (cons name search)
172                                    notmuch-saved-searches))
173     (message "Saved '%s' as '%s'." search name)
174     (notmuch-hello-update)))
175
176 (defun notmuch-hello-longest-label (tag-alist)
177   (or (loop for elem in tag-alist
178             maximize (length (car elem)))
179       0))
180
181 (defun notmuch-hello-reflect-generate-row (ncols nrows row list)
182   (let ((len (length list)))
183     (loop for col from 0 to (- ncols 1)
184           collect (let ((offset (+ (* nrows col) row)))
185                     (if (< offset len)
186                         (nth offset list)
187                       ;; Don't forget to insert an empty slot in the
188                       ;; output matrix if there is no corresponding
189                       ;; value in the input matrix.
190                       nil)))))
191
192 (defun notmuch-hello-reflect (list ncols)
193   "Reflect a `ncols' wide matrix represented by `list' along the
194 diagonal."
195   ;; Not very lispy...
196   (let ((nrows (ceiling (length list) ncols)))
197     (loop for row from 0 to (- nrows 1)
198           append (notmuch-hello-reflect-generate-row ncols nrows row list))))
199
200 (defun notmuch-hello-widget-search (widget &rest ignore)
201   (notmuch-search (widget-get widget
202                               :notmuch-search-terms)
203                   notmuch-search-oldest-first
204                   nil nil #'notmuch-hello-search-continuation))
205
206 (defun notmuch-saved-search-count (search)
207   (car (process-lines notmuch-command "count" search)))
208
209 (defun notmuch-hello-tags-per-line (widest)
210   "Determine how many tags to show per line and how wide they
211 should be. Returns a cons cell `(tags-per-line width)'."
212   (let ((tags-per-line
213          (cond
214           ((integerp notmuch-column-control)
215            (max 1
216                 (/ (- (window-width) notmuch-hello-indent)
217                    ;; Count is 9 wide (8 digits plus space), 1 for the space
218                    ;; after the name.
219                    (+ 9 1 (max notmuch-column-control widest)))))
220
221           ((floatp notmuch-column-control)
222            (let* ((available-width (- (window-width) notmuch-hello-indent))
223                   (proposed-width (max (* available-width notmuch-column-control) widest)))
224              (floor available-width proposed-width)))
225
226           (t
227            (max 1
228                 (/ (- (window-width) notmuch-hello-indent)
229                    ;; Count is 9 wide (8 digits plus space), 1 for the space
230                    ;; after the name.
231                    (+ 9 1 widest)))))))
232
233     (cons tags-per-line (/ (max 1
234                                 (- (window-width) notmuch-hello-indent
235                                    ;; Count is 9 wide (8 digits plus
236                                    ;; space), 1 for the space after the
237                                    ;; name.
238                                    (* tags-per-line (+ 9 1))))
239                            tags-per-line))))
240
241 (defun notmuch-hello-insert-tags (tag-alist widest target)
242   (let* ((tags-and-width (notmuch-hello-tags-per-line widest))
243          (tags-per-line (car tags-and-width))
244          (widest (cdr tags-and-width))
245          (count 0)
246          (reordered-list (notmuch-hello-reflect tag-alist tags-per-line))
247          ;; Hack the display of the buttons used.
248          (widget-push-button-prefix "")
249          (widget-push-button-suffix "")
250          (found-target-pos nil))
251     ;; dme: It feels as though there should be a better way to
252     ;; implement this loop than using an incrementing counter.
253     (mapc (lambda (elem)
254             ;; (not elem) indicates an empty slot in the matrix.
255             (when elem
256               (let* ((name (car elem))
257                      (query (cdr elem))
258                      (formatted-name (format "%s " name)))
259                 (widget-insert (format "%8s "
260                                        (notmuch-hello-nice-number
261                                         (string-to-number (notmuch-saved-search-count query)))))
262                 (if (string= formatted-name target)
263                     (setq found-target-pos (point-marker)))
264                 (widget-create 'push-button
265                                :notify #'notmuch-hello-widget-search
266                                :notmuch-search-terms query
267                                formatted-name)
268                 ;; Insert enough space to consume the rest of the
269                 ;; column.  Because the button for the name is `(1+
270                 ;; (length name))' long (due to the trailing space) we
271                 ;; can just insert `(- widest (length name))' spaces -
272                 ;; the column separator is included in the button if
273                 ;; `(equal widest (length name)'.
274                 (widget-insert (make-string (max 1
275                                                  (- widest (length name)))
276                                             ? ))))
277             (setq count (1+ count))
278             (if (eq (% count tags-per-line) 0)
279                 (widget-insert "\n")))
280           reordered-list)
281
282     ;; If the last line was not full (and hence did not include a
283     ;; carriage return), insert one now.
284     (if (not (eq (% count tags-per-line) 0))
285         (widget-insert "\n"))
286     found-target-pos))
287
288 (defun notmuch-hello-goto-search ()
289   "Put point inside the `search' widget."
290   (interactive)
291   (goto-char notmuch-hello-search-bar-marker))
292
293 (defimage notmuch-hello-logo ((:type png :file "notmuch-logo.png")))
294
295 (defun notmuch-hello-search-continuation()
296   (notmuch-hello-update t))
297
298 (defun notmuch-hello-update (&optional no-display)
299   "Update the current notmuch view."
300   ;; Lazy - rebuild everything.
301   (interactive)
302   (notmuch-hello no-display))
303
304 (defun notmuch-hello-poll-and-update ()
305   "Invoke `notmuch-poll' to import mail, then refresh the current view."
306   (interactive)
307   (notmuch-poll)
308   (notmuch-hello-update))
309
310
311 (defvar notmuch-hello-mode-map
312   (let ((map (make-sparse-keymap)))
313     (set-keymap-parent map widget-keymap)
314     (define-key map "v" '(lambda () "Display the notmuch version" (interactive)
315                            (message "notmuch version %s" (notmuch-version))))
316     (define-key map "?" 'notmuch-help)
317     (define-key map "q" 'notmuch-kill-this-buffer)
318     (define-key map "=" 'notmuch-hello-update)
319     (define-key map "G" 'notmuch-hello-poll-and-update)
320     (define-key map (kbd "<C-tab>") 'widget-backward)
321     (define-key map "m" 'notmuch-mua-new-mail)
322     (define-key map "s" 'notmuch-hello-goto-search)
323     map)
324   "Keymap for \"notmuch hello\" buffers.")
325 (fset 'notmuch-hello-mode-map notmuch-hello-mode-map)
326
327 (defun notmuch-hello-mode ()
328  "Major mode for convenient notmuch navigation. This is your entry portal into notmuch.
329
330 Complete list of currently available key bindings:
331
332 \\{notmuch-hello-mode-map}"
333  (interactive)
334  (kill-all-local-variables)
335  (use-local-map notmuch-hello-mode-map)
336  (setq major-mode 'notmuch-hello-mode
337        mode-name "notmuch-hello")
338  ;;(setq buffer-read-only t)
339 )
340
341 (defun notmuch-hello-generate-tag-alist ()
342   "Return an alist from tags to queries to display in the all-tags section."
343   (notmuch-remove-if-not
344    #'cdr
345    (mapcar (lambda (tag)
346              (cons tag
347                    (cond
348                     ((functionp notmuch-hello-tag-list-make-query)
349                      (concat "tag:" tag " and ("
350                              (funcall notmuch-hello-tag-list-make-query tag) ")"))
351                     ((stringp notmuch-hello-tag-list-make-query)
352                      (concat "tag:" tag " and ("
353                              notmuch-hello-tag-list-make-query ")"))
354                     (t (concat "tag:" tag)))))
355            (notmuch-remove-if-not
356             (lambda (tag)
357               (not (member tag notmuch-hello-hide-tags)))
358             (process-lines notmuch-command "search-tags")))))
359
360 ;;;###autoload
361 (defun notmuch-hello (&optional no-display)
362   "Run notmuch and display saved searches, known tags, etc."
363   (interactive)
364
365   ; Jump through a hoop to get this value from the deprecated variable
366   ; name (`notmuch-folders') or from the default value.
367   (if (not notmuch-saved-searches)
368     (setq notmuch-saved-searches (notmuch-saved-searches)))
369
370   (if no-display
371       (set-buffer "*notmuch-hello*")
372     (switch-to-buffer "*notmuch-hello*"))
373
374   (let ((target (if (widget-at)
375                    (widget-value (widget-at))
376                  (condition-case nil
377                      (progn
378                        (widget-forward 1)
379                        (widget-value (widget-at)))
380                    (error nil)))))
381
382     (kill-all-local-variables)
383     (let ((inhibit-read-only t))
384       (erase-buffer))
385
386     (unless (eq major-mode 'notmuch-hello-mode)
387       (notmuch-hello-mode))
388
389     (let ((all (overlay-lists)))
390       ;; Delete all the overlays.
391       (mapc 'delete-overlay (car all))
392       (mapc 'delete-overlay (cdr all)))
393
394     (when notmuch-show-logo
395       (let ((image notmuch-hello-logo))
396         ;; The notmuch logo uses transparency. That can display poorly
397         ;; when inserting the image into an emacs buffer (black logo on
398         ;; a black background), so force the background colour of the
399         ;; image. We use a face to represent the colour so that
400         ;; `defface' can be used to declare the different possible
401         ;; colours, which depend on whether the frame has a light or
402         ;; dark background.
403         (setq image (cons 'image
404                           (append (cdr image)
405                                   (list :background (face-background 'notmuch-hello-logo-background)))))
406         (insert-image image))
407       (widget-insert "  "))
408
409     (widget-insert "Welcome to ")
410     ;; Hack the display of the links used.
411     (let ((widget-link-prefix "")
412           (widget-link-suffix ""))
413       (widget-create 'link
414                      :notify (lambda (&rest ignore)
415                                (browse-url notmuch-hello-url))
416                      :help-echo "Visit the notmuch website."
417                      "notmuch")
418       (widget-insert ". ")
419       (widget-insert "You have ")
420       (widget-create 'link
421                      :notify (lambda (&rest ignore)
422                                (notmuch-hello-update))
423                      :help-echo "Refresh"
424                      (notmuch-hello-nice-number
425                       (string-to-number (car (process-lines notmuch-command "count")))))
426       (widget-insert " messages.\n"))
427
428     (let ((found-target-pos nil)
429           (final-target-pos nil))
430       (let* ((saved-alist
431               ;; Filter out empty saved searches if required.
432               (if notmuch-show-empty-saved-searches
433                   notmuch-saved-searches
434                 (loop for elem in notmuch-saved-searches
435                       if (> (string-to-number (notmuch-saved-search-count (cdr elem))) 0)
436                       collect elem)))
437              (saved-widest (notmuch-hello-longest-label saved-alist))
438              (alltags-alist (if notmuch-show-all-tags-list (notmuch-hello-generate-tag-alist)))
439              (alltags-widest (notmuch-hello-longest-label alltags-alist))
440              (widest (max saved-widest alltags-widest)))
441
442         (when saved-alist
443           (widget-insert "\nSaved searches: ")
444           (widget-create 'push-button
445                          :notify (lambda (&rest ignore)
446                                    (customize-variable 'notmuch-saved-searches))
447                          "edit")
448           (widget-insert "\n\n")
449           (setq final-target-pos (point-marker))
450           (let ((start (point)))
451             (setq found-target-pos (notmuch-hello-insert-tags saved-alist widest target))
452             (if found-target-pos
453                 (setq final-target-pos found-target-pos))
454             (indent-rigidly start (point) notmuch-hello-indent)))
455
456         (widget-insert "\nSearch: ")
457         (setq notmuch-hello-search-bar-marker (point-marker))
458         (widget-create 'editable-field
459                        ;; Leave some space at the start and end of the
460                        ;; search boxes.
461                        :size (max 8 (- (window-width) notmuch-hello-indent
462                                        (length "Search: ")))
463                        :action (lambda (widget &rest ignore)
464                                  (notmuch-hello-search (widget-value widget))))
465         (widget-insert "\n")
466
467         (when notmuch-hello-recent-searches
468           (widget-insert "\nRecent searches: ")
469           (widget-create 'push-button
470                          :notify (lambda (&rest ignore)
471                                    (setq notmuch-hello-recent-searches nil)
472                                    (notmuch-hello-update))
473                          "clear")
474           (widget-insert "\n\n")
475           (let ((start (point))
476                 (nth 0))
477             (mapc '(lambda (search)
478                      (let ((widget-symbol (intern (format "notmuch-hello-search-%d" nth))))
479                        (set widget-symbol
480                             (widget-create 'editable-field
481                                        ;; Don't let the search boxes be
482                                        ;; less than 8 characters wide.
483                                        :size (max 8
484                                                   (- (window-width)
485                                                      ;; Leave some space
486                                                      ;; at the start and
487                                                      ;; end of the
488                                                      ;; boxes.
489                                                      (* 2 notmuch-hello-indent)
490                                                      ;; 1 for the space
491                                                      ;; before the
492                                                      ;; `[save]' button. 6
493                                                      ;; for the `[save]'
494                                                      ;; button.
495                                                      1 6))
496                                        :action (lambda (widget &rest ignore)
497                                                  (notmuch-hello-search (widget-value widget)))
498                                        search))
499                        (widget-insert " ")
500                        (widget-create 'push-button
501                                       :notify (lambda (widget &rest ignore)
502                                                 (notmuch-hello-add-saved-search widget))
503                                       :notmuch-saved-search-widget widget-symbol
504                                       "save"))
505                      (widget-insert "\n")
506                      (setq nth (1+ nth)))
507                   notmuch-hello-recent-searches)
508             (indent-rigidly start (point) notmuch-hello-indent)))
509
510         (when alltags-alist
511           (widget-insert "\nAll tags: ")
512           (widget-create 'push-button
513                          :notify (lambda (widget &rest ignore)
514                                    (setq notmuch-show-all-tags-list nil)
515                                    (notmuch-hello-update))
516                          "hide")
517           (widget-insert "\n\n")
518           (let ((start (point)))
519             (setq found-target-pos (notmuch-hello-insert-tags alltags-alist widest target))
520             (if (not final-target-pos)
521                 (setq final-target-pos found-target-pos))
522             (indent-rigidly start (point) notmuch-hello-indent)))
523
524         (widget-insert "\n")
525
526         (if (not notmuch-show-all-tags-list)
527             (widget-create 'push-button
528                            :notify (lambda (widget &rest ignore)
529                                      (setq notmuch-show-all-tags-list t)
530                                      (notmuch-hello-update))
531                            "Show all tags")))
532
533       (let ((start (point)))
534         (widget-insert "\n\n")
535         (widget-insert "Type a search query and hit RET to view matching threads.\n")
536         (when notmuch-hello-recent-searches
537           (widget-insert "Hit RET to re-submit a previous search. Edit it first if you like.\n")
538           (widget-insert "Save recent searches with the `save' button.\n"))
539         (when notmuch-saved-searches
540           (widget-insert "Edit saved searches with the `edit' button.\n"))
541         (widget-insert "Hit RET or click on a saved search or tag name to view matching threads.\n")
542         (widget-insert "`=' refreshes this screen. `s' jumps to the search box. `q' to quit.\n")
543         (let ((fill-column (- (window-width) notmuch-hello-indent)))
544           (center-region start (point))))
545
546       (widget-setup)
547
548       (when final-target-pos
549         (goto-char final-target-pos)
550         (unless (widget-at)
551           (widget-forward 1)))
552
553       (unless (widget-at)
554         (notmuch-hello-goto-search)))))
555
556 (defun notmuch-folder ()
557   "Deprecated function for invoking notmuch---calling `notmuch' is preferred now."
558   (interactive)
559   (notmuch-hello))
560
561 ;;
562
563 (provide 'notmuch-hello)