1 # -*- coding: utf-8 -*-
2 # Copyright Neil Brown ©2016-2023 <neil@brown.name>
3 # May be distributed under terms of GPLv2 - see file:COPYING
5 # edlib module for working with "notmuch" email.
8 # - search list: list of saved searches with count of 'current', 'unread',
10 # - message list: provided by notmuch-search, though probably with enhanced threads
12 # Messages and composition is handled by a separte 'email' module. This
13 # module will send messages to that module and provide services for composing
16 # These can be all in with one pane, with sub-panes, or can sometimes
17 # have a pane to themselves.
19 # saved search are stored in config (file or database) as "query.foo"
21 # "query.current" selects messages that have not been archived, and are not spam
22 # "query.unread" selects messages that should be highlighted. It is normally
24 # "query.new" selects new messages. Normally "tag:new not tag:unread"
25 # "query.current-list" should be a conjunction of "query:" searches. They are listed
26 # in the "search list" together with a count of 'current' and 'current/new' messages.
27 # "query.misc-list" is a subset of current-list for which query:current should not
29 # "query.from-list" is a list of other queries for which it is meaningful
30 # to add "from:address" clauses for some address.
31 # "query.to-list" is a list of other queries for which it is meaningful
32 # to add "to:address" clauses for some address.
36 from subprocess import Popen, PIPE, DEVNULL, TimeoutExpired
49 return "%3.1fK" % (float(n)/1000.0)
51 return "%3dK" % (n/1000)
53 return "%3.1fM" % (float(n)/1000000.0)
55 return "%3dM" % (n/1000000)
58 def notmuch_get_tags(msg=None,thread=None):
62 query = "thread:" + thread
66 p = Popen(["/usr/bin/notmuch","search","--output=tags",query],
67 stdout = PIPE, stderr = DEVNULL)
71 out,err = p.communicate(timeout=5)
74 except TimeoutExpired:
76 out,err = p.communicate()
78 return out.decode("utf-8","ignore").strip('\n').split('\n')
80 def notmuch_get_files(msg):
83 p = Popen(["/usr/bin/notmuch","search","--output=files", "--format=text0",
85 stdout = PIPE, stderr = DEVNULL)
89 out,err = p.communicate(timeout=5)
92 except TimeoutExpired:
94 out,err = p.communicate()
96 return out.decode("utf-8","ignore").strip('\0').split('\0')
98 def notmuch_set_tags(msg=None, thread=None, add=None, remove=None):
99 if not add and not remove:
104 query = "thread:" + thread
107 argv = ["/usr/bin/notmuch","tag"]
115 p = Popen(argv, stdin = DEVNULL, stdout = DEVNULL, stderr = DEVNULL)
116 # FIXME I have to wait so that a subsequent 'get' works.
119 def notmuch_start_load_thread(tid, query=None):
121 q = "thread:%s and (%s)" % (tid, query)
123 q = "thread:%s" % (tid)
124 argv = ["/usr/bin/notmuch", "show", "--format=json", q]
125 p = Popen(argv, stdin = DEVNULL, stdout = PIPE, stderr = DEVNULL)
128 def notmuch_load_thread(tid, query=None):
129 p = notmuch_start_load_thread(tid, query)
130 out,err = p.communicate()
133 # we sometimes sees "[[[[]]]]" as the list of threads,
134 # which isn't properly formatted. So add an extr check on
136 # This can happen when the file containing message cannot be found.
137 r = json.loads(out.decode("utf-8","ignore"))
138 if not r or type(r[0][0][0]) != dict:
140 # r is a list of threads, we want just one thread.
144 # manage a queue of queries that need to be counted.
145 def __init__(self, make_search, pane, cb):
146 self.make_search = make_search
153 def enqueue(self, q, priority = False):
157 self.queue.insert(0, q)
164 def is_pending(self, q):
165 if self.pending == q:
176 q = self.queue.pop(0)
178 self.p = Popen("/usr/bin/notmuch count --batch", shell=True, stdin=PIPE,
179 stdout = PIPE, stderr = DEVNULL)
181 self.p.stdin.write((self.make_search(q) + "\n").encode("utf-8"))
182 self.p.stdin.write((self.make_search(q, 'unread') + "\n").encode("utf-8"))
183 self.p.stdin.write((self.make_search(q, 'new') + "\n").encode("utf-8"))
184 except BrokenPipeError:
187 self.start = time.time()
188 self.pane.call("event:read", self.p.stdout.fileno(), self.ready)
191 def ready(self, key, **a):
193 count = 0; unread = 0; new = 0
194 slow = time.time() - self.start > 5
197 c = self.p.stdout.readline()
199 u = self.p.stdout.readline()
201 nw = self.p.stdout.readline()
208 self.cb(q, count, unread, new, slow, more)
210 # return False to tell event handler there is no more to read.
214 # Manage the saved searches
215 # We read all searches from the config file and periodically
216 # update some stored counts.
218 # This is used to present the search-list document.
219 def __init__(self, pane, cb):
228 self.worker = counter(self.make_search, pane, self.updated)
229 self.slow_worker = counter(self.make_search, pane, self.updated)
232 if 'NOTMUCH_CONFIG' in os.environ:
233 self.path = os.environ['NOTMUCH_CONFIG']
234 elif 'HOME' in os.environ:
235 self.path = os.environ['HOME'] + "/.notmuch-config"
237 self.path = ".notmuch-config"
241 def set_tags(self, tags):
242 self.tags = list(tags)
244 def load(self, reload = False):
246 stat = os.stat(self.path)
247 mtime = stat.st_mtime
250 if not reload and mtime <= self.mtime:
253 p = Popen("notmuch config list", shell=True, stdout=PIPE)
257 for line in p.stdout:
258 line = line.decode("utf-8", "ignore")
259 if not line.startswith('query.'):
261 w = line[6:].strip().split("=", 1)
262 self.slist[w[0]] = w[1]
263 if len(w[0]) > self.maxlen:
264 self.maxlen = len(w[0])
269 if "current-list" not in self.slist:
270 self.slist["current-list"] = "query:inbox query:unread"
271 if "current" not in self.slist:
272 self.slist["misc-list"] = "query:inbox query:unread"
273 if "inbox" not in self.slist:
274 self.slist["inbox"] = "tag:inbox"
275 if "unread" not in self.slist:
276 self.slist["unread"] = "tag:inbox AND tag:unread"
278 if "misc-list" not in self.slist:
279 self.slist["misc-list"] = ""
280 if "unread" not in self.slist:
281 self.slist["unread"] = "tag:unread"
282 if "new" not in self.slist:
283 self.slist["new"] = "(tag:new AND tag:unread)"
285 self.current = self.searches_from("current-list")
286 self.misc = self.searches_from("misc-list")
288 self.slist["-ad hoc-"] = ""
289 self.current.append("-ad hoc-")
290 self.misc.append("-ad hoc-")
294 if tt not in self.slist:
296 self.current.append(tt)
299 for i in self.current:
300 if i not in self.count:
302 self.unread[i] = None
308 def is_pending(self, search):
309 return self.worker.is_pending(search) + self.slow_worker.is_pending(search)
312 for i in self.current:
313 if not self.slist[i]:
314 # probably an empty -ad hoc-
317 self.slow_worker.enqueue(i)
319 self.worker.enqueue(i)
320 return self.worker.pending != None and self.slow_worker.pending != None
322 def update_one(self, search):
323 if not self.slist[search]:
325 if search in self.slow:
326 self.slow_worker.enqueue(search, True)
328 self.worker.enqueue(search, True)
330 def updated(self, q, count, unread, new, slow, more):
331 changed = (self.count[q] != count or
332 self.unread[q] != unread or
334 self.count[q] = count
335 self.unread[q] = unread
342 self.worker.pending == None and
343 self.slow_worker.pending == None)
345 patn = "\\bquery:([-_A-Za-z0-9]*)\\b"
346 def map_search(self, query):
347 m = re.search(self.patn, query)
352 query = re.sub('\\bquery:' + s + '\\b',
353 '(' + q + ')', query)
355 query = re.sub('\\bquery:' + s + '\\b',
357 m = re.search(self.patn, query)
360 def make_search(self, name, extra = None):
361 s = '(' + self.slist[name] + ')'
362 if name not in self.misc:
363 s = s + " AND query:current"
365 s = s + " AND query:" + extra
366 return self.map_search(s)
368 def searches_from(self, n):
371 for s in self.slist[n].split(" "):
372 if s.startswith('query:'):
376 def make_composition(db, focus, which = "PopupTile", how = "MD3tsa", tag = None):
377 dir = db['config:database.path']
380 drafts = os.path.join(dir, "Drafts")
383 except FileExistsError:
386 fd, fname = tempfile.mkstemp(dir=drafts)
388 m = focus.call("doc:open", fname, -1, ret='pane')
389 m.call("doc:set-name", "*Unsent mail message*")
390 # always good to have a blank line, incase we add an attachment early.
391 m.call("doc:replace", "\n")
392 m['view-default'] = 'compose-email'
393 m['email-sent'] = 'no'
394 name = db['config:user.name']
395 mainfrom = db['config:user.primary_email']
396 altfrom = db['config:user.other_email']
397 altfrom2 = db['config:user.other_email_deprecated']
398 host_address = db['config:user.host_address']
400 m['email:name'] = name
402 m['email:from'] = mainfrom
404 m['email:altfrom'] = altfrom
406 m['email:deprecated_from'] = altfrom2
408 m['email:host-address'] = host_address
411 set_tag = "/usr/bin/notmuch %s;" % tag
412 m['email:sendmail'] = set_tag + "/usr/bin/notmuch insert --folder=sent --create-folder -new -unread +outbox"
413 # NOTE this cannot be in ThisPane, else the pane we want to copy
414 # content from will disappear.
415 # I think Popuptile is best, with maybe an option to expand it
416 # after the copy is done.
417 if which != "PopupTile":
419 p = focus.call(which, how, ret='pane')
422 v = m.call("doc:attach-view", p, 1, ret='pane')
427 # There are two document types.
428 # notmuch_main presents all the saved searches, and also represents the database
429 # of all messages. There is only one of these.
430 # notmuch_query presents a single query as a number of threads, each with a number
433 class notmuch_main(edlib.Doc):
434 # This is the document interface for the saved-search list.
435 # It contains the searches as items which have attributes
436 # providing name, count, unread-count
437 # Once activated it auto-updates every 5 minutes
438 # Updating first handled "counts" where the 3 counters for each
439 # saved search are checked, then "queries" which each current
440 # thread-list document is refreshed.
442 # We create a container pane to collect all these thread-list documents
443 # to hide them from the general *Documents* list.
445 # Only the 'offset' of doc-references is used. It is an index
446 # into the list of saved searches.
449 def __init__(self, focus):
450 edlib.Doc.__init__(self, focus)
451 self.searches = searches(self, self.updated)
452 self.timer_set = False
453 self.updating = False
454 self.querying = False
455 self.container = edlib.Pane(self.root)
456 self.changed_queries = []
458 def handle_shares_ref(self, key, **a):
459 "handle:doc:shares-ref"
462 def handle_close(self, key, **a):
464 self.container.close()
467 def handle_val_mark(self, key, mark, mark2, **a):
468 "handle:debug:validate-marks"
469 if not mark or not mark2:
471 if mark.pos == mark2.pos:
472 if mark.offset < mark2.offset:
474 edlib.LOG("notmuch_main val_marks: same pos, bad offset:",
475 mark.offset, mark2.offset)
478 edlib.LOG("notmuch_main val_mark: mark.pos is None")
480 if mark2.pos is None:
482 if mark.pos < mark2.pos:
484 edlib.LOG("notmuch_main val_mark: pos in wrong order:",
488 def handle_set_ref(self, key, mark, num, **a):
490 self.to_end(mark, num != 1)
494 mark.pos = len(self.searches.current)
495 if mark.pos == len(self.searches.current):
500 def handle_doc_char(self, key, focus, mark, num, num2, mark2, **a):
506 forward = 1 if steps > 0 else 0
507 if end and end == mark:
509 if end and (end < mark) != (steps < 0):
510 # can never cross 'end'
513 while steps and ret != edlib.WEOF and (not end or mark == end):
514 ret = self.handle_step(key, mark, forward, 1)
515 steps -= forward * 2 - 1
517 return 1 + (num - steps if forward else steps - num)
518 if ret == edlib.WEOF or num2 == 0:
520 if num and (num2 < 0) == (num > 0):
522 # want the next character
523 return self.handle_step(key, mark, 1 if num2 > 0 else 0, 0)
525 def handle_step(self, key, mark, num, num2):
531 pos = len(self.searches.current)
534 if forward and not mark.pos is None:
537 mark.step_sharesref(forward)
540 if mark.pos == len(self.searches.current):
542 if not forward and pos > 0:
545 mark.step_sharesref(forward)
550 def handle_doc_get_attr(self, key, focus, mark, str, comm2, **a):
551 "handle:doc:get-attr"
552 # This must support the line-format used in notmuch_list_view
557 if not o is None and o >= 0:
558 s = self.searches.current[o]
562 if self.searches.new[s]:
564 elif self.searches.unread[s]:
566 elif self.searches.count[s]:
570 if focus['qname'] == s:
572 val = "bg:red+60,"+val
574 val = "bg:yellow+20,"+val
577 elif attr == 'count':
578 c = self.searches.new[s]
580 c = self.searches.unread[s]
582 c = self.searches.count[s]
588 val = "%4dK" % int(c/1000)
590 val = "%4dM" % int(c/1000000)
591 elif attr == 'space':
592 p = self.searches.is_pending(s)
597 elif s in self.searches.slow:
602 comm2("callback", focus, val, mark, str)
604 return edlib.Efallthrough
606 def handle_get_attr(self, key, focus, str, comm2, **a):
608 if not comm2 or not str:
610 if str == "doc-type":
611 comm2("callback", focus, "notmuch")
613 if str == "notmuch:max-search-len":
614 comm2("callback", focus, "%d" % self.searches.maxlen)
616 if str.startswith('config:'):
617 p = Popen(['/usr/bin/notmuch', 'config', 'get', str[7:]],
619 stderr = PIPE, stdout = PIPE)
620 out,err = p.communicate()
623 comm2("callback", focus,
624 out.decode("utf-8","ignore").strip(), str)
626 return edlib.Efallthrough
628 def handle_request_notify(self, key, focus, **a):
629 "handle:doc:notmuch:request:Notify:Tag"
630 focus.add_notify(self, "Notify:Tag")
633 def handle_notmuch_update(self, key, **a):
634 "handle:doc:notmuch:update"
635 if not self.timer_set:
636 self.timer_set = True
637 self.call("event:timer", 5*60*1000, self.tick)
638 tags = notmuch_get_tags()
640 self.searches.set_tags(tags)
644 def handle_notmuch_update_one(self, key, str, **a):
645 "handle:doc:notmuch:update-one"
646 self.searches.update_one(str)
647 # update display of updating status flags
648 self.notify("doc:replaced")
651 def handle_notmuch_query(self, key, focus, str, comm2, **a):
652 "handle:doc:notmuch:query"
653 # Find or create a search-result document as a
654 # child of the collection document - it remains private
655 # and doesn't get registered in the global list
656 q = self.searches.make_search(str)
658 it = self.container.children()
660 if child("doc:notmuch:same-search", str, q) == 1:
663 if (nm and nm.notify("doc:notify-viewers") == 0 and
664 int(nm['last-refresh']) + 8*60*60 < int(time.time())):
665 # no-one is looking and they haven't for a long time,
666 # so just discard this one.
670 nm = notmuch_query(self, str, q)
671 # FIXME This is a a bit ugly. I should pass self.container
672 # as the parent, but notmuch_query needs to stash maindoc
673 # Also I should use an edlib call to get notmuch_query
674 nm.reparent(self.container)
675 nm.call("doc:set-name", str)
676 elif nm.notify("doc:notify-viewers") == 0 and nm['need-update']:
677 # no viewers, so trigger a full update to discard old content
678 nm.call("doc:notmuch:query:reload")
679 elif nm['need-update'] or int(nm['last-refresh']) + 60 < int(time.time()):
680 nm.call("doc:notmuch:query-refresh")
681 nm['background-update'] = "0"
683 comm2("callback", focus, nm)
686 def handle_notmuch_byid(self, key, focus, str1, str2, comm2, **a):
687 "handle:doc:notmuch:byid"
688 # Return a document for the email message.
689 # This is a global document.
690 fn = notmuch_get_files(str1)
693 doc = focus.call("doc:open", "email:"+fn[0], -2, ret='pane')
695 doc['notmuch:id'] = str1
696 doc['notmuch:tid'] = str2
697 for i in range(len(fn)):
698 doc['notmuch:fn-%d' % i] = fn[i]
699 comm2("callback", doc)
702 def handle_notmuch_byid_tags(self, key, focus, num2, str, comm2, **a):
703 "handle:doc:notmuch:byid:tags"
704 # return a string with tags of message
705 t = notmuch_get_tags(msg = str)
709 comm2("callback", focus, tags)
712 def handle_notmuch_bythread_tags(self, key, focus, str, comm2, **a):
713 "handle:doc:notmuch:bythread:tags"
714 # return a string with tags of all messages in thread
715 t = notmuch_get_tags(thread = str)
719 comm2("callback", focus, tags)
722 def handle_notmuch_query_updated(self, key, **a):
723 "handle:doc:notmuch:query-updated"
724 # A child search document has finished updating.
728 def handle_notmuch_mark_read(self, key, str, str2, **a):
729 "handle:doc:notmuch:mark-read"
730 notmuch_set_tags(msg=str2, remove = ["unread", "new"])
731 self.notify("Notify:Tag", str, str2)
734 def handle_notmuch_remove_tag(self, key, str, str2, **a):
735 "handle-prefix:doc:notmuch:tag-"
736 if key.startswith("doc:notmuch:tag-add-"):
739 elif key.startswith("doc:notmuch:tag-remove-"):
746 # adjust a list of messages
747 for id in str2.split("\n"):
749 notmuch_set_tags(msg=id, add=[tag])
751 notmuch_set_tags(msg=id, remove=[tag])
752 self.notify("Notify:Tag", str, id)
754 # adjust whole thread
756 notmuch_set_tags(thread=str, add=[tag])
758 notmuch_set_tags(thread=str, remove=[tag])
759 self.notify("Notify:Tag", str)
760 # FIXME can I ever optimize out the Notify ??
763 def handle_set_adhoc(self, key, focus, str, **a):
764 "handle:doc:notmuch:set-adhoc"
766 self.searches.slist["-ad hoc-"] = str
768 self.searches.slist["-ad hoc-"] = ""
771 def handle_get_query(self, key, focus, str1, comm2, **a):
772 "handle:doc:notmuch:get-query"
773 if str1 and str1 in self.searches.slist:
774 comm2("cb", focus, self.searches.slist[str1])
777 def handle_set_query(self, key, focus, str1, str2, **a):
778 "handle:doc:notmuch:set-query"
779 if not (str1 and str2):
781 self.searches.slist[str1] = str2
782 p = Popen(["/usr/bin/notmuch", "config", "set",
783 "query."+str1, str2],
784 stdout = DEVNULL, stderr=DEVNULL, stdin=DEVNULL)
786 p.communicate(timeout=5)
787 except TimeoutExpired:
791 if p.returncode != 0:
795 def tick(self, key, **a):
796 if not self.updating:
797 self.searches.load(False)
799 self.searches.update()
800 # updating status flags might have change
801 self.notify("doc:replaced")
802 for c in self.container.children():
803 if c.notify("doc:notify-viewers") == 0:
804 # no point refreshing this, might be time to close it
805 lr = c['last-refresh']
806 if int(lr) + 8*60*60 < int(time.time()):
810 def updated(self, query, changed, finished):
812 self.updating = False
814 self.changed_queries.append(query)
815 if not self.querying:
817 # always trigger 'replaced' as scan-status symbols may change
818 self.notify("doc:replaced")
820 def next_query(self):
821 self.querying = False
822 while self.changed_queries:
823 q = self.changed_queries.pop(0)
824 for c in self.container.children():
826 if c.notify("doc:notify-viewers") > 0:
827 # there are viewers, so just do a refresh.
829 c("doc:notmuch:query-refresh")
830 # will get callback when time to continue
832 elif int(c['background-update']) == 0:
833 # First update with no viewers - full refresh
834 c.call("doc:set:background-update",
835 "%d" % int(time.time()))
837 c("doc:notmuch:query:reload")
838 # will get callback when time to continue
840 elif int(time.time()) - int(c['background-update']) < 5*60:
841 # less than 5 minutes, keep updating
843 c("doc:notmuch:query-refresh")
844 # will get callback when time to continue
847 # Just mark for refresh-on-visit
848 c.call("doc:set:need-update", "true")
850 # notmuch_query document
851 # a mark.pos is a list of thread-id and message-id.
853 class notmuch_query(edlib.Doc):
854 def __init__(self, focus, qname, query):
855 edlib.Doc.__init__(self, focus)
859 self['qname'] = qname
860 self['query'] = query
862 self['last-refresh'] = "%d" % int(time.time())
863 self['need-update'] = ""
868 self["render-default"] = "notmuch:threads"
869 self["line-format"] = ("<%BG><%TM-hilite>%TM-date_relative</>" +
870 "<tab:130> <fg:blue>%TM-authors</>" +
871 "<tab:350>%TM-size%TM-threadinfo<%TM-hilite>" +
872 "<fg:red,bold>%TM-flag</>" +
873 "<wrap-tail:,wrap-nounderline,wrap-head: ,wrap> </>" +
874 "<wrap-margin><fg:#FF8C00-40,action-activate:notmuch:select-1>%TM-subject</></></>")
875 self.add_notify(self.maindoc, "Notify:Tag")
876 self.add_notify(self.maindoc, "Notify:Close")
877 self['doc-status'] = ""
879 self.marks_unstable = False
881 self.this_load = None
882 self.load_thread_active = False
883 self.thread_queue = []
886 def open_email(self, key, focus, str1, str2, comm2, **a):
887 "handle:doc:notmuch:open"
888 if not str1 or not str2:
890 if str2 not in self.threadinfo:
892 minfo = self.threadinfo[str2]
893 if str1 not in minfo:
896 fn = minfo[str1][0][0]
904 doc = focus.call("doc:open", "email:"+fn, -2, ret='pane')
906 doc['notmuch:id'] = str1
907 doc['notmuch:tid'] = str2
908 doc['notmuch:timestamp'] = "%d"%ts
909 for i in range(len(minfo[str1][0])):
910 doc['notmuch:fn-%d' % i] = minfo[str1][0][i]
911 comm2("callback", doc)
914 def set_filter(self, key, focus, str, **a):
915 "handle:doc:notmuch:set-filter"
918 if self.filter == str:
923 self.notify("doc:replaced")
924 self.maindoc.notify("doc:replaced", 1)
927 def handle_shares_ref(self, key, **a):
928 "handle:doc:shares-ref"
931 def handle_val_mark(self, key, mark, mark2, **a):
932 "handle:debug:validate-marks"
933 if not mark or not mark2:
935 if mark.pos == mark2.pos:
936 if mark.offset < mark2.offset:
938 edlib.LOG("notmuch_query val_marks: same pos, bad offset:",
939 mark.offset, mark2.offset)
942 edlib.LOG("notmuch_query val_mark: mark.pos is None")
944 if mark2.pos is None:
950 edlib.LOG("notmuch_query val_mark: m1 mid is None",
955 if self.messageids[t1].index(m1) < self.messageids[t1].index(m2):
957 edlib.LOG("notmuch_query val_mark: messages in wrong order",
960 if self.marks_unstable:
962 if self.threadids.index(t1) < self.threadids.index(t2):
964 edlib.LOG("notmuch_query val_mark: pos in wrong order:",
969 def setpos(self, mark, thread, msgnum = 0):
973 if thread in self.messageids:
974 msg = self.messageids[thread][msgnum]
977 mark.pos = (thread, msg)
982 # busy, don't reload just now
987 # mark all threads inactive, so any that remain that way
989 for id in self.threads:
990 self.threads[id]['total'] = 0
993 self.pos = edlib.Mark(self)
994 self['need-update'] = ""
997 def load_update(self):
999 # busy, don't reload just now
1007 self.pos = edlib.Mark(self)
1008 self['need-update'] = ""
1011 def start_load(self):
1012 self['last-refresh'] = "%d" % int(time.time())
1013 cmd = ["/usr/bin/notmuch", "search", "--output=summary",
1014 "--format=json", "--limit=100", "--offset=%d" % self.offset ]
1016 cmd += [ "date:-24hours.. AND " ]
1018 cmd += [ "date:-%ddays.. AND " % (self.age * 30)]
1020 cmd += [ "( %s ) AND " % self.filter ]
1021 cmd += [ "( %s )" % self.query ]
1022 self['doc-status'] = "Loading..."
1023 self.notify("doc:status-changed")
1024 self.p = Popen(cmd, shell=False, stdout=PIPE, stderr = DEVNULL)
1025 self.call("event:read", self.p.stdout.fileno(), self.get_threads)
1027 def get_threads(self, key, **a):
1029 was_empty = not self.threadids
1031 tl = json.load(self.p.stdout)
1037 while (self.tindex < len(self.threadids) and
1038 self.threads[self.threadids[self.tindex]]["timestamp"] > j["timestamp"]):
1039 # Skip over this thread before inserting
1040 tid2 = self.threadids[self.tindex]
1042 while self.pos.pos and self.pos.pos[0] == tid2:
1043 self.call("doc:step-thread", self.pos, 1, 1)
1045 if tid in self.threads and tid in self.messageids:
1046 oj = self.threads[tid]
1047 if (oj['timestamp'] != j['timestamp'] or
1048 (oj['total'] != 0 and oj['total'] != j['total']) or
1049 oj['matched'] != j['matched']):
1052 self.threads[tid] = j
1054 if self.tindex >= len(self.threadids) or self.threadids[self.tindex] != tid:
1055 # need to insert and possibly move the old marks
1057 old = self.threadids.index(tid)
1061 # debug:validate-marks looks in self.threadids
1062 # which is about to become inconsistent
1063 self.marks_unstable = True
1064 self.threadids.insert(self.tindex, tid)
1067 # move marks on tid to before self.pos
1068 if old < self.tindex - 1:
1069 m = self.first_mark()
1074 self.threadids.pop(old)
1075 while (m and m.pos and m.pos[0] != tid and
1076 self.threadids.index(m.pos[0]) < old):
1078 self.pos.step_sharesref(0)
1079 mp = self.pos.prev_any()
1080 if mp and mp.pos and mp.pos[0] == tid:
1081 # All marks for tid are already immediately before self.pos
1082 # so nothing to be moved.
1084 while m and m.pos and m.pos[0] == tid:
1086 # m needs to be before pos
1087 if m.seq > self.pos.seq:
1088 m.to_mark_noref(self.pos)
1089 elif self.pos.prev_any().seq != m.seq:
1090 m.to_mark_noref(self.pos.prev_any())
1092 self.marks_unstable = False
1093 self.notify("notmuch:thread-changed", tid, 1)
1095 if self.pos.pos and self.pos.pos[0] == tid:
1096 self.load_thread(self.pos, sync=False)
1098 # might be previous thread
1101 self.call("doc:step-thread", m, 0, 1)
1102 if m.pos and m.pos[0] == tid:
1103 self.load_thread(m, sync=False)
1108 self['doc-status'] = ""
1109 self.notify("doc:status-changed")
1111 if was_empty and self.threadids:
1112 # first insertion, all marks other than self.pos must be at start
1113 m = self.first_mark()
1114 while m and m.pos is None:
1116 if m.seq != self.pos.seq:
1118 self.setpos(m, self.threadids[0])
1120 self.notify("doc:replaced")
1121 if found < 100 and self.age == None:
1122 # must have found them all
1124 if not self.partial:
1126 self.call("doc:notmuch:query-updated")
1130 # allow for a little over-lap across successive calls
1131 self.offset += found - 3
1133 # stop worrying about age
1135 if found < 100 and self.age:
1141 # remove any threads with a 'total' of zero.
1142 # Any marks on them must be moved later
1143 m = edlib.Mark(self)
1144 while m.pos != None:
1147 self.call("doc:step-thread", 1, 1, m2)
1148 if self.threads[tid]['total'] == 0:
1149 # notify viewers to close threads
1150 self.notify("notmuch:thread-changed", tid)
1155 del self.threads[tid]
1156 self.threadids.remove(tid)
1159 def cvt_depth(self, depth):
1160 # depth is an array of int
1161 # 2 is top-level in the thread, normally only one of these
1162 # 1 at the end of the array means there are children
1163 # 1 before the end means there are more children at this depth
1164 # 0 means no more children at this depth
1167 for level in depth[:-2]:
1168 ret += u" │ "[level]
1169 ret += u"╰├─"[depth[-2]]
1170 ret += u"─┬"[depth[-1]]
1174 def add_message(self, msg, lst, info, depth, old_ti):
1175 # Add the message ids in depth-first order into 'lst',
1176 # and for each message, place summary info info in info[mid]
1177 # particularly including a 'depth' description which is a
1178 # list of "1" if this message is not the last reply to the parent,
1180 # If old_ti, then the thread is being viewed and messages mustn't
1181 # disappear - so preserve the 'matched' value.
1186 was_matched = old_ti and mid in old_ti and old_ti[mid][2]
1187 info[mid] = (m['filename'], m['timestamp'],
1188 m['match'] or was_matched,
1189 depth + [1 if l else 0],
1190 m['headers']["From"],
1191 m['headers']["Subject"],
1194 l.sort(key=lambda m:(m[0]['timestamp'],m[0]['headers']['Subject']))
1196 self.add_message(m, lst, info, depth + [1], old_ti)
1197 self.add_message(l[-1], lst, info, depth + [0], old_ti)
1199 def step_load_thread(self):
1200 # start thread loading, either current with query, or next
1201 if not self.this_load:
1202 if not self.thread_queue:
1203 self.load_thread_active = False
1205 self.this_load = self.thread_queue.pop(0)
1206 tid, mid, m, query = self.this_load
1207 self.thread_text = b""
1208 self.load_thread_active = True
1209 self.thread_p = notmuch_start_load_thread(tid, query)
1210 fd = self.thread_p.stdout.fileno()
1211 fl = fcntl.fcntl(fd, fcntl.F_GETFL)
1212 fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
1213 self.call("event:read", fd, self.load_thread_read)
1215 def load_thread_read(self, key, **a):
1217 b = os.read(self.thread_p.stdout.fileno(), 4096)
1219 self.thread_text += b
1220 b = os.read(self.thread_p.stdout.fileno(), 4096)
1224 self.thread_p.wait()
1225 self.thread_p = None
1226 # Must have read EOF to get here.
1227 th = json.loads(self.thread_text.decode("utf-8","ignore"))
1228 self.thread_text = None
1229 tid, mid, m, query = self.this_load
1230 if query and not th:
1231 # query must exclude everything
1232 self.this_load = (tid, mid, m, None)
1234 self.merge_thread(tid, mid, m, th[0])
1235 self.this_load = None
1236 self.step_load_thread()
1239 def load_thread(self, mark, sync):
1240 (tid, mid) = mark.pos
1242 self.thread_queue.append((tid, mid, mark.dup(), self.query))
1243 if not self.load_thread_active:
1244 self.step_load_thread()
1247 thread = notmuch_load_thread(tid, self.query)
1249 thread = notmuch_load_thread(tid)
1252 self.merge_thread(tid, mid, mark, thread)
1254 def thread_is_open(self, tid):
1255 return self.notify("notmuch:thread-open", tid) > 0
1257 def merge_thread(self, tid, mid, mark, thread):
1258 # thread is a list of top-level messages
1259 # in each m[0] is the message as a dict
1260 thread.sort(key=lambda m:(m[0]['timestamp'],m[0]['headers']['Subject']))
1264 if tid in self.threadinfo and self.thread_is_open(tid):
1265 # need to preserve all messages currently visible
1266 old_ti = self.threadinfo[tid]
1270 self.add_message(m, midlist, minfo, [2], old_ti)
1271 self.messageids[tid] = midlist
1272 self.threadinfo[tid] = minfo
1275 # need to update all marks at this location to hold mid
1277 pos = (tid, midlist[0])
1278 while m and m.pos and m.pos[0] == tid:
1282 while m and m.pos and m.pos[0] == tid:
1286 # Need to make sure all marks on this thread are properly
1287 # ordered. If we find two marks out of order, the pos of
1288 # the second is changed to match the first.
1291 while prev and prev.pos and prev.pos[0] == tid:
1295 midlist = self.messageids[tid]
1296 while m and m.pos and m.pos[0] == tid:
1297 if m.pos[1] not in midlist:
1298 self.setpos(m, tid, ind)
1300 mi = midlist.index(m.pos[1])
1302 self.setpos(m, tid, ind)
1306 self.notify("notmuch:thread-changed", tid, 2)
1308 def get_matched(self, key, focus, num, str, comm2, **a):
1309 "handle:doc:notmuch-query:matched-mids"
1310 if str not in self.threadinfo:
1312 ti = self.threadinfo[str]
1315 if num or ti[mid][2]:
1316 # this message matches, or viewing all messages
1318 comm2("cb", focus, '\n'.join(ret))
1321 def get_replies(self, key, focus, num, str, str2, comm2, **a):
1322 "handle:doc:notmuch-query:matched-replies"
1323 if str not in self.threadinfo:
1325 ti = self.threadinfo[str]
1326 mi = self.messageids[str]
1332 # d[ppos] will be 1 if there are more replies.
1335 while i < len(mi) and dpos < len(d) and d[dpos]:
1342 while dpos < len(d) and d[dpos] == 0:
1343 # no more children at this level, but maybe below
1346 comm2("cb", focus, '\n'.join(ret))
1349 def rel_date(self, sec):
1350 then = time.localtime(sec)
1351 now = time.localtime()
1353 if sec < nows and sec > nows - 60:
1354 val = "%d secs. ago" % (nows - sec)
1355 elif sec < nows and sec > nows - 60*60:
1356 val = "%d mins. ago" % ((nows - sec)/60)
1357 elif sec < nows and sec > nows - 60*60*6:
1358 mn = (nows - sec) / 60
1361 val = "%dh%dm ago" % (hr,mn)
1362 elif then[:3] == now[:3]:
1363 val = time.strftime("Today %H:%M", then)
1365 val = time.strftime("%Y-%b-%d!", then)
1366 elif sec > nows - 7 * 24 * 3600:
1367 val = time.strftime("%a %H:%M", then)
1368 elif then[0] == now[0]:
1369 val = time.strftime("%d/%b %H:%M", then)
1371 val = time.strftime("%Y-%b-%d", then)
1376 def step(self, mark, forward, move):
1378 if mark.pos is None:
1382 (tid,mid) = mark.pos
1383 mark.step_sharesref(1)
1384 i = self.threadids.index(tid)
1386 j = self.messageids[tid].index(mid) + 1
1387 if mid and j < len(self.messageids[tid]):
1388 self.setpos(mark, tid, j)
1389 elif i+1 < len(self.threadids):
1390 tid = self.threadids[i+1]
1391 self.setpos(mark, tid, 0)
1393 self.setpos(mark, None)
1394 mark.step_sharesref(1)
1398 if mark.pos == None:
1399 i = len(self.threadids)
1401 (tid,mid) = mark.pos
1402 i = self.threadids.index(tid)
1404 j = self.messageids[tid].index(mid)
1405 if i == 0 and j == 0:
1409 mark.step_sharesref(0)
1413 tid = self.threadids[i]
1414 if tid in self.messageids:
1415 j = len(self.messageids[tid])
1419 self.setpos(mark, tid, j)
1420 mark.step_sharesref(0)
1424 def handle_notify_tag(self, key, str, str2, **a):
1427 # re-evaluate tags of a single message
1428 if str in self.threadinfo:
1429 t = self.threadinfo[str]
1432 s = self.maindoc.call("doc:notmuch:byid:tags", str2, ret='str')
1433 tg[:] = s.split(",")
1435 if str in self.threads:
1436 t = self.threads[str]
1437 s = self.maindoc.call("doc:notmuch:bythread:tags", str, ret='str')
1438 t['tags'] = s.split(",")
1439 self.notify("doc:replaced")
1442 def handle_notify_close(self, key, focus, **a):
1443 "handle:Notify:Close"
1444 if focus == self.maindoc:
1445 # Main doc is closing, so must we
1448 return edlib.Efallthrough
1450 def handle_set_ref(self, key, mark, num, **a):
1451 "handle:doc:set-ref"
1452 self.to_end(mark, num != 1)
1454 if num == 1 and len(self.threadids) > 0:
1455 self.setpos(mark, self.threadids[0], 0)
1459 def handle_doc_char(self, key, focus, mark, num, num2, mark2, **a):
1465 forward = 1 if steps > 0 else 0
1466 if end and end == mark:
1468 if end and (end < mark) != (steps < 0):
1469 # can never cross 'end'
1472 while steps and ret != edlib.WEOF and (not end or mark == end):
1473 ret = self.handle_step(key, mark, forward, 1)
1474 steps -= forward * 2 - 1
1476 return 1 + (num - steps if forward else steps - num)
1477 if ret == edlib.WEOF or num2 == 0:
1479 if num and (num2 < 0) == (num > 0):
1481 # want the next character
1482 return self.handle_step(key, mark, 1 if num2 > 0 else 0, 0)
1484 def handle_step(self, key, mark, num, num2):
1487 return self.step(mark, forward, move)
1489 def handle_step_thread(self, key, mark, num, num2, **a):
1490 "handle:doc:step-thread"
1491 # Move to the start of the current thread, or the start
1496 if mark.pos == None:
1500 (tid,mid) = mark.pos
1501 m2 = mark.next_any()
1502 while m2 and m2.pos != None and m2.pos[0] == tid:
1504 m2 = mark.next_any()
1505 i = self.threadids.index(tid) + 1
1506 if i < len(self.threadids):
1507 self.setpos(mark, self.threadids[i], 0)
1509 self.setpos(mark, None)
1512 if mark.pos == None:
1513 # EOF is not in a thread, so to move to the start
1514 # we must stary where we are. Moving further would be in
1515 # a different thread.
1516 return self.prior(mark)
1518 (tid,mid) = mark.pos
1519 m2 = mark.prev_any()
1520 while m2 and (m2.pos == None or m2.pos[0] == tid):
1522 m2 = mark.prev_any()
1523 self.setpos(mark, tid, 0)
1526 def handle_step_matched(self, key, mark, num, num2, **a):
1527 "handle:doc:step-matched"
1528 # Move to the next/prev message which is matched
1529 # or to an unopened thread
1535 ret = self.step(m, forward, 1)
1536 while ret != edlib.WEOF and m.pos != None:
1540 ms = self.threadinfo[tid][mid]
1543 ret = self.step(m, forward, 1)
1546 def handle_to_thread(self, key, mark, str, **a):
1547 "handle:doc:notmuch:to-thread"
1548 # move to first message of given thread.
1549 if not mark or not str:
1551 if str not in self.threadids:
1553 if not mark.pos or self.threadids.index(mark.pos[0]) > self.threadids.index(str):
1555 self.call("doc:step-thread", 0, 1, mark)
1556 while (self.prev(mark) and
1557 self.call("doc:step-thread", 0, 1, mark, ret='char') and
1559 self.threadids.index(mark.pos[0]) > self.threadids.index(str)):
1563 elif self.threadids.index(mark.pos[0]) < self.threadids.index(str):
1565 while (self.call("doc:step-thread", 1, 1, mark, ret='char') and
1567 self.threadids.index(mark.pos[0]) < self.threadids.index(str)):
1573 self.call("doc:step-thread", 0, 1, mark)
1576 def handle_to_message(self, key, mark, str, **a):
1577 "handle:doc:notmuch:to-message"
1578 # move to given message in current thread
1579 if not mark or not str:
1581 if not mark.pos or mark.pos[0] not in self.messageids:
1583 mlist = self.messageids[mark.pos[0]]
1584 if str not in mlist:
1586 i = mlist.index(str)
1587 while mark.pos and mark.pos[1] and mlist.index(mark.pos[1]) > i and self.prev(mark):
1590 while mark.pos and mark.pos[1] and mlist.index(mark.pos[1]) < i and self.next(mark):
1591 # keep going forward
1593 return 1 if mark.pos and mark.pos[1] == str else edlib.Efalse
1595 def handle_doc_get_attr(self, key, mark, focus, str, comm2, **a):
1596 "handle:doc:get-attr"
1598 if not mark or mark.pos == None:
1599 # No attributes for EOF
1601 (tid,mid) = mark.pos
1602 i = self.threadids.index(tid)
1605 j = self.messageids[tid].index(mid)
1609 tid = self.threadids[i]
1610 t = self.threads[tid]
1612 m = self.threadinfo[tid][mid]
1614 m = ("", 0, False, [0,0], "" ,"", t["tags"], 0)
1615 (fn, dt, matched, depth, author, subj, tags, size) = m
1616 if attr == "message-id":
1618 elif attr == "thread-id":
1620 elif attr == "T-hilite":
1621 if "inbox" not in t["tags"]:
1622 # FIXME maybe I should test 'current' ??
1624 elif "new" in t["tags"] and "unread" in t["tags"]:
1626 elif "unread" in t["tags"]:
1630 elif attr == "T-flag":
1631 if 'deleted' in t["tags"]:
1632 val = "🗑" # WASTEBASKET #1f5d1
1633 elif 'flagged' in t["tags"]:
1634 val = "★" # BLACK STAR #2605
1635 elif 'newspam' in t["tags"]:
1636 val = "✘" # HEAVY BALLOT X #2718
1637 elif 'notspam' in t["tags"]:
1638 val = "✔" # HEAVY CHECK MARK #2714
1639 elif 'replied' in t["tags"]:
1640 val = "↵" # DOWNWARDS ARROW WITH CORNER LEFTWARDS #21B5
1641 elif 'forwarded' in t["tags"]:
1642 val = "→" # RIGHTWARDS ARROW #2192
1645 elif attr == "T-date_relative":
1646 val = self.rel_date(t['timestamp'])
1647 elif attr == "T-threadinfo":
1648 val = "[%d/%d]" % (t['matched'],t['total'])
1651 elif attr == "T-size":
1653 elif attr[:2] == "T-" and attr[2:] in t:
1655 if type(val) == int:
1657 elif type(val) == list:
1660 # Some mailers use ?Q to insert =0A (newline) in a subject!!
1661 val = t[attr[2:]].replace('\n',' ')
1662 if attr == "T-authors":
1665 elif attr == "matched":
1666 val = "True" if matched else "False"
1667 elif attr == "tags" or attr == "M-tags":
1668 val = ','.join(tags)
1669 elif attr == "M-hilite":
1670 if "inbox" not in tags:
1672 if "new" in tags and "unread" in tags:
1674 elif "new" in tags and "unread" in tags:
1676 elif "unread" in tags:
1680 elif attr == "M-flag":
1681 if 'deleted' in tags:
1682 val = "🗑" # WASTEBASKET #1f5d1
1683 elif 'flagged' in tags:
1684 val = "★" # BLACK STAR #2605
1685 elif 'newspam' in tags:
1686 val = "✘" # HEAVY BALLOT X #2718
1687 elif 'notspam' in tags:
1688 val = "✔" # HEAVY CHECK MARK #2714
1689 elif 'replied' in tags:
1690 val = "↵" # DOWNWARDS ARROW WITH CORNER LEFTWARDS #21B5
1691 elif 'forwarded' in tags:
1692 val = "→" # RIGHTWARDS ARROW #2192
1695 elif attr == "M-date_relative":
1696 val = self.rel_date(dt)
1697 elif attr == "M-authors":
1698 val = author[:20].replace('\n',' ')
1699 elif attr == "M-subject":
1700 val = subj.replace('\n',' ')
1701 elif attr == "M-threadinfo":
1702 val = self.cvt_depth(depth)
1703 elif attr == "M-size":
1704 if size < 0 and fn and fn[0]:
1706 st = os.lstat(fn[0])
1708 except FileNotFoundError:
1710 self.threadinfo[tid][mid] = (fn, dt, matched, depth,
1711 author, subj, tags, size)
1713 val = cvt_size(size)
1718 comm2("callback", focus, val, mark, attr)
1720 return edlib.Efallthrough
1722 def handle_get_attr(self, key, focus, str, comm2, **a):
1724 if str == "doc-type" and comm2:
1725 comm2("callback", focus, "notmuch-query")
1727 return edlib.Efallthrough
1729 def handle_maindoc(self, key, **a):
1730 "handle-prefix:doc:notmuch:"
1731 # any doc:notmuch calls that we don't handle directly
1732 # are handed to the maindoc
1733 return self.maindoc.call(key, **a)
1735 def handle_reload(self, key, **a):
1736 "handle:doc:notmuch:query:reload"
1740 def handle_load_thread(self, key, mark, **a):
1741 "handle:doc:notmuch:load-thread"
1742 if mark.pos == None:
1744 (tid,mid) = mark.pos
1745 self.load_thread(mark, sync=True)
1746 if tid in self.threadinfo:
1750 def handle_same_search(self, key, str2, **a):
1751 "handle:doc:notmuch:same-search"
1752 if self.query == str2:
1756 def handle_query_refresh(self, key, **a):
1757 "handle:doc:notmuch:query-refresh"
1761 def handle_mark_read(self, key, str, str2, **a):
1762 "handle:doc:notmuch:mark-read"
1763 # Note that the thread might already have been pruned,
1764 # in which case there is no cached info to update.
1765 # In that case we just pass the request down to the db.
1766 if str in self.threadinfo:
1767 ti = self.threadinfo[str]
1770 if "unread" not in tags and "new" not in tags:
1772 if "unread" in tags:
1773 tags.remove("unread")
1778 if "unread" in ti[mid][6]:
1779 # still has unread messages
1782 if not is_unread and str in self.threads:
1783 # thread is no longer 'unread'
1784 j = self.threads[str]
1788 self.notify("doc:replaced")
1789 # Cached info is updated, pass down to
1790 # database document for permanent change
1791 self.maindoc.call(key, str, str2)
1794 class tag_popup(edlib.Pane):
1795 def __init__(self, focus):
1796 edlib.Pane.__init__(self, focus)
1798 def handle_enter(self, key, focus, **a):
1800 str = focus.call("doc:get-str", ret='str')
1801 focus.call("popup:close", str)
1804 class query_popup(edlib.Pane):
1805 def __init__(self, focus):
1806 edlib.Pane.__init__(self, focus)
1808 def handle_enter(self, key, focus, **a):
1810 str = focus.call("doc:get-str", ret='str')
1811 focus.call("popup:close", str)
1815 # There are 4 viewers
1816 # notmuch_master_view manages multiple notmuch tiles. When the notmuch_main
1817 # is displayed, this gets gets attached *under* (closer to root) the
1818 # doc-view pane together with a tiling window. One tile is used to
1819 # display the query list from the main doc, other tiles are used to
1820 # display other components.
1821 # notmuch_list_view displays the list of saved-searched registered with
1822 # notmuch_main. It display list-name and a count of the most interesting
1824 # notmuch_query_view displays the list of threads and messages for a single
1825 # query - a notmuch_query document
1826 # notmuch_message_view displays a single message, a doc-email document
1827 # which is implemented separately. notmuch_message_view primarily
1828 # provides interactions consistent with the rest of edlib-notmuch
1830 class notmuch_master_view(edlib.Pane):
1831 # This pane controls one visible instance of the notmuch application.
1832 # It manages the size and position of the 3 panes and provides common
1833 # handling for some keystrokes.
1834 # 'focus' is normally None and we are created parentless. The creator
1835 # then attaches us and a tiler beneath the main notmuch document.
1837 def __init__(self, focus = None):
1838 edlib.Pane.__init__(self, focus)
1839 self.maxlen = 0 # length of longest query name in list_pane
1840 self.list_pane = None
1841 self.query_pane = None
1842 self.message_pane = None
1844 def handle_set_main_view(self, key, focus, **a):
1845 "handle:notmuch:set_list_pane"
1846 self.list_pane = focus
1850 if self.list_pane and (self.query_pane or self.message_pane):
1851 # list_pane must be no more than 25% total width, and no more than
1853 if self.maxlen <= 0:
1854 m = self.list_pane["notmuch:max-search-len"]
1855 if m and m.isnumeric():
1856 self.maxlen = int(m)
1859 tile = self.list_pane.call("ThisPane", "notmuch", ret='pane')
1861 ch,ln = tile.scale()
1862 max = 5 + 1 + self.maxlen + 1
1863 if space * 100 / ch < max * 4:
1866 w = ch * 10 * max / 1000
1868 tile.call("Window:x+", "notmuch", int(w - tile.w))
1869 if self.query_pane and self.message_pane:
1870 # query_pane must be at least 4 lines, else 1/4 height
1871 # but never more than 1/2 the height
1872 tile = self.query_pane.call("ThisPane", "notmuch", ret='pane')
1873 ch,ln = tile.scale()
1876 if space * 100 / ln > min * 4:
1879 h = ln * 10 * min / 1000
1883 tile.call("Window:y+", "notmuch", int(h - tile.h))
1885 def handle_getattr(self, key, focus, str, comm2, **a):
1889 if str in ["qname","query","filter"] and self.query_pane:
1890 # WARNING these must always be set in the query doc,
1891 # otherwise we can recurse infinitely.
1892 val = self.query_pane[str]
1894 comm2("callback", focus, val, str)
1896 return edlib.Efallthrough
1898 def handle_choose(self, key, **a):
1899 "handle:docs:choose"
1900 # If a notmuch tile needs to find a new doc, e.g. because
1901 # a message doc was killed, reject the request so that the
1902 # pane will be closed.
1905 def handle_clone(self, key, focus, **a):
1907 main = notmuch_master_view(focus)
1908 p = main.call("attach-tile", "notmuch", "main", ret='pane')
1909 frm = self.list_pane.call("ThisPane", "notmuch", ret='pane')
1910 frm.clone_children(p)
1914 def handle_maindoc(self, key, **a):
1915 "handle-prefix:doc:notmuch:"
1916 # any doc:notmuch calls that haven't been handled
1917 # are handled to the list_pane
1918 if self.recursed == key:
1919 edlib.LOG("doc:notmuch: recursed!", key)
1921 prev = self.recursed
1923 # FIXME catch exception to return failure state properly
1924 ret = self.list_pane.call(key, **a)
1925 self.recursed = prev
1928 def handle_size(self, key, **a):
1929 "handle:Refresh:size"
1930 # First, make sure the tiler has adjusted to the new size
1931 self.focus.w = self.w
1932 self.focus.h = self.h
1933 self.focus("Refresh:size")
1934 # then make sure children are OK
1938 def handle_dot(self, key, focus, mark, **a):
1940 # select thing under point, but don't move
1941 focus.call("notmuch:select", mark, 0)
1944 def handle_return(self, key, focus, mark, **a):
1946 # select thing under point, and enter it
1947 focus.call("notmuch:select", mark, 1)
1950 def handle_select_1(self, key, focus, mark, **a):
1951 "handle:notmuch:select-1"
1952 # select thing under point, and enter it
1953 focus.call("notmuch:select", mark, 1)
1956 def handle_search(self, key, focus, **a):
1958 pup = focus.call("PopupTile", "3", "", ret='pane')
1961 pup['done-key'] = "notmuch-do-ad hoc"
1962 pup['prompt'] = "Ad hoc query"
1963 pup.call("doc:set-name", "Ad hoc query")
1964 p = pup.call("attach-history", "*Notmuch Query History*",
1971 def handle_compose(self, key, focus, **a):
1974 def choose(choice, a):
1976 if focus['email-sent'] == 'no':
1977 choice.append(focus)
1980 focus.call("docs:byeach", lambda key,**a:choose(choice, a))
1982 par = focus.call("PopupTile", "MD3tsa", ret='pane')
1984 par = choice[0].call("doc:attach-view", par, 1, ret='pane')
1987 focus.call("Message:modal",
1988 "No active email composition documents found.")
1991 def do_search(self, key, focus, str, **a):
1992 "handle:notmuch-do-ad hoc"
1994 self.list_pane.call("doc:notmuch:set-adhoc", str)
1995 self.list_pane.call("notmuch:select-adhoc", 1)
1998 def handle_filter(self, key, focus, **a):
2000 if not self.query_pane:
2005 pup = focus.call("PopupTile", "3", f, ret='pane')
2008 pup['done-key'] = "notmuch-do-filter"
2009 pup['prompt'] = "Query filter"
2010 pup.call("doc:set-name", "*Query filter for %s*" % focus['qname'])
2011 p = pup.call("attach-history", "*Notmuch Filter History*",
2018 def do_filter(self, key, focus, str1, **a):
2019 "handle:notmuch-do-filter"
2020 if self.query_pane and str1:
2021 self.query_pane.call("doc:notmuch:set-filter", str1)
2024 def handle_space(self, key, **a):
2026 if self.message_pane:
2027 m = self.message_pane.call("doc:point", ret='mark')
2028 self.message_pane.call(key, m)
2029 elif self.query_pane:
2030 m = self.query_pane.call("doc:point", ret='mark')
2031 self.query_pane.call("K:Enter", m)
2033 m = self.list_pane.call("doc:point", ret='mark')
2034 self.list_pane.call("K:Enter", m)
2037 def handle_bs(self, key, **a):
2038 "handle:K:Backspace"
2039 if self.message_pane:
2040 m = self.message_pane.call("doc:point", ret='mark')
2041 self.message_pane.call(key, m)
2042 elif self.query_pane:
2043 m = self.query_pane.call("doc:point", ret='mark')
2044 self.query_pane.call("doc:char-p", m)
2046 m = self.list_pane.call("doc:point", ret='mark')
2047 self.list_pane.call("K:A-p", m)
2050 def handle_move(self, key, **a):
2051 "handle-list/K:A-n/K:A-p/doc:char-n/doc:char-p"
2052 if key.startswith("K:A-") or not self.query_pane:
2054 op = self.query_pane
2057 op = self.message_pane
2061 direction = 1 if key[-1] in "na" else -1
2063 # secondary window exists so move, otherwise just select
2065 p.call("Move-Line", direction)
2066 except edlib.commandfailed:
2069 m = p.call("doc:dup-point", 0, edlib.MARK_UNGROUPED, ret='mark')
2070 p.call("notmuch:select", m, direction)
2073 def handle_j(self, key, focus, **a):
2075 # jump to the next new/unread message/thread
2079 m = p.call("doc:dup-point", 0, edlib.MARK_UNGROUPED, ret='mark')
2080 p.call("Move-Line", m, 1)
2081 tg = p.call("doc:get-attr", m, "tags", ret='str')
2082 while tg is not None:
2086 p.call("Move-Line", m, 1)
2087 tg = p.call("doc:get-attr", m, "tags", ret='str')
2089 focus.call("Message", "All messsages read!")
2091 p.call("Move-to", m)
2092 if self.message_pane:
2093 p.call("notmuch:select", m, 1)
2096 def handle_move_thread(self, key, **a):
2097 "handle-list/doc:char-N/doc:char-P"
2099 op = self.message_pane
2100 if not self.query_pane:
2103 direction = 1 if key[-1] in "N" else -1
2104 if self.message_pane:
2105 # message window exists so move, otherwise just select
2106 self.query_pane.call("notmuch:close-thread")
2107 self.query_pane.call("Move-Line", direction)
2109 m = p.call("doc:dup-point", 0, edlib.MARK_UNGROUPED, ret='mark')
2110 p.call("notmuch:select", m, direction)
2113 def handle_A(self, key, focus, num, mark, str, **a):
2114 "handle-list/doc:char-a/doc:char-A/doc:char-k/doc:char-S/doc:char-H/doc:char-*/doc:char-!/doc:char-d/doc:char-D/"
2115 # adjust flags for this message or thread, and move to next
2117 # A - remove inbox from entire thread
2118 # k - remove inbox from this message and replies
2119 # d - remove inbox, add deleted
2120 # D - as D, for entire thread
2122 # H - ham: remove newspam and add notspam
2124 # ! - add unread,inbox remove newspam,notspam,flagged,deleted
2125 # If num is negative reverse the change (except for !)
2126 # If num is not +/-NO_NUMERIC, apply to whole thread
2127 which = focus['notmuch:pane']
2128 if which not in ['message', 'query']:
2133 if num != edlib.NO_NUMERIC and num != -edlib.NO_NUMERIC:
2136 adds = []; removes = []
2156 removes = ['newspam']
2160 adds = ['unread','inbox']
2161 removes = ['newspam','notspam','flagged','deleted']
2163 if num < 0 and key[-1] != '!':
2164 adds, removes = removes, adds
2166 if which == "message":
2167 thid = self.message_pane['thread-id']
2168 msid = self.message_pane['message-id']
2169 elif which == "query":
2170 thid = focus.call("doc:get-attr", "thread-id", mark, ret = 'str')
2171 msid = focus.call("doc:get-attr", "message-id", mark, ret = 'str')
2178 mids = self.query_pane.call("doc:notmuch-query:matched-mids",
2181 # only mark messages which are replies to msid
2182 mids = self.query_pane.call("doc:notmuch-query:matched-replies",
2183 thid, msid, ret='str')
2186 self.do_update(thid, mids, adds, removes)
2188 mid = mids.split("\n")[-1]
2191 m = edlib.Mark(self.query_pane)
2192 self.query_pane.call("notmuch:find-message", thid, mid, m)
2194 self.query_pane.call("Move-to", m)
2195 self.query_pane.call("Move-Line", 1)
2196 if self.message_pane:
2197 # open the thread, and maybe the message, if the msid was open
2198 m = self.query_pane.call("doc:dup-point", 0,
2199 edlib.MARK_UNGROUPED, ret='mark')
2200 if msid and self.message_pane['notmuch:id'] == msid:
2201 self.query_pane.call("notmuch:select", m, 1)
2203 self.query_pane.call("notmuch:select", m, 0)
2206 def handle_new_mail(self, key, focus, **a):
2208 v = make_composition(self.list_pane, focus)
2210 v.call("compose-email:empty-headers")
2213 def handle_reply(self, key, focus, num, **a):
2214 "handle-list/doc:char-r/doc:char-R/doc:char-F/doc:char-z"
2215 if not self.message_pane:
2216 focus.call("Message", "Can only reply when a message is open")
2218 quote_mode = "inline"
2219 if num != edlib.NO_NUMERIC:
2222 hdr_mode = "forward"
2224 if quote_mode == "none":
2225 quote_mode = "attach"
2226 elif key[-1] == 'z':
2227 hdr_mode = "forward"
2229 quote_mode = "attach"
2230 elif key[-1] == 'R':
2231 hdr_mode = "reply-all"
2236 v = make_composition(self.list_pane, focus,
2237 tag="tag +%s -new -unread id:%s" % (tag, self.message_pane['message-id']))
2239 v.call("compose-email:copy-headers", self.message_pane, hdr_mode)
2240 if quote_mode == "inline":
2241 # find first visible text part and copy it
2242 msg = self.message_pane
2245 msg.call("doc:step-part", m, 1)
2246 which = msg.call("doc:get-attr",
2247 "multipart-this:email:which",
2251 if which != "spacer":
2253 vis = msg.call("doc:get-attr", "email:visible", m,
2255 if not vis or vis == 'none':
2257 type = msg.call("doc:get-attr",
2258 "multipart-prev:email:content-type",
2260 if (not type or not type.startswith("text/") or
2261 type == "text/rfc822-headers"):
2263 part = msg.call("doc:get-attr", m,
2264 "multipart:part-num", ret='str')
2265 # try transformed first
2266 c = msg.call("doc:multipart-%d-doc:get-str" % (int(part) - 1),
2268 if not c or not c.strip():
2269 c = msg.call("doc:multipart-%d-doc:get-str" % (int(part) - 2),
2275 v.call("compose-email:quote-content", c)
2277 if quote_mode == "attach":
2278 fn = self.message_pane["filename"]
2280 v.call("compose-email:attach", fn, "message/rfc822")
2283 def handle_mesg_cmd(self, key, focus, mark, num, **a):
2284 "handle-list/doc:char-X"
2285 # general commands to be directed to message view
2286 if self.message_pane:
2287 self.message_pane.call(key, num)
2290 def tag_ok(self, t):
2292 if not (c.isupper() or c.islower() or c.isdigit()):
2296 def do_update(self, tid, mid, adds, removes):
2300 self.list_pane.call("doc:notmuch:tag-add-%s" % t, tid, mid)
2305 self.list_pane.call("doc:notmuch:tag-remove-%s" % t, tid, mid)
2309 self.list_pane.call("Message", "Skipped illegal tags:" + ','.join(skipped))
2311 def handle_tags(self, key, focus, mark, num, **a):
2312 "handle-list/doc:char-+"
2313 # add or remove flags, prompting for names
2315 if self.message_pane and self.mychild(focus) == self.mychild(self.message_pane):
2317 elif self.query_pane and self.mychild(focus) == self.mychild(self.query_pane):
2325 thid = focus.call("doc:get-attr", "thread-id", mark, ret='str')
2326 msid = focus.call("doc:get-attr", "message-id", mark, ret='str')
2328 thid = self.message_pane['thread-id']
2329 msid = self.message_pane['message-id']
2331 # FIXME maybe warn that there is no message here.
2335 pup = focus.call("PopupTile", "2", '-' if num < 0 else '+', ret='pane')
2338 done = "notmuch-do-tags-%s" % thid
2341 pup['done-key'] = done
2342 pup['prompt'] = "[+/-]Tags"
2343 pup.call("doc:set-name", "Tag changes")
2347 def handle_neg(self, key, focus, num, mark, **a):
2350 # double negative is 'tags'
2351 return self.handle_tags(key, focus, mark, num)
2352 # else negative prefix arg
2353 focus.call("Mode:set-num", -num)
2356 def parse_tags(self, tags):
2359 if not tags or tags[0] not in "+-":
2363 for t in tags.split(','):
2364 tl.extend(t.split(' '))
2369 if tg != "" and mode == '+':
2371 if tg != "" and mode == '-':
2377 if tg != "" and mode == '+':
2379 if tg != "" and mode == '-':
2381 return (adds, removes)
2383 def do_tags(self, key, focus, str1, **a):
2384 "handle-prefix:notmuch-do-tags-"
2388 ids = suffix.split(' ', 1)
2394 t = self.parse_tags(str1)
2396 focus.call("Message", "Tags list must start with + or -")
2398 self.do_update(thid, msid, t[0], t[1])
2401 def handle_close_message(self, key, num, **a):
2402 "handle:notmuch-close-message"
2403 self.message_pane = None
2404 if num and self.query_pane:
2405 pnt = self.query_pane.call("doc:point", ret='mark')
2407 pnt['notmuch:current-message'] = ''
2408 pnt['notmuch:current-thread'] = ''
2411 def handle_xq(self, key, **a):
2412 "handle-list/doc:char-x/doc:char-q/doc:char-Q"
2413 if self.message_pane:
2414 if key != "doc:char-x":
2416 p = self.message_pane
2417 self("notmuch-close-message", 1)
2418 p.call("Window:close", "notmuch")
2419 elif self.query_pane:
2420 if (self.query_pane.call("notmuch:close-whole-thread") == 1 and
2421 key != "doc:char-Q"):
2423 if (self.query_pane.call("notmuch:close-thread") == 1 and
2424 key != "doc:char-Q"):
2426 if self.query_pane['filter']:
2427 self.query_pane.call("doc:notmuch:set-filter")
2428 if key != "doc:char-Q":
2430 if key != "doc:char-x":
2431 self.query_pane.call("notmuch:mark-seen")
2433 self.query_pane = None
2434 pnt = self.list_pane.call("doc:point", ret='mark')
2435 pnt['notmuch:query-name'] = ""
2436 self.list_pane.call("view:changed")
2438 p.call("Window:close", "notmuch")
2439 elif key == "doc:char-Q":
2440 p = self.call("ThisPane", ret='pane')
2445 def handle_v(self, key, **a):
2447 # View the current message as a raw file
2448 if not self.message_pane:
2450 p2 = self.call("doc:open", self.message_pane["filename"], -1,
2452 p2.call("doc:set:autoclose", 1)
2453 p0 = self.call("DocPane", p2, ret='pane')
2457 p0 = self.call("OtherPane", ret='pane')
2459 p2.call("doc:attach-view", p0, 1, "viewer")
2462 def handle_o(self, key, focus, **a):
2464 # focus to next window
2465 focus.call("Window:next", "notmuch")
2468 def handle_O(self, key, focus, **a):
2470 # focus to prev window
2471 focus.call("Window:prev", "notmuch")
2474 def handle_g(self, key, focus, **a):
2476 focus.call("doc:notmuch:update")
2479 def handle_Z(self, key, **a):
2480 "handle-list/doc:char-Z/doc:char-=/"
2482 return self.query_pane.call(key)
2483 return edlib.Efallthrough
2485 def handle_select_query(self, key, num, str, **a):
2486 "handle:notmuch:select-query"
2487 # A query was selected, identifed by 'str'. Close the
2488 # message window and open a threads window.
2489 if self.message_pane:
2490 p = self.message_pane
2491 self("notmuch-close-message", 1)
2492 p.call("Window:close", "notmuch")
2493 self.message_pane = None
2495 # doc:notmuch:query might auto-select a message, which will
2496 # call doc:notmuch:open, which tests self.query_pane,
2497 # which might be in the process of being closed. Don't want
2498 # that, so just clear query_pane early.
2499 self.query_pane = None
2500 p0 = self.list_pane.call("doc:notmuch:query", str, ret='pane')
2501 p1 = self.list_pane.call("OtherPane", "notmuch", "threads", 15,
2503 self.query_pane = p0.call("doc:attach-view", p1, ret='pane')
2505 pnt = self.list_pane.call("doc:point", ret='mark')
2506 pnt['notmuch:query-name'] = str
2507 self.list_pane.call("view:changed");
2510 self.query_pane.take_focus()
2514 def handle_select_message(self, key, focus, num, str1, str2, **a):
2515 "handle:notmuch:select-message"
2516 # a thread or message was selected. id in 'str1'. threadid in str2
2517 # Find the file and display it in a 'message' pane
2522 p0 = self.query_pane.call("doc:notmuch:open", str1, str2, ret='pane')
2524 p0 = self.list_pane.call("doc:notmuch:byid", str1, str2, ret='pane')
2526 focus.call("Message", "Failed to find message %s" % str2)
2528 p0['notmuch:tid'] = str2
2530 qp = self.query_pane
2533 p1 = focus.call("OtherPane", "notmuch", "message", 13,
2535 p3 = p0.call("doc:attach-view", p1, ret='pane')
2536 p3 = p3.call("attach-render-notmuch:message", ret='pane')
2538 # FIXME This still doesn't work: there are races: attaching a doc to
2539 # the pane causes the current doc to be closed. But the new doc
2540 # hasn't been anchored yet so if they are the same, we lose.
2541 # Need a better way to anchor a document.
2542 #p0.call("doc:set:autoclose", 1)
2543 p3['thread-id'] = str2
2544 p3['message-id'] = str1
2545 self.message_pane = p3
2547 pnt = self.query_pane.call("doc:point", ret='mark')
2549 pnt['notmuch:current-thread'] = str2
2550 pnt['notmuch:current-message'] = str1
2552 self.message_pane.take_focus()
2556 def mark_read(self):
2557 p = self.message_pane
2561 self.query_pane.call("doc:notmuch:mark-read",
2562 p['thread-id'], p['message-id'])
2564 class notmuch_list_view(edlib.Pane):
2565 # This pane provides view on the search-list document.
2566 def __init__(self, focus):
2567 edlib.Pane.__init__(self, focus)
2568 self['notmuch:pane'] = 'main'
2569 self['background'] = 'color:#A0FFFF'
2570 self['line-format'] = '<%fmt>%count%space<underline,action-activate:notmuch:select>%name</></>'
2571 self.call("notmuch:set_list_pane")
2572 self.call("doc:request:doc:replaced")
2573 self.selected = None
2574 pnt = self.call("doc:point", ret='mark')
2575 if pnt and pnt['notmuch:query-name']:
2576 self.call("notmuch:select-query", pnt['notmuch:query-name'])
2578 def handle_clone(self, key, focus, **a):
2580 p = notmuch_list_view(focus)
2581 self.clone_children(focus.focus)
2584 def handle_notify_replace(self, key, **a):
2585 "handle:doc:replaced"
2586 # FIXME do I need to do anything here? - of not, why not
2587 return edlib.Efallthrough
2589 def handle_select(self, key, focus, mark, num, **a):
2590 "handle:notmuch:select"
2591 s = focus.call("doc:get-attr", "query", mark, ret='str')
2593 focus.call("notmuch:select-query", s, num)
2596 def handle_select_adhoc(self, key, focus, mark, num, **a):
2597 "handle:notmuch:select-adhoc"
2598 focus.call("notmuch:select-query", "-ad hoc-", num)
2602 # query_view shows a list of threads/messages that match a given
2604 # We generate the thread-ids using "notmuch search --output=summary"
2605 # For a full-scan we collect at most 100 and at most 1 month at a time, until
2606 # we reach an empty month, then get all the rest together
2607 # For an update, we just check the last day and add anything missing.
2608 # We keep an array of thread-ids
2610 # Three different views are presented of this document depending on
2611 # whether 'selected' and possibly 'whole_thread' are set.
2613 # By default when neither is set, only the threads are displayed.
2614 # If a threadid is 'selected' but not 'whole_thread', then other threads
2615 # appear as a single line, but the selected thread displays as all
2616 # matching messages.
2617 # If whole_thread is set, then only the selected thread is visible,
2618 # and all messages, matched or not, of that thread are visisble.
2621 class notmuch_query_view(edlib.Pane):
2622 def __init__(self, focus):
2623 edlib.Pane.__init__(self, focus)
2624 self.selected = None
2626 self.whole_thread = False
2627 self.seen_threads = {}
2629 self['notmuch:pane'] = 'query'
2631 # thread_start and thread_end are marks which deliniate
2632 # the 'current' thread. thread_end is the start of the next
2633 # thread (if there is one).
2634 # thread_matched is the first matched message in the thread.
2635 self.thread_start = None
2636 self.thread_end = None
2637 self.thread_matched = None
2638 (xs,ys) = self.scale()
2640 self.call("Draw:text-size", "M", -1, ys,
2641 lambda key, **a: ret.append(a))
2643 lh = ret[0]['xy'][1]
2646 # fixme adjust for pane size
2647 self['render-vmargin'] = "%d" % (4 * lh)
2649 # if first thread is new, move to it.
2650 m = edlib.Mark(self)
2651 t = self.call("doc:get-attr", m, "T-tags", ret='str')
2652 if t and 'new' in t.split(','):
2653 self.call("Move-to", m)
2655 # otherwise restore old state
2656 pt = self.call("doc:point", ret='mark')
2658 if pt['notmuch:selected']:
2659 self("notmuch:select", 1, pt, pt['notmuch:selected'])
2660 mid = pt['notmuch:current-message']
2661 tid = pt['notmuch:current-thread']
2663 self.call("notmuch:select-message", mid, tid)
2666 self.call("doc:request:doc:replaced")
2667 self.call("doc:request:notmuch:thread-changed")
2668 self.updating = False
2669 self.call("doc:request:notmuch:thread-open")
2671 def handle_getattr(self, key, focus, str, comm2, **a):
2673 if comm2 and str == "doc-status":
2674 val = self.parent['doc:status']
2678 val = "filter: %s %s" % (
2679 self['filter'], val)
2680 elif self['qname'] == '-ad hoc-':
2681 val = "query: %s %s" % (
2683 comm2("callback", focus, val)
2686 def handle_clone(self, key, focus, **a):
2688 p = notmuch_query_view(focus)
2689 self.clone_children(focus.focus)
2692 def handle_close(self, key, focus, **a):
2695 # Reload the query so archived messages disappear
2696 self.call("doc:notmuch:query:reload")
2697 self.call("doc:notmuch:update-one", self['qname'])
2700 def handle_matched_mids(self, key, focus, str, str2, comm2, **a):
2701 "handle-prefix:doc:notmuch-query:matched-"
2702 # if whole_thread, everything should be considered matched.
2703 if str and str == self.selected and self.whole_thread:
2704 return self.parent.call(key, focus, str, str2, 1, comm2)
2705 return edlib.Efallthrough
2707 def handle_notify_replace(self, key, **a):
2708 "handle:doc:replaced"
2709 if self.thread_start:
2710 # Possible insertion before thread_end - recalc.
2711 self.thread_end = self.thread_start.dup()
2712 self.leaf.call("doc:step-thread", 1, 1, self.thread_end)
2713 self.leaf.call("view:changed")
2714 self.call("doc:notify:doc:status-changed")
2715 return edlib.Efallthrough
2717 def close_thread(self, gone = False):
2718 if not self.selected:
2720 # old thread is disappearing. If it is not gone, clip marks
2721 # to start, else clip to next thread.
2722 self.leaf.call("Notify:clip", self.thread_start, self.thread_end,
2724 if self.whole_thread:
2725 # And clip anything after (at eof) to thread_end
2726 eof = edlib.Mark(self)
2727 self.leaf.call("doc:set-ref", eof, 0)
2729 eof.index = 1 # make sure all eof marks are different
2730 self.leaf.call("Notify:clip", self.thread_end, eof, 1)
2732 self.leaf.call("view:changed", self.thread_start, self.thread_end)
2733 self.selected = None
2734 self.thread_start = None
2735 self.thread_end = None
2736 self.thread_matched = None
2737 self.whole_thread = False
2740 def move_thread(self):
2741 if not self.selected:
2743 # old thread is or moving
2744 # thread is still here, thread_start must have moved, thread_end
2745 # might be where thread used to be.
2746 self.thread_end = self.thread_start.dup()
2747 self.call("doc:step-thread", self.thread_end, 1, 1)
2748 self.leaf.call("view:changed", self.thread_start, self.thread_end)
2751 def find_message(self, key, focus, mark, str, str2, **a):
2752 "handle:notmuch:find-message"
2753 if not str or not mark:
2755 if not self.selected or self.selected != str:
2757 if self.call("doc:notmuch:to-thread", mark, str) <= 0:
2759 if str2 and self.call("doc:notmuch:to-message", mark, str2) <= 0:
2763 def trim_thread(self):
2764 if self.whole_thread:
2766 # clip any non-matching messages
2767 self.thread_matched = None
2768 m = self.thread_start.dup()
2769 while m < self.thread_end:
2770 mt = self.call("doc:get-attr", m, "matched", ret='str')
2773 self.call("doc:step-matched", m2, 1, 1)
2774 self.leaf.call("Notify:clip", m, m2)
2776 if not self.thread_matched:
2777 self.thread_matched = m.dup()
2779 self.leaf.call("view:changed", self.thread_start, self.thread_end)
2781 def handle_notify_thread(self, key, str, num, **a):
2782 "handle:notmuch:thread-changed"
2783 if not str or self.selected != str:
2786 self.close_thread(True)
2791 self.updating = False
2794 def handle_notify_thread_open(self, key, str1, **a):
2795 "handle:notmuch:thread-open"
2796 # If we have requested an update (so .updating is set) pretend
2797 # the thread isn't open, so a full update happens
2798 return 1 if not self.updating and str1 and self.selected == str1 else 0
2800 def handle_set_ref(self, key, mark, num, **a):
2801 "handle:doc:set-ref"
2804 if self.whole_thread:
2805 mark.to_mark(self.thread_start)
2807 if (self.selected and self.thread_matched and
2808 self.parent.prior(self.thread_start) is None):
2809 # first thread is open
2810 mark.to_mark(self.thread_matched)
2812 # otherwise fall-through to real start or end
2813 return edlib.Efallthrough
2815 def handle_doc_char(self, key, focus, mark, num, num2, mark2, **a):
2821 forward = 1 if steps > 0 else 0
2822 if end and end == mark:
2824 if end and (end < mark) != (steps < 0):
2825 # can never cross 'end'
2828 while steps and ret != edlib.WEOF and (not end or mark == end):
2829 ret = self.handle_step(key,focus, mark, forward, 1)
2830 steps -= forward * 2 - 1
2832 return 1 + (num - steps if forward else steps - num)
2833 if ret == edlib.WEOF or num2 == 0:
2835 if num and (num2 < 0) == (num > 0):
2837 # want the next character
2838 return self.handle_step(key, focus, mark, 1 if num2 > 0 else 0, 0)
2840 def handle_step(self, key, focus, mark, num, num2):
2843 if self.whole_thread:
2844 # move one message, but stop at thread_start/thread_end
2846 if mark < self.thread_start:
2847 mark.to_mark(self.thread_start)
2848 if mark >= self.thread_end:
2851 focus.call("doc:set-ret", mark, 0)
2854 ret = self.parent.call("doc:char", focus, mark,
2855 1 if move else 0, 0 if move else 1)
2856 if mark.pos and mark.pos[0] != self.selected:
2857 focus.call("doc:set-ref", mark, 0)
2860 if mark <= self.thread_start:
2864 if mark > self.thread_end:
2865 mark.to_mark(self.thread_end)
2866 ret = self.parent.call("doc:char", focus, mark,
2867 -1 if move else 0, 0 if move else -1)
2870 # if between thread_start/thread_end, move one message,
2871 # else move one thread
2872 if not self.thread_start:
2874 elif forward and mark >= self.thread_end:
2876 elif not forward and mark <= self.thread_start:
2878 elif forward and mark < self.thread_start:
2880 elif not forward and mark > self.thread_end:
2885 # move one matched message
2886 ret = self.parent.call("doc:step-matched", focus, mark, forward, move)
2887 # We might be in the next thread, make sure we are at the
2889 if forward and move and mark > self.thread_end:
2890 mark.to_mark(self.thread_end)
2891 if not forward and move and mark < self.thread_start:
2892 focus.call("doc:step-thread", mark, forward, move)
2893 if not forward and move and mark == self.thread_start:
2894 # must be at the start of the first thread, which is open.
2895 if self.thread_matched:
2896 mark.to_mark(self.thread_matched)
2901 ret = focus.call("doc:step-thread", focus, mark, forward, move)
2902 if (self.thread_matched and
2903 self.thread_start and mark == self.thread_start):
2904 mark.to_mark(self.thread_matched)
2906 # make sure we are at the start of the thread
2907 self.parent.call("doc:step-thread", focus, mark, forward, 1)
2908 ret = self.parent.call("doc:char", focus, mark,
2909 -1 if move else 0, 0 if move else -1)
2910 if move and ret != edlib.WEOF:
2911 focus.call("doc:step-thread", focus, mark, forward, move)
2914 def handle_get_attr(self, key, focus, mark, num, num2, str, comm2, **a):
2915 "handle:doc:get-attr"
2917 mark = focus.call("doc:point", ret='mark')
2918 if not mark or not mark.pos:
2919 return edlib.Efallthrough
2920 if self.whole_thread and mark >= self.thread_end:
2921 # no attributes after end
2925 (tid,mid) = mark.pos
2926 if tid == self.selected and comm2:
2927 if mid == self.selmsg:
2928 comm2("cb", focus, "bg:magenta+60", mark, attr)
2929 elif self.whole_thread:
2930 # only set background for matched messages
2931 mt = self.call("doc:get-attr", mark, "matched", ret='str')
2932 if mt and mt == "True":
2933 comm2("cb", focus, "bg:yellow+60", mark, attr)
2935 comm2("cb", focus, "bg:yellow+60", mark, attr)
2937 if attr[:3] == "TM-":
2938 if self.thread_start and mark < self.thread_end and mark >= self.thread_start:
2939 return self.parent.call("doc:get-attr", focus, num, num2, mark, "M-" + str[3:], comm2)
2941 return self.parent.call("doc:get-attr", focus, num, num2, mark, "T-" + str[3:], comm2)
2942 if attr == "message-id":
2943 # high message-id when thread isn't open
2944 if not self.selected or not mark.pos or mark.pos[0] != self.selected:
2947 return edlib.Efallthrough
2949 def handle_Z(self, key, focus, **a):
2951 if not self.thread_start:
2953 if self.whole_thread:
2954 # all non-match messages in this thread are about to
2955 # disappear, we need to clip them.
2956 if self.thread_matched:
2957 mk = self.thread_start.dup()
2958 mt = self.thread_matched.dup()
2959 while mk < self.thread_end:
2961 focus.call("Notify:clip", mk, mt)
2963 self.parent.call("doc:step-matched", mt, 1, 1)
2964 self.parent.next(mk)
2966 focus.call("Notify:clip", self.thread_start, self.thread_end)
2967 # everything after to EOF moves to thread_end.
2968 eof = edlib.Mark(self)
2969 self.leaf.call("doc:set-ref", eof, 0)
2971 eof.offset = 1 # make sure all eof marks are different
2972 self.leaf.call("Notify:clip", self.thread_end, eof, 1)
2975 self['doc-status'] = "Query: %s" % self['qname']
2977 # everything before the thread, and after the thread disappears
2978 m = edlib.Mark(self)
2979 focus.call("Notify:clip", m, self.thread_start)
2980 focus.call("doc:set-ref", m, 0)
2981 focus.call("Notify:clip", self.thread_end, m)
2982 self['doc-status'] = "Query: %s - single-thread" % self['qname']
2983 self.whole_thread = not self.whole_thread
2984 # notify that everything is changed, don't worry about details.
2985 focus.call("view:changed")
2988 def handle_update(self, key, focus, **a):
2991 self.updating = True
2992 focus.call("doc:notmuch:load-thread", self.thread_start)
2993 focus.call("doc:notmuch:query:reload")
2996 def handle_close_whole_thread(self, key, focus, **a):
2997 "handle:notmuch:close-whole-thread"
2998 # 'q' is requesting that we close thread if it is open
2999 if not self.whole_thread:
3001 self.handle_Z(key, focus)
3004 def handle_close_thread(self, key, focus, **a):
3005 "handle:notmuch:close-thread"
3006 if self.close_thread():
3010 def handle_select(self, key, focus, mark, num, num2, str1, **a):
3011 "handle:notmuch:select"
3012 # num = 0 - open thread but don't show message
3013 # num > 0 - open thread and do show message
3014 # num < 0 - open thread, go to last message, and show
3015 # if 'str1' and that thread exists, go there instead of mark
3020 if self.call("notmuch:find-message", m, str1) > 0:
3023 s = focus.call("doc:get-attr", "thread-id", mark, ret='str')
3024 if s and s != self.selected:
3027 ret = focus.call("doc:notmuch:load-thread", mark)
3029 focus.call("Message", "Cannot load thread %s" % s)
3033 self.thread_start = mark.dup()
3035 self.thread_start = focus.call("doc:dup-point", 0,
3036 edlib.MARK_UNGROUPED, ret='mark')
3037 focus.call("doc:step-thread", 0, 1, self.thread_start)
3038 self.thread_end = self.thread_start.dup()
3039 focus.call("doc:step-thread", 1, 1, self.thread_end)
3040 self.thread_matched = self.thread_start.dup()
3041 matched = focus.call("doc:get-attr", self.thread_matched, "matched", ret="str")
3042 if matched != "True":
3043 focus.call("doc:step-matched", 1, 1, self.thread_matched)
3044 focus.call("view:changed", self.thread_start, self.thread_end)
3045 self.thread_start.step(0)
3047 # we moved backward to land here, so go to last message
3048 m = self.thread_end.dup()
3049 focus.call("doc:step-matched", 0, 1, m)
3051 # choose first new, unread or thread_matched
3052 new = None; unread = None
3053 m = self.thread_matched.dup()
3054 while focus.call("doc:get-attr", m, "thread-id",
3056 tg = focus.call("doc:get-attr", m, "tags", ret='str')
3061 if "new" in tg and not new:
3063 focus.call("doc:step-matched", 1, 1, m)
3069 m = self.thread_matched
3070 # all marks on this thread get moved to chosen start
3071 focus.call("Notify:clip", self.thread_start, m)
3073 mark.clip(self.thread_start, m)
3074 pnt = self.call("doc:point", ret='mark')
3076 pnt['notmuch:selected'] = s
3078 # thread-id shouldn't have changed, but it some corner-cases
3079 # it can, so get both ids before loading the message.
3080 s = focus.call("doc:get-attr", "thread-id", mark, ret='str')
3081 s2 = focus.call("doc:get-attr", "message-id", mark, ret='str')
3083 focus.call("notmuch:select-message", s2, s)
3085 self.call("view:changed")
3089 def handle_reposition(self, key, focus, mark, mark2, **a):
3090 "handle:render:reposition"
3091 # some messages have been displayed, from mark to mark2
3092 # collect threadids and message ids
3093 if not mark or not mark2:
3094 return edlib.Efallthrough
3098 tg = focus.call("doc:get-attr", "tags", m, ret='str')
3099 if tg and 'new' in tg.split(','):
3100 i1 = focus.call("doc:get-attr", "thread-id", m, ret='str')
3101 i2 = focus.call("doc:get-attr", "message-id", m, ret='str')
3102 if i1 and not i2 and i1 not in self.seen_threads:
3103 self.seen_threads[i1] = True
3105 if i1 in self.seen_threads:
3106 del self.seen_threads[i1]
3107 if i2 not in self.seen_msgs:
3108 self.seen_msgs[i2] = i1
3109 if self.next(m) is None:
3111 return edlib.Efallthrough
3113 def handle_mark_seen(self, key, focus, **a):
3114 "handle:notmuch:mark-seen"
3115 for id in self.seen_threads:
3116 notmuch_set_tags(thread=id, remove=['new'])
3117 self.notify("Notify:Tag", id)
3119 for id in self.seen_msgs:
3120 notmuch_set_tags(msg=id, remove=['new'])
3121 self.notify("Notify:Tag", self.seen_msgs[id], id)
3123 self.seen_threads = {}
3127 class notmuch_message_view(edlib.Pane):
3128 def __init__(self, focus):
3129 edlib.Pane.__init__(self, focus)
3130 # Need to set default visibility on each part.
3131 # step forward with doc:step-part and for any 'odd' part,
3132 # which is a spacer, we look at email:path and email:content-type.
3133 # If alternative:[1-9] is found, or type isn't "text*", make it
3135 self['notmuch:pane'] = 'message'
3137 focus.call("doc:notmuch:request:Notify:Tag", self)
3138 self.do_handle_notify_tag()
3140 # a 'view' for recording where quoted sections are
3141 self.qview = focus.call("doc:add-view", self) - 1
3143 self.extra_headers = False
3144 self.point = focus.call("doc:point", ret='mark')
3145 self.prev_point = None
3146 self.have_prev = False
3147 self.call("doc:request:mark:moving")
3151 self['word-wrap'] = '1' # Should this be different in different parts?
3154 m = edlib.Mark(focus)
3156 self.call("doc:step-part", m, 1)
3157 which = focus.call("doc:get-attr", "multipart-this:email:which",
3161 if which != "spacer":
3163 path = focus.call("doc:get-attr",
3164 "multipart-prev:email:path", m, ret='str')
3165 type = focus.call("doc:get-attr",
3166 "multipart-prev:email:content-type", m, ret='str')
3167 disp = focus.call("doc:get-attr",
3168 "multipart-prev:email:content-disposition", m, ret='str')
3169 fname = focus.call("doc:get-attr",
3170 "multipart-prev:email:filename", m, ret='str')
3172 if fname and '/' in fname:
3173 fname = os.path.basename(prefix)
3175 if fname and '.' in fname:
3176 d = fname.rindex('.')
3179 if not ext and type:
3180 ext = mimetypes.guess_extension(type)
3182 # if there is an extension, we can pass to xdg-open, so maybe
3183 # there is an external viewer
3184 focus.call("doc:set-attr", "multipart-prev:email:ext", m, ext)
3185 focus.call("doc:set-attr", "multipart-prev:email:prefix", m, prefix)
3186 focus.call("doc:set-attr", "multipart-prev:email:actions", m,
3187 "hide:save:external view");
3189 if (type.startswith("text/") and
3190 (not disp or "attachment" not in disp)):
3191 # mark up URLs and quotes in any text part.
3192 # The part needs to be visible while we do this.
3193 # Examine at most 50000 chars from the start.
3194 self.set_vis(focus, m, True)
3197 self.call("doc:step-part", start, 0)
3199 self.call("doc:char", end, 10000, m)
3201 self.call("url:mark-up", start, end)
3202 self.mark_quotes(start, end)
3204 # When presented with alternatives we are supposed to show
3205 # the last alternative that we understand. However html is
3206 # only partly understood, so I only want to show that if
3207 # there is no other option.... but now I have w3m let's try
3209 # An alternate may contain many parts and we need to make
3210 # everything invisible within an invisible part - and we only
3211 # know which is invisible when we get to the end.
3212 # So record the visibility of each group of alternatives
3213 # now, and the walk through again setting visibility.
3214 # An alternative itself may be multi-part, typically
3215 # multipart/related. In this case we only look at
3216 # whether we can handle the type of the first part.
3219 while (i > 0 and p[i].endswith(":0") and
3220 not p[i].startswith("alternative:")):
3221 # Might be the first part of a multi-path alternative,
3222 # look earlier in the path
3224 if p[i].startswith("alternative:"):
3225 # this is one of several - can we handle it?
3226 group = ','.join(p[:i])
3228 if type in ['text/plain', 'text/calendar', 'text/rfc822-headers',
3230 choose[group] = this
3231 if type.startswith('image/'):
3232 choose[group] = this
3233 if type == 'text/html': # and group not in choose:
3234 choose[group] = this
3236 # Now go through and set visibility for alternates.
3237 m = edlib.Mark(focus)
3239 self.call("doc:step-part", m, 1)
3240 which = focus.call("doc:get-attr", "multipart-this:email:which",
3244 if which != "spacer":
3246 path = focus.call("doc:get-attr", "multipart-prev:email:path",
3248 type = focus.call("doc:get-attr", "multipart-prev:email:content-type",
3250 disp = focus.call("doc:get-attr", "multipart-prev:email:content-disposition",
3255 # Is this allowed to be visible by default?
3257 type.startswith("text/") or
3258 type.startswith("image/")):
3261 if disp and "attachment" in disp:
3262 # Attachments are never visible - even text.
3265 # Is this in a non-selected alternative?
3267 for el in path.split(','):
3269 if el.startswith("alternative:"):
3270 group = ','.join(p[:-1])
3272 if choose[group] != this:
3275 self.set_vis(focus, m, vis)
3277 def set_vis(self, focus, m, vis):
3279 it = focus.call("doc:get-attr", "multipart-prev:email:is_transformed", m, ret='str')
3280 if it and it == "yes":
3281 focus.call("doc:set-attr", "email:visible", m, "transformed")
3283 focus.call("doc:set-attr", "email:visible", m, "orig")
3285 focus.call("doc:set-attr", "email:visible", m, "none")
3288 def mark_quotes(self, ms, me):
3289 # if we find more than 7 quoted lines in a row, we add the
3290 # 4th and 4th-last to the qview with the first of these
3291 # having a 'quote-length' attr with number of lines
3295 self.call("text-search", "^>", ms, me)
3301 while (cnt <= 7 and self.call("doc:EOL", 1, 1, ms) > 0 and
3302 self.following(ms) == '>'):
3306 self.call("text-search", "^[^>]", ms, me)
3310 self.mark_one_quote(start, ms.dup())
3312 def mark_one_quote(self, ms, me):
3313 self.call("doc:EOL", 3, 1, ms) # Start of 3rd line
3314 self.call("doc:EOL", -4, 1, me) # End of 4th last line
3317 st = edlib.Mark(self, self.qview)
3321 self.call("doc:EOL", 1, 1, ms)
3323 st['quote-length'] = "%d" % lines
3324 st['quote-hidden'] = "yes"
3325 ed = edlib.Mark(orig=st)
3328 def do_handle_notify_tag(self):
3329 # tags might have changed.
3330 tg = self.call("doc:notmuch:byid:tags", self['notmuch:id'], ret='str')
3332 self['doc-status'] = "Tags:" + tg
3334 self['doc-status'] = "No Tags"
3336 def handle_notify_tag(self, key, str1, str2, **a):
3338 if str1 != self['notmuch:tid']:
3341 if str2 and str2 != self['notmuch:id']:
3344 return self.do_handle_notify_tag()
3346 def handle_close(self, key, **a):
3348 self.call("notmuch-close-message")
3351 def handle_clone(self, key, focus, **a):
3353 p = notmuch_message_view(focus)
3354 self.clone_children(focus.focus)
3357 def handle_replace(self, key, **a):
3361 def handle_slash(self, key, focus, mark, **a):
3363 s = focus.call("doc:get-attr", mark, "email:visible", ret='str')
3366 self.set_vis(focus, mark, s == "none")
3369 def handle_space(self, key, focus, mark, **a):
3371 if focus.call("K:Next", 1, mark) == 2:
3372 focus.call("doc:char-n", mark)
3375 def handle_backspace(self, key, focus, mark, **a):
3376 "handle:K:Backspace"
3377 if focus.call("K:Prior", 1, mark) == 2:
3378 focus.call("doc:char-p", mark)
3381 def handle_return(self, key, focus, mark, **a):
3383 m = self.vmark_at_or_before(self.qview, mark)
3384 if m and not m['quote-length'] and m == mark:
3385 # at end of last line, wan't previous mark
3387 if m and m['quote-length']:
3388 if m['quote-hidden'] == 'yes':
3389 m['quote-hidden'] = "no"
3391 m['quote-hidden'] = "yes"
3392 self.leaf.call("view:changed", m, m.next())
3395 def handle_vis(self, focus, mark, which):
3396 v = focus.call("doc:get-attr", mark, "email:visible", ret='str')
3397 self.parent.call("email:select:" + which, focus, mark)
3398 v2 = focus.call("doc:get-attr", mark, "email:visible", ret='str')
3399 if v != v2 or which == "extras":
3400 # when visibility changes, move point to start.
3401 focus.call("doc:email-step-part", mark, -1)
3402 pt = focus.call("doc:point", ret='mark');
3406 def handle_toggle_hide(self, key, focus, mark, **a):
3407 "handle-list/email-hide/email:select:hide"
3408 return self.handle_vis(focus, mark, "hide")
3410 def handle_toggle_full(self, key, focus, mark, **a):
3411 "handle-list/email-full/email:select:full"
3412 return self.handle_vis(focus, mark, "full")
3414 def handle_toggle_extras(self, key, focus, mark, **a):
3415 "handle-list/email-extras/email:select:extras/doc:char-X"
3417 # a mark at the first "sep" part will identify the headers
3418 mark = edlib.Mark(focus)
3419 focus.call("doc:email-step-part", mark, 1)
3420 self.handle_vis(focus, mark, "extras")
3421 if self.extra_headers:
3423 self.extra_headers = 1
3424 hdrdoc = focus.call("doc:multipart:get-part", 1, ret='pane')
3425 point = hdrdoc.call("doc:vmark-new", edlib.MARK_POINT, ret='mark')
3426 hdrdoc.call("doc:set-ref", point)
3428 f = self['notmuch:fn-%d' % i]
3431 if f == self['filename']:
3433 hdr = "Filename-%d: " % i
3434 hdrdoc.call("doc:replace", 1, point, point, hdr,
3435 ",render:rfc822header=%d" % (len(hdr)-1))
3436 hdrdoc.call("doc:replace", 1, point, point, f + '\n')
3437 hdrdoc.call("doc:replace", 1, point, point, "Thread-id: ",
3438 ",render:rfc822header=10")
3439 hdrdoc.call("doc:replace", 1, point, point, self['notmuch:tid'] + '\n')
3441 ts = self['notmuch:timestamp']
3446 tm = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime(ts))
3447 hdrdoc.call("doc:replace", 1, point, point, "Local-Time: ",
3448 ",render:rfc822header=11")
3449 hdrdoc.call("doc:replace", 1, point, point, tm + '\n')
3452 def handle_save(self, key, focus, mark, **a):
3453 "handle-list/email-save/email:select:save"
3455 file = focus.call("doc:get-attr", "multipart-prev:email:filename", mark, ret='str')
3457 file = "edlib-saved-file"
3460 part = focus.call("doc:get-attr", mark, "multipart:part-num", ret='str')
3464 content = focus.call("doc:multipart-%d-doc:get-bytes" % part, ret = 'bytes')
3465 f.buffer.write(content)
3467 focus.call("Message", "Content saved as %s" % fn)
3470 def handle_external(self, key, focus, mark, **a):
3471 "handle-list/email-external view/email:select:external view"
3472 type = focus.call("doc:get-attr", "multipart-prev:email:content-type", mark, ret='str')
3473 prefix = focus.call("doc:get-attr", "multipart-prev:email:prefix", mark, ret='str')
3474 ext = focus.call("doc:get-attr", "multipart-prev:email:ext", mark, ret='str')
3480 part = focus.call("doc:get-attr", mark, "multipart:part-num", ret='str')
3483 content = focus.call("doc:multipart-%d-doc:get-bytes" % part, ret = 'bytes')
3484 fd, path = tempfile.mkstemp(ext, prefix)
3485 os.write(fd, content)
3487 focus.call("Display:external-viewer", path, prefix+"XXXXX"+ext)
3490 def handle_map_attr(self, key, focus, mark, str, str2, comm2, **a):
3492 if str == "render:rfc822header":
3493 comm2("attr:callback", focus, int(str2), mark, "fg:#6495ed,nobold", 121)
3494 comm2("attr:callback", focus, 0, mark, "wrap-tail: ,wrap-head: ",
3497 if str == "render:rfc822header-addr":
3498 # str is len,tag,hdr
3500 if w[2] in ["From", "To", "Cc"]:
3501 comm2("attr:callback", focus, int(w[0]), mark,
3502 "underline,action-menu:notmuch-addr-menu,addr-tag:"+w[1],
3505 if str == "render:rfc822header-wrap":
3506 comm2("attr:callback", focus, int(str2), mark, "wrap", 120)
3508 if str == "render:rfc822header:subject":
3509 comm2("attr:callback", focus, 10000, mark, "fg:blue,bold", 120)
3511 if str == "render:rfc822header:to":
3512 comm2("attr:callback", focus, 10000, mark, "word-wrap:0,fg:blue,bold", 120)
3514 if str == "render:rfc822header:cc":
3515 comm2("attr:callback", focus, 10000, mark, "word-wrap:0", 120)
3517 if str == "render:hide":
3518 comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3519 mark, "hide", 100000)
3520 if str == "render:bold":
3521 comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3523 if str == "render:internal":
3524 comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3526 if str == "render:imgalt":
3527 comm2("attr:callback", focus, 100000 if str2 == "1" else -1,
3528 mark, "fg:green-60", 120)
3529 if str == "render:char":
3532 if w[1] and w[1][0] == '!' and w[1] != '!':
3533 # not recognised, so highlight the name
3534 attr = "fg:magenta-60,bold"
3535 comm2("attr:callback", focus, int(w[0]), mark,
3536 attr, 120, str2=w[1])
3537 # Don't show the html entity description, just the rendering.
3538 comm2("attr:callback", focus, int(w[0]), mark,
3540 if str == 'start-of-line':
3541 m = self.vmark_at_or_before(self.qview, mark)
3543 if m and m['quote-length']:
3545 # if line starts '>', give it some colour
3546 if focus.following(mark) == '>':
3547 colours = ['red', 'red-60', 'green-60', 'magenta-60']
3551 while c and c in ' >':
3556 if cnt >= len(colours):
3558 comm2("cb", focus, mark, 0, "fg:"+colours[cnt-1], 102)
3560 comm2("cb", focus, mark, 0, "bg:"+bg, 102)
3561 return edlib.Efallthrough
3563 def handle_menu(self, key, focus, mark, xy, str1, **a):
3564 "handle:notmuch-addr-menu"
3566 self.menu.call("Cancel")
3567 for at in str1.split(','):
3568 if at.startswith("addr-tag:"):
3570 addr = focus.call("doc:get-attr", 0, mark, "addr-"+t, ret='str')
3573 ad = email.utils.getaddresses([addr])
3574 if ad and ad[0] and len(ad[0]) == 2:
3576 focus.call("Message", "Menu for address %s" % addr)
3577 mp = self.call("attach-menu", "", "notmuch-addr-choice", xy, ret='pane')
3578 mp.call("menu-add", "Compose", "C")
3579 for dir in [ "from", "to" ]:
3580 q = focus.call("doc:notmuch:get-query", dir + "-list", ret='str')
3583 if t.startswith("query:"):
3585 qq = focus.call("doc:notmuch:get-query", t, ret='str')
3586 if qq and (dir + ":" + addr) in qq:
3587 mp.call("menu-add", 1, 'Already in "%s"' % t, "-" + t)
3589 mp.call("menu-add", 'Add to "%s"' % t, dir+':'+t)
3590 mp.call("doc:file", -1)
3593 self.add_notify(mp, "Notify:Close")
3596 def handle_notify_close(self, key, focus, **a):
3597 "handle:Notify:Close"
3598 if focus == self.menu:
3601 return edlib.Efallthrough
3603 def handle_addr_choice(self, key, focus, mark, str1, **a):
3604 "handle:notmuch-addr-choice"
3605 if not str1 or not self.addr:
3607 if str1.startswith('-'):
3608 # already in this query
3610 dir = str1.split(':')[0]
3611 if len(str1) <= len(dir):
3613 query = str1[len(dir)+1:]
3614 q = focus.call("doc:notmuch:get-query", query, ret='str')
3616 # notmuch combines "to:" with "and" - weird
3618 q = q + " OR " + dir + ":" + self.addr
3620 q = q + " " + dir + ":" + self.addr
3621 if focus.call("doc:notmuch:set-query", query, q) > 0:
3622 focus.call("Message",
3623 "Updated query.%s with %s" % (query, self.addr))
3625 focus.call("Message",
3626 "Update for query.%s failed." % query)
3629 def handle_render_line(self, key, focus, num, mark, mark2, comm2, **a):
3630 "handle:doc:render-line"
3631 # If between active quote marks, render a simple marker
3632 p = self.vmark_at_or_before(self.qview, mark)
3633 if p and not p['quote-length'] and p == mark:
3634 # at end of last line
3636 cursor_at_end = mark2 and mark2 > mark
3638 if not(p and p['quote-length'] and p['quote-hidden'] == 'yes'):
3639 return edlib.Efallthrough
3642 mark.to_mark(p.next())
3646 # don't move mark from start of line
3647 # So 'click' always places at start of line.
3651 line = "<fg:yellow,bg:blue+30>%d quoted lines</>%s" % (int(p['quote-length']), eol)
3657 return comm2("cb", focus, cpos, line)
3660 def handle_render_line_prev(self, key, focus, num, mark, comm2, **a):
3661 "handle:doc:render-line-prev"
3662 # If between active quote marks, move to start first
3663 p = self.vmark_at_or_before(self.qview, mark)
3664 if not(p and p['quote-length'] and p['quote-active'] == 'yes'):
3665 return edlib.Efallthrough
3667 return edlib.Efallthrough
3669 def handle_moving(self, key, focus, mark, mark2, **a):
3670 "handle:mark:moving"
3671 if mark == self.point and not self.have_prev:
3672 # We cannot dup because that triggers a recursive notification
3673 #self.prev_point = mark.dup()
3674 self.prev_point = self.vmark_at_or_before(self.qview, mark)
3675 self.have_prev = True
3676 self.damaged(edlib.DAMAGED_VIEW)
3679 def handle_review(self, key, focus, **a):
3680 "handle:Refresh:view"
3681 # if point is in a "quoted line" section that is hidden,
3682 # Move it to start or end opposite prev_point
3683 if not self.have_prev:
3685 m = self.vmark_at_or_before(self.qview, self.point)
3686 if m and m != self.point and m['quote-length'] and m['quote-hidden'] == "yes":
3687 if not self.prev_point or self.prev_point < self.point:
3688 # moving toward end of file
3691 self.point.to_mark(m)
3692 self.prev_point = None
3693 self.have_prev = False
3696 def notmuch_doc(key, home, focus, comm2, **a):
3697 # Create the root notmuch document
3698 nm = notmuch_main(home)
3699 nm['render-default'] = "notmuch:master-view"
3700 nm.call("doc:set-name", "*Notmuch*")
3701 nm.call("global-multicall-doc:appeared-")
3702 nm.call("doc:notmuch:update")
3703 if comm2 is not None:
3704 comm2("callback", focus, nm)
3707 def render_query_attach(key, focus, comm2, **a):
3708 p = notmuch_query_view(focus)
3709 p["format:no-linecount"] = "1"
3710 p = p.call("attach-render-format", ret='pane')
3711 p['render-wrap'] = 'yes'
3713 comm2("callback", p)
3716 def render_message_attach(key, focus, comm2, **a):
3717 p = focus.call("attach-email-view", ret='pane')
3718 p = notmuch_message_view(p)
3720 p2 = p.call("attach-render-url-view", ret='pane')
3724 comm2("callback", p)
3727 def render_master_view_attach(key, focus, comm2, **a):
3728 # The master view for the '*Notmuch*' document uses multiple tiles
3729 # to display the available searches, the current search results, and the
3730 # current message, though each of these is optional.
3731 # The tile which displays the search list does not have a document, as it
3732 # refers down the main document. So it doesn't automatically get borders
3733 # from a 'view', so we must add one explicitly.
3734 # We assume 'focus' is a 'view' pane on a "doc" pane for the notmuch primary doc,
3735 # which is (probably) on a "tile" pane.
3736 # The master view needs to be below the "doc" pane, above the tile.
3737 # We attach it on focus and use Pane.reparent to rotate
3738 # from: master_view -> view -> doc -> tile
3739 # to: view -> doc -> master_view -> tile
3742 main = notmuch_master_view(focus)
3744 p = main.call("attach-tile", "notmuch", "main", ret='pane')
3745 # Now we have tile(main) -> view -> doc -> master_view -> tile
3746 # and want tile(main) above doc
3748 # Now 'view' doesn't have a child -we give it 'list_view'
3749 p = notmuch_list_view(focus)
3750 p = p.call("attach-render-format", ret='pane')
3753 comm2("callback", p)
3756 def notmuch_pane(focus):
3757 p0 = focus.call("ThisPane", ret='pane')
3759 p1 = focus.call("docs:byname", "*Notmuch*", ret='pane')
3760 except edlib.commandfailed:
3761 p1 = focus.call("attach-doc-notmuch", ret='pane')
3764 return p1.call("doc:attach-view", p0, ret='pane')
3766 def notmuch_mode(key, focus, **a):
3767 if notmuch_pane(focus):
3771 def notmuch_compose(key, focus, **a):
3773 def choose(choice, a):
3775 if focus['email-sent'] == 'no':
3776 choice.append(focus)
3779 focus.call("docs:byeach", lambda key,**a:choose(choice, a))
3781 par = focus.call("ThisPane", ret='pane')
3783 par = choice[0].call("doc:attach-view", par, 1, ret='pane')
3787 db = focus.call("docs:byname", "*Notmuch*", ret='pane')
3788 except edlib.commandfailed:
3789 db = focus.call("attach-doc-notmuch", ret='pane')
3790 v = make_composition(db, focus, "ThisPane")
3792 v.call("compose-email:empty-headers")
3795 def notmuch_search(key, focus, **a):
3796 p = notmuch_pane(focus)
3798 p.call("doc:char-s")
3801 edlib.editor.call("global-set-command", "attach-doc-notmuch", notmuch_doc)
3802 edlib.editor.call("global-set-command", "attach-render-notmuch:master-view",
3803 render_master_view_attach)
3804 edlib.editor.call("global-set-command", "attach-render-notmuch:threads",
3805 render_query_attach)
3806 edlib.editor.call("global-set-command", "attach-render-notmuch:message",
3807 render_message_attach)
3808 edlib.editor.call("global-set-command", "interactive-cmd-nm", notmuch_mode)
3809 edlib.editor.call("global-set-command", "interactive-cmd-nmc", notmuch_compose)
3810 edlib.editor.call("global-set-command", "interactive-cmd-nms", notmuch_search)