comparison src/browser/sat_browser/list_manager.py @ 467:97c72fe4a5f2

browser_side: import fixes: - moved browser modules in a sat_browser packages, to avoid import conflicts with std lib (e.g. logging), and let pyjsbuild work normaly - refactored bad import practices: classes are most of time not imported directly, module is imported instead.
author Goffi <goffi@goffi.org>
date Mon, 09 Jun 2014 22:15:26 +0200
parents src/browser/list_manager.py@981ed669d3b3
children 32dbbc941123
comparison
equal deleted inserted replaced
466:01880aa8ea2d 467:97c72fe4a5f2
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2013, 2014 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 pyjamas.ui.Grid import Grid
23 from pyjamas.ui.Button import Button
24 from pyjamas.ui.ListBox import ListBox
25 from pyjamas.ui.FlowPanel import FlowPanel
26 from pyjamas.ui.AutoComplete import AutoCompleteTextBox
27 from pyjamas.ui.Label import Label
28 from pyjamas.ui.HorizontalPanel import HorizontalPanel
29 from pyjamas.ui.VerticalPanel import VerticalPanel
30 from pyjamas.ui.DialogBox import DialogBox
31 from pyjamas.ui.KeyboardListener import KEY_ENTER
32 from pyjamas.ui.MouseListener import MouseHandler
33 from pyjamas.ui.FocusListener import FocusHandler
34 from pyjamas.ui.DropWidget import DropWidget
35 from pyjamas.Timer import Timer
36 from pyjamas import DOM
37
38 import base_panels
39 import base_widget
40
41 # HTML content for the removal button (image or text)
42 REMOVE_BUTTON = '<span class="recipientRemoveIcon">x</span>'
43
44 # Item to be considered for an empty list box selection.
45 # Could be whatever which doesn't look like a JID or a group name.
46 EMPTY_SELECTION_ITEM = ""
47
48
49 class ListManager():
50 """A manager for sub-panels to assign elements to lists."""
51
52 def __init__(self, parent, keys_dict={}, contact_list=[], offsets={}, style={}):
53 """
54 @param parent: FlexTable parent widget for the manager
55 @param keys_dict: dict with the contact keys mapped to data
56 @param contact_list: list of string (the contact JID userhosts)
57 @param offsets: dict to set widget positions offset within parent
58 - "x_first": the x offset for the first widget's row on the grid
59 - "x": the x offset for all widgets rows, except the first one if "x_first" is defined
60 - "y": the y offset for all widgets columns on the grid
61 """
62 self._parent = parent
63 if isinstance(keys_dict, set) or isinstance(keys_dict, list):
64 tmp = {}
65 for key in keys_dict:
66 tmp[key] = {}
67 keys_dict = tmp
68 self.__keys_dict = keys_dict
69 if isinstance(contact_list, set):
70 contact_list = list(contact_list)
71 self.__list = contact_list
72 self.__list.sort()
73 # store the list of contacts that are not assigned yet
74 self.__remaining_list = []
75 self.__remaining_list.extend(self.__list)
76 # mark a change to sort the list before it's used
77 self.__remaining_list_sorted = True
78
79 self.offsets = {"x_first": 0, "x": 0, "y": 0}
80 if "x" in offsets and not "x_first" in offsets:
81 offsets["x_first"] = offsets["x"]
82 self.offsets.update(offsets)
83
84 self.style = {
85 "keyItem": "recipientTypeItem",
86 "popupMenuItem": "recipientTypeItem",
87 "buttonCell": "recipientButtonCell",
88 "dragoverPanel": "dragover-recipientPanel",
89 "keyPanel": "recipientPanel",
90 "textBox": "recipientTextBox",
91 "textBox-invalid": "recipientTextBox-invalid",
92 "removeButton": "recipientRemoveButton",
93 }
94 self.style.update(style)
95
96 def createWidgets(self, title_format="%s"):
97 """Fill the parent grid with all the widgets (some may be hidden during the initialization)."""
98 self.__children = {}
99 for key in self.__keys_dict:
100 self.addContactKey(key, title_format=title_format)
101
102 def addContactKey(self, key, dict_={}, title_format="%s"):
103 if key not in self.__keys_dict:
104 self.__keys_dict[key] = dict_
105 # copy the key to its associated sub-map
106 self.__keys_dict[key]["title"] = key
107 self._addChild(self.__keys_dict[key], title_format)
108
109 def removeContactKey(self, key):
110 """Remove a list panel and all its associated data."""
111 contacts = self.__children[key]["panel"].getContacts()
112 (y, x) = self._parent.getIndex(self.__children[key]["button"])
113 self._parent.removeRow(y)
114 del self.__children[key]
115 del self.__keys_dict[key]
116 self.addToRemainingList(contacts)
117
118 def _addChild(self, entry, title_format):
119 """Add a button and FlowPanel for the corresponding map entry."""
120 button = Button(title_format % entry["title"])
121 button.setStyleName(self.style["keyItem"])
122 if hasattr(entry, "desc"):
123 button.setTitle(entry["desc"])
124 if not "optional" in entry:
125 entry["optional"] = False
126 button.setVisible(not entry["optional"])
127 y = len(self.__children) + self.offsets["y"]
128 x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"]
129
130 self._parent.insertRow(y)
131 self._parent.setWidget(y, x, button)
132 self._parent.getCellFormatter().setStyleName(y, x, self.style["buttonCell"])
133
134 _child = ListPanel(self, entry, self.style)
135 self._parent.setWidget(y, x + 1, _child)
136
137 self.__children[entry["title"]] = {}
138 self.__children[entry["title"]]["button"] = button
139 self.__children[entry["title"]]["panel"] = _child
140
141 if hasattr(self, "popup_menu"):
142 # this is done if self.registerPopupMenuPanel has been called yet
143 self.popup_menu.registerClickSender(button)
144
145 def _refresh(self, visible=True):
146 """Set visible the sub-panels that are non optional or non empty, hide the rest."""
147 for key in self.__children:
148 self.setContactPanelVisible(key, False)
149 if not visible:
150 return
151 _map = self.getContacts()
152 for key in _map:
153 if len(_map[key]) > 0 or not self.__keys_dict[key]["optional"]:
154 self.setContactPanelVisible(key, True)
155
156 def setVisible(self, visible):
157 self._refresh(visible)
158
159 def setContactPanelVisible(self, key, visible=True, sender=None):
160 """Do not remove the "sender" param as it is needed for the context menu."""
161 self.__children[key]["button"].setVisible(visible)
162 self.__children[key]["panel"].setVisible(visible)
163
164 @property
165 def list(self):
166 """Return the full list of potential contacts."""
167 return self.__list
168
169 @property
170 def keys(self):
171 return self.__keys_dict.keys()
172
173 @property
174 def keys_dict(self):
175 return self.__keys_dict
176
177 @property
178 def remaining_list(self):
179 """Return the contacts that have not been selected yet."""
180 if not self.__remaining_list_sorted:
181 self.__remaining_list_sorted = True
182 self.__remaining_list.sort()
183 return self.__remaining_list
184
185 def setRemainingListUnsorted(self):
186 """Mark a change (deletion) so the list will be sorted before it's used."""
187 self.__remaining_list_sorted = False
188
189 def removeFromRemainingList(self, contacts):
190 """Remove contacts after they have been added to a sub-panel."""
191 if not isinstance(contacts, list):
192 contacts = [contacts]
193 for contact_ in contacts:
194 if contact_ in self.__remaining_list:
195 self.__remaining_list.remove(contact_)
196
197 def addToRemainingList(self, contacts, ignore_key=None):
198 """Add contacts after they have been removed from a sub-panel."""
199 if not isinstance(contacts, list):
200 contacts = [contacts]
201 assigned_contacts = set()
202 assigned_map = self.getContacts()
203 for key_ in assigned_map.keys():
204 if ignore_key is not None and key_ == ignore_key:
205 continue
206 assigned_contacts.update(assigned_map[key_])
207 for contact_ in contacts:
208 if contact_ not in self.__list or contact_ in self.__remaining_list:
209 continue
210 if contact_ in assigned_contacts:
211 continue # the contact is assigned somewhere else
212 self.__remaining_list.append(contact_)
213 self.setRemainingListUnsorted()
214
215 def setContacts(self, _map={}):
216 """Set the contacts for each contact key."""
217 for key in self.__keys_dict:
218 if key in _map:
219 self.__children[key]["panel"].setContacts(_map[key])
220 else:
221 self.__children[key]["panel"].setContacts([])
222 self._refresh()
223
224 def getContacts(self):
225 """Get the contacts for all the lists.
226 @return: a mapping between keys and contact lists."""
227 _map = {}
228 for key in self.__children:
229 _map[key] = self.__children[key]["panel"].getContacts()
230 return _map
231
232 @property
233 def target_drop_cell(self):
234 """@return: the panel where something has been dropped."""
235 return self._target_drop_cell
236
237 def setTargetDropCell(self, target_drop_cell):
238 """@param: target_drop_cell: the panel where something has been dropped."""
239 self._target_drop_cell = target_drop_cell
240
241 def registerPopupMenuPanel(self, entries, hide, callback):
242 "Register a popup menu panel that will be bound to all contact keys elements."
243 self.popup_menu = base_panels.PopupMenuPanel(entries=entries, hide=hide, callback=callback, style={"item": self.style["popupMenuItem"]})
244
245
246 class DragAutoCompleteTextBox(AutoCompleteTextBox, base_widget.DragLabel, MouseHandler, FocusHandler):
247 """A draggable AutoCompleteTextBox which is used for representing a contact.
248 This class is NOT generic because of the onDragEnd method which call methods
249 from ListPanel. It's probably not reusable for another scenario.
250 """
251
252 def __init__(self, parent, event_cbs, style):
253 AutoCompleteTextBox.__init__(self)
254 base_widget.DragLabel.__init__(self, '', 'CONTACT_TEXTBOX') # The group prefix "@" is already in text so we use only the "CONTACT_TEXTBOX" type
255 self._parent = parent
256 self.event_cbs = event_cbs
257 self.style = style
258 self.addMouseListener(self)
259 self.addFocusListener(self)
260 self.addChangeListener(self)
261 self.addStyleName(style["textBox"])
262 self.reset()
263
264 def reset(self):
265 self.setText("")
266 self.setValid()
267
268 def setValid(self, valid=True):
269 if self.getText() == "":
270 valid = True
271 if valid:
272 self.removeStyleName(self.style["textBox-invalid"])
273 else:
274 self.addStyleName(self.style["textBox-invalid"])
275 self.valid = valid
276
277 def onDragStart(self, event):
278 self._text = self.getText()
279 base_widget.DragLabel.onDragStart(self, event)
280 self._parent.setTargetDropCell(None)
281 self.setSelectionRange(len(self.getText()), 0)
282
283 def onDragEnd(self, event):
284 target = self._parent.target_drop_cell # parent or another ListPanel
285 if self.getText() == "" or target is None:
286 return
287 self.event_cbs["drop"](self, target)
288
289 def setRemoveButton(self):
290
291 def remove_cb(sender):
292 """Callback for the button to remove this contact."""
293 self._parent.remove(self)
294 self._parent.remove(self.remove_btn)
295 self.event_cbs["remove"](self)
296
297 self.remove_btn = Button(REMOVE_BUTTON, remove_cb, Visible=False)
298 self.remove_btn.setStyleName(self.style["removeButton"])
299 self._parent.add(self.remove_btn)
300
301 def removeOrReset(self):
302 if hasattr(self, "remove_btn"):
303 self.remove_btn.click()
304 else:
305 self.reset()
306
307 def onMouseMove(self, sender):
308 """Mouse enters the area of a DragAutoCompleteTextBox."""
309 if hasattr(sender, "remove_btn"):
310 sender.remove_btn.setVisible(True)
311
312 def onMouseLeave(self, sender):
313 """Mouse leaves the area of a DragAutoCompleteTextBox."""
314 if hasattr(sender, "remove_btn"):
315 Timer(1500, lambda timer: sender.remove_btn.setVisible(False))
316
317 def onFocus(self, sender):
318 sender.setSelectionRange(0, len(self.getText()))
319 self.event_cbs["focus"](sender)
320
321 def validate(self):
322 self.setSelectionRange(len(self.getText()), 0)
323 self.event_cbs["validate"](self)
324
325 def onChange(self, sender):
326 """The textbox or list selection is changed"""
327 if isinstance(sender, ListBox):
328 AutoCompleteTextBox.onChange(self, sender)
329 self.validate()
330
331 def onClick(self, sender):
332 """The list is clicked"""
333 AutoCompleteTextBox.onClick(self, sender)
334 self.validate()
335
336 def onKeyUp(self, sender, keycode, modifiers):
337 """Listen for ENTER key stroke"""
338 AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers)
339 if keycode == KEY_ENTER:
340 self.validate()
341
342
343 class DropCell(DropWidget):
344 """A cell where you can drop widgets. This class is NOT generic because of
345 onDrop which uses methods from ListPanel. It has been created to
346 separate the drag and drop methods from the others and add a bit of
347 lisibility, but it's probably not reusable for another scenario.
348 """
349
350 def __init__(self, drop_cbs):
351 DropWidget.__init__(self)
352 self.drop_cbs = drop_cbs
353
354 def onDragEnter(self, event):
355 self.addStyleName(self.style["dragoverPanel"])
356 DOM.eventPreventDefault(event)
357
358 def onDragLeave(self, event):
359 if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop()\
360 or event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1\
361 or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1:
362 # We check that we are inside widget's box, and we don't remove the style in this case because
363 # if the mouse is over a widget inside the DropWidget, we don't want the style to be removed
364 self.removeStyleName(self.style["dragoverPanel"])
365
366 def onDragOver(self, event):
367 DOM.eventPreventDefault(event)
368
369 def onDrop(self, event):
370 DOM.eventPreventDefault(event)
371 dt = event.dataTransfer
372 # 'text', 'text/plain', and 'Text' are equivalent.
373 item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed
374 if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and
375 item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report
376 if item_type in self.drop_cbs.keys():
377 self.drop_cbs[item_type](self, item)
378 self.removeStyleName(self.style["dragoverPanel"])
379
380
381 VALID = 1
382 INVALID = 2
383 DELETE = 3
384
385
386 class ListPanel(FlowPanel, DropCell):
387 """Sub-panel used for each contact key. Beware that pyjamas.ui.FlowPanel
388 is not fully implemented yet and can not be used with pyjamas.ui.Label."""
389
390 def __init__(self, parent, entry, style={}):
391 """Initialization with a button and a DragAutoCompleteTextBox."""
392 FlowPanel.__init__(self, Visible=(False if entry["optional"] else True))
393 drop_cbs = {"GROUP": lambda panel, item: self.addContact("@%s" % item),
394 "CONTACT": lambda panel, item: self.addContact(item),
395 "CONTACT_TITLE": lambda panel, item: self.addContact('@@'),
396 "CONTACT_TEXTBOX": lambda panel, item: self.setTargetDropCell(panel)
397 }
398 DropCell.__init__(self, drop_cbs)
399 self.style = style
400 self.addStyleName(self.style["keyPanel"])
401 self._parent = parent
402 self.key = entry["title"]
403 self._addTextBox()
404
405 def _addTextBox(self, switchPrevious=False):
406 """Add a text box to the last position. If switchPrevious is True, simulate
407 an insertion before the current last textbox by copying the text and valid state.
408 @return: the created textbox or the previous one if switchPrevious is True.
409 """
410 if hasattr(self, "_last_textbox"):
411 if self._last_textbox.getText() == "":
412 return
413 self._last_textbox.setRemoveButton()
414 else:
415 switchPrevious = False
416
417 def focus_cb(sender):
418 if sender != self._last_textbox:
419 # save the current value before it's being modified
420 self._parent.addToRemainingList(sender.getText(), ignore_key=self.key)
421 sender.setCompletionItems(self._parent.remaining_list)
422
423 def remove_cb(sender):
424 """Callback for the button to remove this contact."""
425 self._parent.addToRemainingList(sender.getText())
426 self._parent.setRemainingListUnsorted()
427 self._last_textbox.setFocus(True)
428
429 def drop_cb(sender, target):
430 """Callback when the textbox is drag-n-dropped."""
431 parent = sender._parent
432 if target != parent and target.addContact(sender.getText()):
433 sender.removeOrReset()
434 else:
435 parent._parent.removeFromRemainingList(sender.getText())
436
437 events_cbs = {"focus": focus_cb, "validate": self.addContact, "remove": remove_cb, "drop": drop_cb}
438 textbox = DragAutoCompleteTextBox(self, events_cbs, self.style)
439 self.add(textbox)
440 if switchPrevious:
441 textbox.setText(self._last_textbox.getText())
442 textbox.setValid(self._last_textbox.valid)
443 self._last_textbox.reset()
444 previous = self._last_textbox
445 self._last_textbox = textbox
446 return previous if switchPrevious else textbox
447
448 def _checkContact(self, contact, modify):
449 """
450 @param contact: the contact to check
451 @param modify: True if the contact is being modified
452 @return:
453 - VALID if the contact is valid
454 - INVALID if the contact is not valid but can be displayed
455 - DELETE if the contact should not be displayed at all
456 """
457 def countItemInList(list_, item):
458 """For some reason the built-in count function doesn't work..."""
459 count = 0
460 for elem in list_:
461 if elem == item:
462 count += 1
463 return count
464 if contact is None or contact == "":
465 return DELETE
466 if countItemInList(self.getContacts(), contact) > (1 if modify else 0):
467 return DELETE
468 return VALID if contact in self._parent.list else INVALID
469
470 def addContact(self, contact, sender=None):
471 """The first parameter type is checked, so it is also possible to call addContact(sender).
472 If contact is not defined, sender.getText() is used. If sender is not defined, contact will
473 be written to the last textbox and a new textbox is added afterward.
474 @param contact: unicode
475 @param sender: DragAutoCompleteTextBox instance
476 """
477 if isinstance(contact, DragAutoCompleteTextBox):
478 sender = contact
479 contact = sender.getText()
480 valid = self._checkContact(contact, sender is not None)
481 if sender is None:
482 # method has been called to modify but to add a contact
483 if valid == VALID:
484 # eventually insert before the last textbox if it's not empty
485 sender = self._addTextBox(True) if self._last_textbox.getText() != "" else self._last_textbox
486 sender.setText(contact)
487 else:
488 sender.setValid(valid == VALID)
489 if valid != VALID:
490 if sender is not None and valid == DELETE:
491 sender.removeOrReset()
492 return False
493 if sender == self._last_textbox:
494 self._addTextBox()
495 try:
496 sender.setVisibleLength(len(contact))
497 except:
498 # IndexSizeError: Index or size is negative or greater than the allowed amount
499 log.warning("FIXME: len(%s) returns %d... javascript bug?" % (contact, len(contact)))
500 self._parent.removeFromRemainingList(contact)
501 self._last_textbox.setFocus(True)
502 return True
503
504 def emptyContacts(self):
505 """Empty the list of contacts."""
506 for child in self.getChildren():
507 if hasattr(child, "remove_btn"):
508 child.remove_btn.click()
509
510 def setContacts(self, tab):
511 """Set the contacts."""
512 self.emptyContacts()
513 if isinstance(tab, set):
514 tab = list(tab)
515 tab.sort()
516 for contact in tab:
517 self.addContact(contact)
518
519 def getContacts(self):
520 """Get the contacts
521 @return: an array of string"""
522 tab = []
523 for widget in self.getChildren():
524 if isinstance(widget, DragAutoCompleteTextBox):
525 # not to be mixed with EMPTY_SELECTION_ITEM
526 if widget.getText() != "":
527 tab.append(widget.getText())
528 return tab
529
530 @property
531 def target_drop_cell(self):
532 """@return: the panel where something has been dropped."""
533 return self._parent.target_drop_cell
534
535 def setTargetDropCell(self, target_drop_cell):
536 """
537 XXX: Property setter here would not make it, you need a proper method!
538 @param target_drop_cell: the panel where something has been dropped."""
539 self._parent.setTargetDropCell(target_drop_cell)
540
541
542 class ContactChooserPanel(DialogBox):
543 """Display the contacts chooser dialog. This has been implemented while
544 prototyping and is currently not used. Left for an eventual later use.
545 Replaced by the popup menu which allows to add a panel for Cc or Bcc.
546 """
547
548 def __init__(self, manager, **kwargs):
549 """Display a listbox for each contact key"""
550 DialogBox.__init__(self, autoHide=False, centered=True, **kwargs)
551 self.setHTML("Select contacts")
552 self.manager = manager
553 self.listboxes = {}
554 self.contacts = manager.getContacts()
555
556 container = VerticalPanel(Visible=True)
557 container.addStyleName("marginAuto")
558
559 grid = Grid(2, len(self.manager.keys_dict))
560 index = -1
561 for key in self.manager.keys_dict:
562 index += 1
563 grid.add(Label("%s:" % self.manager.keys_dict[key]["desc"]), 0, index)
564 listbox = ListBox()
565 listbox.setMultipleSelect(True)
566 listbox.setVisibleItemCount(15)
567 listbox.addItem(EMPTY_SELECTION_ITEM)
568 for element in manager.list:
569 listbox.addItem(element)
570 self.listboxes[key] = listbox
571 grid.add(listbox, 1, index)
572 self._reset()
573
574 buttons = HorizontalPanel()
575 buttons.addStyleName("marginAuto")
576 btn_close = Button("Cancel", self.hide)
577 buttons.add(btn_close)
578 btn_reset = Button("Reset", self._reset)
579 buttons.add(btn_reset)
580 btn_ok = Button("OK", self._validate)
581 buttons.add(btn_ok)
582
583 container.add(grid)
584 container.add(buttons)
585
586 self.add(container)
587 self.center()
588
589 def _reset(self):
590 """Reset the selections."""
591 for key in self.manager.keys_dict:
592 listbox = self.listboxes[key]
593 for i in xrange(0, listbox.getItemCount()):
594 if listbox.getItemText(i) in self.contacts[key]:
595 listbox.setItemSelected(i, "selected")
596 else:
597 listbox.setItemSelected(i, "")
598
599 def _validate(self):
600 """Sets back the selected contacts to the good sub-panels."""
601 _map = {}
602 for key in self.manager.keys_dict:
603 selections = self.listboxes[key].getSelectedItemText()
604 if EMPTY_SELECTION_ITEM in selections:
605 selections.remove(EMPTY_SELECTION_ITEM)
606 _map[key] = selections
607 self.manager.setContacts(_map)
608 self.hide()