0

En primer lugar, hola a todos y gracias por vuestro tiempo. Estoy tratando de realizar una pequeña GUI, con una serie de botones para invocar a distintos módulos.

He hecho una versión resumida, en la que se muestra una pequeña interfaz con un botón, dicho botón debe llamar a una función que realiza una secuencia (en el programa real va realizando llamadas a módulos) pero en este caso simplemente actualiza el valor de un label.

Por mucho que lo intento no consigo ver la secuencia de cambio de la etiqueta (primero pone un valor, después espera y finalmente pone otra valor) sino que simplemente se visualiza el valor final de la etiqueta (tras la espera).

Imagino que tengo algún problema en cómo he creado la GUI, pero el caso es que no tengo mucho conocimiento en la programación orientada a objetos y me pierdo...por ejemplo ¿Por qué se usa tanto el self? (en teoría es para hablar de una propiedad de si mismo..pero me lío un poco) Gracias a todos por la ayuda.

from tkinter import *
from tkinter import ttk
import time

class Application(Frame):

    def UpdateStatus(self):
        self.text.set("Text 1...")
        self.labl.config(textvar=self.text)
        time.sleep(4)
        self.text.set("Text 2...")
        self.labl.config(textvar=self.text)



    def createWidgets(self):
        # left pane
        self.left_pane = PanedWindow(root, orient=VERTICAL)
        self.left_pane.grid(column=0, row=0, rowspan=2, sticky=(N, W, E, 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=(N, W, E, 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=(N, W, E, 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 = PanedWindow(root, orient=VERTICAL)
        self.right_pane.grid(column=1, row=0, rowspan=2, sticky=(N, W, E, 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=(N, W, E, 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=(N, W, E, 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 = Button(self.left_bottomframe)
        self.discover["text"] = "Button"
        self.discover["command"] = self.UpdateStatus
        self.discover.grid(column=0, row=0, sticky=(W, S, E, N))


        # label
        self.labl=Label(self.left_upperframe)
        self.text = StringVar()
        self.text.set("Inicial text \n")
        self.labl.config(textvar=self.text)
        self.labl.grid(column=0, row=0, sticky=W, pady=4, padx=5)

    def __init__(self, master=None):
        Frame.__init__(self, 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.createWidgets()

root = Tk()
app = Application(master=root)
app.mainloop()
  • Es un error común cuando se empieza con GUIs. Bloqueo del mainloop. `sleep` es bloqueante por lo que el mainloop encargado de dibujar la interfaz y de responder a eventos de la aplicación se queda bloqueado, tu GUI al completo se congela y deja de responder y no vuelve a hacerlo hasta que la llamada a la función bloqueante termina. Esto causa que no veas el cambio en el label, porque la GUI no se actualiza mientras la función. Las llamadas bloqueantes (cualquier función o método que tarde en retornar) deben hacerse en otro hilo/proceso para que el hilo principal nunca se bloquee. – FJSevilla Apr 01 '18 at 21:16
  • Muchas gracias @FJSevilla. No se me había ocurrido, he probado la solución de usar un while y update_idetasks() y update(), en lugar del mainloop y ahora todo funciona como quería. Muchas gracias por tu ayuda. Un saludo – Sergio Rueda Apr 01 '18 at 22:06
  • `update/update_ideltasks` funcionan porque lleva a cabo todas las tareas que el mainloop tenía pendientes, aún así la GUI se bloquea entre cada iteración del ciclo. En tu caso usar `after` sería más simple. Si tus métodos reales van a tardar en retornar usar un hilo es lo normal, en caso contrario vas a tener una interfaz que se va congelando a cada rato. Voy a crear una respuesta mostrando lo de `after` , un ejemplo muy simple con hilos y un par de observaciones más. – FJSevilla Apr 01 '18 at 22:19

1 Answers1

0

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:

FJSevilla
  • 55,603
  • 7
  • 35
  • 58
  • Sinceramente estoy impresionado con la respuesta Muchísimas gracias por tu ayuda, hay mucho por estudiar, pero el ejemplo y los enlaces son justo lo que necesitaba... Ahora a tratar de aprender cómo funciona (efectivamente se comporta de maravilla, ahora el reto es tratar de entenderlo). Sinceramente muchas gracias por la respuesta...como digo hay mucho que estudiar aquí. Un saludo – Sergio Rueda Apr 02 '18 at 23:24