Mercurial > libervia-web
diff browser_side/list_manager.py @ 254:28d3315a8003
browser_side: isolate the basic stuff of RecipientManager in a new class ListManager:
- renamed most occurences of "recipient" to "contact" and "recipient type" to "contact key" or "list"
- data to represent the lists and autocomplete values are parametrized
- UI elements styles are set by default but can be ovewritten by a sub-class
- popup menu for the list Button element has to be set with registerPopupMenuPanel
- richtext UI uses the definitions from sat.tool.frontends.composition
Know issues:
- drag and drop AutoCompleteTextBox corrupts the list of remaining autocomplete values
- selecting an autocomplete value with the mouse and not keybord is not working properly
author | souliane <souliane@mailoo.org> |
---|---|
date | Sat, 09 Nov 2013 09:38:17 +0100 |
parents | browser_side/recipients.py@86055ccf69c3 |
children | 0e7f3944bd27 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser_side/list_manager.py Sat Nov 09 09:38:17 2013 +0100 @@ -0,0 +1,516 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +""" +Libervia: a Salut à Toi frontend +Copyright (C) 2013 Adrien Cossa <souliane@mailoo.org> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. +""" + +from pyjamas.ui.Grid import Grid +from pyjamas.ui.Button import Button +from pyjamas.ui.ListBox import ListBox +from pyjamas.ui.FlowPanel import FlowPanel +from pyjamas.ui.AutoComplete import AutoCompleteTextBox +from pyjamas.ui.Label import Label +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.DialogBox import DialogBox +from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler +from pyjamas.ui.MouseListener import MouseHandler +from pyjamas.ui.FocusListener import FocusHandler +from pyjamas.ui.DropWidget import DropWidget +from pyjamas.ui.DragWidget import DragWidget + +from pyjamas.Timer import Timer +from pyjamas import DOM + +import panels +from pyjamas.ui import FocusListener, KeyboardListener, MouseListener, Event + +# HTML content for the removal button (image or text) +REMOVE_BUTTON = '<span class="richTextRemoveIcon">x</span>' + +# Item to be considered for an empty list box selection. +# Could be whatever which doesn't look like a JID or a group name. +EMPTY_SELECTION_ITEM = "" + + +class ListManager(): + """A manager for sub-panels to assign elements to lists.""" + + def __init__(self, parent, keys_dict={}, contact_list=[], offsets={}, style={}): + """ + @param parent: FlexTable parent widget for the manager + @param keys_dict: dict with the contact keys mapped to data + @param contact_list: list of string (the contact JID userhosts) + @param offsets: dict to set widget positions offset within parent + - "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 + """ + self.host = parent.host + self._parent = parent + if isinstance(keys_dict, set) or isinstance(keys_dict, list): + tmp = {} + for key in keys_dict: + tmp[key] = {} + keys_dict = tmp + self.__keys_dict = keys_dict + if isinstance(contact_list, set): + contact_list = list(contact_list) + self.__list = contact_list + self.__list.sort() + # store the list of contacts that are not assigned yet + self.__remaining_list = [] + self.__remaining_list.extend(self.__list) + # mark a change to sort the list before it's used + self.__remaining_list_sorted = True + + self.offsets = {"x_first": 0, "x": 0, "y": 0} + if "x" in offsets and not "x_first" in offsets: + offsets["x_first"] = offsets["x"] + self.offsets.update(offsets) + + self.style = { + "keyItem": "recipientTypeItem", + "buttonCell": "recipientButtonCell", + "dragoverPanel": "dragover-recipientPanel", + "keyPanel": "recipientPanel", + "textBox": "recipientTextBox", + "removeButton": "recipientRemoveButton", + } + self.style.update(style) + + def createWidgets(self, title_format="%s"): + """Fill the parent grid with all the widgets (some may be hidden during the initialization).""" + self.__children = {} + for key in self.__keys_dict: + self.addContactKey(key, title_format) + + def addContactKey(self, key, dict_={}, title_format="%s"): + if key not in self.__keys_dict: + self.__keys_dict[key] = dict_ + # copy the key to its associated sub-map + self.__keys_dict[key]["title"] = key + self._addChild(self.__keys_dict[key], title_format) + + def _addChild(self, entry, title_format): + """Add a button and FlowPanel for the corresponding map entry.""" + button = Button(title_format % entry["title"]) + button.addStyleName(self.style["keyItem"]) + if hasattr(entry, "desc"): + button.setTitle(entry["desc"]) + if not "optional" in entry: + entry["optional"] = False + button.setVisible(not entry["optional"]) + y = len(self.__children) + self.offsets["y"] + x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"] + + self._parent.setWidget(y, x, button) + self._parent.getCellFormatter().setStyleName(y, x, self.style["buttonCell"]) + + _child = ListPanel(self, entry, self.style) + self._parent.setWidget(y, x + 1, _child) + + self.__children[entry["title"]] = {} + self.__children[entry["title"]]["button"] = button + self.__children[entry["title"]]["panel"] = _child + + if hasattr(self, "popup_menu"): + # this is done if self.registerPopupMenuPanel has been called yet + self.popup_menu.registerClickSender(button) + + def _refresh(self): + """Set visible the sub-panels that are non optional or non empty, hide the rest.""" + for key in self.__children: + self.setContactPanelVisible(key, False) + _map = self.getContacts() + for key in _map: + if len(_map[key]) > 0 or not self.__keys_dict[key]["optional"]: + self.setContactPanelVisible(key, True) + + def setContactPanelVisible(self, key, visible=True, sender=None): + """Do not remove the "sender" param as it is needed for the context menu.""" + self.__children[key]["button"].setVisible(visible) + self.__children[key]["panel"].setVisible(visible) + + @property + def list(self): + """Return the full list of potential contacts.""" + return self.__list + + @property + def keys(self): + return self.__keys_dict.keys() + + @property + def keys_dict(self): + return self.__keys_dict + + @property + def remaining_list(self): + """Return the contacts that have not been selected yet.""" + if not self.__remaining_list_sorted: + self.__remaining_list_sorted = True + self.__remaining_list.sort() + return self.__remaining_list + + def setRemainingListUnsorted(self): + """Mark a change (deletion) so the list will be sorted before it's used.""" + self.__remaining_list_sorted = False + + def removeFromRemainingList(self, contact_): + """Remove an available contact after it has been added to a sub-panel.""" + if contact_ in self.__remaining_list: + self.__remaining_list.remove(contact_) + + def addToRemainingList(self, contact_): + """Add a contact after it has been removed from a sub-panel.""" + if contact_ not in self.__list or contact_ in self.__remaining_list: + return + self.__remaining_list.append(contact_) + self.__sort_remaining_list = True + + def setContacts(self, _map={}): + """Set the contacts for each contact key.""" + for key in self.__keys_dict: + if key in _map: + self.__children[key]["panel"].setContacts(_map[key]) + else: + self.__children[key]["panel"].setContacts([]) + self._refresh() + + def getContacts(self): + """Get the contacts for all the lists. + @return: a mapping between keys and contact lists.""" + _map = {} + for key in self.__children: + _map[key] = self.__children[key]["panel"].getContacts() + return _map + + def setTargetDropCell(self, panel): + """Used to drag and drop the contacts from one panel to another.""" + self._target_drop_cell = panel + + def getTargetDropCell(self): + """Used to drag and drop the contacts from one panel to another.""" + return self._target_drop_cell + + def registerPopupMenuPanel(self, entries, hide, callback): + "Register a popup menu panel that will be bound to all contact keys elements." + self.popup_menu = panels.PopupMenuPanel(entries=entries, hide=hide, callback=callback, item_style="recipientTypeItem") + + +class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget, MouseHandler): + """A draggable AutoCompleteTextBox which is used for representing a contact. + This class is NOT generic because of the onDragEnd method which call methods + from ListPanel. It's probably not reusable for another scenario. + """ + + def __init__(self): + AutoCompleteTextBox.__init__(self) + DragWidget.__init__(self) + self.addMouseListener(self) + + def onDragStart(self, event): + dt = event.dataTransfer + # The group prefix "@" is already in text so we use only the "CONTACT" type + dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX")) + + def onDragEnd(self, event): + if self.getText() == "": + return + # get the ListPanel containing self + parent = self.getParent() + while parent is not None and not isinstance(parent, ListPanel): + parent = parent.getParent() + if parent is None: + return + # it will return parent again or another ListPanel + target = parent.getTargetDropCell() + if target == parent: + return + target.addContact(self.getText()) + if hasattr(self, "remove_btn"): + # self is not the last textbox, just remove it + self.remove_btn.click() + else: + # reset the value of the last textbox + self.setText("") + + def onMouseMove(self, sender): + """Mouse enters the area of a DragAutoCompleteTextBox.""" + if hasattr(sender, "remove_btn"): + sender.remove_btn.setVisible(True) + + def onMouseLeave(self, sender): + """Mouse leaves the area of a DragAutoCompleteTextBox.""" + if hasattr(sender, "remove_btn"): + Timer(1500, lambda: sender.remove_btn.setVisible(False)) + + +class DropCell(DropWidget): + """A cell where you can drop widgets. This class is NOT generic because of + onDrop which uses methods from ListPanel. It has been created to + separate the drag and drop methods from the others and add a bit of + lisibility, but it's probably not reusable for another scenario. + """ + + def __init__(self, host): + DropWidget.__init__(self) + + def onDragEnter(self, event): + self.addStyleName(self.style["dragoverPanel"]) + DOM.eventPreventDefault(event) + + def onDragLeave(self, event): + if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop()\ + or event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1\ + or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1: + # We check that we are inside widget's box, and we don't remove the style in this case because + # if the mouse is over a widget inside the DropWidget, we don't want the style to be removed + self.removeStyleName(self.style["dragoverPanel"]) + + def onDragOver(self, event): + DOM.eventPreventDefault(event) + + def onDrop(self, event): + DOM.eventPreventDefault(event) + dt = event.dataTransfer + # 'text', 'text/plain', and 'Text' are equivalent. + item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed + if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and + item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report + if item_type == "GROUP": + item = "@%s" % item + self.addContact(item) + elif item_type == "CONTACT": + self.addContact(item) + elif item_type == "CONTACT_TEXTBOX": + self._parent.setTargetDropCell(self) + pass + else: + return + self.removeStyleName(self.style["dragoverPanel"]) + + +class ListPanel(FlowPanel, DropCell, FocusHandler, KeyboardHandler): + """Sub-panel used for each contact key. Beware that pyjamas.ui.FlowPanel + is not fully implemented yet and can not be used with pyjamas.ui.Label.""" + + def __init__(self, parent, entry, style={}): + """Initialization with a button and a DragAutoCompleteTextBox.""" + FlowPanel.__init__(self, Visible=(False if entry["optional"] else True)) + DropCell.__init__(self) + self.style = style + self.addStyleName(self.style["keyPanel"]) + self._parent = parent + self.host = parent.host + + self._last_textbox = None + self.__remove_cbs = [] + + self.__resetLastTextBox() + + def __resetLastTextBox(self, setFocus=True): + """Reset the last input text box with KeyboardListener.""" + if self._last_textbox is None: + self._last_textbox = DragAutoCompleteTextBox() + self._last_textbox.addStyleName(self.style["textBox"]) + self._last_textbox.addKeyboardListener(self) + self._last_textbox.addFocusListener(self) + else: + # ensure we move it to the last position + self.remove(self._last_textbox) + self._last_textbox.setText("") + self.add(self._last_textbox) + self._last_textbox.setFocus(setFocus) + + def onKeyUp(self, sender, keycode, modifiers): + """This is called after DragAutoCompleteTextBox.onKeyDown, + so the completion is done before we reset the text box.""" + if not isinstance(sender, DragAutoCompleteTextBox): + return + if keycode == KEY_ENTER: + self.onLostFocus(sender) + self._last_textbox.setFocus(True) + + def onFocus(self, sender): + """A DragAutoCompleteTextBox has the focus.""" + if not isinstance(sender, DragAutoCompleteTextBox): + return + if sender != self._last_textbox: + # save the current value before it's being modified + text = sender.getText() + self._focused_textbox_previous_value = text + self._parent.addToRemainingList(text) + sender.setCompletionItems(self._parent.remaining_list) + + def onLostFocus(self, sender): + """A DragAutoCompleteTextBox has lost the focus.""" + if not isinstance(sender, DragAutoCompleteTextBox): + return + self.changeContact(sender) + + def changeContact(self, sender): + """Modify the value of a DragAutoCompleteTextBox.""" + text = sender.getText() + if sender == self._last_textbox: + if text != "": + # a new box is added and the last textbox is reinitialized + self.addContact(text, setFocusToLastTextBox=False) + return + if text == "": + sender.remove_btn.click() + return + # text = new value needs to be removed 1. if the value is unchanged, because we + # added it when we took the focus, or 2. if the value is changed (obvious!) + self._parent.removeFromRemainingList(text) + if text == self._focused_textbox_previous_value: + return + sender.setVisibleLength(len(text)) + self._parent.addToRemainingList(self._focused_textbox_previous_value) + + def addContact(self, contact, resetLastTextBox=True, setFocusToLastTextBox=True): + """Add a contact and signal it to self._parent panel.""" + if contact is None or contact == "": + return + textbox = DragAutoCompleteTextBox() + textbox.addStyleName(self.style["textBox"]) + textbox.setText(contact) + self.add(textbox) + try: + textbox.setVisibleLength(len(str(contact))) + except: + #FIXME: . how come could this happen?! len(contact) is sometimes 0 but contact is not empty + print "len(contact) returns %d where contact == %s..." % (len(str(contact)), str(contact)) + self._parent.removeFromRemainingList(contact) + + remove_btn = Button(REMOVE_BUTTON, Visible=False) + remove_btn.setStyleName(self.style["removeButton"]) + + def remove_cb(sender): + """Callback for the button to remove this contact.""" + self.remove(textbox) + self.remove(remove_btn) + self._parent.addToRemainingList(contact) + self._parent.setRemainingListUnsorted() + + remove_btn.addClickListener(remove_cb) + self.__remove_cbs.append(remove_cb) + self.add(remove_btn) + self.__resetLastTextBox(setFocus=setFocusToLastTextBox) + + textbox.remove_btn = remove_btn + textbox.addFocusListener(self) + textbox.addKeyboardListener(self) + + def emptyContacts(self): + """Empty the list of contacts.""" + for remove_cb in self.__remove_cbs: + remove_cb() + self.__remove_cbs = [] + + def setContacts(self, tab): + """Set the contacts.""" + self.emptyContacts() + for contact in tab: + self.addContact(contact, resetLastTextBox=False) + self.__resetLastTextBox() + + def getContacts(self): + """Get the contacts + @return: an array of string""" + tab = [] + for widget in self.getChildren(): + if isinstance(widget, DragAutoCompleteTextBox): + # not to be mixed with EMPTY_SELECTION_ITEM + if widget.getText() != "": + tab.append(widget.getText()) + return tab + + def getTargetDropCell(self): + """Returns self or another panel where something has been dropped.""" + return self._parent.getTargetDropCell() + + +class ContactChooserPanel(DialogBox): + """Display the contacts chooser dialog. This has been implemented while + prototyping and is currently not used. Left for an eventual later use. + Replaced by the popup menu which allows to add a panel for Cc or Bcc. + """ + + def __init__(self, manager, **kwargs): + """Display a listbox for each contact key""" + DialogBox.__init__(self, autoHide=False, centered=True, **kwargs) + self.setHTML("Select contacts") + self.manager = manager + self.listboxes = {} + self.contacts = manager.getContacts() + + container = VerticalPanel(Visible=True) + container.addStyleName("marginAuto") + + grid = Grid(2, len(self.manager.keys_dict)) + index = -1 + for key in self.manager.keys_dict: + index += 1 + grid.add(Label("%s:" % self.manager.keys_dict[key]["desc"]), 0, index) + listbox = ListBox() + listbox.setMultipleSelect(True) + listbox.setVisibleItemCount(15) + listbox.addItem(EMPTY_SELECTION_ITEM) + for element in manager.list: + listbox.addItem(element) + self.listboxes[key] = listbox + grid.add(listbox, 1, index) + self._reset() + + buttons = HorizontalPanel() + buttons.addStyleName("marginAuto") + btn_close = Button("Cancel", self.hide) + buttons.add(btn_close) + btn_reset = Button("Reset", self._reset) + buttons.add(btn_reset) + btn_ok = Button("OK", self._validate) + buttons.add(btn_ok) + + container.add(grid) + container.add(buttons) + + self.add(container) + self.center() + + def _reset(self): + """Reset the selections.""" + for key in self.manager.keys_dict: + listbox = self.listboxes[key] + for i in xrange(0, listbox.getItemCount()): + if listbox.getItemText(i) in self.contacts[key]: + listbox.setItemSelected(i, "selected") + else: + listbox.setItemSelected(i, "") + + def _validate(self): + """Sets back the selected contacts to the good sub-panels.""" + _map = {} + for key in self.manager.keys_dict: + selections = self.listboxes[key].getSelectedItemText() + if EMPTY_SELECTION_ITEM in selections: + selections.remove(EMPTY_SELECTION_ITEM) + _map[key] = selections + self.manager.setContacts(_map) + self.hide()