Mercurial > libervia-web
view src/browser/sat_browser/list_manager.py @ 659:8e7d4de56e75 frontends_multi_profiles
browser_side: allow to drop a widget in the "+" tab
author | souliane <souliane@mailoo.org> |
---|---|
date | Fri, 27 Feb 2015 16:05:28 +0100 |
parents | 6d3142b782c3 |
children | 9877607c719a |
line wrap: on
line source
#!/usr/bin/python # -*- coding: utf-8 -*- # Libervia: a Salut à Toi frontend # Copyright (C) 2013, 2014 Adrien Cossa <souliane@mailoo.org> # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from sat.core.log import getLogger log = getLogger(__name__) from pyjamas.ui.Button import Button from pyjamas.ui.FlowPanel import FlowPanel from pyjamas.ui.AutoComplete import AutoCompleteTextBox from pyjamas.ui.KeyboardListener import KEY_ENTER from pyjamas.ui.DragWidget import DragWidget from pyjamas.Timer import Timer import base_panel import base_widget import libervia_widget from sat_frontends.tools import jid unicode = str # FIXME: pyjamas workaround # HTML content for the removal button (image or text) REMOVE_BUTTON = '<span class="itemRemoveIcon">x</span>' # FIXME: dirty method and magic string to fix ASAP def tryJID(obj): return jid.JID(obj) if (isinstance(obj, unicode) and not obj.startswith('@')) else obj class ListManager(object): """A base class to manage one or several lists of items.""" def __init__(self, container, keys=None, items=None, offsets=None, style=None): """ @param container (FlexTable): FlexTable parent widget @param keys (dict{unicode: dict{unicode: unicode}}): dict binding items keys to their display config data. @param items (list): list of items @param offsets (dict): define widgets positions offsets within container: - "x_first": the x offset for the first widget's row on the grid - "x": the x offset for all widgets rows, except the first one if "x_first" is defined - "y": the y offset for all widgets columns on the grid @param style (dict): define CSS styles """ self.container = container self.keys = {} if keys is None else keys self.items = [] if items is None else items self.items.sort() # store the list of items that are not assigned yet self.items_remaining = [item for item in self.items] self.items_remaining_sorted = True self.offsets = {"x_first": 0, "x": 0, "y": 0} if offsets is not None: if "x" in offsets and "x_first" not in offsets: offsets["x_first"] = offsets["x"] self.offsets.update(offsets) self.style = {"keyItem": "itemKey", "popupMenuItem": "itemKey", "buttonCell": "itemButtonCell", "keyPanel": "itemPanel", "textBox": "itemTextBox", "textBox-invalid": "itemTextBox-invalid", "removeButton": "itemRemoveButton", } if style is not None: self.style.update(style) def createWidgets(self, title_format="%s"): """Fill the container widget with one ListPanel per item key (some may be hidden during the initialization). @param title_format (unicode): format string for the title """ self.children = {} for key in self.keys: self.addItemKey(key, title_format=title_format) def addItemKey(self, key, data=None, title_format="%s"): """Add to the container a Button and ListPanel for a new item key. @param key (unicode): item key @param data (dict{unicode: unicode}): config data """ key_data = self.keys.setdefault(key, {}) if data is not None: key_data.update(data) key_data["title"] = key # copy the key to its associated sub-map button = Button(title_format % key) button.setStyleName(self.style["keyItem"]) if hasattr(key_data, "desc"): button.setTitle(key_data["desc"]) if "optional" not in key_data: key_data["optional"] = False button.setVisible(not key_data["optional"]) y = len(self.children) + self.offsets["y"] x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"] self.container.insertRow(y) self.container.setWidget(y, x, button) self.container.getCellFormatter().setStyleName(y, x, self.style["buttonCell"]) _child = ListPanel(self, key_data, self.style) self.container.setWidget(y, x + 1, _child) self.children[key] = {} self.children[key]["button"] = button self.children[key]["panel"] = _child if hasattr(self, "popup_menu"): # self.registerPopupMenuPanel has been called yet self.popup_menu.registerClickSender(button) def removeItemKey(self, key): """Remove from the container a ListPanel representing an item key, and all its associated data. @param key (unicode): item key """ items = self.children[key]["panel"].getItems() (y, x) = self.container.getIndex(self.children[key]["button"]) self.container.removeRow(y) del self.children[key] del self.keys[key] self.addToRemainingList(items) def refresh(self, hide_everything=False): """Set visible the sub-panels that are non optional or non empty, hide the rest. Setting the attribute "hide_everything" to True you can also hide everything. @param hide_everything (boolean): set to True to hide everything """ for key in self.children: self.setItemPanelVisible(key, False) if hide_everything: return for key, items in self.getItemsByKey().iteritems(): if len(items) > 0 or not self.keys[key]["optional"]: self.setItemPanelVisible(key, True) def setVisible(self, visible): self.refresh(not visible) def setItemPanelVisible(self, key, visible=True, sender=None): """Set the item key's widgets visibility. @param key (unicode): item key @param visible (bool): set to True to display the widgets @param sender """ self.children[key]["button"].setVisible(visible) self.children[key]["panel"].setVisible(visible) @property def items_remaining(self): """Return the unused items.""" if not self.items_remaining_sorted: self.items_remaining.sort() self.items_remaining_sorted = True return self.items_remaining def setRemainingListUnsorted(self): """Mark the list of unused items as being unsorted.""" self.items_remaining_sorted = False def removeFromRemainingList(self, items): """Remove some items from the list of unused items. @param items (list): items to be removed """ for item in items: if item in self.items_remaining: self.items_remaining.remove(item) def addToRemainingList(self, items, ignore_key=None): """Add some items to the list of unused items. Check first if the items are really not used in any ListPanel. @param items (list): items to be removed @param ignore_key (unicode): item key to be ignored while checking """ items_assigned = set() for key, current_items in self.getItemsByKey().iteritems(): if ignore_key is not None and key == ignore_key: continue items_assigned.update(current_items) for item in items: if item not in self.items or item in self.items_remaining or item in items_assigned: continue self.items_remaining.append(item) self.setRemainingListUnsorted() def resetItems(self, data={}): """Repopulate all the lists (one per item key) with the given items. @param data (dict{unicode: list}): dict binding items keys to items. """ for key in self.keys: if key in data: self.children[key]["panel"].resetItems(data[key]) else: self.children[key]["panel"].resetItems([]) self.refresh() def getItemsByKey(self): """Get all the items by key. @return: dict{unicode: set} """ return {key: self.children[key]["panel"].getItems() for key in self.children} def getKeysByItem(self): """Get all the keys by item. @return: dict{object: set(unicode)} """ result = {} for key in self.children: for item in self.children[key]["panel"].getItems(): result.setdefault(item, set()).add(key) return result def registerPopupMenuPanel(self, entries, hide, callback): """Register a popup menu panel for the item keys buttons. @param entries (dict{unicode: dict{unicode: unicode}}): menu entries @param hide (callable): method to call in order to know if a menu item should be hidden from the menu. Takes in the button widget and the item key and returns a boolean. @param callback (callable): common callback for all menu items, takes in the button widget and the item key. """ self.popup_menu = base_panel.PopupMenuPanel(entries, hide, callback, style={"item": self.style["popupMenuItem"]}) class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget): """A draggable AutoCompleteTextBox which is used for representing an item.""" def __init__(self, list_panel, event_cbs, style): """ @param list_panel (ListPanel) @param event_cbs (list[callable]) @param style (dict) """ AutoCompleteTextBox.__init__(self) DragWidget.__init__(self) self.list_panel = list_panel self.event_cbs = event_cbs self.style = style self.addStyleName(style["textBox"]) self.reset() # Parent classes already init self as an handler for these events self.addMouseListener(self) self.addFocusListener(self) self.addChangeListener(self) def onDragStart(self, event): """The user starts dragging the text box.""" self.list_panel.manager.target_drop_cell = None self.setSelectionRange(len(self.getText()), 0) dt = event.dataTransfer dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX")) dt.setDragImage(self.getElement(), 15, 15) def onDragEnd(self, event): """The user dropped the text box.""" target = self.list_panel.manager.target_drop_cell # parent or another ListPanel if self.getText() == "" or target is None: return self.event_cbs["drop"](self, target) def onClick(self, sender): """The choices list is clicked""" assert sender == self.choices AutoCompleteTextBox.onClick(self, sender) self.validate() def onChange(self, sender): """The list selection or the text has been changed""" assert sender == self.choices or sender == self if sender == self.choices: AutoCompleteTextBox.onChange(self, sender) self.validate() def onKeyUp(self, sender, keycode, modifiers): """Listen for key stroke""" assert sender == self AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers) if keycode == KEY_ENTER: self.validate() def onMouseMove(self, sender): """Mouse enters the area of a DragAutoCompleteTextBox.""" assert sender == self if hasattr(sender, "remove_btn"): sender.remove_btn.setVisible(True) def onMouseLeave(self, sender): """Mouse leaves the area of a DragAutoCompleteTextBox.""" assert sender == self if hasattr(sender, "remove_btn"): Timer(1500, lambda timer: sender.remove_btn.setVisible(False)) def onFocus(self, sender): """The DragAutoCompleteTextBox has the focus.""" assert sender == self # FIXME: this raises runtime JS error "Permission denied to access property..." when you drag the object #sender.setSelectionRange(0, len(sender.getText())) sender.event_cbs["focus"](sender) def reset(self): """Reset the text box""" self.setText("") self.setValid() def setValid(self, valid=True): """Change the style according to the text validity.""" if self.getText() == "": valid = True if valid: self.removeStyleName(self.style["textBox-invalid"]) else: self.addStyleName(self.style["textBox-invalid"]) self.valid = valid def validate(self): """Check if the text is valid, update the style.""" self.setSelectionRange(len(self.getText()), 0) self.event_cbs["validate"](self) def setRemoveButton(self): """Add the remove button after the text box.""" def remove_cb(sender): """Callback for the button to remove this item.""" self.list_panel.remove(self) self.list_panel.remove(self.remove_btn) self.event_cbs["remove"](self) self.remove_btn = Button(REMOVE_BUTTON, remove_cb, Visible=False) self.remove_btn.setStyleName(self.style["removeButton"]) self.list_panel.add(self.remove_btn) def removeOrReset(self): """Remove the text box if the remove button exists, or reset the text box.""" if hasattr(self, "remove_btn"): self.remove_btn.click() else: self.reset() VALID = 1 INVALID = 2 DELETE = 3 class ListPanel(FlowPanel, libervia_widget.DropCell): """Panel used for listing items sharing the same key. The key is showed as a Button to which you can bind a popup menu and the items are represented with a sequence of DragAutoCompleteTextBox.""" # XXX: beware that pyjamas.ui.FlowPanel is not fully implemented yet and can not be used with pyjamas.ui.Label def __init__(self, manager, data, style={}): """Initialization with a button and a DragAutoCompleteTextBox. @param manager (ListManager) @param data (dict{unicode: unicode}) @param style (dict{unicode: unicode}) """ FlowPanel.__init__(self, Visible=(False if data["optional"] else True)) def setTargetDropCell(host, item): self.manager.target_drop_cell = self # FIXME: dirty magic strings '@' and '@@' drop_cbs = {"GROUP": lambda host, item: self.addItem("@%s" % item), "CONTACT": lambda host, item: self.addItem(tryJID(item)), "CONTACT_TITLE": lambda host, item: self.addItem('@@'), "CONTACT_TEXTBOX": setTargetDropCell } libervia_widget.DropCell.__init__(self, None) self.drop_keys = drop_cbs self.style = style self.addStyleName(self.style["keyPanel"]) self.manager = manager self.key = data["title"] self._addTextBox() def onDrop(self, event): try: libervia_widget.DropCell.onDrop(self, event) except base_widget.NoLiberviaWidgetException: pass def _addTextBox(self, switchPrevious=False): """Add an empty text box to the last position. @param switchPrevious (bool): if True, simulate an insertion before the current last textbox by switching the texts and valid states @return: an DragAutoCompleteTextBox, the created text box or the previous one if switchPrevious is True. """ if hasattr(self, "_last_textbox"): if self._last_textbox.getText() == "": return self._last_textbox.setRemoveButton() else: switchPrevious = False def focus_cb(sender): if sender != self._last_textbox: # save the current value before it's being modified self.manager.addToRemainingList([tryJID(sender.getText())], ignore_key=self.key) items = [unicode(item) for item in self.manager.items_remaining] sender.setCompletionItems(items) def add_cb(sender): self.addItem(tryJID(sender.getText()), sender) def remove_cb(sender): """Callback for the button to remove this item.""" self.manager.addToRemainingList([tryJID(sender.getText())]) self.manager.setRemainingListUnsorted() self._last_textbox.setFocus(True) def drop_cb(sender, target): """Callback when the textbox is drag-n-dropped.""" list_panel = sender.list_panel if target != list_panel and target.addItem(tryJID(sender.getText())): sender.removeOrReset() else: list_panel.manager.removeFromRemainingList([tryJID(sender.getText())]) events_cbs = {"focus": focus_cb, "validate": add_cb, "remove": remove_cb, "drop": drop_cb} textbox = DragAutoCompleteTextBox(self, events_cbs, self.style) self.add(textbox) if switchPrevious: textbox.setText(self._last_textbox.getText()) textbox.setValid(self._last_textbox.valid) self._last_textbox.reset() previous = self._last_textbox self._last_textbox = textbox return previous if switchPrevious else textbox def _checkItem(self, item, modify): """ @param item (object): the item to check @param modify (bool): True if the item is being modified @return: int value defined by one of these constants: - VALID if the item is valid - INVALID if the item is not valid but can be displayed - DELETE if the item should not be displayed at all """ def count(list_, item): # XXX: list.count in not implemented by pyjamas return len([elt for elt in list_ if elt == item]) if not item: return DELETE if count(self.getItems(), item) > (1 if modify else 0): return DELETE return VALID if item in self.manager.items else INVALID def addItem(self, item, sender=None): """Try to add an item. It will be added if it's a valid one. @param item (object): item to be added @param (DragAutoCompleteTextBox): widget triggering the event @param sender: if True, the item will be "written" to the last textbox and a new text box will be added afterward. @return: True if the item has been added. """ valid = self._checkItem(item, sender is not None) item_s = unicode(item) if sender is None: # method has been called not to modify but to add an item if valid == VALID: # eventually insert before the last textbox if it's not empty sender = self._addTextBox(True) if self._last_textbox.getText() != "" else self._last_textbox sender.setText(item_s) else: sender.setValid(valid == VALID) if valid != VALID: if sender is not None and valid == DELETE: sender.removeOrReset() return False if sender == self._last_textbox: self._addTextBox() sender.setVisibleLength(len(item_s)) self.manager.removeFromRemainingList([item]) self._last_textbox.setFocus(True) return True def emptyItems(self): """Empty the list of items.""" for child in self.getChildren(): if hasattr(child, "remove_btn"): child.remove_btn.click() def resetItems(self, items): """Repopulate the items. @param items (list): the items to be listed. """ self.emptyItems() if isinstance(items, set): items = list(items) items.sort() for item in items: self.addItem(item) def getItems(self): """Get the listed items. @return: set""" items = set() for widget in self.getChildren(): if isinstance(widget, DragAutoCompleteTextBox) and widget.getText() != "": items.add(tryJID(widget.getText())) return items