Need help with QSlider collating as 1 undo

Hi all, I need some insights on this gui signals + undo.

In my tool, there is a QLineEdit and a QSlider in which both are connected/ linked to each other, where if you made changes in the QLineEdit, it will updates the QSlider, likewise if you made changes on the QSlider, it will updates the values in the QLineEdit.

Currently I am having issues with QSlider where I am trying to collate the actions (as User slides it) as 1 undo action.

Here is my code:

# The signal for the slider
self.ui.planeSizeHorizontalSlider.valueChanged.connect(self.plane_size_slider)

def plane_size_slider(self, value):
    img_plane_node = self.get_current_img_plane()
    value /= 10.00
    self.ui.planeSizeLineEdit.setText(str(value))
    self.set_plane_size(img_plane_node, value)

def set_plane_size(self, node, value):
    width = cmds.getAttr("{0}.width".format(node))
    height = cmds.getAttr("{0}.height".format(node))
    ratio =  height / width

    with UndoManager(): # UndoManager is a contextmanager
        cmds.setAttr("{0}.maintainRatio".format(node), 0)
        cmds.setAttr("{0}.width".format(node), value)
        cmds.setAttr("{0}.height".format(node), value * ratio)
        cmds.setAttr("{0}.maintainRatio".format(node), 1)

While in viewport, the imagePlane does gets scaled accordingly as I perform changes on the slider for visual purposes, but when it comes to undo, it seems to be to factoring into account of all the values that I have made (eg. the slider value was initially at 0 and I slide it to 10, and so 10 actions/ undos)

My question here is, if there are ways in which I can tell my QSlider to:

  • still uses valueChanged (so that User can still visualize the scaling)
  • but collate the ‘sliding’ as one action/ undo instead?

I tried using sliderReleased but it only seems to scale the item after it has been released and it is pretty much giving me the same results as without the use of it.

Any insights?

I think you need to use both sliderPressed and sliderReleased. Connect sliderPressed to a method that opens the undo queue. And connect sliderReleased to one that will close it.
This is a little dangerous though, because you can no longer use the context manager, and therefore can no longer absolutely guarantee that your undo will get closed. Also, there are some ways that you can cancel dragging a slider that won’t fire off the sliderReleased signal, so you need to be even more careful.

Hi @tfox_TD, thanks for getting back.

By any chance, if you may know of any tools/ scripts that adopt such approach in which I can take a look for reference if possible?

Additionally, I did not mention in my first post, but I have about 7 sets of the QLineEdit + QSlider… And so, will this means that I will need to create 7 sets of different methods for it?

Sorry, I don’t know of anything that does this with sliders, though I did something similar when I implemented MMB drag in Qt.

And no, you don’t need a separate undo open/close function for each slider. You can re-use the same one over and over. There’s just lots of connections to make.

self.ui.planeSizeHorizontalSlider.sliderPressed.connect(self.open_undo)
self.ui.planeSizeHorizontalSlider.sliderPressed.connect(self.close_undo)
self.ui.planeSizeHorizontalSlider.valueChanged.connect(self.plane_size_slider)

self.ui.planeXRotHorizontalSlider.sliderPressed.connect(self.open_undo)
self.ui.planeXRotHorizontalSlider.sliderPressed.connect(self.close_undo)
self.ui.planeXRotHorizontalSlider.valueChanged.connect(self.plane_x_rot_slider)

self.ui.planeXPosHorizontalSlider.sliderPressed.connect(self.open_undo)
self.ui.planeXPosHorizontalSlider.sliderPressed.connect(self.close_undo)
self.ui.planeXPosHorizontalSlider.valueChanged.connect(self.plane_x_pos_slider)

Also, If you’re feeling really fancy, you could even do it something like this so you can easily add lots and lots of sliders just by adding slider/func pairs to pairs. Currently this code and the code above will do the exact same thing, it’s just two different ways of writing it.

pairs = [
    (self.ui.planeSizeHorizontalSlider, self.plane_size_slider),
    (self.ui.planeXRotHorizontalSlider, self.plane_x_rot_slider),
    (self.ui.planeXPosHorizontalSlider, self.plane_x_pos_slider),
]

for slider, func_to_call in pairs:
    slider.sliderPressed.connect(self.open_undo)
    slider.sliderPressed.connect(self.close_undo)
    slider.valueChanged.connect(func_to_call)

You could create a partial method with args of the line edit/slider you want to update for each one:

from functools import partial

def do_update(self, line_edit):
    # Do something with the line edit.
    ...

self.slider_01.valueChanged.connect(partial(self.do_update, line_edit_01))
self.slider_02.valueChanged.connect(partial(self.do_update, line_edit_02))
self.slider_03.valueChanged.connect(partial(self.do_update, line_edit_03))

Hi @tfox_TD and @chalk, appreciate your replies :smiley:

I tried using the method as mentioned by @tfox_TD:

self.ui.planeSizeHorizontalSlider.sliderPressed.connect(self.slider_pressed)
self.ui.planeSizeHorizontalSlider.sliderReleased.connect(self.slider_released)
self.ui.planeSizeHorizontalSlider.valueChanged.connect(self.plane_size_slider)


def slider_pressed(self):
    cmds.undoInfo(openChunk=True, chunkName='SlideTest')
    
def slider_released(self):
    cmds.undoInfo(closeChunk=True, chunkName='SlideTest')

def plane_size_slider(self, value):
    img_plane_node = self.get_current_img_plane()
    value /= 10.00
    self.ui.planeSizeLineEdit.setText(str(value))
    self.set_plane_size(img_plane_node, value)

def set_plane_size(self, node, value):
    width = cmds.getAttr("{0}.width".format(node))
    height = cmds.getAttr("{0}.height".format(node))
    ratio =  height / width

    # This got regiters as additional actions...
    cmds.setAttr("{0}.maintainRatio".format(node), 0)
    cmds.setAttr("{0}.width".format(node), value)
    cmds.setAttr("{0}.height".format(node), value * ratio)
    cmds.setAttr("{0}.maintainRatio".format(node), 1)

However my undo queue is incorrect. Current queue stemming from the above code (where the queue is empty to begin with)

# 1:  # 
# 2:  # 
# 3:  # 
# 4:  # 
# 5: SlideTest # 
# 6:  # 
# 7:  # 
# 8:  # 
# 9:  # 
# 10: SlideTest # 

instead of what I am expecting it to be:

# 1: SlideTest # 
# 2: SlideTest # 

As you can see that it seems to registers the setAttr in set_plane_size() as 4 actions. If I tried using the contextmanager as I have done initially, while the undo queue looks somewhat reasonable, eg:

# 1: SlideTest # 
# 2: ContextChunk # 
# 3: SlideTest # 
# 4: ContextChunk # 

While I can perform the undo(s), the values changing aren’t exactly consistent with the previous value.

Eg. I toggle the translateX. From 0 -> 10, 10 -> 14.8 but instead when I perform the first undo, the value of translateX instead of 10, I got a slightly offsetted value such as 11.5 etc

First off, we should have a minimal test case. When things really don’t work as expected, step 1 is to make the smallest possible code that reproduces the bug. This has gone back and forth enough that it’s worth taking that time.

(I don’t know what they’re actually called, so I’m calling the thing we drag the “indicator” and the line that we can drag the indicator along the “bar”)

And in my testing, it looks the signals aren’t getting triggered in the order I’m expecting when I click on the bar and the indicator pops over to the click position. In that case, the valueChanged is getting called before the undoOpen, and we’re getting changes happening outside the undo chunk. Best way I can think to do this is add a variable that keeps track of whether we’re supposed to be in a chunk, and add some protections for double-open or double-close.

# I'm using Qt.py
# If you're not, you'll have to change these Qt imports to something that works for you
from Qt.QtWidgets import QDialog, QVBoxLayout, QSlider, QApplication
from Qt.QtCore import Qt
from maya import cmds

class SliderTest(QDialog):
	def __init__(self, parent=None):
		super(SliderTest, self).__init__(parent)
		self.resize(400, 37)
		self.vLayout = QVBoxLayout(self)
		self.hSlider = QSlider(self)
		self.hSlider.setOrientation(Qt.Horizontal)
		self.vLayout.addWidget(self.hSlider)

		self.hSlider.sliderPressed.connect(lambda : self.undoOpen("SlideTest"))
		self.hSlider.sliderReleased.connect(self.undoClose)
		self.hSlider.valueChanged.connect(self.updateValue)
		self._inChunk = False
		# Create a new image plane just for testing
		self.node = cmds.imagePlane(width=100, height=50)[-1]

	def undoOpen(self, name):
		if not self._inChunk: # make sure we don't double-open the chunk
			self._inChunk = True
			cmds.undoInfo(openChunk=True, chunkName=name)

	def undoClose(self, name):
		if self._inChunk: # make sure we don't double-close the chunk
			self._inChunk = False
			cmds.undoInfo(closeChunk=True)

	def updateValue(self, val):
		self.undoOpen("SlideTest")
		width = cmds.getAttr("{0}.width".format(self.node))
		height = cmds.getAttr("{0}.height".format(self.node))
		ratio =  float(height) / width

		cmds.setAttr("{0}.maintainRatio".format(self.node), 0)
		cmds.setAttr("{0}.width".format(self.node), val)
		cmds.setAttr("{0}.height".format(self.node), val * ratio)
		cmds.setAttr("{0}.maintainRatio".format(self.node), 1)


# Get the Maya Main Window quick-n-dirty
app = QApplication.instance()
mayaWin = [i for i in app.topLevelWidgets() if i.objectName() == "MayaWindow"][0]

# start the test UI
st = SliderTest(mayaWin)
st.show()
1 Like

Thank you for your help!
I was not aware that I need to open the chunk at the valueChanged as I have thought the opening and closing of chunks should and only be driven at sliderPressed and sliderReleased…

That is the portion that I am missing.

That’s what I was missing too, until I made that test case :slight_smile:

It still feels ugly to me, though. In the future, I may dig a little deeper and play with the actionTriggered and sliderMoved signals to see if I can get rid of the open chunk at the beginning of valueChanged. But since this works as expected now, I’ll probably just leave it alone.