Search code examples
pythonpython-3.xtkinterpython-multithreading

How to switch between Text widget contents with multiple buttons?


I want to make app to read log files from FTP server - when user clicks button, it connects to FTP, changes directory and reads data every 1sec. If data changed (new logs appeared), then it adds it to Text widget. It works cool until user pressed same button more than once - then here's error 'RuntimeError: threads can only be started once'. So in different version of this code below, I added additional button "Show Logs" for each USB button. Also, each USB button had it's own Text widget. So when user pressed USB button first, then he's able to switch between Text widgets by "Show Logs" buttons - but it got messy, mostly because I had issue with telling my func readdata() which Text widget forget (hide) and which render (.pack again) - for example I wanted to pack_forget Text widget1 and .pack Text widget2 afterwards, but I don't know how to make app remember active contexts. Also scrolling was not working in "switched-to" Text widgets (worked only in "active" Texts), it sometimes couldn't read data file and many other issues. Is here more elegant way to do so? I mean, just clicking buttons "USB0" and "USB1" to switch between logs, without using additional "Show Logs" buttons for each USB button?

import tkinter as tk
import pysftp
import time
import threading

root = tk.Tk()
root.geometry("1200x700")
frame = tk.Frame(root)
frame.place(x=15, y=10)
scroll = tk.Scrollbar(frame)
t = tk.Text(frame, width=183, height=45, yscrollcommand=scroll.set)
scroll.config(command=t.yview)
scroll.pack(side='right', fill='y')

def clears():
    t.delete(1.0, tk.END)

def readdata(text,dir,logfile):
    clears()
    t.insert(tk.END, text+"LOADING LOGS\n\n")
    cnopts = pysftp.CnOpts()
    cnopts.hostkeys = None
    sftp = pysftp.Connection('ip', username='user', password='pass', cnopts=cnopts)
    sftp.chdir('uart-logs')
    sftp.chdir(dir) 
    active_log = logfile 
    with sftp.open(active_log, mode="r") as file:
        old_text = file.read().decode('ASCII')
        t.insert(tk.END, old_text)
        t.see('end')
    while True:
        with sftp.open(active_log, mode="r") as file:
            new_text = file.read().decode('ASCII')
        if new_text != old_text:
            t.insert(tk.END, new_text[len(old_text):])
            root.update()
            t.see('end')
            old_text = new_text
        time.sleep(1)

buttons = tk.Frame()
b0 = tk.Button(buttons, text = "ttyUSB0", command =threading.Thread(target=lambda:readdata('USB0: ','USB0',"USB0-active.log")).start)
b1 = tk.Button(buttons, text = "ttyUSB1", command =threading.Thread(target=lambda:readdata('USB1: ','USB1',"USB1-active.log")).start)
clear = tk.Button(buttons, text="Clear", command=lambda:clears())
chkbox = tk.Checkbutton(buttons, text="Auto-scroll")

buttons.pack()
b0.pack(in_=buttons, side=tk.LEFT, padx=10)
b1.pack(in_=buttons, side=tk.LEFT, padx=10)
clear.pack(in_=buttons, side=tk.LEFT, padx=10)
chkbox.pack(in_=buttons, side=tk.RIGHT, padx=40)

frame.pack(fill='both',expand=True)
t.pack(fill='both',expand=True)

tk.mainloop()

Solution

  • It works cool until user pressed same button more than once - then here's error 'RuntimeError: threads can only be started once'.

    That's fixed simply by changing

    command=threading.Thread(target=lambda:readdata('USB0: ','USB0',"USB0-active.log")).start
    

    (which is binding a single thread's start method to the button) to

    command=lambda: threading.Thread(target=lambda:readdata('USB0: ','USB0',"USB0-active.log")).start()
    

    which will create a new thread and start it every time you click it, but you'll also need to be able to stop the previous thread from trying to update the field at the same time.

    You can do that with something like

    current_read_thread = None
    current_stop_event = None
    
    
    def readdata(text, dir, logfile, stop_event):
        ...
        while not stop_event.is_set():  # (not `while True`)
            ...
    
    
    def start_thread(text, dir, logfile):
        global current_read_thread
        global current_stop_event
        if current_read_thread:
            current_stop_event.set()
            # Wait for the other thread to stop
            current_read_thread.join()
            current_read_thread = None
        current_stop_event = threading.Event()
        current_read_thread = threading.Thread(target=readdata, args=(text, dir, logfile, current_stop_event))
        current_read_thread.start()
    
    
    # ...
    
    b0 = tk.Button(buttons, text="ttyUSB0", command=lambda: start_thread("USB0: ", "USB0", "USB0-active.log"))