changeset 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 e4ae8e2b0afd
children 398b54bd97f0
files src/browser/public/libervia.css src/browser/sat_browser/contact_group.py src/browser/sat_browser/list_manager.py src/browser/sat_browser/richtext.py
diffstat 4 files changed, 441 insertions(+), 565 deletions(-) [+]
line wrap: on
line diff
--- a/src/browser/public/libervia.css	Thu Nov 19 11:19:05 2015 +0100
+++ b/src/browser/public/libervia.css	Thu Nov 19 11:41:03 2015 +0100
@@ -300,11 +300,6 @@
 
 /* Misc Pyjamas stuff */
 
-.gwt-AutoCompleteTextBox {
-    width: 80%;
-    border: 1px solid #87B3FF;
-    margin-top: 20px;
-}
 .gwt-DialogBox {
     padding: 10px;
     border: 1px solid #aaa;
@@ -464,6 +459,7 @@
 }
 
 .group {
+    curser: pointer;
     padding: 2px 15px;
     margin: 5px;
     display: inline-block;
@@ -1291,6 +1287,8 @@
     width: 99%;
     margin: auto;
     display: block;
+    border: 0px;
+    border-radius: 5px;
 }
 
 .richTextToolbar {
@@ -1300,6 +1298,7 @@
 
 .richTextArea {
     width: 100%;
+    height: 250px;
 }
 
 .richMessageArea {
@@ -1361,36 +1360,24 @@
 .itemPanel {
 }
 
-.itemTextBox {
+.listItem-box {
     cursor: pointer;
     width: auto;
+    border: 1px solid #87B3FF;
     border-radius: 5px 5px 5px 5px;
-    -webkit-box-shadow: inset 0px 1px 4px rgba(135, 179, 255, 0.6);
-    box-shadow: inset 0px 1px 4px rgba(135, 179, 255, 0.6);
+    -webkit-box-shadow: inset 0px 1px 0px rgba(135, 179, 255, 0.6);
+    box-shadow: inset 0px 1px 2px rgba(135, 179, 255, 0.6);
     padding: 2px 1px;
-    margin: 0px;
-    color: #444;
-    font-size: 1em;
 }
 
-.itemTextBox-invalid {
-    -webkit-box-shadow: inset 0px 1px 4px rgba(255, 0, 0, 0.6);
-    box-shadow: inset 0px 1px 4px rgba(255, 0, 0, 0.6);
+.listItem-box-invalid {
     border: 1px solid rgb(255, 0, 0);
+    -webkit-box-shadow: inset 0px 1px 0px rgba(255, 0, 0, 0.6);
+    box-shadow: inset 0px 1px 0px rgba(255, 0, 0, 0.6);
 }
 
-.itemRemoveButton {
-    margin: 0px 10px 0px 0px;
-    padding: 0px;
-    border: 1px dashed red;
-    border-radius: 5px 5px 5px 5px;
-}
-
-.itemRemoveIcon {
+.listItem-button span {
     color: red;
-    width:15px;
-    height:15px;
-    vertical-align: baseline;
 }
 
 .recipientSpacer {
@@ -1408,29 +1395,38 @@
 /* Contact group manager */
 
 .contactGroupEditor {
-    width: 800px;
-    max-width:800px;
-    min-width: 800px;
-    margin-top: 9px;
-    margin-left:18px;
+    width: 680px !important;
+}
+
+.contactGroupManager {
+    width: 400px !important;
+    height: 300px !important;
+    margin: 20px 0px;
 }
 
-.contactGroupRemoveButton {
-    margin: 0px 10px 0px 0px;
+.contactGroupRoster {
+    width: 280px !important;
+    height: 300px !important;
+    margin: 20px 0px;
+}
+
+.listItem-button {
+    margin: 0px;
     padding: 0px;
-    border: 1px dashed red;
-    border-radius: 5px 5px 5px 5px;
+    border: none;
+    background: transparent;
 }
 
 .addContactGroupPanel {
    
 }
 
-.contactGroupPanel {
-    vertical-align:middle;
+.listPanel {
+    vertical-align:top;
+    padding: 10px 0px;
 }
 
-.contactGroupPanel.dragover {
+.listPanel.dragover {
     border-radius: 5px !important;
     background: none repeat scroll 0% 0% rgb(135, 179, 255) !important;
     border: 1px dashed rgb(35,79,255) !important;
@@ -1440,10 +1436,16 @@
     white-space: nowrap;
 }
 
-.contactGroupButtonCell {
-    vertical-align: baseline;
+.listManager-button-cell {
+    vertical-align: top;
+    padding: 10px 0px;
     width: 55px;
-    white-space: nowrap;
+    white-space: top;
+}
+
+.listManager-button-cell .group {
+    border: 0px;
+	margin: 0px 5px;
 }
 
 /* Room and contacts chooser */
@@ -1459,9 +1461,9 @@
 
 .gwt-StackPanel .gwt-StackPanelItem {
     background-color: #222;
-    background: -webkit-gradient(linear, left top, left bottom, from(#444444), to(#222222));
-    background: -webkit-linear-gradient(top, #444444, #222222);
-    background: linear-gradient(to bottom, #444444, #222222);
+    background: -webkit-gradient(linear, left top, left bottom, from(#888888), to(#666666));
+    background: -webkit-linear-gradient(top, #888888, #666666);
+    background: linear-gradient(to bottom, #888888, #666666);
     text-decoration: none;    
     font-weight: bold;
     height: 100%;
--- a/src/browser/sat_browser/contact_group.py	Thu Nov 19 11:19:05 2015 +0100
+++ b/src/browser/sat_browser/contact_group.py	Thu Nov 19 11:41:03 2015 +0100
@@ -17,19 +17,20 @@
 # 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.FlexTable import FlexTable
-from pyjamas.ui.DockPanel import DockPanel
-from pyjamas.Timer import Timer
 from pyjamas.ui.Button import Button
+from pyjamas.ui.CheckBox import CheckBox
+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.ScrollPanel import ScrollPanel
 from pyjamas.ui import HasAlignment
 
 import dialog
 import list_manager
 import contact_panel
 import contact_list
+from sat_frontends.tools import jid
 
 
 unicode = str  # FIXME: pyjamas workaround
@@ -37,43 +38,39 @@
 
 class ContactGroupManager(list_manager.ListManager):
 
-    def __init__(self, container, keys, contacts, offsets, style):
+    def __init__(self, editor, data, contacts, offsets):
         """
         @param container (FlexTable): FlexTable parent widget
         @param keys (dict{unicode: dict{unicode: unicode}}): dict binding items
             keys to their display config data.
         @param contacts (list): list of contacts
-        @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
         """
-        list_manager.ListManager.__init__(self, container, keys, contacts, offsets, style)
+        self.editor = editor
+        list_manager.ListManager.__init__(self, data, contacts)
         self.registerPopupMenuPanel(entries={"Remove group": {}},
-                                    callback=lambda sender, key: Timer(5, lambda timer: self.removeContactKey(sender, key)))
+                                    callback=lambda sender, key: self.removeGroup(sender))
 
-    def removeContactKey(self, sender, key):
-        key = sender.getText()
+    def removeGroup(self, sender):
+        group = sender.getHTML()
 
         def confirm_cb(answer):
             if answer:
-                list_manager.ListManager.removeItemKey(self, key)
-                self.container.removeKeyFromAddGroupPanel(key)
+                list_manager.ListManager.removeList(self, group)
+                self.editor.add_group_panel.groups.remove(group)
 
-        _dialog = dialog.ConfirmDialog(confirm_cb, text="Do you really want to delete the group '%s'?" % key)
+        _dialog = dialog.ConfirmDialog(confirm_cb, text="Do you really want to delete the group '%s'?" % group)
         _dialog.show()
 
-    def removeFromRemainingList(self, contacts):
-        list_manager.ListManager.removeFromRemainingList(self, contacts)
-        self.container.updateContactList(contacts)
+    def tag(self, contacts):
+        list_manager.ListManager.tag(self, contacts)
+        self.editor.updateContactList(contacts)
 
-    def addToRemainingList(self, contacts, ignore_key=None):
-        list_manager.ListManager.addToRemainingList(self, contacts, ignore_key)
-        self.container.updateContactList(contacts)
+    def untag(self, contacts, ignore_key=None):
+        list_manager.ListManager.untag(self, contacts, ignore_key)
+        self.editor.updateContactList(contacts)
 
 
-class ContactGroupEditor(DockPanel):
+class ContactGroupEditor(VerticalPanel):
     """A big panel including a ContactGroupManager and other UI stuff."""
 
     def __init__(self, host, container=None, onCloseCallback=None):
@@ -83,7 +80,7 @@
         @param container (PanelBase): parent panel or None to display in a popup
         @param onCloseCallback (callable)
         """
-        DockPanel.__init__(self)
+        VerticalPanel.__init__(self, StyleName="contactGroupEditor")
         self.host = host
 
         # eventually display in a popup
@@ -99,28 +96,33 @@
         roster_groups = roster_entities_by_group.keys()
         roster_groups.sort()
 
+        # groups on the left
+        manager = self.initContactGroupManager(roster_entities_by_group)
         self.add_group_panel = self.initAddGroupPanel(roster_groups)
-        south_panel = self.initCloseSaveButtons()
-        center_panel = self.initContactGroupManager(roster_groups)
-        east_panel = self.initContactList()
+        left_container = VerticalPanel(Width="100%")
+        left_container.add(manager)
+        left_container.add(self.add_group_panel)
+        left_container.setCellHorizontalAlignment(self.add_group_panel, HasAlignment.ALIGN_CENTER)
+        left_panel = ScrollPanel(left_container, StyleName="contactGroupManager")
+        left_panel.setAlwaysShowScrollBars(True)
 
-        self.add(self.add_group_panel, DockPanel.CENTER)
-        self.add(east_panel, DockPanel.EAST)
-        self.add(center_panel, DockPanel.NORTH)
-        self.add(south_panel, DockPanel.SOUTH)
+        # contact list on the right
+        east_panel = ScrollPanel(self.initContactList(), StyleName="contactGroupRoster")
+        east_panel.setAlwaysShowScrollBars(True)
+
+        south_panel = self.initCloseSaveButtons()
 
-        self.setCellHorizontalAlignment(center_panel, HasAlignment.ALIGN_LEFT)
-        self.setCellVerticalAlignment(center_panel, HasAlignment.ALIGN_TOP)
-        self.setCellHorizontalAlignment(east_panel, HasAlignment.ALIGN_RIGHT)
-        self.setCellVerticalAlignment(east_panel, HasAlignment.ALIGN_TOP)
-        self.setCellVerticalAlignment(self.add_group_panel, HasAlignment.ALIGN_BOTTOM)
-        self.setCellHorizontalAlignment(self.add_group_panel, HasAlignment.ALIGN_LEFT)
-        self.setCellVerticalAlignment(south_panel, HasAlignment.ALIGN_BOTTOM)
+        main_panel = HorizontalPanel()
+        main_panel.add(left_panel)
+        main_panel.add(east_panel)
+        self.add(Label("You get here an over whole view of your contact groups. There are two ways to assign your contacts to an existing group: write them into auto-completed textboxes or use the right panel to drag and drop them into the group."))
+        self.add(main_panel)
+        self.add(south_panel)
+
         self.setCellHorizontalAlignment(south_panel, HasAlignment.ALIGN_CENTER)
 
         # need to be done after the contact list has been initialized
-        self.groups.resetItems(roster_entities_by_group)
-        self.toggleContacts(showAll=True)
+        self.updateContactList()
 
         # Hide the contacts list from the main panel to not confuse the user
         self.restore_contact_panel = False
@@ -134,31 +136,13 @@
         if isinstance(container, DialogBox):
             container.center()
 
-    def initContactGroupManager(self, groups):
+    def initContactGroupManager(self, data):
         """Initialise the contact group manager.
 
         @param groups (list[unicode]): contact groups
         """
-        flex_table = FlexTable()
-        flex_table.addStyleName('contactGroupEditor')
-
-        # overwrite the default style which has been set for rich text editor
-        style = {"keyItem": "group",
-                 "popupMenuItem": "popupMenuItem",
-                 "removeButton": "contactGroupRemoveButton",
-                 "buttonCell": "contactGroupButtonCell",
-                 "keyPanel": "contactGroupPanel"
-                 }
-
-        groups = {group: {} for group in groups}
-        self.groups = ContactGroupManager(flex_table, groups, self.all_contacts, style=style)
-        self.groups.createWidgets()  # widgets are automatically added to the FlexTable
-
-        # FIXME: clean that part which is dangerous
-        flex_table.updateContactList = self.updateContactList
-        flex_table.removeKeyFromAddGroupPanel = self.add_group_panel.groups.remove
-
-        return flex_table
+        self.groups = ContactGroupManager(self, data, self.all_contacts)
+        return self.groups
 
     def initAddGroupPanel(self, groups):
         """Initialise the 'Add group' panel.
@@ -167,7 +151,7 @@
         """
 
         def add_group_cb(key):
-            self.groups.addItemKey(key)
+            self.groups.addList(key)
             self.add_group_panel.textbox.setFocus(True)
 
         add_group_panel = dialog.AddGroupPanel(groups, add_group_cb)
@@ -178,13 +162,15 @@
         """Add the buttons to close the dialog and save the groups."""
         buttons = HorizontalPanel()
         buttons.addStyleName("marginAuto")
+        buttons.add(Button("Cancel", listener=self.cancelWithoutSaving))
         buttons.add(Button("Save", listener=self.closeAndSave))
-        buttons.add(Button("Cancel", listener=self.cancelWithoutSaving))
         return buttons
 
     def initContactList(self):
         """Add the contact list to the DockPanel."""
-        self.toggle = Button("", self.toggleContacts)
+        
+        self.toggle = CheckBox("Hide assigned contacts")
+        self.toggle.addClickListener(lambda dummy: self.updateContactList())
         self.toggle.addStyleName("toggleAssignedContacts")
         self.contacts = contact_panel.ContactsPanel(self.host)
         for contact in self.all_contacts:
@@ -194,39 +180,25 @@
         panel.add(self.contacts)
         return panel
 
-    def toggleContacts(self, sender=None, showAll=None):
-        """Toggle the button to show contacts and the contact list.
-
-        @param sender (Button)
-        @param showAll (bool): if set, initialise with True to show all contacts
-            or with False to show only the ones that are not assigned yet.
-        """
-        self.toggle.showAll = (not self.toggle.showAll) if showAll is None else showAll
-        self.toggle.setText("Hide assigned" if self.toggle.showAll else "Show assigned")
-        self.updateContactList()
-
     def updateContactList(self, contacts=None):
         """Update the contact list's items visibility, depending of the toggle
-        button and the "contacts" attribute.
+        checkbox and the "contacts" attribute.
 
         @param contacts (list): contacts to be updated, or None to update all.
         """
-        if not hasattr(self, "toggle") or not hasattr(self.toggle, "showAll"):
+        if not hasattr(self, "toggle"):
             return
         if contacts is not None:
-            to_remove = set()
-            for contact in contacts:
-                if contact not in self.all_contacts:
-                    to_remove.add(contact)
-            for contact in to_remove:
-                contacts.remove(contact)
+            contacts = [jid.JID(contact) for contact in contacts]
+            contacts = set(contacts).intersection(self.all_contacts)
         else:
             contacts = self.all_contacts
+
         for contact in contacts:
-            if self.toggle.showAll:
+            if not self.toggle.getChecked():  # show all contacts
                 self.contacts.updateContactBox(contact).setVisible(True)
-            else:
-                if contact in self.groups.items_remaining:
+            else:  # show only non-assigned contacts
+                if contact in self.groups.untagged:
                     self.contacts.updateContactBox(contact).setVisible(True)
                 else:
                     self.contacts.updateContactBox(contact).setVisible(False)
@@ -254,7 +226,8 @@
         """Call bridge methods to save the changes and close the dialog"""
         old_groups_by_entity = contact_list.JIDDict(self.host.contact_list.roster_groups_by_entity)
         old_entities = old_groups_by_entity.keys()
-        groups_by_entity = contact_list.JIDDict(self.groups.getKeysByItem())
+        result = {jid.JID(item): keys for item, keys in self.groups.getKeysByItem().iteritems()}
+        groups_by_entity = contact_list.JIDDict(result)
         entities = groups_by_entity.keys()
 
         for invalid in entities.difference(self.all_contacts):
--- 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"])
--- a/src/browser/sat_browser/richtext.py	Thu Nov 19 11:19:05 2015 +0100
+++ b/src/browser/sat_browser/richtext.py	Thu Nov 19 11:41:03 2015 +0100
@@ -415,7 +415,6 @@
         if not hasattr(self, 'recipient'):
             # recipient types sub-panels are automatically added by the manager
             self.recipient = RecipientManager(self, self.recipient_offset)
-            self.recipient.createWidgets(title_format="%s: ")
             self.recipient_spacer = HTML('')
             self.recipient_spacer.setStyleName('recipientSpacer')
             self.getFlexCellFormatter().setColSpan(self.recipient_spacer_offset, 0, 2)