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:

titulo-listado

radio1.py

class listado

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 emite 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.print.jpg

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.print.jpg

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.print.jpg

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.print.jpg

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.print.jpg

¡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.print.jpg

¡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.print.jpg

¡Mejor!

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

radio-8.print.jpg

¿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:

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.print.jpg

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.print.jpg
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.print.jpg
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.print.jpg

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

radio-13.print.jpg

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:

titulo-listado

radio1.py

class listado

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:

titulo-listado

radio2.py

class listado

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:

titulo-listado

radio2.py

class listado

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

titulo-listado

radio2.py

class listado

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:

titulo-listado

radio3.py

class listado

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:

titulo-listado

radio4.py

class listado

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:

titulo-listado

radio4.py

class listado

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

titulo-listado

radio4.py

class listado

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__):

titulo-listado

radio4.py

class listado

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:

titulo-listado

plsparser.py

class listado

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

Primero, necesitamos importar un par de cosas:

titulo-listado

radio5.py

class listado

Y esta es playURL completa:

titulo-listado

radio5.py

class listado

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.