diff --git a/alot/buffers/bufferlist.py b/alot/buffers/bufferlist.py index 14f94e205..ca524fc18 100644 --- a/alot/buffers/bufferlist.py +++ b/alot/buffers/bufferlist.py @@ -13,7 +13,7 @@ class BufferlistBuffer(Buffer): modename = 'bufferlist' - def __init__(self, ui, filtfun=lambda x: x): + def __init__(self, ui, filtfun=lambda x: True): self.filtfun = filtfun self.ui = ui self.isinitialized = False diff --git a/alot/buffers/namedqueries.py b/alot/buffers/namedqueries.py index ad7c5fb69..8eea8e6f9 100644 --- a/alot/buffers/namedqueries.py +++ b/alot/buffers/namedqueries.py @@ -13,9 +13,8 @@ class NamedQueriesBuffer(Buffer): modename = 'namedqueries' - def __init__(self, ui, filtfun): + def __init__(self, ui): self.ui = ui - self.filtfun = filtfun self.isinitialized = False self.querylist = None self.rebuild() diff --git a/alot/buffers/taglist.py b/alot/buffers/taglist.py index 55fa2fddd..864881f22 100644 --- a/alot/buffers/taglist.py +++ b/alot/buffers/taglist.py @@ -2,6 +2,7 @@ # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import urwid +from notmuch2 import NotmuchError from .buffer import Buffer from ..settings.const import settings @@ -13,14 +14,30 @@ class TagListBuffer(Buffer): modename = 'taglist' - def __init__(self, ui, alltags=None, filtfun=lambda x: x): + def __init__(self, ui, alltags=None, filtfun=lambda x: True, querystring=None, match=None): self.filtfun = filtfun self.ui = ui self.tags = alltags or [] + self.querystring = querystring + self.match = match + self.result_count = 0 self.isinitialized = False self.rebuild() Buffer.__init__(self, ui, self.body) + def __str__(self): + formatstring = '[taglist] for "%s matching %s" (%d message%s)' + return formatstring % (self.querystring or '*', self.match or '*', self.result_count, + 's' if self.result_count > 1 else '') + + def get_info(self): + info = {} + info['querystring'] = self.querystring or '*' + info['match'] = self.match or '*' + info['result_count'] = self.result_count + info['result_count_positive'] = 's' if self.result_count > 1 else '' + return info + def rebuild(self): if self.isinitialized: focusposition = self.taglist.get_focus()[1] @@ -28,9 +45,30 @@ def rebuild(self): focusposition = 0 self.isinitialized = True - lines = list() displayedtags = sorted((t for t in self.tags if self.filtfun(t)), key=str.lower) + + exclude_tags = settings.get_notmuch_setting('search', 'exclude_tags') + if exclude_tags: + exclude_tags = [t for t in exclude_tags.split(';') if t] + + compoundquerystring = ' AND '.join(['(%s)' % q for q in + [self.querystring, + ' OR '.join(['tag:"%s"' % t for t + in displayedtags])] + if q]) + + try: + self.result_count = self.ui.dbman.count_messages( + compoundquerystring or '*') + except NotmuchError: + self.ui.notify('malformed query string: %s' % compoundquerystring, + 'error') + self.taglist = urwid.ListBox([]) + self.body = self.listbox + return + + lines = list() for (num, b) in enumerate(displayedtags): if (num % 2) == 0: attr = settings.get_theming_attribute('taglist', 'line_even') @@ -38,12 +76,14 @@ def rebuild(self): attr = settings.get_theming_attribute('taglist', 'line_odd') focus_att = settings.get_theming_attribute('taglist', 'line_focus') - tw = TagWidget(b, attr, focus_att) - rows = [('fixed', tw.width(), tw)] - if tw.hidden: - rows.append(urwid.Text(b + ' [hidden]')) - elif tw.translated is not b: - rows.append(urwid.Text('(%s)' % b)) + rows = [TagWidget(b, attr, focus_att, True)] + + count = self.ui.dbman.count_messages(' AND '.join(['(%s)' % q for q in + [self.querystring, 'tag:"%s"' % b] if q])) + count_unread = self.ui.dbman.count_messages(' AND '.join(['(%s)' % q for q in + [self.querystring, 'tag:"%s"' % b, 'tag:unread'] if q])) + rows.append(urwid.Text('{0:>7} {1:7}'. + format(count, '({0})'.format(count_unread)))) line = urwid.Columns(rows, dividechars=1) line = urwid.AttrMap(line, attr, focus_att) lines.append(line) @@ -51,7 +91,8 @@ def rebuild(self): self.taglist = urwid.ListBox(urwid.SimpleListWalker(lines)) self.body = self.taglist - self.taglist.set_focus(focusposition % len(displayedtags)) + if len(displayedtags): + self.taglist.set_focus(focusposition % len(displayedtags)) def focus_first(self): """Focus the first line in the tag list.""" @@ -63,8 +104,16 @@ def focus_last(self): lastpos = allpos[0] self.body.set_focus(lastpos) - def get_selected_tag(self): - """returns selected tagstring""" + def get_selected_tagline(self): + """ + returns curently focussed :class:`urwid.AttrMap` tagline widget + from the result list. + """ cols, _ = self.taglist.get_focus() + return cols + + def get_selected_tag(self): + """returns selected tagstring or throws AttributeError if none""" + cols = self.get_selected_tagline() tagwidget = cols.original_widget.get_focus() return tagwidget.tag diff --git a/alot/commands/globals.py b/alot/commands/globals.py index aa1fd4368..e9a6e3944 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -7,6 +7,7 @@ import email import email.utils import glob +import re import logging import os import subprocess @@ -532,7 +533,7 @@ def apply(self, ui): class OpenBufferlistCommand(Command): """open a list of active buffers""" - def __init__(self, filtfun=lambda x: x, **kwargs): + def __init__(self, filtfun=lambda x: True, **kwargs): """ :param filtfun: filter to apply to displayed list :type filtfun: callable (str->bool) @@ -550,45 +551,62 @@ def apply(self, ui): @registerCommand(MODE, 'taglist', arguments=[ + (['--global'], {'action': 'store_true', + 'help': 'list all tags globally instead of just those from the buffer', + 'dest': 'globally'}), (['--tags'], {'nargs': '+', 'help': 'tags to display'}), + (['match'], {'nargs': '?', + 'help': 'regular expression to match tags against'}), ]) class TagListCommand(Command): """opens taglist buffer""" - def __init__(self, filtfun=lambda x: x, tags=None, **kwargs): + + def __init__(self, filtfun=lambda x: True, tags=None, match=None, globally=False, **kwargs): """ :param filtfun: filter to apply to displayed list :type filtfun: callable (str->bool) + :param match: regular expression to match tags against + :type match: string + :param globally: list all tags globally instead of just those from the buffer + :type globally: bool """ - self.filtfun = filtfun + if match: + pattern = re.compile(match) + self.filtfun = lambda x: pattern.search(x) is not None + else: + self.filtfun = filtfun + self.globally = globally + self.match = match self.tags = tags Command.__init__(self, **kwargs) def apply(self, ui): - tags = self.tags or ui.dbman.get_all_tags() - blists = ui.get_buffers_of_type(buffers.TagListBuffer) - if blists: - buf = blists[0] - buf.tags = tags - buf.rebuild() - ui.buffer_focus(buf) - else: - ui.buffer_open(buffers.TagListBuffer(ui, tags, self.filtfun)) + querystring = None + if self.tags: + tags = self.tags + elif (not self.globally) and isinstance(ui.current_buffer, buffers.SearchBuffer): + tags = ui.dbman.collect_tags(ui.current_buffer.querystring) + querystring = ui.current_buffer.querystring + elif (not self.globally) and isinstance(ui.current_buffer, buffers.ThreadBuffer): + tags = list(ui.current_buffer.thread.get_tags()) + querystring = 'thread:%s' % ui.current_buffer.thread.get_thread_id() + else: # self.globally or otherBuffer + tags = ui.dbman.get_all_tags() + ui.buffer_open(buffers.TagListBuffer( + ui, tags, self.filtfun, querystring, self.match)) @registerCommand(MODE, 'namedqueries') class NamedQueriesCommand(Command): """opens named queries buffer""" - def __init__(self, filtfun=bool, **kwargs): + def __init__(self, **kwargs): """ - :param filtfun: filter to apply to displayed list - :type filtfun: callable (str->bool) """ - self.filtfun = filtfun Command.__init__(self, **kwargs) def apply(self, ui): - ui.buffer_open(buffers.NamedQueriesBuffer(ui, self.filtfun)) + ui.buffer_open(buffers.NamedQueriesBuffer(ui)) @registerCommand(MODE, 'flush') diff --git a/alot/commands/taglist.py b/alot/commands/taglist.py index f5e8af73b..f257efd2b 100644 --- a/alot/commands/taglist.py +++ b/alot/commands/taglist.py @@ -2,8 +2,11 @@ # Copyright © 2018 Dylan Baker # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file +import logging + from . import Command, registerCommand from .globals import SearchCommand +from .. import commands MODE = 'taglist' @@ -11,8 +14,71 @@ @registerCommand(MODE, 'select') class TaglistSelectCommand(Command): + """search for messages with selected tag within original buffer""" + async def apply(self, ui): + try: + tagstring = ui.current_buffer.get_selected_tag() + except AttributeError: + logging.debug("taglist select without tag selection") + return + if ' ' in tagstring: + tagstring = '"%s"' % tagstring + querystring = ui.current_buffer.querystring + if querystring: + fullquerystring = '(%s) AND tag:%s' % (querystring, tagstring) + else: + fullquerystring = 'tag:%s' % tagstring + cmd = SearchCommand(query=[fullquerystring]) + await ui.apply_command(cmd) + + +@registerCommand(MODE, 'globalselect') +class TaglistGlobalSelectCommand(Command): + """search for messages with selected tag""" async def apply(self, ui): - tagstring = ui.current_buffer.get_selected_tag() - cmd = SearchCommand(query=['tag:"%s"' % tagstring]) + try: + tagstring = ui.current_buffer.get_selected_tag() + except AttributeError: + logging.debug("taglist globalselect without tag selection") + return + if ' ' in tagstring: + tagstring = '"%s"' % tagstring + cmd = SearchCommand(query=['tag:%s' % tagstring]) await ui.apply_command(cmd) + + +@registerCommand(MODE, 'untag') +class UntagCommand(Command): + + """remove selected tag from all messages within original buffer""" + async def apply(self, ui): + taglistbuffer = ui.current_buffer + taglinewidget = taglistbuffer.get_selected_tagline() + try: + tag = taglistbuffer.get_selected_tag() + except AttributeError: + logging.debug("taglist untag without tag selection") + return + tagstring = 'tag:"%s"' % tag + querystring = taglistbuffer.querystring + if querystring: + fullquerystring = '(%s) AND %s' % (querystring, tagstring) + else: + fullquerystring = tagstring + + def refresh(): + if taglinewidget in taglistbuffer.taglist: + taglistbuffer.taglist.remove(taglinewidget) + if tag in taglistbuffer.tags: + taglistbuffer.tags.remove(tag) + taglistbuffer.rebuild() + ui.update() + + try: + ui.dbman.untag(fullquerystring, [tag]) + except DatabaseROError: + ui.notify('index in read-only mode', priority='error') + return + + await ui.apply_command(commands.globals.FlushCommand(callback=refresh)) diff --git a/alot/db/manager.py b/alot/db/manager.py index 247f28a91..a2c09436b 100644 --- a/alot/db/manager.py +++ b/alot/db/manager.py @@ -238,6 +238,16 @@ def count_messages(self, querystring): return db.count_messages(querystring, exclude_tags=settings.get('exclude_tags')) + def collect_tags(self, querystring): + """returns tags of messages that match `querystring`""" + db = Database(path=self.path, mode=Database.MODE.READ_ONLY) + tagset = notmuch2._tags.ImmutableTagSet( + db.messages(querystring, + exclude_tags=settings.get('exclude_tags')), + '_iter_p', + notmuch2.capi.lib.notmuch_messages_collect_tags) + return [t for t in tagset] + def count_threads(self, querystring): """returns number of threads that match `querystring`""" db = Database(path=self.path, mode=Database.MODE.READ_ONLY) diff --git a/alot/defaults/alot.rc.spec b/alot/defaults/alot.rc.spec index 2d28ad391..c092900d2 100644 --- a/alot/defaults/alot.rc.spec +++ b/alot/defaults/alot.rc.spec @@ -155,9 +155,14 @@ thread_statusbar = mixed_list(string, string, default=list('[{buffer_no}: thread # Format of the status-bar in taglist mode. # This is a pair of strings to be left and right aligned in the status-bar. -# These strings may contain variables listed at :ref:`bufferlist_statusbar ` -# that will be substituted accordingly. -taglist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: taglist]','{input_queue} total messages: {total_messages}')) +# Apart from the global variables listed at :ref:`bufferlist_statusbar ` +# these strings may contain variables: +# +# * `{querystring}`: search string +# * `{match}`: match expression +# * `{result_count}`: number of matching messages +# * `{result_count_positive}`: 's' if result count is greater than 0. +taglist_statusbar = mixed_list(string, string, default=list('[{buffer_no}: taglist] for "{querystring}" matching "{match}"','{input_queue} {result_count} of {total_messages} messages')) # Format of the status-bar in named query list mode. # This is a pair of strings to be left and right aligned in the status-bar. diff --git a/alot/defaults/default.bindings b/alot/defaults/default.bindings index ffca1bdc9..6688ef1bc 100644 --- a/alot/defaults/default.bindings +++ b/alot/defaults/default.bindings @@ -57,6 +57,7 @@ q = exit [taglist] enter = select + meta enter = globalselect [namedqueries] enter = select diff --git a/alot/widgets/globals.py b/alot/widgets/globals.py index 9de9cbbbc..68775b12b 100644 --- a/alot/widgets/globals.py +++ b/alot/widgets/globals.py @@ -287,14 +287,21 @@ class TagWidget(urwid.AttrMap): :type tag: str """ - def __init__(self, tag, fallback_normal=None, fallback_focus=None): + def __init__(self, tag, fallback_normal=None, fallback_focus=None, + amend=False): self.tag = tag representation = settings.get_tagstring_representation(tag, fallback_normal, fallback_focus) self.translated = representation['translated'] self.hidden = self.translated == '' - self.txt = urwid.Text(self.translated, wrap='clip') + txt = self.translated + if amend: + if self.hidden: + txt += self.tag + ' [hidden]' + elif self.translated is not self.tag: + txt += ' (%s)' % self.tag + self.txt = urwid.Text(txt, wrap='clip') self.__hash = hash((self.translated, self.txt)) normal_att = representation['normal'] focus_att = representation['focussed'] diff --git a/docs/source/configuration/alotrc_table b/docs/source/configuration/alotrc_table index e856fcb36..09ce486b8 100644 --- a/docs/source/configuration/alotrc_table +++ b/docs/source/configuration/alotrc_table @@ -635,11 +635,16 @@ Format of the status-bar in taglist mode. This is a pair of strings to be left and right aligned in the status-bar. - These strings may contain variables listed at :ref:`bufferlist_statusbar ` - that will be substituted accordingly. + Apart from the global variables listed at :ref:`bufferlist_statusbar ` + these strings may contain variables: + + * `{querystring}`: search string + * `{match}`: match expression + * `{result_count}`: number of matching messages + * `{result_count_positive}`: 's' if result count is greater than 0. :type: mixed_list - :default: [{buffer_no}: taglist], {input_queue} total messages: {total_messages} + :default: [{buffer_no}: taglist] for "{querystring}" matching "{match}", {input_queue} {result_count} of {total_messages} messages .. _template-dir: diff --git a/docs/source/usage/modes/global.rst b/docs/source/usage/modes/global.rst index 955b6ffe1..52e29de85 100644 --- a/docs/source/usage/modes/global.rst +++ b/docs/source/usage/modes/global.rst @@ -226,6 +226,10 @@ The following commands are available globally: opens taglist buffer + argument + regular expression to match tags against + optional arguments + :---global: list all tags globally instead of just those from the buffer :---tags: tags to display diff --git a/docs/source/usage/modes/taglist.rst b/docs/source/usage/modes/taglist.rst index 08917bb2b..4142e6e00 100644 --- a/docs/source/usage/modes/taglist.rst +++ b/docs/source/usage/modes/taglist.rst @@ -5,10 +5,24 @@ Commands in 'taglist' mode -------------------------- The following commands are available in taglist mode: +.. _cmd.taglist.globalselect: + +.. describe:: globalselect + + search for messages with selected tag + + .. _cmd.taglist.select: .. describe:: select - search for messages with selected tag + search for messages with selected tag within original buffer + + +.. _cmd.taglist.untag: + +.. describe:: untag + + remove selected tag from all messages within original buffer