Mercurial > libervia-web
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() |