Search code examples
pythontkinterttkcustomtkinter

How to update the text of a list element within a CTkListbox


I am writing mass spec data reduction software. I want to provide the user with a list of all samples in the current sequence, next to a check box to show whether the analysis data has been reduced (viewing the analysis reduces the data). The user should be able to click on a sample in the list to jump to it, or use buttons to go to the Next or Previous analysis.

The sample label should appear like this when the analysis is un-reduced:

☐ CB-042

and when viewed/reduced:

☑ CB-042

This worked great when I was using tkinter: I could just destroy the entire list and repopulate it every time I wanted to update an entry, and there was basically no overhead/delay.

I want my software to be more modern-looking, however, so I reached for CTkListbox instead. This looks way better, except that my previous approach of deleting and repopulating the entire list with every update resulted in the CTkListbox visually removing and repopulating the entire list every time. This takes about two seconds with a longer sequence, and is obviously undesirable behavior.

My next idea was to delete the individual entry from the listbox, and re-add it with a check mark. This somehow resulted in program continuously deleting the first unchecked item in the list until the list was emptied of unchecked items and the program froze. This also generates a ton of errors about references to list items that no longer exist, so I guess deleting and re-entering the list item won't work either.

Below is a not-so-minimal-but-still-reproducible example of where I'm currently at. The functions highlight_current_sample() and on_sample_select() are broken and need to be re-written, but I'm not sure how to do this correctly.

How can I accomplish my goal of inserting a checked box for reduced samples in the list? I'd switch to themed tk treeview if that is easier.

import tkinter as tk
import customtkinter as ctk
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.backends.backend_tkagg as tkagg
from tkinter import ttk
from CTkListbox import *


# highlights the current sample in the list
def highlight_current_sample(sample_listbox, filtered_data):
    global analysis_index
    # replace the sample with a checked box if it is reduced
    if filtered_data[analysis_index].reduced and sample_listbox.get(analysis_index)[0] == '☐':
        sample_listbox.delete(analysis_index)
        # print('deleting sample', analysis_index)
        sample_listbox.insert(analysis_index, f'☑ {filtered_data[analysis_index].analysis_label}')
    sample_listbox.activate(analysis_index)



# moves to the selected sample when clicked in the list
def on_sample_select(event, sample_listbox, filtered_data):
    global analysis_index
    widget = event.widget # get the listbox widget
    try:
        index = int(widget.curselection())
        analysis_index = index
        filtered_data[analysis_index].reduced = True
        update_buttons(filtered_data)
        highlight_current_sample(sample_listbox, filtered_data)

    except IndexError:
        pass # ignore clicks outside of items


def on_next(filtered_data, sample_listbox):
    global analysis_index
    filtered_data[analysis_index].reduced = True
    analysis_index = (analysis_index + 1) % len(filtered_data)
    update_buttons(filtered_data)
    highlight_current_sample(sample_listbox, filtered_data)


def on_previous(filtered_data, sample_listbox):
    global analysis_index
    analysis_index = (analysis_index - 1) % len(filtered_data)
    update_buttons(filtered_data)
    highlight_current_sample(sample_listbox, filtered_data)


def update_buttons(filtered_data):
    global analysis_index
    # disable previous button on first index
    if analysis_index == 0:
        prev_button.config(state=tk.DISABLED)
    else:
        prev_button.config(state=tk.NORMAL)
    
    # convert next to finish on last index
    if analysis_index == len(filtered_data) - 1:
        view_button.pack(side=tk.BOTTOM, fill=tk.X, expand=True)  # Show the finish button
        next_button.pack_forget()  # Hide the next button
    else:  
        next_button.pack(side=tk.BOTTOM, fill=tk.X, expand=True)  # Show the next button
        view_button.pack_forget()  # Hide the finish button


class Analysis:
    def __init__(self, analysis_label, reduced):
        self.analysis_label = analysis_label
        self.reduced = reduced

# Create a list of Analysis objects
filtered_data = [
    Analysis("Analysis 1", False),
    Analysis("Analysis 2", False),
    Analysis("Analysis 3", False),
    Analysis("Analysis 4", False),
    Analysis("Analysis 5", False),
    Analysis("Analysis 6", False),
    Analysis("Analysis 7", False),
    Analysis("Analysis 8", False),
    Analysis("Analysis 9", False),
    Analysis("Analysis 10", False),
    Analysis("Analysis 11", False),
    Analysis("Analysis 12", False)
]

# force matplotlib backend TkAgg (for MacOSX development)
matplotlib.use('TkAgg')

# set the appearance mode to light
ctk.set_appearance_mode('light')

# initiate GUI
window = tk.Tk()

# define and pack main frame
main_frame = ctk.CTkFrame(window)
main_frame.pack(fill=tk.BOTH, expand=True)

# left frame (to hold stats and buttons)
left_frame = ctk.CTkFrame(main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

# define and pack frame for buttons
button_frame = ctk.CTkFrame(left_frame)
button_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(0, 1), padx=(2, 0))

# define and pack center frame for raw data plots and sample list
center_frame = ctk.CTkFrame(main_frame)
center_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

# initialize the data frame, figure and canvas
data_frame = ctk.CTkFrame(center_frame)
figure     = plt.figure(figsize=(15,8))
canvas     = tkagg.FigureCanvasTkAgg(figure, master=data_frame)

# initialize the right frame, stats frame, and sample_listbox
# set light_color and dark_color as very light gray and very dark gray respectively
stats_frame    = ctk.CTkFrame(left_frame, border_color='gray95')
right_frame    = ctk.CTkFrame(center_frame) # sample list frame
sample_listbox = CTkListbox(
    right_frame, font=('TkDefaultFont', 11), width=220,
    label_anchor='w'
)

# pack the sample listbox
for analysis in filtered_data:
    if analysis.reduced:
        sample_listbox.insert(tk.END, f'☑ {analysis.analysis_label}')
    else:
        sample_listbox.insert(tk.END, f'☐ {analysis.analysis_label}')
sample_listbox.pack(fill=tk.BOTH, expand=True)

# initialize and pack buttons
global prev_button, exit_button, next_button, view_button
s = ttk.Style()
s.configure('TButton', font=('TkDefaultFont', 16), anchor='center', justify='center')
s.configure("TButton.label", font=('TkDefaultFont', 16), justify='center', anchor="center")
exit_button = ttk.Button(button_frame, text="Exit", 
    command=lambda: window.quit(),
    style='TButton', 
)
prev_button = ttk.Button(button_frame, text="Previous",
    command=lambda: on_previous(filtered_data, sample_listbox),
    style='TButton',
)
next_button = ttk.Button(button_frame, text="Next",
    command=lambda: on_next(filtered_data, sample_listbox),
    style='TButton', 
)
view_button = ttk.Button(button_frame, text="View\nAll\nAnalyses", 
    command=window.quit,
    style='TButton',
)

# Place the buttons in their respective rows
exit_button.pack(side=tk.BOTTOM, fill=tk.X, expand=True, ipady=20, ipadx=10)
prev_button.pack(side=tk.BOTTOM, fill=tk.X, expand=True, ipady=20, ipadx=10)
view_button.pack(side=tk.BOTTOM, fill=tk.X, expand=True, ipady=20, ipadx=10)
next_button.pack(side=tk.BOTTOM, fill=tk.X, expand=True, ipady=20, ipadx=10)

# pack the stats frame on the left
stats_frame.pack(side=tk.TOP, fill=tk.X, expand=True, padx=(3,0))

# pack frame for data 
data_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0,2))

# draw the canvas and pack it
canvas.draw()
canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)

# sample list packing
right_frame.pack(side=tk.LEFT, fill=tk.Y)

# header label for the sample list
header_label = ctk.CTkLabel(right_frame, text='Analyses', font=('TkDefaultFont', 20, 'bold'))
header_label.pack(pady=(10,10))

# set current_plot_index
global analysis_index
analysis_index = 0

# bind the listbox to the sample select function
sample_listbox.bind("<<ListboxSelect>>",
    lambda event: on_sample_select(event, sample_listbox, filtered_data)
)

# update the panes
update_buttons(filtered_data)
highlight_current_sample(sample_listbox, filtered_data)

# main window loop
window.mainloop()

Solution

  • The solution was to only use insert, not delete. I also needed to unbind and rebind the listbox with every change. It also gave buggy behavior while using globals so I switched to passing the variables properly.

    Here's the updated code:

    # highlights the current analysis in the list
    def highlight_current_analysis(analysis_listbox, analysis_index, filtered_data):
        # check that the analysis is reduced and that the symbol is ☐ before changing to ☑
        if filtered_data[analysis_index.get()].reduced and analysis_listbox.get(analysis_index.get())[0] == '☐':
            analysis_listbox.insert(analysis_index.get(), f'☑ {filtered_data[analysis_index.get()].analysis_label}')
        analysis_listbox.activate(analysis_index.get())
    
    
    # moves to the selected analysis when clicked in the list
    def on_analysis_select(analysis_index, analysis_listbox, filtered_data, fit_type, baseline_type, figure, canvas, stats_frame, prev_button, next_button, view_button):
        analysis_index.set(analysis_listbox.curselection())
        analysis = filtered_data[analysis_index.get()]
        interactive_update(analysis, fit_type, baseline_type, figure, canvas, stats_frame)
        analysis_listbox.unbind("<<ListboxSelect>>")
        highlight_current_analysis(analysis_listbox, analysis_index, filtered_data)
        analysis_listbox.bind("<<ListboxSelect>>",
            lambda event: on_analysis_select(analysis_index, analysis_listbox, filtered_data, fit_type, baseline_type, figure, canvas, stats_frame, prev_button, next_button, view_button)
        )