Módulo 2 · Lección 4 de 28
Menús interactivos
El bucle del menú bien hecho: despacho con un diccionario de funciones en vez de cadenas de elif, submenús, pausas y redibujado.
El proyecto del módulo 1 llevaba un menú: un while True, un input y un match con seis casos. Funcionaba, y con seis opciones se lee bien. El problema aparece en la séptima, en la octava y cuando alguien pide un submenú de informes: el match crece, el bucle principal deja de caber en la pantalla y añadir una opción obliga a tocar tres sitios distintos.
Un menú es, en realidad, una estructura de datos: una tabla que asocia lo que se teclea con lo que hay que ejecutar. Si lo escribes así, el bucle se queda en quince líneas para siempre y añadir opciones es añadir filas a la tabla.
El bucle, pieza a pieza#
Todo menú hace lo mismo tres veces por segundo: dibujar, leer, despachar. Escrito a lo bruto, con la cadena de condiciones que sale de forma natural:
while True:
print("=== AGENDA ===")
print(" 1. Listar contactos")
print(" 2. Añadir contacto")
print(" 0. Salir")
elegida = input("\nElige una opción: ").strip()
if elegida == "1":
listar()
elif elegida == "2":
anadir()
elif elegida == "0":
break
else:
print("Opción no válida.")Mira el código con atención y verás que la información del menú está escrita dos veces: el texto de cada opción en los print y el número de cada opción en los elif. Nada obliga a que coincidan. El día que insertes una opción en medio, o que cambies el orden, tendrás un menú que anuncia una cosa y hace otra, y el intérprete no se va a quejar.
Con match en vez de elif el código queda algo más limpio, pero la duplicación es exactamente la misma.
Las funciones también son valores#
La solución sale de algo que ya viste en la lección de funciones lambda: el nombre de una función, sin paréntesis, es un valor como cualquier otro. Se puede guardar en una variable, meter en una lista o poner en un diccionario.
def saludar() -> None:
print("Hola")
accion = saludar # sin paréntesis: guardo la función
print(accion)
accion() # con paréntesis: la llamo<function saludar at 0x109373060>
HolaLa primera línea de la salida confirma que accion es la función, no su resultado. Ese número hexadecimal es la dirección donde vive y será distinto en cada ejecución.
Con esa idea, el menú entero cabe en un diccionario que asocia la tecla con el texto y con la función que hay que llamar:
MENU = {
"1": ("Listar contactos", listar),
"2": ("Añadir contacto", anadir),
"0": ("Salir", None),
}Ya no hay duplicación: el texto y la acción de cada opción viven en la misma línea, y la tecla es la clave que los une. Añadir una opción es añadir una línea. Cambiar el orden es mover una línea. Y el None de la última marca la que termina el bucle.
Dibujar el menú desde el diccionario#
Si la tabla lleva los textos, el menú se pinta recorriéndola:
def dibujar_menu(titulo: str, opciones: dict) -> None:
limpiar_pantalla()
print(f"=== {titulo} ===")
for tecla, (texto, accion) in opciones.items():
flecha = " >" if isinstance(accion, dict) else ""
print(f" {tecla}. {texto}{flecha}")El desempaquetado for tecla, (texto, accion) in opciones.items() es el mismo de la lección de recorrer diccionarios, con una tupla anidada en el valor. Y ese isinstance(accion, dict) es el que dibuja un > al lado de las opciones que abren un submenú, como enseguida verás.
El bucle genérico#
Ahora el bucle no sabe nada de contactos ni de informes. Solo sabe dibujar una tabla de opciones y llamar a lo que le digan, así que sirve para el menú principal y para todos los submenús:
def bucle_menu(titulo: str, opciones: dict) -> None:
"""Dibuja el menú y ejecuta lo elegido hasta que se pide salir.
Cada opción es una tupla (texto, accion), donde accion puede ser una
función, otro diccionario de opciones (un submenú) o None para salir.
"""
while True:
dibujar_menu(titulo, opciones)
try:
elegida = input("\nElige una opción: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if elegida not in opciones:
print(f"\n'{elegida}' no está en el menú.")
pausa()
continue
texto, accion = opciones[elegida]
if accion is None:
return
if isinstance(accion, dict):
bucle_menu(texto, accion)
continue
accion()
pausa()Cinco decisiones que conviene entender antes de seguir:
if elegida not in opcionesva antes de leer la tabla. Sin esa comprobación, escribir una tecla que no existe lanzaría unKeyError. Comprobar la pertenencia a un diccionario es lo natural aquí, y sustituye alcase _delmatch.- Las opciones son cadenas, no números. No se convierte nada con
int(), así que da igual que alguien teclee una letra: no está en el diccionario y ya está. Es la misma decisión que se tomó en el proyecto del módulo 1. accion is Nonesignifica salir. Es elbreakde siempre, escrito como un dato de la tabla en vez de como un caso especial del código. Sirve igual para el "Salir" del menú principal y para el "Volver" de un submenú, porque en los dos casos lo que hay que hacer es terminar este bucle.isinstance(accion, dict)significa submenú. El valor de la opción es otro diccionario de opciones, y lo que se hace con él es llamar abucle_menuotra vez, con su propio título. Elcontinuede después es importante: al volver de un submenú no hay ninguna operación que mostrar, así que no hay que pausar.- La captura de
EOFErroryKeyboardInterruptestá aquí, en el único sitio que lee del teclado, tal como se vio en la lección anterior. Quien pulse Ctrl + C sale del menú como si hubiera elegido salir, sin traceback.
La pausa y el redibujado#
Dos funciones de una línea que marcan la diferencia entre un menú usable y uno que marea:
def pausa() -> None:
input("\nPulsa Intro para continuar... ")
def limpiar_pantalla() -> None:
if LIMPIAR_PANTALLA:
os.system("cls" if os.name == "nt" else "clear")La pausa resuelve un problema real: sin ella, el resultado de la operación aparece y el menú se redibuja inmediatamente encima, así que no hay tiempo de leer nada. Con ella, quien usa el programa decide cuándo continuar. Que el valor devuelto por ese input se ignore no es un descuido: lo único que interesa es que la ejecución se detenga ahí.
El redibujado es la otra mitad. Al limpiar la pantalla antes de dibujar, el menú siempre está arriba y en el mismo sitio, en vez de ir bajando y dejando un rastro de menús viejos que hay que ir saltando con la vista.
Submenús#
Un submenú es un diccionario dentro del diccionario. No hace falta ninguna función nueva, porque bucle_menu ya sabe reconocerlo:
MENU = {
"1": ("Listar contactos", listar),
"2": ("Añadir contacto", anadir),
"3": (
"INFORMES",
{
"1": ("Número de contactos", informe_total),
"2": ("Contactos por ciudad", informe_por_ciudad),
"0": ("Volver", None),
},
),
"0": ("Salir", None),
}Esta es la estructura anidada de la que hablaba la lección de estructuras anidadas, usada para algo útil. Y lo bonito es que no hay límite: un submenú puede contener otro submenú, y el mismo bucle_menu de quince líneas lo maneja, porque se llama a sí mismo. Una función que se llama a sí misma es una función recursiva; aquí no hace falta pensar mucho en ello, basta ver que cada nivel de menú es un bucle independiente que termina cuando se elige "Volver".
El programa completo#
"""Agenda de contactos con un menú de dos niveles."""
import os
from collections import Counter
LIMPIAR_PANTALLA = True
contactos: list[dict[str, str]] = [
{"nombre": "Ana Ruiz", "ciudad": "Madrid", "telefono": "600111222"},
{"nombre": "Luis Soto", "ciudad": "Valencia", "telefono": "600333444"},
{"nombre": "Marta Gil", "ciudad": "Madrid", "telefono": "600555666"},
]
def limpiar_pantalla() -> None:
if LIMPIAR_PANTALLA:
os.system("cls" if os.name == "nt" else "clear")
def pausa() -> None:
input("\nPulsa Intro para continuar... ")
def dibujar_menu(titulo: str, opciones: dict) -> None:
limpiar_pantalla()
print(f"=== {titulo} ===")
for tecla, (texto, accion) in opciones.items():
flecha = " >" if isinstance(accion, dict) else ""
print(f" {tecla}. {texto}{flecha}")
def bucle_menu(titulo: str, opciones: dict) -> None:
"""Dibuja el menú y ejecuta lo elegido hasta que se pide salir.
Cada opción es una tupla (texto, accion), donde accion puede ser una
función, otro diccionario de opciones (un submenú) o None para salir.
"""
while True:
dibujar_menu(titulo, opciones)
try:
elegida = input("\nElige una opción: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
if elegida not in opciones:
print(f"\n'{elegida}' no está en el menú.")
pausa()
continue
texto, accion = opciones[elegida]
if accion is None:
return
if isinstance(accion, dict):
bucle_menu(texto, accion)
continue
accion()
pausa()
def listar() -> None:
print(f"\nHay {len(contactos)} contactos:")
for contacto in contactos:
print(f" {contacto['nombre']:<12} {contacto['ciudad']:<10} {contacto['telefono']}")
def anadir() -> None:
nombre = input("\nNombre: ").strip()
if not nombre:
print("Sin nombre no hay contacto.")
return
ciudad = input("Ciudad: ").strip()
telefono = input("Teléfono: ").strip()
contactos.append({"nombre": nombre, "ciudad": ciudad, "telefono": telefono})
print(f"Añadido {nombre}.")
def informe_total() -> None:
print(f"\nTotal de contactos: {len(contactos)}")
def informe_por_ciudad() -> None:
print()
cuenta = Counter(contacto["ciudad"] for contacto in contactos)
for ciudad, veces in cuenta.most_common():
print(f" {ciudad:<10} {veces}")
MENU = {
"1": ("Listar contactos", listar),
"2": ("Añadir contacto", anadir),
"3": (
"INFORMES",
{
"1": ("Número de contactos", informe_total),
"2": ("Contactos por ciudad", informe_por_ciudad),
"0": ("Volver", None),
},
),
"0": ("Salir", None),
}
def main() -> None:
bucle_menu("AGENDA", MENU)
print("Hasta luego.")
if __name__ == "__main__":
main()Un detalle de orden: MENU está definido después de las funciones a las que apunta y antes de main. Tiene que ser así, porque cuando Python ejecuta esa línea necesita que los nombres listar y anadir ya existan. Dentro de una función no pasaría, porque el cuerpo no se ejecuta hasta que se la llama, pero MENU se construye al importar el fichero.
Una sesión completa#
python3 agenda.py=== AGENDA ===
1. Listar contactos
2. Añadir contacto
3. INFORMES >
0. Salir
Elige una opción: 1
Hay 3 contactos:
Ana Ruiz Madrid 600111222
Luis Soto Valencia 600333444
Marta Gil Madrid 600555666
Pulsa Intro para continuar...
=== AGENDA ===
1. Listar contactos
2. Añadir contacto
3. INFORMES >
0. Salir
Elige una opción: 2
Nombre: Nuria Vega
Ciudad: Bilbao
Teléfono: 600777888
Añadido Nuria Vega.
Pulsa Intro para continuar...
=== AGENDA ===
1. Listar contactos
2. Añadir contacto
3. INFORMES >
0. Salir
Elige una opción: 3
=== INFORMES ===
1. Número de contactos
2. Contactos por ciudad
0. Volver
Elige una opción: 2
Madrid 2
Valencia 1
Bilbao 1
Pulsa Intro para continuar...
=== INFORMES ===
1. Número de contactos
2. Contactos por ciudad
0. Volver
Elige una opción: 0
=== AGENDA ===
1. Listar contactos
2. Añadir contacto
3. INFORMES >
0. Salir
Elige una opción: 9
'9' no está en el menú.
Pulsa Intro para continuar...
=== AGENDA ===
1. Listar contactos
2. Añadir contacto
3. INFORMES >
0. Salir
Elige una opción: 0
Hasta luego.Esta transcripción está tomada con LIMPIAR_PANTALLA en falso, para que se vea la sesión entera de corrido. Con el valor verdadero, cada bloque que empieza por === AGENDA === sustituye al anterior en la misma pantalla y nunca hay que hacer scroll.
Fíjate en el paso por el submenú: entras con el 3, la pantalla cambia a === INFORMES ===, pides el informe por ciudad, y el 0 te devuelve al menú principal sin salir del programa. El segundo 0, ya en el menú principal, sí termina.
Cuando las acciones necesitan datos#
El bucle_menu de la lección llama a las funciones sin argumentos, y las funciones trabajan sobre la lista contactos, que es una variable global. Para un programa de cien líneas es aceptable, pero ya sabes de la lección de ámbito que las globales se vuelven un problema en cuanto el programa crece.
La solución limpia es que cada función reciba los datos con los que trabaja. Y como la tabla solo guarda funciones sin argumentos, se usa una lambda para atar el argumento en el momento de construir la tabla:
def construir_menu(agenda: list[dict[str, str]]) -> dict:
return {
"1": ("Listar contactos", lambda: listar(agenda)),
"2": ("Añadir contacto", lambda: anadir(agenda)),
"0": ("Salir", None),
}Cada lambda es una función sin parámetros que, al llamarla, llama a la de verdad con la agenda que le corresponde. Así listar y anadir reciben su lista por parámetro, no la buscan en el espacio global, y se podrían reutilizar con dos agendas distintas en el mismo programa.