目录

tkinter快键画布

tkinter快键画布

这个画板仅仅是为了服务于个人设计的截图功能贴图功能中的画板,方便我直接进行继承拓展。

截图自制工具详见我的主页。个人认为设计可谓相当简洁优雅。

下面的快捷画布的使用方法:

①按住Ctrl键,调出画笔;松开即结束绘图;

②双击Ctrl键,调出调色板;

③按照Ctrl键并滚动鼠标中键,调整画笔粗细;

第一版:

import time
import ctypes
import tkinter as tk
from tkinter import colorchooser


class CustomCanvas(tk.Canvas):
    DOUBLE_CLICK_INTERVAL: float = 0.3
    LINE_MERGE_SLOPE_THRESHOLD: float = 0.05
    BIG_MOVE_THRESHOLD: int = 10
    PEN_SIZE_RANGE: tuple[int, int] = (1, 124)
    def __init__(self, parent, **kwargs):
        super().__init__(parent, **kwargs)
        self._bind_events()
        self.focus_set()
        self.__preview_items: list[str] = list()
        self.__stroke_history: list[str] = list()
        self.__current_stroke: list[str] = list()
        self.pen_color: str = 'black'
        self.drawing_mode: bool = False
        self.last_ctrl_press: float = 0.0
        self.pen_size: int = 3
        self.last_x: int = None
        self.last_y: int = None

    def _bind_events(self) -> None:
        # 控制键监听
        self.bind('<Control_L>', self.__start_drawing)
        self.bind('<Control-KeyRelease>', self.__stop_drawing)
        # 鼠标监听
        self.bind('<B1-Motion>', self.__draw_continuous)
        self.bind('<Motion>', self.__update_preview)
        self.bind('<Button-1>', self.__draw_single_point)
        self.bind("<ButtonRelease-1>", self.__finalize_stroke)
        # 功能快捷键
        self.bind('<Control-MouseWheel>', self.__adjust_pen_size)
        self.bind("<Control-z>", self.undo_last_stroke)

    def __start_drawing(self, event) -> None:
        if self._is_double_ctrl_click():
            self.__change_pen_color(event)
        self.drawing_mode = True
        self.config(cursor='pencil')

    def __stop_drawing(self, event) -> None:
        self.drawing_mode = False
        self.config(cursor='')

    def __finalize_stroke(self, event) -> None:
        self.last_x = self.last_y = None
        self.__stroke_history.append(self.__current_stroke)
        self.__current_stroke = []

    def __change_pen_color(self, event) -> None:
        color = colorchooser.askcolor()[1]
        if color:
            self.pen_color = color

    def __adjust_pen_size(self, event) -> None:
        min_size, max_size = self.PEN_SIZE_RANGE
        if event.delta > 0:
            self.pen_size = min(max_size, self.pen_size + 1)
        else:
            self.pen_size = max(min_size, self.pen_size - 1)
        self.__show_brush_preview(event)

    def __show_brush_preview(self, event) -> None:
        self.__update_preview(event, force=True)
        preview = self.__draw_single_point(event, record=False)
        self.__preview_items.append(preview)

    def __update_preview(self, event, force: bool = False) -> None:
        if force:
            self.__clear_preview()
        current_x, current_y = event.x, event.y
        if self.last_x is not None and self.last_y is not None:
            dx = abs(current_x - self.last_x)
            dy = abs(current_y - self.last_y)
            if dx < self.BIG_MOVE_THRESHOLD and dy < self.BIG_MOVE_THRESHOLD:
                return
        self.last_x, self.last_y = current_x, current_y
        if len(self.__preview_items) != 0:
            self.__clear_preview()

    def __clear_preview(self) -> None:
        for item in self.__preview_items:
            self.delete(item)
        self.__preview_items.clear()

    def _is_double_ctrl_click(self) -> bool:
        current_time = time.time()
        is_double_click = (current_time - self.last_ctrl_press) < self.DOUBLE_CLICK_INTERVAL
        self.last_ctrl_press = current_time
        return is_double_click

    def _is_collineation(self, line1_coords: tuple[int], line2_coords: tuple[int]) -> bool:
        x1, y1, x2, y2 = line1_coords
        x3, y3, x4, y4 = line2_coords

        dx1 = x2 - x1
        dy1 = y2 - y1
        dx2 = x4 - x3
        dy2 = y4 - y3
        
        if dx1 == 0 or dx2 == 0:
            return x1 == x3

        # 斜率几乎相等
        if abs(dy1 / dx1 - dy2 / dx2) < self.LINE_MERGE_SLOPE_THRESHOLD:
            # 同方向
            return (dx1 * dx2 >= 0) and (dy1 * dy2 >= 0)
        return False
    
    def _collineation_merge(self, event) -> tuple[int]:
        current_line_coords = (self.last_x, self.last_y, event.x, event.y)
        if len(self.__current_stroke) < 2:
            return current_line_coords
        last_line_id = self.__current_stroke[-1]
        last_line_coords = self.coords(last_line_id)
        if len(last_line_coords) != 4:
            return current_line_coords
        if self._is_collineation(last_line_coords, current_line_coords):
            self.delete(last_line_id)
            self.__current_stroke.pop()
            return (last_line_coords[0], last_line_coords[1], event.x, event.y)
        return current_line_coords

    def __draw_continuous(self, event, record: bool = True) -> str:
        line = ""
        if not self.drawing_mode:
            return line
        if self.last_x and self.last_y:
            line_coords = self._collineation_merge(event)
            line = self.create_line(
                *line_coords, fill=self.pen_color, width=self.pen_size,
                capstyle=tk.ROUND, joinstyle=tk.ROUND
            )
            if record:
                self.__current_stroke.append(line)
        self.last_x = event.x
        self.last_y = event.y
        return line

    def __draw_single_point(self, event, record: bool = True) -> str:
        point = ""
        if not self.drawing_mode:
            return point
        point = self.create_oval(
            event.x - self.pen_size // 2, event.y - self.pen_size // 2,
            event.x + self.pen_size // 2, event.y + self.pen_size // 2,
            fill=self.pen_color, outline=self.pen_color, 
        )
        self.last_x = event.x
        self.last_y = event.y
        if record:
            self.__current_stroke.append(point)
        return point

    def undo_last_stroke(self, event) -> None:
        if len(self.__stroke_history) == 0:
            return
        last_draw = self.__stroke_history.pop()
        for item in last_draw:
            self.delete(item)


if __name__ == '__main__':
    ctypes.windll.shcore.SetProcessDpiAwareness(1)
    root = tk.Tk()
    canvas = CustomCanvas(root, bg='white', width=1600, height=1200)
    canvas.pack()
    root.mainloop()

快捷画板配合功能演示

然而如你所见,我并没有展示如何保存这些图形项成实际图片,因为tk.Canvas只支持将图片导出为*.ps文件,而这种文件的解析又不得不依赖Ghostscript,我如果要将模块打包,就必须将把它作为单独软件打包进来,这一下子就让整个程序的大小骤增。而如果采用ImageGrab截图保存,你需要保证用户不会把图片怼到屏幕边缘使部分Canvas图像被遮挡,这样也很不