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

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

Добавление геометрических фигур на полотно

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

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

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

Добавление геометрических фигур на полотно

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

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


import tkinter as tk
from functools import partial

class App(tk.Tk):
shapes = ("прямоугольник", "овал", "дуга")
def __init__(self):
super().__init__()
self.title("Отрисовка стандартных элементов")

self.start = None
self.shape = None
self.canvas = tk.Canvas(self, bg="white")
frame = tk.Frame(self)
for shape in self.shapes:
btn = tk.Button(frame, text=shape.capitalize())
btn.config(command=partial(self.set_selection, btn, shape))
btn.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)

self.canvas.bind("<Button-1>", self.draw_item)
self.canvas.pack()
frame.pack(fill=tk.BOTH)

def set_selection(self, widget, shape):
for w in widget.master.winfo_children():
w.config(relief=tk.RAISED)
widget.config(relief=tk.SUNKEN)
self.shape = shape

def draw_item(self, event):
x, y = event.x, event.y
if not self.start:
self.start = (x, y)
else:
x_origin, y_origin = self.start
self.start = None
bbox = (x_origin, y_origin, x, y)
if self.shape == "прямоугольник":
self.canvas.create_rectangle(*bbox, fill="blue",
activefill="yellow")
elif self.shape == "овал":
self.canvas.create_oval(*bbox, fill="red",
activefill="yellow")
elif self.shape == "дуга":
self.canvas.create_arc(*bbox, fill="green",
activefill="yellow")

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

Как работает рисование фигур

Для сохранения возможности динамически выбирать тип элемента создаются кнопки для каждого из представленных. Они создаются за счет перебора кортежа shapes.

Каждая функция обратного вызова определяется с помощью функции partial из модуля functools. Это позволяет заморозить экземпляр Button и текущую форму цикла в качестве аргументов функции обратного вызова для каждой кнопки:


for shape in self.shapes:
btn = tk.Button(frame, text=shape.capitalize())
btn.config(command=partial(self.set_selection, btn, shape))
btn.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)

Функция обратного вызова set_section() помечает нажатую кнопку с помощью SUNKEN и сохраняет выбор в поле shape.

Остальные кнопки настраиваются со стандартным рельефом RAISED. Это делается с помощью перехода к родителю, который доступен в поле master текущего виджета. Из него и можно получить все дочерние виджеты, используя метод winfo_children():


def set_selection(self, widget, shape):
for w in widget.master.winfo_children():
w.config(relief=tk.RAISED)
widget.config(relief=tk.SUNKEN)
self.shape = shape

Обработчик draw_item() сохраняет координаты первого клика каждой пары событий, чтобы нарисовать элемент при повторном клике по полотну.

В зависимости от типа поля shape вызывается один из следующих методов для отображения соответствующего элемента:

  • canvas.create_rectangele(x0, y0, x1, y,1 **options) – рисует прямоугольник, чей левый верхний угол расположен по координатам (x0, y0), а правый нижний – (x1, y1).
  • canvas.create_oval(x0, y0, x1, y1, **options) – рисует эллипс, который вписывается в прямоугольник с координатами (x0, y0) и (x1, y1).
  • canvas.create_arc(x0, y0, x1, y1, **options) – рисует четверть эллипса, который поместится в прямоугольник с координатами (x0, y0) и (x1, y1).

Поиск элементов по их положению

Класс Canvas включает методы для получения идентификаторов элементов, которые находятся рядом с координатами полотна.

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

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

Поиск элементов по их положению

Для нахождения ближайшего к курсору элемента координаты мыши передаются методу canvas.find_closest(), который и определяют идентификатор ближайшего элемента.

Если в пределах полотна есть хотя бы один элемент, можно быть уверенным в том, что метод всегда вернет валидный идентификатор элемента:


import tkinter as tk

class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Поиск предметов на canvas")

self.current = None
self.canvas = tk.Canvas(self, bg="white")
self.canvas.bind("<Motion>", self.mouse_motion)
self.canvas.pack()

self.update()
w = self.canvas.winfo_width()
h = self.canvas.winfo_height()
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="blue")

def mouse_motion(self, event):
self.canvas.itemconfig(self.current, fill="blue")
self.current = self.canvas.find_closest(event.x, event.y)
self.canvas.itemconfig(self.current, fill="yellow")

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

Как работает поиск элементов

При инициализации приложения создается полотно и определяется поле current для сохранения ссылки на текущий подсвеченный элемент. Также обрабатываются события "<Motion>" с помощью метода mouse_motion():


self.current = None
self.canvas = tk.Canvas(self, bg="white")
self.canvas.bind("<Motion>", self.mouse_motion)
self.canvas.pack()

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


self.update()
w = self.canvas.winfo_width()
h = self.canvas.winfo_height()
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="blue")

Обработчик mouse_motion() задает цвет текущего элемента обратно в синий и сохраняет идентификатор нового. Наконец, цвет fill этого элемента становится желтым:


def mouse_motion(self, event):
self.canvas.itemconfig(self.current, fill="blue")
self.current = self.canvas.find_closest(event.x, event.y)
self.canvas.itemconfig(self.current, fill="yellow")

Изначально при вызове mouse_motion() ошибок нет, а поле current равно None, поскольку это также валидное значение для параметра itemconfig. Просто в таком случае действия не выполняются.

Перемещение элементов в canvas

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

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

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

Чтобы элемент не ушел за пределы экрана, ограничим перемещение в пределах полотна:


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 = self.canvas.winfo_width()
self.height = self.canvas.winfo_height()

self.item = self.canvas.create_rectangle(30, 30, 60, 60,
fill="blue")
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):
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

x0, y0, x1, y1 = self.canvas.coords(self.item)
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()

Как работает перемещение элементов

Для обработки клавиш стрелок на клавиатуре свяжем "<KeyPress>" и "<KeyRelease>" с экземпляром приложения. Нажатые сейчас клавиши сохраняются в словарь pressed_keys:


def __init__(self):
# ...
self.pressed_keys = {}
self.bind("<KeyPress>", self.key_press)
self.bind("<KeyRelease>", self.key_release)

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

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

Такой подход лучше отдельного связывания клавиш "<Up>", "<Down>", "<Right>" и "<Left>", потому что они вызывали бы каждый из обработчиков только при обработке событий клавиатуры. В результате элементы бы «перепрыгивали» с одного положения на другое, а не плавно перемещались.

Последний элемент инициализации экземпляра App – вызов process_movements(), который запускает обработку движения элемента полотна.

Этот метод вычисляет сдвиг по каждой из осей. В зависимости от содержания словаря pressed_keys, скорость speed добавляется или вычисляется из координат компонентов:


def process_movements(self):
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

После этого мы получаем положение текущего элемента с помощью вызова canvas.coords() и распаковки пары точек, которые формируют контейнер из четырех переменных.


Центр каждого элемента вычисляется за счет сложения x и y верхнего левого угла с половиной ширины и высоты. Результат, плюс сдвиг по каждой оси, соответствует финальному положению элемента после перемещения:


x0, y0, x1, y1 = self.canvas.coords(self.item)
pos_x = x0 + (x1 - x0) / 2 + off_x
pos_y = y0 + (y1 - y0) / 2 + off_y

После этого мы проверяем, находимся ли мы в пределах полотна. Для этого используем встроенную в Python поддержку связанных операторов сравнения:


if 0 <= pos_x <= self.width and 0 <= pos_y <= self.height:
self.canvas.move(self.item, off_x, off_y)

Наконец, этот метод планирует сам себя с задержкой в 10 миллисекунд с помощью вызова self.after(10, self.process_movements). Таким образом достигается эффект собственного основного цикла внутри реального цикла Tkinter.

Вас может заинтересовать, почему в этом примере использовался after(), а не after_idle() для планирования метода process_movements().

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

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

С помощью минимальной фиксированной задержки есть возможность одинаково обрабатывать элементы на разных машинах.

Максим
Я создал этот блог в 2018 году, чтобы распространять полезные учебные материалы, документации и уроки на русском. На сайте опубликовано множество статей по основам python и библиотекам, уроков для начинающих и примеров написания программ.
Мои контакты: Почта
admin@pythonru.comAlex Zabrodin2018-10-26OnlinePython, Programming, HTML, CSS, JavaScript