diff --git a/src/pipx/animate.py b/src/pipx/animate.py index 5db2d45ee1..14ac656df0 100644 --- a/src/pipx/animate.py +++ b/src/pipx/animate.py @@ -2,12 +2,22 @@ from contextlib import contextmanager from threading import Event, Thread from typing import Generator, List +import shutil from pipx.constants import emoji_support stderr_is_tty = sys.stderr.isatty() +HIDE_CURSOR = "\033[?25l" +SHOW_CURSOR = "\033[?25h" +CLEAR_LINE = "\033[K" +EMOJI_ANIMATION_FRAMES = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"] +NONEMOJI_ANIMATION_FRAMES = ["", ".", "..", "..."] +EMOJI_FRAME_PERIOD = 0.1 +NONEMOJI_FRAME_PERIOD = 1 + + @contextmanager def animate(message: str, do_animation: bool) -> Generator[None, None, None]: @@ -20,12 +30,12 @@ def animate(message: str, do_animation: bool) -> Generator[None, None, None]: if emoji_support: animate_at_beginning_of_line = True - symbols = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"] - period = 0.1 + symbols = EMOJI_ANIMATION_FRAMES + period = EMOJI_FRAME_PERIOD else: animate_at_beginning_of_line = False - symbols = ["", ".", "..", "..."] - period = 1 + symbols = NONEMOJI_ANIMATION_FRAMES + period = NONEMOJI_FRAME_PERIOD thread_kwargs = { "message": message, @@ -59,12 +69,17 @@ def print_animation( period: float, animate_at_beginning_of_line: bool, ): + (term_cols, _) = shutil.get_terminal_size(fallback=(9999, 24)) while not event.wait(0): for s in symbols: if animate_at_beginning_of_line: - cur_line = f"{s} {message}" + max_message_len = term_cols - len(f"{s} ... ") + cur_line = f"{s} {message:.{max_message_len}}" + if len(message) > max_message_len: + cur_line += "..." else: - cur_line = f"{message}{s}" + max_message_len = term_cols - len("... ") + cur_line = f"{message:.{max_message_len}}{s}" clear_line() sys.stderr.write("\r") @@ -74,13 +89,13 @@ def print_animation( def hide_cursor(): - sys.stderr.write("\033[?25l") + sys.stderr.write(f"{HIDE_CURSOR}") def show_cursor(): - sys.stderr.write("\033[?25h") + sys.stderr.write(f"{SHOW_CURSOR}") def clear_line(): - sys.stderr.write("\033[K") - sys.stdout.write("\033[K") + sys.stderr.write(f"{CLEAR_LINE}") + sys.stdout.write(f"{CLEAR_LINE}") diff --git a/tests/test_animate.py b/tests/test_animate.py new file mode 100644 index 0000000000..0389fafeb9 --- /dev/null +++ b/tests/test_animate.py @@ -0,0 +1,76 @@ +import time + +import pipx.animate +from pipx.animate import ( + HIDE_CURSOR, + CLEAR_LINE, + EMOJI_ANIMATION_FRAMES, + NONEMOJI_ANIMATION_FRAMES, + EMOJI_FRAME_PERIOD, + NONEMOJI_FRAME_PERIOD, +) + + +def check_animate_output( + capsys, test_string, frame_strings, frame_period, frames_to_test +): + expected_string = f"{HIDE_CURSOR}" + "".join(frame_strings) + + chars_to_test = 1 + len("".join(frame_strings[:frames_to_test])) + + with pipx.animate.animate(test_string, do_animation=True): + time.sleep(frame_period * (frames_to_test - 1) + 0.2) + captured = capsys.readouterr() + + assert captured.err[:chars_to_test] == expected_string[:chars_to_test] + + +def test_line_lengths_emoji(capsys, monkeypatch): + # emoji_support and stderr_is_tty is set only at import animate.py + # since we are already after that, we must override both here + monkeypatch.setattr(pipx.animate, "stderr_is_tty", True) + monkeypatch.setattr(pipx.animate, "emoji_support", True) + + frames_to_test = 4 + + # 40-char test_string counts columns e.g.: "0204060810 ... 363840" + test_string = "".join([f"{x:02}" for x in range(2, 41, 2)]) + + columns_to_test = [45, 46, 47] + expected_message = [f"{test_string:.{45-6}}...", f"{test_string}", f"{test_string}"] + + for i, columns in enumerate(columns_to_test): + monkeypatch.setenv("COLUMNS", str(columns)) + + frame_strings = [ + f"{CLEAR_LINE}\r{x} {expected_message[i]}" for x in EMOJI_ANIMATION_FRAMES + ] + check_animate_output( + capsys, test_string, frame_strings, EMOJI_FRAME_PERIOD, frames_to_test + ) + + +def test_line_lengths_no_emoji(capsys, monkeypatch): + # emoji_support and stderr_is_tty is set only at import animate.py + # since we are already after that, we must override both here + monkeypatch.setattr(pipx.animate, "stderr_is_tty", True) + monkeypatch.setattr(pipx.animate, "emoji_support", False) + + frames_to_test = 2 + + # 40-char test_string counts columns e.g.: "0204060810 ... 363840" + test_string = "".join([f"{x:02}" for x in range(2, 41, 2)]) + + columns_to_test = [43, 44, 45] + expected_message = [f"{test_string:.{43-4}}", f"{test_string}", f"{test_string}"] + + for i, columns in enumerate(columns_to_test): + monkeypatch.setenv("COLUMNS", str(columns)) + + frame_strings = [ + f"{CLEAR_LINE}\r{expected_message[i]}{x}" for x in NONEMOJI_ANIMATION_FRAMES + ] + + check_animate_output( + capsys, test_string, frame_strings, NONEMOJI_FRAME_PERIOD, frames_to_test + )