diff browser/sat_browser/list_manager.py @ 1124:28e3eb3bb217

files reorganisation and installation rework: - files have been reorganised to follow other SàT projects and usual Python organisation (no more "/src" directory) - VERSION file is now used, as for other SàT projects - replace the overcomplicated setup.py be a more sane one. Pyjamas part is not compiled anymore by setup.py, it must be done separatly - removed check for data_dir if it's empty - installation tested working in virtual env - libervia launching script is now in bin/libervia
author Goffi <goffi@goffi.org>
date Sat, 25 Aug 2018 17:59:48 +0200
parents src/browser/sat_browser/list_manager.py@f8a7a046ff9c
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/browser/sat_browser/list_manager.py	Sat Aug 25 17:59:48 2018 +0200
@@ -0,0 +1,516 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2013-2016 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 sat.core.i18n import _
+
+from pyjamas.ui.ClickListener import ClickHandler
+from pyjamas.ui.FocusListener import FocusHandler
+from pyjamas.ui.ChangeListener import ChangeHandler
+from pyjamas.ui.DragHandler import DragHandler
+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
+
+import base_panel
+import base_widget
+import libervia_widget
+
+from sat_frontends.quick_frontend import quick_list_manager
+
+
+unicode = str  # FIXME: pyjamas workaround
+
+
+class ListItem(HorizontalPanel):
+    """This class implements a list item with auto-completion and a delete button."""
+
+    STYLE = {"listItem": "listItem",
+             "listItem-box": "listItem-box",
+             "listItem-box-invalid": "listItem-box-invalid",
+             "listItem-button": "listItem-button",
+             }
+
+    VALID = 1
+    INVALID = 2
+    DUPLICATE = 3
+
+    def __init__(self, listener=None, taglist=None, validate=None):
+        """
+
+        @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
+        """
+        HorizontalPanel.__init__(self)
+        self.addStyleName(self.STYLE["listItem"])
+
+        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)
+
+        if listener:
+            self.box.addFocusListener(listener)
+            self.box.addChangeListener(listener)
+            self.box.addKeyboardListener(listener)
+            self.box.choices.addClickListener(listener)
+            self.remove_btn.addClickListener(listener)
+
+        self.taglist = taglist
+        self.validate = validate
+        self.last_checked_value = ""
+        self.last_validity = self.VALID
+
+    @property
+    def text(self):
+        return self.box.getText()
+
+    def setText(self, text):
+        """
+        Set the text and refresh the Widget.
+        
+        @param text (unicode): text to set
+        """
+        self.box.setText(text)
+        self.refresh()
+
+    def refresh(self):
+        if self.last_checked_value == self.text:
+            return
+
+        if self.taglist and self.last_checked_value:
+            self.taglist.untag([self.last_checked_value])
+
+        if self.validate:  # if None, the state is always valid
+            self.last_validity = self.validate(self.text)
+
+        if self.last_validity == self.VALID:
+            self.box.removeStyleName(self.STYLE["listItem-box-invalid"])
+            self.box.setVisibleLength(max(len(self.text), 10))
+        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.box.setSelectionRange(len(self.text), 0)  
+        self.remove_btn.setVisible(len(self.text) > 0)
+                     
+    def setFocus(self, focused):
+        self.box.setFocus(focused)
+
+    def remove(self):
+        """Remove the list item from its parent."""
+        self.removeFromParent()
+
+        if self.taglist and self.text:  # this must be done after the widget has been removed
+            self.taglist.untag([self.text])
+
+
+class DraggableListItem(ListItem, DragWidget):
+    """This class is like ListItem, but in addition it can be dragged."""
+
+    def __init__(self, listener=None, taglist=None, validate=None):
+        """
+    
+        @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.addDragListener(listener)
+
+
+    def onDragStart(self, event):
+        """The user starts dragging the item."""
+        dt = event.dataTransfer
+        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 __init__(self, taglist):
+        """
+        
+        @param taglist (quick_list_manager.QuickTagList): list manager
+        """
+        ClickHandler.__init__(self)
+        FocusHandler.__init__(self)
+        ChangeHandler.__init__(self)
+        KeyboardHandler.__init__(self)
+        self.taglist = taglist
+
+    def addItem(self, item):
+        raise NotImplementedError
+
+    def removeItem(self, item):
+        raise NotImplementedError
+
+    def onClick(self, sender):
+        """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 onFocus(self, sender):
+        """The text box has the focus."""
+        #log.debug("onFocus sender type:  %s" % type(sender))
+        assert isinstance(sender, AutoCompleteTextBox)
+        sender.setCompletionItems(self.taglist.untagged)
+
+    def 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.checkValue(sender)
+
+    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 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()
+        if item == self.last_item and item.last_validity == ListItem.VALID and item.text:
+            self.addItem()
+
+class DraggableListItemHandler(ListItemHandler, DragHandler):
+    """Implements basic handlers for the DraggableListItem events."""
+
+    def __init__(self, manager):
+        """
+        
+        @param manager (ListManager): list manager
+        """
+        ListItemHandler.__init__(self, manager)
+        DragHandler.__init__(self)
+
+    @property
+    def manager(self):
+        return self.taglist
+
+    def onDragStart(self, event):
+        """The user starts dragging the item."""
+        self.manager.drop_target = None
+
+    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))
+
+
+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
+
+    STYLE = {"listPanel": "listPanel"}
+    ACCEPT_NEW_ENTRY = False
+
+    def __init__(self, manager, items=None):
+        """Initialization with a button for the list name (key) and a DraggableListItem.
+
+        @param manager (ListManager): list manager
+        @param items (list): items to be set
+        """
+        FlowPanel.__init__(self)
+        DraggableListItemHandler.__init__(self, manager)
+        libervia_widget.DropCell.__init__(self, None)
+        self.addStyleName(self.STYLE["listPanel"])
+        self.manager = manager
+        self.resetItems(items)
+
+        # FIXME: dirty magic strings '@' and '@@'
+        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 getItem(self, text):
+        """Get an item from its text.
+        
+        @param text(unicode): item text
+        """
+        for child in self.getChildren():
+            if child.text == text:
+                return child
+        return None
+
+    def getItems(self):
+        """Get the non empty items.
+
+        @return list(unicode)
+        """
+        return [widget.text for widget in self.getChildren() if isinstance(widget, ListItem) and widget.text]
+
+    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
+            - DUPLICATE if the item is a duplicate
+        """
+        def count(list_, item): # XXX: list.count in not implemented by pyjamas
+            return len([elt for elt in list_ if elt == item])
+
+        if count(self.getItems(), text) > 1:
+            return ListItem.DUPLICATE  # item already exists in this list so we suggest its deletion
+        if self.ACCEPT_NEW_ENTRY:
+            return ListItem.VALID
+        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 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
+
+    def resetItems(self, items):
+        """Reset the items.
+        
+        @param items (list): items to be set
+        """
+        for child in self.getChildren():
+            child.remove()
+
+        self.addItem()
+        if not items:
+            return
+
+        items.sort()
+        for item in items:
+            self.addItem(unicode(item))
+
+
+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",
+             }
+
+    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
+        """
+        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, 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.
+
+        @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 getItemsByKey(self):
+        """Get the items grouped by list name.
+
+        @return dict{unicode: list}
+        """
+        return {key: self.lists[key]["panel"].getItems() for key in self.lists}
+
+    def getKeysByItem(self):
+        """Get the keys groups by item.
+
+        @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
+
+    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"])
+
+
+class TagsPanel(base_panel.ToggleStackPanel):
+    """A toggle panel to set the tags"""
+
+    TAGS = _("Tags")
+    
+    STYLE = {"main": "tagsPanel-main",
+             "tags": "tagsPanel-tags"}
+
+    def __init__(self, suggested_tags, tags=None):
+        """
+        
+        @param suggested_tags (list[unicode]): list of all suggested tags
+        @param tags (list[unicode]): already assigned tags
+        """
+        base_panel.ToggleStackPanel.__init__(self, Width="100%")
+        self.addStyleName(self.STYLE["main"])
+        
+        if tags is None:
+            tags = []
+
+        self.tags = ListPanel(quick_list_manager.QuickTagList(suggested_tags), tags)
+        self.tags.addStyleName(self.STYLE["tags"])
+        self.tags.ACCEPT_NEW_ENTRY = True
+        self.add(self.tags, self.TAGS)
+        self.addStackChangeListener(self)
+
+    def onStackChanged(self, sender, index, visible=None):
+        if visible is None:
+            visible = sender.getWidget(index).getVisible()
+        text = ", ".join(self.getTags())
+        suffix = "" if (visible or not text) else (": %s" % text)
+        sender.setStackText(index, self.TAGS + suffix)
+
+    def getTags(self):
+        return self.tags.getItems()
+
+    def setTags(self, items):
+        self.tags.resetItems(items)
+