comparison 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
comparison
equal deleted inserted replaced
1123:63a4b8fe9782 1124:28e3eb3bb217
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2013-2016 Adrien Cossa <souliane@mailoo.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.log import getLogger
21 log = getLogger(__name__)
22 from sat.core.i18n import _
23
24 from pyjamas.ui.ClickListener import ClickHandler
25 from pyjamas.ui.FocusListener import FocusHandler
26 from pyjamas.ui.ChangeListener import ChangeHandler
27 from pyjamas.ui.DragHandler import DragHandler
28 from pyjamas.ui.KeyboardListener import KeyboardHandler, KEY_ENTER
29 from pyjamas.ui.DragWidget import DragWidget
30 from pyjamas.ui.ListBox import ListBox
31 from pyjamas.ui.Button import Button
32 from pyjamas.ui.FlowPanel import FlowPanel
33 from pyjamas.ui.HorizontalPanel import HorizontalPanel
34 from pyjamas.ui.FlexTable import FlexTable
35 from pyjamas.ui.AutoComplete import AutoCompleteTextBox
36
37 import base_panel
38 import base_widget
39 import libervia_widget
40
41 from sat_frontends.quick_frontend import quick_list_manager
42
43
44 unicode = str # FIXME: pyjamas workaround
45
46
47 class ListItem(HorizontalPanel):
48 """This class implements a list item with auto-completion and a delete button."""
49
50 STYLE = {"listItem": "listItem",
51 "listItem-box": "listItem-box",
52 "listItem-box-invalid": "listItem-box-invalid",
53 "listItem-button": "listItem-button",
54 }
55
56 VALID = 1
57 INVALID = 2
58 DUPLICATE = 3
59
60 def __init__(self, listener=None, taglist=None, validate=None):
61 """
62
63 @param listener (ListItemHandler): handler for the UI events
64 @param taglist (quick_list_manager.QuickTagList): list manager
65 @param validate (callable): method returning a bool to validate the entry
66 """
67 HorizontalPanel.__init__(self)
68 self.addStyleName(self.STYLE["listItem"])
69
70 self.box = AutoCompleteTextBox(StyleName=self.STYLE["listItem-box"])
71 self.remove_btn = Button('<span>x</span>', Visible=False)
72 self.remove_btn.setStyleName(self.STYLE["listItem-button"])
73 self.add(self.box)
74 self.add(self.remove_btn)
75
76 if listener:
77 self.box.addFocusListener(listener)
78 self.box.addChangeListener(listener)
79 self.box.addKeyboardListener(listener)
80 self.box.choices.addClickListener(listener)
81 self.remove_btn.addClickListener(listener)
82
83 self.taglist = taglist
84 self.validate = validate
85 self.last_checked_value = ""
86 self.last_validity = self.VALID
87
88 @property
89 def text(self):
90 return self.box.getText()
91
92 def setText(self, text):
93 """
94 Set the text and refresh the Widget.
95
96 @param text (unicode): text to set
97 """
98 self.box.setText(text)
99 self.refresh()
100
101 def refresh(self):
102 if self.last_checked_value == self.text:
103 return
104
105 if self.taglist and self.last_checked_value:
106 self.taglist.untag([self.last_checked_value])
107
108 if self.validate: # if None, the state is always valid
109 self.last_validity = self.validate(self.text)
110
111 if self.last_validity == self.VALID:
112 self.box.removeStyleName(self.STYLE["listItem-box-invalid"])
113 self.box.setVisibleLength(max(len(self.text), 10))
114 elif self.last_validity == self.INVALID:
115 self.box.addStyleName(self.STYLE["listItem-box-invalid"])
116 elif self.last_validity == self.DUPLICATE:
117 self.remove_btn.click() # this may do more stuff then self.remove()
118 return
119
120 if self.taglist and self.text:
121 self.taglist.tag([self.text])
122 self.last_checked_value = self.text
123 self.box.setSelectionRange(len(self.text), 0)
124 self.remove_btn.setVisible(len(self.text) > 0)
125
126 def setFocus(self, focused):
127 self.box.setFocus(focused)
128
129 def remove(self):
130 """Remove the list item from its parent."""
131 self.removeFromParent()
132
133 if self.taglist and self.text: # this must be done after the widget has been removed
134 self.taglist.untag([self.text])
135
136
137 class DraggableListItem(ListItem, DragWidget):
138 """This class is like ListItem, but in addition it can be dragged."""
139
140 def __init__(self, listener=None, taglist=None, validate=None):
141 """
142
143 @param listener (ListItemHandler): handler for the UI events
144 @param taglist (quick_list_manager.QuickTagList): list manager
145 @param validate (callable): method returning a bool to validate the entry
146 """
147 ListItem.__init__(self, listener, taglist, validate)
148 DragWidget.__init__(self)
149 self.addDragListener(listener)
150
151
152 def onDragStart(self, event):
153 """The user starts dragging the item."""
154 dt = event.dataTransfer
155 dt.setData('text/plain', "%s\n%s" % (self.text, "CONTACT_TEXTBOX"))
156 dt.setDragImage(self.box.getElement(), 15, 15)
157
158
159 class ListItemHandler(ClickHandler, FocusHandler, KeyboardHandler, ChangeHandler):
160 """Implements basic handlers for the ListItem events."""
161
162 last_item = None # the last item is an empty text box for user input
163
164 def __init__(self, taglist):
165 """
166
167 @param taglist (quick_list_manager.QuickTagList): list manager
168 """
169 ClickHandler.__init__(self)
170 FocusHandler.__init__(self)
171 ChangeHandler.__init__(self)
172 KeyboardHandler.__init__(self)
173 self.taglist = taglist
174
175 def addItem(self, item):
176 raise NotImplementedError
177
178 def removeItem(self, item):
179 raise NotImplementedError
180
181 def onClick(self, sender):
182 """The remove button or a suggested completion item has been clicked."""
183 #log.debug("onClick sender type: %s" % type(sender))
184 if isinstance(sender, Button):
185 item = sender.getParent()
186 self.removeItem(item)
187 elif isinstance(sender, ListBox):
188 # this is called after onChange when you click a suggested item, and now we get the final value
189 textbox = sender._clickListeners[0]
190 self.checkValue(textbox)
191 else:
192 raise AssertionError
193
194 def onFocus(self, sender):
195 """The text box has the focus."""
196 #log.debug("onFocus sender type: %s" % type(sender))
197 assert isinstance(sender, AutoCompleteTextBox)
198 sender.setCompletionItems(self.taglist.untagged)
199
200 def onKeyUp(self, sender, keycode, modifiers):
201 """The text box is being modified - or ENTER key has been pressed."""
202 # this is called after onChange when you press ENTER, and now we get the final value
203 #log.debug("onKeyUp sender type: %s" % type(sender))
204 assert isinstance(sender, AutoCompleteTextBox)
205 if keycode == KEY_ENTER:
206 self.checkValue(sender)
207
208 def onChange(self, sender):
209 """The text box has been changed by the user."""
210 # this is called before the completion when you press ENTER or click a suggest item
211 #log.debug("onChange sender type: %s" % type(sender))
212 assert isinstance(sender, AutoCompleteTextBox)
213 self.checkValue(sender)
214
215 def checkValue(self, textbox):
216 """Internal handler to call when a new value is submitted by the user."""
217 item = textbox.getParent()
218 if item.text == item.last_checked_value:
219 # this method has already been called (by self.onChange) and there's nothing new
220 return
221 item.refresh()
222 if item == self.last_item and item.last_validity == ListItem.VALID and item.text:
223 self.addItem()
224
225 class DraggableListItemHandler(ListItemHandler, DragHandler):
226 """Implements basic handlers for the DraggableListItem events."""
227
228 def __init__(self, manager):
229 """
230
231 @param manager (ListManager): list manager
232 """
233 ListItemHandler.__init__(self, manager)
234 DragHandler.__init__(self)
235
236 @property
237 def manager(self):
238 return self.taglist
239
240 def onDragStart(self, event):
241 """The user starts dragging the item."""
242 self.manager.drop_target = None
243
244 def onDragEnd(self, event):
245 """The user dropped the list item."""
246 text, dummy = libervia_widget.eventGetData(event)
247 target = self.manager.drop_target # self or another ListPanel
248 if text == "" or target is None:
249 return
250 if target != self: # move the item from self to target
251 target.addItem(text)
252 self.removeItem(self.getItem(text))
253
254
255 class ListPanel(FlowPanel, DraggableListItemHandler, libervia_widget.DropCell):
256 """Implements a list of items."""
257 # XXX: beware that pyjamas.ui.FlowPanel is not fully implemented:
258 # - it can not be used with pyjamas.ui.Label
259 # - FlowPanel.insert doesn't work
260
261 STYLE = {"listPanel": "listPanel"}
262 ACCEPT_NEW_ENTRY = False
263
264 def __init__(self, manager, items=None):
265 """Initialization with a button for the list name (key) and a DraggableListItem.
266
267 @param manager (ListManager): list manager
268 @param items (list): items to be set
269 """
270 FlowPanel.__init__(self)
271 DraggableListItemHandler.__init__(self, manager)
272 libervia_widget.DropCell.__init__(self, None)
273 self.addStyleName(self.STYLE["listPanel"])
274 self.manager = manager
275 self.resetItems(items)
276
277 # FIXME: dirty magic strings '@' and '@@'
278 self.drop_keys = {"GROUP": lambda host, item_s: self.addItem("@%s" % item_s),
279 "CONTACT": lambda host, item_s: self.addItem(item_s),
280 "CONTACT_TITLE": lambda host, item_s: self.addItem('@@'),
281 "CONTACT_TEXTBOX": lambda host, item_s: setattr(self.manager, "drop_target", self),
282 }
283
284 def onDrop(self, event):
285 """Something has been dropped in this ListPanel"""
286 try:
287 libervia_widget.DropCell.onDrop(self, event)
288 except base_widget.NoLiberviaWidgetException:
289 pass
290
291 def getItem(self, text):
292 """Get an item from its text.
293
294 @param text(unicode): item text
295 """
296 for child in self.getChildren():
297 if child.text == text:
298 return child
299 return None
300
301 def getItems(self):
302 """Get the non empty items.
303
304 @return list(unicode)
305 """
306 return [widget.text for widget in self.getChildren() if isinstance(widget, ListItem) and widget.text]
307
308 def validateItem(self, text):
309 """Return validation code after the item has been changed.
310
311 @param text (unicode): item text to check
312 @return: int value defined by one of these constants:
313 - VALID if the item is valid
314 - INVALID if the item is not valid but can be displayed
315 - DUPLICATE if the item is a duplicate
316 """
317 def count(list_, item): # XXX: list.count in not implemented by pyjamas
318 return len([elt for elt in list_ if elt == item])
319
320 if count(self.getItems(), text) > 1:
321 return ListItem.DUPLICATE # item already exists in this list so we suggest its deletion
322 if self.ACCEPT_NEW_ENTRY:
323 return ListItem.VALID
324 return ListItem.VALID if text in self.manager.items or not text else ListItem.INVALID
325
326 def addItem(self, text=""):
327 """Add an item.
328
329 @param text (unicode): text to be set.
330 @return: True if the item has been really added or merged.
331 """
332 if text in self.getItems(): # avoid duplicate in the same list
333 return
334
335 item = DraggableListItem(self, self.manager, self.validateItem)
336 self.add(item)
337
338 if self.last_item:
339 if self.last_item.last_validity == ListItem.INVALID:
340 # switch the two values so that the invalid one stays in last position
341 item.setText(self.last_item.text)
342 self.last_item.setText(text)
343 elif not self.last_item.text:
344 # copy the new value to previous empty item
345 self.last_item.setText(text)
346 else: # first item of the list, or previous last item has been deleted
347 item.setText(text)
348
349 self.last_item = item
350 self.last_item.setFocus(True)
351
352 def removeItem(self, item):
353 """Remove an item.
354
355 @param item(DraggableListItem): item to remove
356 """
357 if item == self.last_item:
358 self.addItem("")
359 item.remove() # this also updates the taglist
360
361 def resetItems(self, items):
362 """Reset the items.
363
364 @param items (list): items to be set
365 """
366 for child in self.getChildren():
367 child.remove()
368
369 self.addItem()
370 if not items:
371 return
372
373 items.sort()
374 for item in items:
375 self.addItem(unicode(item))
376
377
378 class ListManager(FlexTable, quick_list_manager.QuickTagList):
379 """Implements a table to manage one or several lists of items."""
380
381 STYLE = {"listManager-button": "group",
382 "listManager-button-cell": "listManager-button-cell",
383 }
384
385 def __init__(self, data=None, items=None):
386 """
387 @param data (dict{unicode: list}): dict binding keys to tagged items.
388 @param items (list): full list of items (tagged and untagged)
389 """
390 FlexTable.__init__(self, Width="100%")
391 quick_list_manager.QuickTagList.__init__(self, [unicode(item) for item in items])
392 self.lists = {}
393
394 if data:
395 for key, items in data.iteritems():
396 self.addList(key, [unicode(item) for item in items])
397
398 def addList(self, key, items=None):
399 """Add a Button and ListPanel for a new list.
400
401 @param key (unicode): list name
402 @param items (list): items to append to the new list
403 """
404 if key in self.lists:
405 return
406
407 if items is None:
408 items = []
409
410 self.lists[key] = {"button": Button(key, Title=key, StyleName=self.STYLE["listManager-button"]),
411 "panel": ListPanel(self, items)}
412
413 y, x = len(self.lists), 0
414 self.insertRow(y)
415 self.setWidget(y, x, self.lists[key]["button"])
416 self.setWidget(y, x + 1, self.lists[key]["panel"])
417 self.getCellFormatter().setStyleName(y, x, self.STYLE["listManager-button-cell"])
418
419 try:
420 self.popup_menu.registerClickSender(self.lists[key]["button"])
421 except (AttributeError, TypeError): # self.registerPopupMenuPanel hasn't been called yet
422 pass
423
424 def removeList(self, key):
425 """Remove a ListPanel from this manager.
426
427 @param key (unicode): list name
428 """
429 items = self.lists[key]["panel"].getItems()
430 (y, x) = self.getIndex(self.lists[key]["button"])
431 self.removeRow(y)
432 del self.lists[key]
433 self.untag(items)
434
435 def untag(self, items):
436 """Untag some items.
437
438 Check first if the items are not used in any panel.
439
440 @param items (list): items to be removed
441 """
442 items_assigned = set()
443 for values in self.getItemsByKey().itervalues():
444 items_assigned.update(values)
445 quick_list_manager.QuickTagList.untag(self, [item for item in items if item not in items_assigned])
446
447 def getItemsByKey(self):
448 """Get the items grouped by list name.
449
450 @return dict{unicode: list}
451 """
452 return {key: self.lists[key]["panel"].getItems() for key in self.lists}
453
454 def getKeysByItem(self):
455 """Get the keys groups by item.
456
457 @return dict{object: set(unicode)}
458 """
459 result = {}
460 for key in self.lists:
461 for item in self.lists[key]["panel"].getItems():
462 result.setdefault(item, set()).add(key)
463 return result
464
465 def registerPopupMenuPanel(self, entries, callback):
466 """Register a popup menu panel for the list names' buttons.
467
468 @param entries (dict{unicode: dict{unicode: unicode}}): menu entries
469 @param callback (callable): common callback for all menu items, arguments are:
470 - button widget
471 - list name (item key)
472 """
473 self.popup_menu = base_panel.PopupMenuPanel(entries, callback=callback)
474 for key in self.lists: # register click sender for already existing lists
475 self.popup_menu.registerClickSender(self.lists[key]["button"])
476
477
478 class TagsPanel(base_panel.ToggleStackPanel):
479 """A toggle panel to set the tags"""
480
481 TAGS = _("Tags")
482
483 STYLE = {"main": "tagsPanel-main",
484 "tags": "tagsPanel-tags"}
485
486 def __init__(self, suggested_tags, tags=None):
487 """
488
489 @param suggested_tags (list[unicode]): list of all suggested tags
490 @param tags (list[unicode]): already assigned tags
491 """
492 base_panel.ToggleStackPanel.__init__(self, Width="100%")
493 self.addStyleName(self.STYLE["main"])
494
495 if tags is None:
496 tags = []
497
498 self.tags = ListPanel(quick_list_manager.QuickTagList(suggested_tags), tags)
499 self.tags.addStyleName(self.STYLE["tags"])
500 self.tags.ACCEPT_NEW_ENTRY = True
501 self.add(self.tags, self.TAGS)
502 self.addStackChangeListener(self)
503
504 def onStackChanged(self, sender, index, visible=None):
505 if visible is None:
506 visible = sender.getWidget(index).getVisible()
507 text = ", ".join(self.getTags())
508 suffix = "" if (visible or not text) else (": %s" % text)
509 sender.setStackText(index, self.TAGS + suffix)
510
511 def getTags(self):
512 return self.tags.getItems()
513
514 def setTags(self, items):
515 self.tags.resetItems(items)
516