Es un error común cuando se empieza con las interfaces gráficas. Por norma general una interfaz gráfica de usuario tiene un ciclo principal que se encarga de redibujar la interfaz y cada widget cuando sea necesario, responder de forma adecuada a eventos (click en botón, redimensión de ventana, entrada de texto en input, etc), procesar sus callbacks asociadas, etc. Iniciar este ciclo infinito es lo que haces precisamente con app.mainloop()
. Esto no es algo único de Tkinter, cualquier otro framework como Qt, Gtk, Kivy, wxWidgets, etc funcionan exactamente igual en este aspecto.
Este ciclo jamás debe bloquearse durante un tiempo relativamente largo. Si llamas a una función desde el hilo principal de tu aplicación debes tener en cuenta que esto bloquea el ciclo hasta que la función retorne. Si la función retorna "inmediatamente" no hay problema, si la función tarda en retornar la GUI se congela, queda bloqueada y deja de responder a cualquier interacción con ella.
En tu caso el método updateStatus
simplemente cambia dos veces el texto del label, no hay problema alguno con esto. El problema está en el sleep
. time.sleep()
es bloqueante lo que causa que tu función tarde unos 4 segundos en retornar y durante ese tiempo el mainloop no ha podido actualizar la interfaz (lo que incluye redibujar el label), quedando esta congelada (observa como no puedes ni pulsar el botón ni redimencionarla durante estos 4 segundos).
Ante esto es bueno tener en cuenta algunos conceptos:
Por norma general nunca usar time.sleep
en el hilo principal de una GUI. Si necesitas esperar un tiempo antes de hacer algo todos los frameworks proveen una alternativa no bloqueante. En Tkinter tenemos after
. Tu código funciona sin congelaciones si haces:
def updateStatus(self):
self.text.set("Text 1...")
self.after(4000, lambda t = "Text 2...": self.text.set(t))
En este caso se usa el método after
para hacer que espere 4 segundos (4000 ms) y que luego llame al método set
de la variable. Para poder pasar un argumento a una callback se usa lambda
o functools.partial
.
Esto no nos sirve si en vez de a StringVar.set
llamáramos a un método que tardara en retornar. En este caso hay que recurrir a concurrencia con hilos, procesos, corrutinas...
Los métodos update
/update_idletask
permiten efectivamente actualizar la interfaz entre llamadas a sleep
, pero la interfaz sigue bloqueándose entre cada llamada a update
/update_idletask
. La primera procesa cualquier evento, callback, redibuajado de widget o actualización del geometry manager pendiente, etc. update_idletask
se limita a completar las tareas pendientes pero no procesa ningún evento o callback.
Advertencia: tener cuidado al llamar a update
. Puede provocar comportamientos inesperados dependiendo de donde se llame por la creación de condiciones de carrera. En mi opinión prácticamente nunca debería recurrirse a ella, por este y otros motivos.
Veamos un ejemplo con tu mismo código muy simple usando un hilo para procesar tareas bloqueantes, en este caso es un mero contador de 0 a 10 con un delay de 2 segundos entre iteración:
import tkinter as tk
from tkinter import ttk
import time
import threading
import queue
class Application(tk.Frame):
def __init__(self, master=None):
super(Application, self).__init__(master)
master.title("Window Title")
master.columnconfigure(1, weight=1)
master.columnconfigure(1, weight=1)
master.rowconfigure(1, weight=1)
master.rowconfigure(1, weight=1)
self.queue = queue.Queue()
self.create_widgets()
@staticmethod
def contador(cola):
cola.put("Contando hasta 10...")
time.sleep(2)
for n in range(10):
cola.put(str(n))
time.sleep(2)
time.sleep(2)
cola.put("He terminado!")
def update_status(self):
threading.Thread(target=self.contador, args=(self.queue,)).start()
self.after(100, self.process_queue)
def process_queue(self):
try:
data = self.queue.get_nowait()
self.text.set(data)
except queue.Empty:
pass
self.master.after(100, self.process_queue)
def create_widgets(self):
# left pane
self.left_pane = tk.PanedWindow(root, orient=tk.VERTICAL)
self.left_pane.grid(column=0, row=0, rowspan=2, sticky=(tk.N, tk.W, tk.E, tk.S))
self.left_pane.rowconfigure(0, weight=1)
self.left_pane.columnconfigure(0, weight=1)
self.left_upperframe = ttk.Frame(root, relief='groove', borderwidth=2)
self.left_upperframe.grid(column=0, row=0, sticky=(tk.N, tk.W, tk.E, tk.S))
self.left_upperframe.columnconfigure(0, weight=1)
self.left_upperframe.rowconfigure(0, weight=1)
self.left_pane.add(self.left_upperframe, heigh=430, width=300)
self.left_bottomframe = ttk.Frame(self.left_pane, relief='groove', borderwidth=2)
self.left_bottomframe.grid(column=0, row=1, sticky=(tk.N, tk.W, tk.E, tk.S))
self.left_bottomframe.columnconfigure(0, weight=1)
self.left_bottomframe.rowconfigure(0, weight=1)
self.left_pane.add(self.left_bottomframe, heigh=40)
# right pane
self.right_pane = tk.PanedWindow(root, orient=tk.VERTICAL)
self.right_pane.grid(column=1, row=0, rowspan=2, sticky=(tk.N, tk.W, tk.E, tk.S))
self.right_pane.rowconfigure(0, weight=1)
self.right_pane.columnconfigure(0, weight=1)
self.right_upperframe = ttk.Frame(root, relief='groove', borderwidth=2)
self.right_upperframe.grid(column=1, row=0, sticky=(tk.N, tk.W, tk.E, tk.S))
self.right_upperframe.columnconfigure(0, weight=1)
self.right_upperframe.rowconfigure(0, weight=1)
self.right_pane.add(self.right_upperframe, width=600, heigh=430)
self.right_bottomframe = ttk.Frame(self.right_pane, relief='groove', borderwidth=2)
self.right_bottomframe.grid(column=1, row=1, sticky=(tk.N, tk.W, tk.E, tk.S))
self.right_bottomframe.columnconfigure(0, weight=1)
self.right_bottomframe.rowconfigure(0, weight=1)
self.right_pane.add(self.right_bottomframe)
# button
self.discover = tk.Button(self.left_bottomframe)
self.discover["text"] = "Button"
self.discover["command"] = self.update_status
self.discover.grid(column=0, row=0, sticky=(tk.W, tk.S, tk.E, tk.N))
# label
self.labl=tk.Label(self.left_upperframe)
self.text = tk.StringVar()
self.text.set("Inicial text \n")
self.labl.config(textvar=self.text)
self.labl.grid(column=0, row=0, sticky=tk.W, pady=4, padx=5)
if __name__ == "__main__":
root = tk.Tk()
app = Application(master=root)
app.mainloop()
Se usa una cola (thread safe) para pasar los datos del hilo hijo al hilo principal. Por norma general no es correcto en una gran mayoría de frameworks actualizar un widget desde otro hilo directamente.
Se puede observar como la interfaz no se congela en ningún momento, de hecho podemos pulsar el botón o redimensionar la ventana cuando queramos mientra el contador está en marcha. Lo dejo así adrede pero lo lógico sería deshabilitar el botón hasta que termine el hilo para evitar el lanzamiento de otros hilos mientras tanto, cosa que podemos hacer y el label irá mostrando las salidas de todos ellos según vallan llegando a la cola.
Una cuantas observaciones sobre otros cambios realizados en el código:
El inicializador (__init__
) suele ser el primer método que se declara en la clase. Tal como lo haces en tu caso no es un error en si, pero es una buena práctica en cuanto a convenciones colocarlo al inicio.
Generalmente en Python las clases se nombran usando mayúscula y CamelCase. Los métodos con minúscula y usando _ para separar palabras. Igualmente son solo convenciones, pero ayudan a la legibilidad y estandarización de tu código. Puedes mirarte las guías de estílo en PEP-8.
Por norma general no es recomendable y se suele considerar mala práctica usar from módulo import *
a la hora de importar. Entre las razones están que puebla el espacio de nombres actual sin necesidad, dificulta la legibilidad del código y que puede provocar solapamiento de nombres que pueden causar errores inesperados. Imagina que tienes una variable W
en tu script, con esto ocultas a la constante tkinter.W
y cuando haces ticky=(W, S, E, N)
te encuentras con sorpresas. A veces son errores difíciles de encontrar o incluso peor, pasan desapercibidos provocando resultados erróneos.
No es necesario que uses self.labl.config(textvar=self.text)
cada vez que usas set. Solo tienes que asignar la variable al label una vez, lo cual puedes hacer directamente en el constructor:
self.text = StringVar()
self.labl=Label(self.left_upperframe, textvar=self.text)
El nombre self
es solo una convención para cuando es necesario hacer referencia a la propia instancia de una clase. Los métodos de instancia necesitan recibir como primer argumento una referencia a la instancia a la que pertenecen. Algunas preguntas relacionadas que te pueden ayudar a entenderlo: