view src/browser/sat_browser/list_manager.py @ 600:32dbbc941123 frontends_multi_profiles

browser_side: fixes the contact group manager
author souliane <souliane@mailoo.org>
date Fri, 06 Feb 2015 17:53:01 +0100
parents 97c72fe4a5f2
children c22b47d63fe2
line wrap: on
line source

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

# Libervia: a Salut à Toi frontend
# Copyright (C) 2013, 2014 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 sat.core.log import getLogger
log = getLogger(__name__)
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.KeyboardListener import KEY_ENTER
from pyjamas.ui.MouseListener import MouseHandler
from pyjamas.ui.FocusListener import FocusHandler
from pyjamas.ui.DropWidget import DropWidget
from pyjamas.Timer import Timer
from pyjamas import DOM

import base_panels
import base_widget

from sat_frontends.tools import jid


unicode = str  # FIXME: pyjamas workaround

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


# FIXME: dirty method and magic string to fix ASAP
def tryJID(obj):
    return jid.JID(obj) if (isinstance(obj, unicode) and not obj.startswith('@')) else obj


class ListManager(object):
    """A base class to manage one or several lists of items."""

    def __init__(self, container, keys=None, items=None, offsets=None, style=None):
        """
        @param container (FlexTable): FlexTable parent widget
        @param keys (dict{unicode: dict{unicode: unicode}}): dict binding items
            keys to their display config data.
        @param items (list): list of items
        @param offsets (dict): define widgets positions offsets within container:
            - "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
        @param style (dict): define CSS styles
        """
        self.container = container
        self.keys = {} if keys is None else keys
        self.items = [] if items is None else items
        self.items.sort()

        # store the list of items that are not assigned yet
        self.items_remaining = [item for item in self.items]
        self.items_remaining_sorted = True

        self.offsets = {"x_first": 0, "x": 0, "y": 0}
        if offsets is not None:
            if "x" in offsets and "x_first" not in offsets:
                offsets["x_first"] = offsets["x"]
            self.offsets.update(offsets)

        self.style = {"keyItem": "itemKey",
                      "popupMenuItem": "itemKey",
                      "buttonCell": "itemButtonCell",
                      "dragoverPanel": "itemPanel-dragover",
                      "keyPanel": "itemPanel",
                      "textBox": "itemTextBox",
                      "textBox-invalid": "itemTextBox-invalid",
                      "removeButton": "itemRemoveButton",
                      }
        if style is not None:
            self.style.update(style)

    def createWidgets(self, title_format="%s"):
        """Fill the container widget with one ListPanel per item key (some may be
        hidden during the initialization).

        @param title_format (unicode): format string for the title
        """
        self.children = {}
        for key in self.keys:
            self.addItemKey(key, title_format=title_format)

    def addItemKey(self, key, data=None, title_format="%s"):
        """Add to the container a Button and ListPanel for a new item key.

        @param key (unicode): item key
        @param data (dict{unicode: unicode}): config data
        """
        key_data = self.keys.setdefault(key, {})
        if data is not None:
            key_data.update(data)
        key_data["title"] = key  # copy the key to its associated sub-map

        button = Button(title_format % key)
        button.setStyleName(self.style["keyItem"])
        if hasattr(key_data, "desc"):
            button.setTitle(key_data["desc"])
        if "optional" not in key_data:
            key_data["optional"] = False
        button.setVisible(not key_data["optional"])
        y = len(self.children) + self.offsets["y"]
        x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"]

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

        _child = ListPanel(self, key_data, self.style)
        self.container.setWidget(y, x + 1, _child)

        self.children[key] = {}
        self.children[key]["button"] = button
        self.children[key]["panel"] = _child

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

    def removeItemKey(self, key):
        """Remove from the container a ListPanel representing an item key, and all
        its associated data.

        @param key (unicode): item key
        """
        items = self.children[key]["panel"].getItems()
        (y, x) = self.container.getIndex(self.children[key]["button"])
        self.container.removeRow(y)
        del self.children[key]
        del self.keys[key]
        self.addToRemainingList(items)

    def refresh(self, hide_everything=False):
        """Set visible the sub-panels that are non optional or non empty, hide
        the rest. Setting the attribute "hide_everything" to True you can also
        hide everything.

        @param hide_everything (boolean): set to True to hide everything
        """
        for key in self.children:
            self.setItemPanelVisible(key, False)
        if hide_everything:
            return
        for key, items in self.getItemsByKey().iteritems():
            if len(items) > 0 or not self.keys[key]["optional"]:
                self.setItemPanelVisible(key, True)

    def setVisible(self, visible):
        self.refresh(not visible)

    def setItemPanelVisible(self, key, visible=True, sender=None):
        """Set the item key's widgets visibility.

        @param key (unicode): item key
        @param visible (bool): set to True to display the widgets
        @param sender
        """
        self.children[key]["button"].setVisible(visible)
        self.children[key]["panel"].setVisible(visible)

    @property
    def items_remaining(self):
        """Return the unused items."""
        if not self.items_remaining_sorted:
            self.items_remaining.sort()
            self.items_remaining_sorted = True
        return self.items_remaining

    def setRemainingListUnsorted(self):
        """Mark the list of unused items as being unsorted."""
        self.items_remaining_sorted = False

    def removeFromRemainingList(self, items):
        """Remove some items from the list of unused items.

        @param items (list): items to be removed
        """
        for item in items:
            if item in self.items_remaining:
                self.items_remaining.remove(item)

    def addToRemainingList(self, items, ignore_key=None):
        """Add some items to the list of unused items. Check first if the
        items are really not used in any ListPanel.

        @param items (list): items to be removed
        @param ignore_key (unicode): item key to be ignored while checking
        """
        items_assigned = set()
        for key, current_items in self.getItemsByKey().iteritems():
            if ignore_key is not None and key == ignore_key:
                continue
            items_assigned.update(current_items)
        for item in items:
            if item not in self.items or item in self.items_remaining or item in items_assigned:
                continue
            self.items_remaining.append(item)
            self.setRemainingListUnsorted()

    def resetItems(self, data={}):
        """Repopulate all the lists (one per item key) with the given items.

        @param data (dict{unicode: list}): dict binding items keys to items.
        """
        for key in self.keys:
            if key in data:
                self.children[key]["panel"].resetItems(data[key])
            else:
                self.children[key]["panel"].resetItems([])
        self.refresh()

    def getItemsByKey(self):
        """Get all the items by key.

        @return: dict{unicode: set}
        """
        return {key: self.children[key]["panel"].getItems() for key in self.children}

    def getKeysByItem(self):
        """Get all the keys by item.

        @return: dict{object: set(unicode)}
        """
        result = {}
        for key in self.children:
            for item in self.children[key]["panel"].getItems():
                result.setdefault(item, set()).add(key)
        return result

    def registerPopupMenuPanel(self, entries, hide, callback):
        """Register a popup menu panel for the item keys buttons.

        @param entries (dict{unicode: dict{unicode: unicode}}): menu entries
        @param hide (callable): method to call in order to know if a menu item
            should be hidden from the menu. Takes in the button widget and the
            item key and returns a boolean.
        @param callback (callable): common callback for all menu items, takes in
            the button widget and the item key.
        """
        self.popup_menu = base_panels.PopupMenuPanel(entries, hide, callback, style={"item": self.style["popupMenuItem"]})


class DragAutoCompleteTextBox(AutoCompleteTextBox, base_widget.DragLabel, MouseHandler, FocusHandler):
    """A draggable AutoCompleteTextBox which is used for representing an item."""
    # XXX: this class is NOT generic because of the onDragEnd method which calls methods from ListPanel. It's probably not reusable for another scenario.

    def __init__(self, list_panel, event_cbs, style):
        """

        @param list_panel (ListPanel)
        @param event_cbs (list[callable])
        @param style (dict)
        """
        AutoCompleteTextBox.__init__(self)
        base_widget.DragLabel.__init__(self, '', 'CONTACT_TEXTBOX')  # The group prefix "@" is already in text so we use only the "CONTACT_TEXTBOX" type
        self.list_panel = list_panel
        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):
        self._text = self.getText()
        base_widget.DragLabel.onDragStart(self, event)
        self.list_panel.manager.target_drop_cell = None
        self.setSelectionRange(len(self.getText()), 0)

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

    def setRemoveButton(self):

        def remove_cb(sender):
            """Callback for the button to remove this item."""
            self.list_panel.remove(self)
            self.list_panel.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.list_panel.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 timer: 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
    readability, but it's probably not reusable for another scenario.
    """

    def __init__(self, drop_cbs):
        """

        @param drop_cbs (list[callable])
        """
        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):
    """Panel used for listing items sharing the same key. The key is showed as
    a Button to which you can bind a popup menu and the items are represented
    with a sequence of DragAutoCompleteTextBoxeditable."""
    # XXX: beware that pyjamas.ui.FlowPanel is not fully implemented yet and can not be used with pyjamas.ui.Label

    def __init__(self, manager, data, style={}):
        """Initialization with a button and a DragAutoCompleteTextBox.

        @param manager (ListManager)
        @param data (dict{unicode: unicode})
        @param style (dict{unicode: unicode})
        """
        FlowPanel.__init__(self, Visible=(False if data["optional"] else True))

        def setTargetDropCell(panel, item):
            self.manager.target_drop_cell = panel

        # FIXME: dirty magic strings '@' and '@@'
        drop_cbs = {"GROUP": lambda panel, item: self.addItem("@%s" % item),
                    "CONTACT": lambda panel, item: self.addItem(tryJID(item)),
                    "CONTACT_TITLE": lambda panel, item: self.addItem('@@'),
                    "CONTACT_TEXTBOX": setTargetDropCell
                    }
        DropCell.__init__(self, drop_cbs)
        self.style = style
        self.addStyleName(self.style["keyPanel"])
        self.manager = manager
        self.key = data["title"]
        self._addTextBox()

    def _addTextBox(self, switchPrevious=False):
        """Add an empty text box to the last position.

        @param switchPrevious (bool): if True, simulate an insertion before the
            current last textbox by switching the texts and valid states
        @return: an DragAutoCompleteTextBox, the created text box 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.manager.addToRemainingList([tryJID(sender.getText())], ignore_key=self.key)

            items = [unicode(item) for item in self.manager.items_remaining]
            sender.setCompletionItems(items)

        def add_cb(sender):
            self.addItem(tryJID(sender.getText()), sender)

        def remove_cb(sender):
            """Callback for the button to remove this item."""
            self.manager.addToRemainingList([tryJID(sender.getText())])
            self.manager.setRemainingListUnsorted()
            self._last_textbox.setFocus(True)

        def drop_cb(sender, target):
            """Callback when the textbox is drag-n-dropped."""
            list_panel = sender.list_panel
            if target != list_panel and target.addItem(tryJID(sender.getText())):
                sender.removeOrReset()
            else:
                list_panel.manager.removeFromRemainingList([tryJID(sender.getText())])

        events_cbs = {"focus": focus_cb, "validate": add_cb, "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 _checkItem(self, item, modify):
        """
        @param item (object): the item to check
        @param modify (bool): True if the item is being modified
        @return: int value defined by one of these constants:
            - VALID if the item is valid
            - INVALID if the item is not valid but can be displayed
            - DELETE if the item should not be displayed at all
        """
        def count(list_, item):
            # XXX: list.count in not implemented by pyjamas
            return len([elt for elt in list_ if elt == item])

        if not item:
            return DELETE
        if count(self.getItems(), item) > (1 if modify else 0):
            return DELETE
        return VALID if item in self.manager.items else INVALID

    def addItem(self, item, sender=None):
        """

        @param item (object): item to be added
        @param (DragAutoCompleteTextBox): widget triggering the event
        @param sender: if True, the item will be "written" to the last textbox
            and a new text box will be added afterward.
        """
        valid = self._checkItem(item, sender is not None)
        item_s = unicode(item)
        if sender is None:
            # method has been called not to modify but to add an item
            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(item_s)
        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()
        sender.setVisibleLength(len(item_s))
        self.manager.removeFromRemainingList([item])
        self._last_textbox.setFocus(True)
        return True

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

    def resetItems(self, items):
        """Repopulate the items.

        @param items (list): the items to be listed.
        """
        self.emptyItems()
        if isinstance(items, set):
            items = list(items)
        items.sort()
        for item in items:
            self.addItem(item)

    def getItems(self):
        """Get the listed items.

        @return: set"""
        items = set()
        for widget in self.getChildren():
            if isinstance(widget, DragAutoCompleteTextBox) and widget.getText() != "":
                items.add(tryJID(widget.getText()))
        return items