pythonuser-interfacetkintertkinter-canvastkinter-layout

Unexpected Grid behavior in Tkinter


I am coding a game in Python with tkinter. The basic functionality is that an image is displayed but it's covered with a grid of boxes that can be destroyed. I've written a linear version of the code that is as simplified as I can get it:

from tkinter import *
from PIL import ImageTk, Image


class Box:
    def __init__(self, parent, row, column, width, height):
        self.row = row
        self.column = column
        self.canvas = Canvas(parent, bg='black', width=width, height=height)
        self.canvas.grid(row=row, column=column, sticky=NSEW)
        self.canvas.bind('<Button-1>', self.destroy)

    def destroy(self, event):
        print('Destroyed box at', self.row, self.column)
        self.canvas.destroy()


root = Tk()
root.title('Image Reveal')
root.state('zoomed')
image_raw = None

# Create a frame for the image
image_frame = Frame(root, highlightbackground="blue", highlightthickness=5)
image_frame.pack(expand=True, anchor=CENTER, fill=BOTH, padx=0, pady=0)

# Load image and put it in a label
image_raw = Image.open('image.png')
image = ImageTk.PhotoImage(image_raw)
image_label = Label(image_frame, image=image, borderwidth=5, relief='ridge')
image_label.pack(anchor=CENTER, expand=True)

# Calculate the size of the boxes
image_label.update()
width = image.width()
height = image.height()
column_amount = 10
row_amount = 10
box_width = width / column_amount
box_height = height / row_amount

# Create boxes that fill the grid
for row in range(row_amount):
    for column in range(column_amount):
        Box(image_label, row, column, box_width, box_height)

root.mainloop()

You can click on the box to destroy it and it also logs the coordinates of the box destroyed.

The desired behavior: You can click on the boxes to destroy them, gradually revealing the image. The other boxes should stay in the same place (the same cells of the grid), so the grid shape doesn't change.

The unexpected behavior: Destroying one box makes the rest of the boxes disappear somehow. Yet you can still click them (it still logs the destruction message). Overall this is the closest I've got to my desired behavior but I can't figure out why this is happening or how to approach this functionality better.

WORKING CODE:

Lukas Krahbichler helped achieve most of the desired behavior. Here is the updated code. Destroying the boxes however led to the grid resizing when a full row or a column of boxes gets destroyed. In order to fix that, I instead hide the box by lowering it behind the image. At first that didn't work, but then I found this - Turns out to lower the WHOLE canvas, there is no direct method for that. The final code:

from tkinter import *
from PIL import ImageTk, Image


class Box:
    def __init__(self, root, image_frame, image_label, row, column):
        self.root = root
        self.row = row
        self.column = column
        self.image_label = image_label
        self.image_frame = image_frame
        self.visible = True
        self.canvas = Canvas(image_frame, bg='black')
        self.canvas.grid(row=row, column=column, sticky=NSEW, padx=5, pady=5)
        
        self.canvas.bind('<Button-1>', self.hide)

    def hide(self, event):
        # Lower the box under the image to hide it
        self.canvas.tk.call('lower', self.canvas._w, None)
        self.visible = False
        print('Hid box at', self.row, self.column)



root = Tk()
root.title('Image Reveal')
root.state('zoomed')
image_raw = None
column_amount = 10
row_amount = 10
boxes = []

# Create a frame for the image
image_frame = Frame(root, highlightbackground="blue", highlightthickness=0)
image_frame.pack(anchor=CENTER, fill=None, padx=0, pady=0)

# Load image and put it in a label
image_raw = Image.open('image.png')
image = ImageTk.PhotoImage(image_raw)
width = image.width()
height = image.height()

# Create a label for the image
image_label = Label(image_frame, image=image, borderwidth=0, relief='ridge')

# Configure the image frame so it doesn't resize             <----- UPDATED
image_frame.config(width=width, height=height)
image_frame.grid_propagate(False)
image_label.grid_propagate(False)

# Place the image label in the frame
image_label.grid(rowspan=row_amount, columnspan=column_amount, sticky="NSEW")

# Configure the grid weights                                <----- UPDATED
for row in range(10):
    image_frame.rowconfigure(row, weight=1)
for column in range(10):
    image_frame.columnconfigure(column, weight=1)

# Calculate the size of the boxes
image_label.update()

# Create boxes that fill the grid
for row in range(row_amount):
    for column in range(column_amount):
        box = Box(root, image_frame, image_label, row, column)
        boxes.append(box)

root.mainloop()

Solution

  • In tkinter you are not really supposed to place other widgets (for a example a Canvas) in a Label. If you grid everything in the "image_frame" and give the "image_label" a column and a rowspan it should work.

    from tkinter import *
    from PIL import ImageTk, Image
    
    
    class Box:
        def __init__(self, parent, row, column, width, height):
            self.row = row
            self.column = column
            self.canvas = Canvas(parent, bg='black', width=width, height=height)
            self.canvas.grid(row=row, column=column, sticky=NSEW)
            self.canvas.bind('<Button-1>', self.destroy)
    
        def destroy(self, event):
            print('Destroyed box at', self.row, self.column)
            self.canvas.destroy()
    
    
    root = Tk()
    root.title('Image Reveal')
    root.state('zoomed')
    image_raw = None
    
    # Create a frame for the image
    image_frame = Frame(root, highlightbackground="blue", highlightthickness=5)
    image_frame.pack(expand=True, anchor=CENTER, fill=BOTH, padx=0, pady=0)
    
    # Define row and column amount
    column_amount = 10
    row_amount = 10
    
    # Load image and put it in a label
    image_raw = Image.open('image.png')
    image = ImageTk.PhotoImage(image_raw)
    image_label = Label(image_frame, image=image, borderwidth=5, relief='ridge')
    image_label.grid(rowspan=row_amount, columnspan=column_amount, sticky="NSEW")
    
    # Calculate the size of the boxes
    image_label.update()
    width = image.width()
    height = image.height()
    box_width = width / column_amount
    box_height = height / row_amount
    
    # Create boxes that fill the grid
    for row in range(row_amount):
        for column in range(column_amount):
            Box(image_frame, row, column, box_width, box_height)
    
    root.mainloop()