Apariencia
🖼️ CustomTkinter — interfaces gráficas (GUI)
customtkinteres una librería que envuelve atkinter(la GUI que trae Python por defecto) pero con widgets modernos, con bordes redondeados y soporte de temas claro/oscuro. Se usa para crear ventanas de escritorio sin depender de HTML/CSS.
🟢 Instalación
bash
pip install customtkinter💡 Se instala una sola vez por entorno (o por
venvsi usas entornos virtuales). Ver comando en 01-Comandos.
⚠️ En macOS con Python de Homebrew, si al correr la app da
ModuleNotFoundError: No module named '_tkinter', falta el paquete de sistemapython-tk. Ver la solución completa en 06-Errores: tkinter no encontrado.
🅰️ Estructura mínima de una app
Toda app de customtkinter sigue siempre el mismo esqueleto: crear la ventana, agregar widgets dentro, y mantenerla abierta con un bucle.
┌─────────────────────────────┐
│ ctk.CTk() │ ← ventana principal (contenedor raíz)
│ ┌─────────────────────────┐│
│ │ CTkLabel "Bienvenido" ││ ← widgets van DENTRO de la ventana
│ ├─────────────────────────┤│
│ │ CTkButton "Presióname" ││
│ └─────────────────────────┘│
└─────────────────────────────┘
ventana.mainloop() ← mantiene todo esto visible y "escuchando" clicsFlujo de creación (siempre en este orden):
- Crear la ventana →
ctk.CTk() - Configurarla →
.title(),.geometry() - Crear widgets pasándoles la ventana como "dueño" →
ctk.CTkLabel(ventana, ...) - Ubicar cada widget en pantalla →
.pack(...)(u otro layout manager) - Arrancar el bucle de eventos →
ventana.mainloop()(va siempre al final, es lo último)
⚠️ Si no llamas a
.mainloop(), la ventana no se muestra o se cierra de inmediato — es obligatorio y siempre es la última línea.
🔍 Piezas clave
| Pieza | Qué es | Notas |
|---|---|---|
ctk.CTk() | Crea la ventana principal (raíz) | Solo puede haber una ventana raíz por app |
.title("texto") | Pone el título en la barra de la ventana | |
.geometry("400x300") | Define el tamaño inicial en píxeles: "anchoxalto" | Es un string, con la x en minúscula como separador |
ctk.CTkLabel(ventana, text="...") | Widget de texto (etiqueta) fijo | Equivale a un <p> o <label> en HTML |
ctk.CTkButton(ventana, text="...", command=funcion) | Botón clickeable | command recibe la función sin paréntesis — si pones funcion() la ejecuta al instante, no al hacer clic |
.pack(pady=20) | Ubica el widget en la ventana y agrega espacio | pady = padding vertical, padx = padding horizontal |
.configure(text="nuevo texto") | Cambia una propiedad de un widget ya creado | Así se actualiza un CTkLabel en vivo, sin recrearlo |
ventana.mainloop() | Bucle que mantiene la ventana abierta escuchando eventos (clics, teclas, etc.) | Bloquea el código hasta que se cierra la ventana |
📌
command=saludar(sin paréntesis) le pasa la referencia a la función. Con paréntesis (command=saludar()) Python ejecutasaludar()inmediatamente al construir el botón y le pasa el resultado (normalmenteNone) comocommand— es un error común.
🧪 Tip de entrevista: ¿Por qué
command=funciony nocommand=funcion()? Porquecommandespera un callback (algo que se ejecuta después, al hacer clic), y en Pythonfuncionsin paréntesis es la función misma, mientras quefuncion()ya es el resultado de haberla ejecutado.
🔁 Patrón: actualizar un widget desde una función
Para que un botón cambie lo que se ve en pantalla, la función debe tener acceso a la variable del widget (por eso titulo se define antes que la función lo use, y Python la busca en el momento del clic, no al definir la función):
python
import customtkinter as ctk
def saludar():
titulo.configure(text="Hola, presionaste el botón") # actualiza el CTkLabel existente
ventana = ctk.CTk()
ventana.title("Mi primera app")
ventana.geometry("400x300")
titulo = ctk.CTkLabel(ventana, text="Bienvenido")
titulo.pack(pady=20)
boton = ctk.CTkButton(ventana, text="Presióname", command=saludar)
boton.pack(pady=10)
ventana.mainloop()💡 Aunque
saludar()está definida antes quetituloexista en el código, funciona porque la función no se ejecuta al definirla, sino al hacer clic — para ese momentotituloya existe. Esto es un ejemplo de closure: la función "recuerda" el entorno donde fue creada.
🧱 Layout managers: pack vs grid vs place
Un layout manager es el método que decide dónde se dibuja cada widget dentro de su contenedor. .pack() es el más simple (apila widgets uno tras otro), pero para diseños con varias columnas/filas (formularios, calculadoras, etc.) se usa .grid().
| Layout manager | Cómo ubica los widgets | Cuándo usarlo |
|---|---|---|
.pack() | Apila en un solo eje (arriba→abajo o lado a lado) | Diseños simples, una sola columna |
.grid() | Ubica en una cuadrícula de filas y columnas (row, column) | Formularios, calculadoras, cualquier diseño en tabla |
.place() | Ubica por coordenadas absolutas (x, y) o relativas | Casos puntuales; poco usado porque no se adapta bien a cambios de tamaño |
⚠️ No mezcles
.pack()y.grid()dentro del mismo contenedor (mismo padre). Tkinter lanza error o se traba porque los dos manejan el espacio de formas incompatibles. Sí puedes usar.pack()en un widget y.grid()en otro si tienen distinto padre (ver Frames más abajo).
🔍 .grid() — filas y columnas
column=0 column=1 column=2
┌───────────┬───────────────┬───────────┐
row=0 │ Label │ Entry (columnspan=2) │
├───────────┼───────────────┼───────────┤
row=1 │ Label │ Entry │ Botón │
└───────────┴───────────────┴───────────┘python
etiqueta = ctk.CTkLabel(ventana, text="Nombre:")
etiqueta.grid(row=0, column=0, padx=10, pady=10, sticky="e")
entrada = ctk.CTkEntry(ventana)
entrada.grid(row=0, column=1, padx=10, pady=10, sticky="w")Parámetro de .grid() | Qué hace |
|---|---|
row, column | Posición en la cuadrícula (empiezan en 0) |
columnspan, rowspan | Cuántas columnas/filas ocupa el widget (para que abarque varias celdas) |
sticky | A qué borde de la celda se "pega" el widget: combinación de "n", "s", "e", "o" (norte/sur/este/oeste). sticky="ew" estira el widget para llenar el ancho de la celda |
padx, pady | Espacio exterior de la celda (igual que en .pack()) |
📌
stickyusa las iniciales en inglés de los puntos cardinales:n,s,e,w(norte, sur, este, oeste) — encustomtkinter/tkinteres"w"(west) y no"o", a diferencia de como se suele escribir "oeste" en español.
Para que las columnas/filas se repartan el espacio sobrante al redimensionar la ventana, hay que decirle al contenedor cómo pesa cada una:
python
ventana.grid_columnconfigure(1, weight=1) # la columna 1 se estira si la ventana crece
ventana.grid_rowconfigure(0, weight=1)🧪 Tip de entrevista: ¿Diferencia entre
packygrid?packapila widgets en un único eje según el orden en que se agregan (flujo tipo pila);gridlos posiciona en filas y columnas explícitas, como una tabla — da control más preciso para diseños en 2D, a costa de tener que declararrow/columnen cada widget.
🗂️ Frames (CTkFrame) — agrupar y organizar widgets
Un CTkFrame es un contenedor invisible (una "caja") que vive dentro de la ventana y que a su vez puede contener otros widgets. Sirve para:
- Agrupar visualmente una sección (p. ej. un formulario, una barra de botones).
- Aplicar un layout distinto en cada sección (un frame con
.grid()adentro de una ventana organizada con.pack(), por ejemplo) sin que entren en conflicto.
┌───────────────────────────────────────────┐
│ ventana (ctk.CTk) │
│ ┌───────────────────────────────────────┐ │
│ │ frame_formulario (CTkFrame) — .pack() │ │
│ │ ┌─────────┬─────────┐ │ │
│ │ │ Label │ Entry │ ← acá adentro │ │
│ │ ├─────────┼─────────┤ se usa │ │
│ │ │ Label │ Entry │ .grid() │ │
│ │ └─────────┴─────────┘ │ │
│ └───────────────────────────────────────┘ │
│ ┌───────────────────────────────────────┐ │
│ │ frame_botones (CTkFrame) — .pack() │ │
│ │ [ Guardar ] [ Cancelar ] │ │
│ └───────────────────────────────────────┘ │
└───────────────────────────────────────────┘python
import customtkinter as ctk
ventana = ctk.CTk()
ventana.title("Formulario")
ventana.geometry("400x300")
# Frame para el formulario, ubicado con pack en la ventana
frame_formulario = ctk.CTkFrame(ventana)
frame_formulario.pack(padx=20, pady=20, fill="x")
# Dentro del frame se usa grid, sin que choque con el pack de afuera
ctk.CTkLabel(frame_formulario, text="Nombre:").grid(row=0, column=0, padx=10, pady=10, sticky="e")
ctk.CTkEntry(frame_formulario).grid(row=0, column=1, padx=10, pady=10, sticky="w")
ctk.CTkLabel(frame_formulario, text="Correo:").grid(row=1, column=0, padx=10, pady=10, sticky="e")
ctk.CTkEntry(frame_formulario).grid(row=1, column=1, padx=10, pady=10, sticky="w")
# Otro frame independiente para los botones, con pack
frame_botones = ctk.CTkFrame(ventana)
frame_botones.pack(pady=10)
ctk.CTkButton(frame_botones, text="Guardar").pack(side="left", padx=5)
ctk.CTkButton(frame_botones, text="Cancelar").pack(side="left", padx=5)
ventana.mainloop()💡 La regla no es "toda la ventana con un solo layout": es "un solo layout por contenedor". Cada
CTkFramees su propio contenedor, así que puede tener.pack()por fuera y.grid()por dentro (o viceversa) sin problema.
📝 Si venías de tutoriales que meten todo directo en
ventanasin frames, funciona para apps chicas, pero en cuanto hay más de una sección (formulario + botones + lista, por ejemplo) conviene usar unCTkFramepor sección — evita pelear consticky/padxpara simular agrupaciones que un frame resuelve solo.
⌨️ CTkEntry — campo de texto que escribe el usuario
Un CTkEntry es una caja donde el usuario escribe. La app lee lo escrito con .get() y puede vaciarlo con .delete(). Es la base de cualquier formulario o campo de entrada.
python
entrada = ctk.CTkEntry(ventana, placeholder_text="Escribe una tarea...")
entrada.pack(side="left", fill="x", expand=True, padx=10)
texto = entrada.get() # LEE lo que escribió el usuario (devuelve "" si está vacío)
entrada.delete(0, "end") # VACÍA el campo (borra del carácter 0 hasta el final)| Pieza | Qué hace |
|---|---|
placeholder_text="..." | Texto gris de ayuda que se ve cuando el campo está vacío |
.get() | Devuelve el texto actual como str (vacío = "", que es falsy) |
.delete(0, "end") | Borra desde la posición 0 hasta el final → deja el campo vacío |
fill="x", expand=True | En .pack(), hace que el Entry ocupe todo el ancho disponible |
💡 Combínalo con truthy/falsy para validar:
if not entrada.get(): returncorta la función si el campo quedó vacío. → ver truthy/falsy en Conceptos.
📜 CTkScrollableFrame — un frame con barra de scroll automática
Es igual que un CTkFrame (un contenedor), pero le aparece sola una barra de scroll cuando el contenido no cabe. Perfecto para listas que crecen (tareas, contactos, mensajes…).
python
frame_lista = ctk.CTkScrollableFrame(ventana, label_text="Mis tareas")
frame_lista.pack(padx=20, pady=10, fill="both", expand=True) # ocupa el espacio de abajo
ctk.CTkLabel(frame_lista, text="Comprar pan").pack() # los hijos van adentro| Parámetro | Qué hace |
|---|---|
label_text="..." | Título que se muestra arriba del área con scroll |
fill="both", expand=True | En .pack(), hace que crezca en ancho y alto con la ventana |
📌 Se usa igual que un
CTkFrame: los widgets se crean pasándole el frame como padre (ctk.CTkLabel(frame_lista, ...)). La barra aparece sola; no hay que configurarla.
🔁 Patrón: "borrar todo y redibujar desde una lista"
El corazón de apps que muestran una colección (lista de tareas, contactos…). En vez de sincronizar la pantalla a mano cada vez que cambia un dato, se borra todo lo dibujado y se vuelve a dibujar desde la lista (la lista es la "verdad").
python
tareas = []
def refrescar_lista():
# 1) LIMPIAR: destruir todo lo que ya estaba dibujado en el frame
for widget in frame_lista.winfo_children():
widget.destroy()
# 2) REDIBUJAR: crear un widget por cada elemento de la lista
for tarea in tareas:
ctk.CTkLabel(frame_lista, text=tarea).pack()| Pieza | Qué hace |
|---|---|
frame.winfo_children() | Devuelve la lista de widgets que están dentro de ese contenedor |
widget.destroy() | Elimina un widget de la pantalla (lo quita de verdad, no lo oculta) |
tareas = ['Pan', 'Python'] cada cambio (agregar/borrar) llama a refrescar_lista()
│ │
▼ ▼
┌──────────────┐ refrescar_lista() ┌──────────────────────┐
│ la LISTA │ ───────────────────▶ │ 1. destruir hijos │
│ (la verdad) │ │ 2. for tarea: dibujar│
└──────────────┘ └──────────────────────┘⚠️ El paso 1 (destruir) es obligatorio: si solo redibujas sin limpiar, las tareas se duplican en pantalla en cada refresco (se apilan las viejas + las nuevas).
💡 Para que cada fila tenga su botón ✕ que borre su tarea, el
commanddel botón usa el trucolambda t=tarea: borrar(t)— sin eset=tareatodos borrarían la última. → explicado en Closures y late binding.
🧪 Tip de entrevista: ¿Por qué destruir y redibujar todo en vez de actualizar solo lo que cambió? Simplicidad: la pantalla siempre refleja el estado real de la lista sin lógica de sincronización. Para listas chicas es perfecto; con miles de elementos ya conviene actualizar solo lo que cambió (por rendimiento).
📎 Código completo de referencia
- Ventana básica (pack) → customtkinter-ventana-basica
- Formulario con Frames + grid → customtkinter-grid-frames
🔗 Relacionado
- Comando de instalación → 01-Comandos