跳转至

数据验证和自动化输入

我们的项目进展顺利:数据输入表单运行良好,代码组织得更加合理,用户也对即将使用的应用程序充满期待。不过,我们还没有准备好投入生产!我们的表单还没有实现其承诺的功能,即防止或减少用户错误:数字输入框仍然允许输入字母,组合框没有限制为给定的选项,日期也只是需要手动填写的文本字段。在本章中,我们将通过以下主题来纠正这些问题:

  • 在验证用户输入方面,我们将讨论一些策略,以在我们的控件中强制实施正确的值,以及如何在Tkinter中实现这些策略。
  • 在创建经过验证的控件类方面,我们将为Tkinter的控件类添加一些自定义验证逻辑,使其功能更强大。
  • 在我们的GUI中实现经过验证的控件方面,我们将使用新的控件来改进ABQ数据输入表单。
  • 在自动化输入方面,我们将实现控件数据的自动填充,以节省用户的时间和精力。让我们开始吧!

验证用户输入

乍一看,Tkinter提供的输入控件选择似乎有些令人失望。它既没有提供仅允许输入数字的真正数字输入框,也没有提供真正便于键盘操作、现代化的下拉选择器。我们没有日期输入框、电子邮件输入框或其他特殊格式的输入控件。

然而,这些不足也可以转化为优势。因为这些控件没有预设任何行为,我们可以根据特定需求来定制它们的行为。例如,在数字输入框中输入字母似乎不合适,但真的如此吗?在Python中,像 NaNInfinity 这样的字符串是有效的浮点数值;在某些应用程序中,拥有一个既能递增数字又能处理这些字符串值的输入框可能会非常有用。

当然,在我们根据需求定制控件之前,我们需要先明确我们希望它们实现什么功能。让我们来做一些分析。

防止数据错误的策略

对于控件应如何应对用户尝试输入错误数据的问题,没有统一的答案。各种GUI工具包中的验证逻辑可能大相径庭;当用户输入错误数据时,输入控件可能通过以下任何一种方式来验证用户输入:

  • 完全阻止无效按键的注册

  • 接受输入,但在提交表单时返回错误或错误列表

  • 当用户离开输入字段时显示错误,可能会禁用表单提交,直到错误被纠正

  • 将用户锁定在输入字段中,直到输入有效数据

  • 使用最佳猜测算法静默地纠正错误数据

在数据输入表单(由同一用户每天填写数百次,甚至可能看都不看)中的正确行为,可能与仪器控制面板(其中的值必须绝对正确以避免灾难)或在线用户注册表单(由从未见过该表单的用户填写一次)中的行为不同。我们需要问自己以及我们的用户,哪种行为最能减少错误。

在与数据输入人员讨论后,您得出了以下一套指南:

  • 尽可能忽略无意义的按键(例如,数字字段中的字母)。
  • 包含错误数据的字段应在失去焦点(当用户退出字段时)时以某种可见方式标记,并显示描述问题的错误信息。
  • 必填字段在失去焦点时仍为空应标记为错误。
  • 如果存在有错误的字段,应禁用表单提交。

在继续之前,让我们在规范中添加以下要求。在Requirements部分,更新Functional如下:

功能要求:

1
2
3
4
5
6
7
Functional Requirements:
  (...)   
  * have inputs that:
    - ignore meaningless keystrokes
    - display an error if the value is invalid on focusout     
    - display an error if a required field is empty on focusout   
  * prevent saving the record when errors are present

到目前为止一切顺利,但我们如何实现这一点呢?

TKinter中的数据验证

Tkinter的验证系统是工具包中不太直观的部分之一。它依赖于我们可以传递给任何输入控件的三个配置参数:

  • validate:此选项决定哪种类型的事件将触发验证回调。
  • validatecommand:此选项接受一个命令,该命令将确定数据是否有效。
  • invalidcommand:如果validatecommand返回False,则此选项接受一个将运行的命令。

这看起来相当简单明了,但其中有一些意想不到的曲折。让我们深入看看每个参数。

validate 参数

validate 参数指定哪种事件会触发验证。它可以是以下字符串值之一:

触发事件
none 从不。此选项关闭验证。
focusin 用户选择或进入该控件。
focusout 用户离开该控件。
focus 同时包括进入焦点(focusin)和离开焦点(focusout)事件。
key 用户在该控件内按下一个按键。
all 进入焦点(focusin)、离开焦点(focusout)或按键(key)事件中的任何一个。

只能指定一个有效的事件参数,所有匹配的事件都将触发相同的验证回调。大多数情况下,你会希望使用 key(按键)和 focusout(失去焦点)事件(在 focusin(获得焦点)时进行验证的情况很少有用),但由于没有一个值能同时包含这两个事件,所以通常最好使用 all,并在回调中根据事件类型切换其验证逻辑(如果需要的话)。

validatecommand 参数

validatecommand 参数指定了当验证事件被触发时将运行的回调函数。这里有点棘手。你可能会认为这个参数接受的是 Python 函数或方法的名称,但事实并非如此。相反,我们需要给它一个元组,其中包含对 Tcl/Tk 函数的字符串引用,以及(可选地)一些替换代码,这些代码指定了我们希望传递给函数的有关触发事件的信息。

如何获取 Tcl/Tk 函数的引用呢?幸运的是,这并不太难;我们只需要将 Python 可调用对象传递给任意 Tkinter 小部件的 register() 方法。这将返回一个字符串引用,我们可以将其与 validatecommand 一起使用。例如,我们可以创建一个(有点无意义的)验证命令,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# validate_demo.py
import tkinter as tk

root = tk.Tk()
entry = tk.Entry(root)
entry.grid()

def always_good():
    return True

validate_ref = root.register(always_good)
entry.configure(
    validate='all',
    validatecommand=(validate_ref,)
)

root.mainloop()

在这个例子中,我们通过将 always_good 函数传递给 root.register() 来获取函数引用。然后,我们可以将这个引用放在一个元组中传递给 validatecommand。我们注册的验证回调必须返回一个布尔值,指示字段中的数据是有效还是无效。

Tip

validatecommand 回调必须返回一个布尔值。如果它返回任何其他内容(包括在没有 return 语句时的隐式 None 值),Tkinter 将关闭该小部件的验证(即,它将 validate 设置为 none)。请记住,它的目的只是指示数据是否可接受。对无效数据的处理将由我们的 invalidcommand 回调来完成。

当然,除非我们为函数提供一些要验证的数据,否则验证数据并不容易。为了让 Tkinter 将信息传递给我们的验证回调,我们可以在 validatecommand 元组中添加一个或多个替换代码。这些代码如下:

代码 传递的值
%d 一个表示正在尝试的操作的代码:0 表示删除,1 表示插入,-1 表示其他事件。请注意,此值是以字符串形式传递的,而不是整数。
%P 更改后字段将具有的建议值(仅适用于按键事件)。
%s 字段中当前的值(仅适用于按键事件)。
%i 按键事件中正在插入或删除的文本的索引(从 0 开始),对于非按键事件为 -1。请注意,此值是以字符串形式传递的,而不是整数。
%S 对于插入或删除操作,正在插入或删除的文本(仅适用于按键事件)。
%v 控件的验证值。
%V 触发验证的事件类型,取值为 focusinfocusoutkeyforced 之一(表示控件的变量已更改)。
%W 控件在 Tcl/Tk 中的名称,以字符串形式表示。

我们可以使用这些代码来创建一个稍微更有用的经过验证的 Entry 小部件,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# validate_demo.py
# 放在 root.mainloop() 之前
entry2 = tk.Entry(root)
entry2.grid(pady=10)

def no_t_for_me(proposed):
    return 't' not in proposed

validate2_ref = root.register(no_t_for_me)
entry2.configure(
    validate='all',
    validatecommand=(validate2_ref, '%P')
)

在这里,我们将 %P 替换代码传递到 validatecommand 元组中,这样我们的回调函数就会接收到小部件的拟议新值(即,如果按键被接受,小部件的值)。在这种情况下,如果拟议的值包含字符 t,我们将返回 False

请注意,当 validatecommand 回调返回时,小部件的行为会根据触发验证的事件类型而有所不同。如果验证回调是由按键事件触发的并且返回 False,Tkinter 的内置行为是拒绝该按键并保持内容不变。如果是焦点事件触发验证,返回 False 将仅把小部件标记为无效。在这两种情况下,invalidcommand 回调也会被执行。如果我们没有指定回调,Tkinter 将不会进行任何进一步的操作。

例如,运行上述脚本;你会发现无法在 Entry 小部件中输入 t。这是因为按键验证返回了 False,所以 Tkinter 拒绝了该按键。

invalidcommand 参数

invalidcommand 参数的工作原理与 validatecommand 参数完全相同,需要使用 register() 方法和相同的替换代码。它指定了一个回调函数,当 validatecommand 返回 False 时运行该函数。它可以用于显示错误或可能更正输入。

为了看看它们组合在一起的效果,考虑以下代码,它创建了一个只接受五个字符的 Entry 小部件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
entry3 = tk.Entry(root)
entry3.grid()

entry3_error = tk.Label(root, fg='red')
entry3_error.grid()

def only_five_chars(proposed):
    return len(proposed) < 6

def only_five_chars_error(proposed):
    entry3_error.configure(
        text=f'{proposed} is too long, only 5 chars allowed.'
    )

validate3_ref = root.register(only_five_chars)
invalid3_ref = root.register(only_five_chars_error)

entry3.configure(
    validate='all',
    validatecommand=(validate3_ref, '%P'),
    invalidcommand=(invalid3_ref, '%P')
)

在这里,我们创建了一个简单的 GUI,包含一个 Entry 小部件和一个 Label 小部件。我们还创建了两个函数,一个用于返回字符串的长度是否小于六个字符,另一个用于配置 Label 小部件以显示错误。然后,我们使用 root.register() 方法将这两个函数注册到 Tk 中,并将它们传递给 Entry 小部件的 validatecommandinvalidcommand 参数。我们还包括了 %P 替换代码,以便将小部件的拟议值传递给每个函数。请注意,你可以传递任意数量的替换代码,并且可以按任意顺序传递,只要你的回调函数编写为接受这些参数即可。

运行这个示例并测试其行为;注意,你不仅无法在框中输入超过五个字符,而且你还会在标签中收到警告,提示你尝试的编辑过长。

创建验证组件类

如你所见,即使是为 Tkinter 小部件添加非常简单的验证,也需要几个步骤,并且逻辑上不太直观。如果对我们的小部件中的一小部分添加验证,代码可能会变得相当冗长和难看。然而,我们在前一章中了解到,可以通过子类化 Tkinter 小部件来添加新的配置和功能,从而对其进行改进。让我们看看是否可以通过创建 Tkinter 小部件类的验证版本来将这一技术应用于小部件验证。

例如,让我们再次实现一个五字符输入框,这次作为 ttk.Entry 的子类,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# five_char_entry_class.py
class FiveCharEntry(ttk.Entry):
    """一个退出时截断为五个字符的输入框。"""
    def __init__(self, parent, *args, **kwargs):
        super().__init__(parent, *args, **kwargs)
        self.error = tk.StringVar()
        self.configure(
            validate='all',
            validatecommand=(self.register(self._validate), '%P'),
            invalidcommand=(self.register(self._on_invalid), '%P')
        )

    def _validate(self, proposed):
        return len(proposed) <= 5

    def _on_invalid(self, proposed):
        self.error.set(
            f'{proposed} is too long, only 5 chars allowed!'
        )

这次,我们通过子类化 Entry 并在方法中定义验证逻辑来实现验证,而不是使用外部函数。这简化了在验证方法中访问小部件(如果需要的话),并允许我们在 __init__() 中引用方法,即使它们尚未定义。我们还添加了一个名为 errorStringVar 作为实例变量。如果验证失败,我们可以使用这个变量来保存错误消息。

请注意,我们使用 self.register() 而不是 root.register() 来注册这些函数。register() 方法不必在根窗口对象上运行;它可以在任何 Tkinter 小部件上运行。由于我们不确定使用我们类的代码是否会将根窗口命名为 root,或者 root 是否在 __init__() 方法运行时处于作用域内,因此使用 FiveCharEntry 小部件本身来注册函数是合理的。但是,这必须在调用 super().__init__() 之后进行,因为在调用该方法之前,底层的 Tcl/Tk 对象实际上并不存在(也无法注册函数)。这就是为什么我们使用 configure() 来设置这些值,而不是将它们传递给 super().__init__()

然后,我们可以这样使用这个类:

1
2
3
4
5
6
7
8
root = tk.Tk()
entry = FiveCharEntry(root)
error_label = ttk.Label(
    root, textvariable=entry.error, foreground='red'
)
entry.grid()
error_label.grid()
root.mainloop()

在这里,我们创建了一个 FiveCharEntry 小部件的实例,以及一个用于显示错误的 Label 小部件。注意,我们将小部件内置的 error 变量 entry.error 传递给标签的 textvariable 参数。当你执行这段代码时,如果你尝试输入超过五个字符,应该会看到标签显示错误信息,如下所示:

图 5.1:五字符输入框拒绝接受“Banana”。

创建一个DateEntry

现在,让我们尝试一些更有用的东西:创建一个用于日期字段的验证 DateEntry 小部件。这个小部件将阻止任何对日期字符串无效的按键输入,并在失去焦点时检查日期的有效性。如果日期无效,我们将以某种方式标记该字段,并在 StringVar 中设置错误信息,其他小部件可以使用它来显示错误。

首先,打开一个新文件,命名为 DateEntry.py,并以下面的代码开始:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# DateEntry.py
import tkinter as tk
from tkinter import ttk
from datetime import datetime

class DateEntry(ttk.Entry):
    """用于ISO风格日期(年-月-日)的输入框"""
    def __init__(self, parent, *args, **kwargs):
        super().__init__(parent, *args, **kwargs)
        self.configure(
            validate='all',
            validatecommand=(
                self.register(self._validate),
                '%S', '%i', '%V', '%d'
            ),
            invalidcommand=(self.register(self._on_invalid), '%V')
        )
        self.error = tk.StringVar()

在导入 tkinterttk 之后,我们还导入了 datetime,这是验证输入的日期字符串所需要的。与之前的类一样,我们重写了 __init__() 方法来设置验证并添加一个错误变量。然而,这次我们将向 validatecommand 方法传递更多参数:正在插入的字符(%S)、插入的位置(%i)、触发验证的事件类型(%V)和操作类型(%d)。invalidcommand 仅接收事件类型(%V)。由于我们在所有事件上触发验证,因此需要这个值来决定如何适当地处理无效数据。

接下来,让我们创建一个名为 _toggle_error() 的方法,以在小部件中打开或关闭错误状态:

1
2
3
    def _toggle_error(self, error=''):
        self.error.set(error)
        self.config(foreground='red' if error else 'black')

我们将使用这个方法来处理当小部件发生错误或错误被修正时的行为。它首先将我们的错误变量设置为提供的字符串。如果字符串不为空,我们设置一个视觉错误指示器(在这种情况下,将文本变为红色);如果为空,我们关闭视觉指示器。

有了这个方法,我们可以创建我们的 _validate() 方法,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    def _validate(self, char, index, event, action):
        # 重置错误状态
        self._toggle_error()
        valid = True

        # ISO日期只需要数字和连字符
        if event == 'key':
            if action == '0':
                valid = True
            elif index in ('0', '1', '2', '3', '5', '6', '8', '9'):
                valid = char.isdigit()
            elif index in ('4', '7'):
                valid = char == '-'
            else:
                valid = False

这个方法将采用“除非证明有罪,否则无罪”的方法来验证用户输入,因此我们首先关闭任何错误状态,并将有效标志设置为 True。然后,我们开始查看按键事件。if action == '0': 这一行告诉我们用户是否试图删除字符。我们总是希望允许这样做,以便用户可以编辑字段,因此这应该总是返回 True

ISO日期的基本格式是四个数字、一个连字符、两个数字、一个连字符和两个数字。我们可以通过检查插入的字符在插入索引处是否符合我们的期望来测试用户是否遵循这个格式。例如,index in ('0', '1', '2', '3', '5', '6', '8', '9') 这一行将告诉我们字符是否插入到需要数字的位置之一,如果是,我们检查字符是否是数字。索引为4和7的字符应该是连字符。任何其他按键都是无效的。

Tip

尽管你可能会认为它们是整数,但Tkinter实际上将动作代码和字符索引都作为字符串传递。在编写比较时,请务必记住这一点。

虽然这是一个非常天真且无法确保日期正确的启发式方法,因为它允许完全无意义的日期如0000-97-46,或看起来对但仍然错误的日期如2000-02-29,但至少它强制了基本格式,并排除了大量无效的按键输入。一个完全准确的部分日期分析器本身就是一个项目,但就目前而言,这个方法已经足够了。

在焦点移出时检查日期的正确性更简单且更加万无一失,如下所示:

1
2
3
4
5
6
7
# 仍在 DateEntry._validate() 中
elif event == 'focusout':
    try:
        datetime.strptime(self.get(), '%Y-%m-%d')
    except ValueError:
        valid = False
    return valid

由于此时我们可以获取用户最终意图输入的值,因此可以使用 datetime.strptime() 尝试使用格式 %Y-%m-%d 将字符串转换为 Python 的 datetime 对象。如果转换失败,我们就知道日期是无效的。最后,在方法的末尾,我们返回我们的有效标志。如你之前所见,对于按键事件,返回 False 就足以阻止字符插入;但对于焦点事件的错误,我们需要以用户可见的方式做出响应。

这将在我们的 _on_invalid() 方法中处理,如下所示:

1
2
3
def _on_invalid(self, event):
    if event != 'key':
        self._toggle_error('Not a valid date')

我们已经配置了这个方法,使其仅接收事件类型,我们将使用该类型来忽略按键事件(它们已经通过默认行为得到了充分处理)。对于任何其他事件类型,我们将使用 _toggle_error() 方法来设置我们的视觉指示器和错误字符串。

让我们在文件末尾用一个小测试脚本来测试 DateEntry 类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if __name__ == '__main__':
    root = tk.Tk()
    entry = DateEntry(root)
    entry.pack()
    ttk.Label(
        textvariable=entry.error, foreground='red'
    ).pack()
    # 添加这个以便我们可以取消 DateEntry 的焦点
    ttk.Entry(root).pack()
    root.mainloop()

保存文件并运行它以尝试新的 DateEntry 类。尝试输入各种错误的日期或无效的按键,然后点击第二个 Entry 小部件以取消 DateEntry 的焦点,并注意观察会发生什么。

你应该会看到类似这样的结果:

图 5.2:一个带验证功能的 DateEntry 组件提醒日期字符串无效

在我们的GUI中实现验证组件

既然你已经知道如何验证小部件,那么接下来就有得忙了!我们有17个输入小部件,你需要为它们所有编写类似于上一节中展示的验证代码,以实现我们所需的行为。在这个过程中,你需要确保这些小部件在出现错误时能够一致地响应,并为应用程序提供一个一致的API。

如果你觉得这听起来像是想无限期推迟的事情,那我不能怪你。也许有一种方法可以减少我们需要编写的重复代码量。

多继承的力量

到目前为止,我们已经了解到,Python允许我们通过子类化来创建新类,从超类中继承特性,并且只添加或修改新类中不同的部分。Python还支持使用多重继承来构建类,即一个子类可以从多个超类中继承。我们可以利用这一特性来创建所谓的混入类(mixin class)。

混入类仅包含一组特定的功能,我们希望能够将这些功能与其他类“混合”在一起,以组成一个新类。

看看下面的示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Fruit():
    _taste = 'sweet'
    def taste(self):
        print(f'It tastes {self._taste}')

class PeelableMixin():
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._peeled = False
    def peel(self):
        self._peeled = True
    def taste(self):
        if not self._peeled:
            print('I will peel it first')
        self.peel()
        super().taste()

在这个示例中,我们有一个名为 Fruit 的类,它有一个类属性 _taste 和一个 taste() 方法,该方法打印一条消息,指示水果的味道。然后,我们有一个名为 PeelableMixin 的混入类。这个混入类添加了一个实例属性 _peeled,用于指示水果是否已被去皮,以及一个 peel() 方法来更新 _peeled 属性。它还重写了 taste() 方法,以便在品尝之前检查水果是否已被去皮。请注意,即使混入类没有从其他类继承,其 __init__() 方法也调用了超类初始化器。我们稍后会看到这样做的原因。

现在,让我们使用多重继承来创建一个新类,如下所示:

1
2
3
4
5
class Plantain(PeelableMixin, Fruit):
    _taste = 'starchy'
    def peel(self):
        print('It has a tough peel!')
        super().peel()

Plantain 类是由 PeelableMixinFruit 类组合而成的。当我们使用多重继承创建类时,我们指定的最右边的类被称为基类,混入类应该在其之前指定(即,在基类的左边)。因此,在这种情况下,Fruit 是基类。让我们创建一个类的实例并调用 taste(),如下所示:

1
2
plantain = Plantain()
plantain.taste()

如你所见,得到的子类既有 taste() 方法,也有 peel() 方法,但请注意,在所有类中定义了这两个方法的两个版本。当我们调用这些方法之一时,会使用哪个版本?

在多重继承的情况下,super() 做的事情比仅仅代替超类要复杂一些。它使用称为方法解析顺序(MRO)的东西来查找继承链,并确定我们调用的方法的最近类。解析顺序从当前类开始,然后按照从左到右的顺序跟随超类的链直到基类。因此,当我们调用 plantain.taste() 时,会发生一系列方法解析,如下所示:

  • plantain.taste() 被解析为 PeelableMixin.taste()
  • PeelableMixin.taste() 然后调用 self.peel()。由于 self 是一个 Plantain 对象,因此 self.peel() 被解析为 Plantain.peel()
  • Plantain.peel() 打印一条消息并调用 super().peel()。Python 将此调用解析为具有 peel() 方法的最左边的类,即 PeelableMixin.peel()
  • 当返回时,PeelableMixin.taste() 然后调用 super().taste()
  • PeelableMixin 开始的下一个最左边的类是 Fruit,因此这被解析为 Fruit.taste()
  • Fruit.taste() 引用类变量 _taste。即使正在运行的方法在 Fruit 类中,但我们的对象的类是 Plantain,因此这里使用 Plantain._taste

如果这看起来令人困惑,只需记住,self.method()self.attribute 总是首先在当前类中查找 method()attribute,然后按照从左到右的继承类列表查找,直到找到方法或属性。super() 对象也会这样做,只不过它会跳过当前类。

这就是为什么我们在示例中的混入类初始化器中调用 super().__init__() 的原因。

如果没有这个调用,只会调用混入类的初始化器。通过调用 super().__init__(),Python 还会继续沿着 MRO 链向上,并调用基类初始化器。在为 Tkinter 类创建混入时,这一点尤其重要,因为 Tkinter 类的初始化器创建了实际的 Tcl/Tk 对象。

Tip

一个类的方法解析顺序存储在其 __mro__ 属性中;如果你在继承方法或属性上遇到问题,可以在 Python 解释器或调试器中检查这个属性。

请注意,PeelableMixin 本身并不能单独使用:它只有在与具有 taste() 方法的类结合时才能发挥作用。这就是为什么它是一个混入类:它的设计目的是与其他类混合使用以增强功能,而不是单独使用。

Tip

遗憾的是,Python 并没有提供一种在代码中明确标注某个类是混入类或者它必须与哪些类混合使用的方法,因此,一定要为你的混入类做好充分的文档说明。

构建一个验证混入类

让我们运用多重继承的知识来构建一个混入类,这个类将帮助我们创建经过验证的小部件类,同时减少样板代码。打开 data_entry_app.py 文件,并在 Application 类定义的正上方添加新的类:

1
2
3
4
5
6
# data_entry_app.py
class ValidatedMixin:
    """为输入小部件添加验证功能"""
    def __init__(self, *args, error_var=None, **kwargs):
        self.error = error_var or tk.StringVar()
        super().__init__(*args, **kwargs)

我们像往常一样开始定义这个类,不过这次我们没有继承任何类,因为这是一个混入类。构造函数还有一个额外的参数 error_var,这允许我们传入一个用于错误消息的变量;如果我们没有传入,这个类会自己创建一个。记住,调用 super().__init__() 将确保基类初始化程序也会被执行。

接下来,我们设置验证,如下所示:

1
2
3
4
5
6
7
vcmd = self.register(self._validate)
invcmd = self.register(self._invalid)
self.configure(
    validate='all',
    validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'),
    invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d')
)

与之前一样,我们为验证和无效数据处理注册实例方法,然后使用 configure 将它们设置到小部件中。我们将传递所有替换代码(除了 %w,即小部件名称,因为在类上下文中它相当无用)。我们在所有条件下运行验证,以便捕获焦点和按键事件。现在,我们将定义错误条件处理程序:

1
2
def _toggle_error(self, on=False):
    self.configure(foreground=('red' if on else 'black'))

如果存在错误,这将把文本颜色更改为红色,否则为黑色。与我们之前的经过验证的小部件类不同,我们不会在这个函数中设置错误字符串;相反,我们将在验证回调中设置,因为在该上下文中我们会更清楚错误是什么。我们的验证回调将如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def _validate(self, proposed, current, char, event, index, action):
    self.error.set('')
    self._toggle_error()
    valid = True
    # 如果小部件被禁用,则不进行验证
    state = str(self.configure('state')[-1])
    if state == tk.DISABLED:
        return valid
    if event == 'focusout':
        valid = self._focusout_validate(event=event)
    elif event == 'key':
        valid = self._key_validate(
            proposed=proposed,
            current=current,
            char=char,
            event=event,
            index=index,
            action=action
        )
    return valid

由于这是一个混入类,我们的 _validate() 方法实际上并不包含任何验证逻辑。相反,它将从处理一些设置任务开始,比如关闭错误和清除错误消息。然后,它检查小部件是否被禁用,方法是获取小部件状态值的最后一个项目。如果小部件被禁用,那么小部件的值就不重要,因此验证应该总是通过。

之后,该方法根据传入的事件类型调用特定于事件的回调方法。目前,我们只关心 keyfocusout 事件,因此任何其他事件都返回 True。这些特定于事件的方法将在我们的子类中定义,以确定使用的实际验证逻辑。

Tip

注意,我们使用关键字参数调用各个方法;当我们创建子类时,将会覆盖这些方法。通过使用关键字参数,我们的覆盖函数可以仅指定所需的关键字或从 **kwargs 中提取单个参数,而不必按正确的顺序获取所有参数。另外,请注意,所有参数都传递给 _key_validate(),但只有 event 传递给 _focusout_validate()。焦点事件不会为其他任何参数传递有用的信息,因此没有必要将它们一起传递。

接下来,我们将为特定事件的验证方法添加占位符:

1
2
3
4
5
def _focusout_validate(self, **kwargs):
    return True

def _key_validate(self, **kwargs):
    return True

这里的最终想法是,我们的子类只需要根据小部件的需求覆盖 _focusout_validate()_key_validate() 中的一个或两个。如果我们不覆盖它们,它们将返回 True,表示验证通过。

现在,让我们为我们的无效输入处理程序做类似的事情:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def _invalid(self, proposed, current, char, event, index, action):
    if event == 'focusout':
        self._focusout_invalid(event=event)
    elif event == 'key':
        self._key_invalid(
            proposed=proposed,
            current=current,
            char=char,
            event=event,
            index=index,
            action=action
        )

def _focusout_invalid(self, **kwargs):
    """处理焦点事件中的无效数据"""
    self._toggle_error(True)

def _key_invalid(self, **kwargs):
    """处理按键事件中的无效数据。默认情况下,我们不执行任何操作"""
    pass

我们对这些方法采用了相同的方法。不过,与验证方法不同的是,我们的无效数据处理程序不需要返回任何内容。对于无效的按键事件,我们默认不执行任何操作;对于焦点移出事件中的无效输入,我们切换错误状态。

最后,我们想添加一种方法,以便在控件上手动执行验证。按键验证在输入按键的上下文中才有意义,但有时我们可能希望手动运行焦点移出检查,因为它们实际上会检查输入的完整值。让我们通过以下公共方法来实现这一点:

1
2
3
4
5
def trigger_focusout_validation(self):
    valid = self._validate('', '', '', 'focusout', '', '')
    if not valid:
        self._focusout_invalid(event='focusout')
    return valid

在这个方法中,我们只是重复了发生焦点移出事件时的逻辑:运行验证函数,如果失败,则运行无效处理程序。这样就完成了 ValidatedMixin。现在,让我们通过将其应用到一些控件上来看看它是如何工作的。

基于ValidatedMixin构建输入组件

首先,让我们思考一下我们需要用新的 ValidatedMixin 类来实现哪些类:

  • 除了“备注”字段外,所有字段(在未禁用时)都是必填项,因此我们需要一个基本的输入框小部件,如果没有输入内容,则会记录错误。
  • 我们有一个日期字段,因此需要一个输入框小部件来强制输入有效的日期字符串。
  • 我们有多个用于小数或整数输入的旋转框(Spinbox)小部件。我们需要确保这些小部件只接受有效的数字字符串。
  • 我们有几个组合框(Combobox)小部件,它们的行为并不完全符合我们的需求。让我们开始吧!

输入数据

让我们从一个需要输入数据的基本输入框小部件开始。我们可以将其用于“技术人员”和“种子样本”字段。在 ValidatedMixin 类之后添加一个新类:

1
2
3
4
5
6
7
8
9
# data_entry_app.py
class RequiredEntry(ValidatedMixin, ttk.Entry):
    """一个需要输入值的输入框"""
    def _focusout_validate(self, event):
        valid = True
        if not self.get():
            valid = False
            self.error.set('A value is required')
        return valid

在这里不需要进行按键验证,所以我们只需要创建 _focusout_validate() 方法。在该方法中,我们只需要检查输入的值是否为空。如果是空的,我们就设置一个错误信息并返回 False。就这么简单!

创建一个Date组件

接下来,让我们将混合类应用于之前创建的 DateEntry 类,并保持相同的验证算法。在 RequiredEntry 类下方添加以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class DateEntry(ValidatedMixin, ttk.Entry):
    """一个只接受 ISO 日期字符串的输入框"""
    def _key_validate(self, action, index, char, **kwargs):
        valid = True
        if action == '0':  # 这是删除操作
            valid = True
        elif index in ('0', '1', '2', '3', '5', '6', '8', '9'):
            valid = char.isdigit()
        elif index in ('4', '7'):
            valid = char == '-'
        else:
            valid = False
        return valid

    def _focusout_validate(self, event):
        valid = True
        if not self.get():
            self.error.set('A value is required')
            valid = False
        try:
            datetime.strptime(self.get(), '%Y-%m-%d')
        except ValueError:
            self.error.set('Invalid date')
            valid = False
        return valid

在这个类中,我们再次简单地重写了按键和失去焦点时的验证方法,这次是将我们之前 DateEntry 小部件中使用的验证逻辑复制过来。_focusout_validate() 方法还包含了 RequiredEntry 类中的逻辑,因为日期值是必填的。

这两个类的创建都相当简单;接下来让我们看看更复杂的内容。

更好的Combobox组件

不同工具包或控件集中的下拉控件在鼠标操作方面的行为相当一致,但对按键的响应则各不相同。例如:

  • 有些不做任何反应,如 Tkinter 的 OptionMenu
  • 有些需要使用箭头键来选择项目,如 Tkinter 的 ListBox
  • 有些会跳转到以按下的任意键开头的第一个条目,并在后续按下时循环浏览以该字母开头的条目。
  • 有些会缩小列表范围,仅显示与输入内容匹配的条目。

我们需要考虑我们的 Combobox 控件应具有的行为。由于我们的用户习惯使用键盘进行数据输入,并且有些人使用鼠标有困难,因此该控件需要能够与键盘良好配合。让他们重复使用按键来选择选项也不太直观。在与数据输入人员沟通后,您决定采用以下行为:

  • 如果建议的文本与任何条目都不匹配,则忽略按键。
  • 当建议的文本与单个条目匹配时,控件设置为该值。
  • 删除键或退格键清除整个框。

让我们看看是否可以通过验证来实现这一点。在 DateEntry 定义之后添加另一个类:

1
2
3
4
5
6
7
class ValidatedCombobox(ValidatedMixin, ttk.Combobox):
    """一个仅从字符串列表中取值的组合框"""
    def _key_validate(self, proposed, action, **kwargs):
        valid = True
        if action == '0':
            self.set('')
            return True

_key_validate() 方法首先设置一个有效标志,并快速检查是否为删除操作。如果是,我们将值设置为空字符串并返回 True。这样就满足了最后一个要求。现在,我们将添加逻辑以将建议的文本与我们的值进行匹配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
        values = self.cget('values')
        # 对输入的文本进行不区分大小写的匹配
        matching = [
            x for x in values
            if x.lower().startswith(proposed.lower())
        ]
        if len(matching) == 0:
            valid = False
        elif len(matching) == 1:
            self.set(matching[0])
            self.icursor(tk.END)
            valid = False
        return valid

使用控件的 cget() 方法获取其值列表的副本。然后,我们使用列表推导式将该列表缩减为仅以建议文本开头的条目。为了使匹配不区分大小写,我们在比较之前对列表项中的值和建议文本都调用 lower()

Tip

每个 Tkinter 控件都支持 cget() 方法。它可以用来按名称检索控件的任何配置值。

如果匹配列表的长度为0,则表示没有条目以输入的值开头,我们将拒绝该按键。如果长度为1,则我们找到了匹配项,因此将变量设置为该值。这是通过调用控件的 set() 方法并传入匹配值来完成的。作为最后一步,我们将使用组合框的 .icursor() 方法将光标移动到字段的末尾。这一步虽然不是严格必要的,但比将光标留在文本中间看起来更好。请注意,即使值成功匹配,我们也将 valid 设置为 False;因为我们自己将值设置为匹配项,所以希望阻止对控件的任何进一步输入。否则,建议的按键将被追加到我们设置的值的末尾,从而创建无效的输入。

另外,如果匹配列表包含多个值,该方法将仅返回 True,允许用户继续输入并过滤列表。接下来,让我们添加失去焦点时的验证器:

1
2
3
4
5
6
def _focusout_validate(self, **kwargs):
    valid = True
    if not self.get():
        valid = False
        self.error.set('A value is required')
    return valid

在这里我们不需要做太多工作,因为按键验证方法确保了唯一可能的值是空字段或来自值列表的项。但是,由于所有字段都需要一个值,我们将从 RequiredEntry 中复制验证逻辑。

这样就处理好了我们的组合框(Combobox)控件。接下来,我们将处理旋转框(Spinbox)控件。