New signal connects to old slot instead of separate slot

Question:

I am trying to tag x-spans of a data trace and populate a table with tagNames, starting x value, and the ending x value. I am using a dictionary of ‘highlight’ objects to keep track of the x-spans in case they need to be edited (increased or decreased) later. The dictionary maps the x Start value to the highlight object, as the x start values are expected to be unique (there is no overlap of x-spans for the tagging).

In order to do this, I am emitting a signal when the user beings to edit a cell on the table. The function that the first signal connects to emits another signal (ideally for whether the xStart is changed vs. the xEnd, but I have only implemented the xStart thus far), which actually changes the appearance of the span to match the edit.

I asked a similar question a few weeks ago but wasn’t able to get an answer. The old question is here: PyQT5 slot parameters not updating after first call. In response to the tips given there, I wrote the following example:

import sys

from PyQt5.QtWidgets import *
from PyQt5 import QtCore
from PyQt5.QtCore import *
from PyQt5.QtGui import *

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import matplotlib.widgets as mwidgets
from functools import partial

class Window(QMainWindow):

    def __init__(self, parent = None):
        super(Window, self).__init__(parent)
        self.resize(1600, 800)
        self.MyUI()
        
    def MyUI(self):

        canvas = Canvas(self, width=14, height=12, dpi=100)
        canvas.move(0,0)

 #use this object in the dictionary to hold onto all the spans. 
class highlight:
    def __init__(self, tag, xStart, xEnd, highlightObj):
        self.tag = tag
        self.xStart = xStart
        self.xEnd = xEnd
        self.highlightObj = highlightObj

class Canvas(FigureCanvas):
    def __init__(self, parent, width = 14, height = 12, dpi = 100):
        Plot = Figure(figsize=(width, height), dpi=dpi)
        self.Axes = Plot.add_subplot(111)
        self.Axes.set_position([0.05, 0.58, 0.66, 0.55])
        self.rowCount = 0
        super().__init__(Plot)
        self.setParent(parent)

        
        ##add all relevant lines to plot 
        self.Axes.plot([0,1,2,3,4], [3, 4, 5, 6, 7])
        self.Axes.set_xlabel('Frame', fontsize = 10) 
        self.Axes.grid()
        self.Axes.set_aspect(1)
        Plot.canvas.draw()

        
        self.highlights = {} #empty dictionary to store all the tags.

        ##define a table to hold the values postselection
        self.taggingTable = QTableWidget(self)
        self.taggingTable.setColumnCount(3)
        self.taggingTable.setRowCount(100)
        self.taggingTable.setGeometry(QRect(1005,85, 330, 310))
        self.taggingTable.setHorizontalHeaderLabels(['Behavior','Start Frame', 'End Frame'])


        Canvas.span = mwidgets.SpanSelector(self.Axes, self.onHighlight, "horizontal", 
                                        interactive = True, useblit=True, props=dict(alpha=0.5, facecolor="blue"),)
        self.draw_idle()

        self.taggingTable.selectionModel().selectionChanged.connect(self.onCellSelect)
        self.draw_idle()

        ##highlighting adds a highlight item to the directory. 
    def onHighlight(self, xStart, xEnd):
        tagName = "No Tag"
        self.taggingTable.setItem(self.rowCount, 0, QTableWidgetItem(tagName))
        self.taggingTable.setItem(self.rowCount, 1, QTableWidgetItem(str(int(xStart))))
        self.taggingTable.setItem(self.rowCount, 2, QTableWidgetItem(str(int(xEnd))))
        self.rowCount = self.rowCount + 1
        highlightObj = self.Axes.axvspan(xStart, xEnd, color = 'blue', alpha = 0.5)
        self.highlights[int(xStart)] = highlight(tagName, xStart, xEnd, highlightObj)
        self.draw_idle()
                
    def xStartChanged(self, xStart, rowVal):
        if self.inCounter == 0:
            print("xStart in slot: ", xStart)
            xEnd = self.highlights[xStart].xEnd
            xStartNew = int(self.taggingTable.item(rowVal, 1).text())
            self.highlights[xStart].highlightObj.remove() #remove old from the plot
            del self.highlights[xStart]                   #remove old from directory
            highlightObj = self.Axes.axvspan(xStartNew, xEnd, color = 'blue', alpha = 0.5) #add new to plot
            self.highlights[xStartNew] = highlight("No tagName", xStartNew, xEnd, highlightObj) #add new to directory
            self.taggingTable.clearSelection() #deselect value from table
            self.draw_idle()
        self.inCounter = self.inCounter + 1

    def onCellSelect(self):
        index = self.taggingTable.selectedIndexes()
        if len(index) != 0:
            rowVal = index[0].row()
            if not (self.taggingTable.item(rowVal, 1) is None):
                xStart = int(self.taggingTable.item(rowVal, 1).text())
                print("--------------")
                print("xStart in signal: ", xStart)
                self.inCounter = 0
                self.taggingTable.itemChanged.connect(lambda: self.xStartChanged(xStart, rowVal))        
    
    


app = QApplication(sys.argv)
window = Window()
window.show()
app.exec()

A test I run is when I highlight two traces:

two traces and their table values

and then I successfully change a first trace:

editing first trace

However, when I attempt to edit the second trace, the program crashes:

editing second trace before hitting enter

To debug, I tried to check what the signals were emitting and receiving. it produces the following output:

--------------
xStart in signal:  0
xStart in slot:  0 ##First slot call gets correct signal 
--------------
xStart in signal:  3
xStart in slot:  0 ## Second slot gets the first signal instead of the second
Traceback (most recent call last):
  File "//Volumes/path/file.py", line 105, in <lambda>
    self.taggingTable.itemChanged.connect(lambda: self.xStartChanged(xStart, rowVal))        
  File "//Volumes/path/file.py", line 86, in xStartChanged
    xEnd = self.highlights[xStart].xEnd
KeyError: 0
zsh: abort      python Volumes/path file.py

I tried to use information online on unique connections but I am not sure how to implement them. Thank you in advance for any help.

Asked By: ssr

||

Answers:

It seems that what you need is table-widget signal that emits an item and its old text value whenever a change is made to a specific column. Unfortunately, the itemChanged signal isn’t really suitable, because it doesn’t indicate what changed, and it doesn’t supply the previous value. So, to work around this limitation, one solution would be to subclass QTableWidget / QTableWidgetItem and emit a custom signal with the required parameters. This will completely side-step the issue with multiple signal-slot connections.

The implementation of the subclasses is quite simple:

class TableWidgetItem(QTableWidgetItem):
    def setData(self, role, value):
        oldval = self.text()
        super().setData(role, value)
        if role == Qt.EditRole and self.text() != oldval:
            table = self.tableWidget()
            if table is not None:
                table.itemTextChanged.emit(self, oldval)

class TableWidget(QTableWidget):
    itemTextChanged = pyqtSignal(TableWidgetItem, str)

Below is basic a demo based on your example that shows how to use them. (Note that I have made no attempt to handle xEnd as well, as that would go beyond the scope of the immediate issue).

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import matplotlib.widgets as mwidgets

class Window(QMainWindow):
    def __init__(self, parent = None):
        super(Window, self).__init__(parent)
        self.resize(1600, 800)
        self.MyUI()

    def MyUI(self):
        canvas = Canvas(self, width=14, height=12, dpi=100)
        canvas.move(0,0)

# CUSTOM SUBCLASSES

class TableWidgetItem(QTableWidgetItem):
    def setData(self, role, value):
        oldval = self.text()
        super().setData(role, value)
        if role == Qt.EditRole and self.text() != oldval:
            table = self.tableWidget()
            if table is not None:
                table.itemTextChanged.emit(self, oldval)

class TableWidget(QTableWidget):
    itemTextChanged = pyqtSignal(TableWidgetItem, str)


 #use this object in the dictionary to hold onto all the spans.
class highlight:
    def __init__(self, tag, xStart, xEnd, highlightObj):
        self.tag = tag
        self.xStart = xStart
        self.xEnd = xEnd
        self.highlightObj = highlightObj

class Canvas(FigureCanvas):
    def __init__(self, parent, width = 14, height = 12, dpi = 100):
        Plot = Figure(figsize=(width, height), dpi=dpi)
        self.Axes = Plot.add_subplot(111)
        self.Axes.set_position([0.05, 0.58, 0.66, 0.55])
        self.rowCount = 0
        super().__init__(Plot)
        self.setParent(parent)

        ##add all relevant lines to plot
        self.Axes.plot([0,1,2,3,4], [3, 4, 5, 6, 7])
        self.Axes.set_xlabel('Frame', fontsize = 10)
        self.Axes.grid()
        self.Axes.set_aspect(1)
        Plot.canvas.draw()

        self.highlights = {} #empty dictionary to store all the tags.

        ##define a table to hold the values postselection
        # USE CUSTOM TABLE SUBCLASS
        self.taggingTable = TableWidget(self)
        self.taggingTable.setColumnCount(3)
        self.taggingTable.setRowCount(100)
        self.taggingTable.setGeometry(QRect(1005,85, 330, 310))
        self.taggingTable.setHorizontalHeaderLabels(['Behavior','Start Frame', 'End Frame'])
        # CONNECT TO CUSTOM SIGNAL
        self.taggingTable.itemTextChanged.connect(self.xStartChanged)

        Canvas.span = mwidgets.SpanSelector(self.Axes, self.onHighlight, "horizontal",
                                        interactive = True, useblit=True, props=dict(alpha=0.5, facecolor="blue"),)
        self.draw_idle()

        ##highlighting adds a highlight item to the directory.
    def onHighlight(self, xStart, xEnd):
        tagName = "No Tag"
        self.taggingTable.setItem(self.rowCount, 0, QTableWidgetItem(tagName))
        # USE CUSTOM ITEM SUBCLASS
        self.taggingTable.setItem(self.rowCount, 1, TableWidgetItem(str(int(xStart))))
        self.taggingTable.setItem(self.rowCount, 2, QTableWidgetItem(str(int(xEnd))))
        self.rowCount = self.rowCount + 1
        highlightObj = self.Axes.axvspan(xStart, xEnd, color = 'blue', alpha = 0.5)
        self.highlights[int(xStart)] = highlight(tagName, xStart, xEnd, highlightObj)
        self.draw_idle()

    def xStartChanged(self, item, oldVal):
        try:
            # VALIDATE NEW VALUES
            xStart = int(oldVal)
            xStartNew = int(item.text())
        except ValueError:
            pass
        else:
            print("xStart in slot: ", xStart)
            xEnd = self.highlights[xStart].xEnd
            self.highlights[xStart].highlightObj.remove() #remove old from the plot
            del self.highlights[xStart]                   #remove old from directory
            highlightObj = self.Axes.axvspan(xStartNew, xEnd, color = 'blue', alpha = 0.5) #add new to plot
            self.highlights[xStartNew] = highlight("No tagName", xStartNew, xEnd, highlightObj) #add new to directory
            self.taggingTable.clearSelection() #deselect value from table
            self.draw_idle()


app = QApplication(sys.argv)
window = Window()
window.show()
app.exec()
Answered By: ekhumoro