Ir al contenido principal

Ralsina.Me — El sitio web de Roberto Alsina

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
Daniel K. Attling / 2009-05-22 23:28:

Not to sound too whiney, but I'm turning really purple here holding my breath for the next session.

Roberto Alsina / 2009-05-23 01:24:

In the last month I have had 60 hour work weeks.

Someone has to pay for my free time to write these things, and it turned out to be me, so it's taking a little longer than expected ;-)

guest / 2009-11-08 21:45:

Testet on Karmic (Python 2.6.4 - Qt 4.5.2 - PyQt 4.6) with acual (2009-11-08) - EasyInstall of Elixir-0.7.0-py2.6.egg

I got an attribute-Error with query() Line ~ 27 - 30 in the main.py-scripts of session 2 - 5:

for task in todo.Task.query().all():

It worked fine without the '()' behind todo.Task.query ... so I had to write:

for task in todo.Task.query.all(): ...

Roberto Alsina / 2009-11-09 00:32:

@guest I'll take a look and see if it needs updating for Elixir 0.7

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

Murphy Randle / 2012-06-17 05:30:

Roberto, thank you so much for these tutorials. They are out of this world! Wonderful detail, and very helpful explanation.

Jack / 2012-09-09 01:09:

Thank you very much for a delightful series introducing me to the wonderful world of PyQt, I don't know what I'd have done without it, just finished this last session, having added in only a few minor modifications of that which you gave.

Do you think you'll write the next part of the guide? Curiosity entices me to know how you'd go about solving the very obvious bugs (and perhaps also a few I've missed). I hope the 60 hour weeks have subsided, so not good.

Oh, one more thing, I noticed that the line numbers on the code pastings always started at 1, even if you were inserting code part way in.

rulolp / 2012-10-20 08:02:

nooo, porqué terminó así! queremos aprender más cosas sobre PyQt, qué hiciste en esas 108 horas restantes! jajaj, en serio, muchísimas gracias por este tutorial, realmente muy bien explicado, incluso también por los enlaces que ibas haciendo en cada uno que estaban muy interesantes. Hasta ahora lo que había encontrado no me había sido de mucha ayuda, había ejemplos que apenas creaban una ventana o la demasiado extensa, y difícil de comprender para empezar, documentación encima en inglés. Gracias de nuevo, me agendo tu blog, y espero que en algún momento puedas continuar esta serie.


Contents © 2000-2023 Roberto Alsina