Tkinter-GUI-应用开发秘籍-全-

Tkinter GUI 应用开发秘籍(全)

原文:zh.annas-archive.org/md5/398a043f4e87ae54140cbfe923282feb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

作为一种更多用途的编程语言之一,Python 以其“电池包含”哲学而闻名,其中包括其标准库中丰富的模块集;Tkinter 是用于构建桌面应用程序的库。Tkinter 是建立在 Tk GUI 工具包之上的,是快速 GUI 开发的常见选择,复杂的应用程序可以从该库的全部功能中受益。本书涵盖了 Tkinter 和 Python GUI 开发的所有问题和解决方案。

Tkinter GUI 应用程序开发食谱首先概述了 Tkinter 类,同时提供了有关基本主题的示例,例如布局模式和事件处理。接下来,本书介绍了如何开发常见的 GUI 模式,例如输入和保存数据,通过菜单和对话框导航,以及在后台执行长时间操作。然后,您可以使您的应用程序有效地利用网络资源,并在画布上执行图形操作以及相关任务,例如检测项目之间的碰撞。最后,本书介绍了使用主题小部件,这是 Tk 小部件的扩展,具有更本地的外观和感觉。

通过本书,您将深入了解 Tkinter 类,并知道如何使用它们构建高效和丰富的 GUI 应用程序。

这本书是为谁准备的

这本书的目标读者是熟悉 Python 语言基础知识(语法、数据结构和面向对象编程)的开发人员,希望学习 GUI 开发常见挑战的有效解决方案,并希望发现 Tkinter 可以提供的有趣功能,以构建复杂的应用程序。

您不需要有 Tkinter 或其他 GUI 开发库的先前经验,因为本书的第一部分将通过介绍性用例教授库的基础知识。

本书涵盖的内容

第一章,开始使用 Tkinter,介绍了 Tkinter 程序的结构,并向您展示如何执行最常见的任务,例如创建小部件和处理用户事件。

第二章,窗口布局,演示了如何使用几何管理器放置小部件并改进大型应用程序的布局。

第三章,自定义小部件,深入探讨了 Tkinter 小部件的配置和外观自定义。

第四章,对话框和菜单,教会您如何通过菜单和对话框改进 Tkinter 应用程序的导航。

第五章,面向对象编程和 MVC,教会您如何在 Tkinter 应用程序中有效应用设计模式。

第六章,异步编程,涵盖了执行长时间操作而不冻结应用程序的几个方法——这是 GUI 开发中经常出现的问题。

第七章,画布和图形,探索了画布小部件以及您可以添加到画布的项目类型以及如何操作它们。

第八章,主题小部件,教会您如何使用 Tk 主题小部件集扩展 Tkinter 应用程序。

充分利用本书

要开始并运行,用户需要安装以下技术:

  • Python 3.x

  • 任何操作系统

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的软件解压或提取文件夹。

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Tkinter-GUI-Application-Development-Cookbook。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/TkinterGUIApplicationDevelopmentCookbook_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这是一个例子:"delete()方法接受两个参数,指示应删除的字符范围。"

代码块设置如下:

from tkinter import * 

root = Tk() 
btn = Button(root, text="Click me!") 
btn.config(command=lambda: print("Hello, Tkinter!"))
btn.pack(padx=120, pady=30)
root.title("My Tkinter app")
root.mainloop()

当我们希望引起您对代码块的特定部分的注意时,相关行或项将以粗体显示:

def show_caption(self, event):
    caption = tk.Label(self, ...)
    caption.place(in_=event.widget, x=event.x, y=event.y)
    # ...

粗体:表示一个新术语,一个重要单词,或者您在屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中以这种方式出现。这是一个例子:"第一个将被标记为选择文件。"

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一章:开始使用 Tkinter

在本章中,我们将涵盖以下内容:

  • 构建 Tkinter 应用程序

  • 使用按钮

  • 创建文本输入

  • 跟踪文本更改

  • 验证文本输入

  • 选择数值

  • 使用单选按钮创建选择

  • 使用复选框实现开关

  • 显示项目列表

  • 处理鼠标和键盘事件

  • 设置主窗口的图标、标题和大小

介绍

由于其清晰的语法和广泛的库和工具生态系统,Python 已经成为一种流行的通用编程语言。从 Web 开发到自然语言处理(NLP),您可以轻松找到一个符合您应用领域需求的开源库,最后,您总是可以使用 Python 标准库中包含的任何模块。

标准库遵循“电池包含”哲学,这意味着它包含了大量的实用程序:正则表达式、数学函数、网络等。该库的标准图形用户界面(GUI)包是 Tkinter,它是 Tcl/Tk 的一个薄的面向对象的层。

从 Python 3 开始,Tkinter模块被重命名为tkinter(小写的 t)。它也影响到tkinter.ttktkinter.tix扩展。我们将在本书的最后一章深入探讨tkinter.ttk模块,因为tkinter.tix模块已经正式弃用。

在本章中,我们将探索tkinter模块的一些基本类的几种模式以及所有小部件子类共有的一些方法。

构建 Tkinter 应用程序

使用 Tkinter 制作应用程序的主要优势之一是,使用几行脚本非常容易设置基本 GUI。随着程序变得更加复杂,逻辑上分离每个部分变得更加困难,因此有组织的结构将帮助我们保持代码整洁。

准备工作

我们将以以下程序为例:

from tkinter import * 

root = Tk() 
btn = Button(root, text="Click me!") 
btn.config(command=lambda: print("Hello, Tkinter!"))
btn.pack(padx=120, pady=30)
root.title("My Tkinter app")
root.mainloop()

它创建一个带有按钮的主窗口,每次点击按钮时都会在控制台中打印Hello, Tkinter!。按钮在水平轴上以 120px 的填充和垂直轴上以 30px 的填充放置。最后一条语句启动主循环,处理用户事件并更新 GUI,直到主窗口被销毁:

您可以执行该程序并验证它是否按预期工作。但是,所有我们的变量都是在全局命名空间中定义的,添加的小部件越多,理清它们的使用部分就变得越困难。

在生产代码中,强烈不建议使用通配符导入(from ... import *),因为它们会污染全局命名空间——我们只是在这里使用它们来说明一个常见的反模式,这在在线示例中经常见到。

这些可维护性问题可以通过基本的面向对象编程技术来解决,在所有类型的 Python 程序中都被认为是良好的实践。

如何做...

为了改进我们简单程序的模块化,我们将定义一个包装我们全局变量的类:

import tkinter as tk 

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.btn = tk.Button(self, text="Click me!", 
                             command=self.say_hello) 
        self.btn.pack(padx=120, pady=30) 

    def say_hello(self): 
        print("Hello, Tkinter!") 

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

现在,每个变量都被封装在特定的范围内,包括command函数,它被移动为一个单独的方法。

工作原理...

首先,我们用import ... as语法替换了通配符导入,以便更好地控制我们的全局命名空间。

然后,我们将我们的App类定义为Tk子类,现在通过tk命名空间引用。为了正确初始化基类,我们将使用内置的super()函数调用Tk类的__init__方法。这对应以下行:

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        # ... 

现在,我们有了对App实例的引用,使用self变量,所以我们将把所有的按钮小部件作为我们类的属性添加。

虽然对于这样一个简单的程序来说可能看起来有点过度,但这种重构将帮助我们理清每个部分,按钮实例化与单击时执行的回调分开,应用程序引导被移动到if __name__ == "__main__"块中,这是可执行 Python 脚本中的常见做法。

我们将遵循这个约定通过所有的代码示例,所以您可以将这个模板作为任何更大应用程序的起点。

还有更多...

在我们的示例中,我们对Tk类进行了子类化,但通常也会对其他小部件类进行子类化。我们这样做是为了重现在重构代码之前的相同语句。

然而,在更大的程序中,比如有多个窗口的程序中,可能更方便地对FrameToplevel进行子类化。这是因为 Tkinter 应用程序应该只有一个Tk实例,如果在创建Tk实例之前实例化小部件,系统会自动创建一个Tk实例。

请记住,这个决定不会影响我们的App类的结构,因为所有的小部件类都有一个mainloop方法,它在内部启动Tk主循环。

使用按钮

按钮小部件表示 GUI 应用程序中可点击的项目。它们通常使用文本或指示单击时将执行的操作的图像。Tkinter 允许您使用Button小部件类的一些标准选项轻松配置此功能。

如何做...

以下包含一个带有图像的按钮,单击后会被禁用,并带有不同类型可用的 relief 的按钮列表:

import tkinter as tk 

RELIEFS = [tk.SUNKEN, tk.RAISED, tk.GROOVE, tk.RIDGE, tk.FLAT] 

class ButtonsApp(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.img = tk.PhotoImage(file="python.gif") 
        self.btn = tk.Button(self, text="Button with image", 
                             image=self.img, compound=tk.LEFT, 
                             command=self.disable_btn) 
        self.btns = [self.create_btn(r) for r in RELIEFS]         
        self.btn.pack() 
        for btn in self.btns: 
            btn.pack(padx=10, pady=10, side=tk.LEFT) 

    def create_btn(self, relief): 
        return tk.Button(self, text=relief, relief=relief) 

    def disable_btn(self): 
        self.btn.config(state=tk.DISABLED) 

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

这个程序的目的是显示在创建按钮小部件时可以使用的几个配置选项。

在执行上述代码后,您将得到以下输出:

它是如何工作的...

Button实例化的最基本方法是使用text选项设置按钮标签和引用在按钮被点击时要调用的函数的command选项。

在我们的示例中,我们还通过image选项添加了PhotoImage,它优先于text字符串。compound选项用于在同一个按钮中组合图像和文本,确定图像放置的位置。它接受以下常量作为有效值:CENTERBOTTOMLEFTRIGHTTOP

第二行按钮是用列表推导式创建的,使用了RELIEF值的列表。每个按钮的标签对应于常量的名称,因此您可以注意到每个按钮外观上的差异。

还有更多...

我们使用了一个属性来保留对我们的PhotoImage实例的引用,即使我们在__init__方法之外没有使用它。原因是图像在垃圾收集时会被清除,如果我们将其声明为局部变量并且方法存在,则会发生这种情况。

为了避免这种情况,始终记住在窗口仍然存在时保留对每个PhotoImage对象的引用。

创建文本输入框

Entry 小部件表示以单行显示的文本输入。它与LabelButton类一样,是 Tkinter 类中最常用的类之一。

如何做...

这个示例演示了如何创建一个登录表单,其中有两个输入框实例用于usernamepassword字段。password的每个字符都显示为星号,以避免以明文显示它:

import tkinter as tk 

class LoginApp(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.username = tk.Entry(self) 
        self.password = tk.Entry(self, show="*") 
        self.login_btn = tk.Button(self, text="Log in", 
                                   command=self.print_login) 
        self.clear_btn = tk.Button(self, text="Clear", 
                                   command=self.clear_form)         
        self.username.pack() 
        self.password.pack() 
        self.login_btn.pack(fill=tk.BOTH) 
        self.clear_btn.pack(fill=tk.BOTH) 

    def print_login(self): 
        print("Username: {}".format(self.username.get())) 
        print("Password: {}".format(self.password.get())) 

    def clear_form(self): 
        self.username.delete(0, tk.END) 
        self.password.delete(0, tk.END) 
        self.username.focus_set() 

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

Log in按钮在控制台中打印值,而Clear按钮删除两个输入框的内容,并将焦点返回到username的输入框:

它是如何工作的...

使用父窗口或框架作为第一个参数实例化 Entry 小部件,并使用一组可选关键字参数来配置其他选项。我们没有为对应username字段的条目指定任何选项。为了保持密码的机密性,我们使用字符串"*"指定show参数,它将显示每个键入的字符为星号。

使用get()方法,我们将检索当前文本作为字符串。这在print_login方法中用于在标准输出中显示条目的内容。

delete()方法接受两个参数,指示应删除的字符范围。请记住,索引从位置 0 开始,并且不包括范围末尾的字符。如果只传递一个参数,它将删除该位置的字符。

clear_form()方法中,我们从索引 0 删除到常量END,这意味着整个内容被删除。最后,我们将焦点设置为username条目。

还有更多...

可以使用insert()方法以编程方式修改 Entry 小部件的内容,该方法接受两个参数:

  • index:要插入文本的位置;请注意,条目位置是从 0 开始的

  • string:要插入的文本

使用delete()insert()的组合可以实现重置条目内容为默认值的常见模式:

entry.delete(0, tk.END) 
entry.insert(0, "default value") 

另一种模式是在文本光标的当前位置追加文本。在这里,您可以使用INSERT常量,而不必计算数值索引:

entry.insert(tk.INSERT, "cursor here")

Button类一样,Entry类还接受reliefstate选项来修改其边框样式和状态。请注意,在状态为"disabled""readonly"时,对delete()insert()的调用将被忽略。

另请参阅

  • 跟踪文本更改配方

  • 验证文本输入配方

跟踪文本更改

Tk变量允许您的应用程序在输入更改其值时得到通知。Tkinter中有四个变量类:BooleanVarDoubleVarIntVarStringVar。每个类都包装了相应 Python 类型的值,该值应与附加到变量的输入小部件的类型匹配。

如果您希望根据某些输入小部件的当前状态自动更新应用程序的某些部分,则此功能特别有用。

如何做...

在以下示例中,我们将使用textvariable选项将StringVar实例与我们的条目关联;此变量跟踪写操作,并使用show_message()方法作为回调:

import tkinter as tk 

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.var = tk.StringVar() 
        self.var.trace("w", self.show_message) 
        self.entry = tk.Entry(self, textvariable=self.var) 
        self.btn = tk.Button(self, text="Clear", 
                             command=lambda: self.var.set("")) 
        self.label = tk.Label(self) 
        self.entry.pack() 
        self.btn.pack() 
        self.label.pack() 

    def show_message(self, *args): 
        value = self.var.get() 
        text = "Hello, {}!".format(value) if value else "" 
        self.label.config(text=text) 

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

当您在 Entry 小部件中输入内容时,标签将使用由Tk变量值组成的消息更新其文本。例如,如果您输入单词Phara,标签将显示Hello, Phara!。如果输入为空,标签将不显示任何文本。为了向您展示如何以编程方式修改变量的内容,我们添加了一个按钮,当您单击它时清除条目:

它是如何工作的...

我们的应用程序构造函数的前几行实例化了StringVar并将回调附加到写入模式。有效的模式值如下:

  • "w":在写入变量时调用

  • "r":在读取变量时调用

  • "u"(对于unset):在删除变量时调用

当调用时,回调函数接收三个参数:内部变量名称,空字符串(在其他类型的Tk变量中使用),以及触发操作的模式。通过使用*args声明方法,我们使这些参数变为可选,因为我们在回调中没有使用这些值。

Tk包装器的get()方法返回变量的当前值,set()方法更新其值。它们还通知相应的观察者,因此通过 GUI 修改输入内容或单击“清除”按钮都将触发对show_message()方法的调用。

还有更多...

对于Entry小部件,Tk 变量是可选的,但对于其他小部件类(例如CheckbuttonRadiobutton类)来说,它们是必要的,以便正确工作。

另请参阅

  • 使用单选按钮创建选择食谱

  • 使用复选框实现开关食谱

验证文本输入

通常,文本输入代表遵循某些验证规则的字段,例如具有最大长度或匹配特定格式。一些应用程序允许在这些字段中键入任何类型的内容,并在提交整个表单时触发验证。

在某些情况下,我们希望阻止用户将无效内容输入文本字段。我们将看看如何使用 Entry 小部件的验证选项来实现此行为。

如何做...

以下应用程序显示了如何使用正则表达式验证输入:

import re 
import tkinter as tk 

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.pattern = re.compile("^\w{0,10}$") 
        self.label = tk.Label(self, text="Enter your username") 
        vcmd = (self.register(self.validate_username), "%i", "%P") 
        self.entry = tk.Entry(self, validate="key", 
                              validatecommand=vcmd, 
                              invalidcommand=self.print_error) 
        self.label.pack() 
        self.entry.pack(anchor=tk.W, padx=10, pady=10) 

    def validate_username(self, index, username): 
        print("Modification at index " + index) 
        return self.pattern.match(username) is not None 

    def print_error(self): 
        print("Invalid username character") 

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

如果您运行此脚本并在 Entry 小部件中键入非字母数字字符,则它将保持相同的内容并打印错误消息。当您尝试键入超过 10 个有效字符时,也会发生这种情况,因为正则表达式还限制了内容的长度。

工作原理...

validate选项设置为"key",我们将激活在任何内容修改时触发的输入验证。默认情况下,该值为"none",这意味着没有验证。

其他可能的值是"focusin""focusout",分别在小部件获得或失去焦点时进行验证,或者简单地使用"focus"在两种情况下进行验证。或者,我们可以使用"all"值在所有情况下进行验证。

validatecommand函数在每次触发验证时调用,如果新内容有效,则应返回true,否则返回false

由于我们需要更多信息来确定内容是否有效,我们使用Widget类的register方法创建了一个围绕 Python 函数的 Tcl 包装器。然后,您可以为将传递给 Python 函数的每个参数添加百分比替换。最后,我们将这些值分组为 Python 元组。这对应于我们示例中的以下行:

vcmd = (self.register(self.validate_username), "%i", "%P") 

一般来说,您可以使用以下任何一个替换:

  • %d:操作类型;插入为 1,删除为 0,否则为-1

  • %i:正在插入或删除的字符串的索引

  • %P:如果允许修改,则输入的值

  • %s:修改前的输入值

  • %S:正在插入或删除的字符串内容

  • %v:当前设置的验证类型

  • %V:触发操作的验证类型

  • %W:Entry 小部件的名称

invalidcommand选项接受一个在validatecommand返回false时调用的函数。这个选项也可以应用相同的百分比替换,但在我们的示例中,我们直接传递了我们类的print_error()方法。

还有更多...

Tcl/Tk 文档建议不要混合validatecommandtextvariable选项,因为将无效值设置为Tk变量将关闭验证。如果validatecommand函数不返回布尔值,也会发生同样的情况。

如果您不熟悉re模块,可以在官方 Python 文档的docs.python.org/3.6/howto/regex.html中查看有关正则表达式的详细介绍。

另请参阅

  • 创建文本输入食谱

选择数值

以前的食谱介绍了如何处理文本输入;我们可能希望强制某些输入只包含数字值。这是SpinboxScale类的用例——这两个小部件允许用户从范围或有效选项列表中选择数值,但它们在显示和配置方式上有几个不同之处。

如何做...

此程序具有用于从05选择整数值的SpinboxScale

import tkinter as tk 

class App(tk.Tk):
    def __init__(self): 
        super().__init__() 
        self.spinbox = tk.Spinbox(self, from_=0, to=5) 
        self.scale = tk.Scale(self, from_=0, to=5, 
                              orient=tk.HORIZONTAL) 
        self.btn = tk.Button(self, text="Print values", 
                             command=self.print_values) 
        self.spinbox.pack() 
        self.scale.pack() 
        self.btn.pack() 

    def print_values(self): 
        print("Spinbox: {}".format(self.spinbox.get())) 
        print("Scale: {}".format(self.scale.get())) 

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

在上面的代码中,出于调试目的,我们添加了一个按钮,当您单击它时,它会打印每个小部件的值:

它是如何工作的...

这两个类都接受from_to选项,以指示有效值的范围——由于from选项最初是在 Tcl/Tk 中定义的,但它在 Python 中是一个保留关键字,因此需要添加下划线。

Scale类的一个方便功能是resolution选项,它设置了舍入的精度。例如,分辨率为 0.2 将允许用户选择值 0.0、0.2、0.4 等。此选项的默认值为 1,因此小部件将所有值舍入到最接近的整数。

与往常一样,可以使用get()方法检索每个小部件的值。一个重要的区别是,Spinbox将数字作为字符串返回,而Scale返回一个整数值,如果舍入接受小数值,则返回一个浮点值。

还有更多...

Spinbox类具有与 Entry 小部件类似的配置,例如textvariablevalidate选项。您可以将所有这些模式应用于旋转框,主要区别在于它限制为数值。

另请参阅

  • 跟踪文本更改食谱

使用单选按钮创建选择

使用 Radiobutton 小部件,您可以让用户在多个选项中进行选择。这种模式适用于相对较少的互斥选择。

如何做...

您可以使用 Tkinter 变量连接多个Radiobutton实例,以便当您单击未选择的选项时,它将取消选择先前选择的任何其他选项。

在下面的程序中,我们为RedGreenBlue选项创建了三个单选按钮。每次单击单选按钮时,它都会打印相应颜色的小写名称:

import tkinter as tk

COLORS = [("Red", "red"), ("Green", "green"), ("Blue", "blue")]

class ChoiceApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.var = tk.StringVar()
        self.var.set("red")
        self.buttons = [self.create_radio(c) for c in COLORS]
        for button in self.buttons:
            button.pack(anchor=tk.W, padx=10, pady=5)

    def create_radio(self, option):
        text, value = option
        return tk.Radiobutton(self, text=text, value=value, 
                              command=self.print_option, 
                              variable=self.var)

    def print_option(self):
        print(self.var.get())

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

如果您运行此脚本,它将显示已选择红色单选按钮的应用程序:

它是如何工作的...

为了避免重复Radiobutton初始化的代码,我们定义了一个实用方法,该方法从列表推导中调用。我们解压了COLORS列表的每个元组的值,然后将这些局部变量作为选项传递给Radiobutton。请记住,尽可能尝试不要重复自己。

由于StringVar在所有Radiobutton实例之间共享,它们会自动连接,并且我们强制用户只能选择一个选项。

还有更多...

我们在程序中设置了默认值为"red";但是,如果我们省略此行,且StringVar的值与任何单选按钮的值都不匹配会发生什么?它将匹配tristatevalue选项的默认值,即空字符串。这会导致小部件显示在特殊的“三态”或不确定模式下。虽然可以使用config()方法修改此选项,但最好的做法是设置一个明智的默认值,以便变量以有效状态初始化。

使用复选框实现开关

通常使用复选框和选项列表实现两个选择之间的选择,其中每个选择与其余选择无关。正如我们将在下一个示例中看到的,这些概念可以使用 Checkbutton 小部件来实现。

如何做...

以下应用程序显示了如何创建 Checkbutton,它必须连接到IntVar变量才能检查按钮状态:

import tkinter as tk

class SwitchApp(tk.Tk):
    def __init__(self):
        super().__init__() 
        self.var = tk.IntVar() 
        self.cb = tk.Checkbutton(self, text="Active?",  
                                 variable=self.var, 
                                 command=self.print_value) 
        self.cb.pack() 

    def print_value(self): 
        print(self.var.get()) 

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

在上面的代码中,我们只是在每次单击小部件时打印小部件的值:

它是如何工作的...

与 Button 小部件一样,Checkbutton 也接受commandtext选项。

使用onvalueoffvalue选项,我们可以指定按钮打开和关闭时使用的值。我们使用整数变量,因为默认情况下这些值分别为10;但是,您也可以将它们设置为任何其他整数值。

还有更多...

对于 Checkbuttons,也可以使用其他变量类型:

var = tk.StringVar() 
var.set("OFF") 
checkbutton_active = tk.Checkbutton(master, text="Active?", variable=self.var, 
                                    onvalue="ON", offvalue="OFF", 
                                    command=update_value)

唯一的限制是要将onvalueoffvalue与 Tkinter 变量的类型匹配;在这种情况下,由于"ON""OFF"是字符串,因此变量应该是StringVar。否则,当尝试设置不同类型的相应值时,Tcl 解释器将引发错误。

另请参阅

  • 跟踪文本更改的方法

  • 使用单选按钮创建选择的方法

显示项目列表

Listbox 小部件包含用户可以使用鼠标或键盘选择的文本项。这种选择可以是单个的或多个的,这取决于小部件的配置。

如何做...

以下程序创建了一个星期几的列表选择。有一个按钮来打印实际选择,以及一个按钮列表来更改选择模式:

import tkinter as tk 

DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", 
        "Friday", "Saturday", "Sunday"] 
MODES = [tk.SINGLE, tk.BROWSE, tk.MULTIPLE, tk.EXTENDED] 

class ListApp(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.list = tk.Listbox(self)  
        self.list.insert(0, *DAYS) 
        self.print_btn = tk.Button(self, text="Print selection", 
                                   command=self.print_selection) 
        self.btns = [self.create_btn(m) for m in MODES] 

        self.list.pack() 
        self.print_btn.pack(fill=tk.BOTH) 
        for btn in self.btns: 
            btn.pack(side=tk.LEFT) 

    def create_btn(self, mode): 
        cmd = lambda: self.list.config(selectmode=mode) 
        return tk.Button(self, command=cmd, 
                         text=mode.capitalize()) 

    def print_selection(self): 
        selection = self.list.curselection() 
        print([self.list.get(i) for i in selection]) 

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

您可以尝试更改选择模式并打印所选项目:

它是如何工作的...

我们创建一个空的 Listbox 对象,并使用insert()方法添加所有文本项。0 索引表示应在列表的开头添加项目。在下面的代码片段中,我们解包了DAYS列表,但是可以使用END常量将单独的项目附加到末尾:

self.list.insert(tk.END, "New item") 

使用curselection()方法检索当前选择。它返回所选项目的索引,以便将它们转换为相应的文本项目,我们为每个索引调用了get()方法。最后,为了调试目的,列表将被打印在标准输出中。

在我们的示例中,selectmode选项可以通过编程方式进行更改,以探索不同的行为,如下所示:

  • SINGLE:单选

  • BROWSE:可以使用上下键移动的单选

  • MULTIPLE:多选

  • EXTENDED:使用ShiftCtrl键选择范围的多选

还有更多...

如果文本项的数量足够大,可能需要添加垂直滚动条。您可以使用yscrollcommand选项轻松连接它。在我们的示例中,我们可以将两个小部件都包装在一个框架中,以保持相同的布局。记得在打包滚动条时指定fill选项,以便在y轴上填充可用空间。

def __init__(self):
    self.frame = tk.Frame(self) 
    self.scroll = tk.Scrollbar(self.frame, orient=tk.VERTICAL) 
    self.list = tk.Listbox(self.frame, yscrollcommand=self.scroll.set) 
    self.scroll.config(command=self.list.yview) 
    # ... 
    self.frame.pack() 
    self.list.pack(side=tk.LEFT) 
    self.scroll.pack(side=tk.LEFT, fill=tk.Y) 

同样,对于水平轴,还有一个xscrollcommand选项。

另请参阅

  • 使用单选按钮创建选择的方法

处理鼠标和键盘事件

能够对事件做出反应是 GUI 应用程序开发中最基本但最重要的主题之一,因为它决定了用户如何与程序进行交互。

按键盘上的键和用鼠标点击项目是一些常见的事件类型,在一些 Tkinter 类中会自动处理。例如,这种行为已经在Button小部件类的command选项上实现,它调用指定的回调函数。

有些事件可以在没有用户交互的情况下触发,例如从一个小部件到另一个小部件的程序性输入焦点更改。

如何做...

您可以使用bind方法将事件绑定到小部件。以下示例将一些鼠标事件绑定到Frame实例:

import tkinter as tk 

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        frame = tk.Frame(self, bg="green", 
                         height=100, width=100) 
        frame.bind("<Button-1>", self.print_event) 
        frame.bind("<Double-Button-1>", self.print_event) 
        frame.bind("<ButtonRelease-1>", self.print_event) 
        frame.bind("<B1-Motion>", self.print_event) 
        frame.bind("<Enter>", self.print_event) 
        frame.bind("<Leave>", self.print_event) 
        frame.pack(padx=50, pady=50) 

    def print_event(self, event): 
        position = "(x={}, y={})".format(event.x, event.y) 
        print(event.type, "event", position) 

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

所有事件都由我们的类的print_event()方法处理,该方法在控制台中打印事件类型和鼠标位置。您可以通过单击鼠标上的绿色框架并在开始打印事件消息时将其移动来尝试它。

以下示例包含一个带有一对绑定的 Entry 小部件;一个用于在输入框获得焦点时触发的事件,另一个用于所有按键事件:

import tkinter as tk 

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        entry = tk.Entry(self) 
        entry.bind("<FocusIn>", self.print_type)  
        entry.bind("<Key>", self.print_key) 
        entry.pack(padx=20, pady=20) 

    def print_type(self, event): 
        print(event.type) 

    def print_key(self, event): 
        args = event.keysym, event.keycode, event.char 
        print("Symbol: {}, Code: {}, Char: {}".format(*args)) 

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

该程序将输出的第一条消息是当您将焦点设置在 Entry 小部件上时的FocusIn事件。如果您尝试一下,您会发现它还会显示与不可打印字符不对应的键的事件,比如箭头键或回车键。

它是如何工作的...

bind方法在widget类中定义,并接受三个参数,一个事件sequence,一个callback函数和一个可选的add字符串:

widget.bind(sequence, callback, add='') 

sequence字符串使用<modifier-type-detail>的语法。

首先,修饰符是可选的,允许您指定事件的一般类型的其他组合:

  • Shift: 当用户按下Shift键时

  • Alt: 当用户按下Alt键时

  • 控制: 当用户按下Ctrl键时

  • Lock: 当用户按下Shift锁定时

  • Double: 当事件快速连续发生两次时

  • Triple: 当事件快速连续发生三次时

事件类型确定事件的一般类型:

  • ButtonPressButton: 鼠标按钮按下时生成的事件

  • ButtonRelease: 鼠标按钮释放时生成的事件

  • Enter: 当鼠标移动到小部件上时生成的事件

  • Leave: 当鼠标指针离开小部件时生成的事件

  • FocusIn: 当小部件获得输入焦点时生成的事件

  • FocusOut: 当小部件失去输入焦点时生成的事件

  • KeyPressKey: 按下键时生成的事件

  • KeyRelease: 松开键时生成的事件

  • Motion: 鼠标移动时生成的事件

详细信息也是可选的,用于指示鼠标按钮或键:

  • 对于鼠标事件,1 是左按钮,2 是中间按钮,3 是右按钮。

  • 对于键盘事件,它是键字符。特殊键使用键符号;一些常见的示例是回车、TabEsc、上、下、右、左、Backspace和功能键(从F1F12)。

callback函数接受一个事件参数。对于鼠标事件,它具有以下属性:

  • xy: 当前鼠标位置(以像素为单位)

  • x_rooty_root: 与xy相同,但相对于屏幕左上角

  • num: 鼠标按钮编号

对于键盘事件,它包含这些属性:

  • char: 按下的字符代码作为字符串

  • keysym: 按下的键符号

  • keycode: 按下的键码

在这两种情况下,事件都有widget属性,引用生成事件的实例,以及type,指定事件类型。

我们强烈建议您为callback函数定义方法,因为您还将拥有对类实例的引用,因此您可以轻松访问每个widget属性。

最后,add参数可以是'',以替换callback函数(如果有先前的绑定),或者是'+',以添加回调并保留旧的回调。

还有更多...

除了这里描述的事件类型之外,还有其他类型,在某些情况下可能会有用,比如当小部件被销毁时生成的<Destroy>事件,或者当小部件的大小或位置发生变化时发送的<Configure>事件。

您可以查看 Tcl/Tk 文档,了解事件类型的完整列表www.tcl.tk/man/tcl/TkCmd/bind.htm#M7

另请参阅

  • 构建 Tkinter 应用程序的配方

设置主窗口的图标、标题和大小

Tk实例与普通小部件不同,它的配置方式也不同,因此我们将探讨一些基本方法,允许我们自定义它的显示方式。

如何做到...

这段代码创建了一个带有自定义标题和图标的主窗口。它的宽度为 400 像素,高度为 200 像素,与屏幕左上角的每个轴向的间隔为 10 像素:

import tkinter as tk 

class App(tk.Tk): 
    def __init__(self): 
        super().__init__() 
        self.title("My Tkinter app") 
        self.iconbitmap("python.ico") 
        self.geometry("400x200+10+10") 

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

该程序假定您在脚本所在的目录中有一个名为python.ico的有效 ICO 文件。

它是如何工作的...

Tk类的title()iconbitmap()方法非常自描述——第一个设置窗口标题,而第二个接受与窗口关联的图标的路径。

geometry()方法使用遵循以下模式的字符串配置窗口的大小:

{width}x{height}+{offset_x}+{offset_y}

如果您向应用程序添加更多的辅助窗口,这些方法也适用于Toplevel类。

还有更多...

如果您想使应用程序全屏,将对geometry()方法的调用替换为self.state("zoomed")

第二章:窗口布局

在本章中,我们将介绍以下食谱:

  • 使用框架对小部件进行分组

  • 使用 Pack 几何管理器

  • 使用 Grid 几何管理器

  • 使用 Place 几何管理器

  • 使用 FrameLabel 小部件对输入进行分组

  • 动态布置小部件

  • 创建水平和垂直滚动条

介绍

小部件确定用户可以在 GUI 应用程序中执行的操作;但是,我们应该注意它们的放置和我们与该安排建立的关系。有效的布局帮助用户识别每个图形元素的含义和优先级,以便他们可以快速理解如何与我们的程序交互。

布局还确定了用户期望在整个应用程序中一致找到的视觉外观,例如始终将确认按钮放在屏幕右下角。尽管这些信息对我们作为开发人员来说可能是显而易见的,但如果我们不按照自然顺序引导他们通过应用程序,最终用户可能会感到不知所措。

本章将深入探讨 Tkinter 提供的不同机制,用于布置和分组小部件以及控制其他属性,例如它们的大小或间距。

使用框架对小部件进行分组

框架表示窗口的矩形区域,通常用于复杂布局以包含其他小部件。由于它们有自己的填充、边框和背景,您可以注意到小部件组在逻辑上是相关的。

框架的另一个常见模式是封装应用程序功能的一部分,以便您可以创建一个抽象,隐藏子部件的实现细节。

我们将看到一个示例,涵盖了从Frame类继承并公开包含小部件上的某些信息的组件的两种情况。

准备就绪

我们将构建一个应用程序,其中包含两个列表,第一个列表中有一系列项目,第二个列表最初为空。两个列表都是可滚动的,并且您可以使用两个中央按钮在它们之间移动项目:

如何做…

我们将定义一个Frame子类来表示可滚动列表,然后创建该类的两个实例。两个按钮也将直接添加到主窗口:

import tkinter as tk

class ListFrame(tk.Frame):
    def __init__(self, master, items=[]):
        super().__init__(master)
        self.list = tk.Listbox(self)
        self.scroll = tk.Scrollbar(self, orient=tk.VERTICAL,
                                   command=self.list.yview)
        self.list.config(yscrollcommand=self.scroll.set)
        self.list.insert(0, *items)
        self.list.pack(side=tk.LEFT)
        self.scroll.pack(side=tk.LEFT, fill=tk.Y)

    def pop_selection(self):
        index = self.list.curselection()
        if index:
            value = self.list.get(index)
            self.list.delete(index)
            return value

    def insert_item(self, item):
        self.list.insert(tk.END, item)

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        months = ["January", "February", "March", "April",
                  "May", "June", "July", "August", "September",
                  "October", "November", "December"]
        self.frame_a = ListFrame(self, months)
        self.frame_b = ListFrame(self)
        self.btn_right = tk.Button(self, text=">",
                                   command=self.move_right)
        self.btn_left = tk.Button(self, text="<",
                                  command=self.move_left)

        self.frame_a.pack(side=tk.LEFT, padx=10, pady=10)
        self.frame_b.pack(side=tk.RIGHT, padx=10, pady=10)
        self.btn_right.pack(expand=True, ipadx=5)
        self.btn_left.pack(expand=True, ipadx=5)

    def move_right(self):
        self.move(self.frame_a, self.frame_b)

    def move_left(self):
        self.move(self.frame_b, self.frame_a)

    def move(self, frame_from, frame_to):
        value = frame_from.pop_selection()
        if value:
            frame_to.insert_item(value)

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

工作原理…

我们的ListFrame类只有两种方法与内部列表进行交互:pop_selection()insert_item()。第一个返回并删除当前选择的项目,如果没有选择项目,则返回 None,而第二个在列表末尾插入新项目。

这些方法用于父类中将项目从一个列表转移到另一个列表:

def move(self, frame_from, frame_to):
    value = frame_from.pop_selection()
    if value:
        frame_to.insert_item(value)

我们还利用父框架容器正确地打包它们,以适当的填充:

# ...
self.frame_a.pack(side=tk.LEFT, padx=10, pady=10) self.frame_b.pack(side=tk.RIGHT, padx=10, pady=10)

由于这些框架,我们对几何管理器的调用在全局布局中更加隔离和有组织。

还有更多...

这种方法的另一个好处是,它允许我们在每个容器小部件中使用不同的几何管理器,例如在框架内使用grid()来布置小部件,在主窗口中使用pack()来布置框架。

但是,请记住,在 Tkinter 中不允许在同一个容器中混合使用这些几何管理器,否则会使您的应用程序崩溃。

另请参阅

  • 使用 Pack 几何管理器食谱

使用 Pack 几何管理器

在之前的食谱中,我们已经看到创建小部件并不会自动在屏幕上显示它。我们调用了每个小部件上的pack()方法来实现这一点,这意味着我们使用了 Pack 几何管理器。

这是 Tkinter 中三种可用的几何管理器之一,非常适合简单的布局,例如当您想要将所有小部件放在彼此上方或并排时。

准备就绪

假设我们想在应用程序中实现以下布局:

它由三行组成,最后一行有三个小部件并排放置。在这种情况下,Pack 布局管理器可以轻松地按预期添加小部件,而无需额外的框架。

操作步骤

我们将使用五个具有不同文本和背景颜色的Label小部件来帮助我们识别每个矩形区域:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        label_a = tk.Label(self, text="Label A", bg="yellow")
        label_b = tk.Label(self, text="Label B", bg="orange")
        label_c = tk.Label(self, text="Label C", bg="red")
        label_d = tk.Label(self, text="Label D", bg="green")
        label_e = tk.Label(self, text="Label E", bg="blue")

        opts = { 'ipadx': 10, 'ipady': 10, 'fill': tk.BOTH }
        label_a.pack(side=tk.TOP, **opts)
        label_b.pack(side=tk.TOP, **opts)
        label_c.pack(side=tk.LEFT, **opts)
        label_d.pack(side=tk.LEFT, **opts)
        label_e.pack(side=tk.LEFT, **opts)

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

我们还向opts字典中添加了一些选项,以便清楚地确定每个区域的大小:

工作原理

为了更好地理解 Pack 布局管理器,我们将逐步解释它如何将小部件添加到父容器中。在这里,我们特别关注side选项的值,它指示小部件相对于下一个将被打包的小部件的位置。

首先,我们将两个标签打包到屏幕顶部。虽然tk.TOP常量是side选项的默认值,但我们明确设置它以清楚地区分它与我们使用tk.LEFT值的调用。

然后,我们使用side选项设置为tk.LEFT来打包下面的三个标签,这会使它们并排放置:

指定label_e上的 side 实际上并不重要,只要它是我们添加到容器中的最后一个小部件即可。

请记住,这就是在使用 Pack 布局管理器时顺序如此重要的原因。为了防止复杂布局中出现意外结果,通常将小部件与框架分组,这样当您将所有小部件打包到一个框架中时,就不会干扰其他小部件的排列。

在这些情况下,我们强烈建议您使用网格布局管理器,因为它允许您直接调用几何管理器设置每个小部件的位置,并且避免了额外框架的需要。

还有更多...

除了tk.TOPtk.LEFT,您还可以将tk.BOTTOMtk.RIGHT常量传递给side选项。它们执行相反的堆叠,正如它们的名称所暗示的那样;但是,这可能是反直觉的,因为我们遵循的自然顺序是从上到下,从左到右。

例如,如果我们在最后三个小部件中用tk.RIGHT替换tk.LEFT的值,它们从左到右的顺序将是label_elabel_dlabel_c

参见

  • 使用网格布局管理器食谱

  • 使用 Place 布局管理器食谱

使用网格布局管理器

网格布局管理器被认为是三种布局管理器中最通用的。它直接重新组合了通常用于用户界面设计的网格概念,即一个二维表格,分为行和列,其中每个单元格代表小部件的可用空间。

准备工作

我们将演示如何使用网格布局管理器来实现以下布局:

这可以表示为一个 3 x 3 的表格,其中第二列和第三列的小部件跨越两行,底部行的小部件跨越三列。

操作步骤

与前面的食谱一样,我们将使用五个具有不同背景的标签来说明单元格的分布:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        label_a = tk.Label(self, text="Label A", bg="yellow")
        label_b = tk.Label(self, text="Label B", bg="orange")
        label_c = tk.Label(self, text="Label C", bg="red")
        label_d = tk.Label(self, text="Label D", bg="green")
        label_e = tk.Label(self, text="Label E", bg="blue")

        opts = { 'ipadx': 10, 'ipady': 10 , 'sticky': 'nswe' }
        label_a.grid(row=0, column=0, **opts)
        label_b.grid(row=1, column=0, **opts)
        label_c.grid(row=0, column=1, rowspan=2, **opts)
        label_d.grid(row=0, column=2, rowspan=2, **opts)
        label_e.grid(row=2, column=0, columnspan=3, **opts)

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

我们还传递了一个选项字典,以添加一些内部填充并将小部件扩展到单元格中的所有可用空间。

工作原理

label_alabel_b的放置几乎是不言自明的:它们分别占据第一列的第一行和第二行,记住网格位置是从零开始计数的:

为了扩展label_clabel_d跨越多个单元格,我们将把rowspan选项设置为2,这样它们将跨越两个单元格,从rowcolumn选项指示的位置开始。最后,我们将使用columnspan选项将label_e放置到3

需要强调的是,与 Pack 几何管理器相比,可以更改对每个小部件的grid()调用的顺序,而不修改最终布局。

还有更多...

sticky选项表示小部件应粘附的边界,用基本方向表示:北、南、西和东。这些值由 Tkinter 常量tk.Ntk.Stk.Wtk.E表示,以及组合版本tk.NWtk.NEtk.SWtk.SE

例如,sticky=tk.N将小部件对齐到单元格的顶部边界(北),而sticky=tk.SE将小部件放置在单元格的右下角(东南)。

由于这些常量代表它们对应的小写字母,我们用"nswe"字符串简写了tk.N + tk.S + tk.W + tk.E表达式。这意味着小部件应该在水平和垂直方向上都扩展,类似于 Pack 几何管理器的fill=tk.BOTH选项。

如果sticky选项没有传递值,则小部件将在单元格内居中。

另请参阅

  • 使用 Pack 几何管理器配方

  • 使用 Place 几何管理器配方

使用 Place 几何管理器

Place 几何管理器允许您以绝对或相对于另一个小部件的位置和大小。

在三种几何管理器中,它是最不常用的一种。另一方面,它可以适应一些复杂的情况,例如您想自由定位一个小部件或重叠一个先前放置的小部件。

准备工作

为了演示如何使用 Place 几何管理器,我们将通过混合绝对位置和相对位置和大小来复制以下布局:

如何做...

我们将显示的标签具有不同的背景,并按从左到右和从上到下的顺序定义:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        label_a = tk.Label(self, text="Label A", bg="yellow")
        label_b = tk.Label(self, text="Label B", bg="orange")
        label_c = tk.Label(self, text="Label C", bg="red")
        label_d = tk.Label(self, text="Label D", bg="green")
        label_e = tk.Label(self, text="Label E", bg="blue")

        label_a.place(relwidth=0.25, relheight=0.25)
        label_b.place(x=100, anchor=tk.N,
                      width=100, height=50)
        label_c.place(relx=0.5, rely=0.5, anchor=tk.CENTER,
                      relwidth=0.5, relheight=0.5)
        label_d.place(in_=label_c, anchor=tk.N + tk.W,
                      x=2, y=2, relx=0.5, rely=0.5,
                      relwidth=0.5, relheight=0.5)
        label_e.place(x=200, y=200, anchor=tk.S + tk.E,
                      relwidth=0.25, relheight=0.25)

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

如果运行前面的程序,您可以看到label_clabel_d在屏幕中心的重叠,这是我们使用其他几何管理器没有实现的。

它是如何工作的...

第一个标签的relwidthrelheight选项设置为0.25,这意味着它的宽度和高度是其父容器的 25%。默认情况下,小部件放置在x=0y=0位置,并对齐到西北,即屏幕的左上角。

第二个标签放置在绝对位置x=100,并使用anchor选项设置为tk.N(北)常量与顶部边界对齐。在这里,我们还使用widthheight指定了绝对大小。

第三个标签使用相对定位在窗口中心,并将anchor设置为tk.CENTER。请记住,relxrelwidth的值为0.5表示父容器宽度的一半,relyrelheight的值为0.5表示父容器高度的一半。

第四个标签通过将其作为in_参数放置在label_c上(请注意,Tkinter 在其后缀中添加了下划线,因为in是一个保留关键字)。使用in_时,您可能会注意到对齐不是几何上精确的。在我们的示例中,我们必须在每个方向上添加 2 个像素的偏移量,以完全重叠label_c的右下角。

最后,第五个标签使用绝对定位和相对大小。正如您可能已经注意到的那样,这些尺寸可以很容易地切换,因为我们假设父容器为 200 x 200 像素;但是,如果调整主窗口的大小,只有相对权重才能按预期工作。您可以通过调整窗口大小来测试此行为。

还有更多...

Place 几何管理器的另一个重要优势是它可以与 Pack 或 Grid 一起使用。

例如,假设您希望在右键单击小部件时动态显示标题。您可以使用 Label 小部件表示此标题,并将其放置在单击小部件的相对位置:

def show_caption(self, event):
    caption = tk.Label(self, ...)
    caption.place(in_=event.widget, x=event.x, y=event.y)
    # ...

作为一般建议,我们建议您在 Tkinter 应用程序中尽可能多地使用其他几何管理器,并且仅在需要自定义定位的专门情况下使用此几何管理器。

另请参阅

  • 使用 Pack 几何管理器的食谱

  • 使用网格几何管理器的食谱

使用 LabelFrame 小部件对输入进行分组

LabelFrame类可用于对多个输入小部件进行分组,指示它们表示的逻辑实体的标签。它通常用于表单,与Frame小部件非常相似。

准备就绪

我们将构建一个带有一对LabelFrame实例的表单,每个实例都有其相应的子输入小部件:

如何做…

由于此示例的目的是显示最终布局,我们将添加一些小部件,而不将它们的引用保留为属性:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        group_1 = tk.LabelFrame(self, padx=15, pady=10,
                               text="Personal Information")
        group_1.pack(padx=10, pady=5)

        tk.Label(group_1, text="First name").grid(row=0)
        tk.Label(group_1, text="Last name").grid(row=1)
        tk.Entry(group_1).grid(row=0, column=1, sticky=tk.W)
        tk.Entry(group_1).grid(row=1, column=1, sticky=tk.W)

        group_2 = tk.LabelFrame(self, padx=15, pady=10,
                               text="Address")
        group_2.pack(padx=10, pady=5)

        tk.Label(group_2, text="Street").grid(row=0)
        tk.Label(group_2, text="City").grid(row=1)
        tk.Label(group_2, text="ZIP Code").grid(row=2)
        tk.Entry(group_2).grid(row=0, column=1, sticky=tk.W)
        tk.Entry(group_2).grid(row=1, column=1, sticky=tk.W)
        tk.Entry(group_2, width=8).grid(row=2, column=1,
                                        sticky=tk.W)

        self.btn_submit = tk.Button(self, text="Submit")
        self.btn_submit.pack(padx=10, pady=10, side=tk.RIGHT)

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

工作原理…

LabelFrame小部件采用labelwidget选项来设置用作标签的小部件。如果不存在,它将显示作为text选项传递的字符串。例如,可以用以下语句替换tk.LabelFrame(master, text="Info")的实例:

label = tk.Label(master, text="Info", ...)
frame = tk.LabelFrame(master, labelwidget=label)
# ...
frame.pack()

这将允许您进行任何类型的自定义,例如添加图像。请注意,我们没有为标签使用任何几何管理器,因为当您放置框架时,它会被管理。

动态布局小部件

网格几何管理器在简单和高级布局中都很容易使用,也是与小部件列表结合使用的强大机制。

我们将看看如何通过列表推导和zipenumerate内置函数,可以减少行数并仅用几行调用几何管理器方法。

准备就绪

我们将构建一个应用程序,其中包含四个Entry小部件,每个小部件都有相应的标签,指示输入的含义。我们还将添加一个按钮来打印所有条目的值:

我们将使用小部件列表而不是创建和分配每个小部件到单独的属性。由于我们将在这些列表上进行迭代时跟踪索引,因此我们可以轻松地使用适当的column选项调用grid()方法。

如何做…

我们将使用zip函数聚合标签和输入列表。按钮将单独创建和显示,因为它与其余小部件没有共享任何选项:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        fields = ["First name", "Last name", "Phone", "Email"]
        labels = [tk.Label(self, text=f) for f in fields]
        entries = [tk.Entry(self) for _ in fields]
        self.widgets = list(zip(labels, entries))
        self.submit = tk.Button(self, text="Print info",
                                command=self.print_info)

        for i, (label, entry) in enumerate(self.widgets):
            label.grid(row=i, column=0, padx=10, sticky=tk.W)
            entry.grid(row=i, column=1, padx=10, pady=5)
        self.submit.grid(row=len(fields), column=1, sticky=tk.E,
                         padx=10, pady=10)

    def print_info(self):
        for label, entry in self.widgets:
            print("{} = {}".format(label.cget("text"), "=", entry.get()))

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

您可以在每个输入上输入不同的文本,并单击“打印信息”按钮以验证每个元组包含相应的标签和输入。

工作原理…

每个列表推导式都会迭代字段列表的字符串。标签使用每个项目作为显示的文本,输入只需要父容器的引用——下划线是一个常见的习惯用法,表示变量值被忽略。

从 Python 3 开始,zip返回一个迭代器而不是列表,因此我们使用列表函数消耗聚合。结果,widgets属性包含一个可以安全多次迭代的元组列表:

fields = ["First name", "Last name", "Phone", "Email"]
labels = [tk.Label(self, text=f) for f in fields]
entries = [tk.Entry(self) for _ in fields]
self.widgets = list(zip(labels, entries))

现在,我们必须在每个小部件元组上调用几何管理器。使用enumerate函数,我们可以跟踪每次迭代的索引并将其作为号传递:

for i, (label, entry) in enumerate(self.widgets):
    label.grid(row=i, column=0, padx=10, sticky=tk.W)
    entry.grid(row=i, column=1, padx=10, pady=5)

请注意,我们使用了for i, (label, entry) in ...语法,因为我们必须解压使用enumerate生成的元组,然后解压widgets属性的每个元组。

print_info()回调中,我们迭代小部件以打印每个标签文本及其相应的输入值。要检索标签的text,我们使用了cget()方法,它允许您通过名称获取小部件选项的值。

创建水平和垂直滚动条

在 Tkinter 中,几何管理器会占用所有必要的空间,以适应其父容器中的所有小部件。但是,如果容器具有固定大小或超出屏幕大小,将会有一部分区域对用户不可见。

在 Tkinter 中,滚动条小部件不会自动添加,因此您必须像其他类型的小部件一样创建和布置它们。另一个考虑因素是,只有少数小部件类具有配置选项,使其能够连接到滚动条。

为了解决这个问题,您将学习如何利用Canvas小部件的灵活性使任何容器可滚动。

准备就绪

为了演示CanvasScrollbar类的组合,创建一个可调整大小和可滚动的框架,我们将构建一个通过加载图像动态更改大小的应用程序。

当单击“加载图像”按钮时,它会将自身移除,并将一个大于可滚动区域的图像加载到Canvas中-例如,我们使用了一个预定义的图像,但您可以修改此程序以使用文件对话框选择任何其他 GIF 图像:

这将启用水平和垂直滚动条,如果主窗口被调整大小,它们会自动调整自己:

操作步骤…

当我们将在单独的章节中深入了解 Canvas 小部件的功能时,本应用程序将介绍其标准滚动界面和create_window()方法。请注意,此脚本需要将文件python.gif放置在相同的目录中:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.scroll_x = tk.Scrollbar(self, orient=tk.HORIZONTAL)
        self.scroll_y = tk.Scrollbar(self, orient=tk.VERTICAL)
        self.canvas = tk.Canvas(self, width=300, height=100,
                                xscrollcommand=self.scroll_x.set,
                                yscrollcommand=self.scroll_y.set)
        self.scroll_x.config(command=self.canvas.xview)
        self.scroll_y.config(command=self.canvas.yview)

        self.frame = tk.Frame(self.canvas)
        self.btn = tk.Button(self.frame, text="Load image",
                             command=self.load_image)
        self.btn.pack()

        self.canvas.create_window((0, 0), window=self.frame,  
                                          anchor=tk.NW)

        self.canvas.grid(row=0, column=0, sticky="nswe")
        self.scroll_x.grid(row=1, column=0, sticky="we")
        self.scroll_y.grid(row=0, column=1, sticky="ns")

        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind("<Configure>", self.resize)
        self.update_idletasks()
        self.minsize(self.winfo_width(), self.winfo_height())

    def resize(self, event):
        region = self.canvas.bbox(tk.ALL)
        self.canvas.configure(scrollregion=region)

    def load_image(self):
        self.btn.destroy()
        self.image = tk.PhotoImage(file="python.gif")
        tk.Label(self.frame, image=self.image).pack()

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

工作原理…

我们应用程序的第一行创建了滚动条,并使用xscrollcommandyscrollcommand选项将它们连接到Canvas对象,这些选项分别使用scroll_xscroll_yset()方法的引用-这是负责移动滚动条滑块的方法。

还需要在定义Canvas后配置每个滚动条的command选项:

self.scroll_x = tk.Scrollbar(self, orient=tk.HORIZONTAL)
self.scroll_y = tk.Scrollbar(self, orient=tk.VERTICAL)
self.canvas = tk.Canvas(self, width=300, height=100,
                        xscrollcommand=self.scroll_x.set,
                        yscrollcommand=self.scroll_y.set)
self.scroll_x.config(command=self.canvas.xview)
self.scroll_y.config(command=self.canvas.yview)

也可以先创建Canvas,然后在实例化滚动条时配置其选项。

下一步是使用create_window()方法将框架添加到我们可滚动的Canvas中。它接受的第一个参数是使用window选项传递的小部件的位置。由于Canvas小部件的xy轴从左上角开始,我们将框架放置在(0, 0)位置,并使用anchor=tk.NW将其对齐到该角落(西北):

self.frame = tk.Frame(self.canvas)
# ...
self.canvas.create_window((0, 0), window=self.frame, anchor=tk.NW)

然后,我们将使用rowconfigure()columnconfigure()方法使第一行和列可调整大小。weight选项指示相对权重以分配额外的空间,但在我们的情况下,没有更多的行或列需要调整大小。

绑定到<Configure>事件将帮助我们在主窗口调整大小时正确重新配置canvas。处理这种类型的事件遵循我们在上一章中看到的相同原则,以处理鼠标和键盘事件:

self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
self.bind("<Configure>", self.resize)

最后,我们将使用winfo_width()winfo_height()方法设置主窗口的最小大小,这些方法可以检索当前的宽度和高度。

为了获得容器的真实大小,我们必须通过调用update_idletasks()强制几何管理器首先绘制所有子小部件。这个方法在所有小部件类中都可用,并强制 Tkinter 处理所有待处理的空闲事件,如重绘和几何重新计算:

self.update_idletasks()
self.minsize(self.winfo_width(), self.winfo_height())

resize方法处理窗口调整大小事件,并更新scrollregion选项,该选项定义了可以滚动的canvas区域。为了轻松地重新计算它,您可以使用bbox()方法和ALL常量。这将返回整个 Canvas 小部件的边界框:

def resize(self, event):
    region = self.canvas.bbox(tk.ALL)
    self.canvas.configure(scrollregion=region)

当我们启动应用程序时,Tkinter 将自动触发多个<Configure>事件,因此无需在__init__方法的末尾调用self.resize()

还有更多...

只有少数小部件类支持标准滚动选项:ListboxTextCanvas允许xscrollcommandyscrollcommand,而输入小部件只允许xscrollcommand。我们已经看到如何将此模式应用于canvas,因为它可以用作通用解决方案,但您可以遵循类似的结构使这些小部件中的任何一个可滚动和可调整大小。

还有一点要指出的是,我们没有调用任何几何管理器来绘制框架,因为create_window()方法会为我们完成这项工作。为了更好地组织我们的应用程序类,我们可以将属于框架及其内部小部件的所有功能移动到专用的Frame子类中。

另请参阅

  • 处理鼠标和键盘事件的方法

  • 使用框架对小部件进行分组的方法

第三章:自定义小部件

在本章中,我们将涵盖以下示例:

  • 使用颜色

  • 设置小部件字体

  • 使用选项数据库

  • 更改光标图标

  • 介绍文本小部件

  • 向文本小部件添加标签

介绍

默认情况下,Tkinter 小部件将显示本机外观和感觉。虽然这种标准外观可能足够快速原型设计,但我们可能希望自定义一些小部件属性,如字体、颜色和背景。

这种自定义不仅影响小部件本身,还影响其内部项目。我们将深入研究文本小部件,它与画布小部件一样是最多功能的 Tkinter 类之一。文本小部件表示具有格式化内容的多行文本区域,具有几种方法,使得可以格式化字符或行并添加特定事件绑定。

使用颜色

在以前的示例中,我们使用颜色名称(如白色、蓝色或黄色)来设置小部件的颜色。这些值作为字符串传递给foregroundbackground选项,这些选项修改了小部件的文本和背景颜色。

颜色名称内部映射到RGB值(一种通过红、绿和蓝强度的组合来表示颜色的加法模型),这种转换基于一个因平台而异的表。因此,如果要在不同平台上一致显示相同的颜色,可以将 RGB 值传递给小部件选项。

准备就绪

以下应用程序显示了如何动态更改显示固定文本的标签的foregroundbackground选项:

颜色以 RGB 格式指定,并由用户使用本机颜色选择对话框选择。以下屏幕截图显示了 Windows 10 上的此对话框的外观:

如何做...

像往常一样,我们将使用标准按钮触发小部件配置——每个选项一个按钮。与以前的示例的主要区别是,可以直接使用tkinter.colorchooser模块的askcolor对话框直接选择值:

from functools import partial

import tkinter as tk
from tkinter.colorchooser import askcolor

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Colors demo")
        text = "The quick brown fox jumps over the lazy dog"
        self.label = tk.Label(self, text=text)
        self.fg_btn = tk.Button(self, text="Set foreground color",
                                command=partial(self.set_color, "fg")) 
        self.bg_btn = tk.Button(self, text="Set background color",
                                command=partial(self.set_color, "bg"))

        self.label.pack(padx=20, pady=20)
        self.fg_btn.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.bg_btn.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

    def set_color(self, option):
        color = askcolor()[1]
        print("Chosen color:", color)
        self.label.config(**{option: color})

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

如果要查看所选颜色的 RGB 值,在对话框确认时会在控制台上打印出来,如果关闭而没有选择颜色,则不会显示任何值。

它是如何工作的...

正如您可能已经注意到的,两个按钮都使用了部分函数作为回调。这是functools模块中的一个实用程序,它创建一个新的可调用对象,其行为类似于原始函数,但带有一些固定的参数。例如,考虑以下语句:

tk.Button(self, command=partial(self.set_color, "fg"), ...)

前面的语句执行与以下语句相同的操作:

tk.Button(self, command=lambda: self.set_color("fg"), ...)

我们这样做是为了同时重用我们的set_color()方法和引入functools模块。这些技术在更复杂的场景中非常有用,特别是当您想要组合多个函数并且非常清楚地知道一些参数已经预定义时。

要记住的一个小细节是,我们用fgbg分别缩写了foregroundbackground。在这个语句中,这些字符串使用**进行解包,用于配置小部件:

def set_color(self, option):
    color = askcolor()[1]
    print("Chosen color:", color)
    self.label.config(**{option: color}) # same as (fg=color)
                      or (bg=color)

askcolor返回一个包含两个项目的元组,表示所选颜色——第一个是表示 RGB 值的整数元组,第二个是十六进制代码作为字符串。由于第一个表示不能直接传递给小部件选项,我们使用了十六进制格式。

还有更多...

如果要将颜色名称转换为 RGB 格式,可以在先前创建的小部件上使用winfo_rgb()方法。由于它返回一个整数元组,表示 16 位 RGB 值的整数从 0 到 65535,您可以通过向右移动 8 位将其转换为更常见的#RRGGBB十六进制表示:

rgb = widget.winfo_rgb("lightblue")
red, green, blue = [x>>8 for x in rgb]
print("#{:02x}{:02x}{:02x}".format(red, green, blue))

在前面的代码中,我们使用{:02x}将每个整数格式化为两个十六进制数字。

设置小部件字体

在 Tkinter 中,可以自定义用于向用户显示文本的小部件的字体,例如按钮、标签和输入框。默认情况下,字体是特定于系统的,但可以使用font选项进行更改。

准备工作

以下应用程序允许用户动态更改具有静态文本的标签的字体系列和大小。尝试不同的值以查看字体配置的结果:

如何做...

我们将有两个小部件来修改字体配置:一个下拉选项,其中包含字体系列名称,以及一个输入字体大小的微调框:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Fonts demo")
        text = "The quick brown fox jumps over the lazy dog"
        self.label = tk.Label(self, text=text)

        self.family = tk.StringVar()
        self.family.trace("w", self.set_font)
        families = ("Times", "Courier", "Helvetica")
        self.option = tk.OptionMenu(self, self.family, *families)

        self.size = tk.StringVar()
        self.size.trace("w", self.set_font)
        self.spinbox = tk.Spinbox(self, from_=8, to=18,
                                  textvariable=self.size)

        self.family.set(families[0])
        self.size.set("10")
        self.label.pack(padx=20, pady=20)
        self.option.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        self.spinbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

    def set_font(self, *args):
        family = self.family.get()
        size = self.size.get()
        self.label.config(font=(family, size))

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

请注意,我们已为与每个输入连接的 Tkinter 变量设置了一些默认值。

它是如何工作的...

FAMILIES元组包含Tk保证在所有平台上支持的三种字体系列:Times(Times New Roman)、CourierHelvetica。它们可以通过与self.family变量连接的OptionMenu小部件进行切换。

类似的方法用于使用Spinbox设置字体大小。这两个变量触发了更改font标签的方法:

def set_font(self, *args):
    family = self.family.get()
    size = self.size.get()
    self.label.config(font=(family, size))

传递给font选项的元组还可以定义以下一个或多个字体样式:粗体、罗马体、斜体、下划线和删除线:

widget1.config(font=("Times", "20", "bold"))
widget2.config(font=("Helvetica", "16", "italic underline"))

您可以使用tkinter.font模块的families()方法检索可用字体系列的完整列表。由于您需要首先实例化root窗口,因此可以使用以下脚本:

import tkinter as tk
from tkinter import font

root = tk.Tk()
print(font.families())

如果您使用的字体系列未包含在可用系列列表中,Tkinter 不会抛出任何错误,而是会尝试匹配类似的字体。

还有更多...

tkinter.font模块包括一个Font类,可以在多个小部件上重复使用。修改font实例的主要优势是它会影响与font选项共享它的所有小部件。

使用Font类的工作方式与使用字体描述符非常相似。例如,此代码段创建一个 18 像素的Courier粗体字体:

from tkinter import font
courier_18 = font.Font(family="Courier", size=18, weight=font.BOLD)

要检索或更改选项值,您可以像往常一样使用cgetconfigure方法:

family = courier_18.cget("family")
courier_18.configure(underline=1)

另请参阅

  • 使用选项数据库配方

使用选项数据库

Tkinter 定义了一个称为选项数据库的概念,这是一种用于自定义应用程序外观的机制,而无需为每个小部件指定它。它允许您将一些小部件选项与单个小部件配置分离开来,根据小部件层次结构提供标准化的默认值。

准备工作

在此配方中,我们将构建一个具有不同样式的多个小部件的应用程序,这些样式将在选项数据库中定义:

如何做...

在我们的示例中,我们将通过option_add()方法向数据库添加一些选项,该方法可以从所有小部件类访问:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Options demo")
        self.option_add("*font", "helvetica 10")
        self.option_add("*header.font", "helvetica 18 bold")
        self.option_add("*subtitle.font", "helvetica 14 italic")
        self.option_add("*Button.foreground", "blue")
        self.option_add("*Button.background", "white")
        self.option_add("*Button.activeBackground", "gray")
        self.option_add("*Button.activeForeground", "black")

        self.create_label(name="header", text="This is the header")
        self.create_label(name="subtitle", text="This is the subtitle")
        self.create_label(text="This is a paragraph")
        self.create_label(text="This is another paragraph")
        self.create_button(text="See more")

    def create_label(self, **options):
        tk.Label(self, **options).pack(padx=20, pady=5, anchor=tk.W)

    def create_button(self, **options):
        tk.Button(self, **options).pack(padx=5, pady=5, anchor=tk.E)

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

因此,Tkinter 将使用选项数据库中定义的默认值,而不是与其他选项一起配置字体、前景和背景。

它是如何工作的...

让我们从解释对option_add的每个调用开始。第一次调用添加了一个选项,将font属性设置为所有小部件——通配符代表任何应用程序名称:

self.option_add("*font", "helvetica 10")

下一个调用将匹配限制为具有header名称的元素——规则越具体,优先级越高。稍后在使用name="header"实例化标签时指定此名称:

self.option_add("*header.font", "helvetica 18 bold")

对于self.option_add("*subtitle.font", "helvetica 14 italic"),也是一样的,所以每个选项都匹配到不同命名的小部件实例。

下一个选项使用Button类名而不是实例名。这样,您可以引用给定类的所有小部件以提供一些公共默认值:

self.option_add("*Button.foreground", "blue")
self.option_add("*Button.background", "white")
self.option_add("*Button.activeBackground", "gray")
self.option_add("*Button.activeForeground", "black")

正如我们之前提到的,选项数据库使用小部件层次结构来确定适用于每个实例的选项,因此,如果我们有嵌套的容器,它们也可以用于限制优先级选项。

这些配置选项不适用于现有小部件,只适用于修改选项数据库后创建的小部件。因此,我们始终建议在应用程序开头调用option_add()

这些是一些示例,每个示例比前一个更具体:

  • *Frame*background:匹配框架内所有小部件的背景

  • *Frame.background:匹配所有框架的背景

  • *Frame.myButton.background:匹配名为myButton的小部件的背景

  • *myFrame.myButton.background:匹配容器名为myFrame内名为myButton的小部件的背景

还有更多...

不仅可以通过编程方式添加选项,还可以使用以下格式在单独的文本文件中定义它们:

*font: helvetica 10
*header.font: helvetica 18 bold
*subtitle.font: helvetica 14 italic
*Button.foreground: blue
*Button.background: white
*Button.activeBackground: gray
*Button.activeForeground: black

这个文件应该使用option_readfile()方法加载到应用程序中,并替换所有对option_add()的调用。在我们的示例中,假设文件名为my_options_file,并且它放在与我们的脚本相同的目录中:

def __init__(self):
        super().__init__()
        self.title("Options demo")
        self.option_readfile("my_options_file")
        # ...

如果文件不存在或其格式无效,Tkinter 将引发TclError

另请参阅

  • 使用颜色

  • 设置小部件字体

更改光标图标

Tkinter 允许您在悬停在小部件上时自定义光标图标。这种行为有时是默认启用的,比如显示 I 型光标的 Entry 小部件。

准备工作

以下应用程序显示了如何在执行长时间操作时显示繁忙光标,以及在帮助菜单中通常使用的带有问号的光标:

如何做...

鼠标指针图标可以使用cursor选项更改。在我们的示例中,我们使用watch值来显示本机繁忙光标,question_arrow来显示带有问号的常规箭头:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Cursors demo")
        self.resizable(0, 0)
        self.label = tk.Label(self, text="Click the button to start")
        self.btn_launch = tk.Button(self, text="Start!",
                                    command=self.perform_action)
        self.btn_help = tk.Button(self, text="Help",
                                  cursor="question_arrow")

        btn_opts = {"side": tk.LEFT, "expand":True, "fill": tk.X,
                    "ipadx": 30, "padx": 20, "pady": 5}
        self.label.pack(pady=10)
        self.btn_launch.pack(**btn_opts)
        self.btn_help.pack(**btn_opts)

    def perform_action(self):
        self.config(cursor="watch")
        self.btn_launch.config(state=tk.DISABLED)
        self.btn_help.config(state=tk.DISABLED)
        self.label.config(text="Working...")
        self.after(3000, self.end_action)

    def end_action(self):
        self.config(cursor="arrow")
        self.btn_launch.config(state=tk.NORMAL)
        self.btn_help.config(state=tk.NORMAL)
        self.label.config(text="Done!")

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

您可以在官方 Tcl/Tk 文档的www.tcl.tk/man/tcl/TkCmd/cursors.htm中查看有效cursor值和特定于系统的完整列表。

它是如何工作的...

如果一个小部件没有指定cursor选项,它将采用父容器中定义的值。因此,我们可以通过在root窗口级别设置它来轻松地将其应用于所有小部件。这是通过在perform_action()方法中调用set_watch_cursor()来完成的:

def perform_action(self):
    self.config(cursor="watch")
    # ...

这里的例外是Help按钮,它明确将光标设置为question_arrow。此选项也可以在实例化小部件时直接设置:

self.btn_help = tk.Button(self, text="Help",
                          cursor="question_arrow")

还有更多...

请注意,如果在调用预定方法之前单击Start!按钮并将鼠标放在Help按钮上,光标将显示为help而不是watch。这是因为如果小部件的cursor选项已设置,它将优先于父容器中定义的cursor

为了避免这种情况,我们可以保存当前的cursor值并将其更改为watch,然后稍后恢复它。执行此操作的函数可以通过迭代winfo_children()列表在子小部件中递归调用:

def perform_action(self):
    self.set_watch_cursor(self)
    # ...

def end_action(self):
 self.restore_cursor(self)
    # ...

def set_watch_cursor(self, widget):
    widget._old_cursor = widget.cget("cursor")
    widget.config(cursor="watch")
    for w in widget.winfo_children():
        self.set_watch_cursor(w)

def restore_cursor(self, widget):
    widget.config(cursor=widget._old_cursor)
    for w in widget.winfo_children():
        self.restore_cursor(w)

在前面的代码中,我们为每个小部件添加了_old_cursor属性,因此如果您遵循类似的方法,请记住在set_watch_cursor()之前不能调用restore_cursor()

介绍 Text 小部件

Text 小部件提供了与其他小部件类相比更高级的功能。它显示可编辑文本的多行,可以按行和列进行索引。此外,您可以使用标签引用文本范围,这些标签可以定义自定义外观和行为。

准备工作

以下应用程序展示了 Text 小部件的基本用法,您可以动态插入和删除文本,并检索所选内容:

如何做...

除了 Text 小部件,我们的应用程序还包含三个按钮,这些按钮调用方法来清除整个文本内容,在当前光标位置插入"Hello, world"字符串,并打印用鼠标或键盘进行的当前选择:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Text demo")
        self.resizable(0, 0)
        self.text = tk.Text(self, width=50, height=10)
        self.btn_clear = tk.Button(self, text="Clear text",
                                   command=self.clear_text)
        self.btn_insert = tk.Button(self, text="Insert text",
                                    command=self.insert_text)
        self.btn_print = tk.Button(self, text="Print selection",
                                   command=self.print_selection)
        self.text.pack()
        self.btn_clear.pack(side=tk.LEFT, expand=True, pady=10)
        self.btn_insert.pack(side=tk.LEFT, expand=True, pady=10)
        self.btn_print.pack(side=tk.LEFT, expand=True, pady=10)

    def clear_text(self):
        self.text.delete("1.0", tk.END)

    def insert_text(self):
        self.text.insert(tk.INSERT, "Hello, world")

    def print_selection(self):
        selection = self.text.tag_ranges(tk.SEL)
        if selection:
            content = self.text.get(*selection)
            print(content)

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

它是如何工作的...

我们的 Text 小部件最初是空的,宽度为 50 个字符,高度为 10 行。除了允许用户输入任何类型的文本,我们还将深入研究每个按钮使用的方法,以更好地了解如何与这个小部件交互。

delete(start, end) 方法从 start 索引到 end 索引删除内容。如果省略第二个参数,它只删除 start 位置的字符。

在我们的示例中,我们通过从 1.0 索引(第一行的第 0 列)调用此方法到 tk.END 索引(指向最后一个字符)来删除所有文本:

def clear_text(self):
    self.text.delete("1.0", tk.END)

insert(index, text) 方法在index位置插入给定的文本。在这里,我们使用INSERT索引调用它,该索引对应于插入光标的位置:

def insert_text(self):
    self.text.insert(tk.INSERT, "Hello, world")

tag_ranges(tag) 方法返回一个元组,其中包含给定 tag 的所有范围的第一个和最后一个索引。我们使用特殊的 tk.SEL 标签来引用当前选择。如果没有选择,这个调用会返回一个空元组。这与 get(start, end) 方法结合使用,该方法返回给定范围内的文本:

def print_selection(self):
    selection = self.text.tag_ranges(tk.SEL)
    if selection:
        content = self.text.get(*selection)
        print(content)

由于 SEL 标签只对应一个范围,我们可以安全地解包它来调用 get 方法。

向 Text 小部件添加标记

在本示例中,您将学习如何配置 Text 小部件中标记的字符范围的行为。

所有的概念都与适用于常规小部件的概念相同,比如事件序列或配置选项,这些概念在之前的示例中已经涵盖过了。主要的区别是,我们需要使用文本索引来识别标记的内容,而不是使用对象引用。

准备工作

为了说明如何使用文本标记,我们将创建一个模拟插入超链接的 Text 小部件。点击时,此链接将使用默认浏览器打开所选的 URL。

例如,如果用户输入以下内容,python.org 文本可以被标记为超链接:

如何做...

对于此应用程序,我们将定义一个名为"link"的标记,它表示可点击的超链接。此标记将被添加到当前选择中,鼠标点击将触发打开浏览器中的链接的事件:

import tkinter as tk
import webbrowser

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Text tags demo")
        self.text = tk.Text(self, width=50, height=10)
        self.btn_link = tk.Button(self, text="Add hyperlink",
                                  command=self.add_hyperlink)

        self.text.tag_config("link", foreground="blue", underline=1)
        self.text.tag_bind("link", "<Button-1>", self.open_link)
        self.text.tag_bind("link", "<Enter>",
                           lambda _: self.text.config(cursor="hand2"))
        self.text.tag_bind("link", "<Leave>",
                           lambda e: self.text.config(cursor=""))

        self.text.pack()
        self.btn_link.pack(expand=True)

    def add_hyperlink(self):
        selection = self.text.tag_ranges(tk.SEL)
        if selection:
            self.text.tag_add("link", *selection)

    def open_link(self, event):
        position = "@{},{} + 1c".format(event.x, event.y)
        index = self.text.index(position)
        prevrange = self.text.tag_prevrange("link", index)
        url = self.text.get(*prevrange)
        webbrowser.open(url)

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

它是如何工作的...

首先,我们将通过配置颜色和下划线样式来初始化标记。我们添加事件绑定来使用浏览器打开点击的文本,并在鼠标悬停在标记文本上时改变光标外观:

def __init__(self):
    # ...
    self.text.tag_config("link", foreground="blue", underline=1)
    self.text.tag_bind("link", "<Button-1>", self.open_link)
    self.text.tag_bind("link", "<Enter>",
                       lambda e: self.text.config(cursor="hand2"))
    self.text.tag_bind("link", "<Leave>",
                       lambda e: self.text.config(cursor=""))

open_link 方法中,我们使用 Text 类的 index 方法将点击的位置转换为相应的行和列:

position = "@{},{} + 1c".format(event.x, event.y)
index = self.text.index(position)
prevrange = self.text.tag_prevrange("link", index)

请注意,与点击的索引对应的位置是"@x,y",但我们将其移动到下一个字符。我们这样做是因为 tag_prevrange 返回给定索引的前一个范围,因此如果我们点击第一个字符,它将不返回当前范围。

最后,我们将从范围中检索文本,并使用 webbrowser 模块的 open 函数在默认浏览器中打开它:

url = self.text.get(*prevrange)
webbrowser.open(url)

还有更多...

由于 webbrowser.open 函数不检查 URL 是否有效,可以通过包含基本的超链接验证来改进此应用程序。例如,您可以使用 urlparse 函数来验证 URL 是否具有网络位置:

from urllib.parse import urlparse def validate_hyperlink(self, url):
    return urlparse(url).netloc

尽管这个解决方案并不打算处理一些特殊情况,但它可能作为丢弃大多数无效 URL 的第一步。

一般来说,您可以使用标签来创建复杂的基于文本的程序,比如带有语法高亮的 IDE。事实上,IDLE——默认的 Python 实现中捆绑的——就是基于 Tkinter 的。

另请参阅

  • 更改光标图标食谱

  • 介绍文本小部件食谱

第四章:对话框和菜单

在本章中,我们将涵盖以下配方:

  • 显示警报对话框

  • 要求用户确认

  • 选择文件和目录

  • 将数据保存到文件中

  • 创建菜单栏

  • 在菜单中使用变量

  • 显示上下文菜单

  • 打开次要窗口

  • 在窗口之间传递变量

  • 处理窗口删除

介绍

几乎每个非平凡的 GUI 应用程序都由多个视图组成。在浏览器中,这是通过从一个 HTML 页面导航到另一个页面实现的,在桌面应用程序中,它由用户可以与之交互的多个窗口和对话框表示。

到目前为止,我们只学习了如何创建一个与 Tcl 解释器关联的根窗口。但是,Tkinter 允许我们在同一个应用程序下创建多个顶级窗口,并且还包括具有内置对话框的特定模块。

另一种构造应用程序导航的方法是使用菜单,通常在桌面应用程序的标题栏下显示。在 Tkinter 中,这些菜单由一个小部件类表示;我们将在稍后深入研究其方法以及如何将其与我们应用程序的其余部分集成。

显示警报对话框

对话框的一个常见用例是通知用户应用程序中发生的事件,例如记录已保存,或者无法打开文件。现在我们将看一下 Tkinter 中包含的一些基本函数来显示信息对话框。

准备就绪

我们的程序将有三个按钮,每个按钮都显示一个不同的对话框,具有静态标题和消息。这种类型的对话框框只有一个确认和关闭对话框的按钮:

当您运行上面的示例时,请注意每个对话框都会播放由您的平台定义的相应声音,并且按钮标签会被翻译成您的语言:

如何做...

在前面的准备就绪部分提到的三个对话框是使用tkinter.messagebox模块中的showinfoshowwarningshowerror函数打开的:

import tkinter as tk
import tkinter.messagebox as mb

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        btn_info = tk.Button(self, text="Show Info",
                             command=self.show_info)
        btn_warn = tk.Button(self, text="Show Warning",
                             command=self.show_warning)
        btn_error = tk.Button(self, text="Show Error",
                              command=self.show_error)

        opts = {'padx': 40, 'pady': 5, 'expand': True, 'fill': tk.BOTH}
        btn_info.pack(**opts)
        btn_warn.pack(**opts)
        btn_error.pack(**opts)

    def show_info(self):
        msg = "Your user preferences have been saved"
        mb.showinfo("Information", msg)

    def show_warning(self):
        msg = "Temporary files have not been correctly removed"
        mb.showwarning("Warning", msg)

    def show_error(self):
        msg = "The application has encountered an unknown error"
        mb.showerror("Error", msg)

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

它是如何工作的...

首先,我们使用较短的别名mb导入了tkinter.messagebox模块。这个模块在 Python 2 中被命名为tkMessageBox,因此这种语法也有助于我们将兼容性问题隔离在一个语句中。

每个对话框通常根据通知给用户的信息类型而使用:

  • showinfo:操作成功完成

  • showwarning:操作已完成,但某些内容未按预期行为

  • showerror:由于错误操作失败

这三个函数接收两个字符串作为输入参数:第一个显示在标题栏上,第二个对应对话框显示的消息。

对话框消息也可以通过添加换行字符\n跨多行生成。

要求用户确认

Tkinter 中包括的其他类型的对话框是用于要求用户确认的对话框,例如当我们要保存文件并且要覆盖同名文件时显示的对话框。

这些对话框与前面的对话框不同,因为函数返回的值将取决于用户点击的确认按钮。这样,我们可以与程序交互,指示是否继续或取消操作。

准备就绪

在这个配方中,我们将涵盖tkinter.messagebox模块中定义的其余对话框函数。每个按钮上都标有单击时打开的对话框类型:

由于这些对话框之间存在一些差异,您可以尝试它们,以查看哪一个可能更适合您每种情况的需求:

如何做...

与我们在前面的示例中所做的一样,我们将使用import ... as语法导入tkinter.messagebox并调用每个函数与titlemessage

import tkinter as tk
import tkinter.messagebox as mb

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.create_button(mb.askyesno, "Ask Yes/No",
                           "Returns True or False")
        self.create_button(mb.askquestion, "Ask a question",
                           "Returns 'yes' or 'no'")
        self.create_button(mb.askokcancel, "Ask Ok/Cancel",
                           "Returns True or False")
        self.create_button(mb.askretrycancel, "Ask Retry/Cancel",
                           "Returns True or False")
        self.create_button(mb.askyesnocancel, "Ask Yes/No/Cancel",
                           "Returns True, False or None")

    def create_button(self, dialog, title, message):
        command = lambda: print(dialog(title, message))
        btn = tk.Button(self, text=title, command=command)
        btn.pack(padx=40, pady=5, expand=True, fill=tk.BOTH)

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

它是如何工作的...

为了避免重复编写按钮实例化和回调方法的代码,我们定义了一个create_button方法,以便根据需要多次重用它以添加所有带有其对话框的按钮。命令只是简单地打印作为参数传递的dialog函数的结果,以便我们可以看到根据点击的按钮返回的值来回答对话框。

选择文件和目录

文件对话框允许用户从文件系统中选择一个或多个文件。在 Tkinter 中,这些函数声明在tkinter.filedialog模块中,该模块还包括用于选择目录的对话框。它还允许您自定义新对话框的行为,例如通过其扩展名过滤文件或选择对话框显示的初始目录。

准备工作

我们的应用程序将包含两个按钮。第一个将被标记为选择文件,并且它将显示一个对话框以选择文件。默认情况下,它只会显示具有.txt扩展名的文件:

第二个按钮将是选择目录,并且它将打开一个类似的对话框以选择目录:

两个按钮都将打印所选文件或目录的完整路径,并且如果对话框被取消,将不执行任何操作。

如何做...

我们应用程序的第一个按钮将触发对askopenfilename函数的调用,而第二个按钮将调用askdirectory函数:

import tkinter as tk
import tkinter.filedialog as fd

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        btn_file = tk.Button(self, text="Choose file",
                             command=self.choose_file)
        btn_dir = tk.Button(self, text="Choose directory",
                             command=self.choose_directory)
        btn_file.pack(padx=60, pady=10)
        btn_dir.pack(padx=60, pady=10)

    def choose_file(self):
        filetypes = (("Plain text files", "*.txt"),
                     ("Images", "*.jpg *.gif *.png"),
                     ("All files", "*"))
        filename = fd.askopenfilename(title="Open file", 
                   initialdir="/", filetypes=filetypes)
        if filename:
            print(filename)

    def choose_directory(self):
        directory = fd.askdirectory(title="Open directory", 
                                    initialdir="/")
        if directory:
            print(directory)

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

由于这些对话框可能会被关闭,我们添加了条件语句来检查对话框函数在将其打印到控制台之前是否返回了非空字符串。我们需要在任何必须对此路径执行操作的应用程序中进行此验证,例如读取或复制文件,或更改权限。

它是如何工作的...

我们使用askopenfilename函数创建第一个对话框,该函数返回一个表示所选文件的完整路径的字符串。它接受以下可选参数:

  • title:对话框标题栏中显示的标题。

  • initialdir:初始目录。

  • filetypes:两个字符串元组的序列。第一个是以人类可读格式指示文件类型的标签,而第二个是用于匹配文件名的模式。

  • multiple:布尔值,指示用户是否可以选择多个文件。

  • defaultextension:如果未明确给出文件名,则添加到文件名的扩展名。

在我们的示例中,我们将初始目录设置为根文件夹和自定义标题。在我们的文件类型元组中,我们有以下三个有效选择:使用.txt扩展名保存的文本文件;带有.jpg.gif.png扩展名的图像;以及通配符("*")以匹配所有文件。

请注意,这些模式不一定与文件中包含的数据的格式匹配,因为可以使用不同的扩展名重命名文件:

filetypes = (("Plain text files", "*.txt"),
             ("Images", "*.jpg *.gif *.png"),
             ("All files", "*"))
filename = fd.askopenfilename(title="Open file", initialdir="/",
                              filetypes=filetypes)

askdirectory函数还接受titleinitialdir参数,以及一个mustexist布尔选项,指示用户是否必须选择现有目录:

directory = fd.askdirectory(title="Open directory", initialdir="/")

还有更多...

tkinter.filedialog模块包括这些函数的一些变体,允许您直接检索文件对象。

例如,askopenfile返回与所选文件对应的文件对象,而不必使用askopenfilename返回的路径调用open。我们仍然必须检查对话框在调用文件方法之前是否已被关闭:

import tkinter.filedialog as fd

filetypes = (("Plain text files", "*.txt"),)
my_file = fd.askopenfile(title="Open file", filetypes=filetypes)
if my_file:
    print(my_file.readlines())
    my_file.close()

将数据保存到文件中

除了选择现有文件和目录外,还可以使用 Tkinter 对话框创建新文件。它们可用于保存应用程序生成的数据,让用户选择新文件的名称和位置。

准备工作

我们将使用保存文件对话框将文本窗口小部件的内容写入纯文本文件:

如何做...

要打开保存文件的对话框,我们从tkinter.filedialog模块调用asksaveasfile函数。它内部使用'w'模式创建文件对象进行写入,或者如果对话框被关闭,则返回None

import tkinter as tk
import tkinter.filedialog as fd

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.text = tk.Text(self, height=10, width=50)
        self.btn_save = tk.Button(self, text="Save",
                                  command=self.save_file)

        self.text.pack()
        self.btn_save.pack(pady=10, ipadx=5)

    def save_file(self):
        contents = self.text.get(1.0, tk.END)
        new_file = fd.asksaveasfile(title="Save file",
                                    defaultextension=".txt",
                                    filetypes=(("Text files", 
                                                "*.txt"),))
        if new_file:
            new_file.write(contents)
            new_file.close()

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

工作原理...

asksaveasfile函数接受与askopenfile函数相同的可选参数,但还允许您使用defaultextension选项默认添加文件扩展名。

为了防止用户意外覆盖先前的文件,此对话框会在您尝试保存与现有文件同名的新文件时自动警告您。

有了文件对象,我们可以写入 Text 小部件的内容-始终记得关闭文件以释放对象占用的资源:

contents = self.text.get(1.0, tk.END)
new_file.write(contents)
new_file.close()

还有更多...

在前面的食谱中,我们看到有一个等价于askopenfilename的函数,它返回一个文件对象而不是一个字符串,名为askopenfile

要保存文件,还有一个asksaveasfilename函数,它返回所选文件的路径。如果要在打开文件进行写入之前修改路径或执行任何验证,可以使用此函数。

另请参阅

  • 选择文件和目录食谱

创建菜单栏

复杂的 GUI 通常使用菜单栏来组织应用程序中可用的操作和导航。这种模式也用于将紧密相关的操作分组,例如大多数文本编辑器中包含的“文件”菜单。

Tkinter 本地支持这些菜单,显示为目标桌面环境的外观和感觉。因此,您不必使用框架或标签模拟它们,因为这样会丢失 Tkinter 中已经构建的跨平台功能。

准备工作

我们将首先向根窗口添加一个菜单栏,并嵌套下拉菜单。在 Windows 10 上,显示如下:

如何做...

Tkinter 有一个Menu小部件类,可用于许多种类型的菜单,包括顶部菜单栏。与任何其他小部件类一样,菜单是用父容器作为第一个参数和一些可选的配置选项来实例化的:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        menu = tk.Menu(self)
        file_menu = tk.Menu(menu, tearoff=0)

        file_menu.add_command(label="New file")
        file_menu.add_command(label="Open")
        file_menu.add_separator()
        file_menu.add_command(label="Save")
        file_menu.add_command(label="Save as...")

        menu.add_cascade(label="File", menu=file_menu)
        menu.add_command(label="About")
        menu.add_command(label="Quit", command=self.destroy)
        self.config(menu=menu)

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

如果运行上述脚本,您会看到“文件”条目显示次级菜单,并且可以通过单击“退出”菜单按钮关闭应用程序。

工作原理...

首先,我们实例化每个菜单,指定父容器。tearoff选项默认设置为1,表示菜单可以通过单击其顶部边框的虚线分离。这种行为不适用于顶部菜单栏,但如果我们想要停用此功能,就必须将此选项设置为0

    def __init__(self):
        super().__init__()
        menu = tk.Menu(self)
        file_menu = tk.Menu(menu, tearoff=0)

菜单条目按照它们添加的顺序排列,使用add_commandadd_separatoradd_cascade方法:

menu.add_cascade(label="File", menu=file_menu)
menu.add_command(label="About")
menu.add_command(label="Quit", command=self.destroy)

通常,add_commandcommand选项一起调用,当单击条目时会调用回调。与 Button 小部件的command选项一样,回调函数不会传递任何参数。

为了举例说明,我们只在“退出”选项中添加了这个选项,以销毁Tk实例并关闭应用程序。

最后,我们通过调用self.config(menu=menu)将菜单附加到顶层窗口。请注意,每个顶层窗口只能配置一个菜单栏。

在菜单中使用变量

除了调用命令和嵌套子菜单外,还可以将 Tkinter 变量连接到菜单条目。

准备工作

我们将向“选项”子菜单添加一个复选框条目和三个单选按钮条目,之间用分隔符分隔。将有两个基础的 Tkinter 变量来存储所选值,因此我们可以轻松地从应用程序的其他方法中检索它们:

如何做...

这些类型的条目是使用Menu小部件类的add_checkbuttonadd_radiobutton方法添加的。与常规单选按钮一样,所有条目都连接到相同的 Tkinter 变量,但每个条目设置不同的值:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.checked = tk.BooleanVar()
        self.checked.trace("w", self.mark_checked)
        self.radio = tk.StringVar()
        self.radio.set("1")
        self.radio.trace("w", self.mark_radio)

        menu = tk.Menu(self)
        submenu = tk.Menu(menu, tearoff=0)

        submenu.add_checkbutton(label="Checkbutton", onvalue=True,
                                offvalue=False, variable=self.checked)
        submenu.add_separator()
        submenu.add_radiobutton(label="Radio 1", value="1",
                                variable=self.radio)
        submenu.add_radiobutton(label="Radio 2", value="2",
                                variable=self.radio)
        submenu.add_radiobutton(label="Radio 3", value="3",
                                variable=self.radio)

        menu.add_cascade(label="Options", menu=submenu)
        menu.add_command(label="Quit", command=self.destroy)
        self.config(menu=menu)

    def mark_checked(self, *args):
        print(self.checked.get())

    def mark_radio(self, *args):
        print(self.radio.get())

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

此外,我们正在跟踪变量更改,以便在运行此应用程序时可以在控制台上看到打印的值。

工作原理...

要将布尔变量连接到Checkbutton条目,我们首先定义BooleanVar,然后使用variable选项调用add_checkbutton创建条目。

请记住,onvalueoffvalue选项应与 Tkinter 变量的类型匹配,就像我们在常规 RadioButton 和 CheckButton 小部件中所做的那样:

self.checked = tk.BooleanVar()
self.checked.trace("w", self.mark_checked)
# ...
submenu.add_checkbutton(label="Checkbutton", onvalue=True,
                        offvalue=False, variable=self.checked)

Radiobutton条目是使用add_radiobutton方法以类似的方式创建的,当单击单选按钮时,只需设置一个value选项即可将其设置为 Tkinter 变量。由于StringVar最初保存空字符串值,因此我们将其设置为第一个单选按钮值,以便它显示为已选中:

self.radio = tk.StringVar()
self.radio.set("1")
self.radio.trace("w", self.mark_radio)
# ...        
submenu.add_radiobutton(label="Radio 1", value="1",
                        variable=self.radio)
submenu.add_radiobutton(label="Radio 2", value="2",
                        variable=self.radio)
submenu.add_radiobutton(label="Radio 3", value="3",
                        variable=self.radio)

两个变量都使用mark_checkedmark_radio方法跟踪更改,这些方法只是将变量值打印到控制台。

显示上下文菜单

Tkinter 菜单不一定要位于菜单栏上,而实际上可以自由放置在任何坐标。这些类型的菜单称为上下文菜单,通常在用户右键单击项目时显示。

上下文菜单广泛用于 GUI 应用程序;例如,文件浏览器显示它们以提供有关所选文件的可用操作,因此用户知道如何与它们交互是直观的。

准备工作

我们将为文本小部件构建一个上下文菜单,以显示文本编辑器的一些常见操作,例如剪切、复制、粘贴和删除:

如何做...

不是使用顶级容器作为顶部菜单栏来配置菜单实例,而是可以使用其post方法将其明确放置。

菜单条目中的所有命令都调用一个使用文本实例来检索当前选择或插入位置的方法:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.menu = tk.Menu(self, tearoff=0)
        self.menu.add_command(label="Cut", command=self.cut_text)
        self.menu.add_command(label="Copy", command=self.copy_text)
        self.menu.add_command(label="Paste", command=self.paste_text)
        self.menu.add_command(label="Delete", command=self.delete_text)

        self.text = tk.Text(self, height=10, width=50)
        self.text.bind("<Button-3>", self.show_popup)
        self.text.pack()

    def show_popup(self, event):
        self.menu.post(event.x_root, event.y_root)

    def cut_text(self):
        self.copy_text()
        self.delete_text()

    def copy_text(self):
        selection = self.text.tag_ranges(tk.SEL)
        if selection:
            self.clipboard_clear()
            self.clipboard_append(self.text.get(*selection))

    def paste_text(self):
        self.text.insert(tk.INSERT, self.clipboard_get())

    def delete_text(self):
        selection = self.text.tag_ranges(tk.SEL)
        if selection:
            self.text.delete(*selection)

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

工作原理...

我们将右键单击事件绑定到文本实例的show_popup处理程序,该处理程序将菜单显示在右键单击位置的左上角。每次触发此事件时,都会再次显示相同的菜单实例:

def show_popup(self, event):
    self.menu.post(event.x_root, event.y_root)

对所有小部件类可用的以下方法与剪贴板交互:

  • 清除剪贴板中的数据

  • clipboard_append(string): 将字符串附加到剪贴板

  • clipboard_get(): 从剪贴板返回数据

复制操作的回调方法获取当前选择并将其添加到剪贴板:

    def copy_text(self):
        selection = self.text.tag_ranges(tk.SEL)
        if selection:
            self.clipboard_clear()
 self.clipboard_append(self.text.get(*selection))

粘贴操作将剪贴板内容插入到由INSERT索引定义的插入光标位置。我们必须将此包装在try...except块中,因为调用clipboard_get会在剪贴板为空时引发TclError

    def paste_text(self):
        try:
 self.text.insert(tk.INSERT, self.clipboard_get())
        except tk.TclError:
            pass

删除操作不与剪贴板交互,但会删除当前选择的内容:

    def delete_text(self):
        selection = self.text.tag_ranges(tk.SEL)
        if selection:
            self.text.delete(*selection)

由于剪切操作是复制和删除的组合,我们重用这些方法来组成其回调函数。

还有更多...

postcommand选项允许您使用post方法每次显示菜单时重新配置菜单。为了说明如何使用此选项,如果文本小部件中没有当前选择,则我们将禁用剪切、复制和删除条目,并且如果剪贴板中没有内容,则禁用粘贴条目。

与我们的其他回调函数一样,我们传递了对我们类的方法的引用以添加此配置选项:

def __init__(self):
    super().__init__()
    self.menu = tk.Menu(self, tearoff=0, 
    postcommand=self.enable_selection)

然后,我们检查SEL范围是否存在,以确定条目的状态应为ACTIVEDISABLED。将此值传递给entryconfig方法,该方法以要配置的条目的索引作为其第一个参数,并以要更新的选项列表作为其第二个参数-请记住菜单条目是0索引的:

def enable_selection(self):
    state_selection = tk.ACTIVE if self.text.tag_ranges(tk.SEL) 
                      else tk.DISABLED
    state_clipboard = tk.ACTIVE
    try:
        self.clipboard_get()
    except tk.TclError:
        state_clipboard = tk.DISABLED

    self.menu.entryconfig(0, state=state_selection) # Cut
    self.menu.entryconfig(1, state=state_selection) # Copy
    self.menu.entryconfig(2, state=state_clipboard) # Paste
    self.menu.entryconfig(3, state=state_selection) # Delete

例如,如果没有选择或剪贴板上没有内容,所有条目都应该变灰。

使用entryconfig,还可以配置许多其他选项,如标签、字体和背景。请参阅www.tcl.tk/man/tcl8.6/TkCmd/menu.htm#M48以获取可用条目选项的完整参考。

打开一个次要窗口

Tk实例代表我们 GUI 的主窗口——当它被销毁时,应用程序退出,事件主循环结束。

然而,在我们的应用程序中创建额外的顶层窗口的另一个 Tkinter 类是Toplevel。您可以使用这个类来显示任何类型的窗口,从自定义对话框到向导表单。

准备就绪

我们将首先创建一个简单的窗口,当主窗口的按钮被点击时打开。它将包含一个关闭它并将焦点返回到主窗口的按钮:

如何做...

Toplevel小部件类创建一个新的顶层窗口,它像Tk实例一样作为父容器。与Tk类不同,您可以实例化任意数量的顶层窗口:

import tkinter as tk

class Window(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)
        self.label = tk.Label(self, text="This is another window")
        self.button = tk.Button(self, text="Close", 
                                command=self.destroy)

        self.label.pack(padx=20, pady=20)
        self.button.pack(pady=5, ipadx=2, ipady=2)

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.btn = tk.Button(self, text="Open new window",
                             command=self.open_window)
        self.btn.pack(padx=50, pady=20)

    def open_window(self):
        window = Window(self)
        window.grab_set()

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

它是如何工作的...

我们定义一个Toplevel子类来表示我们的自定义窗口,它与父窗口的关系在它的__init__方法中定义。小部件被添加到这个窗口,因为我们遵循与子类化Tk相同的约定:

class Window(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)

通过简单地创建一个新实例来打开窗口,但是为了使其接收所有事件,我们必须调用它的grab_set方法。这可以防止用户与主窗口交互,直到该窗口关闭为止。

def open_window(self):
    window = Window(self)
 window.grab_set()

处理窗口删除

在某些情况下,您可能希望在用户关闭顶层窗口之前执行某个操作,例如,以防止丢失未保存的工作。Tkinter 允许您拦截这种类型的事件以有条件地销毁窗口。

准备就绪

我们将重用前面一篇文章中的App类,并修改Window类以显示一个对话框来确认关闭窗口:

如何做...

在 Tkinter 中,我们可以通过为WM_DELETE_WINDOW协议注册处理程序函数来检测窗口即将关闭的情况。这可以通过在大多数桌面环境的标题栏上点击 X 按钮来触发:

import tkinter as tk
import tkinter.messagebox as mb

class Window(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)
        self.protocol("WM_DELETE_WINDOW", self.confirm_delete)

        self.label = tk.Label(self, text="This is another window")
        self.button = tk.Button(self, text="Close", 
                                command=self.destroy)

        self.label.pack(padx=20, pady=20)
        self.button.pack(pady=5, ipadx=2, ipady=2)

    def confirm_delete(self):
        message = "Are you sure you want to close this window?"
        if mb.askyesno(message=message, parent=self):
            self.destroy()

我们的处理程序方法显示一个对话框来确认窗口删除。在更复杂的程序中,这种逻辑通常会通过额外的验证来扩展。

它是如何工作的...

bind()方法用于为小部件事件注册处理程序,protocol方法用于为窗口管理器协议注册处理程序。

当顶层窗口即将关闭时,WM_DELETE_WINDOW处理程序被调用,默认情况下,Tk会销毁接收到它的窗口。由于我们通过注册confirm_delete处理程序来覆盖此行为,如果对话框得到确认,它需要显式销毁窗口。

另一个有用的协议是WM_TAKE_FOCUS,当窗口获得焦点时会调用它。

还有更多...

请记住,为了在显示对话框时保持第二个窗口的焦点,我们必须将对顶层实例的引用,parent选项,传递给对话框函数:

if mb.askyesno(message=message, parent=self):
    self.destroy()

否则,对话框将以根窗口为其父窗口,并且您会看到它弹出到第二个窗口上。这些怪癖可能会让您的用户感到困惑,因此正确设置每个顶层实例或对话框的父窗口是一个好的做法。

在窗口之间传递变量

在程序执行期间,两个不同的窗口可能需要共享信息。虽然这些数据可以保存到磁盘并从使用它的窗口读取,但在某些情况下,更直接地在内存中处理它并将这些信息作为变量传递可能更简单。

准备工作

主窗口将包含三个单选按钮,用于选择我们要创建的用户类型,并且次要窗口将打开表单以填写用户数据:

操作步骤...

为了保存用户数据,我们使用namedtuple创建了一个字段,代表每个用户实例。collections模块中的这个函数接收类型名称和字段名称序列,并返回一个元组子类,用于创建具有给定字段的轻量级对象:

import tkinter as tk
from collections import namedtuple

User = namedtuple("User", ["username", "password", "user_type"])

class UserForm(tk.Toplevel):
    def __init__(self, parent, user_type):
        super().__init__(parent)
        self.username = tk.StringVar()
        self.password = tk.StringVar()
        self.user_type = user_type

        label = tk.Label(self, text="Create a new " + 
                         user_type.lower())
        entry_name = tk.Entry(self, textvariable=self.username)
        entry_pass = tk.Entry(self, textvariable=self.password, 
                              show="*")
        btn = tk.Button(self, text="Submit", command=self.destroy)

        label.grid(row=0, columnspan=2)
        tk.Label(self, text="Username:").grid(row=1, column=0)
        tk.Label(self, text="Password:").grid(row=2, column=0)
        entry_name.grid(row=1, column=1)
        entry_pass.grid(row=2, column=1)
        btn.grid(row=3, columnspan=2)

    def open(self):
        self.grab_set()
        self.wait_window()
        username = self.username.get()
        password = self.password.get()
        return User(username, password, self.user_type)

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        user_types = ("Administrator", "Supervisor", "Regular user")
        self.user_type = tk.StringVar()
        self.user_type.set(user_types[0])

        label = tk.Label(self, text="Please, select the type of user")
        radios = [tk.Radiobutton(self, text=t, value=t, \
                  variable=self.user_type) for t in user_types]
        btn = tk.Button(self, text="Create user", 
                        command=self.open_window)

        label.pack(padx=10, pady=10)
        for radio in radios:
            radio.pack(padx=10, anchor=tk.W)
        btn.pack(pady=10)

    def open_window(self):
        window = UserForm(self, self.user_type.get())
        user = window.open()
        print(user)

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

当执行流返回到主窗口时,用户数据将被打印到控制台。

工作原理...

这个示例的大部分代码已经在其他示例中涵盖,主要区别在于UserForm类的open()方法中,我们将调用grab_set()移到了那里。然而,wait_window()方法实际上是停止执行并防止我们在表单被修改之前返回数据的方法:

    def open(self):
 self.grab_set()
 self.wait_window()
        username = self.username.get()
        password = self.password.get()
        return User(username, password, self.user_type)

需要强调的是,wait_window()进入一个本地事件循环,当窗口被销毁时结束。虽然可以传递我们想要等待移除的部件,但我们可以省略它以隐式地引用调用此方法的实例。

UserForm实例被销毁时,open()方法的执行将继续,并返回User对象,现在可以在App类中使用:

    def open_window(self):
        window = UserForm(self, self.user_type.get())
        user = window.open()
        print(user)

第五章:面向对象编程和 MVC

在本章中,我们将涵盖以下示例:

  • 使用类来构造我们的数据

  • 组合小部件以显示信息

  • 从 CSV 文件中读取记录

  • 将数据持久化到 SQLite 数据库中

  • 使用 MVC 模式进行重构

介绍

到目前为止,我们所有的应用程序都将数据保存在内存中作为本地变量或属性。但是,我们也希望能够持久化信息,以便在程序关闭时不会丢失。

在本章中,我们将讨论如何使用面向对象编程OOP)原则和应用模型-视图-控制器MVC)模式来表示和显示这些数据。简而言之,这种模式提出了三个组件,我们可以将我们的 GUI 分为这三个组件:一个模型保存应用程序数据,一个视图显示这些数据,一个控制器处理用户事件并连接视图和模型。

这些概念与我们如何操作和持久化信息有关,并帮助我们改进程序的组织。大多数这些示例不特定于 Tkinter,您可以将相同的原则应用于其他 GUI 库。

使用类来构造我们的数据

我们将以联系人列表应用程序为例,说明如何使用 Python 类来建模我们的数据。即使用户界面可能提供许多不同的功能,我们仍需要定义哪些属性代表我们的领域模型——在我们的情况下,每个个人联系人。

准备工作

每个联系人将包含以下信息:

  • 名字和姓氏,不能为空

  • 电子邮件地址,例如john.doe@acme.com

  • 电话号码,格式为(123) 4567890

有了这个抽象,我们可以开始编写我们的Contact类的代码。

如何做...

首先,我们定义了一对实用函数,我们将重复使用它们来验证必填字段或必须遵循特定格式的字段:

def required(value, message):
    if not value:
        raise ValueError(message)
    return value

def matches(value, regex, message):
    if value and not regex.match(value):
        raise ValueError(message)
    return value

然后,我们定义我们的Contact类及其__init__方法。我们在这里设置所有参数对应的字段。我们还将编译的正则表达式存储为类属性,因为我们将在每个实例中使用它们来执行字段验证:

import re

class Contact(object):
    email_regex = re.compile(r"[^@]+@[^@]+\.[^@]+")
    phone_regex = re.compile(r"\([0-9]{3}\)\s[0-9]{7}")

    def __init__(self, last_name, first_name, email, phone):
        self.last_name = last_name
        self.first_name = first_name
        self.email = email
        self.phone = phone

然而,这个定义还不足以强制执行每个字段的验证。为此,我们使用@property装饰器,它允许我们包装对内部属性的访问:

    @property
    def last_name(self):
        return self._last_name

    @last_name.setter
    def last_name(self, value):
        self._last_name = required(value, "Last name is required")

相同的技术也适用于first_name,因为它也是必需的。emailphone属性采用类似的方法,使用matches函数和相应的正则表达式:

    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, value):
        self._email = matches(value, self.email_regex,
                              "Invalid email format")

此脚本应保存为chapter5_01.py,因为我们将在以后的示例中使用这个名称导入它。

它是如何工作的...

正如我们之前提到的,property描述符是一种在访问对象的属性时触发函数调用的机制。

在我们的示例中,它们使用下划线包装对内部属性的访问,如下所示:

contact.first_name = "John" # Stores "John" in contact._first_name
print(contact.first_name)   # Reads "John" from contact._first_name
contact.last_name = ""      # ValueError raised by the required function

property描述符通常与@decorated语法一起使用——请记住始终使用相同的名称来装饰函数:

    @property
    def last_name(self):
        # ...

    @last_name.setter
    def last_name(self, value):
        # ...

还有更多...

您可能会发现我们的Contact类的完整实现非常冗长和重复。对于每个属性,我们都需要在__init__方法中分配它,并编写其对应的 getter 和 setter 方法。

幸运的是,我们有几种替代方案来减少这种样板代码的数量。标准库中的namedtuple函数允许我们创建具有命名字段的轻量级元组子类:

from collections import namedtuple

Contact = namedtuple("Contact", ["last_name", "first_name",
                                 "email", "phone"])

但是,我们仍然需要添加一个解决方法来实现字段的验证。为了解决这个常见问题,我们可以使用 Python 包索引中提供的attrs包。

像往常一样,您可以使用以下命令行和pip安装它:

$ pip install attrs

安装后,您可以用attr.ib描述符替换所有属性。它还允许您指定一个validator回调,该回调接受类实例、要修改的属性和要设置的值。

通过一些小的修改,我们可以重写我们的Contact类,将代码行数减少一半:

import re
import attr

def required(message):
    def func(self, attr, val):
        if not val: raise ValueError(message)
    return func

def match(pattern, message):
    regex = re.compile(pattern)
    def func(self, attr, val):
        if val and not regex.match(val):
            raise ValueError(message)
    return func

@attr.s
class Contact(object):
    last_name = attr.ib(validator=required("Last name is required"))
    first_name = attr.ib(validator=required("First name is required"))
    email = attr.ib(validator=match(r"[^@]+@[^@]+\.[^@]+",
                                    "Invalid email format"))
    phone = attr.ib(validator=match(r"\([0-9]{3}\)\s[0-9]{7}",
                                    "Invalid phone format"))

在项目中添加外部依赖时,注意不仅要考虑生产力的好处,还要注意其他重要方面,如文档、支持和许可证。

您可以在其网站www.attrs.org/en/stable/上找到有关attrs包的更多信息。

组合小部件以显示信息

如果所有的代码都包含在一个类中,构建大型应用程序将会很困难。通过将 GUI 代码拆分为特定的类,我们可以模块化程序的结构,并创建具有明确定义目的的小部件。

准备工作

除了导入 Tkinter 包,我们还将从前面的配方中导入Contact类:

import tkinter as tk
import tkinter.messagebox as mb

from chapter5_01 import Contact

验证chapter5_01.py文件是否在相同的目录中;否则,这个import-from语句将引发ImportError

操作步骤...

我们将创建一个可滚动的列表,显示所有联系人。为了将列表中的每个项目表示为一个字符串,我们将显示联系人的姓和名:

class ContactList(tk.Frame):
    def __init__(self, master, **kwargs):
        super().__init__(master)
        self.lb = tk.Listbox(self, **kwargs)
        scroll = tk.Scrollbar(self, command=self.lb.yview)

        self.lb.config(yscrollcommand=scroll.set)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        self.lb.pack(side=tk.LEFT, fill=tk.BOTH, expand=1)

    def insert(self, contact, index=tk.END):
        text = "{}, {}".format(contact.last_name, contact.first_name)
        self.lb.insert(index, text)

    def delete(self, index):
        self.lb.delete(index, index)

    def update(self, contact, index):
        self.delete(index)
        self.insert(contact, index)

    def bind_doble_click(self, callback):
        handler = lambda _: callback(self.lb.curselection()[0])
        self.lb.bind("<Double-Button-1>", handler)

为了显示并允许我们编辑联系人的详细信息,我们还将创建一个特定的表单。我们将以LabelFrame小部件作为基类,为每个字段添加一个Label和一个Entry

class ContactForm(tk.LabelFrame):
    fields = ("Last name", "First name", "Email", "Phone")

    def __init__(self, master, **kwargs):
        super().__init__(master, text="Contact",
                         padx=10, pady=10, **kwargs)
        self.frame = tk.Frame(self)
        self.entries = list(map(self.create_field, 
        enumerate(self.fields)))
        self.frame.pack()

    def create_field(self, field):
        position, text = field
        label = tk.Label(self.frame, text=text)
        entry = tk.Entry(self.frame, width=25)
        label.grid(row=position, column=0, pady=5)
        entry.grid(row=position, column=1, pady=5)
        return entry

    def load_details(self, contact):
        values = (contact.last_name, contact.first_name,
                  contact.email, contact.phone)
        for entry, value in zip(self.entries, values):
            entry.delete(0, tk.END)
            entry.insert(0, value)

    def get_details(self):
        values = [e.get() for e in self.entries]
        try:
            return Contact(*values)
        except ValueError as e:
            mb.showerror("Validation error", str(e), parent=self)

    def clear(self):
        for entry in self.entries:
            entry.delete(0, tk.END)

工作原理...

ContactList类的一个重要细节是,它公开了将回调附加到双击事件的可能性。它还将点击的索引作为参数传递给这个函数。我们这样做是因为我们希望隐藏底层Listbox的实现细节:

    def bind_doble_click(self, callback):
        handler = lambda _: callback(self.lb.curselection()[0])
        self.lb.bind("<Double-Button-1>", handler)

ContactForm还提供了一个抽象,用于从输入的值实例化一个新的联系人:

    def get_details(self):
        values = [e.get() for e in self.entries]
        try:
            return Contact(*values)
        except ValueError as e:
            mb.showerror("Validation error", str(e), parent=self)

由于我们在Contact类中包含了字段验证,实例化一个新的联系人可能会引发ValueError,如果一个条目包含无效值。为了通知用户,我们会显示一个带有错误消息的错误对话框。

从 CSV 文件中读取记录

作为将只读数据加载到我们的应用程序的第一种方法,我们将使用逗号分隔值CSV)文件。这种格式将数据制表在纯文本文件中,其中每个文件对应于记录的字段,用逗号分隔,如下所示:

Gauford,Albertine,agauford0@acme.com,(614) 7171720
Greger,Bryce,bgreger1@acme.com,(616) 3543513
Wetherald,Rickey,rwetherald2@acme.com,(379) 3652495

这种解决方案对于简单的场景很容易实现,特别是如果文本字段不包含换行符。我们将使用标准库中的csv模块,一旦记录加载到我们的应用程序中,我们将填充在前面的配方中开发的小部件。

准备工作

我们将组装在前面的配方中创建的自定义小部件。一旦从 CSV 文件加载记录,我们的应用程序将如下截图所示:

操作步骤...

除了导入Contact类,我们还将导入ContactFormContactList小部件:

import csv
import tkinter as tk

from chapter5_01 import Contact
from chapter5_02 import ContactForm, ContactList

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("CSV Contact list")
        self.list = ContactList(self, height=12)
        self.form = ContactForm(self)
        self.contacts = self.load_contacts()

        for contact in self.contacts:
            self.list.insert(contact)
        self.list.pack(side=tk.LEFT, padx=10, pady=10)
        self.form.pack(side=tk.LEFT, padx=10, pady=10)
        self.list.bind_doble_click(self.show_contact)

    def load_contacts(self):
        with open("contacts.csv", encoding="utf-8", newline="") as f:
            return [Contact(*r) for r in csv.reader(f)]

    def show_contact(self, index):
        contact = self.contacts[index]
        self.form.load_details(contact)

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

工作原理...

load_contacts函数负责读取 CSV 文件,并将所有记录转换为Contact实例的列表。

csv.reader读取的每一行都作为一个字符串元组返回,通过使用逗号分隔符拆分相应的行创建。由于这个元组使用与Contact类的__init__方法中定义的参数相同的顺序,我们可以简单地使用*运算符解包它。这段代码可以用列表推导式总结为一行,如下所示:

def load_contacts(self):
    with open("contacts.csv", encoding="utf-8", newline="") as f:
        return [Contact(*r) for r in csv.reader(f)]

with块中返回列表没有问题,因为上下文管理器在方法执行完成时会自动关闭文件。

将数据持久化到 SQLite 数据库

由于我们希望能够通过我们的应用程序持久保存数据的更改,我们必须实现一个既用于读取又用于写入操作的解决方案。

我们可以在每次修改后将所有记录写入我们从中读取的同一纯文本文件,但是当单独更新一些记录时,这可能是一种低效的解决方案。

由于所有信息都将存储在本地,我们可以使用 SQLite 数据库来持久保存我们的应用程序数据。sqlite3模块是标准库的一部分,因此您无需任何额外的依赖项即可开始使用它。

这个示例并不打算成为 SQLite 的全面指南,而是一个实际的介绍,将其集成到您的 Tkinter 应用程序中。

准备工作

在我们的应用程序中使用数据库之前,我们需要创建并填充它一些初始数据。我们所有的联系人都存储在 CSV 文件中,因此我们将使用迁移脚本读取所有记录并将它们插入数据库。

首先,我们创建到contacts.db文件的连接,我们的数据将存储在其中。然后,我们使用last_namefirst_nameemailphone文本字段创建contacts表。

由于csv.reader返回一个元组的可迭代对象,其字段遵循我们在CREATE TABLE语句中定义的相同顺序,我们可以直接将其传递给executemany方法。它将为每个元组执行INSERT语句,用实际值替换问号:

import csv
import sqlite3

def main():
    with open("contacts.csv", encoding="utf-8", newline="") as f, \
         sqlite3.connect("contacts.db") as conn:
        conn.execute("""CREATE TABLE contacts (
                          last_name text,
                          first_name text,
                          email text,
                          phone text
                        )""")
        conn.executemany("INSERT INTO contacts VALUES (?,?,?,?)",
                         csv.reader(f))

if __name__ == "__main__":
    main()

with语句会自动提交事务,并在执行结束时关闭文件和 SQLite 连接。

如何做...

要将新联系人添加到我们的数据库,我们将定义一个Toplevel子类,它重用ContactForm来实例化一个新联系人:

class NewContact(tk.Toplevel):
    def __init__(self, parent):
        super().__init__(parent)
        self.contact = None
        self.form = ContactForm(self)
        self.btn_add = tk.Button(self, text="Confirm",
                                 command=self.confirm)
        self.form.pack(padx=10, pady=10)
        self.btn_add.pack(pady=10)

    def confirm(self):
        self.contact = self.form.get_details()
        if self.contact:
            self.destroy()

    def show(self):
        self.grab_set()
        self.wait_window()
        return self.contact

以下顶级窗口将显示在主窗口之上,并在对话框确认或关闭后返回焦点:

我们还将扩展我们的ContactForm类,增加两个额外的按钮——一个用于更新联系人信息,另一个用于删除所选联系人:

class UpdateContactForm(ContactForm):
    def __init__(self, master, **kwargs):
        super().__init__(master, **kwargs)
        self.btn_save = tk.Button(self, text="Save")
        self.btn_delete = tk.Button(self, text="Delete")

        self.btn_save.pack(side=tk.RIGHT, ipadx=5, padx=5, pady=5)
        self.btn_delete.pack(side=tk.RIGHT, ipadx=5, padx=5, pady=5)

    def bind_save(self, callback):
        self.btn_save.config(command=callback)

    def bind_delete(self, callback):
        self.btn_delete.config(command=callback)

bind_savebind_delete方法允许我们将回调附加到相应按钮的command上。

要整合所有这些更改,我们将向我们的App类添加以下代码:

class App(tk.Tk):
    def __init__(self, conn):
        super().__init__()
        self.title("SQLite Contacts list")
        self.conn = conn
 self.selection = None
        self.list = ContactList(self, height=15)
        self.form = UpdateContactForm(self)
        self.btn_new = tk.Button(self, text="Add new contact",
 command=self.add_contact)
        self.contacts = self.load_contacts()

        for contact in self.contacts:
            self.list.insert(contact)
        self.list.pack(side=tk.LEFT, padx=10, pady=10)
        self.form.pack(padx=10, pady=10)
        self.btn_new.pack(side=tk.BOTTOM, pady=5)

        self.list.bind_doble_click(self.show_contact)
        self.form.bind_save(self.update_contact)
 self.form.bind_delete(self.delete_contact)

我们还需要修改load_contacts方法以从查询结果创建联系人:

    def load_contacts(self):
        contacts = []
        sql = """SELECT rowid, last_name, first_name, email, phone
                 FROM contacts"""
        for row in self.conn.execute(sql):
            contact = Contact(*row[1:])
            contact.rowid = row[0]
            contacts.append(contact)
        return contacts

    def show_contact(self, index):
        self.selection = index
        contact = self.contacts[index]
        self.form.load_details(contact)

要将联系人添加到列表中,我们将实例化一个NewContact对话框,并调用其show方法以获取新联系人的详细信息。如果这些值有效,我们将按照与我们的INSERT语句中指定的相同顺序将它们存储在一个元组中:

    def to_values(self, c):
        return (c.last_name, c.first_name, c.email, c.phone)

    def add_contact(self):
        new_contact = NewContact(self)
        contact = new_contact.show()
        if not contact:
            return
        values = self.to_values(contact)
        with self.conn:
            cursor = self.conn.cursor()
            cursor.execute("INSERT INTO contacts VALUES (?,?,?,?)", 
            values)
            contact.rowid = cursor.lastrowid
        self.contacts.append(contact)
        self.list.insert(contact)

选择联系人后,我们可以通过检索当前表单值来更新其详细信息。如果它们有效,我们执行UPDATE语句以设置具有指定rowid的记录的列。

由于此语句的字段与INSERT语句的顺序相同,我们重用to_values方法从联系人实例创建一个元组——唯一的区别是我们必须附加rowid的替换参数:

    def update_contact(self):
        if self.selection is None:
            return
        rowid = self.contacts[self.selection].rowid
        contact = self.form.get_details()
        if contact:
            values = self.to_values(contact)
            with self.conn:
                sql = """UPDATE contacts SET
                         last_name = ?,
                         first_name = ?,
                         email = ?,
                         phone = ?
                     WHERE rowid = ?"""
                self.conn.execute(sql, values + (rowid,))
            contact.rowid = rowid
            self.contacts[self.selection] = contact
            self.list.update(contact, self.selection)

要删除所选联系人,我们获取其rowid以替换我们的DELETE语句。一旦事务提交,联系人将从 GUI 中清除表单并从列表中删除。selection属性也设置为None,以避免对无效选择执行操作:

    def delete_contact(self):
        if self.selection is None:
            return
        rowid = self.contacts[self.selection].rowid
        with self.conn:
            self.conn.execute("DELETE FROM contacts WHERE rowid = ?",
                              (rowid,))
        self.form.clear()
        self.list.delete(self.selection)
        self.selection = None

最后,我们将包装代码以初始化我们的应用程序在一个main函数中:

def main():
    with sqlite3.connect("contacts.db") as conn:
        app = App(conn)
        app.mainloop()

if __name__ == "__main__":
    main()

有了所有这些更改,我们完整的应用程序将如下所示:

工作原理...

这种类型的应用程序被称为CRUD首字母缩写,代表创建、读取、更新和删除,并且很容易映射到 SQL 语句INSERTSELECTUPDATEDELETE。我们现在将看一下如何使用sqlite3.Connection类来实现每个操作。

INSERT语句向表中添加新记录,指定列名和相应的值。如果省略列名,将使用列顺序。

当你在 SQLite 中创建一个表时,默认情况下会添加一个名为rowid的列,并自动分配一个唯一值来标识每一行。由于我们通常需要它进行后续操作,我们使用Cursor类中可用的lastrowid属性来检索它:

sql = "INSERT INTO my_table (col1, col2, col3) VALUES (?, ?, ?)"
with connection:
    cursor = connection.cursor()
    cursor.execute(sql, (value1, value2, value3))
    rowid = cursor.lastrowid

SELECT语句从表的记录中检索一个或多个列的值。可选地,我们可以添加一个WHERE子句来过滤要检索的记录。这对于有效地实现搜索和分页非常有用,但在我们的示例应用程序中,我们将忽略这个功能:

sql = "SELECT rowid, col1, col2, col3 FROM my_table"
for row in connection.execute(sql):
    # do something with row

UPDATE语句修改表中记录的一个或多个列的值。通常,我们添加一个WHERE子句,只更新符合给定条件的行 - 在这里,如果我们想要更新特定记录,我们可以使用rowid

sql = "UPDATE my_table SET col1 = ?, col2 = ?, col3 = ? 
WHERE rowid = ?"
with connection:
    connection.execute(sql, (value1, value2, value3, rowid))

最后,DELETE语句从表中删除一个或多个记录。在这些语句中添加WHERE子句更加重要,因为如果我们省略它,该语句将删除表中的所有行:

sql = "DELETE FROM my_table WHERE rowid = ?"
with connection:
    connection.execute(sql, (rowid,))

另请参阅

  • 组合小部件以显示信息食谱

使用 MVC 模式进行重构

现在我们已经开发了应用程序的完整功能,我们可以发现当前设计中存在一些问题。例如,App类有多个职责,从实例化 Tkinter 小部件到执行 SQL 语句。

尽管编写从头到尾执行操作的方法似乎很容易和直接,但这种方法会导致更难以维护的代码库。我们可以通过预期可能的架构更改来检测这种缺陷,例如用通过 HTTP 访问的 REST 后端替换我们的关系数据库。

准备工作

让我们首先定义 MVC 模式以及它如何映射到我们在上一篇文章中构建的应用程序的不同部分。

这种模式将我们的应用程序分为三个组件,每个组件封装一个单一的责任,形成 MVC 三合一:

  • 模型表示领域数据,并包含与之交互的业务规则。在我们的示例中,它是Contact类和特定于 SQLite 的代码。

  • 视图是模型数据的图形表示。在我们的情况下,它由组成 GUI 的 Tkinter 小部件组成。

  • 控制器通过接收用户输入并更新模型数据来连接视图和模型。这对应于我们的回调和事件处理程序以及所需的属性。

我们将重构我们的应用程序以实现这种关注点的分离。您会注意到组件之间的交互需要额外的代码,但它们也帮助我们定义它们的边界。

如何做...

首先,我们将所有与数据库交互的代码片段提取到一个单独的类中。这将允许我们隐藏持久层的实现细节,只暴露四个必要的方法,get_contactsadd_contactupdate_contactdelete_contact

class ContactsRepository(object):
    def __init__(self, conn):
        self.conn = conn

    def to_values(self, c):
        return c.last_name, c.first_name, c.email, c.phone

    def get_contacts(self):
        sql = """SELECT rowid, last_name, first_name, email, phone
                 FROM contacts"""
        for row in self.conn.execute(sql):
            contact = Contact(*row[1:])
            contact.rowid = row[0]
            yield contact

    def add_contact(self, contact):
        sql = "INSERT INTO contacts VALUES (?, ?, ?, ?)"
        with self.conn:
            cursor = self.conn.cursor()
            cursor.execute(sql, self.to_values(contact))
            contact.rowid = cursor.lastrowid
        return contact

    def update_contact(self, contact):
        rowid = contact.rowid
        sql = """UPDATE contacts
                 SET last_name = ?, first_name = ?, email = ?, 
                 phone = ?
                 WHERE rowid = ?"""
        with self.conn:
            self.conn.execute(sql, self.to_values(contact) + (rowid,))
        return contact

    def delete_contact(self, contact):
        sql = "DELETE FROM contacts WHERE rowid = ?"
        with self.conn:
            self.conn.execute(sql, (contact.rowid,))

这个,连同Contact类,将组成我们的模型。

现在,我们的视图将只包含足够的代码来显示 GUI 和让控制器更新它的方法。我们还将将类重命名为ContactsView,以更好地表达其目的。

class ContactsView(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("SQLite Contacts list")
        self.list = ContactList(self, height=15)
        self.form = UpdateContactForm(self)
        self.btn_new = tk.Button(self, text="Add new contact")

        self.list.pack(side=tk.LEFT, padx=10, pady=10)
        self.form.pack(padx=10, pady=10)
        self.btn_new.pack(side=tk.BOTTOM, pady=5)

    def set_ctrl(self, ctrl):
        self.btn_new.config(command=ctrl.create_contact)
        self.list.bind_doble_click(ctrl.select_contact)
        self.form.bind_save(ctrl.update_contact)
        self.form.bind_delete(ctrl.delete_contact)

    def add_contact(self, contact):
        self.list.insert(contact)

    def update_contact(self, contact, index):
        self.list.update(contact, index)

    def remove_contact(self, index):
        self.form.clear()
        self.list.delete(index)

    def get_details(self):
        return self.form.get_details()

    def load_details(self, contact):
        self.form.load_details(contact)

请注意,用户输入由控制器处理,因此我们添加了一个set_ctrl方法来将其连接到 Tkinter 回调。

我们的ContactsController类现在将包含我们初始的App类中缺失的所有代码,也就是界面和持久性之间的交互,具有selectioncontacts属性:

class ContactsController(object):
    def __init__(self, repo, view):
        self.repo = repo
        self.view = view
        self.selection = None
        self.contacts = list(repo.get_contacts())

    def create_contact(self):
        new_contact = NewContact(self.view).show()
        if new_contact:
            contact = self.repo.add_contact(new_contact)
            self.contacts.append(contact)
            self.view.add_contact(contact)

    def select_contact(self, index):
        self.selection = index
        contact = self.contacts[index]
        self.view.load_details(contact)

    def update_contact(self):
        if not self.selection:
            return
        rowid = self.contacts[self.selection].rowid
        update_contact = self.view.get_details()
        update_contact.rowid = rowid

        contact = self.repo.update_contact(update_contact)
        self.contacts[self.selection] = contact
        self.view.update_contact(contact, self.selection)

    def delete_contact(self):
        if not self.selection:
            return
        contact = self.contacts[self.selection]
        self.repo.delete_contact(contact)
        self.view.remove_contact(self.selection)

    def start(self):
        for c in self.contacts:
            self.view.add_contact(c)
        self.view.mainloop()

我们将创建一个__main__.py脚本,不仅允许我们引导我们的应用程序,还可以从压缩文件或包含目录名称启动它:

# Suppose that __main__.py is in the directory chapter5_05
$ python chapter5_05
# Or if we compress the directory contents
$ python chapter5_05.zip

工作原理...

原始的 MVC 实现是在 Smalltalk 编程语言中引入的,并且由以下图表表示:

在前面的图表中,我们可以看到视图将用户事件传递给控制器,控制器再更新模型。为了将这些更改传播到视图,模型实现了观察者模式。这意味着订阅模型的视图在更新发生时会收到通知,因此它们可以查询模型状态并更改显示的数据。

还有一种设计的变体,视图和模型之间没有通信。相反,控制器在更新模型后,通过更改视图来进行视图的更改。

这种方法被称为被动模型,它是现代 MVC 实现中最常见的方法,特别是对于 Web 框架来说。我们在示例中使用了这种变体,因为它简化了我们的ContactsRepository,并且不需要对我们的ContactsController类进行重大修改。

还有更多...

您可能已经注意到,更新和删除操作是通过rowid字段实现的,例如,在ContactsController类的update_contact方法中:

    def update_contact(self):
        if not self.selection:
            return
        rowid = self.contacts[self.selection].rowid
        update_contact = self.view.get_details()
        update_contact.rowid = rowid

由于这是我们 SQLite 数据库的实现细节,这应该对我们的其他组件隐藏起来。

一个解决方案是向Contact类添加另一个字段,例如idcontact_id,注意id也是 Python 的内置函数,一些编辑器可能会错误地将其标记出来。

然后,我们可以假设这个字段是我们领域数据的一部分,作为一个唯一标识符,并将它的生成实现细节留给模型。

第六章:异步编程

在本章中,我们将介绍以下食谱:

  • 调度操作

  • 在线程上运行方法

  • 执行 HTTP 请求

  • 将线程与进度条连接起来

  • 取消已调度的操作

  • 处理空闲任务

  • 生成单独的进程

介绍

与任何其他编程语言一样,Python 允许您将进程执行分成多个可以在时间上独立执行的单元,称为线程。当启动 Python 程序时,它会在主线程中开始执行。

Tkinter 的主循环必须从主线程开始,负责处理所有 GUI 的事件和更新。默认情况下,我们的应用程序代码,如回调和事件处理程序,也将在此线程中执行。

然而,如果我们在这个线程中启动一个长时间运行的操作,主线程的执行将会被阻塞,因此 GUI 将会冻结,并且不会响应用户事件。

在本章中,我们将介绍几种方法来实现应用程序的响应性,同时在后台执行单独的操作,并了解如何与它们交互。

调度操作

在 Tkinter 中防止阻塞主线程的基本技术是调度一个在超时后被调用的操作。

在本食谱中,我们将介绍如何使用after()方法在 Tkinter 中实现这一点,该方法可以从所有 Tkinter 小部件类中调用。

准备就绪

以下代码展示了一个回调如何阻塞主循环的简单示例。

该应用程序由一个按钮组成,当单击时会被禁用,等待 5 秒,然后再次启用。一个简单的实现如下:

import time
import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.button = tk.Button(self, command=self.start_action,
                                text="Wait 5 seconds")
        self.button.pack(padx=20, pady=20)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        time.sleep(5)
        self.button.config(state=tk.NORMAL)

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

如果运行上述程序,您会注意到等待 5 秒按钮根本没有被禁用,但点击它会使 GUI 冻结 5 秒。我们可以直接注意到按钮样式的变化,看起来是活动的而不是禁用的;此外,标题栏在 5 秒时间到之前将不会响应鼠标点击:

如果我们包含了其他小部件,比如输入框和滚动条,这也会受到影响。

现在,我们将看看如何通过调度操作而不是挂起线程执行来实现所需的功能。

如何做...

after()方法允许您注册一个回调函数,在 Tkinter 的主循环中延迟指定的毫秒数后调用。您可以将这些注册的警报视为应该在系统空闲时立即处理的事件。

因此,我们将使用self.after(5000, callback)替换对time.sleep(5)的调用。我们使用self实例,因为after()方法也可以在根Tk实例中使用,并且从子小部件中调用它不会有任何区别:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.button = tk.Button(self, command=self.start_action,
                                text="Wait 5 seconds")
        self.button.pack(padx=50, pady=20)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        self.after(5000, lambda: self.button.config(state=tk.NORMAL))

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

使用上述方法,应用程序在调度操作被调用之前是响应的。按钮的外观将变为禁用状态,我们也可以像往常一样与标题栏交互:

工作原理...

从前面部分提到的示例中,您可能会认为after()方法会在给定的毫秒数经过后准确执行回调。

然而,它只是请求 Tkinter 注册一个警报,保证不会在指定的时间之前执行;因此,如果主线程忙碌,实际执行时间是没有上限的。

我们还应该记住,在调度操作之后,方法的执行立即继续。以下示例说明了这种行为:

print("First")
self.after(1000, lambda: print("Third"))
print("Second")

上述代码段将分别在 1 秒后打印"First""Second""Third"。在此期间,主线程将保持 GUI 响应,并且用户可以像往常一样与应用程序交互。

通常,我们希望防止同一后台操作的运行超过一次,因此最好禁用触发执行的小部件。

不要忘记,任何预定的函数都将在主线程上执行,因此仅仅使用after()是不足以防止 GUI 冻结的;还重要的是避免执行长时间运行的方法作为回调。

在下一个示例中,我们将看看如何利用单独的线程执行这些阻塞操作。

还有更多...

after()方法返回一个预定警报的标识符,可以将其传递给after_cancel()方法以取消回调的执行。

在另一个示例中,我们将看到如何使用这种方法实现停止预定回调的功能。

另请参阅

  • 取消预定操作示例

在线程上运行方法

由于主线程应该负责更新 GUI 和处理事件,因此其余的后台操作必须在单独的线程中执行。

Python 的标准库包括threading模块,用于使用高级接口创建和控制多个线程,这将允许我们使用简单的类和方法。

值得一提的是,CPython——参考 Python 实现——受GIL全局解释器锁)的固有限制,这是一种防止多个线程同时执行 Python 字节码的机制,因此它们无法在单独的核心上运行,无法充分利用多处理器系统。如果尝试使用threading模块来提高应用程序的性能,应该记住这一点。

如何做...

以下示例将time.sleep()的线程暂停与通过after()调度的操作结合起来:

import time
import threading
import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.button = tk.Button(self, command=self.start_action,
                                text="Wait 5 seconds")
        self.button.pack(padx=50, pady=20)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        thread = threading.Thread(target=self.run_action)
        print(threading.main_thread().name)
        print(thread.name)
        thread.start()
        self.check_thread(thread)

    def check_thread(self, thread):
        if thread.is_alive():
            self.after(100, lambda: self.check_thread(thread))
        else:
            self.button.config(state=tk.NORMAL)

    def run_action(self):
        print("Starting long running action...")
        time.sleep(5)
        print("Long running action finished!")

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

它是如何工作的...

要创建一个新的Thread对象,可以使用带有target关键字参数的构造函数,在调用其start()方法时将在单独的线程上调用它。

在前面的部分中,我们在当前应用程序实例上使用了对run_action方法的引用:

    thread = threading.Thread(target=self.run_action)
    thread.start()

然后,我们使用after()定期轮询线程状态,直到线程完成为止:

    def check_thread(self, thread):
        if thread.is_alive():
            self.after(100, lambda: self.check_thread(thread))
        else:
            self.button.config(state=tk.NORMAL)

在前面的代码片段中,我们设置了100毫秒的延迟,因为没有必要以更频繁的频率进行轮询。当然,这个数字可能会根据线程操作的性质而变化。

这个时间线可以用以下序列图表示:

Thread-1上的矩形表示它忙于执行time.sleep(5)的时间。与此同时,MainThread只定期检查状态,没有操作长到足以导致 GUI 冻结。

还有更多...

在这个示例中,我们简要介绍了Thread类,但同样重要的是指出一些关于在 Python 程序中实例化和使用线程的细节。

线程方法 - start、run 和 join

在我们的示例中,我们调用了start(),因为我们希望在单独的线程中执行该方法并继续执行当前线程。

另一方面,如果我们调用了join()方法,主线程将被阻塞,直到新线程终止。因此,即使我们使用多个线程,它也会导致我们想要避免的相同的“冻结”行为。

最后,run()方法是线程实际执行其可调用目标操作的地方。当我们扩展Thread类时,我们将覆盖它,就像下一个示例中一样。

作为一个经验法则,始终记住从主线程调用start()以避免阻塞它。

参数化目标方法

在使用Thread类的构造函数时,可以通过args参数指定目标方法的参数:

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        thread = threading.Thread(target=self.run_action, args=(5,))
        thread.start()
        self.check_thread(thread)

    def run_action(self, timeout):
        # ...

请注意,由于我们正在使用当前实例引用目标方法,因此self参数会自动传递。在新线程需要访问来自调用方实例的信息的情况下,这可能很方便。

执行 HTTP 请求

通过 HTTP 与远程服务器通信是异步编程的常见用例。客户端执行请求,该请求使用 TCP/IP 协议在网络上传输;然后,服务器处理信息并将响应发送回客户端。

执行此操作所需的时间可能会从几毫秒到几秒不等,但在大多数情况下,可以安全地假设用户可能会注意到这种延迟。

做好准备

互联网上有很多第三方网络服务可以免费访问以进行原型设计。但是,我们不希望依赖外部服务,因为其 API 可能会更改,甚至可能会下线。

对于这个示例,我们将实现我们自己的 HTTP 服务器,该服务器将生成一个随机的 JSON 响应,该响应将打印在我们单独的 GUI 应用程序中:

import time
import json
import random
from http.server import HTTPServer, BaseHTTPRequestHandler

class RandomRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # Simulate latency
        time.sleep(3)

        # Write response headers
        self.send_response(200)
        self.send_header('Content-type', 'application/json')
        self.end_headers()

        # Write response body
        body = json.dumps({'random': random.random()})
        self.wfile.write(bytes(body, "utf8"))

def main():
    """Starts the HTTP server on port 8080"""
    server_address = ('', 8080)
    httpd = HTTPServer(server_address, RandomRequestHandler)
    httpd.serve_forever()

if __name__ == "__main__":
    main()

要启动此服务器,请运行server.py脚本,并保持进程运行以接受本地端口8080上的传入 HTTP 请求。

如何做...

我们的客户端应用程序包括一个简单的标签,用于向用户显示信息,以及一个按钮,用于向我们的本地服务器执行新的 HTTP 请求:

import json
import threading
import urllib.request
import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("HTTP request example")
        self.label = tk.Label(self,
                              text="Click 'Start' to get a random 
                              value")
        self.button = tk.Button(self, text="Start",
                                command=self.start_action)
        self.label.pack(padx=60, pady=10)
        self.button.pack(pady=10)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        thread = AsyncAction()
        thread.start()
        self.check_thread(thread)

    def check_thread(self, thread):
        if thread.is_alive():
            self.after(100, lambda: self.check_thread(thread))
        else:
            text = "Random value: {}".format(thread.result)
            self.label.config(text=text)
            self.button.config(state=tk.NORMAL)

class AsyncAction(threading.Thread):
    def run(self):
        self.result = None
        url = "http://localhost:8080"
        with urllib.request.urlopen(url) as f:
            obj = json.loads(f.read().decode("utf-8"))
            self.result = obj["random"]

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

当请求完成时,标签显示服务器中生成的随机值,如下所示:

通常情况下,当异步操作正在运行时,按钮会被禁用,以避免在处理前一个请求之前执行新的请求。

工作原理...

在这个示例中,我们扩展了Thread类,以使用更面向对象的方法实现必须在单独线程中运行的逻辑。这是通过覆盖其run()方法来完成的,该方法将负责执行对本地服务器的 HTTP 请求:

class AsyncAction(threading.Thread):
    def run(self):
        # ...

有很多 HTTP 客户端库,但在这里,我们将简单地使用标准库中的urllib.request模块。该模块包含urlopen()函数,可以接受 URL 字符串并返回一个 HTTP 响应,可以作为上下文管理器使用,即可以使用with语句安全地读取和关闭。

服务器返回一个 JSON 文档,如下所示(您可以通过在浏览器中打开http://localhost:8080URL 来检查):

{"random": 0.0915826359180778}

为了将字符串解码为对象,我们将响应内容传递给json模块的loads()函数。由于这样,我们可以像使用字典一样访问随机值,并将其存储在result属性中,该属性初始化为None,以防止主线程在发生错误时读取未设置的字段:

def run(self):
    self.result = None
    url = "http://localhost:8080"
    with urllib.request.urlopen(url) as f:
        obj = json.loads(f.read().decode("utf-8"))
        self.result = obj["random"]

然后,GUI 定期轮询线程状态,就像我们在前面的示例中看到的那样:

    def check_thread(self, thread):
        if thread.is_alive():
            self.after(100, lambda: self.check_thread(thread))
        else:
            text = "Random value: {}".format(thread.result)
            self.label.config(text=text)
            self.button.config(state=tk.NORMAL)

这里,主要的区别在于一旦线程不再活动,我们可以检索result属性的值,因为它在执行结束之前已经设置。

另请参阅

  • 在线程上运行方法示例

将线程与进度条连接起来

进度条是后台任务状态的有用指示器,显示相对于进度的逐步填充部分。它们经常用于长时间运行的操作,因此通常将它们与执行这些任务的线程连接起来,以向最终用户提供视觉反馈。

做好准备

我们的示例应用程序将包括一个水平进度条,一旦用户点击“开始”按钮,它将增加固定数量的进度:

如何做...

为了模拟后台任务的执行,进度条的增量将由一个不同的线程生成,该线程将在每个步骤之间暂停 1 秒。

通信将使用同步队列进行,这允许我们以线程安全的方式交换信息:

import time
import queue
import threading
import tkinter as tk
import tkinter.ttk as ttk
import tkinter.messagebox as mb

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Progressbar example")
        self.queue = queue.Queue()
        self.progressbar = ttk.Progressbar(self, length=300,
                                           orient=tk.HORIZONTAL)
        self.button = tk.Button(self, text="Start",
                                command=self.start_action)

        self.progressbar.pack(padx=10, pady=10)
        self.button.pack(padx=10, pady=10)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        thread = AsyncAction(self.queue, 20)
        thread.start()
        self.poll_thread(thread)

    def poll_thread(self, thread):
        self.check_queue()
        if thread.is_alive():
            self.after(100, lambda: self.poll_thread(thread))
        else:
            self.button.config(state=tk.NORMAL)
            mb.showinfo("Done!", "Async action completed")

    def check_queue(self):
        while self.queue.qsize():
            try:
                step = self.queue.get(0)
                self.progressbar.step(step * 100)
            except queue.Empty:
                pass

class AsyncAction(threading.Thread):
    def __init__(self, queue, steps):
        super().__init__()
        self.queue = queue
        self.steps = steps

    def run(self):
        for _ in range(self.steps):
            time.sleep(1)
            self.queue.put(1 / self.steps)

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

它是如何工作的...

Progressbartkinter.ttk模块中包含的一个主题小部件。我们将在第八章中深入探讨这个模块,探索它定义的新小部件,但到目前为止,我们只需要将Progressbar作为常规小部件使用。

我们还需要导入queue模块,该模块定义了同步集合,如Queue。在多线程环境中,同步性是一个重要的主题,因为如果在完全相同的时间访问共享资源,可能会出现意外的结果,我们将这些不太可能但可能发生的情况定义为竞争条件

通过这些添加,我们的App类包含了这些新的语句:

# ...
import queue
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self):
        # ...
        self.queue = queue.Queue()
 self.progressbar = ttk.Progressbar(self, length=300,
 orient=tk.HORIZONTAL)

与以前的示例一样,start_action()方法启动一个线程,传递队列和将模拟长时间运行任务的步数:

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        thread = AsyncAction(self.queue, 20)
        thread.start()
        self.poll_thread(thread)

我们的AsyncAction子类定义了一个自定义构造函数来接收这些参数,这些参数将在run()方法中使用:

class AsyncAction(threading.Thread):
    def __init__(self, queue, steps):
        super().__init__()
        self.queue = queue
        self.steps = steps

    def run(self):
        for _ in range(self.steps):
            time.sleep(1)
            self.queue.put(1 / self.steps)

循环暂停线程的执行 1 秒,并根据steps属性中指示的次数将增量添加到队列中。

从应用程序实例中读取队列,从check_queue()中检查队列中添加的项目:

    def check_queue(self):
        while self.queue.qsize():
            try:
                step = self.queue.get(0)
                self.progressbar.step(step * 100)
            except queue.Empty:
                pass

poll_thread()定期调用以下方法,该方法轮询线程状态并使用after()再次调度自己,直到线程完成执行:

    def poll_thread(self, thread):
        self.check_queue()
        if thread.is_alive():
            self.after(100, lambda: self.poll_thread(thread))
        else:
            self.button.config(state=tk.NORMAL)
            mb.showinfo("Done!", "Async action completed")

另请参阅

  • 在线程上运行方法食谱

取消预定的操作

Tkinter 的调度机制不仅提供了延迟回调执行的方法,还提供了取消它们的方法,如果它们尚未执行。考虑一个可能需要太长时间才能完成的操作,因此我们希望让用户通过按下按钮或关闭应用程序来停止它。

准备工作

我们将从第一个食谱中获取示例,并添加一个 Stop 按钮,以允许我们取消预定的操作。

这个按钮只有在操作被预定时才会启用,这意味着一旦单击左按钮,用户可以等待 5 秒,或者单击 Stop 按钮立即再次启用它:

如何做到这一点...

after_cancel()方法通过获取先前调用after()返回的标识符来取消预定操作的执行。在这个例子中,这个值存储在scheduled_id属性中:

import time
import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.button = tk.Button(self, command=self.start_action,
                                text="Wait 5 seconds")
        self.cancel = tk.Button(self, command=self.cancel_action,
                                text="Stop", state=tk.DISABLED)
        self.button.pack(padx=30, pady=20, side=tk.LEFT)
        self.cancel.pack(padx=30, pady=20, side=tk.LEFT)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        self.cancel.config(state=tk.NORMAL)
        self.scheduled_id = self.after(5000, self.init_buttons)

    def init_buttons(self):
        self.button.config(state=tk.NORMAL)
        self.cancel.config(state=tk.DISABLED)

    def cancel_action(self):
        print("Canceling scheduled", self.scheduled_id)
        self.after_cancel(self.scheduled_id)
        self.init_buttons()

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

它是如何工作的...

要取消回调,我们首先需要after()返回的警报标识符。我们将把这个标识符存储在scheduled_id属性中,因为我们将在一个单独的方法中需要它:

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        self.cancel.config(state=tk.NORMAL)
        self.scheduled_id = self.after(5000, self.init_buttons)

然后,该字段被传递给Stop按钮的回调函数中的after_cancel()

    def cancel_action(self):
        print("Canceling scheduled", self.scheduled_id)
        self.after_cancel(self.scheduled_id)
        self.init_buttons()

在我们的情况下,一旦单击Start按钮,将其禁用是很重要的,因为如果start_action()被调用两次,scheduled_id将被覆盖,而Stop按钮只能取消最后一个预定的操作。

顺便说一句,如果我们使用已经执行过的警报标识符调用after_cancel(),它将没有效果。

还有更多...

在本节中,我们介绍了如何取消预定的警报,但是如果此回调正在轮询后台线程的状态,您可能会想知道如何停止线程。

不幸的是,没有官方的 API 可以优雅地停止Thread实例。如果您已经定义了一个自定义子类,您可能需要在其run()方法中定期检查的标志。

class MyAsyncAction(threading.Thread):
    def __init__(self):
        super().__init__()
        self.do_stop = False

    def run(self):
        # Start execution...
        if not self.do_stop:
            # Continue execution...

然后,当调用after_cancel()时,这个标志可以通过设置thread.do_stop = True来外部修改,也可以停止线程。

显然,这种方法将严重依赖于run()方法内部执行的操作,例如,如果它由一个循环组成,那么您可以在每次迭代之间执行此检查。

从 Python 3.4 开始,您可以使用asyncio模块,其中包括管理异步操作的类和函数,包括取消。尽管这个模块超出了本书的范围,但如果您面对更复杂的情况,我们建议您探索一下。

处理空闲任务

有些情况下,某个操作会导致程序执行时出现短暂的暂停。它甚至可能不到一秒就完成,但对于用户来说仍然是可察觉的,因为它在 GUI 中引入了短暂的暂停。

在这个配方中,我们将讨论如何处理这些情况,而无需在单独的线程中处理整个任务。

准备工作

我们将从Scheduling actions配方中取一个例子,但超时时间为 1 秒,而不是 5 秒。

如何做...

当我们将按钮的状态更改为DISABLED时,回调函数继续执行,因此按钮的状态实际上直到系统处于空闲状态时才会更改,这意味着它必须等待time.sleep()完成。

但是,我们可以强制 Tkinter 在特定时刻更新所有挂起的 GUI 更新,如下面的脚本所示:

import time
import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.button = tk.Button(self, command=self.start_action,
                                text="Wait 1 second")
        self.button.pack(padx=30, pady=20)

    def start_action(self):
        self.button.config(state=tk.DISABLED)
        self.update_idletasks()
        time.sleep(1)
        self.button.config(state=tk.NORMAL)

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

工作原理...

在前面部分提到的代码片段中,关键是调用self.update_idletasks()。由于这一点,按钮状态的更改在调用time.sleep()之前由 Tkinter 处理。因此,在回调被暂停的一秒钟内,按钮具有期望的外观,而不是 Tkinter 在调用回调之前设置的ACTIVE状态。

我们使用time.sleep()来说明一个语句执行时间长,但足够短,可以考虑将其移到新线程中的情况——在现实世界的场景中,这将是一个更复杂的计算操作。

生成单独的进程

在某些情况下,仅使用线程可能无法实现应用程序所需的功能。例如,您可能希望调用用不同语言编写的单独程序。

在这种情况下,我们还需要使用subprocess模块从 Python 进程中调用目标程序。

准备工作

以下示例执行对指定 DNS 或 IP 地址的 ping 操作:

如何做...

像往常一样,我们定义一个自定义的AsyncAction方法,但在这种情况下,我们使用 Entry 小部件中设置的值调用subprocess.run()

这个函数启动一个单独的子进程,与线程不同,它使用单独的内存空间。这意味着为了获得ping命令的结果,我们必须将打印到标准输出的结果进行管道传输,并在我们的 Python 程序中读取它:

import threading
import subprocess
import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.entry = tk.Entry(self)
        self.button = tk.Button(self, text="Ping!",
                                command=self.do_ping)
        self.output = tk.Text(self, width=80, height=15)

        self.entry.grid(row=0, column=0, padx=5, pady=5)
        self.button.grid(row=0, column=1, padx=5, pady=5)
        self.output.grid(row=1, column=0, columnspan=2,
                         padx=5, pady=5)

    def do_ping(self):
        self.button.config(state=tk.DISABLED)
        thread = AsyncAction(self.entry.get())
        thread.start()
        self.poll_thread(thread)

    def poll_thread(self, thread):
        if thread.is_alive():
            self.after(100, lambda: self.poll_thread(thread))
        else:
            self.button.config(state=tk.NORMAL)
            self.output.delete(1.0, tk.END)
            self.output.insert(tk.END, thread.result)

class AsyncAction(threading.Thread):
    def __init__(self, ip):
        super().__init__()
        self.ip = ip

    def run(self):
        self.result = subprocess.run(["ping", self.ip], shell=True,
                                     stdout=subprocess.PIPE).stdout

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

工作原理...

run()函数执行数组参数中指定的子进程。默认情况下,结果只包含进程的返回代码,因此我们还传递了stdout选项和PIPE常量,以指示应将标准输出流进行管道传输。

我们使用关键字参数shell设置为True来调用这个函数,以避免为ping子进程打开新的控制台:

    def run(self):
        self.result = subprocess.run(["ping", self.ip], shell=True,
                                     stdout=subprocess.PIPE).stdout

最后,当主线程验证该操作已完成时,将输出打印到 Text 小部件:

    def poll_thread(self, thread):
        if thread.is_alive():
            self.after(100, lambda: self.poll_thread(thread))
        else:
            self.button.config(state=tk.NORMAL)
 self.output.delete(1.0, tk.END)
 self.output.insert(tk.END, thread.result)

第七章:画布和图形

在本章中,我们将涵盖以下配方:

  • 了解坐标系统

  • 绘制线条和箭头

  • 在画布上写字

  • 向画布添加形状

  • 通过它们的位置查找项目

  • 移动画布项目

  • 检测项目之间的碰撞

  • 从画布中删除项目

  • 将事件绑定到画布项目

  • 将画布渲染成 PostScript 文件

介绍

在第一章中,我们涵盖了标准 Tkinter 小部件的几个配方。但是,我们跳过了Canvas小部件,因为它提供了丰富的图形功能,并且值得单独的章节来深入了解其常见用例。

画布是一个矩形区域,您不仅可以在其中显示文本和几何形状,如线条、矩形或椭圆,还可以嵌套其他 Tkinter 小部件。这些对象称为画布项目,每个项目都有一个唯一的标识符,允许我们在它们最初显示在画布上之前对它们进行操作。

我们将使用Canvas类的方法进行交互示例,这将帮助我们识别可能转换为我们想要构建的应用程序的常见模式。

了解坐标系统

要在画布上绘制图形项目,我们需要使用坐标系统指定它们的位置。由于画布是二维空间,点将通过它们在水平和垂直轴上的坐标来表示——通常分别标记为xy

通过一个简单的应用程序,我们可以很容易地说明如何定位这些点与坐标系统原点的关系,该原点位于画布区域的左上角。

如何做...

以下程序包含一个空画布和一个标签,显示光标在画布上的位置;您可以移动光标以查看其所处的位置,清晰地反映了鼠标指针移动的方向,x 和 y 坐标是如何增加或减少的:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Basic canvas")

        self.canvas = tk.Canvas(self, bg="white")
        self.label = tk.Label(self)
        self.canvas.bind("<Motion>", self.mouse_motion)

        self.canvas.pack()
        self.label.pack()

    def mouse_motion(self, event):
        x, y = event.x, event.y
        text = "Mouse position: ({}, {})".format(x, y)
        self.label.config(text=text)

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

它是如何工作的...

Canvas实例像其他 Tkinter 小部件一样创建,即首先通过将父容器和额外的配置选项作为关键字参数传递:

    def __init__(self):
        # ...
        self.canvas = tk.Canvas(self, bg="white")
        self.label = tk.Label(self)
        self.canvas.bind("<Motion>", self.mouse_motion)

下一个屏幕截图显示了由每个轴的垂直投影组成的点:

  • x 坐标对应于水平轴上的距离,当您将光标从左向右移动时,其值会增加

  • y 坐标是垂直轴上的距离,当您将光标从上向下移动时,其值会增加

正如您可能已经注意到的在前面的屏幕截图中,这些坐标直接映射到传递给处理程序的event实例的xy属性:

    def mouse_motion(self, event):
        x, y = event.x, event.y
 text = "Mouse position: ({}, {})".format(x, y)
        self.label.config(text=text)

这是因为这些属性是相对于事件绑定到的小部件计算的,在这种情况下是<Motion>序列。

还有更多...

画布表面还可以显示具有其坐标中的负值的项目。根据项目的大小,它们可能部分显示在画布的顶部或左边界上。

类似地,如果项目放置在任一坐标大于画布大小的点上,它可能部分落在底部或右边界之外。

绘制线条和箭头

您可以使用画布执行的最基本的操作之一是从一个点到另一个点绘制线段。虽然可以使用其他方法直接绘制多边形,但Canvas类的create_line方法具有足够的选项来理解显示项目的基础知识。

准备工作

在这个示例中,我们将构建一个应用程序,允许我们通过单击画布来绘制线条。每条线都将通过首先单击确定线条起点的点,然后第二次设置线条终点来显示。

我们还可以指定一些外观选项,如颜色和宽度:

如何做...

我们的App类将负责创建一个空画布并处理鼠标点击事件。

线选项的信息将从LineForm类中检索。将此组件分离到不同的类中的方法有助于我们抽象其实现细节,并专注于如何使用Canvas小部件。

为了简洁起见,我们在以下片段中省略了LineForm类的实现:

import tkinter as tk

class LineForm(tk.LabelFrame):
    # ...

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Basic canvas")

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

        self.form.pack(side=tk.LEFT, padx=10, pady=10)
        self.canvas.pack(side=tk.LEFT)

    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)

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

您可以在chapter7_02.py文件中找到完整的代码示例。

它是如何工作的...

由于我们想要处理画布上的鼠标点击,我们将draw()方法绑定到这种类型的事件。我们还将定义line_start字段

跟踪每条新线的起点:

    def __init__(self):
        # ...

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

draw()方法包含我们应用程序的主要逻辑。每条新线的第一次点击用于确定原点,并且不执行任何绘图操作。这些坐标是从传递给处理程序的event对象中检索的:

    def draw(self, event):
 x, y = event.x, event.y
 if not self.line_start:
 self.line_start = (x, y)
        else:
            # ...

如果line_start已经有一个值,我们将检索原点,并将其与当前事件的坐标一起传递以绘制线条:

    def draw(self, event):
        x, y = event.x, event.y
        if not self.line_start:
            # ...
        else:
            x_origin, y_origin = self.line_start
 self.line_start = None
 line = (x_origin, y_origin, x, y)
 self.canvas.create_line(*line)
 text = "Line drawn from ({}, {}) to ({}, {})".format(*line)

canvas.create_line()方法需要四个参数,前两个是线条起点的水平和垂直坐标,最后两个是与线条终点对应的坐标。

在画布上写文本

如果我们想在画布上写一些文本,我们不需要使用额外的小部件,比如 Label。Canvas类包括create_text方法来显示一个可以像任何其他类型的画布项一样操作的字符串。

还可以使用与我们可以指定的相同的格式选项来为常规 Tkinter 小部件的文本添加样式,例如颜色、字体系列和大小。

准备就绪

在这个例子中,我们将连接一个 Entry 小部件与文本画布项的内容。虽然输入将具有标准外观,但画布上的文本将具有自定义样式:

如何做...

文本项将首先使用canvas.create_text()方法显示,还有一些额外的选项来使用 Consolas 字体和蓝色。

文本项的动态行为将使用StringVar实现。通过跟踪这个 Tkinter 变量,我们可以修改项目的内容:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Canvas text items")
        self.geometry("300x100")

        self.var = tk.StringVar()
        self.entry = tk.Entry(self, textvariable=self.var)
        self.canvas = tk.Canvas(self, bg="white")

        self.entry.pack(pady=5)
        self.canvas.pack()
        self.update()

        w, h = self.canvas.winfo_width(), self.canvas.winfo_height()
        options = { "font": "courier", "fill": "blue",
                    "activefill": "red" }
        self.text_id = self.canvas.create_text((w/2, h/2), **options)
        self.var.trace("w", self.write_text)

    def write_text(self, *args):
        self.canvas.itemconfig(self.text_id, text=self.var.get())

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

您可以通过在输入框中键入任意文本并注意它如何自动更新画布上的文本来尝试此程序。

它是如何工作的...

首先,我们使用其StringVar变量和 Canvas 小部件初始化Entry实例:

        self.var = tk.StringVar()
        self.entry = tk.Entry(self, textvariable=self.var)
        self.canvas = tk.Canvas(self, bg="white")

然后,我们通过调用 Pack 几何管理器的方法来放置小部件。请注意调用根窗口上的update()的重要性,因为我们希望强制 Tkinter 处理所有未决的更改,在这种情况下在__init__方法继续执行之前渲染小部件:

        self.entry.pack(pady=5)
        self.canvas.pack()
        self.update()

我们这样做是因为下一步将是计算画布的尺寸,直到几何管理器显示小部件,它才会有其宽度和高度的真实值。

之后,我们可以安全地检索画布的尺寸。由于我们想要将文本项与画布的中心对齐,我们将宽度和高度的值除以二。

这些坐标确定了项目的位置,并与样式选项一起传递给create_text()方法。text关键字参数在这里是一个常用选项,但我们将省略它,因为当StringVar更改其值时,它将被动态设置:

        w, h = self.canvas.winfo_width(), self.canvas.winfo_height()
        options = { "font": "courier", "fill": "blue",
                    "activefill": "red" }
        self.text_id = self.canvas.create_text((w/2, h/2), **options)
        self.var.trace("w", self.write_text)

create_text()返回的标识符存储在text_id字段中。它将在write_text()方法中用于引用该项,该方法由var实例的写操作的跟踪机制调用。

要更新write_text()处理程序中的text选项,我们使用canvas.itemconfig()方法调用项目标识符作为第一个参数,然后是配置选项。

在我们的情况下,我们使用了我们在初始化App实例时存储的text_id字段和通过其get()方法获取的StringVar的内容:

    def write_text(self, *args):
        self.canvas.itemconfig(self.text_id, text=self.var.get())

我们定义了write_text()方法,以便它可以接收可变数量的参数,即使我们不需要它们,因为 Tkinter 变量的trace()方法将它们传递给回调函数。

还有更多...

canvas.create_text() 方法有许多其他选项,可以自定义创建的画布项目。

通过其左上角放置文本

anchor选项允许我们控制相对于作为其canvas.create_text()的第一个参数传递的位置放置项目的位置。默认情况下,此选项值为tk.CENTER,这意味着文本小部件居中于这些坐标上。

如果要将文本放在画布的左上角,可以通过传递(0, 0)位置并将anchor选项设置为tk.NW来这样做,将原点对齐到文本放置在其中的矩形区域的西北角:

        # ...
        options = { "font": "courier", "fill": "blue",
                    "activefill": "red", "anchor": tk.NW }
        self.text_id = self.canvas.create_text((0, 0), **options)

上述代码片段将给我们以下结果:

设置换行

默认情况下,文本项目的内容将显示在单行中。width选项允许我们定义最大行宽,用于换行超过该宽度的行:

        # ...
        options = { "font": "courier", "fill": "blue",
                    "activefill": "red", "width": 70 }
        self.text_id = self.canvas.create_text((w/2, h/2), **options)

现在,当我们在输入框中写入Hello, world!时,超过行宽的文本部分将显示在新行中:

向画布添加形状

在本示例中,我们将介绍三种标准画布项目:矩形、椭圆和弧。它们都显示在一个边界框内,因此只需要两个点来设置它们的位置:框的左上角和右下角。

准备工作

以下应用程序允许用户通过三个按钮选择其类型在画布上自由绘制一些项目-每个按钮选择相应的形状。

项目的位置是通过首先在画布上单击来设置项目将包含在其中的框的左上角,然后再单击来设置此框的左下角并使用一些预定义选项绘制项目来确定的:

操作步骤...

我们的应用程序存储当前选择的项目类型,可以使用放置在画布下方框架上的三个按钮之一选择。

使用主鼠标按钮单击画布会触发处理程序,该处理程序存储新项目的第一个角的位置,然后再次单击,它会读取所选形状的值以有条件地绘制相应的项目:

import tkinter as tk
from functools import partial

class App(tk.Tk):
    shapes = ("rectangle", "oval", "arc")
    def __init__(self):
        super().__init__()
        self.title("Drawing standard items")

        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 == "rectangle":
                self.canvas.create_rectangle(*bbox, fill="blue",
                                             activefill="yellow")
            elif self.shape == "oval":
                self.canvas.create_oval(*bbox, fill="red",
                                        activefill="yellow")
            elif self.shape == "arc":
                self.canvas.create_arc(*bbox, fill="green",
                                       activefill="yellow")

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

工作原理...

为了通过单击画布动态选择要绘制的项目类型,我们将通过迭代shapes元组为每个形状创建一个按钮。

我们使用functools模块中的partial函数来定义每个回调命令。由于这样,我们可以将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_selection()回调标记了单击的按钮,并将选择存储在shape字段中,使用SUNKEN relief。

其他小部件兄弟姐妹通过导航到父级(在当前小部件的master字段中可用)并使用winfo_children()方法检索所有子小部件来配置标准的reliefRAISED):

    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_rectangle(x0, y0, x1, y1, **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("Finding canvas items")

        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字段以存储对当前突出显示项目的引用。我们还使用mouse_motion()方法处理画布上的"<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()处理程序将当前项目的颜色设置回blue,并保存新项目的项目标识符,该项目更接近事件坐标。最后,设置此项目的fill颜色为yellow

    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()的有效输入参数;它只是不会在画布上执行任何操作。

移动画布项目

一旦放置,画布项目就可以移动到特定的偏移量,而无需指定绝对坐标。

移动画布项目时,通常需要计算其当前位置,例如确定它们是否放置在具体的画布区域内,并限制它们的移动,使其始终保持在该区域内。

如何做...

我们的示例将包括一个简单的带有矩形项目的画布,可以使用箭头键在水平和垂直方向上移动。

为了防止此项目移出屏幕,我们将限制其在画布尺寸内的移动:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Moving canvas items")

        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>"键更可取,因为这样会在 Tkinter 处理输入键盘事件时仅调用每个处理程序,导致项目从一个位置“跳转”到下一个位置,而不是在水平和垂直轴上平滑移动。

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()并解压形成边界框的一对点来检索当前项目位置到四个变量。

通过将左上角的xy分量加上其宽度和高度的一半来计算项目的中心。这个结果,再加上每个轴上的偏移量,对应于项目移动后的最终位置:

        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)

最后,该方法通过调用self.after(10, self.process_movements)以 10 毫秒的延迟安排自身。因此,我们实现了在 Tkinter 的主循环中具有“自定义主循环”的效果。

还有更多...

您可能会想知道为什么我们没有调用after_idle()而是调用after()来安排process_movements()方法。

这似乎是一个有效的方法,因为除了重新绘制我们的画布和处理键盘输入之外,没有其他事件需要处理,因此在process_movements()之间如果没有待处理的 GUI 事件,就不需要添加延迟。

但是,使用after_idle会导致项目移动的速度取决于计算机的速度。这意味着快速系统将在相同的时间间隔内多次调用process_movements(),而较慢的系统将在项目速度上有所不同。

通过引入最小固定延迟,我们给具有不同功能的机器一个机会,以便以类似的方式行事。

另请参阅

  • 检测项目之间的碰撞食谱

检测项目之间的碰撞

作为前面食谱的延续,我们可以检测矩形项目是否与另一个项目重叠。实际上,假设我们正在使用包含在矩形框中的形状,可以使用Canvas类的find_overlapping()方法来实现这一点。

准备工作

该应用程序通过向画布添加四个绿色矩形并突出显示通过使用箭头键移动的蓝色矩形触摸的矩形,扩展了前一个应用程序:

如何做...

由于此脚本与前一个脚本有许多相似之处,我们标记了创建四个矩形并调用canvas.find_overlapping()方法的代码部分:

import tkinter as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Detecting collisions between items")

        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()

工作原理...

__init__方法的修改类似于按位置查找项目食谱中的修改,因此如果有任何疑问,您可以查看它并直接跳转到process_movements()方法中的更改。

在计算任何重叠之前,除了用户可以控制的项目之外,所有画布项目的填充颜色都更改为绿色。这些项目的标识符由canvas.find_all()方法检索:

    def process_movements(self):
 all_items = self.canvas.find_all()
 for item in filter(lambda i: i != 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 != self.item, items):
 self.canvas.itemconfig(item, fill="yellow")

该方法继续执行,通过计算偏移量移动蓝色矩形,并再次安排process_movements()自身。

还有更多...

如果要检测移动项目完全与另一个项目重叠,而不是部分重叠,可以将对canvas.find_overlapping()的调用替换为使用相同参数的canvas.find_enclosed()

从画布中删除项目

除了在画布上添加和修改项目,还可以通过Canvas类的delete()方法删除它们。虽然这种方法的使用非常简单,但在下一个示例中我们将看到一些有用的模式。

请记住,在画布上显示的项目越多,Tkinter 重新绘制小部件所需的时间就越长。因此,如果这可能会导致性能问题,最好删除不必要的项目。

准备工作

对于此示例,我们将构建一个应用程序,在画布上随机显示几个圆。单击圆后,每个圆都会自行删除,窗口包含一个按钮来清除所有项目和另一个按钮来重新开始:

如何做...

为了在画布上不规则地放置项目,我们将使用random模块的randint函数生成坐标。项目颜色也将通过调用choice并使用预定义的颜色列表来随机选择。

一旦生成,项目可以通过单击触发on_click处理程序或按下Clear items按钮来删除,后者执行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("Removing canvas items")

        self.canvas = tk.Canvas(self, bg="white")
        frame = tk.Frame(self)
        generate_btn = tk.Button(frame, text="Generate items",
                                 command=self.generate_items)
        clear_btn = tk.Button(frame, text="Clear items",
                              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实例上绑定我们想要处理的所有事件类型,然后根据受影响的项目确定要应用的操作。

准备工作

以下应用程序显示了如何在画布项目上实现拖放功能。这是一个常见的功能,用于说明这种方法如何简化我们的程序。

如何做...

我们将创建两个可以使用鼠标拖放的项目——一个矩形和一个椭圆。不同的形状帮助我们注意到单击事件如何正确应用于相应的项目,即使项目重叠放置:

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()

它是如何工作的...

要将事件绑定到项目,我们使用Canvas类的tag_bind()方法。这将事件绑定添加到与项目指定符匹配的所有项目上,在我们的示例中是"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)

此项目标识符和click事件的xy坐标存储在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)

还有一些变体的拖放功能,还实现了<ButtonRelease-1>序列的处理程序,该处理程序取消当前拖动的项目。

然而,这并非必要,因为一旦发生这种类型的事件,直到再次单击项目,<Button1-Motion>绑定将不会触发。这也使我们免于在button_motion()处理程序的开头检查dnd_item是否不是None

还有更多...

可以通过添加一些基本验证来改进此示例,例如验证用户不能将项目放在画布可见区域之外。

要实现这一点,您可以使用我们在以前的配方中介绍的模式来计算画布的宽度和高度,并通过链接比较运算符来验证项目的最终位置是否在有效范围内。您可以使用以下代码段中显示的结构作为模板:

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

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Basic canvas")

        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):
        # ...

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

工作原理...

原始脚本的主要添加是带有render_canvas()回调的Render canvas按钮。

它在canvas实例上调用postscript()方法,并使用filecolormode参数。这些选项指定了写入 PostScript 和输出颜色信息的目标文件的路径,可以是"color"表示全彩输出,"gray"表示转换为灰度等效,"mono"表示将所有颜色转换为黑色或白色:

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

您可以在 Tk/Tcl 文档的postscript()方法中检查所有可以传递的有效选项,网址为www.tcl.tk/man/tcl8.6/TkCmd/canvas.htm#M61。请记住,PostScript 是一种主要用于打印的语言,因此大多数选项都是指页面设置。

还有更多...

由于 PostScript 文件不像其他文件格式那样流行,您可能希望将生成的文件从 PostScript 转换为更熟悉的格式,如 PDF。

为了做到这一点,您需要一个第三方软件,比如Ghostscript,它是根据 GNU 的Affero 通用公共许可证AGPL)分发的。 Ghostscript 的解释器和渲染器实用程序可以从您的程序中调用,自动将 PostScript 结果转换为 PDF。

www.ghostscript.com/download/gsdnld.html下载并安装软件的最新版本,并将安装的binlib文件夹添加到操作系统路径中。

然后,修改您的 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)

第八章:主题小部件

在本章中,我们将涵盖以下内容:

  • 替换基本的小部件类

  • 使用 Combobox 创建可编辑的下拉菜单

  • 使用 Treeview 小部件

  • 在 Treeview 中填充嵌套项目

  • 使用 Notebook 显示可切换的窗格

  • 应用 Ttk 样式

  • 创建日期选择器小部件

介绍

Tk 主题小部件是 Tk 小部件的一个单独集合,具有本机外观和感觉,并且它们的样式可以使用特定的 API 进行高度定制。

这些类在tkinter.ttk模块中定义。除了定义新的小部件,如 Treeview 和 Notebook,这个模块还重新定义了经典 Tk 小部件的实现,如 Button、Label 和 Frame。

在本章中,我们将不仅涵盖如何将应用程序 Tk 小部件更改为主题小部件,还将涵盖如何对其进行样式设置和使用新的小部件类。

主题 Tk 小部件集是在 Tk 8.5 中引入的,这不应该是一个问题,因为 Python 3.6 安装程序可以让您包含 Tcl/Tk 解释器的 8.6 版本。

但是,您可以通过在命令行中运行python -m tkinter来验证任何平台,这将启动以下程序,输出 Tcl/Tk 版本:

替换基本的小部件类

作为使用主题 Tkinter 类的第一种方法,我们将看看如何从这个不同的模块中使用相同的小部件(按钮、标签、输入框等),在我们的应用程序中保持相同的行为。

尽管这不会充分发挥其样式能力,但我们可以轻松欣赏到带来主题小部件本机外观和感觉的视觉变化。

准备工作

在下面的屏幕截图中,您可以注意到带有主题小部件的 GUI 和使用标准 Tkinter 小部件的相同窗口之间的差异:

我们将构建第一个窗口中显示的应用程序,但我们还将学习如何轻松地在两种样式之间切换。

请注意,这高度依赖于平台。在这种情况下,主题变化对应于 Windows 10 上主题小部件的外观。

操作步骤

要开始使用主题小部件,您只需要导入tkinter.ttk模块,并像往常一样在您的 Tkinter 应用程序中使用那里定义的小部件:

import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    greetings = ("Hello", "Ciao", "Hola")

    def __init__(self):
        super().__init__()
        self.title("Tk themed widgets")

        var = tk.StringVar()
        var.set(self.greetings[0])
        label_frame = ttk.LabelFrame(self, text="Choose a greeting")
        for greeting in self.greetings:
            radio = ttk.Radiobutton(label_frame, text=greeting,
                                    variable=var, value=greeting)
            radio.pack()

        frame = ttk.Frame(self)
        label = ttk.Label(frame, text="Enter your name")
        entry = ttk.Entry(frame)

        command = lambda: print("{}, {}!".format(var.get(), 
                                         entry.get()))
        button = ttk.Button(frame, text="Greet", command=command)

        label.grid(row=0, column=0, padx=5, pady=5)
        entry.grid(row=0, column=1, padx=5, pady=5)
        button.grid(row=1, column=0, columnspan=2, pady=5)

        label_frame.pack(side=tk.LEFT, padx=10, pady=10)
        frame.pack(side=tk.LEFT, padx=10, pady=10)

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

如果您想要使用常规的 Tkinter 小部件运行相同的程序,请将所有ttk.出现替换为tk.

它是如何工作的...

开始使用主题小部件的常见方法是使用import ... as语法导入tkinter.ttk模块。因此,我们可以轻松地用tk名称标识标准小部件,用ttk名称标识主题小部件:

import tkinter as tk
import tkinter.ttk as ttk

正如您可能已经注意到的,在前面的代码中,将tkinter模块中的小部件替换为tkinter.ttk中的等效小部件就像更改别名一样简单:

import tkinter as tk
import tkinter.ttk as ttk

# ...
entry_1 = tk.Entry(root)
entry_2 = ttk.Entry(root)

在我们的示例中,我们为ttk.Framettk.Labelttk.Entryttk.LabelFramettk.Radiobutton小部件这样做。这些类接受的基本选项几乎与它们的标准 Tkinter 等效类相同;事实上,它们实际上是它们的子类。

然而,这个翻译很简单,因为我们没有移植任何样式选项,比如foregroundbackground。在主题小部件中,这些关键字通过ttk.Style类分别使用,我们将在另一个食谱中介绍。

另请参阅

  • 应用 Ttk 样式食谱

使用 Combobox 创建可编辑的下拉菜单

下拉列表是一种简洁的方式,通过垂直显示数值列表来选择数值,只有在需要时才显示。这也是让用户输入列表中不存在的另一个选项的常见方式。

这个功能结合在ttk.Combobox类中,它采用您平台下拉菜单的本机外观和感觉。

准备工作

我们的下一个应用程序将包括一个简单的下拉输入框,带有一对按钮来确认选择或清除其内容。

如果选择了预定义的值之一或单击了提交按钮,则当前 Combobox 值将以以下方式打印在标准输出中:

如何做到...

我们的应用程序在初始化期间创建了一个ttk.Combobox实例,传递了一个预定义的数值序列,可以在下拉列表中进行选择:

import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Ttk Combobox")
        colors = ("Purple", "Yellow", "Red", "Blue")

        self.label = ttk.Label(self, text="Please select a color")
        self.combo = ttk.Combobox(self, values=colors)
        btn_submit = ttk.Button(self, text="Submit",
                                command=self.display_color)
        btn_clear = ttk.Button(self, text="Clear",
                                command=self.clear_color)

        self.combo.bind("<<ComboboxSelected>>", self.display_color)

        self.label.pack(pady=10)
        self.combo.pack(side=tk.LEFT, padx=10, pady=5)
        btn_submit.pack(side=tk.TOP, padx=10, pady=5)
        btn_clear.pack(padx=10, pady=5)

    def display_color(self, *args):
        color = self.combo.get()
        print("Your selection is", color)

    def clear_color(self):
        self.combo.set("")

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

它是如何工作的...

与往常一样,通过将Tk实例作为其构造函数的第一个参数传递给我们的应用程序,我们将ttk.Combobox小部件添加到应用程序中。values选项指定了在单击下拉箭头时显示的可选择选项列表。

我们绑定了"<<ComboboxSelected>>"虚拟事件,当从值列表中选择一个选项时生成该事件:

        self.label = ttk.Label(self, text="Please select a color")
        self.combo = ttk.Combobox(self, values=colors)
        btn_submit = ttk.Button(self, text="Submit",
                                command=self.display_color)
        btn_clear = ttk.Button(self, text="Clear",
                                command=self.clear_color)

        self.combo.bind("<<ComboboxSelected>>", self.display_color)

当单击提交按钮时,也会调用相同的方法,因此它会接收用户输入的值。

我们定义display_color()使用*语法接受可选参数列表。这是因为当通过事件绑定调用它时,会传递一个事件给它,但当从按钮回调中调用它时,不会接收任何参数。

在这个方法中,我们通过其get()方法检索当前 Combobox 值,并将其打印在标准输出中:

    def display_color(self, *args):
        color = self.combo.get()
        print("Your selection is", color)

最后,clear_color()通过调用其set()方法并传递空字符串来清除 Combobox 的内容:

    def clear_color(self):
        self.combo.set("")

通过这些方法,我们已经探讨了如何与 Combobox 实例的当前选择进行交互。

还有更多...

ttk.Combobox类扩展了ttk.Entry,后者又扩展了tkinter模块中的Entry类。

这意味着如果需要,我们也可以使用我们已经介绍的Entry类的方法:

    combobox.insert(0, "Add this at the beginning: ")

前面的代码比combobox.set("Add this at the beginning: " + combobox.get())更简单。

使用 Treeview 小部件

在这个示例中,我们将介绍ttk.Treeview类,这是一个多功能的小部件,可以让我们以表格和分层结构显示信息。

添加到ttk.Treeview类的每个项目都分成一个或多个列,其中第一列可能包含文本和图标,并用于指示项目是否可以展开并显示更多嵌套项目。其余的列包含我们想要为每一行显示的值。

ttk.Treeview类的第一行由标题组成,通过其名称标识每一列,并可以选择性地隐藏。

准备好了

使用ttk.Treeview,我们将对存储在 CSV 文件中的联系人列表的信息进行制表,类似于我们在第五章中所做的面向对象编程和 MVC

如何做到...

我们将创建一个ttk.Treeview小部件,其中包含三列,分别用于每个联系人的字段:一个用于姓,另一个用于名,最后一个用于电子邮件地址。

联系人是使用csv模块从 CSV 文件中加载的,然后我们为"<<TreeviewSelect>>"虚拟元素添加了绑定,当选择一个或多个项目时生成该元素:

import csv
import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self, path):
        super().__init__()
        self.title("Ttk Treeview")

        columns = ("#1", "#2", "#3")
        self.tree = ttk.Treeview(self, show="headings", columns=columns)
        self.tree.heading("#1", text="Last name")
        self.tree.heading("#2", text="First name")
        self.tree.heading("#3", text="Email")
        ysb = ttk.Scrollbar(self, orient=tk.VERTICAL, 
                            command=self.tree.yview)
        self.tree.configure(yscroll=ysb.set)

        with open("contacts.csv", newline="") as f:
            for contact in csv.reader(f):
                self.tree.insert("", tk.END, values=contact)
        self.tree.bind("<<TreeviewSelect>>", self.print_selection)

        self.tree.grid(row=0, column=0)
        ysb.grid(row=0, column=1, sticky=tk.N + tk.S)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)

    def print_selection(self, event):
        for selection in self.tree.selection():
            item = self.tree.item(selection)
            last_name, first_name, email = item["values"][0:3]
            text = "Selection: {}, {} <{}>"
            print(text.format(last_name, first_name, email))

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

如果您运行此程序,每次选择一个联系人,其详细信息都将以标准输出的方式打印出来,以说明如何检索所选行的数据。

它是如何工作的...

要创建一个具有多列的ttk.Treeview,我们需要使用columns选项指定每个列的标识符。然后,我们可以通过调用heading()方法来配置标题文本。

我们使用标识符#1#2#3,因为第一列始终使用#0标识符生成,其中包含可展开的图标和文本。

我们还将"headings"值传递给show选项,以指示我们要隐藏#0列,因为不会有嵌套项目。

show选项的有效值如下:

  • "tree":显示列#0

  • "headings":显示标题行

  • "tree headings":显示列#0和标题行—这是默认值

  • "":不显示列#0或标题行

之后,我们将垂直滚动条附加到我们的ttk.Treeview小部件:

        columns = ("#1", "#2", "#3")
        self.tree = ttk.Treeview(self, show="headings", columns=columns)
        self.tree.heading("#1", text="Last name")
        self.tree.heading("#2", text="First name")
        self.tree.heading("#3", text="Email")
        ysb = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree.configure(yscroll=ysb.set)

要将联系人加载到表中,我们使用csv模块的reader()函数处理文件,并在每次迭代中读取的行添加到ttk.Treeview中。

这是通过调用insert()方法来完成的,该方法接收父节点和放置项目的位置。

由于所有联系人都显示为顶级项目,因此我们将空字符串作为第一个参数传递,并将END常量传递以指示每个新项目插入到最后位置。

您可以选择为insert()方法提供一些关键字参数。在这里,我们指定了values选项,该选项接受在 Treeview 的每一列中显示的值序列:

        with open("contacts.csv", newline="") as f:
            for contact in csv.reader(f):
                self.tree.insert("", tk.END, values=contact)
        self.tree.bind("<<TreeviewSelect>>", self.print_selection)

<<TreeviewSelect>>事件是用户从表中选择一个或多个项目时生成的虚拟事件。在print_selection()处理程序中,我们通过调用selection()方法检索当前选择,对于每个结果,我们将执行以下步骤:

  1. 使用item()方法,我们可以获取所选项目的选项和值的字典

  2. 我们从item字典中检索前三个值,这些值对应于联系人的姓氏、名字和电子邮件

  3. 值被格式化并打印到标准输出:

    def print_selection(self, event):
        for selection in self.tree.selection():
            item = self.tree.item(selection)
            last_name, first_name, email = item["values"][0:3]
            text = "Selection: {}, {} <{}>"
            print(text.format(last_name, first_name, email))

还有更多...

到目前为止,我们已经涵盖了ttk.Treeview类的一些基本方面,因为我们将其用作常规表。但是,还可以通过更高级的功能扩展我们现有的应用程序。

在 Treeview 项目中使用标签

ttk.Treeview项目可用标签,因此可以为contacts表的特定行绑定事件序列。

假设我们希望在双击时打开一个新窗口以给联系人写电子邮件;但是,这仅适用于填写了电子邮件字段的记录。

我们可以通过在插入项目时有条件地向其添加标签,然后在小部件实例上使用"<Double-Button-1>"序列调用tag_bind()来轻松实现这一点——这里我们只是通过其名称引用send_email_to_contact()处理程序函数的实现:

    columns = ("Last name", "First name", "Email")
    tree = ttk.Treeview(self, show="headings", columns=columns)

    for contact in csv.reader(f):
        email = contact[2]
 tags = ("dbl-click",) if email else ()
 self.tree.insert("", tk.END, values=contact, tags=tags)

 tree.tag_bind("dbl-click", "<Double-Button-1>", send_email_to_contact)

与将事件绑定到Canvas项目时发生的情况类似,始终记住在调用tag_bind()之前将带有标签的项目添加到ttk.Treeview中,因为绑定仅添加到现有的匹配项目。

另请参阅

  • 在 Treeview 中填充嵌套项目食谱

在 Treeview 中填充嵌套项目

虽然ttk.Treeview可以用作常规表,但它也可能包含分层结构。这显示为树,其中的项目可以展开以查看层次结构的更多节点。

这对于显示递归调用的结果和多层嵌套项目非常有用。在此食谱中,我们将研究适合这种结构的常见场景。

准备就绪

为了说明如何在ttk.Treeview小部件中递归添加项目,我们将创建一个基本的文件系统浏览器。可展开的节点将表示文件夹,一旦打开,它们将显示它们包含的文件和文件夹:

如何做...

树将最初由populate_node()方法填充,该方法列出当前目录中的条目。如果条目是目录,则还会添加一个空子项以显示它作为可展开节点。

打开表示目录的节点时,它会通过再次调用populate_node()来延迟加载目录的内容。这次,不是将项目添加为顶级节点,而是将它们嵌套在打开的节点内部:

import os
import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self, path):
        super().__init__()
        self.title("Ttk Treeview")

        abspath = os.path.abspath(path)
        self.nodes = {}
        self.tree = ttk.Treeview(self)
        self.tree.heading("#0", text=abspath, anchor=tk.W)
        ysb = ttk.Scrollbar(self, orient=tk.VERTICAL,
                            command=self.tree.yview)
        xsb = ttk.Scrollbar(self, orient=tk.HORIZONTAL,
                            command=self.tree.xview)
        self.tree.configure(yscroll=ysb.set, xscroll=xsb.set)

        self.tree.grid(row=0, column=0, sticky=tk.N + tk.S + tk.E +     tk.W)
        ysb.grid(row=0, column=1, sticky=tk.N + tk.S)
        xsb.grid(row=1, column=0, sticky=tk.E + tk.W)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)

        self.tree.bind("<<TreeviewOpen>>", self.open_node)
        self.populate_node("", abspath)

    def populate_node(self, parent, abspath):
        for entry in os.listdir(abspath):
            entry_path = os.path.join(abspath, entry)
            node = self.tree.insert(parent, tk.END, text=entry, open=False)
            if os.path.isdir(entry_path):
                self.nodes[node] = entry_path
                self.tree.insert(node, tk.END)

    def open_node(self, event):
        item = self.tree.focus()
        abspath = self.nodes.pop(item, False)
        if abspath:
            children = self.tree.get_children(item)
            self.tree.delete(children)
            self.populate_node(item, abspath)

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

当运行上述示例时,它将显示脚本所在目录的文件系统层次结构,但您可以通过App构造函数的path参数明确设置所需的目录。

工作原理

在这个例子中,我们将使用os模块,它是 Python 标准库的一部分,提供了执行操作系统调用的便携方式。

os模块的第一个用途是将树的初始路径转换为绝对路径,以及初始化nodes字典,它将存储可展开项和它们表示的目录路径之间的对应关系:

import os
import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self, path):
        # ...
 abspath = os.path.abspath(path)
 self.nodes = {}

例如,os.path.abspath(".")将返回你从脚本运行的路径的绝对版本。我们更喜欢这种方法而不是使用相对路径,因为这样可以避免在应用程序中处理路径时出现混淆。

现在,我们使用垂直和水平滚动条初始化ttk.Treeview实例。图标标题的text将是我们之前计算的绝对路径:

        self.tree = ttk.Treeview(self)
        self.tree.heading("#0", text=abspath, anchor=tk.W)
        ysb = ttk.Scrollbar(self, orient=tk.VERTICAL,
                            command=self.tree.yview)
        xsb = ttk.Scrollbar(self, orient=tk.HORIZONTAL,
                            command=self.tree.xview)
        self.tree.configure(yscroll=ysb.set, xscroll=xsb.set)

然后,我们使用 Grid 布局管理器放置小部件,并使ttk.Treeview实例在水平和垂直方向上自动调整大小。

之后,我们绑定了"<<TreeviewOpen>>"虚拟事件,当展开项时生成,调用open_node()处理程序并调用populate_node()加载指定目录的条目:

        self.tree.bind("<<TreeviewOpen>>", self.open_node)
        self.populate_node("", abspath)

请注意,第一次调用此方法时,父目录为空字符串,这意味着它们没有任何父项,并显示为顶级项。

populate_node()方法中,我们通过调用os.listdir()列出目录条目的名称。对于每个条目名称,我们执行以下操作:

  • 计算条目的绝对路径。在类 UNIX 系统上,这是通过用斜杠连接字符串来实现的,但 Windows 使用反斜杠。由于os.path.join()方法,我们可以安全地连接路径,而不必担心平台相关的细节。

  • 我们将entry字符串插入到指定的parent节点的最后一个子项中。我们总是将节点初始设置为关闭,因为我们希望仅在需要时延迟加载嵌套项。

  • 如果条目的绝对路径是一个目录,我们在nodes属性中添加节点和路径之间的对应关系,并插入一个空的子项,允许该项展开:

    def populate_node(self, parent, abspath):
        for entry in os.listdir(abspath):
            entry_path = os.path.join(abspath, entry)
            node = self.tree.insert(parent, tk.END, text=entry, open=False)
            if os.path.isdir(entry_path):
                self.nodes[node] = entry_path
                self.tree.insert(node, tk.END)

当单击可展开项时,open_node()处理程序通过调用ttk.Treeview实例的focus()方法检索所选项。

此项标识符用于获取先前添加到nodes属性的绝对路径。为了避免在字典中节点不存在时引发KeyError,我们使用了它的pop()方法,它将第二个参数作为默认值返回——在我们的例子中是False

如果节点存在,我们清除可展开节点的“虚假”项。调用self.tree.get_children(item)返回item的子项的标识符,然后通过调用self.tree.delete(children)来删除它们。

一旦清除了该项,我们通过使用item作为父项调用populate_node()来添加“真实”的子项:

    def open_node(self, event):
        item = self.tree.focus()
        abspath = self.nodes.pop(item, False)
        if abspath:
            children = self.tree.get_children(item)
            self.tree.delete(children)
            self.populate_node(item, abspath)

显示带有 Notebook 的选项卡窗格

ttk.Notebook类是ttk模块中引入的另一种新的小部件类型。它允许您在同一窗口区域中添加许多应用程序视图,让您通过单击与每个视图关联的选项卡来选择应该显示的视图。

选项卡面板是重用 GUI 相同部分的好方法,如果多个区域的内容不需要同时显示。

准备工作

以下应用程序显示了一些按类别分隔的待办事项列表,列表显示为只读数据,以简化示例:

操作步骤

我们使用固定大小实例化ttk.Notebook,然后循环遍历具有一些预定义数据的字典,这些数据将用于创建选项卡并向每个区域添加一些标签:

import tkinter as tk
import tkinter.ttk as ttk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Ttk Notebook")

        todos = {
            "Home": ["Do the laundry", "Go grocery shopping"],
            "Work": ["Install Python", "Learn Tkinter", "Reply emails"],
            "Vacations": ["Relax!"]
        }

        self.notebook = ttk.Notebook(self, width=250, height=100)
        self.label = ttk.Label(self)
        for key, value in todos.items():
            frame = ttk.Frame(self.notebook)
            self.notebook.add(frame, text=key, underline=0,
                              sticky=tk.NE + tk.SW)
            for text in value:
                ttk.Label(frame, text=text).pack(anchor=tk.W)

        self.notebook.pack()
        self.label.pack(anchor=tk.W)
        self.notebook.enable_traversal()
        self.notebook.bind("<<NotebookTabChanged>>", self.select_tab)

    def select_tab(self, event):
        tab_id = self.notebook.select()
        tab_name = self.notebook.tab(tab_id, "text")
        text = "Your current selection is: {}".format(tab_name)
        self.label.config(text=text)

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

每次单击选项卡时,窗口底部的标签都会更新其内容,显示当前选项卡的名称。

它是如何工作的...

我们的ttk.Notebook小部件具有特定的宽度和高度,以及外部填充。

todos字典中的每个键都用作选项卡的名称,并且值列表被添加为标签到ttk.Frame,它代表窗口区域:

 self.notebook = ttk.Notebook(self, width=250, height=100, padding=10)
        for key, value in todos.items():
            frame = ttk.Frame(self.notebook)
 self.notebook.add(frame, text=key,
                              underline=0, sticky=tk.NE+tk.SW)
            for text in value:
                ttk.Label(frame, text=text).pack(anchor=tk.W)

之后,我们在ttk.Notebook小部件上调用enable_traversal()。这允许用户使用Ctrl + Shift + TabCtrl + Tab在选项卡面板之间来回切换选项卡。

它还可以通过按下Alt和下划线字符来切换到特定的选项卡,即Alt + H代表Home选项卡,Alt + W代表Work选项卡,Alt + V代表Vacation选项卡。

当选项卡选择更改时,生成"<<NotebookTabChanged>>"虚拟事件,并将其绑定到select_tab()方法。请注意,当 Tkinter 添加一个选项卡到ttk.Notebook时,此事件会自动触发:

        self.notebook.pack()
        self.label.pack(anchor=tk.W)
 self.notebook.enable_traversal()
 self.notebook.bind("<<NotebookTabChanged>>", self.select_tab)

当我们打包项目时,不需要放置ttk.Notebook子窗口,因为ttk.Notebook调用几何管理器内部完成了这一点:

    def select_tab(self, event):
        tab_id = self.notebook.select()
        tab_name = self.notebook.tab(tab_id, "text")
        self.label.config(text=f"Your current selection is: {tab_name}")

还有更多...

如果您想要检索ttk.Notebook当前显示的子窗口,您不需要使用任何额外的数据结构来将选项卡索引与小部件窗口进行映射。

Tkinter 的nametowidget()方法可从所有小部件类中使用,因此您可以轻松获取与小部件名称对应的小部件对象:

    def select_tab(self, event):
        tab_id = self.notebook.select()
        frame = self.nametowidget(tab_id)
        # Do something with the frame

应用 Ttk 样式

正如我们在本章的第一个配方中提到的,主题小部件具有特定的 API 来自定义它们的外观。我们不能直接设置选项,例如前景色或内部填充,因为这些值是通过ttk.Style类设置的。

在这个配方中,我们将介绍如何修改第一个配方中的小部件以添加一些样式选项。

如何做...

为了添加一些默认设置,我们只需要一个ttk.Style对象,它提供以下方法:

  • configure(style, opts): 更改小部件style的外观opts。在这里,我们设置诸如前景色、填充和浮雕等选项。

  • map(style, query): 更改小部件style的动态外观。参数query是一个关键字参数,其中每个键都是样式选项,值是(state, value)形式的元组列表,表示选项的值由其当前状态确定。

例如,我们已经标记了以下两种情况的示例:

import tkinter as tk
import tkinter.ttk as tk

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Tk themed widgets")

        style = ttk.Style(self)
 style.configure("TLabel", padding=10)
 style.map("TButton",
 foreground=[("pressed", "grey"), ("active", "white")],
 background=[("pressed", "white"), ("active", "grey")]
 ) # ...

现在,每个ttk.Label显示为10的填充,ttk.Button具有动态样式:当状态为pressed时,灰色前景和白色背景,当状态为active时,白色前景和灰色背景。

它是如何工作的...

为我们的应用程序构建ttk.Style非常简单——我们只需要使用我们的父小部件作为它的第一个参数创建一个实例。

然后,我们可以为我们的主题小部件设置默认样式选项,使用大写的T加上小部件名称:TButton代表ttk.ButtonTLabel代表ttk.Label,依此类推。然而,也有一些例外,因此建议您在 Python 解释器上调用小部件实例的winfo_class()方法来检查类名。

我们还可以添加前缀来标识我们不想默认使用的样式,但明确地将其设置为某些特定的小部件:

        style.configure("My.TLabel", padding=10)
        # ...
        label = ttk.Label(master, text="Some text", style="My.TLabel")

创建日期选择器小部件

如果我们想让用户在我们的应用程序中输入日期,我们可以添加一个文本输入,强制他们编写一个带有有效日期格式的字符串。另一种解决方案是添加几个数字输入,用于日期、月份和年份,但这也需要一些验证。

与其他 GUI 框架不同,Tkinter 不包括一个专门用于此目的的类;然而,我们可以选择应用我们对主题小部件的知识来构建一个日历小部件。

准备就绪

在这个配方中,我们将逐步解释使用 Ttk 小部件和功能制作日期选择器小部件的实现:

这是 svn.python.org/projects/sandbox/trunk/ttk-gsoc/samples/ttkcalendar.py 的重构版本,不需要任何外部包。

操作步骤...

除了 tkinter 模块,我们还需要标准库中的 calendardatetime 模块。它们将帮助我们对小部件中保存的数据进行建模和交互。

小部件标题显示了一对箭头,根据 Ttk 样式选项来前后移动当前月份。小部件的主体由一个 ttk.Treeview 表格组成,其中包含一个 Canvas 实例来突出显示所选日期单元格:

import calendar
import datetime
import tkinter as tk
import tkinter.ttk as ttk
import tkinter.font as tkfont
from itertools import zip_longest

class TtkCalendar(ttk.Frame):
    def __init__(self, master=None, **kw):
        now = datetime.datetime.now()
        fwday = kw.pop('firstweekday', calendar.MONDAY)
        year = kw.pop('year', now.year)
        month = kw.pop('month', now.month)
        sel_bg = kw.pop('selectbackground', '#ecffc4')
        sel_fg = kw.pop('selectforeground', '#05640e')

        super().__init__(master, **kw)

        self.selected = None
        self.date = datetime.date(year, month, 1)
        self.cal = calendar.TextCalendar(fwday)
        self.font = tkfont.Font(self)
        self.header = self.create_header()
        self.table = self.create_table()
        self.canvas = self.create_canvas(sel_bg, sel_fg)
        self.build_calendar()

    def create_header(self):
        left_arrow = {'children': [('Button.leftarrow', None)]}
        right_arrow = {'children': [('Button.rightarrow', None)]}
        style = ttk.Style(self)
        style.layout('L.TButton', [('Button.focus', left_arrow)])
        style.layout('R.TButton', [('Button.focus', right_arrow)])

        hframe = ttk.Frame(self)
        btn_left = ttk.Button(hframe, style='L.TButton',
                              command=lambda: self.move_month(-1))
        btn_right = ttk.Button(hframe, style='R.TButton',
                               command=lambda: self.move_month(1))
        label = ttk.Label(hframe, width=15, anchor='center')

        hframe.pack(pady=5, anchor=tk.CENTER)
        btn_left.grid(row=0, column=0)
        label.grid(row=0, column=1, padx=12)
        btn_right.grid(row=0, column=2)
        return label

    def move_month(self, offset):
        self.canvas.place_forget()
        month = self.date.month - 1 + offset
        year = self.date.year + month // 12
        month = month % 12 + 1
        self.date = datetime.date(year, month, 1)
        self.build_calendar()

    def create_table(self):
        cols = self.cal.formatweekheader(3).split()
        table = ttk.Treeview(self, show='', selectmode='none',
                             height=7, columns=cols)
        table.bind('<Map>', self.minsize)
        table.pack(expand=1, fill=tk.BOTH)
        table.tag_configure('header', background='grey90')
        table.insert('', tk.END, values=cols, tag='header')
        for _ in range(6):
            table.insert('', tk.END)

        width = max(map(self.font.measure, cols))
        for col in cols:
            table.column(col, width=width, minwidth=width, anchor=tk.E)
        return table

    def minsize(self, e):
        width, height = self.master.geometry().split('x')
        height = height[:height.index('+')]
        self.master.minsize(width, height)

    def create_canvas(self, bg, fg):
        canvas = tk.Canvas(self.table, background=bg,
                           borderwidth=0, highlightthickness=0)
        canvas.text = canvas.create_text(0, 0, fill=fg, anchor=tk.W)
        handler = lambda _: canvas.place_forget()
        canvas.bind('<ButtonPress-1>', handler)
        self.table.bind('<Configure>', handler)
        self.table.bind('<ButtonPress-1>', self.pressed)
        return canvas

    def build_calendar(self):
        year, month = self.date.year, self.date.month
        month_name = self.cal.formatmonthname(year, month, 0)
        month_weeks = self.cal.monthdayscalendar(year, month)

        self.header.config(text=month_name.title())
        items = self.table.get_children()[1:]
        for week, item in zip_longest(month_weeks, items):
            week = week if week else [] 
            fmt_week = ['%02d' % day if day else '' for day in week]
            self.table.item(item, values=fmt_week)

    def pressed(self, event):
        x, y, widget = event.x, event.y, event.widget
        item = widget.identify_row(y)
        column = widget.identify_column(x)
        items = self.table.get_children()[1:]

        if not column or not item in items:
            # clicked te header or outside the columns
            return

        index = int(column[1]) - 1
        values = widget.item(item)['values']
        text = values[index] if len(values) else None
        bbox = widget.bbox(item, column)
        if bbox and text:
            self.selected = '%02d' % text
            self.show_selection(bbox)

    def show_selection(self, bbox):
        canvas, text = self.canvas, self.selected
        x, y, width, height = bbox
        textw = self.font.measure(text)
        canvas.configure(width=width, height=height)
        canvas.coords(canvas.text, width - textw, height / 2 - 1)
        canvas.itemconfigure(canvas.text, text=text)
        canvas.place(x=x, y=y)

    @property
    def selection(self):
        if self.selected:
            year, month = self.date.year, self.date.month
            return datetime.date(year, month, int(self.selected))

def main():
    root = tk.Tk()
    root.title('Tkinter Calendar')
    ttkcal = TtkCalendar(firstweekday=calendar.SUNDAY)
    ttkcal.pack(expand=True, fill=tk.BOTH)
    root.mainloop()

if __name__ == '__main__':
    main()

工作原理...

我们的 TtkCalendar 类可以通过传递一些选项作为关键字参数来进行自定义。它们在初始化时被检索出来,并在没有提供的情况下使用一些默认值;例如,如果当前日期用于日历的初始年份和月份:

    def __init__(self, master=None, **kw):
        now = datetime.datetime.now()
        fwday = kw.pop('firstweekday', calendar.MONDAY)
        year = kw.pop('year', now.year)
        month = kw.pop('month', now.month)
        sel_bg = kw.pop('selectbackground', '#ecffc4')
        sel_fg = kw.pop('selectforeground', '#05640e')

        super().__init__(master, **kw)

然后,我们定义一些属性来存储日期信息:

  • selected:保存所选日期的值

  • date:表示在日历上显示的当前月份的日期

  • calendar:具有周和月份名称信息的公历日历

小部件的可视部分在 create_header()create_table() 方法中内部实例化,稍后我们将对其进行介绍。

我们还使用了一个 tkfont.Font 实例来帮助我们测量字体大小。

一旦这些属性被初始化,通过调用 build_calendar() 方法来安排日历的可视部分:

        self.selected = None
        self.date = datetime.date(year, month, 1)
        self.cal = calendar.TextCalendar(fwday)
        self.font = tkfont.Font(self)
        self.header = self.create_header()
        self.table = self.create_table()
        self.canvas = self.create_canvas(sel_bg, sel_fg)
        self.build_calendar()

create_header() 方法使用 ttk.Style 来显示箭头以前后移动月份。它返回显示当前月份名称的标签:

    def create_header(self):
        left_arrow = {'children': [('Button.leftarrow', None)]}
        right_arrow = {'children': [('Button.rightarrow', None)]}
        style = ttk.Style(self)
        style.layout('L.TButton', [('Button.focus', left_arrow)])
        style.layout('R.TButton', [('Button.focus', right_arrow)])

        hframe = ttk.Frame(self)
        lbtn = ttk.Button(hframe, style='L.TButton',
                          command=lambda: self.move_month(-1))
        rbtn = ttk.Button(hframe, style='R.TButton',
                          command=lambda: self.move_month(1))
        label = ttk.Label(hframe, width=15, anchor='center')

        # ...
        return label

move_month() 回调隐藏了用画布字段突出显示的当前选择,并将指定的 offset 添加到当前月份以设置 date 属性为上一个或下一个月份。然后,日历再次重绘,显示新月份的日期:

    def move_month(self, offset):
        self.canvas.place_forget()
        month = self.date.month - 1 + offset
        year = self.date.year + month // 12
        month = month % 12 + 1
        self.date = datetime.date(year, month, 1)
        self.build_calendar()

日历主体是在 create_table() 中使用 ttk.Treeview 小部件创建的,它在一行中显示当前月份的每周:

    def create_table(self):
        cols = self.cal.formatweekheader(3).split()
        table = ttk.Treeview(self, show='', selectmode='none',
                             height=7, columns=cols)
        table.bind('<Map>', self.minsize)
        table.pack(expand=1, fill=tk.BOTH)
        table.tag_configure('header', background='grey90')
        table.insert('', tk.END, values=cols, tag='header')
        for _ in range(6):
            table.insert('', tk.END)

        width = max(map(self.font.measure, cols))
        for col in cols:
            table.column(col, width=width, minwidth=width, anchor=tk.E)
        return table

create_canvas() 方法中实例化了突出显示选择的画布。由于它根据所选项的尺寸调整其大小,因此如果窗口被调整大小,它也会隐藏自己:

    def create_canvas(self, bg, fg):
        canvas = tk.Canvas(self.table, background=bg,
                           borderwidth=0, highlightthickness=0)
        canvas.text = canvas.create_text(0, 0, fill=fg, anchor=tk.W)
        handler = lambda _: canvas.place_forget()
        canvas.bind('<ButtonPress-1>', handler)
        self.table.bind('<Configure>', handler)
        self.table.bind('<ButtonPress-1>', self.pressed)
        return canvas

通过迭代 ttk.Treeview 表格的周和项目位置来构建日历。使用 itertools 模块中的 zip_longest() 函数,我们遍历包含大多数项目的集合,并将缺少的日期留空字符串:

这种行为对每个月的第一周和最后一周很重要,因为这通常是我们找到这些空白位置的地方:

    def build_calendar(self):
        year, month = self.date.year, self.date.month
        month_name = self.cal.formatmonthname(year, month, 0)
        month_weeks = self.cal.monthdayscalendar(year, month)

        self.header.config(text=month_name.title())
        items = self.table.get_children()[1:]
        for week, item in zip_longest(month_weeks, items):
            week = week if week else [] 
            fmt_week = ['%02d' % day if day else '' for day in week]
            self.table.item(item, values=fmt_week)

当您单击表项时,pressed() 事件处理程序如果该项存在则设置选择,并重新显示画布以突出显示选择:

    def pressed(self, event):
        x, y, widget = event.x, event.y, event.widget
        item = widget.identify_row(y)
        column = widget.identify_column(x)
        items = self.table.get_children()[1:]

        if not column or not item in items:
            # clicked te header or outside the columns
            return

        index = int(column[1]) - 1
        values = widget.item(item)['values']
        text = values[index] if len(values) else None
        bbox = widget.bbox(item, column)
        if bbox and text:
            self.selected = '%02d' % text
            self.show_selection(bbox)

show_selection() 方法将画布放置在包含选择的边界框上,测量文本大小以使其适合其上方:

    def show_selection(self, bbox):
        canvas, text = self.canvas, self.selected
        x, y, width, height = bbox
        textw = self.font.measure(text)
        canvas.configure(width=width, height=height)
        canvas.coords(canvas.text, width - textw, height / 2 - 1)
        canvas.itemconfigure(canvas.text, text=text)
        canvas.place(x=x, y=y)

最后,selection 属性使得可以将所选日期作为 datetime.date 对象获取。在我们的示例中没有直接使用它,但它是与 TtkCalendar 类一起使用的 API 的一部分:

    @property
    def selection(self):
        if self.selected:
            year, month = self.date.year, self.date.month
            return datetime.date(year, month, int(self.selected))

另请参阅

  • 使用 Treeview 小部件 配方

  • 应用 Ttk 样式 配方

posted @ 2025-09-22 13:21  绝不原创的飞龙  阅读(27)  评论(0)    收藏  举报