Friday, May 9, 2014

Joys of Parenthood

So I was doing some test spadework before launching into a lot of code in the mainwindow module, just to make sure I actually could actually accomplish what I want to accomplish. And I found something that puzzled me a lot, and taught me something.

The basic UI for PPQT2 is a refinement of the previous version: a single window divided into two horizontal panes by a splitter. In V.1 the left pane is the single edit window. The right pane contains a tab-set with a bunch of activity panels. There's the image view panel that shows the OCR scan images, the notes panel where the user can jot notes about the project, the find/replace panel, etc. etc.

So in V.2, the left pane will be not just one edit widget, but a tabset that might contain one edit widget, or two, or however many documents the user wants to open. But what about the right pane? The activities there are used with all documents, but they contain document-unique items. The image view should show the scan images for the document that has the focus. The notes panel should show the notes for the document that has the focus. And so on.

Whenever the user selects a different edit widget from the tabset on the left, all the panels on the right need to change their contents to match. (Only one of eight needn't change when a different document comes into focus, the Help panel.) I thought a bit about how to make this happen.

One way not to do it, would be to design the different view objects, imageview and pageview and notesview and so on, to have some way to switch to a different model object. "Hey, image view, use the pagedata from this other book; hey words view, use the word counts from this other book, etc." Although conceptually a reasonable use of the model/view paradigm, this would complicate the coding of those view classes, and worse, has a performance implication. Some of the views (word view especially) consist of QTables with thousands of rows, and these can take a long time to repopulate. Switching from one 15,000-word vocabulary to a different 15,000 word vocabulary (even if the word lists were substantially the same, as in switching between two versions of the same book), could cause an unpleasant delay. Plus, there are more things than just the data contents that have to change. Perhaps in one book the user has sorted the words table ascending, case-sensitive, and in another book, descending and ignore-case and filtered to show only spellcheck errors. All the settings of these incidental controls need to switch, smoothly and quickly, when the user moves from one document to another.

So what I'd planned to do was to represent each open document with a Book object; and have that Book be the owner of a complete set of objects related to that document, models and views alike. It will own a notes view, an image view, everything—even its own Find/Replace panel, because the user will have set different options and have different lists of recent find/replace strings, for that document.

When the main window finds out that the edit view of a given document has received the focus, the main window needs to go to that document's book, and replace all the activity tabs on the right with the corresponding objects for the book where the user just clicked. They would be sitting in memory, all initialized and ready to show, so this should not take much time. But is this kind of wholesale replacement of tab contents feasible? I wrote a little test program to find out. And here it is in its entirety. Read it; copy it and run it if you like.

from PyQt5.QtCore import Qt, QPoint, QSize
from PyQt5.QtWidgets import QLabel, QMainWindow, QSplitter, QTabWidget

class mypanel(QLabel):
    def __init__(self, parent, text):
        super().__init__(text,None) #<-- see discussion
        self.owner = parent
        self.setFocusPolicy(Qt.StrongFocus)

    def focusInEvent(self,event):
        event.accept()
        self.owner.i_got_focus(int(self.text()))

class MainWindow(QMainWindow):
    def __init__(self, settings=None):
        super().__init__()
        self.editview_tabset = QTabWidget()
        self.panel_tabset = QTabWidget()
        # Create the splitter that contains the above two parts.
        self.splitter = QSplitter(Qt.Horizontal)
        self.splitter.addWidget(self.editview_tabset)
        self.splitter.addWidget(self.panel_tabset)
        # Set that splitter as the main window central (and only) widget
        self.setCentralWidget(self.splitter)
        self.resize(QSize(600, 600)) # make a reasonable size
        # Make some widgies
        self.pinks = []
        for j in range(4):
            self.pinks.append( mypanel(self, str(j)) )
            self.pinks[j].setStyleSheet("color:'#000080'; background:'#e0c0c0';")
        self.blues = []
        for j in range(4):
            self.blues.append( mypanel(self, str(j+4)) )
            self.blues[j].setStyleSheet("color:'#600000'; background:'#00e0d0';")
        # On the left, one pink and one blue with pink one active
        self.editview_tabset.addTab(self.pinks[0],self.pinks[0].text())
        self.editview_tabset.addTab(self.blues[0],self.blues[0].text())
        self.editview_tabset.setCurrentIndex(0)
        # On the right, two pinks to match the focussed one on the left
        self.panel_tabset.addTab(self.pinks[2],self.pinks[2].text())
        self.panel_tabset.addTab(self.pinks[3],self.pinks[3].text())

    def i_got_focus(self,num):
        print('num=',num) # document what's happening
        self.panel_tabset.setUpdatesEnabled(False)
        if num == 0 : # pink left widget got focus, make right side pink
            self.panel_tabset.removeTab(0)
            self.panel_tabset.insertTab(0,self.pinks[2],self.pinks[2].text())
            self.panel_tabset.removeTab(1)
            self.panel_tabset.insertTab(1,self.pinks[3],self.pinks[3].text())
        elif num == 4 : # blue left widget got focus, make right side blue
            self.panel_tabset.removeTab(0)
            self.panel_tabset.insertTab(0,self.blues[2],self.blues[2].text())
            self.panel_tabset.removeTab(1)
            self.panel_tabset.insertTab(1,self.blues[3],self.blues[3].text())
        self.panel_tabset.setUpdatesEnabled(True)

from PyQt5.QtWidgets import QApplication
app = QApplication([])
mw = MainWindow()
mw.show()
app.exec_()

If you run this you see a window divided in half. If you click on the left, nothing much happens (except "num=0" prints on stdout). If you select the other tab on the left, though, the panels on the right turn blue. Select the first tab on the left, the right turns pink. So this small-scale version works: when one of the two tabs on the left receive a focusInEvent, they call the main window's i_got_focus() method, which switches all two of the tabs on the right.

There are some oddities that you can discover if you experiment (put the focus on the right then click on a tab, not a window, on the left) but I think I see how to fix them. What I want to talk about in this post is a bug that had me puzzled for an hour.

Look at the initialization of the mypanel class where it says "see discussion". Originally it read:

        super().__init__(text,parent)

That's automatic Qt boilerplate that I wrote without even thinking. Change the above program to read that way, and run it. What do you see?

In the upper left corner of the window, overlapping the corner of the splitter, is a small blue rectangle with the number 7. What?!?

Real Qt mavens know immediately what has happened, but it took me a bit of head-scratching. In the main window initialization, when each of eight instances of mypane are created, the main window passes self. The panel widgets need that reference if they are to call the main window's i_got_focus method. But I was also passing it as the "parent" argument to the QLabel initializer. So each of the eight Qlabels was a child of the main window.

Being a parent of a widget means, among other things, that you display that widget. Four of the eight widgets are added to the tabsets, and that re-parents them to their tab widget. But pink 3 and blues 5, 6 and 7 remain parented by the main window. It displays them in the default location, upper left. Blue 7 is created last so it goes in front, hiding the others. Passing no-parent to QLabel fixed the problem.

So just as in life, things go better if you choose the right parents.

No comments: