Tkinter : Finding closest

Hello,

I am new to tkinter module. My issue is that I would like to find the closest object of a certain type but I don’t know how I can use the find_closest method to do that.

Here is a code showing my issue. There are three lines with two of width 3 and one of width 10. I’d like to find the one which is the closest to where the user clicked but I want the line of width 10 being ignored.

from tkinter import *


class Mycanvas(Canvas):
    def __init__(self, parent):
        Canvas.__init__(self, parent)
        self.parent = parent
        self.bind("<Button-1>", self.print_closest)

        self.create_line(100, 100, 100, 200, width=3)
        self.create_line(200, 100, 200, 200, width=10)
        self.create_line(300, 100, 300, 200, width=3)

    def print_closest(self, event):
        id = self.find_closest(event.x, event.y)[0]
        width = self.itemcget(id, "width")
        Label(self.parent, text=f'Closest object width : {width}').pack()


class Root(Tk):
    def __init__(self):
        Tk.__init__(self)
        mycanvas = Mycanvas(self)
        mycanvas.pack()


if __name__ == '__main__':
    root = Root()
    root.mainloop()

I’d keep a list of the widgets you want to ignore. Eg something like
this:

 self.ignored = []
 ...
 line10 = self.create_line(200, 100, 200, 200, width=10)
 self.ignored.append(line10.id)

Then when you use find_closest, use it in a loop with halo=0 and
start being the “find after this widget” widget, initially None. So
you’d call find_closest in a loop until you found a nonignored widget,
eg:

 found = None
 previous = None
 while True:
     widget = self.find_closest(event.x, event.y, halo=0, start=previous)
     if widget is None:
         break
     if widget.id not in self.ignored:
         found = widget
         break
     previous = found
 if found is not None:
     id = found.id
     ... etc ...

That’s all untested, and also assumes that find_closest returns None
if no more widgets are found. Because it is untested, I don’t even know
if I’m using start correctly. But you can see the logic here: look for
the closest widget and if ignored, look for the next closest widget.

Cheers,
Cameron Simpson cs@cskk.id.au

1 Like

BTW, I was using these docs:
https://tkinter-docs.readthedocs.io/en/latest/widgets/canvas.html#Canvas.find_closest

Cheers,
Cameron

Normally I use these:

Cheers,
Cameron Simpson cs@cskk.id.au

I have tested the start option in find_closest but it doesn’t work as you said.

Here is the same example where i replaced the lines by circles intersecting. I want to ignore the blue circle. If I click at the intersection of the blue circle and the red circle, the blue circle is ignored thanks to the start option. However when i click outside of the circle but closer to the blue circle, find_closest will return the blue circle. So, the start option only makes a difference when shapes are overlapping.

from tkinter import *


class Mycanvas(Canvas):
    def __init__(self, parent):
        Canvas.__init__(self, parent)
        self.parent = parent
        self.bind("<Button-1>", self.print_closest)

        self.id1 = self.create_oval(100, 100, 200, 200, fill="red")
        self.id2 = self.create_oval(200, 100, 300, 200, fill="red")
        self.id3 = self.create_oval(150, 100, 250, 200, fill="blue")
        

    def print_closest(self, event):
        id = self.find_closest(event.x, event.y,halo=0,start = self.id3)[0]
        color = self.itemcget(id, "fill")
        Label(self.parent, text=f'Closest object color : {color}').pack()


class Root(Tk):
    def __init__(self):
        Tk.__init__(self)
        mycanvas = Mycanvas(self)
        mycanvas.pack()


if __name__ == '__main__':
    root = Root()
    root.mainloop()

Maybe I am using it wrong. I don’t undestand the point of having a find_closest method if it takes in account all the shapes on the canvas. It seems like a really particular need to me. I think you usually want to find the closest object of a certain type.

I believe you want start=id3.

I edited the code to make it clearer but the id was the right one.

I have tested the start option in find_closest but it doesn’t work
as you said.

I hadn’t tried it, but I’m trying it now.

Here is the same example where i replaced the lines by circles intersecting. I want to ignore the blue circle. If I click at the intersection of the blue circle and the red circle, the blue circle is ignored thanks to the start option. However when i click outside of the circle but closer to the blue circle, find_closest will return the blue circle. So, the start option only makes a difference when shapes are overlapping.

Interesting. And a bit weird.

The docs I cited actually say:

 If start is specified, it names an item using a tag or id (if
 by tag, it selects the bottom / first item in the display list
 with the given tag). Instead of returning the topmost closest
 item, this will return the topmost closest item that is below
 start in the display list; if no such item exists, then it will
 behave as if the start argument had not been specified. This
 will only have an effect if halo is given.

I think the important bit is “if no such item exists, then it will
behave as if the start argument had not been specified”. I had not read
this closely, and assumed that it meant the next-closest object. But it
actually talks about “in the display list”, which is the order in which
items are rendered - probably the order in which they were made, by
default.

So in this mode, if the blue circle is closest, but it is the last
thing in the display list
because it was made last, then “if no such
item exists, then it will behave as if the start argument had not been
specified”, giving the behaviour we see.

So I think you need a new method.

I suspect you will need to actually use find_all to get a list of
widgets, exclude the widgets to be ignored, then sort the list using a
metric for the distance to the source point (in your example, the event
point).

So, sketched, and untested:

 items = [ w for w in self.find_all() if w.id not in ignored_ids ]
 items_by_distance = sorted(items, key=lambda w: distance_to(event.x, event.y, w))
 closest = items_by_distance[0]

You will need to define a function distance_to(x,y,w) to compute the
distance.

It might want to consider the shape of the widget w. For example, the
distance to the closest point on a circle is the distance to its centre,
minus the radius of the circle, clipped to 0 if that’s a negative
value (you’re inside the circle).

For a rectangle it will be the distance to the nearest corner, again
unless you’re inside the rectangle. And so on.

This complication is why you’ll want a distinct function to compute
this.

Maybe I am using it wrong. I don’t undestand the point of having a
find_closest method if it takes in account all the shapes on the
canvas. It seems like a really particular need to me. I think you
usually want to find the closest object of a certain type.

Well, that’s what you want. I do not know what was in the mind if the
tk author when this method was written. You can easily do the “of a
particular type” part by classifying the widgets according to your own
scheme (which might arbitrarily be “rectangles”, or blue things, or some
criterion expressed by a tag), and include/exclude those widgets.

This seems arbitrary enough to expect UI authors (yourself) to write
their own methods for that.

That said, the documented behaviour also seems a little niche, and may
reflect some draw order dependent situation in the author’s head at the
time.

Cheers,
Cameron Simpson cs@cskk.id.au

Thanks a lot for the time you are dedicating to help me finding a solution.

I was hoping that there would a way to use the find_closest method but I guess I will need to define a distance_to function.

Blockquote
You can easily do the “of a particular type” part by classifying the widgets according to your own scheme

I think it can be quite hard to write the function distance_to in particular situtaions. For example, if there are complicated shapes or only one widget that you want to exclude, it might be hard to write distance_to.

For a rectangle it will be the distance to the nearest corner, again unless you’re inside the rectangle.

I am not sure to understand this correctly. If you take the middle point on a side of the rectangle, the distance to the rectangle is 0 and the distance to the nearest corner is half the length of the side.

I was hoping that there would a way to use the find_closest method
but I guess I will need to define a distance_to function.

It seems that way to me, unless someone else here has experience using
find_closest to solve your kind of problem.

For a rectangle it will be the distance to the nearest corner, again
unless you’re inside the rectangle.

I am not sure to understand this correctly. If you take the middle
point on a side of the rectangle, the distance to the rectangle is 0
and the distance to the nearest corner is half the length of the side.

You are correct and I am incorrect.

Maybe the best approach is to consider the line segments of the edges?
This is easy for a vertically aligned rectangle and a bit harder
otherwise (some trigonometry using the corners as endpoints of the line
segments). OTOH, if you solve for a nonaligned rectangle you can use the
same code for other shapes (triangles and other polygons). Provided you
can also tell if you’re “inside” a shape. Inside, you want 0 and
outside you want the shortest distance to a line segment.

It is going to get messy pretty fast unless you have a small number of
widget types to consider.

Cheers,
Cameron Simpson cs@cskk.id.au