# ============================================================================
#
# Copyright (C) 2007-2013 Conceptive Engineering bvba. All rights reserved.
# www.conceptive.be / info@conceptive.be
#
# This file is part of the Camelot Library.
#
# This file may be used under the terms of the GNU General Public
# License version 2.0 as published by the Free Software Foundation
# and appearing in the file license.txt included in the packaging of
# this file. Please review this information to ensure GNU
# General Public Licensing requirements will be met.
#
# If you are unsure which license is appropriate for your use, please
# visit www.python-camelot.com or contact info@conceptive.be
#
# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE
# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
#
# For use of this library in commercial applications, please contact
# info@conceptive.be
#
# ============================================================================
"""Convenience functions and classes to present views to the user"""
from PyQt4 import QtGui
from PyQt4 import QtCore
from PyQt4.QtCore import Qt
import logging
logger = logging.getLogger('camelot.view.workspace')
from camelot.admin.action import ApplicationActionGuiContext
from camelot.core.utils import ugettext as _
from camelot.view.model_thread import object_thread, post
from camelot.view.controls.action_widget import ( ActionLabel,
HOVER_ANIMATION_DISTANCE,
NOTIFICATION_ANIMATION_DISTANCE )
from camelot.view.art import Icon
[docs]class DesktopBackground(QtGui.QWidget):
"""
A custom background widget for the desktop. This widget is contained
by the first tab ('Start' tab) of the desktop workspace.
"""
def __init__( self, gui_context, parent ):
super(DesktopBackground, self).__init__( parent )
self.gui_context = gui_context
mainLayout = QtGui.QVBoxLayout()
actionButtonsLayout = QtGui.QGridLayout()
actionButtonsLayout.setObjectName('actionButtonsLayout')
actionButtonsLayout.setContentsMargins(200, 20, 200, 20)
actionButtonInfoWidget = ActionButtonInfoWidget()
actionButtonInfoWidget.setObjectName('actionButtonInfoWidget')
mainLayout.addWidget(actionButtonInfoWidget, 0, Qt.AlignCenter)
mainLayout.addLayout(actionButtonsLayout)
self.setLayout(mainLayout)
# Set a white background color
palette = self.palette()
self.setAutoFillBackground(True)
palette.setBrush(QtGui.QPalette.Window, Qt.white)
self.setPalette(palette)
# This method is invoked when the desktop workspace decides or gets told
# that the actions should be updated due to the presence of to be added
# actions.
@QtCore.pyqtSlot(object)
[docs] def set_actions(self, actions):
"""
:param actions: a list of EntityActions
"""
#
# Remove old actions
#
for actionButton in self.findChildren(ActionLabel):
actionButton.deleteLater()
# Make sure that the action buttons aren't visually split
# up in two rows when there are e.g. only 3 of them.
# So:
# <= 3 action buttons: 1 row and 1, 2 or 3 columns;
# >= 4 action buttons: 2 rows and 2, 3, 4 or 5 columns.
actionButtonsLayoutMaxItemsPerRowCount = max((len(actions) + 1) / 2, 3)
actionButtonsLayout = self.findChild(QtGui.QGridLayout, 'actionButtonsLayout')
if actionButtonsLayout is not None:
for position in xrange(0, min( len(actions), actionButtonsLayoutMaxItemsPerRowCount) ):
action = actions[position]
actionButton = action.render( self.gui_context, self )
actionButton.entered.connect(self.onActionButtonEntered)
actionButton.left.connect(self.onActionButtonLeft)
actionButton.setInteractive(True)
actionButtonsLayout.addWidget(ActionButtonContainer(actionButton), 0, position, Qt.AlignCenter)
for position in xrange(actionButtonsLayoutMaxItemsPerRowCount, len(actions)):
action = actions[position]
actionButton = action.render( self.gui_context, self )
actionButton.entered.connect(self.onActionButtonEntered)
actionButton.left.connect(self.onActionButtonLeft)
actionButton.setInteractive(True)
actionButtonsLayout.addWidget(ActionButtonContainer(actionButton), 1, position % actionButtonsLayoutMaxItemsPerRowCount, Qt.AlignCenter)
@QtCore.pyqtSlot()
def onActionButtonEntered(self):
actionButton = self.sender()
actionButtonInfoWidget = self.findChild(QtGui.QWidget, 'actionButtonInfoWidget')
if actionButtonInfoWidget is not None:
# @todo : get state should be called with a model context as first
# argument
post( actionButton.action.get_state,
actionButtonInfoWidget.setInfoFromState,
args = (None,) )
@QtCore.pyqtSlot()
def onActionButtonLeft(self):
actionButtonInfoWidget = self.findChild(QtGui.QWidget, 'actionButtonInfoWidget')
if actionButtonInfoWidget is not None:
actionButtonInfoWidget.resetInfo()
# This custom event handler makes sure that the action buttons aren't
# drawn in the wrong position on this widget after the screen has been
# e.g. maximized or resized by using the window handles.
def resizeEvent(self, event):
for actionButton in self.findChildren(ActionLabel):
actionButton.resetLayout()
event.ignore()
# This slot is called after the navpane's animation has finished. During
# this sliding animation, all action buttons are linearly moved to the right,
# giving the user a small window in which he or she may cause visual problems
# by already hovering the action buttons. This switch assures that the user
# cannot perform mouse interaction with the action buttons until they're
# static.
@QtCore.pyqtSlot()
def makeInteractive(self, interactive=True):
for actionButton in self.findChildren(ActionLabel):
actionButton.setInteractive(interactive)
def refresh(self):
pass
class ActionButtonContainer(QtGui.QWidget):
def __init__(self, actionButton, parent = None):
super(ActionButtonContainer, self).__init__(parent)
mainLayout = QtGui.QHBoxLayout()
# Set some margins to avoid the ActionButton being visually clipped
# when performing the hoverAnimation.
mainLayout.setContentsMargins(2*NOTIFICATION_ANIMATION_DISTANCE,
2*HOVER_ANIMATION_DISTANCE,
2*NOTIFICATION_ANIMATION_DISTANCE,
2*HOVER_ANIMATION_DISTANCE)
mainLayout.addWidget(actionButton)
self.setLayout(mainLayout)
def mousePressEvent(self, event):
# Send this event to the ActionButton that is contained by this widget.
self.layout().itemAt(0).widget().onContainerMousePressEvent(event)
class ActionButtonInfoWidget(QtGui.QWidget):
def __init__(self, parent = None):
super(ActionButtonInfoWidget, self).__init__(parent)
mainLayout = QtGui.QHBoxLayout()
font = self.font()
font.setPointSize(14)
actionNameLabel = QtGui.QLabel()
actionNameLabel.setFont(font)
actionNameLabel.setFixedSize(250, 50)
actionNameLabel.setAlignment(Qt.AlignCenter)
actionNameLabel.setObjectName('actionNameLabel')
actionDescriptionLabel = QtGui.QLabel()
actionDescriptionLabel.setFixedSize(250, 200)
actionDescriptionLabel.setObjectName('actionDescriptionLabel')
mainLayout.addWidget(actionNameLabel, 0, Qt.AlignVCenter)
mainLayout.addWidget(actionDescriptionLabel)
self.setLayout(mainLayout)
@QtCore.pyqtSlot( object )
def setInfoFromState(self, state):
actionNameLabel = self.findChild(QtGui.QLabel, 'actionNameLabel')
if actionNameLabel is not None:
actionNameLabel.setText( unicode( state.verbose_name ) )
actionDescriptionLabel = self.findChild(QtGui.QLabel, 'actionDescriptionLabel')
if actionDescriptionLabel is not None:
tooltip = unicode( state.tooltip or '' )
actionDescriptionLabel.setText(tooltip)
if tooltip:
# Do not use show() or hide() in this case, since it will
# cause the actionButtons to be drawn on the wrong position.
# Instead, just set the width of the widget to either 0 or 250.
actionDescriptionLabel.setFixedWidth(250)
else:
actionDescriptionLabel.setFixedWidth(0)
def resetInfo(self):
actionNameLabel = self.findChild(QtGui.QLabel, 'actionNameLabel')
if actionNameLabel is not None:
actionNameLabel.setText('')
actionDescriptionLabel = self.findChild(QtGui.QLabel, 'actionDescriptionLabel')
if actionDescriptionLabel is not None:
actionDescriptionLabel.setText('')
class DesktopTabbar(QtGui.QTabBar):
change_view_mode_signal = QtCore.pyqtSignal()
def mouseDoubleClickEvent(self, event):
self.change_view_mode_signal.emit()
event.accept()
def tabSizeHint(self, index):
originalSizeHint = super(DesktopTabbar, self).tabSizeHint(index)
minimumWidth = max(160, originalSizeHint.width())
return QtCore.QSize(minimumWidth, originalSizeHint.height())
[docs]class DesktopWorkspace(QtGui.QWidget):
"""
A tab based workspace that can be used by views to display themselves.
In essence this is a wrapper around QTabWidget to do some initial setup
and provide it with a background widget.
This was originallly implemented using the QMdiArea, but the QMdiArea has
too many drawbacks, like not being able to add close buttons to the tabs
in a decent way.
.. attribute:: background
The widget class to be used as the view for the uncloseable 'Start' tab.
"""
view_activated_signal = QtCore.pyqtSignal(QtGui.QWidget)
change_view_mode_signal = QtCore.pyqtSignal()
last_view_closed_signal = QtCore.pyqtSignal()
def __init__(self, app_admin, parent):
super(DesktopWorkspace, self).__init__(parent)
self.gui_context = ApplicationActionGuiContext()
self.gui_context.admin = app_admin
self.gui_context.workspace = self
self._app_admin = app_admin
layout = QtGui.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Setup the tab widget
self._tab_widget = QtGui.QTabWidget( self )
tab_bar = DesktopTabbar(self._tab_widget)
tab_bar.setToolTip(_('Double click to (un)maximize'))
tab_bar.change_view_mode_signal.connect(self._change_view_mode)
self._tab_widget.setTabBar(tab_bar)
self._tab_widget.setDocumentMode(True)
self._tab_widget.setTabsClosable(True)
self._tab_widget.tabCloseRequested.connect(self._tab_close_request)
self._tab_widget.currentChanged.connect(self._tab_changed)
layout.addWidget(self._tab_widget)
# Setup the background widget
self._background_widget = DesktopBackground( self.gui_context, self )
self._app_admin.actions_changed_signal.connect(self.reload_background_widget)
self._tab_widget.addTab(self._background_widget,
Icon('tango/16x16/actions/go-home.png').getQIcon(),
_('Home'))
if tab_bar.tabButton(0, QtGui.QTabBar.RightSide):
tab_bar.tabButton(0, QtGui.QTabBar.RightSide).hide()
elif tab_bar.tabButton(0, QtGui.QTabBar.LeftSide):
# mac for example has the close button on the left side by default
tab_bar.tabButton(0, QtGui.QTabBar.LeftSide).hide()
self.setLayout(layout)
self.reload_background_widget()
@QtCore.pyqtSlot()
def reload_background_widget(self):
post(self._app_admin.get_actions, self._background_widget.set_actions)
@QtCore.pyqtSlot()
def _change_view_mode(self):
self.change_view_mode_signal.emit()
@QtCore.pyqtSlot(int)
def _tab_close_request(self, index):
"""
Handle the request for the removal of a tab at index.
Note that only at-runtime added tabs are being closed, implying
the immortality of the 'Start' tab.
"""
if index > 0:
view = self._tab_widget.widget(index)
if view:
# it's not enough to simply remove the tab, because this
# would keep the underlying view widget alive
view.deleteLater()
self._tab_widget.removeTab(index)
@QtCore.pyqtSlot(int)
def _tab_changed(self, _index):
"""
The active tab has changed, emit the view_activated signal.
"""
self.view_activated_signal.emit(self.active_view())
[docs] def active_view(self):
"""
:return: The currently active view or None in case of the 'Start' tab.
"""
i = self._tab_widget.currentIndex()
if i == 0: # 'Start' tab
return None
return self._tab_widget.widget(i)
@QtCore.pyqtSlot(QtCore.QString)
[docs] def change_title(self, new_title):
"""
Slot to be called when the tile of a view needs to change.
Note: the title of the 'Start' tab cannot be overwritten.
"""
# the request of the sender does not work in older pyqt versions
# therefore, take the current index, notice this is not correct !!
#
# sender = self.sender()
sender = self.active_view()
if sender:
index = self._tab_widget.indexOf(sender)
if index > 0:
self._tab_widget.setTabText(index, new_title)
@QtCore.pyqtSlot(QtGui.QIcon)
[docs] def change_icon(self, new_icon):
"""
Slot to be called when the icon of a view needs to change.
Note: the icon of the 'Start' tab cannot be overwritten.
"""
# the request of the sender does not work in older pyqt versions
# therefore, take the current index, notice this is not correct !!
#
# sender = self.sender()
sender = self.active_view()
if sender:
index = self._tab_widget.indexOf(sender)
if index > 0:
self._tab_widget.setTabIcon(index, new_icon)
[docs] def set_view(self, view, icon = None, title = '...'):
"""
Remove the currently active view and replace it with a new view.
"""
index = self._tab_widget.currentIndex()
if index == 0: # 'Start' tab is currently visible.
self.add_view(view, icon, title)
else:
self._tab_close_request(index)
view.title_changed_signal.connect(self.change_title)
view.icon_changed_signal.connect(self.change_icon)
if icon:
index = self._tab_widget.insertTab(index, view, icon, title)
else:
index = self._tab_widget.insertTab(index, view, title)
self._tab_widget.setCurrentIndex(index)
[docs] def add_view(self, view, icon = None, title = '...'):
"""
Add a Widget implementing AbstractView to the workspace.
"""
assert object_thread( self )
view.title_changed_signal.connect(self.change_title)
view.icon_changed_signal.connect(self.change_icon)
if icon:
index = self._tab_widget.addTab(view, icon, title)
else:
index = self._tab_widget.addTab(view, title)
self._tab_widget.setCurrentIndex(index)
[docs] def refresh(self):
"""Refresh all views on the desktop"""
for i in range( self._tab_widget.count() ):
self._tab_widget.widget(i).refresh()
[docs] def close_all_views(self):
"""
Remove all views, except the 'Start' tab, from the workspace.
"""
# NOTE: will call removeTab until tab widget is cleared
# but removeTab does not really delete the page objects
#self._tab_widget.clear()
max_index = self._tab_widget.count()
while max_index > 0:
self._tab_widget.tabCloseRequested.emit(max_index)
max_index -= 1
top_level_windows = []
[docs]def show_top_level(view, parent):
"""Show a widget as a top level window
:param view: the widget extend AbstractView
:param parent: the widget with regard to which the top level
window will be placed.
"""
from camelot.view.register import register
#
# Register the view with reference to itself. This will keep
# the Python object alive as long as the Qt object is not
# destroyed. Hence Python will not trigger the deletion of the
# view as long as the window is not closed
#
register( view, view )
#
# set the parent to None to avoid the window being destructed
# once the parent gets destructed
#
view.setParent( None )
view.setWindowFlags(QtCore.Qt.Window)
#
# Make the window title blank to prevent the something
# like main.py or pythonw being displayed
#
view.setWindowTitle( u' ' )
view.title_changed_signal.connect( view.setWindowTitle )
view.icon_changed_signal.connect( view.setWindowIcon )
view.setAttribute(QtCore.Qt.WA_DeleteOnClose)
#
# position the new window in the center of the same screen
# as the parent
#
screen = QtGui.QApplication.desktop().screenNumber(parent)
available = QtGui.QApplication.desktop().availableGeometry(screen)
point = QtCore.QPoint(available.x() + available.width()/2,
available.y() + available.height()/2)
point = QtCore.QPoint(point.x()-view.width()/2,
point.y()-view.height()/2)
view.move( point )
#view.setWindowModality(QtCore.Qt.WindowModal)
view.show()