Reputation: 111
I wanted to monitor when the text in a tkinter Text
widget was modified so that a user could save any new data they had entered. Then on pressing 'Save' I wanted to reset this.
I bound the Text
widget's <<Modified>>
event to a function so that making any changes to the text would update the 'Save' button from 'disabled'
to 'normal'
state. After hitting the Save button I ran a function which reset the modified
flag and disabled the Save button again until further changes were made.
But I found that it seemed to only fire the event once. Hitting Save didn't reset the button to a 'disabled'
state, and editing the text didn't seem to affect the Save button's state either after the first time.
Below is a minimal example to show how the flag doesn't seem to be reset.
import tkinter as tk
root = tk.Tk()
def text_modified(event=None):
status_label.config(text="Modified = True")
def reset():
if not text_widget.edit_modified():
return
status_label.config(text="Modified = False")
text_widget.edit_modified(False)
text_widget = tk.Text(root, width=30, height=5)
text_widget.pack()
text_widget.bind("<<Modified>>", text_modified)
status_label = tk.Label(root, text="Modified = False")
status_label.pack()
reset_btn = tk.Button(root, text="Reset", command=reset)
reset_btn.pack()
root.mainloop()
Upvotes: 2
Views: 839
Reputation: 250
Using tkinter to solve something as basic as "...to monitor when the text in a tkinter Text
widget was modified..." can be challenging.
The built-in event types of the Text widget allow achieving some things, but not exactly what we want. Firstly, there are the <Key>
and <KeyPress>
event types, but these cause a callback to run before the content of the text box has been modified by the key press, which is useless in most use cases. Executing .get("1.0", "end")
inside the callback yields the old content without a modification caused by the key press. Secondly, there is the <KeyRelease>
event type, but in this one the callback runs too late for most use cases, and the keyboard typing experience for the user can be inadequate. Thirdly, there's the <<Modified>>
event type, but this event type only causes the callback to run sometimes, as you have described in your question.
One solution may be to implement a chain of two callbacks, the chain including: a first callback to handle <Key>
events and executing a after_idle
command pointing to a second callback; and the second callback where the modified text is worked on.
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
class TextBox(ScrolledText):
def __init__(self, *args, **kwargs) -> None:
ScrolledText.__init__(self, *args, **kwargs)
# The next two lines are needed for the two callbacks.
self._execution_id = "" # this one is needed for the first callback
self.edit_modified(False) # and this one is need for the second callback
self.bind("<Key>", self.on_key) # Binding to <KeyPress> may work too.
# First callback:
def on_key(self, event: tk.Event) -> None:
# Firstly, we start by cancelling any scheduled executions of the second
# callback that have not yet been executed.
if self._execution_id:
self.after_cancel(self._execution_id)
# Secondly, we schedule an execution of the second callback using the
# `after_idle` command. (see https://tcl.tk/man/tcl8.5/TclCmd/after.htm#M9)
self._execution_id = self.after_idle(self.on_modified, event)
# Second callback:
def on_modified(self, event: tk.Event) -> None:
# Firstly, we use the built-in `edit_modified` method to check if the
# content of the text widget has changed. (see https://tcl.tk/man/tcl8.5/TkCmd/text.htm#M93)
# Otherwise, we abort the second callback. This is necessary to filter out
# key presses that do not change the contents of the Text widget, such as
# pressing arrow keys, Ctrl, Alt, etc.
if not self.edit_modified():
return
# Secondly, we do whatever we want with the content of the Text widget.
content = self.get("1.0", "end")
print("! on_modified:", repr(content))
# Thirdly, we set the built-in modified flag of the Text widget to False.
self.edit_modified(False)
if __name__ == "__main__":
root = tk.Tk()
text = TextBox(root, height=10, width=50)
text.pack(fill="both", expand=True, padx=10, pady=10)
text.focus_set()
root.mainloop()
This chain of two callbacks (C2C) approach works with basic text editing key presses, such as pressing letter keys. Importantly (at least for my use cases), this approach works without raising ugly Exceptions when you Ctrl+V something into the text widget (independently if a text selection is set within the text widget). I have tried other approaches (self titled "Tk voodoo") that use the rename
command and generate virtual events through a proxy, but those approaches couldn't handle Ctrl+V, and my source became an incomprehensible mess quickly (or at least quicker than usual!).
It's quite nice that this C2C approach requires few lines of code, and it uses public methods of tkinter without going too deep into the inner machinery of tkinter. A disadvantage may be that it requires the self._execution_id
attribute, which can pollute the namespace of the TextBox class. Maybe, there's a better way of doing it.
Upvotes: 0
Reputation: 111
It turns out that binding the <<Modified>>
event to a function means that the function will run not when the Text
widget text is changed, but whenever the modified
flag is changed - whether it changes to True
or to False
. So my Save button was saving the data, disabling itself, and resetting the modified
flag to False
, and this flag change fired the <<Modified>>
event, which was bound to a function which enabled the Save button again.
Here's a minimal example which shows what's going on. We just need to adjust the function we've bound the <<Modified>>
event to so that it deals with modified
being False
as well:
import tkinter as tk
root = tk.Tk()
def modified_flag_changed(event=None):
if text_widget.edit_modified():
status_label.config(text="Modified = True")
print("Text modified")
else:
print("Modified flag changed to False")
def reset():
if not text_widget.edit_modified():
print("Doesn't need resetting")
return
status_label.config(text="Modified = False")
text_widget.edit_modified(False)
print('Reset')
text_widget = tk.Text(root, width=30, height=5)
text_widget.pack()
text_widget.bind("<<Modified>>", modified_flag_changed)
status_label = tk.Label(root, text="Modified = False")
status_label.pack()
reset_btn = tk.Button(root, text="Reset", command=reset)
reset_btn.pack()
root.mainloop()
Upvotes: 4