Módulo 3 · Lección 29 de 50
Paneles y separadores: PanedWindow y Separator
Divisiones que se arrastran: la regla exacta de weight, el sashpos que solo funciona cuando el widget ya mide algo, el panel que se puede encoger hasta desaparecer, y el separador de un píxel que no se ve.
Dos widgets en una lección, porque son pequeños y están relacionados: los dos dividen el sitio. La diferencia es quién manda. Un Separator dibuja una línea donde tú decides; un PanedWindow dibuja una división que se puede arrastrar, y a partir de ahí el reparto lo decide quien use el programa.
El PanedWindow es el que cierra el patrón que quedó prometido en la lección anterior: cuando hay más de cinco secciones, en vez de pestañas se pone una lista de categorías a la izquierda y el contenido a la derecha, con una división arrastrable en medio. Es el diseño de los diálogos de preferencias grandes, de los clientes de correo y de los editores de código, y el ejemplo de esta lección lo monta.
Y trae dos cosas que hacen perder tiempo: una opción weight que casi todo el mundo interpreta mal, y un sashpos que no funciona si lo llamas en el momento natural.
El temático es mucho más pobre#
Aquí hay que elegir familia con criterio, porque la diferencia es grande.
>>> sorted(ttk.Panedwindow(ventana).configure().keys())
['class', 'cursor', 'height', 'orient', 'style', 'takefocus', 'width']Siete opciones. Y lo que le falta respecto al clásico:
>>> sorted(set(tk.PanedWindow(ventana).configure()) - set(ttk.Panedwindow(ventana).configure()))
['background', 'bd', 'bg', 'borderwidth', 'handlepad', 'handlesize',
'opaqueresize', 'proxybackground', 'proxyborderwidth', 'proxyrelief',
'relief', 'sashcursor', 'sashpad', 'sashrelief', 'sashwidth', 'showhandle']Casi todo eso es aspecto de la división y no importa. Lo que sí importa es lo de los paneles, y ahí la diferencia es seria:
>>> sorted(paneles.pane(0).keys()) # ttk
['weight']
>>> sorted(clasico.paneconfigure(panel).keys()) # tk
['after', 'before', 'height', 'hide', 'minsize', 'padx', 'pady', 'sticky',
'stretch', 'width']Un panel temático tiene una sola opción, weight. El clásico tiene diez, y entre ellas minsize, que es la que se echa en falta:
>>> clasico.add(izquierda, minsize=120)
>>> clasico.sash_place(0, 10, 0) # intentamos dejarlo en 10
>>> clasico.sash_coord(0)
(121, 1)Ha respetado el mínimo. En la versión temática eso no existe, y un panel se puede arrastrar hasta desaparecer del todo, dejando una interfaz en la que falta media pantalla y no hay forma obvia de recuperarla más que encontrar la división y volver a tirar de ella.
Crear y añadir#
Dos avisos de golpe en la primera línea:
paneles = ttk.Panedwindow(cuerpo, orient="horizontal")El primero es que orient es de solo lectura:
>>> paneles.configure(orient="vertical")
_tkinter.TclError: attempt to change read-only optionNo se puede girar un PanedWindow después de crearlo. Si tu programa ofrece cambiar entre disposición horizontal y vertical, hay que destruir el widget y montarlo otra vez.
El segundo es que el valor de fábrica quizá no es el que esperas:
>>> str(ttk.Panedwindow(ventana).cget("orient"))
'vertical'Vertical, o sea paneles uno encima de otro. Y orient="horizontal", que es lo que se quiere la mayoría de las veces, significa que los paneles se colocan en fila y la división es vertical. Ese cruce de nombres despista: el orient describe cómo se reparten los paneles, no cómo se dibuja la línea que los separa.
Los paneles se añaden como las pestañas del Notebook:
izquierda = ttk.Frame(paneles)
derecha = ttk.Frame(paneles)
paneles.add(izquierda, weight=1)
paneles.add(derecha, weight=3)Y vale el mismo aviso de la lección anterior, medido también aquí:
>>> ajeno = ttk.Frame(ventana) # hijo de la ventana, no del paned
>>> paneles.add(ajeno)
>>> ajeno.winfo_parent(), ajeno.winfo_ismapped()
('.', 0)Aceptado sin error y nunca se ve. Y un nieto sí protesta:
>>> paneles.add(nieto)
_tkinter.TclError: cannot add ".!panedwindow.!frame.!frame" as content of ".!panedwindow"Es exactamente lo que pasaba con el Notebook, así que se puede subir a regla general: en los contenedores de ttk, el hijo que añades tiene que ser hijo directo del contenedor, y si te equivocas puede que nadie te lo diga.
Los demás métodos son los esperables. panes() da la lista de nombres, pane(n) lee o cambia las opciones de un panel, forget lo quita, e insert lo pone en una posición o mueve uno que ya estaba:
>>> paneles.insert("end", primero)
>>> [paneles.pane(i)["weight"] for i in range(len(paneles.panes()))]
[2, 1, 1]Qué hace weight exactamente#
Esta es la parte que se suele explicar mal, incluso a uno mismo. weight no es la proporción de los paneles. Medido con dos paneles en un ancho de 400:
| Lo que piden los hijos | weight | Dónde queda la división |
|---|---|---|
| 100 y 100 | 1 y 1 | 198 |
| 100 y 100 | 1 y 3 | 149 |
| 300 y 50 | 1 y 3 | 312 |
| 150 y 150 | 0 y 1 | 150 |
La regla que explica las cuatro filas: cada panel recibe primero el tamaño que pide, y lo que sobra se reparte según los pesos.
Compruébalo con la segunda fila. Los hijos piden 100 y 100, o sea 200 de los 400; sobran 200, y con pesos 1 y 3 al primero le toca un cuarto, 50. Total para el primero: 100 + 50 = 150, y ahí está la división. Y la tercera fila: piden 300 y 50, sobran 50, un cuarto son 12,5, y la división sale en 312.
La cuarta fila es la más útil de recordar. Con weight=0 el panel no crece nunca: se queda con lo que pide y todo el espacio de sobra se va al otro. Eso es justo lo que quieres para el panel de una lista de categorías o de una barra de herramientas lateral, donde ensanchar no aporta nada.
Y lo mismo vale al agrandar la ventana después:
>>> paneles.sashpos(0, 250) # la movemos a mano
>>> paneles.sashpos(0)
250
>>> paneles.configure(width=800) # de 400 a 800
>>> paneles.sashpos(0)
450Los 400 píxeles nuevos se han repartido a partes iguales, y la división se ha quedado donde estaba más su parte. No vuelve a su sitio original, que es lo correcto: si alguien movió la división, mover la ventana no debe deshacerlo.
sashpos, y por qué falla#
Colocar la división desde el código es sashpos(indice, posicion), con la posición en píxeles desde el borde. Con dos paneles hay una sola división, la cero:
>>> paneles.sashpos(1)
_tkinter.TclError: sash index 1 out of rangeY ahora el problema. Esto, que es lo que uno escribe, no funciona:
paneles.add(izquierda, weight=0)
paneles.add(derecha, weight=1)
paneles.sashpos(0, 200) # no hace nada útilEl Separator#
Después de eso, el Separator es un descanso. Es una línea, y tiene cinco opciones:
>>> sorted(ttk.Separator(ventana).configure().keys())
['class', 'cursor', 'orient', 'style', 'takefocus']Ni color, ni grosor: eso lo pone el tema, y está bien que sea así porque una línea divisoria más gruesa que las del sistema canta enseguida.
Lo único que hay que saber es esto, y explica el desconcierto habitual:
>>> ttk.Separator(ventana, orient="horizontal").winfo_reqwidth()
1
>>> ttk.Separator(ventana, orient="horizontal").winfo_reqheight()
1Un píxel por un píxel. Un separador no pide sitio a lo largo, solo su grosor, así que si lo colocas sin decirle que se estire, se dibuja un punto de un píxel y se da por hecho que el widget está roto. La línea que hay que escribir siempre:
ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
ttk.Separator(cuerpo, orient="vertical").pack(fill="y", padx=MARGEN)fill="x" en el horizontal y fill="y" en el vertical, o con grid, sticky="ew" y sticky="ns". Y el pady o padx importa casi tanto: una línea pegada al contenido no separa nada, porque lo que separa visualmente es el hueco, y la línea solo lo subraya.
La lista de categorías#
Este es el patrón que prometía la lección 28 para cuando hay demasiadas secciones para pestañas: un Listbox de categorías a la izquierda, el contenido a la derecha, y la división arrastrable.
"""Panedwindow: lista de categorías a la izquierda y contenido a la derecha."""
import tkinter as tk
from tkinter import ttk
MARGEN = 12
AIRE = 4
GRIS = "#8E8E93"
SECCIONES = {
"General": ("Nombre del usuario", "Idioma", "Carpeta de trabajo"),
"Aspecto": ("Tema", "Tamaño de la letra", "Densidad de la vista"),
"Editor": ("Tabulación", "Ajuste de línea", "Número de línea",
"Resaltado de sintaxis"),
"Red": ("Servidor", "Puerto", "Tiempo de espera"),
"Copias": ("Carpeta de destino", "Frecuencia", "Copias que se guardan"),
"Atajos": ("Guardar", "Buscar", "Ir a la línea"),
"Avanzado": ("Registro detallado", "Caché", "Reiniciar los ajustes"),
}
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_paneles(contenedor):
"""orient='horizontal': los paneles van en fila y la división es vertical.
Y es de solo lectura: no se puede girar después de crearlo.
"""
paneles = ttk.Panedwindow(contenedor, orient="horizontal")
paneles.pack(fill="both", expand=True)
# Los dos paneles, hijos del Panedwindow. Si el padre fuera "contenedor",
# add() los aceptaría sin error y no se verían nunca.
izquierda = ttk.Frame(paneles)
derecha = ttk.Frame(paneles, padding=(MARGEN, 0, 0, 0))
# weight=0 en la lista: se queda con lo que pide y no crece.
# Todo el espacio de sobra se va al contenido.
paneles.add(izquierda, weight=0)
paneles.add(derecha, weight=1)
return paneles, izquierda, derecha
def crear_lista(panel):
"""La lista de categorías. exportselection=False, lección 20."""
lista = tk.Listbox(panel, exportselection=False, activestyle="none",
borderwidth=1, relief="solid", highlightthickness=0,
width=16, height=len(SECCIONES))
barra = ttk.Scrollbar(panel, orient="vertical", command=lista.yview)
lista.configure(yscrollcommand=barra.set) # las dos mitades
barra.pack(side="right", fill="y")
lista.pack(side="left", fill="both", expand=True)
for nombre in SECCIONES:
lista.insert("end", f" {nombre}")
lista.selection_set(0)
return lista
def mostrar_seccion(evento=None):
"""Borra el contenido y lo vuelve a montar. Igual que el Canvas."""
for hijo in contenido.winfo_children():
hijo.destroy()
indices = lista.curselection() # índices, no valores
if not indices:
return
nombre = list(SECCIONES)[indices[0]]
ttk.Label(contenido, text=nombre,
font=("Helvetica", 15, "bold")).pack(anchor="w")
ttk.Separator(contenido, orient="horizontal").pack(fill="x",
pady=(AIRE, MARGEN))
for campo in SECCIONES[nombre]:
fila = ttk.Frame(contenido)
fila.pack(fill="x", pady=AIRE)
ttk.Label(fila, text=f"{campo}:", width=22, anchor="w").pack(
side="left")
ttk.Entry(fila).pack(side="left", fill="x", expand=True)
describir()
def describir():
estado.set(f"{len(SECCIONES)} secciones. "
f"División en {paneles.sashpos(0)} px de {paneles.winfo_width()}. "
f"Arrastra la línea vertical: la lista no crece sola porque "
f"su weight es 0.")
def repartir():
"""sashpos con el programa en marcha sí funciona: ya hay tamaño."""
paneles.sashpos(0, max(140, paneles.winfo_width() // 4))
describir()
ventana = tk.Tk()
ventana.title("Preferencias")
ventana.minsize(640, 420)
# Variables sueltas y global: en el bloque E esto pasa a ser una clase.
estado = tk.StringVar()
cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)
paneles, panel_izquierdo, panel_derecho = crear_paneles(cuerpo)
lista = crear_lista(panel_izquierdo)
contenido = ttk.Frame(panel_derecho)
contenido.pack(fill="both", expand=True)
# El Separator antes de los botones: es su caso más claro, y lleva fill="x"
# porque un separador solo pide un píxel y sin estirarse no se ve.
ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
botones = ttk.Frame(cuerpo)
botones.pack(fill="x")
ttk.Button(botones, text="Repartir", command=repartir).pack(side="left")
ttk.Button(botones, text="Guardar").pack(side="right")
ttk.Label(cuerpo, textvariable=estado, foreground=GRIS, wraplength=580,
justify="left").pack(anchor="w", pady=(AIRE, 0))
lista.bind("<<ListboxSelect>>", mostrar_seccion)
mostrar_seccion()
# describir() mide la división, y aquí todavía no hay diseño: diría "0 px de 1".
# after_idle lo deja para cuando Tk termine, que es el mismo remedio que
# necesita sashpos.
ventana.after_idle(describir)
centrar(ventana)
ventana.mainloop()Tres decisiones de ese programa son el resumen práctico de la lección. La lista lleva weight=0, así que al agrandar la ventana crece el contenido y la lista se queda como está, que es lo que tiene sentido. No hay ningún sashpos al arrancar: el reparto inicial sale del ancho que pide el Listbox más el weight, y el botón «Repartir» demuestra que con el programa ya en marcha sashpos funciona sin ningún truco. Y el Separator de antes de los botones lleva su fill="x" y su pady, sin los cuales no sería más que un punto.