Search code examples
pythonpython-3.xtkintertcltkinter-text

-textvariable option not working with ScrolledText widget in Python Tkinter


I've recently found a code in StackOverflow which inherits the Text class to add -textvariable option to it (as Text widget originally has no -textvariable option) and another code which also inherits the Text class to add a scrollbar to it by default (I found it from source code of Tkinter).

Text with scrollbar:

class ScrolledText(Text):
    def __init__(self, master=None, **kwargs):
        self.frame = Frame(master)
        self.vbar = Scrollbar(self.frame)
        self.vbar.pack(side=RIGHT, fill=Y)

        kwargs.update({'yscrollcommand': self.vbar.set})
        Text.__init__(self, self.frame, **kwargs)
        self.pack(side=LEFT, fill=BOTH, expand=True)
        self.vbar['command'] = self.yview

        text_meths = vars(Text).keys()
        methods = vars(Pack).keys() | vars(Grid).keys() | vars(Place).keys()
        methods = methods.difference(text_meths)

        for m in methods:
            if m[0] != '_' and m != 'config' and m != 'configure':
                setattr(self, m, getattr(self.frame, m))

    def __str__(self):
        return str(self.frame)

Text with -textvariable option:

class TextWithVar(Text):
    def __init__(self, parent, *args, **kwargs):
        try:
            self._textvariable = kwargs.pop("textvariable")
        except KeyError:
            self._textvariable = None
        super().__init__(parent, *args, **kwargs)
        if self._textvariable is not None:
            self.insert("1.0", self._textvariable.get())
        self.tk.eval('''
            proc widget_proxy {widget widget_command args} {

                set result [uplevel [linsert $args 0 $widget_command]]

                if {([lindex $args 0] in {insert replace delete})} {
                    event generate $widget <<Change>> -when tail
                }

                return $result
            }
            ''')
        self.tk.eval('''
            rename {widget} _{widget}
            interp alias {{}} ::{widget} {{}} widget_proxy {widget} _{widget}
        '''.format(widget=str(self)))
        self.bind("<<Change>>", self._on_widget_change)

        if self._textvariable is not None:
            self._textvariable.trace("wu", self._on_var_change)

    def _on_var_change(self, *args):
        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):
        if self._textvariable is not None:
            self._textvariable.set(self.get("1.0", "end-1c"))

I tried to merge them:

class ScrolledTextWithVar(Text):
    def __init__(self, master=None, *args, **kwargs):
        try:
            self._textvariable = kwargs.pop("textvariable")
        except KeyError:
            self._textvariable = None
        self.frame = Frame(master)
        self.vbar = Scrollbar(self.frame)
        self.vbar.pack(side=RIGHT, fill=Y)
        kwargs.update({'yscrollcommand': self.vbar.set})
        super().__init__(self.frame, **kwargs)
        self.pack(side=LEFT, fill=BOTH, expand=True)
        self.vbar['command'] = self.yview
        text_meths = vars(Text).keys()
        methods = vars(Pack).keys() | vars(Grid).keys() | vars(Place).keys()
        methods = methods.difference(text_meths)

        for m in methods:
            if m[0] != '_' and m != 'config' and m != 'configure':
                setattr(self, m, getattr(self.frame, m))

        if self._textvariable is not None:
            self.insert("1.0", self._textvariable.get())
        self.tk.eval('''
            proc widget_proxy {widget widget_command args} {

                set result [uplevel [linsert $args 0 $widget_command]]

                if {([lindex $args 0] in {insert replace delete})} {
                    event generate $widget <<Change>> -when tail
                }

                return $result
            }
            ''')
        self.tk.eval('''
            rename {widget} _{widget}
            interp alias {{}} ::{widget} {{}} widget_proxy {widget} _{widget}
        '''.format(widget=str(self)))
        self.bind("<<Change>>", self._on_widget_change)

        if self._textvariable is not None:
            self._textvariable.trace("wu", self._on_var_change)

    def _on_var_change(self, *args):
        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):
        if self._textvariable is not None:
            self._textvariable.set(self.get("1.0", "end-1c"))

    def __str__(self):
        return str(self.frame)

Even though the ScrolledTextWithVar class accepts -textvariable as an argument, it does not update the variable value when the text inside the widget changes. Removing the lines which add scrollbar to the widget makes the -textvariable option work again. I have no idea how can scrollbar and textvariable conflict each other. Is adding a scrollbar externally only solution to this?


Solution

  • The problem here is that ScrolledText is overloading the __str__() method and therefore the widget=str(self) in TextWithVar.__init__() does not refer to the right widget, namely the container frame instead of the text widget. You can fix that by using the original text widget method:

    widget=str(tk.Text.__str__(self)))
    

    Also, you don't really have to merge the two classes, you can make your class inherit directly from ScrolledText instead of Text:

    import tkinter as tk
    from tkinter.scrolledtext import ScrolledText
    
    # same code as TextWithVar but inheriting from ScrolledText and with above mentioned fix
    class ScrolledTextWithVar(ScrolledText):  
        def __init__(self, parent, *args, **kwargs):
            try:
                self._textvariable = kwargs.pop("textvariable")
            except KeyError:
                self._textvariable = None
            super().__init__(parent, *args, **kwargs)
            if self._textvariable is not None:
                self.insert("1.0", self._textvariable.get())
            self.tk.eval('''
                proc widget_proxy {widget widget_command args} {
    
                    set result [uplevel [linsert $args 0 $widget_command]]
    
                    if {([lindex $args 0] in {insert replace delete})} {
                        event generate $widget <<Change>> -when tail
                    }
    
                    return $result
                }
                ''')
            self.tk.eval('''
                rename {widget} _{widget}
                interp alias {{}} ::{widget} {{}} widget_proxy {widget} _{widget}
            '''.format(widget=str(tk.Text.__str__(self))))  # <-- use tk.Text str method
            self.bind("<<Change>>", self._on_widget_change)
    
            if self._textvariable is not None:
                self._textvariable.trace("wu", self._on_var_change)
    
        def _on_var_change(self, *args):
            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):
            if self._textvariable is not None:
                self._textvariable.set(self.get("1.0", "end-1c"))
    
    
    root = tk.Tk()
    var = tk.StringVar(root)
    txt = ScrolledTextWithVar(root, textvariable=var)
    txt.pack(side="left", fill="both", expand=True)