Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/mayaUsd/resources/ae/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ foreach(_SUBDIR ${MAYAUSD_AE_TEMPLATES})
${_SUBDIR}/metadataCustomControl.py
${_SUBDIR}/relationshipCustomControl.py
${_SUBDIR}/observers.py
${_SUBDIR}/dragAndDropTextField.py
DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/python/ufe_ae/usd/nodes/${_SUBDIR}
)

Expand Down
121 changes: 121 additions & 0 deletions lib/mayaUsd/resources/ae/usdschemabase/dragAndDropTextField.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Copyright 2026 Autodesk
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import maya.cmds as cmds

import mayaUsdUtils


class DragAndDropTextField:
'''
Class to hold the UI elements for a text field supporting drag-and-drop.
'''
def __init__(self, uiTooltip):
self.field = cmds.textField(annotation=uiTooltip, editable=False, enableKeyboardFocus=True)
self.lastValue = ''

def fillUI(self, text, placeholder=None, annotation=None, editable=True):
'''
Fill the text field with the given text, optional placeholder and optional annotation.
'''
try:
self.lastValue = text
args = {
'editable': editable,
'text': text
}
if placeholder:
args['placeholderText'] = placeholder
if annotation:
args['annotation'] = annotation
cmds.textField(self.field, edit=True, **args)
except Exception as e:
cmds.warning(f'Error filling text field values: {e}', noContext=True)

def setChangeCallback(self, callback, undoLabel):
'''
Set the callback to be called when the text field value is modified by user and confirmed
by either pressing Enter or losing focus. The callback receives the new value.
'''
@mayaUsdUtils.setUndoLabel(undoLabel)
def changeCallback(value, *args, **kwargs):
try:
callback(value)
except Exception as e:
cmds.warning(f'Error in text field change callback: {e}', noContext=True)
try:
cmds.textField(self.field, edit=True, changeCommand=changeCallback)
except Exception as e:
cmds.warning(f'Error connecting text field change callback: {e}', noContext=True)

def _validateDroppedValue(self, value, validation):
'''
Try to detect drag-and-drop.

In Maya, when text is dropped, it is inserted in the middle of the existing text.
What we want is replacement, not insertion. So we try to detect that and extract
the dropped value from the new text. Unfortunately, there may be corner cases
where we cannot be sure what is the new text. For example, if the previous text
can be found multiple times in the new text, we cannot be sure which one was replaced.
We extract all possible new values and let the validation function decide which one is valid.
'''
# If the last value is the same as the new value, it is not a drop.
if self.lastValue == value:
return None
# If the new value is shorter than the last value, it is not a drop.
# If the new value is just one character longer, then we assume the user is typing and not dropping.
if len(value) <= len(self.lastValue) + 1:
return None

newValues = []
lenToExtract = len(value) - len(self.lastValue)
for insertionPoint in range(len(value) - lenToExtract + 1):
potentialOldValue = value[:insertionPoint] + value[insertionPoint + lenToExtract:]
if potentialOldValue == self.lastValue:
newValues.append(value[insertionPoint:insertionPoint + lenToExtract])

potentialNewValue = validation(self.lastValue, newValues)
if potentialNewValue is not None:
return potentialNewValue

return value

def setImmediateChangeCallback(self, validation, callback, undoLabel):
'''
Set callback for immediate text field value change, such as when the user is typing or dropping text.
The validation function is called to validate the new value and determine if it should be accepted.
If the validation function returns a new value (i.e not None), it will be used; otherwise, the original
new value will be used. Then the callback is called with the new value and a boolean indicating if it
was a dropped value.

The validation function receives the last value and a list of potential new values extracted from the
current text field value.

The callback function receives the new value and a boolean indicating if it was a dropped value.
'''
@mayaUsdUtils.setUndoLabel(undoLabel)
def validationCallback(value, *args, **kwargs):
try:
droppedValue = self._validateDroppedValue(value, validation)
newValue = droppedValue if droppedValue is not None else value
self.lastValue = newValue
callback(newValue, bool(droppedValue is not None))
except Exception as e:
cmds.warning(f'Error in text field validation: {e}', noContext=True)

try:
cmds.textField(self.field, edit=True, textChangedCommand=validationCallback)
except Exception as e:
cmds.warning(f'Error connecting text field change callback: {e}', noContext=True)
141 changes: 78 additions & 63 deletions lib/mayaUsd/resources/ae/usdschemabase/materialCustomControl.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import maya.common.ui as mui

from mayaUsdLibRegisterStrings import getMayaUsdLibString
from .dragAndDropTextField import DragAndDropTextField

@dataclass(slots=True)
class MaterialPurposeUI:
Expand All @@ -51,7 +52,7 @@ class MaterialCustomControl(object):
getMayaUsdLibString('kLabelStrongerMaterial') : 'strongerThanDescendants',
}

TextField = collections.namedtuple('TextField', ['layout', 'field', 'button', 'graphMenu'])
TextField = collections.namedtuple('TextField', ['layout', 'field', 'gotoButton', 'graphButton','graphMenu'])

@staticmethod
def hasMaterial(prim):
Expand Down Expand Up @@ -100,6 +101,7 @@ def onCreate(self, *args):
for purpose in [UsdShade.Tokens.allPurpose, UsdShade.Tokens.preview, UsdShade.Tokens.full]:
textField = self.materialPurposeUIs[purpose].material.field
self._connectTextFieldChangeCallback(purpose, textField)
self._connectTextFieldImmediateChangeCallback(purpose, textField)

# Fill the UI.
self.refresh()
Expand Down Expand Up @@ -131,84 +133,94 @@ def _createMaterialUI(self, purpose):
'''
purposeName = self._getPurposeForLabels(purpose)

# Note: icon image taken from LookdevX plugin.
hasLookdevX = self._hasLookdevX()
graphIcon = 'LookdevX.png' if hasLookdevX else None

purposeUI = self.materialPurposeUIs[purpose]
purposeUI.material = self._createTextField(
'material',
f'kLabel{purposeName}Material',
f'kAnn{purposeName}Material',
graphIcon,
'kAnnShowMaterialInLookdevx', True)
purposeUI.material = self._createTextField('material', f'kLabel{purposeName}Material', f'kAnn{purposeName}Material', canGraph=True)

def _createInheritedUI(self, purpose):
'''
Create the UI for a given material purpose.
'''
purposeName = self._getPurposeForLabels(purpose)

# Note: icon image taken from LookdevX plugin.
hasLookdevX = self._hasLookdevX()
graphIcon = 'LookdevX.png' if hasLookdevX else None

purposeUI = self.materialPurposeUIs[purpose]
purposeUI.inherited = self._createTextField('inherited', f'kLabel{purposeName}InheritedMaterial', image=graphIcon, imageTooltipRes='kAnnShowMaterialInLookdevx', canGraph=True)
purposeUI.inherited = self._createTextField('inherited', f'kLabel{purposeName}InheritedMaterial', canGraph=True)
# Note: inArrow.png icon image taken from Maya resources.
purposeUI.fromPrim = self._createTextField('from prim', f'kLabel{purposeName}InheritedFromPrim', image='inArrow.png')
purposeUI.fromPrim = self._createTextField('from prim', f'kLabel{purposeName}InheritedFromPrim')

def _createTextField(self, longName, uiNameRes, uiTooltipRes=None, image=None, imageTooltipRes=None, canGraph=False):
def _createTextField(self, longName, uiLabelRes, uiTooltipRes=None, canGraph=False):
'''
Create a disabled text field group and an optional image button with the correct label.
'''
uiLabel = getMayaUsdLibString(uiNameRes) if self.useNiceName else longName
uiLabel = getMayaUsdLibString(uiLabelRes) if self.useNiceName else longName
uiTooltip = getMayaUsdLibString(uiTooltipRes) if uiTooltipRes else uiLabel
rowLayout = cmds.rowLayout(numberOfColumns=3, adjustableColumn3=2)
rowLayout = cmds.rowLayout(numberOfColumns=4, adjustableColumn4=2)
with mui.LayoutManager(rowLayout):
cmds.text(label=uiLabel, annotation=uiTooltip)
textField = cmds.textField(annotation=uiTooltip, editable=False, enableKeyboardFocus=True)
if image:
imageTooltip = getMayaUsdLibString(imageTooltipRes) if imageTooltipRes else ''
button = cmds.symbolButton(enable=False, image=image, annotation=imageTooltip)
textField = DragAndDropTextField(uiTooltip)
gotoButton = cmds.symbolButton(enable=False, image='inArrow.png')

hasLookdevX = self._hasLookdevX()
if canGraph and hasLookdevX:
# Note: icon image taken from LookdevX plugin.
graphIcon = 'LookdevX.png' if hasLookdevX else None

graphTooltip = getMayaUsdLibString('kAnnShowMaterialInLookdevx')
graphButton = cmds.symbolButton(enable=False, image=graphIcon, annotation=graphTooltip)
graphMenu = self._createGraphMenu(graphButton)
else:
button = None
graphButton = None
graphMenu = None

if canGraph:
graphMenu = self._createGraphMenu(button)
else:
graphMenu = None
return MaterialCustomControl.TextField(rowLayout, textField, gotoButton, graphButton, graphMenu)

return MaterialCustomControl.TextField(rowLayout, textField, button, graphMenu)
def _setMaterialPurposeBinding(self, purpose, value):
try:
ufePath = ufe.PathString.string(self.item.path())
if value:
cmd = mayaUsd.ufe.BindMaterialCommand(ufePath, value, purpose)
else:
cmd = mayaUsd.ufe.UnbindMaterialCommand(ufePath, purpose)
ufeCmdWrapper.execute(cmd)
except Exception as e:
cmds.warning(f'Error executing material command: {e}', noContext=True)
self.refresh()

def _connectTextFieldChangeCallback(self, purpose, textField):
def callback(value):
self._setMaterialPurposeBinding(purpose, value)

@mayaUsdUtils.setUndoLabel(getMayaUsdLibString('kLabelSetMaterialBindingUndo'))
def callback(value, *args, **kwargs):
textField.setChangeCallback(callback, getMayaUsdLibString('kLabelSetMaterialBindingUndo'))

def _connectTextFieldImmediateChangeCallback(self, purpose, textField):
def callback(newValue, wasDropped):
if not wasDropped:
return
self._setMaterialPurposeBinding(purpose, newValue)

def validation(lastValue, newValues):
try:
ufePath = ufe.PathString.string(self.item.path())
if value:
cmd = mayaUsd.ufe.BindMaterialCommand(ufePath, value, purpose)
else:
cmd = mayaUsd.ufe.UnbindMaterialCommand(ufePath, purpose)
ufeCmdWrapper.execute(cmd)
for newValue in newValues:
if not newValue:
continue
if newValue[0] not in ['|', '/']:
continue
newValue = newValue.split(',')[-1]
# TODO: validate that it is a material?
return newValue
except Exception as e:
print(f'Error executing material command: {e}')
self.refresh()
cmds.warning(f'Error validating material path: {e}', noContext=True)
return None

try:
cmds.textField(textField, edit=True, changeCommand=callback)
except Exception as e:
print(f'Error connecting text field change callback: {e}')
textField.setImmediateChangeCallback(validation, callback, getMayaUsdLibString('kLabelSetMaterialBindingUndo'))

def _createGraphMenu(self, button):
def _createGraphMenu(self, graphButton):
'''
Create a popup menu attached to the given button to graph a material.
'''
if not button:
if not graphButton:
return None

return cmds.popupMenu(parent=button, button=True)
return cmds.popupMenu(parent=graphButton, button=True)

def _createDropDownField(self, longName, uiNameRes, elementsRes):
'''
Expand All @@ -229,7 +241,7 @@ def callback(value, *args, **kwargs):
# Force update of all children in the VP2 delegate.
cmds.evalDeferred(lambda: ufe.Scene.notify(ufe.ObjectRename(self.item, self.item.path())))
except Exception as e:
print(f'Error executing material command: {e}')
cmds.warning(f'Error executing material command: {e.args[0] if e.args else e}', noContext=True)
self.refresh()

uiLabel = getMayaUsdLibString(uiNameRes) if self.useNiceName else longName
Expand Down Expand Up @@ -328,27 +340,30 @@ def _fillUIForPurpose(self, purpose, mat, matRel, directMat):
cmds.rowLayout(purposeUI.inherited.layout, edit=True, visible=False)
cmds.rowLayout(purposeUI.fromPrim.layout, edit=True, visible=False)

self._fillGraphButton(text, purposeUI.material.button, purposeUI.material.graphMenu)
self._fillGraphButton(inherited, purposeUI.inherited.button, purposeUI.inherited.graphMenu)
self._fillGotoPrimButton(purposeUI, fromPathStr)
self._fillGraphButton(text, purposeUI.material.graphButton, purposeUI.material.graphMenu)
self._fillGraphButton(inherited, purposeUI.inherited.graphButton, purposeUI.inherited.graphMenu)

self._fillGotoPrimButton(purposeUI.material.gotoButton, text)
self._fillGotoPrimButton(purposeUI.inherited.gotoButton, inherited)
self._fillGotoPrimButton(purposeUI.fromPrim.gotoButton, fromPathStr)

cmds.textField(purposeUI.material.field, edit=True, editable=True, text=text, placeholderText=placeholder, annotation=annotation)
cmds.textField(purposeUI.inherited.field, edit=True, text=inherited)
cmds.textField(purposeUI.fromPrim.field, edit=True, text=fromPathStr)
purposeUI.material.field.fillUI(text, placeholder, annotation)
purposeUI.inherited.field.fillUI(inherited, editable=False)
purposeUI.fromPrim.field.fillUI(fromPathStr, editable=False)

def _fillGraphButton(self, matPathStr, button, menu):
def _fillGraphButton(self, matPathStr, graphButton, menu):
'''
Fill the graph button with the correct command.
'''
# Note: only show the graph button if LookdevX was loaded when the UI
# was created.
if not button:
if not graphButton:
return

# Note: only show the graph button if LookdevX is currently loaded.
hasLookdevX = self._hasLookdevX()
canGraph = bool(matPathStr and hasLookdevX)
cmds.symbolButton(button, edit=True, enable=canGraph, visible=hasLookdevX)
cmds.symbolButton(graphButton, edit=True, enable=canGraph, visible=hasLookdevX)

if canGraph:
ufePathStr = self._createUFEPathFromUSDPath(matPathStr)
Expand Down Expand Up @@ -414,20 +429,20 @@ def _showInExistingTab(ignore, tabName, ufePathStr):
return
cmds.lookdevXGraph(tabName=tabName, graphObject=ufePathStr)

def _fillGotoPrimButton(self, purposeUI, fromPath):
def _fillGotoPrimButton(self, gotoButton, gotoUsdPath):
'''
Fill the goto-prim button with the correct command.
'''
showButton = bool(fromPath)
cmds.symbolButton(purposeUI.fromPrim.button, edit=True, enable=showButton, visible=showButton)
showButton = bool(gotoUsdPath)
cmds.symbolButton(gotoButton, edit=True, enable=showButton, visible=showButton)

if fromPath:
ufePathStr = self._createUFEPathFromUSDPath(fromPath)
if gotoUsdPath:
ufePathStr = self._createUFEPathFromUSDPath(gotoUsdPath)
melCommand = 'updateAE "%s"' % ufePathStr
command = lambda *_: mel.eval(melCommand)
else:
command = ''
cmds.symbolButton(purposeUI.fromPrim.button, edit=True, command=command)
cmds.symbolButton(gotoButton, edit=True, command=command)

def _createUFEPathFromUSDPath(self, usdPath):
'''
Expand Down
Loading
Loading