首页 > 解决方案 > tkinter:绑定事件中奇怪的操作顺序,如何更改?

问题描述

这与使用 Tkinter 控制回调顺序有关,但并不完全相同

我有一个条目字段(tk.Entry)和一个复选框字段(tk.Checkbutton)。我绑定到Entry 的FocusOut,并且复选框被赋予一个command= 以在它被选中时运行。这俩都被解雇了,但是...

在许多情况下,用户输入文本,并且知道我处理 FocusOut 作为提交文本更改的一种方式,他不会费心点击 Return。他只是去点击相关的复选框,确保文本更改将在复选框更改状态之前提交。这很重要;文本更改和复选标记状态都会在发生时发送到服务器,服务器将根据文本确定复选标记状态是否合法。

问题是“当它们发生时”。无论出于何种原因,tkinter 决定首先触发复选标记更改,然后才开始在文本字段上触发 FocusOut。我在 ubuntu 上,但我(和我的用户)已经习惯了 Windows SDK 应用程序,这不会发生。结果是,当文本(据被告知)处于错误状态、采用错误路径、发生邪恶事件、用户咬牙切齿、我受到牙医账单的威胁等时,服务器会看到已设置的复选框。

这对我来说没有意义。复选框应该必须获得焦点才能处理点击;为了获得焦点,Entry widgit 应该不得不放弃它。但 tkinter 显然不同意。

我尝试在复选标记的命令处理程序中将焦点设置为复选标记,但显然为时已晚。tkinter 不会在那个时候停下来打电话给 FocusOut。理论上我可以让复选标记命令处理程序到达文本框并首先发送该内容,但由于复杂的原因,复选标记的代码甚至不知道文本字段的存在,并且更改它会非常困难。我大概可以让复选标记命令在 50 毫秒后排队一个“之后”操作以发送复选标记状态,但是我正在与快速手指的用户竞争。

在我看来,这是一个 tkinter 错误 - 单击时 checkbutton 接收焦点,处理空间作为切换的方式,就像其他任何可以获取焦点的东西一样。在我看来,好像 tkinter 通过以错误的顺序报告焦点来短暂断言焦点属于 2 个小工具。但是假设 tkinter 不会改变,并且在某些知识用户不会停止并在编辑每个文本字段后按 Return 的情况下,是否有一些巧妙的解决方法?

根据要求,代码。对长度感到抱歉,但我想保留所有绑定以防万一。按照显示的说明进行操作,结果将显示在标题栏(和标准输出)中。

"""See line 130 to enable the hackAround
Python 3.6.7 linux mint
"""

import tkinter as tk
from tkinter import ttk
from tkinter import font

myFont = None
zerowidth = 10
tkinterHandle = None
firstThingsFirst = False

#infrastructure, ignore
class About:
    def __init__(self, isA, info):
        self.typeIs = isA

#infrastructure, ignore
class Cmd:
    def __init__(self, name, about, text):
        self.about = about
        self.name = name
        self.text = text

#holds a widget, base class of specific widget types
class WidgetHolder: 
    def __init__(self, name, about):
        self.widgit = None
        self.name = name
        self.about = about
        self.parentW = self.about.widgit 

    def canTakeFocus(self):
        try: #apparently not everything has a 'state'.
            if self.widgit['state'] == "disabled":
                return False
        except: #sigh
            pass
        return isinstance(self.widgit, tk.Entry) or isinstance(self.widgit, ttk.Button)\
              or isinstance(self.widgit, ttk.Combobox) or isinstance(self.widgit, tk.Checkbutton)\
              or isinstance(self.widgit, ScrolledText)

    def set(self, t):
        self.apply(t)

    def doEnable(self, b):
        pass

    def setErrorColor(self):
        pass

#Holds an Entry
class SingleLineInputOnAPage(WidgetHolder):
    def __init__(self, c):
        WidgetHolder.__init__(self, c.name, c.about)
        self.txt = tk.StringVar()
        vcmd = (self.parentW.register(self.onValidate), '%P')

        #key, not mouse or something?
        self.widgit = tk.Entry(self.parentW,   validate="key",
                          validatecommand=vcmd, textvariable=self.txt, font=myFont, width=0)
        self.savedText = c.text
        self.apply(c.text)
        self.widgit.bind("<Return>", (lambda event: self.timeToSend()))
        self.widgit.bind("<Key-ISO_Left_Tab>", (lambda event: self.doNot()))
        self.widgit.bind("<Tab>", (lambda event: self.doNot()))
        self.widgit.bind("<Button>", (lambda event: self.click(event)))
        self.widgit.bind("<FocusIn>", (lambda event: self.save()))
        self.widgit.bind("<FocusOut>", (lambda event: self.timeToSendMaybe()))
        self.widgit.bind("<Escape>", (lambda event: self.gotEsc()))
        self.widgit.bind("<Control-s>", (lambda event: self.timeToSend()))
        self.widgit.bind("<Control-a>", (lambda event: self.selectAll()))

    def gotEsc(self):
        pass
    def doNot(self):
        print("Reproduing the problem does not involve tab!")

    def save(self):
        self.savedText = self.txt.get()

    def timeToSendMaybe(self):
        self.timeToSend()

    def click(self, event):
        pass

    def setErrorColor(self):
        self.widgit.config(background="#ff5000")

    def selectAll(self):
        self.widgit.select_range(0, 'end')
        return 'break'

    def timeToSend(self):
        self.savedText = self.txt.get()
        global firstThingsFirst
        firstThingsFirst = True
        print("Ideally this happens first. Entry text would be sent now:", self.savedText)
        return 'break'

    def apply(self, t):
        self.savedText = t
        self.txt.set(t)

    def onValidate(self, P):
      return True

#Holds a checkbox
class CheckboxOnAPage(WidgetHolder):
    def __init__(self, c, cmd):
        WidgetHolder.__init__(self, c.name, c.about)
        if cmd == None:
            cmd = self.fire
        self.value = tk.IntVar()
        self.widgit = tk.Checkbutton(self.parentW, text="", variable=self.value, command=self.fire)
        self.apply(c.text)
        #self.widgit.bind("<Key-ISO_Left_Tab>", (lambda event: self.timeToSendMaybeAndPrev()))
        #self.widgit.bind("<Tab>", (lambda event: self.timeToSendMaybeAndNext()))

    def apply(self, t):
        pass

    def fire(self):
        self.widgit.focus()
        v = "X"[0:self.value.get()]

        #to get things in a better order, set to True
        if False:
          global tkinterHandle #clumsy, is there a way to get  self.widgit from the tk instance? 
          tkinterHandle.after(30, lambda sendthis=v: self.push(sendthis))
        else:
          self.push(v)

    def push(self, v):
        print("Checkmark ", v, " would be sent now. Ideally this does not happen first")
        global firstThingsFirst
        if firstThingsFirst:
          tkinterHandle.title("In order")
        else:
          tkinterHandle.title("Out of order!")


class CharSheet(tk.Tk):
    def __init__(self, argThing, *args, **kwargs):
        global myFont
        global zerowidth
        global tkinterHandle 

        tk.Tk.__init__(self, *args, **kwargs)
        self.geometry("360x80")
        self.title("Out of order?")

        myFont = tk.font.Font(family="Courier", size=10)
        zerowidth=myFont.measure("0")
        tkinterHandle = self
        container = tk.Frame(self)
        container.pack(side="top", fill="both", expand = True)

        abt2 = About("C", "")
        abt2.widgit = container
        cmd2 = Cmd("b", abt2, "give me focus; then click checkbox")
        w2 = SingleLineInputOnAPage(cmd2)
        w2.widgit.pack()

        abt = About("C", "")
        abt.widgit = container
        cmd = Cmd("a", abt, "")
        w1 = CheckboxOnAPage(cmd, None)
        w1.widgit.pack()

CharSheet(None).mainloop()

标签: tkinterfocus

解决方案


在我看来,这是一个 tkinter 错误 - 单击时检查按钮会获得焦点,

不,这不是 tkinter 错误。Tkinter 正在按设计执行。单击时,标准检查按钮不会获​​得焦点。但是,ttk 复选按钮可以。

在我看来,好像 tkinter 通过以错误的顺序报告焦点来短暂断言焦点属于 2 个小工具。

这是一个不正确的评估。一次不可能将焦点集中在一个以上的小部件上。

问题是您明确地将焦点设置为检查按钮,但您没有让 tkinter 事件循环有机会<FocusOut>在继续之前处理事件。因此,即使您认为焦点在复选按钮上,但事实并非如此。在 tkinter 有机会处理更改焦点的请求之前,焦点无法更改。

一个快速、hacky 的解决方案是update在更改焦点后调用,这让 tkinter 有机会处理所有未决事件。但是,update如果存在可能导致再次调用处理程序的未决事件,则调用可能会很棘手。我个人的经验法则是update除非绝对必要,否则永远不要打电话。

如果您的主要目标是将焦点更改为检查按钮(从而强制在<FocusOut>单击检查按钮之前处理条目小部件绑定),您可以在小部件类 ( Checkbutton) 上创建一个绑定,该绑定在按钮处理程序之前处理。那,或使用自动更改焦点的 ttk 检查按钮。

一个适当的最小可重现示例

我不会向您展示如何更改您的代码,因为它太复杂而无法作为一个好的最小示例。相反,这是一个说明问题的程序。

从这个基本代码开始,它作为您观察到的问题的最小可重现示例。请注意,当您单击条目然后单击复选按钮时,文本小部件中显示的顺序是先单击复选按钮,然后更改焦点。

import tkinter as tk
from tkinter import ttk

class Example():
    def __init__(self):
        self.root = tk.Tk()
        self.entry = tk.Entry(self.root)
        self.cb = tk.Checkbutton(self.root, command=self.handle_checkbutton, text="Click me")
        self.text = tk.Text(self.root, height=8, width=80, background="bisque")
        self.vsb = tk.Scrollbar(self.root, command=self.text.yview)
        self.text.configure(yscrollcommand=self.vsb.set)

        self.entry.pack(side="top", fill="x")
        self.cb.pack(side="top", anchor="w")
        self.vsb.pack(side="right", fill="y")
        self.text.pack(side="bottom", fill="both", expand=True)

        self.entry.insert(0, "click here, then click on the checkbutton")

        self.entry.bind("<FocusOut>", self.handle_focus_out)
        self.entry.focus_set()

    def handle_focus_out(self, event):
        self.text.insert("end", "received entry <FocusOut>\n")
        self.text.see("end")

    def handle_checkbutton(self):
        self.cb.focus_set()
        self.text.insert("end", "received checkbutton command\n")
        self.text.see("end")

e = Example()
tk.mainloop()

解决方案一:调用update强制tkinter处理焦点变化

self.root.update()在此解决方案中,在更改焦点后立即添加调用。这将使 tkinter 有机会在继续处理其余代码之前处理焦点更改。

def handle_checkbutton(self):
    self.cb.focus_set()
    self.root.update()
    self.text.insert("end", "received checkbutton command\n")
    self.text.see("end")

解决方案 2:使用 ttk 复选按钮

update您可以使用 ttk 复选按钮,而不是调用。它具有在单击时将焦点设置到复选按钮的内置行为。

首先,修改处理程序以删除管理焦点的代码:

def handle_checkbutton(self):
    self.text.insert("end", "received checkbutton command\n")
    self.text.see("end")

然后,使用ttk.Checkbuttoninstad tk.Checkbutton

self.cb = ttk.Checkbutton(self.root, command=self.handle_checkbutton, text="Click me")

这里的优点是您不必编写代码来管理焦点。

解决方案 3:将焦点处理添加到 tk Checkbutton 类

第三种解决方案是向 checkbutton 类添加一个绑定,以模仿 ttk checkbutton 的焦点窃取行为。这里的优点是您只需设置一次绑定,它将应用于 UI 中的每个检查按钮。

首先,添加以下处理程序:

def set_cb_focus(self, event):
    self.text.insert("end", "setting focus via class binding\n")
    self.text.see("end")
    event.widget.focus_set()

接下来,在__init__. 请注意,检查按钮的内置行为也是通过类绑定完成的。为了不删除该行为,我们需要将其包含add=Truebind命令中,该命令会添加新行为而不是替换内置行为。

self.root.bind_class("Checkbutton", "<ButtonPress-1>", self.set_cb_focus)

最后,将代码切换回使用 atk.Checkbutton而不是ttk.Checkbutton

self.cb = tk.Checkbutton(self.root, command=self.handle_checkbutton, text="Click me")

推荐阅读