La GUI es la Parte Fácil

“There are no original ideas. There are only original people.” -– Barbara Grizzuti Harrison

Empezar a crear la interfaz gráfica de una aplicación es como empezar a escribir un libro. Tenés un espacio en blanco, esperando que hagas algo, y si no sabés qué es lo que querés poner ahí, la infinitud de los caminos que se te abren es paralizante.

Este capítulo no te va a ayudar en absoluto con ese problema, si no que vamos a tratar de resolver su opuesto: sabiendo qué querés hacer: ¿cómo se hace?

Vamos a aprender a hacer programas sencillos usando PyQt, un toolkit de interfaz gráfica potente, multiplataforma, y relativamente sencillo de usar.

Proyecto

Vamos a hacer una aplicación completa. Como esto es un libro de Python y no específicamente de PyQt, no va a ser tan complicada. Veamos un escenario para entender de dónde viene este proyecto.

Supongamos que estás usando tu computadora y querés escuchar música. Supongamos también que te gusta escuchar radios online.

Hoy en día hay varias maneras de hacerlo:

  • Ir al sitio de la radio.
  • Utilizar un reproductor de medios (Amarok, Banshee, Media Player o similar).
  • Usar RadioTray.

Resulta que mi favorita es la tercera opción, y nuestro proyecto es crear una aplicación similar, minimalista y fácil de entender.

En nuestro caso, como nos estamos basando (en principio) en clonar otra aplicación [1] no hace falta pensar demasiado el diseño de la interfaz o el uso de la misma (de ahí eso de que este capítulo no te va a ayudar a saber qué hacer).

[1]Actividad con la que no estoy demasiado contento en general, pero bueno, es con fines educativos. (¡me encanta esa excusa!)

Sin embargo, en el capítulo siguiente vamos a darle una buena repasada a lo que creamos en este, y vamos a pulir todos los detalles. ¡No es demasiado grave si empezamos con una versión un poco rústica!

Programación con Eventos

La función principal que se ejecuta en cualquier aplicación gráfica, en particular en una en PyQt, es sorprendentemente corta, y es igual en el 90% de los casos:

radio1.py

 9 def main():
10     app = QtGui.QApplication(sys.argv)
11     window=Main()
12     window.show()
13     sys.exit(app.exec_())
14 
15 if __name__ == "__main__":
16     main()

Esto es porque no hace gran cosa:

  1. Crea un objeto "aplicación".
  2. Crea y muestra una ventana.
  3. Lanza el "event loop", y cuando este termina, muere.

Eso es así porque las aplicaciones de escritorio no hacen casi nada por su cuenta, son reactivas, reaccionan a eventos que suceden.

Estos eventos pueden ser iniciados por el usuario (click en un botón) o por el sistema (se enchufó una cámara), u otra cosa (un timer que se dispara periódicamente), pero el estado natural de la aplicación es estar en el event loop, esperando, justamente, un evento.

Entonces nuestro trabajo es crear todas las cosas que se necesiten en la aplicación -- ventanas, diálogos, etc -- esperar que se produzcan los eventos y escribir el código que responda a los mismos.

En PyQt, casi siempre esos eventos los vamos a manejar mediante Signals (señales) y Slots.

¿Qué son esas cosas? Bueno, son un mecanismo de manejo de eventos ;-)

En particular, una señal es un mensaje. Y un slot es un receptor de esos mensajes. Por ejemplo, cuando el usuario aprieta un botón, el objeto QPushButton correspondiente emite la señal clicked().

¿Y qué sucede? Absolutamente nada, porque las señales no tienen efectos. Es como si el botón se pusiera a gritar "me apretaron". Eso en sí no hace nada.

Pero imaginemos que hay otro objeto que está escuchando y tiene instrucciones de que si ese botón específico dice "me apretaron", debe cerrar la ventana. Bueno, cerrar la ventana es un slot, y el ejemplo es una conexión a un slot.

La conexión observa esperando una señal [2], y cuando la señal se produce, ejecuta una función común y corriente, que es el slot.

[2]Hay un "despachador de señales" que se encarga de ejecutar cada slot cuando se emiten las señales conectadas a él.

Pero lo más lindo de señales y slots es que tiene acoplamiento débil (es "loosely coupled"). Cada señal de cada objeto puede estar conectada a ninguno, a uno, o a muchos slots. Cada slot puede tener conectadas ninguna, una o muchas señales.

Hasta es posible "encadenar" señales: si uno conecta una señal a otra, al emitirse una se emita la otra.

Es más, en principio, ni al emisor de la señal ni al receptor de la misma les importa quién es el otro.

La sintaxis de conexión que vamos a usar es la nueva, que sólo está disponible en PyQt 4.7 o superior, porque es mucho más agradable que la otra.

Por ejemplo, si cerrar es un QPushButton (o sea, un botón común y corriente), y ventana es un QDialog ( o sea, una ventana de diálogo), se pueden conectar así:

cerrar.clicked.connect(ventana.accept)

Eso significaría "cuando se emita la señal clicked del botón cerrar, entonces ejecutá el método accept de ventana." Como el método QDialog.accept cierra la ventana, la ventana se cierra.

También es posible usar autoconexión de signals y slots, pero eso lo vemos más adelante.

Ventanas / Diálogos

Empecemos con la parte divertida: ¡dibujitos!

Radiotray tiene exactamente dos ventanas [3]:

radio-1.screen.png

El diálogo de administración de radios y el de añadir radio.

[3]Bueno, mentira, tiene también una ventana "Acerca de".

No creo en hacer ventanas a mano. Creo que acomodar los widgets en el lugar adonde van es un problema resuelto, y la solución es usar un diseñador de diálogos. [4]

[4]Sí, ya sé, "no tenés el mismo control". Tampoco tengo mucho control sobre la creación de la pizzanesa a la española en La Farola de San Isidro, pero si alguna vez la comiste sabés que eso es lo de menos.

En nuestro caso, como estamos usando PyQt, la herramienta es Qt Designer [5].

[5]Lamentablemente una buena explicación de Designer requiere videos y mucho más detalle del que puedo incluir en un capítulo, pero vamos a tratar de ver lo importante, sin quedarnos en cómo se hace cada cosa exactamente.
radio-2.screen.png

Designer a punto de crear un diálogo vacío.

El proceso de crear una interfaz en Designer tiene varios pasos. Sabiendo qué interfaz queremos [6], el primero es acomodar más o menos a ojo los objetos deseados.

[6]En nuestro caso, como estamos robando, es muy sencillo. En la vida real, este trabajo se basaría en wireframing, o algún otro proceso de creación de interfaces.
radio-3.screen.png

El primer borrador.

Literalmente, tomé unos botones y una lista y los tiré adentro de la ventana más o menos en posición.

El acomodarlos muy así nomás es intencional, porque el siguiente paso es usar Layout Managers para que los objetos queden bien acomodados. En una GUI moderna no tiene sentido acomodar las cosas en posiciones absolutas, porque no tenés idea de como va a ser la interfaz para el usuario final con tanto nivel de detalle. Por ejemplo:

  • Traducciones a otros idiomas hacen que los botones deban ser más anchos o angostos.
  • Cambios en la tipografía del sistema pueden hacer que sean más altos o bajos.
  • Cambios en el estilo de widgets, o en la plataforma usada pueden cambiar la forma misma de un botón (¿más redondeado? ¿más plano?)

Dadas todas esas variables, es nuestro trabajo hacer un layout que funcione con todas las combinaciones posibles, que sea flexible y responda a esos cambios con gracia.

En nuestro caso, podríamos imponer las siguientes "restricciones" a las posiciones de los widgets:

  • El botón de "Cerrar" va abajo a la derecha.
  • Los otros botones van en una columna a la derecha de la lista, en la parte de arriba de la ventana.
  • La lista va a la izquierda de los botones.

Veamos por partes.

Los botones se agrupan con un "Vertical Layout", para que queden alineados y en columna. Los seleccionamos todos usando Ctrl+click y apretamos el botón de "vertical layout" en la barra de herramientas:

radio-4.screen.png

El layout vertical de botones se ve como un recuadro rojo.

Un layout vertical solo hace que los objetos que contiene queden en una columna. Todos tienen el mismo ancho y están espaciados regularmente.

Para que los botones queden al lado de la lista, seleccionamos el layout y la lista, y hacemos un layout horizontal:

radio-5.screen.png

¡Layouts anidados!

El layout horizontal hace exactamente lo mismo que el vertical, pero en vez de una columna forman una fila.

Por último, deberíamos hacer un layout vertical conteniendo el layout horizontal que acabamos de crear y el botón que nos queda.

Como ese layout es el "top level" y tiene que cubrir toda la ventana, se hace ligeramente distinto: botón derecho en el fondo de la ventana y "Lay out" -> "Lay Out Vertically":

radio-6.screen.png

¡Feo!

Si bien el resultado cumple las cosas que habíamos definido, es horrible:

  • El botón de cerrar cubre todo el fondo de la ventana.
  • El espaciado de los otros botones es antinatural.

La solución en ambos casos es el uso de espaciadores, que "empujen" el botón de abajo hacia la derecha (luego de meterlo en un layout horizontal) y los otros hacia arriba:

radio-7.screen.png

¡Mejor!

Por supuesto que hay más de una solución para cada problema de cómo acomodar widgets:

radio-8.screen.png

¿Mejor o peor que la anterior? ¡Vean el capítulo siguiente!

El siguiente paso es poner textos [7], iconos [8], y nombres de objetos para que la interfaz empiece a parecer algo útil.

[7]Sí, estoy haciendo la interfaz en inglés. Después vamos a ver como traducirla al castellano. Si la hacés directamente en castellano te estás encerrando en un nicho (por lo menos si la aplicación es software libre, como esta).
[8]Yo uso los iconos de Reinhardt: me gustan estéticamente, son minimalistas y se ven igual de raros en todos los sistemas operativos. Si querés usar otros, hay millones de iconos gratis dando vueltas. Es cuestión de ser consistente (¡y fijarse la licencia!)

Los iconos se van a cargar en un archivo de recursos, icons.qrc:

<RCC>
  <qresource prefix="/">
    <file>ok.svg</file>
    <file>configure.svg</file>
    <file>filenew.svg</file>
    <file>delete.svg</file>
    <file>1downarrow.svg</file>
    <file>1uparrow.svg</file>
    <file>antenna.svg</file>
    <file>exit.svg</file>
    <file>stop.svg</file>
  </qresource>
</RCC>

Ese archivo se compila para generar un módulo python con todas las imágenes en su interior. Eso simplifica el deployment.

[codigo/5]$ pyrcc4 icons.qrc -o icons_rc.py
[codigo/5]$ ls -lth icons_rc.py
-rw-r--r-- 1 ralsina users 58K Apr 30 10:14 icons_rc.py

El diálogo en sí está definido en el archivo radio.ui, y se ve de esta manera:

radio-9.screen.png

Nuestro clon.

El otro diálogo es mucho más simple, y no voy a mostrar el proceso de layout, pero tiene un par de peculiaridades.

Buddies

Cuando se tiene una pareja etiqueta/entrada (por ejemplo, "Radio Name:" y el widget donde se ingresa), hay que poner el atajo de teclado en la etiqueta. Para eso se usan "buddies".

Se elije el modo "Edit Buddies" del designer y se marca la etiqueta y luego el widget de ingreso de datos. De esa forma, el atajo de teclado elegido para la etiqueta activa el widget.

radio-10.screen.png
Tab Order

¿En qué orden se pasa de un widget a otro usando Tab? Es importante que se siga un orden lógico acorde a lo que se ve en pantalla y no andar saltando de un lado para otro sin una lógica visible.

Se hace en el modo "Edit Tab Order" de designer.

radio-11.screen.png
Signals/Slots

Los diálogos tienen métodos accept y reject que coinciden con el objetivo obvio de los botones. ¡Entonces conectémoslos!

En el modo "Edit Signals/Slots" de designer, se hace click en el botón y luego en el diálogo en sí, y se elige qué se conecta.

radio-12.screen.png

Pasemos a una comparativa lado a lado de los objetos terminados:

radio-13.screen.png

Son similares. ¡Hasta tienen algunos problemas similares!

Mostrando una Ventana

Ya tenemos dos bonitas ventanas creadas, necesitamos hacer que la aplicación muestre una de ellas. Esto es código standard, y aquí tenemos una aplicación completa que muestra la ventana principal y no hace absolutamente nada:

radio1.py

 1 # -*- coding: utf-8 -*-
 2 
 3 """La interfaz de nuestra aplicación."""
 4 
 5 import os,sys
 6 
 7 # Importamos los módulos de Qt
 8 from PyQt4 import QtCore, QtGui, uic
 9 
10 # Cargamos los iconos
11 import icons_rc
12 
13 class Main(QtGui.QDialog):
14     """La ventana principal de la aplicación."""
15     def __init__(self):
16         QtGui.QDialog.__init__(self)
17         
18         # Cargamos la interfaz desde el archivo .ui
19         uifile = os.path.join(
20             os.path.abspath(
21                 os.path.dirname(__file__)),'radio.ui')
22         uic.loadUi(uifile, self)
23 
24 
25 class AddRadio(QtGui.QDialog):
26     """El diálogo de agregar una radio"""
27     def __init__(self, parent):
28         QtGui.QDialog.__init__(self, parent)
29 
30         # Cargamos la interfaz desde el archivo .ui
31         uifile = os.path.join(
32             os.path.abspath(
33                 os.path.dirname(__file__)),'addradio.ui')
34         uic.loadUi(uifile, self)
35 
36 
37 class EditRadio(AddRadio):
38     """El diálogo de editar una radio.
39     Es exactamente igual a AddRadio, excepto
40     que cambia el texto de un botón."""
41     def __init__(self, parent):
42         AddRadio.__init__(self, parent)
43         self.addButton.setText("&Save")
44 
45 
46 def main():
47     app = QtGui.QApplication(sys.argv)
48     window=Main()
49     window.show()
50     sys.exit(app.exec_())
51 
52 if __name__ == "__main__":
53     main()

El que Main y AddRadio sean casi exactamente iguales debería sugerir que esto es código standard... y es cierto, es siempre lo mismo:

Creamos una clase cuya interfaz está definida por un archivo .ui que se carga en tiempo de ejecución. Toda la interfaz está definida en el .ui, (casi) toda la lógica en el .py.

Normalmente, por prolijidad, usaríamos un módulo para cada clase, pero en esta aplicación, y por organización de los ejemplos, no vale la pena.

¡Que haga algo!

Un lugar fácil para empezar es hacer que apretar "Add" muestre el diálogo de agregar una radio. Bueno, es casi tan fácil como decirlo, tan solo hay que agregar un método a la clase Main:

radio2.py

55     @QtCore.pyqtSlot()
56     def on_add_clicked(self):
57         addDlg = AddRadio(self)
58         r = addDlg.exec_()
59         if r: # O sea, apretaron "Add"
60             self.radios.append ((unicode(addDlg.name.text()),
61                                  unicode(addDlg.url.text())))
62             self.saveRadios()
63             self.listRadios()
64 

Veamos qué es cada línea:

@QtCore.pyqtSlot()

Para explicar esta línea hay que dar un rodeo:

En C++, se pueden tener dos métodos que se llamen igual pero difieran en el tipo de sus argumentos. Y de acuerdo al tipo de los argumentos con que se lo llame, se ejecuta uno u otro.

La señal clicked se emite dos veces. Una con un argumento (que se llama checked y es booleano) y otra sin él. En C++ no es problema, si on_add_clicked recibe un argumento booleano, entonces se ejecuta, si no, no.

En Python no es así por como funcionan los tipos. En consecuencia, on_add_clicked se ejecutaría dos veces, una al llamarla con checked y la otra sin.

Si bien dije que un slot es simplemente una función, este decorador declara que este es un slot sin argumentos. De esa manera sólo se ejecuta una única llamada al slot.

Si en cambio hubiera sido @QtCore.pyqtSlot(int) hubiera sido un slot que toma un argumento de tipo entero.

def on_add_clicked(self):

Definimos un método on_add_clicked. Al cargarse la interfaz vía loadUi se permite hacer autoconexión de slots. Esto significa que si la clase tiene un método que se llame on_NOMBRE_SIGNAL queda automáticamente conectado a la señal SIGNAL del objeto NOMBRE.

En consecuencia, este método se va a ejecutar cada vez que se haga click en el botón que se llama add.

addDlg = AddRadio(self)

Creamos un objeto AddRadio con parent nuestro diálogo principal. Cuando un diálogo tiene "padre" se muestra centrado sobre él, y el sistema operativo tiene algunas ideas de como mostrarlo mejor.

r = addDlg.exec_()

Mostramos este diálogo para que el usuario interactúe con él. Se muestra por default de forma modal, es decir que bloquea la interacción con el diálogo "padre". El valor de r va a depender de qué botón presione el usuario para cerrar el diálogo.

if r: # O sea, apretaron "ok"
    self.radios.append ((unicode(addDlg.name.text()),
                         unicode(addDlg.url.text())))
    self.saveRadios()
    self.listRadios()

Si dijo "Add", guardamos los datos y refrescamos la lista de radios. Si no, no hacemos nada.

Los métodos saveRadios, loadRadios y listRadios son cortos, y me parece que son lo bastante tontos como para que no valga la pena hacer un backend de datos "serio" para esta aplicación:

radio2.py

29     def loadRadios(self):
30         "Carga la lista de radios de disco"
31         try:
32             f = open(os.path.expanduser('~/.radios'))
33             data = f.read()
34             f.close()
35             self.radios = json.loads(data)
36         except:
37             self.radios = []
38 
39         if self.radios is None:
40             # El archivo estaba vacío
41             self.radios = []
42 
43     def saveRadios(self):
44         "Guarda las radios a disco"
45         f = open(os.path.expanduser('~/.radios'),'w')
46         f.write(json.dumps(self.radios))
47         f.close()
48 
49     def listRadios(self):
50         "Muestra las radios en la lista"
51         self.radioList.clear()
52         for nombre,url in self.radios:
53             self.radioList.addItem(nombre)
54 

Finalmente, estos son los métodos para editar una radio, eliminarla, y moverla en la lista, sin explicación. Deberían ser bastante obvios:

radio2.py

 67     @QtCore.pyqtSlot()
 68     def on_edit_clicked(self):
 69         "Edita la radio actualmente seleccionada"
 70         curIdx = self.radioList.currentRow()
 71         name, url = self.radios[curIdx]
 72         editDlg = EditRadio(self)
 73         editDlg.name.setText(name)
 74         editDlg.url.setText(url)
 75         r = editDlg.exec_()
 76         if r: # O sea, apretaron "Save"
 77             self.radios[curIdx]= [unicode(editDlg.name.text()),
 78                                  unicode(editDlg.url.text())]
 79             self.saveRadios()
 80             self.listRadios()
 81             self.radioList.setCurrentRow(curIdx)
 82 
 83     @QtCore.pyqtSlot()
 84     def on_remove_clicked(self):
 85         "Borra la radio actualmente seleccionada"
 86         curIdx = self.radioList.currentRow()
 87         del (self.radios[curIdx])
 88         self.saveRadios()
 89         self.listRadios()
 90         
 91     @QtCore.pyqtSlot()
 92     def on_up_clicked(self):
 93         "Sube la radio seleccionada una posicion."
 94         curIdx = self.radioList.currentRow()
 95         if curIdx > 0:
 96             self.radios=self.radios[:curIdx-1]+\
 97                 [self.radios[curIdx], self.radios[curIdx-1]]+\
 98                 self.radios[curIdx+1:]
 99             self.saveRadios()
100             self.listRadios()
101             self.radioList.setCurrentRow(curIdx-1)
102 
103     @QtCore.pyqtSlot()
104     def on_down_clicked(self):
105         "Baja la radio seleccionada una posicion."
106         curIdx = self.radioList.currentRow()
107         if curIdx < len(self.radios):
108             self.radios=self.radios[:curIdx]+\
109                 [self.radios[curIdx+1], self.radios[curIdx]]+\
110                 self.radios[curIdx+2:]
111             self.saveRadios()
112             self.listRadios()
113             self.radioList.setCurrentRow(curIdx+1)
114     
115 

Con esto, ya tenemos una aplicación que permite agregar, editar, y eliminar radios identificadas por nombre, con una URL asociada.

Nos faltan solamente dos cosas para que esta aplicación esté terminada:

  1. El icono en area de notificación, que es la forma normal de operación de Radiotray.
  2. ¡Que sirva para escuchar la radio!

Empecemos por la primera...

Icono de Notificación

No es muy difícil, porque PyQt trae una clase para hacer esto en forma multiplataforma sin demasiado esfuerzo.

Tan solo hay que cambiar la función main de esta forma:

radio3.py

15 class TrayIcon(QtGui.QSystemTrayIcon):
16     "Icono en area de notificación"
17     def __init__(self):
18         QtGui.QSystemTrayIcon.__init__ (self,
19             QtGui.QIcon(":/antenna.svg"))
20 
21 def main():
22     app = QtGui.QApplication(sys.argv)
23     tray = TrayIcon()
24     tray.show()
25     sys.exit(app.exec_())

Esta versión de la aplicación muestra el icono de una antena en el área de notificación... y no permite ninguna interacción.

Lo que queremos es un menú al hacer click con el botón izquierdo mostrando las radios disponibles, y la opción "Apagar la radio", y otro menú con click del botón derecho para las opciones de "Configuración", "Acerca de", y "Salir".

Para eso, vamos a tener que aprender Acciones...

Acciones

Una Acción (una instancia de QAction) es una abstracción de un elemento de interfaz con el que el usuario interactúa. Una acción puede verse como un botón en una barra de herramientas, o como una entrada en un menú, o como un atajo de teclado.

La idea es que al usar acciones, uno las integra en la interfaz en los lugares que desee, y si, por ejemplo, deseo hacer que la acción tenga un estado "deshabilitado", el efecto se produce tanto para el atajo de teclado como para el botón en la barra de herramientas, como para la entrada en el menú.

Realmente simplifica mucho el código.

Entonces, para cada entrada en los menúes de contexto del icono de área de notificación, debemos crear una acción. Si estuviéramos trabajando con una ventana, podríamos usar designer [9] que tiene un cómodo editor de acciones.

[9]Podríamos hacer trampa y definir las acciones en el diálogo de cofiguración de radios, pero es una chanchada.

De todas formas no es complicado. Comencemos con el menú de botón derecho:

radio4.py

 92 class TrayIcon(QtGui.QSystemTrayIcon):
 93     "Icono en area de notificación"
 94 
 95     loadRadios = _loadRadios
 96     
 97     def __init__(self):
 98         QtGui.QSystemTrayIcon.__init__ (self,
 99             QtGui.QIcon(":/antenna.svg"))
100 
101         ## Acciones del menú de botón derecho
102         self.configAction = QtGui.QAction(
103             QtGui.QIcon(":/configure.svg"),
104             "&Configure...",self )
105         self.aboutAction = QtGui.QAction(
106             "&About...",self )
107         self.quitAction = QtGui.QAction(
108             QtGui.QIcon(":/exit.svg"),
109             "&Quit",self )
110 
111         # Armamos el menú con las acciones
112         self.rmbMenu=QtGui.QMenu()
113         self.rmbMenu.addActions([
114             self.configAction,
115             self.aboutAction,
116             self.quitAction
117             ])
118         # Ponemos este menú como menú de contexto
119         self.setContextMenu(self.rmbMenu)

Por supuesto, necesitamos que las acciones que creamos... bueno, hagan algo. Necesitamos conectar sus señales triggered a distintos métodos que hagan lo que corresponda:

radio4.py

169         self.configAction.triggered.connect(self.showConfig)
170         self.aboutAction.triggered.connect(self.showAbout)
171         self.quitAction.triggered.connect(
172             QtCore.QCoreApplication.instance().quit)
173 

Obviamente falta implementar showConfig y showAbout, pero no tienen nada que no hayamos visto antes:

radio4.py

204     @QtCore.pyqtSlot()
205     def showConfig(self):
206         "Muestra diálogo de configuración"
207         self.confDlg = Main()
208         self.confDlg.exec_()
209     
210     @QtCore.pyqtSlot()
211     def showAbout(self):
212         QtGui.QMessageBox.about(None, u"Radio",
213             u"Example app from 'Python No Muerde'<br>"\
214             u"© 2010 Roberto Alsina<br>"\
215             u"More information: http://nomuerde.netmanagers.com.ar"
216          )
217     
218 

El menú del botón izquierdo es un poco más complicado. Para empezar, tiene una entrada "normal" como las que vimos antes, pero las otras son dinámicas y dependen de cuáles radios están definidas.

Para mostrar un menú ante un click de botón izquierdo, debemos conectarnos a la señal activated (las primeras líneas son parte de TrayIcon.__init__):

radio4.py

175         # Conectamos el botón izquierdo
176         self.activated.connect(self.activatedSlot)
177 
178     def activatedSlot(self, reason):
179         """El usuario activó este icono"""
180         if reason == QtGui.QSystemTrayIcon.Trigger:
181             # El menú del botón izquierdo
182             self.stopAction=QtGui.QAction(
183                 QtGui.QIcon(":/stop.svg"),
184                 "&Turn Off Radio",self )
185 
186             self.lmbMenu=QtGui.QMenu()
187             self.lmbMenu.addAction(self.stopAction)
188             self.lmbMenu.addSeparator()
189 
190             self.loadRadios()
191             self.radioActions = []
192             for r in self.radios:
193                 receiver = lambda url=r[1]: self.playURL(url)
194                 self.lmbMenu.addAction(
195                     r[0], receiver)
196 
197             # Mostramos el menú en la posición del cursor
198             self.lmbMenu.exec_(QtGui.QCursor.pos())
199 
200     def playURL(self, url):
201         print url
202 
203     
204 

En vez de crear las QAction a mano, dejamos que el menú las cree implícitamente con addAction y --esta es la parte rara-- creamos un "receptor" lambda para cada señal, que llama a playURL con la URL que corresponde a cada radio.

¿Porqué tenemos que hacer eso? Porque si conectáramos todas las señales a playURL, no tendríamos manera de saber cuál radio queremos escuchar.

¿Se acuerdan que les dije que signals y slots tienen "acoplamiento débil"? Bueno, este es el lado malo de eso. No es terrible, la solución son dos líneas de código, pero... tampoco es obvio.

En este momento, nuestra aplicación tiene todos los elementos de interfaz terminados. Tan solo falta que, dada la URL de una radio, produzca sonido.

Por suerte, Qt es muy completo. Tan completo que tiene casi todo lo que necesitamos para hacer eso. Veámoslo en detalle...

Ruido

Comencemos con un ejemplo de una radio por Internet. Es gratis, y me gusta escucharla mientras escribo o programo, y se llama Blue Mars [10]. Pueden ver más información en http://bluemars.org

[10]De hecho son tres estaciones, vamos a probar la que se llama Blue Mars.

En el sitio dice "Tune in to BLUEMARS" y da la URL de un archivo listen.pls.

Ese archivo es el "playlist", y a su vez contiene la URL desde donde se baja el audio. El contenido es algo así:

[playlist]
NumberOfEntries=1
File1=http://207.200.96.225:8020/

El formato es muy sencillo, hay una explicación completa en Wikipedia pero básicamente es un archivo INI, que:

  • DEBE tener una sección playlist
  • DEBE tener una entrara NumberOfEntries
  • Tiene una cantidad de entradas llamadas File1...``FileN``, que son URLs de los audios, y (opcionalmente) Title1...``TitleN`` y Length1...``LengthN`` para títulos y duraciones.

Seguramente en alguna parte hay un módulo para parsear estos archivos y/o todos los otros formatos de playlist que hay dando vueltas por el mundo, pero esto es un programa de ejemplo, y me conformo con cumplir las leyes del TDD:

  • Hacé un test que falle
  • Programá hasta que el test no falle
  • Pará de programar

Así que... les presento una función que puede parsear exactamente este playlist y probablemente ningún otro:

plsparser.py

 1 # -*- coding: utf-8 -*-
 2 
 3 """Módulo de parsing de playlists PLS."""
 4 
 5 import urllib
 6 from ConfigParser import RawConfigParser
 7 
 8 def parse_pls(url):
 9     u"""
10     Dada una URL, baja el contenido, y devuelve una lista de [título,url]
11     obtenida del PLS al que la URL apunta.
12 
13     Devuelve [] si el archivo no se puede parsear o si hubo
14     cualquier problema.
15 
16     >>> parse_pls('http://207.200.96.225:8020/listen.pls')
17     [['', 'http://207.200.96.225:8020/']]
18 
19     """
20     try:
21         parser = RawConfigParser()
22         parser.readfp(urllib.urlopen(url))
23 
24         # Hacemos las cosas de acuerdo a la descripción de Wikipedia:
25         # http://en.wikipedia.org/wiki/PLS_(file_format)
26 
27         if not parser.has_section('playlist'):
28             return []
29         if not parser.has_option('playlist', 'NumberOfEntries'):
30             return []
31 
32         result=[]
33         for i in range(1, parser.getint('playlist', 'NumberOfEntries')+1):
34 
35             if parser.has_option('playlist', 'Title%s'%i):
36                 title=parser.get('playlist', 'Title%s'%i)
37             else:
38                 title=''
39             result.append([
40                     title,
41                     parser.get('playlist', 'File%s'%i)
42                     ])
43         return result
44     except:
45         # FIXME: reportar el error en log
46         return []

Teniendo esto, podemos comenzar a implementar playURL. Preparáte para entrar al arduo mundo de la multimedia...

Primero, necesitamos importar un par de cosas:

radio5.py

12 # Soporte de sonido
13 from PyQt4.phonon import Phonon
14 
15 # Parser de playlists
16 from plsparser import parse_pls

Y esta es playURL completa:

radio5.py

207     def playURL(self, url):
208         """Toma la URL de un playlist, y empieza a hacer ruido"""
209         data = parse_pls(url)
210         if data: # Tengo una URL
211             # Sí, tomamos el primer stream y listo.
212             url = data[0][1]
213 
214             self.player = Phonon.createPlayer(Phonon.MusicCategory,
215                 Phonon.MediaSource(url))
216             self.player.play()
217             
218         else: # Pasó algo malo
219             QtGui.QMessageBox.information(None,
220                 "Radio - Error reading playlist",
221                 "Sorry, error starting this radio.")
222     
223 

Y efectivamente, radio5.py permite escuchar (algunas) radios de internet. Tiene montones de problemas y algunos features aún no están implementados (por ejemplo, "Stop" no hace nada), pero es una aplicación funcional. Rústica, pero funcional.

En el siguiente capítulo la vamos a pulir. Y la vamos a pulir hasta que brille.

Último cambio: Thu May 6 08:50:39 2010.  |  Historial  |  PDF  |  Código fuente de los ejemplos

blog comments powered by Disqus