comparison src/browser/sat_browser/base_panels.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/base_panels.py@981ed669d3b3
children 830b50593597
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) 2011, 2012, 2013, 2014 Jérôme Poisson <goffi@goffi.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 import pyjd # this is dummy in pyjs
21 from sat.core.log import getLogger
22 log = getLogger(__name__)
23 from sat.core.i18n import _
24 from sat_frontends.tools import strings
25
26 from pyjamas.ui.AbsolutePanel import AbsolutePanel
27 from pyjamas.ui.VerticalPanel import VerticalPanel
28 from pyjamas.ui.HorizontalPanel import HorizontalPanel
29 from pyjamas.ui.HTMLPanel import HTMLPanel
30 from pyjamas.ui.Button import Button
31 from pyjamas.ui.HTML import HTML
32 from pyjamas.ui.SimplePanel import SimplePanel
33 from pyjamas.ui.PopupPanel import PopupPanel
34 from pyjamas.ui.StackPanel import StackPanel
35 from pyjamas.ui.TextArea import TextArea
36 from pyjamas.ui.Event import BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT
37 from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_SHIFT, KeyboardHandler
38 from pyjamas.ui.FocusListener import FocusHandler
39 from pyjamas.ui.ClickListener import ClickHandler
40 from pyjamas import DOM
41
42 from datetime import datetime
43 from time import time
44
45 import html_tools
46 from constants import Const as C
47
48
49 class ChatText(HTMLPanel):
50
51 def __init__(self, timestamp, nick, mymess, msg, xhtml=None):
52 _date = datetime.fromtimestamp(float(timestamp or time()))
53 _msg_class = ["chat_text_msg"]
54 if mymess:
55 _msg_class.append("chat_text_mymess")
56 HTMLPanel.__init__(self, "<span class='chat_text_timestamp'>%(timestamp)s</span> <span class='chat_text_nick'>%(nick)s</span> <span class='%(msg_class)s'>%(msg)s</span>" %
57 {"timestamp": _date.strftime("%H:%M"),
58 "nick": "[%s]" % html_tools.sanitize(nick),
59 "msg_class": ' '.join(_msg_class),
60 "msg": strings.addURLToText(html_tools.sanitize(msg)) if not xhtml else html_tools.inlineRoot(xhtml)} # FIXME: images and external links must be removed according to preferences
61 )
62 self.setStyleName('chatText')
63
64
65 class Occupant(HTML):
66 """Occupant of a MUC room"""
67
68 def __init__(self, nick, state=None, special=""):
69 """
70 @param nick: the user nickname
71 @param state: the user chate state (XEP-0085)
72 @param special: a string of symbols (e.g: for activities)
73 """
74 HTML.__init__(self)
75 self.nick = nick
76 self._state = state
77 self.special = special
78 self._refresh()
79
80 def __str__(self):
81 return self.nick
82
83 def setState(self, state):
84 self._state = state
85 self._refresh()
86
87 def addSpecial(self, special):
88 """@param special: unicode"""
89 if special not in self.special:
90 self.special += special
91 self._refresh()
92
93 def removeSpecials(self, special):
94 """@param special: unicode or list"""
95 if not isinstance(special, list):
96 special = [special]
97 for symbol in special:
98 self.special = self.special.replace(symbol, "")
99 self._refresh()
100
101 def _refresh(self):
102 state = (' %s' % C.MUC_USER_STATES[self._state]) if self._state else ''
103 special = "" if len(self.special) == 0 else " %s" % self.special
104 self.setHTML("<div class='occupant'>%s%s%s</div>" % (html_tools.sanitize(self.nick), special, state))
105
106
107 class OccupantsList(AbsolutePanel):
108 """Panel user to show occupants of a room"""
109
110 def __init__(self):
111 AbsolutePanel.__init__(self)
112 self.occupants_list = {}
113 self.setStyleName('occupantsList')
114
115 def addOccupant(self, nick):
116 _occupant = Occupant(nick)
117 self.occupants_list[nick] = _occupant
118 self.add(_occupant)
119
120 def removeOccupant(self, nick):
121 try:
122 self.remove(self.occupants_list[nick])
123 except KeyError:
124 log.error("trying to remove an unexisting nick")
125
126 def clear(self):
127 self.occupants_list.clear()
128 AbsolutePanel.clear(self)
129
130 def updateSpecials(self, occupants=[], html=""):
131 """Set the specified html "symbol" to the listed occupants,
132 and eventually remove it from the others (if they got it).
133 This is used for example to visualize who is playing a game.
134 @param occupants: list of the occupants that need the symbol
135 @param html: unicode symbol (actually one character or more)
136 or a list to assign different symbols of the same family.
137 """
138 index = 0
139 special = html
140 for occupant in self.occupants_list.keys():
141 if occupant in occupants:
142 if isinstance(html, list):
143 special = html[index]
144 index = (index + 1) % len(html)
145 self.occupants_list[occupant].addSpecial(special)
146 else:
147 self.occupants_list[occupant].removeSpecials(html)
148
149
150 class PopupMenuPanel(PopupPanel):
151 """This implementation of a popup menu (context menu) allow you to assign
152 two special methods which are common to all the items, in order to hide
153 certain items and also easily define their callbacks. The menu can be
154 bound to any of the mouse button (left, middle, right).
155 """
156 def __init__(self, entries, hide=None, callback=None, vertical=True, style=None, **kwargs):
157 """
158 @param entries: a dict of dicts, where each sub-dict is representing
159 one menu item: the sub-dict key can be used as the item text and
160 description, but optional "title" and "desc" entries would be used
161 if they exists. The sub-dicts may be extended later to do
162 more complicated stuff or overwrite the common methods.
163 @param hide: function with 2 args: widget, key as string and
164 returns True if that item should be hidden from the context menu.
165 @param callback: function with 2 args: sender, key as string
166 @param vertical: True or False, to set the direction
167 @param item_style: alternative CSS class for the menu items
168 @param menu_style: supplementary CSS class for the sender widget
169 """
170 PopupPanel.__init__(self, autoHide=True, **kwargs)
171 self._entries = entries
172 self._hide = hide
173 self._callback = callback
174 self.vertical = vertical
175 self.style = {"selected": None, "menu": "recipientTypeMenu", "item": "popupMenuItem"}
176 if isinstance(style, dict):
177 self.style.update(style)
178 self._senders = {}
179
180 def _show(self, sender):
181 """Popup the menu relative to this sender's position.
182 @param sender: the widget that has been clicked
183 """
184 menu = VerticalPanel() if self.vertical is True else HorizontalPanel()
185 menu.setStyleName(self.style["menu"])
186
187 def button_cb(item):
188 """You can not put that method in the loop and rely
189 on _key, because it is overwritten by each step.
190 You can rely on item.key instead, which is copied
191 from _key after the item creation.
192 @param item: the menu item that has been clicked
193 """
194 if self._callback is not None:
195 self._callback(sender=sender, key=item.key)
196 self.hide(autoClosed=True)
197
198 for _key in self._entries.keys():
199 entry = self._entries[_key]
200 if self._hide is not None and self._hide(sender=sender, key=_key) is True:
201 continue
202 title = entry["title"] if "title" in entry.keys() else _key
203 item = Button(title, button_cb)
204 item.key = _key
205 item.setStyleName(self.style["item"])
206 item.setTitle(entry["desc"] if "desc" in entry.keys() else title)
207 menu.add(item)
208 if len(menu.getChildren()) == 0:
209 return
210 self.add(menu)
211 if self.vertical is True:
212 x = sender.getAbsoluteLeft() + sender.getOffsetWidth()
213 y = sender.getAbsoluteTop()
214 else:
215 x = sender.getAbsoluteLeft()
216 y = sender.getAbsoluteTop() + sender.getOffsetHeight()
217 self.setPopupPosition(x, y)
218 self.show()
219 if self.style["selected"]:
220 sender.addStyleDependentName(self.style["selected"])
221
222 def _onHide(popup):
223 if self.style["selected"]:
224 sender.removeStyleDependentName(self.style["selected"])
225 return PopupPanel.onHideImpl(self, popup)
226
227 self.onHideImpl = _onHide
228
229 def registerClickSender(self, sender, button=BUTTON_LEFT):
230 """Bind the menu to the specified sender.
231 @param sender: the widget to which the menu should be bound
232 @param: BUTTON_LEFT, BUTTON_MIDDLE or BUTTON_RIGHT
233 """
234 self._senders.setdefault(sender, [])
235 self._senders[sender].append(button)
236
237 if button == BUTTON_RIGHT:
238 # WARNING: to disable the context menu is a bit tricky...
239 # The following seems to work on Firefox 24.0, but:
240 # TODO: find a cleaner way to disable the context menu
241 sender.getElement().setAttribute("oncontextmenu", "return false")
242
243 def _onBrowserEvent(event):
244 button = DOM.eventGetButton(event)
245 if DOM.eventGetType(event) == "mousedown" and button in self._senders[sender]:
246 self._show(sender)
247 return sender.__class__.onBrowserEvent(sender, event)
248
249 sender.onBrowserEvent = _onBrowserEvent
250
251 def registerMiddleClickSender(self, sender):
252 self.registerClickSender(sender, BUTTON_MIDDLE)
253
254 def registerRightClickSender(self, sender):
255 self.registerClickSender(sender, BUTTON_RIGHT)
256
257
258 class ToggleStackPanel(StackPanel):
259 """This is a pyjamas.ui.StackPanel with modified behavior. All sub-panels ca be
260 visible at the same time, clicking a sub-panel header will not display it and hide
261 the others but only toggle its own visibility. The argument 'visibleStack' is ignored.
262 Note that the argument 'visible' has been added to listener's 'onStackChanged' method.
263 """
264
265 def __init__(self, **kwargs):
266 StackPanel.__init__(self, **kwargs)
267
268 def onBrowserEvent(self, event):
269 if DOM.eventGetType(event) == "click":
270 index = self.getDividerIndex(DOM.eventGetTarget(event))
271 if index != -1:
272 self.toggleStack(index)
273
274 def add(self, widget, stackText="", asHTML=False, visible=False):
275 StackPanel.add(self, widget, stackText, asHTML)
276 self.setStackVisible(self.getWidgetCount() - 1, visible)
277
278 def toggleStack(self, index):
279 if index >= self.getWidgetCount():
280 return
281 visible = not self.getWidget(index).getVisible()
282 self.setStackVisible(index, visible)
283 for listener in self.stackListeners:
284 listener.onStackChanged(self, index, visible)
285
286
287 class TitlePanel(ToggleStackPanel):
288 """A toggle panel to set the message title"""
289 def __init__(self):
290 ToggleStackPanel.__init__(self, Width="100%")
291 self.text_area = TextArea()
292 self.add(self.text_area, _("Title"))
293 self.addStackChangeListener(self)
294
295 def onStackChanged(self, sender, index, visible=None):
296 if visible is None:
297 visible = sender.getWidget(index).getVisible()
298 text = self.text_area.getText()
299 suffix = "" if (visible or not text) else (": %s" % text)
300 sender.setStackText(index, _("Title") + suffix)
301
302 def getText(self):
303 return self.text_area.getText()
304
305 def setText(self, text):
306 self.text_area.setText(text)
307
308
309 class BaseTextEditor(object):
310 """Basic definition of a text editor. The method edit gets a boolean parameter which
311 should be set to True when you want to edit the text and False to only display it."""
312
313 def __init__(self, content=None, strproc=None, modifiedCb=None, afterEditCb=None):
314 """
315 Remark when inheriting this class: since the setContent method could be
316 overwritten by the child class, you should consider calling this __init__
317 after all the parameters affecting this setContent method have been set.
318 @param content: dict with at least a 'text' key
319 @param strproc: method to be applied on strings to clean the content
320 @param modifiedCb: method to be called when the text has been modified.
321 If this method returns:
322 - True: the modification will be saved and afterEditCb called;
323 - False: the modification won't be saved and afterEditCb called;
324 - None: the modification won't be saved and afterEditCb not called.
325 @param afterEditCb: method to be called when the edition is done
326 """
327 if content is None:
328 content = {'text': ''}
329 assert('text' in content)
330 if strproc is None:
331 def strproc(text):
332 try:
333 return text.strip()
334 except (TypeError, AttributeError):
335 return text
336 self.strproc = strproc
337 self.__modifiedCb = modifiedCb
338 self._afterEditCb = afterEditCb
339 self.initialized = False
340 self.edit_listeners = []
341 self.setContent(content)
342
343 def setContent(self, content=None):
344 """Set the editable content. The displayed content, which is set from the child class, could differ.
345 @param content: dict with at least a 'text' key
346 """
347 if content is None:
348 content = {'text': ''}
349 elif not isinstance(content, dict):
350 content = {'text': content}
351 assert('text' in content)
352 self._original_content = {}
353 for key in content:
354 self._original_content[key] = self.strproc(content[key])
355
356 def getContent(self):
357 """Get the current edited or editable content.
358 @return: dict with at least a 'text' key
359 """
360 raise NotImplementedError
361
362 def setOriginalContent(self, content):
363 """Use this method with care! Content initialization should normally be
364 done with self.setContent. This method exists to let you trick the editor,
365 e.g. for self.modified to return True also when nothing has been modified.
366 @param content: dict
367 """
368 self._original_content = content
369
370 def getOriginalContent(self):
371 """
372 @return the original content before modification (dict)
373 """
374 return self._original_content
375
376 def modified(self, content=None):
377 """Check if the content has been modified.
378 Remark: we don't use the direct comparison because we want to ignore empty elements
379 @content: content to be check against the original content or None to use the current content
380 @return: True if the content has been modified.
381 """
382 if content is None:
383 content = self.getContent()
384 # the following method returns True if one non empty element exists in a but not in b
385 diff1 = lambda a, b: [a[key] for key in set(a.keys()).difference(b.keys()) if a[key]] != []
386 # the following method returns True if the values for the common keys are not equals
387 diff2 = lambda a, b: [1 for key in set(a.keys()).intersection(b.keys()) if a[key] != b[key]] != []
388 # finally the combination of both to return True if a difference is found
389 diff = lambda a, b: diff1(a, b) or diff1(b, a) or diff2(a, b)
390
391 return diff(content, self._original_content)
392
393 def edit(self, edit, abort=False, sync=False):
394 """
395 Remark: the editor must be visible before you call this method.
396 @param edit: set to True to edit the content or False to only display it
397 @param abort: set to True to cancel the edition and loose the changes.
398 If edit and abort are both True, self.abortEdition can be used to ask for a
399 confirmation. When edit is False and abort is True, abortion is actually done.
400 @param sync: set to True to cancel the edition after the content has been saved somewhere else
401 """
402 if edit:
403 if not self.initialized:
404 self.syncToEditor() # e.g.: use the selected target and unibox content
405 self.setFocus(True)
406 if abort:
407 content = self.getContent()
408 if not self.modified(content) or self.abortEdition(content): # e.g: ask for confirmation
409 self.edit(False, True, sync)
410 return
411 if sync:
412 self.syncFromEditor(content) # e.g.: save the content to unibox
413 return
414 else:
415 if not self.initialized:
416 return
417 content = self.getContent()
418 if abort:
419 self._afterEditCb(content)
420 return
421 if self.__modifiedCb and self.modified(content):
422 result = self.__modifiedCb(content) # e.g.: send a message or update something
423 if result is not None:
424 if self._afterEditCb:
425 self._afterEditCb(content) # e.g.: restore the display mode
426 if result is True:
427 self.setContent(content)
428 elif self._afterEditCb:
429 self._afterEditCb(content)
430
431 self.initialized = True
432
433 def setFocus(self, focus):
434 """
435 @param focus: set to True to focus the editor
436 """
437 raise NotImplementedError
438
439 def syncToEditor(self):
440 pass
441
442 def syncFromEditor(self, content):
443 pass
444
445 def abortEdition(self, content):
446 return True
447
448 def addEditListener(self, listener):
449 """Add a method to be called whenever the text is edited.
450 @param listener: method taking two arguments: sender, keycode"""
451 self.edit_listeners.append(listener)
452
453
454 class SimpleTextEditor(BaseTextEditor, FocusHandler, KeyboardHandler, ClickHandler):
455 """Base class for manage a simple text editor."""
456
457 def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
458 """
459 @param content
460 @param modifiedCb
461 @param afterEditCb
462 @param options: dict with the following value:
463 - no_xhtml: set to True to clean any xhtml content.
464 - enhance_display: if True, the display text will be enhanced with strings.addURLToText
465 - listen_keyboard: set to True to terminate the edition with <enter> or <escape>.
466 - listen_focus: set to True to terminate the edition when the focus is lost.
467 - listen_click: set to True to start the edition when you click on the widget.
468 """
469 self.options = {'no_xhtml': False,
470 'enhance_display': True,
471 'listen_keyboard': True,
472 'listen_focus': False,
473 'listen_click': False
474 }
475 if options:
476 self.options.update(options)
477 self.__shift_down = False
478 if self.options['listen_focus']:
479 FocusHandler.__init__(self)
480 if self.options['listen_click']:
481 ClickHandler.__init__(self)
482 KeyboardHandler.__init__(self)
483 strproc = lambda text: html_tools.sanitize(html_tools.html_strip(text)) if self.options['no_xhtml'] else html_tools.html_strip(text)
484 BaseTextEditor.__init__(self, content, strproc, modifiedCb, afterEditCb)
485 self.textarea = self.display = None
486
487 def setContent(self, content=None):
488 BaseTextEditor.setContent(self, content)
489
490 def getContent(self):
491 raise NotImplementedError
492
493 def edit(self, edit, abort=False, sync=False):
494 BaseTextEditor.edit(self, edit)
495 if edit:
496 if self.options['listen_focus'] and self not in self.textarea._focusListeners:
497 self.textarea.addFocusListener(self)
498 if self.options['listen_click']:
499 self.display.clearClickListener()
500 if self not in self.textarea._keyboardListeners:
501 self.textarea.addKeyboardListener(self)
502 else:
503 self.setDisplayContent()
504 if self.options['listen_focus']:
505 try:
506 self.textarea.removeFocusListener(self)
507 except ValueError:
508 pass
509 if self.options['listen_click'] and self not in self.display._clickListeners:
510 self.display.addClickListener(self)
511 try:
512 self.textarea.removeKeyboardListener(self)
513 except ValueError:
514 pass
515
516 def setDisplayContent(self):
517 text = self._original_content['text']
518 if not self.options['no_xhtml']:
519 text = strings.addURLToImage(text)
520 if self.options['enhance_display']:
521 text = strings.addURLToText(text)
522 self.display.setHTML(html_tools.convertNewLinesToXHTML(text))
523
524 def setFocus(self, focus):
525 raise NotImplementedError
526
527 def onKeyDown(self, sender, keycode, modifiers):
528 for listener in self.edit_listeners:
529 listener(self.textarea, keycode)
530 if not self.options['listen_keyboard']:
531 return
532 if keycode == KEY_SHIFT or self.__shift_down: # allow input a new line with <shift> + <enter>
533 self.__shift_down = True
534 return
535 if keycode == KEY_ENTER: # finish the edition
536 self.textarea.setFocus(False)
537 if not self.options['listen_focus']:
538 self.edit(False)
539
540 def onKeyUp(self, sender, keycode, modifiers):
541 if keycode == KEY_SHIFT:
542 self.__shift_down = False
543
544 def onLostFocus(self, sender):
545 """Finish the edition when focus is lost"""
546 if self.options['listen_focus']:
547 self.edit(False)
548
549 def onClick(self, sender=None):
550 """Start the edition when the widget is clicked"""
551 if self.options['listen_click']:
552 self.edit(True)
553
554 def onBrowserEvent(self, event):
555 if self.options['listen_focus']:
556 FocusHandler.onBrowserEvent(self, event)
557 if self.options['listen_click']:
558 ClickHandler.onBrowserEvent(self, event)
559 KeyboardHandler.onBrowserEvent(self, event)
560
561
562 class HTMLTextEditor(SimpleTextEditor, HTML, FocusHandler, KeyboardHandler):
563 """Manage a simple text editor with the HTML 5 "contenteditable" property."""
564
565 def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
566 HTML.__init__(self)
567 SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options)
568 self.textarea = self.display = self
569
570 def getContent(self):
571 text = DOM.getInnerHTML(self.getElement())
572 return {'text': self.strproc(text) if text else ''}
573
574 def edit(self, edit, abort=False, sync=False):
575 if edit:
576 self.textarea.setHTML(self._original_content['text'])
577 self.getElement().setAttribute('contenteditable', 'true' if edit else 'false')
578 SimpleTextEditor.edit(self, edit, abort, sync)
579
580 def setFocus(self, focus):
581 if focus:
582 self.getElement().focus()
583 else:
584 self.getElement().blur()
585
586
587 class LightTextEditor(SimpleTextEditor, SimplePanel, FocusHandler, KeyboardHandler):
588 """Manage a simple text editor with a TextArea for editing, HTML for display."""
589
590 def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None):
591 SimplePanel.__init__(self)
592 SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options)
593 self.textarea = TextArea()
594 self.display = HTML()
595
596 def getContent(self):
597 text = self.textarea.getText()
598 return {'text': self.strproc(text) if text else ''}
599
600 def edit(self, edit, abort=False, sync=False):
601 if edit:
602 self.textarea.setText(self._original_content['text'])
603 self.setWidget(self.textarea if edit else self.display)
604 SimpleTextEditor.edit(self, edit, abort, sync)
605
606 def setFocus(self, focus):
607 if focus:
608 self.textarea.setCursorPos(len(self.textarea.getText()))
609 self.textarea.setFocus(focus)