how to update a customtkinter label character-by-character, with timed delays in between?

72 views Asked by At

I'm trying to use CustomTkinter to create a game, and I want to make the text in a CTkLabel be slowly printed out, one character at a time. I want the effect of seeing text appear as if a person was typing it out in real time. Most of the resources I found are for Tkinter, so I'm not sure how to do this.

This is part of the code I'm struggling with:

dialogue_var = ("Hello User")
dialogue_var = ("Ready To Protect It")

dialogue = customtkinter.CTkLabel(app, width=350, height=80, text=dialogue_var)
dialogue.pack(padx=1, pady=1)

DirkS posted the following print_slow function in the Raspberry Pi forums:

from time import sleep

def print_slow(txt):
    # cycle through the text one character at a time
    for x in txt:
        # print one character, no new line, flush buffer
        print(x, end='', flush=True)
        sleep(0.3)
    # go to new line
    print()

print_slow("Hello. I'm feeling a bit slow today")

but I don't know how to implement it with a CTkLabel. It just keeps saying that the command wasn't closed.

3

There are 3 answers

0
drmuelr On

Here's a solution in pure Tkinter. Based on my understanding from the docs, you should be able to just replace class names from Tkinter with CTkinter.

In case you aren't familiar with event-driven applications, the TkDocs tutorial offers this quick introduction:

As with most user interface toolkits, Tk runs an event loop that receives events from the operating system. These are things like button presses, keystrokes, mouse movement, window resizing, and so on.

Generally, Tk takes care of managing this event loop for you. It will figure out what widget the event applies to (did a user click on this button? if a key was pressed, which textbox had the focus?), and dispatch it accordingly. Individual widgets know how to respond to events; for example, a button might change color when the mouse moves over it and revert back when the mouse leaves.

In other words, something like

dialogue = ""
buffer   = "Ready To Protect It"
for x in buffer:
    dialogue.append(x)
    sleep(0.3)

won't work because that sleep call will repeatedly pause execution, preventing the event loop from doing its thing; the app would be almost completely unresponsive.

Instead, we need to set up structures that can work in small, discrete steps based on timers. First, a couple imports:

import tkinter as tk
from collections import deque

Now we define a custom StringVar class. This will let us add to a string character by character. Each time the step method is called, a character gets moved from the buffer to the end of the displayed string:

class TeletypeVar(tk.StringVar):
    """StringVar that appends characters from a buffer one at a time.
    
    Parameters
    ----------
    value : string, optional
        Initial string value.
    buffer : string, optional
        Initial buffer content.
    """

    def __init__(self, *args, **kwargs):
        buffer = kwargs.pop("buffer", "")
        super().__init__(*args, **kwargs)
        self.buffer = deque(buffer)
        
    def clear(self):
        """Clear contents of the string and buffer."""
        self.set("")
        self.buffer.clear()
        
    def enqueue(self, str):
        """Add the given string to the end of the buffer."""
        self.buffer.extend(str)
        
    def step(self, _event=None):
        """Move 1 character from the buffer to the string."""
        if len(self.buffer) > 0:
            self.set(self.get() + self.buffer.popleft())

Now we define a class that will repeatedly invoke callback functions like TeletypeVar.step at a steady pace. It also has a step method, which invokes the assigned callback function, and starts the timer for its own next step:

class Clock():
    """A clock that calls ``cb`` every ``T`` milliseconds.

    Parameters
    ----------
    T : int
        Delay between calls, in milliseconds.
    cb : function
        Callback function to be repeatedly called.
    """

    def __init__(self, T, cb):
        self.T = T
        self.cb = cb
        self.after = root.after
    
    def step(self):
        """Called every T milliseconds."""
        self.cb()
        self.after(self.T, self.step)
        
    def start(self):
        """Start running the clock."""
        self.after(self.T, self.step)

That takes care of the prep work. Creating the frame and starting the main loop should look familiar:

class App(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()

        # create our custom StringVar pre-populated with "Hello User"
        #   and "_Ready To Protect It" waiting in the buffer
        self.tt_dynamic = TeletypeVar(value="Hello User",
                                      buffer="_Ready To Protect It")

        # create our clock, but don't start it yet
        #   this replaces a line of code like `sleep(0.150)`
        self.clk = Clock(150, self.tt_dynamic.advance)  

        # create a TkLabel; same as using a CTkLabel
        # using `textvariable` instead of `text` lets the label
        #   know that the string is a dynamic property
        dialogue = tk.Label(self, width=35, height=8,
                            textvariable=self.tt_dynamic)
        dialogue.pack(padx=1, pady=1)

        # call self.delayed_cb1() after 2 seconds
        #   note how there is no delay between the two print statements
        print("before after")
        root.after(2_000, self.delayed_cb1)
        print("after after")

    def delayed_cb1(self):
        # start the clock defined in `__init__`
        self.clk.start()

        # call self.delayed_cb2() after a further 5 seconds
        root.after(5_000, self.delayed_cb2)
    
    def delayed_cb2(self):
        # clear out the teletype string
        self.tt_dynamic.clear()

        # speed up the clock
        self.clk.T = 75

        # and queue up more text
        self.tt_dynamic.enqueue("_And Protect It Some More")


root = tk.Tk()
myapp = App(root)
myapp.mainloop()

I wrote this in a way that will hopefully make it easy to play around with the different parts to see how everything works. If anything is unclear, just let me know.

Disclaimer: I haven't worked with Tkinter or CustomTkinter before. This is all based on quickly skimming the docs and similarity to many other event-driven toolkits, so this might not be the most elegant solution.

0
Jan_B On

With all respect to @drmuelr elaborate response, I would do it by creating a own Label class and give it the ability to type the text instead of the StringVar. Also his Clock class is mostly just the functionality of the after method.

This is my approach:

from customtkinter import CTk, CTkLabel, CTkButton, StringVar


class TypedLabel(CTkLabel):
    """ Label that can slowly type the provided text """
    def __init__(self, master, text, type_delay):
        self.delay = type_delay
        self.display_text = StringVar(master, value='')
        self.text_buffer = text
        super().__init__(master, textvariable=self.display_text)

    def change_delay(self, new_delay):
        """change the speed for the typing by setting lower millisecond intervalls"""
        self.delay = new_delay

    def set_buffer(self, text):
        """Change buffer text"""
        self.text_buffer = text

    def append_buffer(self, text, newline=True):
        """append the buffer with new text. Default will print a newline before the appended text"""
        if newline:
            text = '\n' + text
        self.text_buffer += text

    def clear_text(self):
        """reset both bufffer and display text to empty string"""
        self.text_buffer = ''
        self.display_text.set('')

    def type_text(self):
        if len(self.text_buffer) > 0:
            self.display_text.set(self.display_text.get() + self.text_buffer[0])  # append first character from buffer
            self.text_buffer = self.text_buffer[1:]  # remove first character from buffer
            self.after(self.delay, self.type_text)  # type next char after given delay


class TestGui(CTk):
    def __init__(self):
        super().__init__()
        self.type_text_lbl = TypedLabel(self, 'Test', 300)
        self.type_text_lbl.pack()
        test_btn = CTkButton(self, text='new test', command=self._new_test)  # test buffer append
        test_btn.pack()
        self.after(2000, self.type_text_lbl.type_text)  # type text 2 sec after instantiation of the GUI

    def _new_test(self):
        self.type_text_lbl.clear_text()
        self.type_text_lbl.set_buffer('Test 2')
        self.type_text_lbl.append_buffer('succesfull!')
        self.type_text_lbl.type_text()


if __name__ == '__main__':
    gui = TestGui()
    gui.mainloop()

0
toyota Supra On

how do i properly syntax customtkinter so that it allows me to print the output of the ctk label slowly.

The problem can be fixed. Just a small code to work around.

Just 33 lines.

  • Add Button.
  • Create two functions.

Snippet:

import tkinter as tk
import customtkinter as ctk
 
app = tk.Tk()

def clicked() -> None:
    n = 0
    txt = r"Hello User. Ready To Protect It"
    showChar(n, txt)


def showChar(n: int, txt: str) -> int and str:
    n += 1
    diaglogue.configure(text = txt[:n])
    if n < len(txt):
        app.after(1000, lambda: showChar(n, txt))

        
diaglogue = ctk.CTkLabel(master=app, width=350,
                         height=80,
                         text_color="#000",
                         )


#diaglogue_var = ("Hello User")
#diaglogue_var = ("Ready To Protect It")  
diaglogue.pack(padx=1, pady=1)
 
button = ctk.CTkButton(master=app, text="Click Me", 
                   command=clicked)
button.pack()

app.mainloop()