Showing posts with label QPushButton. Show all posts
Showing posts with label QPushButton. Show all posts

Saturday, August 30, 2014

And the missing puzzle piece was...

Yesterday I struggled with the problem of how to direct the clicked() signal of a QPushButton. Although the symptoms were confusing, it seemed fairly clear that I was unintentionally connecting the default signal, whose signature is "clicked(bool)", when what I wanted was the no-parameter version, "clicked()".

In the old code, using the PyQt4 syntax, the signature of the desired signal was specified using the SIGNAL macro, SIGNAL("clicked()"). In the new signal/slot API, the doc shows how to specify a signal with a different parameter list, by "indexing" the signal name with a type, e.g. signalname[str].connect(...). What the doc didn't show was how to select the no-parameter signal.

This question was quickly answered on the PyQt mailing list by Baz Walter: you "index" the signal name with an empty tuple, signalname[()].connect(...). And that worked just fine, so my user-button connecting code now reads,

        for j in range(FindPanel.USER_BUTTON_MAX):
            self.user_buttons[j].clicked[()].connect(
                lambda b=j: self.user_button_click(b)
                )
            self.user_buttons[j].user_button_ctl_click.connect(
                lambda b=j : self.user_button_load(b)
                )

I worked out for myself what I was doing with an expression like

    lambda b=j: self.user_button_click(b)

A lambda expression lambda args : expr is exactly equivalent to

    def anonymous (args):
        expr

so

    lambda b=j: self.user_button_click(b)

is the equivalent of

    def anonymous (b=j):
        self.user_button_click(b)

In other words, I am specifying an argument with a default value! At execution time in the for-loop, the current value of j is substituted, for example

    def anonymous (b=17):
        self.user_button_click(b)

When the no-argument version of the signal is specified, the anonymous function is called with no arguments and the default index value is provided. When I unintentionally invoked the bool-passing signal version, the boolean it passed was taken as the argument b, and passed along instead of the default.

This does not explain to me why the current value of j is not also substituted when I write it this way:

    lambda : self.user_button_click(j)

This ought to mean,

    def anonymous ():
        self.user_button_click(17)

What happens instead is that every user-button clicked passes 23, the last-defined value of j in that loop. Which suggests to me that it is actually referencing the variable j. Oh wait, I could test that... Uh-huh! When I code it this way:

        for j in range(FindPanel.USER_BUTTON_MAX):
            self.user_buttons[j].clicked[()].connect(
                lambda : self.user_button_click(j)
                )
            self.user_buttons[j].user_button_ctl_click.connect(
                lambda b=j : self.user_button_load(b)
                )
            j = 'gotcha!'

and then click a user button, guess what the parameter to user_button_click is. Yup. "gotcha!"

So when a variable appears in the argument part of a lambda expression, its value is substituted for its name (b=j becomes b=17), but when a variable appears in the expression part, it is parsed as a reference to that variable, just as if the expression was in open code.

I'm glad it works this way or I'd have to think of a different scheme for directing the signals from multiple buttons to a single method (I'm sure there are other ways). But it seems inconsistent.

Edit: Actually, this is the result of Python's documented handling of default arguments. From the doc,

Default parameter values are evaluated when the function definition is executed. This means that the expression is evaluated once, when the function is defined, and that the same “pre-computed” value is used for each call.

So the conversion of b=j into b=17 (or whatever number) is exactly what should happen. So this is not some kind of hack, but rather an involved but acceptable way of getting a literal value into a lambda.

Friday, August 29, 2014

Connecting signals for an array of buttons

The Find panel has an array of 24 UserButton objects, which are custom QPushButtons. Two signals from each button are significant. The built-in clicked() signal causes one action to happen. A custom signal, generated during a control-click, causes a different action to happen. In version 1, under PyQt4, the code to connect these signals looked like this.

        for i in range(UserButtonMax):
            self.connect(self.userButtons[i], SIGNAL("clicked()"),
                                lambda b=i : self.userButtonClick(b) )
            self.connect(self.userButtons[i], SIGNAL("userButtonLoad"),
                                lambda b=i : self.userButtonLoad(b) )

The slot for each signal is a lambda that just calls the relevant function passing the integer index of the button. So in the userButtonClick(self,button) method, button is the index of the button.

It took a while to work out how to generate a succession of lambda functions, each passing a different number. I'm still not sure exactly why

lambda b=i : self.userButtonClick(b)

passes the literal value of i as it was when the expression was evaluated, while

lambda: self.userButtonClick(i)

does not. Lambdas are magic. Anyway, here is the very similar code for version 2, using the new and improved PyQt5 signal syntax.

        for j in range(FindPanel.USER_BUTTON_MAX):
            self.user_buttons[j].clicked.connect(
                lambda b=j : self.user_button_click(b)
                )
            self.user_buttons[j].user_button_ctl_click.connect(
                lambda b=j : self.user_button_load(b)
                )

The only difference between those, besides trivial changes of nomenclature, is the use of .clicked.connect instead of SIGNAL("clicked()"), I swear. I have looked at those until my eyes were bloodshot and they are the same.

But of course they don't work the same.

Well, actually, the custom button does work. When I control-click on a user-button, the self.user_button_load() method is called with its index number from 0 to 23, just like it ought to be. But a normal click calls the self.user_button_click() method with an argument of... wait for it... False. Not an int in 0..23, and the same for every button.

Now, right away I noticed that although in V1 I was invoking SIGNAL("clicked()"), in fact the clicked signal of QPushButton is documented as passing a boolean representing its checked state. Which would be False, as these are (by default and also by explicit code) not checkable buttons.

If that's the case, the only issue is how in the PyQt5 API to select the no-parameter overload of that signal. The documentation shows how to select an overloaded signal definition with a different parameter list, but not how to select one with no parameter.

But then I thought, OK, suppose that value does represent the boolean "checked" parameter of the signal. How is it getting into the parameter list of my method?!? The "slot" that receives the signal is an anonymous function defined as

lambda b=j : self.user_button_load(b)

which when it was evaluated was self.user_button_load(17) or such. That anonymous function gets a boolean as a parameter and ignores it, passing a literal when it calls user_button_load(). So howinahell does False get in there?

I've written this to the PyQt mailing list. We shall see.

Tuesday, August 12, 2014

How wide is a button?

Reviewing imageview.py I discovered a bit of code commented-out. In the setup of the UI, imageview creates two QPushButtons for instant zooms, labelled (in English) "to Height" and "to Width". Obviously these label strings are not the same width. And, if they are translated to some other language, they will have different widths still.

(Well, in German they would be zu Breite and zu Höhe, thank you Google Translate, the Width one still the shorter, but in French à Largeur and à Hauteur, almost equal. Can't quickly find a language in which the Height one is the shorter, but I'm sure it exists.)

Point is, I would like these buttons to always have identical widths, and not go from o_O to O_o as the translated UI changes. So, get the max of the two widths and assign it as the minimum width to both. Right? Right?

Seems not. The original code, which I'd commented out and then forgotten about, read

        w = max(self.zoom_to_height.width(),self.zoom_to_width.width())
        self.zoom_to_height.setMinimumWidth(w)
        self.zoom_to_width.setMinimumWidth(w)

That was a disaster, I don't know what Qt was returning for a width but the buttons ended up about 500 pixels wide each. No wonder I'd commented it out. But how to do it right? I just want to know the width of the label-text of the button, at run-time, after the locale has selected for the language.

Cutting short an hour's browsing around the Qt forums and the Qt Assistant, I find the answer is quite simple, really. One gets the fontMetrics from the widget. Then you can ask the fontMetrics object for the width() of any string of text, or specifically of the widget's current text. So I end up with an inner subroutine:

        # Function to return the actual width of the label text
        # of a widget. Get the fontMetrics and ask it for the width.
        def _label_width(widget):
            fm = widget.fontMetrics()
            return fm.width(widget.text())

And then apply it like so:

        w = 20 + max(_label_width(self.zoom_to_height),_label_width(self.zoom_to_width))
        self.zoom_to_height.setMinimumWidth(w)
        self.zoom_to_width.setMinimumWidth(w)

The 20-pixel extra is to make sure that even at a minimum squeeze, there will be some margin around the label text. The labels should be pushed to their minimum because they are in an HBoxLayout with stretch items on both sides.

Other than this, and the usual obsessive nit-picking of comment wording, all I had to change was some out-of-date code in the unit test driver. One more down.

Thursday, June 26, 2014

Doing It "Pythonically" (with added thought)

I'm starting work on the Find module. One of the features of that is that each of the input fields—the Find text and each of three independent Replace texts—has a memory pop-up: a button that pops up a menu of the ten most recent strings used in that field. It's a very nice help when you are alternating between two or three complex regex searches. (And stolen from Guiguts and BBEdit.)

The details of this widget were of course encapsulated in a class; for V.2 it is class RecallMenuButton. In V.1 this was a QComboBox because that is easy to present. I maintained the recent strings in a QStringList, and any time the list was updated the widget could reload itself in one call, self.insertItems(list). However, I wanted it to look like a square button, not a list, so I had it set its own max width to 29px. Under Windows that didn't work with the default style, so I had to set it to a non-native style "CleanLooks", and that no longer exists.

Anyway for V.2 I am using a "command button" which is a button that has an associated menu. I maintain a python list of QActions, one for each string. When the menu for the button emits the aboutToShow signal, I clear the menu and add the list of actions to it.

So I was starting to code the remember() method of this class, which takes a string and, if it is in the list now, deletes it; then adds it to the front of the list. But some considerations:

  • The string might very likely be in the list already as the first item, because it will be frequent to Find or Replace the same string over and over.
  • The list might be empty, at least the first time.
  • The string might not be in the list at all.
  • The list should never exceed MAX_STRINGS in length.

So the V.1 code is about a dozen lines, with a Fortran-like loop over the list to find and remove the string if it exists. But I'd like to do it more "pythonically" this time. I came up with this:

    def remember(self, string):
        new_stack = [act for act in self.string_stack if string != act.text()]
        new_stack[0:0] = QAction(string)
        self.string_stack = new_stack[0:self.MAX_STRINGS]

After composing which I said,

Now, this does not try to short-cut the presumptively common case of the string already being on top of the stack. If the added string is at the front of the stack it will be removed and added back in the same position. Is this a waste of time? Yes, but the test for that special case would look like:

    def remember(self, string):
        if len(self.string_stack):
            if string == self.string_stack[0].text() :
                return

...and that much extra code would surely waste as much time as it saved, or nearly. Or would it? Hmmm.

Edit: no, it would not be worth it and here's why. The Find UI is only going to "remember" a find or replace string if that string is manually edited by the user. When the Find or Replace line-edit field is filled by the program (from the remembered-strings popup menu or from a user-loadable macro button) a flag is cleared. Said flag is only set when the user edits in the field (the textChanged signal). When the field is actually used (Find or Replace action done), the flag is tested and remember() is called only if the flag is set. Thus remember() is only ever called for strings that the user has entered or altered. This greatly reduces both the number of calls to remember() and the chances that a remembered string already exists at any position in the stack. So there is no point in guarding against the input string being at stack[0].