Módulo 3 · Lección 30 de 50
El marco con título: LabelFrame
El widget que cierra la escala de separación, el labelwidget que convierte el título en una casilla, y el hallazgo que da de verdad trabajo: desactivar un marco no desactiva nada de lo que hay dentro.
La lección 29 dejó una escala de tres peldaños para separar cosas en una ventana: primero espacio en blanco, luego un marco con título, y la línea del Separator solo cuando no cabe ninguna de las dos. Este es el peldaño de en medio, y es el que se usa más: un Frame normal, de los de la lección 11, con un borde y un rótulo encima.
La diferencia con una línea es que un marco dice qué separa. Un Separator entre dos grupos de campos informa de que hay un corte; un marco con el título «Conexión» encima informa de que los tres campos de dentro van juntos y de por qué. Cuando un formulario pasa de una docena de campos, eso es la diferencia entre una ventana que se entiende de un vistazo y una lista larga.
Y trae un regalo que casi nadie usa y una trampa que todo el mundo se come. El regalo es que el título puede ser un widget cualquiera, normalmente una casilla que enciende y apaga el grupo entero. La trampa es que apagarlo no apaga nada.
Las opciones#
>>> sorted(ttk.Labelframe(ventana).configure().keys())
['borderwidth', 'class', 'cursor', 'height', 'labelanchor', 'labelwidget',
'padding', 'relief', 'style', 'takefocus', 'text', 'underline', 'width']Trece, y solo cuatro son de este widget y no de cualquier otro:
| Opción | Qué hace |
|---|---|
text | El rótulo del marco. |
labelwidget | Un widget que hace de rótulo en lugar del text. |
labelanchor | Dónde se coloca el rótulo. De fábrica nw. |
underline | Subraya una letra del text, por su posición. |
ttk.Labelframe es uno de los pocos widgets temáticos que conserva relief y borderwidth, aunque los dos vienen vacíos porque de fábrica decide el tema. Y como en todo el módulo, ttk.LabelFrame con la F grande es el mismo objeto:
>>> ttk.LabelFrame is ttk.Labelframe
TrueLo que le falta respecto al clásico es lo de siempre, más una cosa que se echa en falta de verdad:
>>> sorted(set(tk.LabelFrame(ventana).configure()) - set(ttk.Labelframe(ventana).configure()))
['background', 'bd', 'bg', 'colormap', 'container', 'fg', 'font', 'foreground',
'highlightbackground', 'highlightcolor', 'highlightthickness', 'padx', 'pady',
'visual']font y foreground: en la versión temática no se puede poner el título en negrita sin crear un estilo, que es asunto de la lección de temas del bloque E. Si lo único que quieres es un rótulo en negrita, casi siempre sale mejor un Frame normal con un Label en negrita encima que pelearse con esto.
Y el reparto del relleno es el habitual entre las dos familias:
>>> "padx" in ttk.Labelframe(ventana).configure()
False
>>> "padding" in tk.LabelFrame(ventana).configure()
FalseEl temático usa padding, que ya conoces del Frame de la lección 11, y acepta uno, dos o cuatro números. El clásico usa padx y pady. Aquí el relleno importa más que en un Frame pelado, porque el contenido pegado al borde de un marco con título queda peor que pegado a la nada:
>>> sin_relleno.winfo_reqwidth(), sin_relleno.winfo_reqheight()
(39, 38)
>>> con_padding_10.winfo_reqwidth(), con_padding_10.winfo_reqheight()
(44, 58)Dónde va el rótulo#
Y aquí hay que romper una costumbre del bloque. Hasta ahora, cuando había que saber los valores válidos de una opción, la lección los sacaba del error de Tk, porque es más fiable que una lista copiada a mano. Con esta opción eso no funciona:
>>> ttk.Labelframe(ventana, labelanchor="z")
_tkinter.TclError: Bad label anchor specification zNo los enumera. Es el primer caso del módulo en que Tk protesta sin decir qué esperaba. Y al buscarlos a la fuerza, probando las cuatro letras solas y todas sus parejas, aparece algo peor:
>>> [c for c in candidatos if acepta(c)]
['n', 's', 'e', 'w', 'nn', 'ns', 'ne', 'nw', 'sn', 'ss', 'se', 'sw',
'en', 'es', 'ee', 'ew', 'wn', 'ws', 'we', 'ww']Veinte valores aceptados, y ahí dentro hay cosas que no significan nada: nn, ww, ee, ss, ew. Tk las traga sin decir nada y coloca el rótulo donde le parece. Los que tienen sentido son doce, y se leen en dos partes: la primera letra es el lado del marco y la segunda, dónde queda a lo largo de ese lado.
| Lado | Valores | Dónde sale el rótulo |
|---|---|---|
| Arriba | nw, n, ne | Sobre el borde superior, a la izquierda, centrado o a la derecha. |
| Derecha | en, e, es | Sobre el borde derecho, girado, arriba, centrado o abajo. |
| Abajo | sw, s, se | Sobre el borde inferior. |
| Izquierda | wn, w, ws | Sobre el borde izquierdo, girado. |
El título ensancha el marco#
Un detalle de colocación que sorprende la primera vez. El marco pide sitio para su título, aunque el contenido no lo necesite:
>>> reqwidth con text="" → 39
>>> reqwidth con text="Red" → 51
>>> reqwidth con text="Opciones avanzadas de conexion" → 193En los tres casos el contenido es la misma etiqueta de dos letras. O sea que un título largo fija el ancho mínimo del marco, y en una columna de marcos apilados, el del título más largo estira a todos los demás. No es un fallo, pero explica por qué a veces un grupo sale mucho más ancho de lo que su contenido justifica, y es una razón práctica para que los títulos sean cortos.
Y width, si lo pones, manda por encima de eso:
>>> marco = ttk.Labelframe(ventana, text="Opciones avanzadas de conexion", width=60)
>>> marco.winfo_reqwidth()
60Sesenta, con el título recortado. Es lo mismo que pasaba con el Notebook en la lección 28, así que ya son dos veces y se puede dar por regla: en los contenedores de ttk, width y height son tamaños estrictos y no mínimos. Si lo que quieres es un mínimo, es cosa de los pesos del grid o del minsize de la ventana.
Una casilla como título#
Esto es lo mejor que tiene el widget. labelwidget sustituye el rótulo por un widget de verdad:
grupo = ttk.Labelframe(cuerpo)
activo = tk.BooleanVar(value=False)
casilla = ttk.Checkbutton(grupo, text="Copia automática", variable=activo,
command=cambiar)
grupo.configure(labelwidget=casilla)Y lo que sale es el patrón del grupo que se enciende: una casilla en el borde del marco que activa o desactiva todo lo que hay dentro. Se ve en cualquier diálogo de preferencias del sistema y evita la alternativa mala, que es una casilla suelta encima de un marco con el título repetido.
Dos cosas que hay que saber. La primera, el padre, que a estas alturas ya es la cantinela del módulo: la casilla tiene que ser hija del propio Labelframe. Y el castigo depende de con quién te equivoques:
>>> ajeno = ttk.Checkbutton(ventana, text="y") # hijo de la ventana
>>> marco.configure(labelwidget=ajeno) # aceptado, sin error
>>> ajeno.winfo_ismapped(), marco.winfo_reqwidth()
(0, 1)
>>> de_otro_marco = ttk.Checkbutton(otro_marco, text="x")
>>> marco.configure(labelwidget=de_otro_marco)
_tkinter.TclError: cannot add ".!labelframe2.!checkbutton" as content of ".!labelframe3"Un hermano se acepta en silencio y deja el marco en un píxel de ancho, sin rótulo y sin sitio; un hijo de otro widget sí lanza el error. Es exactamente el mismo reparto que en el add del Notebook y del Panedwindow, tercera vez que aparece: cuando un contenedor de ttk adopta un widget, el padre tiene que ser él.
La segunda es que el text no se pierde:
>>> marco = ttk.Labelframe(ventana, text="Conexión")
>>> marco.configure(labelwidget=casilla)
>>> str(marco.cget("text"))
'Conexión'
>>> marco.configure(labelwidget="") # quitamos la casillaEl labelwidget gana mientras está puesto, pero el texto sigue guardado y vuelve a verse al quitarlo. Sirve para alternar entre las dos formas del mismo marco sin llevar la cuenta a mano.
Desactivar el grupo no desactiva nada#
Y llegamos al motivo de que esta lección no sea corta.
La primera es que hay que bajar a los nietos, porque un grupo casi siempre tiene un Frame por medio para colocar una fila, como manda la convención del bloque B.
La segunda es que no todos los widgets tienen state:
>>> "state" in ttk.Frame(ventana).configure()
False
>>> "state" in ttk.Label(ventana).configure()
TrueUn Frame no lo tiene y protesta con unknown option "-state", así que el recorrido tiene que saltárselo. Lo bueno es que un Label sí lo tiene, y conviene usarlo: si los campos se apagan y sus etiquetas siguen en negro, el grupo se ve a medio apagar.
La tercera es la más sucia, y son dos trampas juntas:
Con las tres pegas resueltas, la función queda así, y es de las que conviene copiar tal cual al resto del curso:
ESTADOS = {} # el estado de cada widget antes del primer apagón
def cambiar_estado(marco, activo, salvo=()):
"""Apaga o enciende todo lo que hay dentro de un marco.
Hace falta recorrer los hijos porque en Tk el estado de un
contenedor no se hereda. `salvo` es para la casilla del título,
que es hija del marco y no debe apagarse nunca.
"""
for hijo in marco.winfo_children():
if hijo in salvo:
continue
if hijo.winfo_children(): # un Frame por medio: baja
cambiar_estado(hijo, activo, salvo)
if "state" not in hijo.configure(): # un Frame no tiene state
continue
if hijo not in ESTADOS: # guarda el original una vez
ESTADOS[hijo] = str(hijo.cget("state"))
hijo.configure(state=ESTADOS[hijo] if activo else "disabled")El ejemplo#
Un diálogo de copias de seguridad, con tres grupos: uno normal, uno que se activa con la casilla del título y uno con el rótulo centrado.
"""Labelframe: grupos de campos y el patrón del grupo que se activa."""
import tkinter as tk
from tkinter import ttk
MARGEN = 12
AIRE = 4
GRIS = "#8E8E93"
DESTINOS = ("Disco externo", "Carpeta en red", "Servicio en la nube")
FRECUENCIAS = ("Cada hora", "Cada día", "Cada semana")
# El estado de cada widget antes de que lo apaguemos por primera vez.
ESTADOS = {}
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 cambiar_estado(marco, activo, salvo=()):
"""Apaga o enciende todo lo que hay dentro de un marco.
Hace falta recorrer los hijos porque en Tk el estado de un
contenedor no se hereda. `salvo` es para la casilla del título,
que es hija del marco y no debe apagarse nunca.
"""
for hijo in marco.winfo_children():
if hijo in salvo:
continue
if hijo.winfo_children():
cambiar_estado(hijo, activo, salvo)
if "state" not in hijo.configure():
continue
if hijo not in ESTADOS:
ESTADOS[hijo] = str(hijo.cget("state"))
hijo.configure(state=ESTADOS[hijo] if activo else "disabled")
def fila(marco, etiqueta):
"""Una etiqueta a la izquierda y sitio a la derecha. Convención del bloque B."""
caja = ttk.Frame(marco)
caja.pack(fill="x", pady=AIRE)
ttk.Label(caja, text=etiqueta, width=14, anchor="w").pack(side="left")
return caja
def crear_origen(contenedor):
"""Un marco con título de los normales."""
marco = ttk.Labelframe(contenedor, text="Qué se copia", padding=MARGEN)
marco.pack(fill="x")
caja = fila(marco, "Carpeta:")
ttk.Entry(caja, textvariable=carpeta).pack(side="left", fill="x", expand=True)
caja = fila(marco, "Destino:")
ttk.Combobox(caja, values=DESTINOS, textvariable=destino,
state="readonly").pack(side="left", fill="x", expand=True)
ttk.Checkbutton(marco, text="Incluir las subcarpetas",
variable=subcarpetas).pack(anchor="w", pady=(AIRE, 0))
return marco
def crear_programada(contenedor):
"""El patrón del grupo que se activa: la casilla es el título.
La casilla tiene que ser hija del propio Labelframe. Si fuera hija
de `contenedor`, se aceptaría sin error y el marco se quedaría en
un píxel de ancho.
"""
marco = ttk.Labelframe(contenedor, padding=MARGEN)
casilla = ttk.Checkbutton(marco, text="Copia programada",
variable=programada, command=cambiar_programada)
marco.configure(labelwidget=casilla)
marco.pack(fill="x", pady=(MARGEN, 0))
caja = fila(marco, "Frecuencia:")
ttk.Combobox(caja, values=FRECUENCIAS, textvariable=frecuencia,
state="readonly").pack(side="left", fill="x", expand=True)
caja = fila(marco, "Copias:")
ttk.Spinbox(caja, from_=1, to=30, width=6,
textvariable=copias).pack(side="left")
ttk.Label(caja, text="(según el plan contratado)",
foreground=GRIS).pack(side="left", padx=(AIRE, 0))
# Este campo no es editable por su cuenta, y al encender el grupo tiene
# que volver a readonly y no a normal. De eso se ocupa ESTADOS.
caja = fila(marco, "Verificar:")
ttk.Entry(caja, textvariable=comprobacion,
state="readonly").pack(side="left", fill="x", expand=True)
return marco, casilla
def crear_avisos(contenedor):
"""labelanchor='n': el rótulo centrado, que en un marco ancho queda mejor."""
marco = ttk.Labelframe(contenedor, text="Avisos", labelanchor="n",
padding=MARGEN)
marco.pack(fill="x", pady=(MARGEN, 0))
for texto, variable in (("Al terminar", al_terminar),
("Si algo falla", si_falla)):
ttk.Checkbutton(marco, text=texto, variable=variable).pack(
side="left", padx=(0, MARGEN))
return marco
def cambiar_programada():
"""El command de la casilla del título."""
activo = programada.get()
cambiar_estado(marco_programada, activo, salvo=(casilla_programada,))
describir()
def describir():
partes = [f"Origen: {carpeta.get() or '(sin elegir)'} → {destino.get()}"]
if programada.get():
partes.append(f"Programada: {frecuencia.get().lower()}, "
f"{copias.get()} copias")
else:
partes.append("Programada: no")
avisos = [t for t, v in (("al terminar", al_terminar), ("si falla", si_falla))
if v.get()]
partes.append("Avisos: " + (", ".join(avisos) if avisos else "ninguno"))
estado.set(" | ".join(partes))
ventana = tk.Tk()
ventana.title("Copias de seguridad")
ventana.minsize(520, 460)
ventana.resizable(True, False)
# Variables sueltas y global: en el bloque E esto pasa a ser una clase.
carpeta = tk.StringVar(value="~/Documentos")
destino = tk.StringVar(value=DESTINOS[0])
subcarpetas = tk.BooleanVar(value=True)
programada = tk.BooleanVar(value=False)
frecuencia = tk.StringVar(value=FRECUENCIAS[1])
copias = tk.IntVar(value=7)
comprobacion = tk.StringVar(value="Suma de comprobación SHA-256")
al_terminar = tk.BooleanVar(value=True)
si_falla = tk.BooleanVar(value=True)
estado = tk.StringVar()
cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)
marco_origen = crear_origen(cuerpo)
marco_programada, casilla_programada = crear_programada(cuerpo)
marco_avisos = crear_avisos(cuerpo)
ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
botones = ttk.Frame(cuerpo)
botones.pack(fill="x")
ttk.Button(botones, text="Copiar ahora").pack(side="left")
ttk.Button(botones, text="Guardar", command=describir).pack(side="right")
ttk.Label(cuerpo, textvariable=estado, foreground=GRIS, wraplength=470,
justify="left").pack(anchor="w", pady=(AIRE, 0))
# El grupo arranca apagado, según la casilla. Esta llamada es la que
# guarda el estado original de cada campo en ESTADOS.
cambiar_programada()
for variable in (destino, subcarpetas, frecuencia, al_terminar, si_falla):
variable.trace_add("write", lambda *_: describir())
centrar(ventana)
ventana.mainloop()Hay dos cosas en ese programa que merecen la mirada. El grupo programado arranca apagado, y al encenderlo el campo «Verificar», que era readonly, vuelve a readonly y no a editable: eso es lo que hace el diccionario ESTADOS. Y la casilla del título sigue funcionando cuando todo lo demás está apagado, porque va en salvo; si la quitas de ahí, el programa se queda muerto en cuanto desmarcas la casilla, que es exactamente el fallo que describe la lección.