Tkinter: set StringVar after <Key> event, including the key pressed
Asked Answered
L

2

6

Every time a character is entered into a Text widget, I want to get the contents of that widget and subtract its length from a certain number (basically a "you have x characters left" deal).

But the StringVar() is always one event behind. This is, from what I gather, because the event is processed before the character is entered into the Text widget. This means that if I have 3 characters in the field and I enter a 4th, the StringVar is updated but is still 3 characters long, then it updates to 4 when I enter a 5th character.

Is there a way to keep the two in line?

Here's some code. I removed irrelevant parts.

def __init__(self, master):
    self.char_count = StringVar()
    self.char_count.set("140 chars left")

    self.post_tweet = Text(self.master)
    self.post_tweet.bind("<Key>", self.count)
    self.post_tweet.grid(...)

    self.char_count = Label(self.master, textvariable=self.foo)
    self.char_count.grid(...)

def count(self):
    self.x = len(self.post_tweet.get(1.0, END))
    self.char_count.set(str(140 - self.x))
Labium answered 2/4, 2013 at 18:36 Comment(3)
Can you post some code? It's hard to understand what you want and what you've tried without seeing anything.Pet
No, the StringVar isn't one event behind. The problem is that the default handler for the <Key> event—the one that updates the variable—runs after your handler, instead of before. You can see this is you just check the variable from any other event after your <Key> event.Redon
I think you might have another problem here. IIRC, a default Text widget has an implicit newline at the end. So, when you do that self.post_tweet.get(1.0, END)), don't you also get one character too many?Redon
W
4

A simple solution is to add a new bindtag after the class binding. That way the class binding will fire before your binding. See this answer to the question How to bind self events in Tkinter Text widget after it will binded by Text widget? for an example. That answer uses an entry widget rather than a text widget, but the concept of bindtags is identical between those two widgets. Just be sure to use Text rather than Entry where appropriate.

Another solution is to bind on KeyRelease, since the default bindings happen on KeyPress.

Here's an example showing how to do it with bindtags:

import Tkinter as tk

class Example(tk.Frame):
    def __init__(self, master):
        tk.Frame.__init__(self, master)

        self.post_tweet = tk.Text(self)
        bindtags = list(self.post_tweet.bindtags())
        bindtags.insert(2, "custom") # index 1 is where most default bindings live
        self.post_tweet.bindtags(tuple(bindtags))

        self.post_tweet.bind_class("custom", "<Key>", self.count)
        self.post_tweet.grid()

        self.char_count = tk.Label(self)
        self.char_count.grid()

    def count(self, event):
        current = len(self.post_tweet.get("1.0", "end-1c"))
        remaining = 140-current
        self.char_count.configure(text="%s characters remaining" % remaining)

if __name__ == "__main__":
    root = tk.Tk()
    Example(root).pack(side="top", fill="both", expand=True)
    root.mainloop()
Wiedmann answered 2/4, 2013 at 20:52 Comment(3)
Alright, it worked fine for me. Mostly. I discovered that the default widget name was something to the tune of ".4456585", rather than what I named the instance of the widget. Still all worked fine, but when I ran len(self.text.get(1.0, END), it was 1 bigger than expected. So 'hello' would be 6 characters long rather than 5. I solved this by just subtracting 1 and all is fine, but I'm curious about why this happens in the first place.Labium
@RoryByrne: the text widget always adds an extra newline, to guarantee there's always at least one newline. It seems odd, but it really does make a lot of things simpler. Whenever you pull text from the widget you should use "end-1c".Wiedmann
That makes a lot of sense. Thanks for all the help both. @RedonLabium
R
3

Like most events in Tk, your <Key> handler is fired before the event is processed by the built-in bindings, rather than after. This allows you to, for example, prevent the normal processing from happening, or change what it does.

But this means that you can't access the new value (whether via a StringVar, or just by calling entry.get()), because it hasn't been updated yet.


If you're using Text, there's a virtual event <<Modified>> that gets fired after the "modified" flag changes. Assuming you weren't using that flag for another purpose (e.g., in a text editor, you might want to use it to mean "enable the Save button"), you can use it to do exactly what you want:

def count(self, event=None):
    if not self.post_tweet.edit_modified():
        return
    self.post_tweet.edit_modified(False)
    self.x = len(self.post_tweet.get(1.0, END))
    self.char_count.set(str(140 - self.x))

# ...

self.post_tweet.bind("<<Modified>>", self.count)

Usually, when you want something like this, you want an Entry rather than a Text. Which provides a much nicer way to do this: validation. As with everything beyond the basics in Tkinter, there's no way you're going to figure this out without reading the Tcl/Tk docs (which is why the Tkinter docs link to them). And really, even the Tk docs don't describe validation very well. But here's how it works:

def count(self, new_text):
    self.x = len(new_text)
    self.char_count.set(str(140 - self.x))
    return True

# ...

self.vcmd = self.master.register(self.count)
self.post_tweet = Edit(self.master, validate='key',
                       validatecommand=(self.vcmd, '%P'))

The validatecommand can take a list of 0 or more arguments to pass to the function. The %P argument gets the new value the entry will have if you allow it. See VALIDATION in the Entry manpage for more details.

If you want the entry to be rejected (e.g., if you want to actually block someone from entering more than 140 characters), just return False instead of True.


By the way, it's worth looking over the Tk wiki and searching for Tkinter recipes on ActiveState. It's a good bet someone's got wrappers around Text and Entry that hide all the extra stuff you have to do to make these solutions (or others) work so you just have to write the appropriate count method. There might even be a Text wrapper that adds Entry-style validation.

There are a few other ways you could do this, but they all have downsides.

Add a trace to hook all writes to a StringVar attached to your widget. This will get fired by any writes to the variable. I guarantee that you will get the infinite-recursive-loop problem the first time you try to use it for validation, and then you'll run into other more subtle problems in the future. The usual solution is to create a sentinel flag, which you check every time you come into the handler to make sure you're not doing it recursively, and then set while you're doing anything that can trigger a recursive event. (That wasn't necessary for the edit_modified example above because we could just ignore anyone setting the flag to False, and we only set it to False, so there's no danger of infinite recursion.)

You can get the new char (or multi-char string) out of the <Key> virtual event. But then, what do you do with it? You need to know where it's going to be added, which character(s) it's going to be overwriting, etc. If you don't do all the work to simulate Entry—or, worse, Text—editing yourself, this is no better than just doing len(entry.get()) + 1.

Redon answered 2/4, 2013 at 19:13 Comment(3)
While I don't understand quite how validation works, it does the trick here if I use an Entry widget. The problem is that I'm using a Text widget (and I can't use an Entry widget instead), and so I get "TclError: unknown option '-validatecommand'". Is there a way to get validation to work with the Text widget? I can try look into your other suggestions but I don't want to get in over my head.Labium
This solution can't work for the text widget, as it doesn't support the validation options.Wiedmann
Sorry, without seeing your code, I assumed you were looking for a very short string, which is an obvious case for Entry. For entering tweets, yeah, you want Text, and there's no validation there. It's possible that the Tk wiki has a way to apply Entry-validation to Text, but otherwise… let me think about it and write up something appropriate.Redon

© 2022 - 2024 — McMap. All rights reserved.