跳转至

使用组件创建基本表单

好消息!你的设计已经过主任的审阅并获得批准。现在是时候开始实施了!在本章中,我们将创建一个非常简单的应用程序,它只提供规范中的核心功能,其他一概没有。这被称为最小可行性产品(MVP)。MVP将不具备生产就绪性,但它会给我们一些可以向用户展示的东西,并帮助我们更好地理解问题和我们正在使用的技术。我们将通过以下主题来介绍这一点:

  • 在“Ttk小部件集”中,我们将了解Tkinter的一个更好的小部件集Ttk。
  • 在“实现应用程序”中,我们将使用Python、Tkinter和Ttk来构建我们的表单设计。

让我们开始编码吧!

TTK 组件集合

第1章《Tkinter简介》 中,我们使用默认的Tkinter小部件创建了一个调查应用程序。这些小部件功能完善,仍在许多Tkinter应用程序中使用,但现代的Tkinter应用程序更倾向于使用一组改进的小部件,称为Ttk。Ttk是Tkinter的一个子模块,提供了许多(但并非全部)Tkinter小部件的主题版本。这些小部件与传统小部件大多相同,但提供了更高级的样式选项,旨在在Windows、macOS和Linux上看起来更现代、更自然。

在每个平台上,Ttk都包含特定于平台的主题,这些主题模仿了该平台的原生小部件。此外,Ttk还添加了一些额外的小部件,提供了默认库中没有的功能。

Tip

尽管本章将介绍Ttk小部件的基本用法,但有关Ttk小部件的字体、颜色和其他样式自定义的完整内容,请参阅第9章《使用样式和主题改善外观》。

Ttk已经作为Tkinter的一部分包含在内,因此我们不需要安装任何额外的东西。要在我们的Tkinter应用程序中使用Ttk小部件,我们需要这样导入Ttk:

python from tkinter import ttk 在本节中,我们将更深入地了解在我们的应用程序中有用的Ttk小部件。根据我们的设计,记得我们的应用程序需要以下类型的小部件:

  • 标签(Labels)
  • 日期输入(Date entry)
  • 文本输入(Text entry)
  • 数字输入(Number entry)
  • 复选框(Check boxes)
  • 单选按钮(Radio buttons)
  • 选择列表(Select list)
  • 长文本输入(Long text entry)
  • 按钮(Buttons)
  • 带标题的框架(Boxed frames with headers)

让我们看看我们可以使用哪些Ttk小部件来满足这些需求。

Label

在第1章《Tkinter简介》中,我们很好地使用了Tkinter的标签(Label)小部件,而Ttk版本的标签小部件基本相同。我们可以这样创建一个:

1
mylabel = ttk.Label(root, text='This is a label')

这将生成一个看起来像这样的标签:

图 3.1:一个TTK Label组件

Ttk的标签(Label)小部件与Tk版本的标签小部件共享大多数相同的选项,其中最常用的选项如下:

参数名称 类型 描述
text String 标签的文本内容
textvariable StringVar 与标签内容绑定的变量
anchor Cardinal direction 文本相对于内部边距的位置
justify left, right, or center 文本行的相对对齐方式
foreground Color string 文本的颜色
wraplength Integer 文本换行前的像素数
underline Integer 要下划线的文本中字符的索引
font Font string or tuple 要使用的字体

注意,标签的文本可以直接使用text参数指定,也可以绑定到一个StringVar变量,以实现标签文本的动态更新。underline参数允许在标签文本中为单个字符添加下划线;这对于指示用户按键绑定很有用,例如,激活由该标签标记的控制小部件。这个参数实际上并不会创建按键绑定;它仅仅是视觉上的效果。我们将在第10章《保持跨平台兼容性》中学习如何创建按键绑定。

Entry

ttk.Entry小部件是一个简单的单行文本输入框,与Tkinter版本的输入框一样。它看起来像这样:

图 3.2:一个TTK Entry 组件

我们可以使用以下代码创建一个Entry小部件:

1
myentry = ttk.Entry(root, textvariable=my_string_var, width=20)
Ttk的Entry小部件与我们之前见过的Tkinter的Entry小部件非常相似,并且支持许多相同的参数。以下是一些较常见的Entry选项:

参数 描述
textvariable StringVar,Tkinter控件变量,用于绑定
show String,用户输入时显示的字符或字符串,例如用于密码字段
justify left, right, or center,文本在输入框中的对齐方式,默认为left
foreground Color string,文本颜色

在未来的章节中,随着我们深入探索Ttk小部件的功能,我们将学习Entry小部件的更多选项。Entry小部件将被用于我们所有的文本输入框,以及日期字段。Ttk并没有专门的日期小部件,但我们将在第5章《通过验证和自动化减少用户错误》中学习如何将我们的Entry小部件转变为一个可以处理日期的输入框。

Spinbox

与Tkinter版本一样,Ttk的Spinbox在标准Entry小部件上添加了增加和减少按钮,使其适用于数值数据。

Ttk的Spinbox如下所示:

图 3.3:一个TTK Spinbox 组件

我们可以像这样创建一个Spinbox:

1
2
3
4
5
6
myspinbox = ttk.Spinbox(
    root,
    from_=0, to=100, increment=.01,
    textvariable=my_int_var,
    command=my_callback
)

如这段代码所示,Ttk的Spinbox接受多个参数来控制其箭头按钮的行为,这些参数列在下表中:

参数名称 类型 描述
from_ Float 或 Int 箭头将递减到的最小值
to Float 或 Int 箭头将递增到的最大值
increment Float 或 Int 箭头将增加或减少的值
command Python function 当任一按钮被按下时要执行的回调
textvariable Control variable 绑定到字段值的控制变量
values List of strings 或 numbers 按钮将滚动浏览的选择集。覆盖from_to值。

请注意,这些参数并不会限制可以输入到Spinbox中的内容;它们仅影响箭头的行为。此外,要注意,如果你只指定了from_to中的一个,另一个会自动默认为0。这可能导致意外行为;例如,如果你设置了from_=1而没有指定to,那么to将默认为0,你的箭头将仅在1和0之间切换。要显式设置无限制,可以使用from_='-infinity'to='infinity'

Spinbox小部件不仅限于数字使用,尽管这将是我们的主要用途。如你所见,它还可以接受一个values参数,这是一个字符串或数字的列表,可以使用箭头按钮滚动浏览。因此,Spinbox可以绑定到任何类型的控制变量,而不仅仅是IntVarDoubleVar变量。

Tip

请记住,这些参数实际上都不会限制可以输入到Spinbox小部件中的内容。它实际上只是一个带有附加按钮的Entry小部件,你不仅可以输入超出有效范围的数值,还可以输入字母和符号。如果你已将该小部件绑定到非字符串变量,这样做可能会导致异常。在第5章《通过验证和自动化减少用户错误》中,我们将学习如何使Spinbox小部件仅限制输入有效的数字字符。

Checkbutton

Ttk的Checkbutton小部件是一个带标签的复选框,非常适合用于输入布尔数据。可以这样创建:

1
2
3
4
5
6
mycheckbutton = ttk.Checkbutton(
    root,
    variable=my_bool_var,
    textvariable=my_string_var,
    command=my_callback
)

除了上面列出的参数外,Checkbutton小部件还可以接受多个其他参数,如下表所示:

参数 描述
variable 可控变量 绑定复选框选中/未选中状态的变量
text 字符串 标签文本
textvariable StringVar 绑定标签文本的变量
command Python函数 当复选框被选中或未选中时执行的回调函数
onvalue 任意 当复选框被选中时设置变量的值
offvalue 任意 当复选框未被选中时设置变量的值
underline 整数 text中下划线的字符索引

Checkbutton 中包含的标签可以直接使用 text 参数设置,或者可以使用 textvariable 将其绑定到一个控制变量。这允许对控件进行动态标签设置,在许多情况下都非常有用。

尽管 Checkbutton 非常适合布尔数据,并且默认将其绑定的变量设置为 True 或 False,但我们可以通过 onvalue 和 offvalue 参数覆盖此行为,使其能够与任何类型的控制变量一起使用。

例如,我们可以将其与 DoubleVar 一起使用,如下所示:

1
2
3
4
5
6
7
8
mycheckbutton2 = ttk.Checkbutton(
    root,
    variable=my_dbl_var,
    text='Would you like Pi?',
    onvalue=3.14159,
    offvalue=0,
    underline=15
)

Ttk Checkbutton 将标签放置在复选框的右侧,如以下截图所示:

图 3.4:一个带有内置标签的 Ttk 复选框控件

Radiobutton

与 Tkinter 中的对应控件类似,Ttk 单选按钮控件用于在一组互斥选项中进行选择。单独一个单选按钮控件并不是特别有用;相反,它们通常是成组创建的,如下所示:

图 3.5:一对 Ttk 单选按钮控件

以下代码展示了如何创建这些按钮:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
buttons = tk.Frame(root)
r1 = ttk.Radiobutton(
    buttons,
    variable=my_int_var,
    value=1,
    text='One'
)
r2 = ttk.Radiobutton(
    buttons,
    variable=my_int_var,
    value=2,
    text='Two'
)

要分组单选按钮控件,只需为它们分配相同的控制变量,然后为每个按钮添加一个不同的值。在我们的示例中,我们还将它们放在同一个父控件上进行分组,但这仅仅是为了视觉上的原因,并不是严格必要的。

这个表格展示了一些你可以与单选按钮一起使用的各种参数:

参数 描述
variable 控制变量 一个绑定到按钮选中状态的变量
value 任何 当按钮被选中时设置变量的值
command Python函数 当按钮被点击时执行的回调函数
text 字符串 连接到单选按钮的标签
textvariable StringVar 绑定到按钮标签文本的变量
underline 整数 文本中需要下划线的字符的索引

Combobox

第1章《Tkinter简介》中,我们了解了在不同选项之间进行选择的几种选项:ListboxOptionMenu控件。Ttk为此目的提供了一个新控件:ComboboxCombobox控件是一个Entry控件,增加了一个下拉列表框。它不仅允许使用鼠标选择,还允许键盘输入。尽管在某些方面,OptionMenu似乎更适合我们的应用程序,但我们将利用Combobox控件的键盘功能来构建一个更优秀的下拉控件。

我们可以这样创建一个Combobox控件:

1
2
3
4
mycombo = ttk.Combobox(
    root, textvariable=my_string_var,
    values=['This option', 'That option', 'Another option']
)

运行这段代码后,我们将得到一个如下所示的组合框:

图 3.6:一个 Ttk Combobox 控件

请注意,虽然我们可以指定一个可能的值列表来填充下拉列表框,但Combobox控件并不限于这些值。用户可以在框中输入他们想要的任何文本,并且绑定的变量将相应地更新。默认情况下,Combobox不适合那些必须严格限制在固定列表中的值;但是,在第5章“通过验证和自动化减少用户错误”中,我们将学习如何解决这个问题。

这个表格展示了一些与Combobox一起使用的常见参数:

参数 描述
textvariable StringVar Combobox内容绑定的参数变量。
values List of strings 下拉菜单中要填充的值。
postcommand Python函数 listbox显示之前执行的回调函数。
justify leftright或者center 框中文本的对齐方式

Text

我们在第1章《Tkinter简介》中已经介绍过的Text小部件,是我们将要使用的唯一一个没有Ttk版本的小部件。虽然这个小部件最常用于多行文本输入,但它实际上提供的功能远不止于此。Text小部件可以用来显示或编辑包含图像、多色文本、超链接样式的可点击文本以及更多内容的文本。

我们可以按以下方式将其添加到应用程序中:

1
2
3
4
5
6
mytext = tk.Text(
    root,
    undo=True, maxundo=100,
    spacing1=10, spacing2=2, spacing3=5,
    height=5, wrap='char'
)
上面的代码将生成一个看起来像这样的东西:

图 3.7:一个 Tk Text 控件

Text小部件有大量的参数可以指定,以控制其外观和行为。其中一些比较有用的参数列在了这个表中:

参数 描述
height Integer 小部件的高度,以文本行数为单位。
width Integer 小部件的宽度,以字符数为单位。对于可变宽度字体,使用“0”字符的宽度来计算宽度。
undo Boolean 激活或停用撤销功能。撤销和重做操作使用平台的默认快捷键激活。
maxundo Integer 将存储用于撤销的最大编辑次数。
wrap none, char或者word 指定当一行文本超过小部件的宽度时如何断行和换行。
spacing1 Integer 在每行完整文本上方填充的像素数。
spacing2 Integer 在显示的换行文本行之间填充的像素数。
spacing3 Integer 在每行完整文本下方填充的像素数。

Text小部件的更高级的可视化配置是通过Tags来实现的。 我们将在第9章“使用样式和主题改善外观”中讨论标签。

文本小部件索引

请记住,文本小部件不能绑定到控制变量;要访问、设置或清除其内容,我们需要分别使用其get()insert()delete()方法。 使用这些方法进行读取或修改时,需要传入一个或两个索引值来选择要操作的字符或字符范围。这些索引值是字符串,可以采用以下任何格式:

  • 用点分隔的行号和字符号。行从1开始编号,字符从0开始编号,因此第一行的第一个字符是1.0,而第四行的第十二个字符是4.11。请注意,行是由换行符的存在来确定的;对于索引目的,换行的文本行仍仅被视为一行。

  • 字符串字面量end,或Tkinter常量END,表示文本的末尾。

  • 一个数字索引加上linestartlineendwordstartwordend中的一个词,表示相对于数字索引的行或词的开始或结束。例如:
    • 6.2 wordstart将是包含第6行第3个字符的单词的开始。
    • 2.0 lineend将是第2行的末尾。
  • 上述任何一种,加上加号或减号运算符,以及一定数量的字符或行。例如:
    • 2.5 wordend - 1 chars将是包含第2行第6个字符的单词末尾的前一个字符。

以下示例展示了这些索引的实际用法:

1
2
3
4
5
6
7
8
# 在开头插入一个字符串
mytext.insert('1.0', "I love my text widget!")
# 在当前文本中插入一个字符串
mytext.insert('1.2', 'REALLY ')
# 获取整个字符串
mytext.get('1.0', tk.END)
# 删除最后一个字符。
mytext.delete('end - 2 chars')
请注意,在最后一个示例中,我们删除了两个字符以便删除最后一个字符。文本小部件会自动在其文本内容的末尾添加一个换行符,因此在处理索引或提取的文本时,我们始终需要记住考虑这个额外的字符。

Tip

记住,这些索引应该是字符串,而不是浮点数!由于隐式类型转换,浮点数有时会起作用,但不要依赖这种行为。

Button

Ttk 按钮是一个简单的可点击按钮,可以激活一个回调函数。它看起来大概是这样的:

图 3.8:一个 Ttk Button 控件

我们可以像这样创建一个按钮:

1
2
3
4
5
6
mybutton = ttk.Button(
    root,
    command=my_callback,
    text='Click Me!',
    default='active'
)

按钮是一个非常直观的控件,但它有一些选项可以用来配置。这些选项在下表中列出:

Arguments Values Description
text String 按钮上的标签文本。
textvariable StringVar 绑定到按钮标签文本的变量。
command Python function 按钮被点击时要执行的回调函数。
default normal, active, disabled 如果按钮在按下回车键时执行,active表示它将响应回车键执行,normal表示只有在被选中时才执行,disabled表示它不会响应回车键。
underline Integer 文本中要加下划线的字符的索引。

按钮也可以配置为显示图像而不是文本。我们将在第9章“使用样式和主题改善外观”中了解更多相关内容。

LabelFrame

第1章《Tkinter简介》中,我们使用了Frame小部件将我们的小部件组合在一起。Ttk为我们提供了一个更强大的选项——LabelFrame,它提供了一个带有边框和标签的框架。这是一个非常有用的小部件,可用于在我们的GUI中为小部件提供可视化分组。

这段代码展示了一个 LabelFrame 的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
mylabelframe = ttk.LabelFrame(
    root,
    text='Button frame'
)
b1 = ttk.Button(
    mylabelframe,
    text='Button 1'
)
b2 = ttk.Button(
    mylabelframe,
    text='Button 2'
)
b1.pack()
b2.pack()

生成的图形用户界面将如下所示:

图 3.9:一个 Ttk LabelFrame 控件

LabelFrame 控件为我们提供了一些用于配置的参数,如下所示:

Argument Values Description
text String 要显示的标签的文本。
labelanchor Cardinal direction 文本标签的锚定位置。
labelwidget ttk.Label object 用于标签的标签小部件。会覆盖text。
underline Integer 文本中要加下划线的字符的索引。

如你所见,我们可以通过指定 text 参数来配置 LabelFrame 的标签,或者通过创建一个 Label 小部件并使用 labelwidget 参数进行分配。如果我们想利用 Label 小部件的一些高级功能(例如绑定一个文本变量),那么后一种情况可能更为合适。如果我们使用了 labelwidget,它将覆盖 text 参数。

Tip

Tkinter 和 Ttk 包含了许多其他小部件,其中一些我们将在本书的后面遇到。Python 还附带了一个名为 tix 的小部件库,其中包含了几十个小部件。然而,tix 已经非常过时,我们不会在本书中介绍它。不过,你应该知道它的存在。

实现该应用程序

到目前为止,我们已经学习了一些 Tkinter 的基础知识,调研了用户的需求,设计了我们的应用程序,并确定了在我们的应用程序中哪些 Ttk 小部件会派上用场。现在,是时候把所有这些整合起来,实际编写 ABQ 数据输入应用程序的第一个版本了。回想一下我们在第2章《设计GUI应用程序》中的设计,如下所示:

图 3.10:ABQ 数据输入应用程序的布局

花点时间回顾一下我们需要创建的小部件,然后我们就开始编码。

第一步

在你的编辑器中打开一个新文件,命名为 data_entry_app.py,然后我们这样开始:

1
2
3
4
5
6
7
# data_entry_app.py
"""ABQ 数据输入应用程序"""
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from pathlib import Path
import csv

我们的脚本以一个文档字符串开始,这是所有 Python 脚本都应该有的。这个字符串至少应该给出文件所属应用程序的名称,并且还可以包含有关用法、作者或其他未来维护者需要知道的事项的说明。

接下来,我们导入这个应用程序所需的 Python 模块:

  • 当然是 tkinterttk,用于我们的 GUI 元素
  • datetime 模块中的 datetime 类,我们将用它来生成文件名的日期字符串
  • pathlib 模块中的 Path 类,在我们的保存例程中用于一些文件操作
  • csv 模块,我们将用它来与 CSV 文件交互

接下来,让我们创建一些全局变量,应用程序将使用这些变量来跟踪信息:

1
2
variables = dict()
records_saved = 0

variables 字典将保存表单的所有控件变量。将它们放在字典中将使管理它们变得更容易,并保持我们的全局命名空间简洁干净。records_saved 变量将存储用户自打开应用程序以来保存了多少条记录。

现在是创建和配置根窗口的时候了:

1
2
3
root = tk.Tk()
root.title('ABQ Data Entry Application')
root.columnconfigure(0, weight=1)

我们已经为应用程序设置了窗口标题,并配置了其布局网格,以便允许第一列扩展。根窗口将只有一列,但通过这样设置,当窗口扩展时,表单将保持居中于应用程序。如果不这样做,当窗口扩展时,表单将固定在窗口的左侧。

现在,我们将为应用程序添加一个标题:

1
2
3
4
ttk.Label(
    root, text="ABQ Data Entry Application",
    font=("TkDefaultFont", 16)
).grid()

因为我们不需要再次引用这个小部件,所以我们不会将其分配给变量。这也允许我们在同一行上对 Label 调用 grid(),使代码更简洁,命名空间也不那么杂乱。对于应用程序中的大多数小部件,我们都会这样做,除非有某种原因我们需要在代码的其他地方与小部件交互。

Tip

注意,我们为这个标签小部件使用了 TkDefaultFont 作为字体族值。这是 Tkinter 中定义的一个别名,指向你所在平台上的默认窗口字体。我们将在第9章“使用样式和主题改善外观”中了解更多关于字体的内容。

构建数据记录表单

随着初始应用程序窗口的设置完成,我们开始构建实际的数据输入表单。我们将创建一个框架来包含整个数据记录表单,称为drf:

1
2
3
drf = ttk.Frame(root)
drf.grid(padx=10, sticky=(tk.E + tk.W))
drf.columnconfigure(0, weight=1)

drf框架被添加到主窗口中,并带有一些水平填充,sticky参数确保当包含它的列被拉伸时,它也会跟着拉伸。我们还将配置其网格以扩展第一列。

Tip

对于使用网格布局的窗口或框架,如果你希望子控件在父控件拉伸时也随之拉伸,你需要确保两点:一是容器能够扩展(通过在父控件上使用columnconfigurerowconfigure),二是子控件能够随着容器一起扩展(通过在子控件上调用grid()时使用sticky参数)。

记录信息部分

我们表单的第一部分是“记录信息”部分。让我们创建并配置一个LabelFrame来存储它:

1
2
3
4
r_info = ttk.LabelFrame(drf, text='Record Information')
r_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
    r_info.columnconfigure(i, weight=1)

我们首先创建一个Ttk LabelFrame小部件,其父级是数据记录表单。我们将其添加到父级网格中,并设置sticky参数,以便在窗口调整大小时它能够扩展。此表单的每个框架都将有三列输入小部件,我们希望每列能够均匀扩展以填满框架的宽度。因此,我们使用了一个for循环,将每列的weight属性设置为1。现在,我们可以开始创建框架的内容,首先从第一个输入小部件“日期”字段开始:

1
2
3
variables['Date'] = tk.StringVar()
ttk.Label(r_info, text='Date').grid(row=0, column=0)
ttk.Entry(r_info, textvariable=variables['Date']).grid(row=1, column=0, sticky=(tk.W + tk.E))

首先,我们创建了一个控制变量,并将其放入variables字典中。然后,我们为“日期”字段创建了Label小部件,并将其添加到LabelFrame小部件的网格中。我们将在这里使用明确的行和列值,即使在不是严格必要的情况下也是如此,因为我们将要稍微打乱顺序放置对象。如果没有明确的坐标,事情可能会变得混乱。

最后,我们创建了Entry小部件,并传入了控制变量。请注意,如果我们可以使用变量来存储值,我们将不会保存对任何小部件的引用。这将使代码更加简洁。我们将小部件添加到网格中,通过指定下一行的第一列,将其放置在标签下方。对于EntryLabel,我们都使用了sticky参数,以确保在GUI扩展时小部件能够拉伸。

现在,让我们添加第一行的其余部分:“时间”和“技术人员”字段:

1
2
3
4
5
6
7
time_values = ['8:00', '12:00', '16:00', '20:00']
variables['Time'] = tk.StringVar()
ttk.Label(r_info, text='Time').grid(row=0, column=1)
ttk.Combobox(r_info, textvariable=variables['Time'], values=time_values).grid(row=1, column=1, sticky=(tk.W + tk.E))
variables['Technician'] = tk.StringVar()
ttk.Label(r_info, text='Technician').grid(row=0, column=2)
ttk.Entry(r_info, textvariable=variables['Technician']).grid(row=1, column=2, sticky=(tk.W + tk.E))

再次,我们为每个项目创建了一个变量、一个Label和一个输入小部件。请记住,Combobox小部件的values参数接受一个字符串列表,该列表将填充小部件的下拉部分。这样就完成了第一行。在第二行,我们从“实验室”输入开始:

1
2
3
4
5
6
variables['Lab'] = tk.StringVar()
ttk.Label(r_info, text='Lab').grid(row=2, column=0)
labframe = ttk.Frame(r_info)
for lab in ('A', 'B', 'C'):
    ttk.Radiobutton(labframe, value=lab, text=lab, variable=variables['Lab']).pack(side=tk.LEFT, expand=True)
labframe.grid(row=3, column=0, sticky=(tk.W + tk.E))

与之前一样,我们创建了控制变量和Label,但对于输入小部件,我们创建了一个Frame来容纳三个Radiobutton小部件。我们还使用for循环创建了Radiobutton小部件,以使代码更加简洁和一致。在这里,pack()几何管理器非常方便,因为我们可以从左到右填充,而无需显式管理列号。expand参数使得在窗口调整大小时,小部件能够使用额外空间;这将有助于我们的按钮利用可用空间,而不会被挤压到窗口的左侧。

现在,让我们完成第二行的剩余部分:“地块”和“种子样本”字段:

1
2
3
4
5
6
variables['Plot'] = tk.IntVar()
ttk.Label(r_info, text='Plot').grid(row=2, column=1)
ttk.Combobox(r_info, textvariable=variables['Plot'], values=list(range(1, 21))).grid(row=3, column=1, sticky=(tk.W + tk.E))
variables['Seed Sample'] = tk.StringVar()
ttk.Label(r_info, text='Seed Sample').grid(row=2, column=2)
ttk.Entry(r_info, textvariable=variables['Seed Sample']).grid(row=3, column=2, sticky=(tk.W + tk.E))

这里的过程相同:创建一个变量,创建一个Label,创建输入小部件。请注意,对于Plot值,我们使用range()生成了一个列表,以保持代码的简洁性。

环境数据部分

表单的下一部分是“环境数据”框架。让我们按以下方式开始该部分:

1
2
3
4
e_info = ttk.LabelFrame(drf, text="Environment Data")
e_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
    e_info.columnconfigure(i, weight=1)

这与我们为上一个LabelFrame所做的完全相同,只是名称进行了更新。接下来,我们开始在其中添加湿度、光照和温度控件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
variables['Humidity'] = tk.DoubleVar()
ttk.Label(e_info, text="Humidity (g/m³)").grid(row=0, column=0)
ttk.Spinbox(
    e_info, textvariable=variables['Humidity'], from_=0.5, to=52.0, increment=0.01,
).grid(row=1, column=0, sticky=(tk.W + tk.E))

variables['Light'] = tk.DoubleVar()
ttk.Label(e_info, text='Light (klx)').grid(row=0, column=1)
ttk.Spinbox(
    e_info, textvariable=variables['Light'], from_=0, to=100, increment=0.01
).grid(row=1, column=1, sticky=(tk.W + tk.E))

variables['Temperature'] = tk.DoubleVar()
ttk.Label(e_info, text='Temperature (°C)').grid(row=0, column=2)
ttk.Spinbox(
    e_info, textvariable=variables['Temperature'], from_=4, to=40, increment=.01
).grid(row=1, column=2, sticky=(tk.W + tk.E))

很好!现在,在这个部分的第二行,我们只需要添加一个“设备故障”复选框:

1
2
3
4
variables['Equipment Fault'] = tk.BooleanVar(value=False)
ttk.Checkbutton(
    e_info, variable=variables['Equipment Fault'], text='Equipment Fault'
).grid(row=2, column=0, sticky=tk.W, pady=5)

前三个值都是浮点数,因此我们使用DoubleVar控制变量和Spinbox控件进行输入。不要忘记为Spinbox控件填充from_toincrement值,以便箭头能够正常工作。我们的Checkbutton使用一个BooleanVar控制变量,并且由于其内置了标签,因此不需要额外的Label控件。另外,请注意,由于我们已经开始了一个新的框架,所以网格的行和列编号重新开始。这是将表单拆分为更小框架的一个好处:我们不需要跟踪不断增加的行或列编号。

植物数据部分

我们将创建下一个框架“植物数据”,就像前两个一样:

1
2
3
4
p_info = ttk.LabelFrame(drf, text="Plant Data")
p_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
    p_info.columnconfigure(i, weight=1)

现在,我们已经创建并配置了框架,接下来添加第一行输入:植物数量、花朵数量和果实数量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
variables['Plants'] = tk.IntVar()
ttk.Label(p_info, text='Plants').grid(row=0, column=0)
ttk.Spinbox(
    p_info, textvariable=variables['Plants'], from_=0, to=20, increment=1
).grid(row=1, column=0, sticky=(tk.W + tk.E))

variables['Blossoms'] = tk.IntVar()
ttk.Label(p_info, text='Blossoms').grid(row=0, column=1)
ttk.Spinbox(
    p_info, textvariable=variables['Blossoms'], from_=0, to=1000, increment=1
).grid(row=1, column=1, sticky=(tk.W + tk.E))

variables['Fruit'] = tk.IntVar()
ttk.Label(p_info, text='Fruit').grid(row=0, column=2)
ttk.Spinbox(
    p_info, textvariable=variables['Fruit'], from_=0, to=1000, increment=1
).grid(row=1, column=2, sticky=(tk.W + tk.E))

这里没有什么新内容,只是因为我们使用了IntVar控制变量,所以将Spinbox的增量设置为1。这并不能真正阻止用户输入小数(或者任意字符串),但至少按钮不会误导用户。在第5章“通过验证和自动化减少用户错误”中,我们将看到如何更彻底地强制执行增量。现在,最后是我们最后一行输入:最小高度、最大高度和平均高度:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
variables['Min Height'] = tk.DoubleVar()
ttk.Label(p_info, text='Min Height (cm)').grid(row=2, column=0)
ttk.Spinbox(
    p_info, textvariable=variables['Min Height'], from_=0, to=1000, increment=0.01
).grid(row=3, column=0, sticky=(tk.W + tk.E))

variables['Max Height'] = tk.DoubleVar()
ttk.Label(p_info, text='Max Height (cm)').grid(row=2, column=1)
ttk.Spinbox(
    p_info, textvariable=variables['Max Height'], from_=0, to=1000, increment=0.01
).grid(row=3, column=1, sticky=(tk.W + tk.E))

variables['Med Height'] = tk.DoubleVar()
ttk.Label(p_info, text='Median Height (cm)').grid(row=2, column=2)
ttk.Spinbox(
    p_info, textvariable=variables['Med Height'], from_=0, to=1000, increment=0.01
).grid(row=3, column=2, sticky=(tk.W + tk.E))

我们又创建了三个DoubleVar对象、三个标签和三个Spinbox控件。如果这感觉有点重复,不要惊讶;GUI代码往往会相当重复。在第4章“用类组织代码”中,我们将找到减少这种重复性的方法。

完成 GUI

我们已经完成了三个信息部分;现在我们需要添加Notes输入。我们将直接在drf框架中添加一个标签,如下所示:

1
2
3
ttk.Label(drf, text="Notes").grid()
notes_inp = tk.Text(drf, width=75, height=10)
notes_inp.grid(sticky=(tk.W + tk.E))

由于我们无法为Text小部件关联一个控制变量,因此我们需要保留一个常规的变量引用来访问它。

Tip

当你需要保存一个小部件的引用时,不要忘记将grid()(以及其他几何管理器方法)单独调用!因为grid()返回的是None,如果你在一行语句中同时创建和定位小部件,那么你保存的小部件引用将会是None

我们几乎完成了表单!我们只需要添加一些按钮:

1
2
3
4
5
6
buttons = tk.Frame(drf)
buttons.grid(sticky=tk.E + tk.W)
save_button = ttk.Button(buttons, text='Save')
save_button.pack(side=tk.RIGHT)
reset_button = ttk.Button(buttons, text='Reset')
reset_button.pack(side=tk.RIGHT)

为了保持表单的网格布局更简洁,我们将两个按钮打包进了一个子框架,使用pack()方法并通过side参数将它们放在右侧。这样就完成了数据记录表单;为了完成应用程序的GUI,我们只需要添加一个状态栏以及一个关联的变量,如下所示:

1
2
status_variable = tk.StringVar()
ttk.Label(root, textvariable=status_variable).grid(sticky=tk.W + tk.E, row=99, padx=10)

状态栏只是一个Label小部件,我们将其放置在根窗口的网格中的第99行,以确保它始终位于底部,以防将来对应用程序进行任何添加。请注意,我们没有将状态变量添加到variables字典中;该字典是保留给将保存用户输入的变量的。这个变量只是用来向用户显示消息。

编写回调函数

现在我们的布局已经完成了,接下来让我们来实现应用程序的功能。我们的表单有两个按钮需要回调函数:重置和保存。

构建重置方法

我们的重置函数的任务是将整个表单恢复到空白状态,以便用户可以输入更多数据。我们不仅需要这个函数作为重置按钮的回调函数,还需要在用户保存记录后准备表单以输入下一条记录。否则,用户将不得不手动删除并覆盖每个字段中的数据以输入每条新记录。

由于我们需要在保存回调中调用重置回调,因此我们需要先编写重置函数。在data_entry_app.py的末尾,开始一个新的函数,如下所示:

1
2
3
# data_entry_app.py
def on_reset():
    """当重置按钮被点击或在保存后被调用"""

这个函数名为on_reset()。回顾第1章《Tkinter简介》中的内容,按照惯例,回调函数通常命名为on_<事件名>,其中事件名指的是触发它的事件。由于这个函数将由点击重置按钮触发,因此我们将其命名为on_reset()

在函数内部,我们需要将所有小部件重置为空值。但是等等!除了备注输入外,我们没有保存任何其他小部件的引用。我们该怎么办?

很简单:我们将所有变量重置为空字符串,如下所示:

1
2
3
4
5
6
for variable in variables.values():
    if isinstance(variable, tk.BooleanVar):
        variable.set(False)
    else:
        variable.set('')
notes_inp.delete('1.0', tk.END)

StringVarDoubleVarIntVar对象可以被设置为空字符串,这将导致绑定到它们的任何小部件变为空白。如果我们尝试对BooleanVar变量这样做,将会引发异常,因此我们将使用Python内置的isinstance()函数检查变量是否是BooleanVar。如果是,我们只需将其设置为False

对于备注输入,我们可以使用Text小部件的delete()方法来清除其内容。这个方法需要一个起始和结束位置,就像get()方法一样。值1.0tk.END表示小部件的全部内容。回顾我们之前关于Text小部件的讨论,这个索引是字符串1.0,而不是浮点数。这就是我们的重置回调中所需的全部内容。要将其绑定到按钮,请使用按钮的configure()方法:

1
reset_button.configure(command=on_reset)

Tip

configure()方法可以在任何Tkinter小部件上调用,以更改其属性。它接受与小部件构造函数相同的关键字参数。

构建保存方法

我们最后也是最重要的功能是保存回调。根据我们的程序规范,我们的应用程序需要将输入的数据追加到一个CSV(逗号分隔值)文件中,文件名格式为abq_data_record_CURRENTDATE.csv,其中CURRENTDATE为ISO格式的日期(年-月-日)。如果该文件不存在,则应创建它并写入表头行。因此,这个函数需要完成以下任务: - 确定当前日期并生成文件名 - 判断文件是否存在,如果不存在则创建它并写入表头行 - 从表单中提取数据并进行必要的清理 - 将数据行追加到文件中 - 增加records_saved计数并通知用户记录已保存 - 重置表单以输入下一条记录

让我们以这种方式开始编写函数:

1
2
3
def on_save():
    """处理保存按钮点击事件"""
    global records_saved

再次使用on_<事件名>的命名约定。我们首先声明records_saved为全局变量。如果不这样做,Python会将records_saved解释为局部变量,我们将无法更新它。

修改全局变量通常不是一个好的做法,但Tkinter在这里并没有给我们太多选择:我们不能使用返回值来更新变量,因为这是一个事件响应的回调函数,而不是我们代码中可以直接访问records_saved的地方。在第4章《用类组织代码》中,我们将学习一种更好的方法来实现这个功能,而不需要使用全局变量;不过,现在我们只能这样处理。

接下来,让我们确定文件名的细节以及它是否存在:

1
2
3
datestring = datetime.today().strftime("%Y-%m-%d")
filename = f"abq_data_record_{datestring}.csv"
newfile = not Path(filename).exists()

datetime.today()函数返回当前日期的Date对象,其strftime()方法允许我们将该日期格式化为我们指定的任何字符串格式。strftime()的语法源自C语言编程,因此在某些情况下可能有些晦涩;但希望%Y表示年、%m表示月、%d表示日是清晰的。这将返回ISO格式的日期;例如,2021年10月31日返回2021-10-31

有了日期字符串,我们就可以用它来构建当天CSV文件的文件名。在下一行中,Path(filename).exists()告诉我们文件是否存在于当前工作目录中。它通过使用文件名构造一个Path对象,然后调用其exists()方法来查看文件是否已经在文件系统中。我们将这个信息保存到一个名为newfile的变量中。现在是时候从表单中获取数据了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
data = dict()
fault = variables['Equipment Fault'].get()
for key, variable in variables.items():
    if fault and key in ('Light', 'Humidity', 'Temperature'):
        data[key] = ''
    else:
        try:
            data[key] = variable.get()
        except tk.TclError:
            status_variable.set(
                f'字段错误: {key}. 数据未保存!'
            )
            return
# 单独获取Text小部件的内容
data['Notes'] = notes_inp.get('1.0', tk.END)

我们将把数据存储在一个新的字典对象data中。为此,我们将遍历我们的variables字典,对每个变量调用get()。当然,如果存在设备故障,我们希望跳过LightHumidityTemperature的值,因此我们先获取Equipment Fault的值,并在检索这些字段值之前进行检查。如果我们确实需要从变量中检索值,我们将在try块中执行此操作。记住,如果变量中存在无效值,调用get()方法时会引发TclError,因此我们需要处理该异常。在这种情况下,我们会让用户知道该特定字段存在问题,并立即退出函数。最后,我们需要使用get()Notes字段中获取数据。

现在我们有了数据,需要将其写入CSV。接下来添加以下代码:

1
2
3
4
5
with open(filename, 'a', newline='') as fh:
    csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
    if newfile:
        csvwriter.writeheader()
    csvwriter.writerow(data)

首先,我们使用上下文管理器(with关键字)打开文件。这样做可以确保在退出缩进块时关闭文件。我们以追加模式打开文件(由opena参数指示),这意味着我们写入的任何数据都将简单地添加到已存在内容的末尾。注意newline参数,我们将其设置为空字符串。这是为了解决Windows上CSV模块的一个bug,该bug会导致每条记录之间出现额外的空行。在其他平台上这样做没有坏处。

在块内,我们需要创建一个称为CSV Writer的对象。标准库csv模块包含几种不同类型的对象,可以将数据写入CSV文件。DictWriter类很方便,因为它可以接受任何顺序的值字典,并将它们写入CSV的适当字段,前提是第一行包含列名。我们可以通过传递data.keys()来告诉DictWriter这些表头值应该是什么,data.keys()是我们所有数据值的名称。

追加模式会在文件不存在时创建文件,但它不会自动写入标题行。因此,我们需要检查文件是否是新文件(使用之前找到的newfile值),如果是新文件,我们将写入标题行。DictWriter对象有一个方法可以实现这一点,即只写入包含所有字段名的一行。

最后,我们可以使用DictWriter对象的writerow()方法,将我们的数据字典传入以写入文件。当我们退出缩进块时,Python会关闭文件并将其保存到磁盘。

这样,on_save()函数中就只剩下几行代码了:

1
2
3
4
5
records_saved += 1
status_variable.set(
    f"{records_saved} 条记录已在此会话中保存"
)
on_reset()
首先,我们增加records_saved变量的值,然后在状态栏中通知用户到目前为止已保存了多少条记录。这是一个很好的反馈,可以帮助用户知道他们的操作是成功的。最后,我们调用on_reset()来准备表单,以便输入下一条记录。

实现了保存方法后,让我们将其绑定到按钮上:

1
save_button.configure(command=on_save)

最后,让我们重置表单并启动主事件循环:

1
2
on_reset()
root.mainloop()

就这样,你的第一个ABQ应用程序就完成了,可以运行了!

完成并测试

在我们将应用程序发布到世界之前,让我们先启动它并进行一下测试:

图 3.11:我们的第一个ABQ数据录入应用程序

看起来不错!而且它也能正常工作。继续输入一些测试数据并保存。当然,这并不是终点——我们还没有完全解决程序规范中的所有问题,而且一旦用户开始使用这个应用程序,功能请求无疑会接踵而至。但现在,我们可以庆祝这个可工作的最小可行产品(MVP)的胜利。

总结

在这一章中,我们已经取得了很大的进展!你从规范和一些草图开始,设计出了一个已经运行的最小可行产品(MVP)应用程序,它已经涵盖了所需的基本功能。你学习了基本的Ttk小部件,如Entry、Spinbox、Combobox、Radiobutton和Checkbutton,以及Tkinter的Text小部件。你学会了如何使用嵌套的LabelFrame小部件将这些小部件组装成一个复杂但有序的图形用户界面(GUI),以及如何使用回调方法保存文件。

在下一章中,我们将利用类和面向对象编程技术来清理代码并扩展小部件的功能。