view browser_side/list_manager.py @ 328:835a8ae799e7

Add notifications support, fixes bug 7.
author Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
date Sat, 23 Feb 2013 16:27:32 +0100
parents 0b7934e75e76
children 2067d6241927
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
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

from base_panels import PopupMenuPanel


# 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 ListManager():
    """A manager for sub-panels to assign elements to lists."""

    def __init__(self, parent, keys_dict={}, contact_list=[], offsets={}, style={}):
        """
        @param parent: FlexTable parent widget for the manager
        @param keys_dict: dict with the contact keys mapped to data
        @param contact_list: list of string (the contact JID userhosts)
        @param offsets: dict to set widget positions offset within parent
        - "x_first": the x offset for the first widget's row on the grid
        - "x": the x offset for all widgets rows, except the first one if "x_first" is defined
        - "y": the y offset for all widgets columns on the grid
        """
        self._parent = parent
        if isinstance(keys_dict, set) or isinstance(keys_dict, list):
            tmp = {}
            for key in keys_dict:
                tmp[key] = {}
            keys_dict = tmp
        self.__keys_dict = keys_dict
        if isinstance(contact_list, set):
            contact_list = list(contact_list)
        self.__list = contact_list
        self.__list.sort()
        # store the list of contacts that are not assigned 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.offsets = {"x_first": 0, "x": 0, "y": 0}
        if "x" in offsets and not "x_first" in offsets:
            offsets["x_first"] = offsets["x"]
        self.offsets.update(offsets)

        self.style = {
           "keyItem": "recipientTypeItem",
           "popupMenuItem": "recipientTypeItem",
           "buttonCell": "recipientButtonCell",
           "dragoverPanel": "dragover-recipientPanel",
           "keyPanel": "recipientPanel",
           "textBox": "recipientTextBox",
           "textBox-invalid": "recipientTextBox-invalid",
           "removeButton": "recipientRemoveButton",
        }
        self.style.update(style)

    def createWidgets(self, title_format="%s"):
        """Fill the parent grid with all the widgets (some may be hidden during the initialization)."""
        self.__children = {}
        for key in self.__keys_dict:
            self.addContactKey(key, title_format=title_format)

    def addContactKey(self, key, dict_={}, title_format="%s"):
        if key not in self.__keys_dict:
            self.__keys_dict[key] = dict_
        # copy the key to its associated sub-map
        self.__keys_dict[key]["title"] = key
        self._addChild(self.__keys_dict[key], title_format)

    def removeContactKey(self, key):
        """Remove a list panel and all its associated data."""
        contacts = self.__children[key]["panel"].getContacts()
        (y, x) = self._parent.getIndex(self.__children[key]["button"])
        self._parent.removeRow(y)
        del self.__children[key]
        del self.__keys_dict[key]
        self.addToRemainingList(contacts)

    def _addChild(self, entry, title_format):
        """Add a button and FlowPanel for the corresponding map entry."""
        button = Button(title_format % entry["title"])
        button.setStyleName(self.style["keyItem"])
        if hasattr(entry, "desc"):
            button.setTitle(entry["desc"])
        if not "optional" in entry:
            entry["optional"] = False
        button.setVisible(not entry["optional"])
        y = len(self.__children) + self.offsets["y"]
        x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"]

        self._parent.insertRow(y)
        self._parent.setWidget(y, x, button)
        self._parent.getCellFormatter().setStyleName(y, x, self.style["buttonCell"])

        _child = ListPanel(self, entry, self.style)
        self._parent.setWidget(y, x + 1, _child)

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

        if hasattr(self, "popup_menu"):
            # this is done if self.registerPopupMenuPanel has been called yet
            self.popup_menu.registerClickSender(button)

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

    def setContactPanelVisible(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)

    @property
    def list(self):
        """Return the full list of potential contacts."""
        return self.__list

    @property
    def keys(self):
        return self.__keys_dict.keys()

    @property
    def keys_dict(self):
        return self.__keys_dict

    @property
    def remaining_list(self):
        """Return the contacts 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, contacts):
        """Remove contacts after they have been added to a sub-panel."""
        if not isinstance(contacts, list):
            contacts = [contacts]
        for contact_ in contacts:
            if contact_ in self.__remaining_list:
                self.__remaining_list.remove(contact_)

    def addToRemainingList(self, contacts, ignore_key=None):
        """Add contacts after they have been removed from a sub-panel."""
        if not isinstance(contacts, list):
            contacts = [contacts]
        assigned_contacts = set()
        assigned_map = self.getContacts()
        for key_ in assigned_map.keys():
            if ignore_key is not None and key_ == ignore_key:
                continue
            assigned_contacts.update(assigned_map[key_])
        for contact_ in contacts:
            if contact_ not in self.__list or contact_ in self.__remaining_list:
                continue
            if contact_ in assigned_contacts:
                continue  # the contact is assigned somewhere else
            self.__remaining_list.append(contact_)
            self.setRemainingListUnsorted()

    def setContacts(self, _map={}):
        """Set the contacts for each contact key."""
        for key in self.__keys_dict:
            if key in _map:
                self.__children[key]["panel"].setContacts(_map[key])
            else:
                self.__children[key]["panel"].setContacts([])
        self._refresh()

    def getContacts(self):
        """Get the contacts for all the lists.
        @return: a mapping between keys and contact lists."""
        _map = {}
        for key in self.__children:
            _map[key] = self.__children[key]["panel"].getContacts()
        return _map

    @property
    def target_drop_cell(self):
        """@return: the panel where something has been dropped."""
        return self.target_drop_cell

    @target_drop_cell.setter
    def target_drop_cell(self, target_drop_cell):
        """@param: target_drop_cell: the panel where something has been dropped."""
        self.target_drop_cell = target_drop_cell

    def registerPopupMenuPanel(self, entries, hide, callback):
        "Register a popup menu panel that will be bound to all contact keys elements."
        self.popup_menu = PopupMenuPanel(entries=entries, hide=hide, callback=callback, style={"item": self.style["popupMenuItem"]})


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

    def __init__(self, parent, event_cbs, style):
        AutoCompleteTextBox.__init__(self)
        DragWidget.__init__(self)
        self._parent = parent
        self.event_cbs = event_cbs
        self.style = style
        self.addMouseListener(self)
        self.addFocusListener(self)
        self.addChangeListener(self)
        self.addStyleName(style["textBox"])
        self.reset()

    def reset(self):
        self.setText("")
        self.setValid()

    def setValid(self, valid=True):
        if self.getText() == "":
            valid = True
        if valid:
            self.removeStyleName(self.style["textBox-invalid"])
        else:
            self.addStyleName(self.style["textBox-invalid"])
        self.valid = valid

    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(), "CONTACT_TEXTBOX"))
        self.setSelectionRange(len(self.getText()), 0)

    def onDragEnd(self, event):
        if self.getText() == "":
            return
        target = self._parent.target_drop_cell  # parent or another ListPanel
        self.event_cbs["drop"](self, target)

    def setRemoveButton(self):

        def remove_cb(sender):
            """Callback for the button to remove this contact."""
            self._parent.remove(self)
            self._parent.remove(self.remove_btn)
            self.event_cbs["remove"](self)

        self.remove_btn = Button(REMOVE_BUTTON, remove_cb, Visible=False)
        self.remove_btn.setStyleName(self.style["removeButton"])
        self._parent.add(self.remove_btn)

    def removeOrReset(self):
        if hasattr(self, "remove_btn"):
            self.remove_btn.click()
        else:
            self.reset()

    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 onFocus(self, sender):
        sender.setSelectionRange(0, len(self.getText()))
        self.event_cbs["focus"](sender)

    def validate(self):
        self.setSelectionRange(len(self.getText()), 0)
        self.event_cbs["validate"](self)

    def onChange(self, sender):
        """The textbox or list selection is changed"""
        if isinstance(sender, ListBox):
            AutoCompleteTextBox.onChange(self, sender)
        self.validate()

    def onClick(self, sender):
        """The list is clicked"""
        AutoCompleteTextBox.onClick(self, sender)
        self.validate()

    def onKeyUp(self, sender, keycode, modifiers):
        """Listen for ENTER key stroke"""
        AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers)
        if keycode == KEY_ENTER:
            self.validate()


class DropCell(DropWidget):
    """A cell where you can drop widgets. This class is NOT generic because of
    onDrop which uses methods from ListPanel. 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, drop_cbs):
        DropWidget.__init__(self)
        self.drop_cbs = drop_cbs

    def onDragEnter(self, event):
        self.addStyleName(self.style["dragoverPanel"])
        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(self.style["dragoverPanel"])

    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 in self.drop_cbs.keys():
            self.drop_cbs[item_type](self, item)
        self.removeStyleName(self.style["dragoverPanel"])


VALID = 1
INVALID = 2
DELETE = 3


class ListPanel(FlowPanel, DropCell):
    """Sub-panel used for each contact key. Beware that pyjamas.ui.FlowPanel
    is not fully implemented yet and can not be used with pyjamas.ui.Label."""

    def __init__(self, parent, entry, style={}):
        """Initialization with a button and a DragAutoCompleteTextBox."""
        FlowPanel.__init__(self, Visible=(False if entry["optional"] else True))
        drop_cbs = {"GROUP": lambda panel, item: self.addContact("@%s" % item),
                    "CONTACT": lambda panel, item: self.addContact(item),
                    "CONTACT_TEXTBOX": lambda panel, item: self.setTargetDropCell(panel)
                    }
        DropCell.__init__(self, drop_cbs)
        self.style = style
        self.addStyleName(self.style["keyPanel"])
        self._parent = parent
        self.key = entry["title"]
        self._addTextBox()

    def _addTextBox(self, switchPrevious=False):
        """Add a text box to the last position. If switchPrevious is True, simulate
        an insertion before the current last textbox by copying the text and valid state.
        @return: the created textbox or the previous one if switchPrevious is True.
        """
        if hasattr(self, "_last_textbox"):
            if self._last_textbox.getText() == "":
                return
            self._last_textbox.setRemoveButton()
        else:
            switchPrevious = False

        def focus_cb(sender):
            if sender != self._last_textbox:
                # save the current value before it's being modified
                self._parent.addToRemainingList(sender.getText(), ignore_key=self.key)
            sender.setCompletionItems(self._parent.remaining_list)

        def remove_cb(sender):
            """Callback for the button to remove this contact."""
            self._parent.addToRemainingList(sender.getText())
            self._parent.setRemainingListUnsorted()
            self._last_textbox.setFocus(True)

        def drop_cb(sender, target):
            """Callback when the textbox is drag-n-dropped."""
            parent = sender._parent
            if target != parent and target.addContact(sender.getText()):
                sender.removeOrReset()
            else:
                parent._parent.removeFromRemainingList(sender.getText())

        events_cbs = {"focus": focus_cb, "validate": self.addContact, "remove": remove_cb, "drop": drop_cb}
        textbox = DragAutoCompleteTextBox(self, events_cbs, self.style)
        self.add(textbox)
        if switchPrevious:
            textbox.setText(self._last_textbox.getText())
            textbox.setValid(self._last_textbox.valid)
            self._last_textbox.reset()
            previous = self._last_textbox
        self._last_textbox = textbox
        return previous if switchPrevious else textbox

    def _checkContact(self, contact, modify):
        """
        @param contact: the contact to check
        @param modify: True if the contact is being modified
        @return:
        - VALID if the contact is valid
        - INVALID if the contact is not valid but can be displayed
        - DELETE if the contact should not be displayed at all
        """
        def countItemInList(list_, item):
            """For some reason the built-in count function doesn't work..."""
            count = 0
            for elem in list_:
                if elem == item:
                    count += 1
            return count
        if contact is None or contact == "":
            return DELETE
        if countItemInList(self.getContacts(), contact) > (1 if modify else 0):
            return DELETE
        return VALID if contact in self._parent.list else INVALID

    def addContact(self, contact, sender=None):
        """The first parameter type is checked, so it is also possible to call addContact(sender).
        If contact is not defined, sender.getText() is used. If sender is not defined, contact will
        be written to the last textbox and a new textbox is added afterward.
        @param contact: unicode
        @param sender: DragAutoCompleteTextBox instance
        """
        if isinstance(contact, DragAutoCompleteTextBox):
            sender = contact
            contact = sender.getText()
        valid = self._checkContact(contact, sender is not None)
        if sender is None:
            # method has been called to modify but to add a contact
            if valid == VALID:
                # eventually insert before the last textbox if it's not empty
                sender = self._addTextBox(True) if self._last_textbox.getText() != "" else self._last_textbox
                sender.setText(contact)
        else:
            sender.setValid(valid == VALID)
        if valid != VALID:
            if sender is not None and valid == DELETE:
                sender.removeOrReset()
            return False
        if sender == self._last_textbox:
            self._addTextBox()
        try:
            sender.setVisibleLength(len(contact))
        except:
            # IndexSizeError: Index or size is negative or greater than the allowed amount
            print "FIXME: len(%s) returns %d... javascript bug?" % (contact, len(contact))
        self._parent.removeFromRemainingList(contact)
        self._last_textbox.setFocus(True)
        return True

    def emptyContacts(self):
        """Empty the list of contacts."""
        for child in self.getChildren():
            if hasattr(child, "remove_btn"):
                child.remove_btn.click()

    def setContacts(self, tab):
        """Set the contacts."""
        self.emptyContacts()
        if isinstance(tab, set):
            tab = list(tab)
        tab.sort()
        for contact in tab:
            self.addContact(contact)

    def getContacts(self):
        """Get the contacts
        @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

    @property
    def target_drop_cell(self):
        """@return: the panel where something has been dropped."""
        return self._parent.target_drop_cell

    @target_drop_cell.setter
    def target_drop_cell(self, target_drop_cell):
        """@param target_drop_cell: the panel where something has been dropped."""
        self.setTargetDropCell(target_drop_cell)

    def setTargetDropCell(self, target_drop_cell):
        self._parent.target_drop_cell = target_drop_cell


class ContactChooserPanel(DialogBox):
    """Display the contacts 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 contact key"""
        DialogBox.__init__(self, autoHide=False, centered=True, **kwargs)
        self.setHTML("Select contacts")
        self.manager = manager
        self.listboxes = {}
        self.contacts = manager.getContacts()

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

        grid = Grid(2, len(self.manager.keys_dict))
        index = -1
        for key in self.manager.keys_dict:
            index += 1
            grid.add(Label("%s:" % self.manager.keys_dict[key]["desc"]), 0, index)
            listbox = ListBox()
            listbox.setMultipleSelect(True)
            listbox.setVisibleItemCount(15)
            listbox.addItem(EMPTY_SELECTION_ITEM)
            for element in manager.list:
                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 self.manager.keys_dict:
            listbox = self.listboxes[key]
            for i in xrange(0, listbox.getItemCount()):
                if listbox.getItemText(i) in self.contacts[key]:
                    listbox.setItemSelected(i, "selected")
                else:
                    listbox.setItemSelected(i, "")

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