Ventanas secundarias en Tcl/Tk (tkinter)

Ventanas secundarias en Tcl/Tk (tkinter)

Las aplicaciones de escritorio pueden estar compuestas por más de una ventana. La ventana principal se crea a partir de la clase tk.Tk y controla el ciclo de vida de la aplicación. Ventanas secundarias, también conocidas como popups, pueden crearse al iniciar la aplicación o en respuesta a un evento (por ejemplo, la presión de un botón) vía la clase tk.Toplevel. Cuando el usuario cierra la ventana principal, todas las ventanas secundarias asimismo se cierran, finalizando la ejecución del programa. No obstante, las ventanas secundarias se pueden abrir y cerrar múltiples veces durante el ciclo de vida de la aplicación.

El siguiente código crea una aplicación con una ventana principal y una ventana secundaria. La ventana principal contiene un botón para abrir la ventana secundaria. Esta, a su vez, tiene otro botón para cerrarse.

import tkinter as tk
from tkinter import ttk


def abrir_ventana_secundaria():
    # Crear una ventana secundaria.
    ventana_secundaria = tk.Toplevel()
    ventana_secundaria.title("Ventana secundaria")
    ventana_secundaria.config(width=300, height=200)
    # Crear un botón dentro de la ventana secundaria
    # para cerrar la misma.
    boton_cerrar = ttk.Button(
        ventana_secundaria,
        text="Cerrar ventana", 
        command=ventana_secundaria.destroy
    )
    boton_cerrar.place(x=75, y=75)


# Crear la ventana principal.
ventana_principal = tk.Tk()
ventana_principal.config(width=400, height=300)
ventana_principal.title("Ventana principal")
# Crear un botón dentro de la ventana principal
# que al ejecutarse invoca a la función
# abrir_ventana_secundaria().
boton_abrir = ttk.Button(
    ventana_principal,
    text="Abrir ventana secundaria",
    command=abrir_ventana_secundaria
)
boton_abrir.place(x=100, y=100)
ventana_principal.mainloop()

Al tener dos ventanas diferentes, siempre que creamos un control (trátese de un botón o de cualquier otro), debemos especificar su ventana padre (esto es, la ventana dentro de la cual se encuentra) como primer argumento. El boton_abrir se encuentra dentro de la ventana principal, de ahí que en la línea 28 se pase el objeto ventana_principal como primer argumento. Lo mismo ocurre con el boton_cerrar y la ventana secundaria en la línea 13. El resultado es el siguiente:

Ventana secundaria

Para que la ventana secundaria obtenga el foco automáticamente una vez creada, usamos el método focus():

def abrir_ventana_secundaria():
    # Crear una ventana secundaria.
    ventana_secundaria = tk.Toplevel()
    ventana_secundaria.title("Ventana secundaria")
    ventana_secundaria.config(width=300, height=200)
    # Crear un botón dentro de la ventana secundaria
    # para cerrar la misma.
    boton_cerrar = ttk.Button(
        ventana_secundaria,
        text="Cerrar ventana", 
        command=ventana_secundaria.destroy
    )
    boton_cerrar.place(x=75, y=75)
    ventana_secundaria.focus()

Cuando las dos ventanas estén abiertas, el usuario podrá interactuar con ellas sin inconvenientes. Si queremos que el usuario no pueda utilizar la ventana principal mientras la secundaria está visible, estado conocido en la jerga como modal, invocamos el método grab_set():

def abrir_ventana_secundaria():
    # Crear una ventana secundaria.
    ventana_secundaria = tk.Toplevel()
    ventana_secundaria.title("Ventana secundaria")
    ventana_secundaria.config(width=300, height=200)
    # Crear un botón dentro de la ventana secundaria
    # para cerrar la misma.
    boton_cerrar = ttk.Button(
        ventana_secundaria,
        text="Cerrar ventana", 
        command=ventana_secundaria.destroy
    )
    boton_cerrar.place(x=75, y=75)
    ventana_secundaria.focus()
    ventana_secundaria.grab_set()

Tanto la ventana principal como las ventanas secundarias proveen el método destroy() para cerrarlas vía código. Téngase en cuenta que ventana_principal.destroy() finaliza la ejecución de la aplicación.

Ahora bien, aunque esta forma de organizar el código puede resultar útil para pequeñas aplicaciones, una mejor solución consiste en crear una clase por cada ventana. Así, el código anterior en su versión orientada a objetos se vería más o menos de este modo:

import tkinter as tk
from tkinter import ttk


class VentanaSecundaria(tk.Toplevel):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.config(width=300, height=200)
        self.title("Ventana secundaria")
        self.boton_cerrar = ttk.Button(
            self,
            text="Cerrar ventana",
            command=self.destroy
        )
        self.boton_cerrar.place(x=75, y=75)
        self.focus()
        self.grab_set()


class VentanaPrincipal(tk.Tk):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.config(width=400, height=300)
        self.title("Ventana principal")
        self.boton_abrir = ttk.Button(
            self,
            text="Abrir ventana secundaria",
            command=self.abrir_ventana_secundaria
        )
        self.boton_abrir.place(x=100, y=100)

    def abrir_ventana_secundaria(self):
        self.ventana_secundaria = VentanaSecundaria()


ventana_principal = VentanaPrincipal()
ventana_principal.mainloop()

Esta implementación tiene el beneficio de que los controles y métodos de ambas ventanas están encapsulados dentro de sus respectivos objetos (ventana_principal y ventana_secundaria), evitando la colisión de nombres y reduciendo el uso de objetos globales. Las clases podrían incluso estar en módulos diferentes: es un patrón común en el desarrollo de aplicaciones de escritorio colocar cada ventana en un archivo de código de fuente propio.

Otras funcionalidades son también más fáciles de implementar con esta distribución de las ventanas en clases. Por ejemplo, ¿qué ocurre si el usuario presiona dos o más veces el boton_abrir? Si la ventana secundaria no es modal (o sea, no se ha llamado a grab_set()), se permitirá que el usuario abra tantas ventanas secundarias como clics haya hecho en el botón. Esto es generalmente un efecto indeseable, por lo cual es útil añadir una restricción para que la VentanaSecundaria no se abra más de una vez al mismo tiempo.

import tkinter as tk
from tkinter import ttk


class VentanaSecundaria(tk.Toplevel):

    # Atributo de la clase que indica si la ventana
    # secundaria está en uso.
    en_uso = False

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.config(width=300, height=200)
        self.title("Ventana secundaria")
        self.boton_cerrar = ttk.Button(
            self,
            text="Cerrar ventana",
            command=self.destroy
        )
        self.boton_cerrar.place(x=75, y=75)
        self.focus()
        # Indicar que está en uso luego de crearse.
        self.__class__.en_uso = True
    
    def destroy(self):
        # Restablecer el atributo al cerrarse.
        self.__class__.en_uso = False
        return super().destroy()


class VentanaPrincipal(tk.Tk):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.config(width=400, height=300)
        self.title("Ventana principal")
        self.boton_abrir = ttk.Button(
            self,
            text="Abrir ventana secundaria",
            command=self.abrir_ventana_secundaria
        )
        self.boton_abrir.place(x=100, y=100)

    def abrir_ventana_secundaria(self):
        if not VentanaSecundaria.en_uso:
            self.ventana_secundaria = VentanaSecundaria()


ventana_principal = VentanaPrincipal()
ventana_principal.mainloop()

La lógica de este código es sencilla. Creamos el atributo en_uso que es verdadero cuando la ventana está en uso y falso en caso contrario, y lo consultamos cada vez antes de crear una instancia de la ventana secundaria.

Por otro lado, es habitual querer acceder a un objeto contenido en una ventana secundaria desde la ventana principal. Por ejemplo, si quisiéramos crear una ventana secundaria para que el usuario ingrese su nombre y luego necesitáramos acceder al nombre ingresado para mostrarlo en una etiqueta en la ventana principal:

Solicitar nombre con ventana secundaria

Una solución elegante para este caso es utilizar una función callback, característica de la programación orientada a eventos, que es invocada por la ventana secundaria cuando el nombre ingresado está disponible. Este funcionamiento es similar al de la función callback empleada en los botones vía el argumento command.

import tkinter as tk
from tkinter import ttk


class VentanaNombre(tk.Toplevel):

    def __init__(self, *args, callback=None, **kwargs):
        super().__init__(*args, **kwargs)
        # callback es una función que esta ventana llamará
        # una vez presionado el botón para comunicarle el nombre
        # ingresado a la ventana padre.
        self.callback = callback
        self.config(width=300, height=90)
        # Deshabilitar el botón para maximizar la ventana.
        self.resizable(0, 0)
        self.title("Ingresa tu nombre")
        self.caja_nombre = ttk.Entry(self)
        self.caja_nombre.place(x=20, y=20, width=260)
        self.boton_listo = ttk.Button(
            self,
            text="¡Listo!",
            command=self.boton_listo_presionado
        )
        self.boton_listo.place(x=20, y=50, width=260)
        self.focus()
        self.grab_set()
    
    def boton_listo_presionado(self):
        # Obtener el dato ingresado y llamar a la función
        # especificada al crear esta ventana.
        self.callback(self.caja_nombre.get())
        # Cerrar la ventana.
        self.destroy()


class VentanaPrincipal(tk.Tk):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.config(width=400, height=300)
        self.title("Ventana principal")
        self.boton_solicitar_nombre = ttk.Button(
            self,
            text="Solicitar nombre",
            command=self.solicitar_nombre
        )
        self.boton_solicitar_nombre.place(x=50, y=50)
        self.etiqueta_nombre = ttk.Label(
            self,
            text="Aún no has ingresado ningún nombre."
        )
        self.etiqueta_nombre.place(x=50, y=150)

    def solicitar_nombre(self):
        # Crear la ventana secundaria y pasar como argumento
        # la función en la cual queremos recibir el dato
        # ingresado.
        self.ventana_nombre = VentanaNombre(
            callback=self.nombre_ingresado
        )
    
    def nombre_ingresado(self, nombre):
        # Esta función es invocada cuando el usuario presiona el
        # botón "¡Listo!" de la ventana secundaria. El dato
        # ingresado estará en el argumento "nombre".
        self.etiqueta_nombre.config(
            text="Ingresaste el nombre: " + nombre
        )


ventana_principal = VentanaPrincipal()
ventana_principal.mainloop()

Curso online 👨‍💻

¡Ya lanzamos el curso oficial de Recursos Python en Udemy! Un curso moderno para aprender Python desde cero con programación orientada a objetos, SQL y tkinter en 2024.

Consultoría 💡

Ofrecemos servicios profesionales de desarrollo y capacitación en Python a personas y empresas. Consultanos por tu proyecto.

2 comentarios.

Deja una respuesta