bluepiplup
bluepiplup

Reputation: 21

Why my code prints multiple same lines when it reaches the end of terminal?

import time
import sys

def animate_string(input_string, delay_second=0.01):
    current_string = ""
    for target_char in input_string:
        if target_char == '\n':
            # Print a newline and reset current_string
            print()
            current_string = ""
            continue

        current_char = 'a'
        while current_char != target_char:
            a_code = ord(current_char)
            # previous string + current letter
            sys.stdout.write('\r' + current_string + current_char)
            sys.stdout.flush()
            time.sleep(delay_second)
            # Move to the next char (used ASCII)
            current_char = chr(ord(current_char) + 1)
            # Handle bound issue
            if current_char == '{':  # After 'z'
                current_char = 'A'
            elif current_char == '[':  # After 'Z'
                current_char = target_char
        # Add the target character to the current string
        current_string += target_char
        # Print the updated current string
        sys.stdout.write('\r' + current_string)
        sys.stdout.flush()
    # Print a newline at the end to finish the output cleanly
    print()

animate_string("fjweiofjewoifjweofjewofjewffewjfiowjfwaifjewipfajeehgpwth235r2fjweiofjewoifjweofjewofjewffewjfiowjfwaifjewipfajeehgpwth235r2fjweiofjewoifjweofjewofjewffewjfiowjfwaifjewipfajeehgpwth235r2fjweiofjewoifjweofjewofjewffewjfiowjfwaifjewipfajeehgpwth235r2", 0.0002)

This is my code. I am working on printing string in a cool way. It works fine with small number of characters, but when the string becomes long enough to reach the end of terminal, it starts printing the same line over and over. why does this happen and how can I solve the issue?

Upvotes: 2

Views: 100

Answers (1)

ImpeccableChicken
ImpeccableChicken

Reputation: 125

The Problem

As pointed out in one of the comments, a carriage return ("\r") moves the cursor to the start of the current line. When text requires more terminal columns to display than the width of the terminal, the text is displayed across multiple lines. Thus, when writing a "long" string to the terminal, the cursor will end on a different line than the one on which the output started. Any subsequent "\r" then moves the cursor to the start of this final line.

Example

Assume that the terminal width is 50 columns, and you attempt to animate the 60-character string "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12345678".

When target_char contains the 41st character (O), this is the result of the first subsequent sys.stdout.write call (with line numbers):

1 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNa
  ^                                        ^ cursor is here
  cursor will be returned here by "\r"

When target_char contains the 51st character (Y), this is the result of the first subsequent sys.stdout.write call (with line numbers):

1 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX
2 a
  ^^ cursor is here
  cursor will be returned here by "\r"

The next call to sys.stdout.write (when current_char contains b) will move the cursor to the start of the second line and overwrite only the a, resulting in:

1 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX
2 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX
3 b
  ^^ cursor is here
  cursor will be returned here by "\r"

From this point onward, every sys.stdout.write call will add a new line to the output.

A Solution

One solution is to overwrite only a single character, rather than the whole line, during the "cycling" animation. Using "\b" to move the cursor back one column initially appears to work, but, for some reason, does not "wrap backwards" across lines. However, using an ANSI control sequence to set the cursor position does work:

import os
import sys
import time

def animate_string(input_string, delay_second=0.001):
    stdout = sys.stdout
    if not stdout.isatty():
        # Standard output is not a terminal; just output the string
        stdout.write(f"{input_string}\n")
        return

    # Get the size of the terminal to control line breaks correctly
    cols, rows = os.get_terminal_size(stdout.fileno())
    # Assume we are at the start of a new line
    current_col = 1
    for target_char in input_string:
        if target_char == "\n":
            # Write a newline and reset `current_col`
            stdout.write(target_char)
            current_col = 1
            continue

        # Escape sequence that sets the horizontal position of the cursor to `current_col`
        # Setting this to "\b" does not work for long strings
        undo_seq = f"\x1b[{current_col}G"

        current_char = "a"
        time.sleep(delay_second)
        stdout.write(current_char)
        stdout.flush()
        while current_char != target_char:
            # Move to the next char (used ASCII)
            current_char = chr(ord(current_char) + 1)
            # Handle bound issue
            if current_char == "{":  # After 'z'
                current_char = "A"
            elif current_char == "[":  # After 'Z'
                current_char = target_char

            time.sleep(delay_second)
            # "Undo" sequence followed by current letter
            stdout.write(f"{undo_seq}{current_char}")
            stdout.flush()

        if current_col >= cols:
            # Detect line wrapping performed by the terminal
            current_col = 1
        else:
            current_col += 1

    # Print a newline at the end to finish the output cleanly
    stdout.write("\n")

We use os.get_terminal_size to get the terminal width. We keep track of the current column in current_col and use the CHA ANSI control sequence to reset the cursor position during the "cycling" animation.

Additionally, we use stdout.isatty to check if standard output is a terminal, and, if not, we simply write the input out.

Finally, I've moved the time.sleep calls around a bit to make the timing more consistent.

Assumptions

This solution is not perfect and relies on the following assumptions:

  • The cursor is at the start of a line when this function is called.
  • Every character occupies exactly one terminal column.
  • The terminal supports the ANSI escape sequence used (I've tested it in xterm, Alacritty, and WezTerm).

For slightly more advanced control of the cursor, this question and its answers, as well as the Wikipedia entry on ANSI escape sequences, might be a good starting point. The termios and tty modules in the Python standard library might also be of interest to you, although they are not available everywhere.

Upvotes: 1

Related Questions