slightlynybbled
slightlynybbled

Reputation: 2645

Python3 tkinter: Deletion of row of widgets works on first iteration but not on subsequent iterations

I have created a widget that consists of rows and columns using tkinter entry widgets. I wish to place a '-' button next to each row which will simply delete that row.

My object is called DisplayTable, which is an instance of tk.Frame and it consists of rows of entries, each row of which contains a button with a - on it that should allow easy removal of that row.

I'll try to skip the more verbose portions of the code so that I can get to the point. The DisplayTable object creates a list of rows, self.rows within __init__. As rows are added this list will grow. For instance, a table that has three rows and two columns would look like:

[
    [<tkinter.Entry object>, <tkinter.Entry object>, <tkinter.Button object>],
    [<tkinter.Entry object>, <tkinter.Entry object>, <tkinter.Button object>],
    [<tkinter.Entry object>, <tkinter.Entry object>, <tkinter.Button object>]
]

The resulting graphic: Resulting graphic

In DisplayTable.add_row(), I have this code snippet (removed a lot of extra code to get to the point):

def add_row(self):
    row = []
    for i in range(self.columns):
        cell = tk.Entry(self)
        cell.grid(row=offset, column=i)
        row.append(cell)        

    row_to_delete = len(self.rows)
    btn = tk.Button(self, image=self.minus, command=lambda: self.delete_row(row_to_delete))
    btn.grid(row=offset, column=self.columns)
    row.append(btn)

    self.rows.append(row)

The above code snippet works perfectly for adding rows to the end of the array. It works properly for row deletion one time.

def delete_row(self, row_number):
    for widget in self.rows[row_number]:
        widget.destroy()
    self.rows.pop(row_number)

    # re-assign the delete row buttons
    for i, row in enumerate(self.rows):
        button = row[-1]
        button.configure(command=lambda: self.delete_row(i))

My intent in delete_row is to re-assign the delete buttons since the row index of self.rows just changed for each row. After executing this function, the behavior that I get is that the last row_number is always deleted. For instance, if there are 10 rows, the row with index 9 is always deleted.

Replacing the re-assignment of the delete buttons with a more direct reference doesn't appear to change the behavior:

def delete_row(self, row_number):
    for widget in self.rows[row_number]:
        widget.destroy()
    self.rows.pop(row_number)

    # re-assign the delete row buttons
    for i, row in enumerate(self.rows):
        self.rows[i][-1].configure(command=lambda: self.delete_row(i))

Using print commands, I have verified that the variable i is definitely different on each round and that the button object referenced is a different button each time around, so the command assigned should also be different. Somehow, it appears that only the last time around the loop is sticking to all o the buttons rather than just the one it was intended for.

Upvotes: 2

Views: 581

Answers (2)

PM 2Ring
PM 2Ring

Reputation: 55469

jasonharper has explained the cause of your problem: variables in lambdas aren't "frozen" at the time of creation, they're dynamic.

For further info, please see Why do my lambda functions or nested functions created in a loop all use the last loop value when called? in the SO Python Wiki.

Here's a way to avoid that problem. We can use a default argument in the callback function. Default values are evaluated when the function is defined, not when it's called (it doesn't matter whether the function is a lambda or a full def function).

Rather than storing the row index number, which requires the messy re-assignment after row deletion, we can use the row object itself as the argument to the delete_row method.

import tkinter as tk

class Grid(tk.Frame):
    def __init__(self, root):
        super().__init__(root)
        self.pack()

        self.rows = []
        self.numcols = 3
        self.numrows = 4
        for _ in range(self.numrows):
            self.add_row()

    def add_row(self):
        rownum = len(self.rows)
        row = []
        for i in range(self.numcols):
            cell = tk.Entry(self)
            cell.grid(row=rownum, column=i)
            row.append(cell)

        btn = tk.Button(self, text='-', command=lambda r=row: self.delete_row(r))
        btn.grid(row=rownum, column=self.numcols)
        row.append(btn)

        self.rows.append(row)

    def delete_row(self, row):
        for widget in row:
            widget.destroy()
        self.rows.remove(row)


root = tk.Tk()
gui = Grid(root)
root.mainloop()

Upvotes: 1

jasonharper
jasonharper

Reputation: 9597

for i, row in enumerate(self.rows):
    button = row[-1]
    button.configure(command=lambda: self.delete_row(i))

When the button is clicked, you delete the row specified by the value of the variable i at that moment in time. Since this loop will have long since completed before you can possibly click a button, i will always refer to the last row. This is a common problem with lambdas in a loop, and the solution is to capture the values of all used variables in default parameters: command=lambda i=i: self.delete_row(i))

I think a better approach would be to pass your row variable to delete_row(), rather than an index - that way, nothing needs to change when a row is deleted. You can iterate for widget in row: to get the widgets to delete, and get rid of the row itself via self.rows.remove(row).

Upvotes: 1

Related Questions