Módulo 3 · Lección 36 de 50
Diálogos estándar con messagebox
La función que devuelve la cadena 'no' y hace que cualquier if sea cierto, la que devuelve tres cosas, la opción detail que casi nadie usa, y cuándo no hay que preguntar nada.
Hasta ahora todo lo que ha aparecido en el módulo se dibuja dentro de tu ventana. Los diálogos son lo primero que no: son ventanas del sistema, las pinta el sistema, y por eso un messagebox de Tkinter en macOS es indistinguible del de cualquier otro programa.
Son también el sitio donde se concentra más mala interfaz por línea de código, porque preguntar es fácil y decidir no. Así que esta lección tiene dos mitades: la técnica, que es corta y tiene una trampa buena, y la de criterio, que es la que de verdad mejora un programa.
Las ocho funciones#
>>> sorted(n for n in dir(messagebox) if not n.startswith("_"))
['Dialog', 'Message', 'askokcancel', 'askquestion', 'askretrycancel',
'askyesno', 'askyesnocancel', 'showerror', 'showinfo', 'showwarning']Todas tienen la misma firma, (title=None, message=None, **options), y se diferencian en los botones que muestran y en lo que devuelven, que es la parte importante:
| Función | Botones | Devuelve |
|---|---|---|
showinfo | Aceptar | 'ok' |
showwarning | Aceptar | 'ok' |
showerror | Aceptar | 'ok' |
askokcancel | Aceptar, Cancelar | True / False |
askyesno | Sí, No | True / False |
askretrycancel | Reintentar, Cancelar | True / False |
askyesnocancel | Sí, No, Cancelar | True / False / None |
askquestion | Sí, No | 'yes' / 'no' |
Las tres primeras solo informan y su valor de vuelta no sirve para nada. Las cuatro siguientes devuelven un booleano de verdad. Y las dos últimas son las que hay que mirar con cuidado.
Las opciones#
Tk las enumera cuando le pasas una que no existe, y son siete:
>>> ventana.tk.call("tk_messageBox", "-inventada", "x")
_tkinter.TclError: bad option "-inventada": must be -default, -detail, -icon,
-message, -parent, -title, -type, or -commandtitle y message ya las conoces. De las otras, dos importan mucho y el resto poco.
Los icon y los type se pueden cambiar, y sus valores también los enumera Tk:
>>> icon: must be error, info, question, or warning
>>> type: must be abortretryignore, ok, okcancel, retrycancel, yesno, or yesnocancel
>>> default: must be abort, retry, ignore, ok, cancel, no, or yesEn la práctica no hace falta tocarlos, porque cada función ya pone el suyo. La única que se usa de vez en cuando es default, que dice qué botón está preseleccionado, y tiene una condición que no es obvia:
>>> messagebox.askyesno("t", "m", default="retry")
_tkinter.TclError: Illegal default optiondefault tiene que ser uno de los botones que ese type muestra de verdad. Y sirve para algo importante: en una pregunta destructiva, el botón preseleccionado debe ser el que no destruye, porque el Enter es reflejo.
messagebox.askyesno("Borrar", "¿Borrar los 12 contactos seleccionados?",
default="no")Cuándo no hay que preguntar#
Esta es la mitad de la lección que cambia programas. Un diálogo interrumpe: para todo, se pone delante, y obliga a leer y decidir antes de seguir. Eso es mucho poder para algo que se escribe en una línea.
El ejemplo#
Un editor de notas con los tres casos legítimos: cerrar con cambios, borrar sin deshacer, y un fallo real. Los diálogos van encapsulados en funciones propias, que es lo que permite pasar el parent en un solo sitio y probar el programa sin abrir ninguno.
"""messagebox: los tres casos en los que un diálogo está justificado."""
import tkinter as tk
from tkinter import messagebox, ttk
MARGEN = 12
AIRE = 4
GRIS = "#8E8E93"
VERDE = "#2E7D32"
ROJO = "#C2185B"
NOTAS = {
"Compra": "leche\npan\ncafé",
"Ideas": "un curso de Tkinter en español",
"Pendiente": "llamar al fontanero",
}
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}")
# Los diálogos, encapsulados: el parent se pasa en un solo sitio y así el
# programa se puede probar sustituyendo estas tres funciones.
def informar_error(titulo, mensaje, detalle=""):
"""detail va debajo y más pequeño: ahí cabe la explicación."""
messagebox.showerror(title=titulo, message=mensaje, detail=detalle,
parent=ventana)
def preguntar(titulo, mensaje, detalle="", por_omision="no"):
"""askyesno, nunca askquestion: esta devuelve un bool de verdad.
default="no" en las preguntas destructivas, porque el Enter es reflejo.
"""
return messagebox.askyesno(title=titulo, message=mensaje, detail=detalle,
default=por_omision, parent=ventana)
def preguntar_con_cancelar(titulo, mensaje, detalle=""):
"""Tres respuestas: True (sí), False (no) y None (cancelar)."""
return messagebox.askyesnocancel(title=titulo, message=mensaje,
detail=detalle, parent=ventana)
def crear_lista(contenedor):
lista = tk.Listbox(contenedor, exportselection=False, activestyle="none",
width=18, borderwidth=1, relief="solid",
highlightthickness=0)
lista.pack(side="left", fill="y")
for titulo in NOTAS:
lista.insert("end", f" {titulo}")
lista.selection_set(0)
return lista
def crear_editor(contenedor):
caja = ttk.Frame(contenedor, padding=(MARGEN, 0, 0, 0))
caja.pack(side="left", fill="both", expand=True)
# width en caracteres, no en píxeles: sin él son las 80 columnas de
# fábrica y la ventana pide más de mil píxeles de ancho.
texto = tk.Text(caja, wrap="word", undo=True, font=("Menlo", 12),
width=40, height=12, borderwidth=1, relief="solid",
highlightthickness=0)
barra = ttk.Scrollbar(caja, orient="vertical", command=texto.yview)
texto.configure(yscrollcommand=barra.set) # las dos mitades
barra.pack(side="right", fill="y")
texto.pack(side="left", fill="both", expand=True)
return texto
def titulo_actual():
indices = lista.curselection()
if not indices:
return None
return list(NOTAS)[indices[0]]
def cargar(titulo):
texto.delete("1.0", "end")
texto.insert("1.0", NOTAS[titulo])
texto.edit_modified(False)
actual.set(titulo)
marcar_estado()
def marcar_estado():
"""La señal discreta de que hay cambios: aquí, no en un diálogo."""
sucio = bool(texto.edit_modified())
ventana.title(f"Notas — {actual.get()}{' •' if sucio else ''}")
estado.set("Sin guardar" if sucio else "Guardado")
pie.configure(foreground=ROJO if sucio else VERDE)
def al_cambiar_texto(evento=None):
marcar_estado()
def cambiar_nota(evento=None):
"""Caso 1 de diálogo justificado: una decisión que no se puede posponer."""
nuevo = titulo_actual()
if nuevo is None or nuevo == actual.get():
return
if texto.edit_modified():
respuesta = preguntar_con_cancelar(
"Cambiar de nota",
f"¿Guardar los cambios de «{actual.get()}»?",
"Si no los guardas se perderán.")
if respuesta is None: # Cancelar, antes que nada
volver_a_seleccionar(actual.get())
return
if respuesta:
guardar()
cargar(nuevo)
def volver_a_seleccionar(titulo):
lista.selection_clear(0, "end")
lista.selection_set(list(NOTAS).index(titulo))
def guardar():
titulo = actual.get()
if titulo in SOLO_LECTURA:
# Caso 3: un fallo que impide continuar. Aquí sí hace falta el diálogo.
informar_error(
"Guardar",
f"No se ha podido guardar «{titulo}».",
f"La nota está marcada como de solo lectura. Quítale la marca "
f"con el botón «Solo lectura» y vuelve a intentarlo.")
return False
NOTAS[titulo] = texto.get("1.0", "end-1c")
texto.edit_modified(False)
marcar_estado()
return True
def borrar():
"""Caso 2: una acción irreversible. Sin deshacer, la pregunta se gana."""
titulo = actual.get()
if len(NOTAS) == 1:
informar_error("Borrar", "No se puede borrar la única nota.",
"Crea otra antes de borrar esta.")
return
if not preguntar("Borrar", f"¿Borrar la nota «{titulo}»?",
"Esta acción no se puede deshacer.", por_omision="no"):
return
del NOTAS[titulo]
SOLO_LECTURA.discard(titulo)
lista.delete(0, "end")
for nombre in NOTAS:
lista.insert("end", f" {nombre}")
lista.selection_set(0)
cargar(list(NOTAS)[0])
def cambiar_solo_lectura():
titulo = actual.get()
if titulo in SOLO_LECTURA:
SOLO_LECTURA.discard(titulo)
else:
SOLO_LECTURA.add(titulo)
bloqueo.set(titulo in SOLO_LECTURA)
estado.set(f"«{titulo}»: "
f"{'solo lectura' if bloqueo.get() else 'editable'}")
def al_cerrar():
"""El protocolo de la ventana, lección 4. Otra decisión que no se pospone.
Si guardar falla no se cierra: cerrar perdería los cambios en silencio.
"""
if texto.edit_modified():
respuesta = preguntar_con_cancelar(
"Salir", "¿Guardar los cambios antes de salir?",
"Si no los guardas se perderán.")
if respuesta is None:
return
if respuesta and not guardar():
return # no se ha podido guardar: no se cierra
ventana.destroy()
ventana = tk.Tk()
ventana.minsize(600, 420)
# Variables sueltas y global: en el bloque E esto pasa a ser una clase.
actual = tk.StringVar(value=list(NOTAS)[0])
estado = tk.StringVar()
bloqueo = tk.BooleanVar(value=False)
SOLO_LECTURA = {"Ideas"}
cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)
caja = ttk.Frame(cuerpo)
caja.pack(fill="both", expand=True)
lista = crear_lista(caja)
texto = crear_editor(caja)
ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
botones = ttk.Frame(cuerpo)
botones.pack(fill="x")
ttk.Button(botones, text="Guardar", command=guardar).pack(side="left")
ttk.Checkbutton(botones, text="Solo lectura", variable=bloqueo,
command=cambiar_solo_lectura).pack(side="left", padx=MARGEN)
ttk.Button(botones, text="Borrar", command=borrar).pack(side="right")
pie = ttk.Label(cuerpo, textvariable=estado)
pie.pack(anchor="w", pady=(AIRE, 0))
ttk.Label(cuerpo, foreground=GRIS, wraplength=540, justify="left",
text="No hay ningún diálogo al guardar bien: eso se dice en el pie y "
"con el punto del título. Los diálogos son solo para cambiar de "
"nota con cambios, borrar, y cuando guardar falla.").pack(
anchor="w", pady=(AIRE, 0))
lista.bind("<<ListboxSelect>>", cambiar_nota)
texto.bind("<<Modified>>", al_cambiar_texto)
ventana.protocol("WM_DELETE_WINDOW", al_cerrar)
cargar(list(NOTAS)[0])
bloqueo.set(actual.get() in SOLO_LECTURA)
centrar(ventana)
ventana.mainloop()