view src/browser/sat_browser/list_manager.py @ 681:3b185ccb70b4

browser side: updated trigger import according to backend renaming (sat.tools.misc is now sat.tools.trigger)
author Goffi <goffi@goffi.org>
date Thu, 19 Mar 2015 17:27:22 +0100
parents 6d3142b782c3
children 9877607c719a
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.FlowPanel import FlowPanel
from pyjamas.ui.AutoComplete import AutoCompleteTextBox
from pyjamas.ui.KeyboardListener import KEY_ENTER
from pyjamas.ui.DragWidget import DragWidget
from pyjamas.Timer import Timer

import base_panel
import base_widget
import libervia_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",
                      "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_panel.PopupMenuPanel(entries, hide, callback, style={"item": self.style["popupMenuItem"]})


class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget):
    """A draggable AutoCompleteTextBox which is used for representing an item."""

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

        @param list_panel (ListPanel)
        @param event_cbs (list[callable])
        @param style (dict)
        """
        AutoCompleteTextBox.__init__(self)
        DragWidget.__init__(self)
        self.list_panel = list_panel
        self.event_cbs = event_cbs
        self.style = style
        self.addStyleName(style["textBox"])
        self.reset()

        # Parent classes already init self as an handler for these events
        self.addMouseListener(self)
        self.addFocusListener(self)
        self.addChangeListener(self)

    def onDragStart(self, event):
        """The user starts dragging the text box."""
        self.list_panel.manager.target_drop_cell = None
        self.setSelectionRange(len(self.getText()), 0)

        dt = event.dataTransfer
        dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX"))
        dt.setDragImage(self.getElement(), 15, 15)

    def onDragEnd(self, event):
        """The user dropped the text box."""
        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 onClick(self, sender):
        """The choices list is clicked"""
        assert sender == self.choices
        AutoCompleteTextBox.onClick(self, sender)
        self.validate()

    def onChange(self, sender):
        """The list selection or the text has been changed"""
        assert sender == self.choices or sender == self
        if sender == self.choices:
            AutoCompleteTextBox.onChange(self, sender)
        self.validate()

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

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

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

    def onFocus(self, sender):
        """The DragAutoCompleteTextBox has the focus."""
        assert sender == self
        # FIXME: this raises runtime JS error "Permission denied to access property..." when you drag the object
        #sender.setSelectionRange(0, len(sender.getText()))
        sender.event_cbs["focus"](sender)

    def reset(self):
        """Reset the text box"""
        self.setText("")
        self.setValid()

    def setValid(self, valid=True):
        """Change the style according to the text validity."""
        if self.getText() == "":
            valid = True
        if valid:
            self.removeStyleName(self.style["textBox-invalid"])
        else:
            self.addStyleName(self.style["textBox-invalid"])
        self.valid = valid

    def validate(self):
        """Check if the text is valid, update the style."""
        self.setSelectionRange(len(self.getText()), 0)
        self.event_cbs["validate"](self)

    def setRemoveButton(self):
        """Add the remove button after the text box."""

        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):
        """Remove the text box if the remove button exists, or reset the text box."""
        if hasattr(self, "remove_btn"):
            self.remove_btn.click()
        else:
            self.reset()


VALID = 1
INVALID = 2
DELETE = 3


class ListPanel(FlowPanel, libervia_widget.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 DragAutoCompleteTextBox."""
    # 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(host, item):
            self.manager.target_drop_cell = self

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

    def onDrop(self, event):
        try:
            libervia_widget.DropCell.onDrop(self, event)
        except base_widget.NoLiberviaWidgetException:
            pass

    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):
        """Try to add an item. It will be added if it's a valid one.

        @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.
        @return: True if the item has been added.
        """
        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