Módulo 3 · Lección 37 de 50
Diálogos de ficheros y de color
El askcolor que devuelve un par y hace cierto el if al cancelar, los dos valores distintos que significan «he cancelado», el fichero que se queda abierto, y la opción que impide sobrescribir sin querer.
Los diálogos de fichero son los que más se usan y los que más código de alrededor necesitan, porque lo que devuelven no es lo que uno espera. La lección anterior tenía una función que devolvía la cadena 'no' donde todos ven un booleano; esta tiene una que devuelve dos cosas a la vez y otra en la que «he cancelado» se escribe de tres maneras distintas según a cuál llames.
Las siete funciones de fichero#
>>> sorted(n for n in dir(filedialog) if n.startswith("ask"))
['askdirectory', 'askopenfile', 'askopenfilename', 'askopenfilenames',
'askopenfiles', 'asksaveasfile', 'asksaveasfilename']Se dividen en dos familias, y la diferencia es la que da nombre a la mitad de ellas:
- Las que acaban en
namedevuelven una ruta de texto. Son las que hay que usar. - Las que no devuelven un fichero ya abierto, y tienen un problema que está en su sección.
from tkinter import filedialog
ruta = filedialog.askopenfilename() # una ruta, o ''
rutas = filedialog.askopenfilenames() # una tupla de rutas, o ()
destino = filedialog.asksaveasfilename() # una ruta, o ''
carpeta = filedialog.askdirectory() # una ruta, o ''Las opciones#
Tk las enumera, y cada diálogo acepta las suyas:
>>> # tk_getOpenFile
-command, -defaultextension, -filetypes, -initialdir, -initialfile,
-message, -multiple, -parent, -title, -typevariable
>>> # tk_getSaveFile: las mismas más
-confirmoverwrite
>>> # tk_chooseDirectory
-command, -initialdir, -message, -mustexist, -parent, -title
>>> # tk_chooseColor
-initialcolor, -parent, -titleLas que se usan de verdad son cuatro.
filetypes es una lista de pares, descripción y patrón:
TIPOS = [("Texto", "*.txt"),
("Markdown", "*.md *.markdown"), # varios, separados por espacios
("Todos los ficheros", "*.*")]
ruta = filedialog.askopenfilename(filetypes=TIPOS)Si te equivocas en el formato, el error explica la sintaxis:
>>> filedialog.askopenfilename(filetypes="texto")
_tkinter.TclError: bad file type "texto", should be
"typeName {extension ?extensions ...?} ?{macType ?macTypes ...?}?"El orden importa: el primer par es el filtro activo al abrirse el diálogo, así que «Todos los ficheros» va al final. Y conviene que esté, porque un filtro sin escapatoria es una forma habitual de que alguien no encuentre su fichero.
initialdir e initialfile dicen dónde empieza el diálogo. Y aquí hay un detalle bueno de Tkinter: las clases recuerdan la última carpeta por su cuenta. Se ve en el código de SaveAs:
if result:
path, file = os.path.split(result)
self.options["initialdir"] = path
self.options["initialfile"] = fileEso solo funciona si reutilizas el mismo objeto filedialog.SaveAs(...), no si llamas a la función suelta cada vez. Con las funciones, ese trabajo lo haces tú, y hay que hacerlo: un diálogo que siempre empieza en la carpeta de inicio obliga a navegar desde cero cada vez.
El fichero que se queda abierto#
El selector de color#
Es un módulo aparte y una sola función:
>>> from tkinter import colorchooser
>>> colorchooser.askcolor(color="#1565C0", title="Color del fondo")
((21, 101, 192), '#1565c0')Para qué sirve la tupla de componentes: lo más habitual es decidir si el texto encima va en blanco o en negro, que es un problema real en cuanto dejas elegir un color de fondo.
def texto_sobre(rgb):
"""Negro sobre claro y blanco sobre oscuro, con la fórmula de luminancia."""
rojo, verde, azul = rgb
luminancia = (0.299 * rojo + 0.587 * verde + 0.114 * azul) / 255
return "black" if luminancia > 0.55 else "white"Y si solo tienes la cadena, se convierte con lo que ya trae Tk:
>>> ventana.winfo_rgb("#1565C0")
(5397, 25957, 49344)Ojo: winfo_rgb devuelve componentes de 16 bits, no de 8. Hay que dividir entre 256, que es exactamente lo que hace askcolor por dentro.
Los diálogos de texto#
Hay un cuarto módulo con tres funciones que piden un valor:
from tkinter import simpledialog
nombre = simpledialog.askstring("Nueva nota", "Título:", parent=ventana)
cuantos = simpledialog.askinteger("Copias", "¿Cuántas?", minvalue=1, maxvalue=99)
factor = simpledialog.askfloat("Escala", "Factor:", initialvalue=1.0)Devuelven None al cancelar, y askinteger y askfloat validan el rango con minvalue y maxvalue, volviendo a preguntar si el valor no cabe. Son útiles y tienen un problema: no son nativas, están dibujadas con Tkinter, así que se ven distintas del resto de los diálogos del sistema. Para pedir un dato suelto valen; para algo que se usa a menudo, es mejor un campo en la ventana o un Toplevel propio, que es la lección siguiente.
El ejemplo#
Un visor de texto que abre, guarda, guarda como y deja elegir los colores, con todas las comprobaciones en su sitio.
"""Diálogos de fichero y de color, con el valor de cancelar bien tratado."""
import pathlib
import tkinter as tk
from tkinter import colorchooser, filedialog, messagebox, ttk
MARGEN = 12
AIRE = 4
GRIS = "#8E8E93"
# El primer par es el filtro activo al abrirse, y «todos» va al final para
# que nadie se quede sin poder elegir su fichero.
TIPOS = [("Texto", "*.txt"),
("Markdown", "*.md *.markdown"),
("Todos los ficheros", "*.*")]
EXTENSION = ".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 informar_error(titulo, mensaje, detalle=""):
"""Encapsulado: el parent en un solo sitio, y el programa se puede probar."""
messagebox.showerror(title=titulo, message=mensaje, detail=detalle,
parent=ventana)
def preguntar_guardar():
"""Tres respuestas: True, False y None. El is None va primero."""
return messagebox.askyesnocancel(
title="Cambios sin guardar",
message="¿Guardar los cambios antes de continuar?",
detail="Si no los guardas se perderán.", parent=ventana)
def pedir_apertura():
"""askopenfilename, no askopenfile: la que abre el fichero lo deja abierto.
initialdir recuerda la última carpeta: sin eso el diálogo empieza cada
vez en la carpeta de inicio y hay que navegar desde cero.
"""
return filedialog.askopenfilename(title="Abrir", filetypes=TIPOS,
initialdir=ultima_carpeta.get(),
parent=ventana)
def pedir_destino():
"""confirmoverwrite viene activado: el sistema pregunta por ti.
Por eso aquí no hay ninguna confirmación escrita a mano.
"""
return filedialog.asksaveasfilename(
title="Guardar como", filetypes=TIPOS, defaultextension=EXTENSION,
initialdir=ultima_carpeta.get(), initialfile=nombre_sugerido(),
confirmoverwrite=True, parent=ventana)
def pedir_color(inicial):
"""El [1] primero y el is None después: las dos trampas de una vez."""
_, color = colorchooser.askcolor(color=inicial, title="Elegir color",
parent=ventana)
return color
def nombre_sugerido():
return pathlib.Path(ruta_actual.get()).name if ruta_actual.get() else "nota"
def asegurar_extension(destino):
"""En código, porque defaultextension se comporta distinto en cada sistema."""
if destino and "." not in pathlib.Path(destino).name:
return destino + EXTENSION
return destino
def texto_sobre(rgb):
"""Blanco o negro según la luminancia: para eso sirve la tupla del par."""
rojo, verde, azul = rgb
luminancia = (0.299 * rojo + 0.587 * verde + 0.114 * azul) / 255
return "black" if luminancia > 0.55 else "white"
def componentes(color):
"""winfo_rgb da 16 bits, así que hay que dividir entre 256."""
return tuple(x // 256 for x in ventana.winfo_rgb(color))
def hay_cambios():
return bool(texto.edit_modified())
def continuar_perdiendo_cambios():
"""El guardián que va antes de abrir, de cerrar o de empezar de nuevo."""
if not hay_cambios():
return True
respuesta = preguntar_guardar()
if respuesta is None: # Cancelar, antes que nada
return False
if respuesta:
return guardar() # si guardar falla, no se continúa
return True
def abrir():
if not continuar_perdiendo_cambios():
return
ruta = pedir_apertura()
if not ruta: # cadena vacía: se ha cancelado
return
try:
contenido = pathlib.Path(ruta).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as error:
# Entre elegir el fichero y leerlo pueden pasar cosas.
informar_error("Abrir", "No se ha podido leer el fichero.",
f"{pathlib.Path(ruta).name}: {error}")
return
texto.delete("1.0", "end")
texto.insert("1.0", contenido)
texto.edit_reset() # el deshacer empieza de cero
texto.edit_modified(False)
ruta_actual.set(ruta)
ultima_carpeta.set(str(pathlib.Path(ruta).parent))
marcar_estado(f"Abierto: {pathlib.Path(ruta).name}")
def guardar():
if not ruta_actual.get():
return guardar_como()
return escribir(ruta_actual.get())
def guardar_como():
destino = asegurar_extension(pedir_destino())
if not destino:
return False
return escribir(destino)
def escribir(destino):
try:
pathlib.Path(destino).write_text(texto.get("1.0", "end-1c"),
encoding="utf-8")
except OSError as error:
informar_error("Guardar", "No se ha podido guardar el fichero.",
f"{pathlib.Path(destino).name}: {error}")
return False
texto.edit_modified(False)
ruta_actual.set(destino)
ultima_carpeta.set(str(pathlib.Path(destino).parent))
marcar_estado(f"Guardado: {pathlib.Path(destino).name}")
return True
def elegir_carpeta():
"""askdirectory: mustexist viene activado y conviene dejarlo."""
carpeta = filedialog.askdirectory(title="Carpeta de trabajo",
initialdir=ultima_carpeta.get(),
mustexist=True, parent=ventana)
if not carpeta:
return
ultima_carpeta.set(carpeta)
cuantos = len(list(pathlib.Path(carpeta).glob("*.txt")))
marcar_estado(f"{pathlib.Path(carpeta).name}: {cuantos} ficheros .txt")
def cambiar_fondo():
color = pedir_color(fondo.get())
if color is None: # y no `if not color`, que sería cierto
return
fondo.set(color)
aplicar_colores()
def cambiar_letra():
color = pedir_color(letra.get())
if color is None:
return
letra.set(color)
aplicar_colores()
def contraste_automatico():
"""La tupla del par sirve para esto: decidir el color de encima."""
letra.set(texto_sobre(componentes(fondo.get())))
aplicar_colores()
def aplicar_colores():
texto.configure(background=fondo.get(), foreground=letra.get(),
insertbackground=letra.get())
muestra.configure(background=fondo.get(), foreground=letra.get(),
text=f" {fondo.get()} sobre {letra.get()} ")
def marcar_estado(mensaje=None):
if mensaje is not None:
estado.set(mensaje)
nombre = pathlib.Path(ruta_actual.get()).name if ruta_actual.get() \
else "sin título"
ventana.title(f"Visor — {nombre}{' •' if hay_cambios() else ''}")
def al_modificar(evento=None):
marcar_estado()
def al_cerrar():
if continuar_perdiendo_cambios():
ventana.destroy()
ventana = tk.Tk()
ventana.minsize(620, 460)
# Variables sueltas y global: en el bloque E esto pasa a ser una clase.
ruta_actual = tk.StringVar(value="")
ultima_carpeta = tk.StringVar(value=str(pathlib.Path.home()))
estado = tk.StringVar(value="Sin título")
fondo = tk.StringVar(value="#FFFFFF")
letra = tk.StringVar(value="#000000")
cuerpo = ttk.Frame(ventana, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)
barra = ttk.Frame(cuerpo)
barra.pack(fill="x")
for etiqueta, orden in (("Abrir…", abrir), ("Guardar", guardar),
("Guardar como…", guardar_como),
("Carpeta…", elegir_carpeta)):
ttk.Button(barra, text=etiqueta, command=orden,
style="Toolbutton").pack(side="left", padx=(0, AIRE))
ttk.Separator(cuerpo, orient="horizontal").pack(fill="x", pady=MARGEN)
caja = ttk.Frame(cuerpo)
caja.pack(fill="both", expand=True)
texto = tk.Text(caja, wrap="word", undo=True, width=44, height=14,
font=("Menlo", 12), borderwidth=1, relief="solid",
highlightthickness=0)
vertical = ttk.Scrollbar(caja, orient="vertical", command=texto.yview)
texto.configure(yscrollcommand=vertical.set) # las dos mitades
vertical.pack(side="right", fill="y")
texto.pack(side="left", fill="both", expand=True)
colores = ttk.Frame(cuerpo)
colores.pack(fill="x", pady=(MARGEN, 0))
ttk.Button(colores, text="Fondo…", command=cambiar_fondo).pack(side="left")
ttk.Button(colores, text="Letra…", command=cambiar_letra).pack(
side="left", padx=AIRE)
ttk.Button(colores, text="Contraste automático",
command=contraste_automatico).pack(side="left")
muestra = tk.Label(colores, borderwidth=1, relief="solid")
muestra.pack(side="right")
ttk.Label(cuerpo, textvariable=estado, foreground=GRIS).pack(
anchor="w", pady=(AIRE, 0))
texto.bind("<<Modified>>", al_modificar)
ventana.protocol("WM_DELETE_WINDOW", al_cerrar)
aplicar_colores()
marcar_estado()
centrar(ventana)
ventana.mainloop()