Tkinter-GUI-应用开发蓝图第二版-全-

Tkinter GUI 应用开发蓝图第二版(全)

原文:zh.annas-archive.org/md5/e38cc0002c70f051fb16a899a5bc6177

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

《Tkinter GUI 应用程序开发蓝图》,第二版将指导您通过使用 Python 和 Tkinter(Python 内置的 GUI 模块)开发实际应用图形界面的过程。

本书旨在突出 Tkinter 的特点和能力,同时展示在编写 GUI 程序过程中涉及的最佳实践,无论你选择哪个库来构建你的应用程序。在这里,你将学习如何使用 Tkinter 和 Python 开发令人兴奋、有趣且实用的 GUI 应用程序。

我们希望带你踏上一段充满乐趣的旅程,穿越超过 10 个来自不同问题领域的项目。随着我们在每个项目中开发新的应用,这本书也逐步建立了一个常用策略目录,用于开发现实世界中的应用。

这本书面向的对象

软件开发者、科学家、研究人员、工程师、学生以及那些对 Python 有基本了解的编程爱好者会发现这本书既有趣又有信息量。一个有编程背景且对 Python 充满热情的新手,通过一点额外的研究就能填补知识上的空白。

熟悉其他编程语言基本编程结构的开发者也可以通过阅读一些关于 Python 的简要内容来跟上进度。假设没有 GUI 编程经验。

本书涵盖的内容

第一章,认识 Tkinter,从零开始,提供了 Tkinter 的概述,并涵盖了如何创建根窗口、向根窗口添加小部件、使用几何管理器处理布局以及处理事件等细节。

第二章,制作文本编辑器,通过过程式编程风格开发了一个文本编辑器。它让读者首次体验了 Tkinter 的几个特性以及开发真实应用程序的感觉。

第三章,可编程鼓机,使用面向对象编程开发了一个能够演奏用户创作的节奏的鼓机。该应用程序还可以保存创作,并在以后编辑或重放它们。在这里,你将学习使用以模型优先的哲学设计 GUI 应用程序以及编写多线程 GUI 应用程序的技术。

第四章,《棋局》,介绍了使用模型-视图-控制器(MVC)架构构建 GUI 应用程序的关键方面。它还教授了如何将现实世界中的对象(棋类游戏)建模成程序可以操作的形式。此外,它向读者介绍了 Tkinter 画布小部件的强大功能。

第五章,构建音频播放器,涵盖了在使用外部库的同时展示如何使用许多不同的 Tkinter 小部件的概念。最重要的是,它展示了如何创建自己的 Tkinter 小部件,从而扩展 Tkinter 的多功能性。

第六章,画布应用,详细探讨了 Tkinter 的 Canvas 小部件。正如您将看到的,Canvas 小部件确实是 Tkinter 的亮点。本章还介绍了 GUI 框架的概念,从而为您的所有未来程序创建了可重用的代码。

第七章,《钢琴辅导》,展示了如何使用 JSON 表示给定的领域信息,然后将创建的数据应用于创建一个交互式应用程序。它还讨论了程序响应性的概念以及如何使用 Tkinter 来处理它。

第八章,Canvas 中的乐趣,致力于利用 Tkinter 画布小部件强大的可视化能力。它从几个重要的数学领域选取实例,构建不同种类的有用且美观的模拟。

第九章, 《多个趣味项目》,通过一系列小型但实用的项目进行讲解,展示了来自不同领域的问题,例如动画、网络编程、套接字编程、数据库编程、异步编程和多线程编程。

第十章,杂项提示,讨论了 GUI 编程中一些重要的方面,尽管这些内容在前几章中没有涉及,但它们在许多 GUI 程序中构成了一个共同的主题。

为了最大限度地利用这本书

我们假设读者对 Python 编程语言的基本结构有入门级别的熟悉程度。我们使用 Python 3.6 版本和 Tkinter 8.6,建议坚持使用这些确切版本以避免兼容性问题。

本书讨论的程序是在 Linux Mint 平台上开发的。然而,考虑到 Tkinter 的多平台能力,您可以在其他平台如 Windows、Mac OS 以及其他 Linux 发行版上轻松工作。各章节中提到了下载和安装其他项目特定模块和软件的链接。

下载示例代码文件

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

您可以通过以下步骤下载代码文件:

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

  2. 选择“支持”标签。

  3. 点击代码下载与勘误表。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的指示。

一旦文件下载完成,请确保您使用最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Tkinter-GUI-Application-Development-Blueprints-Second-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!

下载彩色图片

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

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:

代码块设置如下:

def toggle_play_button_state(self):
  if self.now_playing:
    self.play_button.config(state="disabled")
  else:
    self.play_button.config(state="normal")

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

def on_loop_button_toggled(self):
  self.loop = self.to_loop.get()
  self.keep_playing = self.loop
  if self.now_playing:
 self.now_playing = self.loop
  self.toggle_play_button_state()

任何命令行输入或输出都应按照以下格式编写:

>>> import pyglet
>>> help(pyglet.media)

粗体: 表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词在文本中会像这样显示。以下是一个例子:“在我们的例子中,我们将向文件、编辑和关于菜单中添加菜单项。”

警告或重要提示会像这样显示。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

总体反馈:请发送邮件至 feedback@packtpub.com 并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送邮件至 questions@packtpub.com

勘误: 尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告这一点,我们将不胜感激。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至copyright@packtpub.com与我们联系。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com

评论

请留下您的评价。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packtpub.com.

第一章:认识 Tkinter

欢迎来到使用 Tkinter 进行 GUI 编程的激动人心世界。本章旨在让您熟悉 Tkinter,这是所有标准 Python 发行版内置的图形用户界面GUI)库。

Tkinter(发音为 tea-kay-inter)是 Python 对 Tk 的接口,Tk 是 Tcl/Tk 的 GUI 工具包。

Tcl工具命令语言),发音为 tickle,是一种在嵌入式应用、测试、原型设计和 GUI 开发领域流行的脚本语言。另一方面,Tk 是一个开源的多平台小部件工具包,被许多不同的语言用来构建 GUI 程序。

Tkinter 界面作为 Python 模块实现——在 Python 2.x 版本中为Tkinter.py,在 Python 3.x 版本中为tkinter/__init__.py。如果你查看源代码,Tkinter 实际上是一个围绕使用 Tcl/Tk 库的 C 扩展的包装器。

Tkinter 适用于广泛的领域,从小型桌面应用程序到跨各种学科的科学研究建模和研究努力。

当一个学习 Python 的人需要过渡到 GUI 编程时,Tkinter 似乎是最简单和最快完成工作的方法。

Tkinter 是 Python 中用于 GUI 应用程序编程的强大工具。使 Tkinter 成为 GUI 编程优秀选择的特性包括以下内容:

  • 学习起来很简单(比任何其他 Python GUI 包都要简单)

  • 相当少的代码就能生成强大的图形用户界面应用程序

  • 分层设计确保易于掌握

  • 它可以在所有操作系统上通用

  • 它易于访问,因为它随标准 Python 发行版预安装

没有任何其他的 Python GUI 工具包同时具备所有这些功能。

本章的目的是让您对 Tkinter 感到舒适。它旨在向您介绍使用 Tkinter 进行 GUI 编程的各种组件。

我们相信,在本章中你将发展的概念将使你能够在你感兴趣的领域中应用和发展 GUI 应用程序。

我们希望您从本章学习的关键要点包括以下内容:

  • 理解根窗口的概念和主循环

  • 理解小部件——程序的构建块

  • 熟悉可用的组件列表

  • 使用不同的几何管理器来开发布局

  • 将事件和回调应用于使程序功能化

  • 通过使用样式选项和配置根部件来设置部件样式

技术要求

我们假设您对 Python 有基本的了解。您必须知道如何编写和运行 Python 的基本程序。

我们将在 Linux Mint 平台上开发我们的应用程序。然而,由于 Tkinter 是跨平台的,你可以在 Windows、Mac 或任何其他 Linux 发行版上按照本书中的说明进行操作,无需对代码进行任何修改。

项目概述

到本章结束时,你将已经开发出几个部分功能性的模拟应用程序,例如以下截图所示:

图片

我们将这些应用程序称为虚拟应用程序,因为它们既不是完全功能性的,也不提供任何实际用途,除了展示 Tkinter 的特定功能。

开始使用

我们将使用 Python 3.6.3 版本编写所有项目,这是撰写时 Python 的最新 稳定 版本。

Python 下载包和不同平台的下载说明可在www.python.org/downloads/release/python-363/找到。

macOS X 和 Windows 平台的安装程序二进制文件可在上述链接中获取。

如果你正在使用 Unix、Linux 或 BSD 系统,以下步骤将指导你从源代码安装 Python。

首先,使用您适用的包管理器在您的计算机上安装tk8.6-devpython3-tk软件包。例如,在基于 Debian 的系统上,如 Ubuntu 和 Mint,请在终端中运行以下两个命令:

sudo apt install tk8.6-dev
sudo apt install python3-tk

从前面的链接下载 Python 3.6.3 并将其解压到您选择的任何位置。在您解压 Python 的位置打开终端,并输入以下命令:

./configure
 make
 make test
 sudo make altinstall

这应该在您的计算机上安装 Python 3.6.3。现在打开命令行并输入以下命令:

$ python3.6

这将打开 Python 3.6 交互式 shell。输入以下命令:

>>> import tkinter

此命令应无错误执行。如果没有错误信息,则 Tkinter 模块已安装在你的 Python 发行版上。

当使用本书中的示例时,我们不支持除 Python 3.6.3 以外的任何 Python 版本,该版本捆绑了 Tkinter Tcl/Tk 版本 8.6。然而,大多数示例应该在其他次要的 Python 3 版本上直接运行无误。

要检查您的 Python 安装是否具有正确的 Tkinter 版本,请在您的 IDLE 或交互式 shell 中输入以下命令:

>>> import tkinter
>>> tkinter._test()

这应该确认 Tcl/Tk 版本为 8.6。我们现在可以开始构建我们的 GUI 程序了!

下一步骤是可选的,您可以根据自己的意愿跳过它们。虽然前面的步骤对我们开发程序已经足够,但我强烈建议您使用虚拟环境来开发您的程序。

虚拟环境提供了一个与系统程序无冲突的独立环境,并且可以在任何其他系统上轻松复制。

因此,现在让我们设置一个虚拟环境。首先,创建一个文件夹,用于存放本书的所有项目。我们可以称它为myTkinterProjects或者任何适合你的名字。

接下来,找到您计算机上 Python 3.6 的安装位置。在我的计算机上,我可以通过运行以下命令来找到 Python 安装的位置:

$ which python3.6

记录下位置。对我来说是/usr/local/bin/python3.6现在在你的myTkinterProjects文件夹中打开一个终端,并运行以下命令:

$ virtualenv -p /location/of /python3.6   myvenv/

这将在你的项目文件夹内名为myvenv的文件夹中创建一个新的虚拟环境。

最后,我们需要激活这个虚拟环境。这通过运行以下命令来完成:

$ source myenv/bin/activate

现在如果您输入命令 python,它应该从您的虚拟环境中选择 Python 3.6.3。

从现在开始,每次我们需要运行一个 Python 脚本或安装一个新的模块,我们首先使用前面的命令激活虚拟环境,然后在新的虚拟环境中运行或安装该模块。

图形用户界面编程 – 整体概述

作为图形用户界面(GUI)程序员,你通常需要负责决定你程序以下三个方面的内容:

  • 屏幕上应该显示哪些组件?

这涉及到选择构成用户界面的组件。典型的组件包括按钮、输入字段、复选框、单选按钮和滚动条。在 Tkinter 中,您添加到 GUI 的组件被称为小部件。小部件(简称窗口小工具)是构成应用程序前端图形组件。

  • 组件应该放在哪里?

这包括决定各种组件的位置和结构布局。在 Tkinter 中,这被称为几何管理

  • 组件是如何交互和表现的?

这涉及到为每个组件添加功能。每个组件或小部件都执行某些操作。例如,一个按钮在被点击时会产生响应。滚动条处理滚动,复选框和单选按钮使用户能够做出一些选择。在 Tkinter 中,各种小部件的功能是通过命令绑定或使用回调函数事件绑定来管理的。

以下图表展示了 GUI 编程的三个组成部分:

图片

根窗口 – 你的绘图板

图形用户界面编程是一门艺术,就像所有艺术一样,你需要一块画板来捕捉你的想法。你将使用的画板被称为根窗口。我们的第一个目标是准备好根窗口。

以下截图展示了我们将要创建的根窗口:

图片

绘制根窗口很简单。你只需要以下三行代码:

import tkinter as tk
root = tk.Tk() #line 2
root.mainloop()

将其保存为.py文件扩展名或查看1.01.py文件中的代码。在 IDLE 窗口中打开它,或使用以下命令在激活的虚拟环境中运行它:

$ python 1.01.py

运行此程序应生成一个空白的根窗口,如图中所示的前一个截图。此窗口配备了功能性的最小化、最大化和关闭按钮,以及一个空白框架。

下载示例代码

您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。除了访问 Packt 的官方网站外,您还可以在github.com/PacktPublishing/Tkinter-GUI-Application-Development-Blueprints-Second-Edition找到这本书的代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接将文件通过电子邮件发送给您。

以下是对前面代码的描述:

  • 第一行将tkinter模块导入到命名空间中,并以tk作为其别名。现在我们可以通过在名称后附加别名 tk 来访问 Tkinter 中所有类的定义、属性和方法,例如tk.Tk()

  • 第二行创建了一个tkinter.Tk类的实例。这创建了一个被称为根窗口的东西,这在前面截图中有展示。根据惯例,Tkinter 中的根窗口通常被称为root,但你也可以自由地用任何其他名字来称呼它。

  • 第三行执行了 root 对象的mainloop(即事件循环)方法。mainloop方法正是保持 root 窗口可见的原因。如果你删除第三行,第二行创建的窗口将在脚本停止运行后立即消失。这会发生得如此之快,以至于你甚至看不到窗口出现在你的屏幕上。保持mainloop方法运行也允许你保持程序运行,直到你按下关闭按钮,这将退出mainloop

  • Tkinter 还暴露了 mainloop 方法为 tkinter.mainloop()。因此,你可以直接调用 mainloop() 而不是调用 root.mainloop()

恭喜!你已经完成了第一个目标,那就是绘制根窗口。现在,你已经准备好了你的画板(根窗口)。现在,准备好用你的想象力来描绘它吧!

将这三行代码(如代码1.01.py所示)牢记于心。这三行代码生成了您的根窗口,它将容纳所有其他图形组件。这些行构成了您在 Tkinter 中开发的任何 GUI 应用程序的骨架。使您的 GUI 应用程序功能化的全部代码将位于此代码的第 2 行(新对象创建)和第 3 行(mainloop)之间。

Widgets – GUI 程序的构建块

现在我们已经准备好了顶级窗口或根窗口,是时候思考这样一个问题了:哪些组件应该出现在窗口中?在 Tkinter 术语中,这些组件被称为小部件

添加小部件所使用的语法如下:

my_widget = tk.Widget-name (its container window, ** its configuration options)

在下面的示例(1.02.py)中,我们将向根容器添加两个小部件,一个标签和一个按钮。同时,请注意所有小部件是如何添加到我们在第一个示例中定义的骨架代码之间的:

import tkinter as tk
root = tk.Tk()
label = tk.Label(root, text="I am a label widget")
button = tk.Button(root, text="I am a button")
label.pack()
button.pack()
root.mainloop()

运行前面的代码(1.02.py)将生成一个带有标签和按钮小部件的窗口,如下面的截图所示:

图片

以下是对前面代码的描述:

  • 此代码为标签小部件添加了一个名为label的新实例。第一个参数定义了根元素作为其父元素或容器。第二个参数配置了其文本选项,以读取我是一个标签小部件

  • 同样,我们定义了一个 Button 小部件的实例。这也被绑定到根窗口作为其父窗口。

  • 我们使用了pack()方法,这基本上是必需的,用于在窗口中定位标签和按钮小部件。当探索几何管理任务时,我们将讨论pack()方法以及几个其他相关概念。然而,你必须注意,对于小部件要显示出来,某种形式的几何规格是必不可少的。

一些重要的部件功能

注意以下所有小部件共有的几个重要特性:

  • 所有小部件实际上都是它们各自小部件类的派生对象。因此,一个如button = Button(its_parent)这样的语句实际上是从Button类创建了一个按钮实例。

  • 每个小部件都有一组选项来决定其行为和外观。这包括文本标签、颜色和字体大小等属性。例如,按钮小部件具有管理其标签、控制其大小、更改前景和背景颜色、更改边框大小等属性。

  • 要设置这些属性,您可以在创建小部件时直接设置值,如前一个示例所示。或者,您也可以稍后通过使用.config().configure()方法来设置或更改小部件的选项。请注意,.config().configure()方法是可互换的,并提供相同的功能。实际上,.config()方法仅仅是.configure()方法的别名。

创建小部件的方法

在 Tkinter 中创建小部件有两种方法。

第一种方法涉及在一行中创建一个小部件,然后在下一行添加pack()方法(或其他布局管理器),如下所示:

my_label = tk.Label(root, text="I am a label widget")
my_label.pack()

或者,你也可以将这两行一起写,如下所示:

tk.Label(root, text="I am a label widget").pack()

您可以选择保存对创建的部件的引用(例如,my_label,如第一个示例所示),或者创建一个部件而不保留对其的任何引用(如第二个示例所示)。

理想情况下,你应该保留对组件的引用,以防在程序中稍后需要访问该组件。例如,当你需要调用其内部方法或修改其内容时,这很有用。如果组件状态在创建后应保持静态,那么你不需要保留对组件的引用。

注意,调用 pack()(或其他几何管理器)总是返回 None。因此,考虑以下情况:你创建了一个小部件,并在同一行上添加了一个几何管理器(例如,pack()),如下所示:my_label = tk.Label(...).pack()。在这种情况下,你并没有创建小部件的引用。相反,你为 my_label 变量创建了一个 None 类型的对象。所以,当你后来尝试通过引用修改小部件时,你会得到一个错误,因为你实际上是在尝试对一个 None 类型的对象进行操作。如果你需要一个小部件的引用,你必须在一行上创建它,然后在第二行上指定其几何(例如 pack()),如下所示:

my_label = tk.Label(...)

my_label.pack()

这是最常见的初学者犯的错误之一。

了解 Tkinter 核心小部件

现在,你将了解所有核心的 Tkinter 小部件。在前面的示例中,你已经看到了其中的两个——标签(Label)和按钮(Button)小部件。现在,让我们来探索所有其他的核心 Tkinter 小部件。

Tkinter 包含 21 个核心小部件,具体如下:

顶级 标签 按钮
画布 复选按钮 输入框
框架 标签框架 列表框
菜单 菜单按钮 消息
选项菜单 分割窗口 单选按钮
尺度 滚动条 微调框
文本 位图 图片

让我们编写一个程序来显示根窗口中的所有这些小部件。

向父窗口添加小部件

添加小部件所使用的格式与我们在上一个任务中讨论的相同。为了给您一个关于如何操作的直观印象,这里有一些示例代码,展示了如何添加一些常见的小部件:

Label(parent, text="Enter your Password:")
Button(parent, text="Search")
Checkbutton(parent, text="Remember Me", variable=v, value=True)
Entry(parent, width=30)
Radiobutton(parent, text="Male", variable=v, value=1)
Radiobutton(parent, text="Female", variable=v, value=2)
OptionMenu(parent, var, "Select Country", "USA", "UK", "India","Others")
Scrollbar(parent, orient=VERTICAL, command= text.yview)

你能否发现每个小部件共有的模式?你能发现其中的差异吗?

作为提醒,添加小部件的语法如下:

Widget-name(its_parent, **its_configuration_options)

使用相同的模式,让我们将所有 21 个核心 Tkinter 小部件添加到一个虚拟应用程序(1.03.py)中。我们在此不展示完整代码。1.03.py 的代码摘要如下:

  1. 我们创建一个顶级窗口和一个mainloop,正如之前示例中所示。

  2. 我们添加了一个框架小部件并将其命名为menu_bar。请注意,框架小部件只是持有其他小部件的容器小部件。框架小部件非常适合将小部件分组在一起。添加框架的语法与所有其他小部件的语法相同:

        frame = Frame(root)
        frame.pack()
  1. 菜单栏框架作为容器,我们向其中添加了两个小部件:

    • 菜单按钮

    • 菜单

  2. 我们创建另一个框架小部件并将其命名为frame。将frame作为容器/父小部件,我们向其中添加以下七个小部件:

    • 标签

    • 输入框

    • 按钮

    • 复选框

    • 单选按钮

    • 选项菜单

    • 位图

  3. 我们接着创建另一个框架小部件。我们向框架中添加了六个更多的小部件:

    • Image

    • 列表框(Listbox)

    • 滚动框(Spinbox)

    • 滑块(Scale)

    • 标签框架(LabelFrame)

    • 消息

  4. 我们接着创建另一个框架小部件。我们再向框架中添加两个小部件:

    • 文本

    • 滚动条

  5. 我们创建另一个框架小部件并将其添加两个更多的小部件:

    • Canvas

    • PanedWindow

这些构成了 Tkinter 的 21 个核心小部件。现在你已经瞥见了所有的小部件,让我们来讨论如何使用布局管理器来指定这些小部件的位置。

Tkinter 布局管理器

你可能还记得,我们在上一节开发的虚拟应用程序中使用了 pack() 方法来添加小部件。pack() 方法是 Tkinter 中几何管理的一个例子。

pack()方法并不是管理您界面几何形状的唯一方式。实际上,Tkinter 中有三种几何形状管理器,允许您指定顶级或父窗口中小部件的位置。

三种几何管理器如下:

  • pack: 这是我们迄今为止所使用的布局方式。对于简单的布局来说,使用起来很简单,但对于稍微复杂一些的布局,它可能会变得非常复杂。

  • 网格布局:这是最常用的几何管理器,它提供了一个类似表格的管理功能布局,便于进行布局管理。

  • 位置: 这是最受欢迎度最低的,但它为小部件的绝对定位提供了最佳的控制。

现在,让我们来看看这三个几何管理器在实际操作中的几个示例。

包布局管理器

打包管理器在用言语解释时可能有些棘手,最好是通过实际操作代码库来理解。Tkinter 的作者 Fredrik Lundh 建议我们想象根节点就像一张带有中心小开口的弹性薄片。打包几何管理器在弹性薄片上打一个足够容纳小部件的孔。小部件被放置在间隙的给定内边缘(默认为顶部边缘)上。然后它会重复这个过程,直到所有小部件都被容纳进去。

最后,当所有的小部件都包装在弹性薄片内时,几何管理器会计算所有小部件的边界框。然后它使父小部件足够大,以便容纳所有子小部件。

当打包子小部件时,打包管理器区分以下三种空间:

  • 未声明的空间

  • 声称但未使用的空间

  • 声称和使用的空间

打包中最常用的选项包括以下内容:

  • side: LEFT, TOP, RIGHT, 和 BOTTOM (这些决定小部件的对齐方式)

  • fill: X, Y, BOTH, 和 NONE(这些决定小部件是否可以增长大小)

  • expand: 布尔值,例如 tkinter.YES/tkinter.NO1 / 0,以及 True/False

  • anchor: NW, N, NE, E, SE, S, SW, W, and CENTER (对应于主要方向)

  • 内部填充(ipadxipady)用于小部件内部的填充,以及外部填充(padxpady),它们的所有默认值均为零

让我们看看演示代码,它展示了该软件包的一些功能特性。

两个最常用的包装选项是 fillexpand

图片

以下代码(1.04.py)生成一个类似于前一个截图所示的图形用户界面:

import tkinter as tk
root = tk.Tk()
frame = tk.Frame(root)
# demo of side and fill options
tk.Label(frame, text="Pack Demo of side and fill").pack()
tk.Button(frame, text="A").pack(side=tk.LEFT, fill=tk.Y)
tk.Button(frame, text="B").pack(side=tk.TOP, fill=tk.X)
tk.Button(frame, text="C").pack(side=tk.RIGHT, fill=tk.NONE)
tk.Button(frame, text="D").pack(side=tk.TOP, fill=tk.BOTH)
frame.pack()
# note the top frame does not expand or fill in X or Y directions
# demo of expand options - best understood by expanding the root
#vwidget and seeing the effect on all the three buttons below.
tk.Label (root, text="Pack Demo of expand").pack()
tk.Button(root, text="I do not expand").pack()
tk.Button(root, text="I do not fill x but I expand").pack(expand = 1)
tk.Button(root, text="I fill x and expand").pack(fill=tk.X, expand=1)
root.mainloop()

以下是对前面代码的描述:

  • 当你在根框架中插入 A 按钮,它会捕获框架的最左侧区域,并扩展以填充 Y 维度。因为填充选项被指定为 fill=tk.Y,它会占据它想要的全部区域,并填充其容器框架的 Y 维度。

  • 因为框架本身包含一个不带任何打包选项的简单 pack() 方法,所以它占用最小的空间来容纳其所有子控件。

  • 如果你通过向下或向侧面拉动来增加根窗口的大小,你会发现框架内的所有按钮都不会随着根窗口的扩大而填充或扩展。

  • BCD按钮的位置是基于为每个按钮指定的侧面和填充选项来确定的。

  • 接下来的三个按钮(在BCD之后)展示了expand选项的使用。expand=1的值表示按钮在窗口调整大小时会移动其位置。没有明确expand选项的按钮将保持在原位,并且不会对其父容器(在这种情况下是根窗口)大小的变化做出响应。

  • 学习这段代码的最佳方式是将根窗口调整大小,以观察它对各种按钮产生的影响。

  • anchor 属性(在前面代码中未使用)提供了一种将小部件相对于参考点定位的方法。如果未指定 anchor 属性,则打包管理器将小部件放置在可用空间或打包框的中心。允许的其他选项包括四个基本方向(N、S、EW)以及任意两个方向的组合。因此,anchor 属性的有效值包括 CENTER(默认值)、N、S、E、W、NW、NE、SW 和 SE。

Tkinter 几何管理器的大多数属性值可以是未加引号的全部大写字母(例如 side=tk.TOPanchor=tk.SE),也可以是加引号的小写字母(例如 side='top'anchor='se')。

我们将在我们的某些项目中使用 pack 布局管理器。因此,熟悉 pack 及其选项将是有益的。

包管理器非常适合以下两种情况:

  • 以自上而下的方式放置小部件

  • 将小部件并排放置

1.05.py 展示了这两个场景的示例:

parent = tk.Frame(root)
# placing widgets top-down
tk.Button(parent, text='ALL IS WELL').pack(fill=tk.X)
tk.Button(parent, text='BACK TO BASICS').pack(fill=tk.X)
tk.Button(parent, text='CATCH ME IF U CAN').pack(fill=tk.X)
# placing widgets side by side
tk.Button(parent, text='LEFT').pack(side=tk.LEFT)
tk.Button(parent, text='CENTER').pack(side=tk.LEFT)
tk.Button(parent, text='RIGHT').pack(side=tk.LEFT)
parent.pack()

上述代码生成了一个图形用户界面,如下截图所示:

图片

要获取完整的包参考,请在 Python 命令行中输入以下命令:

>> import tkinter
>>> help(tkinter.Pack) 

除了通过文档获得交互式帮助外,Python 的交互式解释器(REPL)也是迭代和快速原型化 Tkinter 程序的出色工具。

你应该在何处使用 pack() 布局管理器?

使用包管理器相对于接下来将要讨论的grid方法来说有些复杂,但它在以下情况下是一个很好的选择:

  • 使小部件填充整个容器框架

  • 将几个小部件叠加或并排放置(如前一张截图所示)

尽管您可以通过在多个框架中嵌套小部件来创建复杂的布局,但您会发现网格几何管理器更适合大多数复杂布局。

网格布局管理器

网格布局管理器易于理解,可能是 Tkinter 中最有用的布局管理器。网格布局管理器的核心思想是将容器框架组织成一个二维表格,该表格被划分为若干行和列。然后,表格中的每个单元格都可以被定位以容纳一个小部件。在这种情况下,单元格是想象中的行和列的交点。

注意,在网格方法中,每个单元格只能容纳一个小部件。然而,可以将小部件设置为跨越多个单元格。

在每个单元格内,您可以使用 sticky 选项进一步对组件的位置进行对齐。sticky 选项决定了组件如何扩展。如果其容器单元格的大小大于它所包含的组件大小,则可以使用一个或多个 N、S、EW 选项或 NW、NE、SW 和 SE 选项来指定 sticky 选项。

未指定粘性时,粘性默认设置为单元格中小部件的中心。

让我们看看演示代码,它展示了网格几何管理器的一些特性。1.06.py中的代码生成一个 GUI,如下截图所示:

图片

以下代码(1.06.py)生成了前面的 GUI:


import tkinter as tk
root = tk.Tk()
tk.Label(root, text="Username").grid(row=0, sticky=tk.W)
tk.Label(root, text="Password").grid(row=1, sticky=tk.W)
tk.Entry(root).grid(row=0, column=1, sticky=tk.E)
tk.Entry(root).grid(row=1, column=1, sticky=tk.E)
tk.Button(root, text="Login").grid(row=2, column=1, sticky=tk.E)
root.mainloop()

以下是对前面代码的描述:

  • 查看在行和列位置定义的网格位置,对于跨越整个框架的虚拟网格表。看看在标签上使用sticky=tk.W是如何使它们固定在左侧的,从而实现整洁的布局。

  • 每一列(或每一行的宽度)都是自动根据单元格中小部件的高度或宽度来决定的。因此,您无需担心指定行或列的宽度是否相等。如果您需要额外的控制,您可以指定小部件的宽度。

  • 您可以使用sticky=tk.NSEW参数来使小部件可扩展并填充整个网格的单元格。

在更复杂的场景中,您的组件可能跨越网格中的多个单元格。为了创建跨越多个单元格的网格,grid方法提供了便捷的选项,例如rowspancolumnspan

此外,你可能经常需要在网格中的单元格之间提供一些填充。网格管理器提供了padxpady选项来提供需要放置在部件周围的填充。

类似地,ipadxipady 选项用于内部填充。这些选项在控件内部添加填充。外部和内部填充的默认值是 0

让我们来看一个网格管理器的示例,其中我们使用了网格方法的大部分常用参数,例如 rowcolumnpadxpadyrowspancolumnspan

1.07.py生成了一个 GUI,如下面的截图所示,以演示如何使用网格几何管理器选项:

图片

以下代码(1.07.py)生成了前面的 GUI:

import tkinter as tk
parent = tk.Tk()
parent.title('Find & Replace')
tk.Label(parent, text="Find:").grid(row=0, column=0, sticky='e')
tk.Entry(parent, width=60).grid(row=0, column=1, padx=2, pady=2, 
                                sticky='we', columnspan=9)
tk.Label(parent, text="Replace:").grid(row=1, column=0, sticky='e')
tk.Entry(parent).grid(row=1, column=1, padx=2, pady=2, sticky='we',
                       columnspan=9)
tk.Button(parent, text="Find").grid( row=0, column=10, sticky='e' + 'w', 
                                padx=2, pady=2)
tk.Button(parent, text="Find All").grid(
                     row=1, column=10, sticky='e' + 'w', padx=2)
tk.Button(parent, text="Replace").grid(row=2, column=10, sticky='e' +
                               'w', padx=2)
tk.Button(parent, text="Replace All").grid(
                     row=3, column=10, sticky='e' + 'w', padx=2)
tk.Checkbutton(parent, text='Match whole word only').grid(
                     row=2, column=1, columnspan=4, sticky='w')
tk.Checkbutton(parent, text='Match Case').grid(
                     row=3, column=1, columnspan=4, sticky='w')
tk.Checkbutton(parent, text='Wrap around').grid(
                     row=4, column=1, columnspan=4, sticky='w')
tk.Label(parent, text="Direction:").grid(row=2, column=6, sticky='w')
tk.Radiobutton(parent, text='Up', value=1).grid(
                     row=3, column=6, columnspan=6, sticky='w')
tk.Radiobutton(parent, text='Down', value=2).grid(
                     row=3, column=7, columnspan=2, sticky='e')
parent.mainloop()

注意仅 14 行核心网格管理器代码就能生成如前截图所示的复杂布局。另一方面,如果使用打包管理器来开发,将会更加繁琐。

另一个有时可以使用的网格选项是widget.grid_forget()方法。此方法可用于从屏幕上隐藏小部件。当你使用此选项时,小部件仍然存在于其原始位置,但它变得不可见。隐藏的小部件可以被再次显示,但原本分配给小部件的网格选项将会丢失。

类似地,存在一个 widget.grid_remove() 方法可以移除小部件,但在这个情况下,当你再次使小部件可见时,它所有的网格选项都将被恢复。

要获取完整的网格参考,请在 Python 命令行中输入以下命令:

>>> import tkinter
>>> help(tkinter.Grid)

你应该在何处使用网格几何管理器?

网格管理器是开发复杂布局的强大工具。通过将容器小部件分解成行和列的网格,然后在这些网格中放置所需的小部件,可以轻松实现复杂结构。它也常用于开发不同类型的对话框。

现在我们将深入探讨配置网格的列和行大小。

不同的部件有不同的高度和宽度。因此,当您以行和列的形式指定部件的位置时,单元格会自动扩展以容纳该部件。

通常,所有网格行的长度会自动调整,使其等于其最高单元格的长度。同样,所有网格列的宽度也会调整,使其等于最宽小部件单元格的宽度。

如果你想要一个更小的部件来填充更大的单元格或者使其保持在单元格的任何一边,你可以使用部件上的粘性属性来控制这一方面。

然而,您可以通过以下代码来覆盖列和行的自动尺寸:

w.columnconfigure(n, option=value, ...) AND
w.rowconfigure(n, option=value, ...)

使用这些选项来配置给定小部件w在第n列或第n行的选项,指定选项、最小尺寸、填充和权重。请注意,行号从0开始,而不是1

可用的选项如下:

选项 描述
minsize 这是列或行的最小像素大小。如果给定列或行中没有小部件,即使有此 minsize 指定,单元格也不会出现。
填充 这是在像素中添加到指定列或行的外部填充,其大小将超过最大单元格的大小。

| 重量 | 这指定了行或列的相对权重,然后分配额外的空间。这使得行或列可拉伸。例如,以下代码将额外的五分之二空间分配给第一列,三分之五分配给第二列:w.columnconfigure(0, weight=2)

columnconfigure()rowconfigure() 方法通常用于实现小部件的动态调整大小,尤其是在调整根窗口大小时。

你不能在同一个容器窗口中同时使用gridpack方法。如果你尝试这样做,你的程序将引发一个_tkinter.TclError错误。

地理位置管理器

Tkinter 中,位置几何管理器是最不常用的几何管理器。然而,它有其用途,因为它允许您通过使用 (x,y) 坐标系统来精确地定位其父框架内的小部件。

可以通过在任何标准小部件上使用 place() 方法来访问位置管理器。

空间几何的重要选项包括以下内容:

  • 绝对定位(以 x=Ny=N 的形式指定)

  • 相对定位(关键选项包括 relxrelyrelwidthrelheight

与位置一起常用的其他选项包括 widthanchor(默认为 NW)。

请参考1.08.py以演示常见的选项:

import tkinter as tk
root = tk.Tk()
# Absolute positioning
tk.Button(root, text="Absolute Placement").place(x=20, y=10)
# Relative positioning
tk.Button(root, text="Relative").place(relx=0.8, rely=0.2, relwidth=0.5, 
                              width=10,  anchor=tk.NE)
root.mainloop()

仅通过查看代码或窗口框架,你可能看不出绝对位置和相对位置之间有多大差别。然而,如果你尝试调整窗口大小,你会观察到绝对位置按钮不会改变其坐标,而相对位置按钮则会改变其坐标和大小,以适应根窗口的新尺寸:

图片

要获取完整的位置引用,请在 Python 壳中输入以下命令:

>>> import tkinter
>>> help(tkinter.Place)

你应该在何时使用位置管理器?

地方管理者在需要实现自定义几何管理器或由最终用户决定小部件放置位置的情况下非常有用。

虽然打包管理器和网格管理器不能在同一个框架中一起使用,但位置管理器可以与同一容器框架内的任何几何管理器一起使用。

地点管理器很少被使用,因为如果你使用它,你必须担心确切的坐标。如果你对一个部件进行微小的更改,你很可能还需要更改其他部件的 xy 值,这可能会非常繁琐。我们将在 第七章钢琴辅导 中使用地点管理器。

这是我们关于 Tkinter 中几何管理的讨论的总结。

在本节中,您了解了如何实现 pack、grid 和 place 几何管理器。您还了解了每个几何管理器的优缺点。

你了解到“pack”布局适合简单的横向或纵向小部件排列。你还了解到网格管理器最适合处理复杂布局。你看到了“place”几何管理器的示例,并探讨了它为何很少被使用的原因。

你现在应该能够使用这些 Tkinter 布局管理器来规划和执行你程序的不同布局。

事件和回调 – 为程序注入活力

现在你已经学会了如何将小部件添加到屏幕上并将它们放置到你想要的位置,让我们将注意力转向 GUI 编程的第三个组成部分。

这解决了如何使小部件功能化的问题。

使小工具功能化包括使其对事件做出响应,例如按下按钮、键盘上的按键以及鼠标点击。

这需要将回调函数与特定事件关联起来。回调函数通常通过命令绑定规则与特定的小部件事件关联,这些规则将在下一节中讨论。

命令绑定

向按钮添加功能的最简单方式被称为命令绑定,其中回调函数以command = some_callback的形式在部件选项中提及。请注意,command选项仅适用于少数选定的部件。

查看以下示例代码:

def my_callback ():
   # do something when button is clicked

在定义了前面的回调函数之后,我们可以将其连接到,比如说,一个按钮,使用带有指向回调函数的command选项,如下所示:

tk.Button(root, text="Click me", command=my_callback)

回调是一个函数内存引用(如前例中的my_callback),由另一个函数(如前例中的Button)调用,并将第一个函数作为参数。简单来说,回调是你提供给另一个函数的函数,以便它可以调用它。

注意,my_callback 是从 command 选项的 widget 中不带括号 () 传递的,因为当设置回调函数时,需要传递一个函数的引用而不是实际调用它。

如果你添加括号,即(),就像对任何正常函数所做的那样,程序运行时就会立即调用。相比之下,回调函数仅在事件发生时(在这种情况下是按钮的按下)才会被调用。

将参数传递给回调函数

如果回调函数不接受任何参数,可以使用一个简单的函数来处理,例如前面代码中所示的那个。然而,如果回调函数需要接受参数,我们可以使用lambda函数,如下面的代码片段所示:

def my_callback (argument)
   #do something with argument

然后,在代码的另一个地方,我们定义了一个带有命令回调的按钮,该回调函数接受一些参数,如下所示:

tk.Button(root,text="Click", command=lambda: my_callback ('some argument'))

Python 从函数式编程中借鉴了一种特定的语法,称为 lambda 函数。lambda 函数允许你即时定义一个单行、无名的函数。

使用 lambda 的格式如下:

lambda arg: #do something with arg in a single line

这里有一个例子:

square = lambda x: x**2

现在,我们可以调用square方法,如下所示:

>> print(square(5)) ## prints 25 to the console

命令选项的限制

按钮小部件和其他一些小部件中可用的command选项是一个可以使编程按钮点击事件变得简单的函数。许多其他小部件不提供等效的命令绑定选项。

默认情况下,命令按钮绑定到左键点击和空格键。它不会绑定到回车键。因此,如果你使用command函数绑定按钮,它将响应空格键而不是回车键。这对许多用户来说是不直观的。更糟糕的是,你不能轻易地更改命令函数的绑定。教训是,虽然命令绑定是一个非常方便的工具,但在决定自己的绑定时,它的灵活性不足。

这就带我们来到了处理事件的下一个方法。

事件绑定

幸运的是,Tkinter 提供了一种名为 bind() 的替代事件绑定机制,让您能够处理不同的事件。绑定事件的常用语法如下:

widget.bind(event, handler, add=None)

当小部件中发生与事件描述相对应的事件时,它不仅调用相关的处理程序,并将事件对象的实例作为参数传递,而且还调用事件的详细信息。如果此小部件已存在对该事件的绑定,则通常用新处理程序替换旧回调,但您可以通过将add='+'作为最后一个参数传递来触发两个回调。

让我们来看一下bind()方法的示例(代码1.09.py):

import tkinter as tk
root = tk.Tk()
tk.Label(root, text='Click at different\n locations in the frame below').pack()

def callback(event):
    print(dir(event))
    print("you clicked at", event.x, event.y)

frame = tk.Frame(root, bg='khaki', width=130, height=80)
frame.bind("<Button-1>", callback)
frame.pack()
root.mainloop()

以下是对前面代码的描述:

  • 我们将 Frame 小部件绑定到 <Button-1> 事件,这对应于左键点击。当此事件发生时,它调用 callback 函数,并将一个对象实例作为其参数传递。

  • 我们定义了callback(event)函数。注意,它接受由事件生成的事件对象event作为参数。

  • 我们通过使用dir(event)来检查事件对象,它返回传递给它的事件对象的属性名称的排序列表。这将打印出以下列表:

 [ '__doc__' , '__module__' , 'char' , 'delta' , 'height' , 'keycode' , 'keysym' , keysym_num' , 'num' , 'send_event' , 'serial' , 'state' ,'time' , 'type' , 'widget' , 'width' , 'x' , 'x_root' , 'y' , 'y_root ']
  • 从对象生成的属性列表中,我们使用两个属性,event.xevent.y,来打印点击点的坐标。

当你运行前面的代码(代码1.09.py),它会产生一个窗口,如下面的截图所示:

图片

当你在根窗口中的黄色色框内任意位置进行左键点击时,它会在控制台输出信息。传递到控制台的一个示例信息如下:

['__doc__', '__module__', 'char', 'delta', 'height', 'keycode', 'keysym', 'keysym_num', 'num', 'send_event', 'serial', 'state', 'time', 'type', 'widget', 'width', 'x', 'x_root', 'y', 'y_root']
 You clicked at 63 36.

事件模式

在上一个示例中,你学习了如何使用 <Button-1> 事件来表示左键点击。这是 Tkinter 中的一个内置模式,将其映射到左键点击事件。Tkinter 具有一个详尽的映射方案,可以完美地识别此类事件。

这里有一些示例,以帮助您了解事件模式:

事件模式 相关事件
<Button-1> 鼠标左键点击
<KeyPress-B> 按下 B 键的键盘操作
<Alt-Control-KeyPress- KP_Delete> 按下 Alt + Ctrl + Del 的键盘操作

通常,映射模式具有以下形式:

<[event modifier-]...event type [-event detail]>

通常,一个事件模式将包括以下内容:

  • 事件类型:一些常见的事件类型包括 ButtonButtonReleaseKeyReleaseKeypressFocusInFocusOutLeave(当鼠标离开小部件时)和 MouseWheel。要获取事件类型的完整列表,请参阅www.tcl.tk/man/tcl8.6/TkCmd/bind.htm#M7中的event类型部分。

  • 事件修饰符(可选):一些常见的事件修饰符包括 AltAny(类似于 <Any-KeyPress> 使用),ControlDouble(类似于 <Double-Button-1> 表示左鼠标按钮的双击),LockShift。要获取事件修饰符的完整列表,请参阅www.tcl.tk/man/tcl8.6/TkCmd/bind.htm#M6中的“事件修饰符”部分。

  • 事件详情(可选):鼠标事件详情通过数字 1 表示左键点击,数字 2 表示右键点击。同样,键盘上的每个按键按下都可以由按键字母本身表示(例如,在<KeyPress-B>中表示为B),或者使用缩写为keysym的按键符号。例如,键盘上的向上箭头键由keysymKP_Up表示。对于完整的keysym映射,请参阅www.tcl.tk/man/tcl8.6/TkCmd/bind.htm

让我们来看一个在部件上绑定事件的实际例子(完整的工作示例请参考代码 1.10.py):

图片

以下是一个修改过的代码片段;它将给你一个关于常见

使用了事件绑定:

widget.bind("<Button-1>", callback) #bind widget to left mouse click
widget.bind("<Button-2>", callback) # bind to right mouse click
widget.bind("<Return>", callback)# bind to Return(Enter) Key
widget.bind("<FocusIn>", callback) #bind to Focus in Event
widget.bind("<KeyPress-A>", callback)# bind to keypress A
widget.bind("<KeyPress-Caps_Lock>", callback)# bind to CapsLock keysym
widget.bind("<KeyPress-F1>", callback)# bind widget to F1 keysym
widget.bind("<KeyPress-KP_5>", callback)# bind to keypad number 5
widget.bind("<Motion>", callback) # bind to motion over widget
widget.bind("<Any-KeyPress>", callback) # bind to any keypress

而不是将事件绑定到特定的控件,您也可以将其绑定到顶级窗口。语法保持不变,只是现在您需要在根窗口的根实例上调用它,例如root.bind()

绑定级别

在上一节中,你学习了如何将事件绑定到小部件的实例。这可以被称为实例级绑定

然而,有时你可能需要将事件绑定到整个应用程序。在某些情况下,你可能希望将事件绑定到特定类别的窗口小部件。Tkinter 为此提供了以下级别的绑定选项:

  • 应用级绑定:应用级绑定允许你在应用程序的所有窗口和控件中使用相同的绑定,只要应用程序中的任何一个窗口处于焦点状态。应用级绑定的语法如下:
widget.bind_all(event, callback, add=None)

典型的使用模式如下:

root.bind_all('<F1>', show_help)

在此处的应用级绑定意味着,无论当前聚焦的窗口小部件是什么,只要应用程序处于聚焦状态,按下 F1 键总是会触发 show_help 回调。

  • 类级绑定:您还可以在特定类级别绑定事件。这通常用于为特定小部件类的所有实例设置相同的行为。类级绑定的语法如下:
w.bind_class(class_name, event, callback, add=None)

典型的使用模式如下:

my_entry.bind_class('Entry', '<Control-V>', paste)

在前面的示例中,所有条目小部件都将绑定到 <Control-V> 事件,这将调用一个名为 paste (event) 的方法。

事件传播

大多数键盘和鼠标事件发生在操作系统级别。事件从其源头按层次向上传播,直到找到具有相应绑定的窗口。事件的传播并不会停止在这里。它会继续向上传播,寻找来自其他小部件的其他绑定,直到达到根窗口。如果它达到了根窗口并且没有发现任何绑定,则该事件将被忽略。

处理特定小部件的变量

你需要具有广泛多样小部件的变量。你很可能需要一个字符串变量来跟踪用户输入到输入小部件或文本小部件中的内容。你很可能需要一个布尔变量来跟踪用户是否勾选了复选框小部件。你需要整数变量来跟踪在旋转框或滑块小部件中输入的值。

为了应对小部件特定变量的变化,Tkinter 提供了自己的 variable 类。您可以使用从该 Tkinter variable 类派生的变量来跟踪小部件特定的值。Tkinter 提供了一些常用的预定义变量。它们是 StringVarIntVarBooleanVarDoubleVar

您可以使用这些变量在回调函数内部捕获并处理变量值的变化。如果需要,您还可以定义自己的变量类型。

创建 Tkinter 变量很简单。你只需调用构造函数:

my_string = tk.StringVar()
ticked_yes = tk.BooleanVar()
group_choice = tk.IntVar()
volume = tk.DoubleVar() 

变量创建后,您可以用作小部件选项,如下所示:

tk.Entry(root, textvariable=my_string)
tk.Checkbutton(root, text="Remember Me", variable=ticked_yes)
tk.Radiobutton(root, text="Option1", variable=group_choice, value="option1") 
tk.Scale(root, label="Volume Control", variable=volume, from =0, to=10)

此外,Tkinter 通过 set()get() 方法提供了访问变量值的途径,如下所示:

my_var.set("FooBar") # setting value of variable
my_var.get() # Assessing the value of variable from say a callback 

Tkinter 变量 类的演示可以在 1.11.py 代码文件中找到。该代码生成一个窗口,如下面的截图所示:

图片

这就结束了我们对事件和回调的简要讨论。以下是我们所讨论内容的简要总结:

  • 命令绑定,用于将简单的小部件绑定到特定函数

  • 使用widget.bind_all(event, callback, add=None)方法进行事件绑定,将键盘和鼠标事件绑定到您的控件上,并在特定事件发生时调用回调函数

  • 使用 lambda 函数向回调传递额外的参数

  • 使用 bind_all()bind_class() 将事件绑定到整个应用程序或特定类别的控件

  • 使用 Tkinter 的 variable 类来设置和获取特定小部件的变量值

简而言之,你现在已经知道了如何让你的图形用户界面程序对最终用户请求做出响应!

事件解绑和虚拟事件

除了你之前看到的绑定方法之外,你可能会在某些情况下发现以下两个与事件相关的选项很有用:

  • 解绑: Tkinter 提供了解绑选项来撤销之前绑定的效果。语法如下:
widget.unbind(event)

以下是一些其用法的示例:

entry.unbind('<Alt-Shift-5>')
root.unbind_all('<F1>')
root.unbind_class('Entry', '<KeyPress-Del>')
  • 虚拟事件:Tkinter 还允许你创建自己的事件。你可以为这些虚拟事件命名任何你想要的名称。例如,假设你想创建一个名为 <<commit>> 的新事件,该事件由 F9 键触发。要在指定的小部件上创建此虚拟事件,请使用以下语法:
widget.event_add('<<commit>>', '<KeyRelease-F9>')

您可以通过使用常规的 bind() 方法将 <<commit>> 绑定到一个回调,如下所示:

widget.bind('<<commit>>', callback)

其他与事件相关的方法可以通过在 Python 终端中输入以下行来访问:

>>> import tkinter
>>> help(tkinter.Event)

现在你已经准备好使用 Tkinter 深入实际应用开发,让我们花些时间探索 Tkinter 提供的一些自定义样式选项。我们还将查看一些与根窗口常用的一些配置选项。

以风格化的方式完成

到目前为止,我们一直依赖 Tkinter 为我们的小部件提供特定平台的样式。然而,您可以指定您自己的小部件样式,例如它们的颜色、字体大小、边框宽度和浮雕效果。以下章节提供了 Tkinter 中可用的样式功能的简要介绍。

你可能记得,我们可以在实例化小部件时指定其选项,如下所示:

my_button = tk.Button(parent, **configuration options)

或者,您可以通过以下方式使用 configure() 来指定小部件选项:

my_button.configure(**options)

样式选项也可以作为小部件的选项来指定,无论是在创建小部件时,还是通过使用configure选项来稍后指定。

指定样式

在样式管理的范畴内,我们将介绍如何将不同的颜色、字体、边框宽度、浮雕效果、光标和位图图标应用到小部件上。

首先,让我们看看如何指定小部件的颜色选项。对于大多数小部件,你可以指定以下两种类型的颜色:

  • 背景颜色

  • 前景颜色

您可以通过使用红色(r)、绿色(g)和蓝色(b)的十六进制颜色代码来指定颜色。常用的表示方法有#rgb(4 位)、#rrggbb(8 位)和#rrrgggbbb(12 位)。

例如,#fff 是白色,#000000 是黑色,#f00 是红色(R=0xf,G=0x0,B=0x0),#00ff00 是绿色(R=0x00,G=0xff,B=0x00),而 #000000fff 是蓝色(R=0x000,G=0x000,B=0xfff)。

或者,Tkinter 提供了标准颜色名称的映射。要查看预定义的命名颜色列表,请访问 wiki.tcl.tk/37701wiki.tcl.tk/16166.

接下来,让我们看看如何为我们的小部件指定字体。字体可以通过以下字符串签名表示为一个字符串:

{font family} fontsize fontstyle

上述语法的元素可以解释如下:

  • 字体族: 这是指完整的字体族长名称。它最好使用小写,例如 font="{nimbus roman} 36 bold italic"

  • 字体大小: 这是以打印机点单位(pt)或像素单位(px)表示的。

  • fontstyle: 这是一种普通/粗体/斜体和下划线/删除线的混合。

以下是一些说明指定字体方法的示例:

widget.configure (font='Times 8')
widget.configure(font='Helvetica 24 bold italic')

如果你将 Tkinter 的尺寸设置为普通整数,则测量将以像素为单位进行。或者,Tkinter 还接受其他四种测量单位,分别是 m(毫米)、c(厘米)、i(英寸)和 p(打印点,大约为 1/72")。

例如,如果你想以打印机的点为单位指定按钮的换行长度,你可以这样指定:

button.configure(wraplength="36p")

Tkinter 大多数小部件的默认边框宽度为 2 像素。您可以通过明确指定来更改小部件的边框宽度,如下所示行所示:

button.configure(borderwidth=5)

小部件的浮雕样式指的是小部件中最高和最低海拔之间的差异。Tkinter 提供了六种可能的浮雕样式——flatraisedsunkengroovesolidridge

button.configure(relief='raised')

Tkinter 允许你在鼠标悬停在特定小部件上时更改鼠标光标的样式。这可以通过使用 cursor 选项来完成,如下所示:

button.configure(cursor='cross')

要查看可用的光标完整列表,请参阅www.tcl.tk/man/tcl8.6/TkCmd/cursors.htm.

虽然您可以在每个小部件级别指定样式选项,但有时对每个小部件单独进行操作可能会感到繁琐。小部件特定的样式有以下缺点:

  • 它将逻辑和展示合并到一个文件中,使得代码变得庞大且难以管理

  • 任何样式更改都必须单独应用于每个小部件

  • 它违反了有效编码的“不要重复自己”(DRY)原则,因为你一直在为大量的小部件指定相同的样式

幸运的是,Tkinter 现在提供了一种将展示与逻辑分离并在所谓的 外部选项数据库 中指定样式的途径。这只是一个文本文件,您可以在其中指定常见的样式选项。

一个典型的选项数据库文本文件看起来是这样的:

*background: AntiqueWhite1
*Text*background: #454545
*Button*foreground: gray55
*Button*relief: raised
*Button*width: 3

在其最简单的用法中,这里的星号(*****)符号表示该特定样式应用于给定小部件的所有实例。对于星号在样式中的更复杂用法,请参阅infohost.nmt.edu/tcc/help/pubs/tkinter/web/resource-lines.html

这些条目被放置在一个外部文本(.txt)文件中。要将这种样式应用到特定的代码片段上,你可以在代码中早期使用option_readfile()函数来调用它,如下所示:

root.option_readfile('optionDB.txt')

让我们来看一个使用这个外部样式文本文件在程序中的示例(见代码 1.12.py):

import tkinter as tk
root = tk.Tk()
root.configure(background='#4D4D4D')#top level styling
# connecting to the external styling optionDB.txt
root.option_readfile('optionDB.txt')
#widget specific styling
mytext = tk.Text(root, background='#101010', foreground="#D6D6D6",
              borderwidth=18, relief='sunken',width=17, height=5)
mytext.insert(tk.END, "Style is knowing who you are, what you want to
                   say, and not giving a damn.")
mytext.grid(row=0, column=0, columnspan=6, padx=5, pady=5)
# all the below widgets get their styling from optionDB.txt file
tk.Button(root, text='*').grid(row=1, column=1)
tk.Button(root, text='^').grid(row=1, column=2)
tk.Button(root, text='#').grid(row=1, column=3)
tk.Button(root, text='<').grid(row=2, column=1)
tk.Button(root, text='OK', cursor='target').grid(row=2, column=2)#changing cursor style
tk.Button(root, text='>').grid(row=2, column=3)
tk.Button(root, text='+').grid(row=3, column=1)
tk.Button(root, text='v').grid(row=3, column=2)
tk.Button(root, text='-').grid(row=3, column=3)
for i in range(9):
   tk.Button(root, text=str(i+1)).grid(row=4+i//3, column=1+i%3)
root.mainloop()

以下是对前面代码的描述:

  • 代码连接到一个名为 optionDB.txt 的外部样式文件,该文件定义了小部件的通用样式。

  • 下一个代码段创建了一个文本小部件,并在小部件级别指定了样式。

  • 下一段代码包含几个按钮,所有这些按钮的样式都源自集中式的optionDB.txt文件。其中一个按钮还定义了一个自定义的光标。

在绝对数值中指定属性,例如字体大小、边框宽度、小部件宽度、小部件高度和填充,正如我们在前面的示例中所做的那样,这可能会导致在不同操作系统之间(如 Ubuntu、Windows 和 Mac)出现一些显示差异,如下面的截图所示。这是由于不同操作系统的渲染引擎存在差异:

图片

当部署跨平台应用时,最好避免使用绝对数值来指定属性大小。通常,让平台处理属性大小是最佳选择。

一些常见的根窗口选项

现在我们已经讨论完了样式选项,让我们来总结一下根窗口常用的一些选项:

方法 描述
*root.geometry('142x280+150+200') 您可以通过使用widthxheight + xoffset + yoffset格式的字符串来指定根窗口的大小和位置。
self.root.wm_iconbitmap('mynewicon.ico') OR self.root.iconbitmap('mynewicon.ico ') 这将标题栏图标更改为与默认 Tk 图标不同的图标。

| root.overrideredirect(1) | 这将移除根边框。它隐藏包含最小化按钮的框架。

最大化,和关闭按钮。 |

让我们更详细地解释这些样式选项:

  • root.geometry('142x280+150+200'): 指定根窗口的几何形状限制了根窗口的启动大小。如果小部件无法适应指定的尺寸,它们将被从窗口中裁剪掉。通常最好不指定此参数,让 Tkinter 为你决定。

  • self.root.wm_iconbitmap('my_icon.ico')self.root.iconbitmap('my_icon.ico '): 此选项仅适用于 Windows。基于 Unix 的操作系统不会显示标题栏图标。

获取交互式帮助

本节不仅适用于 Tkinter,也适用于任何可能需要帮助的 Python 对象。

假设你需要参考 Tkinter 的 pack 布局管理器。你可以在 Python 的交互式 shell 中通过使用 help 命令来获取交互式帮助,如下所示:

>>> import tkinter
>>> help(tkinter.Pack)

这提供了 Tkinter 中定义在 Pack 类下所有方法的详细帮助文档。

您同样可以为所有其他单个小部件获得帮助。例如,您可以通过在交互式外壳中输入以下命令来查看标签小部件的全面且权威的帮助文档:

 >>>help(tkinter.Label)

这提供了一份以下内容的列表:

  • 所有在Label类中定义的方法

  • Label小部件的所有标准和小部件特定选项

  • 所有从其他类继承的方法

最后,当对某个方法不确定时,请查看 Tkinter 的源代码,它位于<Python 安装位置>\lib\。例如,在我的 Linux Mint 操作系统上,Tkinter 的源代码位于/usr/lib/python3.6.3/tkinter目录中。您还可能发现查看其他模块的源代码实现很有用,例如颜色选择器文件对话框ttk模块,以及其他位于上述目录中的模块。

摘要

这章内容到此结束。本章旨在提供一个 Tkinter 的高级概述。我们逐一探讨了驱动 Tkinter 程序的所有重要概念。

你现在已经知道了什么是根窗口以及如何设置它。你也了解了 21 个核心 Tkinter 小部件以及如何设置它们。我们还探讨了如何使用PackGridPlace布局管理器来布局我们的程序,以及如何通过使用eventscallbacks来使我们的程序具有功能性。最后,你看到了如何将自定义样式应用到 GUI 程序中。

总结来说,我们现在可以开始思考如何使用 Tkinter 制作有趣、实用且时尚的图形用户界面程序了!在下一章中,我们将构建我们的第一个真实应用程序——一个文本编辑器。

QA 部分

在你继续阅读下一章之前,请确保你能满意地回答这些问题:

  • 什么是根窗口?

  • 什么是主循环?

  • 你如何创建一个根窗口?

  • 小部件是什么?如何在 Tkinter 中创建小部件?

  • 你能列出或识别出 Tkinter 中所有可用的控件吗?

  • 几何管理器有哪些用途?

  • 你能列出 Tkinter 中所有可用的几何管理器吗?

  • 在 GUI 程序中,什么是事件?

  • 什么是回调函数?回调函数与普通函数有何不同?

  • 你如何将回调应用于事件?

  • 你如何使用样式选项来设置小部件的样式?

  • 根窗口有哪些常见的配置选项?

进一步阅读

将本章的示例进行修改,以不同的方式布局小部件,或者调整代码以实现其他功能,这样可以帮助你熟悉操作。

我们建议您使用以下命令在您的 Python shell 中查看所有三个几何管理器的文档:

>>> import tkinter
>>> help(tkinter.Pack)
>>> help(tkinter.Grid)
>>> help(tkinter.Place)

你也可以在infohost.nmt.edu/tcc/help/pubs/tkinter/web/index.html找到关于 Tkinter 的优秀文档。

第二章:制作一个文本编辑器

我们在第一章,“认识 Tkinter”中,对 Tkinter 有一个相当高级的概述。现在,既然我们已经了解了一些关于 Tkinter 核心小部件、布局管理和将命令和事件绑定到回调函数的知识,让我们运用这些技能在这个项目中创建一个文本编辑器。

在创建文本编辑器的过程中,我们将更深入地了解一些小部件,并学习如何调整它们以满足我们的特定需求。

以下是这个项目的关键目标:

  • 深入了解一些常用的小部件,例如菜单(Menu)、菜单按钮(Menubutton)、文本框(Text)、输入框(Entry)、复选框(Checkbutton)和按钮(Button)小部件

  • 探索 Tkinter 的 filedialogmessagebox 模块

  • 学习 Tkinter 中索引和标签的重要概念

  • 识别不同类型的顶层窗口

项目概述

此处的目标是创建一个具有一些实用功能的文本编辑器。让我们称它为 Footprint Editor:

图片

我们打算在文本编辑器中包含以下功能:

  • 创建新文档、打开和编辑现有文档以及保存文档

  • 实现常见的编辑选项,如剪切、复制、粘贴、撤销和重做

  • 在文件中搜索给定的搜索词

  • 实现行号显示和隐藏功能

  • 实现主题选择功能,让用户可以为编辑器选择自定义颜色主题

  • 实现关于和帮助窗口

开始使用 – 设置编辑器框架

我们的首要目标是实现文本编辑器的广泛视觉元素。作为程序员,我们所有人都使用过文本编辑器来编辑代码。我们对文本编辑器的常见 GUI 元素大多有所了解。因此,无需多言,让我们开始吧。

第一阶段实现了菜单(Menu)、菜单按钮(Menubutton)、标签(Label)、按钮(Button)、文本(Text)和滚动条(Scrollbar)小部件。尽管我们将在详细内容中涵盖所有这些,但你可能会发现查看 Tkinter 作者 Frederick Lundh 维护的文档中的小部件特定选项很有帮助,该文档位于effbot.org/tkinterbook/。你还可以使用如第一章,遇见 Tkinter中讨论的交互式 shell。

您可能还想将 Tcl/Tk 的官方文档页面收藏起来,网址为 www.tcl.tk/man/tcl8.6/TkCmd/contents.htm。该网站包含了原始的 Tcl/Tk 参考信息。虽然它与 Python 无关,但它提供了每个小部件的详细概述,是一个有用的参考资料。请记住,Tkinter 只是 Tk 的一个包装器。

在这次迭代中,我们将完成编辑器更广泛视觉元素的实现。

我们将使用pack()布局管理器来放置所有小部件。我们选择使用 pack 管理器,因为它非常适合放置小部件,无论是并排还是自上而下的位置。

幸运的是,在文本编辑器中,我们所有的组件都放置在左右两侧或上下位置。因此,使用打包管理器是有益的。我们也可以用网格管理器做同样的事情。

关于代码风格的说明

Python 社区的一个重要洞见是代码的阅读频率远高于其编写频率。遵循良好的命名规范和代码风格的统一是保持程序可读性和可扩展性的关键。我们将努力遵守官方的 Python 风格指南,该指南在 PEP8 文档中指定,网址为 www.python.org/dev/peps/pep-0008

我们将坚持的一些重要格式规范包括以下内容:

  • 每个缩进级别使用四个空格

  • 变量和函数名称将采用小写,单词之间用下划线分隔

  • 类名将使用CapWords约定

让我们从使用以下代码添加顶层窗口开始:

from tkinter import Tk
root = Tk()
# all our code goes here
root.mainloop()

注意我们在这里导入 tkinter 的方式略有不同。在上一章中,我们使用以下代码导入 tkinter

import tkinter as tk

由于我们使用了tk作为别名,因此必须将别名名称添加到对 Tkinter 中定义的每个类的调用中,例如tk.Tk()tk.Frametk.Buttontk.END等等。

从本章开始,我们将直接导入给定程序所需的单个类。因此,现在我们需要从 Tkinter 导入Tk()类,我们将其直接导入到我们的命名空间中,如下所示:

from tkinter import Tk

这意味着我们现在可以直接在我们的程序中将它引用为Tk类,而不需要像在root = Tk()中那样给它添加任何别名。

第三种方法是使用以下命令将 Tkinter 中的所有(*)类导入到命名空间中:

from tkinter import *

星号符号表示我们希望将tkinter库中的所有内容导入到命名空间中,无论我们是否使用它。然而,这种做法是糟糕的编程习惯,因为它会导致命名空间污染。此外,在更大的程序中,很难确定某个类是从哪个模块导入的,这使得调试变得困难。

添加菜单和菜单项

菜单提供了一种非常紧凑的方式来向用户展示大量选择,而不会使界面显得杂乱。Tkinter 提供了以下两个小部件来处理菜单:

  • 菜单小部件:这出现在应用程序的顶部,始终对最终用户可见

  • 菜单项:当用户点击菜单时显示出来

我们将使用以下代码来添加顶层菜单按钮:

my_menu = Menu(parent, **options)

例如,要添加一个“文件”菜单,我们将使用以下代码:

# Adding Menubar in the widget
menu_bar = Menu(root)
file_menu = Menu(menu_bar, tearoff=0)
# all file menu-items will be added here next
menu_bar.add_cascade(label='File', menu=file_menu)
root.config(menu=menu_bar)

以下截图是前面代码(2.01.py)的结果:

图片

同样,我们将添加编辑、查看和关于菜单(2.01.py*)。

我们还将定义一个如下常量:

PROGRAM_NAME = " Footprint Editor "

然后,我们将设置根窗口瓷砖,如下所示:

root.title(PROGRAM_NAME)

大多数 Linux 平台支持可撕离菜单。当tearoff设置为1(启用)时,菜单选项上方会出现一条虚线。点击虚线允许用户实际上撕离或从顶部分离菜单。然而,由于这不是一个跨平台特性,我们已决定禁用撕离功能,将其标记为tearoff = 0

添加菜单项

接下来,我们将在每个单独的菜单中添加菜单项。不出所料,菜单项的代码需要添加到相应的菜单实例中,如下面的截图所示:

图片

在我们的示例中,我们将向文件、编辑和关于菜单中添加菜单项 (2.02.py)。

视图菜单包含某些菜单项变体,这些将在下一节中讨论,因此在此处不予处理。

菜单项是通过使用add_command()方法添加的。添加菜单项所使用的格式如下:

my_menu.add_command(label="Menu Item Label", accelerator='KeyBoard Shortcut', compound='left', image=my_image, underline=0, command=callback)

例如,您可以通过以下语法创建撤销菜单项:

edit_menu.add_command(label="Undo", accelerator='Ctrl + Z', compound='left', image=undo_icon, command=undo_callback)

在前面代码中引入的一些特定菜单选项如下:

  • 加速器: 此选项用于指定一个字符串,通常是键盘快捷键,可以用来调用菜单。作为加速器指定的字符串将出现在菜单项文本旁边。请注意,这并不会自动创建键盘快捷键的绑定。我们还需要手动设置它们。这将在稍后讨论。

  • compound: 为菜单项指定compound选项可以让您在菜单标签旁边添加图片。例如,compound='left', label= 'Cut', image=cut_icon这样的指定意味着剪切图标将出现在“剪切”菜单标签的左侧。我们将在这里使用的图标存储并引用在一个名为icons的单独文件夹中。

  • 下划线: 下划线选项允许您指定菜单文本中需要加下划线的字符索引。索引从0开始,这意味着指定underline=1将下划线添加到文本的第二个字符。除了下划线外,Tkinter 还使用它来定义菜单键盘遍历的默认绑定。这意味着我们可以用鼠标指针或使用Alt + 下划线索引处的字符快捷键来选择菜单。

要在文件菜单中添加“新建”菜单项,请使用以下代码:

file_menu.add_command(label="New", accelerator='Ctrl+N', compound='left', image=new_file_icon, underline=0, command=new_file)

菜单分隔符

有时,在菜单项中,你会遇到如下代码 my_menu.add_separator()。此小部件显示一个分隔条,仅用于将相似菜单项分组,通过水平条分隔各个组。

接下来,我们将添加一个框架小部件来容纳快捷图标。我们还将向左侧添加一个文本小部件来显示行号,如下面的屏幕截图所示 (2.02.py):

图片

当使用包装布局管理器时,按照它们将出现的顺序添加小部件非常重要,因为pack()使用可用空间的概念来适应小部件。这就是为什么文本内容小部件在代码中相对于两个标签小部件会出现在更低的位置。

保留空间后,我们可以在以后添加快捷图标或行号,并将框架小部件作为父小部件。添加框架非常简单;我们以前已经这样做过了。代码如下(参考2.02.py):

shortcut_bar = Frame(root, height=25, background='light sea green')
shortcut_bar.pack(expand='no', fill='x')
line_number_bar = Text(root, width=4, padx=3, takefocus=0, border=0, background='khaki', state='disabled', wrap='none')
line_number_bar.pack(side='left', fill='y')

我们已经为这两个小部件应用了背景颜色,目前是为了将它们与 Toplevel 窗口的主体区分开来。

最后,让我们添加主文本小部件和滚动条小部件,如下所示 (2.02.py):

content_text = Text(root, wrap='word')
content_text.pack(expand='yes', fill='both')
scroll_bar = Scrollbar(content_text)
content_text.configure(yscrollcommand=scroll_bar.set)
scroll_bar.config(command=content_text.yview)
scroll_bar.pack(side='right', fill='y')

代码与我们迄今为止实例化所有其他小部件的方式相似。然而,请注意,滚动条被配置为与 Text 小部件的 yview 相关联,而 Text 小部件被配置为连接到滚动条小部件。这样,小部件之间就实现了交叉连接。

现在,当你向下滚动文本小部件时,滚动条会做出反应。或者,当你移动滚动条时,文本小部件会相应地做出反应。

实现视图菜单

Tkinter 提供以下三种菜单项类型:

  • 复选框菜单项:这些选项允许您通过勾选/取消勾选菜单项来做出是/否的选择

  • 单选按钮菜单项:这些选项允许您从许多不同的选项中选择一个

  • 级联菜单项:这些菜单项仅展开以显示另一列表选项

以下视图菜单显示了这三种菜单项类型在实际操作中的效果:

图片

视图菜单中的前三个菜单项允许用户通过勾选或取消勾选它们来做出明确的肯定或否定选择。这些都是复选框菜单的例子。

前一截图中的“主题”菜单项是一个级联菜单的示例。将鼠标悬停在此级联菜单上会简单地打开另一个菜单项列表。然而,我们也可以通过使用postcommand=callback选项来绑定一个菜单项。这可以在显示级联菜单项的内容之前管理某些事情,通常用于动态列表创建。

在级联菜单中,您将看到为您的编辑器主题提供的选项列表。然而,您只能选择一个主题。选择一个主题将取消之前的选择。这是一个单选按钮菜单的示例。

我们在此处不会展示完整的代码(请参考代码包中的2.03.py代码)。然而,用于添加这三种菜单项的示例代码如下:

view_menu.add_checkbutton(label="Show Line Number", variable=show_line_no)
view_menu.add_cascade(label="Themes", menu=themes_menu)
themes_menu.add_radiobutton(label="Default", variable=theme_name)

现在,我们需要通过添加一个变量来跟踪是否已做出选择,这个变量可以是BooleanVar()IntVar()StringVar(),正如在第一章,认识 Tkinter中讨论的那样。

这标志着我们第一次迭代的结束。在这个迭代中,我们奠定了文本编辑器的大部分视觉元素。现在,是时候给编辑器添加一些功能了。

添加内置功能

Tkinter 的 Text 小部件提供了一些方便的内置功能来处理常见的文本相关功能。让我们利用这些功能来实现文本编辑器中的常见特性。

让我们从实现剪切复制粘贴功能开始。现在我们已经准备好了编辑器 GUI。如果你打开程序并玩一下文本小部件,你会看到你可以通过使用Ctrl + XCtrl + CCtrl + V分别来在文本区域执行基本的剪切复制粘贴操作。所有这些功能都存在,我们不需要为这些功能添加任何一行代码。

文本小部件显然自带了这些内置事件。现在,我们只需要将这些事件连接到它们各自的菜单项。

Tcl/Tk 通用小部件方法的文档告诉我们,我们可以通过以下命令来触发事件,而不需要外部刺激:

widget.event_generate(sequence, **kw)

要触发剪切事件,我们只需要在代码中添加以下这一行:

content_text.event_generate("<<Cut>>")

让我们通过使用一个cut函数来称呼它,并通过回调命令将其与 Cut 菜单关联(2.04.py):

def cut():
  content_text.event_generate("<<Cut>>")

然后,从现有的剪切菜单中定义一个回调命令,如下所示:

edit_menu.add_command(label='Cut', accelerator='Ctrl+X', compound='left', image=cut_icon, command=cut)

同样,从各自的菜单项中触发复制粘贴功能。

接下来,我们将继续介绍撤销重做功能的实现。Tcl/Tk 文本文档告诉我们,只要我们将文本小部件的撤销选项设置为true1,文本小部件就具有无限撤销和重做机制。为了利用这个选项,让我们首先将文本小部件的撤销选项设置为true1,如下面的代码所示:

content_text = Text(root, wrap='word', undo=1)

现在,如果你打开文本编辑器并尝试使用 Ctrl + Z 来测试 撤销 功能,它应该可以正常工作。现在,我们只需要将事件关联到函数,并从撤销菜单中调用这些函数。这与我们为 剪切复制粘贴 所做的是类似的。请参考 2.03.py 中的代码。

然而,redo 有一个需要解决的问题的小特性。默认情况下,redo 并没有绑定到 Ctrl + Y 键。相反,Ctrl + Y 键绑定到了 paste 功能。这并不是我们预期的绑定行为,但由于与 Tcl/Tk 相关的一些历史原因,它存在。

幸运的是,通过添加事件绑定,可以轻松覆盖此功能,如下所示:

content_text.bind('<Control-y>', redo) # handling Ctrl + small-case y
content_text.bind('<Control-Y>', redo) # handling Ctrl + upper-case y

由于像前面代码中那样的事件绑定会发送一个事件参数,因此 undo 函数必须能够处理这个传入的参数。因此,我们将为 redo 函数添加一个 event=None 可选参数,如下所示 (2.04.py):

def redo(event=None):
  content_text.event_generate("<<Redo>>")
  return 'break'

事件从操作系统级别传播,并且可以被订阅该事件或希望利用该事件的窗口访问。前一个函数中的return 'break'表达式告诉系统它已经处理了该事件,并且不应该进一步传播。

这防止了相同的事件触发粘贴事件,即使在 Tkinter 中这是默认行为。现在,Ctrl + Y 触发的是重做事件,而不是触发粘贴事件。

事实上,一旦我们完成了一个事件,我们不想它进一步传播。因此,我们将向所有事件驱动函数添加返回break

索引和标签

虽然我们设法利用了一些内置功能来获得快速优势,但我们仍需要更多对文本区域的控制,以便我们可以随心所欲地调整它。这需要我们能够精确地针对每个字符或文本位置进行操作。

我们需要知道每个字符、光标或所选区域的精确位置,以便对编辑器中的内容进行任何操作。

文本小部件为我们提供了使用 索引标签标记 来操作其内容的能力,这些功能使我们能够针对文本区域内的特定位置进行操作。

目录

索引可以帮助你在文本中定位特定的位置。例如,如果你想将某个特定的单词加粗、设置为红色或改变字体大小,只要你知道起始点和目标结束点的索引,就可以做到这一点。

索引必须指定为以下格式之一:

索引格式 描述
x.y 这指的是第 x 行和第 y 列的字符。
@x,y 这指的是覆盖文本窗口内 x,y 坐标的字符。
end 这指的是文本的结束。
mark 这指的是命名标记之后的字符。
tag.first 这指的是文本中被给定标签标记的第一个字符。
tag.last 这指的是文本中被给定标签标记的最后一个字符。
selection (SEL_ FIRST, SEL_LAST) 这对应于当前的选择。SEL_FIRSTSEL_LAST 常量分别指选择中的起始位置和结束位置。如果没有任何选择,Tkinter 会引发一个 TclError 异常。
window_name 这指的是名为 window_name 的嵌入式窗口的位置。
image_name 这指的是嵌入图像image_name的位置。
INSERT 这指的是插入光标的位置。
CURRENT 这指的是离鼠标指针最近的字符位置。

注意这里的一个小细节。在文本小部件中行数的计数从 1 开始,而列数的计数从 0 开始。因此,文本小部件起始位置的索引是 1.0(即行号为 1,列号为 0)。

索引可以通过使用修饰符和子修饰符进行进一步操作。以下是一些修饰符和子修饰符的示例:

  • end - 1 charsend - 1 c:这指的是位于末尾字符之前的字符索引

  • 插入+5 行: 这指的是插入光标前方五行的索引

  • insertwordstart - 1 c: 这指的是包含插入光标的首个字符之前的一个字符

  • end linestart: 这指的是结束行的起始行索引

索引通常用作函数的参数。请参考以下列表以获取一些示例:

  • my_text.delete(1.0,END) : 这意味着你可以从第1行,第0列删除到文件末尾

  • my_text.get(1.0, END) : 这是从 1.0(开始)到末尾的内容获取

  • my_text.delete('insert-1c', INSERT) : 这将删除插入光标处的字符

标签

标签用于使用识别字符串注释文本,然后可以用来操作标记的文本。Tkinter 内置了一个名为 SEL 的标签,它会被自动应用到选中的文本上。除了 SEL,您还可以定义自己的标签。一个文本范围可以关联多个标签,同一个标签也可以用于许多不同的文本范围。

这里是一些标签示例:

my_text.tag_add('sel', '1.0', 'end') # add SEL tag from start(1.0) to end
my_text.tag_add('danger', "insert linestart", "insert lineend+1c")
my_text.tag_remove('danger', 1.0, "end")
my_text.tag_config('danger', background=red)
my_text.tag_config('outdated', overstrike=1)

您可以使用 tag_config 来指定给定标签的视觉样式,使用选项如 background(颜色)、bgstipple(位图)、borderwidth(距离)、fgstipple(位图)、font(字体)、foreground(颜色)、justify(常量)、lmargin1(距离)、lmargin2(距离)、offset(距离)、overstrike

(flag), relief (常数), rmargin (距离), spacing1 (距离), tabs (字符串), underline (标志), 和 wrap (常数).

要获取关于文本索引和标记的完整参考信息,请在 Python 交互式壳中输入以下命令:

>>> import Tkinter
>>> help(Tkinter.Text)

拥有基本的索引和标签理解,让我们在代码编辑器中实现一些更多功能。

实现全选功能

我们知道 Tkinter 有一个内置的sel标签,它可以应用于给定的文本范围。我们希望将这个标签应用到小部件中的整个文本上。

我们可以简单地定义一个函数来处理这个问题,如下所示 (2.05.py):

def select_all(event=None):
  content_text.tag_add('sel', '1.0', 'end')
  return "break"

在完成这个操作后,给“全选”菜单项添加一个回调函数:

edit_menu.add_command(label='Select All', underline=7, accelerator='Ctrl+A', command=select_all)

我们还需要将功能绑定到 Ctrl + A 键盘快捷键。我们通过以下键绑定(2.05.py)来实现:

content_text.bind('<Control-A>', select_all)
content_text.bind('<Control-a>', select_all)

全选功能的编码已完成。要尝试使用它,请向文本小部件添加一些文本,然后点击菜单项,全选,或使用 Ctrl + A (快捷键)。

实现查找文本功能

接下来,让我们编写查找文本功能(2.05.py)。以下截图展示了查找文本功能的示例:

图片

这里是对所需功能的一个简要总结。当用户点击“查找”菜单项时,会打开一个新的顶层窗口。用户输入搜索关键词并指定是否需要区分大小写。当用户点击“查找所有”按钮时,所有匹配项都会被突出显示。

为了在文档中进行搜索,我们依赖于text_widget.search()方法。搜索方法接受以下参数:

search(pattern, startindex, stopindex=None, forwards=None, backwards=None, exact=None, regexp=None, nocase=None, count=None)

对于编辑器,定义一个名为 find_text 的函数,并将其作为回调函数附加到“查找”菜单(2.05.py):

edit_menu.add_command(label='Find',underline= 0, accelerator='Ctrl+F', command=find_text)

此外,将其绑定到 Ctrl + F 快捷键,如下所示:

content_text.bind('<Control-f>', find_text)
content_text.bind('<Control-F>', find_text) 

然后,定义find_text函数,如下所示(2.05.py):

def find_text(event=None):
    search_toplevel = Toplevel(root)
    search_toplevel.title('Find Text')
    search_toplevel.transient(root)
    Label(search_toplevel, text="Find All:").grid(row=0, 
                                      column=0,sticky='e')
    search_entry_widget = Entry(search_toplevel, width=25)
    search_entry_widget.grid(row=0, column=1, padx=2, pady=2, 
    sticky='we')
    search_entry_widget.focus_set()
    ignore_case_value = IntVar()
    .... more code here to crate checkbox and button 
    def close_search_window():
       content_text.tag_remove('match', '1.0', END)
       search_toplevel.destroy()
       search_toplevel.protocol('WM_DELETE_WINDOW', 
       close_search_window)
       return "break"

以下是对前面代码(2.05.py)的描述:

  • 当用户点击“查找”菜单项时,它会调用一个find_text回调函数。

  • find_text()函数的前四行创建了一个新的顶层窗口,添加了窗口标题,指定了其几何形状(大小、形状和位置),并将其设置为临时窗口。将其设置为临时窗口意味着它始终位于其父窗口或根窗口之上。如果你取消注释这一行并点击根编辑器窗口,查找窗口将会位于根窗口之后。

  • 下面的八行代码相当直观;它们设置了查找窗口的控件。它们添加了标签(Label)、输入框(Entry)、按钮(Button)和复选框(Checkbutton)控件,并设置了search_stringignore_case_value变量来跟踪用户输入到输入框控件中的值以及用户是否勾选了复选框。控件是通过使用网格几何管理器来排列,以适应查找窗口的。

  • “查找所有”按钮有一个命令选项,该选项调用一个search_output函数,将搜索字符串作为第一个参数传递,并将搜索是否需要区分大小写作为第二个参数传递。第三个、第四个和第五个参数将 Toplevel 窗口、Text 小部件和 Entry 小部件作为参数传递。

  • 我们重写了查找窗口的关闭按钮,并将其重定向到名为 close_search() 的回调函数。close_search 函数是在 find_text 函数内部定义的。这个函数负责移除在搜索过程中添加的匹配标签。如果我们不重写关闭按钮并移除这些标签,即使搜索结束后,匹配的字符串仍然会继续用红色和黄色标记。

接下来,我们定义search_output函数,该函数执行实际的搜索并将匹配标签添加到匹配的文本中。该函数的代码如下:

def search_output(needle, if_ignore_case, content_text,
 search_toplevel, search_box):
 content_text.tag_remove('match', '1.0', END)
 matches_found = 0
 if needle:
   start_pos = '1.0'
   while True:
      start_pos = content_text.search(needle, start_pos,
           nocase=if_ignore_case, stopindex=END)
      if not start_pos:
           break
      end_pos = '{}+{}c'.format(start_pos, len(needle))
      content_text.tag_add('match', start_pos, end_pos)
      matches_found += 1
      start_pos = end_pos
   content_text.tag_config('match', foreground='red', background='yellow')
 search_box.focus_set()
 search_toplevel.title('{} matches found'.format(matches_found))

以下是对前面代码的描述:

  • 这段代码是search函数的核心。它通过使用while True循环遍历整个文档,只有当没有更多文本项需要搜索时才会退出循环。

  • 代码首先会移除之前搜索相关的匹配标签(如果有),因为我们不希望将新搜索的结果附加到之前的搜索结果上。该函数使用search()方法,该方法由 Tkinter 中的 Text 小部件提供。search()方法接受以下参数:

      search(pattern, index, stopindex=None, forwards=None,
      backwards=None, exact=None, regexp=None, nocase=None, count=None)
  • search() 方法返回第一个匹配项的起始位置。我们将其存储在一个名为 start_pos 的变量中,计算匹配单词中最后一个字符的位置,并将其存储在 end_pos 变量中。

  • 对于它找到的每一个搜索匹配项,它都会将匹配标签添加到文本中,范围从第一个位置到最后一个位置。在每次匹配之后,我们将start_pos的值设置为等于end_pos。这确保了下一次搜索从end_pos之后开始。

  • 循环还通过使用count变量来跟踪匹配的数量。

  • 循环外部,标签匹配被配置为红色字体和黄色背景。此函数的最后一行更新了查找窗口的标题,显示找到的匹配数量。

在事件绑定的情况下,输入设备(键盘/鼠标)与您的应用程序之间发生交互。除了事件绑定之外,Tkinter 还支持协议处理。

协议一词指的是您的应用程序与窗口管理器之间的交互。一个协议的例子是 WM_DELETE_WINDOW,它处理窗口管理器的关闭窗口事件。

Tkinter 允许您通过指定自己的处理程序来覆盖这些协议处理程序,用于根或 Toplevel 小部件。要覆盖窗口退出协议,我们使用以下命令:

root.protocol(WM_DELETE_WINDOW, callback)

一旦添加此命令,Tkinter 将协议处理重定向到指定的回调/处理程序。

顶级窗口的类型

在本章之前,我们使用了以下这一行代码:

search_toplevel.transient(root)

让我们来探讨这里的意思。Tkinter 支持以下四种类型的 Toplevel 窗口:

  • 主顶层窗口:这是我们迄今为止一直在构建的类型。

  • 顶级窗口的子窗口:此类型与根无关。Toplevel 子窗口独立于其根窗口运行,但如果其父窗口被销毁,它也会被销毁。

  • 瞬态顶层窗口:此窗口始终出现在其父窗口的顶部,但它并不完全占据焦点。再次点击父窗口允许您与之交互。当父窗口最小化时,瞬态窗口将被隐藏,如果父窗口被销毁,瞬态窗口也将被销毁。这与所谓的模态窗口进行比较。模态窗口会从父窗口中夺取所有焦点,并要求用户首先关闭模态窗口,然后才能重新访问父窗口。

  • 未装饰的顶层窗口: 如果一个顶层窗口周围没有窗口管理器的装饰,则称为未装饰的。它通过设置overrideredirect标志为1来创建。未装饰的窗口不能调整大小或移动。

请参考2.06.py代码以演示所有四种类型的 Toplevel 窗口。

这标志着我们的第二次迭代结束。恭喜!我们已经将全选查找文本功能编码到我们的程序中。

更重要的是,你已经接触到了索引和标签——这两个与许多 Tkinter 小部件紧密相关的非常强大的概念。你会在你的项目中不断使用这两个概念。

我们还探讨了四种顶级窗口类型及其各自的用途。

与表单和对话框一起工作

本迭代的目标是实现文件菜单选项的功能:打开、保存和另存为。

我们可以通过使用标准的 Tkinter 小部件来实现这些对话框。然而,由于这些组件被广泛使用,一个名为 filedialog 的特定 Tkinter 模块已经被包含在标准的 Tkinter 发行版中。

这里是一个典型的 filedialog 示例:

图片

Tkinter 定义了以下关于 filedialogs 的常用用例:

函数 描述
askopenfile 这返回打开的文件对象
askopenfilename 这返回文件名字符串,而不是打开的文件对象
askopenfilenames 这将返回一个文件名列表
askopenfiles 这将返回一个打开的文件对象列表,或者如果选择取消则返回一个空列表
asksaveasfile 这个函数用于请求一个保存文件的文件名,并返回打开的文件对象
asksaveasfilename 这个函数用于请求保存文件的文件名,并返回该文件名
askdirectory 这将请求一个目录并返回目录名称

使用方法简单。导入filedialog模块并调用所需函数。以下是一个示例:

import tkinter.filedialog

我们随后使用以下代码调用所需的函数:

file_object = tkinter.filedialog.askopenfile(mode='r')

或者,我们使用以下代码:

my_file_name = tkinter.filedialog.askopenfilename()

在前面的代码中指定的mode='r'选项是可用于对话框的许多可配置选项之一。

你可以为filedialog指定以下附加选项:

文件对话框 可配置选项
askopenfile (mode='r', `options) parent, title, message, defaultextension, filetypes, initialdir, initialfile, and multiple
askopenfilename (`options) parent, title, message, defaultextension, filetypes, initialdir, initialfile, 和 multiple
asksaveasfile (mode='w', `options) parent, title, message, defaultextension, filetypes, initialdir, initialfile, and multiple
asksaveasfilename (`options) parent, title, message, defaultextension, filetypes, initialdir, initialfile, and multiple
askdirectory (`options) parenttitleinitialdir必须存在

配备了filedialog模块的基本理解后,我们现在来看看它的实际应用。我们将从实现文件|打开功能开始。

让我们从导入所需的模块开始,如下所示:

import tkinter.filedialog
import os # for handling file operations

接下来,让我们创建一个全局变量,该变量将存储当前打开的文件名,具体如下:

file_name = None

全局变量的使用通常被认为是不良的编程实践,因为它很难理解使用大量全局变量的程序。

全局变量可以在程序的许多不同地方进行修改或访问。因此,记住或确定变量的所有可能用途变得困难。

全局变量不受访问控制的限制,在某些情况下可能会带来安全风险,比如说当这个程序需要与第三方代码交互时。

然而,当你使用这种过程式风格的程序工作时,全局变量有时是不可避免的。

编程的另一种方法涉及在类结构(也称为面向对象编程)中编写代码,其中变量只能被预定义类的成员访问。在接下来的章节中,我们将看到许多面向对象编程的示例。

以下代码位于 open_file (2.07.py) 中:

def open_file(event=None):
   input_file_name = 
     tkinter.filedialog.askopenfilename(defaultextension=".txt", 
       filetypes=[("All Files", "*.*"),("Text Documents", "*.txt")])
   if input_file_name:
     global file_name
     file_name = input_file_name
     root.title('{} - {}'.format(os.path.basename(file_name),PROGRAM_NAME))
     content_text.delete(1.0, END)
     with open(file_name) as _file:
       content_text.insert(1.0, _file.read())
     on_content_changed()

修改“打开”菜单,为此新定义的方法添加一个回调命令,具体如下:

file_menu.add_command(label='Open', accelerator='Ctrl+O', compound='left', image=open_file_icon, underline =0, command=open_file)

以下是对前面代码的描述:

  • 我们在global作用域中声明了一个file_name变量,用于跟踪打开文件的文件名。这是为了跟踪文件是否已被打开。我们需要在global作用域中设置这个变量,因为我们希望这个变量能够被其他方法,如save()save_as()所访问。

  • 没有将其指定为 global,意味着它仅在函数内部可用。因此,save()save_as() 函数将无法检查编辑器中是否已经打开了文件。

  • 我们使用 askopenfilename 来获取打开文件的文件名。如果用户取消打开文件或未选择任何文件,返回的 file_nameNone。在这种情况下,我们不做任何操作。

  • 然而,如果filedialog返回一个有效的文件名,我们将使用os模块来隔离文件名,并将其添加为根窗口的标题。

  • 如果文本小部件已经包含了一些文本,我们将删除所有内容。

  • 我们随后以读取模式打开给定的文件,并将其内容插入到内容小部件中。

  • 我们使用上下文管理器(with命令),它会为我们正确关闭文件,即使在发生异常的情况下也是如此。

  • 最后,我们将一个命令回调添加到文件 | 打开菜单项。

这完成了“文件 | 打开”的编码。如果你现在导航到“文件 | 打开”,选择一个文本文件,然后点击“打开”,内容区域将填充文本文件的内容。

接下来,我们将看看如何保存文件。保存文件有两个方面:

  • 保存

  • 另存为

如果内容文本小部件已经包含一个文件,我们不会提示用户输入文件名。我们直接覆盖现有文件的内容。如果文本区域当前的内容没有关联的文件名,我们将通过“另存为”对话框提示用户。此外,如果文本区域有一个打开的文件,并且用户点击“另存为”,我们仍然会提示他们使用“另存为”对话框,以便他们可以将内容写入不同的文件名。

savesave_as 的代码如下 (2.07.py):

def save(event=None):
 global file_name
 if not file_name:
    save_as()
 else:
    write_to_file(file_name)
 return "break"

def save_as(event=None):
 input_file_name = tkinter.filedialog.asksaveasfilename
   (defaultextension=".txt", filetypes=[("All Files", "*.*"),
   ("Text Documents", "*.txt")])
 if input_file_name:
     global file_name
     file_name = input_file_name
     write_to_file(file_name)
    root.title('{} - {}'.format(os.path.basename(file_name),PROGRAM_NAME))
 return "break"

def write_to_file(file_name):
    try:
      content = content_text.get(1.0, 'end')
      with open(file_name, 'w') as the_file:
        the_file.write(content)
    except IOError:
      pass  
      # pass for now but we show some warning - we do this in next section

定义了savesave_as函数之后,让我们将它们连接到相应的菜单回调:

file_menu.add_command(label='Save', accelerator='Ctrl+S',  compound='left', image=save_file_icon,underline=0, command= save)
file_menu.add_command(label='Save as',    accelerator='Shift+Ctrl+S', command= save_as)

以下是对前面代码的描述:

  • save函数首先尝试检查文件是否已打开。如果文件已打开,它将直接用文本区域当前的内容覆盖文件内容。如果没有文件打开,它将直接将工作传递给save_as函数。

  • save_as 函数通过使用 asksaveasfilename 打开一个对话框,并尝试获取用户为指定文件提供的文件名。如果成功,它将以写入模式打开新文件,并将文本内容写入此新文件名下。写入后,它关闭当前文件对象,并将窗口标题更改为反映新文件名。

  • 如果用户没有指定文件名或者用户取消了save_as操作,它将简单地通过使用 pass 命令忽略该过程。

  • 我们添加了一个write_to_file(file_name)辅助函数来执行实际的文件写入操作。

当我们着手进行时,让我们完成“文件 | 新建”的功能。代码很简单(2.07.py):

def new_file(event=None):
   root.title("Untitled")
   global file_name
   file_name = None
   content_text.delete(1.0,END) 

现在,将一个回调命令添加到这个新功能的文件 | 新菜单项中:

file_menu.add_command(label='New', accelerator='Ctrl+N', compound='left', image=new_file_icon, underline=0, command=new_file)

以下是对前面代码的描述:

  1. new_file 函数首先将根窗口的标题属性更改为 Untitled

  2. 然后将全局filename变量的值设置为None。这很重要,因为savesave_as功能使用这个全局变量名来跟踪文件是否已存在或为新的。

  3. 该函数随后删除了Text小部件的所有内容,并在其位置创建了一个新的文档。

让我们通过添加新创建功能的快捷键来结束这次迭代(2.07.py):

content_text.bind('<Control-N>', new_file)
content_text.bind('<Control-n>', new_file)
content_text.bind('<Control-O>', open_file)
content_text.bind('<Control-o>', open_file)
content_text.bind('<Control-S>', save)
content_text.bind('<Control-s>',save)

在这次迭代中,我们实现了“新建”、“打开”、“保存”和“另存为”菜单项的编码功能。更重要的是,我们学习了如何使用filedialog模块来实现程序中常用的某些文件功能。我们还探讨了如何使用索引来实现程序的各种任务。

与消息框一起工作

现在,让我们完成关于和帮助菜单的代码。功能很简单。当用户点击帮助或关于菜单时,一个消息窗口会弹出并等待用户通过点击按钮进行响应。虽然我们可以轻松地编写新的顶层窗口来显示关于和帮助信息,但我们将使用名为 messagebox 的模块来实现这一功能。

messagebox模块提供了现成的消息框,用于在应用程序中显示各种消息。通过此模块可用的功能包括showinfoshowwarningshowerroraskquestionaskokcancelaskyesnoaskyesnocancelaskretrycancel,如下截图所示:

图片

要使用此模块,我们只需使用以下命令将其导入当前命名空间:

import tkinter.messagebox 

在代码包中的2.08.py文件提供了一个messagebox常用函数的演示。以下是一些常见的使用模式:

 import tkinter.messagebox as tmb
 tmb.showinfo(title="Show Info", message="This is FYI")
 tmb.showwarning(title="Show Warning", message="Don't be silly")
 tmb.showerror(title="Show Error", message="It leaked")
 tmb.askquestion(title="Ask Question", message="Can you read this?")
 tmb.askokcancel(title="Ask OK Cancel", message="Say Ok or Cancel?")
 tmb.askyesno(title="Ask Yes-No", message="Say yes or no?")
 tmb.askyesnocancel(title="Yes-No-Cancel", message="Say yes no cancel")
 tmb.askretrycancel(title="Ask Retry Cancel", message="Retry or what?")

了解了messagebox模块后,让我们为代码编辑器编写abouthelp函数。功能很简单。当用户点击“关于”或“帮助”菜单项时,会弹出一个showinfomessagebox

要实现这一点,请在编辑器中包含以下代码(2.09.py):

def display_about_messagebox(event=None):
     tkinter.messagebox.showinfo("About", "{}{}".format(PROGRAM_NAME,                     
       "\nTkinter GUI Application\n Development Blueprints"))

def display_help_messagebox(event=None):
     tkinter.messagebox.showinfo("Help", "Help Book: \nTkinter GUI                           
       Application\n Development Blueprints", icon='question')

然后,按照以下方式将这些功能附加到相应的菜单项上:

about_menu.add_command(label='About', command=display_about_messagebox)
about_menu.add_command(label='Help', command=display_help_messagebox)

接下来,我们将添加退出确认功能。理想情况下,我们应该在文本内容被修改的情况下实现文件保存,但为了简化,我没有在这里加入那个逻辑,而是显示一个提示,让用户决定程序应该关闭还是保持打开。因此,当用户点击文件 | 退出时,它会弹出一个确定-取消对话框来确认退出操作:

def exit_editor(event=None):
       if tkinter.messagebox.askokcancel("Quit?", "Really quit?"):
            root.destroy() 

然后,我们覆盖了关闭按钮,并将其重定向到我们之前定义的exit_editor函数,如下所示:

root.protocol('WM_DELETE_WINDOW', exit_editor)

然后,我们为所有单个菜单项添加一个回调命令,如下所示:

file_menu.add_command(label='Exit', accelerator='Alt+F4', command= exit_editor)
about_menu.add_command(label='About', command = display_about_messagebox)
about_menu.add_command(label='Help', command = display_help_messagebox)

最后,添加用于显示帮助的快捷键绑定:

content_text.bind('<KeyPress-F1>', display_help_messagebox) 

这完成了迭代。

图标工具栏和查看菜单功能

在这次迭代中,我们将向文本编辑器添加以下功能:

  • 在工具栏上显示快捷图标

  • 显示行号

  • 高亮当前行

  • 更改编辑器的颜色主题

让我们先从一项简单的任务开始。在这个步骤中,我们将向工具栏添加快捷图标,如下面的截图所示:

图片

你可能还记得我们已经创建了一个框架来存放这些图标。现在让我们添加这些图标吧。

在添加这些图标时,我们遵循了一个约定。图标的命名与处理它们相应的功能完全一致。遵循这一约定使我们能够遍历一个列表,同时将图标图像应用到每个按钮上,并在循环内部添加命令回调。所有图标都已放置在图标文件夹中。

以下代码为工具栏添加图标(2.10.py):

icons = ('new_file', 'open_file', 'save', 'cut', 'copy', 'paste', 'undo', 'redo', 'find_text')
for i, icon in enumerate(icons):
   tool_bar_icon = PhotoImage(file='icons/{}.gif'.format(icon))
   cmd = eval(icon)
   tool_bar = Button(shortcut_bar, image=tool_bar_icon, command=cmd)
   tool_bar.image = tool_bar_icon
   tool_bar.pack(side='left')

以下是对前面代码的描述:

  • 我们已经在第一次迭代中创建了一个快捷栏。现在,我们将在框架中简单地添加带有图像的按钮。

  • 我们创建一个图标列表,并确保它们的名称与图标名称完全一致。

  • 我们随后通过创建一个按钮小部件,将图片添加到按钮中,并添加相应的回调命令来遍历列表。

  • 在添加回调命令之前,我们必须使用eval命令将字符串转换为等效的表达式。如果我们不使用eval,则不能将其作为表达式应用于回调命令。

因此,我们在快捷栏中添加了快捷图标。现在,如果您运行代码(参考代码包中的2.10.py),它应该会显示所有快捷图标。此外,因为我们已经将每个按钮链接到其回调函数,所以所有这些快捷图标都应该可以正常工作。

显示行号

让我们努力实现显示文本小部件左侧的行号。这需要我们在代码的多个地方进行微调。因此,在我们开始编码之前,让我们看看我们试图实现的目标。

“查看”菜单中有一个菜单项允许用户选择是否显示行号。我们只想在选中该选项时显示行号,如下面的截图所示:

图片

如果选择了该选项,我们需要在之前创建的左侧框架中显示行号。

行号应在用户每次输入新行、删除行、剪切或粘贴行文本、执行撤销或重做操作、打开现有文件或点击“新建”菜单项时更新。简而言之,每次活动导致内容发生变化后,行号都应该更新。因此,我们需要定义一个名为 on_content_changed() 的函数。这个函数应该在定义每个 presscutpasteundoredonewopen 键之后调用,以检查文本区域中是否添加或删除了行,并相应地更新行号。

我们通过以下两种策略实现这一点(参考代码包中的2.10.py):

def on_content_changed(event=None):
     update_line_numbers() 

将按键事件绑定到update_line_number()函数,如下所示:

content_text.bind('<Any-KeyPress>', on_content_changed) 

接下来,在每个cutpasteundoredonewopen的定义中添加对on_content_changed()函数的调用。

然后定义一个get_line_numbers()函数,该函数返回一个包含所有行号(直到最后一行)的字符串,行号之间用换行符分隔。

例如,如果内容小部件中的最后一行非空是5,这个函数就会返回一个1 /n 2 /n 3 /n 4/n 5 /n格式的字符串。

以下为函数定义:

def get_line_numbers():
  output = ''
  if show_line_number.get():
    row, col = content_text.index("end").split('.')
    for i in range(1, int(row)):
      output += str(i)+ '\n'
  return output 

现在,让我们定义update_line_numbers()函数,该函数简单地使用前一个函数的字符串输出更新显示行的文本小部件:

def update_line_numbers(event = None):
   line_numbers = get_line_numbers()
   line_number_bar.config(state='normal')
   line_number_bar.delete('1.0', 'end')
   line_number_bar.insert('1.0', line_numbers)
   line_number_bar.config(state='disabled')

以下是对前面代码的描述:

  • 你可能记得我们之前将一个show_line_number变量分配给了菜单项:

    show_line_number = IntVar()

    show_line_number.set(1)

    view_menu.add_checkbutton(label="显示行号", variable=show_line_number)

  • 如果将show_line_number选项设置为1(即,在菜单项中已勾选),我们将计算文本中的最后一行和最后一列。

  • 我们随后创建一个由数字1至最后一行的行数组成的文本字符串,每个数字之间用换行符(\n)分隔。然后使用line_number_bar.config()方法将这个字符串添加到左侧标签中。

  • 如果菜单中未勾选“显示行号”,变量文本将保持空白,因此不会显示行号。

  • 最后,我们将之前定义的每个cutpasteundoredonewopen函数更新为在其末尾调用on_content_changed()函数。

我们已经完成了在文本编辑器中添加行号功能的工作。然而,我想补充一点,尽管这个实现很简单,但在处理自动换行和字体大小变化方面存在一些局限性。一个万无一失的行号解决方案需要使用 Canvas 小部件——这是我们将在第四章“棋盘游戏”及其之后讨论的内容。同时,如果你对此感兴趣,可以查看一个基于 Canvas 的示例实现,链接为stackoverflow.com/a/16375233/2348704

最后,在这个迭代中,我们将实现一个功能,用户可以选择高亮显示当前行(2.10.py)。

这个想法很简单。我们需要定位光标所在的行,并给该行添加一个标签。我们还需要配置这个标签,使其以不同颜色的背景显示,以便突出显示。

你可能还记得,我们已经为用户提供了一个菜单选项来决定是否突出显示当前行。现在,我们将从这个菜单项添加一个回调命令到我们将定义的toggle_highlight函数:

to_highlight_line = BooleanVar()
view_menu.add_checkbutton(label='Highlight Current Line', onvalue=1, offvalue=0,     variable=to_highlight_line, command=toggle_highlight)

现在,我们定义了三个函数来帮我们处理这个问题:

def highlight_line(interval=100):
   content_text.tag_remove("active_line", 1.0, "end")
   content_text.tag_add("active_line", 
                     "insert linestart", "insert lineend+1c")                                                                               
   content_text.after(interval, toggle_highlight)

def undo_highlight():
   content_text.tag_remove("active_line", 1.0, "end")

def toggle_highlight(event=None):
   if to_highlight_line.get():
      highlight_line()
    else:
      undo_highlight()

以下是对前面代码的描述:

  • 每次用户勾选/取消勾选“视图 | 高亮当前行”,都会调用toggle_highlight函数。此函数检查菜单项是否被勾选。如果被勾选,它将调用highlight_line函数。否则,如果菜单项未被勾选,它将调用undo_highlight函数。

  • highlight_line 函数简单地将一个名为 active_line 的标签添加到当前行,并且每隔 100 毫秒调用一次 toggle_highlight 函数来检查当前行是否仍然需要高亮显示。

  • 当用户在视图菜单中取消勾选高亮时,会调用undo_highlight函数。一旦调用,它将简单地从整个文本区域中移除active_line标签。

最后,我们可以配置名为 active_line 的标签,使其以不同的背景色显示,如下所示:

content_text.tag_configure('active_line', background='ivory2')

我们在代码中使用了.widget.after(ms, callback)处理器。

允许我们执行某些周期性操作的方法被称为闹钟处理器。以下是一些常用的 Tkinter 闹钟处理器:

after(delay_ms, callback, args...): 这将注册一个回调闹钟,可以在给定数量的毫秒数后调用。

after_cancel(id): 这将取消指定的回调闹钟。

添加光标信息栏

光标信息栏简单地说是位于文本组件右下角的一个小标签,用于显示光标当前的位置,如下面的截图所示:

图片

用户可以选择从查看菜单中显示/隐藏此信息栏(2.11.py)。

首先在文本小部件内创建一个标签小部件,并将其打包到右下角,具体操作如下:

cursor_info_bar = Label(content_text, text='Line: 1 | Column: 1')
cursor_info_bar.pack(expand=NO, fill=None, side=RIGHT, anchor='se')

在许多方面,这类似于显示行号。在这里,同样需要在每次按键后、在诸如剪切粘贴撤销重做新建打开等事件之后,或者导致光标位置改变的活动之后计算位置。因为这也需要更新所有更改的内容,对于每一次按键,我们将更新on_content_changed来更新这一点,如下所示:

def on_content_changed(event=None):
 update_line_numbers()
 update_cursor_info_bar()

def show_cursor_info_bar():
 show_cursor_info_checked = show_cursor_info.get()
 if show_cursor_info_checked:
   cursor_info_bar.pack(expand='no', fill=None, side='right', anchor='se')
 else:
   cursor_info_bar.pack_forget()

def update_cursor_info_bar(event=None):
 row, col = content_text.index(INSERT).split('.')
 line_num, col_num = str(int(row)), str(int(col)+1) # col starts at 0
 infotext = "Line: {0} | Column: {1}".format(line_num, col_num)
 cursor_info_bar.config(text=infotext)

代码很简单。我们通过使用index(INSERT)方法获取当前光标位置的行和列,并用光标最新的行和列更新标签。

最后,通过使用回调命令将函数连接到现有的菜单项:

view_menu.add_checkbutton(label='Show Cursor Location at Bottom',
                 variable=show_cursor_info, command=show_cursor_info_bar)

添加主题

你可能记得,在定义主题菜单时,我们定义了一个包含名称和十六进制颜色代码作为键值对的配色方案字典,如下所示:

color_schemes = {
'Default': '#000000.#FFFFFF',
'Greygarious':'#83406A.#D1D4D1',
'Aquamarine': '#5B8340.#D1E7E0',
'Bold Beige': '#4B4620.#FFF0E1',
'Cobalt Blue':'#ffffBB.#3333aa',
'Olive Green': '#D1E7E0.#5B8340',
'Night Mode': '#FFFFFF.#000000',
}

主题选择菜单已经定义好了。让我们添加一个回调命令来处理选中的菜单(2.12.py):

themes_menu.add_radiobutton(label=k, variable=theme_choice, command=change_theme)

最后,让我们定义一个change_theme函数来处理主题的切换,具体如下:

def change_theme(event=None):
   selected_theme = theme_choice.get()
   fg_bg_colors = color_schemes.get(selected_theme)
   foreground_color, background_color = fg_bg_colors.split('.')
   content_text.config(background=background_color, fg=foreground_color)

函数很简单。它从定义的颜色方案字典中提取键值对。它将颜色分成其两个组成部分,并使用widget.config()将每种颜色分别应用于文本小部件的前景和背景。

现在,如果您从主题菜单中选择不同的颜色,背景和前景颜色将相应地改变。

这完成了迭代。我们在这个迭代中完成了快捷图标工具栏和视图菜单功能的编码。在这个过程中,我们学习了如何处理CheckbuttonRadiobutton菜单项。我们还查看了一下如何创建复合按钮,同时强化了之前章节中提到的几个 Tkinter 选项。

创建上下文/弹出式菜单

让我们在这次最后的迭代中通过添加到编辑器的上下文菜单来完善编辑器 (2.12.py),如图下截图所示:

图片

在光标位置右键点击弹出的菜单被称为上下文菜单弹出菜单

让我们在文本编辑器中编写这个功能。首先,定义上下文菜单,如下所示:

popup_menu = Menu(content_text)
for i in ('cut', 'copy', 'paste', 'undo', 'redo'):
      cmd = eval(i)
     popup_menu.add_command(label=i, compound='left', command=cmd)
     popup_menu.add_separator()
popup_menu.add_command(label='Select All',underline=7, command=select_all)

然后,将鼠标的右键与名为show_popup_menu的回调函数绑定,具体操作如下:

content_text.bind('<Button-3>', show_popup_menu)

最后,按照以下方式定义show_popup_menu函数:

def show_popup_menu(event):
  popup_menu.tk_popup(event.x_root, event.y_root)

您现在可以在编辑器中的文本小部件的任何位置右键单击以打开上下文菜单。

这标志着迭代以及章节的结束。

摘要

在本章中,我们涵盖了以下要点:

  • 我们在十二次迭代中完成了编辑器的编码。我们首先将所有小部件放置在顶层窗口中。

  • 我们随后利用文本小部件的一些内置功能来编写一些功能。

  • 我们学习了索引和标签的重要概念。

  • 我们还学习了如何使用filedialogmessagebox模块来快速编写程序中的一些常见功能。

恭喜!你已经完成了文本编辑器的编码。在下一章中,我们将制作一个可编程的鼓机。

QA 部分

这里有一些问题供您思考:

  • 检查按钮菜单项和单选按钮菜单项之间有什么区别?

  • 缪斯菜单按钮是用来做什么的?

  • 识别不同类型的顶层窗口。

  • 列出 Tkinter 中可用的不同类型的 filedialogs 和消息框。

  • 我们使用了包装几何管理器来构建这个文本编辑器。我们能否使用网格几何管理器来构建?网格几何管理器与包装几何管理器相比会表现如何?

  • 我们如何在 Tkinter 中触发事件而不需要外部刺激?

  • 菜单项中的加速器选项是什么?

  • 什么是瞬态窗口?

进一步阅读

filedialog模块的源代码可以在 Tkinter 源代码中找到一个名为filedialog.py的单独文件中。我们鼓励您查看其实现方式。

如果你喜欢冒险并且想要进一步探索文本编辑器程序,我鼓励你查看 Python 内置编辑器 IDLE 的源代码,它使用 Tkinter 编写。IDLE 的源代码可以在你的本地 Python 库目录中的idlelib文件夹中找到。在 Linux Mint 上,它位于/usr/lib/python3.4/idlelib

阅读官方的 Python 风格指南,该指南在 PEP8 文档中指定,网址为 www.python.org/dev/peps/pep-0008.

如果您喜欢,可以尝试在文本编辑器中实现 Python 代码的语法高亮。一个简单的实现首先需要定义一个关键字列表。然后我们可以将 <KeyRelease> 事件绑定到检查输入的单词是否是关键字之一。接着,我们可以使用 tag_add 为单词添加一个自定义标签。最后,我们可以通过使用如 textarea.tag_config("the_keyword_tag", foreground="blue") 这样的代码来改变其颜色。

一个稍微高级一些的想法,即懒加载,值得阅读并实施。这在你想在文本编辑器中打开一个非常大的文件时尤其有用。在当前实现中,打开一个非常大的文件可能需要非常长的时间。相比之下,懒加载只会读取文本编辑器中当前可见的文件部分,从而使程序响应更快。

第三章:可编程鼓机

我们在第二章“制作文本编辑器”中探讨了几个常见的 Tkinter 小部件,例如菜单、按钮、标签和文本。现在,让我们扩展我们对 Tkinter 的经验,来制作一些音乐。让我们使用 Tkinter 和一些其他 Python 模块构建一个跨平台的鼓机。

本章的一些关键目标包括:

  • 学习以面向对象的风格结构 Tkinter 程序

  • 深入了解几个更多的 Tkinter 小部件,例如 Spinbox、Button、Entry 和 Checkbutton

  • 在实际项目中应用网格几何管理器

  • 理解选择合适的数据结构对我们程序的重要性

  • 学习将高阶回调函数绑定到小部件

  • 学习如何结合一些标准和第三方模块使用 Tkinter

  • 理解多线程的必要性以及如何编写多线程应用程序

  • 学习关于对象序列化pickle

  • 学习 ttk 小部件

入门

我们的目标是构建一个可编程的鼓机。让我们称它为爆炸鼓机

鼓机允许用户使用无限数量的鼓样本创建无限数量的节奏模式。然后您可以在项目中存储多个 riff,并在稍后回放或编辑该项目。在最终形态下,鼓机将看起来像以下截图:

图片

要创建自己的鼓点节奏模式,只需使用左侧的按钮加载一些鼓样本(可以是任何具有.wav.ogg扩展名的音频文件)。您可以通过点击右侧的按钮来设计您的鼓点节奏。

您可以决定每个单位节拍数BPU)。大多数西方节拍有 4 BPU,华尔兹有 3 BPU,而我在这台机器上创作的某些印度和阿拉伯节奏则有 3-16 BPU!您还可以更改每分钟节拍数(BPM),这反过来又决定了节奏的速度。

如前一张截图所示,单个模式构成一个单独的节拍模式。您可以通过更改顶部左侧的“模式编号”微调部件来设计多个节拍模式。

一旦你制作了一些节奏模式,你甚至可以保存这个模式,稍后重新播放或修改它。保存和重新加载文件的操作都通过顶部的文件菜单完成。

Loops子目录中提供了一些鼓样本;然而,您可以加载任何其他鼓样本。您可以从互联网上免费下载大量样本。

技术要求

我们将在本章中使用一些来自标准 Python 分发的更多内置库。这包括tkinterosmaththreadingpickle模块。

为了验证这些模块是否存在,只需在您的 Python3 IDLE 交互式提示符中运行以下语句:

 >>> import tkinter, os, math, time, threading, pickle 

这不应该导致错误,因为 Python3 将这些模块内置到了发行版中。

除了这个,你还需要添加一个名为 pygame 的额外 Python 模块。我们将使用名为 1.9.3 的软件包版本,可以在 www.pygame.org/download.shtml 下载。

Linux 用户可能还需要查看以下页面以获取将pygame与 Python 3.x 一起工作的说明:www.pygame.org/wiki/CompileUbuntu?parent=Compilation.

pygame 是一个跨平台包,通常用于使用 Python 制作游戏。然而,我们只会使用该包中的一个名为 pygame.mixer 的小模块,该模块用于加载和播放声音。该模块的 API 文档可以在 www.pygame.org/docs/ref/mixer.html 找到。

在您安装了该模块之后,您可以通过导入它来验证它:

>>> import pygame
>>> pygame.version.ver 

如果没有错误报告,并且版本输出为 1.9.3,你就可以开始编程鼓机了。让我们开始吧!

在面向对象编程(OOP)中设置 GUI

在上一章中我们开发的文本编辑器是使用过程式代码实现的。尽管它为快速编码提供了一些好处,但它也有一些典型的局限性:

  • 我们开始遇到全局变量

  • 需要在调用它们的代码上方定义所需的功能定义

  • 最重要的是,代码不可重用

因此,我们需要某种方法来确保我们的代码是可重用的。这就是为什么程序员更倾向于使用面向对象编程OOP)来组织他们的代码成类。

面向对象编程(OOP)是一种编程范式,它将焦点转移到我们想要操作的对象上,而不是操作它们的逻辑。这与过程式编程形成对比,后者将程序视为一个逻辑过程,它接受输入,处理它,并产生一些输出。

面向对象编程提供了多个好处,例如数据抽象封装继承多态。此外,面向对象编程为程序提供了一个清晰的模块结构。代码修改和维护变得容易,因为可以创建新的对象而不需要修改现有的对象。

让我们使用面向对象编程(OOP)来构建我们的鼓程序,以此展示一些这些特性。我们的鼓程序的一个指示性 OOP 结构可能如下所示(代码3.01.py):

from tkinter import Tk

PROGRAM_NAME = ' Explosion Drum Machine '

class DrumMachine:

    def __init__(self, root):
        self.root = root
        self.root.title(PROGRAM_NAME)

if __name__ == '__main__':
    root = Tk()
    DrumMachine(root)
    root.mainloop()

代码的描述如下:

  • 我们创建了一个名为 DrumMachine 的类结构,并将其作为参数传递给 Toplevel 窗口进行初始化

  • 如果脚本作为独立程序运行,即if __name__ == '__main__',则会创建一个新的Tk()根对象,并将根窗口作为参数传递给DrumMachine对象

  • 我们随后从DrumMachine类中初始化一个对象以获取一个顶层窗口

现在我们已经准备好了顶层窗口,让我们停止添加任何更多的视觉元素,并思考一些对我们程序最终效果至关重要的因素。让我们花些时间确定我们程序的数据结构。

确定数据结构

如 Linux 的开发者林纳斯·托瓦兹(Linus Torvalds)曾说过:

"糟糕的程序员担心代码。优秀的程序员担心数据结构和它们之间的关系。"

他所说的意思是,设计良好的数据结构使得代码非常容易设计、维护和扩展。相比之下,如果你从一个糟糕的数据结构开始,即使代码再好也无法弥补这一点。

从一个良好的数据结构开始,你的代码自然会变得更加简单、优雅且易于维护。

有了这个想法,让我们尝试决定为我们程序选择一个合适的数据结构。回顾一下之前的截图(在入门部分)。你认为需要哪种数据结构来捕捉所有必要的信息字段?

好的,首先我们的鼓机需要保存有关节拍模式的资料。所以,让我们先创建一个名为 all_patterns = [] 的列表。

现在,列表中的每个模式都需要捕获与该模式相关的鼓文件信息:模式中的单元数量、模式的 BPU、BPM 以及形成模式的按钮点击。

因此,我们需要设计一种数据结构,其中all_patterns是一个列表,其中每个项目代表一个单独的模式。每个模式随后由一个字典表示,如下所示:

{
 'list_of_drum_files': a list of location of audio drum files,
 'number_of_units': an integer, 'bpu': an integer,
 'beats_per_minute' : an integer,'button_clicked_list' : a 2
 dimensional list of boolean values where True means button is
 clicked and false means button is not clicked in the pattern
 }

对于我们的鼓机,熟悉前面的数据结构定义非常重要。请注意,仅凭这些数据,我们就可以定义逻辑来显示最终鼓机中显示的所有内容。

还要注意,这个数据结构不包含任何 GUI 元素的信息,例如小部件信息或小部件状态。在尽可能的情况下,我们应该始终努力将后端(程序逻辑)的数据与前端(用户界面)相关的数据干净地分开。我们这里的数据结构仅仅代表后端,但足够让我们安排出确定我们前端逻辑的布局。

前面的数据结构是我认为对现有数据的一个良好表示。可能存在一个同样有效但完全不同的数据表示。关于数据表示的问题没有唯一的正确答案。然而,围绕语言内置集合构建表示使我们能够使用高度优化的代码,这通常是一个好主意。数据结构的选择直接影响到应用程序的性能——有时是微不足道的,但在其他时候则非常严重。

我们相应地修改了我们的代码(见代码3.02.py)以初始化此数据结构:

    def init_all_patterns(self):
        self.all_patterns = [
            {
                'list_of_drum_files': [None] * MAX_NUMBER_OF_DRUM_SAMPLES,
                'number_of_units': INITIAL_NUMBER_OF_UNITS,
                'bpu': INITIAL_BPU,
                'is_button_clicked_list':
                self.init_is_button_clicked_list(
                    MAX_NUMBER_OF_DRUM_SAMPLES,
                    INITIAL_NUMBER_OF_UNITS * INITIAL_BPU
                )
            }
            for k in range(MAX_NUMBER_OF_PATTERNS)]

我们还将 is_button_clicked_list 初始化为所有值都设置为 False,如下所示:

    def init_is_button_clicked_list(self, num_of_rows, num_of_columns):
        return [[False] * num_of_columns for x in range(num_of_rows)]

为了支持这个结构,我们定义了一些常量(参见代码3.02.py):

MAX_NUMBER_OF_PATTERNS = 10
MAX_NUMBER_OF_DRUM_SAMPLES = 5
INITIAL_NUMBER_OF_UNITS = 4
INITIAL_BPU = 4
INITIAL_BEATS_PER_MINUTE = 240

现在,如果你运行这个程序,你将只看到一个根窗口——与之前的代码没有任何不同。但我们的代码在内部正在为构建逻辑所需的所有数据保留内存。我们已经为程序的运行打下了坚实的基础。信不信由你,我们已经完成了一半的工作。

创建更广泛的视觉元素

接下来,让我们梳理我们程序更广泛的视觉元素。为了模块化,我们将程序划分为四个广泛的视觉部分,如下所示图解:

图片

让我们定义一个名为 init_gui() 的方法,该方法在 __init__ 方法内部被调用,如下所示(参见代码 3.03.py):

def init_gui(self):
   self.create_top_bar()
   self.create_left_drum_loader()
   self.create_right_button_matrix()
   self.create_play_bar()

我们接下来定义这四种方法(3.03.py)。由于我们在前面的章节中已经进行过类似的编码,所以这里不讨论代码。

我们从顶部栏部分开始。顶部栏很简单。它包含几个标签,三个旋转框(Spinboxes),以及一个输入框(Entry widget)。我们在这里不会展示完整的代码(见代码3.03.py),因为我们已经在之前的章节中多次看到了创建标签和输入框的示例。对于Spinbox,选项的指定如下:

Spinbox(frame, from_=1, to=MAX_BPU, width=5,command=self.on_bpu_changed).grid(row=0, column=7)

我们相应地设置了类级别属性:

 self.beats_per_minute = INITIAL_BEATS_PER_MINUTE
 self.current_pattern_index = 0

由于我们将允许设计多个模式,我们需要跟踪当前显示或激活的模式。self.current_pattern_index 属性用于跟踪当前激活的模式。

接下来,让我们编写create_left_drum_loader()方法。这同样非常直观。我们创建一个循环(参见code 3.03.py):

for i in range (MAX_NUMBER_OF_DRUM_SAMPLES):
    # create compound button here
    # create entry widgets here and keep reference to each entry widget in
    #a list for future update of values

在我们继续编写 create_right_button_matrix() 方法之前,让我们先完成 create_play_bar() 方法的编码,因为它比另一个简单。它只包含两个按钮、一个复选框、一个微调框和一个图像。我们在书中之前已经编码过类似的控件,所以我会留给你自己探索(参见代码 3.03.py)。

接下来,让我们编写create_right_button_matrix()方法。这是所有方法中最复杂的。

正确按钮矩阵由行和列组成的二维数组。矩阵中的行数等于常数MAX_NUMBER_OF_DRUM_SAMPLES,列数代表每个周期内的节拍单位数量,通过将单位数量和每个单位节拍数相乘得到。

创建按钮矩阵的代码看起来是这样的(参见代码3.03.py):

self.buttons = [[None for x in range(self.find_number_of_columns())] for x in range(MAX_NUMBER_OF_DRUM_SAMPLES)]
for row in range(MAX_NUMBER_OF_DRUM_SAMPLES):
    for col in range(self.find_number_of_columns()):
        self.buttons[row][col] = Button(right_frame,
                 command=self.on_button_clicked(row, col))
        self.buttons[row][col].grid(row=row, column=col)
        self.display_button_color(row, col)

find_number_of_columns() 方法的相关代码如下:

    def find_number_of_columns(self):
        return int(self.number_of_units_widget.get()) * 
          int(self.bpu_widget.get())

我们已经创建了按钮矩阵,但希望按钮以两种交替的色调着色。因此,我们定义了两个常量:

COLOR_1 = 'grey55'
COLOR_2 = 'khaki'

这可以是任何十六进制颜色代码,也可以是 Tkinter 预定义颜色列表中的任何颜色。我们还需要一个第三种颜色来表示按钮的按下状态。

常量 BUTTON_CLICKED_COLOR = 'green' 负责处理这一点。

我们随后定义两种方法:

def display_button_color(self, row, col):
  original_color = COLOR_1 if ((col//self.bpu)%2) else COLOR_2
  button_color = BUTTON_CLICKED_COLOR if
         self.get_button_value(row, col) else original_color
  self.buttons[row][col].config(background=button_color)

def display_all_button_colors(self):
  number_of_columns = self.find_number_of_columns()
  for r in range(MAX_NUMBER_OF_DRUM_SAMPLES):
    for c in range(number_of_columns):
      self.display_button_color(r, c)

这个想法很简单。如果一个按钮在我们数据结构中被发现其值为True,那么这个按钮应该被涂成绿色;否则,每个交替的节拍单位应该用COLOR_1COLOR_2的图案来着色。

这种交替颜色是通过使用这个数学公式获得的:

original_color = COLOR_1 if (col//bpu)%2) else COLOR_2

记住我们在原始数据结构中创建了一个名为 is_button_clicked_list 的二维布尔列表,作为字典项来存储这个值。

如果发现该值是True,我们将按钮的颜色更改为BUTTON_CLICKED_COLOR。相应地,我们定义一个getter方法来获取按钮的值:

def get_button_value(self, row, col):
  return 
    self.all_patterns[self.current_pattern.get()]  
      ['is_button_clicked_list'][row][col]

现在每一个按钮都连接到名为 on_button_clicked 的命令回调,其代码如下(参见代码 3.03.py):

def on_button_clicked(self, row, col):
  def event_handler():
    self.process_button_clicked(row, col)
  return event_handler

注意到这段代码的巧妙之处了吗?这种方法在函数内部定义了一个函数。它并不像典型的函数那样返回一个值。相反,它返回一个可以在稍后阶段执行的函数。这些被称为高阶函数,或者更精确地说,函数闭包

我们为什么需要这样做呢?我们必须这样做是因为每个按钮都通过其独特的行和列索引来识别。行值和列值只有在创建按钮时循环运行时才可用。在那之后,rowcol变量就会丢失。因此,如果我们需要稍后识别哪个按钮被点击,我们就需要某种方法来保持这些变量活跃。

这些回调函数在需要时伸出援手,因为它们在创建时将行和列的值封装在它们返回的函数中。

函数在 Python 中是一等对象。这意味着你可以将一个函数作为参数传递给另一个函数,你也可以从一个函数中返回一个函数。简而言之,你可以将函数当作任何其他对象来对待。

你可以通过在方法内部嵌套方法的方式将一个方法对象绑定到特定的上下文中,就像我们在之前的代码中所做的那样。这类高阶函数是 GUI 编程中将函数与控件关联的常见方式。

你可以在en.wikipedia.org/wiki/Closure_(computer_programming)找到更多关于函数闭包的信息。

我们随后定义了一个名为 process_button_clicked 的方法:

def process_button_clicked(self, row, col):
   self.set_button_value(row, col, not self.get_button_value(row, col))
   self.display_button_color(row, col)

def set_button_value(self, row, col, bool_value):
   self.all_patterns[self.current_pattern.get()][
           'is_button_clicked_list'][row][col] = bool_value

代码中的关键部分是使用not运算符将按钮值设置为与其当前值相反的行。一旦值被切换,该方法将调用display_button_color方法来重新着色按钮。

最后,让我们通过定义一些临时方法来完成这个迭代,并将它们作为命令回调附加到相应的部件上:

on_pattern_changed()
on_number_of_units_changed()
on_bpu_changed()
on_open_file_button_clicked()
on_button_clicked()
on_play_button_clicked()
on_stop_button_clicked()
on_loop_button_toggled()
on_beats_per_minute_changed()

这就完成了迭代。现在如果你运行程序(见代码3.03.py),它应该会显示所有主要的视觉元素:

图片

按钮矩阵应涂成两种交替的色调,并且按下按钮应在其绿色和之前颜色之间切换。

所有其他小部件在此阶段仍然无法使用,因为我们已经将它们附加到了非功能性的命令回调。我们很快将使它们变得可用,但在我们这样做之前,让我们做一些事情来使我们的未来编码变得简单、整洁和优雅。

定义获取器和设置器方法

在我们之前的章节中,我们需要知道在按钮矩阵中给定行和列的按钮值对于给定模式。如果该值为True,我们就将按钮涂成绿色。如果该值为False,我们就用另一种颜色涂色。

我们可以通过调用这一行代码来获取按钮的值:

self.all_patterns[self.current_pattern.get()]['is_button_clicked_list'][row][col]

注意到这一行有四组方括号[]。由于这种嵌套上标功能很快就会变得难看,我们将这个逻辑封装在一个名为get_button_value(row, col)的方法中。现在,每当我们需要获取一个按钮的值时,我们只需用正确的参数调用这个方法即可。

现在我们的代码将不会充斥着那些难看的嵌套上标。每当我们需要获取按钮的值时,我们可以调用get_button_value(row, col)方法,这个方法有一个很好的指示性名称来描述其功能。这难道不是比其相对丑陋的对应物更易于阅读和理解吗?

一件事是肯定的:从现在开始,我们构建的所有逻辑都将严重依赖于我们从数据结构中获取或设置的数据。鉴于我们将在程序中一直需要所有这些数据,让我们提前编写它的gettersetter方法。这无疑会让我们的生活变得更加容易。

本迭代部分的目标很简单——为所有我们决定存储在我们数据结构中的数据定义gettersetter方法。

代码如下(见code 3.04.py):

def get_current_pattern_dict(self):
  return self.all_patterns[self.current_pattern_index]

def get_bpu(self):
  return self.get_current_pattern_dict()['bpu']

def set_bpu(self):
  self.get_current_pattern_dict()['bpu'] = int(self.bpu_widget.get())

def get_number_of_units(self):
  return self.get_current_pattern_dict()['number_of_units']

def set_number_of_units(self):
  self.get_current_pattern_dict()['number_of_units']
          = int(self.number_of_units_widget.get())

def get_list_of_drum_files(self):
  return self.get_current_pattern_dict()['list_of_drum_files']

def get_drum_file_path(self, drum_index):
  return self.get_list_of_drum_files()[drum_index]

def set_drum_file_path(self, drum_index, file_path):
  self.get_list_of_drum_files()[drum_index] = file_path 

def get_is_button_clicked_list(self):
  return self.get_current_pattern_dict()['is_button_clicked_list']

def set_is_button_clicked_list(self, num_of_rows, num_of_columns):
  self.get_current_pattern_dict()['is_button_clicked_list']
        = [[False] * num_of_columns for x in range(num_of_rows)]

这就是编写gettersetter方法的全部内容。如果你已经理解了底层的数据结构,代码应该是自解释的,因为我们在这里所做的只是获取或为数据结构中的各种项目设置值。

现在我们有了这些方法,让我们来完成之前未编码的小部件功能。

单位数量和每单位节拍数

我们之前编写了名为 create_right_button_matrix 的矩阵代码,该代码创建一个二维矩阵,其行数等于 MAX_NUMBER_OF_DRUM_SAMPLES。列数将由用户选择的每个单元的节拍数乘以单元数来决定。其公式可以表示如下:

按钮列数 = 单元数 x BPU

图片

这意味着每次用户更改单位数量或每单位节拍数时,按钮矩阵应该重新绘制以更改列数。这种变化也应该反映在我们的底层数据结构中。让我们将这个功能添加到我们的鼓机中。

我们之前定义了两个虚拟方法——on_number_of_units_changed()on_bpu_changed()。我们现在对它们进行如下修改(见 code 3.04.py):

def on_number_of_units_changed(self):
  self.set_number_of_units()
  self.set_is_button_clicked_list(MAX_NUMBER_OF_DRUM_SAMPLES,
                    self.find_number_of_columns())
  self.create_right_button_matrix()

def on_bpu_changed(self):
  self.set_bpu()
  self.set_is_button_clicked_list(MAX_NUMBER_OF_DRUM_SAMPLES,
                    self.find_number_of_columns())
  self.create_right_button_matrix()

前述方法做两件事:

  • 修改数据结构以反映 BPU 或单元数量的变化

  • 调用 create_right_button_matrix() 方法来重新创建按钮矩阵

现在,如果你去运行代码(见代码3.04.py)并更改单元数量或 BPU 的值,按钮矩阵应该会重新绘制自己以反映这些更改。

加载鼓样本

我们的主要目标是按照用户决定的节奏模式顺序播放声音文件。为了实现这一点,我们需要将声音文件添加到鼓机中。

我们的项目中没有预加载的鼓点文件。相反,我们希望让用户从众多鼓点文件中选择。

因此,除了普通的鼓,你还可以演奏日本太鼓、印度塔布拉鼓、拉丁美洲邦戈鼓,或者几乎任何你想添加到你的节奏中的其他声音。你只需要一个包含该声音样本的小型 .wav.ogg 文件即可。

鼓样本需要加载到左侧栏中,如下截图所示:

图片

让我们的程序具备添加鼓样本的能力。

我们已经在鼓垫的左侧创建了带有文件夹图标的按钮。现在我们需要使其具有功能。所需的功能很简单。当用户点击任何左侧按钮时,应该打开一个文件对话框,让用户选择.wav.ogg文件。当用户选择文件并点击“打开”时,该按钮旁边的 Entry 小部件应该填充文件的名称。

此外,应该将鼓样本文件的位置添加到我们的数据结构中适当的位置。

首先,我们将导入所需的模块。

我们将使用filedialog模块来让用户选择鼓点文件。我们已经在第二章,制作文本编辑器中使用了文件对话框模块。这里的功能非常相似。我们还将需要使用os模块来提取给定声音样本的文件名。让我们先导入这两个模块(见code 3.05.py):

import os
from tkinter import filedialog

我们为上传鼓文件创建的按钮通过命令回调连接到on_open_file_button_clicked方法。我们之前已经通过那个名字创建了一个虚拟方法。现在我们修改那个方法以添加所需的功能(见code 3.05.py):

def on_open_file_button_clicked(self, drum_index):
   def event_handler():
     file_path = filedialog.askopenfilename
        (defaultextension=".wav", filetypes=[("Wave Files",
       "*.wav"), ("OGG Files", "*.ogg")])
     if not file_path:
        return
     self.set_drum_file_path(drum_index, file_path)
     self.display_all_drum_file_names()
     return event_handler

前述方法再次返回一个函数,因为我们需要追踪从所有鼓文件行中实际选择了哪个鼓文件。

上述代码做了三件事:

  • 使用 Tkinter 的filedialog请求用户输入文件路径

  • 修改底层数据结构以保存提供的文件路径

  • 调用另一个方法以在相邻的 Entry 小部件中显示文件名

接下来的两种方法负责在前端显示所有鼓的名称(参见代码 3.05.py):

def display_all_drum_file_names(self):
   for i, drum_name in enumerate(self.get_list_of_drum_files()):
       self.display_drum_name(i, drum_name)

def display_drum_name(self, text_widget_num, file_path):
   if file_path is None: return
   drum_name = os.path.basename(file_path)
   self.drum_load_entry_widget [text_widget_num].delete(0, END)
   self.drum_load_entry_widget[text_widget_num].insert(0, drum_name)

上述方法使用os.path.basename函数,该函数位于os模块中,从文件路径中获取文件名。

这部分内容已经完成。我们的代码现在能够加载鼓样本,并将所有文件路径的记录存储在数据结构中。请继续尝试加载一些鼓样本(参见代码3.05.py),程序应该会在相邻的 Entry 小部件中显示鼓文件名。

演奏鼓机

现在我们已经有了加载鼓样本的机制和定义节拍模式的机制,接下来让我们添加播放这些节拍模式的能力。在许多方面,这构成了我们程序的核心。

让我们先了解我们在这里想要实现的功能。

一旦用户加载了一个或多个鼓样本,并使用切换按钮定义了一个节奏模式,我们需要扫描模式的每一列,以查看是否找到了绿色按钮(在我们数据结构中的True值)。

如果矩阵中某个位置的值为 True,我们的代码应该在继续前进之前播放相应的鼓样本。如果同一列中选择了两个或更多鼓样本,所有样本应该几乎同时播放。

此外,在播放每一连续列之间应该有一个固定的时间间隔,这将定义音乐的节奏。

要实现这一功能,我们需要导入pygame模块来播放声音,以及导入time模块来定义它们之间的时间间隔。

初始化 pygame

pygame 模块是一组高度可移植的模块,可以在大多数操作系统上运行。我们将使用 pygame 中的混音器模块来播放声音文件。

假设您已安装该包,让我们首先导入 pygame(参见代码 3.06.py):

import pygame

根据混合器模块的官方 API 文档www.pygame.org/docs/ref/mixer.html,在播放音频文件之前,我们需要初始化pygame

我们在名为 init_pygame 的新方法中初始化 pygame(参见代码 3.06.py):

def init_pygame(self):
 pygame.mixer.pre_init(44100, -16, 1, 512)
 pygame.init()

mixer.pre_init 方法是我们鼓机的特殊要求,因为缺少它会导致很多声音滞后。我们在这里不会深入讨论音频编程的细节,但可以简单地说,pre_init 方法的参数如下:

pre_init(frequency=22050, size=-16, channels=2, buffersize=512) 

在将 pygame 初始化为如下所示后,文档建议使用以下代码来播放声音。让我们也将这段代码添加到我们的代码中(参见代码 3.06.py):

 def play_sound(self, sound_filename):
   if sound_filename is not None:
      pygame.mixer.Sound(sound_filename).play()

演奏完整的模式

现在我们的程序具有播放任何声音的能力。但我们需要的不只是播放单个声音。我们需要播放一个模式。让我们定义一个名为 play_pattern 的方法,它读取我们的内部数据结构并相应地播放文件(参见代码 3.06.py):

import time
    def play_pattern(self):
        self.now_playing = True
        while self.now_playing:
            play_list = self.get_is_button_clicked_list()
            num_columns = len(play_list[0])
            for column_index in range(num_columns):
                column_to_play = self.get_column_from_matrix(
                      play_list, column_index)
                for i, item in enumerate(column_to_play):
                    if item:
                        sound_filename = self.get_drum_file_path(i)
                        self.play_sound(sound_filename)
                time.sleep(self.time_to_play_each_column())
                if not self.now_playing: break
            if not self.loop: break 
        self.now_playing = False

我们还添加了一个相关方法,该方法可以从矩阵中返回第 i 列:

def get_column_from_matrix(self, matrix, i):
  return [row[i] for row in matrix]

前述代码的描述如下:

  • 我们创建了一个名为 self.keep_playing 的类属性,用来决定模式是只播放一次还是连续循环播放。

  • 我们创建了一个名为 self.now_playing 的另一个类属性,用于跟踪是否有节拍正在播放。这将帮助我们做出一些决策,比如如何处理程序突然关闭或用户更改模式的情况。

  • 我们随后从我们的数据结构中获取二维布尔列表,并对列表的每一列进行扫描以查找True值。我们通过定义一个名为get_column_from_matrix(self, matrix, i)的独立方法来从矩阵中获取列数据。

  • 对于每一列,如果遇到True值,我们就获取相应的鼓文件路径并调用self.play_sound()方法来播放文件。

  • 代码在读取第二列之前会暂停固定的时间。这个暂停时间定义了鼓点的节奏。如果代码在每列之间不暂停一段时间,所有模式几乎会立即播放,甚至听起来都不像节奏。我们需要导入time模块来使用time.sleep()方法。

  • 代码在扫描每一列之间的睡眠时间是由另一种名为 self.time_to_play_each_column() 的方法决定的,我们将在下文中定义该方法。

确定节奏的节奏速度

定义节奏速度的数学计算很简单。我们获取与beats_per_minute属性相关的值,并将其除以60以得到每秒的节拍数。然后,播放每个节拍(或给定列中同时播放的一组节拍)的时间是beats_per_second的倒数。

代码如下(见代码 3.06.py):

def time_to_play_each_column(self):
  beats_per_second = self.beats_per_minute/60
  time_to_play_each_column = 1/beats_per_second
  return time_to_play_each_column

当我们处理模式的节奏时,也让我们完成与每分钟节拍 Spinbox 小部件相关联的命令回调的编码(参见代码 3.06.py):

def on_beats_per_minute_changed(self):
   self.beats_per_minute = int(self.beats_per_minute_widget.get())

现在,让我们编写与循环复选框相关的功能代码。我们已经在play_pattern方法中通过使用self.loop变量解决了循环问题。我们只需通过读取 Spinbox 小部件的值来设置self.loop属性的值(参见代码3.06.py):

def on_loop_button_toggled(self):
  self.loop = self.loopbuttonvar.get()

解决了这些问题之后,让我们编写与我们的播放按钮和停止按钮关联的命令回调函数(参见代码 3.06.py):

def on_play_button_clicked(self):
  self.start_play()

def start_play(self):
  self.init_pygame()
  self.play_pattern()

def on_stop_button_clicked(self):
  self.stop_play()

def stop_play(self):
  self.now_playing = False

我们的音乐鼓机现在已投入使用(见代码 3.06.py)。您可以加载鼓样本并定义节拍模式,当您点击播放按钮时,鼓机就会播放该节拍模式!

然而,存在一个小问题。play_sound方法阻塞了我们 Tkinter 程序的主循环。它不会将控制权交还给主循环,直到完成声音样本的播放。

由于我们的self.loop变量被设置为True,这意味着pygame永远不会将控制权交还给 Tkinter 的主循环,我们的播放按钮和程序就卡住了!这可以在以下屏幕截图中看到:

图片

这意味着如果你现在想要点击停止按钮或更改其他小部件,或者甚至关闭窗口,你将不得不等待播放循环完成,在我们这个情况下这种情况永远不会发生。

这显然是一个错误。在播放仍在进行时,我们需要某种方法将控制权交还给 Tkinter 主循环。

这就带我们来到了下一个迭代阶段,我们将在这个阶段讨论并实现我们应用中的多线程

Tkinter 和线程

我们可以使根窗口响应的最简单方法之一是,在play_pattern循环中使用root.update()方法。这将在每个声音样本播放后更新root.mainloop()方法。

然而,这是一个不够优雅的方法,因为控制权在 GUI 中传递时伴随着一些令人震惊的延迟。因此,你可能会在其他 Toplevel 窗口的小部件响应中体验到轻微的延迟。

此外,如果其他事件导致调用该方法,可能会导致嵌套事件循环。

一个更好的解决方案是从一个单独的线程运行play_pattern方法。

让我们使用 Python 的线程模块在单独的线程中播放模式。这样,pygame 就不会干扰 Tkinter 的主循环。

线程是一种编码结构,可以在运行程序(进程)的实例中同时推进两个或更多独立的逻辑工作流,在各个工作流之间进行上下文切换。运行程序中的每个线程都有自己的堆栈和自己的程序计数器,但进程中的所有线程共享相同的内存。

与线程不同,进程是程序的独立执行实例,每个进程都维护着自己的状态信息和地址空间。进程只能通过进程间通信机制与其他进程进行交互。

线程本身就是一个值得一本书来探讨的主题。然而,我们不会深入细节,而是将使用 Python 标准库中的线程模块。线程模块提供了一个高级的线程接口,以隐藏实现多线程程序的内层复杂性。要使用此模块,我们首先需要将线程模块导入到我们的命名空间中(参见代码 3.07.py):

import threading

现在,让我们创建一个方法,play_in_thread(),如下所示 (3.07.py):

def play_in_thread(self):
  self.thread = threading.Thread(target = self.play_pattern)
  self.thread.start() 

最后,将start_play方法改为调用play_in_thread而不是直接调用play_pattern:

def start_play(self):
  self.init_pygame()
  self.play_in_thread() # deleted direct call to self.play_pattern()

现在如果您加载一些鼓样本,定义节拍模式,然后点击播放按钮,声音将在单独的线程中播放,而不会导致其他小部件变得无响应(参见代码3.07.py)。

然而,这又带来了一个新的问题。如果用户多次点击播放按钮会发生什么?那将会产生多个同时播放的节奏模式线程。

我们可以通过在音频播放时禁用播放按钮来克服这个问题。这可以通过定义toggle_play_button_state()函数来实现(参见代码3.07.py):

def toggle_play_button_state(self):
  if self.now_playing:
    self.play_button.config(state="disabled")
  else:
   self.play_button.config(state="normal")

我们然后将这种状态切换方法附加到播放、停止和循环小部件的命令回调中,如下所示 (3.07.py):

def on_play_button_clicked(self):
  self.start_play()
  self.toggle_play_button_state()

def on_stop_button_clicked(self):
  self.stop_play()
  self.toggle_play_button_state()

def on_loop_button_toggled(self):
  self.loop = self.to_loop.get()
  self.keep_playing = self.loop
  if self.now_playing:
    self.now_playing = self.loop
    self.toggle_play_button_state()

我们还修改了play_pattern()方法,在末尾添加了对toggle_play_button_state()的调用(参见代码3.07.py)。这将确保当模式播放结束后,播放按钮返回到其正常状态。

播放按钮现在在有音频播放时保持禁用状态。当没有音频播放时,它将恢复到正常状态。

Tkinter 和线程安全性

Tkinter 不是线程安全的。Tkinter 解释器仅在运行主循环的线程中有效。任何对小部件的调用理想情况下都应该从创建主循环的线程中进行。从其他线程调用特定于小部件的命令是可能的,但不可靠。

当您从另一个线程调用小部件时,事件会被排队到解释器线程,该线程执行命令并将结果传回调用线程。如果主循环正在运行但未处理事件,有时会导致不可预测的异常。

事实上,如果你发现自己从主循环以外的线程调用小部件,那么很可能你没有将视觉元素与底层数据结构分离。你可能是做错了。

在我们完成这个迭代之前,让我们处理一个小细节。如果当前有节拍正在播放,用户点击窗口的关闭按钮会发生什么?主循环将会终止,而我们的音频播放线程将会处于孤儿状态。这可能会导致向用户抛出难看的错误信息。

因此,在我们退出窗口之前,让我们覆盖关闭按钮并停止音频播放。要覆盖关闭按钮,我们在类的 __init__ 方法中添加一行代码,如下所示(参见代码 3.07.py):

 self.root.protocol('WM_DELETE_WINDOW', self.exit_app)

然后,我们定义了一个名为 exit_app() 的方法,具体如下(参见代码 3.07.py):

def exit_app(self):
  self.now_playing = False
  if messagebox.askokcancel("Quit", "Really quit?"):
    self.root.destroy()

这完成了项目迭代。

总结来说,我们改进了start_play()方法,使其在单独的线程上播放音频文件。我们还确保在音频播放时禁用播放按钮。最后,我们重写了关闭按钮,以便在播放某些音频时处理退出操作。

我们使用了 Python 内置的线程模块来在单独的线程中播放循环。我们还探讨了 Tkinter 中一些与线程相关的限制。然而,线程本身是一个庞大的主题,我们在这里只是略作触及。

你可以在docs.python.org/3/library/threading.html找到有关线程模块的更多详细信息。

支持多种节拍模式

我们的小鼓程序现在已可用。您可以加载鼓样本并定义一个节奏模式,我们的鼓机将会播放它。

现在我们将扩展我们的鼓机,使其能够在同一个程序中创建多个模式。这将使我们能够通过更改模式编号来简单地播放不同的模式。这使用户能够为歌曲的引子、副歌、桥段以及其他部分制作不同的节奏。以下截图中的模式更改用户界面以红色突出显示:

图片

在一开始,我们就有一个 Entry 小部件紧邻 Pattern Number Spinbox 小部件。我们希望在 Entry 小部件中显示当前的图案编号。因此,我们创建了一个方法display_pattern_name(),用于完成这项任务(参见代码3.08.py):

 def display_pattern_name(self):
   self.current_pattern_name_widget.config(state='normal')
   self.current_pattern_name_widget.delete(0, 'end')
   self.current_pattern_name_widget.insert(0,
               'Pattern {}'.format(self.current_pattern_index))
   self.current_pattern_name_widget.config(state='readonly')

我们希望模式名称在程序首次启动时显示在文本小部件中。因此,我们修改了create_top_bar()方法,以包含对这个新定义方法的调用(参见代码3.08.py)。

改变模式需要几个变更。首先,让我们修改on_pattern_changed()命令回调函数,以调用一个新的方法change_pattern(),如下所示(参见代码3.08.py):

 def on_pattern_changed(self):
   self.change_pattern()

接下来,让我们定义change_pattern()方法:

 def change_pattern(self):
   self.current_pattern_index = int(self.pattern_index_widget.get())
   self.display_pattern_name()
   self.create_left_drum_loader()
   self.display_all_drum_file_names()
   self.create_right_button_matrix()
   self.display_all_button_colors()

上述代码几乎应该像普通英语一样易读,并且模式变化中涉及的步骤应该是自解释的。

这完成了我们的鼓机编码,以支持多种节拍模式。现在运行代码3.08.py。加载一些鼓文件,定义第一个节拍模式,并播放它。使用左上角的 Spinbox 小部件更改节拍模式,

加载新的鼓点,并定义一个新的节奏模式。然后,演奏这个节奏模式。当它在播放时,尝试切换到你的第一个鼓点模式。变化应该无缝进行。

保存节拍模式

在前一个迭代中,我们增加了定义多个节拍模式的功能。

然而,节拍模式只能在单个脚本运行时播放。当程序关闭并重新启动时,所有之前的模式数据都会丢失。

我们需要一个方法来持久化或存储超过单个程序运行的节拍模式。我们需要在某种形式的文件存储中存储值,并能够重新加载、播放,甚至编辑这些模式。我们需要某种形式的对象持久化

Python 提供了多个用于对象持久化的模块。我们将用于持久化的模块称为pickle模块。Pickle 是 Python 的标准库。

在 Python 中,将表示为字节串的对象称为 picklePickling,也称为对象 序列化,允许我们将我们的对象转换成字节串。从字节串中重建对象的过程称为 unpickling反序列化

更多关于pickle模块的信息可在docs.python.org/3/library/pickle.html找到。

让我们用一个简单的例子来说明:

import pickle
party_menu= ['Bread', 'Salad', 'Bordelaise', 'Wine', 'Truffles']
pickle.dump(party_menu, open("my_menu", "wb"))

首先,我们使用 pickle.dump 将列表 party_menu 序列化或冻结,并将其保存在外部文件 my_menu 中。

我们稍后使用 pickle.load 来检索对象:

import pickle
menu= pickle.load( open( "my_menu", "rb" ) )
print(menu) # prints ['Bread', 'Salad', 'Bordelaise', 'Wine', 'Truffles']

回到我们的鼓机——如果我们需要存储和重用节拍模式,我们只需要将名为 self.all_patterns 的数据结构列表进行序列化。保存了对象之后,我们稍后可以轻松地反序列化文件来重建我们的节拍模式。

我们首先需要向我们的程序中添加三个顶部菜单项,如图所示:

图片

三个顶级菜单项包括:

  • 文件 | 加载项目

  • 文件 | 保存项目

  • 文件 | 退出

当我们创建菜单项时,也让我们添加一个“关于”菜单项。

在这里,我们特别关注保存项目(序列化),以及将项目重新加载(反序列化)。菜单项的代码定义在一个单独的方法中,称为 create_top_menu,如下所示(参见代码 3.09.py):

def create_top_menu(self):
  self.menu_bar = Menu(self.root)
  self.file_menu = Menu(self.menu_bar, tearoff=0)
  self.file_menu.add_command( 
       label="Load Project", command=self.load_project)
  self.file_menu.add_command(
      label="Save Project", command=self.save_project)
  self.file_menu.add_separator()
  self.file_menu.add_command(label="Exit", command=self.exit_app)
  self.menu_bar.add_cascade(label="File", menu=self.file_menu)
  self.about_menu = Menu(self.menu_bar, tearoff=0)
  self.about_menu.add_command(label="About",command=self.show_about)
  self.menu_bar.add_cascade(label="About", menu=self.about_menu)
  self.root.config(menu=self.menu_bar)

代码是自我解释的。我们在前两个项目中创建了类似的菜单项。最后,为了显示这个菜单,我们从init_gui()方法中调用此方法。

要对我们的对象进行腌制,我们首先将 pickle 模块导入到当前命名空间中,如下所示 (3.09.py):

import pickle 

“保存项目”菜单中有一个命令回调附加到self.save_project,这是我们定义序列化过程的地点:

def save_project(self):
  saveas_file_name = filedialog.asksaveasfilename
               (filetypes = [('Explosion Beat File','*.ebt')],
              title="Save project as...")
  if saveas_file_name is None: return
  pickle.dump( self.all_patterns, open(saveas_file_name, "wb"))
  self.root.title(os.path.basename(saveas_file_name) +PROGRAM_NAME)

代码的描述如下:

  • 当用户点击“保存项目”菜单时,会调用save_project方法;因此,我们需要为用户提供一个选项,以便将项目保存到文件中。

  • 我们选择定义一个新的文件扩展名(.ebt)来跟踪我们的节奏模式。这是一个完全随意的扩展名命名选择。

  • 当用户指定文件名时,文件将以 .ebt 扩展名保存。该文件包含序列化的列表 self.all_patterns,使用 pickle.dump 将其写入文件。

  • 最后,Toplevel 窗口的标题被更改以反映文件名。

我们已经完成了对象的腌制。现在让我们编写反腌制过程。反腌制过程由一个名为load_project的方法处理,该方法从“加载项目”菜单中调用,如下所示:

  def load_project(self):
     file_path = filedialog.askopenfilename(
     filetypes=[('Explosion Beat File', '*.ebt')], title='Load Project')
     if not file_path:
        return
     pickled_file_object = open(file_path, "rb")
     try:
        self.all_patterns = pickle.load(pickled_file_object)
     except EOFError:
        messagebox.showerror("Error", "Explosion Beat file seems corrupted or invalid !")
     pickled_file_object.close()
     try:
       self.change_pattern()
       self.root.title(os.path.basename(file_path) + PROGRAM_NAME)
     except:
       messagebox.showerror("Error",
        "An unexpected error occurred trying to process the beat file")

代码的描述如下:

  • 当用户点击“加载项目”菜单时,它会触发与该load_project方法连接的命令回调。

  • 该方法的第一行会弹出一个打开文件窗口。当用户指定一个具有.ebt扩展名的已保存文件时,文件名将被存储在一个名为pickled_file_object的变量中。

  • 如果返回的文件名为 None 因为用户取消了打开文件对话框,则不执行任何操作。此时文件将以读取模式打开,并使用 pickle.load 将文件内容读取到 self.all_patterns 中。

  • self.all_patterns 现在包含在之前的 pickle 中定义的节拍模式列表。

  • 文件已关闭,通过调用我们之前定义的change_pattern()方法,重新构建了self.all_patterns的第一个模式。

这应该会在我们的鼓机中加载第一个模式。尝试播放任何模式,你应该能够精确地回放模式,就像在保存时定义的那样。

注意,然而,经过腌制的 .ebt 文件无法从一个计算机转移到另一个计算机。这是因为我们只是腌了我们的鼓文件路径。我们没有腌制实际的音频文件。所以如果你尝试在另一台机器上运行 .ebt 文件,或者如果音频文件的路径自从腌制后已经改变,我们的代码将无法加载音频文件,并会报告错误。

将未压缩的音频文件(如.wav文件、.ogg文件或 PCM 数据)进行腌制的流程与前面的流程相同。毕竟,这些未压缩的音频文件不过是数字列表。

然而,在这里尝试腌制音频文件会使我们偏离当前的主题很远。因此,我们在这里没有实现它。

Pickling,虽然非常适合序列化,但容易受到恶意或错误数据的攻击。你可能只想在数据来自受信任的来源,或者有适当的验证机制的情况下进行 Pickling。

你也可能发现json模块对于将对象序列化为 JSON 格式很有用。此外,ElementTreexml.minidom库对于解析 XML 数据也是相关的。

为了结束本节,让我们完成对点击“关于”菜单项的响应代码:

def show_about(self):
  messagebox.showinfo(PROGRAM_NAME, 
                   "Tkinter GUI Application\n Development  Blueprints")

这一点不言而喻。我们在之前的项目中已经做过类似的编码工作。

总结这次迭代,我们使用了 Python 内置的 pickle 模块来序列化和反序列化用户定义的节奏模式。

现在我们可以保存我们的节奏模式了。我们可以在以后加载、回放和编辑在鼓机中保存的这些模式(参见代码3.09.py)。

使用 ttk 主题小部件

我们几乎完成了鼓机的编程。然而,我们希望通过介绍 ttk 主题小部件来结束这一章。

Tkinter 在许多平台上不绑定到本地平台小部件,例如 Microsoft Windows 和 X11。

Tk 工具包(以及 Tkinter)最初出现在X-Window 系统上;因此,它采用了当时 X-Window 系统上 GUI 开发的事实标准——motif 风格。

当 Tk 被移植到其他平台,例如 Windows 和 Mac OS 时,这种主题风格开始与这些平台的视觉风格显得格格不入。

由于这个原因,有些人甚至认为 Tkinter 小部件相当丑陋,并且与这样的桌面环境不太兼容。

Tkinter 的另一个批评基于这样一个事实:Tkinter 允许通过小部件选项来同时更改逻辑和样式,从而将它们混合在一起。

Tkinter 也受到了缺乏任何主题支持批评。尽管我们看到了通过选项数据库实现的集中式样式的例子,但这种方法要求在部件级别进行样式设置。例如,它不允许对两个按钮部件进行不同的选择性样式设置。这使得开发者难以在保持相似组部件视觉一致性同时,将它们与其他组部件区分开来。

由于这个原因,许多图形用户界面(GUI)开发者转向了 Tkinter 的替代品,例如 wxPythonPySidePyQT

在 Tkinter 8.5 版本中,Tkinter 的开发者通过引入ttk 模块来尝试解决所有这些担忧,该模块可以被视为对原始 Tkinter 模块的改进。

让我们来看看 ttk 主题小部件模块提供的一些功能。

ttk 首先要做的事情是提供一组内置主题,这使得 Tk 小部件看起来像应用程序正在运行的本地桌面环境。

此外,它还向小部件列表中引入了 6 个新的小部件——ComboboxNotebookProgressbarSeparatorSizegripTreeview,同时支持 11 个核心 Tkinter 小部件,包括 Button、Checkbutton、Entry、Frame、Label、LabelFrame、Menubutton、PanedWindow、Radiobutton、Scale 和 Scrollbar。

要使用 ttk 模块,我们首先将其导入到当前命名空间中:

from tkinter import ttk

您可以按照以下方式显示 ttk 小部件(参见代码 3.10.py):

ttk.Button(root, text='ttk Button').grid(row=1, column=1)
ttk.Checkbutton(root, text='tkCheckButton').grid(row=2, column=1)

代码 3.10.py 提供了正常 Tkinter 小部件与其对应 ttk 小部件之间的显示比较,如下所示截图:

图片

注意,前面的截图是在 Microsoft Windows 平台上拍摄的,因为在没有明确使用 X-Window 系统的系统中,差异更为明显。注意 Tkinter 小部件(在左侧)与 ttk 小部件(在右侧)相比,在 Microsoft Windows 上的外观显得格格不入,后者是 Microsoft Windows 的原生外观和感觉(参见代码 3.10.py)。

你甚至可以通过在 Tkinter 之后导入 ttk 来覆盖基本的 Tkinter 小部件,如下所示:

从 tkinter 导入所有内容

从 tkinter.ttk 导入所有内容

这将导致所有 Tk 和 ttk 共有的小部件都被 ttk 小部件所替换。

这直接的好处是使用新的小部件,这使得跨平台看起来和感觉都更好。

然而,这种导入方式的缺点是您无法区分导入小部件类的模块。这一点很重要,因为 Tkinter 和 ttk 小部件类并不是完全可互换的。在这种情况下,一个明确的解决方案是按照以下代码导入它们:

import tkinter as tk

from tkinter import ttk

尽管 Tkinter 和 ttk 小部件的大多数配置选项都是通用的,但 ttk 主题小部件不支持 fg、bg、relief 和 border 等样式选项。这是有意从 ttk 中移除的,目的是为了保持逻辑和样式的不同控制。

相反,所有与样式相关的选项都由相应的样式名称处理。在一个标准的 ttk 模块中,每个小部件都有一个关联的样式名称。您可以使用 widget.winfo_class() 方法检索小部件的默认样式名称。

例如,考虑一个 ttk 按钮:

>>> my_button = ttk.Button()
>>> my_button.winfo_class()

这将打印 Tbutton,它是 ttk.Button 的默认样式名称。有关不同小部件的默认 ttk 样式名称列表,请参阅infohost.nmt.edu/tcc/help/pubs/tkinter/web/ttk-style-layer.html

除了默认样式外,您还可以为小部件或小部件组分配一个自定义样式类。要设置新样式,您可以使用以下方法:

 style = ttk.Style()

要配置默认样式的样式选项,您可以使用以下命令:

style.configure('style.Defaultstyle', **styling options)

要从内置样式创建一个新的样式,请定义一个形式为newName.oldName的样式名称。例如,要创建一个用于存储日期的 Entry 小部件,你可以将其命名为Date.Tentry

要在部件上使用新样式,您需要使用以下命令:

ttk.Widget(root, style='style.Defaultstyle') 

接下来,我们将讨论ttk 主题

样式用于控制单个小部件的外观。另一方面,主题控制整个 GUI 的外观。更简单地说,主题是一组样式的集合。将样式分组为主题允许用户一次性切换整个 GUI 的设计。与样式一样,所有主题都通过它们的名称唯一标识。

可用主题列表的获取方法如下:

 >> from tkinter.ttk import *
 >>> style = Style()
 >>> style.theme_names()
 ('winnative', 'clam', 'alt', 'default', 'classic', 'xpnative')
 To obtain the name of the currently active theme:
 >>> style.theme_use()
 'default'

您可以从style.theme_names()列表中切换到另一个主题;使用以下方法:

style.theme_use('your_new_theme_name') 

要探索 ttk 的各种样式和主题相关选项,请参考示例(见 code 3.11.py):

 from tkinter import Tk
 from tkinter import ttk
 root = Tk()
 style = ttk.Style()
 # defining the global style - applied when no other style is defined
 style.configure('.', font='Arial 14', foreground='brown', 
   background='yellow')
 # this label inherits the global style as style option not specified for it
 ttk.Label(root, text='I have no style of my own').pack()
 # defining a new style named danger and configuring its style only for the
 # button widget
 style.configure('danger.TButton', font='Times 12', foreground='red', padding=1)
 ttk.Button(root, text='Styled Dangerously',  style='danger.TButton').pack()
 # Different styling for different widget states
 style.map("new_state_new_style.TButton", foreground=[('pressed', 'red'), ('active', 'blue')])
 ttk.Button(text="Different Style for different states",style="new_state_new_style.TButton").pack()
 # Overriding current theme styles for the Entry widget
 current_theme = style.theme_use()
 style.theme_settings( current_theme,
    {"TEntry":
        {"configure":
          {"padding": 10},
          "map": {"foreground": [("focus", "red")] }
        } 
    })
 print(style.theme_names())
 print(style.theme_use())
 # this is effected by change of themes even though no style specified
 ttk.Entry().pack()
 root.mainloop()

代码的描述如下:

  • 前三行代码导入 Tkinter 和 ttk,并设置一个新的 root 窗口。

  • 下一个代码行,style = ttk.Style(),定义了一个新的样式。

  • 下一行配置了一个程序范围内的样式配置,使用style.configure。点号字符(.),作为configure的第一个参数,表示此样式将应用于Toplevel窗口及其所有子元素。这就是为什么我们所有的小部件都得到了黄色的背景。

  • 下一行创建了一个扩展(danger)到默认样式(TButton)。这就是创建自定义样式的方法,这些样式是基于基础默认样式的变体。

  • 下一行创建了一个 ttk.Label 小部件。由于我们没有为这个小部件指定任何样式,它继承了为 Toplevel 窗口指定的全局样式。

  • 下一行创建了一个 ttk.button 小部件,并指定使用我们自定义的 danger.TButton 风格定义进行样式化。这就是为什么这个按钮的前景色变成了红色。注意它仍然继承了我们在之前定义的全局 Toplevel 风格中的背景色,黄色。

  • 下面的两行代码展示了 ttk 如何允许对不同的小部件状态进行样式设置。在这个例子中,我们为 ttk.Button 小部件的不同状态设置了不同的样式,以显示不同的颜色。请点击这个第二个按钮,看看不同的样式是如何应用到按钮的不同状态上的。在这里,我们使用 map(style, query_options, **kw) 来指定小部件状态变化时的动态样式值。

  • 下一行获取当前适用的主题。然后使用style.theme_settings('themename', ***options)覆盖主题的 Entry 小部件的一些选项。

  • 下一行定义了一个条目小部件(Entry widget),但没有指定任何样式给它。因此,它继承了我们之前配置的主题属性。如果你现在在这个条目小部件中输入任何内容,你会注意到它获得了 10 像素的内边距,并且条目小部件内的前景文本颜色为红色。

现在我们知道了如何让我们的小部件看起来更像原生平台的小部件,让我们将鼓机的播放停止按钮改为ttk.button。同时,也将Loop复选按钮从Tkinter复选按钮改为ttk复选按钮,并在播放栏部分添加几个分隔符。

以下截图显示了更改前后的播放栏:

图片

我们首先将ttk导入到我们的命名空间中,并将ttk附加到播放和停止按钮上,如下所示(代码3.12.py):

from tkinter import ttk

我们随后简单地修改了create_play_bar中的按钮和 Checkbutton,将button替换为ttk.Button,将loopbutton替换为ttk.Checkbutton

 button = ttk.Button()
 loopbutton = ttk.Checkbutton(**options)

注意,这些更改使得按钮和复选框看起来更像是您工作平台的原生小部件。

最后,让我们将 ttk.separators 添加到我们的播放条(参见代码 3.12.py)。添加分隔符的格式如下:

ttk.Separator(playbar_frame, orient='vertical').grid(row=start_row, column = 5, sticky="ns", padx=5)

注意,我们无法将右键矩阵中的按钮从button更改为ttk.Button。这是因为 ttk 按钮不支持指定选项,如背景颜色。

这标志着本项目的最后一个迭代结束。在这个迭代中,我们首先了解了如何以及为什么使用ttk 主题小部件来改善我们程序的外观和感觉。

我们在鼓程序中使用了 ttk 按钮(Buttons)和 ttk 复选按钮(Checkbuttons)来改善其外观。我们还了解了为什么我们程序中的某些 Tkinter 按钮不能被 ttk 按钮所替代的原因。

这就带我们结束了这一章。

摘要

这里是本章涵盖内容的简要总结。

我们首先学习了如何将 Tkinter 程序结构化为类和对象。

我们随后决定了我们程序的数据结构。这使得我们能够为编写程序逻辑的其余部分奠定基础,保持了数据、逻辑及其视觉表示之间的清晰分离。我们看到了提前决定数据结构的重要益处。

我们还使用了更多 Tkinter 小部件,例如 Spinbox、Button、Entry 和 Checkbutton。我们还在本章中看到了网格布局管理器的实际应用。

我们接着学习了如何使用命令回调将小部件绑定到高阶函数。这是一种在图形用户界面编程中非常常见的技巧。

我们随后在 Tkinter 的上下文中理解了多线程编程。我们将音频播放移到了一个单独的线程上。这使得我们能够在不任何方式妨碍 Tkinter 主循环的情况下保持音频播放。

我们随后了解了如何使用 pickle 模块持久化对象的状态,以及之后如何反序列化它以检索对象的状态。

最后,我们了解了如何使用 ttk 主题小部件来确保我们的 GUI 在运行平台上感觉是本地的。

恭喜!你现在已经完成了鼓机的编码。

QA 部分

在你继续阅读下一章之前,请确保你能满意地回答这些问题:

  • 你如何以面向对象的方式组织 Tkinter 程序?使用面向对象结构而不是编写纯过程性代码有哪些优点?又有哪些缺点?

  • 在编程的哪个阶段你应该考虑为你的 GUI 程序设计数据结构?拥有一个数据结构或模型有什么好处?

  • 什么是高阶函数?

  • 为什么需要线程?它的优点和缺点是什么?

  • 进程和线程之间的区别是什么?

  • 什么是对象持久化?

  • 你如何在 Python 中进行对象的序列化和反序列化?

  • 除了腌制,还有哪些常见的对象持久化方式?

  • ttk 小部件是什么?为什么它们会被使用?

进一步阅读

了解面向对象编程术语,如类(class)、对象(objects)、构造函数(constructor)、继承(inheritance)、封装(encapsulation)、类方法(class methods)、静态方法(static methods)、获取器(getters)、设置器(setters)及其在 Python 中的具体实现。一个好的起点是查看类官方文档,链接为docs.python.org/3/tutorial/classes.html

阅读 Python 对象序列化的官方文档,请访问docs.python.org/3/library/pickle.html

了解更多关于线程、上下文切换以及一般基于线程的并行性的内容,以及 Python 中的具体实现。线程的官方文档位于docs.python.org/3/library/threading.html

第四章:国际象棋

让我们在 Tkinter 中构建一个棋盘游戏。如果你已经了解棋盘游戏的基本规则,你就可以开始编写这个程序了。然而,如果你不知道规则,你应该在开始编程这个应用程序之前先阅读它们。

本章的一些关键目标如下:

  • 学习如何在模型-视图-控制器(MVC)架构中构建程序

  • 学习如何通过实现模块化结构来驯服复杂性

  • 查看 Tkinter Canvas 小部件的多样性和强大功能

  • 学习画布坐标、对象 ID 和标签的基本用法

  • 学习推荐的错误处理实践

  • 学习如何扩展 Python 的内置数据类型

  • 使用对象继承来编写具有相似属性和行为的类

  • 使用 Python 的内置 configparser 模块来存储程序首选项

  • 熟悉你将在各种应用开发项目中经常使用的几个 Python 模块

章节概述

我们现在将实现一个人机对战的棋局。我们的棋局将执行所有适用于棋局的标准规则。一些高级规则,如王车易位和吃过路兵,将作为练习留给你。

在其最终形态下,我们的棋类程序将看起来是这样的:

图片

本章节的模块要求

在本章中,我们将不使用任何外部第三方模块。然而,我们将使用几个内置的 Python 模块。

要检查是否所有必需的库确实由您的 Python 发行版提供,请在您的 Python 命令行中输入以下命令:

>> import tkinter, copy, sys, configparser

这应该在没有错误消息的情况下执行。如果没有抛出错误,你就可以准备构建棋类应用程序了。让我们开始吧!

结构化我们的程序

在本节中,我们为我们的程序确定一个整体结构。

大型应用程序的开发通常从记录软件需求规格说明书SRS)开始。这通常随后是使用几种建模工具对结构进行图形表示,例如类、组合、继承以及使用信息隐藏。这些工具可以是流程图、统一建模语言UML)工具、数据流图、维恩图(用于数据库建模)等等。

当问题域不是很明确时,这些工具非常有用。然而,如果你曾经玩过国际象棋游戏,你应该非常熟悉问题域。此外,我们的国际象棋程序可以归类为中等规模程序,跨越了几百行代码。因此,让我们跳过这些视觉工具,直接进入实际程序设计。

我们之前的所有项目都结构化为单个文件。然而,随着程序的复杂性增加,我们需要将程序分解为模块和类结构。

本章的关键目标之一是学习编写MVC架构的程序。MVC 架构的一些核心方面如下:

  • 模型处理后端数据和逻辑

  • 视图处理前端展示

  • 模型和视图从不直接交互

  • 每当视图需要访问后端数据时,它请求控制器介入模型并获取所需数据

考虑到这些方面,让我们为我们的棋类程序创建三个文件:model.pyview.pycontroller.py(参见4.01.py)。

现在,让我们在各自的文件中创建一个空的 Model 类,一个空的 View 类,以及一个 Controller 类,具体如下:

class Model(): #in model.py
  def __init__(self):
    pass

class View(): #in view.py
  def __init__(self):
    pass

class Controller(): # in controller.py
  def __init__(self):
    self.init_model()

  def init_model(self):
    self.model = model.Model()

注意,由于Controller类需要从Model类获取数据,我们在Controller类内部实例化了一个新的Model类。这现在为我们提供了一种按需从Model类获取数据的方法。

让我们再添加一个名为 exceptions.py 的单独文件。这个文件将成为我们处理所有错误和异常的中心位置。在这个文件中,添加以下单行代码:

class ChessError(Exception): pass

我们创建了一个自定义的 ChessError 类,它继承自标准 Exception 类。现在,这一行简单的代码允许 ChessError 类及其所有子类抛出错误,这些错误可以通过使用 try…except 块来处理。从现在开始,我们代码中定义的所有新错误类都将从这个 ChessError 基类派生。

将这个模板代码移除后,让我们创建另一个名为 configurations.py (4.01)的空白文件。我们将使用这个文件来存储所有常量和可配置值在一个地方。

让我们立即定义一些常量,如下所示(参见代码4.01configurations.py):

NUMBER_OF_ROWS = 8
NUMBER_OF_COLUMNS = 8
DIMENSION_OF_EACH_SQUARE = 64 # denoting 64 pixels
BOARD_COLOR_1 = "#DDB88C"
BOARD_COLOR_2 = "#A66D4F"

为了让这些常量值对所有文件可用,让我们将它们导入到model.pyview.pycontroller.py文件夹中(参见4.01):

from configurations import *

根据 MVC 架构的原则,View 类永远不应该直接与 Model 类交互。它应该始终与 Controller 类交互,然后由 Controller 类负责从 Model 类获取数据。因此,让我们按照以下方式在 View 类中导入控制器,在 Controller 类中导入模型:

import controller # in view.py
import model # in controller.py

让我们从编辑view.py文件开始,以显示棋盘(参见4.01view.py)。本次迭代的目的是显示如下截图所示的空棋盘:

图片

查看代码实现,请参阅view.py(见4.01)。

View 类的 __init__ 方法调用了名为 create_chess_base 的方法,该方法定义如下:

def create_chess_base(self):
  self.create_top_menu()
  self.create_canvas()
  self.draw_board()
  self.create_bottom_frame()

我们不会重现创建根窗口、顶部菜单或底部框架的代码。我们已在之前的章节中实现了类似的控件(参见4.01view.py以获取完整参考)。

然而,我们将讨论创建棋盘的代码:

def create_canvas(self):
  canvas_width = NUMBER_OF_COLUMNS * DIMENSION_OF_EACH_SQUARE
  canvas_height = NUMBER_OF_ROWS * DIMENSION_OF_EACH_SQUARE
  self.canvas = Canvas(self.parent, width=canvas_width, height=canvas_height)
  self.canvas.pack(padx=8, pady=8)

这里没有太多花哨的东西。创建一个 Canvas 小部件与在 Tkinter 中创建其他小部件类似。Canvas 小部件接受两个可配置选项的widthheight。接下来,用交替的色调绘制 Canvas 小部件以形成棋盘(view.py):

def draw_board(self):
  current_color = BOARD_COLOR_2
  for row in range(NUMBER_OF_ROWS):
    current_color = self.get_alternate_color(current_color)
    for col in range(NUMBER_OF_COLUMNS):
       x1, y1 = self.get_x_y_coordinate(row, col)
       x2, y2 = x1 + DIMENSION_OF_EACH_SQUARE, y1 +DIMENSION_OF_EACH_SQUARE
       self.canvas.create_rectangle(x1, y1, x2, y2, fill=current_color)
       current_color = self.get_alternate_color(current_color)

def get_x_y_coordinate(self, row, col):
        x = (col * DIMENSION_OF_EACH_SQUARE)
        y = ((7 - row) * DIMENSION_OF_EACH_SQUARE)
       return (x, y)

def get_alternate_color(self, current_color):
     if current_color == self.board_color_2:
        next_color = self.board_color_1
     else:
        next_color = self.board_color_2
     return next_color

以下是对代码的描述:

  • 我们使用了 Canvas 小部件的create_rectangle()方法来绘制交替阴影的方块,以模拟棋盘。

  • 矩形是从点 x1, y1 绘制,并延伸到 x2, y2。这些值对应于矩形的两个对角相对的角(上左和下右边的坐标)。

  • xy值是通过使用一种新定义的方法get_x_y_coordinate()计算得出的,该方法根据之前以像素单位定义的每个正方形的尺寸执行简单的数学运算。

    y 值是通过首先从(7 行)中减去一行来计算的,因为 Canvas 小部件是从左上角开始测量坐标的。画布的左上角坐标为 (0, 0)。

  • get_alternate_color 方法是一个辅助方法,不出所料,它返回交替颜色。

Tkinter 画布小部件允许你在指定的坐标处绘制线条、椭圆形、矩形、圆弧和多边形等形状。你还可以为这些形状指定各种配置选项,例如填充、轮廓、宽度等。

画布小部件使用一个坐标系来指定小部件上对象的位置。坐标以像素为单位进行测量。画布的左上角坐标为 (0, 0)。

在 Canvas 小部件上绘制的对象通常通过分配一个 ID 或标签来处理。我们将在本章后面看到一个例子。

如果画布小部件上的对象被标记为多个标签,则堆栈顶部标签定义的选项具有优先权。

然而,您可以通过使用tag_raise(name)tag_lower(name)来改变标签的优先级。

要获取 Canvas 小部件相关选项的完整列表,请参考使用命令行中的help(Tkinter.Canvas)获取 Canvas 小部件的交互式帮助,如下所示:

>>> import tkinter

>>> help(tkinter.Canvas)

接下来,让我们在View类的__init__方法中将鼠标点击绑定到 Canvas 小部件(参见4.01view.py),如下所示:

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

绑定方法调用另一个名为 get_clicked_row_column() 的方法,目前它将结果按如下方式打印到控制台:

def on_square_clicked(self, event):
  clicked_row, clicked_column =  self.get_clicked_row_column(event)
  print("Hey you clicked on", clicked_row, clicked_column)

get_clicked_row_column() 方法定义如下:

def get_clicked_row_column(self, event):
  col_size = row_size = DIMENSION_OF_EACH_SQUARE
  clicked_column = event.x // col_size
  clicked_row = 7 - (event.y // row_size)
  return (clicked_row, clicked_column)

现在,如果你运行代码(见4.01view.py)并点击不同的方块,它应该会在控制台输出类似以下的消息:

Hey you clicked on 0 7
Hey you clicked on 3 3

这完成了我们的第一次迭代。在这个迭代中,我们确定了棋类程序的更广泛文件结构。我们创建了modelviewcontroller类。我们还决定将所有常量和配置值保存在一个名为configurations.py的单独文件中。

我们现在已经初步体验了 Canvas 小部件。我们创建了一个空白画布,然后使用canvas.create_rectangle方法添加了方形区域,从而创建了一个棋盘。

现在,如果你运行 4.01view.py,你会看到一个空白的棋盘。你还会发现 文件 菜单和 编辑 菜单的下拉菜单不可用。关于 菜单应该显示一个标准的 messagebox 小部件。

在你继续阅读下一节之前,我们鼓励你全面地探索4.01文件夹中的代码。

模拟数据结构

回到那句古老的谚语,数据结构,而非算法,是编写良好程序的核心。因此,花些时间定义数据结构是很重要的。

模型需要记录的关键数据是棋盘上棋子的位置。因此,我们首先需要一个方法来定义这些位置,以及一个独特的方式来识别棋子。让我们首先同意我们将遵守的程序中的命名约定。

国际象棋棋子命名公约

每个棋子都由一个单独的字母来标识(兵 = p,马 = n(是的,马用 n 表示!),象 = b,车 = r,后 = q,王 = k)。

白色棋子用大写字母(PNBRQK)表示,黑色棋子用小写字母(pnbrqk)表示。

国际象棋棋盘上地点命名的规则

为了给棋盘上的每一个方格分配唯一的标识符,我们将使用字母 A 到 H 来标记x轴上的方格。我们将使用数字 1 到 8 来标记y轴。

因此,棋盘上的方格将被标识如下:

图片

因此,A1 表示棋盘最左下角的方块。目前,它被一个白方车占据。C3 位置目前是空的,E8 有一个黑方国王,而 A8 有一个黑方车。

让我们将这个添加到configurations.py文件中(参见4.02):

X_AXIS_LABELS = ('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H')
Y_AXIS_LABELS = (1, 2, 3, 4, 5, 6, 7, 8)

现在,如果你想在某个时间点表示棋盘,你所需要的只是一个将位置映射到该位置棋子的映射。看起来这是一个存储为 Python 字典的完美候选者。

因此,棋盘上所有棋子的初始位置可以表示如下:

START_PIECES_POSITION = {
"A8": "r", "B8": "n", "C8": "b", "b", "G8": "n", "H8": "r",
"A7": "p", "B7": "p", "C7": "p", "p", "G7": "p", "H7": "p",
"A2": "P", "B2": "P", "C2": "P", "P", "G2": "P", "H2": "P",
"A1": "R", "B1": "N", "C1": "B", "D8": "q", "E8": "k", "F8":
"D7": "p", "E7": "p", "F7": "D2": "P", "E2": "P", "F2":
"D1": "Q", "E1": "K", "F1":"B", "G1": "N", "H1": "R"
}

我们需要这些数据来开始工作。因此,让我们将此作为常量添加到configurations.py文件中(见4.02)。

现在,让我们继续为我们的程序编写Model类。我们已经决定将使用 Python 字典来存储棋盘上棋子的位置。我们可以继续为类添加一个字典属性。

然而,我们将采取一种略有不同的方法。

让我们将Model类设置为内置字典类的子类,如下所示:

class Model(dict): 

因此,指向当前类对象实例的self变量也将拥有字典中可用的所有属性和方法。现在可以在Model对象(self)上调用标准字典类中可用的所有方法。

因此,现在我们可以定义一个方法,当给定棋盘上的位置时,它返回该位置棋子的简称,如下所示(参见 4.02model.py):

def get_piece_at(self, position):
   return self.get(position) 

如果该位置没有棋子,则返回None而不是抛出KeyError异常。

接下来,让我们给Model类添加一些更重要的属性,如下所示(见4.02model.py):

captured_pieces = { 'white': [], 'black': [] }
player_turn = None
halfmove_clock = 0
fullmove_number = 1
history = []

half-move_clock 记录自上次兵的推进或上次捕获以来的回合数。这用于确定是否可以根据 五十回合 规则要求判和。

全移动数是指在每一步黑棋移动后增加1的计数。这用于跟踪整场比赛的总长度。

最后,让我们再添加一种方法,该方法根据正方形的行-列元组返回其字母数字位置(例如,输入(12)返回 B3):

def get_alphanumeric_position(self, rowcol):
  if self.is_on_board(rowcol):
     row, col = rowcol
     return "{}{}".format(X_AXIS_LABELS[col], Y_AXIS_LABELS[row])

接下来,让我们定义一个相关的辅助方法,以确保我们只处理发生在 Canvas 小部件上的鼠标点击,而不是根窗口的任何其他地方,具体如下:

def is_on_board(self, rowcol):
     row, col = rowcol
    return 0 <= row <= 7 and 0 <= col <= 7 

目前为止,在Model类中添加的内容并不多,直到我们编写代码逻辑来处理棋子。

我们可以在Model类中定义所有棋子的规则,但这会使Model类变得过于庞大。

因此,让我们在一个名为 piece.py 的新文件中定义棋子相关的逻辑。由于这本质上是 Model 类的一部分,但它定义在一个新文件中,所以让我们在这个文件内添加对 Model 类的引用。

(参见 4.02piece.py)

我们接下来做这个。

创建一个 Piece 类

想想看。我们需要为所有不同的棋子定义规则。一些属性和方法,例如颜色,将适用于所有棋子,而其他属性/方法,例如移动规则,将因每个棋子而异。

首先,我们将定义一个新的 Piece 类。这个类将包含所有棋子共有的属性和方法。然后,我们将为每个单独的棋子定义一个类,作为这个父 Piece 类的子类。我们可以在这些单独的类中重写所有的属性和方法。代码将看起来像这样(参见 4.02piece.py):

from configurations import *

class Piece():
  def __init__(self, color):
    self.name = self.__class__.__name__.lower()
    if color == 'black':
      self.name = self.name.lower()
    elif color == 'white':
      self.name = self.name.upper()
      self.color = color

def keep_reference(self, model):
   self.model = model

class King(Piece):
  pass

class Queen(Piece):
  pass

class Rook(Piece):
  pass

class Bishop(Piece):
  pass

class Knight(Piece):
  pass

class Pawn(Piece):
  pass

注意,Piece 类在对象创建时需要 color 作为参数。我们在类中创建了两个属性,分别命名为 self.nameself.color

还要注意keep_reference(self, model)方法的定义。由于Piece类不过是Model类的一个扩展,因此在这个方法中我们需要获取到Model类的引用以便与之通信。

同样,Model 类需要引用新的 Piece 类。因此,我们将此作为导入添加到 Model 类中,如下所示(见 4.02model.py):

import piece

最后,我们需要一个方法,它接受一个与给定棋子对象名称相关的字符串,并创建一个新的棋子对象。例如,我们需要一个方法,给定参数(兵,黑色)或简单地("p"),动态创建一个新的具有颜色属性定义为黑色的Pawn对象。

因此,让我们在piece.py文件中但不在Piece类外部定义一个辅助方法,如下所示(见4.02piece.py):

def create_piece (piece, color='white'):
  if isinstance(piece, str):
    if piece.upper() in SHORT_NAME.keys():
      color = "white" if piece.isupper() else "black"
      piece = SHORT_NAME[piece.upper()]
    piece = piece.capitalize()
    if piece in SHORT_NAME.values():
      return eval("{classname} (color)".format(classname=piece))
 raise exceptions.ChessError("invalid piece name: '{}'".format(piece))

为了支持上述方法,请将以下常量添加到configurations.py文件中(参见4.02):

SHORT_NAME = {
 'R':'Rook', 'N':'Knight', 'B':'Bishop', 'Q':'Queen', 'K':'King', 'P':'Pawn'
 }

上述代码简单地接受单个字符作为输入。然后它获取对应棋子类的完整名称(例如,如果输入的是 p,它将获取完整的名称,即 Pawn)。接着它检查字符的大小写,如果输入字符是大写,则将颜色变量定义为 白色。否则,颜色设置为 黑色。然后它动态创建一个对应的棋子对象。

这就结束了迭代。我们已经创建了Piece类及其所有子类,并且能够从给定的输入字符动态创建Piece对象。这个类仅仅是Model类的一个扩展,两个类都可以通过保持对彼此的引用来访问对方的方法。

在棋盘上显示棋子

现在,让我们将注意力转向在棋盘上显示所有棋子。

首先,我们将定义一个名为 draw_single_piece 的方法,该方法在给定位置并传入棋子字符时,将在该位置绘制一个棋子,其字符表示如下(参见 4.03view.py):

def draw_single_piece(self, position, piece):
  x, y = self.controller.get_numeric_notation(position)
  if piece:
    filename = "../pieces_image/{}_{}.png".format(piece.name.lower(), piece.color)
    if filename not in self.images:
      self.images[filename] = PhotoImage(file=filename)
    x0, y0 = self.calculate_piece_coordinate(x, y)
    self.canvas.create_image(x0, y0, image=self.images[filename], 
                          tags=("occupied"),  anchor="c")

以下是对前面代码的描述:

  • 棋子的图片存储在一个名为 pieces_image 的文件夹中,并以 lowercase + _ + color.png 格式命名。例如,黑后棋子的图片保存为 queen_black.png

  • 通过使用canvas.create_image()方法将图像添加到棋盘上,该方法需要xy坐标以及一个以图像文件位置为参数的PhotoImage()对象。

  • 我们使用了 Tkinter 的PhotoImage类来引用.png文件。

  • 除了在棋盘上创建和显示棋子外,我们还用自定义标签occupied对它们进行了标记。标记是 Canvas 小部件的一个重要功能,它使我们能够唯一地识别放置在 Canvas 小部件上的项目。

我们在前面代码中使用了以下辅助方法(见4.03view.py):

def calculate_piece_coordinate(self, row, col):
  x0 = (col * DIMENSION_OF_EACH_SQUARE) + int(DIMENSION_OF_EACH_SQUARE / 2)
  y0 = ((7 - row) * DIMENSION_OF_EACH_SQUARE) + 
    int(DIMENSION_OF_EACH_SQUARE / 2)
  return (x0, y0)

我们还定义了另一个辅助方法,该方法返回棋子位置的数字表示(参见4.03controller.py):

def get_numeric_notation(self, position):
  return piece.get_numeric_notation(position)

这只是对以下来自4.03piece.py的代码的一个包装:

def get_numeric_notation(rowcol):
  row, col = rowcol
  return int(col)-1, X_AXIS_LABELS.index(row) 

现在,只需在所有棋子(4.03view.py)上调用前面的 draw_single_piece 方法:

def draw_all_pieces(self):
 self.canvas.delete("occupied")
 for position, piece in  self.controller.get_all_pieces_on_chess_board():
   self.draw_single_piece(position, piece)

在这里需要你注意的一个关键方面是,当我们需要从Model类获取一些数据时,比如说,一个包含棋盘上所有棋子的字典,我们并没有直接调用Model类来获取数据。相反,我们请求控制器从模型中获取数据。get_all_pieces_on_chess_board()控制器方法只是Model类实际方法的包装(参见4.03controller.py):

def get_all_pieces_on_chess_board(self):
  return self.model.items()

太好了!我们现在已经有了在棋盘上绘制所有棋子的方法。但仅仅定义它们是不够的。这些方法需要从某个地方被调用。因此,让我们定义一个新的方法名为 start_new_game() 并在 View 类的 __init__ 方法中调用它,如下所示(见 4.03view.py):

 def start_new_game(self):
   self.controller.reset_game_data()
   self.controller.reset_to_initial_locations()
   self.draw_all_pieces()

除了调用draw_all pieces()方法外,此方法还通过调用两个包装控制器方法来重置Model(参见4.03controller.py):

def reset_game_data(self):
  self.model.reset_game_data()

def reset_to_initial_locations(self):
  self.model.reset_to_initial_locations()

实际方法在Model类中定义,如下所示:

def reset_game_data(self):
  captured_pieces = {'white': [], 'black': []}
  player_turn = None
  halfmove_clock = 0
  fullmove_number = 1
  history = []

def reset_to_initial_locations(self):
  self.clear()
  for position, value in START_PIECES_POSITION.items():
    self[position] = piece.create_piece(value)
    self[position].keep_reference(self)
    self.player_turn = 'white'

reset_game_data() 方法很简单。它只是将 Model 类的所有属性重置为其初始状态。

reset_to_initial_locations() 方法将所有棋子位置初始化为反映游戏起始位置。如果你了解我们之前讨论过的数据结构,这一点也应该不言而喻。

现在,当你运行代码(见4.03view.py)时,棋盘应该显示所有棋子在游戏起始位置的状态,如下面的截图所示:

图片

这完成了当前的迭代。下一个迭代将定义棋盘上棋子移动的规则。在我们考虑移动棋子之前,我们需要这部分完成。

定义棋子的规则

不同棋子的移动规则各不相同。让我们尝试将这些规则制成表格:

允许移动 |

国王 1
王后 8
车卒 8
主教 8
骑士 N/A N/A N/A
是的,但可以斜着吃子 1 或 2

如表格所示,除了之外,所有棋子的规则都非常简单明了。

骑士与其他棋子不同。它们必须在一个方向上移动两个方格,然后以 90 度角再移动一步,形成 L 形。骑士也是唯一能够跳过其他棋子的棋子。

兵棋向前移动,但它们可以斜向捕获。兵棋每次只能向前移动一个方格,除了它们的第一步移动,那时它们可以向前移动两个方格。兵棋只能斜向捕获它们前方的一个方格。

国王、王后、车和象的规则

让我们先看看正交和对角移动的简单棋子案例,这些是国王、王后、车和象。我们需要找到一种方法,通过数学规则来改变这些棋子的位置。

以下图表展示了将棋子从当前位置(比如说 xy)沿正交和斜向移动所需的步骤:

图片

如果您查看前面的图表,x 代表列号,而 y 代表行号。很明显,我们可以通过向当前位置添加元组(-1, 0)、(0, 1)、(1, 0)、(0, -1)中的项来表示正交移动。

同样,对角线移动可以通过向元组(-1, 1),(1, 1),(1, -1),(-1, -1)中添加来表示。

让我们在configurations.py文件中添加这两个元组(见4.04),如下所示:

ORTHOGONAL_POSITIONS = ((-1,0),(0,1),(1,0),(0, -1))
DIAGONAL_POSITIONS = ((-1,-1),(-1,1),(1,-1),(1,1)) 

如果一个棋子可以同时进行水平和斜向移动,例如皇后,那么其代表元组就是前两个元组的简单相加。

如果一个棋子可以移动超过一个方格,那么只需将代表元组乘以一个整数,就可以得到棋盘上所有允许的其他位置。

带着这些信息,让我们编写一个moves_available方法,该方法根据棋子的当前位置、与棋子相关的方向元组以及棋子可以移动的最大距离,返回所有允许的allowed_moves列表,如下所示(参见4.04piece.py):

def moves_available(self, current_position, directions,distance):
  model = self.model
  allowed_moves = []
  piece = self
  start_row, start_column = get_numeric_notation(current_position)
  for x, y in directions:
    collision = False
    for step in range(1, distance + 1):
      if collision: break
      destination = start_row + step * x, start_column + step * y
      if self.possible_position(destination) not in 
        model.all_occupied_positions():
         allowed_moves.append(destination)
      elif self.possible_position(destination) in
                         model.all_positions_occupied_by_color 
                           (piece.color):
         collision = True
      else:
         allowed_moves.append(destination)
         collision = True
  allowed_moves = filter(model.is_on_board, allowed_moves)
  return map(model.get_alphanumeric_position, allowed_moves)

以下是对前面代码的描述:

  • 根据参数,该方法将给定棋子的所有允许移动收集到一个名为 allowed_moves 的列表中。

  • 代码遍历所有位置以检测可能的碰撞。如果检测到碰撞,它将跳出循环。否则,它将坐标添加到allowed_moves列表中。

  • 倒数第二行过滤掉所有超出棋盘范围的走法,最后一行返回所有允许走法的等效位置,以字母数字符号表示。

我们还可以定义几个辅助方法来支持前面的方法,具体如下:

def possible_position(self, destination): #4.04 piece.py
  return self.model.get_alphanumeric_position(destination)

def all_positions_occupied_by_color(self, color): #4.04 model.py
  result = []
  for position in self.keys():
    piece = self.get_piece_at(position)
    if piece.color == color:
      result.append(position)
  return result

def all_occupied_positions(self): #4.04 model.py
  return self.all_positions_occupied_by_color('white') +\
         self.all_positions_occupied_by_color('black')

接下来,让我们按照以下方式修改国王、王后、车和象的Piece子类(参见4.04piece.py):

class King(Piece):
  directions = ORTHOGONAL_POSITIONS + DIAGONAL_POSITIONS
  max_distance = 1

  def moves_available(self,current_position):
     return super().moves_available(current_position, self.directions, self.max_distance)

class Queen(Piece):
  directions = ORTHOGONAL_POSITIONS + DIAGONAL_POSITIONS
  max_distance = 8

  def moves_available(self,current_position):
    return super(Queen, self).moves_available
               (current_position, self.directions, self.max_distance)

class Rook(Piece):
  directions = ORTHOGONAL_POSITIONS
  max_distance = 8

  def moves_available(self,current_position):
      return super(Rook, self).moves_available(current_position,
                                    self.directions, self.max_distance)

class Bishop(Piece):
  directions = DIAGONAL_POSITIONS
  max_distance = 8

  def moves_available(self,current_position):
     return super(Bishop, self).moves_available
              (current_position, self.directions, self.max_distance)

骑士规则

骑士是一种不同的棋子,因为它既不沿正交方向也不沿对角线移动。它还可以跳过其他棋子。

就像我们之前遵循的规则来达到ORTHOGONAL_POSITIONSDIAGONAL_POSITIONS,我们同样可以得出确定KNIGHT_POSITIONS元组的所需规则。这定义在4.04configurations.py中,如下所示:

KNIGHT_POSITIONS = ((-2,-1),(-2,1),(-1,-2),(-1,2),(1,-2),(1,2),(2,-1),(2,1))

接下来,让我们重写Knight类中的moves_available方法(参见代码4.04piece.py):

class Knight(Piece):

 def moves_available(self, current_position):
   model = self.model
   allowed_moves = []
   start_position = get_numeric_notation(current_position.upper())
   piece = model.get(pos.upper())
   for x, y in KNIGHT_POSITIONS:
     destination = start_position[0] + x, start_position[1] + y
     if(model.get_alphanumeric_position(destination) not 
             in model.all_positions_occupied_by_color(piece.color)):
       allowed_moves.append(destination)
   allowed_moves = filter(model.is_on_board, allowed_moves)
   return map(model.get_alphanumeric_position, allowed_moves)

以下是对前面代码的描述:

  • 该方法与之前的超类方法相当相似。然而,与超类方法不同,这些变化是通过使用KNIGHT_POSITIONS元组来表示的捕获移动。

  • 与超类不同,我们不需要跟踪碰撞,因为骑士可以跳过其他棋子。

车的规则

车兵也有独特的移动方式,它向前移动,但可以斜着吃子。我们可以类似地从Pawn类内部覆盖moves_available类,如下所示(参见4.04piece.py):

class Pawn(Piece):

  def moves_available(self, current_position):
    model = self.model
    piece = self
    if self.color == 'white':
      initial_position, direction, enemy = 1, 1, 'black'
    else:
      initial_position, direction, enemy = 6, -1, 'white'
    allowed_moves = []
    # Moving
    prohibited = model.all_occupied_positions()
    start_position = get_numeric_notation(current_position.upper())
    forward = start_position[0] + direction, start_position[1]
    if model.get_alphanumeric_position(forward) not in prohibited:
      allowed_moves.append(forward)
      if start_position[0] == initial_position:
        # If pawn is in starting position allow double  moves
        double_forward = (forward[0] + direction, forward[1])
        if model.get_alphanumeric_position(double_forward) not in 
          prohibited:
            allowed_moves.append(double_forward)
    # Attacking
    for a in range(-1, 2, 2):
      attack = start_position[0] + direction,
      start_position[1] + a
      if model.get_alphanumeric_position(attack) in
                 model.all_positions_occupied_by_color(enemy):
         allowed_moves.append(attack)
    allowed_moves = filter(model.is_on_board, allowed_moves)
    return map(model.get_alphanumeric_position, allowed_moves)

以下是对前面代码的描述:

  • 我们首先根据兵是黑色还是白色,分配了初始行位置、方向和敌人变量。

  • 与之前的 moves_allowed 方法类似,此方法将所有允许的移动收集到一个名为 allowed_moves 的空白列表中。

  • 然后,我们通过连接所有黑白棋子占据的方格的两个列表,收集了一个所有禁止移动的列表。

  • 我们定义了一个名为forward的变量,它保存了棋子当前位置前方正方形的坐标。

  • 如果前方有棋子,则兵不能向前移动。如果前方位置没有被禁止,则该位置将被添加到allowed_moves列表中。兵可以从起始位置向前移动两格。我们检查当前位置是否是起始位置,如果是起始位置,则将双倍移动添加到allowed_moves列表中。

  • 车棋只能捕获其前方斜线上的棋子。因此,我们分配了一个变量来追踪棋盘上斜线相邻的位置。如果斜线相邻的方格被敌方棋子占据,那么该位置符合添加到allowed_moves列表的条件。

  • 然后,我们将列表过滤,移除所有可能超出棋盘边界的位置。最后一行返回所有允许的走法,以对应的双字符代数符号列表形式呈现,正如我们在所有之前的定义中所做的那样。

这完成了当前迭代。我们编写了执行棋盘上棋子移动相关规则的逻辑代码。

棋子的移动验证

在我们允许棋子移动之前,我们必须记录下棋盘上所有可能的移动选项。在每一步移动中,我们还需要检查是否是给定玩家的合法回合,并且提议的移动不应该对当前玩家的国王造成将军。

现在,检查可能不仅来自被移动的棋子,还可能来自棋盘上因这种移动而产生的任何其他棋子。因此,在每一步之后,我们需要计算对手所有棋子的可能走法。

因此,我们需要两种方法来完成以下任务:

  • 跟踪记录一个玩家的所有可用移动

  • 检查是否有对国王的检查

让我们在Model类中添加两种新的方法(参见4.05model.py)。

跟踪所有可用的移动

用于跟踪玩家所有可用移动的代码如下:

def get_all_available_moves(self, color):
  result = []
  for position in self.keys():
    piece = self.get_piece_at(position)
    if piece and piece.color == color:
       moves = piece.moves_available(position)
       if moves:
         result.extend(moves)
  return result

代码的描述如下:

  • 我们已经在之前的迭代中编写了moves_available方法

  • 上述方法简单地遍历字典中的每个项目,并将给定颜色的每个棋子的moves_available结果追加到名为result的列表中

查找国王当前的位置

在我们编写检查国王是否处于将军状态的代码之前,我们首先需要知道国王的确切位置。让我们定义一个方法来找出国王的当前位置,如下所示(见4.05model.py):

 def get_alphanumeric_position_of_king(self, color):
   for position in self.keys():
     this_piece = self.get_piece_at(position)
     if isinstance(this_piece, piece.King) and this_piece.color == color:
        return position

上述代码简单地遍历字典中的所有项。如果给定位置是King类的实例,它就简单地返回其位置。

检查国王是否处于将军状态

让我们定义一种方法来检查国王是否受到对手的将军,具体如下:

def is_king_under_check(self, color):
  position_of_king = self.get_alphanumeric_position_of_king(color)
  opponent = 'black' if color =='white' else 'white'
  return position_of_king in self.get_all_available_moves(opponent) 

以下是对前面代码的描述:

  • 首先,我们获取了国王的当前位置和对手的颜色。

  • 我们随后找出对手所有棋子的所有可能走法。如果国王的位置与所有可能走法中的任何一个位置重合,那么国王处于将军状态,我们返回True。否则,我们返回False

这完成了迭代的目标。我们现在可以检查游戏在某个特定时刻所有可用的棋子移动。我们还可以检查是否对方正在将军。

使游戏功能化

现在我们已经设置了所有棋子和棋盘相关的验证规则,让我们给我们的棋程序注入生命力。在这个迭代中,我们将使我们的棋游戏完全功能化。

本迭代的目的是通过点击左鼠标按钮来移动棋子。当玩家点击一个棋子时,代码应首先检查该棋子是否处于合法的移动回合。

在第一次点击时,需要移动的棋子被选中,并且该棋子所有允许的走法在棋盘上都会被高亮显示。第二次点击应该执行在目标方格上。如果第二次点击是在一个有效的目标方格上,那么棋子应该从起始方格移动到目标方格。

我们还需要编码捕捉棋子和国王被将军的事件。需要跟踪的其他属性包括已捕获的棋子列表、半回合时钟计数、全回合数计数以及所有先前移动的历史记录。

你可能还记得我们创建了一个与左键点击事件绑定的虚拟方法。目前,该方法只是简单地将在控制台上打印行和列的值。

让我们修改这个方法,如下所示(见 4.06view.py):

def on_square_clicked(self, event):
  clicked_row, clicked_column = self.get_clicked_row_column(event)
  position_of_click =  self.controller.get_alphanumeric_position 
                                   ((clicked_row, clicked_column))
  if self.selected_piece_position: # on second click
     self.shift(self.selected_piece_position, position_of_click)
     self.selected_piece_position = None
  self.update_highlight_list(position_of_click)
  self.draw_board()
  self.draw_all_pieces()

以下是对前面代码的描述:

  • 代码的第一部分计算您点击的棋子的坐标。根据计算出的坐标,它将相应的字母标记存储在一个名为position_of_click的变量中。

  • 然后它尝试将片段变量分配给相应的片段实例。如果点击的方格上没有片段实例,它就简单地忽略这次点击。

  • 该方法的第二部分检查这是否是意图将棋子移动到目标格子的第二次点击。如果是第二次点击,它将调用shift方法,并将源坐标和目标坐标作为其两个参数传递。

  • 如果shift方法成功执行,它将所有之前设置的属性重置为其原始的空值,并调用draw_boarddraw_pieces方法来重新绘制棋盘和棋子。

在编写on_square_clicked方法所需的功能时,我们在其中调用了几个新的方法。我们需要定义这些新方法。

密切关注on_square_clicked方法。这是所有其他方法在尝试使棋盘游戏功能化的过程中演变的核心方法。

获取源位置和目标位置

我们从on_square_clicked方法中调用了shift方法。shift方法的代码负责收集shift操作所需的必要参数。

shift 方法的代码如下:

def shift(self, start_pos, end_pos):
  selected_piece = self.controller.get_piece_at(start_pos)
  piece_at_destination =  self.controller.get_piece_at(end_pos)
  if not piece_at_destination or piece_at_destination.color
                      != selected_piece.color:
     try:
        self.controller.pre_move_validation(start_pos, end_pos)
     except exceptions.ChessError as error:
        self.info_label["text"] = error.__class__.__name__
  else:
     self.update_label(selected_piece, start_pos, end_pos)

代码首先检查目标位置是否存在棋子。如果目标方格上不存在棋子,它将调用来自控制器的方法,shift,这是一个围绕Model类实际shift方法的包装器。

收集需要突出显示的动作列表

我们也从on_square_clicked方法中调用了update_highlight_list(position)方法。这个方法的目的在于收集给定棋子在列表all_squares_to_be_highlighted中所有可能的走法。

实际上,可用的移动聚焦发生在 GUI 类的draw_board方法中。该方法的代码如下(见4.06view.py):

def update_highlight_list(self, position):
  self.all_squares_to_be_highlighted = None
  try:
    piece = self.controller.get_piece_at(position)
  except:
    piece = None
  if piece and (piece.color == self.controller.player_turn()):
    self.selected_piece_position = position
  self.all_squares_to_be_highlighted = list(map(self.controller.get_numeric_notation,
                   self.controller.get_piece_at(position).moves_available(position)))

突出显示允许移动的选项

on_square_clicked方法中,我们调用了draw_board方法来处理棋子坐标的重绘或更改。当前的draw_board方法尚未准备好处理这种情况,因为我们最初只设计它在一轮迭代中为我们提供一个空白的棋盘。

首先,让我们在configurations.py文件中添加一个HIGHLIGHT_COLOR常量,具体如下:

HIGHLIGHT_COLOR = "#2EF70D"

然后,修改draw_board方法以处理这个问题,如下所示(见4.06view.py):

def draw_board(self):
  current_color = BOARD_COLOR_2
  for row in range(NUMBER_OF_ROWS):
     current_color = self.get_alternate_color(current_color)
     for col in range(NUMBER_OF_COLUMNS):
        x1, y1 = self.get_x_y_coordinate(row, col)
        x2, y2 = x1 + DIMENSION_OF_EACH_SQUARE, y1 + 
          DIMENSION_OF_EACH_SQUARE
        if(self.all_squares_to_be_highlighted and (row, col) in 
                       self.all_squares_to_be_highlighted):
           self.canvas.create_rectangle(x1, y1, x2, y2, 
             fill=HIGHLIGHT_COLOR)
        else:
           self.canvas.create_rectangle(x1, y1, x2, y2, fill=current_color)
        current_color = self.get_alternate_color(current_color)

预迁移验证

国际象棋的棋子只有在不会违反游戏规则的情况下才能移动。例如,一个棋子只能移动到未被同色棋子占据的有效位置。同样,一个棋子只有在轮到该玩家移动时才能移动。另一条规定是,一个棋子只有在移动结果不会对该色国王构成将军的情况下才能移动。

这个 pre_move_validation 方法负责检查所有规则。如果所有验证都通过,它将调用 move 方法来更新移动,如下所示(见 4.06model.py):

    def pre_move_validation(self, initial_pos, final_pos):
        initial_pos, final_pos = initial_pos.upper(), final_pos.upper()
        piece = self.get_piece_at(initial_pos)
        try:
            piece_at_destination = self.get_piece_at(final_pos)
        except:
            piece_at_destination = None
        if self.player_turn != piece.color:
            raise exceptions.NotYourTurn("Not " + piece.color + "'s turn!")
        enemy = ('white' if piece.color == 'black' else 'black')
        moves_available = piece.moves_available(initial_pos)
        if final_pos not in moves_available:
            raise exceptions.InvalidMove
        if self.get_all_available_moves(enemy):
            if self.will_move_cause_check(initial_pos, final_pos):
                raise exceptions.Check
        if not moves_available and self.is_king_under_check(piece.color):
            raise exceptions.CheckMate
        elif not moves_available:
            raise exceptions.Draw
        else:
            self.move(initial_pos, final_pos)
            self.update_game_statistics(
                piece, piece_at_destination, initial_pos, final_pos)
            self.change_player_turn(piece.color)

如果规则没有被遵循,此代码会引发几个异常,这些异常在异常类中如下定义(见4.06exceptions.py):

 class Check(ChessError): pass
 class InvalidMove(ChessError): pass
 class CheckMate(ChessError): pass
 class Draw(ChessError): pass
 class NotYourTurn(ChessError): pass

我们本可以进一步对错误类别进行编码,但我们选择不这样做,因为我们只是将错误类别的名称更新为底部标签,这对我们当前的目的来说已经足够了。错误信息是从View类的位移方法中显示的,如下所示(见4.06view.py):

self.info_label["text"] = error.__class__.__name__

检查一个移动是否会导致对国王的将军(即王后将受到攻击)

虽然前面几行中进行的验证检查的大部分是简单的,但其中一步验证需要检查一个移动是否会使得国王处于被将军的状态。这是一个棘手的情况。我们只能在实际移动之后才能找出这一点。然而,我们不允许这种移动在棋盘上发生。

要实现这一点,pre_move_validation 方法调用了一个名为 will_move_cause_check 的方法,该方法创建了一个 Model 类的副本。然后,它在新创建的临时副本上进行移动操作,以检查这会不会导致国王处于被将军的状态。相应的代码如下 (4.06model.py):

def will_move_cause_check(self, start_position, end_position):
  tmp = deepcopy(self)
  tmp.move(start_position, end_position)
  return tmp.is_king_under_check(self[start_position].color)

注意,当你通过简单赋值创建一个副本时,Python 会创建一个浅拷贝。在浅拷贝中,两个变量现在共享相同的数据。因此,对其中一个位置的修改也会影响另一个。

与此相反,深拷贝会创建一个包含所有内容的副本——包括结构和元素。我们需要创建棋盘的深拷贝,因为我们希望在国王实际移动之前检查其是否进行了一次有效的移动,并且我们希望在不以任何方式修改原始对象状态的情况下完成这项操作。

记录数据结构中的移动

view.py 中定义的 shift 方法负责在棋盘上实际移动棋子。然而,这会导致底层数据结构发生变化。因此,Model 类的 move 方法负责更新数据结构。只有当没有错误抛出时,这个 move 方法才会从之前定义的 pre_move_validation() 方法中被调用,如下所示(4.06model.py):

def move(self, start_pos, final_pos):
 self[final_pos] = self.pop(start_pos, None)

注意,一旦这次更新完成,控制权就会返回到view.py中的on_square_clicked()方法. 然后,该方法会调用draw_all_pieces()方法,该方法会更新视图。

保持游戏统计数据

pre_move_validation() 方法在成功记录一个移动后,也会调用另一个名为 update_game_statistics() 的方法(参见 4.06model.py):

    def update_game_statistics(self, piece, dest, start_pos, end_pos):
        if piece.color == 'black':
            self.fullmove_number += 1
        self.halfmove_clock += 1
        abbr = piece.name
        if abbr == 'pawn':
            abbr = ''
            self.halfmove_clock = 0
        if dest is None:
            move_text = abbr + end_pos.lower()
        else:
            move_text = abbr + 'x' + end_pos.lower()
            self.halfmove_clock = 0
        self.history.append(move_text)

恭喜,我们的棋局现在已启用!

让我们通过将文件 | 新游戏菜单项绑定到启动新游戏来完成迭代。之前,我们定义了 start_new_game() 方法。现在,只需从 on_new_game_menu_clicked() 方法中调用它即可,如下所示 (4.06view.py):

def on_new_game_menu_clicked(self):
  self.start_new_game()

管理用户偏好

在多个图形用户界面程序中,一个非常常见的主题是让用户设置程序的偏好设置。

例如,如果我们想让用户能够自定义棋盘颜色怎么办?如果我们想让用户选择颜色,一旦选择,它就被保存为用户偏好,并在下次程序运行时加载?让我们把这个作为一个功能来实现。

Python 提供了一个名为configparser的标准模块,允许我们保存用户偏好设置。让我们看看configparser模块的实际应用。

首先,从configparser模块中导入ConfigParser类到configurations.py文件中,如下所示(参见4.07 preferenceswindow.py):

from configparser import ConfigParser

configparser模块使用.ini文件来存储和读取配置值。该文件包含一个或多个命名部分。这些部分包含具有名称和值的单个选项。

为了说明这一点,让我们在项目的根目录下创建一个名为 chess_options.ini 的文件(参见 4.07)。该文件看起来是这样的:

[chess_colors]
 board_color_1 = #DDB88C
 board_color_2 = #A66D4F
 highlight_color = #2EF70D

方括号内(在我们的示例中为 [chess_colors])的第一行被称为部分。一个 .ini 文件可以有多个部分。此文件只有一个部分。每个部分可以有多个如示例中指定的 键值 选项。

我们可以通过使用getter方法在我们的程序中读取这些值,如下所示(参见4.07configurations.py):

config = ConfigParser()
config.read('chess_options.ini')
BOARD_COLOR_1 = config.get('chess_colors', 'board_color_1', 
  fallback="#DDB88C")
BOARD_COLOR_2 = config.get('chess_colors', 'board_color_2', fallback = 
  "#A66D4F")
HIGHLIGHT_COLOR =config.get('chess_colors', 'highlight_color', fallback 
  = "#2EF70D")

上述代码替换了我们在代码中之前定义的三个颜色常量。

现在,如果你在 .ini 文件中更改选项,棋盘的颜色会相应地改变。然而,我们无法期望最终用户熟悉编辑 .ini 文件。因此,我们将让他们通过 Tkinter 的 颜色选择器 模块来选择颜色。用户选择的颜色会反映在 .ini 文件中,并相应地显示在棋盘上。

当用户点击编辑 | 预设菜单项时,我们希望打开一个 短暂窗口,其中包含三个不同的按钮,用于选择两种棋盘颜色和一种高亮颜色。点击单个按钮将打开一个颜色选择窗口,如下面的截图所示:

图片

我们在名为 preferenceswindow.py 的新文件中创建了这个短暂的窗口(参见 4.07.py)。我们不会讨论创建此窗口的代码,因为这对你来说现在应该是一个简单的任务。

注意,此窗口通过以下代码转换为相对于顶级窗口的瞬态窗口:

self.pref_window.transient(self.parent)

作为提醒,一个瞬态窗口是指始终位于其父窗口顶部的窗口。当其父窗口最小化时,它也会被最小化。若想快速回顾瞬态窗口的相关内容,请参阅第二章,制作一个文本编辑器—2.06.py

正如我们在 preferencewindow.py 中创建了窗口,我们将将其导入到 View 类中,如下所示(参见 2.07view.py):

import preferenceswindow

然后,通过以下两种方法使用命令绑定首选项菜单:

def on_preference_menu_clicked(self):
  self.show_prefereces_window()

def show_prefereces_window(self):
  preferenceswindow.PreferencesWindow(self)

当用户点击取消按钮时,我们只想让设置窗口关闭。为此,请使用以下代码(参见4.07preferencewindow.py):

def on_cancel_button_clicked(self):
  self.pref_window.destroy()

当用户更改颜色并点击保存按钮时,该方法会调用set_new_values()方法,该方法首先将新值写入.ini文件,然后将值返回给View类以立即更新棋盘:

def set_new_values(self):
  color_1 = self.board_color_1.get()
  color_2 = self.board_color_2.get()
  highlight_color = self.highlight_color.get()
  config = ConfigParser()
  config.read('chess_options.ini')
  config.set('chess_colors', 'board_color_1',color_1)
  config.set('chess_colors', 'board_color_2',color_2)
  config.set('chess_colors', 'highlight_color', highlight_color)
  configurations.BOARD_COLOR_1 = self.board_color_1.get()
  configurations.BOARD_COLOR_2 = self.board_color_2.get()
  configurations.HIGHLIGHT_COLOR = self.highlight_color.get()
  with open('chess_options.ini', 'w') as config_file:
     config.write(config_file)

当前面的代码将新值写入.ini文件时,请从View类中调用reload_colors()方法来立即更新棋盘的颜色。如果不这样做,颜色变化将在下次运行棋程序时发生(参见4.07view.py):

def reload_colors(self, color_1, color_2, highlight_color):
 self.board_color_1 = color_1
 self.board_color_2 = color_2
 self.highlight_color = highlight_color
 self.draw_board()
 self.draw_all_pieces()

修改了这些属性后,我们调用 draw_board()draw_all_pieces() 函数来以新定义的颜色重新绘制棋盘。(见 4.07view.py)。

这就完成了迭代。程序的用户可以根据自己的喜好更改颜色,程序将记住所选的值。

摘要

我们已经到达了这一章节的结尾。那么,我们在这里都取得了哪些成果呢?让我们来看看我们从这一章节中学到的所有关键要点。

我们学习了如何使用 MVC 架构来构建程序。

我们瞥了一眼 Tkinter Canvas 小部件的多样性和强大功能。这包括对画布坐标、对象 ID 和标签的基本用法进行了一次巡礼。

我们讨论了如何通过实现模块化结构来处理复杂性。我们通过将代码分解成几个较小的文件来实现这种模块化。我们从一个文件中处理整个配置,并在另一个文件中处理所有错误。

我们探讨了如何扩展 Python 的内置错误类来定义自定义错误和异常。我们还查看了一下如何扩展 Python 的内置数据类型,例如在 Model 类的情况下,它直接扩展了 dict 类。

我们研究了如何在构建Piece类及其所有子类时,利用对象继承来编写具有相似属性和行为的类。

最后,你学会了如何使用 Python 的内置 configparser 模块来存储用户偏好。

我们将在下一章创建一个音频播放器。除此之外,我们还将使用几个新的小部件。我们还将探讨如何创建我们自己的小部件!

QA 部分

在你继续阅读下一章之前,请确保你能回答这些问题

满意度:

  • 模型-视图-控制器框架的核心原则是什么?

  • 什么是编程中的模块化?为什么模块化是好的?

  • 使用类继承在程序中的优缺点是什么?

  • 虽然继承为我们提供了一个重用代码的工具,但许多专家都不赞成使用多重继承。这其中的原因可能是什么呢?

  • Tkinter Canvas 小部件中标签用于什么?

  • 为什么我们使用configparser模块?使用configparser模块有哪些替代方案?

进一步阅读

MVC 是一种流行的软件 架构模式,但还有许多其他适合不同用例的架构模式。了解更多不同的架构模式,请参阅 en.wikipedia.org/wiki/Architectural_pattern

如果你热衷于下棋,或者想要开始学习人工智能,你可能会尝试实现一个作为对手的棋引擎。这需要阅读一些关于最优搜索算法的资料。这里有一个教程,它将引导我们完成这个过程:medium.freecodecamp.org/simple-chess-ai-step-by-step-1d55a9266977。教程中的引擎是用 JavaScript 实现的,但我们可以用它作为参考来构建我们自己的 Python 引擎。

第五章:构建一个音频播放器

让我们构建一个音频播放器!我们的应用程序应该具备典型音频播放器提供的功能,例如播放、暂停、快进、快退、下一曲、静音、音量调节、时间搜索等。它应该让听众能够轻松访问他们本地驱动器上的媒体文件或媒体库。音频播放器应该做到所有这些,并且更多。让我们开始吧!

以下为本章节的关键目标:

  • 探索 Tkinter 小部件,即滑块(Slider)、列表框(Listbox)、单选按钮(Radiobutton)和画布(Canvas)

  • 通过扩展现有小部件在 Tkinter 中创建新小部件

  • 理解虚拟活动及其用法

  • 学习在 Tkinter 动画中使用的最常见编码模式

  • 学习一些常见的 Tkinter 扩展,如 Pmw、WCK 和 TIX

章节概述

让我们把我们的音频播放器称为Achtung Baby

音频播放器将能够播放AUMP2MP3OGG/VorbisWAVWMA格式的音频文件。它将具备您期望的小型媒体播放器所拥有的所有控制功能。

我们将使用跨平台模块来编写代码。这将确保玩家可以在 Windows、macOS X 和 Linux 平台上播放音频文件。

完成后,音频播放器将呈现如下:

图片

本章最重要的收获可能是学习如何创建自己的 Tkinter 小部件。

在前一张截图中的搜索栏是一个自定义小部件的例子,这个小部件在 Tkinter 中不是原生可用的,但为了这个特定的用例而手工制作。

在你学会了如何创建自定义小部件之后,你能够创造的内容将只受限于你的想象力。

外部库需求

除了 Python 的几个内置模块外,我们将在本项目中使用以下两个外部模块:

  1. 用于音频处理的 pyglet

  2. Pmw(代表Python megawidget)用于核心 Tkinter 中不可用的控件

Pyglet 模块

Pyglet 是一个跨平台的 Python 窗口和多媒体库。您可以从 bitbucket.org/pyglet/pyglet/wiki/Download 下载。

Pyglet 可以使用 pip 安装程序进行安装,这是 Python 的默认包管理器,通过以下命令进行安装:

pip3 install pyglet

Windows 用户也可以从www.lfd.uci.edu/~gohlke/pythonlibs/#pyglet下载并安装pyglet的二进制包。

Pyglet 需要另一个名为 AVbin 的模块来支持播放 MP2 和 MP3 等文件格式。AVbin 可以从 avbin.github.io 的下载部分获取。

Pmw Tkinter 扩展

我们将使用 Pmw Tkinter 扩展来编写一些在核心 Tkinter 中不可用的小部件功能。Pmw 可以通过使用 pip 命令行工具进行安装,如下所示:

pip3 install pmw 

Pmw 也可以从所有平台的源代码包中安装。该包可以从sourceforge.net/projects/pmw/下载。

安装完 pygletAVbin 和 Pmw 后,从 Python 壳中执行以下命令:

>> import pyglet, Pmw
>>> pyglet.version
 '1.3.0'
>>> Pmw.version()
 '2.0.1' 

如果命令执行没有错误信息,并且pyglet和 Pmw 的版本与前面代码中显示的相同,那么你就可以开始编写你的音频播放器了。

程序结构和宏观骨架

我们的首要目标是构建程序的广泛模块化结构。像往常一样,我们将数据结构、音频相关逻辑和展示逻辑分别保存在三个独立的文件中。因此,我们将创建三个独立的文件,分别命名为model.pyplayer.pyview.py(参见代码 5.01)。

让我们在各自的文件中创建一个空的Model类和一个空的Player类。以下是为5.01版本的model.py文件提供的代码:

class Model:
  def __init__(self):
    pass

这里是5.01版本的player.py代码:

import pyglet
class Player():
  def __init__(self):
    pass

接下来,让我们创建View类。现在我们将ModelPlayer类留空。然而,我们将通过编写大多数玩家视图元素来完成这个迭代。

让我们从在View类中导入所需的模块开始,如下所示:

import tkinter as tk
import tkinter.filedialog
import tkinter.messagebox
import tkinter.ttk

此外,在View命名空间中导入空的ModelPlayer类(参见代码5.01view.py):

import model
import player

然而,由于我们不希望将逻辑与其表示混合在一起,我们在Model类中不导入View。简而言之,Model类对如何将其数据呈现给前端用户一无所知。

注意,在这个程序中我们没有使用Controller类。我们在第四章,“棋盘游戏”中看到了如何使用控制器。虽然控制器是避免Model类和View类之间直接耦合的好方法,但对于像这样的小程序来说,它们可能有些过度。

现在,让我们创建顶层窗口。同时,我们还将创建ModelPlayer类的实例,并将它们作为参数传递给View类,如下所示(参见代码5.01view.py):

if __name__ == '__main__':
  root = Tk()
  root.resizable(width=False, height=False)
  player = player.Player()
  model = model.Model()
  app = View(root, model, player)
  root.mainloop()

现在模板代码已经编写完成,让我们开始编写实际的View类,如下所示(参见代码5.01view.py):

 class View:
   def __init__(self,root, model, player):
    self.root = root
    self.model = model
    self.player = player
    self.create_gui()

  def create_gui(self):
    self.root.title(AUDIO_PLAYER_NAME)
    self.create_top_display()
    self.create_button_frame()
    self.create_list_box()
    self.create_bottom_frame()
    self.create_context_menu()

__init__ 方法现在应该对你来说已经很熟悉了。__init__ 方法的最后一行调用了一个名为 create_gui 的方法,该方法负责创建整个 GUI。create_gui 方法反过来又简单地调用了五个不同的方法,其中每个方法负责创建 GUI 的不同部分。

我们通过在代码中添加 root.resizable(width=False, height=False) 使根窗口不可调整大小。

我们不会重新展示创建 GUI 的完整代码,因为我们之前已经编写过类似的控件。但是,这五种方法结合在一起,就能创建出以下截图所示的 GUI:

图片

为了区分,我们在前面的截图中也用不同的方式标记了这四个部分。第五种方法创建的是右键点击上下文菜单,这里没有显示。

用于创建所有这些图形用户界面元素的代码你现在应该已经熟悉了。然而,请注意以下几点关于代码的内容(参见代码 5.01view.py):

  • 在前面的代码中使用的所有图片都已存储在一个名为 icons 的单独文件夹中。

  • 我们已经使用网格几何管理器将所有元素放置在顶层窗口上。

  • 顶部显示区域通过使用 canvas.create_image() 方法创建了一个画布小部件,并放置了一个覆盖图像。当前播放的文本和顶部显示中的计时器是通过使用 canvas.create_text() 方法创建的。放置这些元素所使用的坐标是基于试错法决定的。作为提醒,画布坐标是从左上角测量的。

  • 按钮框架部分简单创建按钮,并使用图像代替文本,使用以下代码:

    button=tk.Button(parent, image=previous_track_icon)
    
  • 按钮框架部分也使用了 ttk Scale 小部件,它可以作为音量滑块使用。这是通过以下代码创建的:

    self.volume_scale = tkinter.ttk.Scale(frame, from_=0.0, to=1.0, command=self.on_volume_scale_changed)
    
  • Scale 控件中的fromto值被选为0.01.0,因为这些是pyglet库用来表示最小和最大音量的数字,这将在下一节中看到。

  • 列表框部分通过使用 Tkinter 列表框小部件来创建播放列表,该小部件使用以下代码:

    self.list_box = tk.Listbox(frame, activestyle='none', cursor='hand2', bg='#1C3D7D', fg='#A0B9E9', selectmode=tk.EXTENDED, height=10)
    
  • 上一段代码中的select mode=EXTENDED选项意味着这个列表框将允许一次性选择多个列表项。如果省略这一行,列表框小部件的默认行为是每次只允许选择一个项。

  • activestyle='none'选项意味着我们不希望为选定的项目添加下划线。

  • 列表框部分连接到滚动条小部件,这与我们在前面的章节中所做的是类似的。

  • 底部框架部分添加了几个图像按钮,就像我们之前做的那样。它还使用for循环创建了三个单选按钮小部件。

  • 最后,请注意,我们完全跳过了滚动条的创建,因为它是一个在 Tkinter 中未原生定义的自定义小部件。这是我们将在其独立部分中创建的内容。

列表框小部件通过 selectmode 选项提供了以下四种选择模式:

  • SINGLE: 这允许每次只选择一行

  • BROWSE(默认模式):这与SINGLE类似,但它允许你通过拖动鼠标来移动选择项

  • MULTIPLE: 这允许通过逐个点击项目进行多次选择

  • EXTENDED: 这允许使用 ShiftCtrl 键选择多个范围的项

除了创建所有这些小部件外,我们还为其中大多数小部件添加了命令回调。这些命令回调目前指向以下空的非功能方法(参见代码5.01view.py):

 on_previous_track_button_clicked()
 on_rewind_button_clicked()
 on_play_stop_button_clicked()
 on_pause_unpause_button_clicked()
 on_mute_unmute_button_clicked()
 on_fast_forward_button_clicked()
 on_next_track_button_clicked()
 on_volume_scale_changed(, value)
 on_add_file_button_clicked()
 on_remove_selected_button_clicked()
 on_add_directory_button_clicked()
 on_empty_play_list_button_clicked()
 on_remove_selected_context_menu_clicked()
 on_play_list_double_clicked(event=None)

这些方法现在都不起作用。我们将在这里结束迭代,因为在我们考虑使小部件起作用之前,我们还需要做几件事情。

决定数据结构

坚持模型第一的哲学,让我们花些时间来决定程序中合适的数据库结构或模型。

音频播放器的数据结构相当简单。我们期望模型做的只是跟踪播放列表。主要数据是一个名为 play_list 的列表,而 Model 类则负责向播放列表中添加和移除项目。

因此,我们为该程序设计了以下Model类(参见代码5.02model.py):

class Model:
  def __init__(self):
    self.__play_list = []

  @property
  def play_list(self):
     return self.__play_list

  def get_file_to_play(self, file_index):
    return self.__play_list[file_index]

  def clear_play_list(self):
    self.__play_list.clear()

  def add_to_play_list(self, file_name):
    self.__play_list.append(file_name)

  def remove_item_from_play_list_at_index(self, index):
    del self.__play_list[index]

前面的代码中没有什么花哨的地方。这个对象仅仅是一个 Python 列表,其中包含各种实用方法,可以用来向列表中添加和删除项目。

play_list 方法已被声明为一个属性,因此我们不需要为播放列表编写 getter 方法。这无疑是更符合 Python 风格的,因为像 play_list = self.play_list 这样的语句比 play_list = self.get_play_list() 更易于阅读。

创建玩家类

现在,我们来编写Player类的代码。这个类将负责处理音频播放及其相关功能,例如暂停、停止、快进、快退、音量调整、静音等等。

我们将使用pyglet库来处理这些功能。

Pyglet 是一个跨平台库,它使用 AVbin 模块来支持大量音频文件。

你可能想查看 pyglet 播放器的 API 文档,该文档可在 bitbucket.org/pyglet/pyglet/wiki/Home 找到。

您也可以通过在 Python 交互式壳中输入以下两行来访问 pyglet 媒体播放器类的文档:

>>> import pyglet
>>> help(pyglet.media) 

pyglet.readthedocs.org/的在线文档中,我们了解到可以通过以下代码播放音频文件:

 player= pyglet.media.Player()
 source = pyglet.media.load(<<audio file to be played>>)
 player.queue(source)
 player.play()

因此,Player 类的代码如下(参见代码 5.02player.py):

 import pyglet

 FORWARD_REWIND_JUMP_TIME = 20

 class Player:
   def __init__(self):
     self.player = pyglet.media.Player()
     self.player.volume = 0.6

  def play_media(self, audio_file):
    self.reset_player()
    self.player = pyglet.media.Player()
    self.source = pyglet.media.load(audio_file)
    self.player.queue(self.source)
    self.player.play()

  def reset_player(self):
    self.player.pause()
    self.player.delete()

 def is_playing(self):
   try:
     elapsed_time = int(self.player.time)
     is_playing = elapsed_time < int(self.track_length)
   except:
     is_playing = False
   return is_playing

 def seek(self, time):
   try:
    self.player.seek(time)
   except AttributeError:
    pass

 @property
 def track_length(self):
   try:
     return self.source.duration
   except AttributeError:
     return 0

 @property
 def volume(self):
   return self.player.volume

 @property
 def elapsed_play_duration(self):
  return self.player.time

@volume.setter
def volume(self, volume):
  self.player.volume = volume

def unpause(self):
  self.player.play()

def pause(self):
  self.player.pause()

def stop(self):
  self.reset_player()

def mute(self):
 self.player.volume = 0.0

def unmute(self, newvolume_level):
 self.player.volume = newvolume_level

def fast_forward(self):
  time = self.player.time + FORWARD_REWIND_JUMP_TIME
  try:
    if self.source.duration > time:
      self.seek(time)
    else:
      self.seek(self.source.duration)
  except AttributeError:
    pass

def rewind(self):
 time = self.player.time - FORWARD_REWIND_JUMP_TIME
 try:
   self.seek(time)
 except:
   self.seek(0)

上述代码基于pyglet API 构建,该 API 非常直观。我们在此不会深入探讨音频编程的细节,并将pyglet库视为一个黑盒,它能够实现其声明的内容,即能够播放和控制音频。

以下是你应该注意的关于前面代码的重要事项:

  • 我们定义了play_media方法,该方法负责播放音频。所有其他方法支持与播放相关的其他功能,例如暂停、停止、倒带、快进、静音等等。

  • 注意,每次代码想要播放音频文件时,都会定义一个新的 pyglet Player 类。虽然我们可以使用相同的播放器实例来播放多个音频文件,但结果却是 pyglet 库没有 stop 方法。我们唯一能够停止播放音频文件的方式就是通过终止 Player 对象,并为下一个音频文件播放创建一个新的 Player 对象。

  • 当我们选择外部实现,就像在这里为音频 API 所做的那样时,我们首先在docs.python.org/3.6/library/的 Python 标准库中进行了搜索。

  • 由于标准库中没有适合我们的包,我们将注意力转向了 Python 包索引,以检查是否存在另一个高级音频接口实现。Python 包索引可以在pypi.python.org/找到。

  • 幸运的是,我们遇到了几个音频包。在将包与我们的需求进行比较并观察其社区活跃度后,我们选择了pyglet。虽然同样可以使用其他几个包来实现这个程序,但这将会涉及不同复杂程度的操作。

通常情况下,你向下深入协议栈,你的程序将变得更加复杂。

然而,在协议的较低层,你将获得对实现细节的更精细控制,但这会以增加学习曲线为代价。

此外,请注意,大多数音频库都会随着时间的推移而发生变化。虽然这个当前的音频库可能会随着时间的推移而变得无法使用,但你可以轻松修改Player类以使用其他音频库,并且仍然能够使用这个程序,只要你保持Player类中定义的接口。

这就结束了迭代。我们现在有一个可以操作音频文件的Player类。我们有一个由play_list组成的数据结构,它包含各种方法来向播放列表中添加和删除文件。接下来,我们将探讨如何从程序的前端添加和删除文件到播放列表。

从播放列表中添加和删除项目

让我们编写一些代码来实现一个功能,允许我们从播放列表中添加和删除项目。更具体地说,我们将编写以下截图中标出的四个按钮对应的函数代码:

图片

从左到右,四个按钮执行以下功能:

  • 从左数第一个按钮可以将单个音频文件添加到播放列表

  • 第二个按钮会从播放列表中删除所有选中的项目

  • 第三个按钮会扫描目录以查找音频文件,并将所有找到的音频文件添加到播放列表中

  • 最后一个按钮清空播放列表

由于添加这些功能需要我们与 Tkinter 的 Listbox 小部件进行交互,让我们花些时间来了解 Listbox 小部件:

我们可以创建一个类似于创建任何其他小部件的 Listbox 小部件,如下所示:

 play_list = tk.ListBox(parent, **configurable options)

当你最初创建一个 Listbox 小部件时,它是空的。要将一行或多行文本插入到 Listbox 中,请使用insert()方法,该方法需要两个参数,即文本需要插入的位置的索引和需要插入的实际字符串,如下所示:

 play_list.insert(0, "First Item")
 play_list.insert(1, "Second Item")
 play_list.insert(END, "Last Item")

curselection() 方法返回列表中所有选中项的索引,而 get() 方法返回给定索引的列表项,如下所示:

 play_list.curselection() # returns a tuple of all selected items
 play_list.curselection()[0] # returns first selected item
 play_list.get(1) # returns second item from the list
 play_list.get(0, END) # returns all items from the list

此外,Listbox 小部件还有其他可配置选项。

要获取完整的 Listbox 小部件参考,请在 Python 交互式壳中输入以下内容:

 >>> import tkinter
 >>> help(tkinter.Listbox)

现在我们已经知道了如何从列表框小部件中添加和删除项目,让我们将这些函数编码到播放器中。

让我们从修改与四个按钮相关联的命令回调开始,具体如下(参见代码 5.03view.py):

def on_add_file_button_clicked(self):
 self.add_audio_file()

def on_remove_selected_button_clicked(self):
 self.remove_selected_file()

def on_add_directory_button_clicked(self):
 self.add_all_audio_files_from_directory()

def on_clear_play_list_button_clicked(self):
 self.clear_play_list()

def on_remove_selected_context_menu_clicked(self):
 self.remove_selected_file()

这四种方法所做的只是调用其他四种方法来完成实际的任务,即向播放列表中添加或删除项目。所有这些方法都会在以下两个地方更新play_list项目:

  • 在可见的 Listbox 小部件中

  • 在由Model类维护的后端数据结构播放列表中

让我们定义四种新的方法。

添加单个音频文件

添加文件涉及使用 Tkinter filedialog请求位置并更新前端和后端,如下所示(参见代码5.03view.py):

def add_audio_file(self):
 audio_file = tkinter.filedialog.askopenfilename(filetypes=[(
       'All supported', '.mp3 .wav'), ('.mp3 files', '.mp3'),('.wav files', '.wav')])
 if audio_file:
   self.model.add_to_play_list(audio_file)
   file_path, file_name = os.path.split(audio_file)
   self.list_box.insert(tk.END, file_name)

从播放列表中移除所选文件

由于列表框允许进行多项选择,我们遍历所有选定的项目,将它们从前端列表框小部件以及模型play_list中移除,如下所示(参见代码5.03view.py):

def remove_selected_files(self):
 try:
  selected_indexes = self.list_box.curselection()
  for index in reversed(selected_indexes):
    self.list_box.delete(index)
    self.model.remove_item_from_play_list_at_index(index)
 except IndexError:
   pass

注意,我们在从播放列表中删除项目之前会反转元组,因为我们希望从末尾开始删除项目,因为删除操作会导致播放列表项的索引发生变化。如果我们不从末尾删除项目,我们可能会最终从列表中删除错误的项目,因为它的索引在每次迭代中都会被修改。

由于我们在这里已经定义了这种方法,让我们将其添加为右键删除菜单的命令回调,如下所示:

def on_remove_selected_context_menu_clicked(self):
  self.remove_selected_files()

添加目录中的所有文件

以下代码使用os.walk()方法递归遍历所有文件,查找.wav.mp3文件,具体如下(参见代码5.03view.py):

def add_all_audio_files_from_directory(self):
  directory_path = tkinter.filedialog.askdirectory()
  if not directory_path: return
  audio_files_in_directory =  self.get_all_audio_file_from_directory(directory_path)
  for audio_file in audio_files_in_directory:
     self.model.add_to_play_list(audio_file)
     file_path, file_name = os.path.split(audio_file)
     self.list_box.insert(tk.END, file_name)

def get_all_audio_file_from_directory(self, directory_path):
  audio_files_in_directory = []
  for (dirpath, dirnames, filenames) in os.walk(directory_path):
    for audio_file in filenames:
      if audio_file.endswith(".mp3") or audio_file.endswith(".wav"):
        audio_files_in_directory.append(dirpath + "/"  + audio_file)
  return audio_files_in_directory

清空播放列表

代码如下(见代码 5.03view.py):

def empty_play_list(self):
 self.model.empty_play_list()
 self.list_box.delete(0, END)

这完成了我们的第三次迭代。在这个迭代中,我们看到了如何使用 Listbox 小部件。特别是,我们看到了如何向 Listbox 小部件添加项目,从 Listbox 小部件中选择一个特定的项目,以及如何从中删除一个或多个项目。

您现在有一个播放列表,您可以使用音频播放器左下角的四个按钮添加和删除项目。

播放音频和添加音频控件

在这次迭代中,我们将编写以下截图中所标记的功能的代码:

图片

这包括播放/停止、暂停/恢复、下一曲、上一曲、快进、倒带、音量调整以及静音/取消静音等功能。

添加播放/停止功能

现在我们有了播放列表和可以播放音频的Player类,播放音频的操作仅仅是更新当前曲目索引并调用play方法。

因此,让我们添加一个属性,如下所示(参见代码5.04view.py):

current_track_index = 0 

此外,播放按钮应作为在播放停止功能之间的切换。Python 的 itertools 模块提供了 cycle 方法,这是一个在两个或多个值之间切换的非常方便的方式。

因此,导入itertools模块并定义一个新的属性,如下所示(参见代码5.04view.py):

toggle_play_stop = itertools.cycle(["play","stop"]) 

现在,每次我们调用 next(toggle_play_stop),返回的值会在 playstop 字符串之间切换。

Itertools 是 Python 中一个非常强大的标准库,它可以模拟许多来自函数式编程范式的 可迭代对象。在 Python 中,可迭代对象是一个实现了 next() 方法的接口。每次对 next() 的后续调用都是 惰性评估 的——这使得它们以最有效的方式遍历大型序列变得合适。这里使用的 cycle() 工具是一个可以提供无限交替值序列的迭代器的例子,而无需定义大型数据结构。

以下为itertools模块的文档:

docs.python.org/3/library/itertools.html

接下来,修改on_play_stop_button_clicked()方法,使其看起来像这样(参见代码5.04view.py):

def on_play_stop_button_clicked(self):
  action = next(self.toggle_play_stop)
  if action == 'play':
   try:
     self.current_track_index = self.list_box.curselection()[0]
   except IndexError:
     self.current_track_index = 0
   self.start_play()
 elif action == 'stop':
   self.stop_play()

上述方法简单地切换在调用start_play()stop_play()方法之间,这些方法定义如下:

def start_play(self):
 try:
   audio_file = self.model.get_file_to_play(self.current_track_index)
 except IndexError:
   return
 self.play_stop_button.config(image=self.stop_icon)
 self.player.play_media(audio_file)

def stop_play(self):
 self.play_stop_button.config(image=self.play_icon)
 self.player.stop()

上述代码调用了Player类中定义的playstop方法。它还通过使用widget.config(image=new_image_icon)方法,将按钮图像从播放图标更改为停止图标。

当我们处理play函数时,让我们修改命令回调,以便用户只需通过双击即可播放曲目。我们之前已经定义了一个名为on_play_list_double_clicked的方法,目前它是空的。

简单地按照以下方式修改它:

def on_play_list_double_clicked(self, event=None):
 self.current_track_index = int(self.list_box.curselection()[0])
 self.start_play()

添加暂停/恢复功能

由于我们需要一个按钮来在暂停和继续之间切换,我们再次使用来自 itertools 模块的 cycle() 方法。定义一个属性,如下所示(参见代码 5.04view.py):

toggle_pause_unpause = itertools.cycle(["pause","unpause"])

然后,修改按钮附加的命令回调,如下所示:

def on_pause_unpause_button_clicked(self):
 action = next(self.toggle_pause_unpause)
 if action == 'pause':
   self.player.pause()
 elif action == 'unpause':
   self.player.unpause()

这处理了程序中的暂停和恢复功能。

添加静音/取消静音功能

这与编码暂停/恢复功能类似。我们需要一个属性,可以在“静音”和“取消静音”字符串之间切换。相应地,添加一个属性,如下所示(参见代码5.04view.py):

toggle_mute_unmute = itertools.cycle(["mute","unmute"]) 

然后,修改命令回调以从player类调用muteunmute函数,更改按钮图标为静音或取消静音的图片,并相应地调整音量刻度,如下所示(参见代码5.04view.py):

def on_mute_unmute_button_clicked(self):
  action = next(self.toggle_mute_unmute)
  if action == 'mute':
    self.volume_at_time_of_mute = self.player.volume
    self.player.mute()
    self.volume_scale.set(0)
    self.mute_unmute_button.config(image=self.mute_icon)
  elif action == 'unmute':
    self.player.unmute(self.volume_at_time_of_mute)
    self.volume_scale.set(self.volume_at_time_of_mute)
    self.mute_unmute_button.config(image=self.unmute_icon)

快进/倒退功能

快进和快退的代码是最简单的。我们已经在Player类中定义了处理这些功能的方法。现在,只需要将它们连接到相应的命令回调函数,如下所示:

def on_fast_forward_button_clicked(self):
  self.player.fast_forward()

def on_rewind_button_clicked(self):
  self.player.rewind()

添加下一曲目/上一曲目功能

虽然我们在Player类中定义了快进和快退的代码,但我们没有在那里定义与下一曲和上一曲相关的方法,因为这可以通过现有的play方法来处理。你所需要做的只是简单地增加或减少current_track的值,然后调用play方法。因此,在View类中定义两个方法,如下所示(参见代码5.04view.py):

def play_previous_track(self):
  self.current_track_index = max(0, self.current_track_index - 1)
  self.start_play()

def play_next_track(self):
  self.current_track_index = min(self.list_box.size() - 1, 
    self.current_track_index + 1)
  self.start_play()

然后,只需将这些两种方法分别附加到相应的命令回调中,如下所示(参见代码5.04view.py):

def on_previous_track_button_clicked(self):
  self.play_previous_track()

def on_next_track_button_clicked(self):
  self.play_next_track()

添加体积变化函数

我们已经在Player类中定义了volume方法。现在,你所需要做的就是简单地获取音量比例小部件的值,并在Player类中设置音量。

此外,确保在音量变为零的情况下,我们将音量按钮图标更改为静音图像(参见代码5.04view.py):

def on_volume_scale_changed(self, value):
  self.player.volume = self.volume_scale.get()
  if self.volume_scale.get() == 0.0:
    self.mute_unmute_button.config(image=self.mute_icon)
  else:
    self.mute_unmute_button.config(image=self.unmute_icon)

这就完成了迭代。现在玩家已经足够功能化,可以被称为音频播放器。请继续向播放器添加一些音乐文件。按下播放按钮,享受音乐吧!尝试使用我们在这次迭代中定义的其他播放器控制功能,它们应该会按预期工作。

创建一个搜索栏

现在,让我们给音频播放器添加一个进度条。Tkinter 提供了 Scale 小部件,我们之前用它来制作音量条。Scale 小部件原本也可以用作进度条。

但我们想要更华丽一些。此外,缩放小部件在不同平台上看起来也会不同。相反,我们希望滑块在所有平台上看起来都是统一的。这就是我们可以创建自己的小部件来满足音频播放器定制需求的地方。

让我们创建自己的 Seekbar 小部件,如图下所示:

图片

创建我们自己的小部件最简单的方法是从现有的小部件或Widget类继承。

当你查看 Tkinter 的源代码时,你会发现所有的小部件都继承自一个名为 Widget 的类。Widget 类反过来又继承自另一个名为 BaseWidget 的类。BaseWidget 类包含了用于处理小部件的 destroy() 方法的代码,但它并不了解几何管理器。

因此,如果我们想让我们的自定义小部件能够了解并使用几何管理器,例如 packgridplace,我们需要从 Widget 类或另一个 Tkinter 小部件继承。

假设我们想要创建一个名为 Wonderwidget 的部件。我们可以通过从 Widget 类继承来实现这一点,如下所示:

from tkinter import *

class Wonderwidget(Widget):
 def __init__(self, parent, **options):
    Widget.__init__(self, parent, options)

这四行代码创建了一个名为 Wonderwidget 的部件,它可以使用 packplacegrid 等几何管理器进行定位。

然而,对于更实用的用例,我们通常继承现有的 Tkinter 小部件,例如 TextButtonScaleCanvas 等等。在我们的情况下,我们将通过继承 Canvas 类来创建 Seekbar 小部件。

创建一个名为 seekbar.py 的新文件(参见代码 5.05.py)。然后,创建一个名为 Seekbar 的新类,该类继承自 Canvas 小部件,如下所示:

from tkinter import *

class Seekbar(Canvas):

  def __init__(self, parent, called_from, **options):
    Canvas.__init__(self, parent, options)
    self.parent = parent
    self.width = options['width']
    self.red_rectangle = self.create_rectangle(0, 0, 0, 0,fill="red")
    self.seekbar_knob_image = PhotoImage(file="../icons/seekbar_knob.gif")
    self.seekbar_knob = self.create_image(0, 0, 
      image=self.seekbar_knob_image)

上述代码调用了父类 Canvas__init__ 方法,以所有作为参数传递的与画布相关的选项初始化底层画布。

就这么少的代码,让我们回到View类中的create_top_display()方法,添加这个新小部件,具体如下:

self.seek_bar = Seekbar(frame, background="blue", width=SEEKBAR_WIDTH, height=10)
self.seek_bar.grid(row=2, columnspan=10, sticky='ew', padx=5)

在这里,SEEKBAR_WIDTH 是一个我们定义为等于 360 像素的常量。

如果你现在运行 view.py,你将看到 Seekbar 小部件在其位置上。

搜索栏无法使用,因为当点击搜索栏旋钮时,它不会移动。

为了使滑动条能够滑动,我们将通过定义一个新的方法并从__init__方法中调用它来绑定鼠标按钮,如下所示(参见代码5.05seekbar.py):

def bind_mouse_button(self):
  self.bind('<Button-1>', self.on_seekbar_clicked)
  self.bind('<B1-Motion>', self.on_seekbar_clicked)
  self.tag_bind(self.red_rectangle, '<B1-Motion>',
  self.on_seekbar_clicked)
  self.tag_bind(self.seekbar_knob, '<B1-Motion>',
  self.on_seekbar_clicked) 

我们将整个画布、红色矩形和滑动条旋钮绑定到单个名为 on_seekbar_clicked 的方法上,该方法可以定义为如下(参见代码 5.05seekbar.py):

def on_seekbar_clicked(self, event=None):
  self.slide_to_position(event.x) 

上述方法简单地调用了另一个名为 slide_to_position 的方法,该方法负责改变旋钮的位置和红色矩形的大小(参见代码 5.05seekbar.py):

def slide_to_position(self, new_position):
  if 0 <= new_position <= self.width:
  self.coords(self.red_rectangle, 0, 0, new_position, new_position)
  self.coords(self.seekbar_knob, new_position, 0)
  self.event_generate("<<SeekbarPositionChanged>>", x=new_position)

上述代码将旋钮滑动到新位置。更重要的是,最后一行创建了一个名为 SeekbarPositionChanged 的自定义事件。这个事件将允许任何使用此自定义小部件的代码适当地处理该事件。

第二个参数 x=new_positionx 的值添加到 event.x 中,使其对事件处理器可用。

到目前为止,我们只处理过事件。Tkinter 还允许我们创建自己的事件,这些事件被称为 虚拟事件

我们可以通过将名称用双对<<...>>括起来来指定任何事件名称。

在前面的代码中,我们生成了一个名为<<SeekbarPositionChanged>>的虚拟事件。

我们随后将其绑定到View类中的相应事件处理器,具体如下:

self.root.bind("<<SeekbarPositionChanged>>",self.seek_new_position)

这就是自定义Seekbar小部件的全部内容。我们可以在seekbar.py中编写一个小测试,以检查Seekbar小部件是否按预期工作:

class TestSeekBar :
  def __init__(self):
    root = tk.Tk()
    root.bind("<<SeekbarPositionChanged>>", self.seek_new_position)
    frame = tk.Frame(root)
    frame.grid(row=1, pady=10, padx=10)
    c = Seekbar(frame, background="blue", width=360, height=10)
    c.grid(row=2, columnspan=10, sticky='ew', padx=5)
    root.mainloop()

  def seek_new_position(self, event):
    print("Dragged to x:", event.x)

if __name__ == '__main__':
 TestSeekBar()

尝试运行 5.05seekbar.py 程序;它应该会生成一个进度条。进度条应该在您拖动进度条旋钮或在画布上的各个位置点击时滑动。

这一次迭代到此结束。我们将在下一次迭代中使音频播放器的定位条功能化。

音频播放期间的单一更新

音频程序必须在音频轨道开始播放时立即更新一些信息。总的来说,程序需要监控和更新的更新类型有两种:

  • 一次性更新:此类例子包括轨道名称和轨道总长度。

  • 持续更新:此类例子包括寻道按钮的位置和播放进度。我们还需要持续检查是否已播放完一首曲目,以便根据用户选择的循环选项播放下一首曲目、重新播放当前曲目或停止播放。

这两种更新将影响音频播放器的部分区域,如下所示截图:

图片

让我们从一次性更新开始,因为它们相对容易实现。

由于这些更新必须在播放开始时发生,让我们定义一个名为 manage_one_time_updates() 的方法,并在 View 类的 start_play() 方法中调用它,如下所示(参见代码 5.06view.py):

def manage_one_time_track_updates_on_play_start(self):
  self.update_now_playing_text()
  self.display_track_duration()

接下来,按照以下方式定义在前面方法中调用的所有方法:

def update_now_playing_text(self):
  current_track = self.model.play_list[self.current_track_index]
  file_path, file_name = os.path.split(current_track)
  truncated_track_name = truncate_text(file_name, 40)
  self.canvas.itemconfig(self.track_name, text=truncated_track_name)

def display_track_duration(self):
  self.track_length = self.player.track_length
  minutes, seconds = get_time_in_minute_seconds(self.track_length)
  track_length_string = 'of {0:02d}:{1:02d}'.format(minutes, seconds)
  self.canvas.itemconfig(self.track_length_text, text=track_length_string)

这两种方法简单地通过调用 canvas.itemconfig 来找出轨道名称和轨道时长,并更新相关的画布文本。

就像我们使用 config 来更改与小部件相关的选项一样,Canvas 小部件使用 itemconfig 来更改画布内单个项目的选项。itemconfig 的格式如下:

canvas.itemconfig(itemid, **options).

让我们在名为 helpers.py 的新文件中定义两个辅助方法,并在视图命名空间中导入这些方法。这两个方法分别是 truncate_textget_time_in_minutes_seconds。相关代码可以在 5.06helpers.py 文件中找到。

这样就处理了一次性更新。现在,当你运行 5.06view.py 并播放一些音频文件时,播放器应该更新轨道名称,并在顶部控制台中显示总轨道时长,如下面的截图所示:

图片

我们将在下一次迭代中负责定期更新。

管理持续更新

接下来,我们将更新寻道旋钮的位置和已播放的持续时间,如下所示截图:

图片

这只是基于 Tkinter 的简单动画形式。

使用 Tkinter 动画最常见的方式是先绘制一个单独的框架,然后通过 Tkinter 的 after 方法调用相同的方法,具体如下:

def animate(self):
 self.draw_frame()
 self.after(500, self.animate)

记录一下self.after方法,它会在循环中调用animate方法。一旦被调用,这个函数将每隔500毫秒更新一次帧。你还可以添加一些条件来跳出动画循环。在 Tkinter 中,所有动画都是这样处理的。我们将在接下来的几个示例中反复使用这项技术。

现在我们已经知道了如何在 Tkinter 中管理动画,让我们使用这个模式来定义一个处理这些周期性更新的方法。

定义一个名为 manage_periodic_updates_during_play 的方法,该方法每秒调用自己一次以更新计时器和进度条,如下所示(参见代码 5.07view.py):

def manage_periodic_updates_during_play(self):
 self.update_clock()
 self.update_seek_bar()
 self.root.after(1000, self.manage_periodic_updates_during_play)

然后,定义两个名为 update_clockupdate_seek_bar 的方法,它们用于更新前一个截图中所突出显示的部分。

update_clock 方法从 Player 类获取已过时间(以秒为单位),将其转换为分钟和秒,并使用 canvas.itemconfig 更新画布文本,具体如下(参见代码 5.07view.py):

def update_clock(self):
  self.elapsed_play_duration = self.player.elapsed_play_duration
  minutes, seconds = get_time_in_minute_seconds(self.elapsed_play_duration)
  current_time_string = '{0:02d}:{1:02d}'.format(minutes, seconds)
  self.canvas.itemconfig(self.clock, text=current_time_string)

你可能还记得,我们之前在Seekbar类中定义了一个slide_to_position方法。update_seek_bar方法只是简单地计算滑块的相对位置,然后调用slide_to_position方法来滑动滑块的旋钮,如下所示(参见代码5.07view.py):

def update_seek_bar(self):
  seek_bar_position = SEEKBAR_WIDTH *
  self.player.elapsed_play_duration /self.track_length
  self.seek_bar.slide_to_position(seek_bar_position)

现在,如果你运行 5.07view.py,添加一个音频文件并播放它,顶部显示中的已过时长应该会持续更新。随着播放的进行,进度条也应该向前移动。

那很好,但还有一个小的细节没有完善。我们希望当用户在进度条上点击某个位置时,播放的音频能够跳转到新的位置。跳转到新位置的代码很简单(见代码 5.07view.py):

def seek_new_position(self, event=None):
 time = self.player.track_length * event.x /SEEKBAR_WIDTH
 self.player.seek(time) 

然而,前述方法需要在滑动条位置改变时被调用。我们可以通过在5.07view.py中添加对虚拟事件的绑定来实现这一点,如下所示:

self.root.bind("<<SeekbarPositionChanged>>", self.seek_new_position)

现在,当你运行 5.07view.py,播放音频文件并点击进度条;音频应该从新位置开始播放。

这就结束了迭代。我们将在下一次迭代中查看如何遍历轨道。

遍历轨道

让我们添加一个允许用户循环播放歌曲的功能。我们已经定义了单选按钮来允许三种选择,如下面的截图所示:

图片

从本质上讲,玩家应从以下三个选项中选择:

  • 无循环:播放一首曲目并结束

  • 循环播放:重复播放单个曲目

  • 循环全部:依次循环整个播放列表

在特定轨道播放结束后,需要立即做出选择其中一种选项的决定。判断一个轨道是否已经结束的最佳位置,是在我们之前创建的周期性更新循环中。

因此,修改manage_periodic_updates_during_play()方法,添加以下两条高亮代码(见代码5.08view.py):

def manage_periodic_updates_during_play(self):
  self.update_clock()
  self.update_seek_bar()
  if not self.player.is_playing():
    if self.not_to_loop(): return
  self.root.after(1000, self.manage_periodic_updates_during_play)

这实际上意味着只有在当前播放的曲目结束时才会检查循环决策。然后,定义not_to_loop()方法,如下所示(参见代码5.09view.py):

def not_to_loop(self):
  selected_loop_choice = self.loop_value.get()
  if selected_loop_choice == 1: # no loop
    return True
  elif selected_loop_choice == 2: # loop current
    self.start_play()
    return False
  elif selected_loop_choice == 3: #loop all
    self.play_next_track()
  return True 

代码首先检查所选单选按钮的值,并根据所选选项,做出循环选择:

  • 如果选择的循环值为 1(无循环),则不执行任何操作并返回 True,从而跳出连续更新循环。

  • 如果选定的循环值为 2(循环当前歌曲),它将再次调用 start_play 方法并返回 False。因此,我们不会跳出更新循环。

  • 如果循环值是 3(循环全部),它将调用 play_next_track 方法并返回 True。因此,我们跳出之前的更新循环。

音频播放器现在可以根据用户设置的循环偏好来循环播放播放列表。

让我们通过重写关闭按钮来结束这次迭代,这样当用户在播放时决定关闭播放器时,音频播放器可以正确地删除播放器对象。

要重写销毁方法,首先在View __init__方法中添加一个协议覆盖命令,如下所示(参见代码5.08view.py):

self.root.protocol('WM_DELETE_WINDOW', self.close_player) 

最后,定义close_player方法,如下所示:

def close_player(self):
 self.player.stop()
 self.root.destroy() 

这就完成了迭代。我们编写了循环遍历轨道所需的逻辑,然后覆盖了关闭按钮,以确保在我们退出播放器之前,正在播放的轨道被停止。

添加工具提示

在这个最终迭代中,我们将为我们的播放器中的所有按钮添加一个名为气球小部件的工具提示。

工具提示是一个当你在“边界小部件”(在我们的例子中是按钮)上悬停鼠标时出现的弹出窗口。应用程序的典型工具提示将如下所示:

图片

尽管 Tkinter 的核心库拥有许多有用的控件,但它远非完整。对我们来说,工具提示或气球控件并不是作为 Tkinter 的核心控件提供的。因此,我们在所谓的Tkinter 扩展中寻找这些控件。

这些扩展不过是扩展的 Tkinter 小部件集合,就像我们创建的自定义进度条一样。

实际上,Tkinter 扩展有成百上千种。事实上,我们就在本章中编写了自己的 Tkinter 扩展。

然而,以下是一些流行的 Tkinter 扩展:

Pmw 扩展列表

谈及 Pmw,以下是从该包中快速列出的一些小部件扩展和对话框。

小部件

以下表格显示了一系列小部件扩展列表:

按钮框 组合框 计数器 输入字段
群组 历史文本 标记小部件 主菜单栏
菜单栏 消息栏 笔记本 选项菜单
分割小部件 单选选择框 滚动画布 滚动字段
滚动框架 滚动列表框 滚动文本 时间计数器

对话

以下表格显示了一组小部件对话框列表:

关于对话框 组合框对话框 计数器对话框 对话框
消息对话框 提示对话框 选择对话框 文本对话框

杂项

以下是由 Pmw 提供的杂项小部件列表:

  • 气球

  • Blt(用于图形生成)

  • 颜色模块功能

Pmw 提供了大量扩展小部件。要演示所有这些小部件,请浏览您之前安装的 Pmw 包,并寻找名为 demo 的目录。在 demo 目录中,寻找一个名为 All.py 的文件,该文件使用示例代码演示了所有这些 Pmw 扩展。

Pmw 提供了 Balloon 小部件的实现,该实现将在当前示例中使用。首先,将 Pmw 导入命名空间,如下所示(参见代码 5.09view.py):

import Pmw 

接下来,在create_gui方法中实例化Balloon小部件,如下所示:

self.balloon = Pmw.Balloon(self.root) 

最后,将气球小部件绑定到音频播放器中的每个按钮小部件。我们不会为每个按钮重复代码。然而,格式如下:

balloon.bind(name of widget, 'Description for the balloon') 

因此,添加文件按钮将具有气球绑定,如下所示:

self.balloon.bind(add_file_button, 'Add New File') 

5.09view.py中的每个按钮添加类似的代码。

这完成了迭代。我们使用 Pmw Tkinter 扩展为音频播放器的按钮添加了气球提示。最重要的是,我们了解了 Tkinter 扩展及其使用时机。

当你需要一个作为核心小部件不可用的 widget 实现时,尝试在 Pmw 或 TIX 中寻找其实现。如果你找不到一个符合你需求的实现,请在互联网上搜索其他 Tkinter 扩展。如果你仍然找不到你想要的实现,那么是时候自己构建一个了。

这就结束了本章的内容。音频播放器已准备就绪!

摘要

让我们回顾一下本章中我们提到的事情。

除了加强我们在前几章讨论的许多 GUI 编程技术之外,你还学会了如何使用更多的小部件,例如 Listbox、ttk Scale 和 Radiobutton。我们还深入探讨了 Canvas 小部件的功能。

最重要的是,我们学会了如何创建自定义小部件,从而扩展了 Tkinter 的核心小部件。这是一个非常强大的技术,可以应用于将各种功能构建到程序中。

我们看到了如何生成和处理虚拟事件。

我们看到了在 Tkinter 程序中应用动画的最常见技术。这种技术也可以用来构建各种有趣的游戏。

最后,我们了解了一些常见的 Tkinter 扩展,例如 Pmw、WCK、TIX 等等。

现在,让我们迷失在一些音乐中!

QA 部分

在你继续阅读下一章之前,请确保你能回答这些问题

满意度:

  • 我们如何在 Tkinter 中创建自己的自定义小部件?

  • 你如何使用 Tkinter 创建动画?

  • 什么是虚拟活动?何时以及如何使用它们?

  • Tkinter 扩展是什么?哪些是最受欢迎的?

进一步阅读

查看 Tkinter 流行扩展(如 Pmw、Tix、WCK 等)的文档。注意记录这些扩展中常见的控件。

第六章:涂鸦应用

我们在第五章,“构建音频播放器”中使用了 Canvas 小部件来定义一个自定义小部件。Canvas 小部件确实是 Tkinter 的亮点之一。它是一个非常强大且灵活的小部件。因此,让我们将本章的大部分内容用于详细探讨 Canvas 小部件。

我们现在将开发一个绘图应用程序。该应用程序将允许用户绘制手绘线条、直线、圆形、矩形、弧线和其他多边形。它还将允许用户定义新的复杂形状。

除了探索 Canvas 小部件,我们还将基于 Tkinter 接口开发一个微型的 GUI 框架。正如您将看到的,框架是最大化代码重用的一种很好的方式。这使得它们成为快速应用开发(RAD)的强大工具。快速应用开发(RAD)

本章的一些关键学习目标如下:

  • 掌握 Canvas 小部件 API

  • 学习构建和使用自定义 GUI 框架以实现最大程度地代码复用和快速应用开发

  • 学习使用 Tkinter 的 colorchooser 模块

  • 学习使用 ttk ComboBox 小部件

  • 了解可用的小部件方法

  • 巩固我们在先前项目中学习到的知识

应用概述

在其最终形态下,我们的油漆应用程序将如下所示:

图片

本章没有外部库的要求,因此让我们直接进入代码部分。

创建一个微型框架

那么为什么我们还需要在 Tkinter 之上再添加一个框架呢?如果我们只需要构建一个单独的程序,我们其实不需要构建一个框架。然而,如果我们发现自己反复地编写相同的样板代码,那么我们就需要框架。也就是说,框架是一个工具,它让我们能够轻松地生成通用的和经常使用的模式。

例如,考虑一下程序中使用的菜单。菜单在大多数程序中都是一个常见的元素,然而每次我们坐下来编写程序时,都需要手动制作每个菜单项。如果我们能进一步抽象以简化菜单生成会怎样呢?

这就是框架派上用场的地方。

假设你有一个程序,它有 10 个不同的顶级菜单。假设每个顶级菜单有五个菜单项。那么我们不得不写上 50 行代码仅仅是为了显示这 50 个菜单项。你不仅要手动将它们链接到其他命令,还要为每个菜单项设置大量的选项。

如果我们对所有的小部件都这样做,我们的 GUI 编程就变成了打字练习。你写的每一行额外代码都会增加程序的复杂性,使得其他人阅读、维护、修改和/或调试代码变得更加困难。

这就是使用自定义框架能帮到我们的地方。让我们开发一个微型的框架,使菜单生成对我们来说变得简单易行。

我们创建一个文件,framework.py,并在该文件中创建一个新的类,Framework。每个使用此框架的类都必须继承这个类,并且应该通过调用 super 方法将根窗口作为参数传递给这个类,如下所示:

super().__init__(root)

这将使在Framework类中定义的所有方法对继承类可用。

我们现在将定义一个方法,build_menu,它接受一个预期格式的元组作为输入,并自动为我们创建菜单。让我们定义一个任意规则,即菜单项的每一组必须由元组中的一个单独条目来表示。

此外,我们制定了一条规则,即元组中的每个项目都必须按照以下格式呈现:

'Top Level Menu Name – Menu Item Name / Accelrator /Commandcallback/Underlinenumber'

MenuSeparator 用字符串 'sep' 表示。

菜单定义的另一种表示方法可以是将其指定为一个元组,而不是字符串定义,这就像是要求用户预先分割定义,而不是我们不得不从字符串中提取菜单定义。

例如,将这个元组作为参数传递给build_menu方法应该会生成如下代码所示的三种菜单:

menu_items = (
'File - &New/Ctrl+N/self.new_file, &Open/Ctrl+O/self.open_file',
'Edit - Undo/Ctrl+Z/self.undo', 
'sep',
'Options/Ctrl+T/self.options',
'About - About//self.about'
)

查看以下截图:

图片

字符串的第一个项目(破折号-之前的部分)代表顶级菜单按钮。字符串中每个由正斜杠(/)分隔的后续部分代表一个菜单项、其快捷键以及附加的命令回调函数。

& 符号的位置代表需要加下划线的快捷键位置。如果我们遇到字符串 sep,我们添加一个菜单分隔符。

现在我们已经定义了规则,build_menu 函数的代码如下:(参见 framework.py 代码):

def build_menu(self, menu_definition):
  menu_bar = tk.Menu(self.root)
  for definition in menu_definition:
    menu = tk.Menu(menu_bar, tearoff=0)
    top_level_menu, pull_down_menus = definition.split('-')
    menu_items = map(str.strip, pull_down_menus.split(','))
    for item in menu_items:
      self._add_menu_command(menu, item)
    menu_bar.add_cascade(label=top_level_menu, menu=menu)
  self.root.config(menu=menu_bar)

def _add_menu_command(self, menu, item):
  if item == 'sep':
    menu.add_separator()
  else:
    menu_label, accelrator_key, command_callback =item.split('/')
    try:
      underline = menu_label.index('&')
      menu_label = menu_label.replace('&', '', 1)
    except ValueError:
      underline = None
    menu.add_command(label=menu_label,underline=underline, 
      accelerator=accelrator_key,
        command=eval(command_callback))

代码的描述如下:

  • build_menu方法通过名为menu_definition的元组进行操作,必须按照之前讨论的精确格式指定所有所需的菜单和菜单项。

  • 它遍历元组中的每个项目,根据破折号()分隔符拆分项目,为破折号(-)分隔符左侧的每个项目构建顶部菜单按钮。

  • 然后根据逗号(,)分隔符将字符串的第二部分进行分割。

  • 然后它遍历这一部分,为每个部分创建菜单项,使用另一个方法 _add_menu_command 添加加速键、命令回调和下划线键。

  • _add_menu_command 方法遍历字符串,如果找到字符串 sep,则添加一个分隔符。如果没有找到,它将在字符串中搜索下一个和号(&)。如果找到了一个,它将计算其索引位置并将其分配给下划线变量。然后,它将和号值替换为空字符串,因为我们不希望在菜单项中显示和号。

  • 如果字符串中未找到与号(&),则代码将None赋值给下划线变量。

  • 最后,代码为菜单项添加了一个命令回调、快捷键和下划线值。请注意,我们的框架仅添加了快捷键标签。将事件绑定到键上是由开发者负责的。

我们的 GUI 框架制作演示到此结束。现在我们可以通过为每组菜单添加一行新内容,简单地定义 literally hundreds of 菜单。

然而,这是一个相当基础的框架。定义条目的规则是完全任意的。分隔符的选择意味着我们不能再使用作为我们使用此框架定义的任何菜单的分隔符的破折号(-)、斜杠(/)和和号(&)字符。

我们的框架并未为任何其他小部件设定规则。实际上,这个定义甚至不足以生成其他类型的菜单,例如级联菜单、复选框菜单或单选按钮菜单。然而,我们不会进一步扩展框架,因为已经开发出了框架设计和使用的概念,而这正是我们在绘图应用程序中需要使用的所有内容。

我们还在framework.py文件中包含了一个小测试。如果你将该文件作为一个独立程序执行,它应该会弹出一个窗口并定义一些用于测试的菜单。

完整的框架使用更结构化的标记语言来表示规则。XML 是编写 GUI 框架中最受欢迎的选择之一。您可以在以下链接中找到一个基于 XML 的完整 Tkinter RAD (tkRAD) 框架的示例:github.com/muxuezi/tkRAD。使用此框架的一个简单菜单实现可以在此处查看:github.com/muxuezi/tkRAD/blob/master/xml/rad_xml_menu.py.

使用框架来编写小型程序可能有些过度,但对于大型程序来说,它们是无价之宝。希望你现在能够欣赏到使用框架来编写大型程序的益处。

现在我们有了build_menu的代码,我们可以扩展它以添加所需的任意数量的菜单项,而无需为每个菜单项编写重复且类似的代码。

这标志着我们第一次迭代的结束。在下一步中,我们将使用这个小巧的框架来定义我们的绘图程序的菜单。

设置一个广泛的 GUI 结构

让我们现在设置程序的大致 GUI 元素。我们将在6.01.py中创建一个PaintApplication类。由于我们想使用我们的框架来绘制菜单,我们将框架导入到我们的文件中,并如下继承自Framework类:

import framework

class PaintApplication(framework.Framework):

  def __init__(self, root):
    super().__init__(root)
    self.create_gui()

__init__ 方法调用了另一个方法,create_gui,该方法负责为我们程序创建基本的 GUI 结构。

create_gui 方法简单地将任务委托给五个独立的方法,每个方法负责创建 GUI 的一个部分,具体如下(参见代码 6.01.py):

def create_gui(self):
  self.create_menu()
  self.create_top_bar()
  self.create_tool_bar()
  self.create_drawing_canvas()
  self.bind_menu_accelrator_keys()

这五种方法共同构建了一个结构,如下面的截图所示(参见代码 6.01.py):

图片

我们在前面的所有章节中都编写了类似的代码,因此在这里我们将不会重复这些五种方法的代码。然而,请注意关于6.01.py中代码的以下几点:

  • 由于我们想使用该框架,我们继承自Framework类并使用super()调用其__init__方法

  • create_menu 方法指定了我们的菜单定义的元组,并调用我们在框架中之前定义的 build_menu 方法

我们定义了许多将在以后实现的方法。每个空方法都被添加为单个菜单项的命令回调。这里定义的空方法有:

on_new_file_menu_clicked()
on_save_menu_clicked()
on_save_as_menu_clicked()
on_close_menu_clicked()
on_canvas_zoom_out_menu_clicked()
on_canvas_zoom_in_menu_clicked()
on_undo_menu_clicked()
on_about_menu_clicked() 

这为我们程序提供了一个广泛的 GUI 结构。接下来,我们将探讨如何与绘图画布进行交互。

处理鼠标事件

当我们在绘画程序中绘制时,我们使用鼠标作为主要输入设备。

主要有两种鼠标事件会在绘图画布上引起变化,因此值得关注:

  • 点击并释放

  • 点击、拖动并释放

此外,还有一种第三种事件,我们对它的兴趣有限——那就是没有点击按钮的鼠标移动。由于未点击的移动通常不会在画布上引起任何变化,所以我们对此的兴趣有限。

我们忽略右键点击和滚轮滚动,因为我们不会在我们的程序中使用它们。

在这两种先前情况下,我们需要知道鼠标最初点击的位置以及释放的位置。对于点击和释放,这两个位置可能是相同的。对于点击、拖动和释放,通常这两个位置是不同的。

因此,我们定义了四个属性来跟踪这两个位置的坐标(参见代码6.02.py):

start_x, start_y = 0, 0
end_x, end_y = 0, 0

我们当前的目标是将鼠标事件绑定,以便任何点击或拖动都能给我们提供这四个起始和结束坐标的值。

Canvas 小部件的坐标从左上角开始(0, 0是顶部角落)。

Canvas 小部件使用两个坐标系:

  • 窗口坐标系,其始终以左上角为0, 0,无论你在画布上下滚动多少

  • 画布坐标系,它指定了项目实际上在画布上的绘制位置

我们主要关注画布坐标系,但鼠标事件会发出窗口坐标系统上的数据。要将窗口坐标系转换为画布坐标系,我们可以使用以下方法:

 canvas_x = canvas.canvasx(event.x)
 canvas_y = canvas.canvasy(event.y) 

让我们现在修改我们的__init__方法,使其也调用一个方法,bind_mouse。我们定义bind_mouse方法如下(见代码6.02.py):

def bind_mouse(self):
  self.canvas.bind("<Button-1>", self.on_mouse_button_pressed)
  self.canvas.bind( "<Button1-Motion>", 
                   self.on_mouse_button_pressed_motion)
  self.canvas.bind( "<Button1-ButtonRelease>",  
    self.on_mouse_button_released)
  self.canvas.bind("<Motion>", self.on_mouse_unpressed_motion)

我们随后定义了刚才所提到的前三种方法。目前我们通过创建一个空方法来忽略未按下的运动。请记住,我们感兴趣的是获取起始和结束坐标,这些坐标的获取方式如下(参见代码6.02.py):

def on_mouse_button_pressed(self, event):
  self.start_x = self.end_x = self.canvas.canvasx(event.x)
  self.start_y = self.end_y = self.canvas.canvasy(event.y)
  print("start_x, start_y = ", self.start_x, self.start_y)

def on_mouse_button_pressed_motion(self, event):
  self.end_x = self.canvas.canvasx(event.x)
  self.end_y = self.canvas.canvasy(event.y)

def on_mouse_button_released(self, event):
  self.end_x = self.canvas.canvasx(event.x)
  self.end_y = self.canvas.canvasy(event.y)
  print("end_x, end_y = ", self.end_x, self.end_y)

我们暂时添加了两个print语句来在控制台显示这四个值(参见代码6.02.py)。

现在我们已经知道了鼠标开始和结束事件的坐标位置,我们可以对这些事件进行操作,在画布上执行各种活动。

添加工具栏按钮

接下来,我们需要在左侧工具栏中添加 16 个按钮。此外,根据点击的是哪个按钮,顶部栏将显示不同的选项,如下所示:

图片

我们不希望我们的代码结构因为需要在 16 个函数之间切换而变得过于臃肿,因此我们将动态调用这些方法。

我们首先定义了一个包含所有 16 个函数名称的元组(参见代码6.01.py):

tool_bar_functions = (
 "draw_line", "draw_oval", "draw_rectangle", "draw_arc",
 "draw_triangle", "draw_star", "draw_irregular_line",
 "draw_super_shape", "draw_text", "delete_item",
 "fill_item", "duplicate_item", "move_to_top",
 "drag_item", "enlarge_item_size", "reduce_item_size"
 )

这样做可以确保我们不需要从代码中显式地调用每个方法。我们可以使用元组的索引来检索方法名称,并通过以下方式动态调用它:

 getattr(self, self.toolbar_functions[index]) 

这在这里是有道理的,因为我们最终将通过简单地扩展toolbar_functions元组来为我们的绘图程序添加更多功能。

我们进一步定义一个属性,selected_tool_bar_function,它将跟踪哪个按钮是最后被点击的。我们将其初始化为第一个按钮(draw_line)如下:

selected_tool_bar_function = tool_bar_functions[0] 

接下来,我们创建一个名为 icons 的文件夹,并为所有这 16 个工具栏按钮添加图标。这些图标的命名与对应的功能名称相同。

保持这种一致性使我们能够使用相同的元组来遍历并构建我们的工具栏按钮。这种编程风格可以称之为约定优于配置的编程。

我们接下来创建生成实际按钮的方法(参见代码6.03.py):

def create_tool_bar_buttons(self):
  for index, name in enumerate(self.tool_bar_functions):
    icon = tk.PhotoImage(file='icons/' + name + '.gif')
    self.button = tk.Button(self.tool_bar, image=icon, command=lambda
 index=index: self.on_tool_bar_button_clicked(index))
    self.button.grid(row=index // 2, column=1 + index % 2, sticky='nsew')
    self.button.image = icon

上述代码创建了所有按钮并将命令回调添加到按钮中,如所示。因此,我们相应地定义命令回调如下(参见代码6.03.py):

def on_tool_bar_button_clicked(self, button_index):
  self.selected_tool_bar_function = self.tool_bar_functions[button_index]
  self.remove_options_from_top_bar()
  self.display_options_in_the_top_bar()

前述方法设置了selected_tool_bar_function的值。接下来,它调用了以下定义的两个方法(参见代码6.03.py):

def remove_options_from_top_bar(self):
  for child in self.top_bar.winfo_children():
    child.destroy()

在我们能够显示新选按钮的选项之前,需要移除顶部栏中当前显示的所有选项。刚才使用的 winfo_children 方法返回的是这个部件所有子部件的列表。

现在我们已经从顶部栏中移除了所有项目,我们定义了顶部栏上选定的工具图标:

def display_options_in_the_top_bar(self):
  self.show_selected_tool_icon_in_top_bar(self.selected_tool_bar_function)

目前,此方法仅调用另一个方法来在顶部栏中显示所选工具图标。然而,我们将在本章的后续部分将此方法用作添加选项到顶部栏的中心位置。

我们在这里不讨论show_selected_tool_icon_in_top_bar方法,因为它只是将带有图标的标签添加到顶部栏(参见代码6.03.py):

图片

现在,如果你运行代码6.03.py,它应该会在左侧工具栏中显示所有 16 个按钮。此外,点击任何一个按钮都应该在顶部栏中显示所选的按钮,如图中所示的前一个截图。

之前使用的 winfo_children() 方法是所有小部件都可以调用的部件方法的一个例子。Tkinter 中定义了几个有用的部件方法。

除了所有小部件都有的小部件方法外,还有一些方法仅在顶层窗口中可用。您可以通过在您的 Python 3 控制台中输入以下内容来获取所有这些可用方法及其描述的列表:

  • **>>> 导入 tkinter**

  • **>>> help (tkinter.Misc)**

  • **>>> help(tkinter.Wm)**

这些资源可在effbot.org/tkinterbook/widget.htmeffbot.org/tkinterbook/wm.htm在线获取。

鼓励您查看所有这些可用的方法。

接下来,我们将扩展我们的程序,以便实际上在画布上绘制项目。

在画布上绘制项目

添加到画布上的对象被称为项目。使用不同的创建方法如create_linecreate_arccreate_ovalcreate_rectanglecreate_polygoncreate_textcreate_bitmapcreate_image可以将新项目添加到画布上。

添加到画布上的项目将被放置在堆栈中。新项目被添加到画布上已有的项目之上。每次您使用各种创建方法之一添加一个项目时,它都会返回一个唯一的项句柄或一个唯一的整数项 ID。这个项句柄可以用来引用和操作添加的项目。

除了项目处理程序外,项目还可以具有以下项目指定符:

  • tags 是我们可以添加到一项或多项内容的指定符

  • ALL(或字符串 all)匹配画布上的所有项目

  • 当前(或 current)匹配鼠标指针下的项目(如果有)

我们可以使用前面提到的任何项目指定符来指定作用于画布项的方法。

要给一个项目添加标签,您需要指定标签(它是一个字符串)作为其配置选项,无论是在创建对象时还是在之后使用itemconfig方法或addtag_withtag方法,如下所示:

canvas.create_rectangle(10, 10, 50, 50, tags="foo")
canvas.itemconfig(item_specifier, tags="spam")
canvas.addtag_withtag("spam", "baz")

您可以通过传入字符串元组作为标签,一次性给一个项目添加多个标签,如下所示:

canvas.itemconfig(item_specifier, tags=("tag_A", "tag_B"))

要获取与项目处理程序关联的所有标签,请按以下方式使用gettags

canvas.gettags(item_handle)

这将返回与该项目句柄相关联的所有标签的元组。

要获取所有具有给定标签的项的句柄,请使用 find_withtag

canvas.find_withtag("spam")

这将返回所有带有垃圾邮件标签的项目句柄的元组。

根据这些信息,让我们编写前六个按钮的功能代码,如图所示:

图片

更具体地说,我们将为以下在之前定义的元组 tool_bar_functions 中已经定义的功能名称编写代码:"draw_line""draw_oval""draw_rectangle""draw_arc",以及 "draw_triangle""draw_star"

这里是draw_line函数的代码(参见代码6.04.py):

def draw_line(self):
  self.current_item = self.canvas.create_line(self.start_x, 
    self.start_y, self.end_x,                                
      self.end_y, fill=self.fill, width=self.width, arrow=self.arrow, 
        dash=self.dash)

这使用了create_line方法,并从起始的x, y坐标绘制一条线到结束的x, y坐标。我们定义了四个新的属性来处理线的四个不同属性:

  • fill: 线条颜色。默认为 black,在我们的程序中初始化为红色。

  • width: 默认值为 1,在我们的程序中初始化为 2

  • 箭头: 默认为 None。可用的选项有:NoneFirstLastBoth

  • 破折号: 一种破折号模式,表示一系列线段长度。只有奇数线段会被绘制。

我们将在稍后提供从顶部栏更改这四个值的选项,因此这些值已被添加为类属性。

还要注意的是,由于 create_line(以及所有创建方法)返回创建项的项句柄,我们将其存储在一个名为 current_item 的属性中。这使得我们可以访问最后创建的项,我们很快就会将其用于良好。

接下来,这是draw_oval函数的代码(参见代码6.04.py):

def draw_oval(self):
 self.current_item = self.canvas.create_oval(self.start_x, 
   self.start_y, self.end_x,  
     self.end_y, outline=self.outline, fill=self.fill,width=self.width)

这与draw_line的代码相同,除了我们添加了一个名为 outline 的新属性,用于处理轮廓颜色。

我们将不会讨论create_rectanglecreate_arc的代码,这些代码几乎与这里讨论的draw_oval代码相同(参见代码6.04.py)。

现在我们来讨论 create_polygon 方法。这个方法可以用来创建各种有趣的形状。让我们从一个简单的例子开始,绘制一个等边三角形(参见代码 6.04.py):

def draw_triangle(self):
  dx = self.end_x - self.start_x
  dy = self.end_y - self.start_y
  z = complex(dx, dy)
  radius, angle0 = cmath.polar(z)
  edges = 3
  points = list()
  for edge in range(edges):
    angle = angle0 + edge * (2 * math.pi) / edges
    points.append(self.start_x + radius * math.cos(angle))
    points.append(self.start_y + radius * math.sin(angle))
  self.current_item = self.canvas.create_polygon(points, 
    outline=self.outline,  
      fill=self.fill, width=self.width)

上述代码首先将 xy 坐标系中的变化从笛卡尔坐标系转换为由角度和半径表示的极坐标系。然后,使用以下公式计算三角形所有三边的 xy 坐标:

x = r*cosσ and y = r*sinσ 

一旦我们得到了三角形三个顶点的 x, y 坐标,我们就调用 create_polygon 方法来绘制三角形。

现在我们使用 create_polygon 方法来绘制星星。星星(以及许多其他多边形)可以想象成两个同心圆上的点或辐条集合,如下面的图所示:

图片

前图中显示的星星有五个辐条。我们稍后会让用户能够更改辐条的数量。因此,让我们首先定义一个类属性如下:

number_of_spokes = 5

星星的形状也由内圆半径与外圆半径的比值决定,正如前图所示。这个比值被称为辐条比。对于标准星,这个比值是 2。改变这个比值也可以产生各种有趣的星星形状。然而,在我们的例子中,我们将保持这个比值在2。根据这些规则,draw_star函数的代码定义如下(参见代码6.04.py):


 def draw_star(self):
   dx = self.end_x - self.start_x
   dy = self.end_y - self.start_y
   z = complex(dx, dy)
   radius_out, angle0 = cmath.polar(z)
   radius_in = radius_out / 2 # this is the spoke ratio
   points = list()
   for edge in range(self.number_of_spokes):
      # outer circle angle
      angle = angle0 + edge * (2 * math.pi) / self.number_of_spokes  
      # x coordinate (outer circle)
      points.append(self.start_x + radius_out * math.cos(angle)) 
      # y coordinate (outer circle)
      points.append(self.start_y + radius_out * math.sin(angle)) 
      # inner circle angle
      angle += math.pi / self.number_of_spokes
      # x coordinate (inner circle)
      points.append(self.start_x + radius_in * math.cos(angle))
      # y coordinate (inner circle)
      points.append(self.start_y + radius_in * math.sin(angle))
   self.current_item = self.canvas.create_polygon(points, outline=self.outline, fill=self.fill, width=self.width)

上述代码注释丰富,以便您理解。这与我们用来绘制三角形的代码非常相似。

现在,我们不再像三角形那样在一个圆上有点,而是在两个圆上有点。我们再次使用同样的技术,首先将鼠标事件中的 xy 坐标转换为极坐标。一旦我们得到极坐标,移动圆上的点就变得容易了。

然后,我们将点按照给定的角度移动,并转换回笛卡尔坐标系。我们持续将所有点追加到一个名为 points 的空列表中。一旦我们有了所有点,最后一行调用画布对象的 create_polygon 方法来绘制星星。

现在我们已经拥有了创建这六个形状的所有方法。但是,它们需要从某个地方被调用,以便绘图能够发生。而且,我们已经决定它们将会被动态调用。

因此,我们定义了一个方法,execute_selected_method,它接受所选工具栏功能的字符串,将字符串转换为可调用的函数,并动态执行它。

代码如下(见代码 6.04.py):

def execute_selected_method(self):
  self.current_item = None
  func = getattr(self, self.selected_tool_bar_function, 
    self.function_not_defined)
  func()

此方法 getattr 在运行时提供了一个对给定字符串中方法的引用。第二个参数提供了一个回退机制,如果第一个参数中的方法对象未找到,则提供第二个方法的引用。

这有助于我们优雅地处理动态创建的方法不存在的情况。我们只需将这些情况的处理方法定义为空方法即可(参见代码6.04.py):

def function_not_defined(self):
   pass

因此,我们现在有一个方法来动态执行所选的方法。我们把这个方法插在哪里呢?

由于绘图必须在鼠标点击时开始,我们从on_mouse_button_pressed方法中调用一次execute_selected_method方法。

在鼠标被拖动到点击位置时,绘图必须继续。因此,我们从on_mouse_button_pressed_motion方法中再次调用此方法。

然而,尽管我们希望在鼠标移动过程中保留最后绘制的对象,但我们希望移除除最后绘制的对象之外的所有其他项目。因此,我们修改了on_mouse_button_pressed_motion函数,如下所示(参见代码6.04.py):

def on_mouse_button_pressed_motion(self, event):
  self.end_x = self.canvas.canvasx(event.x)
  self.end_y = self.canvas.canvasy(event.y)
  self.canvas.delete(self.current_item)
  self.execute_selected_method()

现在,如果你运行 6.04.py,工具栏上最上面的六个按钮应该像以下截图所示那样工作:

图片

添加颜色调色板

我们现在可以在我们的绘图程序中绘制基本形状。然而,我们仍然不能改变这些形状的颜色。在我们允许用户更改颜色之前,我们必须提供一个让他们选择颜色的方法。

因此,我们将提供一个颜色选择器,让用户选择两种不同的颜色:前景色和背景色。

图片

当我们进行时,也可以添加一个标签来显示鼠标在画布上悬停时的 xy 坐标,正如前一个截图中所突出显示的。

让我们从调色板开始。这两个调色板不过是放置在画布上的两个小矩形项目。为了展示这两个矩形,我们定义了一个方法,create_color_palette,并从现有的create_gui方法中调用它。

create_color_palette 函数的代码如下(参见代码 6.05.py):

def create_color_palette(self):
  self.color_palette = Canvas(self.tool_bar, height=55, width=55)
  self.color_palette.grid(row=10, column=1, columnspan=2, pady=5, padx=3)
  self.background_palette = self.color_palette.create_rectangle( 15, 
    15, 48, 48,       
      outline=self.background, fill=self.background)
  self.foreground_palette = self.color_palette.create_rectangle(
    1, 1, 33, 33, outline=self.foreground, fill=self.foreground)
  self.bind_color_palette()

该方法以调用名为 bind_color_palette 的方法结束,该方法定义如下(见代码 6.05.py):

def bind_color_palette(self):
   self.color_palette.tag_bind(self.background_palette, 
                              "<Button-1>", self.set_background_color)
   self.color_palette.tag_bind(self.foreground_palette, 
                              "<Button-1>", self.set_foreground_color)

上述代码简单地使用 Canvas 小部件的tag_bind方法将鼠标点击绑定到两个尚未定义的方法,set_background_colorset_foreground_color

这是tag_bind方法的签名:

tag_bind(item, event=None, callback, add=None)

此方法为所有匹配项添加事件绑定。请注意,这些绑定应用于项,而不是标签。例如,如果你在调用tag_bind之后将现有标签添加到新项中,新项将不会自动绑定到事件。

接下来,让我们定义一个实际打开颜色选择器并根据用户选择的颜色设置前景色和背景色的方法。

Tkinter 自带一个内置的colorchooser模块,我们将其导入到我们的命名空间中,如下所示(参见代码6.06.py):

from tkinter import colorchooser

要打开颜色选择器,我们需要调用它的askcolor方法,如下所示:

def get_color_from_chooser(self, initial_color, color_type="a"):
  color = colorchooser.askcolor(color=initial_color, title="select {}            
                                    color".format(color_type))[-1]
  if color:
    return color
  else:  # dialog has been cancelled
    return initial_color

点击“确定”后,颜色选择器返回一个形式为的元组:

((217.84765625, 12.046875, 217.84765625), '#d90cd9') 

当元组的第一个元素是包含所选颜色的 RGB 值的另一个元组,而元组的最后一个元素代表所选颜色的十六进制颜色代码时,如果点击了取消按钮,它将返回None

我们随后使用前面的方法来设置前景和背景颜色如下:

def set_foreground_color(self, event=None):
  self.foreground = self.get_color_from_chooser(self.foreground, 
    "foreground")
  self.color_palette.itemconfig(self.foreground_palette, width=0, 
    fill=self.foreground)

def set_background_color(self, event=None):
  self.background = self.get_color_from_chooser( self.background,  
    "background")
  self.color_palette.itemconfig(self.background_palette, width=0, 
    fill=self.background)

这就完成了我们画图程序中颜色选择器的编码。然而,请注意,您所选择的颜色将仅改变前景和背景属性的值。它不会改变画布上绘制项目的颜色。我们将在单独的迭代中完成这一点。

最后,让我们定义显示当前鼠标位置在标签中的方法。

我们创建了两种新的方法(参见代码6.05.py):

def create_current_coordinate_label(self):
  self.current_coordinate_label = Label(self.tool_bar, text='x:0\ny: 0 ')
  self.current_coordinate_label.grid( row=13, column=1, columnspan=2, 
    pady=5, padx=1, sticky='w')

def show_current_coordinates(self, event=None):
  x_coordinate = event.x
  y_coordinate = event.y
  coordinate_string = "x:{0}\ny:{1}".format(x_coordinate, y_coordinate)
  self.current_coordinate_label.config(text=coordinate_string)

我们从现有的 on_mouse_unpressed_motion 方法中调用 show_current_coordinates 如下(参见代码 6.05.py):

def on_mouse_unpressed_motion(self, event):
 self.show_current_coordinates(event)

添加绘图方法顶部栏选项

每个工具栏上的 16 个按钮都可以有自己的选项。就像我们动态调用与工具栏按钮相关的函数一样,我们还将再次调用方法来动态显示顶栏的选项。

因此,我们决定处理顶部栏选项的方法将通过在现有方法后附加字符串 _options 来命名。

假设我们想要显示draw_line方法的选项,它将在名为draw_line_options的方法中定义。同样,我们还需要定义诸如draw_arc_optionsdraw_star_options等其他方法。

我们在display_options_in_the_top_bar方法中实现这种动态调用,具体如下(参见代码6.06.py):

def display_options_in_the_top_bar(self):
  self.show_selected_tool_icon_in_top_bar(self.selected_tool_bar_function)
  options_function_name = 
    "{}_options".format(self.selected_tool_bar_function)
  func = getattr(self, options_function_name, self.function_not_defined)
  func()

现在,有了这段代码,每次点击工具栏按钮时,程序都会寻找一个通过在当前按钮相关的方法名称后附加_options字符串来命名的方法。如果找到了,它将被执行。如果没有找到,将调用后备函数function_not_defined,这是一个空方法,用于静默忽略方法的缺失。

Canvas 小部件允许您指定大多数形状的填充颜色、轮廓颜色和边框宽度作为它们的可配置选项。

除了这些,Canvas 小部件还有许多其他可配置选项,用于这些基本形状。例如,对于一条线,你可以指定它是否在末端有箭头形状,或者是否为虚线。

我们需要在前六个按钮中显示以下顶级选项:

图片

如所示,我们需要创建用于填充、轮廓、宽度、箭头和虚线的 Combobox 小部件。我们首先将 ttk 模块导入到我们的命名空间中,然后创建如以下代码所示的小部件 Combobox(参见代码 6.06.py):

def create_fill_options_combobox(self):
  Label(self.top_bar, text='Fill:').pack(side="left")
  self.fill_combobox = ttk.Combobox(self.top_bar, state='readonly', 
    width=5)
  self.fill_combobox.pack(side="left")
  self.fill_combobox['values'] = ('none', 'fg', 'bg', 'black', 'white')
  self.fill_combobox.bind('<<ComboboxSelected>>', self.set_fill)
  self.fill_combobox.set(self.fill)

ttk Combobox 小部件绑定到另一个名为 set_fill 的方法,该方法定义如下 (6.06.py):

def set_fill(self, event=None):
  fill_color = self.fill_combobox.get()
  if fill_color == 'none':
    self.fill = '' # transparent
  elif fill_color == 'fg':
    self.fill = self.foreground
  elif fill_color == 'bg':
   self.fill = self.background
  else:
   self.fill = fill_color

我们为widthoutlinearrowdash属性定义了一个类似的combobox。我们还定义了一个combobox,允许用户更改星形中的辐条数量。

由于所有这些方法的代码与我们刚刚讨论的代码非常相似,我们在这里不对其进行探讨 (6.06.py).

最后,我们将所需的组合框添加到六个选项方法中的每一个,具体如下:

def draw_line_options(self):
  self.create_fill_options_combobox()
  self.create_width_options_combobox()
  self.create_arrow_options_combobox()
  self.create_dash_options_combobox() 

所有其他五个工具栏按钮也有类似的代码(参见代码6.06.py)。

现在,如果你运行代码6.06.py,它应该显示前六个按钮的选项。

当你更改选项时,更改将在画布上的所有后续绘图中得到反映。

然而,我们的代码中存在一个小错误。如果有人将填充颜色选为了前景颜色怎么办?然后他们从颜色调色板中更改了前景颜色。尽管这改变了前景属性的值,但它并没有改变填充属性的值。我们的程序将继续使用旧的填充颜色值。

为了修复这个错误,我们修改了set_background_colorset_foreground_color的代码,使其调用两个新的方法:

def try_to_set_fill_after_palette_change(self):
  try:
    self.set_fill()
  except:
    pass

def try_to_set_outline_after_palette_change(self):
  try:
   self.set_outline()
  except:
   pass

这两种方法被放在一个 try…except 块中,因为并非每个工具栏按钮都会有一个填充和轮廓选项的 combobox。即使一个工具栏按钮有填充或轮廓 combobox,它也可能没有被选中来使用前景或背景颜色。

最后,由于我们希望draw_line选项在程序启动时立即填充顶部栏,我们在create_gui方法中添加了以下两行代码(参见6.06.py代码):

self.show_selected_tool_icon_in_top_bar("draw_line")
self.draw_line_options() 

这次迭代到此结束。我们将在下一个迭代中为几个其他工具栏按钮添加功能。

绘制不规则线条和超级形状

让我们现在添加绘制不规则或连续自由流动线条的功能。我们还将添加在绘图画布上绘制各种有趣形状的能力,如图所示:

图片

作为提醒,我们所有的按钮都链接到在tool_bar_functions元组中定义的动态函数。此外,我们可以通过在函数名中添加_options字符串来为特定的函数指定唯一选项。

绘制不规则线条

要添加绘制不规则线条的功能,我们只需定义名为 draw_irregular_line 的方法。要指定出现在顶部栏中的选项,我们需要定义名为 draw_irregular_line_options 的方法。

我们将 draw_irregular_line 方法定义为如下(参见代码 6.07.py):

def draw_irregular_line(self):
 self.current_item = self.canvas.create_line(
   self.start_x, self.start_y, self.end_x, self.end_y, fill=self.fill, 
     width=self.width)
 self.canvas.bind("<B1-Motion>", self.draw_irregular_line_update_x_y)

def draw_irregular_line_update_x_y(self, event=None):
 self.start_x, self.start_y = self.end_x, self.end_y
 self.end_x, self.end_y = event.x, event.y
 self.draw_irregular_line()

上述代码与draw_line函数的代码类似,但增加了一条额外的线,将鼠标点击的移动绑定到一个新方法,该方法用结束的xy坐标替换开始的xy坐标,并再次调用draw_irregular_line方法,从而以连续的方式绘制。

顶部栏中显示的选项是通过以下方法定义的(参见代码6.07.py):

def draw_irregular_line_options(self):
  self.create_fill_options_combobox()
  self.create_width_options_combobox() 

现在我们可以画不规则的线条在画布上了。然而,由于我们修改了鼠标绑定,所有其他方法也将开始以连续的方式绘制。

因此,我们需要将按钮重新绑定到它们原始的绑定上。我们通过修改on_tool_bar_button_clicked函数来调用bind_mouse函数,然后该函数将鼠标绑定恢复到其原始行为。

将事件绑定添加到多个方法中会清除之前的绑定,新绑定将替换任何现有的绑定。

或者,您可以使用add="+"作为附加参数来保持对同一事件的多个绑定,如下所示:

mywidget.bind("<SomeEvent>", method1, add="+")

mywidget.bind("<SameEvent>", method2, add="+")

这将使相同的事件绑定到method1method2

绘制超级形状

我们将这些形状称为超级形状,因为我们可以使用一个名为超级公式的单个数学公式构建许多有趣的形状。有关公式的更多详细信息,请参阅en.wikipedia.org/wiki/Superformula

超级公式包含六个输入参数:abmn1n2n3。改变这五个参数会产生自然界中发现的多种形状,例如贝壳、海星、花朵等形状。

我们并不深入探讨这个公式的原理或运作方式。我们只是编写了一个方法,给定这五个参数,它会返回独特形状的坐标。然后我们将这些坐标传递给我们的create_polygon方法,在画布上创建这些形状。返回这些点的这个方法定义如下(见代码6.07.py):

def get_super_shape_points(self, a, b, m, n1, n2, n3):
  # https://en.wikipedia.org/wiki/Superformula
  points = []
  for i in self.float_range(0, 2 * math.pi, 0.01):
   raux = (abs(1 / a * abs(math.cos(m * i / 4))) ** n2 + \
           abs(1 / b * abs(math.sin(m * i / 4))) ** n3)
   r = abs(raux) ** (-1 / n1)
   x = self.end_x + r * math.cos(i)
   y = self.end_y + r * math.sin(i)
   points.extend((x, y))
return points

该方法使用自定义的 float_range 方法,因为 Python 的内置 range 方法不允许使用浮点步长。float_range 生成器方法定义如下:

def float_range(self, x, y, step):
  while x < y:
   yield x
   x += step

接下来,我们定义draw_super_shape方法,该方法使用计算出的点创建一个多边形(参见代码6.07.py):

def draw_super_shape(self):
  points = self.get_super_shape_points 
    (*super_shapes[self.selected_super_shape])
  self.current_item = self.canvas.create_polygon(points, 
    outline=self.outline,  
      fill=self.fill, width=self.width)

现在我们希望向超级公式提供一组不同的五个参数。我们定义一个新的文件名为 supershapes.py,其中包含一个名为 super_shapes 的字典,该字典以形状名称和五个参数的形式表示不同的形状,如下所示:

super_shapes = {
 "shape A": (1.5, 1.5, 5, 2, 7, 7),
 "shape B": (1.5, 1.5, 3, 5, 18, 18),
 "shape C": (1.4, 1.4, 4, 2, 4, 13),
 "shape D": (1.6, 1.6, 7, 3, 4, 17),
 "shape E": (1.9, 1.9, 7, 3, 6, 6),
 "shape F": (4, 4, 19, 9, 14, 11),
 "shape G": (12, 12, 1, 15, 20, 3),
 "shape H": (1.5, 1.5, 8, 1, 1, 8),
 "shape I": (1.2, 1.2, 8, 1, 5, 8),
 "shape J": (8, 8, 3, 6, 6, 6),
 "shape K": (8, 8, 2, 1, 1, 1),
 "shape L": (1.1, 1.1, 16, 0.5, 0.5, 16)
 }

我们还定义了一个属性(参见代码6.07.py):

selected_super_shape = "shape A" 

接下来,我们定义一个combobox,让用户从之前定义的形状中选择(6.07.py):

def create_super_shapes_options_combobox(self):
  Label(self.top_bar, text='Select shape:').pack(side="left")
  self.super_shape_combobox = ttk.Combobox(self.top_bar, 
    state='readonly', width=8)
  self.super_shape_combobox.pack(side="left")
  self.super_shape_combobox['values'] = sorted(tuple(shape for shape in  
    super_shapes.keys()))
  self.super_shape_combobox.bind('<<ComboboxSelected>>', 
    self.set_selected_super_shape)
  self.super_shape_combobox.set(self.selected_super_shape)

我们定义了一种方法,用于设置selected_super_shape的选定形状值(参见代码6.07.py):

def set_selected_super_shape(self, event=None):
  self.selected_super_shape = self.super_shape_combobox.get()

最后,我们定义了draw_super_shapes_options,它显示了我们在顶部选项栏中想要显示的所有选项(参见代码6.07.py):

def draw_super_shape_options(self):
  self.create_super_shapes_options_combobox()
  self.create_fill_options_combobox()
  self.create_outline_options_combobox()
  self.create_width_options_combobox()

这就完成了迭代。现在你可以运行 *6.07.py* 并绘制不规则线条以及我们在 supershapes.py 文件中定义的所有超形状。实际上,你只需通过更改五个参数的值,就可以扩展 super_shapes 字典来添加更多形状。你可以查看 en.wikipedia.org/wiki/Superformula 了解创建有趣形状的参数值。

向剩余的按钮添加功能

我们现在将编写与剩余工具栏按钮相关的功能代码:

图片

具体来说,我们将编写以下函数:draw_textdelete_itemfill_itemduplicate_itemmove_to_topdrag_itemenlarge_item_sizereduce_item_size

让我们从draw_text的代码开始。当用户点击draw_text按钮时,我们希望在顶部栏显示以下选项:

图片

用户可以在文本框中输入文本并指定其字体大小和填充颜色。一旦用户按下“Go”按钮,文本就会出现在画布的中央。

因此,我们将draw_text_options方法定义为如下(参见代码6.08.py):

def draw_text_options(self):
  Label(self.top_bar, text='Text:').pack(side="left")
  self.text_entry_widget = Entry(self.top_bar, width=20)
  self.text_entry_widget.pack(side="left")
  Label(self.top_bar, text='Font size:').pack(side="left")
  self.font_size_spinbox = Spinbox(self.top_bar, from_=14, to=100, width=3)
  self.font_size_spinbox.pack(side="left")
  self.create_fill_options_combobox()
  self.create_text_button = Button(self.top_bar,                  
    text="Go", command=self.on_create_text_button_clicked)
  self.create_text_button.pack(side="left", padx=5)

上述代码一目了然。Go 按钮连接到一个名为on_create_text_button_clicked的命令回调,其定义如下(参见代码6.08.py):

def on_create_text_button_clicked(self):
  entered_text = self.text_entry_widget.get()
  center_x = self.canvas.winfo_width()/2
  center_y = self.canvas.winfo_height()/2
  font_size = self.font_size_spinbox.get()
  self.canvas.create_text(center_x, center_y, font=("", font_size),   
    text=entered_text, fill=self.fill)

我们的 draw_text 方法现在已可用。接下来,让我们编写 delete_item 方法。

我们现在想要进行的操作与它们的 predecessors 略有不同。以前,我们在画布上创建项目。现在,我们必须针对画布上已经存在的项目。

需要被定位的目标项是用户用鼠标点击的那个。幸运的是,使用当前标签获取鼠标下方的目标项句柄非常简单。

因此,delete_item 函数的代码如下(参见代码 6.08.py):

def delete_item(self):
  self.current_item = None
  self.canvas.delete("current") 

现在,如果您从工具栏中选择删除按钮并点击画布上的任何项目,该项目将被删除。

接下来,让我们编写fill_itemfill_item_options方法(参见代码6.08.py):

def fill_item(self):
  try:
    self.canvas.itemconfig("current", fill=self.fill, outline=self.outline)
  except TclError:
     self.canvas.itemconfig("current", fill=self.fill)

我们不得不使用try…except块,因为一些画布项目如线条和文本没有轮廓选项:

def fill_item_options(self):
  self.create_fill_options_combobox()
  self.create_outline_options_combobox()

接下来,我们编写duplicate_item方法。为了复制一个项目,我们需要知道以下三件事:

  • 项目类型——如果项目是直线椭圆弧线矩形多边形

  • 该项目的坐标

  • 项目配置

我们可以使用type方法将项目类型作为字符串获取,如下所示:canvas.type(item_specifier)

这将返回一个如lineovalarcrectanglepolygon这样的字符串。为了重新创建相同类型的项,我们需要将字符串create_附加到返回的类型上并调用该方法。

通过调用坐标方法,可以获取给定项目的坐标,如下所示:

`coordinates = canvas.coords("item_specifier")`

获取一个项目的配置可以通过以下命令以字典的形式获得:

canvas.itemconfig(item_specifier)

这将返回一个项目的所有配置,无论是否指定。例如,以下是调用前面方法在画布项目上返回的字典的一个示例:

{'outline': ('outline', '', '', 'black', 'red'), 'outlinestipple':
 ('outlinestipple', '', '', '', ''), 'activestipple':
 ('activestipple', '', '', '', ''), 'state': ('state', '', '',
 '', ''), 'offset': ('offset', '', '', '0,0', '0,0'),
 'activefill': ('activefill', '', '', '', ''), 'disabledwidth':
 ('disabledwidth', '', '', '0.0', '0'), 'disabledoutlinestipple':
 ('disabledoutlinestipple', '', '', '', ''), 'outlineoffset':
 ('outlineoffset', '', '', '0,0', '0,0'), 'width': ('width', '',
 '', '1.0', '2.0'), 'disabledfill': ('disabledfill', '', '', '',
 ''), 'disabledoutline': ('disabledoutline', '', '', '', ''),
 'dash': ('dash', '', '', '', ''), 'disableddash':
 ('disableddash', '', '', '', ''), 'disabledstipple':
 ('disabledstipple', '', '', '', ''), 'tags': ('tags', '', '',
 '', 'current'), 'stipple': ('stipple', '', '', '', ''),
 'activewidth': ('activewidth', '', '', '0.0', '0.0'),
 'activedash': ('activedash', '', '', '', ''), 'dashoffset':
 ('dashoffset', '', '', '0', '0'), 'activeoutlinestipple':
 ('activeoutlinestipple', '', '', '', ''), 'activeoutline':
 ('activeoutline', '', '', '', ''), 'fill': ('fill', '', '', '',
 'red')}

显然,我们不需要那些为空或为零的配置值。因此,我们编写了一个方法来过滤掉所有不必要的配置:

def get_all_configurations_for_item(self):
  configuration_dict = {}
  for key, value in self.canvas.itemconfig("current").items():
      if value[-1] and value[-1] not in ["0", "0.0", "0,0", "current"]:
         configuration_dict[key] = value[-1]
 return configuration_dict

现在我们知道了如何获取所有必要的元素来复制画布项目,以下是duplicate_item函数的代码(参见代码6.08.py):

def duplicate_item(self):
  try:
     function_name = "create_" + self.canvas.type("current")
  except TypeError:
     return
  coordinates = tuple(map(lambda i: i+10, self.canvas.coords("current")))
  configurations = self.get_all_configurations_for_item()
   self.canvas_function_wrapper(function_name, coordinates, configurations)

最后,最后一行调用了一个包装器函数,该函数实际上运行了复制画布项的函数(参见代码6.08.py):

def canvas_function_wrapper(self, function_name, *arg, **kwargs):
  func = getattr(self.canvas, function_name)
  func(*arg, **kwargs) 

现在,如果您创建一个项目,选择复制项目按钮,然后点击该项目,就会创建一个复制项目。然而,由于我们不希望复制项目正好位于现有项目上方,我们将其坐标从被复制的项目坐标偏移10像素。这种偏移是在以下行中完成的:

coordinates = tuple(map(lambda i: i+10, self.canvas.coords("current")))

现在,如果你在画布上创建一个项目,选择复制项目按钮,然后点击该项目,它的副本将在原始项目偏移10像素的位置创建。

接下来,我们编写move_to_top方法。我们已经讨论过,添加到画布上的项目是逐个叠加的。如果我们想移动之前添加到画布上的一个项目呢?以下图示展示了将一个项目移动到另一个项目之上的含义:

图片

我们使用tag_raisetag_lower方法来移动栈中的项目上下移动。我们使用tag_raise来定义move_to_top方法如下(参见代码6.08.py):

def move_to_top(self):
  self.current_item = None
  self.canvas.tag_raise("current")

上述代码将点击的项目在项目堆栈中提升到最高位置。

当你在画布上绘制多个项目时,这些项目会被放置在一个堆栈中。默认情况下,新项目会被添加到之前绘制在画布上的项目之上。然而,你可以通过以下方式更改堆栈顺序:

canvas.tag_raise(item).

如果多个项目匹配,它们都会被移动,并保持它们相对的顺序。然而,这种方法不会改变您在画布内绘制的任何新窗口项目的堆叠顺序。

然后还有 find_abovefind_below 方法,您可以使用这些方法在画布堆叠顺序中查找位于某个项目上方或下方的项目。

接下来,我们将定义drag_item方法。此方法使用移动方法来改变给定项的坐标(参见代码6.08.py):

def drag_item(self):
 self.canvas.move("current", self.end_x - self.start_x, self.end_y -  self.start_y)
 self.canvas.bind("<B1-Motion>", self.drag_item_update_x_y)

def drag_item_update_x_y(self, event):
 self.start_x, self.start_y = self.end_x, self.end_y
 self.end_x, self.end_y = event.x, event.y
 self.drag_item()

由于我们希望阻力连续发生而不是从一个地方跳跃到另一个地方,我们暂时将鼠标绑定绑定到更新起始和结束坐标,就像我们在定义draw_irregular_line方法时做的那样。

最后,我们定义了两种方法来放大和缩小项目大小。我们将使用canvas.scale方法将项目大小增加和减少 20%:

def enlarge_item_size(self):
  self.current_item = None
  if self.canvas.find_withtag("current"):
    self.canvas.scale("current", self.end_x, self.end_y, 1.2, 1.2)
    self.canvas.config(scrollregion=self.canvas.bbox(tk.ALL))

def reduce_item_size(self):
  self.current_item = None
  if self.canvas.find_withtag("current"):
    self.canvas.scale("current", self.end_x, self.end_y, .8, .8)
    self.canvas.config(scrollregion=self.canvas.bbox(tk.ALL))

注意,在项目大小调整后,我们立即重新配置滚动区域选项以更新滚动条。

bbox 方法返回一个项目的边界框。语法是:.canvas.bbox(item_specifier)。这返回一个长度为 4 的元组作为边界框。如果省略了项目指定符,则返回所有项目的边界框。

注意,边界框的值是近似的,可能比实际值相差几个像素。

这完成了迭代。左侧工具栏中的所有按钮现在都已启用(参见代码6.08.py)。

向菜单项添加功能

回想一下,在创建我们的菜单时使用Framework类,我们创建了与菜单项相关联的空方法。现在,我们将修改这些空方法,使它们变得可用(参见代码6.09.py

文件 | 新菜单:

画布删除方法可以用于删除一个项目,给定一个项目指定符。在这里,我们使用ALL来删除画布上的所有项目:

def on_new_file_menu_clicked(self, event=None):
  self.start_new_project()

def start_new_project(self):
  self.canvas.delete(ALL)
  self.canvas.config(bg="#ffffff")
  self.root.title('untitled')

文件 | 保存, 文件 | 另存为:

Tkinter 允许您使用 postscript() 命令将画布对象保存为 postscript 文件。请注意,然而,生成的 postscript 文件无法保存画布上的图像或任何嵌入的控件。此外,请注意,Tkinter 控件的 pickling 或保存为 .jpg.png 格式是不可能的。这是 Tkinter 的主要限制之一。

这里是保存和另存为功能的代码(参见代码6.09.py):

def actual_save(self):
  self.canvas.postscript(file=self.file_name, colormode='color')
  self.root.title(self.file_name)

我们没有讨论“关闭”和“关于”菜单,因为我们已经在所有之前的项目中编码了类似的菜单(见代码6.09.py)。

编辑 | 撤销:

回想一下,所有添加到画布上的项目都存储在堆栈中。我们可以使用画布命令来访问堆栈:

canvas.find("all") 

使用这个功能,我们实现了一个非常基础的撤销操作,它允许我们删除画布上最后绘制的项目。

因此,添加撤销功能的代码如下(参见代码6.09.py):

def on_undo_menu_clicked(self, event=None):
   self.undo()

def undo(self):
  items_stack = list(self.canvas.find("all"))
  try:
    last_item_id = items_stack.pop()
  except IndexError:
    return
  self.canvas.delete(last_item_id)

注意,这不会撤销任何样式更改,例如颜色、宽度、轮廓等变化。实际上,它只能删除堆栈中的最后一个项目。

我们可以通过保存所有操作到一个合适的数据结构中来实现一个完整的撤销栈,但这将是一个值得单独成章的练习。

除了我们在这里使用的查找方法外,Canvas 小部件还有一个名为:

find_closest(x, y, halo=None, start=None) 

它返回画布上给定位置最近的项的手柄。这意味着如果画布上只有一个项,无论你点击得有多近或多远,它都会被选中。

如果,另一方面,你只想在某个特定区域内使用对象,你可以使用:

find_overlapping(x1, y1, x2, y2) 

这将返回所有与给定矩形重叠或完全被其包围的项目。

现在我们已经掌握了要操作的项目,我们可以继续进行我们想要对项目做的任何操作。

要查看完整的画布方法列表,请参阅infohost.nmt.edu/tcc/help/pubs/tkinter/web/canvas-methods.html.

查看视图 | 放大视图,查看视图 | 缩小视图:

最后,我们使用 canvas.scale 方法定义了这两种方法。我们之前已经使用过缩放方法来放大和缩小单个项目。在这里,我们只需在 ALL 项目指定器上使用该方法,如下代码所示(参见代码 6.09.py):

def canvas_zoom_in(self):
  self.canvas.scale("all", 0, 0, 1.2, 1.2)
  self.canvas.config(scrollregion=self.canvas.bbox(ALL))

这就结束了迭代和这一章节。

摘要

总结来说,在本章中,我们首先在 Tkinter 之上创建了一个自定义的 GUI 框架。

我们看到了如何使用 GUI 框架来生成我们程序的样板代码,从而确保最大程度的代码复用和快速应用开发。

接下来,我们详细探讨了 Canvas 小部件。我们了解了如何创建各种画布项目。然后,我们看到了如何使用标签或 ID 来操作这些画布项目的属性。

我们看到了 Tkinter 的 colorchooser 模块在实际中的应用。我们使用了 ttk Combobox 小部件。我们还查看了一些所有 Tkinter 小部件都有的常用方法。

我们也看到了编写使用约定而非配置来简化程序逻辑流程的程序的好处。

QA 部分

在你继续阅读下一章之前,请确保你能回答这些问题

满意度:

  • 什么是软件框架?为什么它们会被使用?

  • 在什么情况下使用软件框架而不是从头编写代码是有益的?

  • 什么是结构化标记语言?你能列举一些吗?

  • 什么是约定优于配置的软件设计范式?

  • 在 Tkinter 的 Canvas 小部件的上下文中,标签有什么用途?

进一步阅读

阅读 Tkinter Canvas 小部件的完整文档。您可以通过在 Python 命令行中输入以下命令来找到文档:

>>> import tkinter
>>>  help(tkinter.Canvas)

第七章:钢琴辅导

在上一章中,我们探讨了 Canvas 小部件可用的常见选项。现在,让我们看看 PhotoImage 小部件的实际应用。

让我们构建一个名为 钢琴辅导 的程序。这个程序将帮助新手钢琴演奏者识别音阶、和弦以及和弦进行。它还将帮助钢琴学习者学习和识别乐谱上的音乐。拥有一些音乐知识的人会感到如鱼得水,但如果你对钢琴或音阶、和弦、和弦进行等音乐术语一无所知,也不要担心。随着我们的进展,我们将涵盖音乐知识的最基本内容。

在其最终形式中,该程序看起来如下:

图片

钢琴辅导课程将包含三个主要部分,您可以从最顶部的下拉菜单中选择。具体如下:

  • 规模查找器

  • 和弦查找器

  • 和弦进行构建器

本章的一些关键目标包括:

  • 理解在根窗口上定义的一些重要方法

  • 使用 PhotoImage 小部件类

  • Place 几何管理器的实际应用

  • 理解网格权重

  • 学习如何处理看似复杂的概念,例如以计算机能够理解的方式表示音乐知识

  • 使用 JSON 存储数据

技术要求

除了 Tkinter,我们还将使用几个标准的 Python 库。接下来的导入应该不会出现任何错误,因为它们在大多数 Python 发行版中都是内置的:

import json, collections, functools, math

此外,我们使用simpleaudio模块,这是一个允许我们在钢琴上播放音符的模块。

您可以使用以下命令安装 simpleaudio

pip3 install simpleaudio

钢琴术语简要入门

由于这是一个与钢琴相关的程序,因此需要简要了解在此背景下使用的某些常用术语。

在本节中,我们将以此图作为参考:

图片

钢琴的键盘由一组 12 个键(七个白键和五个黑键)组成,这形成了所谓的音阶。这 12 个键的模式不断重复,在标准钢琴上总共有 88 个键。在先前的图像中,这个模式重复了两次(从C1B1,然后从C2B2)。

任何两个相邻键之间的距离被称为半音。请注意这个术语,因为我们将会使用半音来定义所有与钢琴相关的规则——即键之间的距离度量。两个半音的间隔被称为全音。就我们的程序而言,我们不会关心全音。

钢琴的白键被标记为音符名称 AG。然而,按照惯例,音符的计数从 C 开始。C 是位于两组黑色键之前的第一个白键。白键的名称标记在键上,而黑键的名称则标记在其上方。

由于存在多套 12 个键的集合,它们通过在其后附加一个数字来相互区分。例如,C1 是第一个白键,而 C2 是同一位置上的键,但音高高一个八度。紧挨着 C 的黑键被称为 C#C sharp)。由于它也在 D 键之前,它还有一个名字——D 平(D♭)。然而,我们将坚持使用升号符号(#)来称呼所有黑键。由于音符 EB 没有升音键,它们后面不直接跟着任何黑键。

学习关于比例的知识

音阶是一系列从特定模式中选取的 12 个音符的有序序列,这赋予它一种特有的感觉,可能是快乐的、悲伤的、异国情调的、东方的、神秘的或叛逆的感觉。音阶可以从 12 个音符中的任何一个音符开始,并遵循一个确定的模式。音阶的第一个音符被称为其基音,它遵循的模式决定了音阶的类型。

对我们来说,有一个特别的音阶称为大调音阶。从任何键开始,大调音阶遵循以下模式:

W W S W W W S

其中 W 代表全音(两个键的跳跃)和 S 代表半音(一个键的跳跃)。

例如,如果你依次演奏音符 C1D1E1F1G1A1B1C2,然后再返回,你就演奏了一个 C 大调。大调听起来让人振奋,而另一个名为小调的音阶听起来可能有点悲伤。不用担心名称——有成百上千种音阶,我们只需要知道音阶是一系列按照一定规则一起演奏的音符的 序列

学习和弦

相比之下,和弦是指同时演奏两个或更多音符。例如,如果我同时演奏三个音符 C、F 和 G;那就是一个和弦。和弦通常为音乐提供低音部分。

如果你一遍又一遍地弹奏同一个和弦,它听起来会单调乏味——所以你会从一个和弦跳到另一个和弦——再次遵循一个规则。这被称为和弦进行。更简单地说,一系列有序的和弦被称为和弦进行。

音乐符号可以写在乐谱或五线谱上,它由五条线组成。音符由黑色圆点表示,这些圆点可以位于线上或它们之间的空白处。以下五线谱上显示了两个八度音符的名称。标记为高音谱号的图标表示这些音符应由右手演奏:

图片

不要担心,我们完成项目时不需要记住音乐符号。然而,我们在绘制乐谱时将会将其作为参考。

我们现在已经拥有了编写这个程序所需的所有音乐知识。让我们开始编码。

构建广泛的 GUI 结构

我们像往常一样从构建根窗口开始(7.01/view.py):

root = Tk()
SCREEN_WIDTH = root.winfo_screenwidth()
SCREEN_HEIGHT = root.winfo_screenheight()
SCREEN_X_CENTER = (SCREEN_WIDTH - WINDOW_WIDTH) / 2
SCREEN_Y_CENTER = (SCREEN_HEIGHT - WINDOW_HEIGHT) / 2
root.geometry('%dx%d+%d+%d' % (WINDOW_WIDTH, WINDOW_HEIGHT, SCREEN_X_CENTER,  SCREEN_Y_CENTER))
root.resizable(False, False)
PianoTutor(root)
root.mainloop()

我们还创建了一个名为 constants.py7.01)的新文件,该文件目前包含窗口的高度参数。

我们使用两种根窗口方法,root.winfo_screenwidth()root_winfo_screenheight(),分别获取屏幕的宽度和高度。我们定义了两个常量,WINDOW_WIDTHWINDOW_HEIGHT,然后将窗口放置在计算机屏幕的 xy 中心。

注意到这一行代码 root.resizable(False, False)。这个 root 窗口方法接受两个布尔参数来决定窗口是否在 xy 方向上可调整大小。将这两个参数都设置为 False 使得我们的窗口大小固定。

根窗口随后被传递给一个新的类,PianoTutor,该类负责构建程序的内部结构。这个类将在下面定义。

该程序的图形用户界面分为四个主要行:

图片

最顶行是在名为 mode_selector_frameFrame 中构建的,并包含一个 combobox,允许用户从三个选项中选择一个——音阶、和弦以及和弦进行。

第二行是放置乐谱的占位符,因此被称为score_sheet_frame

第三行需要一点注意。根据在顶部combobox中选择的选项,这一行的内容会发生变化。在我们目前的代码中(7.01/view.py*),它显示三个不同颜色的框架,对应于使用顶部combobox可以做出的三种不同选择。由于我们将在这一框架上放置控件,我们决定将其称为controls_frame,因为没有更好的名字。

第四行显示了钢琴键盘,框架被命名为 keyboard_frame,其实现将在标题为 制作钢琴键盘 的章节中进行讨论。

建立骨架结构

首先,我们创建一个名为 PianoTutor 的类(7.01/view.py),其 __init__ 方法定义如下:

class PianoTutor:

  def __init__(self, root):
    self.root = root
    self.root.title('Piano Tutor')
    self.build_mode_selector_frame()
    self.build_score_sheet_frame()
    self.build_controls_frame()
    self.build_keyboard_frame()
    self.build_chords_frame()
    self.build_progressions_frame()
    self.build_scales_frame()

在前面的代码中,我们只是简单地定义了方法调用,以构建多个具有预定义高度的 Frame 小部件。由于我们在前几章中已经编写了类似的代码,因此我们不会对前面的代码进行过多解释。

让我们来看一下创建框架的一个示例。所有其他框架都遵循类似的模式(7.01 /view.py)并且在此处不会进行讨论:

 def build_score_sheet_frame(self):
   self.score_sheet_frame = Frame(self.root, width=WINDOW_WIDTH, height=                    
                      SCORE_DISPLAY_HEIGHT, background='SteelBlue1')
   self.score_sheet_frame.grid_propagate(False)
   Label(self.score_sheet_frame, text='placeholder for score sheet',  
                       background='SteelBlue1').grid(row=1, column=1)
   self.score_sheet_frame.grid(row=1, column=0)

这是通过grid布局管理器创建简单Frame的过程。然而,请注意这一行self.score_sheet_frame.grid_propagate(False)

在 Tkinter 中,容器窗口(前一个示例中的 Frame)被设计为自动调整大小以适应其内容。

尽管我们明确地为框架添加了宽度或高度,如果我们注释掉grid_propagate(false)这一行,你会注意到我们提供的宽度和高度参数被简单地忽略,框架将缩小以恰好适应其子元素——在我们的例子中是标签小部件的高度。我们不希望允许框架缩小,而grid_propagate(False)则让我们实现了这一点。

如果我们使用包管理器,我们会使用 frame.pack_propagate(False) 来达到相同的效果。

接下来,我们最顶层的模式选择器 combobox 绑定到以下回调函数(7.01/view.py):

self.mode_selector.bind("<<ComboboxSelected>>", self.on_mode_changed)

这是我们定义on_mode_changed方法的方式(7.01/view.py):

def on_mode_changed(self, event):
  selected_mode = self.mode_selector.get()
  if selected_mode == 'Scales':
    self.show_scales_frame()
  elif selected_mode == 'Chords':
    self.show_chords_frame()
  elif selected_mode == 'Chord Progressions':
    self.show_progressions_frame()

def show_scales_frame(self):
  self.chords_frame.grid_remove()
  self.progressions_frame.grid_remove()
  self.scales_frame.grid()

def show_chords_frame(self):
  self.chords_frame.grid()
  self.progressions_frame.grid_remove()
  self.scales_frame.grid_remove()

def show_progressions_frame(self):
  self.chords_frame.grid_remove()
  self.progressions_frame.grid()
  self.scales_frame.grid_remove()

记下之前提到的 grid_remove() 方法。此方法将从网格管理器中移除小部件,从而使其不可见。您可以通过对其使用 grid() 来再次使其可见。因此,每当用户从最顶部的 combobox 中选择三个选项之一(ScalesChordsChord Progression)时,其他两个框架将使用 grid_remove 隐藏,而所选选项的框架将使用 grid 显示出来。

这完成了第一次迭代,我们在其中定义了具有根据顶部组合框中的选择在音阶、和弦和弦进行框架之间切换能力的广泛 GUI 结构。

制作钢琴键盘

让我们现在构建钢琴键盘。键盘上的所有键都将使用标签小部件(Label widget)制作。我们将使用 Tkinter 的PhotoImage类将标签小部件与黑白键的图像叠加。

PhotoImage 类用于在标签、文本、按钮和画布小部件中显示图像。我们在第二章,“制作文本编辑器”中使用了它来为按钮添加图标。由于这个类只能处理 .gif.bpm 格式的图像,我们在名为 pictures 的文件夹中添加了四个 .gif 图像。这四个图像分别是 black_key.gifwhite_key.gifblack_key_pressed.gifwhite_key_pressed.gif

由于我们将反复引用这些图像,我们将其引用添加到7.02 constants.py文件中:

WHITE_KEY_IMAGE = '../pictures/white_key.gif'
WHITE_KEY_PRESSED_IMAGE = '../pictures/white_key_pressed.gif'
BLACK_KEY_IMAGE = '../pictures/black_key.gif'
BLACK_KEY_PRESSED_IMAGE = '../pictures/black_key_pressed.gif'

之前使用的符号 ../ 是一种指定相对于当前工作目录的文件路径的方法。单个 ../ 表示退回一个目录,一对两个 ../../ 表示退回两个目录,以此类推。这个系统通常被大多数现代操作系统所支持。然而,一些非常古老的操作系统可能不支持它。因此,一个更好但更啰嗦的方法是使用 Python 的 os 模块来遍历目录。

接下来,我们将定义一个名为 create_key 的方法,该方法在给定的 x 位置为我们创建一个钢琴键:

 def create_key(self, img, key_name, x_coordinate):
   key_image = PhotoImage(file=img)
   label = Label(self.keyboard_frame, image=key_image, border=0)
   label.image = key_image
   label.place(x=x_coordinate, y=0)
   label.name = key_name
   label.bind('<Button-1>', self.on_key_pressed)
   label.bind('<ButtonRelease-1>', self.on_key_released)
   self.keys.append(label)
   return label

这里是一个简短的代码描述:

  • 注意,由于我们希望将键放置在特定的 x 坐标上,我们使用了 place 几何管理器。我们曾在 第一章,认识 Tkinter 中简要介绍了 place 几何管理器。现在是一个很好的时机来观察这个很少使用的几何管理器在实际中的应用。

  • 此方法也接受一个图像位置作为其输入,并创建一个PhotoImage类,然后使用前一个示例中的image=key_image选项将该类附加到标签小部件上。

  • 第三个参数,key_name,通过使用命令widget.name = key_name附加到创建的标签小部件上。这需要在以后识别哪个特定的键被按下。例如,为了创建第一个键C1,我们将名称C1附加到标签小部件上,然后以后可以通过调用widget.name来访问这个字符串值。

  • 我们将标签绑定到两个事件,'<Button-1>''<ButtonRelease-1>',以处理鼠标按下事件。

  • 最后,我们将对新创建的小部件的引用添加到此处新定义的属性self.keys中。我们保留这个引用,因为我们还需要更改这些小部件的图片以突出显示键。

现在我们已经将事件附加到两个回调函数上了,接下来让我们定义它们:

def on_key_pressed(self, event):
  print(event.widget.name + ' pressed') 
  self.change_image_to_pressed(event)

def on_key_released(self, event):
  print(event.widget.name + ' released' ) 
  self.change_image_to_unpressed(event)

目前,之前的方法会在按下键后打印按键名称,然后调用另外两个方法,在按键按下和释放时将按下标签的图像更改为不同颜色的图像:

def change_image_to_pressed(self, event):
 if len(event.widget.name) == 2:
   img = WHITE_KEY_PRESSED_IMAGE
 elif len(event.widget.name) == 3:
   img = BLACK_KEY_PRESSED_IMAGE
 key_img = PhotoImage(file=img)
 event.widget.configure(image=key_img)
 event.widget.image = key_img

def change_image_to_unpressed(self, event):
  if len(event.widget.name) == 2:
    img = WHITE_KEY_IMAGE
  elif len(event.widget.name) == 3:
    img = BLACK_KEY_IMAGE
  key_img = PhotoImage(file=img)
  event.widget.configure(image=key_img)
  event.widget.image = key_img

白键小部件将有一个长度为2的名称(例如,C1D2G1),而黑键将有一个长度为3的图像(例如,C#1D#1)。我们利用这一事实来决定是否使用黑键图像或白键图像。前述代码的其余部分应该是不言自明的。

组装键盘

现在终于到了将所有前面的方法结合起来构建我们完整的八度键盘的时候了。

我们首先在文件 constants.py 中定义了从 C1B2 所有键的精确 x_coordinates 如下:

WHITE_KEY_X_COORDINATES = [0,40, 80,120, 160, 200, 240,280, 320, 360, 400, 440, 480,520]
BLACK_KEY_X_COORDINATES = [30,70,150,190, 230, 310, 350, 430,470, 510]

前面的 x 坐标数值是通过简单的试错法获得的,以模拟它们在键盘上的位置。

然后我们将之前定义的 build_keyboard_frame 方法修改如下:

 def build_keyboard_frame(self):
   self.keyboard_frame = Frame(self.root, width=WINDOW_WIDTH, 
               height=KEYBOARD_HEIGHT,   background='LavenderBlush2')
   self.keyboard_frame.grid_propagate(False)
   self.keyboard_frame.grid(row=4, column=0, sticky="nsew")
   for index, key in enumerate(WHITE_KEY_NAMES):
     x = WHITE_KEY_X_COORDINATES[index]
     self.create_key(WHITE_KEY_IMAGE, key, x )
   for index, key in enumerate(BLACK_KEY_NAMES):
     x = BLACK_KEY_X_COORDINATES[index]
     self.create_key(BLACK_KEY_IMAGE, key, x)

前一种方法的头三行保持与上一次迭代中定义的状态不变。然后我们遍历所有白键和黑键,在给定的 x 坐标处为它们创建标签。

这就完成了迭代。如果你现在运行 7.02 view.py,你应该看到一个八度键盘。当你按下任何键时,该键的图像应该变为蓝色,并且应该在终端中打印出按下的或释放的键的名称:

图片

播放音频

首先,我们在本章代码文件夹中名为 sounds 的文件夹中添加了 24 个 .wav 格式的声音样本。这些音频文件对应于我们键盘上的 24 个音符。音频文件按照其代表的音符名称命名。

为了将音频处理与 GUI 代码分离,我们创建了一个名为audio.py的新文件(7.03)。代码定义如下:

import simpleaudio as sa
from _thread import start_new_thread
import time

def play_note(note_name):
 wave_obj = sa.WaveObject.from_wave_file('sounds/' + note_name + '.wav')
 wave_obj.play()

def play_scale(scale):
 for note in scale:
   play_note(note)
   time.sleep(0.5)

def play_scale_in_new_thread(scale):
  start_new_thread(play_scale,(scale,))

def play_chord(scale):
  for note in scale:
    play_note(note)

def play_chord_in_new_thread(chord):
  start_new_thread(play_chord,(chord,))

代码描述如下:

  • play_note 方法遵循 simpleaudio 提供的 API 来播放音频文件

  • play_scale 方法接收一个音符列表并按顺序播放它们,在每播放一个音符之间留有时间间隔

  • play_chord 方法接受一个音符列表并一起播放这些音符

  • 最后两种方法在新线程中调用这些方法,因为我们不想在播放这些音符时阻塞主 GUI 线程。

接下来,让我们导入这个文件(7.03 view.py):

from audio import play_note    

接下来,修改on_key_pressed方法以播放指定的音符:

def on_key_pressed(self, event):
 play_note(event.widget.name)
 self.change_image_to_pressed(event)

这就完成了迭代。如果你现在运行代码并按键盘上的任意键,它应该会播放那个键的音符。

接下来,我们开始构建实际的辅导工具。接下来的三个部分将开发音阶、和弦和弦进行部分。我们将从构建音阶辅导工具开始。

构建音阶辅导师

定义给定音阶应演奏哪些音符的所有规则都添加在一个名为 scales.json 的 JSON 文件中,该文件位于名为 json 的文件夹内。让我们来看看 scales.json 文件的前几行:

{
 "Major": [ 0, 2, 4, 5, 7, 9, 11 ],
 "Minor": [ 0, 2, 3, 5, 7, 8, 10 ],
 "Harmonic minor": [ 0, 2, 3, 5, 7, 8, 11 ],
 "Melodic minor": [ 0, 2, 3, 5, 7, 9, 11 ],
 "Major blues": [ 0, 2, 3, 4, 7, 9 ],
 "Minor blues": [ 0, 3, 5, 6, 7, 10 ],
...
}

回想一下,音阶是一系列依次演奏的音符。音阶的第一个音符被称为其根音调性。所以,如果你从一个音符,比如说音符B开始演奏音阶,你就是在B调上演奏音阶。

让我们来看一下键值对中的第四项。键名为 "Melodic minor",其关联的值为 [ 0, 2, 3, 5, 7, 9, 11 ]。这意味着,要在 B 音上演奏旋律小调音阶,你需要将 B 作为第一个音项——在值列表中由 0 表示。下一个键比 B 高两个半音,第三个键比 B 高三个半音,接下来是 5,然后是 7,9,最后是比 B 高 11 个半音。

所以总结一下——为了在 B 调上演奏旋律小调,你需要按下以下键位:

B, B+2, B+3, B+5, B+7, B+9 and B+11 keys

前面的数字代表半音。

因此,我们的任务是——给定一个音阶及其键,我们的程序应该突出显示这些键并播放它们:

图片

首先需要构建两个combobox,一个用于缩放,另一个用于键,正如之前所示。这对您来说应该很容易,因为我们之前章节中已经构建过combobox多次。

第二步涉及将 JSON 文件读入我们的程序中。

引用自 json.org (json.org/):

JSON(JavaScript 对象表示法)是一种轻量级的数据交换格式。它易于人类阅读和编写。它也易于机器解析和生成。这些是通用的数据结构。几乎所有的现代编程语言都以某种形式支持它们。一个可以与编程语言交换的数据格式也基于这些结构是有意义的。更多关于 JSON 的信息请参阅www.json.org

Python 实现了一个用于读取和写入 JSON 数据的标准模块,该模块名称恰当地被称为 json

我们首先在我们的命名空间中导入内置的json模块:

import json

我们接下来添加一个新的方法,self.load_json_files(),并在类的__init__方法中调用它:

def load_json_files(self):
  with open(SCALES_JSON_FILE, 'r') as f:
    self.scales = json.load(f, object_pairs_hook=OrderedDict)

SCALES_JSON_FILE 路径在文件 constants.py 中定义这会将刻度数据作为字典加载到 self.scales 属性中:

您可以使用json.load命令读取 JSON 文件。您可以使用json.dump命令写入 JSON 文件。然而,json.load方法不会保留解析的 JSON 文档中的键顺序。也就是说,json.load打乱了键的顺序。我们不希望打乱键的顺序,并希望它们按照在文件中提到的顺序出现。因此,我们使用collections模块中的OrderedDict类来保留键顺序。这是通过将第二个参数传递为object_pairs_hook=OrderedDict来实现的。OrderedDict是一个 Python 字典对象,它记得键首次插入的顺序。

现在我们已经有了scales数据作为字典self.scales可用,我们的下一个任务是确定要突出的键。我们首先在类的__init__方法中创建一个新的属性:

self.keys_to_highlight = []  

接下来,我们定义了用于突出显示一个关键点的方法,以及用于突出显示一系列关键点的方法:

def highlight_key(self, key_name):
 if len(key_name) == 2:
   img = WHITE_KEY_PRESSED_IMAGE
 elif len(key_name) == 3:
   img = BLACK_KEY_PRESSED_IMAGE
 key_img = PhotoImage(file=img)
 for widget in self.keys:
  if widget.name == key_name:
    widget.configure(image=key_img)
    widget.image = key_img

def highlight_list_of_keys(self, key_names):
  for key in key_names:
     self.highlight_key(key)

上述代码与之前我们编写的用于在按键时突出显示关键内容的代码类似。接下来,我们还需要方法来移除现有的突出显示:

def remove_key_highlight(self, key_name):
  if len(key_name) == 2:
    img = WHITE_KEY_IMAGE
  elif len(key_name) == 3:
    img = BLACK_KEY_IMAGE
  key_img = PhotoImage(file=img)
  for widget in self.keys:
   if widget.name == key_name:
    widget.configure(image=key_img)
    widget.image = key_img

def remove_all_key_highlights(self):
  for key in self.keys_to_highlight:
    self.remove_key_highlight(key)
  self.keys_to_highlight = []

这里的逻辑与我们所应用的用于突出显示键的逻辑完全相同。

现在我们有了高亮和移除键高亮的方法,让我们定义两个组合框(用于缩放和键选择)所附加的回调函数:

def on_scale_changed(self, event):
  self.remove_all_key_highlights()
  self.find_scale(event)

def on_scale_key_changed(self, event):
  self.remove_all_key_highlights()
  self.find_scale(event)

最后,这是选择哪些键要高亮的逻辑。此外,一旦我们有了要高亮的键列表,我们就将其传递给之前定义的play_scale_in_new_thread方法,该方法播放实际的音阶声音:

def find_scale(self, event=None):
 self.selected_scale = self.scale_selector.get()
 self.scale_selected_key = self.scale_key_selector.get()
 index_of_selected_key = KEYS.index(self.scale_selected_key)
 self.keys_to_highlight = [ ALL_KEYS[i+index_of_selected_key] \
 for i in self.scales[self.selected_scale]]
 self.highlight_list_of_keys(self.keys_to_highlight)
 play_scale_in_new_thread(self.keys_to_highlight)

请注意代码中高亮的部分。

因此,给定所选键的索引,我们只需将所选比例列表中的所有项相加,即可获得要突出显示的键列表。

我们也希望程序运行时立即调用这个方法。因此,我们在__init__方法中直接添加了对self.find_scale()的调用。这确保了程序运行后,我们会被演奏的C 大调音阶combobox中的默认选择)所迎接。

这就完成了迭代。现在如果你去运行 7.04 view.py 并选择合适的比例和键名,键盘将会高亮显示这些键,并且为你播放出来。

构建和弦查找部分

现在我们已经对处理 JSON 文件有了一定的了解,这应该很容易。让我们来看看 json 目录下 chords.json 文件的头几行:

{
 "Major" : [0, 4, 7],
 "Minor" : [0, 3, 7],
 "Sus4" : [0, 5, 7],
 "5" : [0, 4, 6],
 "Diminished" : [0, 3, 6],
 ...
}

这与音阶结构非常相似。假设我们想要了解 C#大和弦的形态。所以我们从 C#键开始,它是0。然后我们查看大和弦的列表,它读作:[0, 4, 7]。所以从 C#开始,下一个需要突出的键是比它高 4 个半音的4,下一个是比 C#高 7 个半音的键。因此,C#大和弦的最终和弦结构将是:

C#,    (C# + 4 semitones) ,      (C# + 7 semitones) 

界面(GUI)也非常类似于刻度(scales)部分:

图片

我们首先在7.05 constants.py文件中为chords.json文件路径添加一个常数:

CHORDS_JSON_FILE = '../json/chords.json'

接下来,我们在新的类属性中读取这个文件的内容,self.chords

 with open(CHORDS_JSON_FILE, 'r') as f:
   self.chords = json.load(f, object_pairs_hook=OrderedDict)

我们随后修改和弦框架 GUI 以添加两个combobox(参见7.05 view.py中的完整 GUI build_chords_frame):

self.chords_selector = ttk.Combobox(self.chords_frame,  values=[k for k 
  in self.chords.keys()])
self.chords_selector.current(0)
self.chords_selector.bind("<<ComboboxSelected>>", self.on_chord_changed)
self.chords_selector.grid(row=1, column=1, sticky='e', padx=10, 
  pady=10)
self.chords_key_selector = ttk.Combobox(self.chords_frame, values=[k  
  for k in KEYS])
self.chords_key_selector.current(0)
self.chords_key_selector.bind("<<ComboboxSelected>>", self.on_chords_key_changed)

接下来,我们添加了之前定义的两个事件回调:

def on_chord_changed(self, event):
  self.remove_all_key_highlights()
  self.find_chord(event)

def on_chords_key_changed(self, event):
  self.remove_all_key_highlights()
  self.find_chord(event)

find_chord 方法查询 self.chords 字典以获取要高亮显示的键,将根音符的键偏移量添加到其中,并调用它进行高亮显示和播放:

def find_chord(self, event=None):
  self.selected_chord = self.chords_selector.get()
  self.chords_selected_key = self.chords_key_selector.get()
  index_of_selected_key = KEYS.index(self.chords_selected_key)
  self.keys_to_highlight = [ ALL_KEYS[i+index_of_selected_key] for \
                         i in self.chords[self.selected_chord]]
  self.highlight_list_of_keys(self.keys_to_highlight)
  play_chord_in_new_thread(self.keys_to_highlight)

本迭代中的最终代码修改了on_mode_changed方法,以便在选择和弦模式时立即突出显示和弦:

 def on_mode_changed(self, event):
   self.remove_all_key_highlights()
   selected_mode = self.mode_selector.get()
   if selected_mode == 'Scales':
     self.show_scales_frame()
     self.find_scale()
   elif selected_mode == 'Chords':
     self.show_chords_frame()
     self.find_chord()
   elif selected_mode == 'Chord Progressions':
     self.show_progressions_frame()

这就完成了迭代。如果你现在运行 7.05 view.py,你会找到一个功能性的和弦部分,它让我们能够在不同的音阶中找到不同种类的和弦。

构建和弦进行教程

和弦进行部分的 GUI 组件比前两个部分稍微进化一些。下面是一个典型的和弦进行 GUI 的样貌:

图片

注意,本节使用的是组合框,而前几节使用的是两个。根据中间组合框中选择的进度,我们需要绘制一定数量的按钮,每个按钮代表完整和弦进行中的一个和弦。

在前面的截图里,请注意进度组合框的值为 I-V-vi-IV。这代表四个用连字符分隔的罗马数字。这意味着这个和弦进行由四个和弦组成。同时,注意其中一些罗马数字(I、V、IV 等)是大写字母,而另一些(vi)是小写字母。系列中的所有大写字母表示大和弦,而每个小写字母代表小和弦。

接下来,让我们看一下来自json文件夹的progressions.json文件:

{
 "Major": {
 "I-IV-V": [ "0", "5", "7" ],
 "ii-V-I": [ "2", "7", "0" ],
 "I-V-vi-IV": [ "0", "7", "9", "5" ],
... more here},
 "Minor": {
 "i-VI-VII": [ "0", "9", "11"],
 "i-iv-VII": [ "0", "5", "11"],
 "i-iv-v": [ "0", "5", "7" ],
..more here
}
 }

首先,和弦进行大致分为两种类型——大调和小调。每种类型都有一系列和弦进行,这些和弦进行通过一组罗马数字来标识。

让我们看看一个例子,看看这是如何工作的。

假设我们想要在C调中显示主要和弦进行ii-V-I,如下截图所示:

图片

JSON 文件在“主要”部分列出的进度如下:

 "ii-V-I": [ "2", "7", "0" ]

让我们先在表中列出和弦进行中的 12 个音符,从和弦的根音开始(以我们的例子中的 C 音为例)。我们需要为这个进行选择 2(nd),7(th),和 0^(th)的音符:

C C# D D# E F F# G G# A A# B

关键音符是 D(第 2 个), G(第 7 个), 和 C(第 0 个)。手握这些音符后——我们接下来需要确定每个音符是构成大和弦还是小和弦。这很简单。那些用小写罗马数字标注的音符构成小和弦,而那些用大写罗马数字标注的音符构成大和弦。

根据这个规则,我们在 C 调的和弦进行中的最终和弦是:

D 小调 - G 大调 - C 大调

确定这些之后,我们的程序应该动态创建三个按钮。点击这些按钮后,应该分别播放前面的三个和弦。

让我们编写这个功能。我们首先在7.06 constants.py中定义和弦进行文件的位置:

PROGRESSIONS_JSON_FILE = '../json/progressions.json'

我们随后从方法 load_json_files() 中将其加载到一个名为 self.progressions 的属性中:

 with open(PROGRESSIONS_JSON_FILE, 'r') as f:
    self.progressions = json.load(f, object_pairs_hook=OrderedDict)

接下来,我们修改进度框架以添加三个combobox元素。请参阅build_progressions_frame的代码7.06 view.py

这三个组合框连接到以下三个回调函数:

def on_progression_scale_changed(self, event):
 selected_progression_scale = self.progression_scale_selector.get()
 progressions = [k for k in  
   self.progressions[selected_progression_scale].keys()]
 self.progression_selector['values'] = progressions
 self.progression_selector.current(0)
 self.show_progression_buttons()

def on_progression_key_changed(self,event):
 self.show_progression_buttons()

def on_progression_changed(self,event):
 self.show_progression_buttons()

三种组合框中最复杂的是进度尺度组合框。它允许你选择主调副调进度尺度。根据你的选择,它将从 JSON 文件中填充第二个组合框的进度值。这正是on_progression_scale_changed方法的前四行所做的事情。

除了那个之外,前面定义的所有三个回调方法都会调用show_progression_buttons方法,该方法定义如下:

def show_progression_buttons(self):
 self.destroy_current_progression_buttons()
 selected_progression_scale = self.progression_scale_selector.get()
 selected_progression = self.progression_selector.get().split('-')
 self.progression_buttons = []
 for i in range(len(selected_progression)):
   self.progression_buttons.append(Button(self.progressions_frame,                     
         text=selected_progression[i],
         command=partial(self.on_progression_button_clicked, i)))
   sticky = 'W' if i == 0 else 'E' 
   col = i if i > 1 else 1
   self.progression_buttons[i].grid(column=col, row=2, sticky=sticky, 
     padx=10)

上述代码动态创建按钮——每个和弦进行一个按钮,并将所有按钮存储在一个名为 self.progression_buttons 的列表中。我们将保留这个引用,因为每次选择新的和弦进行时,我们都需要销毁这些按钮并创建新的按钮。

注意使用来自functools模块的partial方法来定义按钮命令回调。由于按钮是动态创建的,我们需要跟踪按钮编号。我们使用这个方便的partials方法,它允许我们只使用部分参数调用一个方法。引用 Python 的文档——partial()函数用于部分函数应用,它将函数的部分参数和/或关键字冻结,从而生成一个具有简化签名的新的对象。您可以在docs.python.org/3/library/functools.html#functools.partial了解更多关于部分函数应用的信息。

前面的代码调用了destroy_button方法,其任务是清除框架以便绘制下一组按钮,以防选择新的进度。代码如下:

def destroy_current_progression_buttons(self):
 for buttons in self.progression_buttons:
    buttons.destroy()

最后,我们希望在点击按钮时显示和弦进行中的单个和弦。这被定义为如下:

def on_progression_button_clicked(self, i):
  self.remove_all_key_highlights()
  selected_progression = self.progression_selector.get().split('-')[i]
  if any(x.isupper() for x in selected_progression):
     selected_chord = 'Major'
  else: 
    selected_chord = 'Minor'
  key_offset = ROMAN_TO_NUMBER[selected_progression]
  selected_key = self.progression_key_selector.get() 
  index_of_selected_key = (KEYS.index(selected_key)+ key_offset)% 12
  self.keys_to_highlight = [ ALL_KEYS[j+index_of_selected_key] for j in   
                             self.chords[selected_chord]]
  self.highlight_list_of_keys(self.keys_to_highlight)
  play_chord_in_new_thread(self.keys_to_highlight)

这里是对前面代码的简要描述:

  • 我们首先使用连字符(-)分隔符将文本拆分,例如ii-V-I。然后我们遍历列表并检查它是否为大写或小写。如果它是大写,则selected_chord变量被设置为Major,如果不是,则设置为Minor.

  • 键的索引是通过将键值与 JSON 文件中提到的数字相加来计算的。我们应用模运算符(%)到相加的值上,以确保该值不超过 12 个音符的限制。

  • 由于数字是以罗马数字存储的(这是音乐家使用的惯例),我们需要将其转换为整数。我们通过在7.05/constants.py中定义一个简单的键值映射来实现这一点:

ROMAN_TO_NUMBER = { 'I':0, 'II': 2, 'III':4, 'IV':5, 'V': 7, 'VI':9, 'VII': 11, 'i':0, 'ii': 2, 'iii':4, 'iv':5, 'v': 7, 'vi':9, 'vii': 11}
  • 注意,我们已经将所有从 0 开始的数字进行了映射,映射遵循大调式模式(W W H W W S),其中W代表全音(两个键跳跃)而S代表半音(一个键跳跃)。

  • 现在我们知道,如果和弦是大调或小调,其余的代码与我们之前用来识别单个和弦的代码完全相同。然后我们突出显示并演奏单个和弦。

最后,我们将on_mode_changed进行修改,以添加对show_progression_buttons()的调用,这样每次我们切换到和弦进行部分时,第一个和弦进行按钮就会自动为我们设置。

这完成了迭代。我们的和弦进行部分已经准备好了。运行代码7.06/view.py。在和弦进行辅导程序中,你可以从下拉菜单中选择和弦进行类型(大调或小调)、进行方式和其键,程序将为和弦进行中的每个和弦创建一个按钮。按下单个按钮,它将为你播放该进行方式中的和弦。

构建得分生成器

让我们现在构建得分生成器。这个生成器将显示钢琴上演奏的任何音乐符号。为了程序的模块化,我们将把程序构建在一个名为 score_maker.py 的单独文件中。

我们首先定义一个类 ScoreMaker。由于我们只展示两个八度的音符,我们将定义一个常量 NOTES,列出所有音符(7.06/score_maker.py):

class ScoreMaker:

NOTES = ['C1','D1', 'E1', 'F1', 'G1','A1', 'B1', 'C2','D2', 'E2', 'F2', 'G2','A2', 'B2']

这个类的__init__方法接收容器作为参数。这是这个类将要绘制分数的容器(7.06/score_maker.py):

def __init__(self, container):
   self.canvas = Canvas(container, width=500, height = 110)
   self.canvas.grid(row=0, column = 1)
   container.update_idletasks() 
   self.canvas_width = self.canvas.winfo_width()
   self.treble_clef_image = PhotoImage(file='../pictures/treble-clef.gif')
   self.sharp_image = PhotoImage(file='../pictures/sharp.gif')

注意在container框架中使用update_idletasks()。在这里调用此方法是必要的,因为我们上一行代码中创建了一个画布,这需要重绘小部件。然而,重绘将在事件循环的下一轮运行后才会发生。但我们希望在画布创建后立即知道其宽度。显式调用update_idletasks会立即执行所有挂起的任务,包括几何管理。这确保我们在下一步中能够得到正确的画布宽度。如果你注释掉update_idletasks这一行并尝试打印画布的宽度,即使我们明确将其设置为500,它也会打印出1

我们还初始化了两个 .gif 图片,我们将使用它们来绘制分数。treble_clef 图片将被用来在分数左侧绘制高音谱号,而 sharp_image 将在所有升音(黑键上的音符)之前绘制一个升号(#)符号。

Tkinter 使用事件循环的概念来处理所有事件。这里有一篇优秀的文章深入解释了这个概念 wiki.tcl.tk/1527update_idletask 是所有小部件上可用的方法的一个例子。访问 effbot.org/tkinterbook/widget.htm 查看所有小部件上可调用的方法列表。

我们的首要任务是画五条等间距的线在画布上。因此,我们定义了一种新的方法来完成这个任务:

 def _draw_five_lines(self):
   w = self.canvas_width
   self.canvas.create_line(0,40,w,40, fill="#555")
   self.canvas.create_line(0,50,w,50, fill="#555")
   self.canvas.create_line(0,60,w,60, fill="#555")
   self.canvas.create_line(0,70,w,70, fill="#555")
   self.canvas.create_line(0,80,w,80, fill="#555")

这创建了五个相互平行的线条,每条线之间相隔 10 像素。方法名称中的下划线表示这是一个类中的私有方法。虽然 Python 不强制执行方法隐私,但这告诉用户不要直接在他们的程序中使用此方法。

然后我们构建一个方法,该方法实际上调用前一个方法并在左侧添加一个高音谱号,从而创建一个空谱表,我们可以在其上绘制音符:

 def _create_treble_staff(self):
  self._draw_five_lines()
  self.canvas.create_image(10, 20, image=self.treble_clef_image, anchor=NW)

首先,我们需要区分绘制和弦与绘制音阶的音符。由于和弦中的所有音符都是一起演奏的,因此和弦的音符在单个 x 位置上绘制。相比之下,音阶中的音符在规则的 x 偏移量上绘制,如下所示:

图片

由于我们需要在固定间隔内调整 x 值以适应刻度,我们使用 itertools 模块中的 count 方法来提供一个不断增长的 x 值:

import itertools 
self.x_counter = itertools.count(start=50, step=30)

现在每次调用x = next(self.x_counter)都会将x增加30

现在是绘制实际笔记到画布上的代码:

 def _draw_single_note(self, note, is_in_chord=False):
   is_sharp = "#" in note 
   note = note.replace("#","")
   radius = 9
   if is_in_chord:
     x = 75
   else: 
     x = next(self.x_counter)
   i = self.NOTES.index(note)
   y = 85-(5*i)
   self.canvas.create_oval(x,y,x+radius, y+ radius, fill="#555")
   if is_sharp:
     self.canvas.create_image(x-10,y, image=self.sharp_image, anchor=NW)
   if note=="C1":
     self.canvas.create_line(x-5,90,x+15, 90, fill="#555")
   elif note=="G2":
     self.canvas.create_line(x-5,35,x+15, 35, fill="#555")
   elif note=="A2":
     self.canvas.create_line(x-5,35,x+15, 35, fill="#555")
   elif note=="B2":
     self.canvas.create_line(x-5,35,x+15, 35, fill="#555")
     self.canvas.create_line(x-5,25,x+15, 25, fill="#555") 

前述代码的描述如下:

  • 该方法接受一个音符名称,例如,C1D2#,并在适当的位置绘制一个椭圆形。

  • 我们需要获取绘制椭圆的xy值。

  • 我们首先计算 x 值。如果音符是和弦的一部分,我们将 x 值固定在 75 像素,而如果音符是音阶的一部分,则通过在 itertool counter 方法上调用 next 来将 x 值从上一个 x 值增加 30 像素。

接下来,我们计算 y 值。 执行此操作的代码如下:

i = self.NOTES.index(note)
y = 85-(5*i)

基本上,y 偏移量是根据列表中音符的索引计算的,每个后续音符偏移 5 像素。数字 85 是通过试错法得到的。

现在我们有了 xy 值,我们只需绘制给定 半径 的椭圆:

self.canvas.create_oval(x,y,x+radius, y+ radius, fill="#555")

如果音符是升音符,即如果它包含字符#,则会在音符的椭圆左侧 10 像素处绘制#图像。

音符 C1、G2、A2 和 B2 绘制在五条线之外。因此,除了椭圆形之外,我们还需要画一条小横线穿过它们。这正是最后 11 行if…else语句所实现的功能。

最后,我们有draw_notes方法和draw_chord方法,分别用于绘制音符和和弦。这两个方法的名字前没有下划线,这意味着我们只通过这两个方法暴露了我们的程序接口:

def draw_notes(self, notes):
  self._clean_score_sheet()
  self._create_treble_staff()
  for note in notes:
    self._draw_single_note(note)

def draw_chord(self, chord):
  self._clean_score_sheet()
  self._create_treble_staff()
  for note in chord:
    self._draw_single_note(note, is_in_chord=True)

现在我们已经准备好了ScoreMaker,我们只需将其导入到7.07/view.py中:

from score_maker import ScoreMaker

我们修改build_score_sheet_frame以实例化ScoreMaker

self.score_maker = ScoreMaker(self.score_sheet_frame) 

我们随后修改find_scale以添加此行(7.07/view.py):

self.score_maker.draw_notes(self.keys_to_highlight)

我们同样修改了 find_chordon_progression_button_clicked 以添加此行 (7.07/view.py):

self.score_maker.draw_chord(self.keys_to_highlight)

这就标志着这个项目的结束。如果你现在运行 7.07/view.py,你应该会看到一个功能齐全的得分生成器和一个功能齐全的钢琴辅导工具。

然而,让我们以对窗口响应性的简要讨论来结束这一章。

关于窗口响应性的说明

在这个程序中,我们使用了.grid_propagate(False)来确保我们的框架不会缩小以适应其内容,而是保持在我们制作框架时指定的固定高度和宽度。

这个例子中这做得很好,但这使得我们的窗口及其内容大小固定。这通常被称为非响应式窗口。

让我们以程序 nonresponsive.py 作为非响应式窗口的例子。这个程序简单地在一行中绘制了 10 个按钮:

from tkinter import Tk, Button
root = Tk()

for x in range(10):
 btn = Button(root, text=x )
 btn.grid(column=x, row=1, sticky='nsew')

root.mainloop()

运行此程序并调整窗口大小。这些按钮绘制在根窗口上,并且无响应。按钮的大小保持固定。它们不会根据窗口大小的变化而调整大小。如果窗口大小减小,一些按钮甚至会从视图中消失。

相比之下,让我们来看看程序 responsive.py

from tkinter import Tk, Button, Grid

root = Tk()

for x in range(10):
 Grid.rowconfigure(root, x, weight=1)
 Grid.columnconfigure(root, x, weight=1)
 btn = Button(root, text=x )
 btn.grid(column=x, row=1, sticky='nsew')

root.mainloop()

如果你运行这个程序并调整窗口大小,你会看到按钮会相应地调整自身大小以适应容器根窗口。那么这两段之前的代码有什么区别呢?我们只是简单地在第二个程序中添加了这两行:

Grid.rowconfigure(root, x, weight=1)
Grid.columnconfigure(root, x, weight=1) 

这两行代码向容器中的x^(th)按钮小部件添加了非零权重weight=1)(在前面的示例中是根)。

这里的关键是理解权重的重要性。如果我们有两个小部件,widget1widget2,并且分别给它们分配权重 3 和 1。现在当你调整其父元素的大小时,widget1 将占据 3/4 的空间,而 widget2 将占据 1/4 的空间。

这是rowconfigurecolumnconfigure的文档:

infohost.nmt.edu/tcc/help/pubs/tkinter/web/grid-config.html.

在代码上进行实验

体验这段代码的最佳方式是逐一进行以下调整,运行程序,并调整窗口大小以查看每个选项的效果。

作为第一次调整,将权重改为0

 Grid.rowconfigure(root, x, weight=0)
 Grid.columnconfigure(root, x, weight=0)

这将再次使窗口无响应。

接下来,将权重重新分配回 1,然后注释掉其中一行,观察差异。如果你注释掉rowconfigure行,按钮将在y方向上响应,但在x方向上则不响应,反之亦然对于columnconfigure

将程序恢复到其原始状态,然后在每个循环中通过将权重更改为x来分配不同的权重:

 Grid.rowconfigure(root, x, weight=x)
 Grid.columnconfigure(root, x, weight=x)

因此,第一个按钮的权重将为 0,第二个按钮的权重将为 1,以此类推。现在,如果你运行程序并调整窗口大小,权重为 9 的最后一个按钮将是最灵敏的(将占据可用空间的最大比例),而权重为 0 的第一个按钮将完全不灵敏(固定大小),如下面的截图所示:

图片

作为最后的调整,将程序恢复到其原始状态,并将第二个参数的值更改为一个固定数字,比如说 2

 Grid.rowconfigure(root, 2, weight=1)
 Grid.columnconfigure(root, 2, weight=1)

这将只分配权重到第三个按钮(计数从 0 开始),因此第三个按钮变得响应,而其他按钮保持非响应状态,如下面的截图所示:

图片

实际上,在这个最后的情况下,因为我们只对一个单个的小部件分配权重,我们完全可以把它分配在循环之外。

使用 <Configure> 处理小部件尺寸调整

可能会有这样的情况,当用户调整窗口或小部件大小时,你想要执行一些特定的操作。Tkinter 提供了一个名为 <Configure> 的事件,它可以绑定到一个回调函数上,以响应小部件大小的变化。

这里有一个简单的例子(见 handle_widget_resize.py):

from tkinter import Tk, Label, Pack

root= Tk()
label = Label(root, text = 'I am a Frame', bg='red')
label.pack(fill='both', expand=True)

def on_label_resized(event):
  print('New Width', label.winfo_width())
  print('New Height', label.winfo_height())

label.bind("<Configure>", on_label_resized)
root.mainloop()

代码的描述如下:

  • 我们在root窗口中有一个标签小部件。我们为标签设置了pack选项为(fill='both', expand=True),因为我们希望它在root窗口大小调整时能够相应地调整大小。

  • 我们将一个回调函数附加到 <Configure> 事件上,以监听标签小部件大小的任何变化。一旦标签小部件发生变化,它就会触发对方法 on_label_resized 的调用。

现在如果您调整窗口大小,标签也会随之调整,这会触发on_label_resized事件,将标签小部件的新高度和宽度打印到控制台。这可以用来调整屏幕上项目的位置。

这就结束了我们对窗口响应性的简要讨论。

摘要

我们使用了几个有用的标准模块,例如 functoolsitertoolsjson

我们学习了如何处理 JSON 文件。JSON 帮助我们呈现关于我们领域的复杂规则,并且相较于在数据库中存储相同信息来说,它是一个更简单、更便携的替代方案。

我们探讨了widget.grid_propagate(False)的实际用法及其在非响应性方面的局限性。

我们看到了collections模块中OrderedDict的用法和functools模块中partials的用法。

我们研究了各种根窗口方法,例如root.geometryroot.winfo_screenwidthroot.resizable

我们查看了widget.update_idletasks,它允许我们在不需要等待主循环下一次运行的情况下清除所有挂起的更新。

最后,我们探讨了在 Tkinter 中使窗口响应式所需的步骤。

QA 部分

在你继续阅读下一章之前,请确保你能回答这些问题

满意度:

  • functools模块中的partial函数有什么用途?

  • 在 Tkinter 程序中,何时以及为什么需要使用widget.update_idletasks

  • 如果需要,我们该如何处理 Tkinter 中主窗口或任何其他小部件的调整大小问题?

  • JSON 中有哪些可用的数据结构?(了解更多信息请参阅:www.json.org/)

  • 你如何在 Tkinter 中使小部件响应式?

进一步阅读

了解更多关于 JSON 数据结构的信息。它们非常流行,并被广泛应用于各个领域。另一种结构是 XML。阅读有关 XML 和 JSON 的内容,以及何时以及为什么应该选择其中一种而不是另一种。

Python 的 collections 模块提供了一些非常灵活和有用的数据结构,例如 namedtupledequeCounterdictOrderedDictdefaultdictchainMapUserDictUserListuserString。这些数据结构可以在各种用例中适当使用。更多信息可以在这里找到。

我们在程序中使用了外部音频文件和外部图像。这意味着如果需要打包和分发,它们需要与程序捆绑在一起。可以使用所谓的base-64 编码来对音频文件和图像进行另一种打包。音频文件和图像可以被 base-64 编码到文本文件中,然后由程序读取并解码,用作音频文件或图像文件。阅读有关 base-64 编码的信息,如果你有足够的动力,尝试将此程序中使用的所有音频文件和图像转换为 base-64 编码。有关 base-64 编码的更多信息,请参阅此处:en.wikipedia.org/wiki/Base64

Python 的 base-64 编码实现可以在这里找到:docs.python.org/3/library/base64.html.

第八章:在画布上尽情玩耍

Canvas 无疑是 Tkinter 中最灵活的控件之一。鉴于它提供了对每个单独像素绘制的直接控制,结合一些数学知识,它可以用来创建各种巧妙的可视化效果。虽然可能性是无限的,但我们将在本章中探讨如何实现一些重要的数学思想。

本章的关键目标是:

  • 学习使用 Tkinter 画布进行动画制作

  • 理解在画布上使用极坐标和笛卡尔坐标的用法

  • 实现常微分方程

  • 给定公式列表进行建模模拟

  • 建模 3D 图形和研究在 3D 动画中常用的某些常见变换矩阵

注意,本章中的许多代码示例需要进行大量计算。然而,为了速度而进行的代码优化并不是我们的首要选择。这里的主要目标是理解底层概念。

创建屏幕保护程序

我们将从一个屏幕保护程序开始。该程序将包含几个随机颜色和随机大小的球,以随机速度在屏幕上四处弹跳,如下面的截图所示:

图片

让我们创建一个类来生成具有随机属性的球。相应地,我们定义一个新的类名为RandomBall。请参考代码文件8.01_screensaver

class RandomBall:

def __init__(self, canvas):
 self.canvas = canvas
 self.screen_width = canvas.winfo_screenwidth()
 self.screen_height = canvas.winfo_screenheight()
 self.create_ball()

def create_ball(self):
 self.generate_random_attributes()
 self.create_oval() 

def generate_random_attributes(self):
 self.radius = r = randint(40, 70)
 self.x_coordinate = randint(r, self.screen_width - r)
 self.y_coordinate = randint(r, self.screen_height - r)
 self.x_velocity = randint(6, 12)
 self.y_velocity = randint(6, 12)
 self.color = self.generate_random_color()

def generate_random_color(self):
 r = lambda: randint(0, 0xffff)
 return '#{:04x}{:04x}{:04x}'.format(r(), r(), r())

def create_oval(self):
 x1 = self.x_coordinate - self.radius
 y1 = self.y_coordinate - self.radius
 x2 = self.x_coordinate + self.radius
 y2 = self.y_coordinate + self.radius
 self.ball = self.canvas.create_oval( x1, y1, x2, y2, fill=self.color, 
   outline=self.color)

def move_ball(self):
 self.check_screen_bounds()
 self.x_coordinate += self.x_velocity
 self.y_coordinate += self.y_velocity
 self.canvas.move(self.ball, self.x_velocity, self.y_velocity)

def check_screen_bounds(self):
 r = self.radius
 if not r < self.y_coordinate < self.screen_height - r:
   self.y_velocity = -self.y_velocity
 if not r < self.x_coordinate < self.screen_width - r:
   self.x_velocity = -self.x_velocity

这是前面代码的描述:

  • 这里有两个关键方法:create_ballmove_ball。所有其他方法都是这两个方法的辅助。__init__ 方法接受一个 canvas 作为参数,然后调用 create_ball 方法在给定的画布上绘制球。要移动球,我们需要显式地调用 move_ball 方法。

  • create_ball 方法使用了 canvas.create_oval() 方法,而 move_ball 方法使用了 canvas.move(item, dx, dy) 方法,其中 dxdy 是画布项的 xy 偏移量。

  • 此外,请注意我们是如何为球体创建一个随机颜色的。因为十六进制颜色编码系统为红色、绿色和蓝色中的每一个都使用最多四个十六进制数字,所以每种颜色都有多达 0xffff 种可能性。因此,我们创建了一个生成从 0-0xffff 的随机数的 lambda 函数,并使用这个函数生成三个随机数。我们使用格式说明符 #{:04x}{:04x}{:04x} 将这个十进制数字转换为它的两位等效十六进制表示,以获取球体的随机颜色代码。

这就是RandomBall类的全部内容。我们可以使用这个类来创建我们想要在屏幕保护程序中显示的任意数量的球对象。

接下来,让我们创建一个将显示实际屏保的ScreenSaver类:

class ScreenSaver:

balls = []

def __init__(self, number_of_balls):
 self.root = Tk()
 self.number_of_balls = number_of_balls
 self.root.attributes('-fullscreen', True)
 self.root.attributes('-alpha', 0.1)
 self.root.wm_attributes('-alpha',0.1)
 self.quit_on_interaction()
 self.create_screensaver()
 self.root.mainloop()

def create_screensaver(self):
 self.create_canvas()
 self.add_balls_to_canvas()
 self.animate_balls()

def create_canvas(self):
 self.canvas = Canvas(self.root)
 self.canvas.pack(expand=1, fill=BOTH)

def add_balls_to_canvas(self):
 for i in range(self.number_of_balls):
    self.balls.append(RandomBall(self.canvas))

def quit_on_interaction(self):
  for seq in ('<Any-KeyPress>', '<Any-Button>', '<Motion>'):
    self.root.bind(seq, self.quit_screensaver)

def animate_balls(self):
 for ball in self.balls:
    ball.move_ball()
 self.root.after(30, self.animate_balls)

def quit_screensaver(self, event):
   self.root.destroy()

代码的描述如下:

  • ScreenSaver 类的 __init__ 方法接受球的数量 (number_of_balls) 作为其参数

  • 我们使用 root.attributes ( -fullscreen, True ) 来从父窗口移除包围框架,使其成为一个全屏窗口。

  • quit_on_interaction 方法将根绑定到在用户端发生任何交互时调用我们的 quit_screensaver 方法。

  • 我们随后使用 Canvas(self.root) 创建一个画布来覆盖整个屏幕,并使用 pack ( expand=1, fill=BOTH ) 选项来填充整个屏幕。

  • 我们使用RandomBall类创建了几个随机的球体对象,并将画布小部件实例作为其参数传递。

  • 我们最终调用了animate_balls方法,该方法使用标准的widget.after()方法以每 30 毫秒的固定间隔循环运行动画。

  • 要运行屏幕保护程序,我们需要从我们的ScreenSaver类实例化一个对象,并将球的数量作为其参数传递,如下所示:ScreenSaver(number_of_balls=18)

我们的保护屏现在已经准备好了!实际上,如果你正在使用 Windows 平台,并且当你学习如何从 Python 程序创建可执行程序(在第十章 Chapter 10,杂项提示)中讨论过),你可以为这个保护屏创建一个带有.exe扩展名的可执行文件。然后你可以将其扩展名从.exe更改为.scr,右键点击,并选择安装来将其添加到你的屏幕保护程序列表中。

使用 Tkinter 绘图

Tkinter 不是一个绘图工具。然而,如果您需要使用 Tkinter 绘制图表,您可以使用 Canvas 小部件来绘制图表。

在这次迭代中,我们将绘制以下图表:

  • 饼图 (8.02_pie_chart.py)

  • 柱状图 (8.03_bar_graph.py)

  • 散点图 (8.04_scatter_plot.py)

三张图如下所示:

图片

首先让我们看看饼图。您可以使用 Tkinter 中的 Canvas 小部件的create_arc方法轻松创建饼图。

create_arc 方法具有以下签名:

item_id = canvas.create_arc(x1, y1, x2, y2, option, ...)

点(x1, y1)是矩形左上角的顶点,而点(x2, y2)是矩形右下角的顶点,弧线就拟合在这个矩形内。如果边界矩形是正方形,它就形成了一个圆。该方法还接受两个参数,分别命名为startextent,我们将使用这两个参数来创建饼图。

start选项指定了弧线的起始角度,以度为单位,从+x方向测量。当省略时,得到完整的椭圆。extent选项指定了弧线的宽度,以度为单位。

弧线从由start选项给出的角度开始,逆时针绘制,直到达到extent选项指定的度数。

要创建饼图,我们定义一个方法,该方法在给定一个数字 n 的情况下,将圆分成,比如说,1,000 个相等的部分,然后给定一个小于 1,000 的数字,返回弧线上的等效角度。由于圆有 360 度,该方法定义如下:

total_value_to_represent_by_pie_chart = 1000
def angle(n):
   return 360.0 * n / total_value_to_represent_by_pie_chart

接下来,我们使用如下代码来绘制饼图的各个部分:

canvas.create_arc((2,2,152,152), fill="#FAF402", outline="#FAF402", start=angle(0), extent = angle(200))

您可以在 8.02_pie_chart.py 中查看饼图的示例。

接下来是条形图。这非常简单。我们使用create_rectangle来绘制条形图:

plot_data= [random.randint(75,200) for r in range(12)]
for x, y in enumerate(plot_data):
  x1 = x + x * bar_width
  y1 = canvas_height - y 
  x2 = x + x * bar_width + bar_width
  y2 = canvas_height
  canv.create_rectangle(x1, y1, x2, y2, fill="blue")
  canv.create_text(x1+3, y1, font=("", 6),
    text=str(y),anchor='sw' )

这里有一个重要的事项需要注意。由于 Canvas 小部件表示从左上角开始的 y 坐标,我们需要从画布高度中减去 y 位置,以获得图表的 y 坐标。

您可以查看条形图的完整代码,位于8.03_bar_graph.py文件中。

同样,我们使用 create_oval 方法来绘制散点图。请查看 8.04_scatter_plot.py. 中的散点图代码。

接下来,让我们看看如何将 matplotlib 图形嵌入到 Tkinter 中。

使用 Tkinter 画布绘制图表在简单情况下可能效果不错。然而,当涉及到绘制更复杂和交互式的图表时,Tkinter 并不是最佳选择。

几个 Python 模块已被开发用于制作图表。然而,matplotlib在用 Python 生成专业质量的交互式图表方面脱颖而出,成为当之无愧的佼佼者。

尽管对matplotlib的详细讨论超出了本书的范围,但我们仍将简要地看看如何在 Tkinter 画布上嵌入由 matplotlib 生成的图形。

您可以使用以下命令安装matplotlib和 NumPy(matplotlib的依赖项):

pip3 install matplotlib pip3 install numpy

matplotlib 针对许多类型的用例和输出格式。matplotlib 的不同用例包括:

  • 从 Python 命令行创建交互式图表

  • matplotlib 集成到 Tkinter、wxPython 或 PyGTK 等 GUI 模块中

  • 从模拟中生成 PostScript 图像

  • 在后端 Web 服务器上提供网页服务

为了针对所有这些用例,matplotlib 使用了后端的概念。为了在 Tkinter 上显示 matplotlib 图形,我们使用了一个名为 TkAgg 的后端。

我们如下将后端导入matplotlib

import tkinter as tk
from numpy import arange, sin, pi
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg,NavigationToolbar2TkAgg
from matplotlib.figure import Figure

我们随后创建matplotlib图表,就像在matplotlib API 中通常所做的那样:

f = Figure(figsize=(5,4), dpi=100)
a = f.add_subplot(111)
t = arange(-1.0, 1.0, 0.001)
s = t*sin(1/t)
a.plot(t, s)

最后,我们使用TkAgg后端将生成的图嵌入到tkinter主循环中,如下所示:

canvas = FigureCanvasTkAgg(f, master=root)
canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=1)

我们也可以使用以下命令嵌入matplotlib的导航工具栏:

toolbar = NavigationToolbar2TkAgg(canvas, root )
toolbar.update()

上述代码(8.05_matplotlib_embedding_graphs.py)生成了一个如图所示的图表:

图片

使用 Tkinter 绘制的极坐标图

空间中的一个点可以用笛卡尔坐标系中的两个数字 xy 来表示。同样的点也可以通过使用从原点(r)的距离和从 x 轴的角度(theta)来表示极坐标,如下面的图所示:

图片

要在极坐标和笛卡尔坐标之间进行转换,我们使用以下等式:

x = r cos(θ) 和 y = r sin(θ)

在极坐标图上绘制以rθ表示的方程更容易,这种特殊的图称为极坐标图,它被分成许多同心圆和从中心辐射出的径线。径线通常以 15◦的间隔排列,而同心圆的半径取决于从中心测量的距离所使用的比例尺。以下是我们将要绘制的极坐标图的示例:

图片

Tkinter 画布理解笛卡尔坐标系。然而,从极坐标转换为笛卡尔坐标很容易。因此,我们相应地定义了一个名为polar_to_cartesian的方法;请参阅8.06_polar_plot.py

def polar_to_cartesian(r, theta, scaling_factor, x_center, y_center):
 x = r * math.cos(theta) * scaling_factor + x_center
 y = r * math.sin(theta) * scaling_factor + y_center
 return(x, y)

这里是对前面代码的简要描述:

  • 该方法将输入的 (r, theta) 值转换为 (x, y) 坐标,使用等式 x= r cos(θ) 和 y = rsin(θ)

  • 前一个方程中的scaling_factor决定了在我们极坐标图中多少像素等于一个单位,并且被设置为常数。改变它将改变图表的大小。

  • 我们将 x_centery_center 的值加到最终结果中。x_center 被定义为 window_width 的一半,而 y_center 是窗口大小的一半。我们添加这些值作为偏移量,因为 Canvas 将 (0,0) 视为画布的左上角,而我们的目标是把画布的中心视为 (0,0)。

我们首先在 Tkinter 根窗口中创建一个画布,并使用以下代码在画布上添加径向线和同心圆:

# draw radial lines at interval of 15 degrees
for theta in range(0,360,15): 
 r = 180
 x, y = x_center + math.cos(math.radians(theta))*r, \
        y_center - math.sin(math.radians(theta)) *r
 c.create_line(x_center, y_center, x, y, fill='green', dash=(2, 4),\
               activedash=(6, 5, 2, 4) )
 c.create_text(x, y, anchor=W, font="Purisa 8", text=str(theta) + '°')

# draw concentric_circles
for radius in range(1,4):
 x_max = x_center + radius * scaling_factor
 x_min = x_center - radius * scaling_factor
 y_max = y_center + radius * scaling_factor
 y_min = y_center - radius * scaling_factor
 c.create_oval(x_max, y_max, x_min, y_min, width=1, outline='grey', \
               dash=(2, 4), activedash=(6, 5, 2, 4))

现在我们已经准备好了坐标纸,是时候绘制实际的极坐标图了。以下代码在图上绘制了极坐标方程 r = 2*math.sin(2*theta)3000 个点:

for theta in range(0, 3000):
  r = 2*math.sin(2*theta)
  x, y = polar_to_cartesian(r, theta, scaling_factor, x_center, y_center)
  c.create_oval(x, y, x, y, width=1, outline='navy')

这就形成了 form. r = a sin nθ 的曲线,其中 n 是偶数。它是一个 2n 瓣的蔷薇花。如果 n 是奇数,它将形成一个 n 瓣的蔷薇花。通过改变前一种方法中的 r 方程,你可以绘制出许多其他好看的图形。以下是一些你可以尝试的其他方程:

r = 0.0006 * theta   # an archimedean spiral
r = 1 + 2*math.cos(theta) # cardoid pattern
r = 3 * math.cos(theta) # circle
r = 2*math.sin(5*theta) # 5 leaved rose
r = 3 * math.cos(3*theta) # 3 leaved rose
r = 2 * math.sin(theta)**2 # a  lemniscate
r = (4 * math.cos(2*theta))**1/2 # another lemniscate

你还可以调整单个方程的参数,看看它们对图表造成的影响。

这完成了迭代。

重力模拟

让我们现在模拟重力。我们将使用牛顿的万有引力定律来模拟四个行星(水星、金星、地球和火星)以及我们自己的月球的运动。

我们的模拟假设太阳位于中心,但不会为太阳画一个椭圆形,因为这会使我们的行星在那个尺度上变得不可见。我们的模拟程序显示了四颗行星和月球在圆形轨道上旋转(8.07_gravity_simulation.py):

图片

尽管该系统可以扩展以包括太阳系中的其他行星——但由于行星大小和距离的差异如此不成比例,将它们全部放在我们屏幕的矩形窗口中是不可能的。例如,要让木星这样的行星显示出来,会使地球等行星的大小和距离变得小于一个像素,从而使其变得不可见。因此,我们的可视化仅限于四个相对较近的行星和我们的月球。您可以在以下链接找到整个太阳系的一个非常富有洞察力的交互式可视化,标题为如果月亮只有 1 像素joshworth.com/dev/pixelspace/pixelspace_solarsystem.html.

牛顿万有引力定律确立了万有引力是普遍存在的,并且所有物体都通过一个与两物体质量和它们之间的距离有关的引力相互吸引的事实,使用以下公式表示:

图片

位置:

  • F = 两个物体之间的引力

  • m1 = 物体 1 的质量

  • m2 = 物体 2 的质量

  • d = 两个物体之间的距离

  • G = 6.673 x 10^(-11) N m²/kg²

一旦前述方程给出了引力,我们就可以使用这个公式来找到物体的角速度:

图片

上述公式适用于圆周运动,这在某种程度上是对椭圆轨道上行星实际运动的近似。掌握了角速度后,我们可以得到角位置(θ):

图片

拥有从太阳(中心)的距离和θ值,我们可以将其从极坐标转换为笛卡尔坐标,就像我们在之前的例子中所做的那样。接下来,只需在 Tkinter 画布上不同位置绘制球体即可。

拿到公式后,我们定义一个Planet类(8.07_gravity_simulation.py):

class Planet:
 sun_mass = 1.989 * math.pow(10, 30)
 G = 6.67 * math.pow(10, -11)

 def __init__(self, name, mass, distance, radius, color, canvas):
  self.name = name
  self.mass = mass
  self.distance = distance
  self.radius = radius
  self.canvas = canvas
  self.color = color
  self.angular_velocity = -math.sqrt(self.gravitational_force() /
                          (self.mass * self.distance))
  self.oval_id = self.draw_initial_planet()
  self.scaled_radius = self.radius_scaler(self.radius)
  self.scaled_distance = self.distance_scaler(self.distance)

尽管前面的代码大部分是变量的简单实例化,请注意它接受一个画布作为输入,并在其上绘制行星。

我们还需要将行星的距离和半径缩小以适应我们的窗口屏幕,因此我们在类中定义了两种方法来缩放距离和半径 (8.07_gravity_simulation.py):

def distance_scaler(self, value):
  #[57.91, 4497.1] scaled to [0, self.canvas.winfo_width()/2]
  return (self.canvas.winfo_width() / 2 - 1) * (value - 1e10) /  
    (2.27e11 - 1e10) + 1 

def radius_scaler(self, value):
  #[2439, 6051.8] scaled to [0, self.canvas.winfo_width()/2]
  return (16 * (value - 2439) / (6052 - 2439)) + 2

为了缩放距离,我们取最大距离并将其缩放到画布宽度的一半。对于半径的缩放,我们从前四个行星中取最大和最小半径,并将它们与任意数 16 相乘,这样行星的缩放在屏幕上看起来是可接受的。大部分前面的代码是通过实验确定屏幕上看起来最好的效果,数字完全是任意选择的。

构造函数随后调用一个方法,draw_initial_planet,该方法在画布上创建一个按比例缩放的椭圆,并位于按比例缩放的距离处。同时,它还返回创建的椭圆的唯一 ID,以便可以使用 ID 作为句柄来更新椭圆的位置。

我们随后定义了两个辅助方法,使用我们之前讨论过的公式:

def gravitational_force(self):
 force = self.G * (self.mass * self.sun_mass) / math.pow(self.distance, 2)
 return force

def angular_position(self, t):
 theta = self.angular_velocity * t
 return theta

现在我们计算角位置(theta),将其从极坐标转换为笛卡尔坐标,并更新与该行星相关的椭圆的 xy 位置。我们使用 create_rectangle 函数为行星位置留下一个 1 像素的轨迹:

def update_location(self, t):
 theta = self.angular_position(t)
 x, y = self.coordinates(theta)
 scaled_radius = self.scaled_radius
 self.canvas.create_rectangle(x, y, x, y, outline="grey")
 self.canvas.coords(self.oval_id, x - scaled_radius, y - scaled_radius,
                   x + scaled_radius, y + scaled_radius)

将极坐标转换为笛卡尔坐标的代码如下:

def coordinates(self, theta):
 screen_dim = self.canvas.winfo_width()
 y = self.scaled_distance * math.sin(theta) + screen_dim / 2
 x = self.scaled_distance * math.cos(theta) + screen_dim / 2
 return (x, y)

接下来,我们定义一个Moon类,它在所有方面都与Planet类相似,因此它继承自Planet类。然而,最重要的区别是它不是以距离太阳和太阳的质量作为参考,而是以距离地球和地球的质量作为参考。由于按实际值缩放会使月球的大小小于 1 像素,因此我们为月球硬编码了缩放距离和缩放半径值,以便在屏幕上显示。由于月球需要绕地球运行,我们还需要将地球作为额外参数传递给Moon类的__init__方法(8.07_gravity_simulation.py)。

最后,我们创建了四颗行星和月球,传入它们从维基百科获取的实际值:

#name,mass,distance,radius, color, canvas
mercury = Planet("Mercury", 3.302e23, 5.7e10, 2439.7, 'red2', canvas)
venus = Planet("Venus", 4.8685e24, 1.08e11, 6051.8, 'CadetBlue1', canvas)
earth = Planet("Earth", 5.973e24, 1.49e11, 6378, 'RoyalBlue1', canvas)
mars = Planet("Mars", 6.4185e23, 2.27e11, 3396, 'tomato2', canvas)
planets = [mercury, venus, earth, mars]
moon = Moon("Moon", 7.347e22, 3.844e5, 173, 'white', canvas, earth)

然后我们创建一个 Tkinter 画布并定义一个每 100 毫秒运行一次的update_bodies_positions方法,如下所示:

time = 0
time_step = 100000

def update_bodies_position():
 global time, time_step
 for planet in planets:
   planet.update_location(time)
 moon.update_location(time)
 time = time + time_step
 root.after(100, update_bodies_position)

这标志着重力模拟项目的结束。如果你现在去运行8.07_gravity_simulation.py,你可以看到行星和我们的月球对重力作用的响应。

绘制分形

分形是一种永无止境的图案,它在所有尺度上都会重复出现。分形在自然界中无处不在。我们在我们的血管、树木的枝条以及我们星系的结构中都能找到它们,它们的美丽之处在于它们是由简单的公式构成的。

我们将通过绘制一个名为曼德布罗特集的分数形来展示这些看似复杂现象的简单性。在本节中,我们假设读者具备集合理论和复数的基本知识。我们的代码生成的曼德布罗特集看起来如下所示:

图片

曼德布罗特集定义为复数集,c

图片

以便复数 c 遵循以下递归关系:

图片

将递归关系视为函数,其中最后一个输出作为输入传递到下一个迭代中相同的函数。

因此,曼德布罗特集只包括那些在经过任意次迭代后,前述方程不会将复数z[n]的值爆炸到无穷大的复数。

为了更清晰地理解,如果我们把数字 1 视为c并应用于前面的方程(注意,1 也是一个没有虚部的复数——因此实数是复数的一个子集,所以它们也位于复平面上):

n 次迭代后 z 的值(z[n]) z[n+1] = z²[n] + c,其中 c = 1
z[0] 0² + 1 = 1
z[1] 1² + 1 = 2
z[2] 2² +1 = 5
z[3] 5² +1 = 26

很明显,随着迭代次数趋向于无穷大,之前的序列将会爆炸到无穷大。由于这个复数 1 会使方程爆炸,它不是曼德布罗集的一部分。

将此与另一个数字 c = -1 进行对比,其值在下一表中给出:

n 次迭代后 z 的值(z[n]) z[n+1] = z²[n] + c 对于 c = -1 的值
z[0] 0² + -1 = -1
z[1] -1² + -1  = 0
z[2] 0² + -1 = -1
z[3] -1² + -1 = 0

注意,你可以将前面的序列一直延续到无穷大,但数值将在 -10 之间交替,因此永远不会爆炸。这使得复数 -1 有资格被包含在曼德布罗特集中。

现在,让我们尝试对前面的方程进行建模。

需要克服的一个直接问题是,我们无法在之前的方程中建模无穷大。幸运的是,从方程中可以看出,如果复数 z 的绝对值一旦超过 2,方程最终会爆炸。

因此,检查方程是否爆炸的确定方法就是检查复数 Z > 2 的模。复数 a + ib 的模定义为如下:

图片

因此,为了检查复数 a+ib 是否会使前面的方程爆炸,我们需要检查以下内容:

图片

或者:

图片

接下来需要考虑的问题是,我们应该迭代Zn多少次才能看到其幅度是否超过2

这个答案取决于你希望在最终图像中获得的图像分辨率类型。一般来说,最大迭代次数越高,图像分辨率就越高,但受限于单个像素的大小,超过这个限制你将无法在细节上更进一步。在实践中,几百次的迭代就足够了。我们使用最大迭代次数为200,因为这对于我们将要绘制的较小规模的图像来说,足够确定方程是否爆炸。因此,我们在8.08_Mandelbrot.py中定义了一个变量,如下所示:

max_number_of_iterations = 200

接下来,我们定义一个方法,该方法接受复数的实部和虚部,并判断该复数输入是否会导致方程爆炸。

例如,该方法对于输入值1应该返回2,因为对于输入值1,爆炸路径在第二次迭代时就被检测到了。然而,如果我们给它一个输入值-1,方程永远不会爆炸,因此它会运行最大迭代次数并返回maximum_iteration_count,我们将其定义为200,这相当于说该数值属于曼德布罗特集(8.08_Mandelbrot.py):

print(mandelbrot_set_check(1, 0)) # returns 2
print(mandelbrot_set_check(-1, 0)) # returns 200

因此,我们定义mandelbrot_set_check方法如下(8.08_Mandelbrot.py):

def mandelbrot_set_check(real, imaginary):
  iteration_count = 0
  z_real = 0.0
  z_imaginary = 0.0
  while iteration_count < max_number_of_iterations and \
        z_real * z_real + z_imaginary * z_imaginary < 4.0:
    temp = z_real * z_real - z_imaginary * z_imaginary + real
    z_imaginary = 2.0 * z_real * z_imaginary + imaginary
    z_real = temp
    iteration_count += 1
  return iteration_count

代码简单地实现了曼德布罗集的递归关系。

虽然知道一个复数是否位于曼德布罗特集中已经足够,但我们还记录了迭代次数,也称为逃逸时间,这是复数爆炸所需的迭代次数,如果它确实爆炸了。如果迭代次数返回为maximum_number_of_iterations,这意味着复数没有使方程爆炸,逃逸时间是无限的,也就是说,这个数是曼德布罗特集的一部分。我们记录迭代次数,因为我们将会使用这些数据以不同的颜色绘制具有不同逃逸时间的区域。

现在我们有了判断一个复数是否属于曼德布罗集的方法,我们需要一组复数来运行这个方法。为了做到这一点,我们首先定义一个最大和最小的复数,然后我们将检查这个范围内的复数是否包含在曼德布罗集中。注意,在下面的例子中,我们已将复数的范围设置为 -1.5-1i 和 0.7+1i

你可以尝试这些复数的不同范围,只要面积落在半径为 2 的圆内,它将打印出曼德布罗特集的不同区域:

min_real, max_real, min_imaginary, max_imaginary = -1.5, 0.7, -1.0, 1.0

让我们接下来定义image_widthimage_height变量,如下所示:

image_width = 512
image_height = 512

要在图像中绘制曼德布罗特集,我们需要将图像的每个像素坐标映射到我们的复数上。定义了复数的实部和虚部的最大和最小范围后,只需将复数插值映射到像素坐标即可。

以下两种方法为我们做到了这一点 (8.08_Mandelbrot.py):

def map_pixels_to_real(x):
  real_range = max_real - min_real
  return x * (real_range / image_width) + min_real

def map_pixels_to_imaginary(y):
  imaginary_range = max_imaginary - min_imaginary
  return y * (imaginary_range / image_height) + min_imaginary

现在我们准备绘制实际图像。我们创建一个 Tkinter 根窗口,在其上方绘制一个画布,然后运行以下循环:

for y in range(image_height):
 for x in range(image_width):
   real = map_pixels_to_real(x)
   imaginary = map_pixels_to_imaginary(y)
   num_iterations = mandelbrot_set_check(real, imaginary)
   rgb = get_color(num_iterations)
   canvas.create_rectangle([x, y, x, y], fill=rgb, width=0)

上述代码对图像中的每个像素进行处理,将其xy坐标分别映射到实数和虚数,然后将这个数字发送到mandelbrot_set_check方法,该方法反过来返回数字爆炸所需的迭代次数。如果数字没有爆炸,它返回maximum_number_of_iterations的值。有了这个数字,我们调用另一个方法,该方法提供一个 RGB 颜色代码,这仅仅基于一些任意数字。它只是增加了美观价值,你可以玩转不同任意设计的颜色映射方案来生成不同颜色的 Mandelbrot 图像。最后,我们使用这个颜色填充画布上的(xy)^(th)像素。

这就完成了迭代。我们的代码现在可以生成曼德布罗特集了。然而,请注意,这段代码生成曼德布罗特集需要一些时间。

Voronoi 图

我们现在将绘制一个 Voronoi 图。Voronoi 图是一种简单但非常强大的工具,用于模拟许多物理系统。维基百科(en.wikipedia.org/wiki/Voronoi_diagram#Applications)列出了超过 20 个科学和技术领域,其中 Voronoi 图被用于模拟和解决现实世界的问题。

绘制 Voronoi 图的规则有很多小的变化,但最常见的 Voronoi 图是通过在二维平面上选择有限数量的点来制作的。我们称这些点为种子或吸引点。以下图像中显示的蓝色小点即为吸引点。然后我们将平面上所有的点映射或附着到它们最近的吸引点上。所有靠近特定吸引点的点都绘制成一种颜色,这把平面分割成所谓的Voronoi 单元,如下面的图所示:

图片

绘制 Voronoi 图有许多高效但复杂的算法。然而,我们将使用最简单的算法来理解。然而,简单是有代价的。与其他更快但更复杂的算法相比,该算法在计算时需要更多的时间。

我们将首先在给定宽度和高度的画布上创建一定数量的随机吸引点。相应地,我们在程序中定义了三个变量(8.09_vornoi_diagram.py):

width = 800
height = 500
number_of_attractor_points = 125

接下来,我们在 Tkinter 根窗口上创建一个具有先前宽度和高度的画布,并将画布传递给名为generate_vornoi_diagram的方法,该方法为我们完成所有处理和绘图工作。其代码如下:

def create_voronoi_diagram(canvas, w, h, number_of_attractor_points):
  attractor_points = []
  colors = []
  for i in range(number_of_attractor_points):
    attractor_points.append((random.randrange(w), random.randrange(h)))
    colors.append('#%02x%02x%02x' % (random.randrange(256),
                                     random.randrange(256),
                                     random.randrange(256)))
  for y in range(h):
    for x in range(w):
      minimum_distance = math.hypot(w , h )
      index_of_nearest_attractor = -1
      for i in range(number_of_attractor_points):
        distance = math.hypot(attractor_points[i][0] - x, 
          attractor_points[i][1] - y)
        if distance < minimum_distance:
          minimum_distance = distance
          index_of_nearest_attractor = i
      canvas.create_rectangle([x, y, x, y], 
        fill=colors[index_of_neasrest_attractor], width=0)
  for point in attractor_points:
    x, y = point
    dot = [x - 1, y - 1, x + 1, y + 1]
    canvas.create_rectangle(dot, fill='blue', width=1)

这里是对前面代码的简要描述:

  • 我们首先创建两个列表。第一个for循环用于将每个吸引点(attractor_points)的元组(xy)填充到attractor_points列表中。我们还创建另一个列表,colors,它包含每个吸引点单元格的随机颜色十六进制字符串。

  • 第二层嵌套的for循环遍历画布上的每个像素,并找到最近的吸引子的索引。一旦确定了这个索引,它就会使用分配给那个吸引子点的颜色来着色单个像素。

  • 最后的 for 循环将为每个吸引点绘制一个重叠的蓝色方块。这个循环故意放在最后运行,以确保吸引点能够覆盖彩色单元格区域。

由于前面的代码需要通过三个嵌套循环来检查平面上每个 x, y 位置与每个吸引点之间的对应关系,根据大 O 记号,它的计算复杂度为 O(n³)。这意味着该算法根本无法扩展到绘制更大尺寸的图像,这也解释了为什么即使是这个尺寸适中的图像,这段代码生成 Voronoi 图也需要一些时间。更高效的算法是可用的,如果你不想重新发明轮子,甚至可以使用来自 scipy.spatial 模块的 Voronoi 类来实现这一点,这将快得多。这留给你作为探索的练习。

这部分内容到此结束。如果你现在运行8.09_vornoi_diagram.py程序,它应该会生成一个沃罗诺伊图。

弹簧摆模拟

许多现实世界现象都可以被称为动态系统。这类系统的状态随时间变化。建模这类系统需要使用微分方程。以下我们将以一个例子来说明如何建模一个连接到弹簧的摆,如图所示。摆来回摆动。此外,由于摆锤连接到弹簧,摆锤也会上下振荡:

图片

我们研究两个变量随时间的变化:

  • 弹簧的长度 l

  • 弹簧与中心线之间的角度(θ),如图所示的前一图。

由于有两个变量随时间变化,我们系统在任何时刻的状态都可以通过使用四个状态变量来表示:

  • 弹簧长度(l)

  • 春季长度变化(dl/dt),即速度

  • 角度 (θ)

  • 角度变化(dθ/dt),即角速度

它们由以下四个微分方程建模:

  • 图片

  • 图片

  • 图片

  • 图片

第一个方程衡量线性速度,即 L 随时间变化的速率。第二个方程是二阶导数,它给出了加速度。第三个方程衡量 theta 随时间的变化,因此代表角速度。最后一个方程是 theta 随时间的二阶导数,因此它代表角加速度。

让我们先定义以下常数:

UNSTRETCHED_SPRING_LENGTH = 30
SPRING_CONSTANT = 0.1
MASS = 0.3
GRAVITY = 9.8
NUMBER_OF_STEPS_IN_SIMULATION = 500

因此,让我们首先定义这四个状态变量的初始值:

state_vector = [ 1, 1, 0.3, 1 ]  
# 4 values represent 'l', 'dl/dt', 'θ', 'dθ/dt' respectively

然后,我们定义了differentials_functions方法,该方法返回先前定义的四个微分函数的数组:

def differential_functions(state_vector, time):
 func1 = state_vector[1]
 func2 = (UNSTRETCHED_SPRING_LENGHT + state_vector[0]) * 
   state_vector[3]**2 -  
   (SPRING_CONSTANT / MASS * state_vector[0]) + GRAVITY * 
     np.cos(state_vector[2])
 func3 = state_vector[3]
 func4 = -(GRAVITY * np.sin(state_vector[2]) + 2.0 * state_vector[1] * 
   state_vector[3]) / (UNSTRETCHED_SPRING_LENGHT + state_vector[0])
 return np.array([func1, func2, func3, func4])

接下来,我们将使用 scipy.integrate.odeint 来求解微分方程。此方法可以用来求解以下形式的常微分方程组:

图片

这是scipy.integrate.odeint函数的签名:

scipy.integrate.odeint(func, y0, t, optional arguments)

位置:

  • func: 可调用函数(y, t0, ...), 用于计算 y 在 t0 处的导数

  • y0: 初始条件数组(可以是一个向量)

  • t: 用于求解 y 的时间点数组

初始值点应该是这个序列的第一个元素。

此方法以导数函数(func)、初始状态值数组()和时间数组(t)作为输入。它返回与这些时间相对应的状态值数组。

由于我们是针对时间进行微分,我们需要一个变量来跟踪时间(8.10_spring_pendulum.py):

time = np.linspace(0, 37, NUMBER_OF_STEPS_IN_SIMULATION)

这里数字37是采样时间的步长。改变这个值将会改变模拟的速度。

现在我们终于使用 scipy.integrate.odeint 解决了微分方程组,如下所示 (8.10_spring_pendulum.py):

ode_solution = odeint(differential_functions, state_vector, time)

由于我们将模拟步数设置为 500,并且有四个状态变量,odeint方法返回一个形状为(500, 4)的 numpy 数组,其中每一行代表在特定时间点上四个状态变量的值。

现在回想一下,我们的状态向量是一个包含四个值的列表,['l', 'dl/dt', 'θ', 'dθ/dt']。因此,第 0 列返回值 'l',而第 2 列代表值 'θ'。这是极坐标格式的表示。我们的画布理解笛卡尔坐标系。因此,我们按照以下方式获得每个(l, θ)值的笛卡尔坐标(x, y)(8.10_spring_pendulum.py):

x_coordinates = (UNSTRETCHED_SPRING_LENGHT + ode_solution[:, 0]) 
                * np.sin(ode_solution[:, 2])
y_coordinates = (UNSTRETCHED_SPRING_LENGHT + ode_solution[:, 0]) 
                * np.cos(ode_solution[:, 2])

拿到这些数据后,现在只需将其绘制在画布上。因此,我们在mainloop中创建一个 Canvas 小部件,并调用一个每 15 毫秒运行一次的update_graph方法,该方法会删除画布上的所有内容并重新绘制线条和一个椭圆(摆球)。我们还添加了一个增量变量,plot_step,每次模拟结束时都会重置为零。这使摆锤能够永远摆动(8.10_spring_pendulum.py):

plot_step = 0

def update_graph():
 global plot_step
 if plot_step == NUMBER_OF_STEPS_IN_SIMULATION: # simulation ended
   plot_step = 0 # repeat the simulation
 x, y = int(x_coordinates[plot_step]) + w / 2, 
   int(y_coordinates[plot_step] + h / 2)
 canvas.delete('all')
 canvas.create_line(w / 2, 0, x, y, dash=(2, 1), width=1, fill="gold4")
 canvas.create_oval(x - 10, y - 10, x + 10, y + 10, outline="gold4", 
   fill="lavender")
 plot_step = plot_step + 1
 root.after(15, update_graph)

这将创建一个弹簧摆,如下面的截图所示:

图片

这就完成了迭代。你可以通过改变常数的值(质量、弹簧常数和重力)来探索这个模拟。此外,改变初始状态向量的元素,例如角度和速度,程序应该会像在现实世界中那样做出响应。

我们看到了如何获得常微分方程(ODE),它是对仅一个变量的导数。这个概念的扩展是偏微分方程PDEs),它是对多个变量的导数。更复杂的现象,如电磁学、流体力学、热传递、电磁理论和各种生物模型,都是由偏微分方程建模的。

FEniCS 计算平台(fenicsproject.org/)是一个流行的开源软件工具,用于通过 Python 绑定解决偏微分方程(PDEs)。

混沌游戏 – 从随机性中构建三角形

混沌游戏指的是当随机数的选取受到某些约束时,随机数产生的分形图案。让我们来看一下最简单的混沌游戏之一的规则:

  1. 我们首先在平面上创建三个点以形成一个三角形。

  2. 要开始游戏,我们在三角形内随机画一个点。

  3. 我们随后掷骰子。根据结果,我们在上一点和三角形的任意一个顶点之间移动一半的距离。例如,如果结果是 1 或 2,我们就从上一点移动到顶点 A 的一半距离。如果结果是 3 或 4,我们就从当前点向顶点 B 移动一半的距离,或者如果结果是 5 或 6,我们就画出下一个点,这个点位于当前点和顶点 C 之间的一半距离,如下面的图像所示。这个过程会反复进行:

图片

这里是其中的惊喜部分。虽然除了三个顶点之外的所有点都是随机选择的,但最终结果并不是一组随机的点集,而是一个分形——一组重复的三角形模式,被称为谢尔宾斯基三角形,如下面的截图所示。据一些数学家所说,这是对宇宙秩序的窥视,这种秩序隐藏在看似混乱的事物之中:

图片

注意,在四个点的集合内重复这条规则并不会产生分形。然而,对顶点选择施加一些特定的限制会产生各种有趣的分形形状。您可以在en.wikipedia.org/wiki/Chaos_game上了解更多关于从混沌游戏中产生的不同分形品种的信息。

让我们现在编写这个程序。我们首先定义三角形的三个顶点,如图中所示的前一个截图:

v1 = (float(WIDTH/2), 0.0)
v2 = (0.00, float(HEIGHT))
v3 = (float(WIDTH), float(HEIGHT))

在这里,WIDTHHEIGHT 代表窗口的尺寸。

我们接下来的任务是选择三角形内部的一个随机点作为起点。这可以通过所谓的重心坐标来实现。

V1, V2, V3 为三角形的三个顶点。三角形内部的点 P 可以表示为 P = aV[1] + bV[2] + cV[3],其中 a+b+c=1a, b, c 均满足 ≥ 0。如果我们已知 ab,则可以通过 1-a-b 计算出 c

因此我们生成两个随机数,ab,每个都在范围 [0,1] 内,使得它们的和 ≤ 1。如果两个随机点的和超过 1,我们将 a 替换为 1-a,将 b 替换为 1-b,这样它们的和就会回到 1 以下。然后,aV[1] + bV[2] + cV[3] 在三角形内部是均匀分布的。

现在我们已经得到了重心坐标 a、b 和 c,我们可以计算出三角形内部的点 P,即 aV1 + bV2 + cV3。以下是这个想法在代码(8.11_chaos_game.py)中的表达:

def random_point_inside_triangle(v1, v2, v3):
  a = random.random()
  b = random.random()
  if a + b > 1:
    a = 1-a
    b = 1-b
  c = 1 - a -b
  x = (a*v1[0])+(b*v2[0])+(c*v3[0]);
  y = (a*v1[1])+(b*v2[1])+(c*v3[1]);
  return (x,y)

我们接下来定义一种计算两点之间中点距离的方法:

def midway_point(p1, p2):
  x = p1[0] + (p2[0] - p1[0]) //2
  y = p1[1] + (p2[1] - p1[1]) //2
  return (x,y)

这是在两点之间基于勾股定理的简单线性插值。注意,在 Python 中,/运算符执行浮点除法,而//运算符执行整数除法(丢弃余数)。

接下来,我们将游戏的规则放入一个名为 get_next_point 的方法中:

def get_next_point():
  global last_point
  roll = random.choice(range(6))+1
  mid_point = None
  if roll == 1 or roll == 2:
    mid_point = midway_point(last_point, v1)
  elif roll == 3 or roll == 4:
    mid_point = midway_point(last_point, v2)
  elif roll == 5 or roll == 6:
    mid_point = midway_point(last_point, v3)
 last_point = mid_point
 return mid_point

最后,我们创建一个 Tkinter 画布并定义一个名为update的方法,以便每 1 毫秒绘制单个像素,如下所示:

def update():
 x,y = get_next_point()
 canvas.create_rectangle(x, y, x, y, outline="#FFFF33")
 root.after(1, update)

调用这个update方法会在我们的混沌游戏中创建分形图案。

叶序

叶序一词源自希腊语单词 phýllon(意为叶子)和 táxis(意为排列)。因此,叶序是研究叶子与花朵中发现的螺旋排列方式的研究。

在本节中,我们将编写以下花卉图案的代码:

图片

该程序的数学细节来自书籍《植物算法学》的第四章——您可以从这里获取其 PDF 版本:algorithmicbotany.org/papers/abop/abop-ch4.pdf.

这里是我们将在本章中使用的两个公式:

图片 1  和 图片 2

代表画布上每个点的极坐标。正如您将看到的,我们的叶序将由螺旋图案排列的点组成。所以,前一个例子中的变量 n 代表从螺旋中心开始计数的第 n 个点的数量或索引。变量 c 被用作一个比例因子,它决定了点在最终图像中看起来是近还是远。角度 137.5 与黄金比例和斐波那契角度相关,看起来最自然。您可以在链接的 PDF 中了解更多相关信息。

首先,我们定义到目前为止所讨论的所有值:

width, height = 500, 500
number_of_dots = 2000
angle = 137.5
scaling_factor = 4
dot_size = 4
n = np.arange(number_of_dots)
r = np.zeros(number_of_dots)
phi = np.zeros(number_of_dots)
x= np.zeros(number_of_dots)
y= np.zeros(number_of_dots)
dots = []
colors = []

接下来,我们创建一个 Tkinter 画布,并将颜色添加到colors列表中。我们还使用create_oval创建点,并将所有椭圆的引用保存到dots列表中:

for i in n:
  r = (scaling_factor * np.sqrt(i) * 6 ) %256
  color = '#%02x%02x%02x' % (int(r) , 0, 0)
  colors.append(color)
  dots.append(canvas.create_oval(x[i]-dot_size, y[i]-dot_size,
                    x[i]+dot_size, y[i]+dot_size, fill=color ))

在前面代码中定义的颜色是基于r的值,并且完全是任意的。我们本可以使用任何其他变量或规则来定义颜色。

最后,我们定义了更新函数,该函数每 15 毫秒计算r的值,并更新画布上所有椭圆的坐标:

def update():
 global angle
 angle +=0.000001
 phi = angle * n
 r = scaling_factor * np.sqrt(n)
 x = r * np.cos(phi) + width/2
 y = r * np.sin(phi) + height/2
 for i in n:
 canvas.coords(dots[i],x[i]-dot_size, y[i]-dot_size,x[i]+dot_size, 
   y[i]+dot_size )
 root.after(15, update )

你现在应该能看到叶序模式。尝试更改所有参数,看看图像如何变化。

使用 Tkinter 的 3D 图形

Tkinter 的 Canvas 小部件允许使用精确的坐标进行绘图。因此,它可以用来创建各种 3D 图形。此外,我们之前已经看到了 Tkinter 的动画功能。我们还可以将这些功能应用到 3D 动画中。

让我们创建一个简单的应用程序,在其中我们创建一个位于中心的立方体。我们添加事件监听器以在鼠标事件上旋转立方体。我们还制作了一个小动画,当没有鼠标干预时,立方体会自行旋转。

在其最终形态下,应用程序将如下所示 (8.13_3D_graphics.py):

图片

在 Python 中,通过使用特殊的 * 操作符可以进行转置或解压,任何三维空间中的点都可以用 xyz 坐标来表示。这通常表示为以下形式的向量:

图片

这是一个行向量的例子,因为所有三个点都写在一行中。

这对人类阅读来说很方便。然而,按照惯例,并且为了某些我们稍后会看到的数学优势,位置被视为一个列向量。因此,它被写成如下列的形式:

图片

由于形状是一系列点的集合,因此它本质上是一系列列向量的集合。一系列列向量构成了一个矩阵,其中矩阵的每一列代表 3D 空间中的一个单独的点:

图片

让我们以一个立方体为例。一个立方体有八个定义的顶点。一个代表性的立方体可以有以下八个点,其中心位于[000]:

Vertex 1 : [-100,-100,-100],
Vertex 2 : [-100, 100,-100],
Vertex 3: [-100,-100,100],
Vertex 4: [-100,100,100],
Vertex 5: [100,-100,-100],
Vertex 6: [100,100,-100],
Vertex 7: [100,-100,100],
Vertex 8: [100,100,100]

然而,在这里顶点被表示为行向量。为了将向量表示为列向量,我们需要转置前面的矩阵。由于转置将是一个常见的操作,让我们首先创建一个名为 MatrixHelpers 的类,并定义一个名为 transpose_matrix8.13_3D_graphics.py): 的方法。

class MatrixHelpers():

  def transpose_matrix(self,matrix):
    return list(zip(*matrix))

在 Python 中,可以通过使用特殊的*运算符来实现转置或解压,这使得zip函数成为其自身的逆函数。

前述坐标的另一个问题是它以 (0,0,0) 为中心。这意味着如果我们尝试在画布上绘制前述点,它只会部分显示,位于画布的左上角,类似于这样:

图片

我们需要将所有点移动到屏幕中心。我们可以通过向原始矩阵添加xy偏移值来实现这一点。

因此,我们定义一个新的方法,命名为 translate_matrix,如下所示:

def translate_vector(self, x,y,dx,dy):
  return x+dx, y+dy

现在我们来绘制实际的立方体。我们定义了一个名为 Cube 的新类,它继承自 MatrixHelper 类,因为我们想使用在 MatrixHelper 类中定义的 transpose_matrixtranslate_vector 方法(参见 code 8.13_3D_graphics.py):

class Cube(MatrixHelpers):
  def __init__(self, root):
   self.root = root
   self.init_data()
   self.create_canvas()
   self.draw_cube()

__init__ 方法简单地调用了四个新方法。init_data 方法设置了立方体所有八个顶点的坐标值(8.13_3D_graphics.py):

def init_data(self):
  self.cube = self.transpose_matrix([
            [-100,-100,-100],
            [-100, 100,-100],
            [-100,-100,100],
            [-100,100,100],
            [100,-100,-100],
            [100,100,-100],
            [100,-100,100],
            [100,100,100]
         ])

create_canvas 方法在根窗口上创建一个 400 x 400 尺寸的画布,并为画布指定背景和填充颜色:

 def create_canvas(self):
   self.canvas = Canvas(self.root, width=400, height=400, background=self.bg_color)
   self.canvas.pack(fill=BOTH,expand=YES)

最后,我们定义了draw_cube方法,该方法使用canvas.create_line在选定的点之间绘制线条。我们不想在所有点之间绘制线条,而是希望在选定的某些顶点之间绘制线条以形成一个立方体。因此,我们相应地定义了该方法,如下所示(8.13_3D_graphics.py):

def draw_cube(self):
 cube_points_to_draw_line = [[0, 1, 2, 4],
           [3, 1, 2, 7],
           [5, 1, 4, 7],
           [6, 2, 4, 7]]
 w = self.canvas.winfo_width()/2
 h = self.canvas.winfo_height()/2
 self.canvas.delete(ALL)
 for i in cube_points_to_draw_line:
  for j in i:
    self.canvas.create_line(self.translate_vector(self.cube[0][i[0]], 
     self.cube[1][i[0]], w, h), 
    self.translate_vector(self.cube[0][j], self.cube[1][j], w, h), fill 
      = self.fg_color)

这段代码在画布上绘制了一个立方体。然而,由于立方体是优先绘制的,所以我们看到的是正面的一个正方形。为了看到立方体,我们需要将立方体旋转到不同的角度。这引出了 3D 变换的话题。

通过将形状矩阵与另一个称为变换矩阵的矩阵相乘,可以实现多种 3D 变换,例如缩放、旋转、剪切、反射和正交投影。

例如,缩放形状的变换矩阵是:

图片

其中 S[x]、S[y] 和 S[z] 分别是沿 xyz 方向的缩放因子。将任何形状矩阵与这个矩阵相乘,你就能得到缩放形状的矩阵。

因此,让我们向我们的MatrixHelper类(8.13_3D_graphics.py)中添加一个名为matrix_multiply的新方法:

def matrix_multiply(self, matrix_a, matrix_b):
  zip_b = list(zip(*matrix_b))
  return [[sum(ele_a*ele_b for ele_a, ele_b in zip(row_a, col_b)) 
          for col_b in zip_b] for row_a in matrix_a]

接下来,让我们添加旋转立方体的功能。我们将使用旋转变换矩阵。此外,由于旋转可以沿着任何 xyz 轴进行,实际上存在三个不同的变换矩阵。以下为三个旋转矩阵:

图片

将形状坐标乘以给定值 a 的第一个矩阵,你得到形状绕 x 轴逆时针旋转了角度 a。同样,其他两个矩阵分别绕 y 轴和 z 轴旋转。

要按顺时针方向旋转,我们只需将前一个矩阵中所有正弦值的符号翻转即可。

注意,然而,旋转的顺序很重要。所以如果你首先沿着x轴旋转,然后沿着y轴旋转,这和首先沿着y轴旋转,然后沿着x轴旋转是不一样的。

更多关于旋转矩阵的详细信息可以在en.wikipedia.org/wiki/Rotation_matrix找到。

因此,既然我们已经知道了三个旋转矩阵,那么让我们在我们的MatrixHelper类(8.13_3D_graphics.py)中定义以下三个方法:

def rotate_along_x(self, x, shape):
   return self.matrix_multiply([[1, 0, 0],
                                [0, cos(x), -sin(x)], 
                                [0, sin(x), cos(x)]], shape)

def rotate_along_y(self, y, shape):
   return self.matrix_multiply([[cos(y), 0, sin(y)], 
                                [0, 1, 0], 
                                [-sin(y), 0, cos(y)]], shape)

def rotate_along_z(self, z, shape):
   return self.matrix_multiply([[cos(z), sin(z), 0],
                                [-sin(z), cos(z), 0], 
                                [0, 0, 1]], shape)

接下来,我们定义一个名为 continually_rotate 的方法,并在我们的 Cube 类的 __init__ 方法中调用此方法:

def continually_rotate(self):
 self.cube = self.rotate_along_x(0.01, self.cube)
 self.cube = self.rotate_along_y(0.01, self.cube)
 self.cube = self.rotate_along_z(0.01, self.cube)
 self.draw_cube()
 self.root.after(15, self.continually_rotate)

该方法使用 root.after 每 15 毫秒调用自身。在每次循环中,立方体的坐标沿所有三个轴旋转 0.01 度。随后调用绘制立方体,使用一组新的坐标。现在,如果你运行此代码,立方体会持续旋转。

接下来,让我们将立方体的旋转绑定到鼠标按钮点击和鼠标移动。这样用户就可以通过点击并拖动鼠标在立方体上旋转立方体。

因此,我们定义以下方法并将其命名为Cube类的__init__方法:

def bind_mouse_buttons(self):
  self.canvas.bind("<Button-1>", self.on_mouse_clicked)
  self.canvas.bind("<B1-Motion>", self.on_mouse_motion)

前一个事件绑定中链接的方法定义如下:

def on_mouse_clicked(self, event):
  self.last_x = event.x
  self.last_y = event.y

def on_mouse_motion(self, event):
  dx = self.last_y - event.y
  self.cube = self.rotate_along_x(self.epsilon(-dx), self.cube)
  dy = self.last_x - event.x
  self.cube = self.rotate_along_y(self.epsilon(dy), self.cube)
  self.draw_cube()
  self.on_mouse_clicked(event)

注意,前面提到的方法是将鼠标沿 y 轴的位移映射到沿 x 轴的旋转,反之亦然。

此外,请注意代码的最后一行调用了on_mouse_clicked()函数来更新last_xlast_y的值。如果你跳过那一行,当你从最后点击的位置增加位移时,旋转会变得极其快速。

此方法还指代另一种名为 epsilon 的方法,该方法将距离转换为等效角度以进行旋转。epsilon 方法定义如下:

self.epsilon = lambda d: d * 0.01

这里所说的ε是通过将位移 d 与任意值 0.01 相乘得到的。您可以通过改变这个值来增加或减少旋转对鼠标位移的灵敏度。

现在,立方体对鼠标点击和拖动操作在画布上的响应。这标志着本章最后一个项目的结束。

在这里,我们只是刚刚触及了 3D 图形的表面。有关使用 Tkinter 进行 3D 编程的更详细讨论,可以在sites.google.com/site/3dprogramminginpython/找到。

也有尝试进一步抽象化并构建 Tkinter 的 3D 编程框架。你可以在 github.com/calroc/Tkinter3D 找到一个 Tkinter 的 3D 框架示例。

本章内容到此结束,同时也结束了我们对 Canvas 小部件的实验。在下一章中,我们将探讨编写 GUI 应用程序中最常见的一些主题,例如使用队列数据结构、数据库编程、网络编程、进程间通信、使用asyncio模块,以及编程中的一些其他重要概念。

摘要

让我们总结一下本章讨论的概念。

我们制作了一个屏幕保护程序,在这个过程中学习了如何在 Tkinter 画布上实现动画。接下来,我们看到了如何在画布上创建笛卡尔和极坐标图。我们还学习了如何将 matplotlib 图表嵌入到 Tkinter 窗口中。

我们随后实现了一个基本的引力模拟,展示了我们如何将一个物理模型通过 Tkinter 画布进行实现。我们窥见了沃罗诺伊图(Voronoi diagrams)的实现过程,这些图被用于模拟和解决许多实际世界的实际问题。

我们还构建了一些漂亮的可视化效果,例如曼德布罗特集和叶形星系。

最后,我们学习了如何使用 Tkinter 画布通过变换矩阵来绘制和动画化 3D 图形。

QA 部分

这里有一些问题供您思考:

  • 你如何将极坐标转换为笛卡尔坐标?在什么情况下我们应该优先选择一个坐标系而不是另一个?

  • 你如何在 Tkinter 画布上动画化?什么决定了动画的速度?

  • 我们如何使用微分方程在 Tkinter 画布上模拟现实世界的现象?

  • 分形在现实世界中有哪些应用?

  • 分形仍在积极研究中。你能找出一些依赖分形使用的尖端技术吗?

  • Voronoi 图在现实世界中有哪些应用?

  • 我们如何将我们的 3D 立方体程序扩展以显示其他物体的网格——比如说汽车模型、人体或现实世界中的物体?

进一步阅读

曼德布罗集的近亲是朱利亚集。阅读有关朱利亚集的内容,然后修改8.07_Mandelbrot.py以生成朱利亚集。分形是一个非常有趣的研究主题,而且它们背后的许多数学问题仍然未被探索。除了它们看起来很美之外,它们还被广泛应用于许多实际应用中。参见en.wikipedia.org/wiki/Fractal#Applications_in_technology

如果分形引起了你的兴趣,你还可以看看曼德布罗集的其他变体,例如磁铁 1 分形和佛陀分形。

如果你对学习混沌行为感兴趣,尝试在 Tkinter 画布上绘制 Hénon 函数。

我们模拟了一个弹簧摆,它以确定性的方式工作。然而,将两个摆组合成一个双摆会形成一个混沌的动态系统。尽管这样的系统遵循常微分方程,但最终结果可能会因初始条件的微小变化而大幅变化。尝试通过修改我们的弹簧摆来模拟双摆可能值得尝试。

我们使用了 scipy 中的内置 odeint 方法。然而,我们也可以使用欧拉法或龙格-库塔法编写自己的变体。你可以在这里了解更多关于这些用于近似常微分方程的数值方法:常微分方程的数值方法

如果整洁或引人入胜的视觉呈现看起来像是一件有趣的事情去做,这里还有一些你可能会感兴趣的项目:巴恩斯利蕨叶、细胞自动机、洛伦兹吸引子,以及使用 Verlet 积分模拟可撕裂布料。

光线追踪是一种强大但非常简单的 3D 渲染技术,可以轻松地在大约 100 行代码中实现。

第九章:多种趣味项目

到目前为止,我们已经探索了 Tkinter 的大部分重要特性。让我们利用本章来探讨在编写 GUI 应用程序时虽然不是 Tkinter 的核心,但经常遇到的一些编程方面。

在本章中,我们将从不同领域开发几个小型应用程序。我们将构建的应用程序包括:

  • 蛇形游戏应用

  • 天气预报应用

  • 端口扫描应用程序

  • 聊天应用程序

  • 电话簿应用程序

  • 超声波测距扫描仪应用

本章的一些关键目标包括:

  • 学习使用 Queue 模块以避免在编写多线程程序时出现的竞态条件和其它同步问题

  • 理解网络数据挖掘的基本原理

  • 为了理解套接字编程以及学习服务器-客户端架构的基础

  • 学习数据库编程

  • 学习如何使用 asyncio 与 Tkinter

  • 学习如何使用串行通信接口和交互外部硬件组件

技术要求

本章中的大多数项目都依赖于标准库,不需要额外的东西。例外的是超声波测距仪项目,该项目需要一个 Arduino 板和一个超声波测距仪传感器。硬件相对便宜(低于 10 美元)。你也可以选择不购买硬件,仍然可以阅读该项目,了解两台设备之间如何进行串行通信。

此外,您还需要下载并安装 Arduino 集成开发环境IDE),其具体细节将在项目本身中进行讨论。

构建一个贪吃蛇游戏

让我们现在构建一个简单的贪吃蛇游戏。像往常一样,我们将使用Canvas小部件为我们的蛇程序提供平台。我们将使用canvas.create_line来绘制蛇,以及canvas.create_rectangle来绘制蛇的食物。

本项目的首要目标是学习如何在多线程应用程序中把Queue模块用作同步技术

编写多线程应用程序会面临不同线程之间同步的挑战。当多个线程试图同时访问共享数据时,数据很可能会被损坏或以程序中未预期的方式修改。这被称为竞态条件

理解竞态条件

9.01_race_condition.py 代码演示了竞态条件。程序如下:

import threading

class RaceConditionDemo:
  def __init__(self):
    self.shared_var = 0
    self.total_count = 100000
    self.demo_of_race_condition()

  def increment(self):
    for i in range(self.total_count):
      self.shared_var += 1

  def decrement(self):
    for i in range(self.total_count):
      self.shared_var -= 1

  def demo_of_race_condition(self):
    t1 = threading.Thread(target=self.increment)
    t2 = threading.Thread(target=self.decrement)
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("value of shared_var after all increments & decrements :", self.shared_var)

if __name__ == "__main__":
  for i in range(100):
     RaceConditionDemo()

前面的代码包含两个名为 incrementdecrement 的方法,这两个方法都操作一个名为 shared_var 的单个共享变量。这两个方法是从不同的线程中调用的。

人们会预期在共享变量上进行相同数量的增加和减少操作,其值在结束时不会发生变化。然而,当你运行这个程序,比如像之前那样运行 100 次,每次连续运行都会得到共享变量的不同值。这是一个经典的例子,说明了竞争条件如何使程序输出变得非确定性。

竞态条件发生是因为我们根本无法预测线程的执行顺序。操作系统执行得非常随机,因此每次程序运行时线程的执行顺序都会有所不同。

使用同步原语

为了处理这种复杂性,threading模块提供了一些同步原语,例如锁、join 操作、信号量、事件和条件变量。

9.02_lock_demo.py 通过引入此行使用 lock 对前面的代码进行了轻微修改:

self.lock = threading.Lock()

接下来,每次要修改shared_variable时,都是在获取一个lock之后进行的。当变量被修改后,lock会被释放,如下面的代码所示:

self.lock.acquire()
self.shared_var += 1
self.lock.release()

这使我们能够避免竞态条件。由于此代码使用进行操作,在等量的增加和减少之后,它不会在共享变量中产生任何变化。

使用机制来避免竞态条件似乎很简单。然而,随着程序复杂性的增加,可能会有许多地方会修改变量。追踪大型代码库中可能被修改的变量位置通常是一项困难的任务。

使用队列

在大多数情况下,使用队列更安全、更简单。简单来说,队列是一种线程安全的复合内存结构。队列有效地按顺序将资源的访问权限分配给多个线程,并且是推荐的设计模式,适用于大多数需要并发的场景。

Queue模块提供了一种实现不同类型队列的方法,例如 FIFO(默认实现)、LIFO 队列和优先队列,并且该模块自带了运行多线程程序所需的所有锁定语义的内置实现。

这里是Queue模块基本用法的快速总结:

my_queue = Queue() #create empty queue
my_queue.put(data)# put items into queue
task = my_queue.get () #get the next item in the queue
my_queue.task_done() # called when a queued task has completed
my_queue.join() # awaits for all tasks in queue to get completed

让我们看看使用队列实现多线程应用程序的一个简单示例(见9.03_threading_with_queue.py):

import queue
import threading

class Consumer(threading.Thread):

  def __init__(self, queue):
   threading.Thread.__init__(self)
   self.queue = queue

  def run(self):
    while True:
      task = self.queue.get()
      self.do_task(task)

  def do_task(self, task):
    print ('doing task{}'.format(task))
    self.queue.task_done()

def producer(tasks):
    my_queque = queue.Queue()
    # populate queue with tasks
    for task in tasks:
      my_queque.put(task)
    # create 6 threads and pass the queue as its argument
    for i in range(6):
      my_thread = Consumer(my_queque)
      my_thread.daemon = True
      my_thread.start()
    # wait for the queue to finish
    my_queque.join()
    print ('all tasks completed')

if __name__ == "__main__":
  tasks = 'A B C D E F'.split()
  producer(tasks)

代码的描述如下:

  • 我们首先创建一个Consumer类,它继承自 Python 的threading模块。__init__方法接受一个队列作为其参数。

  • 我们随后覆盖了threading模块的run方法,通过使用queue.get()从队列中获取每个项目,然后将其传递给task_handler方法,该方法实际上执行当前队列项中指定的任务。在我们的示例中,它除了打印任务的名称外,没有做任何有用的事情。

  • 在我们的task_handler方法完成特定线程的工作后,它通过使用queue.task_done()方法向队列发送一个信号,告知任务已完成。

  • 在我们的Consumer类外部,我们在producer()模块函数中创建了一个空队列。这个队列通过使用queue.put(task)方法被填充了一个任务列表。

  • 我们随后创建了六个不同的线程,并将这个已填充的队列作为其参数传递。现在,由于任务由队列处理,所有线程都会自动确保任务按照线程遇到它们的顺序完成,而不会造成任何死锁或两个不同的线程试图处理同一个队列任务的情况。

  • 在创建每个线程时,我们也会使用 my_thread.daemon = True 创建一个守护线程池。这样做一旦所有线程完成执行,就会将控制权传递给我们的主程序。如果你注释掉这一行,程序仍然会运行,但在所有线程完成执行队列中的任务后,程序将无法退出。如果没有守护线程,你将不得不跟踪所有线程,并在你的程序完全退出之前告诉它们退出。

  • 最后,queue.join() 方法确保程序流程在此等待,直到所有排队任务实际上完成并且队列变为空。

构建贪吃蛇游戏

在了解了使用队列处理多线程应用程序的背景信息之后,让我们构建我们的贪吃蛇游戏。

游戏完成后,其外观将如下所示:

图片

视图类

让我们先通过创建一个基本的View类来开始编写我们的游戏代码。这个类将负责创建 GUI,检查游戏结束逻辑,并且最重要的是充当消费者,从队列中取出项目并处理它们以更新视图(参见9.04_game_of_snake.py):

class View(Tk):
  def __init__(self, queue):
    Tk.__init__(self)
    self.queue = queue
    self.create_gui()

  def create_gui(self):
    self.canvas = Canvas(self, width=495, height=305, bg='#FF75A0')
    self.canvas.pack()
    self.snake = self.canvas.create_line((0, 0), (0,0),fill='#FFCC4C', 
      width=10)
    self.food = self.canvas.create_rectangle(0, 0, 0, 0,  
      fill='#FFCC4C', outline='#FFCC4C')
    self.points_earned = self.canvas.create_text(455, 15, fill='white', 
      text='Score:0')

到现在为止,前面的代码应该对你来说已经很熟悉了,因为我们以前写过类似的代码。然而,请注意,我们现在的View类不是将根实例作为参数传递给其__init__方法,而是从Tk类继承。这行代码Tk.__init__(self)确保根窗口对所有这个类的所有方法都是可用的。这样我们就可以通过简单地引用self来避免在每一行都写上根属性。

本节课还将包含处理队列中放入的项目代码。在编写了将项目放入队列的类之后,我们将继续编写本节课的其余代码。

食品类别

接下来,我们将创建Food类(参见9.04_game_of_snake.py):

class Food:

def __init__(self, queue):
 self.queue = queue
 self.generate_food()

def generate_food(self):
 x = random.randrange(5, 480, 10)
 y = random.randrange(5, 295, 10)
 self.position = (x, y)
 rectangle_position = (x - 5, y - 5, x + 5, y + 5)
 self.queue.put({'food': rectangle_position})

代码的描述如下:

  • 因为我们希望从队列内部集中处理所有数据,所以我们把队列作为参数传递给Food类的__init__方法。

  • __init__ 方法调用了另一个名为 generate_food 的方法,该方法负责在画布上随机位置生成蛇的食物。

  • generate_food 方法在画布上生成一个随机的 (x, y) 位置。然而,因为坐标重合的地方只是画布上的一个很小的点,几乎看不见。因此,我们生成一个扩展的坐标(rectangle_position),范围从比 (x, y) 坐标小五个值的范围到比相同坐标高五个值的范围。使用这个范围,我们可以在画布上创建一个容易看见的小矩形,它将代表我们的食物。

  • 然而,我们在这里并没有创建矩形。相反,我们使用 queue.put 将食物(矩形)的坐标传递到我们的队列中。

蛇类

现在我们来创建Snake类。我们已经在中央队列中分配了一个任务来生成我们的食物。然而,这个任务并没有涉及到额外的线程。我们也可以在不使用线程的情况下生成我们的Snake类。但是,因为我们正在讨论实现多线程应用程序的方法,所以让我们将我们的Snake类实现为从单独的线程中工作(参见9.04_game_of_snake.py):

class Snake(threading.Thread):

  is_game_over = False

  def __init__(self, queue):
    threading.Thread.__init__(self)
    self.queue = queue
    self.daemon = True
    self.points_earned = 0
    self.snake_points = [(495, 55), (485, 55), (475, 55), (465, 55), 
      (455, 55)]
    self.food = Food(queue)
    self.direction = 'Left'
    self.start()

  def run(self):
    while not self.is_game_over:
      self.queue.put({'move': self.snake_points})
    time.sleep(0.1)
    self.move()

  def on_keypress(self, e):
    self.direction = e.keysym

  def move(self):
   new_snake_point = self.calculate_new_coordinates()
   if self.food.position == new_snake_point:
     self.points_earned += 1
     self.queue.put({'points_earned': self.points_earned})
     self.food.generate_food()
   else:
     self.snake_points.pop(0)
   self.check_game_over(new_snake_point)
   self.snake_points.append(new_snake_point)

  def calculate_new_coordinates(self):
    last_x, last_y = self.snake_points[-1]
    if self.direction == 'Up':
      new_snake_point = (last_x, last_y - 10)
    elif self.direction == 'Down':
      new_snake_point = (last_x, last_y + 10)
    elif self.direction == 'Left':
      new_snake_point = (last_x - 10, last_y)
    elif self.direction == 'Right':
      new_snake_point = (last_x + 10, last_y)
    return new_snake_point

  def check_game_over(self, snake_point):
    x, y = snake_point
    if not -5 < x < 505 or not -5 < y < 315 or snake_point in self.snake_points:
      self.is_game_over = True
      self.queue.put({'game_over': True})

代码的描述如下:

  • 我们创建一个名为 Snake 的类,使其在一个独立的线程中运行。这个类以 queue 作为其输入参数。

  • 我们将玩家获得的成绩初始化为零,并使用属性self.snake_points设置蛇的初始位置。注意,最初,蛇的长度为40像素。

  • 最后,我们启动线程并创建一个无限循环,以小间隔调用move()方法。在每次循环运行期间,该方法通过self.snake_points属性,将一个字典填充到queue中,其中键为移动,值为蛇的更新位置。

  • 首先,move方法根据键盘事件获取蛇的最新坐标。它使用一个名为calculate_new_coordinates的独立方法来获取最新的坐标。

  • 然后它会检查新坐标的位置是否与食物的位置相匹配。如果它们匹配,它会将玩家的得分增加一分,并调用Food类的generate_food方法在新的位置生成新的食物。

  • 如果当前点与食物坐标不重合,它将使用 self.snake_points.pop(0) 删除蛇坐标中的第一个项目。

  • 然后,它调用另一个名为 check_game_over 的方法来检查蛇是否撞到墙壁或自身。如果蛇发生碰撞,它将在队列中添加一个新的字典项,其值为 'game_over':True

  • 最后,如果游戏尚未结束,它会将蛇的新位置添加到列表 self.snake_points 中。这会自动加入到队列中,因为我们已经在 Snake 类的 run() 方法中定义了 self.queue.put({'move': self.snake_points }),以便在游戏未结束的情况下每 0.1 秒更新一次。

队列处理器

现在队列中已经填充了各种可执行项,让我们创建queue_handler方法来处理队列中的项目并相应地更新View

我们在View类中定义了queue_handler()方法如下:

def queue_handler(self):
 try:
   while True:
     task = self.queue.get_nowait()
     if 'game_over' in task:
       self.game_over()
     elif 'move' in task:
       points = [x for point in task['move'] for x in point]
       self.canvas.coords(self.snake, *points)
     elif 'food' in task:
       self.canvas.coords(self.food, *task['food'])
     elif 'points_earned' in task:
       self.canvas.itemconfigure(self.points_earned, text='Score:  
                               {}'.format (task['points_earned'])) 
     self.queue.task_done()
 except queue.Empty:
   self.after(100, self.queue_handler)

代码的描述如下:

  • queue_handler 方法会陷入一个无限循环,使用 task = self.queue.get_nowait() 在队列中查找任务。如果队列变为空,循环会通过 canvas.after 重新启动。

  • 当我们使用 queue_get_nowait() 时,调用不会阻塞调用线程直到有项目可用。如果可用,它会从队列中移除并返回一个项目。如果队列是空的,它会引发 Queue.Empty

  • 一旦从队列中获取了一个任务,该方法会检查其键。

  • 如果键是game_over,它将调用另一个名为game_over()的方法,我们将在下面定义它。

  • 如果任务的键是move,它使用canvas.coords将线条移动到其新位置。

  • 如果键是points_earned,它将更新画布上的分数。

当一个任务执行完成后,它通过task_done()方法向线程发出信号。最后,我们创建主循环如下:

def main():
  q = queue.Queue()
  gui = View(q)
  snake = Snake(q)
  for key in ("Left", "Right", "Up", "Down"):
    gui.bind("<Key-{}>".format(key), snake.on_keypress)
    gui.mainloop()

if __name__ == '__main__':
  main()

我们的游戏现在已可运行。去尝试控制蛇,同时保持它的肚子饱饱的。

创建一个天气播报应用程序

让我们现在构建一个简单的天气报告应用。任何给定位置的天气数据将从网络中获取,进行适当的格式化,并展示给用户。

我们将使用一个名为 urllib 的高级模块从网络中获取天气数据。urllib 模块是 Python 的标准库的一部分,它提供了一个易于使用的 API,用于处理 URL。它包含四个子模块:

  • urllib.request: 用于打开和读取 URL

  • urllib.error: 用于处理由 urllib.request 引起的异常

  • urllib.parse: 用于解析 URL

  • urllib.robotparser: 用于解析 robots.txt 文件

使用 urllib.request,获取网页内容只需三行代码   (参见 9.05_urllib_demo.py):

import urllib.request

with urllib.request.urlopen('http://www.packtpub.com/') as f:
  print(f.read())

这将打印整个 HTML 源代码或网页www.packtpub.com的响应。本质上,这是从网络中挖掘信息的核心。

现在我们已经知道了如何从 URL 获取数据,让我们将其应用到构建我们的天气报告应用中。这个应用应该从用户那里获取位置输入,并获取相关的天气数据,如下面的截图所示:

图片

我们创建了一个名为 WeatherReporter 的类,并在类外部的 mainloop 中调用它(参见 9.06_weather_reporter.py 的代码):

def main():
  root=Tk()
  WeatherReporter(root)
  root.mainloop()

if __name__ == '__main__':
  main()

我们在这里不讨论创建此 GUI 的代码,因为我们已经在所有前面的章节中多次进行过类似的编码。天气数据在画布上显示(见9.06_weather_reporter.py):

当你指定一个位置并点击前往按钮时,它会调用一个名为on_show_weather_button_clicked的命令回调。

我们随后从网站获取天气数据。

从网站获取数据有两种方法。第一种方法涉及从网站获取 HTML 响应,然后解析收到的 HTML 响应以获取与我们相关的数据。这种数据提取方式被称为网站抓取

ScrapyBeautiful Soup 是两个流行的网站抓取框架,用于从网站中提取数据。您可以在scrapy.org/www.crummy.com/software/BeautifulSoup/ 找到这两个库的官方文档。

网站抓取是一种相当粗糙的方法,只有在给定网站不提供结构化数据检索方式时才会使用。另一方面,一些网站愿意通过一组 API 共享数据,前提是你使用指定的 URL 结构查询数据。这显然比网站抓取更优雅,因为数据是以可靠和相互同意的格式进行交换的。

对于我们的天气报道应用,我们希望针对特定位置查询一些天气频道,然后检索并显示数据到我们的画布上。

幸运的是,有几个天气 API 我们可以使用。在我们的示例中,我们将使用以下网站提供的天气数据:

openweathermap.org/

为了使用 API,您需要在此处注册一个免费的 API 密钥:

注册

OpenWeatherMap 服务提供免费的天气数据和预报 API。该网站收集来自全球超过 40,000 个气象站的天气数据,数据可以通过城市名称和地理坐标,或者它们的内部城市 ID 进行评估。

该网站提供两种数据格式的天气数据:

  • JSONJavaScript 对象表示法

  • XML可扩展标记语言

XML 和 JSON 是两种流行的可互换数据序列化格式,广泛用于在不同应用程序之间交换数据,这些应用程序可能运行在不同的平台和不同的编程语言上,从而提供了互操作性的好处。

JSON 比 XML 更简单,因为它的语法更简单,并且更直接地映射到现代编程语言中使用的数据结构。JSON 更适合交换数据,但 XML 适合交换文档。

API 文档告诉我们查询的例子如下:

api.openweathermap.org/data/2.5/weather?q=London,uk&APPID={APIKEY} 

上述代码返回伦敦的天气数据,格式如下:

{"coord":{"lon":-0.12574,"lat":51.50853},"sys":{"country":"GB","sunrise":1377147503,"sunset":1377198481},"weather":[{"id":500,"main":"Rain", "description": "light rain","icon":"10d"}],"base":"gdps stations","main":{"temp":294.2, "pressure":1020,"humidity":88, "temp_min":292.04,"temp_max":296.48},"wind":{"speed":1,"deg":0},"rain":{"1h":0.25},"clouds":{"
 all":40},"dt":1377178327,"id":2643743,"name":"London","cod":200}

JSON 的语法简单。任何 JSON 数据都是一个名称/值对,并且每条数据都由逗号与其它数据分隔。JSON 使用花括号 {} 来包含对象,使用方括号 [ ] 来包含数组。因此,我们在应用程序中定义了一个方法来获取以 JSON 格式的天气数据(见 9.06_weather_reporter.py):

def get_data_from_url(self):
 try:
   params = urllib.parse.urlencode( {'q': self.location.get(), 'APPID': self.APIKEY},
    encoding="utf-8")
   api_url = ('http://api.openweathermap.org/data/2.5/weather?{}'.format(params))
   with urllib.request.urlopen(api_url) as f:
     json_data = f.read()
     return json_data
 except IOError as e:
   messagebox.showerror('Unable to connect', 'Unable to connect %s' % e)
   sys.exit(1)

代码的描述如下:

  • 此方法使用 urllib 从网站检索响应。它以 JSON 格式返回响应。

  • 现在,我们将开始处理 JSON 数据。使用 API 返回的天气数据是以 JSON 格式编码的。我们需要将这些数据转换为 Python 数据类型。Python 提供了一个内置的 json 模块,它简化了编码/解码 JSON 数据的过程。因此,我们将 json 模块导入到我们的当前命名空间中。

然后,我们将使用这个模块将检索到的 JSON 数据转换为 Python 字典格式(参见 9.06_weather_reporter.py):

def json_to_dict(self, json_data):
 decoder = json.JSONDecoder()
 decoded_json_data = decoder.decode(json_data.decode("utf-8"))
 flattened_dict = {}
 for key, value in decoded_json_data.items():
   if key == 'weather':
     for ke, va in value[0].items():
       flattened_dict[str(ke)] =  str(va).upper()
     continue
   try:
     for k, v in value.items():
     flattened_dict[str(k)] = str(v).upper()
   except:
     flattened_dict[str(key)] = str(value).upper()
 return flattened_dict 

现在我们已经拥有了 API 提供的所有与天气相关的信息字典,我们只需使用canvas.create_textcanvas.create_image来显示检索到的天气数据。显示天气数据的代码是自我解释的(见9.06_weather_reporter.py)。

我们的天气报道应用现在已启用。

当您从 Python 程序访问服务器时,在发送请求后留出小的时间间隔非常重要。

一个典型的 Python 程序每秒可以执行数百万条指令。然而,向你发送数据的另一端的服务器并没有配备以那种速度工作的能力。如果你在短时间内有意或无意地向服务器发送大量请求,你可能会阻止它为正常网络用户处理常规请求。这构成了对服务器所谓的拒绝服务DOS)攻击。如果你的程序没有发送有限数量的良好行为请求,你可能会被禁止访问,或者在最坏的情况下,因为破坏服务器而被告上法庭。

总结天气报告的代码,我们使用urllib模块来查询数据提供者提供的天气 API。数据以 JSON 格式获取。然后,将 JSON 数据解码成 Python 可读的格式(字典)。

转换后的数据随后使用 create_textcreate_image 方法在画布上显示。

一个简单的套接字示例

本项目的目标是向您介绍网络编程的基础知识以及如何在您的图形用户界面应用程序中使用它。

Python 对网络编程有着强大的支持。在最低级别上,Python 提供了一个 socket 模块,它允许你使用简单易用、面向对象的接口连接和与网络进行交互。

对于那些刚开始学习套接字编程的人来说,套接字是计算机进行任何网络通信的基本概念。例如,当你在你浏览器中输入www.packtpub.com时,你的计算机操作系统会打开一个套接字并连接到远程服务器以为你获取网页。任何需要连接到网络的应用程序都会发生同样的事情。

更具体地说,套接字指的是一个通信端点,它由一个包含以下信息的五个元素元组来表征:

(protocol, local address, local port, remote address, remote port) 

这个元组必须对于在本地机器和远程机器之间通信的通道是唯一的。

套接字可以是面向连接的或无连接的。面向连接的套接字允许根据需要双向传输数据。无连接套接字(或数据报套接字)一次只能传输一条消息,而不需要建立开放连接。

套接字可以分为不同的类型或家族。最常用的两种套接字家族是AF_INET(用于互联网连接)和AF_UNIX(用于 Unix 机器上的进程间通信)。在我们的聊天程序中,我们将使用AF_INET

这是最底层程序员可以访问网络的级别。在套接字层之下,是原始的 UDP 和 TCP 连接,这些连接由计算机的操作系统处理,程序员没有直接的访问点。

让我们简要地看看socket模块中可用的一些 API:

API 描述

| socket.socket (addressfamily=AF_INET,

type=SOCK_STREAM, proto=0,

fileno=None) | 创建一个套接字。addressfamily 表示提供地址的格式,通常是 IP 地址;类型通常是 SOCK_STREAM 用于 TCP 或 SOCK_DGRAM 用于 UDP 连接协议。协议号通常是零,也可以省略。返回一个套接字对象。 |

socket.bind(address) 将本地地址与套接字关联。套接字必须尚未绑定。(地址的格式取决于创建套接字时定义的地址族。)
socket.listen(backlog) 声明接受连接的意愿。backlog 参数指定了队列中最大连接数,其值至少为零;最大值依赖于系统。
socket.accept() 被动建立传入连接。在接受之前,套接字必须绑定到一个地址并监听连接。返回一个 (conn, address) 对,其中 conn 是一个可用于在连接上发送和接收数据的新的套接字对象,而 address 是连接另一端绑定到套接字上的地址。
socket.connect() 主动尝试在指定地址上与远程套接字建立连接。
socket.send(bytes)/socket.sendall(bytes) 通过连接发送一些数据。与 send() 不同,sendall() 会继续从字节中发送数据,直到所有数据都已发送或发生错误。成功时返回 None
socket.recv(bufsize) 通过连接接收一些数据。返回一个表示接收到的数据的字节对象。一次要接收的数据的最大量由bufsize指定。
socket.close() 释放连接。底层系统资源(例如,文件描述符)也被关闭。

如果你查看本项目代码包中的 9.07_socket_demo.py Python 文件,你会发现它发送了一个看起来非常晦涩的 GET 请求,以从以下代码行中的 URL 获取内容:

message = "GET / HTTP/1.1 \r\nHost:" + host + "\r\n\r\nAccept: text/html\r\n\r\n"

从服务器接收到的数据也是以数据包的形式发送的,我们的任务是收集所有数据并在我们这一端将其组装起来。

构建一个端口扫描器

现在我们已经了解了套接字编程的基础,让我们来构建一个端口扫描器。

端口对计算机而言就像入口对房屋一样。一台计算机有 65,535 个端口,通过这些端口它可以与外界进行通信。大多数端口默认是关闭的。然而,通常情况下,计算机需要保持某些端口开启,以便网络上的其他计算机能够连接并进行通信。

端口扫描器是一种软件,它扫描计算机的所有端口,以找出哪些端口是开放的并且正在监听传入的通信。端口扫描被网络管理员用来加强他们的安全制度,但它也被黑客用来寻找入侵计算机的入口点。

在您使用此工具扫描随机网站服务器之前,重要的是要知道,在一些司法管辖区,未经适当授权的端口扫描是非法的。许多互联网服务提供商(ISP)禁止端口扫描。此外,许多网站都有明确政策禁止任何端口扫描尝试。已有未经授权扫描被定罪的案例。

如果您使用此工具扫描第三方网站,甚至可能需要咨询律师。即使网站对端口扫描保持沉默,但在扫描其端口之前获得网站的授权总是更好的。重复扫描

对单个目标的尝试也可能导致您的 IP 地址被管理员阻止。

我们建议您仅在使用您有权扫描的计算机或具有宽松政策允许有限且非破坏性扫描的网站上使用此工具来分析安全漏洞。

在消除这些免责声明之后,让我们开始构建端口扫描器。在完成之后,我们的端口扫描器将如下所示:

图片

我们不讨论创建前面 GUI 的代码,因为这应该对你来说很简单。请参阅9.08_port_scanner.py以获取完整的代码。我们反而讨论与端口扫描相关的代码。

端口扫描使用了多种技术。TCP SYN 扫描是最常用的技术。它利用了 TCP 使用的三次握手协议,这涉及到发送和接收 SYN、SYN-ACK 和 ACK 消息。在这里,SYN 代表同步,ACK 代表确认。访问 en.wikipedia.org/wiki/Transmission_Control_Protocol 了解关于此三次握手协议的更多详情。

TCP SYN 扫描涉及发送一个 SYN 数据包,仿佛你将要建立一个真实连接,然后等待响应。来自目标的主机 SYN/ACK 响应意味着端口是开放的。RST(重置)响应表明端口是关闭的。如果没有收到任何响应,则认为端口被过滤。

另一种常见的技巧,也是我们将用于端口扫描的技巧,被称为 TCP 连接扫描器。这涉及到使用 connect 系统调用向目标操作系统请求建立连接。这正是网络浏览器和其他高级客户端建立连接的方式。

connect 命令与目标建立实际连接,这与 TCP SYN 扫描的半开扫描相反。由于建立了完整的连接,connect 扫描比 SYN 扫描慢,并且需要更多的传输来找出端口是否开放。此外,目标机器更有可能记录连接,因此它不如 SYN 扫描隐蔽。

因此,检查端口是否开放的代码定义如下(见9.08_port_scanner.py):

def is_port_open(self,url, port):
 try:
   s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   s.settimeout(1)
   s.connect((socket.gethostbyname(url), port))
   s.close()
   return True
 except:
   return False

注意,前面的代码只是简单地使用 socket.connect 来建立连接以探测端口

我们将之前的方法称为另一种方法,即start_scan,它简单地遍历用户提供的范围内的每个端口:

def start_scan(self, url, start_port, end_port):
 for port in range (start_port, end_port+1):
   if not self.stop:
     self.output_to_console("Scanning port{}".format(port))
     if self.is_port_open(url, port):
       self.output_to_console(" -- Port {} open \n".format(port))
     else:
       self.output_to_console("-- Port {} closed \n".format(port))

最后,我们不希望调用此方法阻塞我们的 Tkinter 主循环。因此,我们如下在一个新线程中调用前面的方法:

def scan_in_a_new_thread(self):
  url = self.host_entry.get()
  start_port = int(self.start_port_entry.get())
  end_port = int(self.end_port_entry.get())
  thread = Thread(target=self.start_scan, args=(url, start_port, 
    end_port ))
  thread.start()

上述方法获取用户输入的值,并将它们作为参数传递给新线程中的start_scan方法。

代码的其余部分仅用于创建和更新带有结果的图形用户界面,应该很容易理解。这标志着端口扫描项目的结束。

构建一个聊天应用

接下来,让我们构建一个多客户端聊天室。这个程序的目标是更深入地探索套接字编程。本节还实现了并讨论了在所有网络程序中都非常常见的客户端-服务器架构。

我们的聊天程序将包括一个聊天服务器,该服务器会在指定的端口上监听并接收所有传入的消息。

它还维护一个连接到服务器的聊天客户端列表。然后,它将任何接收到的消息广播给所有已连接的客户端:

图片

让我们从聊天服务器的代码开始。

服务器运行在远程主机上,并绑定到一个特定的端口号。服务器只是等待,监听套接字以等待客户端发起连接请求。

这是聊天服务器的代码(见9.09_chat_server.py):

class ChatServer:
  clients_list = []
  last_received_message = ""

  def __init__(self):
    self.create_listening_server()

  def create_listening_server(self):
    self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    local_ip = '127.0.0.1'
    local_port = 10319
    self.server_socket.setsockopt(socket.SOL_SOCKET, 
      socket.SO_REUSEADDR, 1)
    self.server_socket.bind((local_ip, local_port))
    print("Listening for incoming messages..")
    self.server_socket.listen(5)
    self.receive_messages_in_a_new_thread()

  def receive_messages_in_a_new_thread(self):
    while 1:
      client = so, (ip, port) = self.server_socket.accept()
      self.add_to_clients_list(client)
      print ('Connected to ', ip , ':' , str(port))   
      t = threading.Thread(target=self.receive_messages, args=(so,))
      t.start()

  def receive_messages(self, so):
    while True:
      incoming_buffer = so.recv(256)
      if not incoming_buffer: break
      self.last_received_message = incoming_buffer.decode('utf-8')
      self.broadcast_to_all_clients(so)
      so.close()

  def broadcast_to_all_clients(self, senders_socket):
     for client in self.clients_list:
        socket, (ip, port) = client
        if socket is not senders_socket:
           socket.sendall(self.last_received_message.encode('utf-8'))

  def add_to_clients_list(self, client):
      if client not in self.clients_list:
         self.clients_list.append(client)

if __name__ == "__main__":
ChatServer()

前述代码的描述如下:

  • 我们使用行self.server_socket = socket(AF_INET, SOCK_STREAM)创建一个 IPv4 地址族的 TCP 套接字。IPv4 套接字使用 32 位数字来表示地址大小。它是最受欢迎的寻址方案,占用了大部分当前的互联网流量。IPv6 是一个较新的编号系统,具有 128 位的地址大小,从而提供了更大的地址池。IPv6 已经得到一些采用,但尚未成为主流标准。

  • SOCK_STREAM参数表示我们将使用 TCP 连接进行通信。另一个不太受欢迎的选项是使用SOCK_DGRAM,它指的是 UDP 传输模式。

  • TCP 比 UDP 更可靠,因为它提供了防止数据包丢失的保证。它还负责在接收端正确排序字节。如果我们使用 UDP 协议,我们就必须处理数据包丢失、重复以及接收端数据包的排序问题。

  • 我们在上一段代码中使用了 socket.bind(('127.0.01', 10319)) 来绑定套接字。我们也可以选择使用 socket.bind ((socket.gethostname( ), 10319),这样套接字就会对外界可见。或者,我们也可以指定一个空字符串,例如 socket.bind((' ', 10319)),使得套接字可以通过机器上的任何地址访问。

  • socket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 这行代码允许其他套接字绑定到这个本地端口,除非端口上已经有一个活跃的套接字绑定。这使我们能够在服务器崩溃后重启时绕过“地址已在使用”的错误信息。

  • self.server_socket.accept() 在远程客户端连接到服务器后立即返回一个形式为 (socket, (ip, port)) 的值。然后,每个客户端通过以下数据唯一标识:(socket, (ip, port))

  • 这行代码 Thread(target=self.receive_messages, args=(so,)) 在一个新的线程上接收每条新消息。

  • 最后,这一行代码 socket.sendall(self.last_received_message.encode('utf-8')) 将消息发送给单个客户端。

  • receive_messages 方法使用 socket.recv 方法接收消息。socket.recv 方法在缓冲区中接收消息。你有责任反复调用该方法,直到整个消息被处理完毕。当 socket.recv 方法返回 0 字节时,意味着发送者已经关闭了连接。然后我们跳出无限循环,并从缓冲区中获取完整的消息。

还要注意,网络上的消息传输是以字节为单位的。

我们发送的任何消息都必须使用outgoing_message.encode('utf-8')转换为字节形式。同样,我们从网络接收到的任何消息都必须从字节转换为字符串或任何其他格式。

将字节转换为字符串,我们使用 incoming_bytes.decode('utf-8')

我们的服务器现在已经准备好了。接下来,让我们构建聊天客户端。

我们的聊天客户端应该连接到远程服务器并向服务器发送消息。它还应该监听来自中央聊天服务器的任何传入消息。我们没有为我们的聊天客户端复制整个代码。具体来说,我们省略了生成我们聊天客户端 GUI 的代码,因为我们之前已经编写过类似的控件。

我们聊天客户端中发送和接收聊天服务器消息的部分代码如下(见 9.10_chat_client.py):

class ChatClient:
  client_socket = None
  last_received_message = None

  def __init__(self, root):
    self.root = root
    self.initialize_socket()
    self.initialize_gui()
    self.listen_for_incoming_messages_in_a_thread()

  def initialize_socket(self):
    self.client_socket = socket(AF_INET, SOCK_STREAM)
    remote_ip = '127.0.0.1'
    remote_port = 10319
    self.client_socket.connect((remote_ip, remote_port))

  def listen_for_incoming_messages_in_a_thread(self):
    t = Thread(target=self.recieve_message_from_server,
    args=(self.client_socket,))
    t.start()

  def recieve_message_from_server(self, so):
    while True:
      buf = so.recv(256)
      if not buf:
        break 
      self.chat_transcript_area.insert('end',buf.decode('utf-8') + '\n')
      self.chat_transcript_area.yview(END)
    so.close()

  def send_chat(self):
    senders_name = self.name_widget.get().strip() + ":"
    data = self.enter_text_widget.get(1.0, 'end').strip()
    message = (senders_name + data).encode('utf-8')
    self.chat_transcript_area.insert('end', message.decode('utf-8') + '\n')
    self.chat_transcript_area.yview(END)
    self.client_socket.send(message)
    self.enter_text_widget.delete(1.0, 'end')
    return 'break'

这段代码与我们聊天服务器的代码非常相似。以下是代码的简要描述:

  • 我们首先使用 socket(AF_INET, SOCK_STREAM) 创建一个套接字

  • 我们随后使用 socket.connect() 将套接字连接到我们的聊天服务器的远程 IP 地址和端口。

  • 我们使用 socket.recv() 从服务器接收消息

  • 我们使用 socket.send() 向服务器发送消息

注意,当客户端尝试使用 socket.connect 方法连接到服务器时,操作系统将为客户端分配一个唯一但随机的端口号,以便在服务器返回消息时识别该客户端。

端口号从 01023 被称为知名端口、保留端口或系统端口。它们被操作系统用于提供广泛使用的网络服务。例如,端口 21 保留用于 FTP 服务,端口 80 保留用于 HTTP 服务,端口 22 保留用于 SSH 和 SFTP,端口 443 保留用于基于 TLS/SSL 的安全 HTTP 服务(HTTPS)。

操作系统分配给我们的客户端的随机端口是从高于系统保留端口的端口池中选择的。所有保留端口的列表可以在以下链接找到:

TCP 和 UDP 端口号列表.

完整的聊天客户端代码可以在9.10_chat_client.py中找到。聊天功能现在已启用,但请注意,我们尚未在ChatServer中编写从clients_list中移除用户的逻辑。这意味着即使你关闭了聊天窗口,聊天服务器仍然会尝试向已关闭的客户端发送聊天消息,因为我们尚未从服务器中移除该客户端。我们在此处不会实现它,但如果你希望实现这一功能,你可以轻松地覆盖窗口的close方法并向ChatServer发送一条消息以从客户端列表中删除该客户端。

那就结束了聊天应用项目。

创建一个电话簿应用程序

让我们现在构建一个简单的电话簿应用程序,允许用户存储姓名和电话号码。

本项目的学习目标主要涉及能够使用 Tkinter 与关系型数据库一起存储和操作记录。我们已看到一些使用序列化的基本对象持久化示例。关系型数据库通过关系代数的规则扩展这种持久化,将数据存储在表中。

Python 为多种数据库引擎提供了数据库接口。其中一些常用的数据库引擎包括 MySQL、SQLite、PostgreSQL、Oracle、Ingres、SAP DB、Informix、Sybase、Firebird、IBM DB2、Microsoft SQL Server 和 Microsoft Access。

我们将使用 SQLite 来存储我们的电话簿应用程序的数据。

SQLite 是一个无需服务器、无需配置、自包含的 SQL 数据库引擎,适用于开发嵌入式应用程序。SQLite 的源代码属于公共领域,这使得它在各种商业和非商业项目中都可以免费使用。

与许多其他 SQL 数据库不同,SQLite 不需要运行一个单独的服务器进程。相反,SQLite 将所有数据直接存储到存储在计算机磁盘上的平面文件中。这些文件在不同平台之间易于移植,使其成为满足较小和较简单数据库实施需求的一个非常受欢迎的选择。

Python 自带对 SQLite3 的支持标准库。然而,我们需要下载 SQLite3 命令行工具,这样我们才能使用命令行创建、修改和访问数据库。Windows、Linux 和 macOS 的命令行 shell 可以从sqlite.org/download.html下载。

按照网站上的说明,将 SQLite 命令行工具安装到您选择的任何位置。

让我们现在来实现我们的电话簿应用程序。应用程序将如下所示:

图片

该应用程序将演示数据库编程中涉及的一些常见操作。用户应能够使用此应用程序创建新记录、读取现有记录、更新现有记录以及从数据库中删除记录。这些活动共同构成了所谓的数据库上的CRUD(创建、读取、更新和删除)操作。

为了创建数据库,我们打开操作系统的命令行工具。在命令行中,我们首先导航到需要创建新数据库文件的目录。为了创建数据库,我们只需使用以下命令:

sqlite3 phonebook.db

这将在我们执行命令的文件夹中创建一个名为 phonebook.db 的数据库文件。同时,它还会显示类似于以下的消息:

SQLite version 3.7.17 2018-01-31 00:56:22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite>

我们已经创建了一个名为 phonebook.db 的数据库。然而,数据库文件目前是空的。它不包含任何表或任何数据。因此,如果我们运行此命令,将不会得到任何结果:

sqlite> .tables

目前,让我们通过输入以下命令来退出命令行工具:

sqlite> .exit

我们希望在数据库中存储联系人信息,因此我们将创建contacts表。我们的数据库表应存储一个人的姓名和电话号码。此外,为每个人或表中的每条记录保留一个唯一的识别号码是良好的实践。这是因为可能有多个人的姓名或联系号码相同。

在我们的phonebook.db数据库中创建一个表格时,我们再次打开命令行工具并导航到创建数据库的目录。我们再次通过输入以下命令进入 SQLite3 终端:

sqlite3 phonebook.db

这次,没有创建新的数据库。相反,现在该命令打开现有的phonebook.db数据库,因为它已经存在于磁盘上。

接下来,我们创建一个名为 contacts 的表,并从命令行向表中添加三个列:

sqlite> CREATE TABLE contacts
(
contactid INTEGER PRIMARY KEY AUTOINCREMENT,
name STRING NOT NULL,
contactnumber INTEGER NOT NULL
);

您可以通过输入以下命令来验证联系人表是否已创建:

sqlite>.table

这将打印出当前打开的数据库中所有表的名字。你将得到以下输出:

sqlite>.table
contacts

让我们先创建一个基本的 GUI,它将允许我们添加、查看、删除和修改记录。我们创建一个名为phoneBook的类,并在其中创建所有 GUI 小部件。

我们不讨论创建 GUI 的整个代码,因为我们过去已经编写过类似的结构。然而,我们使用了一个名为Treeview的新 ttk 小部件。创建Treeview的代码如下(9.11_phonebook.py):

def create_tree_view(self):
 self.tree = ttk.Treeview(height=5, columns=2)
 self.tree.grid(row=4, column=0, columnspan=2)
 self.tree.heading('#0', text='Name', anchor=W)
 self.tree.heading(2, text='Phone Number', anchor=W)

要向Treeview添加项目,我们使用以下代码:

self.tree.insert('', 0, text=row[1], values=row[2])

要获取Treeview中的所有项目,我们使用以下代码:

items = self.tree.get_children()

要从Treeview中删除项目,我们使用以下代码:

self.tree.delete(item)

接下来,让我们准备查询数据库的代码:

db_filename = 'phonebook.db'

def execute_db_query(self, query, parameters=()):
  with sqlite3.connect(self.db_filename) as conn:
    cursor = conn.cursor()
    query_result = cursor.execute(query, parameters)
    conn.commit()
  return query_result

代码描述如下:

  • 该方法建立了与我们之前创建的phonebook.db数据库的连接。

  • 下一行,cursor = conn.cursor() 创建了一个游标对象。游标是一个按照 SQL 标准要求的控制结构,它使我们能够遍历数据库中的记录。

  • 下一行,cursor.execute(query),将对数据库执行查询操作。

  • 这行代码 conn.commit() 实际上是将这些更改提交/保存到数据库中。

我们现在可以使用前面的方法在数据库上执行 CRUD 查询。

创建一个新的记录

每当用户在提供的输入小部件中输入新的姓名和电话号码,然后点击添加记录按钮时,都需要创建一个新的记录。

添加新记录的数据库查询如下:

query = 'INSERT INTO contacts VALUES(NULL,?, ?)'
parameters = (self.namefield.get(), self.numfield.get())
self.execute_db_query(query, parameters)

从数据库中读取

从数据库中读取所有记录的数据库查询如下:

query = 'SELECT * FROM contacts ORDER BY name desc'
phone_book_entries = self.execute_db_query(query)

前一个变量phone_book_entries包含数据库中所有记录的列表。

更新记录

要更新现有联系人的电话号码,我们使用以下代码:

query = 'UPDATE contacts SET contactnumber=? WHERE contactnumber=? AND name=?'
parameters = (newphone, old_phone_number, name)
self.execute_db_query(query, parameters)

删除记录

要删除现有联系人的电话号码,我们使用以下代码:

query = 'DELETE FROM contacts WHERE name = ?'
self.execute_db_query(query, (name,))

剩余的代码是支持性图形用户界面。请参阅9.11_phonebook.py以获取完整的代码。我们现在已经完成了基本电话簿应用的编码。

我们已经了解了如何创建数据库,向数据库中添加表,以及如何查询数据库来添加、修改、删除和查看数据库中的项目。我们的电话簿应用程序展示了如何在数据库上执行基本的 CRUD 操作。

此外,由于基本数据库操作相似,你现在可以考虑使用其他数据库系统,例如 MySQL、PostgreSQL、Oracle、Ingres、SAP DB、Informix、Sybase、Firebird、IBM DB2、Microsoft SQL Server 和 Microsoft Access。

使用 asyncio 与 Tkinter 结合

从 Python 3.4 版本开始,引入了一个名为 asyncio 的新模块,作为 Python 的标准模块。

术语 Asyncio 是由两个单词组合而成:async + I/O。Async 指的是并发,意味着一次做多件事。另一方面,I/O 指的是处理 I/O 绑定任务。一个 绑定任务 指的是让程序忙碌的事情。例如,如果你在进行计算密集型的数学处理,处理器会花费大部分时间——因此,这是一个 CPU 绑定任务。相反,如果你正在等待来自网络的响应、数据库的结果或用户的输入,那么这个任务就是 I/O 绑定

简而言之,asyncio模块提供了并发性,尤其是针对 I/O 密集型任务。并发性确保你不必等待 I/O 密集型任务的结果。

假设你需要从多个 URL 获取内容,然后处理获取到的内容以提取标题并在 Tkinter 窗口中显示。显然,你不能在运行 Tkinter 主循环的同一线程中获取内容,因为这会使根窗口在获取内容时变得无响应。

所以一个选择是为每个 URL 创建一个新的线程。虽然这可以是一个选择,但它并不是一个非常可扩展的选择,因为一次创建成千上万个线程会导致代码复杂性大大增加。 我们在当前章节的开头已经看到了一个竞态条件的演示(9.01_race_condition.py),其中运行多个线程会使控制共享状态变得困难。 此外,由于上下文切换是一项昂贵且耗时的操作,程序在仅创建几个线程后可能会变得缓慢。

正是这里,asyncio 出现来拯救我们。与依赖于线程的多线程相比,asyncio 使用了事件循环的概念。

为了演示,这里是一个 Tkinter 程序,点击按钮后模拟获取 10 个 URL:

from tkinter import Tk, Button
import asyncio
import threading
import random

def asyncio_thread(event_loop):
  print('The tasks of fetching multiple URLs begins')
  event_loop.run_until_complete(simulate_fetch_all_urls())

def execute_tasks_in_a_new_thread(event_loop):
  """ Button-Event-Handler starting the asyncio part. """
  threading.Thread(target=asyncio_thread, args=(event_loop, )).start()

async def simulate_fetch_one_url(url):
  """ We simulate fetching of URL by sleeping for a random time """
  seconds = random.randint(1, 8)
  await asyncio.sleep(seconds)
  return 'url: {}\t fetched in {} seconds'.format(url, seconds)

async def simulate_fetch_all_urls():
  """ Creating and starting 10 i/o bound tasks. """
  all_tasks = [simulate_fetch_one_url(url) for url in range(10)]
  completed, pending = await asyncio.wait(all_tasks)
  results = [task.result() for task in completed]
  print('\n'.join(results))

def check_if_button_freezed():
  print('This button is responsive even when a list of i/o tasks are in progress')

def main(event_loop):
  root = Tk()
  Button( master=root, text='Fetch All URLs',
        command=lambda: execute_tasks_in_a_new_thread(event_loop)).pack()
  Button(master=root, text='This will not Freeze',  
                      command=check_if_button_freezed).pack()
  root.mainloop()

if __name__ == '__main__':
  event_loop = asyncio.get_event_loop()
  main(event_loop)

这里是对代码(9.12_async_demo.py)的简要描述:

  • 使用 asyncio 模块的第一步是使用代码 event_loop = asyncio.get_event_loop() 构建一个事件循环。内部,这个 event_loop 将使用协程和未来来安排分配给它的所有任务,以异步方式执行 I/O 绑定任务。

  • 我们将这个event_loop作为参数传递给 Tkinter 根窗口,以便它可以使用这个事件循环来调度异步任务。

  • 负责执行 I/O 密集型任务的该方法通过在方法定义前添加关键字async来定义。本质上,任何需要从事件循环中执行的方法都必须添加关键字async

  • 该方法使用 await asyncio.sleep(sec) 模拟耗时 I/O 阻塞任务。在实际情况下,你可能使用它来获取 URL 的内容或执行类似的 I/O 阻塞任务。

  • 我们在一个新的线程中开始执行异步任务。这个单独的线程使用event_loop.run_until_complete(simulate_fetch_all_urls())命令来执行任务列表。请注意,这与为每个任务创建一个线程是不同的。在这种情况下,我们只创建一个线程来将其从 Tkinter 主循环中隔离出来。

  • 这行代码 all_tasks = [simulate_fetch_one_url(url) for url in range(10)] 将所有异步任务合并为一个列表。然后,这个包含所有 I/O 密集型任务的列表被传递给 completed, pending = await asyncio.wait(all_tasks),它以非阻塞方式等待所有任务完成。一旦所有任务完成,结果将被填充到 completed 变量中。

  • 我们通过results = [task.result() for task in completed]获取单个任务的成果。

  • 我们最终将所有结果打印到控制台。

使用 asyncio 的好处是,我们不需要为每个任务创建一个线程,因此代码也不必为每个单独的任务进行上下文切换。因此,使用 asyncio 我们可以扩展到检索数千个 URL,而不会减慢我们的程序,也不必担心单独管理每个线程的结果。

这就结束了我们关于使用asyncio模块与 Tkinter 结合的简要讨论。

与硬件/串行通信接口

物联网(IoT)现在正成为现实。我们在智能医疗设备、自动驾驶汽车、智能工厂和智能家居中看到了物联网的一瞥。大量此类物联网应用都是围绕使用传感器和执行器捕获数据这一理念构建的。

物联网(IoT)的兴起很大程度上归功于微控制器的普及,这使得测试和构建此类嵌入式系统的产品原型变得非常容易。微控制器是一种集成的设备,内置处理器和可编程内存。大多数典型的微控制器提供通用输入/输出引脚,这些引脚既可以用来接收来自传感器的数据,也可以根据上传到微控制器的某些程序发送数据。

在这个项目中,我们将使用最受欢迎的微控制器之一——Arduino Uno——来演示如何构建一个可以从外部设备读取数据的程序。我们将构建一个超声波测距仪。如果你对这个项目感兴趣,你可以购买硬件并自己搭建——这个项目的总成本不到五美元。然而,如果你不打算实施它,你只需阅读这一节即可。我们在这里的主要目标是展示如何使用所谓的串行通信将外部硬件的数据导入 Tkinter。

硬件

首先,我们需要一块 Arduino Uno 板(或任何其他 Arduino 板)。我们还需要一个超声波测距传感器。快速的网络搜索显示,价格低于四分之一的美元就有数百种测距传感器。我们使用的是名为 HC-SR04-Ultrasonic Range Finder 的传感器,但几乎任何其他传感器都可以。我们选择的传感器提供了 2 厘米至 300 厘米距离范围内的测距能力,精度高达 3 毫米。

这些传感器使用声纳来确定物体距离,就像海豚和蝙蝠一样。以下是传感器计算距离的方法。该模块有两个单元。发射器发射超声波,而接收器读取任何反射回来的超声波。由于超声波的速度是固定且已知的,通过计算发射和反射之间的时间,我们可以计算出反射超声波的物体距离。

这就是硬件的设置方式:

图片

在左侧是 Arduino Uno 板。超声波传感器位于右侧。如您所见,传感器有四个标记为 VCC、Trig、Echo 和 GND 的引脚。传感器的规格说明显示它需要 5 伏电压才能运行。因此,我们将 VCC 引脚连接到 Arduino 引脚上读取 5V 的引脚。同样,传感器的地线引脚(GND)连接到 Arduino 板上的 GND 引脚。现在传感器已通电。我们将 Trig 引脚连接到 Arduino 板上的 8 号引脚,将 Echo 引脚连接到 7 号引脚。每次我们在 8 号引脚上提供一个高脉冲时,传感器将触发超声波,然后 Echo 引脚将返回超声波反射所需的时间,我们将这个时间读取到 Arduino 的 7 号引脚上。

编写 Arduino 草图

在 Arduino 的世界里,您上传到微控制器的程序被称为草图。您可以使用以下链接下载的免费集成开发环境IDE)来编写这些草图:

Arduino 软件下载

一旦你完成了程序,你可以通过 IDE 上的上传按钮将其上传到你的 Arduino 板,然后就是:你的板子开始执行你所要求它做的事情。

每个 Arduino 草图都将包含两个方法,你可以在这里定义程序的逻辑:

  • setup(): 用于一次性初始化

  • loop(): 对于板子持续不断执行直到耗尽电力的操作

这里是我们上传到 Arduino 的代码(见9.13.arduino_sketch.ino):

const int triggerPin = 8;
const int echoBackPin = 7;

void setup() {
 Serial.begin(9600);
 pinMode(triggerPin, OUTPUT);
 pinMode(echoBackPin, INPUT);
}

void loop() {
  long duration, distanceIncm;
  // trigger ultrasound ping
  digitalWrite(triggerPin, LOW);
  delayMicroseconds(2);
  digitalWrite(triggerPin, HIGH);
  delayMicroseconds(5);
  digitalWrite(triggerPin, LOW);
  // receive input from the sensor
  duration = pulseIn(echoBackPin, HIGH);

  //calculate distance
  distanceIncm = duration / 29 / 2;

  // send data over serial port
  Serial.print(distanceIncm);
  Serial.println();
  delay(100);
}

代码描述如下:

  • 前两行表示我们将在 Arduino 板上使用引脚编号78,并将它们分配给变量名triggerPinechoBackPin

  • setup 函数初始化串行端口并将其波特率设置为 9600。波特率定义为每秒发生的信号变化次数。在用 Python 的 Tkinter 读取数据时,我们将使用相同的波特率。

  • 代码 pinMode(triggerPin, OUTPUT) 表示我们现在将使用引脚 8 向传感器发送输出脉冲。

  • 同样地,代码 pinMode(echoBackPin, INPUT); 声明我们将使用引脚 7 来接收传感器的输入。

  • 在循环内部,我们首先将引脚 triggerPin 设置为低脉冲。然后通过触发一个持续 2 微秒的高电压脉冲来触发传感器发射超声波。这会使传感器发射超声波持续 5 微秒。随后我们将引脚标记为 LOW 以停止触发超声波脉冲。

  • 我们随后使用duration = pulseIn(ioPin, HIGH)来计时在echoBackPin上接收到的信号。这给出了超声波反射回来的时间(以微秒为单位)。

  • 由于声速为 340 m/s 或每厘米 29 微秒,我们使用公式 distance = speed * time 来计算距离。但是,由于这是反射声波往返所需的时间,实际距离是这个值的一半。也许应该用 Python 来做这个数学计算?在这里使用long方法进行除法将得到一个整数,因此不会很精确。注意,我们也可以将这个计算从 Arduino 转移到我们的 Python 代码中,因为大多数 Arduino 处理器不支持硬件上的浮点数,在这样的有限处理器上通过软件进行计算可能会使其变得缓慢。

  • delay(100)确保前一行代码每100毫秒运行一次,发送超声波脉冲并测量传感器指向的物体的距离。

当这段代码上传到 Arduino 板后,它会在延迟 100 毫秒后开始发送5微秒的超声波脉冲。同时,它还会在每一个这样的循环中向你的电脑的串行端口发送一条消息。

现在是时候用 Python 来读取它,然后在 Tkinter 小部件中显示它了。

读取串行数据

我们将使用pyserial模块从串行端口读取数据。然而,这并不是一个标准的 Python 模块,需要安装。我们可以使用以下 pip 命令来安装它:

pip install pyserial

一旦我们能够从 Arduino 板上获取数据,我们就可以进一步处理它或以我们想要的方式绘制它。然而,这里的目的是简单地显示 Arduino 板通过串行端口发送的任何数据,如下面的 Tkinter 窗口所示(9.14_read_from_serial_port.py):

图片

为了读取串行端口,我们首先需要确定这条消息是通过哪个端口发送的。你可以通过两种方式来完成这个操作。

首先,您可以从 Arduino IDE 的“工具”菜单中找到端口的名称,如下所示:

图片

或者,您可以从命令行运行以下命令:

python -m serial.tools.list_ports

这将打印出所有活动串行端口的列表。一旦你手头有了端口名称,数据读取将使用以下代码完成:

from tkinter import Tk, Label
import serial

ser = serial.Serial()
ser.port = "/dev/ttyUSB0"
ser.baudrate = 9600
try:
 ser.open()
except serial.SerialException:
 print("Could not open serial port: " + ser.port)

root = Tk()
root.geometry('{}x{}'.format(200, 100))
label = Label(root, font=("Helvetica", 26))
label.pack(fill='both')

def read_serial_data():
 if ser.isOpen():
   try:
     response = ser.readline()
     label.config(text='Distance : \n' + response.decode("utf-8").rstrip() + ' cm')
   except serial.SerialException:
     print("no message received")

root.after(100, read_serial_data)
read_serial_data()
root.mainloop()

代码的描述如下:

  • 我们首先通过调用ser = serial.Serial()来获取Serial类的一个实例。然后我们指定端口号和波特率。这与我们在之前的 Arduino 代码中使用的波特率相同。

  • 我们通过调用 ser.open() 打开串行端口,并使用 ser.readline() 读取数据。

  • 剩余的代码是 Tkinter 特定的,用于创建 GUI 并在Label小部件中显示结果。

这部分和这一章到此结束。

在下一章中,我们将通过讨论你在编写 GUI 程序时可能遇到的各种问题来结束本书。

摘要

让我们总结一下本章讨论的概念。

我们学习了创建线程的潜在危险以及由此产生的竞态条件。

我们学习了如何使用队列数据结构来编写多线程应用程序,无需担心多个试图访问相同内存的线程之间的同步问题,也不需要使用复杂的同步原语。

天气预报应用向我们介绍了网络编程的基础以及如何接入互联网获取数据。我们讨论了两种用于数据交换的流行结构,即 XML 和 JSON。

端口扫描器和聊天程序讨论了进程间和远程通信的套接字编程基础。在我们的聊天程序中,我们使用了 TCP/IP 协议来发送和接收消息。我们还看到了客户端-服务器架构的基本示例。

我们看到了网络中所有形式的通信都是通过字节来实现的,以及我们如何将数据转换为字节,再将字节转换回所需格式的数据。

电话簿应用程序向我们展示了如何与数据库打交道。我们看到了如何在数据库上执行基本的 CRUD 操作。

接下来,我们了解了如何使用 asyncio 模块以非阻塞和可扩展的方式获取 I/O 绑定任务,无需担心一次性管理大量线程的状态。

最后,我们学习了如何通过串行通信与外部硬件接口,以收集传感器的数据。

QA 部分

这里有一些问题供您思考:

  • 什么是竞态条件?如何避免竞态条件?

  • 使用队列数据结构的优点有哪些?

  • 市面上最受欢迎的开源数据库有哪些?

  • 最常见的进程间通信模式有哪些?

  • 你会在什么情况下使用 asyncio 模块?

  • 使用串行通信有哪些优缺点?有哪些替代方案?

  • JSON 和 XML 文件格式用于什么?与使用数据库相比,它们有什么优点和缺点?

进一步阅读

我们使用 Python 代码在我们的数据库上执行基本的 CRUD 操作。值得注意的是,随着应用程序变得更大和更复杂,程序员应该考虑使用ORM(对象关系映射)库来代替直接的 CRUD 操作。更多关于 ORM 及其优势的信息,请参阅blogs.learnnowonline.com/2012/08/28/4-benefits-of-object-relational-mapping-orm/

我们在9.02_lock_demo.py中使用了线程锁作为同步原语。还有其他几种同步原语可以被用来替代。了解更多其他同步原语的信息,请访问www.usenix.org/legacy/publications/library/proceedings/bsdcon02/full_papers/baldwin/baldwin_html/node5.html.

Python PEP 3156

asyncio中的事件循环内部使用协程和未来来实现异步行为。学习如何使用协程和未来可以成为编写更高效和可扩展程序的有价值工具。

套接字通常用于进程间通信。然而,还有许多其他进程间通信的方法。简要阅读nptel.ac.in/courses/106108101/pdf/Lecture_Notes/Mod%207_LN.pdf是值得努力的。

第十章:杂项提示

我们已经到达了这本书的最后一章。让我们通过探讨一些概念来结束我们对 Tkinter 的讨论,尽管这些概念在许多图形用户界面GUI)程序中非常常见,但在前面的章节中并未出现。

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

  • 跟踪 Tkinter 变量并在变量值变化时附加回调函数

  • 理解默认键盘小部件遍历规则以提供一致的用户体验

  • 使用内置的 Tkinter 机制验证用户输入

  • 将小部件的内容格式化,以便用户与小部件交互

  • 理解 Tkinter 如何处理字体以及在 Tkinter 中使用自定义字体的最佳实践

  • 将命令行输出重定向到 Tkinter

  • 查看 Tkinter 的源代码以理解类层次结构

  • 突出一些当前在程序设计和实现中涉及的最佳实践

  • 深入了解代码清理和程序优化

  • 将 Tkinter 应用程序作为独立程序分发给最终用户

  • 理解 Tkinter 的局限性

  • 探索 Tkinter 的替代方案,了解何时使用它们代替 Tkinter 更为合适以及涉及到的权衡利弊

  • 将 Tkinter 程序回迁到较旧的 Python 2.x 版本中,这些程序是用 Python 3.x 版本编写的

让我们开始吧!

跟踪 Tkinter 变量

当你为小部件指定一个 Tkinter 变量,例如textvariabletextvariable = myvar),小部件会自动在变量值变化时更新。然而,有时除了更新小部件外,你还需要在读取或写入(或修改)变量时进行一些额外的处理。

Tkinter 提供了一种方法来附加一个回调方法,该方法将在每次访问变量的值时被触发。因此,回调函数充当变量观察者。

回调创建方法命名为 trace_variable(self, mode, callback) 或简称为 trace(self, mode, callback)

模式参数可以取值为 rwu,分别代表 读取写入未定义。根据模式指定,当变量被读取或写入时,回调方法会被触发。

默认情况下,回调方法接收三个参数。参数按照其位置顺序如下:

  • Tkinter 变量的名称

  • 当 Tkinter 变量是一个数组时,变量的索引,否则为空字符串

  • 访问模式(rwu

注意,触发的回调函数也可能修改变量的值。然而,这种修改不会触发额外的回调。

让我们来看一个 Tkinter 中变量追踪的例子。看看一个追踪的 Tkinter 变量的变化是如何触发回调函数的(参见代码 10.01_trace_variable.py):

from tkinter import Tk, Label, Entry, StringVar
root = Tk()
my_variable = StringVar()  

def trace_when_my_variable_written(var, indx, mode):
   print ("Traced variable {}".format(my_variable.get()))

my_variable.trace_variable("w", trace_when_my_variable_written)

Label(root, textvariable = my_variable).pack(padx=5, pady=5)
Entry(root, textvariable = my_variable).pack(padx=5, pady=5)

root.mainloop()

以下行代码将回调函数附加到trace变量:

my_variable.trace_variable("w", trace_when_my_variable_written)

现在,每次你在条目小部件中写入内容时,都会修改my_variable的值。因为我们已经在my_variable上设置了trace,它会触发回调方法,在我们的例子中,这个方法简单地将在控制台中打印新的值,如下面的截图所示:

图片

变量的trace(跟踪)在显式删除之前是活跃的。你可以使用以下命令来删除trace

trace_vdelete(self, mode, callback_to_be_deleted)

trace 方法返回回调方法的 ID 和名称。这可以用来获取需要删除的回调方法的名称。

小部件遍历

如果一个图形用户界面(GUI)包含多个小部件,当你明确地点击某个小部件时,该小部件可以获得焦点。或者,可以通过按键盘上的Tab键,按照小部件在程序中创建的顺序将焦点转移到其他小部件上。

因此,按照我们希望用户遍历它们的顺序创建小部件至关重要。否则,用户在使用键盘在各个小部件之间导航时将会遇到困难。

不同的控件被设计成对不同的键盘按键有不同的响应。因此,让我们花些时间来尝试理解使用键盘在控件之间导航的规则。

查看10.02_widget_traversal.py文件以了解不同小部件的键盘遍历行为。代码显示的窗口如下截图所示:

图片

代码将不会在此处给出,因为它非常简单(参见10.02_widget_traversal.py代码)。它仅仅添加了一个条目小部件、几个按钮、几个单选按钮、一个文本小部件、一个标签小部件和一个刻度小部件。

代码演示了这些小部件在 Tkinter 中的默认键盘遍历行为。

以下是一些你应该注意的重要要点:

  • 使用 Tab 键可以向前遍历,而 Shift + Tab 可以向后遍历。

  • 用户可以按照它们创建的顺序遍历小部件。首先访问父级小部件(除非使用takefocus = 0将其排除),然后是所有子小部件。

  • 您可以使用 widget.focus_force() 来强制将输入焦点置于一个部件上。

  • 您不能通过使用 Tab 键来遍历文本小部件,因为文本小部件可以包含作为其内容的制表符。相反,您可以通过使用 Ctrl + Tab 来遍历文本小部件。

  • 小部件上的按钮可以使用空格键进行按下。同样,复选框和单选按钮也可以使用空格键进行遍历。

  • 您可以通过使用上下箭头在列表框小部件中上下移动项目。

  • 比例控件对左右箭头键以及上下箭头键做出响应。同样,滚动条控件根据其方向对左右或上下箭头键做出响应。

  • 默认情况下,大多数小部件(除 Frame、Label 和菜单外)在它们获得焦点时都会有一个轮廓。这个轮廓通常显示为围绕小部件的细黑边框。您甚至可以通过将这些小部件的highlightthickness选项设置为非零整数来使 Frame 和 Label 小部件显示这个轮廓。

  • 我们可以通过在代码中使用highlightcolor= 'red'来改变轮廓的颜色。

  • 标签页的导航路径不包括框架、标签和菜单。然而,可以通过使用takefocus = 1选项将它们包含在导航路径中。您可以使用takefocus = 0选项显式地从标签页的导航路径中排除一个小部件。

验证用户输入

让我们讨论 Tkinter 中的输入数据验证。

我们在书中开发的大部分应用程序都是基于点选的(如鼓机、棋类游戏和绘图应用),在这些应用中不需要验证用户输入。然而,在如电话簿应用这样的程序中,用户输入一些数据,我们将这些数据存储在数据库中,数据验证是必须的。

忽略用户输入验证在此类应用中可能很危险,因为输入数据可能会被用于 SQL 注入。一般来说,允许用户输入文本数据的应用程序是验证用户输入的良好候选者。实际上,不信任用户输入几乎被视为一条准则。

错误的用户输入可能是故意的或偶然的。在任何情况下,如果你未能验证或清理数据,你的程序可能会出现意外的错误。在最坏的情况下,用户输入可能被用来注入有害代码,这些代码可能足以使程序崩溃或清除整个数据库。

小部件,例如列表框(Listbox)、组合框(Combobox)和单选按钮(Radiobuttons),允许有限的输入选项,因此它们通常不能被误用来输入错误的数据。另一方面,像输入框小部件(Entry widget)、旋转框小部件(Spinbox widget)和文本小部件(Text widget)这样的小部件允许用户有较大的输入可能性,因此它们需要被验证以确保正确性。

要在组件上启用验证,您需要向组件指定一个额外的validate = 'validationmode'表单选项。

例如,如果你想在 Entry 小部件上启用验证,你首先需要指定验证选项,如下所示:

Entry( root, validate="all", validatecommand=vcmd)

验证可以在以下验证模式之一中发生:

验证模式 说明
none 这是默认模式。如果将 validate 设置为 none,则不会发生验证。
focus validate设置为 focus 时,validate命令会被调用两次——一次是在小部件获得焦点时,另一次是在焦点丢失时。
focusin 当小部件获得焦点时调用validate命令。
focusout 当小部件失去焦点时调用validate命令。
key 当条目被编辑时,会调用validate命令。
all 在所有上述情况下都会调用 validate 命令。

10.03_validation_mode_demo.py 代码文件通过将它们附加到单个 validation 方法上来演示所有这些验证模式。在代码中,注意不同 Entry 小部件对不同事件的响应方式。一些 Entry 小部件在焦点事件上调用 validation 方法,其他小部件在将按键输入到小部件时调用 validation 方法,还有一些小部件则使用焦点和按键事件的组合。

尽管我们确实设置了验证模式以触发validate方法,但我们仍需要一些数据来与规则进行验证。这些数据通过百分比替换传递给validate方法。例如,我们将模式作为参数传递给

通过在validate命令上执行百分比替换来使用validate方法,如下命令所示:

vcmd = (self.root.register(self.validate_data), '%V')

这之后是将v的值作为参数传递给validate方法:

def validate_data(self, v)

除了 %V,Tkinter 还识别以下百分比替换:

百分比替换 解释
%d 在小部件上执行的操作类型(1 表示插入,0 表示删除,-1 表示焦点、强制或 textvariable 验证)。
%i 插入或删除的字符字符串的索引(如果有的话)。否则,它将是 -1
%P 如果允许编辑,条目中的值。如果您正在配置 Entry 小部件以具有新的 textvariable,这将是指定 textvariable 的值。
%s 编辑前的条目当前值。
%S 正在被插入或删除的文本字符串(如果有)。否则,{}
%v 当前已设置的验证类型。
%V 触发回调方法的验证类型(keyfocusinfocusout和强制)。
%W 条目小部件的名称。

这些替换值为我们提供了验证输入所需的数据。

让我们传递所有这些值,并通过一个虚拟的 validate 方法来打印它们,只是为了看看在执行验证时我们可以期望得到什么样的数据(参见 10.04_percent_substitutions_demo.py 代码):

class PercentSubstitutionsDemo():

  def __init__(self):
    self.root = tk.Tk()
    tk.Label(text='Type Something Below').pack()
    vcmd = (self.root.register(self.validate), '%d', '%i', '%P', '%s',
                                                '%S', '%v', '%V', '%W')
    tk.Entry(self.root, validate="all", validatecommand=vcmd).pack()
    self.root.mainloop()

  def validate(self, d, i, P, s, S, v, V, W):
    print("Following Data is received for running our validation checks:")
    print("d:{}".format(d))
    print("i:{}".format(i))
    print("P:{}".format(P))
    print("s:{}".format(s))
    print("S:{}".format(S))
    print("v:{}".format(v))
    print("V:{}".format(V))
    print("W:{}".format(W))
    # returning true for now 
    # in actual validation you return true if data is valid 
    # else return false
    return True

注意我们通过传递所有可能的百分比替换到回调函数中注册的validate方法所在的行。

特别注意由 %P%s 返回的数据,因为它们与用户在输入小部件中实际输入的数据相关。在大多数情况下,您将检查这两个数据源中的任何一个,以验证规则。

现在我们已经了解了数据验证的规则背景,让我们看看两个演示输入验证的实际例子。

关键验证模式演示

假设我们有一个要求输入用户名的表单。我们希望用户在输入名字时只使用字母或空格字符。因此,不应允许使用一些特殊字符,如下面的小部件截图所示:

图片

这显然是一个需要使用key验证模式的案例,因为我们希望在每次按键后检查一个条目是否有效。我们需要检查的百分比替换是%S,因为它会返回插入或删除的文本字符串。

Entry 小部件。因此,验证 Entry 小部件的代码如下(参见10.05_key_validation.py代码):

import tkinter as tk

class KeyValidationDemo():
  def __init__(self):
    root = tk.Tk()
    tk.Label(root, text='Enter your name / only alpabets & space 
      allowed').pack()
    vcmd = (root.register(self.validate_data), '%S')
    invcmd = (root.register(self.invalid_name), '%S')
    tk.Entry(root, validate="key",validatecommand=vcmd, 
               invalidcommand=invcmd).pack(pady=5, padx=5)
    self.error_message = tk.Label(root, text='', fg='red')
    self.error_message.pack()
    root.mainloop()

  def validate_data(self, S):
    self.error_message.config(text='')
    return (S.isalpha() or S == ' ')

  def invalid_name(self, S):
    self.error_message.config(text='Invalid character %s \n 
                     name can only have alphabets and spaces' % S)
    app = KeyValidationDemo()

前述代码的描述如下:

  • 我们首先注册两个选项,即validatecommand ( vcmd )invalidcommand ( invcmd )

  • 在示例中,validatecommand 被注册为调用 validate_data 方法,而 invalidcommand 选项被注册为调用另一个名为 invalid_name 的方法。

  • validatecommand选项指定了一个需要评估的方法,该方法将验证输入。验证方法必须返回一个布尔值,其中True表示输入的数据有效,而False返回值表示数据无效。

  • 如果验证方法返回 False(无效数据),则不会将数据添加到条目小部件中,并且会评估为 invalidcommand 注册的脚本。在我们的情况下,无效验证将调用 invalid_name 方法。invalidcommand 方法通常负责显示错误消息或将焦点设置回条目小部件。

离焦验证模式演示

之前的示例演示了在key模式下的验证。这意味着验证方法在每次按键后都会被调用,以检查输入是否有效。

然而,有些情况下你可能想要检查整个输入到小部件中的字符串,而不是检查单个按键输入。

例如,当一个条目小部件接受一个有效的电子邮件地址时,我们理想情况下希望在用户输入完整电子邮件地址之后而不是在每次按键输入之后检查其有效性。这将适用于focusout模式的验证。

查看10.06_focus_out_validation.py以了解在focusout模式下的电子邮件验证演示,该模式为我们提供了以下 GUI:

图片

上述演示的代码如下:

import tkinter as tk
import re

class FocusOutValidationDemo():
  def __init__(self):
    self.master = tk.Tk()
    self.error_message = tk.Label(text='', fg='red') 
    self.error_message.pack()
    tk.Label(text='Enter Email Address').pack()
    vcmd = (self.master.register(self.validate_email), '%P')
    invcmd = (self.master.register(self.invalid_email), '%P')
    self.email_entry = tk.Entry(self.master, validate="focusout", 
              validatecommand=vcmd, invalidcommand=invcmd)
    self.email_entry.pack()
    tk.Button(self.master, text="Login").pack()
    tk.mainloop()

  def validate_email(self, P):
    self.error_message.config(text='')
    x = re.match(r"[^@]+@[^@]+\.[^@]+", P)
    return (x != None)

  def invalid_email(self, P):
    self.error_message.config(text='Invalid Email Address')
    self.email_entry.focus_set()

app = FocusOutValidationDemo()

这段代码与之前的验证示例有很多相似之处。然而,请注意以下差异:

  • 验证模式设置为focusout,与上一个示例中的key模式相对比。这意味着验证仅在 Entry 小部件失去焦点时进行。验证发生在你按下Tab键时。因此,如果输入无效,输入框不会失去焦点。

  • 此程序使用由 %P 百分比替换提供的数据,而先前的示例使用了 %S。这是可以理解的,因为 %P 提供了在输入小部件中输入的值,但 %S 提供了最后按键的值。

  • 此程序使用正则表达式来检查输入的值是否对应有效的电子邮件格式。验证通常依赖于正则表达式。要涵盖这个主题需要大量的解释,但这超出了本书的范围。有关正则表达式模块的更多信息,请访问docs.python.org/3.6/library/re.html

这就结束了我们对 Tkinter 中输入验证的讨论。希望你现在应该能够根据你的自定义需求实现输入验证。

格式化小部件数据

输入数据,如日期、时间、电话号码、信用卡号码、网站 URL 和 IP 地址,都有相应的显示格式。例如,日期可以更好地以MM/DD/YYYY格式表示。

幸运的是,当用户在组件中输入数据时,格式化所需格式的数据很容易,如下面的截图所示:

图片

10.07_formatting_entry_widget_to_display_date.py 代码自动格式化用户输入,在需要的位置插入正斜杠,以显示用户输入的日期,格式为 MM/DD/YYYY

from tkinter import Tk, Entry, Label, StringVar, INSERT

class FormatEntryWidgetDemo:

  def __init__(self, root):
    Label(root, text='Date(MM/DD/YYYY)').pack()
    self.entered_date = StringVar()
    self.date_entry = Entry(textvariable=self.entered_date)
    self.date_entry.pack(padx=5, pady=5)  
    self.date_entry.focus_set()
    self.slash_positions = [2, 5]
    root.bind('<Key>', self.format_date_entry_widget)

  def format_date_entry_widget(self, event):
    entry_list = [c for c in self.entered_date.get() if c !='/']
    for pos in self.slash_positions:
      if len(entry_list) > pos:
        entry_list.insert(pos, '/')
    self.entered_date.set(''.join(entry_list))
    # Controlling cursor
    cursor_position = self.date_entry.index(INSERT) # current cursor 
      position
    for pos in self.slash_positions:
      if cursor_position == (pos + 1): # if cursor position is on slash
        cursor_position += 1
    if event.keysym not in ['BackSpace', 'Right', 'Left','Up', 'Down']:
      self.date_entry.icursor(cursor_position)

root = Tk()
FormatEntryWidgetDemo(root)
root.mainloop()

前述代码的描述如下:

  • Entry 小部件绑定到按键事件,每次新的按键都会调用相关的format_date_entry_widget回调方法。

  • 首先,format_date_entry_widget 方法将输入的文本分解成一个名为 entry_list 的等效列表,并忽略用户可能输入的斜杠/符号。

  • 然后它遍历self.slash_positions列表,并在entry_list中所有必需的位置插入斜杠符号。这个操作的结果是一个在所有正确位置都插入了斜杠的列表。

  • 下一行使用 join() 将此列表转换为等效的字符串,然后将 Entry 小部件的值设置为该字符串。这确保了 Entry 小部件的文本格式化为上述日期格式。

  • 剩余的代码片段仅用于控制光标,确保光标在遇到斜杠符号时前进一个位置。它还确保了诸如退格、右键、左键、上键和下键等按键操作得到正确处理。

注意,此方法并不验证日期值,用户可能会添加一个无效的日期。这里定义的方法将简单地通过在第三位和第六位添加正斜杠来格式化它。将日期验证添加到此示例作为一项练习留给你来完成。

这就结束了我们在小部件内格式化数据的简要讨论。希望你现在能够创建适用于各种输入数据的格式化小部件,以便在特定格式中更好地显示。

更多关于字体

许多 Tkinter 小部件允许你在创建小部件时或稍后通过使用 configure() 选项来指定自定义字体规范。在大多数情况下,默认字体提供了标准的视觉和感觉。然而,如果你想更改字体规范,Tkinter 允许你这样做。但有一个注意事项。

当你指定自己的字体时,你需要确保它在你的程序打算部署的所有平台上看起来都很好,因为某个平台上字体可能看起来不错,但在另一个平台上可能看起来很糟糕。除非你清楚自己在做什么,否则始终建议坚持使用 Tkinter 的默认字体。

大多数平台都有自己的标准字体集,这些字体由平台的本地小部件使用。因此,与其试图在特定平台上重新发明轮子,或者为特定平台提供什么字体可用,Tkinter 将这些标准平台特定字体分配给其小部件,从而在每一个平台上提供原生外观和感觉。

Tkinter 将九种字体分配给九个不同的名称;你可以在你的程序中使用这些字体。字体名称如下:

  • TkDefaultFont

  • TkTextFont

  • TkFixedFont

  • TkMenuFont

  • TkHeadingFont

  • TkCaptionFont

  • TkSmallCaptionFont

  • TkIconFont

  • TkTooltipFont

因此,你可以在你的程序中以以下方式使用它们:

Label(text="Sale Up to 50% Off !", font="TkHeadingFont 20")
Label(text="**Conditions Apply", font="TkSmallCaptionFont 8") 

使用这些字体标记,你可以放心,你的字体将在所有平台上看起来都很原生。

对字体有更精细的控制

除了上述处理字体的方法外,Tkinter 还提供了一个独立的 Font 类实现。这个类的源代码位于与 Tkinter 源代码相同的文件夹中。

在我的 Linux 机器上,源代码位于 /usr/local/lib/python3.6/tkinter/font.py。在 Windows(默认安装 Python 3.6)的情况下,位置是 C:\Program Files (x86)\Python36-32\Lib\tkinter\font.py

要使用此模块,您需要将字体导入到您的命名空间中,如下所示(参见10.08_font_demo.py代码):

from tkinter import Tk, Label, Pack
from tkinter import font
root = Tk()
label = Label(root, text="Humpty Dumpty was pushed")
label.pack()
current_font = font.Font(font=label['font'])
print ('Actual :', str(current_font.actual()))
print ('Family : ', current_font.cget("family"))
print ('Weight : ', current_font.cget("weight"))
print ('Text width of Dumpty : {}'.format(current_font.measure("Dumpty")))
print ('Metrics:', str(current_font.metrics()))
current_font.config(size=14)
label.config(font=current_font)
print ('New Actual :', str(current_font.actual()))
root.mainloop()

这个程序在我的终端上的控制台输出如下:

Actual: {'slant': 'roman', 'underline': 0, 'family': 'DejaVu Sans', 'weight': 'normal', 'size': -12, 'overstrike': 0}
Family: DejaVu Sans
Weight: normal
Text width of Dumpty: 49
Metrics: {'fixed': 0, 'descent': 3, 'ascent': 12, 'linespace':15}
New actual: {'slant': 'roman', 'underline': 0, 'family': 'DejaVu Sans', 'weight': 'normal', 'size': 14, 'overstrike': 0}

正如您所看到的,font模块提供了对字体各个方面的更精细的控制,这些方面在其他情况下是无法访问的。

构建字体选择器

现在我们已经看到了 Tkinter 的 font 模块中可用的基本功能,接下来让我们实现一个类似于以下截图所示的字体选择器:

图片

构建前一个屏幕截图所示字体选择器的关键是获取系统上安装的所有字体的列表。从font模块调用families()方法可以获取系统上所有可用的字体的元组。因此,当你运行以下代码时,系统上所有可用的字体的元组将被打印出来(请参阅10.09_all_fonts_on_a_system.py代码):

from tkinter import Tk, font
root = Tk()
all_fonts = font.families()
print(all_fonts) # this prints the tuple containing all fonts on a system.

注意,由于font是 Tkinter 的一个子模块,在它能够获取元组之前,需要有一个Tk()实例,该实例加载了 Tcl 解释器。

现在我们已经拥有了系统上所有可用的字体元组,我们只需要创建前面截图所示的 GUI,并将相关的回调函数附加到所有小部件上。

我们将不会讨论创建前面截图所示 GUI 的代码。请查看10.10_font_selector.py以获取完整的代码。然而,请注意,该代码将以下回调附加到所有小部件上:

def on_value_change(self, event=None):
  self.current_font.config(family=self.family.get(), size=self.size.get(),    
           weight=self.weight.get(), slant=self.slant.get(),  
           underline=self.underline.get(),  
           overstrike=self.overstrike.get())
  self.text.tag_config('fontspecs', font=self.current_font)

在这里,fontspecs 是我们附加到文本小部件中示例文本上的自定义标签,如下所示:

self.text.insert(INSERT, '{}\n{}'.format(self.sample_text,  
                          self.sample_text.upper()), 'fontspecs')

这就结束了我们在 Tkinter 中玩转字体的简短讨论。

将命令行输出重定向到 Tkinter

你可能偶尔需要将命令行的输出重定向到图形用户界面,例如 Tkinter。将命令行的输出传递到 Tkinter 的能力,为在 Unix 和 Linux 操作系统以及 Windows 机器上的 Windows Shell 使用 shell 的固有功能打开了一个广阔的可能性池。

我们将通过使用 Python 的 subprocess 模块来演示这一点,该模块允许我们启动新的进程,连接到新进程的输入、输出和错误管道,并从程序中获取返回代码。

关于subprocess模块的详细讨论可以在docs.python.org/3/library/subprocess.html找到。

我们将使用来自subprocess模块的Popen类来创建一个新的进程。

Popen 类提供了一种跨平台创建新进程的方法,并且具有以下长签名来处理大多数常见和特殊的使用场景:

subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0, restore_signals=True, start_new_session=False, pass_fds=())

这是一个简单的程序,展示了我们如何将 ls Bash 命令的输出重定向到 Tkinter 的文本小部件。作为提醒,Bash 脚本语言中的 ls 命令返回所有文件和目录的列表(参见 10.11_reading_from_command_line.py 代码):

from tkinter import Tk, Text, END
from subprocess import Popen, PIPE
root = Tk()
text = Text(root)
text.pack()

#replace "ls" with "dir" in the next line on windows platform
with Popen(["ls"], stdout=PIPE, bufsize=1, universal_newlines=True) as p:
   for line in p.stdout:
      text.insert(END, line)

root.mainloop()

Windows 用户请注意,您需要在前面代码的高亮部分将ls替换为dir以获得等效的结果。

此外,请注意,您可以通过以下格式向Popen传递额外的参数:

Popen(['your command', arg0, arg1, ...])

更好的是,你可以在新进程中传递需要执行脚本的文件名。运行脚本文件的代码如下:

Popen('path/toexecutable/script',stdout=sub.PIPE,stderr=sub.PIPE)

然而,需要执行的脚本文件必须包含一个适当的 shebang 声明,以便程序为您的脚本选择一个合适的执行环境。例如,如果您打算运行 Python 脚本,您的脚本必须以#!/usr/bin/env python3形式的 shebang 开始。同样,您需要包含#!/bin/sh来运行与 Bourne 兼容的 shell 脚本。在 Windows 上不需要 shebang。二进制可执行文件也不需要。

运行前面的程序会弹出一个窗口,并将当前目录下所有文件的列表添加到文本小部件中,如下面的截图所示:

图片

虽然前面的程序很简单,但这种技术有很多实际用途。例如,你可能记得我们在上一章中构建了一个聊天服务器。每次有新的客户端连接到服务器时,它都会将客户端详细信息打印到终端。我们本可以将那个输出重定向到一个新的 Tkinter 应用程序。这将使我们能够为服务器创建一个仪表板;从那里,我们可以监控所有连接到服务器的传入连接。

这为我们打开了重用任何用其他编程语言(如 Perl 或 Bash)编写的命令行脚本的大门,并直接将其集成到 Tkinter 程序中。

这就结束了关于将命令行输出重定向到 Tkinter 程序的简要部分。

Tkinter 的类层次结构

作为程序员,我们几乎不需要理解 Tkinter 的类层次结构。毕竟,到目前为止,我们能够在不关心整体类层次结构的情况下编写所有应用程序。然而,了解类层次结构使我们能够追踪方法在源代码或方法源文档中的起源。

为了理解 Tkinter 的类层次结构,让我们来看看 Tkinter 的源代码。在 Windows 安装中,Tkinter 的源代码位于 path\of\Python\Installation\Lib\tkinter\。在我的 Linux 机器上,

源代码位于 /usr/lib/python3.6/tkinter/

如果你在一个代码编辑器中打开这个文件夹中的 __init__.py 文件,并查看其 Tkinter 中的类定义列表,你会看到以下结构:

图片

那么,你在这里能看到什么?我们为每个核心 Tkinter 小部件提供了类定义。

此外,我们还有为不同的几何管理器和 Tkinter 内部定义的变量类型提供的类定义。这些类定义正是您通常期望存在的。

然而,除了这些之外,你还会看到一些更多的类名,例如BaseWidget, Misc, Tk, Toplevel, Widget, 和 Wm。所有这些类都在前面的截图中被圈出。那么,这些类提供了哪些服务,它们在更大的体系结构中又处于什么位置呢?

让我们使用inspect模块来查看 Tkinter 的类层次结构。我们将首先检查 Frame 小部件的类层次结构,以此代表所有其他小部件的类层次结构。我们还将查看

TkToplevel 类在 Tkinter 的整体类层次结构中的作用(10.12_tkinter_class_hierarchy.py):

import tkinter
import inspect

print ('Class Hierarchy for Frame Widget')

for i, classname in enumerate(inspect.getmro(tkinter.Frame)):
  print( '\t{}: {}'.format(i, classname))

print ('Class Hierarchy for Toplevel')
for i, classname in enumerate(inspect.getmro(tkinter.Toplevel)):
  print ('\t{}:{}'.format(i, classname))

print ('Class Hierarchy for Tk')
for i, classname in enumerate(inspect.getmro(tkinter.Tk)):
  print ('\t{}: {}'.format(i, classname))

前一个程序的输出如下:

Class Hierarchy for Frame Widget
 0: <class 'tkinter.Frame'>
 1: <class 'tkinter.Widget'>
 2: <class 'tkinter.BaseWidget'>
 3: <class 'tkinter.Misc'>
 4: <class 'tkinter.Pack'>
 5: <class 'tkinter.Place'>
 6: <class 'tkinter.Grid'>
 7: <class 'object'>
Class Hierarchy for Toplevel
 0:<class 'tkinter.Toplevel'>
 1:<class 'tkinter.BaseWidget'>
 2:<class 'tkinter.Misc'>
 3:<class 'tkinter.Wm'>
 4:<class 'object'>
Class Hierarchy for Tk
 0: <class 'tkinter.Tk'>
 1: <class 'tkinter.Misc'>
 2: <class 'tkinter.Wm'>
 3: <class 'object'>

前述代码的描述如下:

  • inspect模块中的getmro(classname)函数返回一个元组,其中包含classname的所有祖先,按照方法解析顺序MRO)指定的顺序排列。MRO 指的是基类被调用的顺序。

    在查找给定方法时,会搜索类。

  • 通过检查 MRO(方法解析顺序)和源代码,你会了解到Frame类继承自Widget类,而Widget类又继承自BaseWidget类。

  • Widget 类是一个空的类,其类定义如下:class Widget(BaseWidget, Pack, Place, Grid)

  • 正如您所看到的,这就是在几何管理器中定义的方法(包括 pack、place 和 grid 混合器)如何对所有小部件可用。

  • BaseWidget 类具有以下类定义:class BaseWidget(Misc)。此类公开了可以被程序员使用的销毁方法。

  • 在这个层级中,Misc 类中定义的所有实用方法都对小部件可用。

  • Misc 类是一个通用的混入类,它提供了我们在应用程序中大量使用到的功能。我们程序中使用的部分方法,如 Misc 类中定义的,包括 after(), bbox(), bind_all(), bind_tag(), focus_set(), mainloop(), update(), update_idletask(), 和 winfo_children()。要获取 Misc 类提供的完整功能列表,请在 Python 交互式外壳中运行以下命令:

>>> import tkinter
>>> help(tkinter.Misc)

现在,让我们来看看TkToplevel类:

  • Tk 类在屏幕上返回一个新的 Toplevel 小部件。Tk 类的 __init__ 方法通过调用名为 loadtk() 的方法来负责创建一个新的 Tcl 解释器。该类定义了一个名为 report_callback_exception() 的方法,该方法负责在 sys.stderr 上报告错误和异常。

  • Tkinter 的 Toplevel 类的 __init__ 方法负责创建应用程序的主窗口。该类的构造函数接受各种可选参数,例如 bgbackgroundbdborderwidthclassheighthighlightbackgroundhighlightcolorhighlightthicknessmenurelief

  • 要获取ToplevelTk类提供的所有方法列表,请在 Python 交互式 shell 中运行以下命令:help(tkinter.Toplevel); help(tkinter.Tk)

  • 除了继承自Misc混合类之外,ToplevelTk类还继承自Wm混合类的方法。

  • Wm(代表窗口管理器)混合类提供了许多方法,使我们能够与窗口管理器进行通信。这个类中一些常用的方法包括 wm_iconifywm_deiconifywm_overrideredirecttitlewm_withdrawwm_transientwm_resizable。要获取 Wm 类提供的所有函数的完整列表,请在 Python 交互式外壳中运行以下命令:help(tkinter.Wm)

在将类层次结构翻译后,从上一个程序中获得并通过检查源代码,我们得到了 Tkinter 的层次结构,如下所示:

图片

除了正常的继承关系,这在前面图表中通过不间断的线条展示,Tkinter 还提供了一系列的混入(或辅助类)。

mixin 是一个设计用来不直接使用,而是通过多重继承与其他类结合的类。

Tkinter 混合类可以大致分为以下两类:

  • 几何混合类:这些包括GridPackPlace

  • 实现混入(mixins):这些包括以下类:

    • Misc 类,由根窗口和 widget 类使用,提供了一些 Tk 和窗口相关的服务

    • Wm 类,该类被根窗口和 Toplevel 小部件使用,提供了一些窗口管理服务

这就结束了我们对 Tkinter 的简要内部探索。希望这能让你对 Tkinter 的内部工作原理有所了解。如果你对任何给定方法的文档有任何疑问,可以直接查看该方法的实际实现。

程序设计技巧

让我们来看看一些通用的程序设计技巧。

模型优先策略与代码优先策略

一个设计良好的模型就是完成了一半的工作。话虽如此,当你开始编写程序时,模型有时并不十分明显。在这种情况下,你可以打破规则,尝试先编写代码的哲学。其思路是从零开始逐步构建你的程序,随着你对程序愿景的逐渐清晰,重构你的代码和模型。

将模型与视图分离

将模型或数据结构从视图中分离出来是构建可扩展应用的关键。虽然将这两个组件混合使用是可能的,但你很快会发现你的程序变得杂乱无章,难以维护。

选择合适的数据结构

选择合适的数据结构可以对程序的性能产生深远影响。如果你的程序需要你花费大量时间进行查找,如果可行的话,请使用字典。当你只需要遍历一个集合时,相比于字典,更倾向于使用列表,因为字典占用的空间更大。当你的数据是不可变时,更倾向于使用元组而不是列表,因为元组比列表遍历得更快。

命名变量和方法

为你的变量和方法使用有意义的、自我说明的名称。名称应该不留下任何关于变量或方法意图的混淆。对于集合使用复数名称,否则使用单数名称。返回布尔值的方法应该附加诸如 ishas 这样的词语。坚持风格指南,但你也应该知道何时打破它们。

单一职责原则

单一职责原则建议一个函数/类/方法应该只做一件事,并且应该把它做到极致,做好。这意味着我们不应该试图在函数内部处理多件事情。

松散耦合

尽可能地减少程序中的耦合或依赖。以下是这个主题的一个著名引言:

计算机科学中的所有问题都可以通过另一层间接方式来解决。

– 戴维·惠勒

假设你的程序有一个播放按钮。一个直接的冲动可能是将它链接到程序中的play方法。然而,你可以进一步将其拆分为两个方法。你可能将播放按钮链接到一个名为on_play_button_clicked的方法,该方法随后调用实际的play方法。这种做法的优势在于,你可能希望在点击播放按钮时处理额外的事情,例如在程序中的某个位置显示当前曲目信息。

因此,你现在可以使用on_play_button_clicked方法将点击事件从实际播放方法中解耦,然后处理对多个方法的调用。

然而,你必须抵制添加过多间接层的诱惑,因为你的程序可能会迅速变得混乱,并且可能失去控制。

处理错误和异常

Python 遵循EAFP(即更容易请求原谅而不是请求许可)的编码风格,这与大多数编程语言遵循的LBYL(即三思而后行)风格相反。

因此,在 Python 中,以类似于以下方式处理异常通常比使用 if-then 块检查条件更简洁。

所以当用 Python 编写代码时,而不是使用以下这种编码风格:

if some_thing_wrong:
  do_something_else()
else:
  do_something_normal()

考虑使用这个代替:

try:
  do_some_thing_normal()
except some_thing_wrong:
  do_some_thing_else()

处理跨平台差异

尽管 Tkinter 是一个跨平台模块,但你可能会遇到这样的情况:为某个操作系统编写的代码在其他操作系统上可能无法按预期工作。我们已经在之前的“将命令行输出重定向到 Tkinter”的例子中看到了这样一个例子。在这种情况下,你可以通过首先识别程序正在运行的操作系统,然后使用条件语句为不同的操作系统运行不同的代码行来克服这些跨平台差异。

这里是一个简短的片段,展示了这个概念:

 from platform import uname
 operating_system = uname()[0]
 if ( operating_system == "Linux" ):
   canvas.bind('<Button-4>', wheelUp) # X11
   canvas.bind('<Button-5>', wheelDown)
 elif ( operating_system == "Darwin" ):
   canvas.bind('<MouseWheel>', wheel) # MacOS
 else: 
   canvas.bind_all('<MouseWheel>', wheel) # windows

这里特定的问题是,在 Windows 和 macOS 上,鼠标滚轮事件由 <MouseWheel> 事件名称表示,但在 Linux 发行版上则是 <Button-4><Button-5>。前面的代码使用 Python 的平台模块来识别操作系统,并为不同的操作系统遵循不同的代码行。

程序优化技巧

接下来,让我们看看一些通用的优化程序的建议。

使用过滤器和映射

Python 提供了两个内置函数,名为filtermap,可以直接操作集合,而不是必须遍历集合中的每个项目。filtermapreduce函数比循环更快,因为大部分工作都是由用 C 语言编写的底层代码完成的。

filter(function, list) 函数返回一个列表(Python 3.x 中的迭代器),其中包含所有函数返回真值的项目。以下命令是一个示例:

print filter(lambda num: num>6, range(1,10))# prints [7, 8,9]

这比在列表上运行条件 if-then 检查要快。

map(function_name, list) 函数将 function_name 应用到列表中的每个项目上,并返回一个新列表中的值(在 Python 3.x 中返回迭代器而不是列表)。以下命令是一个示例:

print map(lambda num: num+5, range(1,5)) #prints [6, 7, 8,9]

这比通过循环遍历列表并将每个元素加5要快。

优化变量

你在程序中选择变量的方式可以显著影响程序执行的速度。例如,如果你在实例化小部件后不需要更改其内容或属性,那么不要创建一个类级别的实例。

例如,如果需要使标签小部件保持静态,请使用Label(root, text='Name').pack(side=LEFT)而不是使用以下代码片段:

self.mylabel = Label(root, text='Name')
self.mylabel.pack(side=LEFT)

同样,如果你不打算多次使用它们,请不要创建局部变量。例如,使用 mylabel.config (text= event.keysym) 而不是首先创建一个名为 key 的局部变量然后只使用一次:

key = event.keysym
mylabel.config (text=key)

如果局部变量需要被多次使用,那么创建一个局部变量可能是有意义的。

分析你的程序

性能分析涉及生成详细的统计数据,以显示程序中各种例程执行的频率和持续时间。这有助于你隔离程序中的问题部分,而这些部分可能需要重新设计。

Python 提供了一个名为cProfile的内置模块,该模块能够生成与程序相关的详细统计信息。该模块提供了诸如总程序运行时间、每个函数的运行时间以及每个函数被调用的次数等详细信息。这些统计信息使得确定需要优化的代码部分变得容易。

特别是,cProfile为函数或脚本提供以下数据:

  • ncalls: 这表示一个函数被调用的次数

  • tottime: 这表示一个函数所花费的时间,不包括调用其他函数所花费的时间

  • percall: 这是指 tottime 除以 ncalls

  • cumtime: 这表示一个函数所花费的时间,包括调用其他函数的时间

  • percall: 这是指 cumtime 除以 tottime

要分析名为 spam() 的函数,请使用以下代码:

import cProfile
cProfile.run('spam()','spam.profile')

您可以使用另一个名为 pstats 的模块来查看配置文件的结果,具体操作如下:

import pstats
stats = pstats.Stats('spam.profile')
stats.strip_dirs().sort_stats('time').print_stats()

更重要的是,您可以分析整个脚本。假设您想分析一个名为 myscript.py 的脚本。您只需使用命令行工具导航到该脚本的目录,然后输入并运行以下命令:

python -m cProfile myscript.py

8.08_vornoi_diagram代码上运行前面命令的部分输出如下,来自第八章,《在画布上玩转》:

57607465 function calls (57607420 primitive calls) in 110.040 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
 1 50.100 50.100 95.452 95.452 8.09_vornoi_diagram.py:15(create_voronoi_diagram)
 1 0.000 0.000 110.040 110.040 8.09_vornoi_diagram.py:5(<module>)
 400125 2.423 0.000 14.616 0.000 __init__.py:2313(_create)
 400125 0.661 0.000 15.277 0.000 __init__.py:2342(create_rectangle)
 400128 1.849 0.000 2.743 0.000 __init__.py:95(_cnfmerge)
 625 0.001 0.000 0.003 0.000 random.py:170(randrange)
 625 0.002 0.000 0.002 0.000 random.py:220(_randbelow)
 50400000 30.072 0.000 30.072 0.000 {built-in method math.hypot}
 1 14.202 14.202 14.358 14.358 {method 'mainloop' of '_tkinter.tkapp' objects}

我特意选择分析这个程序,因为它执行时间很长。在这种情况下,它运行了大约 110 秒,大部分时间都花在了运行create_vornoi_diagram函数上(大约 95 秒)。所以现在这个函数是优化完美的候选者。

除了cProfile模块之外,还有其他模块,例如PyCallGraphobjgraph,它们为配置数据提供可视化的图表。

其他优化技巧

优化是一个广泛的话题,有很多事情可以做。如果你对代码优化感兴趣,可以开始阅读官方的 Python 优化技巧,这些技巧可以在wiki.python.org/moin/PythonSpeed/PerformanceTips找到。

分发 Tkinter 应用程序

因此,你已经准备好了你的新应用程序,并想要与世界上其他人分享。你该如何做呢?

当然,你需要安装 Python 以便你的程序能够运行。Windows 系统没有预装 Python。大多数现代 Linux 发行版和 macOS 都预装了 Python,但你并不需要任何版本的 Python。你需要的是与程序最初编写时版本兼容的 Python 版本。

此外,如果你的程序使用了第三方模块,你需要安装适合所需 Python 版本的相应模块。这无疑是一个处理起来过于多样化的情况。

幸运的是,我们有像 Freeze tools 这样的工具,它允许我们将 Python 程序作为独立应用程序进行分发。

由于需要处理的平台种类繁多,您可以从大量 Freeze 工具选项中进行选择。因此,本书不会对其中任何一种工具进行详细讨论。

我们将在以下章节中列出一些最先进的冷冻工具。如果你找到一个符合你分发需求的工具,你可以查看其文档以获取更多信息。

py2exe

如果你只需要在 Windows 上分发你的 Python 应用程序,py2exe 可能是最坚固的工具。它可以将 Python 程序转换为可执行 Windows 程序,这些程序可以在不要求安装 Python 的情况下运行。

更多信息、下载链接以及相关教程可在www.py2exe.org/找到。

py2app

py2app 在 macOS 上执行的任务与 py2exe 在 Windows 上执行的任务相同。如果你只需要在 macOS 上分发你的 Python 应用程序,py2app 是一个经过时间考验的工具。更多信息可在pythonhosted.org/py2app/找到。

PyInstaller

PyInstaller 作为冻结工具在过去的几年中获得了人气,因为它支持广泛的平台,例如 Windows、Linux、macOS X、Solaris 和 AIX。

此外,使用 PyInstaller 创建的可执行文件声称比其他冻结工具占用更少的空间,因为它使用了透明压缩。PyInstaller 的另一个重要特性是它对大量第三方软件包的即插即用兼容性。可以通过访问www.pyinstaller.org/来查看完整的功能列表、下载和文档。

其他冷冻工具

以下是一些其他的冷冻工具:

  • 冻结:这个工具包含在标准的 Python 发行版中。冻结只能用于在 Unix 系统上编译可执行文件。然而,该程序过于简单,甚至无法处理常见的第三方库。更多关于这个工具的信息可以在wiki.python.org/moin/Freeze找到。

  • cx_Freeze:这个工具与 py2exe 和 py2app 类似,但它声称可以在 Python 工作的所有平台上通用。更多关于这个工具的信息可以在cx-freeze.sourceforge.net/index.html找到。

如果你正在分发一个小程序,一个冻结工具可能正是你所需要的。

然而,如果你有一个大型程序,比如说,有很多外部第三方库依赖,或者依赖项不被任何现有的冻结工具支持,那么你的应用程序可能正是适合进行打包的候选者。

使用 Python 解释器运行你的应用程序。

Tkinter 的局限性

我们已经探索了 Tkinter 的强大功能。也许 Tkinter 最伟大的力量在于其易用性和轻量级特性。Tkinter 提供了一个非常强大的 API,尤其是在 Text 小部件和 Canvas 小部件方面。

然而,其易用性和轻量级特性也带来了一些限制。

有限数量的核心小部件

Tkinter 只提供少量基本小部件,并且缺少现代小部件的集合。它需要 Ttk、Pmw、TIX 和其他扩展来提供一些真正有用的部件。即使有了这些扩展,Tkinter 也无法与其他 GUI 工具提供的部件范围相匹配,例如高级 wxPython 部件集和 PyQt。

例如,wxPython 的 HtmlWindow 小部件让用户能够轻松地显示 HTML 内容。虽然有人尝试在 Tkinter 中提供类似的扩展,但它们远远不能令人满意。同样,wxPython 的高级用户界面库中还有其他小部件和混入,例如浮动/停靠框架和视角加载与保存;Tkinter 用户只能寄希望于这些小部件将在未来的版本中包含在内。

非 Python 对象

Tkinter 小部件不是一等 Python 对象。因此,我们必须使用诸如IntvarStringVarBooleanVar之类的变通方法来处理 Tkinter 中的变量。这增加了一层小的复杂性,因为 Tcl 解释器返回的错误信息并不非常 Python 友好,这使得调试变得更加困难。

不支持打印

Tkinter 正确地受到批评,因为它没有提供任何打印功能的支持。

Canvas 小部件在 PostScript 格式中提供了有限的打印支持。PostScript 格式在可用性方面过于受限。与此相比,wxPython 提供了一个完整的打印解决方案,以打印框架的形式出现。

不支持较新的图像格式

Tkinter 本身不支持诸如 JPEG 和 PNG 这样的图像格式。Tkinter 的 PhotoImage 类只能读取 GIF 和 PGM/PPM 格式的图像。尽管有诸如使用 PIL 模块的 ImageTkImage 子模块等解决方案,但如果 Tkinter 本身支持流行的图像格式会更好。

消极的开发社区

Tkinter 常被批评为拥有相对不活跃的开发社区。这在很大程度上是正确的。Tkinter 的文档已经多年处于持续完善的状态。

近年来出现了大量的 Tkinter 扩展,但其中大部分已经很长时间没有进行活跃开发了。

Tkinter 支持者用以下论点反驳:Tkinter 是一种稳定且成熟的技术,不需要频繁修订,与一些仍在开发中的其他 GUI 模块不同。

Tkinter 的替代方案

如果一个程序可以用 Tkinter 编写,那么在简单性和可维护性方面,这可能是最佳选择。然而,如果上述限制阻碍了你的进程,你可以探索一些 Tkinter 的其他替代方案。

除了 Tkinter,还有其他几个流行的 Python GUI 工具包。其中最受欢迎的包括 wxPython、PyQt、PySide 和 PyGTK。以下是这些工具包的简要讨论。

wxPython

wxPython 是 wxWidgets 的 Python 接口,一个流行的开源 GUI 库。使用 wxPython 编写的代码可以在大多数主要平台上移植,例如 Windows、Linux 和 macOS。

wxPython 界面通常被认为在构建复杂 GUI 方面优于 Tkinter,主要是因为它拥有大量原生支持的控件。然而,Tkinter 的支持者对此观点提出异议。

wxWidgets 接口最初是用 C++ 编程语言编写的。因此,wxPython 继承了 C++ 程序典型的很大一部分复杂性。wxPython 提供了一个非常庞大的类库,并且通常需要更多的代码来生成与 Tkinter 相同的界面。然而,作为这种复杂性的交换,wxPython 提供了比 Tkinter 更多的内置小部件。

由于其固有的复杂性,wxPython 已经出现了几个 GUI 构建工具包,例如wxGladewxFormBuilderwxDesigner。wxPython 安装包中包含了演示程序,可以帮助你快速开始使用这个工具包。要下载工具包或获取有关 wxPython 的更多信息,请访问wxpython.org/

PyQt

PyQt 是一个用于跨平台 GUI 工具包 Qt 的 Python 接口,Qt 是由一家名为 Riverbank Computing 的英国公司开发和维护的项目。

PyQt,拥有数百个类和数千个函数,可能是目前 Python 中用于 GUI 编程功能最全面的 GUI 库。然而,这种功能负载也带来了许多复杂性和陡峭的学习曲线。

Qt 及其子库 pyQt 支持非常丰富的控件集。除此之外,它还内置了对网络编程、SQL 数据库、线程、多媒体框架、正则表达式、XML、SVG 等多种功能的支持。Qt 的设计器功能允许我们从所见即所得WYSIWYG)的界面生成 GUI 代码。

PyQt 可在多种许可证下使用,包括 GNU、通用公共许可证GPL)以及商业许可证。然而,它最大的缺点是,与 Qt 不同,它不可在 ** Lesser General Public LicenseLGPL**)下使用。

PySide

如果你正在寻找适用于 Python 的 LGPL 版本的 Qt 绑定,你可能想探索 PySide。PySide 最初于 2009 年 8 月由 Qt 工具包的前所有者诺基亚以 LGPL 许可发布。现在它归 Digia 所有。更多关于 PySide 的信息可以通过访问 qt-project.org/wiki/PySide 获取。

PyGTK

PyGTK 是 GTK + 图形用户界面库的 Python 绑定集合。PyGTK 应用程序是跨平台的,可以在 Windows、Linux、macOS 以及其他操作系统上运行。PyGTK 是免费的,并且遵循 LGPL 许可协议。因此,你可以几乎无限制地使用、修改和分发它。

更多关于 PyGTK 的信息可以通过访问 www.pygtk.org/ 获取。

其他选项

除了这些流行的工具包之外,还有一系列适用于 Python GUI 编程的工具包可用。

熟悉 Java GUI 库(如 Swing 和 AWT)的 Java 程序员可以通过使用 Jython 无缝访问这些库。同样,C# 程序员可以使用 IronPython.NET 框架中访问 GUI 构建功能。

要获取一份完整的其他可供 Python 开发者使用的 GUI 工具列表,请访问wiki.python.org/moin/GuiProgramming

Python 2.x 中的 Tkinter

在 2008 年,Python 语言的作者 Guido van Rossum 将该语言分叉为两个分支——2.x 和 3.x。这样做是为了清理语言并使其更加一致。

Python 3.x 与 Python 2.x 不再兼容。例如,Python 2.x 中的 print 语句被现在的 print() 函数所取代,该函数现在将参数作为参数接收。

我们使用 Python 3.x 版本编写了所有 Tkinter 程序。然而,如果你需要维护或编写新的 Python 2.x 版本的 Tkinter 程序,过渡应该不会非常困难。

Tkinter 的核心功能在 2.x 和 3.x 版本之间保持不变。从 Python 2.x 迁移到 Python 3.x 时,Tkinter 的唯一重大变化涉及更改 Tkinter 模块的导入方式。

Tkinter 在 Python 3.x 中已被重命名为 tkinter(已移除大小写)。

注意,在 3.x 版本中,lib-tk目录被重命名为tkinter。在这个目录内部,Tkinter.py文件被重命名为__init__.py,因此tkinter成为一个可导入的模块。

因此,最大的区别在于你将 tkinter 模块导入当前命名空间的方式:

import Tkinter  # for Python 2
import tkinter  # for Python 3

此外,请注意以下变更。

注意到 Python 3 版本在模块命名约定方面更加简洁、优雅和系统化,它使用小写字母命名模块:

Python 3 Python 2
导入 tkinter.ttk从 tkinter 导入 ttk 导入 ttk
导入 tkinter.messagebox 导入 tkMessageBox
导入 tkinter.colorchooser 导入 tkColorChooser
导入 tkinter.filedialog 导入 tkFileDialog
导入 tkinter.simpledialog 导入 tkSimpleDialog
导入 tkinter.commondialog 导入 tkCommonDialog
导入 tkinter.font 导入 tkFont
导入 tkinter.scrolledtext 导入 ScrolledText
导入 tkinter.tix 导入 Tix

以下版本将适用于两种情况:

try:
  import tkinter as tk
except ImportError:
  import Tkinter as tk

try:
  import tkinter.messagebox
except:
  import tkMessageBox

摘要

为了总结本书,让我们概括一下在设计应用程序时涉及的一些关键步骤。根据您想要设计的内容,选择一个合适的数据结构来逻辑地表示您的需求。如果需要,可以将原始数据结构组合成复杂结构,例如,比如说,字典列表或元组。为构成您应用程序的对象创建类。添加需要操作的和用于操作这些属性的方法。通过使用 Python 标准库和外部库提供的不同 API 来操作属性。

我们在这本书中尝试构建了几个应用程序。然后,我们查看了一下代码的解释。然而,当你试图在顺序文本中解释软件开发过程时,你有时会通过暗示而误导你的读者,使他们认为

软件程序的开发是一个线性过程。这几乎是不正确的。

实际的编程通常不会这样进行。实际上,中小型程序通常是在一个逐步的试错过程中编写的,在这个过程中,假设会发生变化,结构也会在应用开发的过程中进行修改。

这里是如何开发一个从小型到中型应用的过程:

  1. 从一个简单的脚本开始。

  2. 设定一个可实现的小目标,实施它,然后以渐进的方式考虑为你的程序添加下一个功能。

  3. 你可能一开始就引入类结构,也可能不引入。如果你对问题域很清楚,你可能会从一开始就引入类结构。

  4. 如果你一开始不确定类的结构,可以先从简单的程序代码开始。随着你的程序开始增长,你可能会开始遇到很多全局变量。正是在这里,你将开始对程序的结构维度有所认识。现在就是时候重构和重新组织你的程序,引入类结构了。

  5. 增强你的程序以抵御未预见的运行时故障和边缘情况,使其准备好投入生产使用。

这本书的内容到此结束。如果您有任何建议或反馈,请给我们留下评论。如果您觉得这本书有帮助,请在线评分并帮助我们传播信息。

QA 部分

这里有一些问题供您思考:

  • 我们如何处理 Tkinter 在不同平台之间的差异?

  • 使用 Tkinter 的优点和局限性是什么?

  • Tkinter 有哪些常见的替代方案?

  • Tkinter 中有哪些验证模式?

  • 什么是程序分析?我们如何在 Python 中进行程序分析?

posted @ 2025-09-22 13:21  绝不原创的飞龙  阅读(55)  评论(0)    收藏  举报