PyQt By Example (Session 5)

Available in: Español|English

Requirements

If you have not done it yet, please check the previous sessions:

All files for this session are here: Session 5 at GitHub. You can use them, or you can follow these instructions starting with the files from Session 4 and see how well you worked!

Dialog

When we finished session 4 we had a TODO application that was able of displaying your task list, and deleting a task, and had the proper UI for that.

/static/tut5-window6.png

A very limited application

The obvious missing pieces are creating and modifying tasks, and that's what we will be implementing today. This is a longer session, because we will do a lot of work.

But first, let's consider a course of action, because there are several different ways to do this, and trying to implement something you have not thought about sucks.

Inline Editing

When the user clicks (or double-clicks?) on a task, he could start editing the task properties in the task item itself.

Examples of this model of interaction include:

  • Spreadsheets
  • File renaming in some file managers
  • Todo lists in some websites

The good:

  • Minimal user interface.
  • Obvious if the user knows about it.
  • Direct manipulation of the task item is a good metaphor.
  • Works well in small screens (netbooks, phones)

The bad:

  • It's not trivial to implement, for example, a due date/time editor this way (feel free to prove me wrong ;-)
  • I don't like it much for task addition. Using this paradigm usually means you create a task using placeholders like "New Task" and then need to edit it. I prefer if the user creates the tasks "fully fledged".

So, maybe it's a good idea to implement this, but I am fully convinced.

Editor Dialog

When the user triggers the "edit task" action, a dialog pops up where he can set the task's properties.

Examples of this model of interaction:

  • File properties in most file managers.
  • Configuration dialogs in most applications.

The good:

  • A separate dialog allows use of a richer UI, because you are not limited by the small space used for inline editing.
  • The same dialog can be used for task creating with minimal changes.
  • It would let me show you how to create a proper dialog ;-)

The bad:

  • Breaks the metaphor. Only do that when it makes sense
  • Popup dialogs often obscure the main window. Then if you need to check a date from another task, you need to move the dialog aside.
  • Old fashioned?

All in all, not a fan of this option as the main mechanism for task editing.

Sliding Panels

This is a more modern interaction model: when a task editing action is triggered, a gadget slides into view from one of the window edges, containing the editing widgets.

Examples of this interaction:

  • Firefox's search dialog (Thankfully many apps are using this now, popup search dialogs suck)
  • Boxee's menu
  • Some phone interfaces

The good:

  • Rich UI like in a dialog
  • No popups (Modern?)
  • Works for adding tasks as well.

The bad:

  • Could be confusing?

  • Could not work great in small screens (needs testing!)

    In my screen the widget is only 300px tall, so it should fit even a small form factor screen (A 800x480 netbook?, a QVGA phone?) nicely, although in those cases it will obscure the window app completely. In such small screens "paginated" interfaces are a good idea.

  • If the main window's form factor is small (as it is for this app), a large panel will obscure most or all the window.

I think I like this option best, but I am not exactly overcome with enthusiasm. It should also work well as a teaching aid, since we will need to learn to do many things to make it work correctly!

Anyway: if you prefer another approach for this task, you can probably reuse some of the code, and I will be happy to present your alternative implementations here, so go ahead and code them!

Creating the Form

As usual when we do UI work, we start with designer. This time, we will not create a Main Window, but a Widget, and laying out the necessary widgetry. Just put them roughly where you think they should go, here is how I started:

/static/tut5-editor1.png

Just a draft

What we are creating here is a classical two-column form: labels on the left, widgets on the right. Almost every program has one of these!

The main goals are (in no particular order):

  1. It should react nicely to resizing.
  2. It should not look weird. On any platform, if you can do it.
  3. The purpose of each thing in the screen should be obvious.
  4. It should be usable by keyboard and mouse.

And here's some steps towards those goals:

Layouts

  1. You can think of this form as a a 2x3 grid. Group things that go in a single cell using a layout. In our case, we don't have a multi-widget cell.

  2. Select the contents of each "cell" and put them in a form layout.

    There are other ways to do this, like using a grid layout. Use a form layout instead, because it will look correct (or at least more correct) on other platforms which have rules like "align the labels left" or "align the labels right".

    /static/tut5-editor6.png

    No form layout is always right...

  3. Right-click on the background and lay out the contents of the widget vertically.

/static/tut5-editor2.png

You can see the layouts outlined in red.

Keyboard Handling

  1. Configure buddies. This associates a label to a widget. It's very important, because then you can do the next item ;-)

    /static/tut5-editor3.png

    Buddies.

  2. Add shortcuts to the labels. If you change "Due Date" for "&Due Date", the "D" will be underlined and Alt-D will jump to the date editor. If a label is not a buddy for a widget, this does not work!

    /static/tut5-editor4.png

    Shortcuts.

  3. Tab order. Usually, you want the tab order to just be the top-to-bottom order of your widgets. That is usually the least surprising option.

    /static/tut5-editor5.png

    Tab order.

Miscelaneous

Some chores:

  1. Change the object names for the important widgets (that is, not for the labels).
  2. Tweak attributes (if you have a good reason!)
  3. Test it in different styles, see if aything bad happens
  4. Rename
  5. Add compiling editor.ui to our build.sh

Let's consider our task editing/creating widget done. Now, we need to integrate it in our main window.

Working on the Main Window

Actions

The user interaction is done via actions. We want to enable the user to:

  • Create a new task: action "actionNew_Task".
  • Modify an existing task: action "actionEdit_Task".

For details on why and how to do this, check session 4, but some UI notes: I placed a separator in the Task menu before "Delete Task" to give it some space, since it's a destructive action. Same thing in the tool bar.

Editor Widget

Since the logic form factor for our app's window is "tall and skinny", it makes sense that the editor will slide in from the bottom.

We will use our editor widget as a "promoted widget" in the main window. That means we do all our designer work using a plain widget as a stand-in and tell designer that it should "promote" it to the real thing later.

If you create a widget that can be reused by others, it's probably a better idea to make it work as a custom widget instead, but for us that will not be necessary.

First, we need to create a class that inherits QWidget, which will be our task editor widget.

The simplest version of such a class would look like this (suppose this is in a file called editor.py):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 """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 designer?

  1. Right-click the task list and break its layout.

  2. Leave some room below the task list, and put a QWidget there.

  3. Select the task list and the placeholder and lay them out in a vertical splitter (it's in the tool bar)

  4. Right-click on the form's background and lay it out vertically.

    /static/tut5-window7.png

    A placeholder in place.

  5. Right click on the placeholder and select "Promote to..."

    • Base class: QWidget
    • Promoted class name: editor
    • Header file: editor
    • Click "Add"
    • Click "Promote"

    You will not notice any changes. But if you recompile the window and try running python main.py you will see this:

    /static/tut5-window8.png

    Yes, the editor is there

Cool, isn't it? It's not difficult to reuse these widget aggregates in other applications, or in different contexts in the same app, for example, if I later decide to create a editor dialog I can just reuse this widget for both places.

However, this is not how we want our app to appear when first started! We only want the editor to be visible when the user is actually editing a task.

There are several ways to achieve this, but let's go with the simplest one, hide the editor in Main.__init__:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 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 editing widget, we can start using it! First, let's use it to add new tasks.

As seen in session 2 in order to react to the "actionAdd_Task" we ned to implement Main.on_actionNew_Task_triggered:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 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 doing should be almost obvious. If you don't get it, check the explanation of Main.__init__ and todo.py in session 1. The non-obvious is the call to editor.edit(item). What's that?

Since the goal of the task editor widget is editing tasks, it needs to know what task to edit, and it needs to be able to save the modifications.

I will implement this using "instant apply", which means there is no save button, no apply button and no ok button. You changed the tags? Well, then the tags are changed ;-)

Here's the full code for the editor widget:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
 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 calling it anywhere. That's because we will use signals and slots to invoke it.

Open editor.ui in designer and right-click on the Form QWidget. Then, select "Change signals/slots...", you will see this dialog. I have added the "save()" slot.

/static/tut5-editor7.png

The custom save() slot.

I did this so I can connect signals to my save() method using designer. So, let's connect every signal marking a change in the content of the dialog to this save() slot:

/static/tut5-editor8.png

Signals connected to save()

And then, just like that, it works:

/static/tut5-window9.png

It's alive! ALIVE!!!!!

Editing Tasks

This will be anticlimactic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 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 longest session yet, and what have we achieved? No longer is our app useless. It does, in fact, kinda work. However, it is buggy as hell. In the next session I will walk you through some informal interface testing which will show where the defects live, and we will fix a lot of them.

/static/tut5-window10.png

Here you can see what changed between the old and new versions:

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
Posted: 2009-03-15 19:09:37 •  | Comments | | Topics: programming, python, kde, qt, pyqt, pyqtbyexample | | Available in: Español|English
blog comments powered by Disqus

View My Stats