From d25d788bdcf91f4066ae8e80ef7aebe85213d4d3 Mon Sep 17 00:00:00 2001 From: Anton Khirnov Date: Sat, 8 Feb 2020 13:56:56 +0100 Subject: thread: drop the use of urwidtrees Their API is misdesigned - forces the use of trees for nontree objects and mixes data relationships with display properties. The result is a mess that is hard to understand/maintain/extend. Replace the use of urwidtrees with urwid Pile and ListBox. This temporarily removes tree-style indentation and decorations for thread buffers. That will be reimplemented in following commits. --- .travis.yml | 1 - alot/buffers/thread.py | 243 ++++++++++----------------- alot/commands/thread.py | 47 ++---- alot/db/message.py | 16 +- alot/db/thread.py | 25 +-- alot/widgets/thread.py | 388 ++++++++++++++++--------------------------- docs/source/conf.py | 1 - docs/source/installation.rst | 5 +- setup.py | 1 - 9 files changed, 274 insertions(+), 453 deletions(-) diff --git a/.travis.yml b/.travis.yml index d207815f..20e92f18 100644 --- a/.travis.yml +++ b/.travis.yml @@ -73,7 +73,6 @@ install: ### PYHON DEPS ### ################################################# - pip install urwid - - pip install urwidtrees - pip install configobj - pip install gpg - pip install twisted diff --git a/alot/buffers/thread.py b/alot/buffers/thread.py index 6434bb04..bc467ded 100644 --- a/alot/buffers/thread.py +++ b/alot/buffers/thread.py @@ -5,11 +5,10 @@ import asyncio import urwid import logging -from urwidtrees import ArrowTree, TreeBox, NestedTree from .buffer import Buffer from ..settings.const import settings -from ..widgets.thread import ThreadTree +from ..widgets.thread import MessageWidget from .. import commands from ..db.errors import NonexistantObjectError @@ -27,16 +26,15 @@ class ThreadBuffer(Buffer): :type thread: :class:`~alot.db.Thread` """ self.thread = thread - self.message_count = thread.total_messages self._indent_width = settings.get('thread_indent_replies') self.rebuild() - Buffer.__init__(self, ui, self.body) + super().__init__(ui, self.body) def __str__(self): return '[thread] %s (%d message%s)' % (self.thread.subject, - self.message_count, - 's' * (self.message_count > 1)) + self.thread.total_messages, + 's' * (self.thread.total_messages > 1)) def translated_tags_str(self, intersection=False): tags = self.thread.get_tags(intersection=intersection) @@ -49,7 +47,7 @@ class ThreadBuffer(Buffer): info['subject'] = self.thread.subject info['authors'] = self.thread.get_authors_string() info['tid'] = self.thread.id - info['message_count'] = self.message_count + info['message_count'] = self.thread.total_messages info['thread_tags'] = self.translated_tags_str() info['intersection_tags'] = self.translated_tags_str(intersection=True) return info @@ -59,78 +57,37 @@ class ThreadBuffer(Buffer): self.thread.refresh() except NonexistantObjectError: self.body = urwid.SolidFill() - self.message_count = 0 return - self._tree = ThreadTree(self.thread) + self._msg_walker = urwid.SimpleFocusListWalker( + [MessageWidget(msg, idx & 1) for (idx, msg) in + enumerate(self.thread.message_list)]) - # define A to be the tree to be wrapped by a NestedTree and displayed. - # We wrap the thread tree into an ArrowTree for decoration if - # indentation was requested and otherwise use it as is. - if self._indent_width == 0: - A = self._tree - else: - # we want decoration. - bars_att = settings.get_theming_attribute('thread', 'arrow_bars') - # only add arrow heads if there is space (indent > 1). - heads_char = None - heads_att = None - if self._indent_width > 1: - heads_char = '➤' - heads_att = settings.get_theming_attribute('thread', - 'arrow_heads') - A = ArrowTree( - self._tree, - indent=self._indent_width, - childbar_offset=0, - arrow_tip_att=heads_att, - arrow_tip_char=heads_char, - arrow_att=bars_att) - - self._nested_tree = NestedTree(A, interpret_covered=True) - self.body = TreeBox(self._nested_tree) - - self.message_count = self.thread.total_messages - - def get_selected_mid(self): - """Return Message ID of focussed message.""" - return self.body.get_focus()[1][0] + self.body = urwid.ListBox(self._msg_walker) def get_selected_message_position(self): """Return position of focussed message in the thread tree.""" - return self._sanitize_position((self.get_selected_mid(),)) + return self.body.focus_position - def get_selected_messagetree(self): - """Return currently focussed :class:`MessageTree`.""" - return self._nested_tree[self.body.get_focus()[1][:1]] + def get_selected_message_widget(self): + """Return currently focused :class:`MessageWidget`.""" + return self.body.focus def get_selected_message(self): """Return focussed :class:`~alot.db.message.Message`.""" - return self.get_selected_messagetree()._message - - def get_messagetree_positions(self): - """ - Return a Generator to walk through all positions of - :class:`MessageTree` in the :class:`ThreadTree` of this buffer. - """ - return [(pos,) for pos in self._tree.positions()] + return self.get_selected_message_widget().get_message() - def messagetrees(self): + def message_widgets(self): """ - returns a Generator of all :class:`MessageTree` in the - :class:`ThreadTree` of this buffer. + Iterate over all the message widgets in this buffer """ - for pos in self._tree.positions(): - yield self._tree[pos] - - def refresh(self): - """Refresh and flush caches of Thread tree.""" - self.body.refresh() + for w in self._msg_walker: + yield w # needed for ui.get_deep_focus.. def get_focus(self): "Get the focus from the underlying body widget." - return self.body.get_focus() + return self.body.focus def set_focus(self, pos): "Set the focus in the underlying body widget." @@ -139,145 +96,119 @@ class ThreadBuffer(Buffer): def focus_first(self): """set focus to first message of thread""" - self.body.set_focus(self._nested_tree.root) + self.set_focus(0) def focus_last(self): - self.body.set_focus(next(self._nested_tree.positions(reverse=True))) - - def _sanitize_position(self, pos): - return self._nested_tree._sanitize_position(pos, - self._nested_tree._tree) + self.set_focus(max(self.thread.total_messages - 1), 0) def focus_selected_message(self): - """focus the summary line of currently focussed message""" - # move focus to summary (root of current MessageTree) + """focus the summary line of currently focused message""" self.set_focus(self.get_selected_message_position()) def focus_parent(self): - """move focus to parent of currently focussed message""" - mid = self.get_selected_mid() - newpos = self._tree.parent_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - self.body.set_focus(newpos) + """move focus to parent of currently focused message""" + pos = self.get_selected_message_position() + cur_depth = self.get_selected_message().depth + + for idx in reversed(range(0, pos)): + msg = self.thread.message_list[idx] + if msg.depth < cur_depth: + self.set_focus(idx) + break def focus_first_reply(self): - """move focus to first reply to currently focussed message""" - mid = self.get_selected_mid() - newpos = self._tree.first_child_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - self.body.set_focus(newpos) + """move focus to first reply to currently focused message""" + msg = self.get_selected_message() + if len(msg.replies) > 0: + new_focus = self.thread.message_list.index(msg.replies[0]) + self.set_focus(new_focus) def focus_last_reply(self): - """move focus to last reply to currently focussed message""" - mid = self.get_selected_mid() - newpos = self._tree.last_child_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - self.body.set_focus(newpos) + """move focus to last reply to currently focused message""" + msg = self.get_selected_message() + if len(msg.replies) > 0: + new_focus = self.thread.message_list.index(msg.replies[-1]) + self.set_focus(new_focus) def focus_next_sibling(self): """focus next sibling of currently focussed message in thread tree""" - mid = self.get_selected_mid() - newpos = self._tree.next_sibling_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - self.body.set_focus(newpos) + pos_next = self.get_selected_message_position() + 1 + depth = self.get_selected_message().depth + if (pos_next < self.thread.total_messages and + self.thread.message_list[pos_next].depth == depth): + self.set_focus(pos_next) def focus_prev_sibling(self): """ focus previous sibling of currently focussed message in thread tree """ - mid = self.get_selected_mid() - localroot = self._sanitize_position((mid,)) - if localroot == self.get_focus()[1]: - newpos = self._tree.prev_sibling_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - else: - newpos = localroot - if newpos is not None: - self.body.set_focus(newpos) + pos_next = self.get_selected_message_position() - 1 + depth = self.get_selected_message().depth + if (pos_next < self.thread.total_messages and + self.thread.message_list[pos_next].depth == depth): + self.set_focus(pos_next) def focus_next(self): """focus next message in depth first order""" - mid = self.get_selected_mid() - newpos = self._tree.next_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - self.body.set_focus(newpos) + next_focus = self.get_selected_message_position() + 1 + if next_focus >= 0 and next_focus < self.thread.total_messages: + self.set_focus(next_focus) def focus_prev(self): """focus previous message in depth first order""" - mid = self.get_selected_mid() - localroot = self._sanitize_position((mid,)) - if localroot == self.get_focus()[1]: - newpos = self._tree.prev_position(mid) - if newpos is not None: - newpos = self._sanitize_position((newpos,)) - else: - newpos = localroot - if newpos is not None: - self.body.set_focus(newpos) + next_focus = self.get_selected_message_position() - 1 + if next_focus >= 0 and next_focus < self.thread.total_messages: + self.set_focus(next_focus) - def focus_property(self, prop, direction): + def _focus_property(self, prop, direction): """does a walk in the given direction and focuses the - first message tree that matches the given property""" - newpos = self.get_selected_mid() - newpos = direction(newpos) - while newpos is not None: - MT = self._tree[newpos] - if prop(MT): - newpos = self._sanitize_position((newpos,)) - self.body.set_focus(newpos) + first message that matches the given property""" + cur_pos = self.get_selected_message_position() + + if direction > 0: + walk = range(cur_pos + 1, self.thread.total_messages) + else: + walk = reversed(range(0, cur_pos)) + + for pos in walk: + if prop(self._msg_walker[pos]): + self.set_focus(pos) break - newpos = direction(newpos) def focus_next_matching(self, querystring): """focus next matching message in depth first order""" - self.focus_property(lambda x: x._message.matches(querystring), - self._tree.next_position) + self._focus_property(lambda x: x.get_message().matches(querystring), 1) def focus_prev_matching(self, querystring): """focus previous matching message in depth first order""" - self.focus_property(lambda x: x._message.matches(querystring), - self._tree.prev_position) + self._focus_property(lambda x: x.get_message().matches(querystring), -1) def focus_next_unfolded(self): """focus next unfolded message in depth first order""" - self.focus_property(lambda x: not x.is_collapsed(x.root), - self._tree.next_position) + self._focus_property(lambda x: x.display_content, 1) def focus_prev_unfolded(self): """focus previous unfolded message in depth first order""" - self.focus_property(lambda x: not x.is_collapsed(x.root), - self._tree.prev_position) + self._focus_property(lambda x: x.display_content, -1) def expand(self, msgpos): """expand message at given position""" - MT = self._tree[msgpos] - MT.expand(MT.root) - - def messagetree_at_position(self, pos): - """get :class:`MessageTree` for given position""" - return self._tree[pos[0]] + self.body.body[msgpos].expand() def expand_all(self): """expand all messages in thread""" - for MT in self.messagetrees(): - MT.expand(MT.root) + for msg in self.message_widgets(): + msg.expand() def collapse(self, msgpos): """collapse message at given position""" - MT = self._tree[msgpos] - MT.collapse(MT.root) + self.body.body[msgpos].collapse() self.focus_selected_message() def collapse_all(self): """collapse all messages in thread""" - for MT in self.messagetrees(): - MT.collapse(MT.root) + for msg in self.message_widgets(): + msg.collapse() self.focus_selected_message() def unfold_matching(self, querystring, focus_first=True): @@ -289,14 +220,14 @@ class ThreadBuffer(Buffer): :param focus_first: set the focus to the first matching message :type focus_first: bool """ + focus_set = False first = None - for MT in self.messagetrees(): - msg = MT._message + for pos, msg_wgt in enumerate(self.message_widgets()): + msg = msg_wgt.get_message() if msg.matches(querystring): - MT.expand(MT.root) - if first is None: - first = (self._tree.position_of_messagetree(MT), MT.root) - self.body.set_focus(first) + msg_wgt.expand() + if focus_first and not focus_set: + self.set_focus(pos) + focus_set = True else: - MT.collapse(MT.root) - self.body.refresh() + msg_wgt.collapse() diff --git a/alot/commands/thread.py b/alot/commands/thread.py index f720e297..6ceaddd2 100644 --- a/alot/commands/thread.py +++ b/alot/commands/thread.py @@ -555,45 +555,40 @@ class ChangeDisplaymodeCommand(Command): logging.debug('matching lines %s...', self.query) if self.query is None: - messagetrees = [tbuffer.get_selected_messagetree()] + msg_wgts = [tbuffer.get_selected_message_widget()] else: - messagetrees = tbuffer.messagetrees() + msg_wgts = list(tbuffer.message_widgets()) if self.query != '*': def matches(msgt): msg = msgt.get_message() return msg.matches(self.query) - messagetrees = [m for m in messagetrees if matches(m)] + msg_wgts = [m for m in msg_wgts if matches(m)] - for mt in messagetrees: + for m in msg_wgts: # determine new display values for this message if self.visible == 'toggle': - visible = mt.is_collapsed(mt.root) + visible = not m.display_content else: visible = self.visible if self.raw == 'toggle': tbuffer.focus_selected_message() - raw = not mt.display_source if self.raw == 'toggle' else self.raw - all_headers = not mt.display_all_headers \ + raw = not m.display_source if self.raw == 'toggle' else self.raw + all_headers = not m.display_all_headers \ if self.all_headers == 'toggle' else self.all_headers # collapse/expand depending on new 'visible' value if visible is False: - mt.collapse(mt.root) + m.collapse() elif visible is True: # could be None - mt.expand(mt.root) + m.expand() tbuffer.focus_selected_message() # set new values in messagetree obj if raw is not None: - mt.display_source = raw + m.display_source = raw if all_headers is not None: - mt.display_all_headers = all_headers - mt.debug() - # let the messagetree reassemble itself - mt.reassemble() - # refresh the buffer (clears Tree caches etc) - tbuffer.refresh() + m.display_all_headers = all_headers @registerCommand(MODE, 'pipeto', arguments=[ @@ -1057,26 +1052,20 @@ class TagCommand(Command): async def apply(self, ui): tbuffer = ui.current_buffer if self.all: - messagetrees = tbuffer.messagetrees() + msg_wgts = list(tbuffer.message_widgets()) else: - messagetrees = [tbuffer.get_selected_messagetree()] - - def refresh_widgets(): - for mt in messagetrees: - mt.refresh() - tbuffer.refresh() + msg_wgts = [tbuffer.get_selected_message_widget()] tags = [t for t in self.tagsstring.split(',') if t] try: - for mt in messagetrees: + for mt in msg_wgts: m = mt.get_message() if self.action == 'add': - m.add_tags(tags, afterwards=refresh_widgets) + m.add_tags(tags) if self.action == 'set': - m.add_tags(tags, afterwards=refresh_widgets, - remove_rest=True) + m.add_tags(tags, remove_rest=True) elif self.action == 'remove': - m.remove_tags(tags, afterwards=refresh_widgets) + m.remove_tags(tags) elif self.action == 'toggle': to_remove = [] to_add = [] @@ -1086,7 +1075,7 @@ class TagCommand(Command): else: to_add.append(t) m.remove_tags(to_remove) - m.add_tags(to_add, afterwards=refresh_widgets) + m.add_tags(to_add) except DatabaseROError: ui.notify('index in read-only mode', priority='error') diff --git a/alot/db/message.py b/alot/db/message.py index f9e8e37b..62b85788 100644 --- a/alot/db/message.py +++ b/alot/db/message.py @@ -35,24 +35,28 @@ class Message: """value of the Message-Id header (str)""" id = None + """this message's depth in the thread tree""" + depth = None + """A list of replies to this message""" replies = None - def __init__(self, dbman, msg, thread, replies): + def __init__(self, dbman, thread, msg, depth): """ :param dbman: db manager that is used for further lookups :type dbman: alot.db.DBManager - :param msg: the wrapped message - :type msg: notmuch.database.Message :param thread: this messages thread :type thread: :class:`~alot.db.Thread` - :param replies: a list of replies to this message - :type replies alot.db.message.Message + :param msg: the wrapped message + :type msg: notmuch.database.Message + :param depth: depth of this message in the thread tree (0 for toplevel + messages, 1 for their replies etc.) + :type depth int """ self._dbman = dbman self.id = msg.get_message_id() self.thread = thread - self.replies = replies + self.depth = depth try: self.date = datetime.fromtimestamp(msg.get_date()) except ValueError: diff --git a/alot/db/thread.py b/alot/db/thread.py index 2792737c..a76997f8 100644 --- a/alot/db/thread.py +++ b/alot/db/thread.py @@ -40,7 +40,7 @@ class Thread: toplevel_messages = None """A list of ids of all messages in this thread in depth-first order""" - message_ids = None + message_list = None """A dict mapping Message-Id strings to Message instances""" messages = None @@ -58,7 +58,7 @@ class Thread: self._tags = set() self.toplevel_messages = [] - self.message_ids = [] + self.message_list = [] self.messages = {} self.refresh(thread) @@ -97,7 +97,7 @@ class Thread: self._tags = {t for t in thread.get_tags()} - self.messages, self.toplevel_messages, self.message_ids = self._gather_messages() + self.messages, self.toplevel_messages, self.message_list = self._gather_messages() def _gather_messages(self): query = self._dbman.query('thread:' + self.id) @@ -105,25 +105,26 @@ class Thread: msgs = {} msg_tree = [] - ids = [] + msg_list = [] - def thread_tree_walk(nm_msg): - msg_id = nm_msg.get_message_id() - ids.append(msg_id) + def thread_tree_walk(nm_msg, depth): + msg = Message(self._dbman, self, nm_msg, depth) + + msg_list.append(msg) replies = [] for m in nm_msg.get_replies(): - replies.append(thread_tree_walk(m)) - msg = Message(self._dbman, nm_msg, self, replies) + replies.append(thread_tree_walk(m, depth + 1)) - msgs[msg_id] = msg + msg.replies = replies + msgs[msg.id] = msg return msg for m in nm_thread.get_toplevel_messages(): - msg_tree.append(thread_tree_walk(m)) + msg_tree.append(thread_tree_walk(m, 0)) - return msgs, msg_tree, ids + return msgs, msg_tree, msg_list def __str__(self): return "thread:%s: %s" % (self.id, self.subject) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py index 97a34417..0b4e2404 100644 --- a/alot/widgets/thread.py +++ b/alot/widgets/thread.py @@ -6,7 +6,6 @@ Widgets specific to thread mode """ import logging import urwid -from urwidtrees import Tree, SimpleTree, CollapsibleTree from .globals import TagWidget from .globals import AttachmentWidget @@ -20,19 +19,19 @@ class MessageSummaryWidget(urwid.WidgetWrap): one line summary of a :class:`~alot.db.message.Message`. """ - def __init__(self, message, even=True): + def __init__(self, message, odd): """ :param message: a message :type message: alot.db.Message - :param even: even entry in a pile of messages? Used for theming. - :type even: bool + :param odd: odd entry in a pile of messages? Used for theming. + :type odd: bool """ self.message = message - self.even = even - if even: - attr = settings.get_theming_attribute('thread', 'summary', 'even') - else: + self.odd = odd + if odd: attr = settings.get_theming_attribute('thread', 'summary', 'odd') + else: + attr = settings.get_theming_attribute('thread', 'summary', 'even') focus_att = settings.get_theming_attribute('thread', 'summary', 'focus') cols = [] @@ -54,7 +53,7 @@ class MessageSummaryWidget(urwid.WidgetWrap): cols.append(('fixed', tag_widget.width(), tag_widget)) line = urwid.AttrMap(urwid.Columns(cols, dividechars=1), attr, focus_att) - urwid.WidgetWrap.__init__(self, line) + super().__init__(line) def __str__(self): mail = self.message.get_email() @@ -81,7 +80,7 @@ class FocusableText(urwid.WidgetWrap): def __init__(self, txt, att, att_focus): t = urwid.Text(txt) w = urwid.AttrMap(t, att, att_focus) - urwid.WidgetWrap.__init__(self, w) + super().__init__(w) def selectable(self): return True @@ -90,27 +89,16 @@ class FocusableText(urwid.WidgetWrap): return key -class TextlinesList(SimpleTree): - def __init__(self, content, attr=None, attr_focus=None): - """ - :class:`SimpleTree` that contains a list of all-level-0 Text widgets - for each line in content. - """ - structure = [(FocusableText(content, attr, attr_focus), None)] - SimpleTree.__init__(self, structure) - - -class DictList(SimpleTree): +class HeadersWidget(urwid.WidgetWrap): """ - :class:`SimpleTree` that displays key-value pairs. - - The structure will obey the Tree API but will not actually be a tree - but a flat list: It contains one top-level node (displaying the k/v pair in - Columns) per pair. That is, the root will be the first pair, - its sibblings will be the other pairs and first|last_child will always - be None. + A flow widget displaying message headers. """ - def __init__(self, content, key_attr, value_attr, gaps_attr=None): + + _key_attr = None + _value_attr = None + _gaps_attr = None + + def __init__(self, key_attr, value_attr, gaps_attr): """ :param headerslist: list of key/value pairs to display :type headerslist: list of (str, str) @@ -121,189 +109,145 @@ class DictList(SimpleTree): :param gaps_attr: theming attribute to wrap lines in :type gaps_attr: urwid.AttrSpec """ - max_key_len = 1 - structure = [] - # calc max length of key-string - for key, value in content: - if len(key) > max_key_len: - max_key_len = len(key) - for key, value in content: - # todo : even/odd - keyw = ('fixed', max_key_len + 1, - urwid.Text((key_attr, key))) - valuew = urwid.Text((value_attr, value)) - line = urwid.Columns([keyw, valuew]) - if gaps_attr is not None: - line = urwid.AttrMap(line, gaps_attr) - structure.append((line, None)) - SimpleTree.__init__(self, structure) - - -class MessageTree(CollapsibleTree): - """ - :class:`Tree` that displays contents of a single :class:`alot.db.Message`. + self._key_attr = key_attr + self._value_attr = value_attr + self._gaps_attr = gaps_attr - Its root node is a :class:`MessageSummaryWidget`, and its child nodes - reflect the messages content (parts for headers/attachments etc). + super().__init__(urwid.Pile([])) + + def set_headers(self, headers): + if len(headers) == 0: + self._w.contents.clear() + return + + max_key_len = max(map(lambda x: len(x[0]), headers)) + + widgets = [] + for key, value in headers: + # TODO even/odd + keyw = ('fixed', max_key_len + 1, + urwid.Text((self._key_attr, key))) + valuew = urwid.Text((self._value_attr, decode_header(value))) + linew = urwid.AttrMap(urwid.Columns([keyw, valuew]), self._gaps_attr) + widgets.append(linew) + + self._w.contents = [(w, ('pack', None)) for w in widgets] + +class MessageWidget(urwid.WidgetWrap): + """ + A flow widget displaying the contents of a single :class:`alot.db.Message`. Collapsing this message corresponds to showing the summary only. """ - def __init__(self, message, odd=True): + + _display_all_headers = None + _display_content = None + _display_source = None + + _headers_wgt = None + _summary_wgt = None + _source_wgt = None + _body_wgt = None + _attach_wgt = None + + def __init__(self, message, odd): """ :param message: Message to display :type message: alot.db.Message - :param odd: theme summary widget as if this is an odd line - (in the message-pile) + :param odd: theme summary widget as if this is an odd line in the thread order :type odd: bool """ - self._message = message - self._odd = odd - self.display_source = False - self._summaryw = None - self._bodytree = None - self._sourcetree = None - self.display_all_headers = False - self._all_headers_tree = None - self._default_headers_tree = None - self.display_attachments = True - self._attachments = None - self._maintree = SimpleTree(self._assemble_structure(True)) - CollapsibleTree.__init__(self, self._maintree) + self._message = message - def get_message(self): - return self._message + self.display_attachments = True - def reassemble(self): - self._maintree._treelist = self._assemble_structure() + self._headers_wgt = self._get_headers() + self._summary_wgt = MessageSummaryWidget(message, odd) + self._source_wgt = self._get_source() + self._body_wgt = self._get_body() + self._attach_wgt = self._get_attachments() - def refresh(self): - self._summaryw = None - self.reassemble() + super().__init__(urwid.Pile([])) - def debug(self): - logging.debug('collapsed %s', self.is_collapsed(self.root)) - logging.debug('display_source %s', self.display_source) - logging.debug('display_all_headers %s', self.display_all_headers) - logging.debug('display_attachements %s', self.display_attachments) - logging.debug('AHT %s', str(self._all_headers_tree)) - logging.debug('DHT %s', str(self._default_headers_tree)) - logging.debug('MAINTREE %s', str(self._maintree._treelist)) + self.display_all_headers = False + self.display_source = False + self.display_content = False - def expand(self, pos): - """ - overload CollapsibleTree.expand method to ensure all parts are present. - Initially, only the summary widget is created to avoid reading the - messafe file and thus speed up the creation of this object. Once we - expand = unfold the message, we need to make sure that body/attachments - exist. - """ - logging.debug("MT expand") - if not self._bodytree: - self.reassemble() - CollapsibleTree.expand(self, pos) - - def _assemble_structure(self, summary_only=False): - if summary_only: - return [(self._get_summary(), None)] - - mainstruct = [] - if self.display_source: - mainstruct.append((self._get_source(), None)) - else: - mainstruct.append((self._get_headers(), None)) + def get_message(self): + return self._message - attachmenttree = self._get_attachments() - if attachmenttree is not None: - mainstruct.append((attachmenttree, None)) + def _reassemble(self, display_content, display_source): + widgets = [self._summary_wgt] - bodytree = self._get_body() - if bodytree is not None: - mainstruct.append((bodytree, None)) + if display_content: + if display_source: + widgets.append(self._source_wgt) + else: + widgets.append(self._headers_wgt) - structure = [ - (self._get_summary(), mainstruct) - ] - return structure + if self._attach_wgt is not None: + widgets.append(self._attach_wgt) - def collapse_if_matches(self, querystring): - """ - collapse (and show summary only) if the :class:`alot.db.Message` - matches given `querystring` - """ - self.set_position_collapsed( - self.root, self._message.matches(querystring)) + widgets.append(self._body_wgt) - def _get_summary(self): - if self._summaryw is None: - self._summaryw = MessageSummaryWidget( - self._message, even=(not self._odd)) - return self._summaryw + self._w.contents = [(w, ('pack', None)) for w in widgets] def _get_source(self): - if self._sourcetree is None: - sourcetxt = self._message.get_email().as_string() - sourcetxt = string_sanitize(sourcetxt) - att = settings.get_theming_attribute('thread', 'body') - att_focus = settings.get_theming_attribute('thread', 'body_focus') - self._sourcetree = TextlinesList(sourcetxt, att, att_focus) - return self._sourcetree + sourcetxt = self._message.get_email().as_string() + sourcetxt = string_sanitize(sourcetxt) + att = settings.get_theming_attribute('thread', 'body') + att_focus = settings.get_theming_attribute('thread', 'body_focus') + return FocusableText(sourcetxt, att, att_focus) def _get_body(self): - if self._bodytree is None: - bodytxt = self._message.get_body_text() - if bodytxt: - att = settings.get_theming_attribute('thread', 'body') - att_focus = settings.get_theming_attribute( - 'thread', 'body_focus') - self._bodytree = TextlinesList(bodytxt, att, att_focus) - return self._bodytree + bodytxt = self._message.get_body_text() + att = settings.get_theming_attribute('thread', 'body') + att_focus = settings.get_theming_attribute( + 'thread', 'body_focus') + return FocusableText(bodytxt, att, att_focus) def _get_headers(self): - if self.display_all_headers is True: - if self._all_headers_tree is None: - self._all_headers_tree = self.construct_header_pile() - ret = self._all_headers_tree - else: - if self._default_headers_tree is None: - headers = settings.get('displayed_headers') - self._default_headers_tree = self.construct_header_pile( - headers) - ret = self._default_headers_tree - return ret + key_attr = settings.get_theming_attribute('thread', 'header_key') + value_attr = settings.get_theming_attribute('thread', 'header_value') + gaps_attr = settings.get_theming_attribute('thread', 'header') + return HeadersWidget(key_attr, value_attr, gaps_attr) def _get_attachments(self): - if self._attachments is None: - alist = [] - for a in self._message.get_attachments(): - alist.append((AttachmentWidget(a), None)) - if alist: - self._attachments = SimpleTree(alist) - return self._attachments - - def construct_header_pile(self, headers=None, normalize=True): + alist = [] + for a in self._message.get_attachments(): + alist.append(AttachmentWidget(a)) + if alist: + return urwid.Pile(alist) + return None + + @property + def display_all_headers(self): + return self._display_all_headers + @display_all_headers.setter + def display_all_headers(self, val): + val = bool(val) + + if val == self._display_all_headers: + return + mail = self._message.get_email() lines = [] - if headers is None: + if val: # collect all header/value pairs in the order they appear - for key, value in mail.items(): - dvalue = decode_header(value, normalize=normalize) - lines.append((key, dvalue)) + lines = list(mail.items()) else: # only a selection of headers should be displayed. # use order of the `headers` parameter - for key in headers: + for key in settings.get('displayed_headers'): if key in mail: if key.lower() in ['cc', 'bcc', 'to']: - values = mail.get_all(key) - values = [decode_header( - v, normalize=normalize) for v in values] - lines.append((key, ', '.join(values))) + lines.append((key, ', '.join(mail.get_all(key)))) else: for value in mail.get_all(key): - dvalue = decode_header(value, normalize=normalize) - lines.append((key, dvalue)) + lines.append((key, value)) elif key.lower() == 'tags': + # TODO this should be in a separate widget logging.debug('want tags header') values = [] for t in self._message.get_tags(): @@ -314,79 +258,35 @@ class MessageTree(CollapsibleTree): lines.append((key, ', '.join(values))) # OpenPGP pseudo headers + # TODO this should be in a separate widget if mail[X_SIGNATURE_MESSAGE_HEADER]: lines.append(('PGP-Signature', mail[X_SIGNATURE_MESSAGE_HEADER])) - key_att = settings.get_theming_attribute('thread', 'header_key') - value_att = settings.get_theming_attribute('thread', 'header_value') - gaps_att = settings.get_theming_attribute('thread', 'header') - return DictList(lines, key_att, value_att, gaps_att) + self._headers_wgt.set_headers(lines) + self._display_all_headers = val -class ThreadTree(Tree): - """ - :class:`Tree` that parses a given :class:`alot.db.Thread` into a tree of - :class:`MessageTrees ` that display this threads individual - messages. As MessageTreess are *not* urwid widgets themself this is to be - used in combination with :class:`NestedTree` only. - """ - def __init__(self, thread): - self._thread = thread - self.root = thread.toplevel_messages[0].id - self._parent_of = {} - self._first_child_of = {} - self._last_child_of = {} - self._next_sibling_of = {} - self._prev_sibling_of = {} - self._message = {} - - def accumulate(msg, odd=True): - """recursively read msg and its replies""" - mid = msg.id - self._message[mid] = MessageTree(msg, odd) - odd = not odd - last = None - self._first_child_of[mid] = None - for reply in msg.replies: - rid = reply.id - if self._first_child_of[mid] is None: - self._first_child_of[mid] = rid - self._parent_of[rid] = mid - self._prev_sibling_of[rid] = last - self._next_sibling_of[last] = rid - last = rid - odd = accumulate(reply, odd) - self._last_child_of[mid] = last - return odd - - last = None - for msg in thread.toplevel_messages: - mid = msg.id - self._prev_sibling_of[mid] = last - self._next_sibling_of[last] = mid - accumulate(msg) - last = mid - self._next_sibling_of[last] = None - - # Tree API - def __getitem__(self, pos): - return self._message.get(pos) - - def parent_position(self, pos): - return self._parent_of.get(pos) - - def first_child_position(self, pos): - return self._first_child_of.get(pos) - - def last_child_position(self, pos): - return self._last_child_of.get(pos) - - def next_sibling_position(self, pos): - return self._next_sibling_of.get(pos) - - def prev_sibling_position(self, pos): - return self._prev_sibling_of.get(pos) - - @staticmethod - def position_of_messagetree(mt): - return mt._message.id + @property + def display_content(self): + return self._display_content + @display_content.setter + def display_content(self, val): + val = bool(val) + + if val == self._display_content: + return + + self._reassemble(val, self.display_source) + self._display_content = val + + def expand(self): + self.display_content = True + def collapse(self): + self.display_content = False + + def collapse_if_matches(self, querystring): + """ + collapse (and show summary only) if the :class:`alot.db.Message` + matches given `querystring` + """ + self.display_content = not self._message.matches(querystring) diff --git a/docs/source/conf.py b/docs/source/conf.py index 7531098a..1a141d68 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,7 +27,6 @@ autodoc_mock_imports = [ 'magic', 'notmuch', 'urwid', - 'urwidtrees', 'validate', ] diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 85a4189b..d54283c5 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -26,18 +26,17 @@ A full list of dependencies is below: * `configobj `_, ≥ `4.7.0` * `libnotmuch `_ and it's python bindings, ≥ `0.27` * `urwid `_ toolkit, ≥ `1.3.0` -* `urwidtrees `_, ≥ `1.0` * `gpg `_ and it's python bindings, ≥ `1.9.0` * `twisted `_, ≥ `18.4.0` On Debian/Ubuntu these are packaged as:: - python3-setuptools python3-magic python3-configobj python3-notmuch python3-urwid python3-urwidtrees python3-gpg python3-twisted + python3-setuptools python3-magic python3-configobj python3-notmuch python3-urwid python3-gpg python3-twisted On Fedora/Redhat these are packaged as:: - python-setuptools python-magic python-configobj python-notmuch python-urwid python-urwidtrees python-gpg python-twisted + python-setuptools python-magic python-configobj python-notmuch python-urwid python-gpg python-twisted To set up and install the latest development version:: diff --git a/setup.py b/setup.py index 5e95cd3e..67628890 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,6 @@ setup( install_requires=[ 'notmuch>=0.27', 'urwid>=1.3.0', - 'urwidtrees>=1.0', 'twisted>=18.4.0', 'python-magic', 'configobj>=4.7.0', -- cgit v1.2.3