diff options
Diffstat (limited to 'alot/widgets')
-rw-r--r-- | alot/widgets/__init__.py | 0 | ||||
-rw-r--r-- | alot/widgets/bufferlist.py | 29 | ||||
-rw-r--r-- | alot/widgets/globals.py | 203 | ||||
-rw-r--r-- | alot/widgets/search.py | 186 | ||||
-rw-r--r-- | alot/widgets/thread.py | 301 | ||||
-rw-r--r-- | alot/widgets/utils.py | 64 |
6 files changed, 783 insertions, 0 deletions
diff --git a/alot/widgets/__init__.py b/alot/widgets/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/alot/widgets/__init__.py diff --git a/alot/widgets/bufferlist.py b/alot/widgets/bufferlist.py new file mode 100644 index 00000000..0ab315c5 --- /dev/null +++ b/alot/widgets/bufferlist.py @@ -0,0 +1,29 @@ +# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +""" +Widgets specific to Bufferlist mode +""" +import urwid + + +class BufferlineWidget(urwid.Text): + """ + selectable text widget that represents a :class:`~alot.buffers.Buffer` + in the :class:`~alot.buffers.BufferlistBuffer`. + """ + + def __init__(self, buffer): + self.buffer = buffer + line = buffer.__str__() + urwid.Text.__init__(self, line, wrap='clip') + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + def get_buffer(self): + return self.buffer diff --git a/alot/widgets/globals.py b/alot/widgets/globals.py new file mode 100644 index 00000000..96fec312 --- /dev/null +++ b/alot/widgets/globals.py @@ -0,0 +1,203 @@ +# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +""" +This contains alot-specific :ref:`urwid.Widgets` used in more than one mode. +""" +import urwid + +from alot.helper import string_decode +from alot.settings import settings +from alot.db.attachment import Attachment + + +class AttachmentWidget(urwid.WidgetWrap): + """ + one-line summary of an :class:`~alot.db.attachment.Attachment`. + """ + def __init__(self, attachment, selectable=True): + self._selectable = selectable + self.attachment = attachment + if not isinstance(attachment, Attachment): + self.attachment = Attachment(self.attachment) + att = settings.get_theming_attribute('thread', 'attachment') + focus_att = settings.get_theming_attribute('thread', + 'attachment_focus') + widget = urwid.AttrMap(urwid.Text(self.attachment.__str__()), + att, focus_att) + urwid.WidgetWrap.__init__(self, widget) + + def get_attachment(self): + return self.attachment + + def selectable(self): + return self._selectable + + def keypress(self, size, key): + return key + + +class ChoiceWidget(urwid.Text): + def __init__(self, choices, callback, cancel=None, select=None): + self.choices = choices + self.callback = callback + self.cancel = cancel + self.select = select + + items = [] + for k, v in choices.items(): + if v == select and select is not None: + items.append('[%s]:%s' % (k, v)) + else: + items.append('(%s):%s' % (k, v)) + urwid.Text.__init__(self, ' '.join(items)) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == 'select' and self.select is not None: + self.callback(self.select) + elif key == 'cancel' and self.cancel is not None: + self.callback(self.cancel) + elif key in self.choices: + self.callback(self.choices[key]) + else: + return key + + +class CompleteEdit(urwid.Edit): + def __init__(self, completer, on_exit, edit_text=u'', + history=None, **kwargs): + self.completer = completer + self.on_exit = on_exit + self.history = list(history) # we temporarily add stuff here + self.historypos = None + + if not isinstance(edit_text, unicode): + edit_text = string_decode(edit_text) + self.start_completion_pos = len(edit_text) + self.completions = None + urwid.Edit.__init__(self, edit_text=edit_text, **kwargs) + + def keypress(self, size, key): + # if we tabcomplete + if key in ['tab', 'shift tab'] and self.completer: + # if not already in completion mode + if not self.completions: + self.completions = [(self.edit_text, self.edit_pos)] + \ + self.completer.complete(self.edit_text, self.edit_pos) + self.focus_in_clist = 1 + else: # otherwise tab through results + if key == 'tab': + self.focus_in_clist += 1 + else: + self.focus_in_clist -= 1 + if len(self.completions) > 1: + ctext, cpos = self.completions[self.focus_in_clist % + len(self.completions)] + self.set_edit_text(ctext) + self.set_edit_pos(cpos) + else: + self.edit_pos += 1 + if self.edit_pos >= len(self.edit_text): + self.edit_text += ' ' + self.completions = None + elif key in ['up', 'down']: + if self.history: + if self.historypos is None: + self.history.append(self.edit_text) + self.historypos = len(self.history) - 1 + if key == 'cursor up': + self.historypos = (self.historypos + 1) % len(self.history) + else: + self.historypos = (self.historypos - 1) % len(self.history) + self.set_edit_text(self.history[self.historypos]) + elif key == 'select': + self.on_exit(self.edit_text) + elif key == 'cancel': + self.on_exit(None) + elif key == 'ctrl a': + self.set_edit_pos(0) + elif key == 'ctrl e': + self.set_edit_pos(len(self.edit_text)) + else: + result = urwid.Edit.keypress(self, size, key) + self.completions = None + return result + + +class HeadersList(urwid.WidgetWrap): + """ renders a pile of header values as key/value list """ + def __init__(self, headerslist, key_attr, value_attr): + self.headers = headerslist + self.key_attr = key_attr + self.value_attr = value_attr + pile = urwid.Pile(self._build_lines(headerslist)) + att = settings.get_theming_attribute('thread', 'header') + pile = urwid.AttrMap(pile, att) + urwid.WidgetWrap.__init__(self, pile) + + def __str__(self): + return str(self.headers) + + def _build_lines(self, lines): + max_key_len = 1 + headerlines = [] + #calc max length of key-string + for key, value in lines: + if len(key) > max_key_len: + max_key_len = len(key) + for key, value in lines: + ##todo : even/odd + keyw = ('fixed', max_key_len + 1, + urwid.Text((self.key_attr, key))) + valuew = urwid.Text((self.value_attr, value)) + line = urwid.Columns([keyw, valuew]) + headerlines.append(line) + return headerlines + + +class TagWidget(urwid.AttrMap): + """ + text widget that renders a tagstring. + + It looks up the string it displays in the `tags` section + of the config as well as custom theme settings for its tag. + """ + def __init__(self, tag, fallback_normal=None, fallback_focus=None): + 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') + normal_att = representation['normal'] + focus_att = representation['focussed'] + self.attmaps = {'normal': normal_att, 'focus': focus_att} + urwid.AttrMap.__init__(self, self.txt, normal_att, focus_att) + + def set_map(self, attrstring): + self.set_attr_map({None: self.attmaps[attrstring]}) + + def width(self): + # evil voodoo hotfix for double width chars that may + # lead e.g. to strings with length 1 that need width 2 + return self.txt.pack()[0] + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + def get_tag(self): + return self.tag + + def set_focussed(self): + self.set_attr_map(self.attmap['focus']) + + def set_unfocussed(self): + self.set_attr_map(self.attmap['normal']) diff --git a/alot/widgets/search.py b/alot/widgets/search.py new file mode 100644 index 00000000..6f8ed126 --- /dev/null +++ b/alot/widgets/search.py @@ -0,0 +1,186 @@ +# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +""" +Widgets specific to search mode +""" +import urwid + +from alot.settings import settings +from alot.helper import shorten_author_string +from alot.helper import tag_cmp +from alot.widgets.utils import AttrFlipWidget +from alot.widgets.globals import TagWidget + + +class ThreadlineWidget(urwid.AttrMap): + """ + selectable line widget that represents a :class:`~alot.db.Thread` + in the :class:`~alot.buffers.SearchBuffer`. + """ + def __init__(self, tid, dbman): + self.dbman = dbman + self.thread = dbman.get_thread(tid) + self.tag_widgets = [] + self.display_content = settings.get('display_content_in_threadline') + self.structure = None + self.rebuild() + normal = self.structure['normal'] + focussed = self.structure['focus'] + urwid.AttrMap.__init__(self, self.columns, normal, focussed) + + def _build_part(self, name, struct, minw, maxw, align): + def pad(string, shorten=None): + if maxw: + if len(string) > maxw: + if shorten: + string = shorten(string, maxw) + else: + string = string[:maxw] + if minw: + if len(string) < minw: + if align == 'left': + string = string.ljust(minw) + elif align == 'center': + string = string.center(minw) + else: + string = string.rjust(minw) + return string + + part = None + width = None + if name == 'date': + newest = None + datestring = '' + if self.thread: + newest = self.thread.get_newest_date() + datestring = settings.represent_datetime(newest) + datestring = pad(datestring) + width = len(datestring) + part = AttrFlipWidget(urwid.Text(datestring), struct['date']) + + elif name == 'mailcount': + if self.thread: + mailcountstring = "(%d)" % self.thread.get_total_messages() + else: + mailcountstring = "(?)" + datestring = pad(mailcountstring) + width = len(mailcountstring) + mailcount_w = AttrFlipWidget(urwid.Text(mailcountstring), + struct['mailcount']) + part = mailcount_w + elif name == 'authors': + if self.thread: + authors = self.thread.get_authors_string() or '(None)' + else: + authors = '(None)' + authorsstring = pad(authors, shorten_author_string) + authors_w = AttrFlipWidget(urwid.Text(authorsstring), + struct['authors']) + width = len(authorsstring) + part = authors_w + + elif name == 'subject': + if self.thread: + subjectstring = self.thread.get_subject() or ' ' + else: + subjectstring = ' ' + # sanitize subject string: + subjectstring = subjectstring.replace('\n', ' ') + subjectstring = subjectstring.replace('\r', '') + subjectstring = pad(subjectstring) + + subject_w = AttrFlipWidget(urwid.Text(subjectstring, wrap='clip'), + struct['subject']) + if subjectstring: + width = len(subjectstring) + part = subject_w + + elif name == 'content': + if self.thread: + msgs = self.thread.get_messages().keys() + else: + msgs = [] + # sort the most recent messages first + msgs.sort(key=lambda msg: msg.get_date(), reverse=True) + lastcontent = ' '.join([m.get_text_content() for m in msgs]) + contentstring = pad(lastcontent.replace('\n', ' ').strip()) + content_w = AttrFlipWidget(urwid.Text( + contentstring, + wrap='clip'), + struct['content']) + width = len(contentstring) + part = content_w + elif name == 'tags': + if self.thread: + fallback_normal = struct[name]['normal'] + fallback_focus = struct[name]['focus'] + tag_widgets = [TagWidget(t, fallback_normal, fallback_focus) + for t in self.thread.get_tags()] + tag_widgets.sort(tag_cmp, + lambda tag_widget: tag_widget.translated) + else: + tag_widgets = [] + cols = [] + length = -1 + for tag_widget in tag_widgets: + if not tag_widget.hidden: + wrapped_tagwidget = tag_widget + tag_width = tag_widget.width() + cols.append(('fixed', tag_width, wrapped_tagwidget)) + length += tag_width + 1 + if cols: + part = urwid.Columns(cols, dividechars=1) + width = length + return width, part + + def rebuild(self): + self.widgets = [] + columns = [] + self.structure = settings.get_threadline_theming(self.thread) + for partname in self.structure['parts']: + minw = maxw = None + width_tuple = self.structure[partname]['width'] + if width_tuple is not None: + if width_tuple[0] == 'fit': + minw, maxw = width_tuple[1:] + align_mode = self.structure[partname]['alignment'] + width, part = self._build_part(partname, self.structure, + minw, maxw, align_mode) + if part is not None: + if isinstance(part, urwid.Columns): + for w in part.widget_list: + self.widgets.append(w) + else: + self.widgets.append(part) + + # compute width and align + if width_tuple[0] == 'weight': + columnentry = width_tuple + (part,) + else: + columnentry = ('fixed', width, part) + columns.append(columnentry) + self.columns = urwid.Columns(columns, dividechars=1) + self.original_widget = self.columns + + def render(self, size, focus=False): + for w in self.widgets: + w.set_map('focus' if focus else 'normal') + return urwid.AttrMap.render(self, size, focus) + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + def get_thread(self): + return self.thread + + def _get_theme(self, component, focus=False): + path = ['search', 'threadline', component] + if focus: + path.append('focus') + else: + path.append('normal') + return settings.get_theming_attribute(path) diff --git a/alot/widgets/thread.py b/alot/widgets/thread.py new file mode 100644 index 00000000..c6d399e8 --- /dev/null +++ b/alot/widgets/thread.py @@ -0,0 +1,301 @@ +# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +""" +Widgets specific to thread mode +""" +import urwid +import logging + +from alot.settings import settings +from alot.db.utils import decode_header +import alot.db.message as message +from alot.helper import tag_cmp +from alot.widgets.globals import HeadersList +from alot.widgets.globals import TagWidget +from alot.widgets.globals import AttachmentWidget + + +class MessageWidget(urwid.WidgetWrap): + """ + Flow widget that renders a :class:`~alot.db.message.Message`. + """ + #TODO: atm this is heavily bent to work nicely with ThreadBuffer to display + #a tree structure. A better way would be to keep this widget simple + #(subclass urwid.Pile) and use urwids new Tree widgets + def __init__(self, message, even=False, folded=True, raw=False, + all_headers=False, depth=0, bars_at=[]): + """ + :param message: the message to display + :type message: alot.db.Message + :param even: use messagesummary_even theme for summary + :type even: bool + :param folded: fold message initially + :type folded: bool + :param raw: show message source initially + :type raw: bool + :param all_headers: show all headers initially + :type all_headers: bool + :param depth: number of characters to shift content to the right + :type depth: int + :param bars_at: defines for each column of the indentation whether to + use a vertical bar instead of a space. + :type bars_at: list(bool) + """ + self.message = message + self.mail = self.message.get_email() + + self.depth = depth + self.bars_at = bars_at + self.even = even + self.folded = folded + self.show_raw = raw + self.show_all_headers = all_headers + + # define subwidgets that will be created on demand + self.sumline = None + self.headerw = None + self.attachmentw = None + self.bodyw = None + self.sourcew = None + + # set available and to be displayed headers + self._all_headers = list(set(self.mail.keys())) + displayed = settings.get('displayed_headers') + self._filtered_headers = [k for k in displayed if k in self.mail] + self._displayed_headers = None + + bars = settings.get_theming_attribute('thread', 'arrow_bars') + self.arrow_bars_att = bars + heads = settings.get_theming_attribute('thread', 'arrow_heads') + self.arrow_heads_att = heads + logging.debug(self.arrow_heads_att) + + self.rebuild() # this will build self.pile + urwid.WidgetWrap.__init__(self, self.pile) + + def get_focus(self): + return self.pile.get_focus() + + def rebuild(self): + self.sumline = self._build_sum_line() + if not self.folded: # only if already unfolded + self.displayed_list = [self.sumline] + if self.show_raw: + srcw = self._get_source_widget() + self.displayed_list.append(srcw) + else: + hw = self._get_header_widget() + aw = self._get_attachment_widget() + bw = self._get_body_widget() + if hw: + self.displayed_list.append(hw) + if aw: + self.displayed_list.append(aw) + self.displayed_list.append(bw) + else: + self.displayed_list = [self.sumline] + self.pile = urwid.Pile(self.displayed_list) + self._w = self.pile + + def _build_sum_line(self): + """creates/returns the widget that displays the summary line.""" + self.sumw = MessageSummaryWidget(self.message, even=self.even) + cols = [] + bc = list() # box_columns + if self.depth > 1: + bc.append(0) + spacer = self._get_spacer(self.bars_at[1:-1]) + cols.append(spacer) + if self.depth > 0: + if self.bars_at[-1]: + arrowhead = [(self.arrow_bars_att, u'\u251c'), + (self.arrow_heads_att, u'\u25b6')] + else: + arrowhead = [(self.arrow_bars_att, u'\u2514'), + (self.arrow_heads_att, u'\u25b6')] + cols.append(('fixed', 2, urwid.Text(arrowhead))) + cols.append(self.sumw) + line = urwid.Columns(cols, box_columns=bc) + return line + + def _get_header_widget(self): + """creates/returns the widget that displays the mail header""" + all_shown = (self._all_headers == self._displayed_headers) + + if self.headerw and (self.show_all_headers == all_shown): + return self.headerw + + if self.show_all_headers: + self._displayed_headers = self._all_headers + else: + self._displayed_headers = self._filtered_headers + + mail = self.message.get_email() + # normalize values if only filtered list is shown + norm = not (self._displayed_headers == self._all_headers) + + #build lines + lines = [] + for key in self._displayed_headers: + if key in mail: + if key.lower() in ['cc', 'bcc', 'to']: + values = mail.get_all(key) + values = [decode_header(v, normalize=norm) for v in values] + lines.append((key, ', '.join(values))) + else: + for value in mail.get_all(key): + dvalue = decode_header(value, normalize=norm) + lines.append((key, dvalue)) + + key_att = settings.get_theming_attribute('thread', 'header_key') + value_att = settings.get_theming_attribute('thread', 'header_value') + cols = [HeadersList(lines, key_att, value_att)] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + self.headerw = urwid.Columns(cols, box_columns=bc) + return self.headerw + + def _get_attachment_widget(self): + if self.message.get_attachments() and not self.attachmentw: + lines = [] + for a in self.message.get_attachments(): + cols = [AttachmentWidget(a)] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + lines.append(urwid.Columns(cols, box_columns=bc)) + self.attachmentw = urwid.Pile(lines) + return self.attachmentw + + def _get_body_widget(self): + """creates/returns the widget that displays the mail body""" + if not self.bodyw: + cols = [MessageBodyWidget(self.message.get_email())] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + self.bodyw = urwid.Columns(cols, box_columns=bc) + return self.bodyw + + def _get_source_widget(self): + """creates/returns the widget that displays the mail body""" + if not self.sourcew: + cols = [urwid.Text(self.message.get_email().as_string())] + bc = list() + if self.depth: + cols.insert(0, self._get_spacer(self.bars_at[1:])) + bc.append(0) + cols.insert(1, self._get_arrowhead_aligner()) + bc.append(1) + self.sourcew = urwid.Columns(cols, box_columns=bc) + return self.sourcew + + def _get_spacer(self, bars_at): + prefixchars = [] + length = len(bars_at) + for b in bars_at: + if b: + c = u'\u2502' + else: + c = ' ' + prefixchars.append(('fixed', 1, urwid.SolidFill(c))) + + spacer = urwid.Columns(prefixchars, box_columns=range(length)) + spacer = urwid.AttrMap(spacer, self.arrow_bars_att) + return ('fixed', length, spacer) + + def _get_arrowhead_aligner(self): + if self.message.has_replies(): + aligner = u'\u2502' + else: + aligner = ' ' + aligner = urwid.SolidFill(aligner) + return ('fixed', 1, urwid.AttrMap(aligner, self.arrow_bars_att)) + + def selectable(self): + return True + + def keypress(self, size, key): + return self.pile.keypress(size, key) + + def get_message(self): + """get contained :class`~alot.db.message.Message`""" + return self.message + + def get_email(self): + """get contained :class:`email <email.Message>`""" + return self.message.get_email() + + +class MessageSummaryWidget(urwid.WidgetWrap): + """ + one line summary of a :class:`~alot.db.message.Message`. + """ + + def __init__(self, message, even=True): + """ + :param message: a message + :type message: alot.db.Message + :param even: even entry in a pile of messages? Used for theming. + :type even: bool + """ + self.message = message + self.even = even + if even: + attr = settings.get_theming_attribute('thread', 'summary', 'even') + else: + attr = settings.get_theming_attribute('thread', 'summary', 'odd') + focus_att = settings.get_theming_attribute('thread', 'summary', + 'focus') + cols = [] + + sumstr = self.__str__() + txt = urwid.Text(sumstr) + cols.append(txt) + + thread_tags = message.get_thread().get_tags(intersection=True) + outstanding_tags = set(message.get_tags()).difference(thread_tags) + tag_widgets = [TagWidget(t, attr, focus_att) for t in outstanding_tags] + tag_widgets.sort(tag_cmp, lambda tag_widget: tag_widget.translated) + for tag_widget in tag_widgets: + if not tag_widget.hidden: + cols.append(('fixed', tag_widget.width(), tag_widget)) + line = urwid.AttrMap(urwid.Columns(cols, dividechars=1), attr, + focus_att) + urwid.WidgetWrap.__init__(self, line) + + def __str__(self): + author, address = self.message.get_author() + date = self.message.get_datestring() + rep = author if author != '' else address + if date is not None: + rep += " (%s)" % date + return rep + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + +class MessageBodyWidget(urwid.AttrMap): + """ + displays printable parts of an email + """ + + def __init__(self, msg): + bodytxt = message.extract_body(msg) + att = settings.get_theming_attribute('thread', 'body') + urwid.AttrMap.__init__(self, urwid.Text(bodytxt), att) diff --git a/alot/widgets/utils.py b/alot/widgets/utils.py new file mode 100644 index 00000000..b50b2db9 --- /dev/null +++ b/alot/widgets/utils.py @@ -0,0 +1,64 @@ +# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +""" +Utility Widgets not specific to alot +""" +import urwid +import logging + + +class AttrFlipWidget(urwid.AttrMap): + """ + An AttrMap that can remember attributes to set + """ + def __init__(self, w, maps, init_map='normal'): + self.maps = maps + urwid.AttrMap.__init__(self, w, maps[init_map]) + + def set_map(self, attrstring): + self.set_attr_map({None: self.maps[attrstring]}) + + +class DialogBox(urwid.WidgetWrap): + def __init__(self, body, title, bodyattr=None, titleattr=None): + self.body = urwid.LineBox(body) + self.title = urwid.Text(title) + if titleattr is not None: + self.title = urwid.AttrMap(self.title, titleattr) + if bodyattr is not None: + self.body = urwid.AttrMap(self.body, bodyattr) + + box = urwid.Overlay(self.title, self.body, + align='center', + valign='top', + width=len(title), + height=None, + ) + urwid.WidgetWrap.__init__(self, box) + + def selectable(self): + return self.body.selectable() + + def keypress(self, size, key): + return self.body.keypress(size, key) + + +class CatchKeyWidgetWrap(urwid.WidgetWrap): + def __init__(self, widget, key, on_catch, relay_rest=True): + urwid.WidgetWrap.__init__(self, widget) + self.key = key + self.relay = relay_rest + self.on_catch = on_catch + + def selectable(self): + return True + + def keypress(self, size, key): + logging.debug('CATCH KEY: %s' % key) + logging.debug('relay: %s' % self.relay) + if key == self.key: + self.on_catch() + elif self._w.selectable() and self.relay: + return self._w.keypress(size, key) |