Gráficas con Python 3 y tkinter

Esta es la tercera parte del tutorial para el grupo de Python en LinuxCabal A.C.

Autor:Patricio Páez
Contacto:nospam en pp.com.mx
Fecha:2010-06-28
Versión:1

Contenido

1   Preparativos

Empezamos con una carpeta de trabajo con el archivo tools.py después de completar la primera y segunda parte del tutorial. Git y Python 3 deben estar ya instalados, y que entiendas los comandos básicos de Git.

En GNU/Linux instala el paquete python3-tk. Aunque tkinter es parte de la biblioteca estándar de Python, las distribuciones lo empaquetan separado del paquete principal de Python. En Windows ya se instaló cuando instalaste Python 3.

2   Dibujo

  1. Cambia a la carpeta del tutorial:

    cd tutorial
    
  2. Asumiendo que estás en la rama master, crea una nueva rama para experimentar:

    git branch gráficas
    
  3. Cambia a esa rama:

    git checkout gráficas
    
  4. Crea el archivo dibujo.py y agrega el siguiente código:

    # Funciones de atención a eventos.
    
    def terminarAplicacion( event ):
        'Salir de la aplicación.'
        ventana.quit()
    
    def pintarElemento( event ):
        'Pintar el o los elementos bajo el cursor.'
    
        # Obtener datos
        canvas = event.widget
        x,y = event.x, event.y
    
        # Encontrar elementos debajo del cursor
        elementos = canvas.find_overlapping( x,y, x+1, y+1 )
    
        # Pintar cada elemento, si hay
        for elemento in elementos:
            canvas.itemconfig( elemento, fill = 'cyan')
    
    
    
    # Construir ventana principal
    import tkinter
    ventana = tkinter.Tk()
    areaDeDibujo = tkinter.Canvas( ventana )
    etiqueta = tkinter.Label( ventana, text='Terminar' )
    
    # Hacer visibles
    areaDeDibujo.pack()
    etiqueta.pack()
    
    # Agregar elementos en el area de dibujo:
    circulo = areaDeDibujo.create_oval( 0,0, 100,100, fill = 'red' )
    
    rectangulo = areaDeDibujo.create_rectangle( 100,0, 300, 100, fill = 'yellow' )
    
    poligono = areaDeDibujo.create_polygon( 100,100, 180,150, 100,200, fill= 'green' )
    
    # Enlazar eventos a las rutinas de atención
    etiqueta.bind( '<Enter>', terminarAplicacion )
    areaDeDibujo.bind( '<Motion>', pintarElemento )
    
    # Atender eventos
    ventana.mainloop()
    
  5. Prueba que funcione:

    python3 dibujo.py
    

    Deberás ver una ventana en la que aparecen un círculo, un rectángulo y un triángulo. Cuando el cursor del ratón pasa encima de alguna figura, ésta se pinta de azul. Si el cursor pasa encima de donde dice Terminar, la aplicación se cierra.

  6. Registra los cambios:

    git add dibujo.py
    git commit -m 'Ejemplo de Canvas'
    

3   Análisis de dibujo.py

Vamos a verlo parte por parte, empezamos con las dos funciones al principio:

# Funciones de atención a eventos.

def terminarAplicacion( event ):
    'Salir de la aplicación.'
    ventana.quit()

def pintarElemento( event ):
    'Pintar el o los elementos bajo el cursor.'

    # Obtener datos
    canvas = event.widget
    x,y = event.x, event.y

    # Encontrar elementos debajo del cursor
    elementos = canvas.find_overlapping( x,y, x+1, y+1 )

    # Pintar cada elemento, si hay
    for elemento in elementos:
        canvas.itemconfig( elemento, fill = 'cyan')

Éstas son llamadas funciones de atención a eventos, Event handlers en Inglés, las cuales serán ejecutadas automáticamente cuando el usuario realice determinados movimientos con el ratón, en este caso.

Estas funciones reciben un objeto evento, el cual tiene atributos. En la primera función simplemente se invoca el método quit de la ventana para terminar. Más adelante analizaremos la segunda función, por lo pronto seguimos a lo que es el bloque principal:

# Construir ventana principal
import tkinter
ventana = tkinter.Tk()
areaDeDibujo = tkinter.Canvas( ventana )
etiqueta = tkinter.Label( ventana, text='Terminar' )

# Hacer visibles
areaDeDibujo.pack()
etiqueta.pack()

Aquí se construye una ventana con la clase Tk del módulo tkinter. Se le agregan una instancia de Canvas y de Label a ventana, con lo cual vamos haciendo la jerarquía de widgets. Tenemos un área para dibujar gráficas y una zona para mostrar texto, respectivamente. Es necesario hacer visibles los widgets invocando el método pack de cada uno. El orden en que lo hacemos determina en qué posición queda en la ventana. En este caso la etiqueta va debajo del área de dibujo.

Las coordenadas del área empiezan en 0,0 arriba a la izquierda, y terminan en canvas['width'], canvas['height'] abajo a la derecha.

Ya definimos la ventana, ahora pongamos algo de contenido en el área de dibujo:

# Agregar elementos en el area de dibujo:
circulo = areaDeDibujo.create_oval( 0,0, 100,100, fill = 'red' )

rectangulo = areaDeDibujo.create_rectangle( 100,0, 300, 100, fill = 'yellow' )

poligono = areaDeDibujo.create_polygon( 100,100, 180,150, 100,200, fill= 'green' )

Cada elemento se crea con un método del área, en el cual proporcionamos las coordenadas necesarias. Para el círculo damos las esquinas del cuadrado que lo rodea, y opcionalmente especificamos el color de fondo, en lugar de sin llenado. Igual para el rectángulo. El polígono nos requiere tantos puntos como se deseen, el caso más sencillo son tres puntos para definir un triángulo. Las coordenadas se pueden proporcionar mediante una secuencia, como podría ser una lista o una tuple.

Ahora vamos a permitir al usuario interactuar con la aplicación:

# Enlazar eventos a las rutinas de atención
etiqueta.bind( '<Enter>', terminarAplicacion )
areaDeDibujo.bind( '<Motion>', pintarElemento )

Con el método bind de cualquier widget, definimos la función que se activará cuando ocurra cierto evento. Cuando el cursor entre en la zona de la etiqueta, se ejecutará terminarAplicacion, y cuando haya cualquier movimiento de cursor sobre el área de dibujo, se ejecutará pintarElemento. Ahora explicamos los detalles de esta función:

def pintarElemento( event ):
    'Pintar el o los elementos bajo el cursor.'

    # Obtener datos
    canvas = event.widget
    x,y = event.x, event.y

    # Encontrar elementos debajo del cursor
    elementos = canvas.find_overlapping( x,y, x+1, y+1 )

    # Pintar cada elemento, si hay
    for elemento in elementos:
        canvas.itemconfig( elemento, fill = 'cyan')

Lo primero es leer cuál es el widget que causó el evento, en este caso: areaDeDibujo, y lo guardamos en canvas. Creamos un alias para usarlo enseguida. Anotamos también las coordenadas del cursor en x y y. Ahora obtenemos cuáles elementos están debajo del cursor. find_overlapping pide un rectángulo, así que especificamos un cuadrado de un pixel por lado. El resultado es una tupla ya sea vacía o con los elementos. Por último, en el ciclo for se va invocando para cada elemento el método itemconfig del área de dibujo , ajustando así su color de llenado a cyan.

Para que los eventos sean atendidos, invocamos el ciclo de atención de tkinter con mainloop de la ventana:

# Atender eventos
ventana.mainloop()

4   Animación

#. Crea un archivo animacion.py y pega el siguiente código:

datos = '''\
A 10 10 N red
B 50 50 O cyan
B 280 280 E blue
B 220 220 O green
B 200 200 S gray
B 160 160 E brown
B 50 50 O yellow
C 100 100 N white
D 75 75 S pink'''

def esquinas( x, y ):
    '''Regresa una tupla (x1,y1,x2,y2).

    x1, y1, x2, y2 definen un cuadrado mediante dos
    esquinas de sus esquinas:

    x1, y1: coordenadas de la esquina superior izquierda
    x2, y2: coordenadas de la esquina inferior derecha

    El cuadrado contiene un círculo con centro en x,y,
    su dimensión dada por la variable global radio.
    '''

    return x-radio, y-radio, x+radio, y+radio

def mover():
    '''Rutina que actualiza posiciones.

    Actualiza las coordenadas de cada robot y de cada
    círculo.

    En caso de llegar al extremo del área gráfica, se
    invierte la dirección.
    '''

    for robot in robots:
        # Actualizar posición del robot:
        if robot.x + radio > ancho:
            robot.avanza('O')
        elif robot.x - radio <= 0:
            robot.avanza('E')
        elif robot.y + radio > altura:
            robot.avanza('S')
        elif robot.y - radio <= 0:
            robot.avanza('N')
        else:
            robot.avanza()
        # Actualizar la posición del círculo correspondiente:
        areaDeDibujo.coords( robot.circulo, esquinas( robot.x, robot.y ) )
    # Programar nueva llamada dentro de mseg milisegundos:
    areaDeDibujo.after( mseg, mover )

# Construir la interfase gráfica:
import tkinter
ventana = tkinter.Tk()
areaDeDibujo = tkinter.Canvas(ventana)
areaDeDibujo.pack()
ancho = int( areaDeDibujo['width'] )
altura = int( areaDeDibujo['height'] )

# Construir una lista de robots
from tools import Robot
radio = 10
robots = []
for dato in datos.splitlines():
    nombre, x, y, direccion, color = dato.split()
    # Crear un robot:
    robot = Robot( nombre, ( int(x), int(y) ), direccion )
    # Crear un círculo:
    uncirculo = areaDeDibujo.create_oval( esquinas( robot.x, robot.y ),
                                          fill = color )
    # Crear atributo:
    robot.circulo = uncirculo
    # Agregar a la lista
    robots.append( robot )

# Definir período de actualización:
mseg = 50
mover()
# Atender eventos:
ventana.mainloop()
  1. Prueba que funcione:

    python3 animacion.py
    

    Aparece una ventana con nueve círculos en movimiento horizontal o vertical. Para terminar deberás cerrar la ventana con Alt-F4.

  2. Registra los cambios:

    git add dibujo.py
    git commit -m 'Ejemplo de animación'
    

5   Análisis de animacion.py

Veamos parte por parte:

datos = '''\
A 10 10 N red
B 50 50 O cyan
B 280 280 E blue
B 220 220 O green
B 200 200 S gray
B 160 160 E brown
B 50 50 O yellow
C 100 100 N white
D 75 75 S pink'''

Al inicio tenemos una cadena datos en la cual se han escrito, renglón a renglón, los nombres, coordenadas y orientación iniciales, y el color con el que se representarán instancias de la clase Robot de nuestro archivo tools.py.

Se usan algunos de los nombre de colores de tkinter. Casi todos pueden llevar el prefijo light, como lightblue, etc.

A continuación tenemos una función:

def esquinas( x, y ):
    '''Regresa una tupla (x1,y1,x2,y2).

    x1, y1, x2, y2 definen un cuadrado mediante dos
    esquinas de sus esquinas:

    x1, y1: coordenadas de la esquina superior izquierda
    x2, y2: coordenadas de la esquina inferior derecha

    El cuadrado contiene un círculo con centro en x,y,
    su dimensión dada por la variable global radio.
    '''

    return x-radio, y-radio, x+radio, y+radio

esquinas traduce la coordenada x,y de un robot las esquinas de un cuadrado para definir el círculo que representará al robot en el área de dibujo. Esta función se tendría que adaptar para otro toolkit gráfico en el que se pida el centro y el radio del círculo, por ejemplo.

Le sigue otra función muy importante en esta aplicación:

def mover():
    '''Rutina que actualiza posiciones.

    Actualiza las coordenadas de cada robot y de cada
    círculo.

    En caso de llegar al extremo del área gráfica, se
    invierte la dirección.
    '''

    for robot in robots:
        # Actualizar posición del robot:
        if robot.x + radio > ancho:
            robot.avanza('O')
        elif robot.x - radio <= 0:
            robot.avanza('E')
        elif robot.y + radio > altura:
            robot.avanza('S')
        elif robot.y - radio <= 0:
            robot.avanza('N')
        else:
            robot.avanza()
        # Actualizar la posición del círculo correspondiente:
        areaDeDibujo.coords( robot.circulo, esquinas( robot.x, robot.y ) )
    # Programar nueva llamada dentro de mseg milisegundos:
    areaDeDibujo.after( mseg, mover )

mover determina el movimiento de todos los robots, y deberá ser ejecutada en forma repetida a un intervalo regular para que se muestre un movimiento uniforme de los elementos en el área de dibujo.

Consta de un ciclo for que itera para cada robot. Primero se evalúa si 'topamos con pared', verificando si el extremo del círculo que representa al robot es 0 (pared izquierda o superior) o máximo (pared derecha o inferior). Para cuando esta función es ejecutada por primera vez, las variables globales ancho y alto ya tienen los valores máximos que se requieren aquí.

En caso de coincidir con algún límite, se cambia la dirección del robot en sentido contrario y se le hace avanzar. Si el círculo está dentro de los límites, simplemente se avanza al robot.

Con las coordenadas nuevas, llamamos el método coords del área de dibujo indicando cuál círculo con robot.circulo, y las esquinas apoyados en la función esquinas a la que le damos las coordenadas del robot.

Al final de la función se programa que ésta se vuelva a ejecutar automáticamente, mediante el método after del área de dibujo.

Ahora empezamos el bloque principal de este programa:

# Construir la interfase gráfica:
import tkinter
ventana = tkinter.Tk()
areaDeDibujo = tkinter.Canvas(ventana)
areaDeDibujo.pack()
ancho = int( areaDeDibujo['width'] )
altura = int( areaDeDibujo['height'] )

Esta primera parte define la ventana con un área de dibujo, incluyendo el guardar los valores de su ancho y altura en pixeles. Nota: debido a que las coordenadas verticales del área de dibujo incrementan hacia abajo, tendremos que considerar que cuando el robot va hacia el Norte, en este caso va hacia abajo. A continuación creamos los robots:

# Construir una lista de robots
from tools import Robot
radio = 10
robots = []
for dato in datos.splitlines():
    nombre, x, y, direccion, color = dato.split()
    # Crear un robot:
    robot = Robot( nombre, ( int(x), int(y) ), direccion )
    # Crear un círculo:
    uncirculo = areaDeDibujo.create_oval( esquinas( robot.x, robot.y ),
                                          fill = color )
    # Crear atributo:
    robot.circulo = uncirculo
    # Agregar a la lista
    robots.append( robot )

Creamos una lista vacía robots y definimos el tamaño del círculo con radio. La cadena de los datos se parte en renglones y el ciclo for itera por cada renglón. En cada ciclo partimos el renglón en sus cinco valores nombre, x, y, direccion y color, creamos una instancia de Robot y un círculo. El círculo lo guardamos como un nuevo atributo de la instancia en cuestión, la cual a su vez es agregada con append a la lista.

Tenemos así en una sola lista toda la información necesaria. Finalmente, tenemos:

# Definir período de actualización:
mseg = 50
mover()
# Atender eventos:
ventana.mainloop()

mseg es el intervalo en el cual se estarán refrescando las coordenadas de los robots y redibujando los círculos que los representan. 50 milisegundos corresponden a 20 actualizaciones por segundo, una frecuencia suficiente para mostrar un movimiento uniforme.

6   Tarea

  1. Modifica dibujo.py para que la etiqueta quede arriba del área de dibujo.
  2. Modifica dibujo.py para que el color de los elementos cambie a gris cuando el cursor se aleja del elemento. Es decir, cuando el cursor entra se pinta de cyan, pero al salir se pinta de gris. Pista: el evento se llama <Leave>.
  3. Experimenta con combinaciones de tamaño de los círculos e intervalos de actualización de las coordenadas de los robots en animacion.py.
  4. Experimenta cambiando el movimiento de los robots en animacion.py a otras trayectorias: en direcciones a 45 grados, en trayectorias definidas por una función, etc.

7   Más información

Este tutorial explica apenas lo más básico de tkinter. Para aprender más conceptos lee el manual An introduction to Tkinter de Fredrik Lundh, de 1999. A pesar de su fecha de publicación sigue siendo bastante vigente.

Para ver más ejemplos de tkinter, descarga el código fuente de Python 3 (9.3 MB) de python.org --> Quick Links 3.1.2 --> Source Distribution. En la carpeta demo/tkinter hay muchos ejemplos de animaciones y demostraciones de cada widget.