view browser_side/panels.py @ 411:a0256b81d367

setup.py: update website (it's not http://www.salut-a-toi.org), and removed pyjamas framework which is not standard in pypi
author Goffi <goffi@goffi.org>
date Tue, 18 Mar 2014 19:18:16 +0100
parents ee8ebfe23e16
children ae598511850d
line wrap: on
line source

#!/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 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
from jid import JID

from html_tools import html_sanitize
from base_panels import ChatText, OccupantsList, PopupMenuPanel, BaseTextEditor, LightTextEditor, HTMLTextEditor
from card_game import CardPanel
from radiocol import RadioColPanel
from menu import Menu
import dialog
import base_widget
import richtext
import contact

from constants import Const
from plugin_xep_0085 import ChatStateMachine
from sat_frontends.tools.strings import addURLToText
from sat_frontends.tools.games import SYMBOLS
from sat.core.i18n import _


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.params_ui['unibox']['value']
        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: 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:
            print "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:
            print "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:
            print "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:
            print "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)

        self.entry_dialog = HorizontalPanel()
        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_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

        is_publisher = self.author == self._blog_panel.host.whoami.bare
        if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare):
            self.delete_label = addIcon(u"✗", "Delete this message")
        if is_publisher:
            self.update_label = addIcon(u"✍", "Edit this message")
        if self.comments:
            self.comment_label = addIcon(u"↶", "Comment 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:
            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._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
        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': Const.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 = LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True})
        self.bubble.setStyleName("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)
            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:
            print "The entry of id %s can not be commented" % self.id
            return
        entry._parent_entry = self
        self._current_comment = entry
        self.edit(True, 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>'
                title = _('Switch to raw text edition')
            else:
                image = '<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>'
                title = _('Switch to rich text edition')
            entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax)
            entry.toggle_syntax_button.setTitle(title)
            entry.entry_dialog.add(entry.toggle_syntax_button)

    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'] = Const.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, Const.SYNTAX_CURRENT, Const.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, Const.SYNTAX_TEXT, Const.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 on top of the panel"""
        enable_button = self.host.uni_box is None
        if hasattr(self, 'new_button'):
            self.new_button.setVisible(enable_button)
            return
        if enable_button:
            def addBox():
                self.new_button.setVisible(False)
                data = {'id': str(time()),
                        'new': True,
                        'author': self.host.whoami.bare,
                        }
                entry = self.addEntry(data)
                entry.edit(True)
            self.new_button = Button("New message", listener=addBox)
            self.new_button.setStyleName("microblogNewButton")
            self.vpanel.insert(self.new_button, 0)

    @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:
                print ("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
        """
        print "Massive insertion of %d microblogs" % len(mblogs)
        for publisher in mblogs:
            print "adding blogs for [%s]" % publisher
            for mblog in mblogs[publisher]:
                if not "content" in mblog:
                    print ("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:
                print ("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"""
        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
            if reverse:
                if child.published < entry.published:
                    break
            else:
                if child.published > entry.published:
                    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:
            print "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: 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:
            print "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(HTMLTextEditor):

    EMPTY_STATUS = '&lt;click to set a status&gt;'

    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
        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 Const.PRESENCE.values():
            content['text'] = ''
        return content

    def getContent(self):
        return self.__cleanContent(HTMLTextEditor.getContent(self))

    def setContent(self, content):
        content = self.__cleanContent(content)
        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 Const.PRESENCE:
                status = Const.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 Const.PRESENCE.keys():
            entries.update({Const.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 = 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) 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:
            print "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 = 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 = 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) else 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 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) else 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(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(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": CardPanel, "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
        print ("%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
        Const.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 == Const.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(host)

        # unibox
        self.unibox_panel = UniBoxPanel(host)
        self.unibox_panel.setVisible(False)

        # status bar
        status = host.status_panel

        # 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)

        header = AbsolutePanel()
        header.add(self.menu)
        header.add(self.unibox_panel)
        header.add(status)
        header.setStyleName('header')
        self.add(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()