2025, Sep 30 21:16

Как в Tkinter Canvas выбрать ближайший овал и игнорировать линии сетки (find_closest)

Практика для Tkinter Canvas: как с помощью find_closest выбирать ближайшие овалы и игнорировать линии сетки. Решение на тегах: временно скрывать лишнее.

Выбрать нужный объект на холсте Tkinter оказывается сложнее, чем кажется. Когда требуется «привязывать» щелчки к ближайшей точке и при этом игнорировать прочие фигуры вроде линий сетки, наивный вызов find_closest(x, y) быстро упирается в пограничные случаи. Функция без раздумий возвращает то, что ближе в экранных координатах — и это может оказаться отрезок линии, а не крошечный овал, который вам действительно нужен. Попытки ограничить поиск параметрами start и halo часто не работают так, как ожидается.

Как воспроизвести проблему

Пример ниже рисует два набора из 3×3 маленьких овалов и простую сетку. Клик по холсту должен помечать ближайший овал, но вызов со start всё равно может вернуть линию сетки.

import tkinter as tk
from tkinter import ttk
class PlotPad:
    def __init__(self, master):
        self.edge = 600
        self.master = master
        self.shell = ttk.Frame(self.master)
        self.board = tk.Canvas(self.shell, width=self.edge, height=self.edge)
        self.board.bind("<Motion>", self.on_move)
        self.board.bind("<Button-1>", self.on_click)
        self.footer = ttk.Frame(self.shell)
        self.txt = ttk.Label(self.footer, relief=tk.GROOVE, text='Coordinates go here')
        self.paint_dots(-150, 'blue', 'first_batch')
        self.paint_dots(-20, 'black', 'second_batch')
        self.paint_grid()
        self.shell.pack()
        self.board.pack()
        self.footer.pack(fill='both', expand=True)
        self.txt.pack(side='left', fill='x', expand=True)
    def on_move(self, event):
        px, py = event.x, event.y
        self.txt.config(text=f"Mouse coordinates: ({px}, {py})")
    def on_click(self, event):
        px, py = event.x, event.y
        # Пытаемся отдать приоритет овалам, ограничив поиск параметром start
        nearest = self.board.find_closest(px, py, halo=0, start=9)
        bbox = self.board.coords(nearest[0])
        cx, cy = (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
        span = 5
        self.board.create_line(cx - span, cy - span, cx + span, cy + span, fill='red')
        self.board.create_line(cx - span, cy + span, cx + span, cy - span, fill='red')
    def paint_dots(self, offset, color, tagname):
        for ix in range(1, 4):
            for iy in range(1, 4):
                self.board.create_oval(
                    ix * 200 + offset - 1,
                    iy * 200 + offset - 1,
                    ix * 200 + offset + 1,
                    iy * 200 + offset + 1,
                    fill=color,
                    tags=tagname
                )
    def paint_grid(self):
        for ix in range(1, 4):
            self.board.create_line(
                ix * 200 + 10,
                0,
                ix * 200 + 10,
                600,
                fill='green',
                tags='grid_lines')
        for iy in range(1, 4):
            self.board.create_line(
                0,
                iy * 200 + 10,
                600,
                iy * 200 + 10,
                fill='green',
                tags='grid_lines')
if __name__ == '__main__':
    root = tk.Tk()
    app = PlotPad(root)
    root.mainloop()

Что происходит на самом деле

Есть два аргумента Canvas.find_closest, которые выглядят многообещающе, но не решают эту задачу. Параметр halo расширяет точку клика до окружности заданного радиуса, фактически увеличивая область попадания вокруг курсора. Он не фильтрует типы объектов. Параметр start не ограничивает поиск диапазоном ID или тегом; вместо этого, если несколько объектов находятся на одинаковой ближайшей дистанции, выбирается тот, который расположен ниже start в списке отображения. Это удобно для разрешения «ничьих» в порядке наложения (z‑order), но не для исключения целых классов фигур вроде линий сетки.

Из‑за этого find_closest по‑прежнему может вернуть линию, если она ближе к точке клика, чем любой маленький овал.

Практическое решение: временно скрывать то, что не должно «ловиться»

Простой обходной путь — переключить состояние фигур, которые нужно игнорировать, выполнить выбор, а затем вернуть их обратно. Поскольку все линии сетки имеют общий тег, правка получается точечной и удобной. Спрячьте 'grid_lines' перед вызовом find_closest и восстановите их после.

def on_click(self, event):
    px, py = event.x, event.y
    # Временно исключаем линии сетки из проверки попадания
    self.board.itemconfig('grid_lines', state="hidden")
    nearest = self.board.find_closest(px, py)
    self.board.itemconfig('grid_lines', state="normal")
    bbox = self.board.coords(nearest[0])
    cx, cy = (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
    span = 5
    self.board.create_line(cx - span, cy - span, cx + span, cy + span, fill='red')
    self.board.create_line(cx - span, cy + span, cx + span, cy - span, fill='red')

Так вы избегаете геометрических перестановок или выноса элементов за пределы холста. Подход опирается на единый тег, чтобы изолировать объекты, которые нужно подавить во время проверки попадания; именно так часто управляют интерактивными слоями на Canvas.

Почему это важно

Выбор объектов указателем лежит в основе многих интерфейсных сценариев — от редакторов карт и средств диаграмм до облегчённых CAD‑подобных взаимодействий. Понимание того, что halo увеличивает зону клика, а start влияет на разрешение «ничьих» в порядке отображения, помогает избежать тупиков, когда нужно ограничить выбор подмножеством элементов. Когда требуется фильтрация, временное скрытие несущественных элементов по тегу — надёжный приём.

Выводы

Если нужно, чтобы find_closest работал только с подмножеством элементов Canvas, не рассчитывайте на start или halo как на фильтр классов объектов. Используйте понятные теги: на мгновение скрывайте слои, которые нужно исключить, и сразу возвращайте их после выбора. Если необходимо ограничить поиск конкретными элементами без скрытия, можно пройтись по элементам с целевым тегом и вручную вычислить расстояние, но при хорошей структуре тегов переключение состояния — лаконичное и эффективное решение.

Статья основана на вопросе на StackOverflow от Mike Duke и ответе Derek.