ayon-core/openpype/tools/settings/settings/base.py
2022-12-07 15:36:43 +01:00

777 lines
25 KiB
Python

import os
import sys
import json
import traceback
import functools
import datetime
from qtpy import QtWidgets, QtGui, QtCore
from openpype.settings.entities import ProjectSettings
from openpype.tools.settings import CHILD_OFFSET
from .widgets import ExpandingWidget
from .lib import create_deffered_value_change_timer
from .constants import (
DEFAULT_PROJECT_LABEL,
SETTINGS_PATH_KEY,
ROOT_KEY,
VALUE_KEY,
SAVE_TIME_KEY,
PROJECT_NAME_KEY,
)
_MENU_SEPARATOR_REQ = object()
class ExtractHelper:
_last_save_dir = os.path.expanduser("~")
@classmethod
def get_last_save_dir(cls):
return cls._last_save_dir
@classmethod
def set_last_save_dir(cls, save_dir):
cls._last_save_dir = save_dir
@classmethod
def ask_for_save_filepath(cls, parent):
dialog = QtWidgets.QFileDialog(
parent,
"Save settings values",
cls.get_last_save_dir(),
"Values (*.json)"
)
# dialog.setOption(dialog.DontUseNativeDialog)
dialog.setAcceptMode(dialog.AcceptSave)
if dialog.exec() != dialog.Accepted:
return
selected_urls = dialog.selectedUrls()
if not selected_urls:
return
filepath = selected_urls[0].toLocalFile()
if not filepath:
return
if not filepath.lower().endswith(".json"):
filepath += ".json"
return filepath
@classmethod
def extract_settings_to_json(cls, filepath, settings_data, project_name):
now = datetime.datetime.now()
settings_data[SAVE_TIME_KEY] = now.strftime("%Y-%m-%d %H:%M:%S")
if project_name != 0:
settings_data[PROJECT_NAME_KEY] = project_name
with open(filepath, "w") as stream:
json.dump(settings_data, stream, indent=4)
new_dir = os.path.dirname(filepath)
cls.set_last_save_dir(new_dir)
class BaseWidget(QtWidgets.QWidget):
allow_actions = True
def __init__(self, category_widget, entity, entity_widget):
self.category_widget = category_widget
self.entity = entity
self.entity_widget = entity_widget
self.ignore_input_changes = entity_widget.ignore_input_changes
self._is_invalid = False
self._style_state = None
super(BaseWidget, self).__init__(entity_widget.content_widget)
if not self.entity.gui_type:
self.entity.on_change_callbacks.append(self._on_entity_change)
if self.entity.tooltip:
self.setToolTip(self.entity.tooltip)
self.label_widget = None
self.create_ui()
@staticmethod
def set_style_property(obj, property_name, property_value):
"""Change QWidget property and polish it's style."""
if obj.property(property_name) == property_value:
return
obj.setProperty(property_name, property_value)
obj.style().polish(obj)
def scroll_to(self, widget):
self.category_widget.scroll_to(widget)
def set_path(self, path):
self.category_widget.set_path(path)
def set_focus(self, scroll_to=False):
"""Set focus of a widget.
Args:
scroll_to(bool): Also scroll to widget in category widget.
"""
if scroll_to:
self.scroll_to(self)
self.setFocus()
def make_sure_is_visible(self, path, scroll_to):
"""Make a widget of entity visible by it's path.
Args:
path(str): Path to entity.
scroll_to(bool): Should be scrolled to entity.
Returns:
bool: Entity with path was found.
"""
raise NotImplementedError(
"{} not implemented `make_sure_is_visible`".format(
self.__class__.__name__
)
)
def trigger_hierarchical_style_update(self):
self.category_widget.hierarchical_style_update()
def create_ui_for_entity(self, *args, **kwargs):
return self.category_widget.create_ui_for_entity(*args, **kwargs)
@property
def is_invalid(self):
return self._is_invalid
@staticmethod
def get_style_state(
is_invalid, is_modified, has_project_override, has_studio_override
):
"""Return stylesheet state by intered booleans."""
if is_invalid:
return "invalid"
if is_modified:
return "modified"
if has_project_override:
return "overridden"
if has_studio_override:
return "studio"
return ""
def hierarchical_style_update(self):
raise NotImplementedError(
"{} not implemented `hierarchical_style_update`".format(
self.__class__.__name__
)
)
def get_invalid(self):
raise NotImplementedError(
"{} not implemented `get_invalid`".format(
self.__class__.__name__
)
)
def _on_entity_change(self):
"""Not yet used."""
print("{}: Warning missing `_on_entity_change` implementation".format(
self.__class__.__name__
))
def _discard_changes_action(self, menu, actions_mapping):
# TODO use better condition as unsaved changes may be caused due to
# changes in schema.
if not self.entity.can_trigger_discard_changes:
return
def discard_changes():
with self.category_widget.working_state_context():
self.ignore_input_changes.set_ignore(True)
self.entity.discard_changes()
self.ignore_input_changes.set_ignore(False)
action = QtWidgets.QAction("Discard changes")
actions_mapping[action] = discard_changes
menu.addAction(action)
def _add_to_studio_default(self, menu, actions_mapping):
"""Set values as studio overrides."""
# Skip if not in studio overrides
if not self.entity.can_trigger_add_to_studio_default:
return
def add_to_studio_default():
with self.category_widget.working_state_context():
self.entity.add_to_studio_default()
action = QtWidgets.QAction("Add to studio default")
actions_mapping[action] = add_to_studio_default
menu.addAction(action)
def _remove_from_studio_default_action(self, menu, actions_mapping):
if not self.entity.can_trigger_remove_from_studio_default:
return
def remove_from_studio_default():
with self.category_widget.working_state_context():
self.ignore_input_changes.set_ignore(True)
self.entity.remove_from_studio_default()
self.ignore_input_changes.set_ignore(False)
action = QtWidgets.QAction("Remove from studio default")
actions_mapping[action] = remove_from_studio_default
menu.addAction(action)
def _add_to_project_override_action(self, menu, actions_mapping):
if not self.entity.can_trigger_add_to_project_override:
return
def add_to_project_override():
with self.category_widget.working_state_context():
self.entity.add_to_project_override
action = QtWidgets.QAction("Add to project override")
actions_mapping[action] = add_to_project_override
menu.addAction(action)
def _remove_from_project_override_action(self, menu, actions_mapping):
if not self.entity.can_trigger_remove_from_project_override:
return
def remove_from_project_override():
with self.category_widget.working_state_context():
self.ignore_input_changes.set_ignore(True)
self.entity.remove_from_project_override()
self.ignore_input_changes.set_ignore(False)
action = QtWidgets.QAction("Remove from project override")
actions_mapping[action] = remove_from_project_override
menu.addAction(action)
def _get_mime_data_from_entity(self):
if self.entity.is_dynamic_item or self.entity.is_in_dynamic_item:
entity_path = None
else:
entity_path = "/".join(
[self.entity.root_key, self.entity.path]
)
value = self.entity.value
# Copy for settings tool
return {
VALUE_KEY: value,
ROOT_KEY: self.entity.root_key,
SETTINGS_PATH_KEY: entity_path
}
def _copy_value_actions(self, menu):
def copy_value():
mime_data = QtCore.QMimeData()
# Copy for settings tool
settings_data = self._get_mime_data_from_entity()
settings_encoded_data = QtCore.QByteArray()
settings_stream = QtCore.QDataStream(
settings_encoded_data, QtCore.QIODevice.WriteOnly
)
settings_stream.writeQString(json.dumps(settings_data))
mime_data.setData(
"application/copy_settings_value", settings_encoded_data
)
# Copy as json
value = settings_data[VALUE_KEY]
json_encoded_data = None
if isinstance(value, (dict, list)):
json_encoded_data = QtCore.QByteArray()
json_stream = QtCore.QDataStream(
json_encoded_data, QtCore.QIODevice.WriteOnly
)
json_stream.writeQString(json.dumps(value))
mime_data.setData("application/json", json_encoded_data)
# Copy as text
if json_encoded_data is None:
# Store value as string
mime_data.setText(str(value))
else:
# Store data as json string
mime_data.setText(json.dumps(value, indent=4))
QtWidgets.QApplication.clipboard().setMimeData(mime_data)
action = QtWidgets.QAction("Copy", menu)
return [(action, copy_value)]
def _extract_to_file(self):
filepath = ExtractHelper.ask_for_save_filepath(self)
if not filepath:
return
settings_data = self._get_mime_data_from_entity()
project_name = 0
if hasattr(self.category_widget, "project_name"):
project_name = self.category_widget.project_name
ExtractHelper.extract_settings_to_json(
filepath, settings_data, project_name
)
def _extract_value_to_file_actions(self, menu):
extract_action = QtWidgets.QAction("Extract to file", menu)
return [
_MENU_SEPARATOR_REQ,
(extract_action, self._extract_to_file)
]
def _parse_source_data_for_paste(self, data):
settings_path = None
root_key = None
if isinstance(data, dict):
data.pop(SAVE_TIME_KEY, None)
data.pop(PROJECT_NAME_KEY, None)
settings_path = data.pop(SETTINGS_PATH_KEY, settings_path)
root_key = data.pop(ROOT_KEY, root_key)
data = data.pop(VALUE_KEY, data)
return {
VALUE_KEY: data,
SETTINGS_PATH_KEY: settings_path,
ROOT_KEY: root_key
}
def _get_value_from_clipboard(self):
clipboard = QtWidgets.QApplication.clipboard()
mime_data = clipboard.mimeData()
app_value = mime_data.data("application/copy_settings_value")
if app_value:
settings_stream = QtCore.QDataStream(
app_value, QtCore.QIODevice.ReadOnly
)
mime_data_value_str = settings_stream.readQString()
return json.loads(mime_data_value_str)
if mime_data.hasUrls():
for url in mime_data.urls():
local_file = url.toLocalFile()
try:
with open(local_file, "r") as stream:
value = json.load(stream)
except Exception:
continue
if value:
return self._parse_source_data_for_paste(value)
if mime_data.hasText():
text = mime_data.text()
try:
value = json.loads(text)
except Exception:
try:
value = self.entity.convert_to_valid_type(text)
except Exception:
return None
return self._parse_source_data_for_paste(value)
def _paste_value_actions(self, menu):
output = []
# Allow paste of value only if were copied from this UI
mime_data_value = self._get_value_from_clipboard()
# Skip if there is nothing to do
if not mime_data_value:
return output
value = mime_data_value[VALUE_KEY]
path = mime_data_value[SETTINGS_PATH_KEY]
root_key = mime_data_value[ROOT_KEY]
# Try to find matching entity to be able paste values to same spot
# - entity can't by dynamic or in dynamic item
# - must be in same root entity as source copy
# Can't copy system settings <-> project settings
matching_entity = None
if path and root_key == self.entity.root_key:
try:
matching_entity = self.entity.get_entity_from_path(path)
except Exception:
pass
def _set_entity_value(_entity, _value):
try:
_entity.set(_value)
except Exception:
dialog = QtWidgets.QMessageBox(self)
dialog.setWindowTitle("Value does not match settings schema")
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setText((
"Pasted value does not seem to match schema of destination"
" settings entity."
))
dialog.exec_()
# Simple paste value method
def paste_value():
with self.category_widget.working_state_context():
_set_entity_value(self.entity, value)
action = QtWidgets.QAction("Paste", menu)
output.append((action, paste_value))
# Paste value to matching entity
def paste_value_to_path():
with self.category_widget.working_state_context():
_set_entity_value(matching_entity, value)
if matching_entity is not None:
action = QtWidgets.QAction("Paste to same place", menu)
output.append((action, paste_value_to_path))
return output
def _apply_values_from_project_action(self, menu, actions_mapping):
for attr_name in ("project_name", "get_project_names"):
if not hasattr(self.category_widget, attr_name):
return
if self.entity.is_dynamic_item or self.entity.is_in_dynamic_item:
return
current_project_name = self.category_widget.project_name
project_names = []
for project_name in self.category_widget.get_project_names():
if project_name != current_project_name:
project_names.append(project_name)
if not project_names:
return
submenu = QtWidgets.QMenu("Apply values from", menu)
for project_name in project_names:
if project_name is None:
project_name = DEFAULT_PROJECT_LABEL
action = QtWidgets.QAction(project_name)
submenu.addAction(action)
actions_mapping[action] = functools.partial(
self._apply_values_from_project,
project_name
)
menu.addMenu(submenu)
def _apply_values_from_project(self, project_name):
with self.category_widget.working_state_context():
try:
path_keys = [
item
for item in self.entity.path.split("/")
if item
]
entity = ProjectSettings(project_name)
for key in path_keys:
entity = entity[key]
self.entity.set(entity.value)
except Exception:
if project_name is None:
project_name = DEFAULT_PROJECT_LABEL
# TODO better message
title = "Applying values failed"
msg = "Applying values from project \"{}\" failed.".format(
project_name
)
detail_msg = "".join(
traceback.format_exception(*sys.exc_info())
)
dialog = QtWidgets.QMessageBox(self)
dialog.setWindowTitle(title)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setText(msg)
dialog.setDetailedText(detail_msg)
dialog.exec_()
def show_actions_menu(self, event=None):
if event and event.button() != QtCore.Qt.RightButton:
return
if not self.allow_actions:
if event:
return self.mouseReleaseEvent(event)
return
menu = QtWidgets.QMenu(self)
actions_mapping = {}
self._discard_changes_action(menu, actions_mapping)
self._add_to_studio_default(menu, actions_mapping)
self._remove_from_studio_default_action(menu, actions_mapping)
self._add_to_project_override_action(menu, actions_mapping)
self._remove_from_project_override_action(menu, actions_mapping)
self._apply_values_from_project_action(menu, actions_mapping)
ui_actions = []
ui_actions.extend(self._copy_value_actions(menu))
ui_actions.extend(self._paste_value_actions(menu))
if ui_actions:
ui_actions.insert(0, _MENU_SEPARATOR_REQ)
ui_actions.extend(self._extract_value_to_file_actions(menu))
for item in ui_actions:
if item is _MENU_SEPARATOR_REQ:
if len(menu.actions()) > 0:
menu.addSeparator()
continue
action, callback = item
menu.addAction(action)
actions_mapping[action] = callback
if not actions_mapping:
action = QtWidgets.QAction("< No action >")
actions_mapping[action] = None
menu.addAction(action)
result = menu.exec_(QtGui.QCursor.pos())
if result:
to_run = actions_mapping[result]
if to_run:
to_run()
def focused_in(self):
if self.entity is not None:
self.set_path(self.entity.path)
def mouseReleaseEvent(self, event):
if self.allow_actions and event.button() == QtCore.Qt.RightButton:
return self.show_actions_menu()
focused_in = False
if event.button() == QtCore.Qt.LeftButton:
focused_in = True
self.focused_in()
result = super(BaseWidget, self).mouseReleaseEvent(event)
if focused_in and not event.isAccepted():
event.accept()
return result
class InputWidget(BaseWidget):
def __init__(self, *args, **kwargs):
super(InputWidget, self).__init__(*args, **kwargs)
# Input widgets have always timer available (but may not be used).
self._value_change_timer = create_deffered_value_change_timer(
self._on_value_change_timer
)
def start_value_timer(self):
self._value_change_timer.start()
def _on_value_change_timer(self):
pass
def create_ui(self):
if self.entity.use_label_wrap:
label = None
self._create_label_wrap_ui()
else:
label = self.entity.label
self.label_widget = None
self.body_widget = None
self.content_widget = self
self.content_layout = self._create_layout(self)
self._add_inputs_to_layout()
self.entity_widget.add_widget_to_layout(self, label)
def _create_label_wrap_ui(self):
content_widget = QtWidgets.QWidget(self)
content_widget.setObjectName("ContentWidget")
content_widget.setProperty("content_state", "")
content_layout_margins = (CHILD_OFFSET, 5, 0, 0)
body_widget = ExpandingWidget(self.entity.label, self)
label_widget = body_widget.label_widget
body_widget.set_content_widget(content_widget)
content_layout = self._create_layout(content_widget)
content_layout.setContentsMargins(*content_layout_margins)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(body_widget)
self.label_widget = label_widget
self.body_widget = body_widget
self.content_widget = content_widget
self.content_layout = content_layout
def _create_layout(self, parent_widget):
layout = QtWidgets.QHBoxLayout(parent_widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
return layout
def _add_inputs_to_layout(self):
raise NotImplementedError(
"Method `_add_inputs_to_layout` not implemented {}".format(
self.__class__.__name__
)
)
def make_sure_is_visible(self, path, scroll_to):
if path:
entity_path = self.entity.path
if entity_path == path:
self.set_focus(scroll_to)
return True
return False
def update_style(self):
has_unsaved_changes = self.entity.has_unsaved_changes
if not has_unsaved_changes and self.entity.group_item:
has_unsaved_changes = self.entity.group_item.has_unsaved_changes
style_state = self.get_style_state(
self.is_invalid,
has_unsaved_changes,
self.entity.has_project_override,
self.entity.has_studio_override
)
if self._style_state == style_state:
return
self._style_state = style_state
self.input_field.setProperty("input-state", style_state)
self.input_field.style().polish(self.input_field)
if self.label_widget:
self.label_widget.setProperty("state", style_state)
self.label_widget.style().polish(self.label_widget)
if self.body_widget:
if style_state:
child_style_state = "child-{}".format(style_state)
else:
child_style_state = ""
self.body_widget.side_line_widget.setProperty(
"state", child_style_state
)
self.body_widget.side_line_widget.style().polish(
self.body_widget.side_line_widget
)
@property
def child_invalid(self):
return self.is_invalid
def hierarchical_style_update(self):
self.update_style()
def get_invalid(self):
invalid = []
if self.is_invalid:
invalid.append(self)
return invalid
class GUIWidget(BaseWidget):
allow_actions = False
separator_height = 2
child_invalid = False
def create_ui(self):
entity_type = self.entity["type"]
if entity_type == "label":
self._create_label_ui()
elif entity_type in ("separator", "splitter"):
self._create_separator_ui()
else:
raise KeyError("Unknown GUI type {}".format(entity_type))
self.entity_widget.add_widget_to_layout(self)
def _create_label_ui(self):
label = self.entity["label"]
word_wrap = self.entity.schema_data.get("word_wrap", False)
label_widget = QtWidgets.QLabel(label, self)
label_widget.setWordWrap(word_wrap)
label_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
label_widget.setObjectName("SettingsLabel")
label_widget.linkActivated.connect(self._on_link_activate)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 5, 0, 5)
layout.addWidget(label_widget)
def _create_separator_ui(self):
splitter_item = QtWidgets.QWidget(self)
splitter_item.setObjectName("Separator")
splitter_item.setMinimumHeight(self.separator_height)
splitter_item.setMaximumHeight(self.separator_height)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
layout.addWidget(splitter_item)
def _on_link_activate(self, url):
if not url.startswith("settings://"):
QtGui.QDesktopServices.openUrl(url)
return
path = url.replace("settings://", "")
self.category_widget.go_to_fullpath(path)
def set_entity_value(self):
pass
def hierarchical_style_update(self):
pass
def make_sure_is_visible(self, *args, **kwargs):
return False
def focused_in(self):
pass
def set_path(self, *args, **kwargs):
pass
def get_invalid(self):
return []
class MockUpWidget(BaseWidget):
allow_actions = False
child_invalid = False
def create_ui(self):
label = "Mockup widget for entity {}".format(self.entity.path)
label_widget = QtWidgets.QLabel(label, self)
label_widget.setObjectName("SettingsLabel")
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 5, 0, 5)
layout.addWidget(label_widget)
self.entity_widget.add_widget_to_layout(self)
def set_entity_value(self):
return
def hierarchical_style_update(self):
pass
def get_invalid(self):
return []