Saltar al contenido
🐍 Curso de Python
/

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.

⏱ 21 min de lectura

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#

consola
>>> 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ónBotonesDevuelve
showinfoAceptar'ok'
showwarningAceptar'ok'
showerrorAceptar'ok'
askokcancelAceptar, CancelarTrue / False
askyesnoSí, NoTrue / False
askretrycancelReintentar, CancelarTrue / False
askyesnocancelSí, No, CancelarTrue / False / None
askquestionSí, 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:

consola
>>> ventana.tk.call("tk_messageBox", "-inventada", "x")
_tkinter.TclError: bad option "-inventada": must be -default, -detail, -icon,
-message, -parent, -title, -type, or -command

title 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:

consola
>>> 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 yes

En 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:

consola
>>> messagebox.askyesno("t", "m", default="retry")
_tkinter.TclError: Illegal default option

default 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.

python
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.

avisos.py
"""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()