mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into enhancement/OP-3095_TrayPublisher-Simple-families-from-settings
This commit is contained in:
commit
0cc02f3abe
11 changed files with 505 additions and 39 deletions
|
|
@ -79,6 +79,7 @@ IMAGE_PREFIXES = {
|
|||
"redshift": "defaultRenderGlobals.imageFilePrefix",
|
||||
}
|
||||
|
||||
RENDERMAN_IMAGE_DIR = "maya/<scene>/<layer>"
|
||||
|
||||
@attr.s
|
||||
class LayerMetadata(object):
|
||||
|
|
@ -1054,6 +1055,8 @@ class RenderProductsRenderman(ARenderProducts):
|
|||
:func:`ARenderProducts.get_render_products()`
|
||||
|
||||
"""
|
||||
from rfm2.api.displays import get_displays # noqa
|
||||
|
||||
cameras = [
|
||||
self.sanitize_camera_name(c)
|
||||
for c in self.get_renderable_cameras()
|
||||
|
|
@ -1066,42 +1069,56 @@ class RenderProductsRenderman(ARenderProducts):
|
|||
]
|
||||
products = []
|
||||
|
||||
default_ext = "exr"
|
||||
displays = cmds.listConnections("rmanGlobals.displays")
|
||||
for aov in displays:
|
||||
enabled = self._get_attr(aov, "enabled")
|
||||
# NOTE: This is guessing extensions from renderman display types.
|
||||
# Some of them are just framebuffers, d_texture format can be
|
||||
# set in display setting. We set those now to None, but it
|
||||
# should be handled more gracefully.
|
||||
display_types = {
|
||||
"d_deepexr": "exr",
|
||||
"d_it": None,
|
||||
"d_null": None,
|
||||
"d_openexr": "exr",
|
||||
"d_png": "png",
|
||||
"d_pointcloud": "ptc",
|
||||
"d_targa": "tga",
|
||||
"d_texture": None,
|
||||
"d_tiff": "tif"
|
||||
}
|
||||
|
||||
displays = get_displays()["displays"]
|
||||
for name, display in displays.items():
|
||||
enabled = display["params"]["enable"]["value"]
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
aov_name = str(aov)
|
||||
aov_name = name
|
||||
if aov_name == "rmanDefaultDisplay":
|
||||
aov_name = "beauty"
|
||||
|
||||
extensions = display_types.get(
|
||||
display["driverNode"]["type"], "exr")
|
||||
|
||||
for camera in cameras:
|
||||
product = RenderProduct(productName=aov_name,
|
||||
ext=default_ext,
|
||||
ext=extensions,
|
||||
camera=camera)
|
||||
products.append(product)
|
||||
|
||||
return products
|
||||
|
||||
def get_files(self, product, camera):
|
||||
def get_files(self, product):
|
||||
"""Get expected files.
|
||||
|
||||
In renderman we hack it with prepending path. This path would
|
||||
normally be translated from `rmanGlobals.imageOutputDir`. We skip
|
||||
this and hardcode prepend path we expect. There is no place for user
|
||||
to mess around with this settings anyway and it is enforced in
|
||||
render settings validator.
|
||||
"""
|
||||
files = super(RenderProductsRenderman, self).get_files(product, camera)
|
||||
files = super(RenderProductsRenderman, self).get_files(product)
|
||||
|
||||
layer_data = self.layer_data
|
||||
new_files = []
|
||||
|
||||
resolved_image_dir = re.sub("<scene>", layer_data.sceneName, RENDERMAN_IMAGE_DIR, flags=re.IGNORECASE) # noqa: E501
|
||||
resolved_image_dir = re.sub("<layer>", layer_data.layerName, resolved_image_dir, flags=re.IGNORECASE) # noqa: E501
|
||||
for file in files:
|
||||
new_file = "{}/{}/{}".format(
|
||||
layer_data["sceneName"], layer_data["layerName"], file
|
||||
)
|
||||
new_file = "{}/{}".format(resolved_image_dir, file)
|
||||
new_files.append(new_file)
|
||||
|
||||
return new_files
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class CreateRender(plugin.Creator):
|
|||
'mentalray': 'defaultRenderGlobals.imageFilePrefix',
|
||||
'vray': 'vraySettings.fileNamePrefix',
|
||||
'arnold': 'defaultRenderGlobals.imageFilePrefix',
|
||||
'renderman': 'defaultRenderGlobals.imageFilePrefix',
|
||||
'renderman': 'rmanGlobals.imageFileFormat',
|
||||
'redshift': 'defaultRenderGlobals.imageFilePrefix'
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +84,9 @@ class CreateRender(plugin.Creator):
|
|||
'mentalray': 'maya/<Scene>/<RenderLayer>/<RenderLayer>{aov_separator}<RenderPass>', # noqa
|
||||
'vray': 'maya/<scene>/<Layer>/<Layer>',
|
||||
'arnold': 'maya/<Scene>/<RenderLayer>/<RenderLayer>{aov_separator}<RenderPass>', # noqa
|
||||
'renderman': 'maya/<Scene>/<layer>/<layer>{aov_separator}<aov>',
|
||||
# this needs `imageOutputDir`
|
||||
# (<ws>/renders/maya/<scene>) set separately
|
||||
'renderman': '<layer>_<aov>.<f4>.<ext>',
|
||||
'redshift': 'maya/<Scene>/<RenderLayer>/<RenderLayer>' # noqa
|
||||
}
|
||||
|
||||
|
|
@ -440,6 +442,10 @@ class CreateRender(plugin.Creator):
|
|||
|
||||
self._set_global_output_settings()
|
||||
|
||||
if renderer == "renderman":
|
||||
cmds.setAttr("rmanGlobals.imageOutputDir",
|
||||
"maya/<scene>/<layer>", type="string")
|
||||
|
||||
def _set_vray_settings(self, asset):
|
||||
# type: (dict) -> None
|
||||
"""Sets important settings for Vray."""
|
||||
|
|
|
|||
|
|
@ -194,13 +194,11 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
|
|||
assert render_products, "no render products generated"
|
||||
exp_files = []
|
||||
multipart = False
|
||||
render_cameras = []
|
||||
for product in render_products:
|
||||
if product.multipart:
|
||||
multipart = True
|
||||
product_name = product.productName
|
||||
if product.camera and layer_render_products.has_camera_token():
|
||||
render_cameras.append(product.camera)
|
||||
product_name = "{}{}".format(
|
||||
product.camera,
|
||||
"_" + product_name if product_name else "")
|
||||
|
|
@ -210,7 +208,8 @@ class CollectMayaRender(pyblish.api.ContextPlugin):
|
|||
product)
|
||||
})
|
||||
|
||||
assert render_cameras, "No render cameras found."
|
||||
has_cameras = any(product.camera for product in render_products)
|
||||
assert has_cameras, "No render cameras found."
|
||||
|
||||
self.log.info("multipart: {}".format(
|
||||
multipart))
|
||||
|
|
|
|||
|
|
@ -69,14 +69,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin):
|
|||
|
||||
redshift_AOV_prefix = "<BeautyPath>/<BeautyFile>{aov_separator}<RenderPass>" # noqa: E501
|
||||
|
||||
# WARNING: There is bug? in renderman, translating <scene> token
|
||||
# to something left behind mayas default image prefix. So instead
|
||||
# `SceneName_v01` it translates to:
|
||||
# `SceneName_v01/<RenderLayer>/<RenderLayers_<RenderPass>` that means
|
||||
# for example:
|
||||
# `SceneName_v01/Main/Main_<RenderPass>`. Possible solution is to define
|
||||
# custom token like <scene_name> to point to determined scene name.
|
||||
RendermanDirPrefix = "<ws>/renders/maya/<scene>/<layer>"
|
||||
renderman_dir_prefix = "maya/<scene>/<layer>"
|
||||
|
||||
R_AOV_TOKEN = re.compile(
|
||||
r'%a|<aov>|<renderpass>', re.IGNORECASE)
|
||||
|
|
@ -116,15 +109,22 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin):
|
|||
|
||||
prefix = prefix.replace(
|
||||
"{aov_separator}", instance.data.get("aovSeparator", "_"))
|
||||
|
||||
required_prefix = "maya/<scene>"
|
||||
|
||||
if not anim_override:
|
||||
invalid = True
|
||||
cls.log.error("Animation needs to be enabled. Use the same "
|
||||
"frame for start and end to render single frame")
|
||||
|
||||
if not prefix.lower().startswith("maya/<scene>"):
|
||||
if renderer != "renderman" and not prefix.lower().startswith(
|
||||
required_prefix):
|
||||
invalid = True
|
||||
cls.log.error("Wrong image prefix [ {} ] - "
|
||||
"doesn't start with: 'maya/<scene>'".format(prefix))
|
||||
cls.log.error(
|
||||
("Wrong image prefix [ {} ] "
|
||||
" - doesn't start with: '{}'").format(
|
||||
prefix, required_prefix)
|
||||
)
|
||||
|
||||
if not re.search(cls.R_LAYER_TOKEN, prefix):
|
||||
invalid = True
|
||||
|
|
@ -198,7 +198,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin):
|
|||
invalid = True
|
||||
cls.log.error("Wrong image prefix [ {} ]".format(file_prefix))
|
||||
|
||||
if dir_prefix.lower() != cls.RendermanDirPrefix.lower():
|
||||
if dir_prefix.lower() != cls.renderman_dir_prefix.lower():
|
||||
invalid = True
|
||||
cls.log.error("Wrong directory prefix [ {} ]".format(
|
||||
dir_prefix))
|
||||
|
|
@ -304,7 +304,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin):
|
|||
default_prefix,
|
||||
type="string")
|
||||
cmds.setAttr("rmanGlobals.imageOutputDir",
|
||||
cls.RendermanDirPrefix,
|
||||
cls.renderman_dir_prefix,
|
||||
type="string")
|
||||
|
||||
if renderer == "vray":
|
||||
|
|
|
|||
|
|
@ -188,6 +188,10 @@ def get_renderer_variables(renderlayer, root):
|
|||
filename_0 = re.sub('_<RenderPass>', '_beauty',
|
||||
filename_0, flags=re.IGNORECASE)
|
||||
prefix_attr = "defaultRenderGlobals.imageFilePrefix"
|
||||
|
||||
scene = cmds.file(query=True, sceneName=True)
|
||||
scene, _ = os.path.splitext(os.path.basename(scene))
|
||||
|
||||
if renderer == "vray":
|
||||
renderlayer = renderlayer.split("_")[-1]
|
||||
# Maya's renderSettings function does not return V-Ray file extension
|
||||
|
|
@ -207,8 +211,7 @@ def get_renderer_variables(renderlayer, root):
|
|||
filename_prefix = cmds.getAttr(prefix_attr)
|
||||
# we need to determine path for vray as maya `renderSettings` query
|
||||
# does not work for vray.
|
||||
scene = cmds.file(query=True, sceneName=True)
|
||||
scene, _ = os.path.splitext(os.path.basename(scene))
|
||||
|
||||
filename_0 = re.sub('<Scene>', scene, filename_prefix, flags=re.IGNORECASE) # noqa: E501
|
||||
filename_0 = re.sub('<Layer>', renderlayer, filename_0, flags=re.IGNORECASE) # noqa: E501
|
||||
filename_0 = "{}.{}.{}".format(
|
||||
|
|
@ -216,6 +219,39 @@ def get_renderer_variables(renderlayer, root):
|
|||
filename_0 = os.path.normpath(os.path.join(root, filename_0))
|
||||
elif renderer == "renderman":
|
||||
prefix_attr = "rmanGlobals.imageFileFormat"
|
||||
# NOTE: This is guessing extensions from renderman display types.
|
||||
# Some of them are just framebuffers, d_texture format can be
|
||||
# set in display setting. We set those now to None, but it
|
||||
# should be handled more gracefully.
|
||||
display_types = {
|
||||
"d_deepexr": "exr",
|
||||
"d_it": None,
|
||||
"d_null": None,
|
||||
"d_openexr": "exr",
|
||||
"d_png": "png",
|
||||
"d_pointcloud": "ptc",
|
||||
"d_targa": "tga",
|
||||
"d_texture": None,
|
||||
"d_tiff": "tif"
|
||||
}
|
||||
|
||||
extension = display_types.get(
|
||||
cmds.listConnections("rmanDefaultDisplay.displayType")[0],
|
||||
"exr"
|
||||
) or "exr"
|
||||
|
||||
filename_prefix = "{}/{}".format(
|
||||
cmds.getAttr("rmanGlobals.imageOutputDir"),
|
||||
cmds.getAttr("rmanGlobals.imageFileFormat")
|
||||
)
|
||||
|
||||
renderlayer = renderlayer.split("_")[-1]
|
||||
|
||||
filename_0 = re.sub('<scene>', scene, filename_prefix, flags=re.IGNORECASE) # noqa: E501
|
||||
filename_0 = re.sub('<layer>', renderlayer, filename_0, flags=re.IGNORECASE) # noqa: E501
|
||||
filename_0 = re.sub('<f[\\d+]>', "#" * int(padding), filename_0, flags=re.IGNORECASE) # noqa: E501
|
||||
filename_0 = re.sub('<ext>', extension, filename_0, flags=re.IGNORECASE) # noqa: E501
|
||||
filename_0 = os.path.normpath(os.path.join(root, filename_0))
|
||||
elif renderer == "redshift":
|
||||
# mapping redshift extension dropdown values to strings
|
||||
ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"]
|
||||
|
|
@ -404,6 +440,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
|
|||
|
||||
output_filename_0 = filename_0
|
||||
|
||||
dirname = os.path.dirname(output_filename_0)
|
||||
|
||||
# Create render folder ----------------------------------------------
|
||||
try:
|
||||
# Ensure render folder exists
|
||||
|
|
@ -799,6 +837,23 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
|
|||
"AssetDependency0": data["filepath"],
|
||||
}
|
||||
|
||||
renderer = self._instance.data["renderer"]
|
||||
|
||||
# This hack is here because of how Deadline handles Renderman version.
|
||||
# it considers everything with `renderman` set as version older than
|
||||
# Renderman 22, and so if we are using renderman > 21 we need to set
|
||||
# renderer string on the job to `renderman22`. We will have to change
|
||||
# this when Deadline releases new version handling this.
|
||||
if self._instance.data["renderer"] == "renderman":
|
||||
try:
|
||||
from rfm2.config import cfg # noqa
|
||||
except ImportError:
|
||||
raise Exception("Cannot determine renderman version")
|
||||
|
||||
rman_version = cfg().build_info.version() # type: str
|
||||
if int(rman_version.split(".")[0]) > 22:
|
||||
renderer = "renderman22"
|
||||
|
||||
plugin_info = {
|
||||
"SceneFile": data["filepath"],
|
||||
# Output directory and filename
|
||||
|
|
@ -812,7 +867,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
|
|||
"RenderLayer": data["renderlayer"],
|
||||
|
||||
# Determine which renderer to use from the file itself
|
||||
"Renderer": self._instance.data["renderer"],
|
||||
"Renderer": renderer,
|
||||
|
||||
# Resolve relative references
|
||||
"ProjectPath": data["workspace"],
|
||||
|
|
|
|||
|
|
@ -52,10 +52,39 @@
|
|||
"environment": {},
|
||||
"variants": {}
|
||||
},
|
||||
"renderman": {
|
||||
"environment": {},
|
||||
"variants": {
|
||||
"24-3-maya": {
|
||||
"host_names": [
|
||||
"maya"
|
||||
],
|
||||
"app_variants": [
|
||||
"maya/2022"
|
||||
],
|
||||
"environment": {
|
||||
"RFMTREE": {
|
||||
"windows": "C:\\Program Files\\Pixar\\RenderManForMaya-24.3",
|
||||
"darwin": "/Applications/Pixar/RenderManForMaya-24.3",
|
||||
"linux": "/opt/pixar/RenderManForMaya-24.3"
|
||||
},
|
||||
"RMANTREE": {
|
||||
"windows": "C:\\Program Files\\Pixar\\RenderManProServer-24.3",
|
||||
"darwin": "/Applications/Pixar/RenderManProServer-24.3",
|
||||
"linux": "/opt/pixar/RenderManProServer-24.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"__dynamic_keys_labels__": {
|
||||
"24-3-maya": "24.3 RFM"
|
||||
}
|
||||
}
|
||||
},
|
||||
"__dynamic_keys_labels__": {
|
||||
"mtoa": "Autodesk Arnold",
|
||||
"vray": "Chaos Group Vray",
|
||||
"yeti": "Pergrine Labs Yeti"
|
||||
"yeti": "Peregrine Labs Yeti",
|
||||
"renderman": "Pixar Renderman"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,11 @@
|
|||
"icon-entity-default": "#bfccd6",
|
||||
"icon-entity-disabled": "#808080",
|
||||
"font-entity-deprecated": "#666666",
|
||||
|
||||
"overlay-messages": {
|
||||
"close-btn": "#D3D8DE",
|
||||
"bg-success": "#458056",
|
||||
"bg-success-hover": "#55a066"
|
||||
},
|
||||
"tab-widget": {
|
||||
"bg": "#21252B",
|
||||
"bg-selected": "#434a56",
|
||||
|
|
|
|||
|
|
@ -687,6 +687,26 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
|
|||
background: none;
|
||||
}
|
||||
|
||||
/* Messages overlay */
|
||||
#OverlayMessageWidget {
|
||||
border-radius: 0.2em;
|
||||
background: {color:bg-buttons};
|
||||
}
|
||||
|
||||
#OverlayMessageWidget:hover {
|
||||
background: {color:bg-button-hover};
|
||||
}
|
||||
#OverlayMessageWidget {
|
||||
background: {color:overlay-messages:bg-success};
|
||||
}
|
||||
#OverlayMessageWidget:hover {
|
||||
background: {color:overlay-messages:bg-success-hover};
|
||||
}
|
||||
|
||||
#OverlayMessageWidget QWidget {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Password dialog*/
|
||||
#PasswordBtn {
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from openpype.settings.lib import (
|
|||
save_local_settings
|
||||
)
|
||||
from openpype.tools.settings import CHILD_OFFSET
|
||||
from openpype.tools.utils import MessageOverlayObject
|
||||
from openpype.api import (
|
||||
Logger,
|
||||
SystemSettings,
|
||||
|
|
@ -221,6 +222,8 @@ class LocalSettingsWindow(QtWidgets.QWidget):
|
|||
|
||||
self.setWindowTitle("OpenPype Local settings")
|
||||
|
||||
overlay_object = MessageOverlayObject(self)
|
||||
|
||||
stylesheet = style.load_stylesheet()
|
||||
self.setStyleSheet(stylesheet)
|
||||
self.setWindowIcon(QtGui.QIcon(style.app_icon_path()))
|
||||
|
|
@ -247,6 +250,7 @@ class LocalSettingsWindow(QtWidgets.QWidget):
|
|||
save_btn.clicked.connect(self._on_save_clicked)
|
||||
reset_btn.clicked.connect(self._on_reset_clicked)
|
||||
|
||||
self._overlay_object = overlay_object
|
||||
# Do not create local settings widget in init phase as it's using
|
||||
# settings objects that must be OK to be able create this widget
|
||||
# - we want to show dialog if anything goes wrong
|
||||
|
|
@ -312,8 +316,10 @@ class LocalSettingsWindow(QtWidgets.QWidget):
|
|||
|
||||
def _on_reset_clicked(self):
|
||||
self.reset()
|
||||
self._overlay_object.add_message("Refreshed...")
|
||||
|
||||
def _on_save_clicked(self):
|
||||
value = self._settings_widget.settings_value()
|
||||
save_local_settings(value)
|
||||
self._overlay_object.add_message("Saved...", message_type="success")
|
||||
self.reset()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ from .lib import (
|
|||
from .models import (
|
||||
RecursiveSortFilterProxyModel,
|
||||
)
|
||||
from .overlay_messages import (
|
||||
MessageOverlayObject,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PlaceholderLineEdit",
|
||||
|
|
@ -45,4 +49,6 @@ __all__ = (
|
|||
"get_asset_icon",
|
||||
|
||||
"RecursiveSortFilterProxyModel",
|
||||
|
||||
"MessageOverlayObject",
|
||||
)
|
||||
|
|
|
|||
324
openpype/tools/utils/overlay_messages.py
Normal file
324
openpype/tools/utils/overlay_messages.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import uuid
|
||||
|
||||
from Qt import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.style import get_objected_colors
|
||||
|
||||
from .lib import set_style_property
|
||||
|
||||
|
||||
class CloseButton(QtWidgets.QFrame):
|
||||
"""Close button drawed manually."""
|
||||
|
||||
clicked = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent):
|
||||
super(CloseButton, self).__init__(parent)
|
||||
colors = get_objected_colors()
|
||||
close_btn_color = colors["overlay-messages"]["close-btn"]
|
||||
self._color = close_btn_color.get_qcolor()
|
||||
self._mouse_pressed = False
|
||||
policy = QtWidgets.QSizePolicy(
|
||||
QtWidgets.QSizePolicy.Fixed,
|
||||
QtWidgets.QSizePolicy.Fixed
|
||||
)
|
||||
self.setSizePolicy(policy)
|
||||
|
||||
def sizeHint(self):
|
||||
size = self.fontMetrics().height()
|
||||
return QtCore.QSize(size, size)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
self._mouse_pressed = True
|
||||
super(CloseButton, self).mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._mouse_pressed:
|
||||
self._mouse_pressed = False
|
||||
if self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
|
||||
super(CloseButton, self).mouseReleaseEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
rect = self.rect()
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setClipRect(event.rect())
|
||||
pen = QtGui.QPen()
|
||||
pen.setWidth(2)
|
||||
pen.setColor(self._color)
|
||||
pen.setStyle(QtCore.Qt.SolidLine)
|
||||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||||
painter.setPen(pen)
|
||||
offset = int(rect.height() / 4)
|
||||
top = rect.top() + offset
|
||||
left = rect.left() + offset
|
||||
right = rect.right() - offset
|
||||
bottom = rect.bottom() - offset
|
||||
painter.drawLine(
|
||||
left, top,
|
||||
right, bottom
|
||||
)
|
||||
painter.drawLine(
|
||||
left, bottom,
|
||||
right, top
|
||||
)
|
||||
|
||||
|
||||
class OverlayMessageWidget(QtWidgets.QFrame):
|
||||
"""Message widget showed as overlay.
|
||||
|
||||
Message is hidden after timeout but can be overriden by mouse hover.
|
||||
Mouse hover can add additional 2 seconds of widget's visibility.
|
||||
|
||||
Args:
|
||||
message_id (str): Unique identifier of message widget for
|
||||
'MessageOverlayObject'.
|
||||
message (str): Text shown in message.
|
||||
parent (QWidget): Parent widget where message is visible.
|
||||
timeout (int): Timeout of message's visibility (default 5000).
|
||||
message_type (str): Property which can be used in styles for specific
|
||||
kid of message.
|
||||
"""
|
||||
|
||||
close_requested = QtCore.Signal(str)
|
||||
_default_timeout = 5000
|
||||
|
||||
def __init__(
|
||||
self, message_id, message, parent, message_type=None, timeout=None
|
||||
):
|
||||
super(OverlayMessageWidget, self).__init__(parent)
|
||||
self.setObjectName("OverlayMessageWidget")
|
||||
|
||||
if message_type:
|
||||
set_style_property(self, "type", message_type)
|
||||
|
||||
if not timeout:
|
||||
timeout = self._default_timeout
|
||||
timeout_timer = QtCore.QTimer()
|
||||
timeout_timer.setInterval(timeout)
|
||||
timeout_timer.setSingleShot(True)
|
||||
|
||||
hover_timer = QtCore.QTimer()
|
||||
hover_timer.setInterval(2000)
|
||||
hover_timer.setSingleShot(True)
|
||||
|
||||
label_widget = QtWidgets.QLabel(message, self)
|
||||
label_widget.setAlignment(QtCore.Qt.AlignCenter)
|
||||
label_widget.setWordWrap(True)
|
||||
close_btn = CloseButton(self)
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 0, 5)
|
||||
layout.addWidget(label_widget, 1)
|
||||
layout.addWidget(close_btn, 0)
|
||||
|
||||
close_btn.clicked.connect(self._on_close_clicked)
|
||||
timeout_timer.timeout.connect(self._on_timer_timeout)
|
||||
hover_timer.timeout.connect(self._on_hover_timeout)
|
||||
|
||||
self._label_widget = label_widget
|
||||
self._message_id = message_id
|
||||
self._timeout_timer = timeout_timer
|
||||
self._hover_timer = hover_timer
|
||||
|
||||
def size_hint_without_word_wrap(self):
|
||||
"""Size hint in cases that word wrap of label is disabled."""
|
||||
self._label_widget.setWordWrap(False)
|
||||
size_hint = self.sizeHint()
|
||||
self._label_widget.setWordWrap(True)
|
||||
return size_hint
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Start timeout on show."""
|
||||
super(OverlayMessageWidget, self).showEvent(event)
|
||||
self._timeout_timer.start()
|
||||
|
||||
def _on_timer_timeout(self):
|
||||
"""On message timeout."""
|
||||
# Skip closing if hover timer is active
|
||||
if not self._hover_timer.isActive():
|
||||
self._close_message()
|
||||
|
||||
def _on_hover_timeout(self):
|
||||
"""Hover timer timed out."""
|
||||
# Check if is still under widget
|
||||
if self.underMouse():
|
||||
self._hover_timer.start()
|
||||
else:
|
||||
self._close_message()
|
||||
|
||||
def _on_close_clicked(self):
|
||||
self._close_message()
|
||||
|
||||
def _close_message(self):
|
||||
"""Emmit close request to 'MessageOverlayObject'."""
|
||||
self.close_requested.emit(self._message_id)
|
||||
|
||||
def enterEvent(self, event):
|
||||
"""Start hover timer on hover."""
|
||||
super(OverlayMessageWidget, self).enterEvent(event)
|
||||
self._hover_timer.start()
|
||||
|
||||
def leaveEvent(self, event):
|
||||
"""Start hover timer on hover leave."""
|
||||
super(OverlayMessageWidget, self).leaveEvent(event)
|
||||
self._hover_timer.start()
|
||||
|
||||
|
||||
class MessageOverlayObject(QtCore.QObject):
|
||||
"""Object that can be used to add overlay messages.
|
||||
|
||||
Args:
|
||||
widget (QWidget):
|
||||
"""
|
||||
|
||||
def __init__(self, widget, default_timeout=None):
|
||||
super(MessageOverlayObject, self).__init__()
|
||||
|
||||
widget.installEventFilter(self)
|
||||
|
||||
# Timer which triggers recalculation of message positions
|
||||
recalculate_timer = QtCore.QTimer()
|
||||
recalculate_timer.setInterval(10)
|
||||
|
||||
recalculate_timer.timeout.connect(self._recalculate_positions)
|
||||
|
||||
self._widget = widget
|
||||
self._recalculate_timer = recalculate_timer
|
||||
|
||||
self._messages_order = []
|
||||
self._closing_messages = set()
|
||||
self._messages = {}
|
||||
self._spacing = 5
|
||||
self._move_size = 4
|
||||
self._move_size_remove = 8
|
||||
self._default_timeout = default_timeout
|
||||
|
||||
def add_message(self, message, message_type=None, timeout=None):
|
||||
"""Add single message into overlay.
|
||||
|
||||
Args:
|
||||
message (str): Message that will be shown.
|
||||
timeout (int): Message timeout.
|
||||
message_type (str): Message type can be used as property in
|
||||
stylesheets.
|
||||
"""
|
||||
# Skip empty messages
|
||||
if not message:
|
||||
return
|
||||
|
||||
if timeout is None:
|
||||
timeout = self._default_timeout
|
||||
|
||||
# Create unique id of message
|
||||
label_id = str(uuid.uuid4())
|
||||
# Create message widget
|
||||
widget = OverlayMessageWidget(
|
||||
label_id, message, self._widget, message_type, timeout
|
||||
)
|
||||
widget.close_requested.connect(self._on_message_close_request)
|
||||
widget.show()
|
||||
|
||||
# Move widget outside of window
|
||||
pos = widget.pos()
|
||||
pos.setY(pos.y() - widget.height())
|
||||
widget.move(pos)
|
||||
# Store message
|
||||
self._messages[label_id] = widget
|
||||
self._messages_order.append(label_id)
|
||||
# Trigger recalculation timer
|
||||
self._recalculate_timer.start()
|
||||
|
||||
def _on_message_close_request(self, label_id):
|
||||
"""Message widget requested removement."""
|
||||
|
||||
widget = self._messages.get(label_id)
|
||||
if widget is not None:
|
||||
# Add message to closing messages and start recalculation
|
||||
self._closing_messages.add(label_id)
|
||||
self._recalculate_timer.start()
|
||||
|
||||
def _recalculate_positions(self):
|
||||
"""Recalculate positions of widgets."""
|
||||
|
||||
# Skip if there are no messages to process
|
||||
if not self._messages_order:
|
||||
self._recalculate_timer.stop()
|
||||
return
|
||||
|
||||
# All message widgets are in expected positions
|
||||
all_at_place = True
|
||||
# Starting y position
|
||||
pos_y = self._spacing
|
||||
# Current widget width
|
||||
widget_width = self._widget.width()
|
||||
max_width = widget_width - (2 * self._spacing)
|
||||
widget_half_width = widget_width / 2
|
||||
|
||||
# Store message ids that should be removed
|
||||
message_ids_to_remove = set()
|
||||
for message_id in reversed(self._messages_order):
|
||||
widget = self._messages[message_id]
|
||||
pos = widget.pos()
|
||||
# Messages to remove are moved upwards
|
||||
if message_id in self._closing_messages:
|
||||
bottom = pos.y() + widget.height()
|
||||
# Add message to remove if is not visible
|
||||
if bottom < 0 or self._move_size_remove < 1:
|
||||
message_ids_to_remove.add(message_id)
|
||||
continue
|
||||
|
||||
# Calculate new y position of message
|
||||
dst_pos_y = pos.y() - self._move_size_remove
|
||||
|
||||
else:
|
||||
# Calculate y position of message
|
||||
# - use y position of previous message widget and add
|
||||
# move size if is not in final destination yet
|
||||
if widget.underMouse():
|
||||
dst_pos_y = pos.y()
|
||||
elif pos.y() == pos_y or self._move_size < 1:
|
||||
dst_pos_y = pos_y
|
||||
elif pos.y() < pos_y:
|
||||
dst_pos_y = min(pos_y, pos.y() + self._move_size)
|
||||
else:
|
||||
dst_pos_y = max(pos_y, pos.y() - self._move_size)
|
||||
|
||||
# Store if widget is in place where should be
|
||||
if all_at_place and dst_pos_y != pos_y:
|
||||
all_at_place = False
|
||||
|
||||
# Calculate ideal width and height of message widget
|
||||
height = widget.heightForWidth(max_width)
|
||||
w_size_hint = widget.size_hint_without_word_wrap()
|
||||
widget.resize(min(max_width, w_size_hint.width()), height)
|
||||
|
||||
# Center message widget
|
||||
size = widget.size()
|
||||
pos_x = widget_half_width - (size.width() / 2)
|
||||
# Move widget to destination position
|
||||
widget.move(pos_x, dst_pos_y)
|
||||
|
||||
# Add message widget height and spacing for next message widget
|
||||
pos_y += size.height() + self._spacing
|
||||
|
||||
# Remove widgets to remove
|
||||
for message_id in message_ids_to_remove:
|
||||
self._messages_order.remove(message_id)
|
||||
self._closing_messages.remove(message_id)
|
||||
widget = self._messages.pop(message_id)
|
||||
widget.hide()
|
||||
widget.deleteLater()
|
||||
|
||||
# Stop recalculation timer if all widgets are where should be
|
||||
if all_at_place:
|
||||
self._recalculate_timer.stop()
|
||||
|
||||
def eventFilter(self, source, event):
|
||||
# Trigger recalculation of timer on resize of widget
|
||||
if source is self._widget and event.type() == QtCore.QEvent.Resize:
|
||||
self._recalculate_timer.start()
|
||||
|
||||
return super(MessageOverlayObject, self).eventFilter(source, event)
|
||||
Loading…
Add table
Add a link
Reference in a new issue