INTRODUCCION A LA PROGRAMACION EN X WINDOW

Artículo 16: FUNCIONES VARIAS EN XLIB (I).

Autor: (c) Santiago Romero.
Revista: Programación Actual (Prensa Técnica) nº 34, Enero-2000



En esta parte final del curso se verán aquellas funciones que pueden hacer más sencilla y rápida la programación en X Window y que no han sido comentadas hasta el momento, como la comunicación entre aplicaciones o los buffers de corte del servidor X, así como algunas de las funciones usadas en nuestro anterior ejemplo X Snake..


Aunque actualmente para la comunicación entre aplicaciones X en cuanto a selecciones se refieren se suelen utilizar otros métodos más potentes (las propiedades), los CutBuffers (Buffers de Corte) son una manera muy eficaz y sencilla de mover datos entre aplicaciones. Esto hace posible, por ejemplo, que sea perfectamente posible copiar datos en cualquier aplicación X y que se puedan pegar en cualquier otra aplicación del sistema.


CUT BUFFERS

Los buffers de corte se almacenan en el servidor (ocho concretamente por servidor, numerados del cero al siete). Dentro de cualquiera de estos 8 buffers de corte podemos almacenar datos (texto, imágenes, etc.). En realidad, a X no le importa el significado de los datos, simplemente almacena el conjunto de bytes que le especifiquemos, independientemente de si se trata de texto, imágenes o sonidos. Son las aplicaciones las que se ponen de acuerdo entre el tipo y formato de intercambio de los datos

Por ejemplo, las terminales de texto en X utilizan siempre el CutBuffer 0, y almacenan simplemente caracteres ASCII con retornos de carro, tabuladores, etc. Si en nuestra aplicación detectamos la pulsación del botón central del mouse (o de la tecla que deseemos asignar a la función de pegado) y leemos los datos contenidos en el CutBuffer 0, insertándolos en nuestra aplicación, tendremos implementada una función de pegado que nos permitirá pegar texto en nuestra aplicación copiado desde cualquier programa de X. Análogamente, si introducimos datos en dicho CutBuffer estaremos posibilitando que en cualquier aplicación pueda pegarse texto copiado desde la nuestra.

Las funciones para la inserción de datos en los CutBuffers son las siguientes:

XStoreBytes( Display *display, char *bytes, int nbytes );
Esta función almacena un vector de bytes (que puede representar cualquier cosa) dentro del CutBuffer 0. Se le pasa como parámetro el display del servidor en el que almacenarlo, un puntero a los datos a almacenar, y el número de bytes que ocupan dichos datos.

XStoreBuffer( Display *display, char *bytes, int nbytes, int buffer );
Esta función almacena un vector de bytes (que puede representar cualquier cosa) dentro del CutBuffer buffer. Utiliza los mismos parámetros que la función anterior seguidos del buffer donde almacenar los datos.

La manera de vaciar los CutBuffers consiste en almacenar en ellos un dato de cero bytes, es decir, llamando a esta función con un valor de nbytes igual a cero.

Las funciones para la lectura de datos desde los CutBuffers son las siguientes:

char *bytes = XFetchBytes( Display *display, int * nbytes );
Esta función lee los datos almacenados en el CutBuffer 0 del servidor X, devolviendo un puntero a dichos datos y en nbytes el tamaño de los mismos. Para devolver los datos la función pide memoria internamente que tendremos que liberar cuando hayamos finalizado con su uso. Ejemplo:


char *datos;
int nbytes;
datos = XFetchBytes( display, &nbytes );

/* más código */

if( datos != NULL )
   XFree( datos );

También existe otra función para recuperar datos de cualquier buffer de corte (no sólo del cero):

char *bytes = XFetchBuffer( Display *display, int * nbytes, int buffer );
Esta función realiza el proceso equivalente a la función anterior pero para el buffer especificado.

Por último, los 8 CutBuffers pueden considerarse como una lista circular de buffers la cual podemos rotar (hacer que el buffer que antes era el 0 ahora sea el 1, etc.). Esto puede ser muy útil para ciertas aplicaciones, y se hace mediante XRotateBuffers( Display *display, int rotate ), donde rotate es la cantida de veces a rotar los buffers (cero los deja como están). Es una buena idea poner todos los buffers con longitud cero antes de comenzar a utilizar esta función.


ENVIO DE EVENTOS ENTRE APLICACIONES

Una cosa que puede resultar útil para comunicar aplicaciones es el envío de eventos X de una aplicación a otra. Esto implica que podamos enviar, por ejemplo, eventos de tipo KeyPress, Expose, etc. a aplicaciones de forma que estas crean que la aplicación que envia el evento es el mismo servidor X.

Supongamos por ejemplo que disponemos de un programa que dispone de muchas funciones a las que se accede a través del teclado. Sería perfectamente posible, gracias al envío de eventos X entre aplicaciones, diseñar un programa con botones que hagan las tareas más habituales del primer programa, de forma que cuando pulsemos en un botón, envío eventos KeyPress al otro programa con las teclas apropiadas para realizar determinadas funciones (es decir, hemos creado un programa de macros). Hay otras muchas aplicaciones para el envío de eventos, de modo que vamos a ver la manera de comunicar nuestras aplicaciones mediante este lazo.

Para ello se utiliza la función XSendEvent:


Status XSendEvent(display, window_dest, propagate, event_mask, event_send)
Display *display;
Window window_dest;
Bool propagate;
long event_mask;
XEvent *event_send;  
Es necesario rellenar correctamente la estructura XEvent (con todos los campos asociados al evento que vamos a enviar) , y la máscara del evento. El evento será enviado a la ventana window_dest, y la función devolverá 0 en caso de error, y distinto de cero en caso de éxito. La propagación se refiere a si el evento se debe propagar a las subventanas de una ventana en el caso de que dicha ventana no esté interesada en el tipo de eventos que enviamos.

Para saber a qué ventana enviar el evento es posible usar las constantes PointerWindow (enviar el evento a la ventana que tenga el cursor del ratón sobre ella) o InputFocus (aquella ventana que tenga el foco del teclado).

Veamos un ejemplo de envío de eventos KeyPress a otra aplicación:


int EnviarCodigo( Display *display, Window window, int keycode, int estado )
{
   int miestado;
   XKeyEvent evento;
 
   evento.display = display;
   evento.window = window;
   evento.root = RootWindow( display, DefaultScreen(display));
   evento.time = CurrentTime;
   evento.state = estado;
   evento.type = KeyPress;
   evento.keycode = keycode;
   evento.x = evento.y = evento.x_root = evento.y_root = 0;
   evento.same_screen = 1;
   evento.subwindow = (Window) NULL;

   miestado = XSendEvent( display, window, 0, KeyPressMask, &evento );
   if( miestado != 0 )
   {
      evento.time = CurrentTime;
      evento.type = KeyRelease;
      miestado = XSendEvent( display, window, 1, KeyReleaseMask, &evento );
   }
   return(miestado);
}
En esta función rellenamos todos los parámetros de la estructura Evento y enviamos el evento. En caso de éxito enviamos el evento de liberación de tecla y salimos de la función.


ENVIO DE EVENTOS CLIENTMESSAGE

Una dimensión más correcta para la comunicación entre aplicaciones es el envío de eventos ClientMessage. Estos eventos son los reservados por X para este tipo de comunicaciones, y no necesitan ser especificados en la máscara de eventos que desea recibir nuestro programa, ya que siempre son recibidos. Mediante estos eventos es posible enviar información a otra aplicación utilizando los campos de la estructura XClientMessageEvent:


typedef struct
{
    int type;
    unsigned long serial;
    Bool send_event;
    Display *display;
    Window window;
    Atom message_type;
    int format;

    /* campos para intercambio de datos: */
    union {
                char b[20];
                short s[10];
                long l[5];
              } data;
 } XClientMessageEvent;             

El resultado dentro de nuestra aplicación para recepción sería algo como:


 switch( evento.tipo )
 {
   case ClientMessage:
           /* trabajar con los campos deseados */
           break;
   etc..
 }
Para el envío de estos eventos a otras aplicaciones ya conocemos XSendEvent(), de modo que podemos establecer métodos de comunicación entre todas las aplicaciones que programemos.


DIMENSIONES CORRECTAS DEL TEXTO

Otro aspecto importante a la hora de programar aplicaciones que usan texto es conocer en todo momento las dimensiones de cada elemento de la fuente de letras, así como el espacio que ocuparán las frases, la anchura o altura de cada letra, etc. Algunos lectores me preguntaban acerca de la manera de que sus programas funcionaran de una forma casi estándar independientemente del tipo de letra que el usuario deseara cargar para su funcionamiento, y precisamente ese es el problema que se resuelve mediante las funciones que veremos a continuación:

Lo primero que veremos es la manera de conocer la altura absoluta de una fuente de letras, de forma que dejemos como mínimo dicho espacio entre 2 líneas de texto consecutivas para que no monte una sobre la otra. Para ello haremos uso de la estructura XFontStruct, parámetro devuelto por la función de informació sobre fuentes XQueryFont( display, nombre), que podremos utilizar sobre una fuente tras su carga. Esta estructura contiene campos que nos informarán de determinadas características de la fuente:


XFontStruct *font_struct;
Font font;
int altura_texto;

font = XLoadFont( display, "9x15" );
font_struct = XQueryFont( display, font );

altura_texto = font_struct->ascent +
             font_struct->descent;
Mediante la variable altura_texto ya podemos calcular las alturas correctas de líneas en nuestro programa.

Para las anchuras en pixels de las fuentes disponemos de la útil función XTextWidth():

int XTextWidth( XFontStruct *font_struct, char *string, int count);
Esta función, dado una estructura de fuente, una cadena, y el número de caracteres de la misma, nos devuelve la anchura en pixels que ocuparía dicha cadena si la escribiésemos en pantalla o en un pixmap, cosa que ya sabemos hacer mediante lo visto en anteriores entregas (XDrawText, XDrawString, XDrawImageString, etc.).

Sobre la carga de fuentes, un consejo que puede leerse en la documentación de muchos programas y en muchos libros sobre X es que se debe intentar cargar primero la fuente que el usuario especifique en línea de comandos mediante -fontname <nombre_de_fuente>. Si dicho intento falla (porque la fuente no se encuentre disponible en ese sistema), hay que intentar cargar la fuente por defecto que deseemos que use nuestro programa (por ejemplo, "9x15" es bastante estándar), y en último extremo en caso de fallo, tratar de cargar la fuente "fixed", que se encuentra en prácticamente todos los sistemas.


TEXTO FORMATEADO

A la hora de dibujar texto en pantalla no se suele trabajar con texto llano (sin colores, ni estilos) sino que probablemente necesitaremos alguna función a la cual se le pase una cadena de caracteres formateada (avances de línea o \n, etc.) y que sea capaz de trazarla correctamente en pantalla. Una función de este tipo la podemos ver en el listado 1. Como se puede ver en ella, simplemente recorremos la cadena parándonos ante los \n, tras los cuales avanzamos la coordenada y de dibujo en altura_fuente pixels, consiguiendo el objetivo de dibujar en diferentes líneas el texto formateado.



 

LISTADO 1: PrintfText()

/*======================================== Imprime cadenas formateadas con \n. Modificable para ampliaciones con colores, estilos, etc. =======================================*/ PrintfText( Display *display, Window window, GC gc, int x, int y, int altura_fuente, char *cadena, int longitud ) { char buff[81]; int i=0, j=0, caracter; while( i<longitud ) { j = 0; caracter = cadena[i]; while( (j<80) && (i<longitud) && (caracter!='\n') ) { buff[j] = cadena[i]; i++; j++; if( i<longitud ) caracter = cadena[i]; } if( j > 0 ) { buff[j] = '; XDrawString( display, window, gc, buff, strlen(buff) ); } i++; y+=altura_fuente; } XFlush(display); }
A esta función de ejemplo que hemos visto se le pueden hacer multitud de ampliaciones. Podríamos crear un modificador \k (por ejemplo) que a partir de ese momento cambie la fuente a itálica (cursiva), otro para negrita, modificadores para cambiar el color, etc. Esto haría que mediante esa función se pudiese trazar cualquier tipo de texto y en cualquier estilo simplemente formateando correctamente la cadena que se desea imprimir, en lugar de realizar en el código llamadas para cambiar el GC cada vez que se traza texto en pantalla.


CAPTURA (GRAB) DEL MOUSE

En ciertas ocasiones puede sernos interesante capturar el puntero del ratón de forma que pertenezca exclusivamente a nuestra aplicación y que de este modo todos los eventos originados por el puntero (en forma de movimientos o pulsaciones) puedan ser atentidos por el bucle de eventos de nuestro programa. Esta acción de captura (que hace que las demás aplicaciones dejen de ver el ratón mientras dura) se denomina Grab, y es perfectamente posible con las funciones básicas de Xlib:

int XGrabPointer( Display display, Window grab_window, Bool owner_events, int event_mask, int pointer_mode, int keyboard_mode, Window confine_to, Cursor cursor, Time time);
Mediante XGrabPointer() se realiza la captura del puntero del ratón. Para ello se le especifica el display y la ventana a la que limitar el ratón (grab_window normalmente será la ventana raíz o sólo nuestra ventana), el modo de puntero y de teclado (que para la captura del ratón suelen valor pointer_mode = GrabModeSync y keyboard_mode=GrabModeAsync), la variable confine_to que puede utilizarse para confinar el puntero a una determinada ventana (y que no pueda salir de ella) y el cursor que deseamos definir para el ratón mientras dure el grab. Esta función devuelve GrabSuccess si el puntero ha sido capturado con éxito.

Una vez capturado el puntero, debemos activar los eventos con XAllowEvents() que permitirá el procesado normal de los eventos hasta que llegue el primer ButtonPress o ButtonRelease. Podemos con XWindowEvent() chequear únicamente aquellos eventos que nos interesen, por ejemplo, y tomar las acciones apropiadas para ellos.

Para deshacer el grab y dejar la situación del mouse como anteriormente, basta con utilizar XUngrabPointer( display, time );

Como ejemplo:


status = XGrabPointer(display,
    RootWindow(display,screen), False,
    ButtonPressMask, GrabModeSync,
    GrabModeAsync, False, cursor, CurrentTime );

if( status == GrabSuccess )
{
   /* usar el ratón aquí */
   XAllowEvents(display, SyncPointer, CurrentTime );
   XWindowEvent(display, RootWindow(display,screen), ButtonPressMask, &evento );
   if( evento.type == ButtonPress )
   {
        /* detectada pulsación del mouse */
        hacer_lo_que_sea();
   }
   XUnGrabPointer( display, CurrentTime );
}
else
  Error();

Mediante estas funciones podemos controlar las coordenadas del raton o sus pulsaciones aunque éstas caiga fuera de nuestra ventana, gestionando así completamente el ratón si nuestra aplicación lo requiere (por ejemplo para juegos controlados mediante el mouse).


POLLING DEL MOUSE

Hacer polling del mouse equivale a tener la posibilidad de leer en cualquier momento las coordenadas (x,y) donde está situado el puntero del mismo, así como el estado de los botones. Esto nos permite realizar importantes comprobaciones fuera del bucle de eventos, donde teníamos acceso a estos datos dentro de los distintos campos de la estructura XEvent, bien cuando se recibía un XButtonPress o XButtonRelease (para la lectura de los botones), o bien cuando se recibía un evento MotionNotify (para su posición o su cambio de posición mientras se mantiene pulsado un botón).

Mediante la función que vamos a ver a continuación no es necesario estar dentro del bucle de eventos ni mirar dentro de las estructuras de eventos para conocer información sobre el estado del dispositivo apuntador:


Bool XQueryPointer(
     Display *display, Window window,
     Window *root_return, Window *child_return,
     int *root_x_return, int *root_y_return,
     int * win_x_return, int *win_y_return,
     unsigned it *mask_return);
Esta función acepta como parámetros el display sobre el que realizar la consulta y la ventana que la realiza, y recibe en el resto de parámetros (usados para recoger los valores devueltos) toda la información requerida:

root_return = Ventana raíz del display indicado.
child_return = Ventana sobre la que está el puntero del ratón.
root_x_return = Coordenada X del ratón relativa a la ventana raíz (escritorio).
root_y_return = Coordenada Y del ratón relativa a la ventana raíz (escritorio).
win_x_return = Coordenada X del ratón relativa a la ventana del programa.
win_y_return = Coordenada Y del ratón relativa a la ventana del programa.
mask_return = estado actual de los botones del mouse.

Mediante la máscara mask_return podemos saber el estado de cualquiera de los botones del mouse utilizando las constantes definidas en <X11/X.h>. Mediante estas constantes no sólo podemos saber el estado de pulsación de los botones sino que también son capaces de informarnos de si durante dicha pulsación o movimiento está siendo pulsada Mayúsculas, Control, Alt, etc. Dichas constantes son:

ShiftMask
ControlMask
CapsLockMask
Mod1Mask  (tecla META o COMPOSE).
Button1Mask
Button2Mask
etc.

Un ejemplo del uso de esta función sería:

XQueryPointer( display, window, &rootwin, &childwin, &rx, &ry, &wx, &wy, &botones );
Esto nos daría toda la información sobre el estado del mouse en cualquier parte de la aplicación, y no sólo en el bucle de eventos (lo cual puede ampliar en mucho la sencillez de funcionamiento del programa), aunque esta función es de muy lento acceso (mucho más lenta que el simple acceso a través de los eventos).


OTRAS FUNCIONES DE USO DEL MOUSE

Hay 2 funciones que permiten manipular el modo de funcionamiento del dispositivo apuntador (la aceleración, etc.): XChangePointerControl y XGetPointerControl. Mediante ellas podemos obtener diferentes datos acerca del ratio de movimiento del puntero del mouse, y modificarlos. Los parámetros más importantes son acc_num/acc_den, que representan la aceleración del ratón en forma de fracción, y el umbral (threshold), que representa el número de unidades de movimiento que hay que desplazar el mouse antes de que empiece a moverse el puntero.

Ejemplo: ralentizar a la mitad la velocidad de movimiento del mouse:


Display *display;
int acc_num, acc_denom, thresh;

XGetPointerControl( display, &acc_num, &acc_denom, &thresh );
acc_num = acc_num/2;
XChangePointerControl( display, 1, 0, acc_num, acc_denom, thresh );

Por último, una función muy útil para aquellos ordenadores en donde no se dispone de ratón es XWarpPointer, porque hace a nuestra aplicación capaz de mover el puntero del ratón mediante teclas a nuestra elección. Esta función tiene el siguiente prototipo:


XWarpPointer(display, src_w, dest_w, src_x, src_y,
                      src_width, src_height, dest_x, dest_y)
Display *display;
Window src_w, dest_w;
int src_x, src_y;
unsigned int src_width, src_height;
int dest_x, dest_y;

Esta función mueve el puntero del ratón desde la ventana origen src_w, con dimensiones src_width y src_height a la ventana destino especificada y en las coordenadas destino que se le pasan en dest_x, dest_y. Si se desea, se puede especificar dest_x y dest_y relativamente a la ventana de destino y especificar como 0 src_w, src_x, src_y, src_width y src_height. Esto nos permitiría mover el cursor del ratón incondicionalmente por toda la pantalla (dst_w=rootwindow) de una manera absoluta. Si dest_w se especifica como 0 (None), entonces el movimiento es relativo (es decir, se mueve el cursor desde la posición actual una cantidad de pixels determinada por dst_x, dst_y).

La correcta utilización de esta función nos permitirá que en nuestras aplicaciones, por ejemplo, el puntero del mouse se pueda mover también mediante ciertas teclas, lo cual beneficiaría a los usuarios sin ratón.


CAPTURA (GRAB) DEL TECLADO

Es perfectamente posible capturar el teclado para que todos los eventos de teclado realizados sean exclusivamente reportados a nuestra aplicación (muy útil, por ejemplo, si se trata de un juego). Para ello se puede provocar una Captura Activa del Teclado (Active Keyboard Grab) mediante las funciones que se van a presentar a continuación.

La captura del teclado se realiza mediante la función XGrabKeyboard(), y se finaliza de varias posibles maneras: bien utilizando la función XUngrabKeyboard(), bien mediante XAllowEvents(), bien haciendo la ventana invisible o, finalmente, terminando la ejecución de nuestra aplicación (o, al menos, la comunicación de ésta con el servidor X).


int XGrabKeyboard(display, grab_window, owner_events, pointer_mode, keyboard_mode, time)
Display *display;
Window grab_window;
Bool owner_events;
int pointer_mode, keyboard_mode;
Time time;                   

El parámetro owner_events especifica, si está a 1, que los eventos que ya están en la cola de eventos (ocurridos durante el grab) se reporten de manera normal, mientras que a 0 se tratan como si hubiesen sido capturados. Pointer_mode normalmente valdrá GrabModeAsync, de forma que el procesador de eventos del puntero sean realizados normalmente. Keyboard_mode normalmente valdrá el mismo valor. Para time suele utilizarse el valor time devuelto por las funciones de evento.

El grab o captura se finaliza mediante la función int XUngrabKeyboard( display, time );.

El modo de uso de XAllowEvents() es el siguiente:


XAllowEvents(display, event_mode, time)
Display *display;
int event_mode;
Time time;        

De estos parámetros, event_mode especifica el modo de permiso, entre AsyncPointer, SyncPointer, AsyncKeyboard, SyncKeyboard, ReplayPointer, ReplayKeyboard, AsyncBoth, or SyncBoth, es decir, permitiendo acceso síncrono o asíncrono al teclado, al ratón, o a ambos.


POLLING DEL TECLADO

El polling del teclado es una lectura asíncrona del mismo (also similar a lo que sucedía con el ratón): no es necesario esperar a que se produzca un evento para poder conocer el estado de cualquier tecla del teclado (o de varias de ellas). Mediante las funciones adecuadas (XQueryKeymap()), es posible obtener un vector que nos informe del estado (pulsada o no pulsada) de todas las teclas del teclado.

XQueryKeymap( Display *display, char keys_return[32]);
Esta función devuelve en el array de 32 elementos de 8 bytes (total = 32*8 = 256 elementos) el estado de las teclas del teclado, con un 1 si está pulsada o un 0 si no lo está. Simplemente tendremos que testear el bit que corresponda a cada tecla para saber su estado.

Mediante esta función podemos desarrollar otra función de gran utilidad, KeyPressed, la cual nos devolverá el estado de una tecla determinada según el código de tecla que deseemos testear:


int KeyPressed( Display *display, int keycode )
{
  char vector[32];
  int bit, codigo;
  unsigned int byteindex, bitindex;

  byteindex = keycode / 8;
  bitindex = keycode & 7;

  XQueryKeymap( display, vector );
  bit = ( 1 & (vector[byteindex] >> bitindex ) );
  return(bit);
}

Esta función permitirá realizar en cualquier parte del programa sentencias como:


#define ESCAPE 9

if( KeyPressed( ESCAPE ) == 1 )
   Salir();

Si vamos a utilizar esta función de forma muy seguida es recomendable llamar una sola vez a XQueryKeymap() y obtener una sola vez el mapa de bits del teclado, y testear luego cada vez los bits deseados en lugar de realizar varias peticiones al servidor X.


FUNCION GETTIMEOFDAY

Esta función tiene el siguiente formato:


#include <sys/time.h>
#include <unistd.h>
int gettimeofday(struct timeval *tv, struct timezone *tz);

La estructura timezone está obsoleta y no nos interesa, de modo que al llamar a esta función pondremos el segundo parámetro a NULL. La estructura timeval, por contra, sí que es útil:

struct timeval 
{
    long    tv_sec;         /* segundos */
    long    tv_usec;        /* microsegundos */
};

Los valores numéricos de esta estructura cambian con el tiempo (tv_sec según los segundos y tv_usec según los microsegundos transcurridos). En el caso de tv_usec, va desde cero hasta 1.000.000 momento en que se resetea a cero e incrementa tv_sec. Hay definidas funciones de temporizacion usando estos parámetros (ver man gettimeofday).


FUNCION USLEEP

#include <unistd.h>
void usleep(unsigned long usec);
Esta función suspende la ejecución del proceso llamante durante un número determinado de microsegundos. La pausa puede prolongarse ligeramente por cualquier actividad en el sistema o por el tiempo gastado procesando la llamada. Otras funciones relacionadas y que permiten el uso de temporizadores en Linux mediante señales (3 por aplicación) son setitimer() y getitimer() (ver páginas man).


FUNCION SLEEP

La función sleep() es el equivalente de usleep() pero para espera de segundos, es decir, duerme a la aplicación durante el número de segundos especificado. Esta función puede ser interrumpida si llega una señal importante para el programa.


EN RESUMEN

Vistas estas funciones y el completo ejemplo de nuestro anterior número queda ya muy poco por comentar en cuanto a Xlib. En la próxima (y última) entrega de nuestro curso veremos la manera de utilizar botones (pulsadores) en nuestras aplicaciones, así como editar textos (la posibilidad de tomar cadenas de texto desde el teclado), 2 cosas realmente útiles en cualquier aplicación.


Santiago Romero


Volver a la tabla de contenidos.