# HG changeset patch # User souliane # Date 1447929663 -3600 # Node ID fe3c2357a8c95ac323eb773c56477bb38223afd9 # Parent e4ae8e2b0afd90077743a5926ab339de2849f73b fixes/improve ListManager and contact group manager + better PEP-8 compliance diff -r e4ae8e2b0afd -r fe3c2357a8c9 src/browser/public/libervia.css --- 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%; diff -r e4ae8e2b0afd -r fe3c2357a8c9 src/browser/sat_browser/contact_group.py --- 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 . -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): diff -r e4ae8e2b0afd -r fe3c2357a8c9 src/browser/sat_browser/list_manager.py --- 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 . 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 = 'x' - -# 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('x', 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"]) diff -r e4ae8e2b0afd -r fe3c2357a8c9 src/browser/sat_browser/richtext.py --- 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)