9

Using Python 3.3 platform independent for this question.

For the Entry widget, you can bind a variable to this widget's text contents like so (note the textvariable parameter in Entry constructor):

var = tkinter.StringVar()
entryField = tkinter.Entry(master, textvariable=var)
e.pack()

var.set("a new value") # entryField text now updated with this value
s = var.get() # whatever text now appears in entryField

For the Text widget however, there is no such variable binding feature. Class Text definition should likely begin at line 2927 in %python dir%/Lib/tkinter/__init__.py for Python 3.3 in Windows releases if interested.

How can I best emulate this variable binding feature with the Text widget? My idea is to bind a tkinter.StringVar to a Text widget and just get/set all text.

Update:

I ended up inheriting tkinter.Frame as a Text wrapper which takes in a textvariable constructor parameter expected as a tkinter.Variable instance. The only reason in my example below why I didn't inherit from Text is just because I wanted a scrollbar too, but that's not important.

The following is my experimental code. For exact relevance to my original question and how the problem was resolved (?), the important lines are self.textvariable.get = self.GetText and self.textvariable.set = self.SetText. Basically, I'm overriding the passed-in tkinter.Variable object's get and set methods to my own devices...

class TextExtension( tkinter.Frame ):
    """Extends Frame.  Intended as a container for a Text field.  Better related data handling
    and has Y scrollbar now."""


    def __init__( self, master, textvariable = None, *args, **kwargs ):
        self.textvariable = textvariable
        if ( textvariable is not None ):
            if not ( isinstance( textvariable, tkinter.Variable ) ):
                raise TypeError( "tkinter.Variable type expected, {} given.".format( type( textvariable ) ) )
            self.textvariable.get = self.GetText
            self.textvariable.set = self.SetText

        # build
        self.YScrollbar = None
        self.Text = None

        super().__init__( master )

        self.YScrollbar = tkinter.Scrollbar( self, orient = tkinter.VERTICAL )

        self.Text = tkinter.Text( self, yscrollcommand = self.YScrollbar.set, *args, **kwargs )
        self.YScrollbar.config( command = self.Text.yview )
        self.YScrollbar.pack( side = tkinter.RIGHT, fill = tkinter.Y )

        self.Text.pack( side = tkinter.LEFT, fill = tkinter.BOTH, expand = 1 )


    def Clear( self ):
        self.Text.delete( 1.0, tkinter.END )


    def GetText( self ):
        text = self.Text.get( 1.0, tkinter.END )
        if ( text is not None ):
            text = text.strip()
        if ( text == "" ):
            text = None
        return text


    def SetText( self, value ):
        self.Clear()
        if ( value is not None ):
            self.Text.insert( tkinter.END, value.strip() )

Side note: It's probably pretty obvious I'm coming from a different language based on spacing. I'm sorry, I can't help it.

I think I answered my own question. Whether or not this is the right thing to do to override the known methods of tkinter.Variable objects passed into my functions like I just did is a separate question I'll have to ask/research even though this is a private bit of code that will never be used outside my app. And I acknowledge that this does beg the question whether this is an effective solution at all.

bob-the-destroyer
  • 3,164
  • 2
  • 23
  • 30

3 Answers3

13

If you're willing to live dangerously, it's possible to hook in to the internals of the text widget, and have it call a function whenever the contents change, regardless of how it changed.

The trick is to replace the underlying tk widget command with a proxy. This proxy is responsible for doing whatever the real text widget would do, then send a virtual event if what it did was insert or delete text.

With that in place, it's just a matter of setting up a binding to that event, and putting a read trace on the variable. Of course, if you try inserting widgets or images into the text they won't be reflected in the textvariable.

Here's a quick and dirty example, not tested at all in anything real. This uses the same technique that I used to implement line numbers in a text widget (see https://stackoverflow.com/a/16375233)

import Tkinter as tk
import random
import timeit

class TextWithVar(tk.Text):
    '''A text widget that accepts a 'textvariable' option'''
    def __init__(self, parent, *args, **kwargs):
        try:
            self._textvariable = kwargs.pop("textvariable")
        except KeyError:
            self._textvariable = None

        tk.Text.__init__(self, parent, *args, **kwargs)

        # if the variable has data in it, use it to initialize
        # the widget
        if self._textvariable is not None:
            self.insert("1.0", self._textvariable.get())

        # this defines an internal proxy which generates a
        # virtual event whenever text is inserted or deleted
        self.tk.eval('''
            proc widget_proxy {widget widget_command args} {

                # call the real tk widget command with the real args
                set result [uplevel [linsert $args 0 $widget_command]]

                # if the contents changed, generate an event we can bind to
                if {([lindex $args 0] in {insert replace delete})} {
                    event generate $widget <<Change>> -when tail
                }
                # return the result from the real widget command
                return $result
            }
            ''')

        # this replaces the underlying widget with the proxy
        self.tk.eval('''
            rename {widget} _{widget}
            interp alias {{}} ::{widget} {{}} widget_proxy {widget} _{widget}
        '''.format(widget=str(self)))

        # set up a binding to update the variable whenever
        # the widget changes
        self.bind("<<Change>>", self._on_widget_change)

        # set up a trace to update the text widget when the
        # variable changes
        if self._textvariable is not None:
            self._textvariable.trace("wu", self._on_var_change)

    def _on_var_change(self, *args):
        '''Change the text widget when the associated textvariable changes'''

        # only change the widget if something actually
        # changed, otherwise we'll get into an endless
        # loop
        text_current = self.get("1.0", "end-1c")
        var_current = self._textvariable.get()
        if text_current != var_current:
            self.delete("1.0", "end")
            self.insert("1.0", var_current)

    def _on_widget_change(self, event=None):
        '''Change the variable when the widget changes'''
        if self._textvariable is not None:
            self._textvariable.set(self.get("1.0", "end-1c"))


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

        self.textvar = tk.StringVar()
        self.textvar.set("Hello, world!")

        # create an entry widget and a text widget that
        # share a textvariable; typing in one should update
        # the other
        self.entry = tk.Entry(self, textvariable=self.textvar)
        self.text = TextWithVar(self,textvariable=self.textvar, 
                                borderwidth=1, relief="sunken", 
                                background="bisque")

        self.entry.pack(side="top", fill="x", expand=True)
        self.text.pack(side="top",fill="both", expand=True)

if __name__ == "__main__":
    root = tk.Tk()
    Example(root).pack(fill="both", expand=True)
    root.mainloop()
Community
  • 1
  • 1
Bryan Oakley
  • 370,779
  • 53
  • 539
  • 685
  • 3
    Tcl. It's the technology that powers tkinter. Tkinter is just a thin interface over an embedded tcl interpreter. – Bryan Oakley Feb 05 '14 at 00:15
  • Any idea why TextWithVar would not allow elements to be placed on top of it? I tried placing a button at the top right of it, but the button gravitates to the bottom instead of overlapping, even when given sticky='NE'. When I do the same thing with the normal Text element, the button overlaps just fine. It's almost like the TextWithVar is somehow ignoring the grid. – Sam Krygsheld Aug 17 '16 at 21:16
  • @SamKrygsheld: I can think of nothing that would cause that to happen. My guess is that you're doing something slightly differently between the two. A `TextWithVar` _is_ a `Text`, just with extra methods. Nothing has been taken away. – Bryan Oakley Aug 17 '16 at 21:24
  • @SamKrygsheld: comments aren't a place for extended discussions.If you have a specific question, click the "New Question" button. – Bryan Oakley Aug 17 '16 at 23:11
  • http://stackoverflow.com/questions/39019667/different-grid-behavior-from-inherited-tkinter-text-element – Sam Krygsheld Aug 18 '16 at 13:40
  • @SamKrygsheld: it is a bug in the code. I've updated the code. – Bryan Oakley Aug 18 '16 at 15:40
  • @BryanOakley: this is **great**, I can't thank you enough. You're the tkinter king! – adder Oct 13 '17 at 18:22
3

I saw that the class proposed in the question didn't actually handle the textvariable like typical Tkinter widgets do, so I took it upon myself to do somewhat of a rewrite to make it a "proper" widget. :-)

Usually, a textvariable instance isn't tinkered with by the class it is passed to, rather its get() function is called when the variable is changed(detected via trace) and the set()-function is called via some internal hook. That way, it can be used by other widgets. Also, monkey patching is perhaps not the safest of practices.

In this case the Text widgets bind-method and the <<Modified>>-tag is used. It is not a typical "on_change" event that keeps on firing, but rather trigger when someone modifies the content of the widget, there to provide a mechanism to help signaling that the value has been modified. So after each time it fires, one needs to reset it using Text.edit_modified(False) as seen in the text_modified and var_modified functions for it to fire again. But it works, I didn't get <<Change>> to work for me.

And finally, the trace of the textvariable is temporarily disabled in text_modified to avoid unwanted looping. Also, the unhook()-method should be called for cleaning up if the widget is used in cases when parent is explicitly deleted, like in a modal window to avoid problems. If not, it could be disregarded.

Here you go:

from tkinter import Frame, Variable, Scrollbar, Text

from tkinter.constants import VERTICAL, RIGHT, LEFT, BOTH, END, Y

class TextExtension(Frame):
    """Extends Frame.  Intended as a container for a Text field.  Better related data handling
    and has Y scrollbar."""


    def __init__(self, master, textvariable=None, *args, **kwargs):

        super(TextExtension, self).__init__(master)
        # Init GUI

        self._y_scrollbar = Scrollbar(self, orient=VERTICAL)

        self._text_widget = Text(self, yscrollcommand=self._y_scrollbar.set, *args, **kwargs)
        self._text_widget.pack(side=LEFT, fill=BOTH, expand=1)

        self._y_scrollbar.config(command=self._text_widget.yview)
        self._y_scrollbar.pack(side=RIGHT, fill=Y)

        if textvariable is not None:
            if not (isinstance(textvariable, Variable)):
                raise TypeError("tkinter.Variable type expected, " + str(type(textvariable)) + " given.".format(type(textvariable)))
            self._text_variable = textvariable
            self.var_modified()
            self._text_trace = self._text_widget.bind('<<Modified>>', self.text_modified)
            self._var_trace = textvariable.trace("w", self.var_modified)

    def text_modified(self, *args):
            if self._text_variable is not None:
                self._text_variable.trace_vdelete("w", self._var_trace)
                self._text_variable.set(self._text_widget.get(1.0, 'end-1c'))
                self._var_trace = self._text_variable.trace("w", self.var_modified)
                self._text_widget.edit_modified(False)

    def var_modified(self, *args):
        self.set_text(self._text_variable.get())
        self._text_widget.edit_modified(False)

    def unhook(self):
        if self._text_variable is not None:
            self._text_variable.trace_vdelete("w", self._var_trace)


    def clear(self):
        self._text_widget.delete(1.0, END)

    def set_text(self, _value):
        self.clear()
        if (_value is not None):
            self._text_widget.insert(END, _value)

You can see the code in use, and using unhook, here, in the on_post_merge_sql-function.

Cheers!

Piti Ongmongkolkul
  • 2,110
  • 21
  • 20
  • The `<>` event isn't strange, I think you're simply expecting it to be something it's not. It was designed as a way for your application to know when the data needs to be saved. It is a one-time event until you reset it. The purpose is to make it easy to create a "needs saving" flag, so you can warn the user or automatically save when things change. It was never designed to fire for every change, only when the state of the data changes from a known state (eg: when you load it from a file) to a changed state. – Bryan Oakley Dec 28 '14 at 22:50
  • You are right, of course, I have misunderstood its purpose completely and have updated the response to reflect that. However, for some reason my answer has been downvoted into the negative, and I can't really see what is so desperately wrong about it other than abusing the <> tag. At least its not about undocumented features. – Nicklas Börjesson Jan 11 '15 at 12:10
  • 1
    @NicklasBörjesson This answer has been a big help to me here in 2022, thank you for posting it! One improvement would be to add a function to unhook that trace if the widget gets destroyed: def destroy(self):\n self.unhook()\n super().destroy() – Carl Kevinson Jun 20 '22 at 21:42
2

Not sure if this is what you were trying to do, but this worked for me:

import tkinter as tk

text_area = tk.Text(parent)

text_area.bind('<KeyRelease>', lambda *args: do_something())

Every time a key is released in the text widget, it will run do_something

double_j
  • 1,636
  • 1
  • 18
  • 27