Ir al contenido principal

Ralsina.Me — El sitio web de Roberto Alsina

PyQt By Example (Session 4)

PyQt By Example: Session 4

Action!

Requirements

If you have not done it yet, please check the pre­vi­ous ses­sion­s:

All files for this ses­sion are here: Ses­sion 4 at GitHub. You can use them, or you can fol­low these in­struc­tions start­ing with the files from Ses­sion 3 and see how well you worked!

Action!

What's an Action?

When we fin­ished ses­sion 3 we had a ba­sic to­do ap­pli­ca­tion, with very lim­it­ed func­tion­al­i­ty: you can mark tasks as done, but you can't ed­it them, you can't cre­ate new ones, and you can't re­move them, much less do things like fil­ter­ing them.

/static/tut3-window5.png

A very lim­it­ed ap­pli­ca­tion

To­day we will start writ­ing code and de­sign­ing UI to do those things.

The key con­cept here is Ac­tion­s.

  • Help? That's an ac­­tion

  • Open File? That's an ac­­tion

  • Cut / Copy / Paste? Those are ac­­tions too.

Let's quote The Fine Man­u­al:

The QAc­tion class pro­vides an ab­stract us­er in­ter­face ac­tion that can be in­sert­ed in­to wid­get­s.

In ap­pli­ca­tions many com­mon com­mands can be in­voked via menus, tool bar but­ton­s, and key­board short­cut­s. Since the us­er ex­pects each com­mand to be per­formed in the same way, re­gard­less of the us­er in­ter­face used, it is use­ful to rep­re­sent each com­mand as an ac­tion.

Ac­tions can be added to menus and tool bars, and will au­to­mat­i­cal­ly keep them in sync. For ex­am­ple, in a word pro­ces­sor, if the us­er press­es a Bold tool bar but­ton, the Bold menu item will au­to­mat­i­cal­ly be checked.

A QAc­tion may con­tain an icon, menu tex­t, a short­cut, sta­tus tex­t, "What's This?" tex­t, and a tooltip.

The beau­ty of ac­tions is that you don't have to code things twice. Why add a "Copy" but­ton to a tool bar, then a "Copy" menu en­try, then write two han­dler­s?

Cre­ate ac­tions for ev­ery­thing the us­er can do then plug them in your UI in the right places. If you put them in a menu, it's a menu en­try. If you put it in a tool bar, it's a but­ton. Then write a han­dler for the ac­tion, con­nect it to the right sig­nal, and you are done.

Let's start with a sim­ple ac­tion: Re­move Task. We will be do­ing the first half of the job, cre­at­ing the ac­tion it­self and the UI, us­ing de­sign­er.

Creating Actions in Designer

First, let's go to the Ac­tion Ed­i­tor and ob­vi­ous­ly click on the "New Ac­tion" but­ton and start cre­at­ing it:

/static/tut4-action1.png

Cre­at­ing a New Ac­tion

A few re­mark­s:

  • If you don't know where the "X" icon came from, you have not read ses­­sion 3 ;-)

  • The ac­tion­Delete_­Task ob­ject name is au­to­mat­i­cal­ly gen­er­at­ed from the text field. In some cas­es that can lead to aw­ful names. If that's the case, you can just change the ob­ject name.

  • The same text is used for the icon­­Text and toolTip prop­er­ties. If that's not cor­rec­t, you can change it lat­er.

Once you cre­ate the ac­tion, it will not be marked as "Used" in the ac­tion ed­i­tor. That's be­cause it ex­ist­s, but has not been made avail­able to the us­er any­where in the win­dow we are cre­at­ing.

There are two ob­vi­ous places for this ac­tion: a tool bar, and a menu bar.

Adding Actions to a Tool Bar

To add an ac­tion to a tool bar, first make sure there is one. If you don't have one in your "Ob­ject In­spec­tor", then right click on Main­Win­dow (ei­ther the ac­tu­al win­dow, or its en­try in the in­spec­tor), and choose "Add Tool Bar".

You can add as many tool bars as you wan­t, but try to want on­ly one, un­less you have a very good rea­son (we will have one in ses­sion 5 ;-)

Af­ter you cre­ate the tool bar, you will see emp­ty space be­tween the menu bar (that says "Type Here") and the task list wid­get. That space is the tool bar.

Drag your ac­tion's icon from the ac­tion ed­i­tor to the tool bar.

That's it!

/static/tut4-action2.png

The Delete Task ac­tion is now in the tool bar.

Adding Actions to a Menu Bar

Again, our menu bar is emp­ty, it has on­ly a sign say­ing "Type Here". While we could just drag our ac­tion to the menu bar, that would place "Delete Task" as a top lev­el menu, which is re­al­ly an un­usu­al choice.

So, let's cre­ate a "Task" menu first:

  • Click on the "Type Here".

  • Type "Task" (with­­out the quotes)

/static/tut4-action3.png

Cre­at­ing a menu

If you see the last im­age, by do­ing that we cre­at­ed a QMenu ob­ject called menu­Task (a­gain, the ob­ject name is based on what we type­d).

We want to add the delete task ac­tion to that menu. To do that, drag the ac­tion to the "Task" in the menu bar, then to the menu that ap­pears when you do that.

/static/tut4-action4.png

The Delete Task ac­tion is now in the menu bar

Now we have our ac­tion in the tool bar and in the menu bar. But of course, it does noth­ing. So, let's work on that nex­t.

Save it, run build.sh, let's move forward.

Deleting a Task

From session 2 you should remember AutoConnect. If you don't, refresh that session, because that's what we will use now. We want to do something when the actionDelete_Task object emits its triggered signal.

Therefore, we want to implement Main.on_actionDelete_Task_triggered (see why action's object names are important? I could have called it delete instead).

An Im­por­tant Is­sue

Here we take a small de­tour be­cause there is a prob­lem with PyQt which is mild­ly an­noy­ing.

Con­sid­er this triv­ial ver­sion of our method:

def on_actionDelete_Task_triggered(self,checked=None):
    print "adtt",checked

What's print­ed if I click on the tool­bar but­ton?

[ralsina@hp session4]$ python main.py
adtt False
adtt None

The same thing hap­pens if you se­lect "Delete Task" from the menu: our slot gets called twice. This prob­lem is there when you use Au­to­Con­nect for sig­nals with ar­gu­ments that can al­so be emit­ted with­out ar­gu­ments.

How can you tell if that's the case? In the Qt docs they will be list­ed with de­fault ar­gu­ments.

For ex­am­ple, this sig­nal has the prob­lem:

void triggered ( bool checked = false )

This one does­n't:

void toggled ( bool checked )

The tech­ni­cal ex­pla­na­tion for this is ... in­volved but the prac­ti­cal so­lu­tion is triv­ial:

Make sure checked is not None in your slot:

def on_actionDelete_Task_triggered(self,checked=None):
    if checked is None: return

This way, you will ig­nore the slot called with no ar­gu­ments, and run the re­al code on­ly once.

And here is the re­al code, which is quite short:

def on_actionDelete_Task_triggered(self,checked=None):
    if checked is None: return
    # First see what task is "current".
    item=self.ui.list.currentItem()

    if not item: # None selected, so we don't know what to delete!
        return
    # Actually delete the task
    item.task.delete()
    todo.saveData()

    # And remove the item. I think that's not pretty. Is it the only way?
    self.ui.list.takeTopLevelItem(self.ui.list.indexOfTopLevelItem(item))

Ex­cept for the last line, that code should be ob­vi­ous. The last line? I am not re­al­ly sure it's even right but it work­s.

You can now test the feature. Remember that if you run out of tasks, you can execute python todo.py and get new ones.

Fine Tuning Your Actions

There are some in­ter­face prob­lems with our work so far:

  1. The Task menu and the Delete Task ac­­tion lack key­board short­­­cut­s.

    This is very im­­por­­tan­t. It makes the app work bet­ter for the reg­u­lar us­er. Be­­sides, of­ten they will ex­pect the short­­­cuts to be there and there is no rea­­son to frus­­trate your user!

    Luck­­i­­ly, this is triv­ial to fix, just set the short­­­cut prop­er­­ty for ac­­tion_Delete_­­Task, and change menu­­Task's text prop­er­­ty to "&­­Task".

  2. The Delete Task ac­tion is en­abled even when it does­n't ap­ply. If you have no task se­lect­ed, the us­er can trig­ger it, but it does noth­ing. That's a bit sur­pris­ing, and sur­pris­ing your users is not very nice, IMHO.

    There is some ar­gu­ment about this, no­tably from Joel Spol­sky so maybe I am just old fash­ioned!

    To selectively enable or disable your actions when there is/isn't a selected item in our task list, we need to react to our list's currentItemChanged signal. Here's the code:

    def on_list_currentItemChanged(self,current=None,previous=None):
        if current:
            self.ui.actionDelete_Task.setEnabled(True)
        else:
            self.ui.actionDelete_Task.setEnabled(False)

    Al­so, we need to make Delete Task start dis­abled be­cause we start the app with no task se­lect­ed. That's done from de­sign­er us­ing its "en­abled" prop­er­ty.

    Since there is on­ly one Delete Task ac­tion, this code af­fects the task bar and al­so the menu bar. That helps keep your UI con­sis­tent and well-be­haved.

/static/tut4-window6.png

A very lim­it­ed ap­pli­ca­tion

Coming Soon

Well, that was a rather long ex­pla­na­tion for a small fea­ture, was­n't it? Don't wor­ry, the next ac­tions will be much eas­i­er to ad­d, be­cause I ex­pect you to read "I added an ac­tion called New Task" and know what I am talk­ing about.

And in the next ses­sion, we will do just that. And we will cre­ate our first di­a­log.

Further Reading


Here you can see what changed be­tween the old and new ver­sion­s:

Modified lines:  None
Added line:  44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62
Removed line:  None
Generated by diff2html
© Yves Bailly, MandrakeSoft S.A. 2001
diff2html is licensed under the GNU GPL.

  session3/main.py     session4/main.py
  60 lines
1557 bytes
Last modified : Thu Mar 5 02:03:34 2009

    79 lines
2300 bytes
Last modified : Sat Mar 7 02:06:29 2009

1 # -*- coding: utf-8 -*-   1 # -*- coding: utf-8 -*-
2   2
3 """The user interface for our app"""   3 """The user interface for our app"""
4   4
5 import os,sys   5 import os,sys
6   6
7 # Import Qt modules   7 # Import Qt modules
8 from PyQt4 import QtCore,QtGui   8 from PyQt4 import QtCore,QtGui
9   9
10 # Import the compiled UI module   10 # Import the compiled UI module
11 from windowUi import Ui_MainWindow   11 from windowUi import Ui_MainWindow
12   12
13 # Import our backend   13 # Import our backend
14 import todo   14 import todo
15   15
16 # Create a class for our main window   16 # Create a class for our main window
17 class Main(QtGui.QMainWindow):   17 class Main(QtGui.QMainWindow):
18     def __init__(self):   18     def __init__(self):
19         QtGui.QMainWindow.__init__(self)   19         QtGui.QMainWindow.__init__(self)
20   20
21         # This is always the same   21         # This is always the same
22         self.ui=Ui_MainWindow()   22         self.ui=Ui_MainWindow()
23         self.ui.setupUi(self)   23         self.ui.setupUi(self)
24   24
25         # Let's do something interesting: load the database contents   25         # Let's do something interesting: load the database contents
26         # into our task list widget   26         # into our task list widget
27         for task in todo.Task.query().all():   27         for task in todo.Task.query().all():
28             tags=','.join([t.name for t in task.tags])   28             tags=','.join([t.name for t in task.tags])
29             item=QtGui.QTreeWidgetItem([task.text,str(task.date),tags])   29             item=QtGui.QTreeWidgetItem([task.text,str(task.date),tags])
30             item.task=task   30             item.task=task
31             if task.done:   31             if task.done:
32                 item.setCheckState(0,QtCore.Qt.Checked)   32                 item.setCheckState(0,QtCore.Qt.Checked)
33             else:   33             else:
34                 item.setCheckState(0,QtCore.Qt.Unchecked)   34                 item.setCheckState(0,QtCore.Qt.Unchecked)
35             self.ui.list.addTopLevelItem(item)   35             self.ui.list.addTopLevelItem(item)
36   36
37     def on_list_itemChanged(self,item,column):   37     def on_list_itemChanged(self,item,column):
38         if item.checkState(0):   38         if item.checkState(0):
39             item.task.done=True   39             item.task.done=True
40         else:   40         else:
41             item.task.done=False   41             item.task.done=False
42         todo.saveData()   42         todo.saveData()
43   43
      44     def on_actionDelete_Task_triggered(self,checked=None):
      45         if checked is None: return
      46         # First see what task is "current".
      47         item=self.ui.list.currentItem()
      48
      49         if not item: # None selected, so we don't know what to delete!
      50             return
      51         # Actually delete the task
      52         item.task.delete()
      53         todo.saveData()
      54
      55         # And remove the item. I think that's not pretty. Is it the only way?
      56         self.ui.list.takeTopLevelItem(self.ui.list.indexOfTopLevelItem(item))
      57
      58     def on_list_currentItemChanged(self,current=None,previous=None):
      59         if current:
      60             self.ui.actionDelete_Task.setEnabled(True)
      61         else:
      62             self.ui.actionDelete_Task.setEnabled(False)
44   63
45 def main():   64 def main():
46     # Init the database before doing anything else   65     # Init the database before doing anything else
47     todo.initDB()   66     todo.initDB()
48   67
49     # Again, this is boilerplate, it's going to be the same on   68     # Again, this is boilerplate, it's going to be the same on
50     # almost every app you write   69     # almost every app you write
51     app = QtGui.QApplication(sys.argv)   70     app = QtGui.QApplication(sys.argv)
52     window=Main()   71     window=Main()
53     window.show()   72     window.show()
54     # It's exec_ because exec is a reserved word in Python   73     # It's exec_ because exec is a reserved word in Python
55     sys.exit(app.exec_())   74     sys.exit(app.exec_())
56   75
57   76
58 if __name__ == "__main__":   77 if __name__ == "__main__":
59     main()   78     main()
60   79

Generated by diff2html on Sat Mar 7 02:08:22 2009
Command-line:
/home/ralsina/bin/diff2html session3/main.py session4/main.py
Emanuele Rampichini / 2009-04-09 16:19:

In order to avoid the "Delete Task" to be enabled at first launch you should put:

self.ui.list.setCurrentItem(None)

next to line 35.

By the way this tutorial is usefull! Thanks mate.

Roberto Alsina / 2009-04-09 19:25:

Yes, that's the other way to do it.
I prefer to keep initial behaviour in designer if I can.

Emanuele Rampichini / 2009-04-10 09:54:

It's not another way. Only setting "disable" for the action in designer doesn't works for me.

Roberto Alsina / 2009-04-10 12:14:

You are right. It should work, though, so I will take a look at why it doesn't.

Roberto Alsina / 2009-04-10 12:18:

apparently, on_list_currentItemChanged is being called, even though I don't see an item being selected, that's why it gets enabled.

Adding a disable() call simply hides the problem.

I will take a better look later today.

Daniel Attling / 2009-06-01 10:32:

How would one go about and intercept/overload/autoconnect the top right x-button so cleanup could be done gracefully?

Bajusz Tamás / 2010-08-23 10:06:

You can use QtCore.pyqtSlot decorator to avoid your slots get called twice:
file:///usr/share/doc/pyqt4/doc/pyqt4ref.html#connecting-slots-by-name

Lorenzo M. Catucci / 2011-02-14 14:28:

Another way to avoid the issue you highlight in the "An Important Issue" box is to add explicit decorations as suggested
at the end of the docs you refer to:

@QtCore.pyqtSlot('bool')
def on_actionDelete_Task_triggered(self, checked):
print "adtt",checked

employment background check / 2011-12-27 23:23:


Hi very nice article

cell phone lookup / 2012-01-17 05:51:


Your blog has the same post as another author but i like your better

Arana Fireheart / 2012-03-22 13:57:

Thank you for this beautiful tutorial! It is simple, informative, easy to follow and VERY well presented. I found it while searching to find an answer for the multiple signals issue, so I thought I would post the "proper" way to eliminate it and offer credit for steering me to the solution as well. The proper way to handle this problem is to place @QtCore.pyqtSignature("") before *every* event handler that you don't want both signals sent to. This can be found in the document referenced in your sidebar as involved". Read section 3.7of the PyQt v4 Bindings Reference Guide once you've clicked the link! 
Thanks again!!

Leandro Moreira Barbosa / 2012-12-06 14:36:

Best PyQt tutorial ever!

Note: I'm following it using PySide instead and it's been working well so far. It's just a matter of adjusting the imports and calling the equivalent tools in the command line. Check: http://qt-project.org/wiki/... for more information.


Contents © 2000-2020 Roberto Alsina