Canvas, рисование графики ч.3 / tkinter 20

Скачайте код уроков с GitLab: https://gitlab.com/PythonRu/tkinter-uroki

Определение пересечений между элементами

В продолжение предыдущего материала о поиске ближайшего элемента стоит отметить, что существует также возможность определять, пересекается ли один прямоугольник с другим. Этого можно добиться благодаря тому, что все элементы заключены в прямоугольные контейнеры. А для определения пересечений используется метод find_overlapping() из класса Canvas.

Это приложение расширяет возможности предыдущего за счет четырех новых прямоугольников, добавленных на полотно. Подсвечиваться будет тот, с которым пересечется синий. Управлять последним можно с помощью клавиш стрелок:

Определение пересечений между элементами

Поскольку код во многом повторяет предыдущий, отметим лишь те части кода, которые отвечают за создание новых прямоугольников и вызов метода canvas.find_overlapping():


import tkinter as tk

class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Обнаружение пересечений между предметами")

self.canvas = tk.Canvas(self, bg="white")
self.canvas.pack()
self.update()
self.width = w = self.canvas.winfo_width()
self.height = h = self.canvas.winfo_height()

pos = (w / 2 - 15, h / 2 - 15, w / 2 + 15, h / 2 + 15)
self.item = self.canvas.create_rectangle(*pos, fill="blue")
positions = [(60, 60), (w - 60, 60), (60, h - 60), (w - 60, h - 60)]
for x, y in positions:
self.canvas.create_rectangle(x - 10, y - 10, x + 10, y + 10,
fill="green")

self.pressed_keys = {}
self.bind("<KeyPress>", self.key_press)
self.bind("<KeyRelease>", self.key_release)
self.process_movements()

def key_press(self, event):
self.pressed_keys[event.keysym] = True

def key_release(self, event):
self.pressed_keys.pop(event.keysym, None)

def process_movements(self):
all_items = self.canvas.find_all()
for item in filter(lambda i: i is not self.item, all_items):
self.canvas.itemconfig(item, fill="green")

x0, y0, x1, y1 = self.canvas.coords(self.item)
items = self.canvas.find_overlapping(x0, y0, x1, y1)
for item in filter(lambda i: i is not self.item, items):
self.canvas.itemconfig(item, fill="yellow")

off_x, off_y = 0, 0
speed = 3
if 'Right' in self.pressed_keys:
off_x += speed
if 'Left' in self.pressed_keys:
off_x -= speed
if 'Down' in self.pressed_keys:
off_y += speed
if 'Up' in self.pressed_keys:
off_y -= speed

pos_x = x0 + (x1 - x0) / 2 + off_x
pos_y = y0 + (y1 - y0) / 2 + off_y
if 0 <= pos_x <= self.width and 0 <= pos_y <= self.height:
self.canvas.move(self.item, off_x, off_y)

self.after(10, self.process_movements)

if __name__ == "__main__":
app = App()
app.mainloop()

Как определяются пересечения

До пересечения цвет заполнения всех прямоугольников на полотне, кроме управляемого пользователем, будет зеленым. Идентификаторы этих элементов можно получить с помощью метода canvas.find_all():


def process_movements(self):
all_items = self.canvas.find_all()
for item in filter(lambda i: i is not self.item, all_items):
self.canvas.itemconfig(item, fill="green")

Когда цвета элемента сброшены, вызываем canvas.find_overlapping() для получения всех элементов, которые пересекаются с двигающимся. Он, в свою очередь, из цикла исключен, а цвет остальных пересекающихся элементов (если такие имеются) меняется на желтый:


def process_movements(self):
# ...

x0, y0, x1, y1 = self.canvas.coords(self.item)
items = self.canvas.find_overlapping(x0, y0, x1, y1)
for item in filter(lambda i: i is not self.item, items):
self.canvas.itemconfig(item, fill="yellow")

Метод продолжает выполнение, перемещая синий прямоугольник на заданный показатель сдвига, и планируя себя же снова с помощью process_movements().

Если нужно определить, когда движущийся элемент полностью перекрывает другой (а не частично), то стоит воспользоваться методом canvas.find_enclosed() вместо canvas.find_overlapping() с теми же параметрами.

Удаление элементов с полотна

Помимо добавления и изменения элементов полотна их также можно удалять с помощью метода delete() класса Canvas. Хотя в принципах его работы нет каких-либо особенностей, существуют кое-какие паттерны, которые будут рассмотрены дальше.

Стоит учитывать, что чем больше элементов на полотне, тем дольше Tkinter будет рендерить виджет. Таким образом важно удалять неиспользуемые для улучшения производительности.

В этом примере создадим приложение, которое случайным образом выбирает несколько кругов на полотне. Каждый кружок будет удаляться по клику. Одна кнопка в нижней части виджета сбрасывает состояние полотна, а вторая — удаляет все элементы.

Canvas, рисование графики ч.3 / tkinter 20

Чтобы случайным образом размещать элементы на полотне, будем генерировать координаты с помощью функции randint модуля random. Цвет элемента будет выбираться случайным образом с помощью вызова choice и определенного набора цветов.

После генерации элементы можно будет удалить с помощью обработчика on_click или кнопки Clearitems, которая, в свою очередь, вызывает функцию обратного вызова clear_all. Внутри этот метод вызывает canvas.delete() с нужными параметрами:


import random
import tkinter as tk

class App(tk.Tk):
colors = ("red", "yellow", "green", "blue", "orange")

def __init__(self):
super().__init__()
self.title("Удаление элементов холста")

self.canvas = tk.Canvas(self, bg="white")
frame = tk.Frame(self)
generate_btn = tk.Button(frame, text="Создавать элементы",
command=self.generate_items)
clear_btn = tk.Button(frame, text="Удалить элементы",
command=self.clear_items)

self.canvas.pack()
frame.pack(fill=tk.BOTH)
generate_btn.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)
clear_btn.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)

self.update()
self.width = self.canvas.winfo_width()
self.height = self.canvas.winfo_height()

self.canvas.bind("<Button-1>", self.on_click)
self.generate_items()

def on_click(self, event):
item = self.canvas.find_withtag(tk.CURRENT)
self.canvas.delete(item)

def generate_items(self):
self.clear_items()
for _ in range(10):
x = random.randint(0, self.width)
y = random.randint(0, self.height)
color = random.choice(self.colors)
self.canvas.create_oval(x, y, x + 20, y + 20, fill=color)

def clear_items(self):
self.canvas.delete(tk.ALL)

if __name__ == "__main__":
app = App()
app.mainloop()

Как работает удаление элементов

Метод canvas.delete() принимает один аргумент, который может быть идентификатором элемента или тегом, и удаляет один или несколько соответствующих элементов (поскольку тег может быть использован несколько раз).

В обработчике on_click() можно увидеть пример удаления элемента по идентификатору:


def on_click(self, event):
item = self.canvas.find_withtag(tk.CURRENT)
self.canvas.delete(item)

Стоит также отметить, что если сейчас кликнуть по пустой точке, то canvas.find_withtag(tk.CURRENT) вернет None, но когда это значение будет передано в canvas.delete(), то ошибки не будет. Это объясняется тем, что параметр None не совпадает ни с одним идентификатором или тегом. Таким образом это валидный параметр, хоть в результате никакое действие и не выполняется.

В функции обратного вызова clear_items() можно найти другой пример удаления элементов. Здесь вместо передачи идентификатора элемента используется тег ALL, который соответствует всем элементам и удаляет их с полотна:


def clear_items(self):
self.canvas.delete(tk.ALL)

Можно обратить внимание на то, что тег ALL работает «из коробки», поэтому его не нужно добавлять каждому элементу полотна.

Связывание событий с элементами полотна

Вы уже знаете, как связывать события с виджетами, но то же самое можно делать и с элементами. Это помогает писать более специфичные и простые обработчики событий. Такой намного удобнее, чем связывать все события с экземпляром Canvas и потом определять, какое из них нужно в текущий момент.

Следующее приложение показывает, как реализовать функциональность drag and drop для элементов полотна. Это распространенная особенность, которая способна значительно упростить программы.

Создадим несколько элементов (прямоугольник и овал), которые можно будет перетаскивать с помощью мыши. Разная форма поможет заметить, как события кликов корректно применяются к элементам, даже когда они пересекаются между собой:


import tkinter as tk

class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Drag and drop")

self.dnd_item = None
self.canvas = tk.Canvas(self, bg="white")
self.canvas.pack()

self.canvas.create_rectangle(30, 30, 60, 60, fill="green",
tags="draggable")
self.canvas.create_oval(120, 120, 150, 150, fill="red",
tags="draggable")

self.canvas.tag_bind("draggable", "<ButtonPress-1>",
self.button_press)
self.canvas.tag_bind("draggable", "<Button1-Motion>",
self.button_motion)

def button_press(self, event):
item = self.canvas.find_withtag(tk.CURRENT)
self.dnd_item = (item, event.x, event.y)

def button_motion(self, event):
x, y = event.x, event.y
item, x0, y0 = self.dnd_item
self.canvas.move(item, x - x0, y - y0)
self.dnd_item = (item, x, y)

if __name__ == "__main__":
app = App()
app.mainloop()

Как работает drag and drop

Для связывания событий с элементами используется метод tag_bind() из класса Canvas. Это добавляет связывание для всех элементов, которые соответствуют конкретному элементу — тегу draggable в этом случае.

И хотя метод называется tag_bind() вместо тега в него можно передавать также идентификатор:


self.canvas.tag_bind("draggable", "<ButtonPress-1>",
self.button_press)
self.canvas.tag_bind("draggable", "<Button1-Motion>",
self.button_motion)

Также стоит отметить, что новое поведение затронет только уже существующие элементы, поэтому если позже добавить новые с тегом draggable, то к ним связывание применено не будет.

Метод button_press() — это обработчик, который запускается после нажатия на элемент. Традиционный паттерн для получения соответствующего элемента — вызов canvas.find_withtag(tk.CURRENT).

Идентификатор элемента, а также координаты x и y события click хранятся в поле dnd_item. Эти значения позже будут использованы для перемещения элемента в соответствии с движением мыши:


def button_press(self, event):
item = self.canvas.find_withtag(tk.CURRENT)
self.dnd_item = (item, event.x, event.y)

Метод button_motion() обрабатывает события движения мыши до тех пор, пока зажата основная кнопка.

Для определения дистанции, на которую должен быть перемещен элемент, нужно вычислить разницу текущей позиции с предыдущими координатами. Эти значения передаются в метод canvas.move() и снова сохраняются в поле dnd_item:


def button_motion(self, event):
x, y = event.x, event.y
item, x0, y0 = self.dnd_item
self.canvas.move(item, x - x0, y - y0)
self.dnd_item = (item, x, y)

Существуют вариации этой drag & drop функциональности, которые также задействуют обработчик последовательности <ButtonRelease-1>. Она сбрасывает текущий элемент.

Однако использовать его необязательно, потому что после того как это событие происходит, связывание <Button1-Motion> не запустится до очередного клика по элементу. Это также помогает избежать проверки того, не является ли None значением dnd_item в начале обработчика button_motion().

Также этот пример можно улучшить, добавив базовую валидацию. Например, можно проверять, чтобы пользователь не мог вытащить элемент за пределы видимой области полотна.

Для этого используются паттерны, которые рассматривались в прошлых примерах. С их помощью можно вычислять ширину и высоту полотна и убедиться, что финальное положение элемента находится в пределах валидного диапазона с помощью цепочки операторов сравнения. В качестве шаблона для этого можно использовать следующий код:


final_x, final_y = pos_x + off_x, pos_y + off_y
if 0 <= final_x <= canvas_width and 0 <= final_y <= canvas_height:
canvas.move(item, off_x, off_y)

Рендер полотна в файл PostScript

Класс Canvas нативно поддерживает сохранение содержимого с помощью языка PostScript и метода postscript(). Он сохраняет графическое представление элементов полотна (линий, прямоугольников, овалов и так далее), но не его виджетов или изображений.

Изменим прошлый пример, который динамически генерирует этот тип простых элементов, и добавим функциональность для сохранения полотна в файл PostScript.

Возьмем уже знакомый код и добавим в него кусок для вывода содержимого полотна в файл PostScript:


import tkinter as tk
from lesson_18.drawing import LineForm

class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Базовый холст")

self.line_start = None
self.form = LineForm(self)
self.render_btn = tk.Button(self, text="Render canvas",
command=self.render_canvas)
self.canvas = tk.Canvas(self, bg="white")
self.canvas.bind("<Button-1>", self.draw)

self.form.grid(row=0, column=0, padx=10, pady=10)
self.render_btn.grid(row=1, column=0)
self.canvas.grid(row=0, column=1, rowspan=2)

def draw(self, event):
x, y = event.x, event.y
if not self.line_start:
self.line_start = (x, y)
else:
x_origin, y_origin = self.line_start
self.line_start = None
line = (x_origin, y_origin, x, y)
arrow = self.form.get_arrow()
color = self.form.get_color()
width = self.form.get_width()
self.canvas.create_line(*line, arrow=arrow,
fill=color, width=width)

def render_canvas(self):
self.canvas.postscript(file="output.ps", colormode="color")

if __name__ == "__main__":
app = App()
app.mainloop()

Как работает рендеринг в .ps

Основное нововведение — это кнопка Render canvas с функцией обратного вызова render_canvas().

Она вызывает метод postscript() для экземпляра canvas с аргументами file и colormode. Эти параметры определяют путь к расположению файла, а также информацию о цвете. Вторым параметром может быть color для полностью цветного вывода, gray — для использования оттенков серого или mono — для конвертации цветов в черный и белый:


def render_canvas(self):
self.canvas.postscript(file="output.ps", colormode="color")

Все параметры, которые можно передать в postscript(), стоит искать в официальной документации Tk/Tcl по ссылке https://www.tcl.tk/m an/tcl8.6/TkCmd/canvas.htm#M61. Стоит напомнить, что PostScript — язык печати, поэтому большая часть его параметров касается настроек страницы.

Поскольку файлы PostScript не так популярны, как другие форматы файлов, возможно, возникнет необходимость конвертировать готовый файл во что-то более знакомое — например, PDF.

Для этого нужен сторонний софт, такой как, например, Ghostscript, который распространяется по лицензии GNU APGL. Интерпретатор и инструмент рендеринга можно вызвать из программы для автоматической конвертации результатов PostScript в PDF.

Установить программу можно с сайта https://w ww.ghostscript.com/download/gsdnld.html. Дальше нужно только добавить папки bin и lib из установки в переменную path операционной системы.

Затем остается изменить приложение Tkinter для вызова программы ps2pdf в качестве подпроцеса и удалить файл output.ps после завершения выполнения:


import os
import subprocess
import tkinter as tk

class App(tk.Tk):
# ...

def render_canvas(self):
output_filename = "output.ps"
self.canvas.postscript(file=output_filename, colormode="color")
process = subprocess.run(["ps2pdf", output_filename, "output.pdf"],
shell=True)
os.remove(output_filename)

Появились вопросы? Задайте на Яндекс Кью

У блога есть сообщество на Кью >> Python Q <<. Там я и другие участники отвечаем на вопросы по python, программированию и контенту сайта.

Обучение Python и Data Science

Профессия Python-разработчик / Skillbox

Профессия Python-разработчик / Skillbox

7 313 2 925 ₽/мес.
Профессия Python Fullstack / Skillbox

Профессия Python Fullstack / Skillbox

6 569 2 627 ₽/мес.
Профессия Data Scientist / Skillbox

Профессия Data Scientist / Skillbox

9 187 3 675 ₽/мес.
Python-фреймворк на Django / Skillbox

Python-фреймворк на Django / Skillbox

818 ₽/мес.
Профессия DS: машинное обучение / Skillbox

Профессия DS: машинное обучение / Skillbox

6172 2469 ₽/мес.
Профессия DS: анализ данных / Skillbox

Профессия DS: анализ данных / Skillbox

6172 2469 ₽/мес.

Вам помогла эта статья? Поделитесь в соцсетях или блоге. Репосты помогают сайту развиться.

Александр эксперт Яндекс.Кью
Я создал этот блог в 2018 году, чтобы распространять полезные учебные материалы, документации и уроки на русском. На сайте опубликовано множество статей по основам python и библиотекам, уроков для начинающих и примеров написания программ.
Мои контакты: