La programación de interfaces gráficas es dependiente de cada arquitectura: Windows, MacOSX, iOS, Android, Gnome, KDE, Xcfe, X.org, etc. El desarrollador tendrá que programar la misma lógica en cada plataforma, lo cual consume recursos cuantiosos. Algunos desarrolladores crean un conjunto de código (clases, funciones libres, etc.) que aíslan a la lógica del programa de la interfaz de cada sistema operativo. Algunos de ellos publican este código como una biblioteca de programación multiplataforma, con diferentes licencias. Ejemplos son wxWidgets y Qt. En este capítulo se introducirá Qt, cuyo aprendizaje serio requiere trabajo adicional del estudiante.
Para poder compilar código Qt requerirá descargar el código fuente de la biblioteca y compilarla, o si está disponible, descargar uno de los instaladores que traen la biblioteca y las herramientas precompiladas. Lo siguiente es un resumen parcial del libro The book of Qt 4 de Daniel Molkentin.
Para hacer un hello world, cree un main.cpp
con el código de abajo {Sugerencia: escríbalo y no lo copie/pegue}. Los includes tienen el mismo nombre de la clase que se quiere usar. Qt hace las conversiones para encontrar el .h
correspondiente. El main()
instancia un objeto QApplication
que luego entrará en el ciclo de eventos (event loop) cuando se le invoque exec()
. El main()
crea un QLabel
invisible hasta que se le invoque el método show()
. Como es la única ventana, esta se convierte en el main window y al cerrarse, invocará automáticamente el método QApplication::quit()
que terminará el programa.
#includeint main(int argc, char* argv[]) { QApplication app(argc, argv); QLabel label("Hello world!"); label.show(); return app.exec(); } ]]>
La forma de compilar el programa anterior varía dependiendo del OS y el compilador que se quiera usar. Trolltech provee un mecanismo independiente de la plataforma: QMake. QMake recibe un project file (.pro) en una notación independiente del OS y del compilador y genera un Makefile dependiente de ambos.
Usted puede crear el archivo .pro manualmente o pedirle a QMake que haga uno por usted con qmake -project
. QMake tomará el nombre de la carpeta donde se invoque como el nombre del proyecto y automáticamente incluirá todos los fuentes (.cpp) que encuentre en esa carpeta:
El archivo .pro generado tiene el aspecto de abajo. La directiva TEMPLATE
indica si se quiere generar una aplicación (app
) o una biblioteca (lib
). SOURCES
indica los archivos que forman parte del proyecto. HEADERS
los encabezados (.h
) del proyecto. Las demás son opcionales. INCLUDEPATH
y DEPENDPATH
indican los directorios en los que el compilador buscará los include files. CONFIG = -moc
indica que el proyecto no necesita uar el MetaObject Compiler.
Para generar un Makefile simplemente corra qmake
en el directorio donde está el archivo .pro. Los archivos generados dependen del OS donde se invoque qmake
. Finalmente para compilar el proyecto emita make
ó make release
ó make debug
. Si usa MinGW cambie make
por mingw32-make
o si usa VC++ por nmake
. Esto generará la biblioteca o el ejecutable de su programa.
El siguiente ejemplo ubica un label bajo el otro, indiferentemente del tamaño de la ventana. En este caso un QWidget
se emplea como ventana principal. ¿Cómo ocurre esto? Cuando un QWidget
es creado y no recibe un objeto parent
por parámetro en el constructor, crea una nueva jerarquía de widgets. Cuando se le invoca su método show()
y aún no tiene un parent
, se convierte en una ventana independiente y se registra con el objeto QApplication
. Varias ventanas independientes pueden estar flotando y pertenecer a la misma aplicación. Cada vez que una ventana independiente se cierra avisa al QApplication
, y éste termina su ejecución cuando la última de ellas se cierra.
#include#include int main(int argc, char* argv[]) { QApplication app(argc, argv); QWidget window; QLabel* label1 = new QLabel("This is the first paragraph"); QLabel* label2 = new QLabel("The history continues here"); QVBoxLayout* mainLayout = new QVBoxLayout(& window); mainLayout->addWidget(label1); mainLayout->addWidget(label2); window.show(); return app.exec(); } ]]>
El objecto QVBoxLayout se encarga del ordenamiento de los widgets hijos del widget que recibe por parámetro en su constructor. Es decir, que ese QVBoxLayout se encargará de ordenar los hijos que tenga window
, automáticamente de acuerdo al tamaño de la ventana y de los labels.
Cuando los QLabel se crean, no reciben un parent en su constructor, es decir, crean una nueva jerarquía de widgets momentáneamente. Sin embargo, cuando se agregan al QVBoxLayout, éste se encarga de asignarles el window como su padre. Cuando el window se muestra, lo hará con todos sus hijos automáticamente.
En el código anterior se crea tres objetos con el operador new ¿por qué no se les invoca delete? Qt ayuda en parte del manejo de memoria. Todos los objetos heredados de QObject pueden tener otros QObject como hijos. Cuando un QObject se destruye, automáticamente destruye todos sus hijos y así recursivamente. En el código de arriba se crea esta jerarquía:
El QVBoxLayout se crea como hijo de window al recibirlo en su contructor, pero cuando se le agregan los labels, éste los hace hijos de su parent widget y no hijos propios, esto porque los widgets necesitan tener como padre el widget donde se dibujan.
El mecanismo de padres e hijos de QObject funciona siempre y cuando los hijos hayan sido creados en el heap con el operador new para poderles hacer delete, como ocurre en la jerarquía de arriba. Si alguno de los labels o el QVBoxLayout fuese creado como una variable en la pila, el compilador la eliminaría automáticamente y luego el mecanismo de QObject trataría de eliminarla por segunda vez haciendo que el programa se caiga. Nótese que es seguro construir QObjects en la pila cuando no tienen un parent QObject, como ocurre con window
.
QVBoxLayout distribuye elemento tras elemento en forma vertical, QHBoxLayout en horizontal y QGridLayout en una cuadrícula los elementos que se le agreguen con addWidget(widget, row, col)
.
#include#include const size_t rows = 13; const size_t cols = 5; int main(int argc, char* argv[]) { QApplication app(argc, argv); QWidget window; QGridLayout* mainLayout = new QGridLayout(& window); for (size_t row = 0; row < rows; ++row) for (size_t col = 0; col < cols; ++col) mainLayout->addWidget( new QLabel( QString("Label (%1,%2)").arg(row + 1).arg(col + 1) ), row, col ); window.show(); return app.exec(); } ]]>
El siguiente ejemplo, muestra la creación de algunos widgets (controles) típicos de las interfaces gráficas, seleccionados aleatoriamente en cada invocación del programa. Nótese que se puede formatear texto dentro de un QString
utilizando secuencias con números "%1"
, "%2"
, etc. en lugar de letras para indicar tipos de datos como ocurre con las funciones printf()
. Qt reemplaza estos secuencias con el i-ésimo argumento, indicado con el método arg()
, lo cual permite cambiar de posición estas secuencias a la hora de traducir la aplicación de un idioma a otro. La línea 44 provoca que al presionarse el botón Salir, se cierre el mainWindow
como se explica en la próxima sección.
Si se quiere manejar inputs del usuario, habrá que comunicar objetos. Qt provee un mecanismo llamado Signals and Slots que comunica dos o más objetos entre sí y además Qt se encarga de romper la comunicación automáticamente cuando un objeto participante es eliminado, evitando que el programa se caiga.
El siguiente ejemplo muestra un push button como ventana principal. La invocación al método estático connect() de QObject, hace una conexión entre un objeto que emite una señal y el objeto que la recibe. Esta conexión se lee: cada vez que el usuario presiona el botón, button emitirá la señal clicked() y en su respuesta, Qt invocará el método quit() en el objeto app que interrumpe el event loop y por consiguiente la ejecución de la aplicación.
#includeint main(int argc, char* argv[]) { QApplication app(argc, argv); QPushButton button("Quit"); button.show(); QObject::connect(&button, SIGNAL(clicked()), &app, SLOT(quit())); return app.exec(); } ]]>
Cualquier método puede ser un signal o un slot si es marcado como tal con macros de Qt. Hay una relación N:M entre ellas. Una señal puede conectarse con M slots, incluso de diferentes objetos; y un mismo slot puede invocarse para N señales distintas o de distintos objetos. Cada vez que una señal es emitida, Qt invocará todos los slots conectados con dicha señal, en cualquier orden. Siempre deben usarse las macros SIGNAL y SLOT en los parámetros indicados ya que connect() espera strings generados de forma consistente a partir de los métodos.
Una conexión puede transportar valores. El siguiente ejemplo muestra un valor en label, el cual puede ser cambiado con un spinbox o un slider. Es decir, cuando cualquiera de los dos widgets que permiten entrada (el spinbox o el slider) es cambiado por el usuario, se debe actualizar los otros dos widgets: el label y el otro control.
#include#include #include #include int main(int argc, char* argv[]) { QApplication app(argc, argv); QWidget window; QVBoxLayout* mainLayout = new QVBoxLayout(& window); QLabel* label = new QLabel("0"); QSpinBox* spinBox = new QSpinBox; QSlider* slider = new QSlider(Qt::Horizontal); mainLayout->addWidget(label); mainLayout->addWidget(spinBox); mainLayout->addWidget(slider); QObject::connect( spinBox, SIGNAL(valueChanged(int)), label, SLOT(setNum(int)) ); QObject::connect( spinBox, SIGNAL(valueChanged(int)), slider, SLOT(setValue(int)) ); QObject::connect( slider, SIGNAL(valueChanged(int)), label, SLOT(setNum(int)) ); QObject::connect( slider, SIGNAL(valueChanged(int)), spinBox, SLOT(setValue(int)) ); window.show(); return app.exec(); } ]]>
Note que todos los signals y todos los slots transportan un entero como parámetro: el nuevo valor en el control modificado por el usuario. Esto funciona si no se tiene que hacer conversiones. Por ejemplo, ninguna de las señales emitidas por el spinbox o el slider pueden conectarse con setText(QString&)
de QLabel. Si una conversión es ineludible, usted deberá heredar la clase e implementar un SLOT que hace la conversión.
Sin embargo una señal que se emite con varios parámetros, puede ser conectada con un slot que recibe menos. Los parámetros adicionales son simplemente ignorados. Así por ejemplo, el QSlider::valueChanged(int)
podría ser conectado con QApplication.quit()
. foo(int, double)
puede se conectado con bar()
, bar(int)
y bar(int, double)
; pero no con bar(double)
o bar(int,double,int)
.
Si usted hace una conexión inválida, ni el compilador ni el linker se quejarán; lo hará la aplicación cuando corra, con un warning message.
Qt se compone de varias bibliotecas (o módulos) y varias herramientas, como QMake. En la versión 4.7 son las siguientes:
Por defecto su aplicación es enlazada (linked) contra QtCore y QtGui. Si usted necesita usar alguna otra biblioteca, deberá indicarlo en la variable QT de su archivo .pro, en el cual también puede quitarlas. Por ejemplo, para hacer una aplicación en línea de comandos con acceso a la red y XML:
También puede separarlas por espacio. Lo siguiente incluye todas las bibliotecas de Qt 4.0:
QtLinguist es la herramienta para traducir su programa de un idioma a otro. Funciona con dos command-line tools: lupdate y lrelease. El primero extrae strings a partir de su código fuente y genera archivos de tradución .ts si hay una regla en el .pro que lo solicite:
QtLinguist abre esos archivos .ts y permite traducirlos en forma gráfica. Finalmente lrelease toma las traduciones hechas y genera un archivo binario que la aplicación carga cuando es ejecutada. De esta forma, no es necesario recompilar código fuente para agregar una traducción.
Para que una aplicación pueda ser traducida su código fuente debe seguir ciertas convenciones. Los strings que serán traducidos deben ser pasados a QObject::tr() o QApplication::translate(). Estos métodos reciben el string, buscarán su correspondiente traducción y la retornarán; por lo que el usuario podrá ver el mensaje en su idioma de elección. Además lupdate levantará la lista de strings a traducir buscando esas funciones en el código fuente. Para que nuestro hello world sea traducible, debemos hacer un cambio como el siguiente:
#includeint main(int argc, char* argv[]) { QApplication app(argc, argv); QLabel label( app.translate("Main", "Hello world!") ); label.show(); return app.exec(); } ]]>
QApplication::translate(context, str) recibe dos textos: str es el string a traducir, pero este puede ser el mismo en varios lugares y sus traduciones podrían ser distintas. Pej: "label" puede traducirse como "etiqueta" en un contexto XML y como "rótulo" en una aplicación sobre publicidad. QtLinguist puede encontrar traducciones diferentes de acuerdo al contexto en que está el str. En el caso de QObject::tr(str), el contexto se asume como el nombre de la clase donde se invoca tr().
Si usted quiere que su programa esté en varios idiomas, empiece a usar tr() desde el inicio, ya que es muy tedioso agregarlo en una etapa posterior.
Esta sección construirá un conversor de Decimal/Hexadecimal/Binario. Un número insertado en cualquiera de esos tres campos, se convertirá y actualizará en los otros automáticamente. Ningún widget de Qt tiene esos tres campos, es necesario crear uno combinado. Por simplicidad se adaptará un diálogo.
#include "BaseConverterDialog.h" int main(int argc, char* argv[]) { QApplication app(argc, argv); BaseConverterDialog dialog; dialog.setAttribute(Qt::WA_QuitOnClose); dialog.show(); return app.exec(); } ]]>
QDialog está diseñado para transferir información entre un main window y el usuario, no para ser el main window, por eso, cuando un QDialog se cierra, por defecto no solicita al QApplication terminar. Esto se puede cambiar asignándole el atributo Qt::WA_QuitOnClose. El diálogo luce así:
class QLineEdit; class BaseConverterDialog : public QDialog { Q_OBJECT private: QLineEdit* decimalEdit; QLineEdit* hexadecimalEdit; QLineEdit* binaryEdit; public: explicit BaseConverterDialog(QWidget *parent = 0); }; #endif // BASECONVERTERDIALOG_H ]]>
Se debe usar forward declarations (ej: QLineEdit) siempre que sea posible para optimizar el tiempo de compilación. Todas las clases que hereden de QObject, aunque sea indirectamente, deben tener la macro Q_OBJECT para que el Meta Object Compiler (moc) genere código que hace funcionar los signal/slots y otras características.
Note que Q_OBJECT no se terminó en punto y coma, ya que en algunos compiladores puede generar errores. Si por error se omite esta macro en un descendiente de QObject, ni el compilador ni el linker se quejarán, el signal/slots y otros mecanismos simplemente no funcionarán. Lo más que puede obtener es un warning en runtime en la terminal si corre su programa con debugging information, quejándose de que el signal o el slot no existe, que tristemente es el mismo mensaje si escribe mal el nombre de un signal o un slot o un parámetro de ellos: No such signal/slot...
Todo archivo que tenga la macro Q_OBJECT debe pasar por el moc, el cual no modifica sus archivos, sino que implementa los signal/slots en archivos separados (moc_MyClass), los cuales deben agregarse al Makefile, lo cual es hecho por QMake. QMake rastrea todos los archivos que referencie desde su .pro y en aquellos que encuentre la macro Q_OBJECT, generará reglas en los Makefile para llamar al moc. Por esta razón, se debe referenciar no sólo los .cpp sino también los .h en la variable HEADERS:
Regresando al diálogo, es necesario crear los widgets que lo componen y alinearlos de tal forma que se vean bien incluso aunque se redimensione el dialog. Es por esto que la creación de controles y layouts se hace simultáneamente. Es conveniente que haga un dibujo antes de empezar a programar.
addLayout( editLayout ); mainLayout->addStretch(); mainLayout->addLayout( buttonLayout ); // Create each label and field into the grid editLayout->addWidget( new QLabel(tr("Decimal")), 0, 0 ); editLayout->addWidget( decimalEdit = new QLineEdit(), 0, 1 ); editLayout->addWidget( new QLabel(tr("Hexadecimal")), 1, 0 ); editLayout->addWidget( hexadecimalEdit = new QLineEdit(), 1, 1 ); editLayout->addWidget( new QLabel(tr("Binary")), 2, 0 ); editLayout->addWidget( binaryEdit = new QLineEdit(), 2, 1 ); // Create the button QPushButton* quitButton = new QPushButton(tr("Quit")); buttonLayout->addStretch(); buttonLayout->addWidget(quitButton); [...] } ]]>
Note que los widgets se agregan a un layout con addWidget() y un layout a otro layout con addLayout(). El layout contenedor debe estar asociado a un widget para poder recibir a otros widgets; de lo contrario poduciría un un error en tiempo de ejecución. Por eso es buena práctica crear y asociar los layouts al inicio de su constructor.
Una tercera función de los layouts addStrecth() agrega un Stretch, que se encarga de ocupar el espacio no requerido por los widgets. Note que los métodos addWidget() y addLayout() se encargan de establecer la jerarquía de widgets y por ende, no tenemos que preocuparnos de eliminar su memoria, sólo la del BaseConverterDialog que es el padre de todos.
Mejoras: El título del diálogo usa el nombre de la aplicación y debería ser algo más descriptivo y traducible. El Quit button debería reaccionar cuando se presione Enter {¿o Escape?}, es decir, hacerlo el Default button. Evitar que el usuario ingrese texto o números decimales mayores a 255, hexadecimales mayores a 0xFF y binarios de 8 bits. El código siguiente soluciona estos inconvenientes.
setDefault(true); // Limit input to valid values decimalEdit->setValidator( new QIntValidator(0, 255, decimalEdit) ); hexadecimalEdit->setValidator( new QRegExpValidator(QRegExp("[0-9A-Fa-f]{1,2}"), hexadecimalEdit) ); binaryEdit->setValidator( new QRegExpValidator(QRegExp("[01]{1,8}"), binaryEdit) ); setWindowTitle(tr("Base converter")); ]]>
Cada validador es asociado a un text field en dos formas. Primero el validador recibe un parent en su constructor, el cual se encargará de eliminar su memoria automáticamente a través de la jerarquía de QObjects, por lo que los validadores siempre deben construirse en heap. El método setValidator() hace que el text field pida ayuda al validador para saber si aceptar cada carácter recién ingresado por el usuario.
Para que el botón Quit tenga efecto, se conecta con el slot accept() del diálogo, por una convención. Los diálogos generalmente proveen dos botones: Accept y Cancel, conectados a los slots accept() y reject() respectivamente. Ambos cierran el diálogo, el primero retorna un valor positivo y el segundo uno negativo.
Ahora queremos hacer conversiones cada vez que un text field cambia su valor, es decir, conectar la señal textChanged() con qué? No hay ningún slot que haga conversiones de base, es responsabilidad nuestra proveerlos. El autor decide crearlos en el diálogo mismo {Aunque es mejor un controlador, de acuerdo al modelo MVC}. Se declaran en la sección especial {public|protected|private} slots de la clase:
Los slots son métodos normales; se declaran, implementan e invocan como cualquier otro método en cualquier momento. La única diferencia es su declaración en una sección especial y la posibilidad de conectarse con signals. Abajo la implementación de decimalChanged()
setText( ok ? QString::number(num, 16) : QString() ); binaryEdit->setText( ok ? QString::number(num, 2) : QString() ); } ]]>
El parámetro newValue contendrá el texto que ha introducido el usuario en el campo decimalEdit, es decir, un string con el número en decimal que debe convertirse a hexadecimal y binario. Las conversiones entre strings y números ya vienen incorporadas en la clase QString: de string a número (toInt, toLong, toLongLong...), de número a string (number, setNum, sprintf, arg).
La implementación anterior asigna el valor hexadecimal y binario a los otros campos sólo si el valor en el campo decimal representa un valor entero válido. Para obtener strings en esas bases se debe convertir de nuevo el número, se hizo con el método estático number(), cuyo segundo parámetro indica la base en la que se quiere generar el número en el string.
Si el método toInt() de QString no puede convertir a un entero, se limpiarán los otros campos de texto. Gracias al validador que sólo permite valores decimales entre 0 y 255, sólo habrá una situación en que lo anterior pase: cuando el usuario borra el campo decimal completamente. Los otros métodos se implementan igual, cambiando nada más las bases y los los otros text fields:
setText( ok ? QString::number(num, 10) : QString() ); binaryEdit->setText( ok ? QString::number(num, 2) : QString() ); } ]]>
Para que esos slots sean invocados, se deben conectar con las señales textChanged() de cada text field al final del constructor:
En el ejemplo actual, la clase BaseConverterDialog se ocupa tanto de la interfaz gráfica como de la lógica de procesamiento. Esto hace al programa más difícil de mantener. Por ejemplo, si después se quisiera cambiar la interfaz gráfica por una web o hacer un refactoring, es difícil no afectar el código de procesamiento. Lo conveniente es separarlos para reducir este retrabajo, así la clase BaseConverterDialog se encarga únicamente de la interfaz, y una clase nueva, BaseConverter se encarga únicamente de hacer conversiones entre números.
La clase BaseConverter tendrá signals y slots de tal forma que el diálogo instancia un BaseConverter y conecta sus text fields con dichos signals/slots. Por ejemplo, cuando el usuario cambia el texto en el decimal text field, se invoca el slot BaseConverter::setDecimal(), quien hará conversiones y emite dos señales: hexadecimalChanged() y binaryChanged(), los cuales el diálogo asociará con los respectivos text fields. De esta forma, la clase BaseConverter no conoce en absoluto la interfaz, y además podrá reutilizarse en otras circunstancias, quizá programación web.
La clase BaseConverter tiene signals/slots, por ende debe heredar de QObject y todas sus implicaciones, como incluir la macro Q_OBJECT y ser compilada con moc (agregarla a HEADERS y SOURCES en el .pro). Los slots deberán ser invocados desde el diálogo, por lo que se declaran públicos.
Las señales se declaran en la sección signals: y no tienen un modo de acceso, siempre son públicas ya que de otro modo serían inútiles para comunicación entre objetos. Además nunca se implementan por el programador, es el moc quien lo hace en los archivos moc_MyClass.cpp y su implementación consiste simplemente en invocar los métodos que se les haya conectado.
La implementación de los slots es muy similar a las del diálogo: recibir el nuevo valor en un string, cambiarlo de base y en lugar de asignarlo a otro text field, emitir la señal correspondiente, lo cual es una invocación normal pero antecedida por la macro emit de Qt. Ej:
Ahora la nueva clase liberará responsabilidad en el diálogo. Elimine los private slots, tanto su declaración como implementación. Ahora haga las nuevas conexiones en el constructor:
Note que el objeto BaseConverter es creado en heap y no es eliminado explícitamente. La jerarquía de QObject se encarga de ello cuando el diálogo es destruido. Esto libera al programador de esta responsabilidad y además, asegura que el BaseConverter estará disponible para el diálogo mientras éste tenga existencia.