Skip to main content

Ralsina.Me — Roberto Alsina's website

PyQt By Example (Session 5)

PyQt By Example: Session 5

Dialog

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 5 at GitHub. You can use them, or you can fol­low these in­struc­tions start­ing with the files from Ses­sion 4 and see how well you worked!

Dialog

When we fin­ished ses­sion 4 we had a TO­DO ap­pli­ca­tion that was able of dis­play­ing your task list, and delet­ing a task, and had the prop­er UI for that.

/static/tut5-window6.png

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

The ob­vi­ous miss­ing pieces are cre­at­ing and mod­i­fy­ing tasks, and that's what we will be im­ple­ment­ing to­day. This is a longer ses­sion, be­cause we will do a lot of work.

But first, let's con­sid­er a course of ac­tion, be­cause there are sev­er­al dif­fer­ent ways to do this, and try­ing to im­ple­ment some­thing you have not thought about suck­s.

Inline Editing

When the us­er clicks (or dou­ble-click­s?) on a task, he could start edit­ing the task prop­er­ties in the task item it­self.

Ex­am­ples of this mod­el of in­ter­ac­tion in­clude:

  • Spread­­sheets

  • File re­­nam­ing in some file man­agers

  • To­­do lists in some we­b­sites

The good:

  • Min­i­­mal us­er in­­ter­­face.

  • Ob­vi­ous if the us­er knows about it.

  • Di­rect ma­nip­u­la­­tion of the task item is a good metaphor.

  • Works well in small screens (net­­book­s, phones)

The bad:

  • It's not triv­ial to im­­ple­­men­t, for ex­am­­ple, a due date/­­time ed­i­­tor this way (feel free to prove me wrong ;-)

  • I don't like it much for task ad­di­­tion. Us­ing this par­a­digm usu­al­­ly means you cre­ate a task us­ing place­hold­ers like "New Task" and then need to ed­it it. I pre­fer if the us­er cre­ates the tasks "ful­­ly fledged".

So, maybe it's a good idea to im­ple­ment this, but I am ful­ly con­vinced.

Editor Dialog

When the us­er trig­gers the "ed­it task" ac­tion, a di­a­log pops up where he can set the task's prop­er­ties.

Ex­am­ples of this mod­el of in­ter­ac­tion:

  • File prop­er­ties in most file man­ager­s.

  • Con­­fig­u­ra­­tion di­alogs in most ap­­pli­­ca­­tion­s.

The good:

  • A sep­a­rate di­a­log al­lows use of a rich­er UI, be­­cause you are not lim­it­ed by the small space used for in­­­line ed­it­ing.

  • The same di­a­log can be used for task cre­at­ing with min­i­­mal changes.

  • It would let me show you how to cre­ate a prop­er di­a­log ;-)

The bad:

  • Breaks the metaphor. On­­ly do that when it makes sense

  • Pop­up di­alogs of­ten ob­s­cure the main win­­dow. Then if you need to check a date from an­oth­er task, you need to move the di­a­log aside.

  • Old fash­ioned?

All in al­l, not a fan of this op­tion as the main mech­a­nism for task edit­ing.

Sliding Panels

This is a more mod­ern in­ter­ac­tion mod­el: when a task edit­ing ac­tion is trig­gered, a gad­get slides in­to view from one of the win­dow edges, con­tain­ing the edit­ing wid­get­s.

Ex­am­ples of this in­ter­ac­tion:

  • Fire­­fox's search di­a­log (Thank­­ful­­ly many apps are us­ing this now, pop­up search di­alogs suck)

  • Box­ee's menu

  • Some phone in­­ter­­faces

The good:

  • Rich UI like in a di­a­log

  • No pop­ups (Mod­­ern?)

  • Works for adding tasks as well.

The bad:

  • Could be con­­fus­ing?

  • Could not work great in small screens (needs test­ing!)

    In my screen the wid­get is on­­ly 300px tal­l, so it should fit even a small form fac­­tor screen (A 800x480 net­­book?, a QV­­GA phone?) nice­­ly, al­though in those cas­es it will ob­s­cure the win­­dow app com­­plete­­ly. In such small screens "pag­i­­nat­ed" in­­ter­­faces are a good idea.

  • If the main win­­dow's form fac­­tor is small (as it is for this ap­p), a large pan­el will ob­s­cure most or all the win­­dow.

I think I like this op­tion best, but I am not ex­act­ly over­come with en­thu­si­as­m. It should al­so work well as a teach­ing aid, since we will need to learn to do many things to make it work cor­rect­ly!

Any­way: if you pre­fer an­oth­er ap­proach for this task, you can prob­a­bly re­use some of the code, and I will be hap­py to present your al­ter­na­tive im­ple­men­ta­tions here, so go ahead and code them!

Creating the Form

As usu­al when we do UI work, we start with de­sign­er. This time, we will not cre­ate a Main Win­dow, but a Wid­get, and lay­ing out the nec­es­sary wid­getry. Just put them rough­ly where you think they should go, here is how I start­ed:

/static/tut5-editor1.png

Just a draft

What we are cre­at­ing here is a clas­si­cal two-­col­umn for­m: la­bels on the left, wid­gets on the right. Al­most ev­ery pro­gram has one of the­se!

The main goals are (in no par­tic­u­lar or­der):

  1. It should re­act nice­­ly to re­­siz­ing.

  2. It should not look weird. On any plat­­for­m, if you can do it.

  3. The pur­­pose of each thing in the screen should be ob­vi­ous.

  4. It should be us­able by key­board and mouse.

And here's some steps to­wards those goal­s:

Layouts
  1. You can think of this form as a a 2x3 grid. ­­Group things that go in a sin­­gle cell us­ing a lay­out. In our case, we don't have a mul­ti­-wid­get cel­l.

  2. Se­lect the con­­tents of each "cel­l" and put them in a form lay­out.

    There are oth­­er ways to do this, like us­ing a grid lay­out. Use a form lay­out in­­stead, be­­cause it will look cor­rect (or at least more cor­rec­t) on oth­­er plat­­forms which have rules like "align the la­­bels left" or "align the la­­bels right".

    /static/tut5-editor6.png

    No form lay­out is al­ways right...

  3. Right-click on the back­­­ground and lay out the con­­tents of the wid­get ver­ti­­cal­­ly.

/static/tut5-editor2.png

You can see the lay­outs out­lined in red.

Keyboard Handling
  1. Con­­fig­ure bud­dies. This as­­so­­ci­ates a la­­bel to a wid­get. It's very im­­por­­tan­t, be­­cause then you can do the next item ;-)

    /static/tut5-editor3.png

    Bud­dies.

  2. Add short­­­cuts to the la­­bel­s. If you change "Due Date" for "&­­Due Date", the "D" will be un­der­­lined and Al­t-D will jump to the date ed­i­­tor. If a la­­bel is not a bud­dy for a wid­get, this does not work!

    /static/tut5-editor4.png

    Short­­­cut­s.

  3. Tab or­der. Usu­al­­ly, you want the tab or­der to just be the top-­­to-bot­­tom or­der of your wid­get­s. That is usu­al­­ly the least sur­pris­ing op­­tion.

    /static/tut5-editor5.png

    Tab or­der.

Miscelaneous

Some chores:

  1. Change the ob­­ject names for the im­­por­­tant wid­gets (that is, not for the la­­bel­s).

  2. Tweak at­tributes (if you have a good rea­­son!)

  3. Test it in dif­fer­­ent styles, see if ay­thing bad hap­pens

  4. Re­­name

  5. Add com­pil­ing ed­i­­tor.ui to our build.sh

Let's con­sid­er our task edit­ing/cre­at­ing wid­get done. Now, we need to in­te­grate it in our main win­dow.

Working on the Main Window

Actions

The us­er in­ter­ac­tion is done via ac­tion­s. We want to en­able the us­er to:

  • Cre­ate a new task: ac­­tion "ac­­tion­New_­­Task".

  • Mod­­i­­fy an ex­ist­ing task: ac­­tion "ac­­tionEd­it_­­Task".

For de­tails on why and how to do this, check ses­sion 4, but some UI notes: I placed a sep­a­ra­tor in the Task menu be­fore "Delete Task" to give it some space, since it's a de­struc­tive ac­tion. Same thing in the tool bar.

Editor Widget

Since the log­ic form fac­tor for our ap­p's win­dow is "tall and skin­ny", it makes sense that the ed­i­tor will slide in from the bot­tom.

We will use our ed­i­tor wid­get as a "pro­mot­ed wid­get" in the main win­dow. That means we do all our de­sign­er work us­ing a plain wid­get as a stand-in and tell de­sign­er that it should "pro­mote" it to the re­al thing lat­er.

If you cre­ate a wid­get that can be reused by oth­er­s, it's prob­a­bly a bet­ter idea to make it work as a cus­tom wid­get in­stead, but for us that will not be nec­es­sary.

First, we need to cre­ate a class that in­her­its QWid­get, which will be our task ed­i­tor wid­get.

The sim­plest ver­sion of such a class would look like this (sup­pose this is in a file called ed­i­tor.py):

 """A custom widget that edits a task's properties"""

 # Import the compiled UI module
 from editorUi import Ui_Form

 class editor(QtGui.QWidget):
     def __init__(self,parent,task=None):
         QtGui.QWidget.__init__(self,parent)

         self.ui=Ui_Form()
         self.ui.setupUi(self)

How can we use it in de­sign­er?

  1. Right-click the task list and break its lay­out.

  2. Leave some room be­low the task list, and put a QWid­get there.

  3. Se­lect the task list and the place­hold­er and lay them out in a ver­ti­­cal split­ter (it's in the tool bar)

  4. Right-click on the for­m's back­­­ground and lay it out ver­ti­­cal­­ly.

    /static/tut5-window7.png

    A place­hold­er in place.

  5. Right click on the place­hold­er and se­lect "Pro­­mote to..."

    • Base class: QWid­get

    • Pro­­­mot­ed class name: ed­i­­­tor

    • Head­­­er file: ed­i­­­tor

    • Click "Ad­d"

    • Click "Pro­­­mote"

    You will not no­tice any changes. But if you re­com­pile the win­dow and try run­ning python main.py you will see this:

    /static/tut5-window8.png

    Yes, the ed­i­­tor is there

Cool, is­n't it? It's not dif­fi­cult to re­use these wid­get ag­gre­gates in oth­er ap­pli­ca­tion­s, or in dif­fer­ent con­texts in the same ap­p, for ex­am­ple, if I lat­er de­cide to cre­ate a ed­i­tor di­a­log I can just re­use this wid­get for both places.

How­ev­er, this is not how we want our app to ap­pear when first start­ed! We on­ly want the ed­i­tor to be vis­i­ble when the us­er is ac­tu­al­ly edit­ing a task.

There are sev­er­al ways to achieve this, but let's go with the sim­plest one, hide the ed­i­tor in Main.__init__:

# Create a class for our main window
class Main(QtGui.QMainWindow):
    def __init__(self):
        QtGui.QMainWindow.__init__(self)

        # This is always the same
        self.ui=Ui_MainWindow()
        self.ui.setupUi(self)

        # Start with the editor hidden
        self.ui.editor.hide()
Adding a New Task

Since we now have a task edit­ing wid­get, we can start us­ing it! ­First, let's use it to add new tasks.

As seen in ses­sion 2 in or­der to re­act to the "ac­tion­Ad­d_­Task" we ned to im­ple­ment Main.on_ac­tion­New_­Task_trig­gered:

 def on_actionNew_Task_triggered(self,checked=None):
     if checked is None: return
     # Create a dummy task
     task=todo.Task(text="New Task")

     # Create an item reflecting the task
     item=QtGui.QTreeWidgetItem([task.text,str(task.date),""])
     item.setCheckState(0,QtCore.Qt.Unchecked)
     item.task=task

     # Put the item in the task list
     self.ui.list.addTopLevelItem(item)
     self.ui.list.setCurrentItem(item)
     # Save it in the DB
     todo.saveData()
     # Open it with the editor
     self.ui.editor.edit(item)

What this code is do­ing should be al­most ob­vi­ous. If you don't get it, check the ex­pla­na­tion of Main.__init__ and to­do.py in ses­sion 1. The non-ob­vi­ous is the call to ed­i­tor.ed­it(item). What's that?

Since the goal of the task ed­i­tor wid­get is edit­ing tasks, it needs to know what task to ed­it, and it needs to be able to save the mod­i­fi­ca­tion­s.

I will im­ple­ment this us­ing "in­stant ap­ply", which means there is no save but­ton, no ap­ply but­ton and no ok but­ton. You changed the tags? Well, then the tags are changed ;-)

Here's the full code for the ed­i­tor wid­get:

 class editor(QtGui.QWidget):
     def __init__(self,parent,task=None):
         QtGui.QWidget.__init__(self,parent)

         self.ui=Ui_Form()
         self.ui.setupUi(self)

         # Start with no task item to edit
         self.item=None

     def edit(self,item):
         """Takes an item, loads the widget with the item's
         task contents, shows the widget"""
         self.item=item
         self.ui.task.setText(self.item.task.text)
         self.ui.done.setChecked(self.item.task.done)
         dt=self.item.task.date
         if dt:
             self.ui.dateTime.setDate(QtCore.QDate(dt.year,dt.month,dt.day))
             self.ui.dateTime.setTime(QtCore.QTime(dt.hour,dt.minute))
         else:
             self.ui.dateTime.setDateTime(QtCore.QDateTime())
         self.ui.tags.setText(','.join( t.name for t in self.item.task.tags))
         self.show()

     def save(self):
         if self.item==None: return

         # Save date and time in the task
         d=self.ui.dateTime.date()
         t=self.ui.dateTime.time()

         self.item.task.date=datetime(
             d.year(),
             d.month(),
             d.day(),
             t.hour(),
             t.minute()
         )

         # Save text in the task
         self.item.task.text=unicode(self.ui.task.text())

         # Save tags.
         tags=[s.strip() for s in unicode(self.ui.tags.text()).split(',')]
         # For each tag, see if it is in the DB. If it is not, create it. If you had
         # a million tags, this would be very very wrong code
         self.item.task.tags=[]
         for tag in tags:
             dbTag=todo.Tag.get_by(name=tag)
             if dbTag is None: # Tag is new, create it
                 print "Creating tag: ",tag
                 self.item.task.tags.append(todo.Tag(name=tag))
             else:
                 self.item.task.tags.append(dbTag)

         # Display the data in the item
         self.item.setText(0,self.item.task.text)
         self.item.setText(1,str(self.item.task.date))
         self.item.setText(2,u','.join(t.name for t in self.item.task.tags))

         todo.saveData()

As you can see, there is a save() method, but we are not call­ing it any­where. That's be­cause we will use sig­nals and slots to in­voke it.

Open ed­i­tor.ui in de­sign­er and right-click on the Form QWid­get. Then, se­lect "Change sig­nal­s/s­lot­s...", you will see this di­a­log. I have added the "save()" slot.

/static/tut5-editor7.png

The cus­tom save() slot.

I did this so I can con­nect sig­nals to my save() method us­ing de­sign­er. So, let's con­nect ev­ery sig­nal mark­ing a change in the con­tent of the di­a­log to this save() slot:

/static/tut5-editor8.png

Sig­nals con­nect­ed to save()

And then, just like that, it work­s:

/static/tut5-window9.png

It's alive! ALIVE!!!!!

Editing Tasks

This will be an­ti­cli­mac­tic:

 def on_actionEdit_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 edit!
         return

     # Open it with the editor
     self.ui.editor.edit(item)

Coming Soon

This was our long­est ses­sion yet, and what have we achieved? No longer is our app use­less. It does, in fac­t, kin­da work. How­ev­er, it is bug­gy as hel­l. In the next ses­sion I will walk you through some in­for­mal in­ter­face test­ing which will show where the de­fects live, and we will fix a lot of them.

/static/tut5-window10.png

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

Modified lines:  60, 61, 62
Added line:  25, 26, 27, 62, 63, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105
Removed line:  None
Generated by diff2html
© Yves Bailly, MandrakeSoft S.A. 2001
diff2html is licensed under the GNU GPL.

  session4/main.py     session5/main.py
  79 lines
2300 bytes
Last modified : Sat Mar 7 02:06:29 2009

    122 lines
3788 bytes
Last modified : Sun Mar 15 22:01:04 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         # Start with the editor hidden
      26         self.ui.editor.hide()
      27
25         # Let's do something interesting: load the database contents   28         # Let's do something interesting: load the database contents
26         # into our task list widget   29         # into our task list widget
27         for task in todo.Task.query().all():   30         for task in todo.Task.query().all():
28             tags=','.join([t.name for t in task.tags])   31             tags=','.join([t.name for t in task.tags])
29             item=QtGui.QTreeWidgetItem([task.text,str(task.date),tags])   32             item=QtGui.QTreeWidgetItem([task.text,str(task.date),tags])
30             item.task=task   33             item.task=task
31             if task.done:   34             if task.done:
32                 item.setCheckState(0,QtCore.Qt.Checked)   35                 item.setCheckState(0,QtCore.Qt.Checked)
33             else:   36             else:
34                 item.setCheckState(0,QtCore.Qt.Unchecked)   37                 item.setCheckState(0,QtCore.Qt.Unchecked)
35             self.ui.list.addTopLevelItem(item)   38             self.ui.list.addTopLevelItem(item)
36   39
37     def on_list_itemChanged(self,item,column):   40     def on_list_itemChanged(self,item,column):
38         if item.checkState(0):   41         if item.checkState(0):
39             item.task.done=True   42             item.task.done=True
40         else:   43         else:
41             item.task.done=False   44             item.task.done=False
42         todo.saveData()   45         todo.saveData()
43   46
44     def on_actionDelete_Task_triggered(self,checked=None):   47     def on_actionDelete_Task_triggered(self,checked=None):
45         if checked is None: return   48         if checked is None: return
46         # First see what task is "current".   49         # First see what task is "current".
47         item=self.ui.list.currentItem()   50         item=self.ui.list.currentItem()
48   51
49         if not item: # None selected, so we don't know what to delete!   52         if not item: # None selected, so we don't know what to delete!
50             return   53             return
51         # Actually delete the task   54         # Actually delete the task
52         item.task.delete()   55         item.task.delete()
53         todo.saveData()   56         todo.saveData()
54   57
55         # And remove the item. I think that's not pretty. Is it the only way?   58         # 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))   59         self.ui.list.takeTopLevelItem(self.ui.list.indexOfTopLevelItem(item))
57   60
58     def on_list_currentItemChanged(self,current=None,previous=None):   61     def on_list_currentItemChanged(self,current=None,previous=None):
      62         # In Session 5, fixes a bug where an item was current but had no visible
      63         # changes, so it could be deleted/edited surprisingly.
59         if current:   64         if current:
60             self.ui.actionDelete_Task.setEnabled(True)   65             current.setSelected(True)
61         else:   66
62             self.ui.actionDelete_Task.setEnabled(False)   67         # Changed in session 5, because we have more than one action
      68         # that should only be enabled only if a task is selected
      69         for action in [self.ui.actionDelete_Task,
      70                         self.ui.actionEdit_Task,
      71                        ]:
      72             if current:
      73                 action.setEnabled(True)
      74             else:
      75                 action.setEnabled(False)
      76
      77     def on_actionNew_Task_triggered(self,checked=None):
      78         if checked is None: return
      79         # Create a dummy task
      80         task=todo.Task(text="New Task")
      81
      82         # Create an item reflecting the task
      83         item=QtGui.QTreeWidgetItem([task.text,str(task.date),""])
      84         item.setCheckState(0,QtCore.Qt.Unchecked)
      85         item.task=task
      86
      87         # Put the item in the task list
      88         self.ui.list.addTopLevelItem(item)
      89         self.ui.list.setCurrentItem(item)
      90         # Save it in the DB
      91         todo.saveData()
      92         # Open it with the editor
      93         self.ui.editor.edit(item)
      94
      95     def on_actionEdit_Task_triggered(self,checked=None):
      96         if checked is None: return
      97
      98         # First see what task is "current".
      99         item=self.ui.list.currentItem()
      100
      101         if not item: # None selected, so we don't know what to edit!
      102             return
      103
      104         # Open it with the editor
      105         self.ui.editor.edit(item)
63   106
64 def main():   107 def main():
65     # Init the database before doing anything else   108     # Init the database before doing anything else
66     todo.initDB()   109     todo.initDB()
67   110
68     # Again, this is boilerplate, it's going to be the same on   111     # Again, this is boilerplate, it's going to be the same on
69     # almost every app you write   112     # almost every app you write
70     app = QtGui.QApplication(sys.argv)   113     app = QtGui.QApplication(sys.argv)
71     window=Main()   114     window=Main()
72     window.show()   115     window.show()
73     # It's exec_ because exec is a reserved word in Python   116     # It's exec_ because exec is a reserved word in Python
74     sys.exit(app.exec_())   117     sys.exit(app.exec_())
75   118
76   119
77 if __name__ == "__main__":   120 if __name__ == "__main__":
78     main()   121     main()
79   122

Generated by diff2html on Sun Mar 15 22:01:22 2009
Command-line:
/home/ralsina/bin/diff2html session4/main.py session5/main.py