# Copyright (C) 2011-2018 Patrick Totzke # 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 asyncio import enum from functools import cached_property import logging import urwid from .buffer import Buffer from ..settings.const import settings from ..widgets.thread import MessageWidget, ThreadNode from ..db.errors import NonexistantObjectError class _ThreadBufFocus(enum.Enum): TREE = enum.auto() MESSAGE = enum.auto() class _LazyMessageWidget: msg = None def __init__(self, msg): self.msg = msg @cached_property def wgt(self): return MessageWidget(self.msg) class ThreadBuffer(Buffer): """displays a thread as a tree of messages.""" modename = 'thread' ui = None thread = None # list of the widgets containing the message body # indexed by its depth-first position in the thread tree _messages = None # widget showing the thread tree _msgtree_widget = None # decorations around the msgtree widget _msgtree_deco = None # WidgetPlaceholder that wraps currently displayed message _cur_msg_holder = None def __init__(self, ui, thread): """ :param thread: thread to display :type thread: :class:`~alot.db.Thread` """ self.ui = ui self.thread = thread self._indent_width = settings.get('thread_indent_replies') attr = settings.get_theming_attribute('thread', 'frame') attr_focus = settings.get_theming_attribute('thread', 'frame_focus') # create the widgets composing the buffer self._msgtree_widget = urwid.ListBox(urwid.SimpleFocusListWalker([])) self._msgtree_deco = urwid.AttrMap(urwid.LineBox(self._msgtree_widget), attr, attr_focus) # initial placeholder has to be selectable self._cur_msg_holder = urwid.WidgetPlaceholder(urwid.SelectableIcon('')) cur_msg_deco = urwid.AttrMap(urwid.LineBox(self._cur_msg_holder), attr, attr_focus) self.body = urwid.Pile([ ('weight', 0.5, self._msgtree_deco), ('weight', 0.5, cur_msg_deco), ]) # clear the command map to prevent cursor movements from switching focus self.body._command_map = self.body._command_map.copy() self.body._command_map._command = {} urwid.connect_signal(self._msgtree_widget.body, "modified", self._update_cur_msg) self.rebuild() super().__init__() def __str__(self): return '[thread] %s (%d message%s)' % (self.thread.subject, self.thread.total_messages, 's' * (self.thread.total_messages > 1)) @property def _focus(self): if self.body.focus_position == 0: return _ThreadBufFocus.TREE elif self.body.focus_position == 1: return _ThreadBufFocus.MESSAGE else: raise ValueError('Invalid focus position: %s' % str(self.body.focus_position)) def _update_cur_msg(self): pos = self._msgtree_widget.body.focus if pos is not None and pos < len(self._messages): logging.debug('displaying message %s ', pos) self._cur_msg_holder.original_widget = self._messages[pos].wgt def translated_tags_str(self, intersection=False): tags = self.thread.get_tags(intersection=intersection) trans = [settings.get_tagstring_representation(tag)['translated'] for tag in tags] return ' '.join(trans) async def get_info(self): info = {} info['subject'] = self.thread.subject.translate(settings.sanitize_header_table) info['authors'] = self.thread.get_authors_string() info['tid'] = self.thread.id 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 def rebuild(self): self._messages = [] self._msgtree_widget.body.clear() self._cur_msg_holder.original_widget = urwid.SolidFill() try: self.thread.refresh() except NonexistantObjectError: return list_walker = self._msgtree_widget.body for pos, msg in enumerate(self.thread.message_list): self._messages.append(_LazyMessageWidget(msg)) list_walker.append(ThreadNode(msg, self.thread, pos, self._indent_width)) # approximate weight for one terminal line # substract 3 for decoration and panels line_weight = 1.0 / max(self.ui.get_cols_rows()[1] - 3, 1) # the default weight given to the thread-tree widget is proportional to # the number of messages in it plus 2 (for surrounding decoration) - # scaled to approximately one line per message - up to maximum weight of # half the screen tree_weight = min(line_weight * (len(self._messages) + 2), 0.5) self.msgtree_weight = tree_weight if len(self._messages) > 0: self._cur_msg_holder.original_widget = self._messages[0].wgt @property def msgtree_weight(self): return self.body.contents[0][1][1] @msgtree_weight.setter def msgtree_weight(self, weight): weight = min(1.0, max(weight, 0.0)) self.body.contents[0] = (self.body.contents[0][0], ('weight', weight)) self.body.contents[1] = (self.body.contents[1][0], ('weight', 1.0 - weight)) def get_selected_message_position(self): """Return position of focussed message in the thread tree.""" return self._msgtree_widget.focus_position def get_selected_message_widget(self): """Return currently focused :class:`MessageWidget`.""" pos = self.get_selected_message_position() return self._messages[pos].wgt def get_selected_message(self): """Return focussed :class:`~alot.db.message.Message`.""" return self.get_selected_message_widget().get_message() def get_selected_attachment(self): """ If an attachment widget is currently in focus, return the associated Attachment. Otherwise return None. """ if self._focus == _ThreadBufFocus.MESSAGE: msg_wgt = self.get_selected_message_widget() return msg_wgt.get_selected_attachment() return None def message_widgets(self): """ Iterate over all the message widgets in this buffer """ for m in self._messages: yield m.wgt def set_focus(self, pos): "Set the focus in the underlying body widget." logging.debug('setting focus to %s ', pos) self._msgtree_widget.set_focus(pos) def focus_first(self): """set focus to first message of thread""" self.set_focus(0) def focus_last(self): self.set_focus(max(self.thread.total_messages - 1, 0)) def focus_selected_message(self): """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 focused message""" msg = self.get_selected_message() if msg.parent: self.set_focus(self.thread.message_list.index(msg.parent)) def focus_first_reply(self): """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 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_sibling(self, offset): msg = self.get_selected_message() siblings = msg.parent.replies if msg.depth > 0 else self.thread.toplevel_messages self_idx = siblings.index(msg) new_idx = self_idx + offset if new_idx >= 0 and new_idx < len(siblings): self.set_focus(self.thread.message_list.index(siblings[new_idx])) def focus_next_sibling(self): """focus next sibling of currently focussed message in thread tree""" self._focus_sibling(1) def focus_prev_sibling(self): """ focus previous sibling of currently focussed message in thread tree """ self._focus_sibling(-1) def focus_next(self): """focus next message in depth first order""" 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""" next_focus = self.get_selected_message_position() - 1 if next_focus >= 0 and next_focus < self.thread.total_messages: self.set_focus(next_focus) _DIR_NEXT = 0 _DIR_PREV = 1 _DIR_FIRST = 2 _DIR_LAST = 3 def _focus_property(self, prop, direction): """does a walk in the given direction and focuses the first message that matches the given property""" cur_pos = self.get_selected_message_position() if direction == self._DIR_NEXT: walk = range(cur_pos + 1, self.thread.total_messages) elif direction == self._DIR_FIRST: walk = range(0, self.thread.total_messages) elif direction == self._DIR_PREV: walk = reversed(range(0, cur_pos)) elif direction == self._DIR_LAST: walk = reversed(range(0, self.thread.total_messages)) else: raise ValueError('Invalid focus_propery direction: ' + str(direction)) for pos in walk: if prop(self._messages[pos]): self.set_focus(pos) break def focus_next_matching(self, querystring): """focus next matching message in depth first order""" self._focus_property(lambda x: x.msg.matches(querystring), self._DIR_NEXT) def focus_prev_matching(self, querystring): """focus previous matching message in depth first order""" self._focus_property(lambda x: x.msg.matches(querystring), self._DIR_PREV) def focus_first_matching(self, querystring): """focus first matching message in depth first order""" self._focus_property(lambda x: x.msg.matches(querystring), self._DIR_FIRST) def focus_last_matching(self, querystring): """focus last matching message in depth first order""" self._focus_property(lambda x: x.msg.matches(querystring), self._DIR_LAST) def focus_thread_widget(self): """set focus on the thread widget""" logging.debug('setting focus to thread widget') self.body.focus_position = 0 def focus_msg_widget(self): """set focus on the message widget""" logging.debug('setting focus to message widget') self.body.focus_position = 1 def focus_toggle(self): if self._focus == _ThreadBufFocus.TREE: self.focus_msg_widget() else: self.focus_thread_widget()