Diseño de Interfaz Gráfica

"¿Cómo se hace una estatua de un elefante? Empezás con un bloque de mármol y sacás todo lo que no parece un elefante."

—Anónimo.

"Abandonen la esperanza del valor añadido a través de la rareza. Es mejor usar técnicas de interacción consistentes que le den a los usuarios el poder de enfocarse en tu contenido, en vez de preguntarse como se llega a él."

—Jakob Nielsen

¿Siendo un programador, qué sabe uno de diseños de interfaces? La respuesta, al menos en mi caso es poco y nada. Sin embargo, hay unos cuantos principios que ayudan a que uno no cree interfaces demasiado horribles, o a veces hasta agradables.

concord.jpg

Concord cockpit by wynner3, licencia CC-BY-NC (http://www.flickr.com/photos/wynner3/3805698150/)

Más allá de esos criterios, en este capítulo vamos a tomar la interfaz creada en el capítulo anterior y la vamos a rehacer, pero bien. Porque esa era la de desarrollo, y la vamos a tirar.

Proyecto

Asumamos que la aplicación de streaming de radio que desarrollamos en el capítulo anterior funciona correctamente y carece de bugs [1]... ¿Qué hay que hacer ahora?

[1]No es así, pero estoy escuchando música con ella ¡En este mismo momento!

Bueno, falta resolver todas las cosas que no son bugs desde el punto de vista de funcionamiento pero que están mal.

Corrigiendo la Interfaz Gráfica

Empecemos con la ventana de configuración, viendo algunos problemas de base en el diseño. Desde ya que el 90% de lo que veamos ahora es discutible. Es más, como no soy un experto en el tema, es probable que el 90% esté equivocado. Sin embargo, hasta que consiga un experto en UI que le pegue una revisada... es lo que hay [2].

[2]De hecho, pedí ayuda en twitter/identi.ca y mi blog y salieron unas cuantas respuestas, incluyendo un post en otro blog. ¡Con mockups y todo!
radio-14.screen.png

Funciona, pero tiene problemas.

Esa ventana tiene muchos problemas.

radio-15.screen.png

Botón "Close" no alineado.

Normalmente no vas a ver este caso cubierto en las guías de diseño de interfaz porque estamos usando un layout "columna de botones" que no es de lo más standard.

Si hubiera más de un botón abajo, entonces tal vez "Close" se vería como perteneciente a ese elemento visual, sin embargo, al estar solo, se lo ve como un elemento de la columna, aunque "destacado" por la separación vertical.

Al ser "absorbido" visualmente por esa columna, queda muy raro que no tenga el mismo ancho que los otros botones.

Como no debemos asignar anchos fijos a los botones (por motivos que vamos a ver más adelante) debemos solucionarlo usando layout managers.

Una manera de resolverlo es una matriz 2x2 con un grid layout:

radio-18.screen.png

Botón "Close" alineado.

El resultado final es bastante más armónico, y divide visualmente el diálogo en dos componentes claros, la lista a la izquierda, los controles a la derecha.

Lo que nos lleva al segundo problema:

radio-17.screen.png

Espacio muerto.

Si el layout es "dos columnas" entonces no tiene sentido que la lista termine antes del fondo del diálogo. Nuevamente, si hubiera dos botones abajo (por ejemplo, "Accept" y "Reject"), entonces sí tendría sentido extender ese componente visual hacia la izquierda.

Al tener sólo uno, ese espacio vacío es innecesario y antifuncional.

Entonces cambiamos el esquema de layouts, y terminamos con un layout horizontal de dos elementos, el derecho un layout vertical conteniendo todos los botones:

radio-19.screen.png

Resultado con layout horizontal.

El siguiente problema es que al tener iconos y texto, y al estar centrado el contenido de los botones, se ve horrible:

radio-16.screen.png

Etiquetas centradas con iconos a la izquierda.

Hay varias soluciones para esto:

  • Podemos no poner iconos: El texto centrado no molesta tanto visualmente.
  • Podemos no centrar el contenido de los botones: Se ve mejor, pero es muy poco standard [3]
[3]Ver la cita de Nielsen al principio del capítulo.
  • Podemos no poner texto en el botón sino en un tooltip: Funciona, es standard, resuelve el alineamiento, hace la interfaz levemente menos obvia.
  • Mover algunos elementos inline en cada item (los que afectan a un único item) y mover los demás a una línea horizontal por debajo de la lista.

O ... podemos dejar de ponerle lapiz de labios al chancho y admitir que es un chancho.

El problema de este diálogo no es que los botones estén desalineados, es que no sabemos siquiera porqué los botones están.

Así que, teniendo una interfaz que funciona, hagamos un desarrollo racional de la versión nueva, y olvidemos la vieja.

¿Qué estamos haciendo?

Pensemos el objetivo, la tarea a realizar. Es controlar una lista de radios. Lo mínimo sería esto:

  • Agregar radios nuevas (Add).
  • Cambiar algo en una radio ya existente (Edit).
  • Sacar radios que no nos gustan más (Delete).
  • Cerrar el diálogo (Close) [4]
[4]Podríamos tener "Apply", "Cancel", etc, pero me gusta más la idea de este diálogo como de aplicación instantánea, "aplicar cambios" es un concepto nerd. La manipulación directa es la metáfora moderna. Bah, es una opinión.

Adicionalmente teníamos esto:

  • Cambiar el orden de las radios en la lista

¿Pero... porqué estaba? En nuestro caso es porque nos robamos la interfaz de RadioTray, pero... ¿alguien necesita hacerlo? ¿Porqué?

Veamos las justificaciones que se me ocurren:

  1. Poner las radios más usadas al principio.

    Pero... ¿No sería mejor si el programa mostrara las últimas radios usadas al principio en forma automática?

  2. Organizarlas por tipo de radio (ejemplo: tener todas las de música country juntas)

    Para hacer esto correctamente, creo que sería mejor tener múltiples niveles de menúes. También podríamos agregarle a cada radio un campo "género" o tags, y usar eso para clasificarlas.

En ambos casos, me parece que el ordenamiento manual no es la manera correcta de resolver el problema. Es casi lo contrario de un feature. Es un anti-feature que sólo sirve para que a los que realmente querrían un feature determinado se les pueda decir "usá los botones de ordenar".

Si existe algún modelo de uso para el que mover las radios usando flechitas es el modo de interacción correcta... no se me ocurre y perdón desde ya.

Por lo tanto, este "feature" va a desaparecer por ahora.

Si no tenemos los botones de subir y bajar, no tiene tanto sentido la idea de una columna de botones a la derecha, y podemos pasar a un layout con botones horizontales:

radio-20.screen.png

Repensando el diálogo. Ya que estamos "Done" es más adecuado para el botón que "Close".

¿En qué se parecen y en qué se diferencian esos cuatro botones que tenemos ahí abajo?

  • Edit y Remove afectan a una radio que esté seleccionada.
  • Add y Done no dependen de la selección en la lista.

¿Que pasaría si pusiéramos Edit y Remove en los items mismos? Bueno, lo primero que pasaría es que tendríamos que cambiar código porque el QListWidget soporta una sola columna y tenemos que pasar a un QTreeWidget. Veamos como funciona en la GUI:

radio-21.screen.png

¡Less is more!

También al no tener más botones de Edit y Remove, hay que mover un poco el código porque ahora responde a otras señales.

La parte interesante (no mucho) del código es esta:

radio6.py

 65     def listRadios(self):
 66         "Muestra las radios en la lista"
 67         self.radioList.clear()
 68         for nombre,url in self.radios:
 69             item = QtGui.QTreeWidgetItem([nombre,"Edit","Remove"])
 70             item.setIcon(1,QtGui.QIcon(":/edit.svg"))
 71             item.setIcon(2,QtGui.QIcon(":/delete.svg"))
 72             self.radioList.addTopLevelItem(item)
 73 
 74     @QtCore.pyqtSlot()
 75     def on_add_clicked(self):
 76         addDlg = AddRadio(self)
 77         r = addDlg.exec_()
 78         if r: # O sea, apretaron "Add"
 79             self.radios.append ((unicode(addDlg.name.text()),
 80                                  unicode(addDlg.url.text())))
 81             self.saveRadios()
 82             self.listRadios()
 83 
 84     def on_radioList_clicked(self, index):
 85         curIdx = index.row()
 86         
 87         if index.column() == 1: # Edit
 88             name, url = self.radios[curIdx]
 89             editDlg = EditRadio(self)
 90             editDlg.name.setText(name)
 91             editDlg.url.setText(url)
 92             r = editDlg.exec_()
 93             if r: # O sea, apretaron "Save"
 94                 self.radios[curIdx]= [unicode(editDlg.name.text()),
 95                                     unicode(editDlg.url.text())]
 96                 self.saveRadios()
 97                 self.listRadios()
 98                 self.radioList.setCurrentRow(curIdx)
 99         
100         elif index.column() == 2: # Remove
101             del (self.radios[curIdx])
102             self.saveRadios()
103             self.listRadios()
104     
105 

¿Es esto todo lo que está mal? Vaya que no.

Pulido

Los iconos que venimos usando son del set "Reinhardt" que a mí personalmente me gusta mucho, pero algunos de sus iconos no son exactamente obvios. ¿Por ejemplo, esto te dice "Agregar"?

filenew.png

Bueno, en cierta forma sí, pero está pensado para documentos. Sería mejor por ejemplo un signo +. De la misma forma, si bien la X funciona como "remove", si usamos un + para "Add", es mejor un - para "Remove".

Y para "Edit" es mejor usar un lápiz y no un destornillador. El problema ahí es usar el mismo icono que para "Configure". Si bien ambos casos son acciones relacionadas, son lo suficientemente distintas para merecer su propio icono.

radio-22.screen.png

¡Shiny!

¿Quiere decir que este diálogo ya está terminado? No, en absoluto.

Nombres y Descripciones

En algunos sistemas operativos tu ventana va a tener un botń extra, generalmente un signo de pregunta. Eso activa el "What's This?" o "¿Qué es esto?" y tambien se lo accede con un atajo de teclado (muchas veces Shift+F1).

Luego, al hacer click en un elemento de la interfaz, se ve un tooltip extendido con información detallada acerca del mismo. Esta información es útil como ayuda online.

Es sencillo agregarlo usando designer, y si lo hacemos se ve de esta forma:

radio-23.screen.png

"What's This?" de la lista de radios.

Los programas deberían ser accesibles para personas con problemas de visión, por lo cual es importante ocuparse de todo lo que sea teconologías asistivas. En Qt, eso quiere decir por lo menos completar los campos accessibleName y accessibleDescription de todos los widgets con los que el usuario pueda interactuar.

radio-24.screen.png

Datos de accesibilidad.

Uso Desde el Teclado

Es importante que una aplicación no obligue al uso del mouse a menos que sea absolutamente indispensable. La única manera de hacer eso que conozco es... usándola completa sin tocar el mouse.

Probar esta aplicación en su estado actual muestra varias partes que fallan esa prueba.

  • En el diálogo de agregar radios no es obvio como usar los botones "Add" y "Cancel" porque no tienen atajo de teclado asignado.

    Eso es fácil de arreglar con Designer, y se hizo en addradio2.ui. De ahora en más utilizaremos la aplicación radio7.py que usa ese archivo.

  • En el diálogo de configuración no hay manera de editar o eliminar radios sin usar el mouse.

    Esto es bastante más complicado, porque involucra varias partes del diseño, y podría hasta ser suficiente para hacernos repensar la idea del "Edit/Remove" dentro de la lista. Veamos qué podemos hacer al respecto.

El primer problema es que la lista de radios está configurada para no aceptar selección, con lo que no hay manera de elegir un item. Eso lo cambiamos en designer, poniendo la propiedad selectionMode en SingleSelection.

Con eso, será posible seleccionar una radio. Luego, debemos permitir que se apliquen acciones a la misma. Una manera es habilitar atajos de teclado para Edit y Remove, por ejemplo "Ctrl+E" y "Delete".

La forma más sencilla es crear dos acciones (clase QAction) con esos atajos y hacer que hagan lo correcto.

radio7.py

57         # Acciones para atajos de teclado
58         self.editAction = QtGui.QAction("Edit", self,
59             triggered = self.editRadio)
60         self.editAction.setShortcut(QtGui.QKeySequence("Ctrl+E"))
61         self.removeAction = QtGui.QAction("Remove", self,
62             triggered = self.removeRadio)
63         self.removeAction.setShortcut(QtGui.QKeySequence("Del"))
64         self.addActions([self.editAction, self.removeAction])
65 
66     def editRadio(self, b=None):
67         # Simulamos un click en Edit
68         items = self.radioList.selectedItems()
69         if items: # Si no hay ninguno seleccionado,
70                   # no hay que hacer nada
71             # Simulamos un click en la segunda columna de ese
72             # item.
73             item = items[0]
74             self.on_radioList_clicked(self.radioList.indexFromItem(item,1))
75 
76     def removeRadio(self, b=None):
77         # Simulamos un click en Remove
78         items = self.radioList.selectedItems()
79         if items: # Si no hay ninguno seleccionado,
80                   # no hay que hacer nada
81             # Simulamos un click en la tercera columna de ese
82             # item.
83             item = items[0]
84             self.on_radioList_clicked(self.radioList.indexFromItem(item,2))
85 

Traducciones

Uno no hace aplicaciones para uno mismo, o aún si las hace, está bueno si las pueden usar otros. Y está muy bueno si la puede usar gente de otros países. Y para eso es fundamental que puedan tenerla en su propio idioma [5]

[5]Yo personalmente es rarísimo que use las aplicaciones traducidas, pero para otros es necesario.

Esta parte es una de esas que dependen mucho de como sea lo que se está programando. Vamos a hacer un ejemplo con las herramientas de Qt, para otros desarrolos hay cosas parecidas.

Hay varios pasos, extracción de strings, traducción, y compilación de los strings generados a un formato usable.

A fin de poder traducir lo que un programa dice, necesitamos saber exactamente qué dice. Las herramientas de extracción de strings se encargan de buscar todas esas cosas en nuestro código y ponerlas en un archivo para que podamos trabajar con ellas.

En la versión actual de nuestro programa, tenemos los siguientes archivos:

  • radio7.py (nuestro programa principal)
  • plsparser.py (parser de archivos .pls, no tiene interfaz)
  • addradio2.ui (diálogo de agregar una radio)
  • radio3.ui (diálogo de configuración)

¡Extraigamos esos strings! Este comando crea un archivo radio.ts con todo lo traducible de esos archivos, para crear una traducción al castellano:

[codigo/6]$ pylupdate4 radio7.py plsparser.py addradio2.ui \
    radio3.ui -ts radio_es.ts

Los archivos .ts son un XML bastante obvio. Este es un ejemplo de una traducción al castellano:

radio_es.ts

1 <?xml version="1.0" encoding="utf-8"?>
2 <!DOCTYPE TS><TS version="1.1" language="es_AR">
3 <context>
4     <name>Dialog</name>
5     <message>
6         <location filename="addradio2.ui" line="14"/>
7         <source>Add Radio</source>
8         <translation>Agregar Radio</translation>
9     </message>

Otras herramientas crean archivos en otros formatos, más o menos fáciles de editar a mano, y/o proveen herramientas para editarlos.

¿Ahora, como editamos la traducción? Usando Linguist, que viene con Qt. Lo primero que hará es preguntarnos a qué idioma queremos traducir:

linguist-1.screen.png

Diálogo inicial de Linguist

Linguist es muy interesante porque te muestra cómo queda la interfaz con la traducción mientras lo estás traduciendo (por lo menos para los archivos .ui), lo que permite apreciar si estamos haciendo macanas.

linguist-2.screen.png

Linguist en acción

Entonces uno tradujo todo lo mejor que pudo, ¿cómo hacemos que la aplicación use nuestra traducción? Por suerte es muy standard. Primero, creamos un archivo "release" de la traducción, con extensión .qm, donde compilamos a un formato más eficiente:

[codigo/6]$ lrelease radio_es.ts -compress -qm radio_es.qm
Updating 'radio_es.qm'...
Generated 15 translation(s) (15 finished and 0 unfinished)

Del lado del código, debemos decirle a nuestra aplicación donde está el archivo .qm. Asumiendo que está junto con el script principal:

radio7.py

27 # Cargamos las traducciones de la aplicación
28     locale = unicode(QtCore.QLocale.system().name())
29     translator=QtCore.QTranslator()
30     translator.load(os.path.join(os.path.abspath(
31                 os.path.dirname(__file__)),
32                 "radio_" + unicode(locale)))
33     app.installTranslator(translator)
34 
35     # También hay que cargar las traducciones de Qt,
36     # para los diálogos  standard.
37     qtTranslator=QtCore.QTranslator()
38     qtTranslator.load("qt_" + locale,
39             QtCore.QLibraryInfo.location(
40                 QtCore.QLibraryInfo.TranslationsPath))
41     app.installTranslator(qtTranslator);
42     # Fin de carga de traducciones

Y nuestra aplicación está traducida:

linguist-3.screen.png

¡Traducida! ... ¿Traducida?

Nos olvidamos que no todo nuestro texto visible (y traducible) viene de designer. Hay partes que están escritas en el código python, y hay que marcarlas como traducibles, para que pylupdate4 las agregue al archivo .ts.

Eso se hace pasando los strings a traducir por el método tr de la aplicación o del widget del que forman parte. Por ejemplo, en vez de hacer así:

item = QtGui.QTreeWidgetItem([nombre,"Edit","Remove"])

Hay que hacer así:

item = QtGui.QTreeWidgetItem([nombre,self.tr("Edit"),
    self.tr("Remove")])

Esta operación hay que repetirla en cada lugar donde queden strings sin traducir. Por ese motivo... ¡hay que marcar para traducción desde el principio!

Como esto modifica fragmentos de código por todas partes, vamos a crear una nueva versión del programa, radio8.py.

Al agregar nuevos strings que necesitan traducción, es necesario actualizar el archivo .ts:

[codigo/6]$ pylupdate4 -verbose radio8.py plsparser.py addradio2.ui\
    radio3.ui -ts radio_es.ts
Updating 'radio_es.ts'...
Found 24 source texts (9 new and 15 already existing)

Y, luego de traducir con linguist, recompilar el .qm:

[codigo/6]$ lrelease radio_es.ts -compress -qm radio_es.qm
Updating 'radio_es.qm'...
Generated 24 translation(s) (24 finished and 0 unfinished)

Como todo este proceso es muy engorroso, puede ser práctico crear un Makefile o algún otro mecanismo de automatización de la actualización y compilación de traducciones. Por ejemplo, con este Makefile un make traducciones se encarga de todo:

Makefile

1 traducciones: radio_es.qm
2 
3 radio_es.qm: radio_es.ts
4         lrelease radio_es.ts -compress -qm radio_es.qm
5 
6 radio_es.ts: radio8.py plsparser.py addradio2.ui radio3.ui 
7         pylupdate4 -verbose radio8.py plsparser.py addradio2.ui\
8         radio3.ui -ts radio_es.ts

Feedback

En este momento, cuando el usuario elige una radio que desea escuchar, suena. ¿Pero qué está sonando? ¿Cuál radio está escuchando? ¿Que tema están pasando en este momento? Deberíamos brindar esa información, si el usuario la desea, de manera lo menos molesta posible.

En este caso puntual, lo que queremos es el "metadata" del objeto reproductor, y un mecanismo posible para mostrar esa información es un OSD (On Screen Display) o usar una de las APIs de notificación del sistema [6].

[6]Hay pros y contras para cada una de las formas de mostrar notificaciones. Voy a hacer una que tal vez no es óptima, pero que funciona en todas las plataformas.

En cuanto a qué notificar, es sencillo, cada vez que nuestro reproductor de audio emita la señal metaDataChanged tenemos que ver el resultado de metaData() y ahí está todo.

También es importante que se pueda ver qué radio se está escuchando en este momento. Eso lo vamos a hacer mediante una marca junto al nombre de la radio actual.

Ya que estamos, tiene más sentido que "Quit" esté en el menú principal (el del botón izquierdo) que en el secundario, así que lo movemos.

Ah, y implementamos que "Turn Off Radio" solo aparezca si hay una radio en uso (y hacemos que funcione).

Para que quede claro qué modificamos, creamos una nueva versión de nuestro programa, radio9.py, y esta es la parte interesante:

radio9.py

197     def activatedSlot(self, reason):
198         """El usuario activó este icono"""
199         if reason == QtGui.QSystemTrayIcon.Trigger:
200             # El menú del botón izquierdo
201             self.lmbMenu=QtGui.QMenu()
202             
203             if self.player and \
204                     self.player.state() == Phonon.PlayingState:
205                 self.stopAction=QtGui.QAction(
206                     QtGui.QIcon(":/stop.svg"),
207                     self.tr("&Turn Off Radio"),self )
208                 self.stopAction.triggered.connect(self.player.stop)
209                 self.lmbMenu.addAction(self.stopAction)
210                 self.lmbMenu.addSeparator()
211 
212             self.loadRadios()
213             self.radioActions = []
214             for r in self.radios:
215                 receiver = lambda url=r[1]: self.playURL(url)
216                 action = self.lmbMenu.addAction(
217                             r[0], receiver)
218                 action.setCheckable(True)
219 
220                 # Marcamos la radio que estamos escuchando ahora,
221                 # si es que estamos escuchando alguna
222                 if self.player and \
223                     self.player.state() == Phonon.PlayingState and\
224                     getattr(self,'playingURL','') == r[1]:
225                     action.setChecked(True)
226 
227             # Ponemos "Quit" en el menú del botón izquierdo.
228             self.lmbMenu.addSeparator()
229             self.lmbMenu.addAction(self.quitAction)
230 
231             # Mostramos el menú en la posición del cursor
232             self.lmbMenu.exec_(QtGui.QCursor.pos())
233 
234     def playURL(self, url):
235         """Toma la URL de un playlist, y empieza a hacer ruido"""
236         data = parse_pls(url)
237         if data: # Tengo una URL
238             # la anoto
239             self.playingURL = url
240             # Sí, tomamos el primer stream y listo.
241             url = data[0][1]
242 
243             self.player = Phonon.createPlayer(Phonon.MusicCategory,
244                 Phonon.MediaSource(url))
245             self.player.play()
246             # Notificar cada cambio en metaData (qué se esta escuchando)
247             self.player.metaDataChanged.connect(self.notify)
248         else: # Pasó algo malo
249             QtGui.QMessageBox.information(None,
250                 self.tr("Radio - Error reading playlist"),
251                 self.tr("Sorry, error starting this radio."))
252 
253     @QtCore.pyqtSlot()
254     def notify(self):
255         # Obtenemos metadata y mostramos en OSD y en tooltip.
256         md = self.player.metaData
257         self.showMessage(self.tr("Now playing:"),
258             "%s"%(md("TITLE")[0]),
259             QtGui.QSystemTrayIcon.Information,
260             5000)
261         self.setToolTip("%s"%(md("TITLE")[0]))
262 
263     
264 
radio-25.screen.png

Musica tranqui.

Último cambio: Sat Aug 7 18:40:25 2010.  |  Historial  |  PDF  |  Código fuente de los ejemplos

blog comments powered by Disqus