Saltar al contenido
🐍 Curso de Python
/

Módulo 3 · Lección 4 de 50

Propiedades de la ventana

Título, tamaño, posición, icono, atributos y cierre controlado: todos los métodos de la clase Tk, con una plantilla de arranque al final.

⏱ 33 min de lectura

Una ventana recién creada con tk.Tk() sale pequeña, pegada a una esquina, con el título tk y con el icono genérico de Python. Funciona, pero tiene el aspecto de algo a medio hacer, y eso es lo primero que ve quien abre tu programa.

Arreglarlo cuesta unas pocas líneas, pero están repartidas en una decena de métodos con nombres poco evidentes y con comportamientos que cambian de un sistema operativo a otro. Esta lección es la referencia de esos métodos: qué hace cada uno, qué devuelve de verdad y qué esperar en macOS, en Windows y en Linux.

Al final hay una plantilla de arranque que junta todo, y que van a reutilizar el resto de las lecciones del módulo.

El título de la barra#

python
ventana.title("Gestor de gastos")

Sin argumentos devuelve el título actual. La costumbre en los editores es marcar con un asterisco que hay cambios sin guardar:

python
ventana.title(f"{'*' if hay_cambios else ''}{nombre_fichero} - Gestor de gastos")

Ese título aparece en la barra de la ventana, en el conmutador de aplicaciones y en la barra de tareas. En macOS el nombre del menú de la aplicación lo decide el sistema, no este método.

geometry: la cadena más rara de la API#

geometry() controla el tamaño y la posición con una sola cadena de texto de formato propio. Viene heredada del gestor de ventanas de X11, donde es el estándar desde los años ochenta.

El formato completo es "anchoxalto+x+y":

ParteQué esUnidad
anchoAnchura del área de contenidoPíxeles
xUna x minúscula literal, de separador—
altoAltura del área de contenidoPíxeles
+xDistancia del borde izquierdo de la ventana al de la pantallaPíxeles
+yDistancia del borde superior de la ventana al de la pantallaPíxeles

Lo que no es evidente es que el tamaño y la posición son dos mitades independientes, y cada una se puede omitir. Eso da tres formas válidas:

CadenaQué hace
"640x480+100+50"Fija el tamaño y la posición
"640x480"Fija solo el tamaño y deja la ventana donde esté
"+100+50"Fija solo la posición y deja el tamaño que tenga

Esa tercera forma es la más útil de las tres, y es la que permite escribir una función que centre cualquier ventana sin tocarle el tamaño.

geometria.py
"""Las cuatro partes de la cadena de geometry(), una a una."""

import tkinter as tk

ventana = tk.Tk()
ventana.title("Geometría")

print("Recién creada:      ", ventana.geometry())

ventana.geometry("480x320+100+50")
ventana.update_idletasks()
print("480x320+100+50:     ", ventana.geometry())

ventana.geometry("+300+200")  # solo posición: el tamaño no se toca
ventana.update_idletasks()
print("solo +300+200:      ", ventana.geometry())

ventana.geometry("600x400")  # solo tamaño: la posición no se toca
ventana.update_idletasks()
print("solo 600x400:       ", ventana.geometry())

ventana.geometry("300x200-0-0")  # pegada a la esquina inferior derecha
ventana.update_idletasks()
print("300x200-0-0:        ", ventana.geometry())
print("posición resuelta:   x =", ventana.winfo_x(), "y =", ventana.winfo_y())
print("tamaño de pantalla:  ", ventana.winfo_screenwidth(), "x",
      ventana.winfo_screenheight())

ventana.mainloop()
Salida en pantalla
Recién creada:       200x200+34+64
480x320+100+50:      480x320+100+50
solo +300+200:       480x320+300+200
solo 600x400:        600x400+300+200
300x200-0-0:         300x200-0-0
posición resuelta:   x = 1940 y = 1028
tamaño de pantalla:   2240 x 1260

Repasa la salida y verás las dos mitades funcionando por separado. Al pedir solo +300+200, el 480x320 se ha quedado. Al pedir solo 600x400, el +300+200 se ha quedado.

Las coordenadas negativas#

Si en vez de + se pone -, la distancia se mide desde el otro extremo de la pantalla: el borde derecho para la x y el inferior para la y. Y no llega hasta el borde izquierdo de la ventana, sino hasta el derecho.

CadenaDónde queda la ventana
"+0+0"Esquina superior izquierda
"-0+0"Esquina superior derecha
"+0-0"Esquina inferior izquierda
"-0-0"Esquina inferior derecha
"-20-20"A veinte píxeles del borde derecho y veinte del inferior

En la salida de arriba se comprueba la cuenta. La pantalla mide 2240 píxeles de ancho, la ventana 300, y -0 la ha dejado en x = 1940, que es 2240 menos 300. El borde derecho de la ventana toca el de la pantalla.

Centrar la ventana en la pantalla#

Centrar parece una resta y tiene una trampa. El tamaño de la pantalla se obtiene sin problemas, con winfo_screenwidth() y winfo_screenheight(). El problema es el otro dato: el tamaño de tu propia ventana no se conoce hasta que Tk ha calculado el diseño. Antes de eso, los métodos que lo consultan mienten.

Esta tabla recoge lo que devuelven en los dos casos posibles, antes y después de forzar las cuentas con update_idletasks():

ConsultaTamaño según el contenidoTamaño fijado con geometry("600x400")
geometry()'200x200+34+64' → '243x62+34+64''1x1+0+0' → '600x400+63+93'
winfo_width()200 → 2431 → 600
winfo_reqwidth()200 → 243200 → 180

Sin forzar las cuentas se obtiene 200x200, que es el relleno que Tk pone por defecto, o directamente 1x1. Pero el dato importante está en la última casilla: con el tamaño fijado a mano, winfo_width() acaba en 600 y winfo_reqwidth() en 180. No son sinónimos.

MétodoQué mide
winfo_width()Lo que mide la ventana ahora mismo
winfo_reqwidth()Lo que pide su contenido para caber, ignorando lo que hayas fijado tú

Para centrar hace falta el primero, porque queremos centrar la ventana tal como se va a ver. Y para que no mienta hay que llamar antes a update_idletasks(), que ya apareció en la lección del bucle de eventos: obliga a Tk a terminar las cuentas del diseño sin dejar entrar ningún evento de fuera.

centrar.py
"""Una ventana centrada en la pantalla, con la función que vas a reutilizar."""

import tkinter as tk


def centrar(ventana: tk.Misc) -> None:
        """Coloca la ventana en el centro de la pantalla.

    El update_idletasks es imprescindible: hasta que Tk no ha calculado el
    diseño, el tamaño de la ventana todavía no es el definitivo.
    """
    ventana.update_idletasks()
    ancho = ventana.winfo_width()
    alto = ventana.winfo_height()
    x = (ventana.winfo_screenwidth() - ancho) // 2
    y = (ventana.winfo_screenheight() - alto) // 2
    ventana.geometry(f"+{x}+{y}")


ventana = tk.Tk()
ventana.title("Centrada")

etiqueta = tk.Label(ventana, text="Esta ventana sale en el centro de la pantalla")
etiqueta.pack(padx=40, pady=40)

print("Antes de centrar:", ventana.geometry())
centrar(ventana)
print("Después:         ", ventana.geometry())

ventana.mainloop()
Salida en pantalla
Antes de centrar: 200x200+34+64
Después:          352x102+944+579

La ventana medía 352 por 102 y la pantalla 2240 por 1260, así que las cuentas salen: 944 es la mitad de 2240 menos 352, y 579 la mitad de 1260 menos 102.

Fíjate en que centrar usa la forma "+x+y", la que solo toca la posición. Por eso sirve igual para una ventana con tamaño fijado a mano y para una ajustada a su contenido, y por eso da igual en qué orden la llames respecto a geometry.

Limitar el tamaño: minsize, maxsize y resizable#

Una ventana que se puede encoger hasta que los botones se solapan parece mal hecha. Poner un mínimo razonable cuesta una línea.

MétodoQué hace
minsize(ancho, alto)Impide encoger por debajo de ese tamaño
maxsize(ancho, alto)Impide crecer por encima de ese tamaño
resizable(ancho, alto)Dos booleanos: si se puede cambiar el ancho y si se puede el alto

Los tres, sin argumentos, devuelven el valor actual como tupla.

tamanos.py
"""minsize, maxsize y resizable: los límites de lo que puede hacer quien usa."""

import tkinter as tk

ventana = tk.Tk()
ventana.title("Tamaños")
ventana.geometry("500x350")

print("Límites por defecto:")
print("  minsize:", ventana.minsize(), "maxsize:", ventana.maxsize())

ventana.minsize(320, 240)
ventana.maxsize(900, 700)
print("Límites puestos a mano:")
print("  minsize:", ventana.minsize(), "maxsize:", ventana.maxsize())

ventana.resizable(True, False)  # el ancho se puede estirar, el alto no
print("resizable(ancho, alto):", ventana.resizable())

etiqueta = tk.Label(ventana, text="Intenta estirarla por los bordes")
etiqueta.pack(padx=30, pady=40)

ventana.mainloop()
Salida en pantalla
Límites por defecto:
  minsize: (72, 15) maxsize: (2240, 1104)
Límites puestos a mano:
  minsize: (320, 240) maxsize: (900, 700)
resizable(ancho, alto): (1, 0)

Dos detalles de esa salida. El maxsize por defecto no es el alto de la pantalla, que es 1260, sino 1104: Tk descuenta la barra de menús y el Dock, porque ese es el espacio utilizable. Y resizable devuelve (1, 0) en vez de (True, False), porque por debajo Tcl solo tiene enteros. Se comporta igual en un if, pero no des por hecho que resizable() == (True, False) sea cierto.

El icono de la ventana#

Aquí es donde Tkinter depende del sistema operativo más que en ningún otro sitio. Hay dos métodos y ninguno funciona igual en los tres.

iconphoto() es el recomendado. Recibe un booleano y una o más imágenes:

python
icono = tk.PhotoImage(file="logotipo.png")
ventana.iconphoto(True, icono)

El primer argumento indica si el icono vale también para las ventanas que se creen después. Con True se pone una vez y sirve para todas.

Se puede evitar el fichero de imagen creando un PhotoImage vacío y pintándolo con put(), que recibe un color y la zona a rellenar. Así el ejemplo es ejecutable sin arrastrar imágenes:

icono.py
"""Un icono de ventana generado por código, sin depender de ficheros de imagen."""

import tkinter as tk

ventana = tk.Tk()
ventana.title("Con icono")
ventana.geometry("340x180")

# PhotoImage vacío de 32x32 píxeles, que se pinta con put()
icono = tk.PhotoImage(width=32, height=32)
icono.put("#1e5aa8", to=(0, 0, 32, 32))  # todo el cuadro, azul
icono.put("#ffd24a", to=(8, 8, 24, 24))  # un cuadrado amarillo en el centro

ventana.iconphoto(True, icono)

print("Tamaño del icono:     ", icono.width(), "x", icono.height())
print("Píxel 0,0 (esquina):  ", icono.get(0, 0))
print("Píxel 16,16 (centro): ", icono.get(16, 16))

etiqueta = tk.Label(ventana, text="El icono lo dibuja el sistema, no Tk")
etiqueta.pack(padx=30, pady=40)

ventana.mainloop()
Salida en pantalla
Tamaño del icono:      32 x 32
Píxel 0,0 (esquina):   (30, 90, 168)
Píxel 16,16 (centro):  (255, 210, 74)

get(x, y) devuelve el color del píxel como tupla de tres enteros, lo que confirma que el dibujo ha salido: azul en la esquina y amarillo en el centro.

El otro método es iconbitmap(), y es el que trae problemas:

SistemaQué acepta iconbitmap()
WindowsFicheros .ico, que es su formato nativo. Es el único sitio donde funciona bien
LinuxMapas de bits .xbm, con el nombre precedido de @
macOSMapas de bits .xbm con @. Los .ico no se aceptan

Queda el aviso que más desconcierta. En macOS, iconphoto() se acepta sin error, pero el sistema no pinta un icono por ventana como hacen Windows y Linux: el icono de una aplicación es el de su paquete .app, no algo que el programa decida al arrancar. Para que una aplicación empaquetada tenga icono en el Dock hay que dárselo al empaquetador, y eso se ve en la lección de PyInstaller. Aun así conviene llamar a iconphoto(), porque es lo correcto en los otros dos sistemas y en macOS no estorba.

attributes: pantalla completa, transparencia y siempre encima#

attributes() da acceso a las opciones que dependen del gestor de ventanas. Se usa con el nombre del atributo precedido de un guion, y sin valor devuelve el actual.

AtributoQué haceDónde funciona
-fullscreenPantalla completa, sin barra de títuloLos tres sistemas
-topmostLa ventana se queda por encima de las demásLos tres sistemas
-alphaOpacidad de la ventana entera, de 0.0 a 1.0Los tres sistemas
-transparentFondo transparente de verdadSolo macOS
-toolwindowBarra de título fina, de ventana auxiliarSolo Windows
-zoomedMaximizadaSolo Windows y Linux

Llamar a attributes() sin argumentos devuelve la lista de lo que admite el sistema, lo que permite comprobarlo en el sitio en vez de fiarse de la documentación:

atributos.py
"""Los atributos de ventana, que son lo más dependiente del sistema que hay."""

import tkinter as tk

ventana = tk.Tk()
ventana.title("Atributos")
ventana.geometry("420x220")

print("Atributos que admite este sistema:")
for clave in ventana.attributes()[::2]:
        print("  ", clave)

ventana.attributes("-alpha", 0.85)  # 0.0 invisible, 1.0 opaca
print("Transparencia:  ", ventana.attributes("-alpha"))

ventana.attributes("-topmost", True)  # por encima de las demás ventanas
print("Siempre encima: ", ventana.attributes("-topmost"))
ventana.attributes("-topmost", False)

etiqueta = tk.Label(ventana, text="Ventana al 85 % de opacidad")
etiqueta.pack(padx=30, pady=40)

ventana.mainloop()
Salida en pantalla
Atributos que admite este sistema:
   -alpha
   -appearance
   -buttons
   -fullscreen
   -isdark
   -modified
   -notify
   -titlepath
   -topmost
   -transparent
   -stylemask
   -class
   -tabbingid
   -tabbingmode
   -type
Transparencia:   0.85
Siempre encima:  1

Esa lista es la de macOS, y no aparece -zoomed: pedirlo aquí da un error que enumera precisamente estos nombres. Atributos como -appearance o -isdark, que informan del tema claro u oscuro del sistema, no existen en ningún otro sitio.

La pantalla completa se pone y se quita con un booleano:

python
ventana.attributes("-fullscreen", True)

Conviene dar siempre una forma de salir, porque en pantalla completa no hay barra de título ni botón de cerrar. Lo habitual es atar la tecla Esc a quitarla, con el bind del bloque de eventos.

Interceptar el cierre con protocol#

Cuando alguien pulsa la X de la barra de título, tu programa no se entera por ningún camino normal: la ventana se destruye y mainloop() termina. Si había cambios sin guardar, se han perdido.

protocol() registra una función para uno de los mensajes que el gestor de ventanas manda a la aplicación. El que interesa es WM_DELETE_WINDOW: "se ha pedido cerrar esta ventana".

python
ventana.protocol("WM_DELETE_WINDOW", al_cerrar)

A partir de ahí, la X ya no cierra nada: llama a tu función. Cerrar pasa a ser tu responsabilidad, y si tu función no llama a destroy(), la ventana no se cierra.

cierre_controlado.py
"""Interceptar el cierre de la ventana para no perder cambios sin guardar."""

import tkinter as tk
from tkinter import messagebox

hay_cambios_sin_guardar = True


def al_cerrar() -> None:
        """La llama Tk cuando se pulsa la X de la barra de título."""
    if not hay_cambios_sin_guardar:
                ventana.destroy()
        return
    # messagebox tiene su propia lección; aquí basta con una pregunta
    if messagebox.askokcancel("Salir", "Hay cambios sin guardar. ¿Salir igualmente?"):
                print("Cierro la ventana y pierdo los cambios")
        ventana.destroy()
    else:
                print("Cierre cancelado: la ventana sigue abierta")


ventana = tk.Tk()
ventana.title("Cierre controlado")
ventana.geometry("380x160")

etiqueta = tk.Label(ventana, text="Pulsa la X de la barra de título")
etiqueta.pack(padx=30, pady=50)

print("Antes de registrar nada:", repr(ventana.protocol("WM_DELETE_WINDOW")))
ventana.protocol("WM_DELETE_WINDOW", al_cerrar)
print("Protocolos registrados: ", ventana.protocol())

ventana.mainloop()
Salida en pantalla
Antes de registrar nada: '4449757312destroy'
Protocolos registrados:  ('WM_DELETE_WINDOW',)

Esa primera línea es reveladora. Antes de que tú registres nada, WM_DELETE_WINDOW ya tiene una función asociada y su nombre acaba en destroy: Tkinter deja puesta de fábrica la que destruye la ventana, y tu llamada a protocol la sustituye. El número de delante es la dirección del objeto y cambia en cada ejecución, igual que el de los <function ...> del módulo 1.

De messagebox.askokcancel basta saber que devuelve True si se acepta y False si se cancela. La familia completa de diálogos tiene su propia lección en el bloque de ventanas secundarias.

destroy frente a quit#

Los dos terminan mainloop(), y ahí se acaba el parecido.

MétodoQué hace
destroy()Destruye la ventana y todos sus widgets, y sale del bucle
quit()Solo sale del bucle. La ventana y los widgets siguen existiendo

La diferencia se ve mejor ejecutando esto, que llama a mainloop() dos veces:

python
ventana.after(500, ventana.quit)  # sale del bucle y no destruye nada
ventana.mainloop()

print("Después de quit():")
print("  ¿existe la ventana?", ventana.winfo_exists())
print("  ¿y el widget?      ", etiqueta.cget("text"))
ventana.title("Sigue viva")
print("  título cambiado a: ", ventana.title())

ventana.after(500, ventana.destroy)  # ahora sí desaparece
ventana.mainloop()
Salida en pantalla
Después de quit():
  ¿existe la ventana? 1
  ¿y el widget?       Esta ventana se cierra sola
  título cambiado a:  Sigue viva

Después de quit() la ventana sigue viva: se puede consultar, se le puede cambiar el título y se puede volver a entrar en mainloop() para seguir usándola. Después de destroy() no queda nada con lo que hablar y cualquier consulta falla.

Para una aplicación normal, la que quieres es destroy(). quit() tiene un uso legítimo y estrecho: salir de un mainloop anidado sin desmontar la interfaz, algo que aparece en los diálogos que esperan respuesta. Usarlo creyendo que cierra el programa deja procesos vivos sin ventana visible.

state: minimizar, maximizar y esconder#

state() consulta y cambia el estado de la ventana. Estos son los valores que devuelve:

EstadoQué significa
'normal'Visible y en su tamaño
'iconic'Minimizada
'withdrawn'Retirada de la pantalla, sin aparecer ni en la barra de tareas
'zoomed'Maximizada

Y estos los métodos que lo cambian:

MétodoQué hace
iconify()Minimiza
deiconify()Devuelve a 'normal' desde cualquier estado
withdraw()Esconde la ventana por completo
state("zoomed")Maximiza
estados.py
"""state() para minimizar, maximizar y esconder la ventana."""

import tkinter as tk


def siguiente_paso() -> None:
        """Recorre los estados uno a uno, con un segundo entre cada paso."""
    if not pasos:
                return
    nombre, accion = pasos.pop(0)
    accion()
    ventana.update_idletasks()
    print(f"{nombre:<14} state() = {ventana.state()!r:<12} {ventana.geometry()}")
    ventana.after(1000, siguiente_paso)


ventana = tk.Tk()
ventana.title("Estados")
ventana.geometry("420x220+100+100")

etiqueta = tk.Label(ventana, text="Mira la terminal")
etiqueta.pack(padx=30, pady=60)

pasos = [
    ("de partida", lambda: None),
    ("iconify()", ventana.iconify),
    ("deiconify()", ventana.deiconify),
    ("state zoomed", lambda: ventana.state("zoomed")),
    ("state normal", lambda: ventana.state("normal")),
    ("withdraw()", ventana.withdraw),
    ("deiconify()", ventana.deiconify),
]

ventana.after(500, siguiente_paso)
ventana.mainloop()
Salida en pantalla
de partida     state() = 'normal'     420x220+100+100
iconify()      state() = 'iconic'     420x220+100+100
deiconify()    state() = 'normal'     420x220+100+100
state zoomed   state() = 'zoomed'     2240x1104+0+30
state normal   state() = 'normal'     420x220+100+100
withdraw()     state() = 'withdrawn'  420x220+100+100
deiconify()    state() = 'normal'     420x220+100+100

Mira la línea de zoomed. La geometría pasa a 2240x1104+0+30, no a los 2240 por 1260 de la pantalla: la ventana maximizada respeta la barra de menús de arriba y el Dock de abajo. Y al volver a 'normal' recupera exactamente el 420x220+100+100 de partida, porque Tk recuerda el tamaño anterior.

Aquí conviene desconfiar de lo que se lee por ahí. Se repite mucho que 'zoomed' solo existe en Windows, y en este sistema, con Tcl/Tk 9.0 en macOS, funciona y devuelve 'zoomed'. Lo que sí es cierto en los tres sistemas es que maximizar es una petición al gestor de ventanas, no una orden: en algunos escritorios de Linux no hace nada. La alternativa portable para que ocupe todo es calcular el tamaño de la pantalla y ponerlo con geometry.

Todo junto: la plantilla de arranque#

Esta es la ventana bien presentada que reutilizarán el resto de las lecciones. Nada de lo que hay aquí es nuevo; lo que aporta es el orden.

plantilla.py
"""Plantilla de arranque: la ventana bien presentada del resto del módulo."""

import tkinter as tk


def centrar(ventana: tk.Misc) -> None:
        """Coloca la ventana en el centro de la pantalla."""
    ventana.update_idletasks()
    ancho = ventana.winfo_width()
    alto = ventana.winfo_height()
    x = (ventana.winfo_screenwidth() - ancho) // 2
    y = (ventana.winfo_screenheight() - alto) // 2
    ventana.geometry(f"+{x}+{y}")


def crear_icono() -> tk.PhotoImage:
        """Icono mínimo pintado por código, para no arrastrar ficheros de imagen."""
    icono = tk.PhotoImage(width=32, height=32)
    icono.put("#1e5aa8", to=(0, 0, 32, 32))
    icono.put("#ffd24a", to=(8, 8, 24, 24))
    return icono


def al_cerrar() -> None:
        """Único punto de salida: aquí se guarda lo que haga falta."""
    print("Guardando antes de salir")
    ventana.destroy()


ventana = tk.Tk()
ventana.title("Gestor de gastos")
ventana.geometry("640x440")
ventana.minsize(480, 320)

icono = crear_icono()
ventana.iconphoto(True, icono)

etiqueta = tk.Label(ventana, text="Aquí irá la interfaz del resto del módulo")
etiqueta.pack(padx=40, pady=40)

ventana.protocol("WM_DELETE_WINDOW", al_cerrar)
centrar(ventana)

print("Ventana lista:", ventana.geometry())
print("Límite mínimo:", ventana.minsize())

ventana.mainloop()
Salida en pantalla
Ventana lista: 640x440+800+410
Límite mínimo: (480, 320)

El orden de las líneas no es casual, y es el que se justificó en la lección del bucle de eventos: primero la ventana con su título, tamaño y límites; después el icono, guardado en una variable que dure; luego la interfaz, que aquí es una etiqueta sola y en las lecciones siguientes será todo lo demás; el cierre controlado; centrar, ya con el contenido puesto; y mainloop() en la última línea.

Lo que queda pendiente es todo lo visual: qué hay dentro, cómo se coloca y de qué color es. Los widgets empiezan en la lección siguiente y la colocación tiene su propio bloque.