comparison 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
comparison
equal deleted inserted replaced
253:19153af4f327 254:28d3315a8003
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 """
5 Libervia: a Salut à Toi frontend
6 Copyright (C) 2013 Adrien Cossa <souliane@mailoo.org>
7
8 This program is free software: you can redistribute it and/or modify
9 it under the terms of the GNU Affero General Public License as published by
10 the Free Software Foundation, either version 3 of the License, or
11 (at your option) any later version.
12
13 This program is distributed in the hope that it will be useful,
14 but WITHOUT ANY WARRANTY; without even the implied warranty of
15 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 GNU Affero General Public License for more details.
17
18 You should have received a copy of the GNU Affero General Public License
19 along with this program. If not, see <http://www.gnu.org/licenses/>.
20 """
21
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, KeyboardHandler
32 from pyjamas.ui.MouseListener import MouseHandler
33 from pyjamas.ui.FocusListener import FocusHandler
34 from pyjamas.ui.DropWidget import DropWidget
35 from pyjamas.ui.DragWidget import DragWidget
36
37 from pyjamas.Timer import Timer
38 from pyjamas import DOM
39
40 import panels
41 from pyjamas.ui import FocusListener, KeyboardListener, MouseListener, Event
42
43 # HTML content for the removal button (image or text)
44 REMOVE_BUTTON = '<span class="richTextRemoveIcon">x</span>'
45
46 # Item to be considered for an empty list box selection.
47 # Could be whatever which doesn't look like a JID or a group name.
48 EMPTY_SELECTION_ITEM = ""
49
50
51 class ListManager():
52 """A manager for sub-panels to assign elements to lists."""
53
54 def __init__(self, parent, keys_dict={}, contact_list=[], offsets={}, style={}):
55 """
56 @param parent: FlexTable parent widget for the manager
57 @param keys_dict: dict with the contact keys mapped to data
58 @param contact_list: list of string (the contact JID userhosts)
59 @param offsets: dict to set widget positions offset within parent
60 - "x_first": the x offset for the first widget's row on the grid
61 - "x": the x offset for all widgets rows, except the first one if "x_first" is defined
62 - "y": the y offset for all widgets columns on the grid
63 """
64 self.host = parent.host
65 self._parent = parent
66 if isinstance(keys_dict, set) or isinstance(keys_dict, list):
67 tmp = {}
68 for key in keys_dict:
69 tmp[key] = {}
70 keys_dict = tmp
71 self.__keys_dict = keys_dict
72 if isinstance(contact_list, set):
73 contact_list = list(contact_list)
74 self.__list = contact_list
75 self.__list.sort()
76 # store the list of contacts that are not assigned yet
77 self.__remaining_list = []
78 self.__remaining_list.extend(self.__list)
79 # mark a change to sort the list before it's used
80 self.__remaining_list_sorted = True
81
82 self.offsets = {"x_first": 0, "x": 0, "y": 0}
83 if "x" in offsets and not "x_first" in offsets:
84 offsets["x_first"] = offsets["x"]
85 self.offsets.update(offsets)
86
87 self.style = {
88 "keyItem": "recipientTypeItem",
89 "buttonCell": "recipientButtonCell",
90 "dragoverPanel": "dragover-recipientPanel",
91 "keyPanel": "recipientPanel",
92 "textBox": "recipientTextBox",
93 "removeButton": "recipientRemoveButton",
94 }
95 self.style.update(style)
96
97 def createWidgets(self, title_format="%s"):
98 """Fill the parent grid with all the widgets (some may be hidden during the initialization)."""
99 self.__children = {}
100 for key in self.__keys_dict:
101 self.addContactKey(key, title_format)
102
103 def addContactKey(self, key, dict_={}, title_format="%s"):
104 if key not in self.__keys_dict:
105 self.__keys_dict[key] = dict_
106 # copy the key to its associated sub-map
107 self.__keys_dict[key]["title"] = key
108 self._addChild(self.__keys_dict[key], title_format)
109
110 def _addChild(self, entry, title_format):
111 """Add a button and FlowPanel for the corresponding map entry."""
112 button = Button(title_format % entry["title"])
113 button.addStyleName(self.style["keyItem"])
114 if hasattr(entry, "desc"):
115 button.setTitle(entry["desc"])
116 if not "optional" in entry:
117 entry["optional"] = False
118 button.setVisible(not entry["optional"])
119 y = len(self.__children) + self.offsets["y"]
120 x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"]
121
122 self._parent.setWidget(y, x, button)
123 self._parent.getCellFormatter().setStyleName(y, x, self.style["buttonCell"])
124
125 _child = ListPanel(self, entry, self.style)
126 self._parent.setWidget(y, x + 1, _child)
127
128 self.__children[entry["title"]] = {}
129 self.__children[entry["title"]]["button"] = button
130 self.__children[entry["title"]]["panel"] = _child
131
132 if hasattr(self, "popup_menu"):
133 # this is done if self.registerPopupMenuPanel has been called yet
134 self.popup_menu.registerClickSender(button)
135
136 def _refresh(self):
137 """Set visible the sub-panels that are non optional or non empty, hide the rest."""
138 for key in self.__children:
139 self.setContactPanelVisible(key, False)
140 _map = self.getContacts()
141 for key in _map:
142 if len(_map[key]) > 0 or not self.__keys_dict[key]["optional"]:
143 self.setContactPanelVisible(key, True)
144
145 def setContactPanelVisible(self, key, visible=True, sender=None):
146 """Do not remove the "sender" param as it is needed for the context menu."""
147 self.__children[key]["button"].setVisible(visible)
148 self.__children[key]["panel"].setVisible(visible)
149
150 @property
151 def list(self):
152 """Return the full list of potential contacts."""
153 return self.__list
154
155 @property
156 def keys(self):
157 return self.__keys_dict.keys()
158
159 @property
160 def keys_dict(self):
161 return self.__keys_dict
162
163 @property
164 def remaining_list(self):
165 """Return the contacts that have not been selected yet."""
166 if not self.__remaining_list_sorted:
167 self.__remaining_list_sorted = True
168 self.__remaining_list.sort()
169 return self.__remaining_list
170
171 def setRemainingListUnsorted(self):
172 """Mark a change (deletion) so the list will be sorted before it's used."""
173 self.__remaining_list_sorted = False
174
175 def removeFromRemainingList(self, contact_):
176 """Remove an available contact after it has been added to a sub-panel."""
177 if contact_ in self.__remaining_list:
178 self.__remaining_list.remove(contact_)
179
180 def addToRemainingList(self, contact_):
181 """Add a contact after it has been removed from a sub-panel."""
182 if contact_ not in self.__list or contact_ in self.__remaining_list:
183 return
184 self.__remaining_list.append(contact_)
185 self.__sort_remaining_list = True
186
187 def setContacts(self, _map={}):
188 """Set the contacts for each contact key."""
189 for key in self.__keys_dict:
190 if key in _map:
191 self.__children[key]["panel"].setContacts(_map[key])
192 else:
193 self.__children[key]["panel"].setContacts([])
194 self._refresh()
195
196 def getContacts(self):
197 """Get the contacts for all the lists.
198 @return: a mapping between keys and contact lists."""
199 _map = {}
200 for key in self.__children:
201 _map[key] = self.__children[key]["panel"].getContacts()
202 return _map
203
204 def setTargetDropCell(self, panel):
205 """Used to drag and drop the contacts from one panel to another."""
206 self._target_drop_cell = panel
207
208 def getTargetDropCell(self):
209 """Used to drag and drop the contacts from one panel to another."""
210 return self._target_drop_cell
211
212 def registerPopupMenuPanel(self, entries, hide, callback):
213 "Register a popup menu panel that will be bound to all contact keys elements."
214 self.popup_menu = panels.PopupMenuPanel(entries=entries, hide=hide, callback=callback, item_style="recipientTypeItem")
215
216
217 class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget, MouseHandler):
218 """A draggable AutoCompleteTextBox which is used for representing a contact.
219 This class is NOT generic because of the onDragEnd method which call methods
220 from ListPanel. It's probably not reusable for another scenario.
221 """
222
223 def __init__(self):
224 AutoCompleteTextBox.__init__(self)
225 DragWidget.__init__(self)
226 self.addMouseListener(self)
227
228 def onDragStart(self, event):
229 dt = event.dataTransfer
230 # The group prefix "@" is already in text so we use only the "CONTACT" type
231 dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX"))
232
233 def onDragEnd(self, event):
234 if self.getText() == "":
235 return
236 # get the ListPanel containing self
237 parent = self.getParent()
238 while parent is not None and not isinstance(parent, ListPanel):
239 parent = parent.getParent()
240 if parent is None:
241 return
242 # it will return parent again or another ListPanel
243 target = parent.getTargetDropCell()
244 if target == parent:
245 return
246 target.addContact(self.getText())
247 if hasattr(self, "remove_btn"):
248 # self is not the last textbox, just remove it
249 self.remove_btn.click()
250 else:
251 # reset the value of the last textbox
252 self.setText("")
253
254 def onMouseMove(self, sender):
255 """Mouse enters the area of a DragAutoCompleteTextBox."""
256 if hasattr(sender, "remove_btn"):
257 sender.remove_btn.setVisible(True)
258
259 def onMouseLeave(self, sender):
260 """Mouse leaves the area of a DragAutoCompleteTextBox."""
261 if hasattr(sender, "remove_btn"):
262 Timer(1500, lambda: sender.remove_btn.setVisible(False))
263
264
265 class DropCell(DropWidget):
266 """A cell where you can drop widgets. This class is NOT generic because of
267 onDrop which uses methods from ListPanel. It has been created to
268 separate the drag and drop methods from the others and add a bit of
269 lisibility, but it's probably not reusable for another scenario.
270 """
271
272 def __init__(self, host):
273 DropWidget.__init__(self)
274
275 def onDragEnter(self, event):
276 self.addStyleName(self.style["dragoverPanel"])
277 DOM.eventPreventDefault(event)
278
279 def onDragLeave(self, event):
280 if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop()\
281 or event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1\
282 or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1:
283 # We check that we are inside widget's box, and we don't remove the style in this case because
284 # if the mouse is over a widget inside the DropWidget, we don't want the style to be removed
285 self.removeStyleName(self.style["dragoverPanel"])
286
287 def onDragOver(self, event):
288 DOM.eventPreventDefault(event)
289
290 def onDrop(self, event):
291 DOM.eventPreventDefault(event)
292 dt = event.dataTransfer
293 # 'text', 'text/plain', and 'Text' are equivalent.
294 item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed
295 if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and
296 item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report
297 if item_type == "GROUP":
298 item = "@%s" % item
299 self.addContact(item)
300 elif item_type == "CONTACT":
301 self.addContact(item)
302 elif item_type == "CONTACT_TEXTBOX":
303 self._parent.setTargetDropCell(self)
304 pass
305 else:
306 return
307 self.removeStyleName(self.style["dragoverPanel"])
308
309
310 class ListPanel(FlowPanel, DropCell, FocusHandler, KeyboardHandler):
311 """Sub-panel used for each contact key. Beware that pyjamas.ui.FlowPanel
312 is not fully implemented yet and can not be used with pyjamas.ui.Label."""
313
314 def __init__(self, parent, entry, style={}):
315 """Initialization with a button and a DragAutoCompleteTextBox."""
316 FlowPanel.__init__(self, Visible=(False if entry["optional"] else True))
317 DropCell.__init__(self)
318 self.style = style
319 self.addStyleName(self.style["keyPanel"])
320 self._parent = parent
321 self.host = parent.host
322
323 self._last_textbox = None
324 self.__remove_cbs = []
325
326 self.__resetLastTextBox()
327
328 def __resetLastTextBox(self, setFocus=True):
329 """Reset the last input text box with KeyboardListener."""
330 if self._last_textbox is None:
331 self._last_textbox = DragAutoCompleteTextBox()
332 self._last_textbox.addStyleName(self.style["textBox"])
333 self._last_textbox.addKeyboardListener(self)
334 self._last_textbox.addFocusListener(self)
335 else:
336 # ensure we move it to the last position
337 self.remove(self._last_textbox)
338 self._last_textbox.setText("")
339 self.add(self._last_textbox)
340 self._last_textbox.setFocus(setFocus)
341
342 def onKeyUp(self, sender, keycode, modifiers):
343 """This is called after DragAutoCompleteTextBox.onKeyDown,
344 so the completion is done before we reset the text box."""
345 if not isinstance(sender, DragAutoCompleteTextBox):
346 return
347 if keycode == KEY_ENTER:
348 self.onLostFocus(sender)
349 self._last_textbox.setFocus(True)
350
351 def onFocus(self, sender):
352 """A DragAutoCompleteTextBox has the focus."""
353 if not isinstance(sender, DragAutoCompleteTextBox):
354 return
355 if sender != self._last_textbox:
356 # save the current value before it's being modified
357 text = sender.getText()
358 self._focused_textbox_previous_value = text
359 self._parent.addToRemainingList(text)
360 sender.setCompletionItems(self._parent.remaining_list)
361
362 def onLostFocus(self, sender):
363 """A DragAutoCompleteTextBox has lost the focus."""
364 if not isinstance(sender, DragAutoCompleteTextBox):
365 return
366 self.changeContact(sender)
367
368 def changeContact(self, sender):
369 """Modify the value of a DragAutoCompleteTextBox."""
370 text = sender.getText()
371 if sender == self._last_textbox:
372 if text != "":
373 # a new box is added and the last textbox is reinitialized
374 self.addContact(text, setFocusToLastTextBox=False)
375 return
376 if text == "":
377 sender.remove_btn.click()
378 return
379 # text = new value needs to be removed 1. if the value is unchanged, because we
380 # added it when we took the focus, or 2. if the value is changed (obvious!)
381 self._parent.removeFromRemainingList(text)
382 if text == self._focused_textbox_previous_value:
383 return
384 sender.setVisibleLength(len(text))
385 self._parent.addToRemainingList(self._focused_textbox_previous_value)
386
387 def addContact(self, contact, resetLastTextBox=True, setFocusToLastTextBox=True):
388 """Add a contact and signal it to self._parent panel."""
389 if contact is None or contact == "":
390 return
391 textbox = DragAutoCompleteTextBox()
392 textbox.addStyleName(self.style["textBox"])
393 textbox.setText(contact)
394 self.add(textbox)
395 try:
396 textbox.setVisibleLength(len(str(contact)))
397 except:
398 #FIXME: . how come could this happen?! len(contact) is sometimes 0 but contact is not empty
399 print "len(contact) returns %d where contact == %s..." % (len(str(contact)), str(contact))
400 self._parent.removeFromRemainingList(contact)
401
402 remove_btn = Button(REMOVE_BUTTON, Visible=False)
403 remove_btn.setStyleName(self.style["removeButton"])
404
405 def remove_cb(sender):
406 """Callback for the button to remove this contact."""
407 self.remove(textbox)
408 self.remove(remove_btn)
409 self._parent.addToRemainingList(contact)
410 self._parent.setRemainingListUnsorted()
411
412 remove_btn.addClickListener(remove_cb)
413 self.__remove_cbs.append(remove_cb)
414 self.add(remove_btn)
415 self.__resetLastTextBox(setFocus=setFocusToLastTextBox)
416
417 textbox.remove_btn = remove_btn
418 textbox.addFocusListener(self)
419 textbox.addKeyboardListener(self)
420
421 def emptyContacts(self):
422 """Empty the list of contacts."""
423 for remove_cb in self.__remove_cbs:
424 remove_cb()
425 self.__remove_cbs = []
426
427 def setContacts(self, tab):
428 """Set the contacts."""
429 self.emptyContacts()
430 for contact in tab:
431 self.addContact(contact, resetLastTextBox=False)
432 self.__resetLastTextBox()
433
434 def getContacts(self):
435 """Get the contacts
436 @return: an array of string"""
437 tab = []
438 for widget in self.getChildren():
439 if isinstance(widget, DragAutoCompleteTextBox):
440 # not to be mixed with EMPTY_SELECTION_ITEM
441 if widget.getText() != "":
442 tab.append(widget.getText())
443 return tab
444
445 def getTargetDropCell(self):
446 """Returns self or another panel where something has been dropped."""
447 return self._parent.getTargetDropCell()
448
449
450 class ContactChooserPanel(DialogBox):
451 """Display the contacts chooser dialog. This has been implemented while
452 prototyping and is currently not used. Left for an eventual later use.
453 Replaced by the popup menu which allows to add a panel for Cc or Bcc.
454 """
455
456 def __init__(self, manager, **kwargs):
457 """Display a listbox for each contact key"""
458 DialogBox.__init__(self, autoHide=False, centered=True, **kwargs)
459 self.setHTML("Select contacts")
460 self.manager = manager
461 self.listboxes = {}
462 self.contacts = manager.getContacts()
463
464 container = VerticalPanel(Visible=True)
465 container.addStyleName("marginAuto")
466
467 grid = Grid(2, len(self.manager.keys_dict))
468 index = -1
469 for key in self.manager.keys_dict:
470 index += 1
471 grid.add(Label("%s:" % self.manager.keys_dict[key]["desc"]), 0, index)
472 listbox = ListBox()
473 listbox.setMultipleSelect(True)
474 listbox.setVisibleItemCount(15)
475 listbox.addItem(EMPTY_SELECTION_ITEM)
476 for element in manager.list:
477 listbox.addItem(element)
478 self.listboxes[key] = listbox
479 grid.add(listbox, 1, index)
480 self._reset()
481
482 buttons = HorizontalPanel()
483 buttons.addStyleName("marginAuto")
484 btn_close = Button("Cancel", self.hide)
485 buttons.add(btn_close)
486 btn_reset = Button("Reset", self._reset)
487 buttons.add(btn_reset)
488 btn_ok = Button("OK", self._validate)
489 buttons.add(btn_ok)
490
491 container.add(grid)
492 container.add(buttons)
493
494 self.add(container)
495 self.center()
496
497 def _reset(self):
498 """Reset the selections."""
499 for key in self.manager.keys_dict:
500 listbox = self.listboxes[key]
501 for i in xrange(0, listbox.getItemCount()):
502 if listbox.getItemText(i) in self.contacts[key]:
503 listbox.setItemSelected(i, "selected")
504 else:
505 listbox.setItemSelected(i, "")
506
507 def _validate(self):
508 """Sets back the selected contacts to the good sub-panels."""
509 _map = {}
510 for key in self.manager.keys_dict:
511 selections = self.listboxes[key].getSelectedItemText()
512 if EMPTY_SELECTION_ITEM in selections:
513 selections.remove(EMPTY_SELECTION_ITEM)
514 _map[key] = selections
515 self.manager.setContacts(_map)
516 self.hide()