From 2da97501cf316f72db4c833dcacb29492e399a11 Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 17:27:52 +0800 Subject: [PATCH 01/19] Implement optional action --- avalon/tools/widgets.py | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index 081957a80..fae99795e 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -322,3 +322,127 @@ def _list_project_silos(): log.warning("Project '%s' has no active silos", project["name"]) return list(sorted(silos)) + + +class OptionalAction(QtWidgets.QWidgetAction): + """Menu action with option box + + A menu action like Maya's menu item with option box, implemented by + subclassing `QtWidgets.QWidgetAction`. + + """ + + def __init__(self, label, parent): + super(OptionalAction, self).__init__(parent) + self.widget = OptionalActionWidget(label, parent) + + def setIcon(self, icon): + self.widget.setIcon(icon) + + def createWidget(self, parent): + return self.widget + + +class OptionalActionWidget(QtWidgets.QWidget): + """Main widget class for `OptionalAction`""" + + def __init__(self, label, parent=None): + super(OptionalActionWidget, self).__init__(parent) + + body = QtWidgets.QWidget() + body.setStyleSheet("background: transparent;") + + icon = QtWidgets.QLabel() + label = QtWidgets.QLabel(label) + option = OptionBox(body) + + icon.setFixedSize(40, 16) + option.setFixedSize(30, 30) + + layout = QtWidgets.QHBoxLayout(body) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(icon) + layout.addWidget(label) + layout.addSpacing(6) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 1, 2, 1) + layout.setSpacing(0) + layout.addWidget(body) + layout.addWidget(option) + + self.setFixedHeight(32) + self.setMouseTracking(True) + + self.icon = icon + self.label = label + self.option = option + self.body = body + + option.clicked.connect(self.on_option) + + def setIcon(self, icon): + pixmap = icon.pixmap(16, 16) + self.icon.setPixmap(pixmap) + + def enterEvent(self, event): + self.body.setBackgroundRole(QtGui.QPalette.Highlight) + self.body.setAutoFillBackground(True) + + def leaveEvent(self, event): + self.body.setBackgroundRole(QtGui.QPalette.Window) + self.body.setAutoFillBackground(False) + + def on_option(self): + dialog = OptionDialog(self) + dialog.show() + + +class OptionBox(QtWidgets.QWidget): + """Option box widget class for `OptionalActionWidget`""" + + clicked = QtCore.Signal() + + def __init__(self, parent): + super(OptionBox, self).__init__(parent) + + label = QtWidgets.QLabel() + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addSpacing(8) + layout.addWidget(label) + + icon = qtawesome.icon("fa.sticky-note-o", color="#c6c6c6") + pixmap = icon.pixmap(18, 18) + label.setPixmap(pixmap) + + self.setStyleSheet("background: transparent;") + self._parent = parent + self._hovered = False + + def mouseReleaseEvent(self, event): + if self._hovered: + self.clicked.emit() + + def enterEvent(self, event): + parent = self._parent + parent.setBackgroundRole(QtGui.QPalette.Highlight) + parent.setAutoFillBackground(True) + self.setBackgroundRole(QtGui.QPalette.Highlight) + self.setAutoFillBackground(True) + self._hovered = True + + def leaveEvent(self, event): + self.setBackgroundRole(QtGui.QPalette.Window) + self.setAutoFillBackground(False) + self._hovered = False + + +class OptionDialog(QtWidgets.QDialog): + """Option dialog shown by option box""" + + def __init__(self, parent=None): + super(OptionDialog, self).__init__(parent) + self.setModal(True) From 03a2e3f03ef5aa5c10731bea316338333c2ea61c Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 20:37:55 +0800 Subject: [PATCH 02/19] Fix for `menu.exec_` to return action --- avalon/tools/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index fae99795e..c43f3dae2 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -425,6 +425,7 @@ def __init__(self, parent): def mouseReleaseEvent(self, event): if self._hovered: self.clicked.emit() + super(OptionBox, self).mouseReleaseEvent(event) def enterEvent(self, event): parent = self._parent From dc4f597086ef927ffd5eadd64631e1aaeaa50dc2 Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 20:54:29 +0800 Subject: [PATCH 03/19] Implement option dialog pop-up --- avalon/tools/loader/widgets.py | 14 +++++++++++++- avalon/tools/widgets.py | 11 +++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index 72cdd7436..41da19cfe 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -10,6 +10,7 @@ from .. import lib as tools_lib from ..delegates import VersionDelegate +from ..widgets import OptionalAction, OptionDialog from .model import ( SubsetsModel, @@ -172,7 +173,12 @@ def sorter(value): # Add the representation as suffix label = "{0} ({1})".format(label, representation["name"]) - action = QtWidgets.QAction(label, menu) + # Load options + if hasattr(loader, "options"): + action = OptionalAction(label, menu) + else: + action = QtWidgets.QAction(label, menu) + action.setData((representation, loader)) # Add tooltip and statustip from Loader docstring @@ -201,6 +207,12 @@ def sorter(value): if not action: return + # Pop option dialog + if getattr(action, "use_option", False): + dialog = OptionDialog(self) + if not dialog.exec_(): + return + # Find the representation name and loader to trigger action_representation, loader = action.data() representation_name = action_representation["name"] # extension diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index c43f3dae2..cb1cc9069 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -335,6 +335,8 @@ class OptionalAction(QtWidgets.QWidgetAction): def __init__(self, label, parent): super(OptionalAction, self).__init__(parent) self.widget = OptionalActionWidget(label, parent) + self.widget.option.clicked.connect(self.on_option) + self.use_option = False def setIcon(self, icon): self.widget.setIcon(icon) @@ -342,6 +344,9 @@ def setIcon(self, icon): def createWidget(self, parent): return self.widget + def on_option(self): + self.use_option = True + class OptionalActionWidget(QtWidgets.QWidget): """Main widget class for `OptionalAction`""" @@ -380,8 +385,6 @@ def __init__(self, label, parent=None): self.option = option self.body = body - option.clicked.connect(self.on_option) - def setIcon(self, icon): pixmap = icon.pixmap(16, 16) self.icon.setPixmap(pixmap) @@ -394,10 +397,6 @@ def leaveEvent(self, event): self.body.setBackgroundRole(QtGui.QPalette.Window) self.body.setAutoFillBackground(False) - def on_option(self): - dialog = OptionDialog(self) - dialog.show() - class OptionBox(QtWidgets.QWidget): """Option box widget class for `OptionalActionWidget`""" From 92ec7803446730c74cd1e819b40210dc9c9b4b5d Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 21:12:46 +0800 Subject: [PATCH 04/19] Show option box only when one item being selected --- avalon/tools/loader/widgets.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index 41da19cfe..619f3d67e 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -156,6 +156,22 @@ def on_context_menu(self, point): self.echo("No compatible loaders available for this version.") return + # Get selected rows + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + # Ensure active point index is also used as first column so we can + # correctly push it to the end in the rows list. + point_index = point_index.sibling(point_index.row(), 0) + # Ensure point index is run first. + try: + rows.remove(point_index) + except ValueError: + pass + rows.insert(0, point_index) + + # Enable optional action when only one item being selected + enable_option = len(rows) == 1 + def sorter(value): """Sort the Loaders by their order and then their name""" Plugin = value[1] @@ -174,7 +190,7 @@ def sorter(value): label = "{0} ({1})".format(label, representation["name"]) # Load options - if hasattr(loader, "options"): + if enable_option and hasattr(loader, "options"): action = OptionalAction(label, menu) else: action = QtWidgets.QAction(label, menu) @@ -219,19 +235,6 @@ def sorter(value): # Run the loader for all selected indices, for those that have the # same representation available - selection = self.view.selectionModel() - rows = selection.selectedRows(column=0) - - # Ensure active point index is also used as first column so we can - # correctly push it to the end in the rows list. - point_index = point_index.sibling(point_index.row(), 0) - - # Ensure point index is run first. - try: - rows.remove(point_index) - except ValueError: - pass - rows.insert(0, point_index) # Trigger for row in rows: From 36b56c4a70c1a1b773495ad13b7cbdc877dc649e Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 22:03:09 +0800 Subject: [PATCH 05/19] Get icon before create action --- avalon/tools/loader/widgets.py | 29 ++++++++++++++++------------- avalon/tools/widgets.py | 15 ++++++++------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index 619f3d67e..09e5a4a60 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -189,11 +189,26 @@ def sorter(value): # Add the representation as suffix label = "{0} ({1})".format(label, representation["name"]) + # Support font-awesome icons using the `.icon` and `.color` + # attributes on plug-ins. + icon = getattr(loader, "icon", None) + if icon is not None: + try: + key = "fa.{0}".format(icon) + color = getattr(loader, "color", "white") + icon = qtawesome.icon(key, color=color) + except Exception as e: + print("Unable to set icon for loader " + "{}: {}".format(loader, e)) + icon = None + # Load options if enable_option and hasattr(loader, "options"): - action = OptionalAction(label, menu) + action = OptionalAction(label, icon, menu) else: action = QtWidgets.QAction(label, menu) + if icon: + action.setIcon(icon) action.setData((representation, loader)) @@ -203,18 +218,6 @@ def sorter(value): action.setToolTip(tip) action.setStatusTip(tip) - # Support font-awesome icons using the `.icon` and `.color` - # attributes on plug-ins. - icon = getattr(loader, "icon", None) - if icon is not None: - try: - key = "fa.{0}".format(icon) - color = getattr(loader, "color", "white") - action.setIcon(qtawesome.icon(key, color=color)) - except Exception as e: - print("Unable to set icon for loader " - "{}: {}".format(loader, e)) - menu.addAction(action) # Show the context action menu diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index cb1cc9069..cd48f64fe 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -332,17 +332,18 @@ class OptionalAction(QtWidgets.QWidgetAction): """ - def __init__(self, label, parent): + def __init__(self, label, icon, parent): super(OptionalAction, self).__init__(parent) - self.widget = OptionalActionWidget(label, parent) - self.widget.option.clicked.connect(self.on_option) + self.label = label + self.icon = icon self.use_option = False - def setIcon(self, icon): - self.widget.setIcon(icon) - def createWidget(self, parent): - return self.widget + widget = OptionalActionWidget(self.label, parent) + widget.option.clicked.connect(self.on_option) + if self.icon: + widget.setIcon(self.icon) + return widget def on_option(self): self.use_option = True From 310b5c8c7540c1d57871060f27f987cc49139344 Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 22:08:03 +0800 Subject: [PATCH 06/19] Implement base option dialog --- avalon/tools/loader/widgets.py | 18 ++++++++++++++---- avalon/tools/widgets.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index 09e5a4a60..c5ce085d0 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -226,15 +226,22 @@ def sorter(value): if not action: return + # Find the representation name and loader to trigger + action_representation, loader = action.data() + representation_name = action_representation["name"] # extension + options = None + # Pop option dialog if getattr(action, "use_option", False): dialog = OptionDialog(self) + dialog.setWindowTitle(action.label + " Options") + dialog.create(loader.options) + if not dialog.exec_(): return - # Find the representation name and loader to trigger - action_representation, loader = action.data() - representation_name = action_representation["name"] # extension + # Get option + options = dialog.options() # Run the loader for all selected indices, for those that have the # same representation available @@ -257,7 +264,10 @@ def sorter(value): continue try: - api.load(Loader=loader, representation=representation) + api.load(Loader=loader, + representation=representation, + options=options) + except pipeline.IncompatibleLoaderError as exc: self.echo(exc) continue diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index cd48f64fe..d67b8e7a1 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -447,3 +447,24 @@ class OptionDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(OptionDialog, self).__init__(parent) self.setModal(True) + self.inputs = list() + + def create(self, options): + decision = QtWidgets.QWidget() + accept = QtWidgets.QPushButton("Accept") + cancel = QtWidgets.QPushButton("Cancel") + + layout = QtWidgets.QHBoxLayout(decision) + layout.addWidget(accept) + layout.addWidget(cancel) + + layout = QtWidgets.QVBoxLayout(self) + for widget in self.inputs: + layout.addWidget(widget) + layout.addWidget(decision) + + accept.clicked.connect(self.accept) + cancel.clicked.connect(self.reject) + + def options(self): + pass From 0871e065a1ff3a3b94156848c6c42a0ff2bbfd63 Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 3 Dec 2019 22:35:24 +0800 Subject: [PATCH 07/19] Unify styles The spacing between `OptionalAction` menu action icon and label may differ between hosts. Make them all the same. --- avalon/tools/loader/widgets.py | 12 ++++-------- avalon/tools/widgets.py | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index c5ce085d0..c566c3cc1 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -202,13 +202,9 @@ def sorter(value): "{}: {}".format(loader, e)) icon = None - # Load options - if enable_option and hasattr(loader, "options"): - action = OptionalAction(label, icon, menu) - else: - action = QtWidgets.QAction(label, menu) - if icon: - action.setIcon(icon) + # Optional action + use_option = enable_option and hasattr(loader, "options") + action = OptionalAction(label, icon, use_option, menu) action.setData((representation, loader)) @@ -232,7 +228,7 @@ def sorter(value): options = None # Pop option dialog - if getattr(action, "use_option", False): + if getattr(action, "optioned", False): dialog = OptionDialog(self) dialog.setWindowTitle(action.label + " Options") dialog.create(loader.options) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index d67b8e7a1..4d07e1978 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -332,21 +332,28 @@ class OptionalAction(QtWidgets.QWidgetAction): """ - def __init__(self, label, icon, parent): + def __init__(self, label, icon, use_option, parent): super(OptionalAction, self).__init__(parent) self.label = label self.icon = icon - self.use_option = False + self.use_option = use_option + self.optioned = False def createWidget(self, parent): widget = OptionalActionWidget(self.label, parent) - widget.option.clicked.connect(self.on_option) + if self.icon: widget.setIcon(self.icon) + + if self.use_option: + widget.option.clicked.connect(self.on_option) + else: + widget.option.setVisible(False) + return widget def on_option(self): - self.use_option = True + self.optioned = True class OptionalActionWidget(QtWidgets.QWidget): @@ -362,7 +369,7 @@ def __init__(self, label, parent=None): label = QtWidgets.QLabel(label) option = OptionBox(body) - icon.setFixedSize(40, 16) + icon.setFixedSize(24, 16) option.setFixedSize(30, 30) layout = QtWidgets.QHBoxLayout(body) From 346057ac89f1d178e9aa8e4c5aa1bae2d8efc1b7 Mon Sep 17 00:00:00 2001 From: David Lai Date: Wed, 4 Dec 2019 18:34:08 +0800 Subject: [PATCH 08/19] Implement base widget for options input --- avalon/tools/inputs.py | 35 +++++++++++++++++++++++++++++++++++ avalon/tools/widgets.py | 11 ++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 avalon/tools/inputs.py diff --git a/avalon/tools/inputs.py b/avalon/tools/inputs.py new file mode 100644 index 000000000..92eb8d951 --- /dev/null +++ b/avalon/tools/inputs.py @@ -0,0 +1,35 @@ + +import re +from ..vendor.Qt import QtWidgets + + +def nice_naming(key): + """Convert camelCase name into UI Display Name""" + words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) + return " ".join(words) + + +class InputBase(QtWidgets.QWidget): + """Base class of option box input widgets (value type oriented)""" + + def __init__(self, name, help=None, parent=None): + super(InputBase, self).__init__(parent=parent) + help = help or "" + + label = QtWidgets.QLabel(nice_naming(name)) + label.setToolTip(help) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label) + + self.layout = layout + self.key = name + self.tip = help + + def link(self, slot): + """Internal use. Connecting widget to option dialog""" + slot[self.key] = self + + def get(self): + """Method for returning value from input widget""" + raise NotImplementedError("Should be implemented in subclass.") diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index 4d07e1978..e1fff7f3a 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -454,7 +454,7 @@ class OptionDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(OptionDialog, self).__init__(parent) self.setModal(True) - self.inputs = list() + self._inputs = dict() def create(self, options): decision = QtWidgets.QWidget() @@ -466,7 +466,8 @@ def create(self, options): layout.addWidget(cancel) layout = QtWidgets.QVBoxLayout(self) - for widget in self.inputs: + for widget in options: + widget.link(self._inputs) layout.addWidget(widget) layout.addWidget(decision) @@ -474,4 +475,8 @@ def create(self, options): cancel.clicked.connect(self.reject) def options(self): - pass + options = dict() + for key, input in self._inputs.items(): + options[key] = input.get() + + return options From bade2733ba7de70b8df385ff1ce19c487650d0d0 Mon Sep 17 00:00:00 2001 From: David Lai Date: Wed, 4 Dec 2019 18:36:19 +0800 Subject: [PATCH 09/19] Assemble option box tool tips --- avalon/tools/loader/widgets.py | 4 ++++ avalon/tools/widgets.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index c566c3cc1..f4ff2fe47 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -206,6 +206,10 @@ def sorter(value): use_option = enable_option and hasattr(loader, "options") action = OptionalAction(label, icon, use_option, menu) + if use_option: + # Add option box tip + action.set_option_tip(loader.options) + action.setData((representation, loader)) # Add tooltip and statustip from Loader docstring diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index e1fff7f3a..482d283df 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -337,6 +337,7 @@ def __init__(self, label, icon, use_option, parent): self.label = label self.icon = icon self.use_option = use_option + self.option_tip = "" self.optioned = False def createWidget(self, parent): @@ -347,11 +348,17 @@ def createWidget(self, parent): if self.use_option: widget.option.clicked.connect(self.on_option) + widget.option.setToolTip(self.option_tip) else: widget.option.setVisible(False) return widget + def set_option_tip(self, options): + sep = "\n\n" + mak = (lambda opt: opt.key + " :\n " + opt.tip) + self.option_tip = sep.join(mak(opt) for opt in options) + def on_option(self): self.optioned = True From f15805faa1865747789d2eb173c541cd8b1bae89 Mon Sep 17 00:00:00 2001 From: David Lai Date: Wed, 4 Dec 2019 19:06:14 +0800 Subject: [PATCH 10/19] Implement input widgets for basic data types --- avalon/tools/inputs.py | 194 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 193 insertions(+), 1 deletion(-) diff --git a/avalon/tools/inputs.py b/avalon/tools/inputs.py index 92eb8d951..451356ec1 100644 --- a/avalon/tools/inputs.py +++ b/avalon/tools/inputs.py @@ -1,6 +1,6 @@ import re -from ..vendor.Qt import QtWidgets +from ..vendor.Qt import QtWidgets, QtGui def nice_naming(key): @@ -33,3 +33,195 @@ def link(self, slot): def get(self): """Method for returning value from input widget""" raise NotImplementedError("Should be implemented in subclass.") + + +class Bool(InputBase): + """Option box input widget for `bool` type value + + Args: + name (str): Value entry name, also taken as widget label + default (bool, optional): Default false + help (str, optional): Widget tool tip + + """ + + def __init__(self, name, default=False, help=None, parent=None): + QtWidgets.QWidget.__init__(self, parent=parent) + help = help or "" + + input = QtWidgets.QCheckBox(nice_naming(name)) + input.setToolTip(help) + if default: + input.setChecked() + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(input) + + self.layout = layout + self.key = name + self.tip = help + self.input = input + + def get(self): + return self.input.isChecked() + + +class Int(InputBase): + """Option box input widget for `int` type value + + Args: + name (str): Value entry name, also taken as widget label + default (int, optional): Default 0 + min (int, optional): Minimum input value, default 0 + max (int, optional): Maximum input value, default 99 + help (str, optional): Widget tool tip + + """ + + def __init__(self, name, default=0, min=0, max=99, help=None, parent=None): + InputBase.__init__(self, name, help, parent) + + input = QtWidgets.QSpinBox() + input.setValue(default) + input.setMinimum(min) + input.setMaximum(max) + + self.layout.addWidget(input) + self.input = input + + def get(self): + return self.input.value() + + +def line_edit_float(default=0.0): + """Return a QLineEdit widget for inputting float value + + Args: + default (float, optional): Default 0.0 + + """ + widget = QtWidgets.QLineEdit() + widget.setValidator(QtGui.QDoubleValidator()) + widget.setText(str(float(default))) + return widget + + +class Float(InputBase): + """Option box input widget for `float` type value + + Args: + name (str): Value entry name, also taken as widget label + default (float, optional): Default 0.0 + help (str, optional): Widget tool tip + + """ + + def __init__(self, name, default=0.0, help=None, parent=None): + InputBase.__init__(self, name, help, parent) + + input = line_edit_float(default) + + self.layout.addWidget(input) + self.input = input + + def get(self): + return float(self.input.text()) + + +class Double3(InputBase): + """Option box input widget for array of three doubles + + Args: + name (str): Value entry name, also taken as widget label + default (tuple or list, optional): Default (0.0, 0.0, 0.0) + help (str, optional): Widget tool tip + + """ + + def __init__(self, name, default=None, help=None, parent=None): + default = default or (0.0, 0.0, 0.0) + assert isinstance(default, (list, tuple)), "Should be list or tuple." + assert len(default) == 3, "Should have exact three elements." + + InputBase.__init__(self, name, help, parent) + + input = QtWidgets.QWidget() + + x, y, z = (line_edit_float(v) for v in default) + + layout = QtWidgets.QHBoxLayout(input) + layout.addWidget(x) + layout.addWidget(y) + layout.addWidget(z) + + self.layout.addWidget(input) + self.inputs = (x, y, z) + + def get(self): + return tuple(float(i.text()) for i in self.inputs) + + +class String(InputBase): + """Option box input widget for `str` type value + + Args: + name (str): Value entry name, also taken as widget label + default (str, optional): Default None + placeholder (str, optional): Widget placeholder text, default None + help (str, optional): Widget tool tip + + """ + + def __init__(self, + name, + default=None, + placeholder=None, + help=None, + parent=None): + InputBase.__init__(self, name, help, parent) + + input = QtWidgets.QLineEdit() + input.setPlaceholderText(placeholder or "") + input.setText(default or "") + + self.layout.addWidget(input) + self.input = input + + def get(self): + return self.input.text() + + +class GetOne(InputBase): + """Option box input widget for selecting one value from a list + + Args: + name (str): Value entry name, also taken as widget label + elements (list): A list of values to select from + default (int, optional): Index for default value from list, default 0 + as_string (bool, optional): Return as index or string, default true + help (str, optional): Widget tool tip + + """ + + def __init__(self, + name, + elements, + default=0, + as_string=True, + help=None, + parent=None): + InputBase.__init__(self, name, help, parent) + + input = QtWidgets.QComboBox() + input.addItems(elements) + input.setCurrentIndex(default) + + self.layout.addWidget(input) + self.input = input + self.as_string = as_string + + def get(self): + if self.as_string: + return self.input.currentText() + else: + return self.input.currentIndex() From 87c167ec945d516fc33b2b5377631bc2251bf32e Mon Sep 17 00:00:00 2001 From: David Lai Date: Wed, 4 Dec 2019 19:39:18 +0800 Subject: [PATCH 11/19] Prettify option dialog layout --- avalon/tools/inputs.py | 11 ++++++----- avalon/tools/widgets.py | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/avalon/tools/inputs.py b/avalon/tools/inputs.py index 451356ec1..f78455cc2 100644 --- a/avalon/tools/inputs.py +++ b/avalon/tools/inputs.py @@ -19,8 +19,9 @@ def __init__(self, name, help=None, parent=None): label = QtWidgets.QLabel(nice_naming(name)) label.setToolTip(help) - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(label) + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(label, stretch=False) + layout.addSpacing(4) self.layout = layout self.key = name @@ -54,7 +55,7 @@ def __init__(self, name, default=False, help=None, parent=None): if default: input.setChecked() - layout = QtWidgets.QVBoxLayout(self) + layout = QtWidgets.QHBoxLayout(self) layout.addWidget(input) self.layout = layout @@ -86,7 +87,7 @@ def __init__(self, name, default=0, min=0, max=99, help=None, parent=None): input.setMinimum(min) input.setMaximum(max) - self.layout.addWidget(input) + self.layout.addWidget(input, stretch=True) self.input = input def get(self): @@ -216,7 +217,7 @@ def __init__(self, input.addItems(elements) input.setCurrentIndex(default) - self.layout.addWidget(input) + self.layout.addWidget(input, stretch=True) self.input = input self.as_string = as_string diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index 482d283df..bc00cec26 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -476,6 +476,7 @@ def create(self, options): for widget in options: widget.link(self._inputs) layout.addWidget(widget) + layout.addSpacing(20) layout.addWidget(decision) accept.clicked.connect(self.accept) From 371632f787319aff227da0ee1df68ed81253fdf2 Mon Sep 17 00:00:00 2001 From: David Lai Date: Thu, 5 Dec 2019 00:49:34 +0800 Subject: [PATCH 12/19] Remove redundant code --- avalon/tools/widgets.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index bc00cec26..1468bb4ce 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -433,7 +433,6 @@ def __init__(self, parent): label.setPixmap(pixmap) self.setStyleSheet("background: transparent;") - self._parent = parent self._hovered = False def mouseReleaseEvent(self, event): @@ -442,9 +441,6 @@ def mouseReleaseEvent(self, event): super(OptionBox, self).mouseReleaseEvent(event) def enterEvent(self, event): - parent = self._parent - parent.setBackgroundRole(QtGui.QPalette.Highlight) - parent.setAutoFillBackground(True) self.setBackgroundRole(QtGui.QPalette.Highlight) self.setAutoFillBackground(True) self._hovered = True From db323ddcdcb37c9bc5d2909ba5021d13532c44d3 Mon Sep 17 00:00:00 2001 From: David Lai Date: Thu, 5 Dec 2019 17:35:45 +0800 Subject: [PATCH 13/19] Minor cleanup --- avalon/tools/widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index 1468bb4ce..aeca48294 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -396,7 +396,6 @@ def __init__(self, label, parent=None): self.setMouseTracking(True) self.icon = icon - self.label = label self.option = option self.body = body From 978c9b167435ec76b5847d9399a73a05c5f6127f Mon Sep 17 00:00:00 2001 From: David Lai Date: Thu, 5 Dec 2019 17:36:51 +0800 Subject: [PATCH 14/19] Fix ugly drop shadow of action widget's QLable in Nuke --- avalon/tools/widgets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index aeca48294..29a50e566 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -399,6 +399,10 @@ def __init__(self, label, parent=None): self.option = option self.body = body + # (NOTE) For removing ugly QLable shadow FX when highlighted in Nuke. + # See https://stackoverflow.com/q/52838690/4145300 + label.setStyle(QtWidgets.QStyleFactory.create("Plastique")) + def setIcon(self, icon): pixmap = icon.pixmap(16, 16) self.icon.setPixmap(pixmap) From 4bb11b4cb570d859fded01fc5787e3954658ce1b Mon Sep 17 00:00:00 2001 From: David Lai Date: Fri, 6 Dec 2019 23:11:09 +0800 Subject: [PATCH 15/19] Replace `tools.inputs` with `vendor.qargparse` --- avalon/tools/inputs.py | 228 -------------- avalon/vendor/qargparse.py | 607 +++++++++++++++++++++++++++++++++++++ 2 files changed, 607 insertions(+), 228 deletions(-) delete mode 100644 avalon/tools/inputs.py create mode 100644 avalon/vendor/qargparse.py diff --git a/avalon/tools/inputs.py b/avalon/tools/inputs.py deleted file mode 100644 index f78455cc2..000000000 --- a/avalon/tools/inputs.py +++ /dev/null @@ -1,228 +0,0 @@ - -import re -from ..vendor.Qt import QtWidgets, QtGui - - -def nice_naming(key): - """Convert camelCase name into UI Display Name""" - words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) - return " ".join(words) - - -class InputBase(QtWidgets.QWidget): - """Base class of option box input widgets (value type oriented)""" - - def __init__(self, name, help=None, parent=None): - super(InputBase, self).__init__(parent=parent) - help = help or "" - - label = QtWidgets.QLabel(nice_naming(name)) - label.setToolTip(help) - - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(label, stretch=False) - layout.addSpacing(4) - - self.layout = layout - self.key = name - self.tip = help - - def link(self, slot): - """Internal use. Connecting widget to option dialog""" - slot[self.key] = self - - def get(self): - """Method for returning value from input widget""" - raise NotImplementedError("Should be implemented in subclass.") - - -class Bool(InputBase): - """Option box input widget for `bool` type value - - Args: - name (str): Value entry name, also taken as widget label - default (bool, optional): Default false - help (str, optional): Widget tool tip - - """ - - def __init__(self, name, default=False, help=None, parent=None): - QtWidgets.QWidget.__init__(self, parent=parent) - help = help or "" - - input = QtWidgets.QCheckBox(nice_naming(name)) - input.setToolTip(help) - if default: - input.setChecked() - - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(input) - - self.layout = layout - self.key = name - self.tip = help - self.input = input - - def get(self): - return self.input.isChecked() - - -class Int(InputBase): - """Option box input widget for `int` type value - - Args: - name (str): Value entry name, also taken as widget label - default (int, optional): Default 0 - min (int, optional): Minimum input value, default 0 - max (int, optional): Maximum input value, default 99 - help (str, optional): Widget tool tip - - """ - - def __init__(self, name, default=0, min=0, max=99, help=None, parent=None): - InputBase.__init__(self, name, help, parent) - - input = QtWidgets.QSpinBox() - input.setValue(default) - input.setMinimum(min) - input.setMaximum(max) - - self.layout.addWidget(input, stretch=True) - self.input = input - - def get(self): - return self.input.value() - - -def line_edit_float(default=0.0): - """Return a QLineEdit widget for inputting float value - - Args: - default (float, optional): Default 0.0 - - """ - widget = QtWidgets.QLineEdit() - widget.setValidator(QtGui.QDoubleValidator()) - widget.setText(str(float(default))) - return widget - - -class Float(InputBase): - """Option box input widget for `float` type value - - Args: - name (str): Value entry name, also taken as widget label - default (float, optional): Default 0.0 - help (str, optional): Widget tool tip - - """ - - def __init__(self, name, default=0.0, help=None, parent=None): - InputBase.__init__(self, name, help, parent) - - input = line_edit_float(default) - - self.layout.addWidget(input) - self.input = input - - def get(self): - return float(self.input.text()) - - -class Double3(InputBase): - """Option box input widget for array of three doubles - - Args: - name (str): Value entry name, also taken as widget label - default (tuple or list, optional): Default (0.0, 0.0, 0.0) - help (str, optional): Widget tool tip - - """ - - def __init__(self, name, default=None, help=None, parent=None): - default = default or (0.0, 0.0, 0.0) - assert isinstance(default, (list, tuple)), "Should be list or tuple." - assert len(default) == 3, "Should have exact three elements." - - InputBase.__init__(self, name, help, parent) - - input = QtWidgets.QWidget() - - x, y, z = (line_edit_float(v) for v in default) - - layout = QtWidgets.QHBoxLayout(input) - layout.addWidget(x) - layout.addWidget(y) - layout.addWidget(z) - - self.layout.addWidget(input) - self.inputs = (x, y, z) - - def get(self): - return tuple(float(i.text()) for i in self.inputs) - - -class String(InputBase): - """Option box input widget for `str` type value - - Args: - name (str): Value entry name, also taken as widget label - default (str, optional): Default None - placeholder (str, optional): Widget placeholder text, default None - help (str, optional): Widget tool tip - - """ - - def __init__(self, - name, - default=None, - placeholder=None, - help=None, - parent=None): - InputBase.__init__(self, name, help, parent) - - input = QtWidgets.QLineEdit() - input.setPlaceholderText(placeholder or "") - input.setText(default or "") - - self.layout.addWidget(input) - self.input = input - - def get(self): - return self.input.text() - - -class GetOne(InputBase): - """Option box input widget for selecting one value from a list - - Args: - name (str): Value entry name, also taken as widget label - elements (list): A list of values to select from - default (int, optional): Index for default value from list, default 0 - as_string (bool, optional): Return as index or string, default true - help (str, optional): Widget tool tip - - """ - - def __init__(self, - name, - elements, - default=0, - as_string=True, - help=None, - parent=None): - InputBase.__init__(self, name, help, parent) - - input = QtWidgets.QComboBox() - input.addItems(elements) - input.setCurrentIndex(default) - - self.layout.addWidget(input, stretch=True) - self.input = input - self.as_string = as_string - - def get(self): - if self.as_string: - return self.input.currentText() - else: - return self.input.currentIndex() diff --git a/avalon/vendor/qargparse.py b/avalon/vendor/qargparse.py new file mode 100644 index 000000000..b56e91463 --- /dev/null +++ b/avalon/vendor/qargparse.py @@ -0,0 +1,607 @@ +import re +import logging + +from collections import OrderedDict as odict +from .Qt import QtCore, QtWidgets + +__version__ = "0.5.1" +_log = logging.getLogger(__name__) +_type = type # used as argument + +try: + # Python 2 + _basestring = basestring +except NameError: + _basestring = str + + +class QArgumentParser(QtWidgets.QWidget): + """User interface arguments + + Arguments: + arguments (list, optional): Instances of QArgument + description (str, optional): Long-form text of what this parser is for + storage (QSettings, optional): Persistence to disk, providing + value() and setValue() methods + + """ + + changed = QtCore.Signal(QtCore.QObject) # A QArgument + + def __init__(self, + arguments=None, + description=None, + storage=None, + parent=None): + super(QArgumentParser, self).__init__(parent) + self.setAttribute(QtCore.Qt.WA_StyledBackground) + + # Create internal settings + if storage is True: + storage = QtCore.QSettings( + QtCore.QSettings.IniFormat, + QtCore.QSettings.UserScope, + __name__, "QArgparse", + ) + + if storage is not None: + _log.info("Storing settings @ %s" % storage.fileName()) + + arguments = arguments or [] + + assert hasattr(arguments, "__iter__"), "arguments must be iterable" + assert isinstance(storage, (type(None), QtCore.QSettings)), ( + "storage must be of type QSettings" + ) + + layout = QtWidgets.QGridLayout(self) + layout.setRowStretch(999, 1) + + if description: + layout.addWidget(QtWidgets.QLabel(description), 0, 0, 1, 2) + + self._row = 1 + self._storage = storage + self._arguments = odict() + self._desciption = description + + for arg in arguments or []: + self._addArgument(arg) + + self.setStyleSheet(style) + + def setDescription(self, text): + self._desciption.setText(text) + + def addArgument(self, name, type=None, default=None, **kwargs): + # Infer type from default + if type is None and default is not None: + type = _type(default) + + # Default to string + type = type or str + + Argument = { + None: String, + int: Integer, + float: Float, + bool: Boolean, + str: String, + list: Enum, + tuple: Enum, + }.get(type, type) + + arg = Argument(name, default=default, **kwargs) + self._addArgument(arg) + return arg + + def _addArgument(self, arg): + if arg["name"] in self._arguments: + raise ValueError("Duplicate argument '%s'" % arg["name"]) + + if self._storage is not None: + default = self._storage.value(arg["name"]) + + if default: + if isinstance(arg, Boolean): + default = bool({ + None: QtCore.Qt.Unchecked, + + 0: QtCore.Qt.Unchecked, + 1: QtCore.Qt.Checked, + 2: QtCore.Qt.Checked, + + "0": QtCore.Qt.Unchecked, + "1": QtCore.Qt.Checked, + "2": QtCore.Qt.Checked, + + # May be stored as string, if used with IniFormat + "false": QtCore.Qt.Unchecked, + "true": QtCore.Qt.Checked, + }.get(default)) + + arg["default"] = default + + arg.changed.connect(lambda: self.on_changed(arg)) + + label = ( + QtWidgets.QLabel(arg["label"]) + if arg.label + else QtWidgets.QLabel() + ) + widget = arg.create() + reset = QtWidgets.QPushButton("") # default + reset.setToolTip("Reset") + reset.setProperty("type", "reset") + reset.clicked.connect(lambda: self.on_reset(arg)) + + # Shown on edit + reset.hide() + + for widget in (label, widget): + widget.setToolTip(arg["help"]) + widget.setObjectName(arg["name"]) # useful in CSS + widget.setProperty("type", type(arg).__name__) + widget.setAttribute(QtCore.Qt.WA_StyledBackground) + widget.setEnabled(arg["enabled"]) + + layout = self.layout() + layout.addWidget(label, self._row, 0, QtCore.Qt.AlignTop) + layout.addWidget(widget, self._row, 1) + layout.addWidget(reset, self._row, 2, QtCore.Qt.AlignTop) + layout.setColumnStretch(1, 1) + + def on_changed(*_): + reset.setVisible(arg["edited"]) + + arg.changed.connect(on_changed) + + self._row += 1 + self._arguments[arg["name"]] = arg + + def clear(self): + assert self._storage, "Cannot clear without persistent storage" + self._storage.clear() + _log.info("Clearing settings @ %s" % self._storage.fileName()) + + def find(self, name): + return self._arguments[name] + + def on_reset(self, arg): + arg.write(arg["default"]) + + def on_changed(self, arg): + arg["edited"] = arg.read() != arg["default"] + self.changed.emit(arg) + + # Optional PEP08 syntax + add_argument = addArgument + + +class QArgument(QtCore.QObject): + changed = QtCore.Signal() + + # Provide a left-hand side label for this argument + label = True + + def __init__(self, name, **kwargs): + super(QArgument, self).__init__(kwargs.pop("parent", None)) + + kwargs["name"] = name + kwargs["label"] = kwargs.get("label", camel_to_title(name)) + kwargs["default"] = kwargs.get("default", None) + kwargs["help"] = kwargs.get("help", "") + kwargs["read"] = kwargs.get("read") + kwargs["write"] = kwargs.get("write") + kwargs["enabled"] = bool(kwargs.get("enabled", True)) + kwargs["edited"] = False + + self._data = kwargs + + def __str__(self): + return self["name"] + + def __repr__(self): + return "%s(\"%s\")" % (type(self).__name__, self["name"]) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def __eq__(self, other): + if isinstance(other, _basestring): + return self["name"] == other + return super(QArgument, self).__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def create(self): + return QtWidgets.QWidget() + + def read(self): + return self._read() + + def write(self, value): + self._write(value) + self.changed.emit() + + +class Boolean(QArgument): + def create(self): + widget = QtWidgets.QCheckBox() + widget.clicked.connect(self.changed.emit) + + if isinstance(self, Tristate): + self._read = lambda: widget.checkState() + state = { + 0: QtCore.Qt.Unchecked, + 1: QtCore.Qt.PartiallyChecked, + 2: QtCore.Qt.Checked, + "1": QtCore.Qt.PartiallyChecked, + "0": QtCore.Qt.Unchecked, + "2": QtCore.Qt.Checked, + } + else: + self._read = lambda: bool(widget.checkState()) + state = { + None: QtCore.Qt.Unchecked, + + 0: QtCore.Qt.Unchecked, + 1: QtCore.Qt.Checked, + 2: QtCore.Qt.Checked, + + "0": QtCore.Qt.Unchecked, + "1": QtCore.Qt.Checked, + "2": QtCore.Qt.Checked, + + # May be stored as string, if used with QSettings(..IniFormat) + "false": QtCore.Qt.Unchecked, + "true": QtCore.Qt.Checked, + } + + self._write = lambda value: widget.setCheckState(state[value]) + widget.clicked.connect(self.changed.emit) + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + def read(self): + return self._read() + + +class Tristate(QArgument): + pass + + +class Number(QArgument): + def create(self): + if isinstance(self, Float): + widget = QtWidgets.QDoubleSpinBox() + else: + widget = QtWidgets.QSpinBox() + + widget.editingFinished.connect(self.changed.emit) + self._read = lambda: widget.value() + self._write = lambda value: widget.setValue(value) + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + +class Integer(Number): + pass + + +class Float(Number): + pass + + +class Range(Number): + pass + + +class String(QArgument): + def __init__(self, *args, **kwargs): + super(String, self).__init__(*args, **kwargs) + self._previous = None + + def create(self): + widget = QtWidgets.QLineEdit() + widget.editingFinished.connect(self.onEditingFinished) + widget.returnPressed.connect(widget.editingFinished.emit) + self._read = lambda: widget.text() + self._write = lambda value: widget.setText(value) + + if isinstance(self, Info): + widget.setReadOnly(True) + + if self["default"] is not None: + self._write(self["default"]) + self._previous = self["default"] + + return widget + + def onEditingFinished(self): + current = self._read() + + if current != self._previous: + self.changed.emit() + self._previous = current + + +class Info(String): + pass + + +class Color(String): + pass + + +class Button(QArgument): + label = False + + def create(self): + widget = QtWidgets.QPushButton(self["label"]) + widget.clicked.connect(self.changed.emit) + + state = [ + QtCore.Qt.Unchecked, + QtCore.Qt.Checked, + ] + + if isinstance(self, Toggle): + widget.setCheckable(True) + self._read = lambda: widget.checkState() + self._write = ( + lambda value: widget.setCheckState(state[int(value)]) + ) + else: + self._read = lambda: "clicked" + self._write = lambda value: None + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + +class Toggle(Button): + pass + + +class InfoList(QArgument): + def __init__(self, name, **kwargs): + kwargs["default"] = kwargs.pop("default", ["Empty"]) + super(InfoList, self).__init__(name, **kwargs) + + def create(self): + class Model(QtCore.QStringListModel): + def data(self, index, role): + return super(Model, self).data(index, role) + + model = QtCore.QStringListModel(self["default"]) + widget = QtWidgets.QListView() + widget.setModel(model) + widget.setEditTriggers(widget.NoEditTriggers) + + self._read = lambda: model.stringList() + self._write = lambda value: model.setStringList(value) + + return widget + + +class Choice(QArgument): + def __init__(self, name, **kwargs): + kwargs["items"] = kwargs.get("items", ["Empty"]) + kwargs["default"] = kwargs.pop("default", kwargs["items"][0]) + super(Choice, self).__init__(name, **kwargs) + + def index(self, value): + """Return numerical equivalent to self.read()""" + return self["items"].index(value) + + def create(self): + def on_changed(selected, deselected): + try: + selected = selected.indexes()[0] + except IndexError: + # At least one item must be selected at all times + selected = deselected.indexes()[0] + + value = selected.data(QtCore.Qt.DisplayRole) + set_current(value) + self.changed.emit() + + def set_current(current): + options = model.stringList() + + if current == "Empty": + index = 0 + else: + for index, member in enumerate(options): + if member == current: + break + else: + raise ValueError( + "%s not a member of %s" % (current, options) + ) + + qindex = model.index(index, 0, QtCore.QModelIndex()) + smodel.setCurrentIndex(qindex, smodel.ClearAndSelect) + self["current"] = options[index] + + def reset(items, default=None): + items = items or ["Empty"] + model.setStringList(items) + set_current(default or items[0]) + + model = QtCore.QStringListModel() + widget = QtWidgets.QListView() + widget.setModel(model) + widget.setEditTriggers(widget.NoEditTriggers) + widget.setSelectionMode(widget.SingleSelection) + smodel = widget.selectionModel() + smodel.selectionChanged.connect(on_changed) + + self._read = lambda: self["current"] + self._write = lambda value: set_current(value) + self.reset = reset + + reset(self["items"], self["default"]) + + return widget + + +class Separator(QArgument): + """Visual separator + + Example: + + item1 + item2 + ------------ + item3 + item4 + + """ + + def create(self): + widget = QtWidgets.QWidget() + + self._read = lambda: None + self._write = lambda value: None + + return widget + + +class Enum(QArgument): + def __init__(self, name, **kwargs): + kwargs["default"] = kwargs.pop("default", 0) + kwargs["items"] = kwargs.get("items", []) + + assert isinstance(kwargs["items"], (tuple, list)), ( + "items must be list" + ) + + super(Enum, self).__init__(name, **kwargs) + + def create(self): + widget = QtWidgets.QComboBox() + widget.addItems(self["items"]) + widget.currentIndexChanged.connect( + lambda index: self.changed.emit()) + + self._read = lambda: widget.currentText() + self._write = lambda value: widget.setCurrentIndex(value) + + if self["default"] is not None: + self._write(self["default"]) + + return widget + + +style = """\ +QWidget { + /* Explicitly specify a size, to account for automatic HDPi */ + font-size: 11px; +} + +*[type="Button"] { + text-align:left; +} + +*[type="Info"] { + background: transparent; + border: none; +} + +QLabel[type="Separator"] { + min-height: 20px; + text-decoration: underline; +} + +QPushButton[type="reset"] { + max-width: 11px; + max-height: 11px; +} + +""" + + +def camelToTitle(text): + """Convert camelCase `text` to Title Case + + Example: + >>> camelToTitle("mixedCase") + "Mixed Case" + >>> camelToTitle("myName") + "My Name" + >>> camelToTitle("you") + "You" + >>> camelToTitle("You") + "You" + >>> camelToTitle("This is That") + "This Is That" + + """ + + return re.sub( + r"((?<=[a-z])[A-Z]|(? Date: Fri, 6 Dec 2019 23:11:53 +0800 Subject: [PATCH 16/19] Adopting `qargparse` --- avalon/tools/loader/widgets.py | 2 +- avalon/tools/widgets.py | 23 +++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index f4ff2fe47..ca72485f7 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -241,7 +241,7 @@ def sorter(value): return # Get option - options = dialog.options() + options = dialog.parse() # Run the loader for all selected indices, for those that have the # same representation available diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index 29a50e566..38da96d3c 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -4,7 +4,7 @@ from .models import AssetModel, RecursiveSortFilterProxyModel from .views import DeselectableTreeView -from ..vendor import qtawesome +from ..vendor import qtawesome, qargparse from ..vendor.Qt import QtWidgets, QtCore, QtGui from .. import style @@ -356,7 +356,7 @@ def createWidget(self, parent): def set_option_tip(self, options): sep = "\n\n" - mak = (lambda opt: opt.key + " :\n " + opt.tip) + mak = (lambda opt: opt["name"] + " :\n " + opt["help"]) self.option_tip = sep.join(mak(opt) for opt in options) def on_option(self): @@ -460,9 +460,11 @@ class OptionDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(OptionDialog, self).__init__(parent) self.setModal(True) - self._inputs = dict() + self._options = dict() def create(self, options): + parser = qargparse.QArgumentParser(arguments=options) + decision = QtWidgets.QWidget() accept = QtWidgets.QPushButton("Accept") cancel = QtWidgets.QPushButton("Cancel") @@ -472,18 +474,15 @@ def create(self, options): layout.addWidget(cancel) layout = QtWidgets.QVBoxLayout(self) - for widget in options: - widget.link(self._inputs) - layout.addWidget(widget) - layout.addSpacing(20) + layout.addWidget(parser) layout.addWidget(decision) accept.clicked.connect(self.accept) cancel.clicked.connect(self.reject) + parser.changed.connect(self.on_changed) - def options(self): - options = dict() - for key, input in self._inputs.items(): - options[key] = input.get() + def on_changed(self, argument): + self._options[argument["name"]] = argument.read() - return options + def parse(self): + return self._options.copy() From ec3b0a601b5677db6070e82681da24f392f7840c Mon Sep 17 00:00:00 2001 From: David Lai Date: Fri, 6 Dec 2019 23:15:27 +0800 Subject: [PATCH 17/19] Add note --- avalon/vendor/qargparse.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/avalon/vendor/qargparse.py b/avalon/vendor/qargparse.py index b56e91463..9060a5479 100644 --- a/avalon/vendor/qargparse.py +++ b/avalon/vendor/qargparse.py @@ -1,3 +1,8 @@ +""" +NOTE: The required `Qt` module has changed to use the one that vendorized. + Remember to change to relative import when updating this. +""" + import re import logging From f8768a7fbfdf3661a56913500508ff9cd5dcd658 Mon Sep 17 00:00:00 2001 From: David Lai Date: Tue, 10 Dec 2019 18:27:26 +0800 Subject: [PATCH 18/19] Bump vendorized `qargparse` version --- avalon/vendor/qargparse.py | 69 ++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/avalon/vendor/qargparse.py b/avalon/vendor/qargparse.py index 9060a5479..2c6c9cad1 100644 --- a/avalon/vendor/qargparse.py +++ b/avalon/vendor/qargparse.py @@ -7,9 +7,9 @@ import logging from collections import OrderedDict as odict -from .Qt import QtCore, QtWidgets +from .Qt import QtCore, QtWidgets, QtGui -__version__ = "0.5.1" +__version__ = "0.5.2" _log = logging.getLogger(__name__) _type = type # used as argument @@ -150,10 +150,15 @@ def _addArgument(self, arg): widget.setAttribute(QtCore.Qt.WA_StyledBackground) widget.setEnabled(arg["enabled"]) + # Align label on top of row if widget is over two times heiger + height = (lambda w: w.sizeHint().height()) + label_on_top = height(label) * 2 < height(widget) + alignment = (QtCore.Qt.AlignTop,) if label_on_top else () + layout = self.layout() - layout.addWidget(label, self._row, 0, QtCore.Qt.AlignTop) + layout.addWidget(label, self._row, 0, *alignment) layout.addWidget(widget, self._row, 1) - layout.addWidget(reset, self._row, 2, QtCore.Qt.AlignTop) + layout.addWidget(reset, self._row, 2, *alignment) layout.setColumnStretch(1, 1) def on_changed(*_): @@ -188,13 +193,15 @@ class QArgument(QtCore.QObject): # Provide a left-hand side label for this argument label = True + # For defining default value for each argument type + default = None - def __init__(self, name, **kwargs): + def __init__(self, name, default=None, **kwargs): super(QArgument, self).__init__(kwargs.pop("parent", None)) kwargs["name"] = name kwargs["label"] = kwargs.get("label", camel_to_title(name)) - kwargs["default"] = kwargs.get("default", None) + kwargs["default"] = self.default if default is None else default kwargs["help"] = kwargs.get("help", "") kwargs["read"] = kwargs.get("read") kwargs["write"] = kwargs.get("write") @@ -284,17 +291,23 @@ class Tristate(QArgument): class Number(QArgument): + default = 0 + def create(self): if isinstance(self, Float): widget = QtWidgets.QDoubleSpinBox() + widget.setMinimum(self._data.get("min", 0.0)) + widget.setMaximum(self._data.get("max", 99.99)) else: widget = QtWidgets.QSpinBox() + widget.setMinimum(self._data.get("min", 0)) + widget.setMaximum(self._data.get("max", 99)) widget.editingFinished.connect(self.changed.emit) self._read = lambda: widget.value() self._write = lambda value: widget.setValue(value) - if self["default"] is not None: + if self["default"] != self.default: self._write(self["default"]) return widget @@ -312,6 +325,46 @@ class Range(Number): pass +class Double3(QArgument): + default = (0, 0, 0) + + def create(self): + widget = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + x, y, z = (self.child_arg(layout, i) for i in range(3)) + + self._read = lambda: ( + float(x.text()), float(y.text()), float(z.text())) + self._write = lambda value: [ + w.setText(str(float(v))) for w, v in zip([x, y, z], value)] + + if self["default"] != self.default: + self._write(self["default"]) + + return widget + + def child_arg(self, layout, index): + widget = QtWidgets.QLineEdit() + widget.setValidator(QtGui.QDoubleValidator()) + + default = str(float(self["default"][index])) + widget.setText(default) + + def focusOutEvent(event): + if not widget.text(): + widget.setText(default) # Ensure value exists for `_read` + QtWidgets.QLineEdit.focusOutEvent(widget, event) + widget.focusOutEvent = focusOutEvent + + widget.editingFinished.connect(self.changed.emit) + widget.returnPressed.connect(widget.editingFinished.emit) + + layout.addWidget(widget) + + return widget + + class String(QArgument): def __init__(self, *args, **kwargs): super(String, self).__init__(*args, **kwargs) @@ -326,6 +379,7 @@ def create(self): if isinstance(self, Info): widget.setReadOnly(True) + widget.setPlaceholderText(self._data.get("placeholder", "")) if self["default"] is not None: self._write(self["default"]) @@ -592,6 +646,7 @@ def _demo(): "Some other value", "And finally, value C", ]) + parser.add_argument("location", type=Double3) parser.show() app.exec_() From 964de130a55291b0f718b60409c4148b02914e8e Mon Sep 17 00:00:00 2001 From: David Lai Date: Thu, 12 Dec 2019 01:50:26 +0800 Subject: [PATCH 19/19] Fix menu action highlight stop update on mouse pressed and hold --- avalon/tools/loader/widgets.py | 4 +- avalon/tools/widgets.py | 82 ++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index ca72485f7..495de7207 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -10,7 +10,7 @@ from .. import lib as tools_lib from ..delegates import VersionDelegate -from ..widgets import OptionalAction, OptionDialog +from ..widgets import OptionalMenu, OptionalAction, OptionDialog from .model import ( SubsetsModel, @@ -178,7 +178,7 @@ def sorter(value): return Plugin.order, Plugin.__name__ # List the available loaders - menu = QtWidgets.QMenu(self) + menu = OptionalMenu(self) for representation, loader in sorted(loaders, key=sorter): # Label diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py index 38da96d3c..a144de51e 100644 --- a/avalon/tools/widgets.py +++ b/avalon/tools/widgets.py @@ -324,6 +324,38 @@ def _list_project_silos(): return list(sorted(silos)) +class OptionalMenu(QtWidgets.QMenu): + """A subclass of `QtWidgets.QMenu` to work with `OptionalAction` + + This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and + `leaveEvent` to provide better action hightlighting and triggering for + actions that were instances of `QtWidgets.QWidgetAction`. + + """ + + def mouseReleaseEvent(self, event): + """Emit option clicked signal if mouse released on it""" + active = self.actionAt(event.pos()) + if active and active.use_option: + option = active.widget.option + if option.is_hovered(event.globalPos()): + option.clicked.emit() + super(OptionalMenu, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + """Add highlight to active action""" + active = self.actionAt(event.pos()) + for action in self.actions(): + action.set_highlight(action is active, event.globalPos()) + super(OptionalMenu, self).mouseMoveEvent(event) + + def leaveEvent(self, event): + """Remove highlight from all actions""" + for action in self.actions(): + action.set_highlight(False) + super(OptionalMenu, self).leaveEvent(event) + + class OptionalAction(QtWidgets.QWidgetAction): """Menu action with option box @@ -342,6 +374,7 @@ def __init__(self, label, icon, use_option, parent): def createWidget(self, parent): widget = OptionalActionWidget(self.label, parent) + self.widget = widget if self.icon: widget.setIcon(self.icon) @@ -362,6 +395,22 @@ def set_option_tip(self, options): def on_option(self): self.optioned = True + def set_highlight(self, state, global_pos=None): + body = self.widget.body + option = self.widget.option + + role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window + body.setBackgroundRole(role) + body.setAutoFillBackground(state) + + if not self.use_option: + return + + state = option.is_hovered(global_pos) + role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window + option.setBackgroundRole(role) + option.setAutoFillBackground(state) + class OptionalActionWidget(QtWidgets.QWidget): """Main widget class for `OptionalAction`""" @@ -392,8 +441,9 @@ def __init__(self, label, parent=None): layout.addWidget(body) layout.addWidget(option) - self.setFixedHeight(32) + body.setMouseTracking(True) self.setMouseTracking(True) + self.setFixedHeight(32) self.icon = icon self.option = option @@ -407,14 +457,6 @@ def setIcon(self, icon): pixmap = icon.pixmap(16, 16) self.icon.setPixmap(pixmap) - def enterEvent(self, event): - self.body.setBackgroundRole(QtGui.QPalette.Highlight) - self.body.setAutoFillBackground(True) - - def leaveEvent(self, event): - self.body.setBackgroundRole(QtGui.QPalette.Window) - self.body.setAutoFillBackground(False) - class OptionBox(QtWidgets.QWidget): """Option box widget class for `OptionalActionWidget`""" @@ -435,23 +477,15 @@ def __init__(self, parent): pixmap = icon.pixmap(18, 18) label.setPixmap(pixmap) + label.setMouseTracking(True) + self.setMouseTracking(True) self.setStyleSheet("background: transparent;") - self._hovered = False - - def mouseReleaseEvent(self, event): - if self._hovered: - self.clicked.emit() - super(OptionBox, self).mouseReleaseEvent(event) - - def enterEvent(self, event): - self.setBackgroundRole(QtGui.QPalette.Highlight) - self.setAutoFillBackground(True) - self._hovered = True - def leaveEvent(self, event): - self.setBackgroundRole(QtGui.QPalette.Window) - self.setAutoFillBackground(False) - self._hovered = False + def is_hovered(self, global_pos): + if global_pos is None: + return False + pos = self.mapFromGlobal(global_pos) + return self.rect().contains(pos) class OptionDialog(QtWidgets.QDialog):