Módulo 3 · Lección 19 de 50
El desplegable: Combobox
Un campo de texto con una lista colgando: la decisión entre dejar escribir o no, el índice que vale menos de lo que parece, y las listas que dependen de otra lista.
Un Combobox es literalmente lo que dice su nombre: la combinación de un campo de texto y una lista. Y no es una metáfora, es la implementación:
>>> issubclass(ttk.Combobox, ttk.Entry)
TrueHereda del Entry de la lección 15, así que todo lo que sabes de ahí sigue valiendo: get, textvariable, justify, los estados, e incluso la validación con sus trampas. Lo que añade es la lista desplegable y tres cosas para manejarla.
Esta lección tiene además una decisión de diseño en el centro, que es si el desplegable deja escribir o no. Se resuelve con una sola opción y cambia por completo el widget con el que estás trabajando.
Solo existe en ttk#
Al contrario que el Text de la lección 16, aquí no hay versión clásica:
>>> hasattr(tk, "Combobox")
FalseEl desplegable llegó con ttk en 2007, así que no hay nada que decidir ni justificar: es ttk.Combobox y punto. Es el caso simétrico del Text, y entre los dos resumen la historia de las dos familias.
Crear y leer#
La lista se pasa en values y el resultado se lee con get, igual que un campo de texto:
AREAS = ["Backend", "iOS", "Diseño", "DevOps", "QA", "Datos"]
area = ttk.Combobox(formulario, values=AREAS, state="readonly", width=24)
area.current(0)Ojo con un detalle: un desplegable recién creado está vacío, aunque tenga valores. No se preselecciona nada:
>>> area = ttk.Combobox(ventana, values=AREAS, state="readonly")
>>> area.get()
''
>>> area.current()
-1Así que después de crearlo hay que elegir: o current(0) para que arranque con el primero, o dejarlo vacío a propósito porque la elección es obligatoria. Es la misma decisión que el valor inicial de los botones de radio, y conviene tomarla igual de conscientemente.
Los dos métodos que añade sobre el Entry son estos, y no hay más:
>>> area.current(0)
>>> area.get()
'Backend'
>>> area.set("Datos")
>>> area.current()
5set(texto) pone un valor y current(índice) pone el que está en esa posición. Sin argumento, current() devuelve la posición del valor actual dentro de la lista.
Y values se guarda como una tupla, se lea como se escriba:
>>> ttk.Combobox(ventana, values=["a", "b"]).cget("values")
('a', 'b')Eso viene bien, porque permite tratarla como una secuencia normal de Python para buscar en ella o contar cuántas opciones hay.
Cerrado o editable#
Aquí está la decisión de la lección. state tiene el mismo juego que el Entry, y los dos primeros valores producen widgets que se usan de forma distinta:
ttk.Combobox(formulario, values=AREAS, state="readonly") # cerrado
ttk.Combobox(formulario, values=AREAS) # editable
ttk.Combobox(formulario, values=AREAS, state="disabled") # apagadoUn desplegable cerrado solo admite valores de la lista. Es lo que quieres casi siempre, y por eso state="readonly" se escribe tanto que conviene tomarlo como la opción por omisión mental: si el dato va a una base de datos con valores fijos, a un campo con opciones cerradas o a cualquier sitio donde "Diseño" y "diseño" sean cosas distintas, cerrado.
Un desplegable editable deja escribir cualquier cosa, y la lista pasa a ser una ayuda en vez de una restricción. Tiene dos usos legítimos y solo dos: cuando de verdad se admite cualquier valor y la lista son solo sugerencias o valores frecuentes, y cuando la lista es tan larga que escribir es más rápido que buscar.
Reaccionar a la elección#
El desplegable trae su propio evento, <<ComboboxSelected>>, que se ata con bind:
area.bind("<<ComboboxSelected>>", lambda evento: actualizar())Funciona, y lo verás en mucho código. Pero tiene una limitación que conviene medir antes de fiarse de él. Con un desplegable que tenga las dos cosas atadas, un bind del evento y un trace_add sobre su variable:
tras set('b') -> [('trace', 'b')]
tras current(2) -> [('trace', 'b'), ('trace', 'c')]El evento no aparece ni una vez. <<ComboboxSelected>> solo se dispara cuando alguien elige con el ratón o el teclado en la lista desplegada; los cambios que hace tu programa con set() o current() no lo disparan.
Es el mismo reparto que en la casilla y en los botones de radio, con los mismos nombres cambiados: el evento responde a la interacción, trace_add responde al dato. Y como lo que suele interesar es el dato, textvariable más trace_add es la opción fiable:
area = tk.StringVar(value="Backend")
ttk.Combobox(formulario, values=AREAS, textvariable=area, state="readonly")
area.trace_add("write", lambda *_: actualizar())El caso en el que sí quieres el evento es cuando la acción debe ocurrir solo si la eligió una persona: recargar algo de la red, por ejemplo, donde no te interesa dispararlo cada vez que el programa ajusta el valor por su cuenta.
Listas que dependen de otra lista#
El caso clásico: país y ciudad, categoría y subcategoría, año y mes. El segundo desplegable cambia de contenido según el primero.
La parte fácil es cambiar la lista, porque values se puede reconfigurar en marcha. La parte que se olvida es lo que pasa con el valor que había:
>>> ciudad = ttk.Combobox(ventana, values=["Madrid", "Barcelona"], state="readonly")
>>> ciudad.current(1)
>>> ciudad.get(), ciudad.current()
('Barcelona', 1)
>>> ciudad.configure(values=["Lisboa", "Oporto"])
>>> ciudad.get(), ciudad.current()
('Barcelona', -1)Cambiar la lista no cambia el contenido del campo. El desplegable sigue mostrando "Barcelona" mientras su lista ofrece Lisboa y Oporto, y current() avisa con un -1 que nadie está mirando. Si alguien acepta el formulario en ese momento, se guarda una ciudad portuguesa llamada Barcelona.
El arreglo es una línea, y es la que hay que acordarse de escribir:
def refrescar_ciudades(*_):
"""Ajusta la lista del segundo desplegable y su valor si quedó huérfano."""
ciudades = PAISES[pais.get()]
combo_ciudad.configure(values=ciudades)
if ciudad.get() not in ciudades:
combo_ciudad.current(0)Con eso, la cadena se porta:
inicial -> 'España' 'Madrid'
tras cambiar a Francia -> 'París'
tras cambiar a Portugal -> 'Lisboa'La comprobación if ciudad.get() not in ciudades es mejor que poner current(0) a secas, porque si el valor sigue siendo válido en la lista nueva conviene respetarlo en vez de reiniciarlo.
Las demás opciones#
>>> sorted(ttk.Combobox(ventana).configure().keys())
['background', 'class', 'cursor', 'exportselection', 'font', 'foreground',
'height', 'invalidcommand', 'justify', 'placeholder', 'placeholderforeground',
'postcommand', 'show', 'state', 'style', 'takefocus', 'textvariable',
'validate', 'validatecommand', 'values', 'width', 'xscrollcommand']Casi todas son del Entry del que hereda. Las propias del desplegable son cuatro:
| Opción | Qué hace |
|---|---|
values | La lista de opciones |
height | Cuántas filas se ven en el desplegable antes de tener que desplazarse |
postcommand | Una función que se llama justo antes de abrir la lista |
state | Con el valor extra readonly, que es el que decide todo |
height está en diez por defecto, y se nota con listas largas:
>>> ttk.Combobox(ventana).cget("height")
10Con cincuenta valores y height=10, el desplegable muestra diez y se desplaza. Subirlo mucho da un desplegable que tapa la ventana; el valor de fábrica está bien elegido y rara vez hay que tocarlo.
postcommand es el más interesante de los cuatro y el menos usado. Sirve para rellenar la lista en el último momento, justo cuando alguien va a abrirla:
ttk.Combobox(barra, textvariable=fichero,
postcommand=lambda: actualizar_lista_de_ficheros())Eso resuelve el problema de una lista que caduca: los ficheros de una carpeta, las impresoras conectadas, las sesiones abiertas. En vez de refrescarla cada pocos segundos o dejarla desactualizada, se refresca en el único momento en que importa. Con la advertencia de que esa función tiene que ser rápida, porque se ejecuta mientras alguien espera a que se abra el desplegable, y aquí vale lo dicho del command del botón.
Y como detalle de ancho: width va en caracteres, como siempre, y conviene ponerlo a la medida de la opción más larga de la lista. Si no, el desplegable se ajusta al contenido inicial y cambia de tamaño al elegir un valor más largo, con el temblor de la interfaz que viste en la lección 13.
Un formulario con desplegables encadenados#
"""Combobox: cerrado, editable, encadenado y con postcommand."""
import tkinter as tk
from tkinter import ttk
from datetime import datetime
MARGEN = 12
AIRE = 4
DESTINOS = {
"España": ("Madrid", "Barcelona", "Valencia", "Sevilla", "Bilbao"),
"Portugal": ("Lisboa", "Oporto", "Faro"),
"Francia": ("París", "Lyon", "Marsella", "Niza"),
"Italia": ("Roma", "Milán", "Florencia", "Nápoles", "Venecia"),
}
MOTIVOS = ("Reunión con cliente", "Formación", "Congreso", "Auditoría")
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_formulario(contenedor):
"""Los tres desplegables: dos cerrados encadenados y uno editable."""
global combo_ciudad
caja = ttk.LabelFrame(contenedor, text="Viaje", padding=MARGEN)
caja.pack(fill="x")
ttk.Label(caja, text="País:", anchor="e", width=9).grid(
row=0, column=0, sticky="e", pady=AIRE)
ttk.Combobox(caja, values=list(DESTINOS), textvariable=pais,
state="readonly", width=24).grid(
row=0, column=1, sticky="ew", pady=AIRE)
ttk.Label(caja, text="Ciudad:", anchor="e", width=9).grid(
row=1, column=0, sticky="e", pady=AIRE)
combo_ciudad = ttk.Combobox(caja, textvariable=ciudad,
state="readonly", width=24)
combo_ciudad.grid(row=1, column=1, sticky="ew", pady=AIRE)
# Editable a propósito: el motivo admite cualquier texto y la lista
# son solo los cuatro más frecuentes.
ttk.Label(caja, text="Motivo:", anchor="e", width=9).grid(
row=2, column=0, sticky="e", pady=AIRE)
ttk.Combobox(caja, values=MOTIVOS, textvariable=motivo, width=24).grid(
row=2, column=1, sticky="ew", pady=AIRE)
caja.columnconfigure(1, weight=1)
return caja
def crear_hora(contenedor):
"""Un desplegable cuya lista se rellena al abrirla, con postcommand."""
caja = ttk.LabelFrame(contenedor, text="Salida", padding=MARGEN)
caja.pack(fill="x", pady=(MARGEN, 0))
ttk.Label(caja, text="Hora:", anchor="e", width=9).grid(
row=0, column=0, sticky="e")
combo = ttk.Combobox(caja, textvariable=hora, state="readonly", width=24,
height=6)
combo.configure(postcommand=lambda: combo.configure(values=proximas_horas()))
combo.grid(row=0, column=1, sticky="ew")
caja.columnconfigure(1, weight=1)
return caja
def proximas_horas():
"""Las seis próximas horas en punto, calculadas al abrir la lista."""
ahora = datetime.now().hour
return tuple(f"{(ahora + i) % 24:02d}:00" for i in range(1, 7))
def refrescar_ciudades(*_):
"""La lista del segundo desplegable y su valor, que puede quedar huérfano."""
ciudades = DESTINOS[pais.get()]
combo_ciudad.configure(values=ciudades)
if ciudad.get() not in ciudades:
combo_ciudad.current(0)
def describir(*_):
"""El resumen. Avisa si el motivo no es uno de los de la lista."""
partes = [f"{ciudad.get()} ({pais.get()})"]
if motivo.get().strip():
partes.append(motivo.get().strip())
if hora.get():
partes.append(f"salida a las {hora.get()}")
resumen.set(" · ".join(partes))
if motivo.get().strip() and motivo.get() not in MOTIVOS:
nota.set("Motivo libre: se guardará tal cual está escrito")
else:
nota.set("")
ventana = tk.Tk()
ventana.title("Solicitud de viaje")
ventana.minsize(520, 340)
# Variables sueltas y global: en el bloque E esto pasa a ser una clase.
pais = tk.StringVar(value="España")
ciudad = tk.StringVar()
motivo = tk.StringVar(value=MOTIVOS[0])
hora = tk.StringVar()
resumen = tk.StringVar()
nota = tk.StringVar()
combo_ciudad = None
cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)
crear_formulario(cuerpo)
crear_hora(cuerpo)
ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
ttk.Label(cuerpo, textvariable=resumen, wraplength=460, justify="left").pack(
anchor="w")
ttk.Label(cuerpo, textvariable=nota, foreground="#B02A37").pack(
anchor="w", pady=(AIRE, 0))
# trace_add y no <<ComboboxSelected>>: así también se entera de refrescar_ciudades.
pais.trace_add("write", refrescar_ciudades)
for variable in (pais, ciudad, motivo, hora):
variable.trace_add("write", describir)
refrescar_ciudades()
describir()
centrar(ventana)
ventana.mainloop()Tres decisiones ahí. El país y la ciudad son cerrados porque solo valen los destinos previstos; el motivo es editable a propósito, y precisamente por eso el programa avisa cuando lo escrito no es una de las opciones conocidas, que es la clase de comprobación que un desplegable editable te obliga a añadir. La hora usa postcommand porque su lista depende de la hora actual y estaría mal calculada si se hiciera al arrancar. Y el orden de los dos trace_add sobre pais importa: primero se refresca la lista de ciudades y luego se describe, porque describir antes usaría la ciudad huérfana.