diff src/browser/sat_browser/list_manager.py @ 736:fe3c2357a8c9

fixes/improve ListManager and contact group manager + better PEP-8 compliance
author souliane <souliane@mailoo.org>
date Thu, 19 Nov 2015 11:41:03 +0100
parents 9877607c719a
children 4545d48dee60
line wrap: on
line diff
--- a/src/browser/sat_browser/list_manager.py	Thu Nov 19 11:19:05 2015 +0100
+++ b/src/browser/sat_browser/list_manager.py	Thu Nov 19 11:41:03 2015 +0100
@@ -18,528 +18,430 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from sat.core.log import getLogger
+from pyjamas.ui.DragHandler import DragHandler
 log = getLogger(__name__)
+
+from pyjamas.ui.ClickListener import ClickHandler
+from pyjamas.ui.FocusListener import FocusHandler
+from pyjamas.ui.ChangeListener import ChangeHandler
+from pyjamas.ui.KeyboardListener import KeyboardHandler, KEY_ENTER
+from pyjamas.ui.DragWidget import DragWidget
+from pyjamas.ui.ListBox import ListBox
 from pyjamas.ui.Button import Button
 from pyjamas.ui.FlowPanel import FlowPanel
+from pyjamas.ui.HorizontalPanel import HorizontalPanel
+from pyjamas.ui.FlexTable import FlexTable
 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
+from sat_frontends.quick_frontend import quick_list_manager
 
 
 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."""
+class ListItem(HorizontalPanel):
+    """This class implements a list item with auto-completion and a delete button."""
 
-    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
+    STYLE = {"listItem-box": "listItem-box",
+             "listItem-box-invalid": "listItem-box-invalid",
+             "listItem-button": "listItem-button",
+             }
 
-        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)
+    VALID = 1
+    INVALID = 2
+    DUPLICATE = 3
 
-        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 __init__(self, listener=None, taglist=None, validate=None):
+        """
 
-    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
+        @param listener (ListItemHandler): handler for the UI events
+        @param taglist (quick_list_manager.QuickTagList): list manager
+        @param validate (callable): method returning a bool to validate the entry
         """
-        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"]
+        HorizontalPanel.__init__(self)
 
-        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.
+        self.box = AutoCompleteTextBox(StyleName=self.STYLE["listItem-box"])
+        self.remove_btn = Button('<span>x</span>', Visible=False)
+        self.remove_btn.setStyleName(self.STYLE["listItem-button"])
+        self.add(self.box)
+        self.add(self.remove_btn)
 
-        @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.
+        if listener:
+            self.box.addFocusListener(listener)
+            self.box.addChangeListener(listener)
+            self.box.addKeyboardListener(listener)
+            self.box.choices.addClickListener(listener)
+            self.remove_btn.addClickListener(listener)
 
-        @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)
+        self.taglist = taglist
+        self.validate = validate
+        self.last_checked_value = ""
+        self.last_validity = self.VALID
 
     @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.
+    def text(self):
+        return self.box.getText()
 
-        @param items (list): items to be removed
-        @param ignore_key (unicode): item key to be ignored while checking
+    def setText(self, text):
         """
-        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.
+        Set the text and refresh the Widget.
+        
+        @param text (unicode): text to set
         """
-        for key in self.keys:
-            if key in data:
-                self.children[key]["panel"].resetItems(data[key])
-            else:
-                self.children[key]["panel"].resetItems([])
+        self.box.setText(text)
         self.refresh()
 
-    def getItemsByKey(self):
-        """Get all the items by key.
+    def refresh(self):
+        if self.last_checked_value == self.text:
+            return
 
-        @return: dict{unicode: set}
-        """
-        return {key: self.children[key]["panel"].getItems() for key in self.children}
+        if self.taglist and self.last_checked_value:
+            self.taglist.untag([self.last_checked_value])
 
-    def getKeysByItem(self):
-        """Get all the keys by item.
+        if self.validate:  # if None, the state is always valid
+            self.last_validity = self.validate(self.text)
 
-        @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
+        if self.last_validity == self.VALID:
+            self.box.removeStyleName(self.STYLE["listItem-box-invalid"])
+        elif self.last_validity == self.INVALID:
+            self.box.addStyleName(self.STYLE["listItem-box-invalid"])
+        elif self.last_validity == self.DUPLICATE:
+            self.remove_btn.click()  # this may do more stuff then self.remove()
+            return
+        
+        if self.taglist and self.text:
+            self.taglist.tag([self.text])
+        self.last_checked_value = self.text
+        self.remove_btn.setVisible(len(self.text) > 0)
+                     
+    def setFocus(self, focused):
+        self.box.setFocus(focused)
 
-    def registerPopupMenuPanel(self, entries, hide, callback):
-        """Register a popup menu panel for the item keys buttons.
+    def remove(self):
+        """Remove the list item from its parent."""
+        self.removeFromParent()
 
-        @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"]})
+        if self.taglist and self.text:  # this must be done after the widget has been removed
+            self.taglist.untag([self.text])
 
 
-class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget):
-    """A draggable AutoCompleteTextBox which is used for representing an item."""
+class DraggableListItem(ListItem, DragWidget):
+    """This class is like ListItem, but in addition it can be dragged."""
 
-    def __init__(self, list_panel, event_cbs, style):
-        """
-
-        @param list_panel (ListPanel)
-        @param event_cbs (list[callable])
-        @param style (dict)
+    def __init__(self, listener=None, taglist=None, validate=None):
         """
-        AutoCompleteTextBox.__init__(self)
+    
+        @param listener (ListItemHandler): handler for the UI events
+        @param taglist (quick_list_manager.QuickTagList): list manager
+        @param validate (callable): method returning a bool to validate the entry
+        """
+        ListItem.__init__(self, listener, taglist, validate)
         DragWidget.__init__(self)
-        self.list_panel = list_panel
-        self.event_cbs = event_cbs
-        self.style = style
-        self.addStyleName(style["textBox"])
-        self.reset()
+        self.addDragListener(listener)
 
-        # 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)
+        """The user starts dragging the item."""
+        self.box.setSelectionRange(len(self.text), 0)
 
         dt = event.dataTransfer
-        dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX"))
-        dt.setDragImage(self.getElement(), 15, 15)
+        dt.setData('text/plain', "%s\n%s" % (self.text, "CONTACT_TEXTBOX"))
+        dt.setDragImage(self.box.getElement(), 15, 15)
+
+
+class ListItemHandler(ClickHandler, FocusHandler, KeyboardHandler, ChangeHandler):
+    """Implements basic handlers for the ListItem events."""
+
+    last_item = None  # the last item is an empty text box for user input
 
-    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 __init__(self, manager, key):
+        ClickHandler.__init__(self)
+        FocusHandler.__init__(self)
+        ChangeHandler.__init__(self)
+        KeyboardHandler.__init__(self)
+        self.manager = manager
+        self.key = key
+
+    def addItem(self, item):
+        raise NotImplementedError
+
+    def removeItem(self, item):
+        raise NotImplementedError
 
     def onClick(self, sender):
-        """The choices list is clicked"""
-        assert sender == self.choices
-        AutoCompleteTextBox.onClick(self, sender)
-        self.validate()
+        """The remove button or a suggested completion item has been clicked."""
+        #log.debug("onClick sender type: %s" % type(sender))
+        if isinstance(sender, Button):
+            item = sender.getParent()
+            self.removeItem(item)
+        elif isinstance(sender, ListBox):
+            # this is called after onChange when you click a suggested item, and now we get the final value
+            textbox = sender._clickListeners[0]
+            self.checkValue(textbox)
+        else:
+            raise AssertionError
 
-    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 onFocus(self, sender):
+        """The text box has the focus."""
+        #log.debug("onFocus sender type:  %s" % type(sender))
+        assert isinstance(sender, AutoCompleteTextBox)
+        sender.setCompletionItems(self.manager.untagged)
 
     def onKeyUp(self, sender, keycode, modifiers):
-        """Listen for key stroke"""
-        assert sender == self
-        AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers)
+        """The text box is being modified - or ENTER key has been pressed."""
+        # this is called after onChange when you press ENTER, and now we get the final value
+        #log.debug("onKeyUp sender type:  %s" % type(sender))
+        assert isinstance(sender, AutoCompleteTextBox)
         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)
+            self.checkValue(sender)
 
-    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 onChange(self, sender):
+        """The text box has been changed by the user."""
+        # this is called before the completion when you press ENTER or click a suggest item
+        #log.debug("onChange sender type:  %s" % type(sender))
+        assert isinstance(sender, AutoCompleteTextBox)
+        self.checkValue(sender)
 
-    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 checkValue(self, textbox):
+        """Internal handler to call when a new value is submitted by the user."""
+        item = textbox.getParent()
+        if item.text == item.last_checked_value:
+            # this method has already been called (by self.onChange) and there's nothing new
+            return
+        item.refresh()
+        item.box.setSelectionRange(len(item.text), 0)  
+        if item == self.last_item and item.last_validity == ListItem.VALID and item.text:
+            self.addItem()
 
-    def validate(self):
-        """Check if the text is valid, update the style."""
-        self.setSelectionRange(len(self.getText()), 0)
-        self.event_cbs["validate"](self)
+class DraggableListItemHandler(ListItemHandler, DragHandler):
+    """Implements basic handlers for the DraggableListItem events."""
 
-    def setRemoveButton(self):
-        """Add the remove button after the text box."""
+    def __init__(self, manager, key):
+        ListItemHandler.__init__(self, manager, key)
+        DragHandler.__init__(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)
+    def onDragStart(self, event):
+        """The user starts dragging the item."""
+        self.manager.drop_target = None
 
-        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()
+    def onDragEnd(self, event):
+        """The user dropped the list item."""
+        text, dummy = libervia_widget.eventGetData(event)
+        target = self.manager.drop_target  # self or another ListPanel
+        if text == "" or target is None:
+            return
+        if target != self:  # move the item from self to target
+            target.addItem(text)
+            self.removeItem(self.getItem(text))
 
 
-VALID = 1
-INVALID = 2
-DELETE = 3
-
+class ListPanel(FlowPanel, DraggableListItemHandler, libervia_widget.DropCell):
+    """Implements a list of items."""
+    # XXX: beware that pyjamas.ui.FlowPanel is not fully implemented:
+    #     - it can not be used with pyjamas.ui.Label
+    #     - FlowPanel.insert doesn't work
 
-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
+    STYLE = {"listPanel": "listPanel"}
 
-    def __init__(self, manager, data, style={}):
-        """Initialization with a button and a DragAutoCompleteTextBox.
+    def __init__(self, manager, key, items):
+        """Initialization with a button for the list name (key) and a DraggableListItem.
 
         @param manager (ListManager)
-        @param data (dict{unicode: unicode})
-        @param style (dict{unicode: unicode})
+        @param key (unicode): list name
+        @param items (list): items to append
         """
-        FlowPanel.__init__(self, Visible=(False if data["optional"] else True))
-
-        def setTargetDropCell(host, item):
-            self.manager.target_drop_cell = self
+        FlowPanel.__init__(self)
+        DraggableListItemHandler.__init__(self, manager, key)
+        libervia_widget.DropCell.__init__(self, None)
+        self.addStyleName(self.STYLE["listPanel"])
+        self.manager = manager
+        items.sort()
+        self.addItem()
+        for item in items:
+            self.addItem(unicode(item))
 
         # 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()
+        self.drop_keys = {"GROUP": lambda host, item_s: self.addItem("@%s" % item_s),
+                          "CONTACT": lambda host, item_s: self.addItem(item_s),
+                          "CONTACT_TITLE": lambda host, item_s: self.addItem('@@'),
+                          "CONTACT_TEXTBOX": lambda host, item_s: setattr(self.manager, "drop_target", self),
+                          }
 
     def onDrop(self, event):
+        """Something has been dropped in this ListPanel"""
         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.
+    
+    def getItem(self, text):
+        """Get an item from its text.
+        
+        @param text(unicode): item text
         """
-        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)
+        for child in self.getChildren():
+            if child.text == text:
+                return child
+        return None
 
-        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())])
+    def getItems(self):
+        """Get the non empty items.
 
-        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
+        @return list(unicode)
+        """
+        return [widget.text for widget in self.getChildren() if isinstance(widget, ListItem) and widget.text]
 
-    def _checkItem(self, item, modify):
-        """
-        @param item (object): the item to check
-        @param modify (bool): True if the item is being modified
+    def validateItem(self, text):
+        """Return validation code after the item has been changed.
+
+        @param text (unicode): item text to check
         @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
+            - DUPLICATE if the item is a duplicate
         """
-        def count(list_, item):
-            # XXX: list.count in not implemented by pyjamas
+        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
+        if count(self.getItems(), text) > 1:
+            return ListItem.DUPLICATE  # item already exists in this list so we suggest its deletion
+        return ListItem.VALID if text in self.manager.items or not text else ListItem.INVALID
+
+    def addItem(self, text=""):
+        """Add an item.
+
+        @param text (unicode): text to be set.
+        @return: True if the item has been really added or merged.
+        """
+        if text in self.getItems():  # avoid duplicate in the same list
+            return
+        
+        item = DraggableListItem(self, self.manager, self.validateItem)
+        self.add(item)
+
+        if self.last_item:
+            if self.last_item.last_validity == ListItem.INVALID:
+                # switch the two values so that the invalid one stays in last position
+                item.setText(self.last_item.text)
+                self.last_item.setText(text)
+            elif not self.last_item.text:
+                # copy the new value to previous empty item
+                self.last_item.setText(text)
+        else:  # first item of the list, or previous last item has been deleted
+            item.setText(text)
+
+        self.last_item = item
+        self.last_item.setFocus(True)
 
-    def addItem(self, item, sender=None):
-        """Try to add an item. It will be added if it's a valid one.
+    def removeItem(self, item):
+        """Remove an item.
+        
+        @param item(DraggableListItem): item to remove
+        """
+        if item == self.last_item:
+            self.addItem("")
+        item.remove()  # this also updates the taglist
+
+
+class ListManager(FlexTable, quick_list_manager.QuickTagList):
+    """Implements a table to manage one or several lists of items."""
+
+    STYLE = {"listManager-button": "group",
+             "listManager-button-cell": "listManager-button-cell",
+             }
 
-        @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.
+    def __init__(self, data=None, items=None):
+        """
+        @param data (dict{unicode: list}): dict binding keys to tagged items.
+        @param items (list): full list of items (tagged and untagged)
+        """
+        FlexTable.__init__(self, Width="100%")
+        quick_list_manager.QuickTagList.__init__(self, [unicode(item) for item in items])
+        self.lists = {}
+
+        if data:
+            for key, items in data.iteritems():
+                self.addList(key, [unicode(item) for item in items])
+
+    def addList(self, key, items=None):
+        """Add a Button and ListPanel for a new list.
+
+        @param key (unicode): list name
+        @param items (list): items to append to the new list
         """
-        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
+        if key in self.lists:
+            return
+
+        if items is None:
+            items = []
+
+        self.lists[key] = {"button": Button(key, Title=key, StyleName=self.STYLE["listManager-button"]),
+                           "panel": ListPanel(self, key, items)}
+
+        y, x = len(self.lists), 0
+        self.insertRow(y)
+        self.setWidget(y, x, self.lists[key]["button"])
+        self.setWidget(y, x + 1, self.lists[key]["panel"])
+        self.getCellFormatter().setStyleName(y, x, self.STYLE["listManager-button-cell"])
+
+        try:
+            self.popup_menu.registerClickSender(self.lists[key]["button"])
+        except (AttributeError, TypeError):  # self.registerPopupMenuPanel hasn't been called yet
+            pass
+
+    def removeList(self, key):
+        """Remove a ListPanel from this manager.
+
+        @param key (unicode): list name
+        """
+        items = self.lists[key]["panel"].getItems()
+        (y, x) = self.getIndex(self.lists[key]["button"])
+        self.removeRow(y)
+        del self.lists[key]
+        self.untag(items)
+
+    def untag(self, items):
+        """Untag some items.
+        
+        Check first if the items are not used in any panel.
 
-    def emptyItems(self):
-        """Empty the list of items."""
-        for child in self.getChildren():
-            if hasattr(child, "remove_btn"):
-                child.remove_btn.click()
+        @param items (list): items to be removed
+        """
+        items_assigned = set()
+        for values in self.getItemsByKey().itervalues():
+            items_assigned.update(values)
+        quick_list_manager.QuickTagList.untag(self, [item for item in items if item not in items_assigned])
 
-    def resetItems(self, items):
-        """Repopulate the items.
+    def getItemsByKey(self):
+        """Get the items grouped by list name.
 
-        @param items (list): the items to be listed.
+        @return dict{unicode: list}
         """
-        self.emptyItems()
-        if isinstance(items, set):
-            items = list(items)
-        items.sort()
-        for item in items:
-            self.addItem(item)
+        return {key: self.lists[key]["panel"].getItems() for key in self.lists}
+
+    def getKeysByItem(self):
+        """Get the keys groups by item.
 
-    def getItems(self):
-        """Get the listed items.
+        @return dict{object: set(unicode)}
+        """
+        result = {}
+        for key in self.lists:
+            for item in self.lists[key]["panel"].getItems():
+                result.setdefault(item, set()).add(key)
+        return result
 
-        @return: set"""
-        items = set()
-        for widget in self.getChildren():
-            if isinstance(widget, DragAutoCompleteTextBox) and widget.getText() != "":
-                items.add(tryJID(widget.getText()))
-        return items
+    def registerPopupMenuPanel(self, entries, callback):
+        """Register a popup menu panel for the list names' buttons.
+
+        @param entries (dict{unicode: dict{unicode: unicode}}): menu entries
+        @param callback (callable): common callback for all menu items, arguments are:
+            - button widget
+            - list name (item key)
+        """
+        self.popup_menu = base_panel.PopupMenuPanel(entries, callback=callback)
+        for key in self.lists:  # register click sender for already existing lists
+            self.popup_menu.registerClickSender(self.lists[key]["button"])