alot/widgets/globals.py

Summary

Maintainability
D
2 days
Test Coverage
# 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 :class:`urwid.Widget` used in more than one mode.
"""
import re
import operator
import urwid

from ..helper import string_decode
from ..settings.const import settings
from ..db.attachment import Attachment
from ..errors import CompletionError


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,
                 separator=' ', choices_to_return=None):
        self.choices = choices
        self.choices_to_return = choices_to_return or {}
        self.callback = callback
        self.cancel = cancel
        self.select = select
        self.separator = separator

        items = []
        for k, v in choices.items():
            if v == select and select is not None:
                items += ['[', k, ']:', v]
            else:
                items += ['(', k, '):', v]
            items += [self.separator]
        urwid.Text.__init__(self, items)

    def selectable(self):
        return True

    def keypress(self, size, key):
        if key == 'enter' and self.select is not None:
            self.callback(self.select)
        elif key == 'esc' and self.cancel is not None:
            self.callback(self.cancel)
        elif key in self.choices_to_return:
            self.callback(self.choices_to_return[key])
        elif key in self.choices:
            self.callback(self.choices[key])
        else:
            return key


class CompleteEdit(urwid.Edit):
    """
    This is a vamped-up :class:`urwid.Edit` widget that allows for
    tab-completion using :class:`~alot.completion.Completer` objects

    These widgets are meant to be used as user input prompts and hence
    react to 'return' key presses by calling a 'on_exit' callback
    that processes the current text value.

    The interpretation of some keypresses is hard-wired:
        :enter: calls 'on_exit' callback with current value
        :esc/ctrl g: calls 'on_exit' with value `None`, which can be
                     interpreted as cancellation
        :tab: calls the completer and tabs forward in the result list
        :shift tab: tabs backward in the result list
        :up/down: move in the local input history
        :ctrl f/b: moves curser one character to the right/left
        :meta f/b shift right/left: moves the cursor one word to the right/left
        :ctrl a/e: moves curser to the beginning/end of the input
        :ctrl d: deletes the character under the cursor
        :meta d: deletes everything from the cursor to the end of the next word
        :meta delete/backspace ctrl w: deletes everything from the cursor to
                                       the beginning of the current word
        :ctrl k: deletes everything from the cursor to the end of the input
        :ctrl u: deletes everything from the cursor to the beginning of the
                 input
    """

    def __init__(self, completer, on_exit,
                 on_error=None,
                 edit_text='',
                 history=None,
                 **kwargs):
        """
        :param completer: completer to use
        :type completer: alot.completion.Completer
        :param on_exit: "enter"-callback that interprets the input (str)
        :type on_exit: callable
        :param on_error: callback that handles
                         :class:`alot.errors.CompletionErrors`
        :type on_error: callback
        :param edit_text: initial text
        :type edit_text: str
        :param history: initial command history
        :type history: list or str
        """
        self.completer = completer
        self.on_exit = on_exit
        self.on_error = on_error
        self.history = list(history)  # we temporarily add stuff here
        self.historypos = None
        self.focus_in_clist = 0

        if not isinstance(edit_text, str):
            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 self.completions is None:
                self.completions = [(self.edit_text, self.edit_pos)]
                try:
                    self.completions += self.completer.complete(self.edit_text,
                                                                self.edit_pos)
                    self.focus_in_clist = 1
                except CompletionError as e:
                    if self.on_error is not None:
                        self.on_error(e)

            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.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 == '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 == 'enter':
            self.on_exit(self.edit_text)
        elif key in ('ctrl g', 'esc'):
            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))
        elif key == 'ctrl f':
            self.set_edit_pos(min(self.edit_pos+1, len(self.edit_text)))
        elif key == 'ctrl b':
            self.set_edit_pos(max(self.edit_pos-1, 0))
        elif key == 'ctrl k':
            self.edit_text = self.edit_text[:self.edit_pos]
        elif key == 'ctrl u':
            self.edit_text = self.edit_text[self.edit_pos:]
            self.set_edit_pos(0)
        elif key == 'ctrl d':
            self.edit_text = (self.edit_text[:self.edit_pos] +
                              self.edit_text[self.edit_pos+1:])
        elif key in ('meta f', 'shift right'):
            self.move_to_next_word(forward=True)
        elif key in ('meta b', 'shift left'):
            self.move_to_next_word(forward=False)
        elif key == 'meta d':
            start_pos = self.edit_pos
            end_pos = self.move_to_next_word(forward=True)
            if end_pos is not None:
                self.edit_text = (self.edit_text[:start_pos] +
                                  self.edit_text[end_pos:])
                self.set_edit_pos(start_pos)
        elif key in ('meta delete', 'meta backspace', 'ctrl w'):
            end_pos = self.edit_pos
            start_pos = self.move_to_next_word(forward=False)
            if start_pos is not None:
                self.edit_text = (self.edit_text[:start_pos] +
                                  self.edit_text[end_pos:])
                self.set_edit_pos(start_pos)
        else:
            result = urwid.Edit.keypress(self, size, key)
            self.completions = None
            return result

    def move_to_next_word(self, forward=True):
        if forward:
            match_iterator = re.finditer(r'(\b\W+|$)', self.edit_text,
                                         flags=re.UNICODE)
            match_positions = [m.start() for m in match_iterator]
            op = operator.gt
        else:
            match_iterator = re.finditer(r'(\w+\b|^)', self.edit_text,
                                         flags=re.UNICODE)
            match_positions = reversed([m.start() for m in match_iterator])
            op = operator.lt
        for pos in match_positions:
            if op(pos, self.edit_pos):
                self.set_edit_pos(pos)
                return pos


class HeadersList(urwid.WidgetWrap):
    """ renders a pile of header values as key/value list """

    def __init__(self, headerslist, key_attr, value_attr, gaps_attr=None):
        """
        :param headerslist: list of key/value pairs to display
        :type headerslist: list of (str, str)
        :param key_attr: theming attribute to use for keys
        :type key_attr: urwid.AttrSpec
        :param value_attr: theming attribute to use for values
        :type value_attr: urwid.AttrSpec
        :param gaps_attr: theming attribute to wrap lines in
        :type gaps_attr: urwid.AttrSpec
        """
        self.headers = headerslist
        self.key_attr = key_attr
        self.value_attr = value_attr
        pile = urwid.Pile(self._build_lines(headerslist))
        if gaps_attr is None:
            gaps_attr = key_attr
        pile = urwid.AttrMap(pile, gaps_attr)
        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.

    Attributes that should be considered publicly readable:
        :attr tag: the notmuch tag
        :type tag: str
    """

    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')
        self.__hash = hash((self.translated, self.txt))
        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 set_focussed(self):
        self.set_attr_map(self.attmaps['focus'])

    def set_unfocussed(self):
        self.set_attr_map(self.attmaps['normal'])

    def __cmp(self, other, comparitor):
        """Shared comparison method."""
        if not isinstance(other, TagWidget):
            return NotImplemented

        self_len = len(self.translated)
        oth_len = len(other.translated)

        if (self_len == 1) is not (oth_len == 1):
            return comparitor(self_len, oth_len)
        return comparitor(self.translated.lower(), other.translated.lower())

    def __lt__(self, other):
        """Groups tags of 1 character first, then alphabetically.

        This groups tags unicode characters at the begnining.
        """
        return self.__cmp(other, operator.lt)

    def __gt__(self, other):
        return self.__cmp(other, operator.gt)

    def __ge__(self, other):
        return self.__cmp(other, operator.ge)

    def __le__(self, other):
        return self.__cmp(other, operator.le)

    def __eq__(self, other):
        if not isinstance(other, TagWidget):
            return NotImplemented
        if len(self.translated) != len(other.translated):
            return False
        return self.translated.lower() == other.translated.lower()

    def __ne__(self, other):
        if not isinstance(other, TagWidget):
            return NotImplemented
        return self.translated.lower() != other.translated.lower()

    def __hash__(self):
        return self.__hash