diff --git a/README.md b/README.md index b2fb11c..cff6754 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ def snooze(days, mode='tag'): import datetime d = datetime.date.today() + datetime.timedelta(days=days) def f(search): - search.tag_thread(f'-inbox -unread +zzz-{d}', mode) + search.tag_thread(tags_remove=['inbox', 'unread'], tags_add=[f'zzz-{d}'], mode=mode) return f dodo.keymap.search_keymap['z z'] = ("snooze for 1 day", snooze(days=1)) diff --git a/dodo/app.py b/dodo/app.py index b02fdf8..38abd27 100644 --- a/dodo/app.py +++ b/dodo/app.py @@ -220,6 +220,10 @@ def open_tags(self, keep_open: bool=False) -> None: p = tag.TagPanel(self, keep_open) self.add_panel(p) + + def open_tags_narrowed(self, filter_tags) -> None: + p = tag.TagPanel(self, filter_tags=filter_tags) + self.add_panel(p) def search_bar(self) -> None: """Open command bar for searching""" @@ -230,8 +234,8 @@ def tag_bar(self, mode: Literal['tag', 'tag marked']='tag') -> None: def callback(tag_expr: str) -> None: w = self.tabs.currentWidget() if w and isinstance(w, panel.Panel): - if isinstance(w, search.SearchPanel): w.tag_thread(tag_expr, mode) - elif isinstance(w, thread.ThreadPanel): w.tag_message(tag_expr) + if isinstance(w, search.SearchPanel): w.tag_thread(tag_expr=tag_expr, mode=mode) + elif isinstance(w, thread.ThreadPanel): w.tag_message(tag_expr=tag_expr) w.refresh() self.command_bar.open(mode, callback) @@ -279,6 +283,13 @@ def refresh_panels(self) -> None: w = self.tabs.currentWidget() if w and isinstance(w, panel.Panel): w.refresh() + def refresh_title(self) -> None: + """Refresh the title of the current panel""" + i = self.tabs.currentIndex() + w = self.tabs.widget(i) + if isinstance(w, panel.Panel): + self.tabs.setTabText(i, w.title()) + def prompt_quit(self) -> None: """A 'soft' quit function, which gives each open tab the opportunity to prompt the user and possible cancel closing.""" diff --git a/dodo/helpwindow.py b/dodo/helpwindow.py index e79b16a..329c47e 100644 --- a/dodo/helpwindow.py +++ b/dodo/helpwindow.py @@ -41,6 +41,7 @@ def __init__(self, parent: Optional[QWidget]=None): maps = [ ("Global", keymap.global_keymap), ("Search view", keymap.search_keymap), + ("Tag view", keymap.tag_keymap), ("Thread view", keymap.thread_keymap), ("Compose view", keymap.compose_keymap), ("Command bar", keymap.command_bar_keymap), diff --git a/dodo/keymap.py b/dodo/keymap.py index fd15f19..bce1b65 100644 --- a/dodo/keymap.py +++ b/dodo/keymap.py @@ -52,7 +52,7 @@ 'C-d': ('down 20', lambda p: [p.next_thread() for i in range(20)]), 'C-u': ('up 20', lambda p: [p.previous_thread() for i in range(20)]), '': ('open thread', lambda p: p.open_current_thread()), - 'a': ('tag -inbox -unread', lambda p: p.tag_thread('-inbox -unread')), + 'a': ('tag -inbox -unread', lambda p: p.tag_thread(tags_remove=['inbox', 'unread'])), 'u': ('toggle unread', lambda p: p.toggle_thread_tag('unread')), 'f': ('toggle flagged', lambda p: p.toggle_thread_tag('flagged')), '': ('toggle marked', lambda p: [p.toggle_thread_tag('marked'), p.next_thread()]), @@ -72,7 +72,11 @@ 'G': ('last tag', lambda p: p.last_tag()), 'C-d': ('down 20', lambda p: [p.next_tag() for i in range(20)]), 'C-u': ('up 20', lambda p: [p.previous_tag() for i in range(20)]), - '': ('search tag', lambda p: p.search_current_tag()), + '': ('search with selected tag', lambda p: p.search_current_tag()), + '': ('search without selected tag', lambda p: p.search_current_view()), + 'N': ('narrow to tag in new panel', lambda p: p.open_current_tag()), + 'n': ('narrow to tag', lambda p: p.narrow_current_tag()), + 'b': ('undo last narrow to tag', lambda p: p.undo_narrow_tag()), } """The local keymap for the tag panel diff --git a/dodo/search.py b/dodo/search.py index 5762b02..360feec 100644 --- a/dodo/search.py +++ b/dodo/search.py @@ -24,9 +24,11 @@ from PyQt6.QtGui import QFont, QColor import subprocess import json +import shlex from . import app from . import settings +from . import util from . import keymap from . import thread from . import panel @@ -94,7 +96,13 @@ def data(self, index: QModelIndex, role: int=Qt.ItemDataRole.DisplayRole) -> Any tag_icons = [] for t in thread_d['tags']: # don't bother showing TAG if it is in settings.hide_tags or the query is specifically 'tag:TAG' - if t not in settings.hide_tags and self.q != 'tag:' + t: + tag_in_q = False + for q_part in shlex.split(self.q): + # Example: shlex.split('asdf tag:blah AND tag:"blah blah"') => ['asdf', 'tag:blah', 'AND', 'tag:blah blah'] + if q_part.startswith('tag:') and t == q_part.lstrip('tag:'): + tag_in_q = True + break + if t not in settings.hide_tags and not tag_in_q: tag_icons.append(settings.tag_icons[t] if t in settings.tag_icons else f'[{t}]') return ' '.join(tag_icons) elif role == Qt.ItemDataRole.FontRole: @@ -257,27 +265,28 @@ def toggle_thread_tag(self, tag: str) -> None: thread = self.model.thread_json(self.tree.currentIndex()) if thread: if tag in thread['tags']: - tag_expr = '-' + tag + self.tag_thread(tags_remove=[tag]) else: - tag_expr = '+' + tag - self.tag_thread(tag_expr) + self.tag_thread(tags_add=[tag]) - - def tag_thread(self, tag_expr: str, mode: Literal['tag', 'tag marked']='tag') -> None: - """Apply the given tag expression to the selected thread + def tag_thread(self, tag_expr: str=None, tags_add: list=None, tags_remove: list=None, mode: Literal['tag', 'tag marked']='tag') -> None: + """Apply the given tag expression or lists of tags to add or remove to the selected thread A tag expression is a string consisting of one more statements of the form "+TAG" - or "-TAG" to add or remove TAG, respectively, separated by whitespace.""" + or "-TAG" to add or remove TAG, respectively, separated by whitespace. + + Tags containing spaces or special characters must be double quoted within the tag expression. + Alternatively, use the lists tags_add and tags_remove with the unquoted values. + Programatic uses of tags should always use the tags_add and tags_remove lists.""" - if not ('+' in tag_expr or '-' in tag_expr): - tag_expr = '+' + tag_expr + tag_args = util.format_tag_args(tag_expr, tags_add, tags_remove) if mode == 'tag': thread_id = self.model.thread_id(self.tree.currentIndex()) if thread_id: - subprocess.run(['notmuch', 'tag'] + tag_expr.split() + ['--', 'thread:' + thread_id]) + subprocess.run(['notmuch', 'tag'] + tag_args + ['--', 'thread:' + thread_id]) elif mode == 'tag marked': - subprocess.run(['notmuch', 'tag'] + tag_expr.split() + ['-marked','--', f'tag:marked AND ({self.q})']) + subprocess.run(['notmuch', 'tag'] + tag_args + ['-marked','--', f'tag:marked AND ({self.q})']) self.app.refresh_panels() diff --git a/dodo/tag.py b/dodo/tag.py index d1d01d7..c7859a7 100644 --- a/dodo/tag.py +++ b/dodo/tag.py @@ -40,21 +40,26 @@ def __init__(self) -> None: super().__init__() self.refresh() - def refresh(self) -> None: + def refresh(self, filter_tags: List[str]=None) -> None: """Refresh the model by (re-) running "notmuch search".""" self.beginResetModel() - r = subprocess.run(['notmuch', 'search', '--output=tags', '*'], - stdout=subprocess.PIPE) + tag_query = ' AND '.join([f'tag:"{tag}"' for tag in filter_tags]) if filter_tags else '' + search_args = [tag_query] if tag_query else ['*'] + cmd = ['notmuch', 'search', '--output=tags'] + search_args + r = subprocess.run(cmd, stdout=subprocess.PIPE) tag_str = r.stdout.decode('utf-8') self.d: List[Tuple[str,str,str]] = [] for t in tag_str.splitlines(): - r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', 'tag:'+t], - stdout=subprocess.PIPE) + if filter_tags and t in filter_tags: + continue + extra_query = (' AND ' + tag_query) if tag_query else '' + cmd = ['notmuch', 'count', '--output=threads', '--', f'tag:"{t}"' + extra_query] + r1 = subprocess.run(cmd, stdout=subprocess.PIPE) c = r1.stdout.decode('utf-8').strip() - r1 = subprocess.run(['notmuch', 'count', '--output=threads', '--', f'tag:{t} AND tag:unread'], - stdout=subprocess.PIPE) + cmd = ['notmuch', 'count', '--output=threads', '--', f'tag:"{t}" AND tag:unread' + extra_query] + r1 = subprocess.run(cmd, stdout=subprocess.PIPE) cu = r1.stdout.decode('utf-8').strip() self.d.append((t, cu, c)) @@ -135,7 +140,7 @@ def parent(self, child: QModelIndex=None) -> Any: class TagPanel(panel.Panel): """A panel showing all tags""" - def __init__(self, a: app.Dodo, keep_open: bool=False, parent: Optional[QWidget]=None): + def __init__(self, a: app.Dodo, keep_open: bool=False, filter_tags: List[str]=None, parent: Optional[QWidget]=None): super().__init__(a, keep_open, parent) self.set_keymap(keymap.tag_keymap) self.tree = QTreeView() @@ -153,23 +158,43 @@ def __init__(self, a: app.Dodo, keep_open: bool=False, parent: Optional[QWidget] if self.tree.model().rowCount() > 0: self.tree.setCurrentIndex(self.tree.model().index(0,0)) + self.filter_tags = filter_tags or [] + self.last_tag_row = [] + self.try_narrowing = False + def refresh(self) -> None: """Refresh the search listing and restore the selection, if possible.""" current = self.tree.currentIndex() - self.model.refresh() + self.model.refresh(self.filter_tags) + if self.try_narrowing: + self.try_narrowing = False + # If we're narrowing and the result is zero tags, don't do the narrow and revert to previous state + if self.model.num_tags() == 0: + last_tag = self.filter_tags.pop() + last_row = self.last_tag_row.pop() + self.model.refresh(self.filter_tags) + self.select_tag_or_nearby_row(last_tag, last_row) + + # also may be sensible to open the search after narrowing as far as possible + self.search_current_tag() + return + if current.row() >= self.model.num_tags(): self.last_tag() else: self.tree.setCurrentIndex(current) + self.app.refresh_title() super().refresh() def title(self) -> str: - """Return constant 'tags'""" - - return 'tags' + """Return constant 'tags' and the list of narrowed tags""" + t = 'tags' + for tag in self.filter_tags: + t += ' > ' + tag + return t def next_tag(self) -> None: """Select the next tag @@ -207,12 +232,62 @@ def search_current_tag(self) -> None: """Open a new search panel for the selected tag""" tag = self.model.tag(self.tree.currentIndex()) - if tag: - self.app.open_search('tag:' + tag) + tags = [tag] + self.filter_tags + tag_query = ' AND '.join([f'tag:"{tag}"' for tag in tags]) + if tags: + self.app.open_search(tag_query) + def search_current_view(self) -> None: + """Opens a new search panel for the current narrowed view, not including current selection""" + tags = self.filter_tags + tag_query = ' AND '.join([f'tag:"{tag}"' for tag in tags]) + if tags: + self.app.open_search(tag_query) + else: + self.app.open_search("*") + def narrow_current_tag(self) -> None: + """Refresh the view narrowing to the selected tag""" - - - + ix = self.tree.currentIndex() + tag = self.model.tag(ix) + if tag: + self.filter_tags.append(tag) + self.last_tag_row.append(ix.row()) + self.try_narrowing = True + self.refresh() + + def open_current_tag(self) -> None: + """Open a new tag panel narrowing to the selected tag""" + ix = self.tree.currentIndex() + tag = self.model.tag(ix) + if tag: + self.app.open_tags_narrowed(filter_tags=[tag] + self.filter_tags) + + def undo_narrow_tag(self) -> None: + if self.filter_tags: + last_tag = self.filter_tags.pop() + last_row = self.last_tag_row.pop() + self.refresh() + self.select_tag_or_nearby_row(last_tag, last_row) + + def select_tag_or_nearby_row(self, last_tag, last_row): + # select row if it matches the tag + row = min((last_row, self.model.num_tags() - 1)) + ix = self.tree.model().index(row, 0) + if self.model.tag(ix) == last_tag: + self.tree.setCurrentIndex(ix) + return + + # else index changed so find the matching tag + for row in range(self.model.num_tags()): + ix = self.tree.model().index(row, 0) + if self.model.tag(ix) == last_tag: + self.tree.setCurrentIndex(ix) + return + + # else go back to the nearest index + row = min((last_row, self.model.num_tags() - 1)) + ix = self.tree.model().index(row, 0) + self.tree.setCurrentIndex(ix) diff --git a/dodo/thread.py b/dodo/thread.py index f7d2be9..01f567d 100644 --- a/dodo/thread.py +++ b/dodo/thread.py @@ -420,7 +420,7 @@ def show_message(self, i: int=-1) -> None: m = self.model.message_at(self.current_message) if 'unread' in m['tags']: # this might change the filename, so we should refresh the model - self.tag_message('-unread') + self.tag_message(tags_remove=['unread']) self.refresh() m = self.model.message_at(self.current_message) @@ -476,22 +476,24 @@ def toggle_message_tag(self, tag: str) -> None: m = self.model.message_at(self.current_message) if m: if tag in m['tags']: - tag_expr = '-' + tag + self.tag_message(tags_remove=[tag]) else: - tag_expr = '+' + tag - self.tag_message(tag_expr) + self.tag_message(tags_add=[tag]) - def tag_message(self, tag_expr: str) -> None: - """Apply the given tag expression to the current message + def tag_message(self, tag_expr: str=None, tags_add: list=None, tags_remove: list=None) -> None: + """Apply the given tag expression or lists of tags to add or remove to the selected thread A tag expression is a string consisting of one more statements of the form "+TAG" - or "-TAG" to add or remove TAG, respectively, separated by whitespace.""" + or "-TAG" to add or remove TAG, respectively, separated by whitespace. + + Tags containing spaces or special characters must be double quoted within the tag expression. + Alternatively, use the lists tags_add and tags_remove with the unquoted values. + Programatic uses of tags should always use the tags_add and tags_remove lists.""" m = self.model.message_at(self.current_message) if m: - if not ('+' in tag_expr or '-' in tag_expr): - tag_expr = '+' + tag_expr - r = subprocess.run(['notmuch', 'tag'] + tag_expr.split() + ['--', 'id:' + m['id']], + tag_args = util.format_tag_args(tag_expr, tags_add, tags_remove) + r = subprocess.run(['notmuch', 'tag'] + tag_args + ['--', 'id:' + m['id']], stdout=subprocess.PIPE) self.app.refresh_panels() diff --git a/dodo/util.py b/dodo/util.py index f28852c..04f28fe 100644 --- a/dodo/util.py +++ b/dodo/util.py @@ -30,6 +30,7 @@ import textwrap from bleach.sanitizer import Cleaner from bleach.linkifier import Linker +import shlex from . import settings @@ -515,3 +516,24 @@ def key_string(e: QKeyEvent) -> str: # print(cmd) return cmd + +def format_tag_args(tag_expr: str=None, tags_add: list=None, tags_remove: list=None) -> list: + tag_args = [] + if tag_expr: + # This is something typed by the user in the search or thread panel + # assume that they use whitepace and quotes correctly + for tag in shlex.split(tag_expr): + if tag[0] not in ['+', '-']: + tag = '+' + tag + if ' ' in tag: + # For example: shlex.split('+asdf +"asdf asdf"') => ['+asdf', '+asdf asdf'] + # shelx.split removed quotes, so add them back + tag = tag[0] + f'"{tag[1:]}"' + tag_args.append(tag) + if tags_add: + for tag in tags_add: + tag_args.append(f'+"{tag}"') + if tags_remove: + for tag in tags_remove: + tag_args.append(f'-"{tag}"') + return tag_args