Saltar al contenido
🐍 Curso de Python
/

Módulo 3 · Lección 31 de 50

La barra de menús

El único widget que no va dentro de la ventana, el accelerator que no ata nada, el índice que vale None en un menú vacío, y el tearoff que desplaza todo lo demás.

⏱ 31 min de lectura

La barra de menús es el widget más raro del módulo, por tres motivos. No se coloca con ningún gestor, porque no es un hijo de la ventana sino una opción de la ventana. No tiene versión temática, y por una vez eso es una buena noticia. Y en macOS ni siquiera se dibuja dentro de la ventana: aparece arriba, en la barra del sistema, compartida con el resto de los programas.

Es también el widget donde más se nota en qué sistema operativo estás, y por eso la lección lo trata como un asunto de primera clase en vez de dejarlo en una nota al pie.

Y cumple una referencia pendiente de la lección 29: el separador de dentro de un menú no es el widget Separator. Es una entrada del propio menú, add_separator(), y es el único sitio del curso donde una línea divisoria se pone sin pensarlo dos veces, porque en un menú es la convención de todos los sistemas.

No hay versión temática, y da igual#

consola
>>> hasattr(ttk, "Menu")
False

Los menús son solo tk, como el Text, el Listbox y el Canvas. Pero al contrario que aquellos, aquí no se pierde nada: Tk dibuja los menús con los del sistema, así que ya tienen el aspecto nativo sin ayuda de ttk. Un menú de Tkinter en macOS es un menú de macOS de verdad, con su tipografía y su comportamiento.

La consecuencia es que casi todas las opciones de aspecto del widget no hacen nada en macOS, y conviene no tocarlas:

consola
>>> sorted(tk.Menu(ventana).configure().keys())
['activebackground', 'activeborderwidth', 'activeforeground', 'activerelief',
 'background', 'bd', 'bg', 'borderwidth', 'cursor', 'disabledforeground',
 'fg', 'font', 'foreground', 'postcommand', 'relief', 'selectcolor',
 'takefocus', 'tearoff', 'tearoffcommand', 'title', 'type']

De esas veintiuna, las que se usan de verdad son tres: tearoff, postcommand y, de vez en cuando, title. Las demás son colores y bordes de un menú dibujado por Tk, que es lo que ocurría en los años noventa.

La barra es una opción de la ventana#

Esto es lo primero que hay que interiorizar, porque rompe todo lo aprendido en el bloque B:

consola
>>> "menu" in ventana.configure()
True

La barra no se empaqueta ni se coloca en una rejilla. Se construye y se le entrega a la ventana:

python
barra = tk.Menu(ventana)
ventana.configure(menu=barra)

Y se quita igual de fácil, lo que vale para el modo de pantalla completa o para una ventana secundaria sin menús:

consola
>>> ventana.configure(menu="")
>>> str(ventana.cget("menu"))
''

La estructura tiene tres niveles y se repite igual en cualquier programa. La barra es un Menu. Cada título de la barra es una entrada de tipo cascade, que apunta a otro Menu. Y dentro de ese van las entradas, que son de cinco tipos:

consola
>>> for i in range(archivo.index("end") + 1):
...     print(i, archivo.type(i))
0 command
1 separator
2 checkbutton
3 radiobutton

Más el cascade, que sirve tanto en la barra como dentro de un menú para hacer un submenú. Cada tipo se añade con su método (add_command, add_separator, add_checkbutton, add_radiobutton, add_cascade) y tiene sus propias opciones, que se leen igual que las de un widget:

consola
>>> sorted(archivo.entryconfigure(0).keys())          # un command
['accelerator', 'activebackground', 'activeforeground', 'background', 'bitmap',
 'columnbreak', 'command', 'compound', 'font', 'foreground', 'hidemargin',
 'image', 'label', 'state', 'underline']
>>> sorted(separadores.entryconfigure(0).keys())      # un separator
['background']

Un separador tiene una sola opción, así que ni se le pone etiqueta ni se le pregunta nada:

consola
>>> separadores.entryconfigure(0, label="X")
_tkinter.TclError: unknown option "-label"

Y una cosa curiosa, que conviene saber para no dudar: un cascade tiene command y menu a la vez. El command de un cascade se ejecuta al abrirlo, y es una forma más torpe de hacer lo que hace postcommand.

El tearoff, que ya casi no molesta#

En los manuales antiguos la primera línea de cualquier menú es tearoff=0, para quitar una línea de puntos que aparecía arriba y que, al pulsarla, arrancaba el menú en una ventanita flotante. Medido hoy:

consola
>>> str(tk.Menu(ventana).cget("tearoff"))
'0'

Ya viene apagado, al menos en Tk 9 sobre macOS. Pero hay que seguir escribiéndolo, y el motivo no es el adorno:

consola
>>> con_tearoff.index("Uno"), con_tearoff.index("end")
(1, 2)
>>> sin_tearoff.index("Uno"), sin_tearoff.index("end")
(0, 1)

Con tearoff=1, la línea de puntos ocupa el índice 0 y desplaza todo lo demás. O sea que cualquier código que diga entryconfigure(0, ...) o delete(0, "end") apunta a otra cosa según cómo esté el menú, y como el valor de fábrica cambia entre versiones de Tk y entre sistemas, un programa escrito sin tearoff=0 funciona en tu máquina y se desordena en otra. Escríbelo siempre, en todos los menús menos en la barra.

accelerator no ata nada#

Este es el hallazgo que más tiempo hace perder, porque lo natural es suponer lo contrario.

Pero cuidado con la conclusión, porque pasarse al otro extremo tiene su propio castigo.

El sistema operativo, aquí sí importa#

Los menús son el único punto del módulo donde escribir un programa que quede bien en los tres sistemas cuesta algo de trabajo. Lo primero es saber dónde estás:

consola
>>> ventana.tk.call("tk", "windowingsystem")
'aqua'

Devuelve aqua en macOS, win32 en Windows y x11 en Linux. Y lo que cambia es sobre todo la tecla:

consola
>>> ventana.bind_all("<Command-s>", guardar)
>>> sorted(b for b in ventana.bind_all() if "s" in b.lower())
['<Control-Key-s>', '<Mod1-Key-s>']

En macOS los atajos van con Command, que Tk registra como Mod1, y en Windows y Linux con Control. No es cosmético: en macOS, Control+S no es el atajo de guardar y usarlo delata un programa mal portado. De ahí el par de constantes del ejemplo, que se calculan una vez:

python
SISTEMA = ventana.tk.call("tk", "windowingsystem")
MODIFICADOR, TECLA = ("Command", "Cmd") if SISTEMA == "aqua" else ("Control", "Ctrl")

Y en cuanto al texto del accelerator, Tk guarda literalmente lo que le pases, sin normalizar nada:

consola
>>> accelerator="Command-S"  →  'Command-S'
>>> accelerator="Cmd+O"      →  'Cmd+O'

Activar y desactivar entradas#

Aquí conecta con la lección 30, y por contraste. Un menú no tiene hijos que recorrer: sus entradas no son widgets, así que ni winfo_children() ni la función cambiar_estado sirven de nada. Las entradas se tocan una por una, por índice o por etiqueta:

python
menu.entryconfigure("Pegar", state="disabled")

Y los estados son tres, con el error enumerándolos como es debido:

consola
>>> menu.entryconfigure(0, state="readonly")
_tkinter.TclError: bad state "readonly": must be active, disabled, or normal

Una entrada desactivada no se puede lanzar, y además falla en silencio, como tantas cosas del módulo:

consola
>>> menu.entryconfigure("Pegar", state="disabled")
>>> menu.invoke("Pegar")
>>> hecho
[]

Ni error ni aviso: simplemente no pasa nada. Que está bien, porque es lo que hace una persona al pulsarla.

Lo interesante es cuándo desactivarlas, y la respuesta no es la que se usa con un widget normal. «Pegar» debe estar gris si el portapapeles está vacío, y «Deshacer» si no hay nada que deshacer; mantener eso al día desde todos los sitios que cambian el estado del programa es imposible de hacer bien. Para eso está postcommand:

python
edicion = tk.Menu(barra, tearoff=0, postcommand=lambda: repasar(edicion))

Tk llama a postcommand justo antes de abrir el menú, así que ahí se pregunta por el estado real y se pone cada entrada como toque. Es el mismo reparto de la lección 20 entre eventos y datos, aplicado a otra cosa: en vez de intentar enterarse de cada cambio, se calcula la respuesta en el único momento en que alguien la va a mirar.

El índice que vale None#

Y un último tropiezo, que aparece en la función más común de todas: la que rellena el submenú de «Abrir recientes».

Con eso, reconstruir un submenú es esto, y hay que fijarse en el caso vacío:

python
def rellenar_recientes():
        recientes.delete(0, "end")
    if not RECIENTES:
                recientes.add_command(label="(ninguno)", state="disabled")
        return
    for ruta in RECIENTES:
                recientes.add_command(label=ruta, command=partial(abrir, ruta))

Un submenú vacío es un submenú que se abre y no muestra nada, y parece un fallo. La entrada (ninguno) desactivada cuesta dos líneas y explica la situación. Y el partial es el de la lección 14: sin él, todas las entradas abrirían el último fichero de la lista.

El ejemplo#

Un editor con la barra completa: submenú de recientes que se reconstruye, casilla y grupo de opciones, entradas que se activan solas con postcommand, y los atajos saliendo de un sitio único.

menus.py
"""La barra de menús de un editor, correcta en los tres sistemas."""

import tkinter as tk
from functools import partial
from tkinter import ttk

MARGEN = 12
AIRE = 4

GRIS = "#8E8E93"

TAMANOS = (11, 13, 16)
RECIENTES = ["notas.txt", "informe.md", "lista de la compra.txt"]


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 cuantas(menu):
        """Número de entradas. index('end') vale None si el menú está vacío."""
    fin = menu.index("end")
    return 0 if fin is None else fin + 1


def atajo(menu, etiqueta, letra, orden, **extras):
        """Una entrada propia del programa, con su atajo atado aquí mismo.

    `accelerator` solo dibuja un texto y no ata nada, así que el bind hay
    que hacerlo aparte. Si las dos mitades no salen del mismo sitio, el
    día que alguien cambie una el menú anunciará una tecla que no existe.
    """
    menu.add_command(label=etiqueta, command=orden,
                     accelerator=f"{TECLA}+{letra.upper()}", **extras)
    ventana.bind_all(f"<{MODIFICADOR}-{letra.lower()}>",
                     lambda evento: orden())


def estandar(menu, etiqueta, letra, evento_virtual):
        """Una entrada de edición, que Tk ya trae atada.

    Aquí NO se ata nada: event_info dice que la tecla ya lleva al evento
    virtual, y el Text ya tiene el evento virtual atado a la acción. Un
    bind de más haría que Cmd+V pegase dos veces.
    """
    menu.add_command(label=etiqueta, accelerator=f"{TECLA}+{letra.upper()}",
                     command=lambda: mandar(evento_virtual))


def mandar(evento_virtual):
        """Al widget con el foco, que es el que sabe copiar o pegar.

    Es para cuando se usa el menú con el ratón; con el teclado no pasa
    por aquí. focus_get() vale None si la ventana no tiene el foco.
    """
    destino = ventana.focus_get() or texto
    destino.event_generate(evento_virtual)


def crear_barra():
        """La barra no se coloca: es una opción de la ventana."""
    barra = tk.Menu(ventana)

    if SISTEMA == "aqua":
        # El menú de la aplicación: el name "apple" es reservado y es el
        # único sitio del curso donde hace falta el argumento `name`.
                aplicacion = tk.Menu(barra, name="apple")
        aplicacion.add_command(label="Acerca de este editor", command=acerca_de)
        barra.add_cascade(menu=aplicacion)
        ventana.createcommand("tk::mac::ShowPreferences", preferencias)

    barra.add_cascade(label="Archivo", menu=crear_archivo(barra), underline=0)
    barra.add_cascade(label="Edición", menu=crear_edicion(barra), underline=0)
    barra.add_cascade(label="Ver", menu=crear_ver(barra), underline=0)

    ventana.configure(menu=barra)
    return barra


def crear_archivo(barra):
        global recientes
    menu = tk.Menu(barra, tearoff=0)   # tearoff=0 siempre: si no, desplaza

    atajo(menu, "Nuevo", "n", nuevo)
    atajo(menu, "Abrir…", "o", abrir_dialogo)

    # El submenú, hijo del menú que lo contiene.
    recientes = tk.Menu(menu, tearoff=0)
    menu.add_cascade(label="Abrir recientes", menu=recientes)
    rellenar_recientes()

    menu.add_separator()               # esto, y no el widget Separator
    atajo(menu, "Guardar", "s", guardar)

    if SISTEMA != "aqua":
        # En macOS "Salir" y "Preferencias" van en el menú de la aplicación.
                menu.add_separator()
        menu.add_command(label="Preferencias…", command=preferencias)
        menu.add_command(label="Salir", command=ventana.destroy)
    return menu


def crear_edicion(barra):
        """postcommand: Tk lo llama justo antes de abrir el menú.

    Las cuatro entradas son estándar, así que ninguna ata su tecla.
    """
    menu = tk.Menu(barra, tearoff=0)
    menu.configure(postcommand=lambda: repasar_edicion(menu))

    estandar(menu, "Deshacer", "z", "<<Undo>>")
    menu.add_separator()
    estandar(menu, "Cortar", "x", "<<Cut>>")
    estandar(menu, "Copiar", "c", "<<Copy>>")
    estandar(menu, "Pegar", "v", "<<Paste>>")
    return menu


def crear_ver(barra):
        """Una casilla y un grupo de opciones, con sus variables de control."""
    menu = tk.Menu(barra, tearoff=0)
    menu.add_checkbutton(label="Ajustar las líneas", variable=ajustar,
                         command=aplicar_ajuste)
    menu.add_separator()
    for tamano in TAMANOS:
                menu.add_radiobutton(label=f"Letra de {tamano} puntos",
                             variable=cuerpo_letra, value=tamano,
                             command=aplicar_letra)
    return menu


def rellenar_recientes():
        """Reconstruir un submenú: borrar y volver a poner.

    El caso vacío necesita una entrada desactivada; un submenú que se abre
    y no muestra nada parece un fallo.
    """
    recientes.delete(0, "end")
    if not RECIENTES:
                recientes.add_command(label="(ninguno)", state="disabled")
        return
    for ruta in RECIENTES:
        # partial, lección 14: sin él todas abrirían el último de la lista.
                recientes.add_command(label=ruta, command=partial(abrir, ruta))


def repasar_edicion(menu):
        """Calcula el estado de las entradas en el único momento en que se miran."""
    hay_seleccion = bool(texto.tag_ranges("sel"))
    for etiqueta in ("Cortar", "Copiar"):
                menu.entryconfigure(etiqueta,
                            state="normal" if hay_seleccion else "disabled")
    try:
                hay_portapapeles = bool(ventana.clipboard_get())
    except tk.TclError:       # el portapapeles vacío lanza TclError
                hay_portapapeles = False
    menu.entryconfigure("Pegar",
                        state="normal" if hay_portapapeles else "disabled")
    menu.entryconfigure("Deshacer",
                        state="normal" if texto.edit_modified() else "disabled")


def nuevo():
        texto.delete("1.0", "end")
    texto.edit_modified(False)
    anunciar("Documento nuevo")


def abrir(ruta):
        texto.delete("1.0", "end")
    texto.insert("1.0", f"(contenido de {ruta})")
    texto.edit_modified(False)
    anunciar(f"Abierto: {ruta}")


def abrir_dialogo():
        """Los diálogos de ficheros son del bloque D; aquí solo se simula."""
    anunciar("Aquí iría el diálogo de abrir, bloque D")


def guardar():
        texto.edit_modified(False)
    anunciar(f"Guardado ({cuantas(recientes)} recientes en la lista)")


def aplicar_ajuste():
        texto.configure(wrap="word" if ajustar.get() else "none")
    anunciar(f"Ajuste de líneas: {'sí' if ajustar.get() else 'no'}")


def aplicar_letra():
        texto.configure(font=("Menlo", cuerpo_letra.get()))
    anunciar(f"Letra de {cuerpo_letra.get()} puntos")


def preferencias():
        anunciar("Aquí iría la ventana de preferencias, bloque D")


def acerca_de():
        """En macOS existe ya el panel del sistema y se puede llamar tal cual."""
    ventana.tk.call("tk::mac::standardAboutPanel")


def anunciar(mensaje):
        estado.set(mensaje)


ventana = tk.Tk()
ventana.title("Editor")
ventana.minsize(560, 420)

# Una vez, al principio: en macOS los atajos van con Command y no con Control.
SISTEMA = ventana.tk.call("tk", "windowingsystem")
MODIFICADOR, TECLA = (("Command", "Cmd") if SISTEMA == "aqua"
                      else ("Control", "Ctrl"))

# Variables sueltas y global: en el bloque E esto pasa a ser una clase.
ajustar = tk.BooleanVar(value=True)
cuerpo_letra = tk.IntVar(value=TAMANOS[1])
estado = tk.StringVar(value=f"Sistema: {SISTEMA}. Atajos con {TECLA}.")
recientes = None

cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)

texto = tk.Text(cuerpo, wrap="word", undo=True, font=("Menlo", TAMANOS[1]),
                borderwidth=1, relief="solid", highlightthickness=0)
barra_texto = ttk.Scrollbar(cuerpo, orient="vertical", command=texto.yview)
texto.configure(yscrollcommand=barra_texto.set)
barra_texto.pack(side="right", fill="y")
texto.pack(side="left", fill="both", expand=True)
texto.insert("1.0", "Abre el menú Edición con el portapapeles vacío y sin "
                    "nada seleccionado: verás Cortar, Copiar y Pegar en gris.")
texto.edit_modified(False)

ttk.Label(cuerpo, textvariable=estado, foreground=GRIS).pack(
    side="bottom", anchor="w", pady=(AIRE, 0))

crear_barra()

centrar(ventana)
ventana.mainloop()