Mercurial > libervia-web
diff src/browser/sat_browser/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/panels.py@b62c1cf0dbf7 |
children | 992b900ab876 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/panels.py Mon Jun 09 22:15:26 2014 +0200 @@ -0,0 +1,1419 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson <goffi@goffi.org> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat_frontends.tools.strings import addURLToText +from sat_frontends.tools.games import SYMBOLS +from sat.core.i18n import _ + +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.AbsolutePanel import AbsolutePanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.HTMLPanel import HTMLPanel +from pyjamas.ui.Frame import Frame +from pyjamas.ui.TextArea import TextArea +from pyjamas.ui.Label import Label +from pyjamas.ui.Button import Button +from pyjamas.ui.HTML import HTML +from pyjamas.ui.Image import Image +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui.FlowPanel import FlowPanel +from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_UP, KEY_DOWN, KeyboardHandler +from pyjamas.ui.MouseListener import MouseHandler +from pyjamas.ui.FocusListener import FocusHandler +from pyjamas.Timer import Timer +from pyjamas import DOM +from pyjamas import Window +from __pyjamas__ import doc + +from datetime import datetime +from time import time + +import jid +import html_tools +import base_panels +import card_game +import radiocol +import menu +import dialog +import base_widget +import richtext +import contact +from constants import Const as C +import plugin_xep_0085 + + +# TODO: at some point we should decide which behaviors to keep and remove these two constants +TOGGLE_EDITION_USE_ICON = False # set to True to use an icon inside the "toggle syntax" button +NEW_MESSAGE_USE_BUTTON = False # set to True to display the "New message" button instead of an empty entry + + +class UniBoxPanel(HorizontalPanel): + """Panel containing the UniBox""" + + def __init__(self, host): + HorizontalPanel.__init__(self) + self.host = host + self.setStyleName('uniBoxPanel') + self.unibox = None + + def refresh(self): + """Enable or disable this panel. Contained widgets are created when necessary.""" + enable = self.host.getUIParam('unibox') + self.setVisible(enable) + if enable and not self.unibox: + self.button = Button('<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>') + self.button.setTitle('Open the rich text editor') + self.button.addStyleName('uniBoxButton') + self.add(self.button) + self.unibox = UniBox(self.host) + self.add(self.unibox) + self.setCellWidth(self.unibox, '100%') + self.button.addClickListener(self.openRichMessageEditor) + self.unibox.addKey("@@: ") + self.unibox.onSelectedChange(self.host.getSelected()) + + def openRichMessageEditor(self): + """Open the rich text editor.""" + self.button.setVisible(False) + self.unibox.setVisible(False) + self.setCellWidth(self.unibox, '0px') + self.host.panel._contactsMove(self) + + def afterEditCb(): + Window.removeWindowResizeListener(self) + self.host.panel._contactsMove(self.host.panel._hpanel) + self.setCellWidth(self.unibox, '100%') + self.button.setVisible(True) + self.unibox.setVisible(True) + self.host.resize() + + richtext.RichMessageEditor.getOrCreate(self.host, self, afterEditCb) + Window.addWindowResizeListener(self) + self.host.resize() + + def onWindowResized(self, width, height): + right = self.host.panel.menu.getAbsoluteLeft() + self.host.panel.menu.getOffsetWidth() + left = self.host.panel._contacts.getAbsoluteLeft() + self.host.panel._contacts.getOffsetWidth() + ideal_width = right - left - 40 + self.host.richtext.setWidth("%spx" % ideal_width) + + +class MessageBox(TextArea): + """A basic text area for entering messages""" + + def __init__(self, host): + TextArea.__init__(self) + self.host = host + self.__size = (0, 0) + self.setStyleName('messageBox') + self.addKeyboardListener(self) + MouseHandler.__init__(self) + self.addMouseListener(self) + self._selected_cache = None + + def onBrowserEvent(self, event): + # XXX: woraroung a pyjamas bug: self.currentEvent is not set + # so the TextBox's cancelKey doens't work. This is a workaround + # FIXME: fix the bug upstream + self.currentEvent = event + TextArea.onBrowserEvent(self, event) + + def onKeyPress(self, sender, keycode, modifiers): + _txt = self.getText() + + def history_cb(text): + self.setText(text) + Timer(5, lambda timer: self.setCursorPos(len(text))) + + if keycode == KEY_ENTER: + if _txt: + self._selected_cache.onTextEntered(_txt) + self.host._updateInputHistory(_txt) + self.setText('') + sender.cancelKey() + elif keycode == KEY_UP: + self.host._updateInputHistory(_txt, -1, history_cb) + elif keycode == KEY_DOWN: + self.host._updateInputHistory(_txt, +1, history_cb) + else: + self.__onComposing() + + def __onComposing(self): + """Callback when the user is composing a text.""" + if hasattr(self._selected_cache, "target"): + self._selected_cache.state_machine._onEvent("composing") + + def onMouseUp(self, sender, x, y): + size = (self.getOffsetWidth(), self.getOffsetHeight()) + if size != self.__size: + self.__size = size + self.host.resize() + + def onSelectedChange(self, selected): + self._selected_cache = selected + + +class UniBox(MessageBox, MouseHandler): # AutoCompleteTextBox): + """This text box is used as a main typing point, for message, microblog, etc""" + + def __init__(self, host): + MessageBox.__init__(self, host) + #AutoCompleteTextBox.__init__(self) + self.setStyleName('uniBox') + host.addSelectedListener(self.onSelectedChange) + + def addKey(self, key): + return + #self.getCompletionItems().completions.append(key) + + def removeKey(self, key): + return + # TODO: investigate why AutoCompleteTextBox doesn't work here, + # maybe it can work on a TextBox but no TextArea. Remove addKey + # and removeKey methods if they don't serve anymore. + try: + self.getCompletionItems().completions.remove(key) + except KeyError: + log.warning("trying to remove an unknown key") + + def _getTarget(self, txt): + """ Say who will receive the messsage + @return: a tuple (selected, target_type, target info) with: + - target_hook: None if we use the selected widget, (msg, data) if we have a hook (e.g. "@@: " for a public blog), where msg is the parsed message (i.e. without the "hook key: "@@: bla" become ("bla", None)) + - target_type: one of PUBLIC, GROUP, ONE2ONE, STATUS, MISC + - msg: HTML message which will appear in the privacy warning banner """ + target = self._selected_cache + + def getSelectedOrStatus(): + if target and target.isSelectable(): + _type, msg = target.getWarningData() + target_hook = None # we use the selected widget, not a hook + else: + _type, msg = "STATUS", "This will be your new status message" + target_hook = (txt, None) + return (target_hook, _type, msg) + + if not txt.startswith('@'): + target_hook, _type, msg = getSelectedOrStatus() + elif txt.startswith('@@: '): + _type = "PUBLIC" + msg = MicroblogPanel.warning_msg_public + target_hook = (txt[4:], None) + elif txt.startswith('@'): + _end = txt.find(': ') + if _end == -1: + target_hook, _type, msg = getSelectedOrStatus() + else: + group = txt[1:_end] # only one target group is managed for the moment + if not group or not group in self.host.contact_panel.getGroups(): + # the group doesn't exists, we ignore the key + group = None + target_hook, _type, msg = getSelectedOrStatus() + else: + _type = "GROUP" + msg = MicroblogPanel.warning_msg_group % group + target_hook = (txt[_end + 2:], group) + else: + log.error("Unknown target") + target_hook, _type, msg = getSelectedOrStatus() + + return (target_hook, _type, msg) + + def onKeyPress(self, sender, keycode, modifiers): + _txt = self.getText() + target_hook, type_, msg = self._getTarget(_txt) + + if keycode == KEY_ENTER: + if _txt: + if target_hook: + parsed_txt, data = target_hook + self.host.send([(type_, data)], parsed_txt) + self.host._updateInputHistory(_txt) + self.setText('') + self.host.showWarning(None, None) + else: + self.host.showWarning(type_, msg) + MessageBox.onKeyPress(self, sender, keycode, modifiers) + + def getTargetAndData(self): + """For external use, to get information about the (hypothetical) message + that would be sent if we press Enter right now in the unibox. + @return a tuple (target, data) with: + - data: what would be the content of the message (body) + - target: JID, group with the prefix "@" or the public entity "@@" + """ + _txt = self.getText() + target_hook, _type, _msg = self._getTarget(_txt) + if target_hook: + data, target = target_hook + if target is None: + return target_hook + return (data, "@%s" % (target if target != "" else "@")) + if isinstance(self._selected_cache, MicroblogPanel): + groups = self._selected_cache.accepted_groups + target = "@%s" % (groups[0] if len(groups) > 0 else "@") + if len(groups) > 1: + Window.alert("Sole the first group of the selected panel is taken in consideration: '%s'" % groups[0]) + elif isinstance(self._selected_cache, ChatPanel): + target = self._selected_cache.target + else: + target = None + return (_txt, target) + + def onWidgetClosed(self, lib_wid): + """Called when a libervia widget is closed""" + if self._selected_cache == lib_wid: + self.onSelectedChange(None) + + """def complete(self): + + #self.visible=False #XXX: self.visible is not unset in pyjamas when ENTER is pressed and a completion is done + #XXX: fixed directly on pyjamas, if the patch is accepted, no need to walk around this + return AutoCompleteTextBox.complete(self)""" + + +class WarningPopup(): + + def __init__(self): + self._popup = None + self._timer = Timer(notify=self._timeCb) + + def showWarning(self, type_=None, msg=None, duration=2000): + """Display a popup information message, e.g. to notify the recipient of a message being composed. + If type_ is None, a popup being currently displayed will be hidden. + @type_: a type determining the CSS style to be applied (see __showWarning) + @msg: message to be displayed + """ + if type_ is None: + self.__removeWarning() + return + if not self._popup: + self.__showWarning(type_, msg) + elif (type_, msg) != self._popup.target_data: + self._timeCb(None) # we remove the popup + self.__showWarning(type_, msg) + + self._timer.schedule(duration) + + def __showWarning(self, type_, msg): + """Display a popup information message, e.g. to notify the recipient of a message being composed. + @type_: a type determining the CSS style to be applied. For now the defined styles are + "NONE" (will do nothing), "PUBLIC", "GROUP", "STATUS" and "ONE2ONE". + @msg: message to be displayed + """ + if type_ == "NONE": + return + if not msg: + log.warning("no msg set uniBox warning") + return + if type_ == "PUBLIC": + style = "targetPublic" + elif type_ == "GROUP": + style = "targetGroup" + elif type_ == "STATUS": + style = "targetStatus" + elif type_ == "ONE2ONE": + style = "targetOne2One" + else: + log.error("unknown message type") + return + contents = HTML(msg) + + self._popup = dialog.PopupPanelWrapper(autoHide=False, modal=False) + self._popup.target_data = (type_, msg) + self._popup.add(contents) + self._popup.setStyleName("warningPopup") + if style: + self._popup.addStyleName(style) + + left = 0 + top = 0 # max(0, self.getAbsoluteTop() - contents.getOffsetHeight() - 2) + self._popup.setPopupPosition(left, top) + self._popup.show() + + def _timeCb(self, timer): + if self._popup: + self._popup.hide() + del self._popup + self._popup = None + + def __removeWarning(self): + """Remove the popup""" + self._timeCb(None) + + +class MicroblogItem(): + # XXX: should be moved in a separated module + + def __init__(self, data): + self.id = data['id'] + self.type = data.get('type', 'main_item') + self.empty = data.get('new', False) + self.title = data.get('title', '') + self.title_xhtml = data.get('title_xhtml', '') + self.content = data.get('content', '') + self.content_xhtml = data.get('content_xhtml', '') + self.author = data['author'] + self.updated = float(data.get('updated', 0)) # XXX: int doesn't work here + self.published = float(data.get('published', self.updated)) # XXX: int doesn't work here + self.service = data.get('service', '') + self.node = data.get('node', '') + self.comments = data.get('comments', False) + self.comments_service = data.get('comments_service', '') + self.comments_node = data.get('comments_node', '') + + +class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler): + + def __init__(self, blog_panel, data): + """ + @param blog_panel: the parent panel + @param data: dict containing the blog item data, or a MicroblogItem instance. + """ + self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data) + for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml', + 'author', 'updated', 'published', 'comments', 'service', 'node', + 'comments_service', 'comments_node']: + getter = lambda attr: lambda inst: getattr(inst._base_item, attr) + setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value) + setattr(MicroblogEntry, attr, property(getter(attr), setter(attr))) + + SimplePanel.__init__(self) + self._blog_panel = blog_panel + + self.panel = FlowPanel() + self.panel.setStyleName('mb_entry') + + self.header = HTMLPanel('') + self.panel.add(self.header) + + self.entry_actions = VerticalPanel() + self.entry_actions.setStyleName('mb_entry_actions') + self.panel.add(self.entry_actions) + + entry_avatar = SimplePanel() + entry_avatar.setStyleName('mb_entry_avatar') + self.avatar = Image(self._blog_panel.host.getAvatar(self.author)) + entry_avatar.add(self.avatar) + self.panel.add(entry_avatar) + + if TOGGLE_EDITION_USE_ICON: + self.entry_dialog = HorizontalPanel() + else: + self.entry_dialog = VerticalPanel() + self.entry_dialog.setStyleName('mb_entry_dialog') + self.panel.add(self.entry_dialog) + + self.add(self.panel) + ClickHandler.__init__(self) + self.addClickListener(self) + + self.__pub_data = (self.service, self.node, self.id) + self.__setContent() + + def __setContent(self): + """Actually set the entry content (header, icons, bubble...)""" + self.delete_label = self.update_label = self.comment_label = None + self.bubble = self._current_comment = None + self.__setHeader() + self.__setBubble() + self.__setIcons() + + def __setHeader(self): + """Set the entry header""" + if self.empty: + return + update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated) + self.header.setHTML("""<div class='mb_entry_header'> + <span class='mb_entry_author'>%(author)s</span> on + <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s + </div>""" % {'author': html_tools.html_sanitize(self.author), + 'published': datetime.fromtimestamp(self.published), + 'updated': update_text if self.published != self.updated else '' + } + ) + + def __setIcons(self): + """Set the entry icons (delete, update, comment)""" + if self.empty: + return + + def addIcon(label, title): + label = Label(label) + label.setTitle(title) + label.addClickListener(self) + self.entry_actions.add(label) + return label + + if self.comments: + self.comment_label = addIcon(u"↶", "Comment this message") + self.comment_label.setStyleName('mb_entry_action_larger') + is_publisher = self.author == self._blog_panel.host.whoami.bare + if is_publisher: + self.update_label = addIcon(u"✍", "Edit this message") + if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare): + self.delete_label = addIcon(u"✗", "Delete this message") + + def updateAvatar(self, new_avatar): + """Change the avatar of the entry + @param new_avatar: path to the new image""" + self.avatar.setUrl(new_avatar) + + def onClick(self, sender): + if sender == self: + try: # prevent re-selection of the main entry after a comment has been focused + if self.__ignoreNextEvent: + self.__ignoreNextEvent = False + return + except AttributeError: + pass + self._blog_panel.setSelectedEntry(self) + elif sender == self.delete_label: + self._delete() + elif sender == self.update_label: + self.edit(True) + elif sender == self.comment_label: + self.__ignoreNextEvent = True + self._comment() + + def __modifiedCb(self, content): + """Send the new content to the backend + @return: False to restore the original content if a deletion has been cancelled + """ + if not content['text']: # previous content has been emptied + self._delete(True) + return False + extra = {'published': str(self.published)} + if isinstance(self.bubble, richtext.RichTextEditor): + # TODO: if the user change his parameters after the message edition started, + # the message syntax could be different then the current syntax: pass the + # message syntax in extra for the frontend to use it instead of current syntax. + extra.update({'content_rich': content['text'], 'title': content['title']}) + if self.empty: + if self.type == 'main_item': + self._blog_panel.host.bridge.call('sendMblog', None, None, self._blog_panel.accepted_groups, content['text'], extra) + else: + self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra) + else: + self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra) + return True + + def __afterEditCb(self, content): + """Remove the entry if it was an empty one (used for creating a new blog post). + Data for the actual new blog post will be received from the bridge""" + if self.empty: + self._blog_panel.removeEntry(self.type, self.id) + if self.type == 'main_item': # restore the "New message" button + self._blog_panel.refresh() + else: # allow to create a new comment + self._parent_entry._current_comment = None + self.entry_dialog.setWidth('auto') + try: + self.toggle_syntax_button.removeFromParent() + except TypeError: + pass + + def __setBubble(self, edit=False): + """Set the bubble displaying the initial content.""" + content = {'text': self.content_xhtml if self.content_xhtml else self.content, + 'title': self.title_xhtml if self.title_xhtml else self.title} + if self.content_xhtml: + content.update({'syntax': C.SYNTAX_XHTML}) + if self.author != self._blog_panel.host.whoami.bare: + options = ['read_only'] + else: + options = [] if self.empty else ['update_msg'] + self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options) + else: # assume raw text message have no title + self.bubble = base_panels.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True}) + self.bubble.addStyleName("bubble") + try: + self.toggle_syntax_button.removeFromParent() + except TypeError: + pass + self.entry_dialog.add(self.bubble) + self.edit(edit) + self.bubble.addEditListener(self.__showWarning) + + def __showWarning(self, sender, keycode): + if keycode == KEY_ENTER: + self._blog_panel.host.showWarning(None, None) + else: + self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment')) + + def _delete(self, empty=False): + """Ask confirmation for deletion. + @return: False if the deletion has been cancelled.""" + def confirm_cb(answer): + if answer: + self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments) + else: # restore the text if it has been emptied during the edition + self.bubble.setContent(self.bubble._original_content) + + if self.empty: + text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.") + dialog.InfoDialog(_("Information"), text).show() + return + text = "" + if empty: + text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.<br/>") + target = _('message and all its comments') if self.comments else _('comment') + text += _("Do you really want to delete this %s?") % target + dialog.ConfirmDialog(confirm_cb, text=text).show() + + def _comment(self): + """Add an empty entry for a new comment""" + if self._current_comment: + self._current_comment.bubble.setFocus(True) + self._blog_panel.setSelectedEntry(self._current_comment) + return + data = {'id': str(time()), + 'new': True, + 'type': 'comment', + 'author': self._blog_panel.host.whoami.bare, + 'service': self.comments_service, + 'node': self.comments_node + } + entry = self._blog_panel.addEntry(data) + if entry is None: + log.info("The entry of id %s can not be commented" % self.id) + return + entry._parent_entry = self + self._current_comment = entry + self.edit(True, entry) + self._blog_panel.setSelectedEntry(entry) + + def edit(self, edit, entry=None): + """Toggle the bubble between display and edit mode + @edit: boolean value + @entry: MicroblogEntry instance, or None to use self + """ + if entry is None: + entry = self + try: + entry.toggle_syntax_button.removeFromParent() + except TypeError: + pass + entry.bubble.edit(edit) + if edit: + if isinstance(entry.bubble, richtext.RichTextEditor): + image = '<a class="richTextIcon">A</a>' + html = '<a style="color: blue;">raw text</a>' + title = _('Switch to raw text edition') + else: + image = '<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>' + html = '<a style="color: blue;">rich text</a>' + title = _('Switch to rich text edition') + if TOGGLE_EDITION_USE_ICON: + entry.entry_dialog.setWidth('80%') + entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax) + entry.toggle_syntax_button.setTitle(title) + entry.entry_dialog.add(entry.toggle_syntax_button) + else: + entry.toggle_syntax_button = HTML(html) + entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax) + entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax') + entry.entry_dialog.add(entry.toggle_syntax_button) + entry.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS + entry.toggle_syntax_button.setStyleAttribute('left', '-20px') + + def toggleContentSyntax(self): + """Toggle the editor between raw and rich text""" + original_content = self.bubble.getOriginalContent() + rich = not isinstance(self.bubble, richtext.RichTextEditor) + if rich: + original_content['syntax'] = C.SYNTAX_XHTML + + def setBubble(text): + self.content = text + self.content_xhtml = text if rich else '' + self.content_title = self.content_title_xhtml = '' + self.bubble.removeFromParent() + self.__setBubble(True) + self.bubble.setOriginalContent(original_content) + if rich: + self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble + + text = self.bubble.getContent()['text'] + if not text: + setBubble(' ') # something different than empty string is needed to initialize the rich text editor + return + if not rich: + def confirm_cb(answer): + if answer: + self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT) + dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show() + else: + self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML) + + +class MicroblogPanel(base_widget.LiberviaWidget): + warning_msg_public = "This message will be PUBLIC and everybody will be able to see it, even people you don't know" + warning_msg_group = "This message will be published for all the people of the group <span class='warningTarget'>%s</span>" + + def __init__(self, host, accepted_groups): + """Panel used to show microblog + @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts + """ + base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True) + self.setAcceptedGroup(accepted_groups) + self.host = host + self.entries = {} + self.comments = {} + self.selected_entry = None + self.vpanel = VerticalPanel() + self.vpanel.setStyleName('microblogPanel') + self.setWidget(self.vpanel) + + def refresh(self): + """Refresh the display of this widget. If the unibox is disabled, + display the 'New message' button or an empty bubble on top of the panel""" + if hasattr(self, 'new_button'): + self.new_button.setVisible(self.host.uni_box is None) + return + if self.host.uni_box is None: + def addBox(): + if hasattr(self, 'new_button'): + self.new_button.setVisible(False) + data = {'id': str(time()), + 'new': True, + 'author': self.host.whoami.bare, + } + entry = self.addEntry(data) + entry.edit(True) + if NEW_MESSAGE_USE_BUTTON: + self.new_button = Button("New message", listener=addBox) + self.new_button.setStyleName("microblogNewButton") + self.vpanel.insert(self.new_button, 0) + elif not self.getNewMainEntry(): + addBox() + + def getNewMainEntry(self): + """Get the new entry being edited, or None if it doesn't exists. + + @return (MicroblogEntry): the new entry being edited. + """ + try: + first = self.vpanel.children[0] + except IndexError: + return None + assert(first.type == 'main_item') + return first if first.empty else None + + @classmethod + def registerClass(cls): + base_widget.LiberviaWidget.addDropKey("GROUP", cls.createPanel) + base_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", cls.createMetaPanel) + + @classmethod + def createPanel(cls, host, item): + """Generic panel creation for one, several or all groups (meta). + @parem host: the SatWebFrontend instance + @param item: single group as a string, list of groups + (as an array) or None (for the meta group = "all groups") + @return: the created MicroblogPanel + """ + _items = item if isinstance(item, list) else ([] if item is None else [item]) + _type = 'ALL' if _items == [] else 'GROUP' + # XXX: pyjamas doesn't support use of cls directly + _new_panel = MicroblogPanel(host, _items) + host.FillMicroblogPanel(_new_panel) + host.bridge.call('getMassiveLastMblogs', _new_panel.massiveInsert, _type, _items, 10) + host.setSelected(_new_panel) + _new_panel.refresh() + return _new_panel + + @classmethod + def createMetaPanel(cls, host, item): + """Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group""" + return MicroblogPanel.createPanel(host, None) + + @property + def accepted_groups(self): + return self._accepted_groups + + def matchEntity(self, entity): + """ + @param entity: single group as a string, list of groups + (as an array) or None (for the meta group = "all groups") + @return: True if self matches the given entity + """ + entity = entity if isinstance(entity, list) else ([] if entity is None else [entity]) + entity.sort() # sort() do not return the sorted list: do it here, not on the "return" line + return self.accepted_groups == entity + + def getWarningData(self, comment=None): + """ + @param comment: True if the composed message is a comment. If None, consider we are + composing from the unibox and guess the message type from self.selected_entry + @return: a couple (type, msg) for calling self.host.showWarning""" + if comment is None: # composing from the unibox + if self.selected_entry and not self.selected_entry.comments: + log.error("an item without comment is selected") + return ("NONE", None) + comment = self.selected_entry is not None + if comment: + return ("PUBLIC", "This is a <span class='warningTarget'>comment</span> and keep the initial post visibility, so it is potentialy public") + elif not self._accepted_groups: + # we have a meta MicroblogPanel, we publish publicly + return ("PUBLIC", self.warning_msg_public) + else: + # we only accept one group at the moment + # FIXME: manage several groups + return ("GROUP", self.warning_msg_group % self._accepted_groups[0]) + + def onTextEntered(self, text): + if self.selected_entry: + # we are entering a comment + comments_url = self.selected_entry.comments + if not comments_url: + raise Exception("ERROR: the comments URL is empty") + target = ("COMMENT", comments_url) + elif not self._accepted_groups: + # we are entering a public microblog + target = ("PUBLIC", None) + else: + # we are entering a microblog restricted to a group + # FIXME: manage several groups + target = ("GROUP", self._accepted_groups[0]) + self.host.send([target], text) + + def accept_all(self): + return not self._accepted_groups # we accept every microblog only if we are not filtering by groups + + def getEntries(self): + """Ask all the entries for the currenly accepted groups, + and fill the panel""" + + def massiveInsert(self, mblogs): + """Insert several microblogs at once + @param mblogs: dictionary of microblogs, as the result of getMassiveLastGroupBlogs + """ + log.debug("Massive insertion of %d microblogs" % len(mblogs)) + for publisher in mblogs: + log.debug("adding blogs for [%s]" % publisher) + for mblog in mblogs[publisher]: + if not "content" in mblog: + log.warning("No content found in microblog [%s]", mblog) + continue + self.addEntry(mblog) + + def mblogsInsert(self, mblogs): + """ Insert several microblogs at once + @param mblogs: list of microblogs + """ + for mblog in mblogs: + if not "content" in mblog: + log.warning("No content found in microblog [%s]", mblog) + continue + self.addEntry(mblog) + + def _chronoInsert(self, vpanel, entry, reverse=True): + """ Insert an entry in chronological order + @param vpanel: VerticalPanel instance + @param entry: MicroblogEntry + @param reverse: more recent entry on top if True, chronological order else""" + assert(isinstance(reverse, bool)) + if entry.empty: + entry.published = time() + # we look for the right index to insert our entry: + # if reversed, we insert the entry above the first entry + # in the past + idx = 0 + + for child in vpanel.children: + if not isinstance(child, MicroblogEntry): + idx += 1 + continue + condition_to_stop = child.empty or (child.published > entry.published) + if condition_to_stop != reverse: # != is XOR + break + idx += 1 + + vpanel.insert(entry, idx) + + def addEntry(self, data): + """Add an entry to the panel + @param data: dict containing the item data + @return: the added entry, or None + """ + _entry = MicroblogEntry(self, data) + if _entry.type == "comment": + comments_hash = (_entry.service, _entry.node) + if not comments_hash in self.comments: + # The comments node is not known in this panel + return None + parent = self.comments[comments_hash] + parent_idx = self.vpanel.getWidgetIndex(parent) + # we find or create the panel where the comment must be inserted + try: + sub_panel = self.vpanel.getWidget(parent_idx + 1) + except IndexError: + sub_panel = None + if not sub_panel or not isinstance(sub_panel, VerticalPanel): + sub_panel = VerticalPanel() + sub_panel.setStyleName('microblogPanel') + sub_panel.addStyleName('subPanel') + self.vpanel.insert(sub_panel, parent_idx + 1) + for idx in xrange(0, len(sub_panel.getChildren())): + comment = sub_panel.getIndexedChild(idx) + if comment.id == _entry.id: + # update an existing comment + sub_panel.remove(comment) + sub_panel.insert(_entry, idx) + return _entry + # we want comments to be inserted in chronological order + self._chronoInsert(sub_panel, _entry, reverse=False) + return _entry + + if _entry.id in self.entries: # update + idx = self.vpanel.getWidgetIndex(self.entries[_entry.id]) + self.vpanel.remove(self.entries[_entry.id]) + self.vpanel.insert(_entry, idx) + else: # new entry + self._chronoInsert(self.vpanel, _entry) + self.entries[_entry.id] = _entry + + if _entry.comments: + # entry has comments, we keep the comments service/node as a reference + comments_hash = (_entry.comments_service, _entry.comments_node) + self.comments[comments_hash] = _entry + self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node) + + return _entry + + def removeEntry(self, type_, id_): + """Remove an entry from the panel + @param type_: entry type ('main_item' or 'comment') + @param id_: entry id + """ + for child in self.vpanel.getChildren(): + if isinstance(child, MicroblogEntry) and type_ == 'main_item': + if child.id == id_: + main_idx = self.vpanel.getWidgetIndex(child) + try: + sub_panel = self.vpanel.getWidget(main_idx + 1) + if isinstance(sub_panel, VerticalPanel): + sub_panel.removeFromParent() + except IndexError: + pass + child.removeFromParent() + self.selected_entry = None + break + elif isinstance(child, VerticalPanel) and type_ == 'comment': + for comment in child.getChildren(): + if comment.id == id_: + comment.removeFromParent() + self.selected_entry = None + break + + def setSelectedEntry(self, entry): + try: + self.vpanel.getParent().ensureVisible(entry) # scroll to the clicked entry + except AttributeError: + log.warning("FIXME: MicroblogPanel.vpanel should be wrapped in a ScrollPanel!") + removeStyle = lambda entry: entry.removeStyleName('selected_entry') + if not self.host.uni_box or not entry.comments: + entry.addStyleName('selected_entry') # blink the clicked entry + clicked_entry = entry # entry may be None when the timer is done + Timer(500, lambda timer: removeStyle(clicked_entry)) + if not self.host.uni_box: + return # unibox is disabled + # from here the previous behavior (toggle main item selection) is conserved + entry = entry if entry.comments else None + if self.selected_entry == entry: + entry = None + if self.selected_entry: + removeStyle(self.selected_entry) + if entry: + log.debug("microblog entry selected (author=%s)" % entry.author) + entry.addStyleName('selected_entry') + self.selected_entry = entry + + def updateValue(self, type_, jid, value): + """Update a jid value in entries + @param type_: one of 'avatar', 'nick' + @param jid: jid concerned + @param value: new value""" + def updateVPanel(vpanel): + for child in vpanel.children: + if isinstance(child, MicroblogEntry) and child.author == jid: + child.updateAvatar(value) + elif isinstance(child, VerticalPanel): + updateVPanel(child) + if type_ == 'avatar': + updateVPanel(self.vpanel) + + def setAcceptedGroup(self, group): + """Add one or more group(s) which can be displayed in this panel. + Prevent from duplicate values and keep the list sorted. + @param group: string of the group, or list of string + """ + if not hasattr(self, "_accepted_groups"): + self._accepted_groups = [] + groups = group if isinstance(group, list) else [group] + for _group in groups: + if _group not in self._accepted_groups: + self._accepted_groups.append(_group) + self._accepted_groups.sort() + + def isJidAccepted(self, jid): + """Tell if a jid is actepted and shown in this panel + @param jid: jid + @return: True if the jid is accepted""" + if self.accept_all(): + return True + for group in self._accepted_groups: + if self.host.contact_panel.isContactInGroup(group, jid): + return True + return False + + +class StatusPanel(base_panels.HTMLTextEditor): + + EMPTY_STATUS = '<click to set a status>' + + def __init__(self, host, status=''): + self.host = host + modifiedCb = lambda content: self.host.bridge.call('setStatus', None, self.host.status_panel.presence, content['text']) or True + base_panels.HTMLTextEditor.__init__(self, {'text': status}, modifiedCb, options={'no_xhtml': True, 'listen_focus': True, 'listen_click': True}) + self.edit(False) + self.setStyleName('statusPanel') + + @property + def status(self): + return self._original_content['text'] + + def __cleanContent(self, content): + status = content['text'] + if status == self.EMPTY_STATUS or status in C.PRESENCE.values(): + content['text'] = '' + return content + + def getContent(self): + return self.__cleanContent(base_panels.HTMLTextEditor.getContent(self)) + + def setContent(self, content): + content = self.__cleanContent(content) + base_panels.BaseTextEditor.setContent(self, content) + + def setDisplayContent(self): + status = self._original_content['text'] + try: + presence = self.host.status_panel.presence + except AttributeError: # during initialization + presence = None + if not status: + if presence and presence in C.PRESENCE: + status = C.PRESENCE[presence] + else: + status = self.EMPTY_STATUS + self.display.setHTML(addURLToText(status)) + + +class PresenceStatusPanel(HorizontalPanel, ClickHandler): + + def __init__(self, host, presence="", status=""): + self.host = host + HorizontalPanel.__init__(self, Width='100%') + self.presence_button = Label(u"◉") + self.presence_button.setStyleName("presence-button") + self.status_panel = StatusPanel(host, status=status) + self.setPresence(presence) + entries = {} + for value in C.PRESENCE.keys(): + entries.update({C.PRESENCE[value]: {"value": value}}) + + def callback(sender, key): + self.setPresence(entries[key]["value"]) # order matters + self.host.send([("STATUS", None)], self.status_panel.status) + + self.presence_list = base_panels.PopupMenuPanel(entries, callback=callback, style={"menu": "gwt-ListBox"}) + self.presence_list.registerClickSender(self.presence_button) + + panel = HorizontalPanel() + panel.add(self.presence_button) + panel.add(self.status_panel) + panel.setCellVerticalAlignment(self.presence_button, 'baseline') + panel.setCellVerticalAlignment(self.status_panel, 'baseline') + panel.setStyleName("marginAuto") + self.add(panel) + + self.status_panel.edit(False) + + ClickHandler.__init__(self) + self.addClickListener(self) + + @property + def presence(self): + return self._presence + + @property + def status(self): + return self.status_panel._original_content['text'] + + def setPresence(self, presence): + self._presence = presence + contact.setPresenceStyle(self.presence_button, self._presence) + + def setStatus(self, status): + self.status_panel.setContent({'text': status}) + self.status_panel.setDisplayContent() + + def onClick(self, sender): + # As status is the default target of uniBar, we don't want to select anything if click on it + self.host.setSelected(None) + + +class ChatPanel(base_widget.LiberviaWidget): + + def __init__(self, host, target, type_='one2one'): + """Panel used for conversation (one 2 one or group chat) + @param host: SatWebFrontend instance + @param target: entity (jid.JID) with who we have a conversation (contact's jid for one 2 one chat, or MUC room) + @param type: one2one for simple conversation, group for MUC""" + base_widget.LiberviaWidget.__init__(self, host, title=target.bare, selectable=True) + self.vpanel = VerticalPanel() + self.vpanel.setSize('100%', '100%') + self.type = type_ + self.nick = None + if not target: + log.error("Empty target !") + return + self.target = target + self.__body = AbsolutePanel() + self.__body.setStyleName('chatPanel_body') + chat_area = HorizontalPanel() + chat_area.setStyleName('chatArea') + if type_ == 'group': + self.occupants_list = base_panels.OccupantsList() + chat_area.add(self.occupants_list) + self.__body.add(chat_area) + self.content = AbsolutePanel() + self.content.setStyleName('chatContent') + self.content_scroll = base_widget.ScrollPanelWrapper(self.content) + chat_area.add(self.content_scroll) + chat_area.setCellWidth(self.content_scroll, '100%') + self.vpanel.add(self.__body) + self.vpanel.setCellHeight(self.__body, '100%') + self.addStyleName('chatPanel') + self.setWidget(self.vpanel) + self.state_machine = plugin_xep_0085.ChatStateMachine(self.host, str(self.target)) + self._state = None + + @classmethod + def registerClass(cls): + base_widget.LiberviaWidget.addDropKey("CONTACT", cls.createPanel) + + @classmethod + def createPanel(cls, host, item): + _contact = item if isinstance(item, jid.JID) else jid.JID(item) + host.contact_panel.setContactMessageWaiting(_contact.bare, False) + _new_panel = ChatPanel(host, _contact) # XXX: pyjamas doesn't seems to support creating with cls directly + _new_panel.historyPrint() + host.setSelected(_new_panel) + _new_panel.refresh() + return _new_panel + + def refresh(self): + """Refresh the display of this widget. If the unibox is disabled, + add a message box at the bottom of the panel""" + self.host.contact_panel.setContactMessageWaiting(self.target.bare, False) + self.content_scroll.scrollToBottom() + + enable_box = self.host.uni_box is None + if hasattr(self, 'message_box'): + self.message_box.setVisible(enable_box) + return + if enable_box: + self.message_box = MessageBox(self.host) + self.message_box.onSelectedChange(self) + self.vpanel.add(self.message_box) + + def matchEntity(self, entity): + """ + @param entity: target jid as a string or jid.JID instance. + Could also be a couple with a type in the second element. + @return: True if self matches the given entity + """ + if isinstance(entity, tuple): + entity, type_ = entity if len(entity) > 1 else (entity[0], self.type) + else: + type_ = self.type + entity = entity if isinstance(entity, jid.JID) else jid.JID(entity) + try: + return self.target.bare == entity.bare and self.type == type_ + except AttributeError as e: + e.include_traceback() + return False + + def getWarningData(self): + if self.type not in ["one2one", "group"]: + raise Exception("Unmanaged type !") + if self.type == "one2one": + msg = "This message will be sent to your contact <span class='warningTarget'>%s</span>" % self.target + elif self.type == "group": + msg = "This message will be sent to all the participants of the multi-user room <span class='warningTarget'>%s</span>" % self.target + return ("ONE2ONE" if self.type == "one2one" else "GROUP", msg) + + def onTextEntered(self, text): + self.host.send([("groupchat" if self.type == 'group' else "chat", str(self.target))], text) + self.state_machine._onEvent("active") + + def onQuit(self): + base_widget.LiberviaWidget.onQuit(self) + if self.type == 'group': + self.host.bridge.call('mucLeave', None, self.target.bare) + + def setUserNick(self, nick): + """Set the nick of the user, usefull for e.g. change the color of the user""" + self.nick = nick + + def setPresents(self, nicks): + """Set the users presents in this room + @param occupants: list of nicks (string)""" + self.occupants_list.clear() + for nick in nicks: + self.occupants_list.addOccupant(nick) + + def userJoined(self, nick, data): + self.occupants_list.addOccupant(nick) + self.printInfo("=> %s has joined the room" % nick) + + def userLeft(self, nick, data): + self.occupants_list.removeOccupant(nick) + self.printInfo("<= %s has left the room" % nick) + + def changeUserNick(self, old_nick, new_nick): + assert(self.type == "group") + self.occupants_list.removeOccupant(old_nick) + self.occupants_list.addOccupant(new_nick) + self.printInfo(_("%(old_nick)s is now known as %(new_nick)s") % {'old_nick': old_nick, 'new_nick': new_nick}) + + def historyPrint(self, size=20): + """Print the initial history""" + def getHistoryCB(history): + # display day change + day_format = "%A, %d %b %Y" + previous_day = datetime.now().strftime(day_format) + for line in history: + timestamp, from_jid, to_jid, message, mess_type, extra = line + message_day = datetime.fromtimestamp(float(timestamp or time())).strftime(day_format) + if previous_day != message_day: + self.printInfo("* " + message_day) + previous_day = message_day + self.printMessage(from_jid, message, extra, timestamp) + self.host.bridge.call('getHistory', getHistoryCB, self.host.whoami.bare, self.target.bare, size, True) + + def printInfo(self, msg, type_='normal', link_cb=None): + """Print general info + @param msg: message to print + @param type_: one of: + "normal": general info like "toto has joined the room" + "link": general info that is clickable like "click here to join the main room" + "me": "/me" information like "/me clenches his fist" ==> "toto clenches his fist" + @param link_cb: method to call when the info is clicked, ignored if type_ is not 'link' + """ + _wid = HTML(msg) if type_ == 'link' else Label(msg) + if type_ == 'normal': + _wid.setStyleName('chatTextInfo') + elif type_ == 'link': + _wid.setStyleName('chatTextInfo-link') + if link_cb: + _wid.addClickListener(link_cb) + elif type_ == 'me': + _wid.setStyleName('chatTextMe') + else: + _wid.setStyleName('chatTextInfo') + self.content.add(_wid) + + def printMessage(self, from_jid, msg, extra, timestamp=None): + """Print message in chat window. Must be implemented by child class""" + _jid = jid.JID(from_jid) + nick = _jid.node if self.type == 'one2one' else _jid.resource + mymess = _jid.resource == self.nick if self.type == "group" else _jid.bare == self.host.whoami.bare # mymess = True if message comes from local user + if msg.startswith('/me '): + self.printInfo('* %s %s' % (nick, msg[4:]), type_='me') + return + self.content.add(base_panels.ChatText(timestamp, nick, mymess, msg, extra.get('xhtml'))) + self.content_scroll.scrollToBottom() + + def startGame(self, game_type, waiting, referee, players, *args): + """Configure the chat window to start a game""" + classes = {"Tarot": card_game.CardPanel, "RadioCol": radiocol.RadioColPanel} + if game_type not in classes.keys(): + return # unknown game + attr = game_type.lower() + self.occupants_list.updateSpecials(players, SYMBOLS[attr]) + if waiting or not self.nick in players: + return # waiting for player or not playing + attr = "%s_panel" % attr + if hasattr(self, attr): + return + log.info("%s Game Started \o/" % game_type) + panel = classes[game_type](self, referee, self.nick, players, *args) + setattr(self, attr, panel) + self.vpanel.insert(panel, 0) + self.vpanel.setCellHeight(panel, panel.getHeight()) + + def getGame(self, game_type): + """Return class managing the game type""" + # TODO: check that the game is launched, and manage errors + if game_type == "Tarot": + return self.tarot_panel + elif game_type == "RadioCol": + return self.radiocol_panel + + def setState(self, state, nick=None): + """Set the chat state (XEP-0085) of the contact. Leave nick to None + to set the state for a one2one conversation, or give a nickname or + C.ALL_OCCUPANTS to set the state of a participant within a MUC. + @param state: the new chat state + @param nick: None for one2one, the MUC user nick or ALL_OCCUPANTS + """ + if nick: + assert(self.type == 'group') + occupants = self.occupants_list.occupants_list.keys() if nick == C.ALL_OCCUPANTS else [nick] + for occupant in occupants: + self.occupants_list.occupants_list[occupant].setState(state) + else: + assert(self.type == 'one2one') + self._state = state + self.refreshTitle() + self.state_machine.started = not not state # start to send "composing" state from now + + def refreshTitle(self): + """Refresh the title of this ChatPanel dialog""" + if self._state: + self.setTitle(self.target.bare + " (" + self._state + ")") + else: + self.setTitle(self.target.bare) + + +class WebPanel(base_widget.LiberviaWidget): + """ (mini)browser like widget """ + + def __init__(self, host, url=None): + """ + @param host: SatWebFrontend instance + """ + base_widget.LiberviaWidget.__init__(self, host) + self._vpanel = VerticalPanel() + self._vpanel.setSize('100%', '100%') + self._url = dialog.ExtTextBox(enter_cb=self.onUrlClick) + self._url.setText(url or "") + self._url.setWidth('100%') + hpanel = HorizontalPanel() + hpanel.add(self._url) + btn = Button("Go", self.onUrlClick) + hpanel.setCellWidth(self._url, "100%") + #self.setCellWidth(btn, "10%") + hpanel.add(self._url) + hpanel.add(btn) + self._vpanel.add(hpanel) + self._vpanel.setCellHeight(hpanel, '20px') + self._frame = Frame(url or "") + self._frame.setSize('100%', '100%') + DOM.setStyleAttribute(self._frame.getElement(), "position", "relative") + self._vpanel.add(self._frame) + self.setWidget(self._vpanel) + + def onUrlClick(self, sender): + self._frame.setUrl(self._url.getText()) + + +class MainPanel(AbsolutePanel): + + def __init__(self, host): + self.host = host + AbsolutePanel.__init__(self) + + # menu + self.menu = menu.Menu(host) + + # unibox + self.unibox_panel = UniBoxPanel(host) + self.unibox_panel.setVisible(False) + + # contacts + self._contacts = HorizontalPanel() + self._contacts.addStyleName('globalLeftArea') + self.contacts_switch = Button(u'«', self._contactsSwitch) + self.contacts_switch.addStyleName('contactsSwitch') + self._contacts.add(self.contacts_switch) + self._contacts.add(self.host.contact_panel) + + # tabs + self.tab_panel = base_widget.MainTabPanel(host) + self.discuss_panel = base_widget.WidgetsPanel(self.host, locked=True) + self.tab_panel.add(self.discuss_panel, "Discussions") + self.tab_panel.selectTab(0) + + self.header = AbsolutePanel() + self.header.add(self.menu) + self.header.add(self.unibox_panel) + self.header.add(self.host.status_panel) + self.header.setStyleName('header') + self.add(self.header) + + self._hpanel = HorizontalPanel() + self._hpanel.add(self._contacts) + self._hpanel.add(self.tab_panel) + self.add(self._hpanel) + + self.setWidth("100%") + Window.addWindowResizeListener(self) + + def _contactsSwitch(self, btn=None): + """ (Un)hide contacts panel """ + if btn is None: + btn = self.contacts_switch + cpanel = self.host.contact_panel + cpanel.setVisible(not cpanel.getVisible()) + btn.setText(u"«" if cpanel.getVisible() else u"»") + self.host.resize() + + def _contactsMove(self, parent): + """Move the contacts container (containing the contact list and + the "hide/show" button) to another parent, but always as the + first child position (insert at index 0). + """ + if self._contacts.getParent(): + if self._contacts.getParent() == parent: + return + self._contacts.removeFromParent() + parent.insert(self._contacts, 0) + + def onWindowResized(self, width, height): + _elts = doc().getElementsByClassName('gwt-TabBar') + if not _elts.length: + tab_bar_h = 0 + else: + tab_bar_h = _elts.item(0).offsetHeight + ideal_height = Window.getClientHeight() - tab_bar_h + self.setHeight("%s%s" % (ideal_height, "px")) + + def refresh(self): + """Refresh the main panel""" + self.unibox_panel.refresh()