view browser_side/recipients.py @ 241:86055ccf69c3

browser_side: added class PopupMenuPanel to manage more complex context menu
author souliane <souliane@mailoo.org>
date Tue, 15 Oct 2013 13:36:51 +0200
parents 0ed09cc0566f
children
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

"""
Libervia: a Salut à Toi frontend
Copyright (C) 2013 Adrien Cossa <souliane@mailoo.org>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""

from pyjamas.ui.Grid import Grid
from pyjamas.ui.Button import Button
from pyjamas.ui.ListBox import ListBox
from pyjamas.ui.FlowPanel import FlowPanel
from pyjamas.ui.AutoComplete import AutoCompleteTextBox
from pyjamas.ui.Label import Label
from pyjamas.ui.HorizontalPanel import HorizontalPanel
from pyjamas.ui.VerticalPanel import VerticalPanel
from pyjamas.ui.DialogBox import DialogBox
from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler
from pyjamas.ui.MouseListener import MouseHandler
from pyjamas.ui.FocusListener import FocusHandler
from pyjamas.ui.DropWidget import DropWidget
from pyjamas.ui.DragWidget import DragWidget
from pyjamas.Timer import Timer
from pyjamas import DOM
import panels

# Map the recipient types to their properties. For convenience, the key
# value is copied during the initialization to its associated sub-map,
# stored in the value of a new entry which uses "title" as its key.
RECIPIENT_TYPES = {"To": {"desc": "Direct recipients", "optional": False},
                   "Cc": {"desc": "Carbon copies", "optional": True},
                   "Bcc": {"desc": "Blind carbon copies", "optional": True}}

# HTML content for the removal button (image or text)
REMOVE_BUTTON = '<span class="richTextRemoveIcon">x</span>'

# Item to be considered for an empty list box selection.
# Could be whatever which doesn't look like a JID or a group name.
EMPTY_SELECTION_ITEM = ""


class RecipientManager():
    """A manager for sub-panels to set the recipients for each recipient type."""

    def __init__(self, parent):
        """"""
        self._parent = parent
        self.host = parent.host

        self.__list = []
        # TODO: be sure we also display empty groups and disconnected contacts + their groups
        # store the full list of potential recipients (groups and contacts)
        self.__list.extend("@%s" % group for group in self.host.contact_panel.getGroups())
        self.__list.extend(contact for contact in self.host.contact_panel.getContacts())
        self.__list.sort()
        # store the list of recipients that are not selected yet
        self.__remaining_list = []
        self.__remaining_list.extend(self.__list)
        # mark a change to sort the list before it's used
        self.__remaining_list_sorted = True

        self.recipient_menu = panels.PopupMenuPanel(entries=RECIPIENT_TYPES,
                                                    hide=lambda sender, key: self.__children[key]["panel"].isVisible(),
                                                    callback=self.setRecipientPanelVisible,
                                                    item_style="recipientTypeItem")

    def createWidgets(self):
        """Fill the parent grid with all the widgets but
        only show those for non optional recipient types."""
        self.__children = {}
        for key in RECIPIENT_TYPES:
            # copy the key to its associated sub-map
            RECIPIENT_TYPES[key]["title"] = key
            self._addChild(RECIPIENT_TYPES[key])

    def _addChild(self, entry):
        """Add a button and FlowPanel for the corresponding map entry."""
        button = Button("%s: " % entry["title"])
        self.recipient_menu.registerClickSender(button)
        button.addStyleName("recipientTypeItem")
        button.setTitle(entry["desc"])
        button.setVisible(not entry["optional"])
        self._parent.setWidget(len(self.__children), 0, button)
        self._parent.getCellFormatter().setStyleName(len(self.__children), 0, "recipientButtonCell")

        _child = RecipientTypePanel(self, entry)
        self._parent.setWidget(len(self.__children), 1, _child)

        self.__children[entry["title"]] = {}
        self.__children[entry["title"]]["button"] = button
        self.__children[entry["title"]]["panel"] = _child

    def _refresh(self):
        """Set visible the sub-panels that are non optional or non empty, hide the rest."""
        for key in self.__children:
            self.setRecipientPanelVisible(key, False)
        _map = self.getRecipients()
        for key in _map:
            if len(_map[key]) > 0 or not RECIPIENT_TYPES[key]["optional"]:
                self.setRecipientPanelVisible(key, True)

    def setRecipientPanelVisible(self, key, visible=True, sender=None):
        """Do not remove the "sender" param as it is needed for the context menu."""
        self.__children[key]["button"].setVisible(visible)
        self.__children[key]["panel"].setVisible(visible)

    def getList(self):
        """Return the full list of potential recipients."""
        return self.__list

    def getRemainingList(self):
        """Return the recipients that have not been selected yet."""
        if not self.__remaining_list_sorted:
            self.__remaining_list_sorted = True
            self.__remaining_list.sort()
        return self.__remaining_list

    def setRemainingListUnsorted(self):
        """Mark a change (deletion) so the list will be sorted before it's used."""
        self.__remaining_list_sorted = False

    def removeFromRemainingList(self, recipient):
        """Remove an available recipient after it has been added to a sub-panel."""
        if recipient in self.__remaining_list:
            self.__remaining_list.remove(recipient)

    def addToRemainingList(self, recipient):
        """Add a recipient after it has been removed from a sub-panel."""
        self.__remaining_list.append(recipient)
        self.__sort_remaining_list = True

    def selectRecipients(self):
        """Display the recipients chooser dialog. This has been implemented while
        prototyping and is currently not used. Left for an eventual later use.
        Replaced by the popup menu which allows to add a panel for Cc or Bcc.
        """
        RecipientChooserPanel(self)

    def setRecipients(self, _map={}):
        """Set the recipients for each recipient types."""
        for key in RECIPIENT_TYPES:
            if key in _map:
                self.__children[key]["panel"].setRecipients(_map[key])
            else:
                self.__children[key]["panel"].setRecipients([])
        self._refresh()

    def getRecipients(self):
        """Get the recipients for all the recipient types.
        @return: a mapping between keys from RECIPIENT_TYPES.keys() and recipient arrays."""
        _map = {}
        for key in self.__children:
            _map[key] = self.__children[key]["panel"].getRecipients()
        return _map

    def setTargetDropCell(self, panel):
        """Used to drap and drop the recipients from one panel to another."""
        self._target_drop_cell = panel

    def getTargetDropCell(self):
        """Used to drap and drop the recipients from one panel to another."""
        return self._target_drop_cell


class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget):
    """A draggable AutoCompleteTextBox which is used for representing a recipient.
    This class is NOT generic because of the onDragEnd method which call methods
    from RecipientTypePanel. It's probably not reusable for another scenario.
    """

    def __init__(self):
        AutoCompleteTextBox.__init__(self)
        DragWidget.__init__(self)

    def onDragStart(self, event):
        dt = event.dataTransfer
        # The group prefix "@" is already in text so we use only the "CONTACT" type
        dt.setData('text/plain', "%s\n%s" % (self.getText(), "RECIPIENT_TEXTBOX"))

    def onDragEnd(self, event):
        if self.getText() == "":
            return
        # get the RecipientTypePanel containing self
        parent = self.getParent()
        while parent is not None and not isinstance(parent, RecipientTypePanel):
            parent = parent.getParent()
        if parent is None:
            return
        # it will return parent again or another RecipientTypePanel
        target = parent.getTargetDropCell()
        if target == parent:
            return
        target.addRecipient(self.getText())
        if hasattr(self, "remove_btn"):
            # self is not the last textbox, just remove it
            self.remove_btn.click()
        else:
            # reset the value of the last textbox
            self.setText("")


class DropCell(DropWidget):
    """A cell where you can drop widgets. This class is NOT generic because of
    onDrop which uses methods from RecipientTypePanel. It has been created to
    separate the drag and drop methods from the others and add a bit of
    lisibility, but it's probably not reusable for another scenario.
    """

    def __init__(self, host):
        DropWidget.__init__(self)

    def onDragEnter(self, event):
        self.addStyleName('dragover-recipientPanel')
        DOM.eventPreventDefault(event)

    def onDragLeave(self, event):
        if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop()\
            or event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1\
            or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1:
            # We check that we are inside widget's box, and we don't remove the style in this case because
            # if the mouse is over a widget inside the DropWidget, we don't want the style to be removed
            self.removeStyleName('dragover-recipientPanel')

    def onDragOver(self, event):
        DOM.eventPreventDefault(event)

    def onDrop(self, event):
        DOM.eventPreventDefault(event)
        dt = event.dataTransfer
        # 'text', 'text/plain', and 'Text' are equivalent.
        item, item_type = dt.getData("text/plain").split('\n')  # Workaround for webkit, only text/plain seems to be managed
        if item_type and item_type[-1] == '\0':  # Workaround for what looks like a pyjamas bug: the \0 should not be there, and
            item_type = item_type[:-1]           # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report
        if item_type == "GROUP":
            item = "@%s" % item
            self.addRecipient(item)
        elif item_type == "CONTACT":
            self.addRecipient(item)
        elif item_type == "RECIPIENT_TEXTBOX":
            self._parent.setTargetDropCell(self)
            pass
        else:
            return
        self.removeStyleName('dragover-recipientPanel')


class RecipientTypePanel(FlowPanel, KeyboardHandler, MouseHandler, FocusHandler, DropCell):
    """Sub-panel used for each recipient type. Beware that pyjamas.ui.FlowPanel
    is not fully implemented yet and can not be used with pyjamas.ui.Label."""

    def __init__(self, parent, entry):
        """Initialization with a button and a DragAutoCompleteTextBox."""
        FlowPanel.__init__(self, Visible=(False if entry["optional"] else True))
        DropCell.__init__(self)
        self.addStyleName("recipientPanel")
        self._parent = parent
        self.host = parent.host

        self._last_textbox = None
        self.__remove_cbs = []

        self.__resetLastTextBox()

    def __resetLastTextBox(self, setFocus=True):
        """Reset the last input text box with KeyboardListener."""
        if self._last_textbox is None:
            self._last_textbox = DragAutoCompleteTextBox()
            self._last_textbox.addStyleName("recipientTextBox")
            self._last_textbox.addKeyboardListener(self)
            self._last_textbox.addFocusListener(self)
        else:
            # ensure we move it to the last position
            self.remove(self._last_textbox)
        self._last_textbox.setText("")
        self.add(self._last_textbox)
        self._last_textbox.setFocus(setFocus)

    def onKeyUp(self, sender, keycode, modifiers):
        """This is called after DragAutoCompleteTextBox.onKeyDown,
        so the completion is done before we reset the text box."""
        if not isinstance(sender, DragAutoCompleteTextBox):
            return
        if keycode == KEY_ENTER:
            self.onLostFocus(sender)
            self._last_textbox.setFocus(True)

    def onFocus(self, sender):
        """A DragAutoCompleteTextBox has the focus."""
        if not isinstance(sender, DragAutoCompleteTextBox):
            return
        if sender != self._last_textbox:
            # save the current value before it's being modified
            text = sender.getText()
            self._focused_textbox_previous_value = text
            self._parent.addToRemainingList(text)
        sender.setCompletionItems(self._parent.getRemainingList())

    def onLostFocus(self, sender):
        """A DragAutoCompleteTextBox has lost the focus."""
        if not isinstance(sender, DragAutoCompleteTextBox):
            return
        self.changeRecipient(sender)

    def changeRecipient(self, sender):
        """Modify the value of a DragAutoCompleteTextBox."""
        text = sender.getText()
        if sender == self._last_textbox:
            if text != "":
                # a new box is added and the last textbox is reinitialized
                self.addRecipient(text, setFocusToLastTextBox=False)
            return
        if text == "":
            sender.remove_btn.click()
            return
        # text = new value needs to be removed 1. if the value is unchanged, because we
        # added it when we took the focus, or 2. if the value is changed (obvious!)
        self._parent.removeFromRemainingList(text)
        if text == self._focused_textbox_previous_value:
            return
        sender.setVisibleLength(len(text))
        self._parent.addToRemainingList(self._focused_textbox_previous_value)

    def addRecipient(self, recipient, resetLastTextBox=True, setFocusToLastTextBox=True):
        """Add a recipient and signal it to self._parent panel."""
        if recipient is None or recipient == "":
            return
        textbox = DragAutoCompleteTextBox()
        textbox.addStyleName("recipientTextBox")
        textbox.setText(recipient)
        self.add(textbox)
        try:
            textbox.setVisibleLength(len(str(recipient)))
        except:
            #TODO: . how come could this happen?! len(recipient) is sometimes 0 but recipient is not empty
            print "len(recipient) returns %d where recipient == %s..." % (len(str(recipient)), str(recipient))
        self._parent.removeFromRemainingList(recipient)

        remove_btn = Button(REMOVE_BUTTON, Visible=False)
        remove_btn.addStyleName("recipientRemoveButton")

        def remove_cb(sender):
            """Callback for the button to remove this recipient."""
            self.remove(textbox)
            self.remove(remove_btn)
            self._parent.addToRemainingList(recipient)
            self._parent.setRemainingListUnsorted()

        remove_btn.addClickListener(remove_cb)
        self.__remove_cbs.append(remove_cb)
        self.add(remove_btn)
        self.__resetLastTextBox(setFocus=setFocusToLastTextBox)

        textbox.remove_btn = remove_btn
        textbox.addMouseListener(self)
        textbox.addFocusListener(self)
        textbox.addKeyboardListener(self)

    def emptyRecipients(self):
        """Empty the list of recipients."""
        for remove_cb in self.__remove_cbs:
            remove_cb()
        self.__remove_cbs = []

    def onMouseMove(self, sender):
        """Mouse enters the area of a DragAutoCompleteTextBox."""
        if hasattr(sender, "remove_btn"):
            sender.remove_btn.setVisible(True)

    def onMouseLeave(self, sender):
        """Mouse leaves the area of a DragAutoCompleteTextBox."""
        if hasattr(sender, "remove_btn"):
            Timer(1500, lambda: sender.remove_btn.setVisible(False))

    def setRecipients(self, tab):
        """Set the recipients."""
        self.emptyRecipients()
        for recipient in tab:
            self.addRecipient(recipient, resetLastTextBox=False)
        self.__resetLastTextBox()

    def getRecipients(self):
        """Get the recipients
        @return: an array of string"""
        tab = []
        for widget in self.getChildren():
            if isinstance(widget, DragAutoCompleteTextBox):
                # not to be mixed with EMPTY_SELECTION_ITEM
                if widget.getText() != "":
                    tab.append(widget.getText())
        return tab

    def getTargetDropCell(self):
        """Returns self or another panel where something has been dropped."""
        return self._parent.getTargetDropCell()


class RecipientChooserPanel(DialogBox):
    """Display the recipients chooser dialog. This has been implemented while
    prototyping and is currently not used. Left for an eventual later use.
    Replaced by the popup menu which allows to add a panel for Cc or Bcc.
    """

    def __init__(self, manager, **kwargs):
        """Display a listbox for each recipient type"""
        DialogBox.__init__(self, autoHide=False, centered=True, **kwargs)
        self.setHTML("Select recipients")
        self.manager = manager
        self.listboxes = {}
        self.recipients = manager.getRecipients()

        container = VerticalPanel(Visible=True)
        container.addStyleName("marginAuto")

        grid = Grid(2, len(RECIPIENT_TYPES))
        index = -1
        for key in RECIPIENT_TYPES:
            index += 1
            grid.add(Label("%s:" % RECIPIENT_TYPES[key]["desc"]), 0, index)
            listbox = ListBox()
            listbox.setMultipleSelect(True)
            listbox.setVisibleItemCount(15)
            listbox.addItem(EMPTY_SELECTION_ITEM)
            for element in manager.getList():
                listbox.addItem(element)
            self.listboxes[key] = listbox
            grid.add(listbox, 1, index)
        self._reset()

        buttons = HorizontalPanel()
        buttons.addStyleName("marginAuto")
        btn_close = Button("Cancel", self.hide)
        buttons.add(btn_close)
        btn_reset = Button("Reset", self._reset)
        buttons.add(btn_reset)
        btn_ok = Button("OK", self._validate)
        buttons.add(btn_ok)

        container.add(grid)
        container.add(buttons)

        self.add(container)
        self.center()

    def _reset(self):
        """Reset the selections."""
        for key in RECIPIENT_TYPES:
            listbox = self.listboxes[key]
            for i in xrange(0, listbox.getItemCount()):
                if listbox.getItemText(i) in self.recipients[key]:
                    listbox.setItemSelected(i, "selected")
                else:
                    listbox.setItemSelected(i, "")

    def _validate(self):
        """Sets back the selected recipients to the good sub-panels."""
        _map = {}
        for key in RECIPIENT_TYPES:
            selections = self.listboxes[key].getSelectedItemText()
            if EMPTY_SELECTION_ITEM in selections:
                selections.remove(EMPTY_SELECTION_ITEM)
            _map[key] = selections
        self.manager.setRecipients(_map)
        self.hide()